frpc-ruby 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ddb7670b1112087e9f2e56e98977ada1bea3f9c741a1bdc4e97f1afc4c059732
4
+ data.tar.gz: 24fea9e84d86352b559985b4259d79ff16ad5ddc84b09ef96299bc3e26765c26
5
+ SHA512:
6
+ metadata.gz: 90ff336c66197c8209324d7d4ca4cc9ccb7813f549dba81a1a01e565a69652eb15ae7c15b3cd2974989a63c0cc9ab43b5d911b50f283310062f8e7e631581623
7
+ data.tar.gz: cf281245d1f5a85f0e2503b265b8cbab978d7aa47510f543fbe622029ad7274bf0ab7af3ac7256ea895fdb58c1179cbc39ff0ab0f1b10b72cbcb9d66b5574dd7
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.70.1
4
+
5
+ - First release. Vendors frp 0.70.1 (`CGO_ENABLED=0` upstream builds) as one
6
+ platform gem per target, with a `ruby`-platform gem for everything else.
7
+ - `Frpc::Ruby.executable` resolution: explicit path, `FRPC_INSTALL_DIR` /
8
+ `FRPC_PATH`, vendored binary, then `PATH`.
9
+ - `Frpc::Client` spawns and supervises frpc; `Frpc::Admin` wraps the admin API
10
+ (`/healthz`, `/api/status`, `/api/config`, `/api/reload`, `/api/stop`, and the
11
+ frp >= 0.68 store CRUD endpoints).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Kidd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # frpc-ruby
2
+
3
+ The [frp](https://github.com/fatedier/frp) client as a Ruby gem: the upstream
4
+ `frpc` binary for your platform, plus a small wrapper that runs it and drives
5
+ its admin HTTP API from Ruby.
6
+
7
+ Packaged the way [`tailwindcss-ruby`](https://github.com/flavorjones/tailwindcss-ruby)
8
+ is — one platform gem per target, each carrying the matching binary from the
9
+ official frp release, and a plain `ruby` gem that carries none.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ gem "frpc-ruby"
15
+ ```
16
+
17
+ Bundler only fetches platform gems for platforms in your lockfile, so add the
18
+ ones you deploy to:
19
+
20
+ ```sh
21
+ bundle lock --add-platform x86_64-linux aarch64-linux arm64-darwin
22
+ ```
23
+
24
+ `BUNDLE_FORCE_RUBY_PLATFORM=true` selects the binary-less gem — don't set it
25
+ unless you're supplying `frpc` yourself.
26
+
27
+ ## Use
28
+
29
+ The wrapper talks to frpc over its admin API, so the config must enable it:
30
+
31
+ ```toml
32
+ serverAddr = "example.com"
33
+ serverPort = 7000
34
+
35
+ webServer.addr = "127.0.0.1"
36
+ webServer.port = 7400
37
+ webServer.user = "admin"
38
+ webServer.password = "hunter2"
39
+
40
+ [[proxies]]
41
+ name = "ssh"
42
+ type = "tcp"
43
+ localPort = 22
44
+ remotePort = 6000
45
+ ```
46
+
47
+ `Client` reads `webServer.*` out of that file, so it needs nothing but a path:
48
+
49
+ ```ruby
50
+ require "frpc"
51
+
52
+ client = Frpc::Client.new("frpc.toml")
53
+
54
+ client.status # => {"tcp" => [{"name" => "ssh", "status" => "running", ...}]}
55
+ client.proxy("ssh") # => that one proxy's hash, or nil
56
+ client.config # => the config file as frpc currently sees it
57
+ client.reload(new_toml) # PUT /api/config, then GET /api/reload — no restart
58
+ client.stop # POST /api/stop, then reap; TERM/KILL if it hangs
59
+ ```
60
+
61
+ `Client.run` guarantees the child is stopped, even on an exception:
62
+
63
+ ```ruby
64
+ Frpc::Client.run("frpc.toml") { |client| pp client.status }
65
+ ```
66
+
67
+ To talk to an frpc you didn't spawn — a systemd unit, another container — skip
68
+ `Client` and use `Admin` directly:
69
+
70
+ ```ruby
71
+ admin = Frpc::Admin.new(host: "127.0.0.1", port: 7400, user: "admin", password: "hunter2")
72
+ admin.wait_until_healthy
73
+ admin.status
74
+ admin.reload
75
+ ```
76
+
77
+ Reloading takes a moment to settle: frpc diffs the new config against the
78
+ running proxies and creates, updates, or removes them asynchronously.
79
+
80
+ ### Just the binary
81
+
82
+ ```ruby
83
+ Frpc::Ruby.executable # => "/…/gems/frpc-ruby-0.70.1-x86_64-linux/exe/x86_64-linux/frpc"
84
+ ```
85
+
86
+ The gem also installs an `frpc` shim on your `PATH`, so `bundle exec frpc -c frpc.toml`
87
+ works, as does `frps`-style usage of any subcommand — the shim `exec`s the real
88
+ binary with your arguments untouched.
89
+
90
+ Resolution order:
91
+
92
+ 1. `Frpc::Ruby.executable(exe_path: "…")`
93
+ 2. `FRPC_INSTALL_DIR` (a directory) or `FRPC_PATH` (a file)
94
+ 3. the binary vendored in this gem
95
+ 4. `frpc` on `PATH`
96
+
97
+ That's the escape hatch for unsupported architectures: install frpc yourself,
98
+ set `FRPC_INSTALL_DIR`, and the `ruby`-platform gem works fine.
99
+
100
+ ## Platforms
101
+
102
+ | gem platform | frp release target |
103
+ | --- | --- |
104
+ | `x86_64-linux` | `linux_amd64` |
105
+ | `aarch64-linux` | `linux_arm64` |
106
+ | `arm-linux` | `linux_arm` |
107
+ | `arm64-darwin` | `darwin_arm64` |
108
+ | `x86_64-darwin` | `darwin_amd64` |
109
+ | `x64-mingw-ucrt` | `windows_amd64` |
110
+ | `aarch64-mingw-ucrt` | `windows_arm64` |
111
+
112
+ Upstream builds every non-darwin target with `CGO_ENABLED=0`, so one Linux
113
+ binary per arch covers glibc and musl alike — no separate `-gnu`/`-musl` gems.
114
+
115
+ ## Why a subprocess and not cgo
116
+
117
+ You can wrap `client.Service` in a `-buildmode=c-shared` object and load it with
118
+ FFI, but a process that has loaded the Go runtime can't `fork` and keep using Go
119
+ in the child ([golang/go#15538](https://github.com/golang/go/issues/15538)),
120
+ which is a live hazard under Puma cluster mode, Unicorn, Resque and Spring. It
121
+ also can't be `dlclose`d ([golang/go#11100](https://github.com/golang/go/issues/11100)),
122
+ and it re-breaks whenever frp changes its internal API.
123
+
124
+ A subprocess has a better failure mode: frpc crashing kills frpc, not your VM.
125
+ The cgo route only pays off if you need the tunnel's data path inside the Ruby
126
+ process — a custom `HandleWorkConnCb` or `ConnectorCreator` — which has no HTTP
127
+ equivalent.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ direnv allow # or: nix develop
133
+ bin/test # unit tests — a fake frpc, no network
134
+ bin/smoke # real frps + frpc, real bytes through a real tunnel
135
+ rake vendor:all # download + verify + unpack every platform's frpc
136
+ rake gem:all # build pkg/*.gem for every platform, plus the ruby gem
137
+ ```
138
+
139
+ ## Releasing
140
+
141
+ Versions mirror upstream: gem `0.70.1` vendors frp `0.70.1`. A wrapper-only fix
142
+ appends a fourth segment (`0.70.1.1`), which RubyGems orders after `0.70.1` and
143
+ before `0.70.2`.
144
+
145
+ ```sh
146
+ bin/sync-upstream # latest frp release: bumps VERSION, pins checksums
147
+ bin/sync-upstream 0.71.0 # or a specific one
148
+ bin/increment-version # wrapper-only bump instead: 0.70.1 -> 0.70.1.1
149
+
150
+ bin/smoke # prove the new binary tunnels before shipping it
151
+ git commit -am "frp 0.71.0" && git push # CI builds the platform gems
152
+ bin/release-gem # push them to RubyGems, then tag
153
+ ```
154
+
155
+ `bin/release-gem` builds the ruby-platform gem locally, downloads the platform
156
+ gems from the latest successful `build-gems.yml` run, refuses to continue if any
157
+ platform is missing or if CI built a different version, and tolerates gems that
158
+ are already published so a half-finished release can be re-run.
159
+
160
+ CI is what builds the shipped binaries, on purpose: it fetches them from GitHub
161
+ Releases and verifies each against the committed `checksums/v<version>.txt`, so
162
+ what ships never depends on the state of somebody's `vendor/` directory. Without
163
+ that pin the Rakefile warns and falls back to the checksum file published
164
+ alongside the release, which only catches transport corruption.
165
+
166
+ ## License
167
+
168
+ MIT for this wrapper. The vendored `frpc` binary is
169
+ [Apache-2.0](https://github.com/fatedier/frp/blob/dev/LICENSE), © the frp authors.
data/exe/frpc ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RubyGems requires gemspec executables to be Ruby scripts, so the binary
5
+ # itself cannot be the $PATH entry. This shim resolves and exec's it, keeping
6
+ # the process (and therefore signals and exit status) intact.
7
+
8
+ require "frpc/ruby"
9
+
10
+ begin
11
+ exec(Frpc::Ruby.executable, *ARGV)
12
+ rescue Frpc::Ruby::ExecutableNotFoundError, Frpc::Ruby::UnsupportedPlatformError => e
13
+ warn e.message
14
+ exit 1
15
+ end
data/lib/frpc/admin.rb ADDED
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Frpc
8
+ # Thin wrapper over the frpc admin HTTP API (`webServer.*` in frpc.toml).
9
+ #
10
+ # webServer.addr = "127.0.0.1"
11
+ # webServer.port = 7400
12
+ # webServer.user = "admin"
13
+ # webServer.password = "hunter2"
14
+ #
15
+ # Endpoints, as registered by client/admin_api.go:
16
+ # GET /healthz (no auth)
17
+ # GET /api/status per-proxy state
18
+ # GET /api/config current config file contents
19
+ # PUT /api/config overwrite the config file
20
+ # GET /api/reload re-read the config file, diff, apply
21
+ # POST /api/stop graceful shutdown
22
+ # GET/POST/PUT/DELETE /api/store/proxies*, /api/store/visitors* (frp >= 0.68)
23
+ class Admin
24
+ class Error < StandardError; end
25
+
26
+ # Raised when frpc answers, but not with success.
27
+ class ResponseError < Error
28
+ attr_reader :code, :body
29
+
30
+ def initialize(method, path, code, body)
31
+ @code = code
32
+ @body = body
33
+ super("frpc #{method} #{path} failed: #{code} #{body}")
34
+ end
35
+ end
36
+
37
+ attr_reader :host, :port, :user
38
+
39
+ def initialize(host: "127.0.0.1", port: 7400, user: nil, password: nil, open_timeout: 2, read_timeout: 10)
40
+ @host = host
41
+ @port = Integer(port)
42
+ @user = user
43
+ @password = password
44
+ @open_timeout = open_timeout
45
+ @read_timeout = read_timeout
46
+ end
47
+
48
+ # true once the admin server is listening. Never raises — this is the
49
+ # readiness probe, so a connection refused is an expected answer.
50
+ def healthy?
51
+ request(Net::HTTP::Get.new("/healthz"), auth: false)
52
+ true
53
+ rescue Error
54
+ false
55
+ end
56
+
57
+ # Blocks until healthy?, then returns self. Raises after the deadline.
58
+ def wait_until_healthy(timeout: 10, interval: 0.1)
59
+ deadline = monotonic_now + timeout
60
+ loop do
61
+ return self if healthy?
62
+ raise Error, "frpc admin server never came up on #{host}:#{port}" if monotonic_now >= deadline
63
+
64
+ sleep interval
65
+ end
66
+ end
67
+
68
+ # Per-proxy state: name, type, status ("running" / "start error" / ...),
69
+ # err, local_addr, plugin, remote_addr. Keyed by proxy type.
70
+ def status
71
+ get("/api/status")
72
+ end
73
+
74
+ # The config file frpc was started with, as a string.
75
+ def config
76
+ get("/api/config")
77
+ end
78
+
79
+ # Overwrite the config file on the frpc host. Does NOT apply it — call
80
+ # reload afterwards, which is what `frpc reload -c ...` does internally.
81
+ def config=(toml)
82
+ put("/api/config", toml)
83
+ end
84
+
85
+ # Re-read the config file and create/update/delete proxies to match.
86
+ # Optionally writes new_toml first.
87
+ def reload(new_toml = nil)
88
+ self.config = new_toml if new_toml
89
+ get("/api/reload")
90
+ end
91
+
92
+ # Graceful shutdown. The HTTP response comes back before the process exits.
93
+ def stop
94
+ post("/api/stop")
95
+ end
96
+
97
+ # --- store API (frp >= 0.68), only meaningful with a store source configured
98
+
99
+ def store_proxies
100
+ get("/api/store/proxies")
101
+ end
102
+
103
+ def store_visitors
104
+ get("/api/store/visitors")
105
+ end
106
+
107
+ def put_store_proxy(name, config)
108
+ put("/api/store/proxies/#{escape(name)}", JSON.generate(config), content_type: "application/json")
109
+ end
110
+
111
+ def delete_store_proxy(name)
112
+ delete("/api/store/proxies/#{escape(name)}")
113
+ end
114
+
115
+ def put_store_visitor(name, config)
116
+ put("/api/store/visitors/#{escape(name)}", JSON.generate(config), content_type: "application/json")
117
+ end
118
+
119
+ def delete_store_visitor(name)
120
+ delete("/api/store/visitors/#{escape(name)}")
121
+ end
122
+
123
+ # --- verbs
124
+
125
+ def get(path)
126
+ request(Net::HTTP::Get.new(path))
127
+ end
128
+
129
+ def post(path, body = nil, content_type: nil)
130
+ request(build(Net::HTTP::Post, path, body, content_type))
131
+ end
132
+
133
+ def put(path, body = nil, content_type: nil)
134
+ request(build(Net::HTTP::Put, path, body, content_type))
135
+ end
136
+
137
+ def delete(path)
138
+ request(Net::HTTP::Delete.new(path))
139
+ end
140
+
141
+ private
142
+
143
+ def build(klass, path, body, content_type)
144
+ req = klass.new(path)
145
+ req.body = body if body
146
+ req["Content-Type"] = content_type if content_type
147
+ req
148
+ end
149
+
150
+ def escape(segment)
151
+ URI.encode_www_form_component(segment.to_s)
152
+ end
153
+
154
+ # Returns parsed JSON when the response is JSON, the raw body when it is
155
+ # not (GET /api/config hands back TOML), and nil for empty bodies.
156
+ def request(req, auth: true)
157
+ req.basic_auth(@user, @password) if auth && @user
158
+
159
+ response = http.request(req)
160
+ unless response.is_a?(Net::HTTPSuccess)
161
+ raise ResponseError.new(req.method, req.path, response.code, response.body.to_s)
162
+ end
163
+
164
+ decode(response)
165
+ rescue SystemCallError, Net::OpenTimeout, Net::ReadTimeout, IOError, EOFError => e
166
+ raise Error, "frpc #{req.method} #{req.path} failed: #{e.class}: #{e.message}"
167
+ end
168
+
169
+ def decode(response)
170
+ body = response.body.to_s
171
+ return nil if body.empty?
172
+ return body unless response["Content-Type"].to_s.include?("json")
173
+
174
+ JSON.parse(body)
175
+ rescue JSON::ParserError
176
+ body
177
+ end
178
+
179
+ def http
180
+ Net::HTTP.new(host, port).tap do |client|
181
+ client.open_timeout = @open_timeout
182
+ client.read_timeout = @read_timeout
183
+ end
184
+ end
185
+
186
+ def monotonic_now
187
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "frpc/admin"
4
+ require "frpc/ruby"
5
+
6
+ module Frpc
7
+ # Runs frpc as a child process and controls it over its admin API.
8
+ #
9
+ # client = Frpc::Client.new("frpc.toml", user: "admin", password: "hunter2")
10
+ # client.status
11
+ # client.reload(File.read("frpc2.toml"))
12
+ # client.stop
13
+ #
14
+ # Or scoped, which always stops the child:
15
+ #
16
+ # Frpc::Client.run("frpc.toml") { |c| pp c.status }
17
+ #
18
+ # The config file must enable the admin server, otherwise there is nothing to
19
+ # talk to and #start raises:
20
+ #
21
+ # webServer.addr = "127.0.0.1"
22
+ # webServer.port = 7400
23
+ class Client
24
+ class Error < StandardError; end
25
+
26
+ DEFAULT_ADMIN = { host: "127.0.0.1", port: 7400 }.freeze
27
+
28
+ attr_reader :config_path, :admin, :pid
29
+
30
+ def self.run(config_path, **kwargs)
31
+ client = new(config_path, **kwargs)
32
+ begin
33
+ yield client
34
+ ensure
35
+ client.stop
36
+ end
37
+ end
38
+
39
+ # host/port/user/password default to whatever webServer.* the config file
40
+ # declares, falling back to 127.0.0.1:7400. Pass them explicitly to skip
41
+ # that sniffing entirely.
42
+ def initialize(config_path,
43
+ executable: nil,
44
+ host: nil, port: nil, user: nil, password: nil,
45
+ env: {}, log_to: nil, timeout: 10, start: true)
46
+ @config_path = File.expand_path(config_path)
47
+ raise Error, "no such frpc config file: #{@config_path}" unless File.exist?(@config_path)
48
+
49
+ @executable = Frpc::Ruby.executable(exe_path: executable)
50
+ @env = env
51
+ @log_to = log_to
52
+ @timeout = timeout
53
+
54
+ settings = web_server_settings
55
+ @admin = Admin.new(
56
+ host: host || settings[:addr] || DEFAULT_ADMIN[:host],
57
+ port: port || settings[:port] || DEFAULT_ADMIN[:port],
58
+ user: user || settings[:user],
59
+ password: password || settings[:password],
60
+ )
61
+
62
+ self.start if start
63
+ end
64
+
65
+ def start
66
+ raise Error, "frpc is already running (pid #{@pid})" if running?
67
+
68
+ @pid = Process.spawn(@env, @executable, "-c", @config_path, **spawn_options)
69
+ wait_for_admin
70
+ self
71
+ end
72
+
73
+ def running?
74
+ return false unless @pid
75
+
76
+ Process.waitpid(@pid, Process::WNOHANG).nil?
77
+ rescue Errno::ECHILD, Errno::ESRCH
78
+ false
79
+ end
80
+
81
+ def status = admin.status
82
+ def config = admin.config
83
+ def reload(new_toml = nil) = admin.reload(new_toml)
84
+
85
+ # Per-proxy hash for `name`, or nil when frpc does not know that proxy.
86
+ def proxy(name)
87
+ status.to_h.each_value do |proxies|
88
+ found = Array(proxies).find { |p| p["name"] == name.to_s }
89
+ return found if found
90
+ end
91
+ nil
92
+ end
93
+
94
+ # Asks frpc to shut down gracefully, then reaps the child. Falls back to
95
+ # TERM and finally KILL if it does not exit within `timeout`.
96
+ def stop(timeout: @timeout)
97
+ return unless @pid
98
+
99
+ begin
100
+ admin.stop
101
+ rescue Admin::Error
102
+ # Admin server already gone or never came up — signals below still apply.
103
+ end
104
+
105
+ reap(timeout: timeout) || terminate(timeout: timeout)
106
+ @pid = nil
107
+ end
108
+
109
+ private
110
+
111
+ def spawn_options
112
+ options = { pgroup: true }
113
+ if @log_to
114
+ options[:out] = @log_to
115
+ options[:err] = [:child, :out]
116
+ end
117
+ options
118
+ end
119
+
120
+ def wait_for_admin
121
+ deadline = now + @timeout
122
+ loop do
123
+ return if admin.healthy?
124
+
125
+ unless running?
126
+ raise Error, "frpc exited immediately; check its logs and #{@config_path}"
127
+ end
128
+ if now >= deadline
129
+ stop
130
+ raise Error, <<~MSG
131
+ frpc started (pid #{@pid}) but its admin API never answered on
132
+ #{admin.host}:#{admin.port} within #{@timeout}s. Does #{@config_path}
133
+ set webServer.addr and webServer.port?
134
+ MSG
135
+ end
136
+
137
+ sleep 0.05
138
+ end
139
+ end
140
+
141
+ # true once the child has been reaped, false on timeout.
142
+ def reap(timeout:)
143
+ deadline = now + timeout
144
+ loop do
145
+ return true unless running?
146
+ return false if now >= deadline
147
+
148
+ sleep 0.05
149
+ end
150
+ end
151
+
152
+ def terminate(timeout:)
153
+ signal("TERM")
154
+ return true if reap(timeout: timeout)
155
+
156
+ signal("KILL")
157
+ reap(timeout: timeout)
158
+ end
159
+
160
+ def signal(name)
161
+ Process.kill(name, @pid)
162
+ rescue Errno::ESRCH, Errno::EPERM
163
+ nil
164
+ end
165
+
166
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
167
+
168
+ # Best-effort scan of the config for webServer settings, supporting both
169
+ # the dotted form (webServer.port = 7400) and the table form
170
+ # ([webServer] / port = 7400). Anything explicitly passed to #initialize
171
+ # wins over this, so a parse miss is never fatal.
172
+ def web_server_settings
173
+ settings = {}
174
+ in_table = false
175
+
176
+ File.foreach(@config_path) do |line|
177
+ line = line.sub(/(?<!\\)#.*/, "").strip
178
+ next if line.empty?
179
+
180
+ if (table = line[/\A\[+\s*([^\]]+?)\s*\]+\z/, 1])
181
+ in_table = table.casecmp?("webServer")
182
+ next
183
+ end
184
+
185
+ key, value = line.split("=", 2)
186
+ next unless value
187
+
188
+ key = key.strip
189
+ dotted = key.sub!(/\AwebServer\./i, "") # nil when the prefix was absent
190
+ next unless dotted || in_table
191
+
192
+ assign(settings, key, value.strip)
193
+ end
194
+
195
+ settings
196
+ rescue SystemCallError
197
+ {}
198
+ end
199
+
200
+ def assign(settings, key, value)
201
+ value = value[1..-2] if value.start_with?('"') && value.end_with?('"')
202
+
203
+ case key.downcase
204
+ when "addr" then settings[:addr] = value
205
+ when "port" then settings[:port] = Integer(value, exception: false)
206
+ when "user" then settings[:user] = value
207
+ when "password" then settings[:password] = value
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Frpc
4
+ module Ruby
5
+ # Mirrors the upstream frp release we vendor. If the wrapper itself needs a
6
+ # release without an upstream bump, append a fourth segment ("0.70.1.1") —
7
+ # UPSTREAM_VERSION drops it again when building download URLs.
8
+ VERSION = "0.70.1"
9
+
10
+ UPSTREAM_VERSION = VERSION.split(".").first(3).join(".")
11
+ end
12
+ end
data/lib/frpc/ruby.rb ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "frpc/ruby/version"
4
+
5
+ module Frpc
6
+ # Locates the `frpc` executable this gem should run.
7
+ #
8
+ # The gem ships as one platform gem per supported target, each carrying the
9
+ # matching CGO_ENABLED=0 binary from the upstream frp release under
10
+ # exe/<gem-platform>/. The plain "ruby" platform gem carries no binary at all;
11
+ # on an unsupported architecture the user installs frpc themselves and points
12
+ # FRPC_INSTALL_DIR at it.
13
+ module Ruby
14
+ # gem platform => upstream frp release target suffix
15
+ PLATFORMS = {
16
+ "arm64-darwin" => "darwin_arm64",
17
+ "x86_64-darwin" => "darwin_amd64",
18
+ "aarch64-linux" => "linux_arm64",
19
+ "arm-linux" => "linux_arm",
20
+ "x86_64-linux" => "linux_amd64",
21
+ "x64-mingw-ucrt" => "windows_amd64",
22
+ "x64-mingw32" => "windows_amd64",
23
+ "aarch64-mingw-ucrt" => "windows_arm64",
24
+ }.freeze
25
+
26
+ # Platforms we actually publish a gem for. x64-mingw32 resolves at runtime
27
+ # (old rubies report that platform string) but shares the ucrt gem's binary,
28
+ # so it is not built separately.
29
+ PACKAGED_PLATFORMS = PLATFORMS.keys - ["x64-mingw32"]
30
+
31
+ class UnsupportedPlatformError < StandardError; end
32
+ class ExecutableNotFoundError < StandardError; end
33
+
34
+ class << self
35
+ # Resolution order, first hit wins:
36
+ # 1. an explicit exe_path: argument
37
+ # 2. FRPC_INSTALL_DIR / FRPC_PATH (a directory or a full path)
38
+ # 3. the binary vendored into this gem for the current platform
39
+ # 4. frpc on PATH
40
+ def executable(exe_path: nil)
41
+ override = exe_path || ENV["FRPC_INSTALL_DIR"] || ENV["FRPC_PATH"]
42
+ return resolve_override(override) unless override.nil? || override.empty?
43
+
44
+ return vendored_executable if vendored_executable
45
+ return path_executable if path_executable
46
+
47
+ raise ExecutableNotFoundError, <<~MSG
48
+ Cannot find the frpc executable for platform #{platform}.
49
+
50
+ This usually means the platform gem for #{platform} was not installed.
51
+ Bundler only locks the platforms it has seen, so try:
52
+
53
+ bundle lock --add-platform #{platform}
54
+
55
+ If #{platform} is not a platform frpc-ruby ships a binary for, install
56
+ frpc yourself from https://github.com/fatedier/frp/releases and set
57
+ FRPC_INSTALL_DIR to the directory containing it (or FRPC_PATH to the
58
+ executable itself).
59
+ MSG
60
+ end
61
+
62
+ # e.g. "x86_64-linux", "arm64-darwin"
63
+ def platform
64
+ @platform ||= [:cpu, :os].map { |part| Gem::Platform.local.public_send(part) }.join("-")
65
+ end
66
+
67
+ # The upstream release target for the running platform, e.g. "linux_amd64".
68
+ def upstream_target(gem_platform = platform)
69
+ PLATFORMS.fetch(gem_platform) do
70
+ raise UnsupportedPlatformError, "frpc-ruby has no upstream frp build for #{gem_platform}"
71
+ end
72
+ end
73
+
74
+ def exe_name
75
+ Gem.win_platform? ? "frpc.exe" : "frpc"
76
+ end
77
+
78
+ # Path the packaging task writes to, and the path executable/2 reads back.
79
+ def vendored_path(gem_platform = platform, name = exe_name)
80
+ File.expand_path(File.join(__dir__, "..", "..", "exe", gem_platform, name))
81
+ end
82
+
83
+ def vendored_executable
84
+ candidate = vendored_path
85
+ candidate if File.exist?(candidate)
86
+ end
87
+
88
+ def path_executable
89
+ ENV.fetch("PATH", "")
90
+ .split(File::PATH_SEPARATOR)
91
+ .reject(&:empty?)
92
+ .map { |dir| File.join(dir, exe_name) }
93
+ .find { |file| File.executable?(file) && !File.directory?(file) }
94
+ end
95
+
96
+ private
97
+
98
+ def resolve_override(path)
99
+ expanded = File.expand_path(path)
100
+ candidate = File.directory?(expanded) ? File.join(expanded, exe_name) : expanded
101
+
102
+ unless File.exist?(candidate)
103
+ raise ExecutableNotFoundError,
104
+ "frpc executable override points at #{candidate}, which does not exist"
105
+ end
106
+
107
+ candidate
108
+ end
109
+ end
110
+ end
111
+ end
data/lib/frpc.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "frpc/ruby"
4
+ require "frpc/admin"
5
+ require "frpc/client"
6
+
7
+ # frpc-ruby ships the upstream frp client binary in a platform gem and drives it
8
+ # over its admin HTTP API.
9
+ #
10
+ # require "frpc"
11
+ # Frpc::Ruby.executable # => path to the vendored frpc
12
+ # Frpc::Client.run("frpc.toml") { |c| pp c.status }
13
+ module Frpc
14
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: frpc-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.70.1
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: |
41
+ Ships the upstream frpc binary for your platform and drives it from Ruby:
42
+ spawn the client, read per-proxy status, push a new config and reload it,
43
+ and shut it down gracefully — all over frpc's admin HTTP API.
44
+ executables:
45
+ - frpc
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - exe/frpc
53
+ - lib/frpc.rb
54
+ - lib/frpc/admin.rb
55
+ - lib/frpc/client.rb
56
+ - lib/frpc/ruby.rb
57
+ - lib/frpc/ruby/version.rb
58
+ homepage: https://github.com/nathankidd/frpc-ruby
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/nathankidd/frpc-ruby
63
+ changelog_uri: https://github.com/nathankidd/frpc-ruby/blob/main/CHANGELOG.md
64
+ upstream_frp_uri: https://github.com/fatedier/frp/releases/tag/v0.70.1
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.1.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.7.2
81
+ specification_version: 4
82
+ summary: The frp client (frpc) as a Ruby gem, with a wrapper for its admin API
83
+ test_files: []