rb-portless 0.5.0 → 0.5.1.dev.20260805.5cf596b
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 +4 -4
- data/AGENTS.md +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +10 -1
- data/lib/portless/cli.rb +14 -4
- data/lib/portless/daemon.rb +2 -1
- data/lib/portless/proxy.rb +31 -4
- data/lib/portless/route_store.rb +8 -3
- data/lib/portless/runner.rb +7 -3
- data/lib/portless/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77538c8604e444cdbe84b5ee01c20e4e27171e550ca21b3c0181b34d09723104
|
|
4
|
+
data.tar.gz: 0b1308e10a949443b7f3184bd682ba337b8ac48eb91f27d4f7565cbdeb4f452f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 19ebe65d313d0a3ac14497aafe5d10557157722a0dfcde4c3d6ccaa99acc51a48b086e9d5ff17ceb2846a38532f5c5ec5b1ad1ca8a1d934dfb8743831a135819
|
|
7
|
+
data.tar.gz: 6bacf132c93f00fb84e1f6d4c8be2437a9d85e45a7e2dd390332e1ffcd3e1dee8844e93ddecc910d81e461f48d39530d19b32cf4f442c8bc625ce857eaadfbc9
|
data/AGENTS.md
CHANGED
|
@@ -27,6 +27,13 @@ injected as `PORT`; the proxy routes the named host to it.
|
|
|
27
27
|
- **Per-host SNI certs.** `*.localhost` wildcard certs are invalid at the
|
|
28
28
|
reserved-TLD boundary, so mint a leaf per hostname on the TLS SNI callback,
|
|
29
29
|
cached on disk + in memory.
|
|
30
|
+
- **Loopback by default; LAN is opt-in twice.** The proxy binds `127.0.0.1` +
|
|
31
|
+
`::1` unless started `--lan` (persisted in `proxy.lan`). Even then only
|
|
32
|
+
routes carrying `lan: true` (registered by `run --lan`) are served to
|
|
33
|
+
off-loopback clients — one daemon serves every project, so without that
|
|
34
|
+
gate one app's `--lan` would expose all of them. Gating fails closed: an
|
|
35
|
+
unreadable peer address counts as remote. The 404 page's app listing is
|
|
36
|
+
suppressed for LAN clients (it would enumerate your projects).
|
|
30
37
|
|
|
31
38
|
## Module map (`lib/portless/`)
|
|
32
39
|
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.5.1]
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **`--lan` exposed every running app, not just the one you shared.** LAN mode
|
|
12
|
+
is a property of the daemon — one proxy serves every project — so
|
|
13
|
+
`run --lan` in one app made every other registered route answer the whole
|
|
14
|
+
Wi-Fi too. Routes now record the opt-in (`lan: true`, set by `run --lan`) and
|
|
15
|
+
the proxy serves *only* those to off-loopback clients; everything else 404s.
|
|
16
|
+
Loopback access is unchanged. The gate fails closed: a peer address we can't
|
|
17
|
+
read counts as remote. (Upstream portless has the same daemon-wide exposure
|
|
18
|
+
and no per-route gating; this goes further deliberately.)
|
|
19
|
+
- **The 404 page listed your running apps to LAN clients.** The app listing
|
|
20
|
+
added in 0.5.0 handed anyone on the network the name of every project you had
|
|
21
|
+
running. Off-loopback clients now get a bare 404.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`proxy restart --no-lan`** — return the daemon to loopback-only without a
|
|
26
|
+
`stop`/`start` dance. (`--lan`/`--no-lan` are both explicit now; a plain
|
|
27
|
+
`restart` still preserves the current mode.) The warning printed when `run
|
|
28
|
+
--lan` switches a loopback-only daemon over now says how to undo it.
|
|
29
|
+
|
|
7
30
|
## [0.5.0]
|
|
8
31
|
|
|
9
32
|
### Security
|
data/README.md
CHANGED
|
@@ -84,10 +84,19 @@ proxy (loopback-only by default, for safety) to listen on the network:
|
|
|
84
84
|
```bash
|
|
85
85
|
rb-portless run --lan bin/dev # also → https://<name>.local
|
|
86
86
|
rb-portless run --lan --ip 10.0.0.5 bin/dev # override the detected IP
|
|
87
|
+
rb-portless proxy restart --no-lan # stop serving the LAN
|
|
87
88
|
```
|
|
89
|
+
|
|
90
|
+
**`--lan` shares that one app.** There's a single proxy for every project, so
|
|
91
|
+
LAN mode opens one socket for all of them — but only routes started with
|
|
92
|
+
`--lan` answer clients from the network. Everything else you have running stays
|
|
93
|
+
loopback-only and 404s (without naming your other apps). The daemon stays in LAN
|
|
94
|
+
mode until `proxy restart --no-lan`.
|
|
95
|
+
|
|
88
96
|
> Devices won't trust your local CA without installing it — use `--lan` with
|
|
89
97
|
> `--no-tls` (set `"tls": false`) for plain HTTP, or install `~/.rb-portless/ca.pem`
|
|
90
|
-
> on the device.
|
|
98
|
+
> on the device. mDNS publishes only `<name>.local`, so tenant subdomains
|
|
99
|
+
> (`kobe.<name>.local`) won't resolve on the device.
|
|
91
100
|
|
|
92
101
|
**Public sharing** (experimental) — expose the app via ngrok or your tailnet:
|
|
93
102
|
|
data/lib/portless/cli.rb
CHANGED
|
@@ -105,10 +105,10 @@ module Portless
|
|
|
105
105
|
case args.first
|
|
106
106
|
when "start"
|
|
107
107
|
Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"),
|
|
108
|
-
foreground: flag?("--foreground"), lan: args
|
|
108
|
+
foreground: flag?("--foreground"), lan: lan_flag(args) || false)
|
|
109
109
|
when "stop" then Daemon.stop
|
|
110
110
|
when "restart" then Daemon.restart(tls: explicit_tls_flag(args), port: int_flag(args, "--port"),
|
|
111
|
-
lan: args
|
|
111
|
+
lan: lan_flag(args))
|
|
112
112
|
when nil then command_help("proxy")
|
|
113
113
|
else invalid_action!("proxy start|stop|restart")
|
|
114
114
|
end
|
|
@@ -280,6 +280,14 @@ module Portless
|
|
|
280
280
|
nil
|
|
281
281
|
end
|
|
282
282
|
|
|
283
|
+
# nil unless the user passed --lan/--no-lan (restart preserves the mode).
|
|
284
|
+
def lan_flag(args)
|
|
285
|
+
return false if args.include?("--no-lan")
|
|
286
|
+
return true if args.include?("--lan")
|
|
287
|
+
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
283
291
|
def int_flag(args, name)
|
|
284
292
|
i = args.index(name)
|
|
285
293
|
i ? Integer(args[i + 1], exception: false) : nil
|
|
@@ -358,8 +366,10 @@ module Portless
|
|
|
358
366
|
example: "rb-portless alias postgres 5432 # -> https://postgres.localhost" },
|
|
359
367
|
"proxy" => { summary: "Manage the proxy daemon.",
|
|
360
368
|
usage: [ "proxy start [--no-tls] [--port <n>] [--lan]", "proxy stop",
|
|
361
|
-
"proxy restart (
|
|
362
|
-
flags: [ [ "--lan", "listen on all interfaces (default: loopback only)" ]
|
|
369
|
+
"proxy restart [--lan | --no-lan] (also picks up an updated gem)" ],
|
|
370
|
+
flags: [ [ "--lan", "listen on all interfaces (default: loopback only)" ],
|
|
371
|
+
[ "--no-lan", "go back to loopback-only" ] ],
|
|
372
|
+
example: "rb-portless proxy restart --no-lan # stop serving the LAN" },
|
|
363
373
|
"trust" => { summary: "Trust the local CA so HTTPS works without warnings.",
|
|
364
374
|
usage: [ "trust" ] },
|
|
365
375
|
"hosts" => { summary: "Manage the /etc/hosts block (Safari / non-.localhost TLDs).",
|
data/lib/portless/daemon.rb
CHANGED
|
@@ -17,7 +17,8 @@ module Portless
|
|
|
17
17
|
if port && lan && !lan_active?
|
|
18
18
|
# The proxy binds loopback-only by default; --lan needs it reachable
|
|
19
19
|
# from the network, so switch the running daemon over.
|
|
20
|
-
warn "rb-portless: restarting the proxy in LAN mode (it was loopback-only)"
|
|
20
|
+
warn "rb-portless: restarting the proxy in LAN mode (it was loopback-only) — only apps run " \
|
|
21
|
+
"with --lan answer network clients; `rb-portless proxy restart --no-lan` undoes it"
|
|
21
22
|
restart(tls: tls, port: port, lan: true)
|
|
22
23
|
return Health.discover_port || port
|
|
23
24
|
end
|
data/lib/portless/proxy.rb
CHANGED
|
@@ -62,10 +62,15 @@ module Portless
|
|
|
62
62
|
# requests forwarded by the tunnel keep the *.ts.net authority, upstream
|
|
63
63
|
# issue #297), then wildcard fallback so *.name.localhost all reach the
|
|
64
64
|
# single app registered as name.localhost.
|
|
65
|
-
|
|
65
|
+
#
|
|
66
|
+
# `lan_client:` restricts the search to routes that opted into LAN serving
|
|
67
|
+
# (`run --lan`). LAN mode opens one socket for the whole daemon, so without
|
|
68
|
+
# this every app you happen to be running would answer the whole Wi-Fi.
|
|
69
|
+
def route_for(host, lan_client: false)
|
|
66
70
|
authority = host.to_s.downcase.delete_suffix(":443")
|
|
67
71
|
host = authority.split(":").first.to_s
|
|
68
72
|
routes = @route_store.routes
|
|
73
|
+
routes = routes.select(&:lan?) if lan_client
|
|
69
74
|
routes.find { |r| r.hostname == host } ||
|
|
70
75
|
routes.find { |r| share_match?(r, authority, host) } ||
|
|
71
76
|
routes.find { |r| host.end_with?(".#{r.hostname}") }
|
|
@@ -76,8 +81,12 @@ module Portless
|
|
|
76
81
|
# (Async::HTTP::Server.for(endpoint, &proxy.method(:call))).
|
|
77
82
|
def call(request)
|
|
78
83
|
host = request_host(request)
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
# Only consulted in LAN mode: with a loopback-only bind there is no
|
|
85
|
+
# off-machine client, and a stricter default there could 404 local dev if
|
|
86
|
+
# the peer address were ever unreadable.
|
|
87
|
+
lan_client = @lan && !loopback_client?(request)
|
|
88
|
+
route = route_for(host, lan_client: lan_client)
|
|
89
|
+
return not_found(host, lan_client: lan_client) unless route
|
|
81
90
|
|
|
82
91
|
hops = request.headers[HOP_HEADER].to_a.first.to_i
|
|
83
92
|
return error(508, "Proxy loop detected for #{host}.") if hops >= Constants::MAX_PROXY_HOPS
|
|
@@ -187,6 +196,20 @@ module Portless
|
|
|
187
196
|
"127.0.0.1"
|
|
188
197
|
end
|
|
189
198
|
|
|
199
|
+
# Is the peer on this machine? Positive identification only — an unreadable
|
|
200
|
+
# address counts as remote, so LAN gating fails closed.
|
|
201
|
+
def loopback_client?(request)
|
|
202
|
+
address = begin
|
|
203
|
+
request.remote_address&.ip_address
|
|
204
|
+
rescue StandardError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
return false unless address
|
|
208
|
+
|
|
209
|
+
address = address.to_s.downcase.delete_prefix("::ffff:")
|
|
210
|
+
address == "::1" || address.start_with?("127.")
|
|
211
|
+
end
|
|
212
|
+
|
|
190
213
|
def strip_hop_headers(response)
|
|
191
214
|
HOP_BY_HOP.each { |key| response.headers.delete(key) }
|
|
192
215
|
end
|
|
@@ -246,7 +269,11 @@ module Portless
|
|
|
246
269
|
|
|
247
270
|
# The 404 lists what IS running (clickable) plus the command that would
|
|
248
271
|
# register the missing name — upstream portless's most-loved error page.
|
|
249
|
-
|
|
272
|
+
# Never to a LAN client though: that would hand anyone on the Wi-Fi the
|
|
273
|
+
# name of every project you have running.
|
|
274
|
+
def not_found(host, lan_client: false)
|
|
275
|
+
return error(404, "No app is registered for <strong>#{escape(host)}</strong>.") if lan_client
|
|
276
|
+
|
|
250
277
|
safe_host = escape(host)
|
|
251
278
|
routes = @route_store.routes
|
|
252
279
|
suffix = @port == (@tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT) ? "" : ":#{@port}"
|
data/lib/portless/route_store.rb
CHANGED
|
@@ -9,8 +9,12 @@ module Portless
|
|
|
9
9
|
# directory mutex (atomic mkdir), and the proxy watches it. Dead-pid entries
|
|
10
10
|
# are reaped on every load. Mirrors portless's RouteStore.
|
|
11
11
|
class RouteStore
|
|
12
|
-
Route = Struct.new(:hostname, :port, :pid, :tailscale, :ngrok, keyword_init: true) do
|
|
12
|
+
Route = Struct.new(:hostname, :port, :pid, :tailscale, :ngrok, :lan, keyword_init: true) do
|
|
13
13
|
def alias? = pid.to_i.zero? # pid 0 = static alias (never reaped)
|
|
14
|
+
|
|
15
|
+
# Did this route opt into being served to LAN clients (`run --lan`)?
|
|
16
|
+
# The proxy refuses to serve anything else off-loopback.
|
|
17
|
+
def lan? = !!lan
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
LOCK_STALE_SECONDS = 10
|
|
@@ -36,7 +40,7 @@ module Portless
|
|
|
36
40
|
@routes_cache_key = key
|
|
37
41
|
@routes_cache = load.map do |h|
|
|
38
42
|
Route.new(hostname: h["hostname"], port: h["port"], pid: h["pid"],
|
|
39
|
-
tailscale: h["tailscale"], ngrok: h["ngrok"])
|
|
43
|
+
tailscale: h["tailscale"], ngrok: h["ngrok"], lan: h["lan"])
|
|
40
44
|
end
|
|
41
45
|
end
|
|
42
46
|
|
|
@@ -44,7 +48,7 @@ module Portless
|
|
|
44
48
|
# unless force, which SIGTERMs the incumbent. Alias routes use pid 0. Public
|
|
45
49
|
# share URLs (tailscale/ngrok), when present, are recorded so `list` can show
|
|
46
50
|
# them while the run is active.
|
|
47
|
-
def add(hostname:, port:, pid:, force: false, tailscale: nil, ngrok: nil)
|
|
51
|
+
def add(hostname:, port:, pid:, force: false, tailscale: nil, ngrok: nil, lan: false)
|
|
48
52
|
with_lock do
|
|
49
53
|
all = load.reject { |r| dead?(r["pid"]) }
|
|
50
54
|
existing = all.find { |r| r["hostname"] == hostname }
|
|
@@ -59,6 +63,7 @@ module Portless
|
|
|
59
63
|
entry = { "hostname" => hostname, "port" => port, "pid" => pid }
|
|
60
64
|
entry["tailscale"] = tailscale if tailscale
|
|
61
65
|
entry["ngrok"] = ngrok if ngrok
|
|
66
|
+
entry["lan"] = true if lan
|
|
62
67
|
all << entry
|
|
63
68
|
write(all)
|
|
64
69
|
end
|
data/lib/portless/runner.rb
CHANGED
|
@@ -29,7 +29,10 @@ module Portless
|
|
|
29
29
|
warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
|
|
30
30
|
ensure_trusted
|
|
31
31
|
proxy_port = Daemon.ensure_running(tls: @config.tls, lan: !!@options[:lan])
|
|
32
|
-
|
|
32
|
+
# lan: marks the route as opted into LAN serving — the proxy serves only
|
|
33
|
+
# these to off-loopback clients, so --lan shares THIS app, not every app.
|
|
34
|
+
@route_store.add(hostname: hostname, port: port, pid: Process.pid,
|
|
35
|
+
force: @options[:force], lan: !!@options[:lan])
|
|
33
36
|
|
|
34
37
|
url = display_url(hostname, proxy_port)
|
|
35
38
|
rows = [ [ "Local", url, :cyan ] ]
|
|
@@ -57,7 +60,8 @@ module Portless
|
|
|
57
60
|
return [ [ "Network", "no LAN IPv4 found", :dim ] ] unless ip
|
|
58
61
|
|
|
59
62
|
@lan_host = "#{@config.name}.local"
|
|
60
|
-
@route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid,
|
|
63
|
+
@route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid,
|
|
64
|
+
force: @options[:force], lan: true)
|
|
61
65
|
@mdns_pid = Mdns.publish(@lan_host, ip)
|
|
62
66
|
warn "rb-portless: trust #{State.ca_cert} on the device for HTTPS over the LAN" if @config.tls
|
|
63
67
|
[ [ "Network", display_url(@lan_host, proxy_port), :green ] ]
|
|
@@ -81,7 +85,7 @@ module Portless
|
|
|
81
85
|
def record_share_urls(hostname, port)
|
|
82
86
|
return unless @ngrok || @tailscale
|
|
83
87
|
|
|
84
|
-
@route_store.add(hostname: hostname, port: port, pid: Process.pid,
|
|
88
|
+
@route_store.add(hostname: hostname, port: port, pid: Process.pid, lan: !!@options[:lan],
|
|
85
89
|
tailscale: @tailscale&.dig(:url), ngrok: @ngrok&.dig(:url))
|
|
86
90
|
end
|
|
87
91
|
|
data/lib/portless/version.rb
CHANGED