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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +169 -0
- data/exe/frpc +15 -0
- data/lib/frpc/admin.rb +190 -0
- data/lib/frpc/client.rb +211 -0
- data/lib/frpc/ruby/version.rb +12 -0
- data/lib/frpc/ruby.rb +111 -0
- data/lib/frpc.rb +14 -0
- metadata +83 -0
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
|
data/lib/frpc/client.rb
ADDED
|
@@ -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: []
|