rb-portless 0.4.0.dev.20260630.0e2b377 → 0.4.0.dev.20260704.9259a17
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/CHANGELOG.md +53 -0
- data/README.md +1 -1
- data/lib/portless/banner.rb +12 -4
- data/lib/portless/cli.rb +7 -5
- data/lib/portless/daemon.rb +73 -2
- data/lib/portless/health.rb +26 -5
- data/lib/portless/multi.rb +1 -1
- data/lib/portless/proxy.rb +42 -5
- data/lib/portless/rails.rb +44 -4
- data/lib/portless/rails_hosts.rb +31 -0
- data/lib/portless/runner.rb +1 -1
- data/lib/portless/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9239a4ef5be3c67695e2633bf59fb8a08cbf5624a9bd3d413439529c198884d
|
|
4
|
+
data.tar.gz: f173e9ac7a171ed118cbc7a9df7c6244338a183841f0dfe6a961d42014e6b929
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf329bdf61a50dd27b782b8f2ecf5141d052e986d5f03150bb3587bb2e0eeae61c21c62cfb45a875660de62e46893ec8696f4a56e681b38c7030e61ac5345fab
|
|
7
|
+
data.tar.gz: 3533215489c1e577481fe42a517157a9afecaa84b1f23da6c6e632e8579312e3931636a3cb1c380360433297b1337de627102e1444c8e905fd9adda2d93e126e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ 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.4.0]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Stale-proxy detection + `proxy restart`.** The proxy daemon outlives gem
|
|
12
|
+
updates, so a `bundle update` could leave last week's code holding :443
|
|
13
|
+
indefinitely. The proxy now stamps its version on every response (the health
|
|
14
|
+
header carries `VERSION` instead of a bare `1`), and `run` compares it with
|
|
15
|
+
the loaded gem: an older proxy prompts `restart the proxy? [Y/n]` (warn-only
|
|
16
|
+
without a TTY); a *newer* proxy warns that the project's gem is the stale
|
|
17
|
+
side. `rb-portless proxy restart` does the stop → wait → start dance manually,
|
|
18
|
+
and the startup banner always prints both sides (`v0.4.0 · proxy v0.4.0`) so a
|
|
19
|
+
drifting daemon is visible at a glance.
|
|
20
|
+
|
|
21
|
+
- **`default_url_options` under portless.** The Rails integration now points
|
|
22
|
+
`config.action_mailer.default_url_options` and the router's default URL
|
|
23
|
+
options at `https://<name>.localhost` whenever the app runs under rb-portless
|
|
24
|
+
(dev only). Mailers and jobs — which build links without a request — stop
|
|
25
|
+
emitting stale `localhost:<port>` URLs, so apps no longer need to hardcode a
|
|
26
|
+
dev host. Untouched when not running under rb-portless.
|
|
27
|
+
- **Action Cable origin allow-listing under portless.** The integration adds the
|
|
28
|
+
portless host and its subdomains to `config.action_cable.allowed_request_origins`
|
|
29
|
+
(dev only), so a WebSocket handshake from `https://<name>.localhost` isn't
|
|
30
|
+
rejected and Cable connects without extra config.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **HTTP/2 cookie splitting corrupted the session.** Browsers speaking HTTP/2
|
|
35
|
+
may send one `cookie` header field per cookie (RFC 9113 §8.2.3); the proxy
|
|
36
|
+
forwarded them as repeated HTTP/1.1 `cookie:` lines, which the backend
|
|
37
|
+
(e.g. Puma) joins with `", "`. Rack then parses that single mangled field and
|
|
38
|
+
every cookie but the first is lost — silently emptying the Rails session and
|
|
39
|
+
breaking CSRF on every form POST. The proxy now concatenates split cookie
|
|
40
|
+
fields into one `"; "`-joined header, as an h2→h1 intermediary must.
|
|
41
|
+
- **WebSockets from HTTP/2 browsers never reached the backend.** Firefox (and
|
|
42
|
+
any client using RFC 8441) opens `wss://` as an h2 *extended CONNECT* with
|
|
43
|
+
`:protocol: websocket`; the proxy forwarded the CONNECT verb raw, which the
|
|
44
|
+
HTTP/1.1 backend rejects as a parse error — Action Cable / Turbo Streams were
|
|
45
|
+
dead through the proxy. The proxy now translates: forwards it as `GET` +
|
|
46
|
+
`Upgrade` with a synthesized `Sec-WebSocket-Key` (extended CONNECT carries no
|
|
47
|
+
nonce, h1 backends demand one), and maps the backend's `101 Switching
|
|
48
|
+
Protocols` back to the `200` h2 requires. Verified end-to-end: an h2 extended
|
|
49
|
+
CONNECT through the old proxy → 400, through the fixed proxy → 200 with
|
|
50
|
+
Action Cable's welcome frame relayed.
|
|
51
|
+
- **A failed second `proxy start` left the live daemon unstoppable.** The
|
|
52
|
+
latecomer overwrote the running daemon's pid/port marker files, then deleted
|
|
53
|
+
them in its own crash cleanup — after which `proxy stop` claimed no proxy was
|
|
54
|
+
running while a (often root-owned) daemon still held the port. A foreground
|
|
55
|
+
start now refuses the port when a proxy already answers on it, cleanup only
|
|
56
|
+
reaps markers the exiting process owns, and `proxy stop` falls back to
|
|
57
|
+
port-owner discovery (re-trying under sudo for a root daemon) when the
|
|
58
|
+
markers are gone.
|
|
59
|
+
|
|
7
60
|
## [0.3.1]
|
|
8
61
|
|
|
9
62
|
### Changed (internal — no behaviour change)
|
data/README.md
CHANGED
|
@@ -113,7 +113,7 @@ anything, same as portless), and each degrades gracefully if the tool is absent:
|
|
|
113
113
|
| --- | --- |
|
|
114
114
|
| `run <cmd>` | run a dev server through the proxy |
|
|
115
115
|
| `<name> <cmd>` | shorthand for `run --name <name> <cmd>` |
|
|
116
|
-
| `proxy start \| stop` | manage the proxy daemon |
|
|
116
|
+
| `proxy start \| stop \| restart` | manage the proxy daemon (`restart` picks up an updated gem) |
|
|
117
117
|
| `trust` | install the local CA into the OS trust store |
|
|
118
118
|
| `service install \| uninstall \| status` | bind the privileged port at boot (launchd/systemd) |
|
|
119
119
|
| `alias <name> <port> [--force]` | a static route (Docker, Postgres, …); `--remove` to drop it |
|
data/lib/portless/banner.rb
CHANGED
|
@@ -10,8 +10,8 @@ module Portless
|
|
|
10
10
|
|
|
11
11
|
# rows: ordered [label, value, color] for the reachable URLs (Local,
|
|
12
12
|
# Network, Public, …); backend is the real 127.0.0.1:port behind the proxy.
|
|
13
|
-
def app(rows:, backend_port:)
|
|
14
|
-
out = [ "", " #{bold('rb-portless')} #{dim(
|
|
13
|
+
def app(rows:, backend_port:, proxy_version: nil)
|
|
14
|
+
out = [ "", " #{bold('rb-portless')} #{dim(versions(proxy_version))}", "" ]
|
|
15
15
|
rows.each { |label, value, paint| out << row(label, send(paint || :cyan, value)) }
|
|
16
16
|
out << row("Backend", dim("127.0.0.1:#{backend_port}"))
|
|
17
17
|
out << ""
|
|
@@ -21,8 +21,8 @@ module Portless
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Multi-app: one row per app (name → URL).
|
|
24
|
-
def multi(apps:)
|
|
25
|
-
out = [ "", " #{bold('rb-portless')} #{dim(
|
|
24
|
+
def multi(apps:, proxy_version: nil)
|
|
25
|
+
out = [ "", " #{bold('rb-portless')} #{dim(versions(proxy_version))}", "" ]
|
|
26
26
|
apps.each { |app| out << row(app.name, cyan(app.url)) }
|
|
27
27
|
out << ""
|
|
28
28
|
out << " #{dim('ready — press Ctrl-C to stop')}"
|
|
@@ -30,6 +30,14 @@ module Portless
|
|
|
30
30
|
warn out.join("\n")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Always show both sides of the version handshake — a stale daemon is
|
|
34
|
+
# invisible otherwise (it keeps last week's code in memory across updates).
|
|
35
|
+
def versions(proxy_version)
|
|
36
|
+
return "v#{VERSION}" unless proxy_version
|
|
37
|
+
|
|
38
|
+
"v#{VERSION} · proxy v#{proxy_version}"
|
|
39
|
+
end
|
|
40
|
+
|
|
33
41
|
def row(label, value) = " #{green('➜')} #{label.to_s.ljust(8)}#{value}"
|
|
34
42
|
|
|
35
43
|
# ── colours (target stderr, where the banner is printed) ──
|
data/lib/portless/cli.rb
CHANGED
|
@@ -92,9 +92,10 @@ module Portless
|
|
|
92
92
|
case args.first
|
|
93
93
|
when "start"
|
|
94
94
|
Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"), foreground: flag?("--foreground"))
|
|
95
|
-
when "stop"
|
|
96
|
-
when
|
|
97
|
-
|
|
95
|
+
when "stop" then Daemon.stop
|
|
96
|
+
when "restart" then Daemon.restart(tls: tls_flag(args), port: int_flag(args, "--port"))
|
|
97
|
+
when nil then command_help("proxy")
|
|
98
|
+
else invalid_action!("proxy start|stop|restart")
|
|
98
99
|
end
|
|
99
100
|
end
|
|
100
101
|
|
|
@@ -282,7 +283,7 @@ module Portless
|
|
|
282
283
|
rb-portless <name> <command> run <command> at https://<name>.localhost
|
|
283
284
|
rb-portless get <name> print a service's URL (--no-worktree)
|
|
284
285
|
rb-portless alias <name> <port> static route for an unmanaged service
|
|
285
|
-
rb-portless proxy start|stop
|
|
286
|
+
rb-portless proxy start|stop|restart manage the proxy daemon
|
|
286
287
|
rb-portless trust trust the local CA (HTTPS)
|
|
287
288
|
rb-portless hosts sync|clean manage /etc/hosts (Safari fallback)
|
|
288
289
|
rb-portless list show active routes
|
|
@@ -326,7 +327,8 @@ module Portless
|
|
|
326
327
|
flags: [ [ "--force", "overwrite an existing route" ] ],
|
|
327
328
|
example: "rb-portless alias postgres 5432 # -> https://postgres.localhost" },
|
|
328
329
|
"proxy" => { summary: "Manage the proxy daemon.",
|
|
329
|
-
usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop"
|
|
330
|
+
usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop",
|
|
331
|
+
"proxy restart (pick up an updated rb-portless)" ] },
|
|
330
332
|
"trust" => { summary: "Trust the local CA so HTTPS works without warnings.",
|
|
331
333
|
usage: [ "trust" ] },
|
|
332
334
|
"hosts" => { summary: "Manage the /etc/hosts block (Safari / non-.localhost TLDs).",
|
data/lib/portless/daemon.rb
CHANGED
|
@@ -12,12 +12,60 @@ module Portless
|
|
|
12
12
|
|
|
13
13
|
def ensure_running(tls:)
|
|
14
14
|
port = Health.discover_port
|
|
15
|
-
return port if port
|
|
15
|
+
return refresh_stale(port, tls: tls) if port
|
|
16
16
|
|
|
17
17
|
start(tls: tls)
|
|
18
18
|
Health.discover_port
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# The daemon outlives gem updates — after a `bundle update` the process on
|
|
22
|
+
# :443 may still be running last week's code. Compare the version it stamps
|
|
23
|
+
# on its responses with ours and offer to restart it; the reverse mismatch
|
|
24
|
+
# (a newer proxy) means this project's gem is the stale side.
|
|
25
|
+
def refresh_stale(port, tls:)
|
|
26
|
+
running = Health.proxy_version(port)
|
|
27
|
+
case version_action(running, VERSION)
|
|
28
|
+
when :restart
|
|
29
|
+
if restart_consented?(running)
|
|
30
|
+
restart(tls: tls, port: port)
|
|
31
|
+
port = Health.discover_port || port
|
|
32
|
+
else
|
|
33
|
+
warn "rb-portless: keeping the v#{running} proxy — run `rb-portless proxy restart` when ready"
|
|
34
|
+
end
|
|
35
|
+
when :update_gem
|
|
36
|
+
warn "rb-portless: the proxy is v#{running} but this project loads rb-portless v#{VERSION} — " \
|
|
37
|
+
"update the gem (`bundle update rb-portless`) so they match"
|
|
38
|
+
end
|
|
39
|
+
port
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# nil → proxy unreadable or versions equal: leave it alone.
|
|
43
|
+
def version_action(running, current)
|
|
44
|
+
return nil unless running
|
|
45
|
+
|
|
46
|
+
case Gem::Version.new(running) <=> Gem::Version.new(current)
|
|
47
|
+
when -1 then :restart
|
|
48
|
+
when 1 then :update_gem
|
|
49
|
+
end
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def restart_consented?(running)
|
|
55
|
+
warn "rb-portless: the running proxy is v#{running}; this rb-portless is v#{VERSION}"
|
|
56
|
+
return false unless Privilege.interactive?
|
|
57
|
+
|
|
58
|
+
$stderr.print "rb-portless: restart the proxy to pick up the update? [Y/n] "
|
|
59
|
+
!$stdin.gets.to_s.strip.downcase.start_with?("n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def restart(tls:, port: nil)
|
|
63
|
+
port ||= Health.discover_port
|
|
64
|
+
stop
|
|
65
|
+
wait_until_stopped(port) if port
|
|
66
|
+
start(tls: tls, port: port)
|
|
67
|
+
end
|
|
68
|
+
|
|
21
69
|
# foreground: become the daemon (binds the port, blocks). Otherwise
|
|
22
70
|
# orchestrate: elevate if needed, then spawn the detached foreground daemon.
|
|
23
71
|
def start(tls:, port: nil, foreground: false)
|
|
@@ -34,8 +82,12 @@ module Portless
|
|
|
34
82
|
end
|
|
35
83
|
|
|
36
84
|
def stop
|
|
37
|
-
pid = read_pid
|
|
85
|
+
pid = read_pid || discovered_pid
|
|
38
86
|
unless pid
|
|
87
|
+
# A proxy answers but we can't see who owns the port (a root daemon
|
|
88
|
+
# whose marker files were lost) — retry the whole stop under sudo.
|
|
89
|
+
return Privilege.reexec_with_sudo([ "proxy", "stop" ]) if Health.discover_port && !Privilege.root?
|
|
90
|
+
|
|
39
91
|
warn "rb-portless: no proxy is running"
|
|
40
92
|
return
|
|
41
93
|
end
|
|
@@ -48,6 +100,14 @@ module Portless
|
|
|
48
100
|
Privilege.reexec_with_sudo([ "proxy", "stop" ]) unless Privilege.root?
|
|
49
101
|
end
|
|
50
102
|
|
|
103
|
+
# Fallback when the pid marker is missing: whoever listens on the port the
|
|
104
|
+
# proxy answered from (lsof only sees our own processes unless root).
|
|
105
|
+
def discovered_pid
|
|
106
|
+
port = Health.discover_port or return nil
|
|
107
|
+
|
|
108
|
+
PortOwner.listeners(port).find { |pid| pid != Process.pid }
|
|
109
|
+
end
|
|
110
|
+
|
|
51
111
|
def start_privileged(port:, tls:)
|
|
52
112
|
unless Privilege.interactive?
|
|
53
113
|
warn "rb-portless: can't bind :#{port} without a terminal — using :#{Constants::FALLBACK_PROXY_PORT}"
|
|
@@ -85,6 +145,17 @@ module Portless
|
|
|
85
145
|
true
|
|
86
146
|
end
|
|
87
147
|
|
|
148
|
+
# A restart can't rebind the port until the old daemon has let go of it.
|
|
149
|
+
def wait_until_stopped(port, timeout: 10)
|
|
150
|
+
deadline = monotonic + timeout
|
|
151
|
+
while Health.proxy_running?(port)
|
|
152
|
+
return false if monotonic > deadline
|
|
153
|
+
|
|
154
|
+
sleep 0.2
|
|
155
|
+
end
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
88
159
|
def default_port(tls) = tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
|
|
89
160
|
|
|
90
161
|
def read_pid
|
data/lib/portless/health.rb
CHANGED
|
@@ -16,7 +16,28 @@ module Portless
|
|
|
16
16
|
REQUEST = "HEAD / HTTP/1.0\r\nHost: rb-portless.localhost\r\n\r\n"
|
|
17
17
|
|
|
18
18
|
def proxy_running?(port, timeout: 1.0)
|
|
19
|
-
|
|
19
|
+
!probe(port, timeout).nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The running proxy's version, read from the health header it stamps on
|
|
23
|
+
# every response. Nil when no proxy answers on the port.
|
|
24
|
+
def proxy_version(port, timeout: 1.0)
|
|
25
|
+
version_from(probe(port, timeout))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Proxies since 0.4.0 stamp their version as the header value; older ones
|
|
29
|
+
# stamped a bare "1" — report those as "0.0.0" so any release compares newer.
|
|
30
|
+
def version_from(response)
|
|
31
|
+
value = response.to_s[/^#{Regexp.escape(Constants::HEALTH_HEADER)}:\s*(\S+)/i, 1]
|
|
32
|
+
return nil unless value
|
|
33
|
+
|
|
34
|
+
value.match?(/\A\d+\.\d+/) ? value : "0.0.0"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The raw probe response when it's our proxy answering, else nil.
|
|
38
|
+
def probe(port, timeout)
|
|
39
|
+
response = probe_tls(port, timeout) || probe_plain(port, timeout)
|
|
40
|
+
marker?(response) ? response : nil
|
|
20
41
|
end
|
|
21
42
|
|
|
22
43
|
def probe_tls(port, timeout)
|
|
@@ -28,9 +49,9 @@ module Portless
|
|
|
28
49
|
ssl.connect
|
|
29
50
|
ssl.write(REQUEST)
|
|
30
51
|
# Read timeout too — a port that accepts but never answers must not hang us.
|
|
31
|
-
|
|
52
|
+
Timeout.timeout(timeout) { ssl.read(4096) }
|
|
32
53
|
rescue StandardError
|
|
33
|
-
|
|
54
|
+
nil
|
|
34
55
|
ensure
|
|
35
56
|
ssl&.close
|
|
36
57
|
socket&.close unless ssl
|
|
@@ -40,10 +61,10 @@ module Portless
|
|
|
40
61
|
Socket.tcp("127.0.0.1", port, connect_timeout: timeout) do |sock|
|
|
41
62
|
sock.write(REQUEST)
|
|
42
63
|
sock.close_write
|
|
43
|
-
|
|
64
|
+
Timeout.timeout(timeout) { sock.read(4096) }
|
|
44
65
|
end
|
|
45
66
|
rescue StandardError
|
|
46
|
-
|
|
67
|
+
nil
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
def marker?(response) = response.to_s.downcase.include?(Constants::HEALTH_HEADER)
|
data/lib/portless/multi.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Portless
|
|
|
24
24
|
proxy_port = Daemon.ensure_running(tls: @config.tls)
|
|
25
25
|
@apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
|
|
26
26
|
|
|
27
|
-
Banner.multi(apps: @apps)
|
|
27
|
+
Banner.multi(apps: @apps, proxy_version: Health.proxy_version(proxy_port))
|
|
28
28
|
install_signal_handlers
|
|
29
29
|
Process.waitall
|
|
30
30
|
ensure
|
data/lib/portless/proxy.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "async/http/client"
|
|
|
6
6
|
require "async/http/endpoint"
|
|
7
7
|
require "protocol/http/headers"
|
|
8
8
|
require "protocol/http/body/buffered"
|
|
9
|
+
require "securerandom"
|
|
9
10
|
|
|
10
11
|
module Portless
|
|
11
12
|
# The reverse proxy daemon (async-http: HTTP/1.1 + TLS + WebSockets; HTTP/2 in
|
|
@@ -30,6 +31,11 @@ module Portless
|
|
|
30
31
|
|
|
31
32
|
def run
|
|
32
33
|
State.ensure_dir!
|
|
34
|
+
# A second daemon on the same port would overwrite the live one's marker
|
|
35
|
+
# files, then wipe them on its own EADDRINUSE crash — leaving the survivor
|
|
36
|
+
# unstoppable by `proxy stop`. Refuse before touching any state.
|
|
37
|
+
raise Error, "a proxy is already running on :#{@port}" if Health.proxy_running?(@port)
|
|
38
|
+
|
|
33
39
|
@certs.ensure_ca! if @tls
|
|
34
40
|
write_markers
|
|
35
41
|
install_signal_handlers
|
|
@@ -63,7 +69,10 @@ module Portless
|
|
|
63
69
|
return error(508, "Proxy loop detected for #{host}.") if hops >= Constants::MAX_PROXY_HOPS
|
|
64
70
|
|
|
65
71
|
response = client_for(route.port).call(build_forward(request, host, hops))
|
|
66
|
-
|
|
72
|
+
# The h1 backend accepts a WebSocket with 101 Switching Protocols, but
|
|
73
|
+
# HTTP/2 forbids 1xx finals — an extended-CONNECT success is a plain 2xx.
|
|
74
|
+
response.status = 200 if response.status == 101 && request.version == "HTTP/2"
|
|
75
|
+
response.headers.add(Constants::HEALTH_HEADER, VERSION)
|
|
67
76
|
response
|
|
68
77
|
rescue StandardError => e
|
|
69
78
|
error(502, "Backend for #{host} is not responding (#{e.class}).")
|
|
@@ -77,16 +86,36 @@ module Portless
|
|
|
77
86
|
|
|
78
87
|
def build_forward(request, host, hops)
|
|
79
88
|
headers = Protocol::HTTP::Headers.new
|
|
89
|
+
cookies = []
|
|
80
90
|
request.headers.each do |key, value|
|
|
81
|
-
|
|
91
|
+
next if HOP_BY_HOP.include?(key.downcase)
|
|
92
|
+
|
|
93
|
+
# HTTP/2 clients may split the cookie header into one field per cookie
|
|
94
|
+
# (RFC 9113 §8.2.3); an intermediary translating to HTTP/1.1 MUST
|
|
95
|
+
# concatenate them with "; " — otherwise the backend joins the repeated
|
|
96
|
+
# lines with "," and cookie parsing (split on ";") corrupts the values.
|
|
97
|
+
key.downcase == "cookie" ? cookies << value : headers.add(key, value)
|
|
82
98
|
end
|
|
99
|
+
headers.add("cookie", cookies.join("; ")) unless cookies.empty?
|
|
83
100
|
headers.set("x-forwarded-host", host.split(":").first)
|
|
84
101
|
headers.set("x-forwarded-proto", @tls ? "https" : "http")
|
|
85
102
|
headers.set("x-forwarded-port", @port.to_s)
|
|
86
103
|
headers.add(HOP_HEADER, (hops + 1).to_s)
|
|
87
104
|
|
|
105
|
+
# HTTP/2 WebSockets arrive as extended CONNECT (RFC 8441, :protocol on the
|
|
106
|
+
# request); the HTTP/1.1 equivalent is GET + Upgrade, which the client
|
|
107
|
+
# layer emits from request.protocol. Forwarded raw, the CONNECT verb makes
|
|
108
|
+
# the backend's parser reject the stream. Extended CONNECT also drops
|
|
109
|
+
# Sec-WebSocket-Key (h2 needs no handshake nonce), but an h1 backend
|
|
110
|
+
# refuses an upgrade without one — synthesize it.
|
|
111
|
+
method = request.method
|
|
112
|
+
if method == "CONNECT" && request.protocol
|
|
113
|
+
method = "GET"
|
|
114
|
+
headers.set("sec-websocket-key", SecureRandom.base64(16)) if headers["sec-websocket-key"].nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
88
117
|
Protocol::HTTP::Request.new(
|
|
89
|
-
"http", host,
|
|
118
|
+
"http", host, method, request.path, request.version,
|
|
90
119
|
headers, request.body, request.protocol
|
|
91
120
|
)
|
|
92
121
|
end
|
|
@@ -128,7 +157,7 @@ module Portless
|
|
|
128
157
|
endpoint = Async::HTTP::Endpoint.parse("http://0.0.0.0:#{Constants::HTTP_PORT}")
|
|
129
158
|
Async::HTTP::Server.for(endpoint) do |request|
|
|
130
159
|
host = request_host(request).split(":").first
|
|
131
|
-
Protocol::HTTP::Response[302, { "location" => "https://#{host}#{request.path}", Constants::HEALTH_HEADER =>
|
|
160
|
+
Protocol::HTTP::Response[302, { "location" => "https://#{host}#{request.path}", Constants::HEALTH_HEADER => VERSION }, []]
|
|
132
161
|
end.run
|
|
133
162
|
rescue StandardError
|
|
134
163
|
nil # port 80 taken / unavailable — non-fatal.
|
|
@@ -142,7 +171,7 @@ module Portless
|
|
|
142
171
|
body = Protocol::HTTP::Body::Buffered.wrap("<!doctype html><meta charset=utf-8><title>rb-portless</title>" \
|
|
143
172
|
"<body style='font:16px system-ui;padding:3rem;max-width:40rem;margin:auto'>" \
|
|
144
173
|
"<h1>#{status}</h1><p>#{message}</p></body>")
|
|
145
|
-
Protocol::HTTP::Response[status, { "content-type" => "text/html; charset=utf-8", Constants::HEALTH_HEADER =>
|
|
174
|
+
Protocol::HTTP::Response[status, { "content-type" => "text/html; charset=utf-8", Constants::HEALTH_HEADER => VERSION }, body]
|
|
146
175
|
end
|
|
147
176
|
|
|
148
177
|
def write_markers
|
|
@@ -155,10 +184,18 @@ module Portless
|
|
|
155
184
|
%w[INT TERM].each { |sig| trap(sig) { cleanup; exit(0) } }
|
|
156
185
|
end
|
|
157
186
|
|
|
187
|
+
# Only reap markers we own — a crashing latecomer must never delete the
|
|
188
|
+
# live daemon's pid/port files.
|
|
158
189
|
def cleanup
|
|
190
|
+
return unless marker_pid == Process.pid
|
|
191
|
+
|
|
159
192
|
[ State.proxy_pid_file, State.proxy_port_file ].each { |f| File.delete(f) if File.exist?(f) }
|
|
160
193
|
rescue StandardError
|
|
161
194
|
nil
|
|
162
195
|
end
|
|
196
|
+
|
|
197
|
+
def marker_pid
|
|
198
|
+
Integer(File.read(State.proxy_pid_file).strip, exception: false) if File.exist?(State.proxy_pid_file)
|
|
199
|
+
end
|
|
163
200
|
end
|
|
164
201
|
end
|
data/lib/portless/rails.rb
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
# gem "rb-portless", require: "portless/rails"
|
|
6
6
|
#
|
|
7
7
|
# It auto-detects when the app is being run through `rb-portless run` (via the
|
|
8
|
-
# PORTLESS_URL env the runner injects) and *only then
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# PORTLESS_URL env the runner injects) and *only then*:
|
|
9
|
+
#
|
|
10
|
+
# * whitelists the matching `*.localhost` hosts in development — so Action
|
|
11
|
+
# Dispatch host authorization doesn't 403 your named subdomains, and
|
|
12
|
+
# * points `default_url_options` at the portless URL — so mailers and jobs
|
|
13
|
+
# (which generate links without a request) link to https://<name>.localhost
|
|
14
|
+
# instead of a bare localhost:<random-port>.
|
|
15
|
+
#
|
|
16
|
+
# Run Rails normally (not under rb-portless) and nothing is touched. Lightweight:
|
|
17
|
+
# does NOT load the proxy stack.
|
|
12
18
|
require "rails/railtie"
|
|
13
19
|
require_relative "rails_hosts"
|
|
14
20
|
|
|
@@ -22,5 +28,39 @@ module Portless
|
|
|
22
28
|
app.config.hosts << pattern unless app.config.hosts.include?(pattern)
|
|
23
29
|
end
|
|
24
30
|
end
|
|
31
|
+
|
|
32
|
+
# Under rb-portless the app is reached at https://<name>.localhost, not the
|
|
33
|
+
# random backend port — so request-less URL generation must target that. We
|
|
34
|
+
# own default_url_options here (authoritatively, when running under portless)
|
|
35
|
+
# rather than leave every app to hardcode a now-wrong localhost:port in
|
|
36
|
+
# development.rb. `config.action_mailer.default_url_options` is applied to the
|
|
37
|
+
# mailer lazily on load, so setting it after the env config still wins.
|
|
38
|
+
initializer "portless.default_url_options" do |app|
|
|
39
|
+
next unless defined?(Rails) && Rails.env.development?
|
|
40
|
+
|
|
41
|
+
options = RailsHosts.url_options or next
|
|
42
|
+
|
|
43
|
+
# The router's defaults cover jobs and any request-less url_for (mailers
|
|
44
|
+
# merge them in too). Set the mailer's own default_url_options via on_load
|
|
45
|
+
# so we win over Rails' earlier action_mailer.set_configs regardless of
|
|
46
|
+
# railtie order.
|
|
47
|
+
app.routes.default_url_options.merge!(options)
|
|
48
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
49
|
+
self.default_url_options = default_url_options.merge(options)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Allow the portless origin (and its subdomains) through Action Cable's
|
|
54
|
+
# WebSocket origin check — otherwise the handshake from https://<name>.
|
|
55
|
+
# localhost is rejected and Cable silently never connects.
|
|
56
|
+
initializer "portless.action_cable_origins" do |app|
|
|
57
|
+
next unless defined?(Rails) && Rails.env.development?
|
|
58
|
+
|
|
59
|
+
origins = RailsHosts.cable_origins
|
|
60
|
+
next if origins.empty?
|
|
61
|
+
|
|
62
|
+
app.config.action_cable.allowed_request_origins ||= []
|
|
63
|
+
app.config.action_cable.allowed_request_origins.concat(origins)
|
|
64
|
+
end
|
|
25
65
|
end
|
|
26
66
|
end
|
data/lib/portless/rails_hosts.rb
CHANGED
|
@@ -25,5 +25,36 @@ module Portless
|
|
|
25
25
|
patterns.push(host, ".#{host}") if host && !host.end_with?(".localhost")
|
|
26
26
|
patterns
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# The `default_url_options` (host + scheme) that mailers, jobs, and other
|
|
30
|
+
# request-less URL generation need so their links point at the portless URL
|
|
31
|
+
# instead of a bare `localhost:<random-port>`. Nil unless we're running
|
|
32
|
+
# under rb-portless. The standard 80/443 ports are dropped (portless serves
|
|
33
|
+
# without a port number); a custom one is carried through.
|
|
34
|
+
def url_options(portless_url = ENV["PORTLESS_URL"])
|
|
35
|
+
return if portless_url.to_s.empty?
|
|
36
|
+
|
|
37
|
+
uri = begin
|
|
38
|
+
URI(portless_url)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
return unless uri&.host
|
|
43
|
+
|
|
44
|
+
options = { host: uri.host, protocol: uri.scheme }
|
|
45
|
+
options[:port] = uri.port if uri.port && ![ 80, 443 ].include?(uri.port)
|
|
46
|
+
options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Action Cable rejects a WebSocket whose `Origin` isn't in
|
|
50
|
+
# `allowed_request_origins`; under rb-portless that's the portless host and
|
|
51
|
+
# any of its subdomains (tenant hosts), over the portless scheme — not the
|
|
52
|
+
# `localhost` default. Empty unless we're running under rb-portless.
|
|
53
|
+
def cable_origins(portless_url = ENV["PORTLESS_URL"])
|
|
54
|
+
options = url_options(portless_url) or return []
|
|
55
|
+
|
|
56
|
+
host = Regexp.escape(options[:host])
|
|
57
|
+
[ %r{\A#{options[:protocol]}://([\w-]+\.)*#{host}(:\d+)?\z} ]
|
|
58
|
+
end
|
|
28
59
|
end
|
|
29
60
|
end
|
data/lib/portless/runner.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Portless
|
|
|
36
36
|
rows.concat(lan_rows(port, proxy_port))
|
|
37
37
|
rows.concat(share_rows(hostname, port))
|
|
38
38
|
record_share_urls(hostname, port) # so `rb-portless list` shows the public URLs
|
|
39
|
-
Banner.app(rows: rows, backend_port: port)
|
|
39
|
+
Banner.app(rows: rows, backend_port: port, proxy_version: Health.proxy_version(proxy_port))
|
|
40
40
|
|
|
41
41
|
status = supervise(command, port, url)
|
|
42
42
|
exit(status)
|
data/lib/portless/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rb-portless
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.0.dev.
|
|
4
|
+
version: 0.4.0.dev.20260704.9259a17
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Afonso
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|