frpc-ruby

The frp client as a Ruby gem: the upstream frpc binary for your platform, plus a small wrapper that runs it and drives its admin HTTP API from Ruby.

Packaged the way tailwindcss-ruby is — one platform gem per target, each carrying the matching binary from the official frp release, and a plain ruby gem that carries none.

Install

gem "frpc-ruby"

Bundler only fetches platform gems for platforms in your lockfile, so add the ones you deploy to:

bundle lock --add-platform x86_64-linux aarch64-linux arm64-darwin

BUNDLE_FORCE_RUBY_PLATFORM=true selects the binary-less gem — don't set it unless you're supplying frpc yourself.

Use

The wrapper talks to frpc over its admin API, so the config must enable it:

serverAddr = "example.com"
serverPort = 7000

webServer.addr = "127.0.0.1"
webServer.port = 7400
webServer.user = "admin"
webServer.password = "hunter2"

[[proxies]]
name = "ssh"
type = "tcp"
localPort = 22
remotePort = 6000

Client reads webServer.* out of that file, so it needs nothing but a path:

require "frpc"

client = Frpc::Client.new("frpc.toml")

client.status              # => {"tcp" => [{"name" => "ssh", "status" => "running", ...}]}
client.proxy("ssh")        # => that one proxy's hash, or nil
client.config              # => the config file as frpc currently sees it
client.reload(new_toml)    # PUT /api/config, then GET /api/reload — no restart
client.stop                # POST /api/stop, then reap; TERM/KILL if it hangs

Client.run guarantees the child is stopped, even on an exception:

Frpc::Client.run("frpc.toml") { |client| pp client.status }

To talk to an frpc you didn't spawn — a systemd unit, another container — skip Client and use Admin directly:

admin = Frpc::Admin.new(host: "127.0.0.1", port: 7400, user: "admin", password: "hunter2")
admin.wait_until_healthy
admin.status
admin.reload

Reloading takes a moment to settle: frpc diffs the new config against the running proxies and creates, updates, or removes them asynchronously.

Just the binary

Frpc::Ruby.executable   # => "/…/gems/frpc-ruby-0.70.1-x86_64-linux/exe/x86_64-linux/frpc"

The gem also installs an frpc shim on your PATH, so bundle exec frpc -c frpc.toml works, as does frps-style usage of any subcommand — the shim execs the real binary with your arguments untouched.

Resolution order:

  1. Frpc::Ruby.executable(exe_path: "…")
  2. FRPC_INSTALL_DIR (a directory) or FRPC_PATH (a file)
  3. the binary vendored in this gem
  4. frpc on PATH

That's the escape hatch for unsupported architectures: install frpc yourself, set FRPC_INSTALL_DIR, and the ruby-platform gem works fine.

Platforms

gem platform frp release target
x86_64-linux linux_amd64
aarch64-linux linux_arm64
arm-linux linux_arm
arm64-darwin darwin_arm64
x86_64-darwin darwin_amd64
x64-mingw-ucrt windows_amd64
aarch64-mingw-ucrt windows_arm64

Upstream builds every non-darwin target with CGO_ENABLED=0, so one Linux binary per arch covers glibc and musl alike — no separate -gnu/-musl gems.

Why a subprocess and not cgo

You can wrap client.Service in a -buildmode=c-shared object and load it with FFI, but a process that has loaded the Go runtime can't fork and keep using Go in the child (golang/go#15538), which is a live hazard under Puma cluster mode, Unicorn, Resque and Spring. It also can't be dlclosed (golang/go#11100), and it re-breaks whenever frp changes its internal API.

A subprocess has a better failure mode: frpc crashing kills frpc, not your VM. The cgo route only pays off if you need the tunnel's data path inside the Ruby process — a custom HandleWorkConnCb or ConnectorCreator — which has no HTTP equivalent.

Development

direnv allow          # or: nix develop
bin/test              # unit tests — a fake frpc, no network
bin/smoke             # real frps + frpc, real bytes through a real tunnel
rake vendor:all       # download + verify + unpack every platform's frpc
rake gem:all          # build pkg/*.gem for every platform, plus the ruby gem

Releasing

Versions mirror upstream: gem 0.70.1 vendors frp 0.70.1. A wrapper-only fix appends a fourth segment (0.70.1.1), which RubyGems orders after 0.70.1 and before 0.70.2.

bin/sync-upstream            # latest frp release: bumps VERSION, pins checksums
bin/sync-upstream 0.71.0     # or a specific one
bin/increment-version        # wrapper-only bump instead: 0.70.1 -> 0.70.1.1

bin/smoke                    # prove the new binary tunnels before shipping it
git commit -am "frp 0.71.0" && git push     # CI builds the platform gems
bin/release-gem              # push them to RubyGems, then tag

bin/release-gem builds the ruby-platform gem locally, downloads the platform gems from the latest successful build-gems.yml run, refuses to continue if any platform is missing or if CI built a different version, and tolerates gems that are already published so a half-finished release can be re-run.

CI is what builds the shipped binaries, on purpose: it fetches them from GitHub Releases and verifies each against the committed checksums/v<version>.txt, so what ships never depends on the state of somebody's vendor/ directory. Without that pin the Rakefile warns and falls back to the checksum file published alongside the release, which only catches transport corruption.

License

MIT for this wrapper. The vendored frpc binary is Apache-2.0, © the frp authors.