rb-portless 0.4.1 → 0.5.0
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 +29 -3
- data/CHANGELOG.md +120 -0
- data/README.md +7 -1
- data/lib/portless/cli.rb +37 -6
- data/lib/portless/config.rb +10 -2
- data/lib/portless/daemon.rb +54 -16
- data/lib/portless/health.rb +1 -1
- data/lib/portless/hosts.rb +4 -0
- data/lib/portless/multi.rb +5 -2
- data/lib/portless/proxy.rb +139 -22
- data/lib/portless/rails.rb +13 -5
- data/lib/portless/rails_hosts.rb +39 -6
- data/lib/portless/route_store.rb +13 -1
- data/lib/portless/run_support.rb +14 -1
- data/lib/portless/runner.rb +1 -1
- data/lib/portless/service.rb +6 -0
- data/lib/portless/share/tailscale.rb +25 -2
- data/lib/portless/state.rb +2 -0
- data/lib/portless/trust.rb +7 -0
- data/lib/portless/version.rb +1 -1
- data/lib/portless/worktree.rb +3 -1
- data/lib/rb-portless.rb +3 -0
- 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: 47bfd748cef02443448a74938054d6cd57db229d76f286df305fbae28e4a2b7c
|
|
4
|
+
data.tar.gz: ba03fd513085d3d6cc56395792367a7211da79791dff2e2a8a017890af55ee5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 140b4a24273bb2bce99a52fcd103b5a21fb7adb397cf4881d1928572985b1446d2c8137f56e5c6b0530359746742f29318c303f7b2b7e9406f075a80c63ea103
|
|
7
|
+
data.tar.gz: ea86eebc7033820eac77352a8178add894d22e08ec5564d92715f22538c239ed78aae1c2289bf1974452f7f61cd9faf00fad6bbee88009f61d1c98a8eae14d70
|
data/AGENTS.md
CHANGED
|
@@ -71,15 +71,41 @@ injected as `PORT`; the proxy routes the named host to it.
|
|
|
71
71
|
(`--lan`: LanIp + mDNS `<name>.local`); public sharing (`--ngrok`/`--tailscale`/
|
|
72
72
|
`--funnel`, experimental).
|
|
73
73
|
|
|
74
|
+
- **Phase 5 ✅** end-to-end tests (`test/e2e_*_test.rb`, modeled on portless's
|
|
75
|
+
`tests/e2e`): a real proxy daemon on a high port + live backends. Cover
|
|
76
|
+
TLS/SNI, wildcard subdomains, X-Forwarded-*, HTTP/2, the WebSocket relay,
|
|
77
|
+
`run` lifecycle, and a real Rails app booted with the railtie.
|
|
78
|
+
|
|
74
79
|
### Roadmap (not yet built)
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
81
|
+
Deliberately skipped for now (revisit on demand):
|
|
82
|
+
|
|
83
|
+
- Windows CA trust + Task Scheduler service (no Windows machine to verify).
|
|
84
|
+
- Custom certs (`proxy start --cert/--key` upstream) — niche; the local CA
|
|
85
|
+
covers the normal flow.
|
|
86
|
+
- HTTP/2 to the backend, mDNS LAN-IP re-publish on network change, and a
|
|
87
|
+
brief backend retry-while-booting — low practical value in dev.
|
|
88
|
+
- `PORTLESS_SYNC_HOSTS` auto hosts-sync (we keep `hosts sync` manual).
|
|
89
|
+
|
|
90
|
+
### Test backlog
|
|
91
|
+
|
|
92
|
+
- CLI arg edge cases (`alias` add/remove, `get`, `doctor` exit codes) and the
|
|
93
|
+
bin/dev → bin/rails resolved-command fallback.
|
|
94
|
+
|
|
95
|
+
### Known divergences from portless (deliberate)
|
|
96
|
+
|
|
97
|
+
- Wildcard subdomain fallback is always on (upstream defaults strict, opt-in
|
|
98
|
+
via `--wildcard`) — subdomain-per-tenant Rails apps are the headline here.
|
|
99
|
+
- `portless.json` schema: `tld`/`tls` are ours; upstream keys `script`,
|
|
100
|
+
`proxy`, path-keyed `apps`, and package.json config don't apply to Ruby.
|
|
101
|
+
- Health header is `x-rb-portless: <version>` (upstream: `X-Portless: 1`);
|
|
102
|
+
the version value drives the stale-proxy handshake.
|
|
79
103
|
|
|
80
104
|
## Conventions
|
|
81
105
|
|
|
82
106
|
- Stdlib-first; the only runtime deps are `async` + `async-http` (for h1/h2/tls/ws).
|
|
83
107
|
- Minitest + fixtures-free tests in `test/`; isolate state via `PORTLESS_STATE_DIR`.
|
|
108
|
+
The e2e layer (`test/e2e_helper.rb`) spawns the real daemon on a free high
|
|
109
|
+
port (no sudo) — Rails-stack gems in the Gemfile are test-only.
|
|
84
110
|
- Mirror portless's naming/structure so the two stay diffable against
|
|
85
111
|
`references/portless`.
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,126 @@ 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.0]
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **The proxy listened on all interfaces.** It bound `0.0.0.0`, so every
|
|
12
|
+
registered dev app was reachable from the LAN, a VPN, or any other network
|
|
13
|
+
the machine sits on — no opt-in. The proxy (and the :80 redirect listener)
|
|
14
|
+
now binds the loopbacks only; `--lan` is the explicit opt-in, as a proxy
|
|
15
|
+
mode: `rb-portless run --lan` switches a loopback-only daemon over
|
|
16
|
+
automatically (persisted in a `proxy.lan` marker so restarts keep it), and
|
|
17
|
+
`proxy start --lan` does it by hand. Upstream portless shipped the same
|
|
18
|
+
change as a security fix.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **HTTP/2 responses lost all headers and their body when the backend sent a
|
|
23
|
+
hop-by-hop header.** A backend `Connection: close` (or `Transfer-Encoding`,
|
|
24
|
+
`Keep-Alive`, …) was relayed verbatim into the h2 stream, where those headers
|
|
25
|
+
are illegal — the header block aborted mid-write and the client saw a bare
|
|
26
|
+
`200` with no headers and no body. The proxy now strips hop-by-hop headers
|
|
27
|
+
from backend responses (like portless), except on a 101 h1 upgrade where they
|
|
28
|
+
carry the WebSocket handshake.
|
|
29
|
+
- **IPv6-first clients got `ECONNREFUSED`.** `*.localhost` resolves to `::1` as
|
|
30
|
+
well as `127.0.0.1`, but the proxy only bound IPv4 — clients without
|
|
31
|
+
Happy-Eyeballs fallback couldn't connect. The proxy now also listens on the
|
|
32
|
+
IPv6 loopback, best-effort (portless binds both).
|
|
33
|
+
- **Generated Rails URLs dropped the proxy port.** `X-Forwarded-Host` was
|
|
34
|
+
stripped to the bare hostname, so whenever the proxy serves on a non-default
|
|
35
|
+
port (e.g. the `:1355` sudo-declined fallback) `request.url` and every
|
|
36
|
+
generated link pointed at the portless host *without* the port. The full
|
|
37
|
+
authority is now forwarded, as in portless.
|
|
38
|
+
- **The railtie crashed apps without Action Cable.** The
|
|
39
|
+
`portless.action_cable_origins` initializer touched `config.action_cable`
|
|
40
|
+
unconditionally; API-only apps (or apps with trimmed railties) failed to boot
|
|
41
|
+
with `NoMethodError`. Now guarded.
|
|
42
|
+
- **The railtie crashed apps that set `allowed_request_origins` to a bare
|
|
43
|
+
Regexp** (idiomatic — Rails' own development default is one) or a frozen
|
|
44
|
+
array: `concat` on it blew up boot. The origins list is now rebuilt with
|
|
45
|
+
`Array(existing) + ours`.
|
|
46
|
+
- **A stale app-configured `:port` leaked into generated links.** With the
|
|
47
|
+
common `config.action_mailer.default_url_options = { host: "localhost",
|
|
48
|
+
port: 3000 }`, the railtie's merge kept `port: 3000` whenever the portless
|
|
49
|
+
URL had no explicit port — every mailer link pointed at
|
|
50
|
+
`https://<name>.localhost:3000`. The merge now drops `:port` unless the
|
|
51
|
+
portless URL carries one.
|
|
52
|
+
- **Custom-tld subdomains beyond one label were 403'd.** Rails' leading-dot
|
|
53
|
+
host shorthand (`.myapp.test`) matches a single subdomain label, so
|
|
54
|
+
`a.b.myapp.test` failed host authorization while `a.b.myapp.localhost`
|
|
55
|
+
passed. Custom tlds now use a multi-level regexp like `.localhost` does.
|
|
56
|
+
- **`--lan` devices hit Rails' blocked-host page.** The mDNS `<name>.local`
|
|
57
|
+
host was never whitelisted (it's not in any Rails default, and PORTLESS_URL
|
|
58
|
+
only carries the `.localhost` URL). `run --lan` now injects
|
|
59
|
+
`PORTLESS_LAN_HOST`, and the railtie whitelists it and adds a matching
|
|
60
|
+
Action Cable origin.
|
|
61
|
+
- **`--tailscale`/`--funnel` requests were rejected by Rails.** The tunnel
|
|
62
|
+
forwards with the raw `*.ts.net` Host, which nothing whitelisted. The runner
|
|
63
|
+
now injects `PORTLESS_TAILSCALE_URL`/`PORTLESS_NGROK_URL` (parity with
|
|
64
|
+
portless), the railtie whitelists those hosts (+ Action Cable origins), and
|
|
65
|
+
the proxy also routes requests addressed to a route's share hostname
|
|
66
|
+
(upstream issue #297).
|
|
67
|
+
- **`clean` left the boot service installed** — a surviving launchd/systemd
|
|
68
|
+
unit would resurrect the proxy against a deleted state dir. `clean` now
|
|
69
|
+
uninstalls it (only when one exists), and both `clean` and `prune` tear down
|
|
70
|
+
tailscale serve/funnel registrations recorded on (stale) routes.
|
|
71
|
+
- **A plain `proxy restart` reverted to defaults.** The daemon now records its
|
|
72
|
+
TLS mode (like the LAN marker); `restart` preserves both unless
|
|
73
|
+
`--tls`/`--no-tls`/`--lan` are passed, and a project whose `tls` setting
|
|
74
|
+
disagrees with the running daemon gets a warning instead of silently
|
|
75
|
+
spawning a rival proxy on the default port.
|
|
76
|
+
- **`clean`/re-trust cycles piled up CAs in the macOS keychain**
|
|
77
|
+
(`remove-trusted-cert` clears the trust setting but keeps the cert) — the
|
|
78
|
+
stale certificates are now deleted by CN.
|
|
79
|
+
- **Monorepo runs took over other projects' routes unconditionally** —
|
|
80
|
+
`Multi` now honors `--force` like the single-app path.
|
|
81
|
+
- Hostname labels (names, worktree branch prefixes) are clamped to the 63-char
|
|
82
|
+
DNS maximum — very long branch names produced invalid hostnames and
|
|
83
|
+
over-long cert filenames.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **Non-interactive privileged starts fail loudly instead of silently moving
|
|
88
|
+
to `:1355`.** When binding :443 needs sudo and there's no terminal (CI, task
|
|
89
|
+
runners), `run` used to fall back to `:1355` — quietly changing every URL.
|
|
90
|
+
It now exits with the ways out (pre-start the proxy, install the boot
|
|
91
|
+
service, or set `PORTLESS_PORT`), matching portless.
|
|
92
|
+
- The proxy dials backends via `localhost` (both loopback families tried in
|
|
93
|
+
sequence), so an IPv6-only dev server (a Node app bound to `::1`) no longer
|
|
94
|
+
502s.
|
|
95
|
+
- The 404 page now lists the active apps (clickable) and the `rb-portless
|
|
96
|
+
<name> <cmd>` command that would register the missing one.
|
|
97
|
+
|
|
98
|
+
### Added
|
|
99
|
+
|
|
100
|
+
- The `PORTLESS_*` env contract, parity with portless: `PORTLESS_HTTPS=0|1`
|
|
101
|
+
forces TLS off/on, `PORTLESS_TLD` overrides the tld, `PORTLESS_APP_PORT`
|
|
102
|
+
pins the backend port, and `PORTLESS_LAN` / `PORTLESS_NGROK` /
|
|
103
|
+
`PORTLESS_TAILSCALE` / `PORTLESS_FUNNEL` enable the matching run flags.
|
|
104
|
+
`PORTLESS_HOSTS_FILE` overrides the hosts-file path (tests, unusual
|
|
105
|
+
setups). Env overrides beat portless.json; explicit CLI flags beat both.
|
|
106
|
+
- A 30s backend response-header timeout (504) so a backend that accepts and
|
|
107
|
+
then hangs can't hold client connections forever, and an (mtime, size) cache
|
|
108
|
+
for routes.json so the proxy no longer re-parses it on every request.
|
|
109
|
+
- `X-Forwarded-For` on proxied requests (the client loopback/LAN address), so
|
|
110
|
+
`request.remote_ip` and request logs see the real client — parity with
|
|
111
|
+
portless.
|
|
112
|
+
- `NODE_EXTRA_CA_CERTS` in the child env: Node dev servers ignore
|
|
113
|
+
`SSL_CERT_FILE`, so Node-side outbound HTTPS to portless hosts now trusts the
|
|
114
|
+
local CA too (an existing value is respected).
|
|
115
|
+
- **End-to-end test suite** (`test/e2e_*_test.rb`), modeled on portless's
|
|
116
|
+
`tests/e2e`: boots the real proxy daemon on a high port plus live backends
|
|
117
|
+
and covers TLS/SNI cert verification, wildcard tenant subdomains,
|
|
118
|
+
`X-Forwarded-*`, HTTP/2, the full WebSocket relay, live route reloads,
|
|
119
|
+
404/502 pages, the daemon lifecycle (detached start → discovery → stop),
|
|
120
|
+
`run` (register → serve → deregister, process-group kill of a grandchild,
|
|
121
|
+
exit-status propagation), `PORTLESS=0` bypass, monorepo multi-app runs,
|
|
122
|
+
`prune` reaping an orphaned dev server, route-store lock contention across
|
|
123
|
+
processes — and a real Rails app booted with the railtie (host
|
|
124
|
+
authorization, `default_url_options`, mailer defaults, Action Cable
|
|
125
|
+
origins). Rails-stack gems are test-only.
|
|
126
|
+
|
|
7
127
|
## [0.4.1]
|
|
8
128
|
|
|
9
129
|
### Fixed
|
data/README.md
CHANGED
|
@@ -78,7 +78,8 @@ starts them all, each at its own name:
|
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
**LAN mode** — reach the app from your phone on the same Wi-Fi. It detects the
|
|
81
|
-
LAN IP, registers `<name>.local`,
|
|
81
|
+
LAN IP, registers `<name>.local`, publishes it over mDNS, and switches the
|
|
82
|
+
proxy (loopback-only by default, for safety) to listen on the network:
|
|
82
83
|
|
|
83
84
|
```bash
|
|
84
85
|
rb-portless run --lan bin/dev # also → https://<name>.local
|
|
@@ -142,6 +143,11 @@ as a subdomain — `feature/auth` → `https://auth.<name>.localhost` — so eve
|
|
|
142
143
|
gets a distinct URL. Pass `--no-worktree` (on `get`) to skip it. Set **`PORTLESS=0`**
|
|
143
144
|
(`false`/`skip`) to run the command directly without the proxy.
|
|
144
145
|
|
|
146
|
+
Every run flag has an env equivalent for scripts/CI: `PORTLESS_LAN`,
|
|
147
|
+
`PORTLESS_NGROK`, `PORTLESS_TAILSCALE`, `PORTLESS_FUNNEL` (set to `1`),
|
|
148
|
+
`PORTLESS_APP_PORT`, plus `PORTLESS_HTTPS=0|1` and `PORTLESS_TLD` overriding
|
|
149
|
+
`portless.json`, and `PORTLESS_PORT` for the proxy port. Explicit flags win.
|
|
150
|
+
|
|
145
151
|
## Rails
|
|
146
152
|
|
|
147
153
|
Rails is first-class: it respects `PORT` and trusts the loopback proxy, so
|
data/lib/portless/cli.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Portless
|
|
|
43
43
|
def cmd_run(args)
|
|
44
44
|
options, command = parse_run(args)
|
|
45
45
|
if command.empty? && Config.load.apps.any?
|
|
46
|
-
Multi.new.run # monorepo: portless.json `apps` map
|
|
46
|
+
Multi.new(options: options).run # monorepo: portless.json `apps` map
|
|
47
47
|
else
|
|
48
48
|
Runner.new(command: command, options: options).run
|
|
49
49
|
end
|
|
@@ -67,7 +67,7 @@ module Portless
|
|
|
67
67
|
# --lan/--ip, sharing (--ngrok/--tailscale/--funnel), --name, --force,
|
|
68
68
|
# --app-port. Everything after `--` is the command verbatim.
|
|
69
69
|
def parse_run(args)
|
|
70
|
-
options =
|
|
70
|
+
options = env_run_options
|
|
71
71
|
command = []
|
|
72
72
|
i = 0
|
|
73
73
|
while i < args.length
|
|
@@ -88,12 +88,27 @@ module Portless
|
|
|
88
88
|
[ options, command ]
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Flag defaults from the PORTLESS_* env contract (explicit flags override
|
|
92
|
+
# by being parsed after these are seeded).
|
|
93
|
+
def env_run_options
|
|
94
|
+
options = {}
|
|
95
|
+
options[:lan] = true if Portless.env_true?("PORTLESS_LAN")
|
|
96
|
+
options[:ngrok] = true if Portless.env_true?("PORTLESS_NGROK")
|
|
97
|
+
options[:tailscale] = true if Portless.env_true?("PORTLESS_TAILSCALE")
|
|
98
|
+
options[:funnel] = true if Portless.env_true?("PORTLESS_FUNNEL")
|
|
99
|
+
app_port = Integer(ENV["PORTLESS_APP_PORT"].to_s, exception: false)
|
|
100
|
+
options[:app_port] = app_port if app_port
|
|
101
|
+
options
|
|
102
|
+
end
|
|
103
|
+
|
|
91
104
|
def cmd_proxy(args)
|
|
92
105
|
case args.first
|
|
93
106
|
when "start"
|
|
94
|
-
Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"),
|
|
107
|
+
Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"),
|
|
108
|
+
foreground: flag?("--foreground"), lan: args.include?("--lan"))
|
|
95
109
|
when "stop" then Daemon.stop
|
|
96
|
-
when "restart" then Daemon.restart(tls:
|
|
110
|
+
when "restart" then Daemon.restart(tls: explicit_tls_flag(args), port: int_flag(args, "--port"),
|
|
111
|
+
lan: args.include?("--lan") || nil)
|
|
97
112
|
when nil then command_help("proxy")
|
|
98
113
|
else invalid_action!("proxy start|stop|restart")
|
|
99
114
|
end
|
|
@@ -163,12 +178,18 @@ module Portless
|
|
|
163
178
|
force = args.include?("--force")
|
|
164
179
|
pruned = RouteStore.new.prune
|
|
165
180
|
killed = pruned.sum { |r| PortOwner.kill(r.port, force: force) }
|
|
181
|
+
# A dead run's tailscale serve/funnel registration outlives it — reap it.
|
|
182
|
+
pruned.each { |r| safe { Share::Tailscale.stop_url(r.tailscale) } if r.tailscale }
|
|
166
183
|
note = killed.positive? ? ", killed #{killed} orphan process(es)" : ""
|
|
167
184
|
ok "pruned #{pruned.size} stale route(s)#{note}"
|
|
168
185
|
end
|
|
169
186
|
|
|
170
187
|
def cmd_clean(_args)
|
|
188
|
+
RouteStore.new.routes.each { |r| safe { Share::Tailscale.stop_url(r.tailscale) } if r.tailscale }
|
|
171
189
|
Daemon.stop
|
|
190
|
+
# Otherwise a boot service survives and resurrects the proxy against a
|
|
191
|
+
# state dir that no longer exists.
|
|
192
|
+
begin; Service.uninstall if Service.installed?; rescue StandardError; end
|
|
172
193
|
begin; Trust.uninstall!; rescue StandardError; end
|
|
173
194
|
begin; with_hosts_write([ "hosts", "clean" ]) { Hosts.clean }; rescue StandardError; end
|
|
174
195
|
require "fileutils"
|
|
@@ -250,6 +271,15 @@ module Portless
|
|
|
250
271
|
true
|
|
251
272
|
end
|
|
252
273
|
|
|
274
|
+
# nil unless the user passed --tls/--no-tls — restart preserves the running
|
|
275
|
+
# daemon's mode by default.
|
|
276
|
+
def explicit_tls_flag(args)
|
|
277
|
+
return false if args.include?("--no-tls")
|
|
278
|
+
return true if args.include?("--tls")
|
|
279
|
+
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
253
283
|
def int_flag(args, name)
|
|
254
284
|
i = args.index(name)
|
|
255
285
|
i ? Integer(args[i + 1], exception: false) : nil
|
|
@@ -327,8 +357,9 @@ module Portless
|
|
|
327
357
|
flags: [ [ "--force", "overwrite an existing route" ] ],
|
|
328
358
|
example: "rb-portless alias postgres 5432 # -> https://postgres.localhost" },
|
|
329
359
|
"proxy" => { summary: "Manage the proxy daemon.",
|
|
330
|
-
usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop",
|
|
331
|
-
"proxy restart (pick up an updated rb-portless)" ]
|
|
360
|
+
usage: [ "proxy start [--no-tls] [--port <n>] [--lan]", "proxy stop",
|
|
361
|
+
"proxy restart (pick up an updated rb-portless)" ],
|
|
362
|
+
flags: [ [ "--lan", "listen on all interfaces (default: loopback only)" ] ] },
|
|
332
363
|
"trust" => { summary: "Trust the local CA so HTTPS works without warnings.",
|
|
333
364
|
usage: [ "trust" ] },
|
|
334
365
|
"hosts" => { summary: "Manage the /etc/hosts block (Safari / non-.localhost TLDs).",
|
data/lib/portless/config.rb
CHANGED
|
@@ -16,9 +16,15 @@ module Portless
|
|
|
16
16
|
def initialize(data, dir = Dir.pwd)
|
|
17
17
|
@dir = dir
|
|
18
18
|
@name = sanitize_label(data["name"] || infer_name(dir))
|
|
19
|
-
|
|
19
|
+
# Env overrides beat portless.json (the portless env-var contract):
|
|
20
|
+
# PORTLESS_TLD replaces the tld, PORTLESS_HTTPS=0/1 forces TLS off/on.
|
|
21
|
+
@tld = (ENV["PORTLESS_TLD"] || data["tld"] || Constants::DEFAULT_TLD).to_s
|
|
20
22
|
@app_port = data["appPort"] || data["app_port"]
|
|
21
23
|
@tls = data.fetch("tls", true)
|
|
24
|
+
case ENV["PORTLESS_HTTPS"].to_s.downcase
|
|
25
|
+
when "0", "false" then @tls = false
|
|
26
|
+
when "1", "true" then @tls = true
|
|
27
|
+
end
|
|
22
28
|
# Monorepo: { "apps": { "web": "bin/rails server", "api": "node api.js" } }.
|
|
23
29
|
@apps = (data["apps"] || {}).transform_keys { |k| sanitize_label(k) }
|
|
24
30
|
end
|
|
@@ -78,9 +84,11 @@ module Portless
|
|
|
78
84
|
nil
|
|
79
85
|
end
|
|
80
86
|
|
|
81
|
-
# A valid DNS label: lowercase alnum + hyphens, trimmed
|
|
87
|
+
# A valid DNS label: lowercase alnum + hyphens, trimmed, clamped to the
|
|
88
|
+
# 63-char DNS maximum (long names would invalidate hostname + cert CN).
|
|
82
89
|
def sanitize_label(value)
|
|
83
90
|
label = value.to_s.downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
91
|
+
label = label[0, 63].to_s.gsub(/-+\z/, "")
|
|
84
92
|
label.empty? ? "app" : label
|
|
85
93
|
end
|
|
86
94
|
end
|
data/lib/portless/daemon.rb
CHANGED
|
@@ -10,14 +10,41 @@ module Portless
|
|
|
10
10
|
module Daemon
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def ensure_running(tls:)
|
|
13
|
+
def ensure_running(tls:, lan: false)
|
|
14
14
|
port = Health.discover_port
|
|
15
|
+
port = nil if tls && port == Constants::HTTP_PORT
|
|
16
|
+
|
|
17
|
+
if port && lan && !lan_active?
|
|
18
|
+
# The proxy binds loopback-only by default; --lan needs it reachable
|
|
19
|
+
# from the network, so switch the running daemon over.
|
|
20
|
+
warn "rb-portless: restarting the proxy in LAN mode (it was loopback-only)"
|
|
21
|
+
restart(tls: tls, port: port, lan: true)
|
|
22
|
+
return Health.discover_port || port
|
|
23
|
+
end
|
|
24
|
+
if port && !recorded_tls.nil? && recorded_tls != tls
|
|
25
|
+
# Another project started the daemon in the other TLS mode; don't fight
|
|
26
|
+
# over it — that used to spawn a rival proxy on the default port.
|
|
27
|
+
warn "rb-portless: the proxy is serving #{recorded_tls ? 'HTTPS' : 'HTTP'} but this project " \
|
|
28
|
+
"sets tls: #{tls} — keeping the running proxy " \
|
|
29
|
+
"(`rb-portless proxy restart #{tls ? '--tls' : '--no-tls'}` to switch)"
|
|
30
|
+
end
|
|
15
31
|
return refresh_stale(port, tls: tls) if port
|
|
16
32
|
|
|
17
|
-
start(tls: tls)
|
|
33
|
+
start(tls: tls, lan: lan)
|
|
18
34
|
Health.discover_port
|
|
19
35
|
end
|
|
20
36
|
|
|
37
|
+
# Was the running proxy started in LAN mode? (Marker written by the daemon.)
|
|
38
|
+
def lan_active? = File.exist?(State.proxy_lan_file)
|
|
39
|
+
|
|
40
|
+
# The TLS mode the running daemon recorded, or nil (no marker / old daemon).
|
|
41
|
+
def recorded_tls
|
|
42
|
+
value = File.read(State.proxy_tls_file).strip
|
|
43
|
+
value != "0"
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
21
48
|
# The daemon outlives gem updates — after a `bundle update` the process on
|
|
22
49
|
# :443 may still be running last week's code. Compare the version it stamps
|
|
23
50
|
# on its responses with ours and offer to restart it; the reverse mismatch
|
|
@@ -59,25 +86,29 @@ module Portless
|
|
|
59
86
|
!$stdin.gets.to_s.strip.downcase.start_with?("n")
|
|
60
87
|
end
|
|
61
88
|
|
|
62
|
-
def restart(tls
|
|
63
|
-
|
|
89
|
+
def restart(tls: nil, port: nil, lan: nil)
|
|
90
|
+
# A plain restart keeps the daemon's recorded modes; explicit flags win.
|
|
91
|
+
tls = recorded_tls if tls.nil?
|
|
92
|
+
tls = true if tls.nil?
|
|
93
|
+
lan = lan_active? if lan.nil?
|
|
94
|
+
port ||= Integer(ENV["PORTLESS_PORT"], exception: false) || default_port(tls)
|
|
64
95
|
stop
|
|
65
96
|
wait_until_stopped(port) if port
|
|
66
|
-
start(tls: tls, port: port)
|
|
97
|
+
start(tls: tls, port: port, lan: lan)
|
|
67
98
|
end
|
|
68
99
|
|
|
69
100
|
# foreground: become the daemon (binds the port, blocks). Otherwise
|
|
70
101
|
# orchestrate: elevate if needed, then spawn the detached foreground daemon.
|
|
71
|
-
def start(tls:, port: nil, foreground: false)
|
|
102
|
+
def start(tls:, port: nil, foreground: false, lan: false)
|
|
72
103
|
port ||= Integer(ENV["PORTLESS_PORT"], exception: false) || default_port(tls)
|
|
73
104
|
|
|
74
|
-
return Proxy.new(port: port, tls: tls).run if foreground
|
|
105
|
+
return Proxy.new(port: port, tls: tls, lan: lan).run if foreground
|
|
75
106
|
return if Health.proxy_running?(port)
|
|
76
107
|
|
|
77
108
|
if Privilege.needs_sudo?(port) && !Privilege.root?
|
|
78
|
-
start_privileged(port: port, tls: tls)
|
|
109
|
+
start_privileged(port: port, tls: tls, lan: lan)
|
|
79
110
|
else
|
|
80
|
-
spawn_detached(port: port, tls: tls)
|
|
111
|
+
spawn_detached(port: port, tls: tls, lan: lan)
|
|
81
112
|
end
|
|
82
113
|
end
|
|
83
114
|
|
|
@@ -108,26 +139,33 @@ module Portless
|
|
|
108
139
|
PortOwner.listeners(port).find { |pid| pid != Process.pid }
|
|
109
140
|
end
|
|
110
141
|
|
|
111
|
-
def start_privileged(port:, tls:)
|
|
142
|
+
def start_privileged(port:, tls:, lan: false)
|
|
112
143
|
unless Privilege.interactive?
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
# Don't silently fall back to :1355 here — that changes every URL
|
|
145
|
+
# (OAuth redirect URIs, bookmarks) with nobody watching. Fail with the
|
|
146
|
+
# ways out instead, like portless does in CI.
|
|
147
|
+
raise NonInteractiveError,
|
|
148
|
+
"binding :#{port} needs sudo and there's no terminal to ask — pre-start the proxy " \
|
|
149
|
+
"(`rb-portless proxy start`), install the boot service (`rb-portless service install`), " \
|
|
150
|
+
"or set PORTLESS_PORT to an unprivileged port"
|
|
115
151
|
end
|
|
116
152
|
|
|
117
153
|
warn "rb-portless: binding :#{port} needs sudo (it's a privileged port) — " \
|
|
118
154
|
"enter your password to serve #{tls ? 'HTTPS' : 'HTTP'} without a port number"
|
|
119
|
-
ok = Privilege.reexec_with_sudo([ "proxy", "start", "--port", port.to_s,
|
|
155
|
+
ok = Privilege.reexec_with_sudo([ "proxy", "start", "--port", port.to_s,
|
|
156
|
+
tls ? "--tls" : "--no-tls", *(lan ? [ "--lan" ] : []) ])
|
|
120
157
|
return wait_until_running(port) if ok
|
|
121
158
|
|
|
122
159
|
warn "rb-portless: sudo declined — using :#{Constants::FALLBACK_PROXY_PORT}"
|
|
123
|
-
spawn_detached(port: Constants::FALLBACK_PROXY_PORT, tls: tls)
|
|
160
|
+
spawn_detached(port: Constants::FALLBACK_PROXY_PORT, tls: tls, lan: lan)
|
|
124
161
|
end
|
|
125
162
|
|
|
126
|
-
def spawn_detached(port:, tls:)
|
|
163
|
+
def spawn_detached(port:, tls:, lan: false)
|
|
127
164
|
State.ensure_dir!
|
|
128
165
|
log = File.open(State.proxy_log, "a")
|
|
129
166
|
args = [ RbConfig.ruby, "-I", lib_dir, Privilege.program,
|
|
130
|
-
"proxy", "start", "--foreground", "--port", port.to_s,
|
|
167
|
+
"proxy", "start", "--foreground", "--port", port.to_s,
|
|
168
|
+
tls ? "--tls" : "--no-tls", *(lan ? [ "--lan" ] : []) ]
|
|
131
169
|
pid = Process.spawn(*args, out: log, err: log, pgroup: true)
|
|
132
170
|
Process.detach(pid)
|
|
133
171
|
log.close
|
data/lib/portless/health.rb
CHANGED
|
@@ -46,7 +46,7 @@ module Portless
|
|
|
46
46
|
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
47
47
|
ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
|
48
48
|
ssl.sync_close = true
|
|
49
|
-
ssl.connect
|
|
49
|
+
Timeout.timeout(timeout) { ssl.connect }
|
|
50
50
|
ssl.write(REQUEST)
|
|
51
51
|
# Read timeout too — a port that accepts but never answers must not hang us.
|
|
52
52
|
Timeout.timeout(timeout) { ssl.read(4096) }
|
data/lib/portless/hosts.rb
CHANGED
|
@@ -10,7 +10,11 @@ module Portless
|
|
|
10
10
|
module Hosts
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
+
# PORTLESS_HOSTS_FILE overrides the target (tests; unusual setups).
|
|
13
14
|
def file
|
|
15
|
+
override = ENV["PORTLESS_HOSTS_FILE"]
|
|
16
|
+
return override unless override.to_s.empty?
|
|
17
|
+
|
|
14
18
|
Constants::WINDOWS ? File.join(ENV.fetch("SystemRoot", "C:/Windows"), "System32/drivers/etc/hosts") : "/etc/hosts"
|
|
15
19
|
end
|
|
16
20
|
|
data/lib/portless/multi.rb
CHANGED
|
@@ -11,9 +11,10 @@ module Portless
|
|
|
11
11
|
|
|
12
12
|
App = Struct.new(:name, :hostname, :port, :url, :pid, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
def initialize(config: Config.load, route_store: RouteStore.new)
|
|
14
|
+
def initialize(config: Config.load, route_store: RouteStore.new, options: {})
|
|
15
15
|
@config = config
|
|
16
16
|
@route_store = route_store
|
|
17
|
+
@options = options # :force
|
|
17
18
|
@apps = []
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -38,7 +39,9 @@ module Portless
|
|
|
38
39
|
prefix = @config.worktree_prefix
|
|
39
40
|
hostname = "#{prefix ? "#{prefix}.#{name}" : name}.#{@config.tld}"
|
|
40
41
|
url = display_url(hostname, proxy_port)
|
|
41
|
-
|
|
42
|
+
# Same conflict semantics as the single-app path: a route held by another
|
|
43
|
+
# LIVE run raises unless --force (dead owners are reaped automatically).
|
|
44
|
+
@route_store.add(hostname: hostname, port: port, pid: Process.pid, force: !!@options[:force])
|
|
42
45
|
# A bare command string runs through the shell (handles "bin/rails server").
|
|
43
46
|
pid = Process.spawn(child_env(port, url), command, pgroup: true)
|
|
44
47
|
App.new(name: name, hostname: hostname, port: port, url: url, pid: pid)
|
data/lib/portless/proxy.rb
CHANGED
|
@@ -20,9 +20,10 @@ module Portless
|
|
|
20
20
|
HOP_BY_HOP = %w[connection keep-alive proxy-authenticate proxy-authorization
|
|
21
21
|
te trailers transfer-encoding upgrade host].freeze
|
|
22
22
|
|
|
23
|
-
def initialize(port:, tls: true, route_store: RouteStore.new, certs: Certs.new)
|
|
23
|
+
def initialize(port:, tls: true, lan: false, route_store: RouteStore.new, certs: Certs.new)
|
|
24
24
|
@port = port
|
|
25
25
|
@tls = tls
|
|
26
|
+
@lan = lan
|
|
26
27
|
@route_store = route_store
|
|
27
28
|
@certs = certs
|
|
28
29
|
@clients = {}
|
|
@@ -41,19 +42,32 @@ module Portless
|
|
|
41
42
|
install_signal_handlers
|
|
42
43
|
|
|
43
44
|
Async do
|
|
44
|
-
make_server(
|
|
45
|
+
listen_hosts.each { |host| make_server(endpoint_for(host)).run }
|
|
45
46
|
start_redirect_listener if @tls && @port != Constants::HTTP_PORT
|
|
46
47
|
end
|
|
47
48
|
ensure
|
|
48
49
|
cleanup
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
#
|
|
52
|
+
# Loopback-only unless LAN mode was asked for: on 0.0.0.0 every registered
|
|
53
|
+
# dev app is reachable from the LAN/VPN by default (upstream shipped the
|
|
54
|
+
# same change as a security fix). `*.localhost` resolves to ::1 too, so an
|
|
55
|
+
# IPv6-loopback sibling always listens (best-effort — its bind failure
|
|
56
|
+
# surfaces as a logged task error, never fatal).
|
|
57
|
+
def listen_hosts
|
|
58
|
+
(@lan ? [ "0.0.0.0" ] : [ "127.0.0.1" ]) + [ "[::1]" ]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Exact host match, then a route's public share hostname (tailscale/ngrok —
|
|
62
|
+
# requests forwarded by the tunnel keep the *.ts.net authority, upstream
|
|
63
|
+
# issue #297), then wildcard fallback so *.name.localhost all reach the
|
|
52
64
|
# single app registered as name.localhost.
|
|
53
65
|
def route_for(host)
|
|
54
|
-
|
|
66
|
+
authority = host.to_s.downcase.delete_suffix(":443")
|
|
67
|
+
host = authority.split(":").first.to_s
|
|
55
68
|
routes = @route_store.routes
|
|
56
69
|
routes.find { |r| r.hostname == host } ||
|
|
70
|
+
routes.find { |r| share_match?(r, authority, host) } ||
|
|
57
71
|
routes.find { |r| host.end_with?(".#{r.hostname}") }
|
|
58
72
|
end
|
|
59
73
|
|
|
@@ -63,23 +77,64 @@ module Portless
|
|
|
63
77
|
def call(request)
|
|
64
78
|
host = request_host(request)
|
|
65
79
|
route = route_for(host)
|
|
66
|
-
return
|
|
80
|
+
return not_found(host) unless route
|
|
67
81
|
|
|
68
82
|
hops = request.headers[HOP_HEADER].to_a.first.to_i
|
|
69
83
|
return error(508, "Proxy loop detected for #{host}.") if hops >= Constants::MAX_PROXY_HOPS
|
|
70
84
|
|
|
71
|
-
response =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
response = with_backend_timeout do
|
|
86
|
+
client_for(route.port).call(build_forward(request, host, hops))
|
|
87
|
+
end
|
|
88
|
+
if response.status == 101 && request.version == "HTTP/2"
|
|
89
|
+
# The h1 backend accepts a WebSocket with 101 Switching Protocols, but
|
|
90
|
+
# HTTP/2 forbids 1xx finals — an extended-CONNECT success is a plain 2xx,
|
|
91
|
+
# and the h1 handshake headers are meaningless (and illegal) on h2.
|
|
92
|
+
response.status = 200
|
|
93
|
+
strip_hop_headers(response)
|
|
94
|
+
response.headers.delete("sec-websocket-accept")
|
|
95
|
+
elsif response.status != 101
|
|
96
|
+
# Hop-by-hop headers are single-hop by definition; relayed into an h2
|
|
97
|
+
# stream a backend's `Connection: close` aborts the whole header block,
|
|
98
|
+
# so the client sees a bare 200 with no headers and no body.
|
|
99
|
+
strip_hop_headers(response)
|
|
100
|
+
end
|
|
75
101
|
response.headers.add(Constants::HEALTH_HEADER, VERSION)
|
|
76
102
|
response
|
|
103
|
+
rescue Async::TimeoutError
|
|
104
|
+
error(504, "Backend for #{host} accepted the connection but never answered.")
|
|
77
105
|
rescue StandardError => e
|
|
78
106
|
error(502, "Backend for #{host} is not responding (#{e.class}).")
|
|
79
107
|
end
|
|
80
108
|
|
|
81
109
|
private
|
|
82
110
|
|
|
111
|
+
# Does the request authority match this route's public tunnel URL
|
|
112
|
+
# (https://<device>.ts.net[:port] / https://xxxx.ngrok.app)?
|
|
113
|
+
def share_match?(route, authority, host)
|
|
114
|
+
[ route.tailscale, route.ngrok ].compact.any? do |url|
|
|
115
|
+
uri = begin
|
|
116
|
+
URI(url)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
next false unless uri&.host
|
|
121
|
+
|
|
122
|
+
share = uri.port && uri.port != 443 ? "#{uri.host}:#{uri.port}" : uri.host
|
|
123
|
+
authority == share.downcase || host == uri.host.downcase
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Bound the wait for the backend's response *headers* (body streaming is
|
|
128
|
+
# unaffected) — a backend that accepts and then hangs must not hold client
|
|
129
|
+
# connections forever. No-op outside a reactor (unit tests drive #call
|
|
130
|
+
# directly).
|
|
131
|
+
BACKEND_HEADER_TIMEOUT = 30
|
|
132
|
+
|
|
133
|
+
def with_backend_timeout(&block)
|
|
134
|
+
task = Async::Task.current?
|
|
135
|
+
task ? task.with_timeout(BACKEND_HEADER_TIMEOUT, &block) : yield
|
|
136
|
+
end
|
|
137
|
+
|
|
83
138
|
def make_server(endpoint)
|
|
84
139
|
Async::HTTP::Server.for(endpoint) { |request| call(request) }
|
|
85
140
|
end
|
|
@@ -97,9 +152,15 @@ module Portless
|
|
|
97
152
|
key.downcase == "cookie" ? cookies << value : headers.add(key, value)
|
|
98
153
|
end
|
|
99
154
|
headers.add("cookie", cookies.join("; ")) unless cookies.empty?
|
|
100
|
-
|
|
155
|
+
# Keep the full authority (host:port): Rails rebuilds request.url from
|
|
156
|
+
# X-Forwarded-Host, so stripping the port breaks generated URLs whenever
|
|
157
|
+
# the proxy serves on a non-default port (e.g. the :1355 fallback).
|
|
158
|
+
headers.set("x-forwarded-host", host)
|
|
101
159
|
headers.set("x-forwarded-proto", @tls ? "https" : "http")
|
|
102
160
|
headers.set("x-forwarded-port", @port.to_s)
|
|
161
|
+
# Append (repeated XFF fields join as a comma list) so Rails' remote_ip
|
|
162
|
+
# sees the real client — 127.0.0.1 locally, the device IP in LAN mode.
|
|
163
|
+
headers.add("x-forwarded-for", client_address(request))
|
|
103
164
|
headers.add(HOP_HEADER, (hops + 1).to_s)
|
|
104
165
|
|
|
105
166
|
# HTTP/2 WebSockets arrive as extended CONNECT (RFC 8441, :protocol on the
|
|
@@ -120,14 +181,27 @@ module Portless
|
|
|
120
181
|
)
|
|
121
182
|
end
|
|
122
183
|
|
|
184
|
+
def client_address(request)
|
|
185
|
+
request.remote_address&.ip_address || "127.0.0.1"
|
|
186
|
+
rescue StandardError
|
|
187
|
+
"127.0.0.1"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def strip_hop_headers(response)
|
|
191
|
+
HOP_BY_HOP.each { |key| response.headers.delete(key) }
|
|
192
|
+
end
|
|
193
|
+
|
|
123
194
|
def client_for(port)
|
|
124
|
-
|
|
195
|
+
# "localhost", not 127.0.0.1: the endpoint tries each resolved address in
|
|
196
|
+
# sequence, so an IPv6-only backend (a Node server bound to ::1) still
|
|
197
|
+
# connects — portless happy-eyeballs both loopbacks the same way.
|
|
198
|
+
@clients[port] ||= Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("http://localhost:#{port}"))
|
|
125
199
|
end
|
|
126
200
|
|
|
127
|
-
def
|
|
201
|
+
def endpoint_for(host)
|
|
128
202
|
scheme = @tls ? "https" : "http"
|
|
129
203
|
options = @tls ? { ssl_context: ssl_context } : {}
|
|
130
|
-
Async::HTTP::Endpoint.parse("#{scheme}
|
|
204
|
+
Async::HTTP::Endpoint.parse("#{scheme}://#{host}:#{@port}", **options)
|
|
131
205
|
end
|
|
132
206
|
|
|
133
207
|
# Base TLS context with an SNI callback that swaps in a per-host leaf cert.
|
|
@@ -152,13 +226,16 @@ module Portless
|
|
|
152
226
|
end
|
|
153
227
|
end
|
|
154
228
|
|
|
155
|
-
# A best-effort :80 listener that bounces plain HTTP to HTTPS.
|
|
229
|
+
# A best-effort :80 listener that bounces plain HTTP to HTTPS. Same bind
|
|
230
|
+
# scope as the main listeners (loopback unless LAN).
|
|
156
231
|
def start_redirect_listener
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
232
|
+
listen_hosts.each do |host|
|
|
233
|
+
endpoint = Async::HTTP::Endpoint.parse("http://#{host}:#{Constants::HTTP_PORT}")
|
|
234
|
+
Async::HTTP::Server.for(endpoint) do |request|
|
|
235
|
+
request_host = request_host(request).split(":").first
|
|
236
|
+
Protocol::HTTP::Response[302, { "location" => "https://#{request_host}#{request.path}", Constants::HEALTH_HEADER => VERSION }, []]
|
|
237
|
+
end.run
|
|
238
|
+
end
|
|
162
239
|
rescue StandardError
|
|
163
240
|
nil # port 80 taken / unavailable — non-fatal.
|
|
164
241
|
end
|
|
@@ -167,16 +244,53 @@ module Portless
|
|
|
167
244
|
(request.authority || request.headers["host"].to_a.first).to_s
|
|
168
245
|
end
|
|
169
246
|
|
|
170
|
-
|
|
247
|
+
# The 404 lists what IS running (clickable) plus the command that would
|
|
248
|
+
# register the missing name — upstream portless's most-loved error page.
|
|
249
|
+
def not_found(host)
|
|
250
|
+
safe_host = escape(host)
|
|
251
|
+
routes = @route_store.routes
|
|
252
|
+
suffix = @port == (@tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT) ? "" : ":#{@port}"
|
|
253
|
+
scheme = @tls ? "https" : "http"
|
|
254
|
+
apps = if routes.empty?
|
|
255
|
+
"<p style='color:#888'>No apps are running.</p>"
|
|
256
|
+
else
|
|
257
|
+
items = routes.map do |r|
|
|
258
|
+
url = "#{scheme}://#{escape(r.hostname)}#{suffix}"
|
|
259
|
+
"<li><a href='#{url}'>#{escape(r.hostname)}</a> <span style='color:#888'>→ :#{r.port.to_i}</span></li>"
|
|
260
|
+
end
|
|
261
|
+
"<p>Active apps:</p><ul>#{items.join}</ul>"
|
|
262
|
+
end
|
|
263
|
+
name = host.to_s.split(":").first.to_s.split(".").first
|
|
264
|
+
name = "myapp" if name.empty?
|
|
265
|
+
hint = "<pre style='background:rgba(128,128,128,.15);padding:.75rem;border-radius:6px'>" \
|
|
266
|
+
"rb-portless #{escape(name)} bin/dev</pre>"
|
|
267
|
+
error(404, "No app is registered for <strong>#{safe_host}</strong>.", extra: "#{apps}#{hint}")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def error(status, message, extra: "")
|
|
171
271
|
body = Protocol::HTTP::Body::Buffered.wrap("<!doctype html><meta charset=utf-8><title>rb-portless</title>" \
|
|
272
|
+
"<meta name=color-scheme content='light dark'>" \
|
|
172
273
|
"<body style='font:16px system-ui;padding:3rem;max-width:40rem;margin:auto'>" \
|
|
173
|
-
"<h1>#{status}</h1><p>#{message}</p
|
|
274
|
+
"<h1>#{status}</h1><p>#{message}</p>#{extra}" \
|
|
275
|
+
"<p style='color:#888;margin-top:2rem'>rb-portless</p></body>")
|
|
174
276
|
Protocol::HTTP::Response[status, { "content-type" => "text/html; charset=utf-8", Constants::HEALTH_HEADER => VERSION }, body]
|
|
175
277
|
end
|
|
176
278
|
|
|
279
|
+
def escape(value)
|
|
280
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """).gsub("'", "'")
|
|
281
|
+
end
|
|
282
|
+
|
|
177
283
|
def write_markers
|
|
178
284
|
File.write(State.proxy_pid_file, Process.pid.to_s)
|
|
179
285
|
File.write(State.proxy_port_file, @port.to_s)
|
|
286
|
+
# Record how this daemon was started so restarts (and `run`'s
|
|
287
|
+
# mode-mismatch detection) can preserve/compare it.
|
|
288
|
+
File.write(State.proxy_tls_file, @tls ? "1" : "0")
|
|
289
|
+
if @lan
|
|
290
|
+
File.write(State.proxy_lan_file, "1")
|
|
291
|
+
elsif File.exist?(State.proxy_lan_file)
|
|
292
|
+
File.delete(State.proxy_lan_file)
|
|
293
|
+
end
|
|
180
294
|
State.fix_ownership
|
|
181
295
|
end
|
|
182
296
|
|
|
@@ -189,7 +303,10 @@ module Portless
|
|
|
189
303
|
def cleanup
|
|
190
304
|
return unless marker_pid == Process.pid
|
|
191
305
|
|
|
192
|
-
[ State.proxy_pid_file, State.proxy_port_file
|
|
306
|
+
[ State.proxy_pid_file, State.proxy_port_file,
|
|
307
|
+
State.proxy_lan_file, State.proxy_tls_file ].each do |f|
|
|
308
|
+
File.delete(f) if File.exist?(f)
|
|
309
|
+
end
|
|
193
310
|
rescue StandardError
|
|
194
311
|
nil
|
|
195
312
|
end
|
data/lib/portless/rails.rb
CHANGED
|
@@ -43,10 +43,13 @@ module Portless
|
|
|
43
43
|
# The router's defaults cover jobs and any request-less url_for (mailers
|
|
44
44
|
# merge them in too). Set the mailer's own default_url_options via on_load
|
|
45
45
|
# so we win over Rails' earlier action_mailer.set_configs regardless of
|
|
46
|
-
# railtie order.
|
|
47
|
-
|
|
46
|
+
# railtie order. merge_url_options drops a stale app-configured :port
|
|
47
|
+
# (e.g. `port: 3000`) that would otherwise survive into every link.
|
|
48
|
+
app.routes.default_url_options.replace(
|
|
49
|
+
RailsHosts.merge_url_options(app.routes.default_url_options, options)
|
|
50
|
+
)
|
|
48
51
|
ActiveSupport.on_load(:action_mailer) do
|
|
49
|
-
self.default_url_options =
|
|
52
|
+
self.default_url_options = RailsHosts.merge_url_options(default_url_options, options)
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
|
|
@@ -55,12 +58,17 @@ module Portless
|
|
|
55
58
|
# localhost is rejected and Cable silently never connects.
|
|
56
59
|
initializer "portless.action_cable_origins" do |app|
|
|
57
60
|
next unless defined?(Rails) && Rails.env.development?
|
|
61
|
+
# Apps without Action Cable (API-only, trimmed railties) have no
|
|
62
|
+
# config.action_cable — touching it would crash boot.
|
|
63
|
+
next unless app.config.respond_to?(:action_cable)
|
|
58
64
|
|
|
59
65
|
origins = RailsHosts.cable_origins
|
|
60
66
|
next if origins.empty?
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
# Array(): apps idiomatically set a bare Regexp (Rails' own dev default
|
|
69
|
+
# is one) — concat on it would crash boot; a frozen array would too.
|
|
70
|
+
app.config.action_cable.allowed_request_origins =
|
|
71
|
+
Array(app.config.action_cable.allowed_request_origins) + origins
|
|
64
72
|
end
|
|
65
73
|
end
|
|
66
74
|
end
|
data/lib/portless/rails_hosts.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Portless
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
# Empty unless we're actually running under rb-portless.
|
|
13
|
-
def allowed(portless_url = ENV["PORTLESS_URL"])
|
|
13
|
+
def allowed(portless_url = ENV["PORTLESS_URL"], lan_host = ENV["PORTLESS_LAN_HOST"])
|
|
14
14
|
return [] if portless_url.to_s.empty?
|
|
15
15
|
|
|
16
16
|
# Rails wraps a Regexp as /\A<re>(:port)?\z/, so match the whole host.
|
|
@@ -21,11 +21,40 @@ module Portless
|
|
|
21
21
|
rescue StandardError
|
|
22
22
|
nil
|
|
23
23
|
end
|
|
24
|
-
#
|
|
25
|
-
|
|
24
|
+
# A custom, non-.localhost tld too. A regexp, not a ".host" string —
|
|
25
|
+
# Rails' leading-dot shorthand only matches a single subdomain label,
|
|
26
|
+
# which would 403 a.b.myapp.test while a.b.myapp.localhost passes.
|
|
27
|
+
patterns.push(host, /.+\.#{Regexp.escape(host)}/) if host && !host.end_with?(".localhost")
|
|
28
|
+
# The `--lan` mDNS host (<name>.local) — not covered by any default.
|
|
29
|
+
patterns.push(lan_host) unless lan_host.to_s.empty?
|
|
30
|
+
# Public tunnels forward with their own Host (*.ts.net / *.ngrok.app).
|
|
31
|
+
patterns.concat(share_hosts)
|
|
26
32
|
patterns
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
# Hostnames of any active public tunnels (`--tailscale` / `--ngrok`),
|
|
36
|
+
# from the env the runner injects.
|
|
37
|
+
def share_hosts(env = ENV)
|
|
38
|
+
[ env["PORTLESS_TAILSCALE_URL"], env["PORTLESS_NGROK_URL"] ].filter_map do |url|
|
|
39
|
+
next if url.to_s.empty?
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
URI(url).host
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# default_url_options merge that can't leave a stale :port behind: when the
|
|
50
|
+
# portless URL has no explicit port (443/80), an app-configured
|
|
51
|
+
# `port: 3000` must not survive into https://<name>.localhost:3000 links.
|
|
52
|
+
def merge_url_options(existing, options)
|
|
53
|
+
merged = existing.to_h.merge(options)
|
|
54
|
+
merged.delete(:port) unless options.key?(:port)
|
|
55
|
+
merged
|
|
56
|
+
end
|
|
57
|
+
|
|
29
58
|
# The `default_url_options` (host + scheme) that mailers, jobs, and other
|
|
30
59
|
# request-less URL generation need so their links point at the portless URL
|
|
31
60
|
# instead of a bare `localhost:<random-port>`. Nil unless we're running
|
|
@@ -50,11 +79,15 @@ module Portless
|
|
|
50
79
|
# `allowed_request_origins`; under rb-portless that's the portless host and
|
|
51
80
|
# any of its subdomains (tenant hosts), over the portless scheme — not the
|
|
52
81
|
# `localhost` default. Empty unless we're running under rb-portless.
|
|
53
|
-
def cable_origins(portless_url = ENV["PORTLESS_URL"])
|
|
82
|
+
def cable_origins(portless_url = ENV["PORTLESS_URL"], lan_host = ENV["PORTLESS_LAN_HOST"])
|
|
54
83
|
options = url_options(portless_url) or return []
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
hosts = [ options[:host] ]
|
|
86
|
+
hosts << lan_host unless lan_host.to_s.empty?
|
|
87
|
+
hosts.concat(share_hosts)
|
|
88
|
+
hosts.map do |host|
|
|
89
|
+
%r{\A#{options[:protocol]}://([\w-]+\.)*#{Regexp.escape(host)}(:\d+)?\z}
|
|
90
|
+
end
|
|
58
91
|
end
|
|
59
92
|
end
|
|
60
93
|
end
|
data/lib/portless/route_store.rb
CHANGED
|
@@ -21,8 +21,20 @@ module Portless
|
|
|
21
21
|
@lock = lock
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# The proxy calls this on every request; re-parsing routes.json each time is
|
|
25
|
+
# a syscall + JSON parse on the hot path. Cache on (mtime, size) — any write
|
|
26
|
+
# through with_lock touches both, so live reload still works.
|
|
24
27
|
def routes
|
|
25
|
-
|
|
28
|
+
stat = begin
|
|
29
|
+
File.stat(@file)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
key = stat && [ stat.mtime, stat.size ]
|
|
34
|
+
return @routes_cache if key && key == @routes_cache_key
|
|
35
|
+
|
|
36
|
+
@routes_cache_key = key
|
|
37
|
+
@routes_cache = load.map do |h|
|
|
26
38
|
Route.new(hostname: h["hostname"], port: h["port"], pid: h["pid"],
|
|
27
39
|
tailscale: h["tailscale"], ngrok: h["ngrok"])
|
|
28
40
|
end
|
data/lib/portless/run_support.rb
CHANGED
|
@@ -12,10 +12,23 @@ module Portless
|
|
|
12
12
|
"PORT" => port.to_s,
|
|
13
13
|
"HOST" => "127.0.0.1",
|
|
14
14
|
"PORTLESS_URL" => url,
|
|
15
|
+
# Under --lan, let the app (the Rails railtie) whitelist <name>.local
|
|
16
|
+
# too — it's not covered by any host-authorization default.
|
|
17
|
+
"PORTLESS_LAN_HOST" => @lan_host,
|
|
18
|
+
# Public tunnel URLs (when sharing): tailscale forwards with the raw
|
|
19
|
+
# *.ts.net Host, so the app must whitelist it — and apps can
|
|
20
|
+
# self-reference their public address (mirrors portless).
|
|
21
|
+
"PORTLESS_TAILSCALE_URL" => @tailscale&.dig(:url),
|
|
22
|
+
"PORTLESS_NGROK_URL" => @ngrok&.dig(:url),
|
|
15
23
|
# Let the app's own server-side TLS verification trust our CA — via a
|
|
16
24
|
# bundle that *also* carries the public roots, so SSL_CERT_FILE replacing
|
|
17
25
|
# the trust store doesn't break the app's outbound HTTPS. See CaBundle.
|
|
18
|
-
"SSL_CERT_FILE" => CaBundle.path
|
|
26
|
+
"SSL_CERT_FILE" => CaBundle.path,
|
|
27
|
+
# Node ignores SSL_CERT_FILE; NODE_EXTRA_CA_CERTS *adds* to its default
|
|
28
|
+
# roots, so the bare CA is enough (mirrors portless). Respect an
|
|
29
|
+
# existing value — additions can only live in one file.
|
|
30
|
+
"NODE_EXTRA_CA_CERTS" => ENV["NODE_EXTRA_CA_CERTS"] ||
|
|
31
|
+
(State.ca_cert if File.exist?(State.ca_cert))
|
|
19
32
|
}.compact
|
|
20
33
|
# Our own bundle (rb-portless is loaded via the app's Bundler binstub) must
|
|
21
34
|
# not leak into the dev command — a foreman-style `bin/dev` isn't in the
|
data/lib/portless/runner.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Portless
|
|
|
28
28
|
|
|
29
29
|
warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
|
|
30
30
|
ensure_trusted
|
|
31
|
-
proxy_port = Daemon.ensure_running(tls: @config.tls)
|
|
31
|
+
proxy_port = Daemon.ensure_running(tls: @config.tls, lan: !!@options[:lan])
|
|
32
32
|
@route_store.add(hostname: hostname, port: port, pid: Process.pid, force: @options[:force])
|
|
33
33
|
|
|
34
34
|
url = display_url(hostname, proxy_port)
|
data/lib/portless/service.rb
CHANGED
|
@@ -28,6 +28,12 @@ module Portless
|
|
|
28
28
|
puts "rb-portless: boot service removed"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Is a boot service on disk? (Both locations are world-readable, so this
|
|
32
|
+
# never needs sudo — `clean` uses it to skip a pointless elevation.)
|
|
33
|
+
def installed?
|
|
34
|
+
Constants::MACOS ? File.exist?(launchd_plist_path) : File.exist?(systemd_unit_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
31
37
|
def status
|
|
32
38
|
if Constants::MACOS
|
|
33
39
|
system("launchctl", "print", "system/#{LABEL}", out: $stdout, err: $stdout) ||
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Portless
|
|
6
7
|
module Share
|
|
@@ -65,6 +66,23 @@ module Portless
|
|
|
65
66
|
off(handle[:mode], handle[:port])
|
|
66
67
|
end
|
|
67
68
|
|
|
69
|
+
# Teardown from a recorded share URL alone (clean/prune reaping a dead
|
|
70
|
+
# run's registration). The URL carries the HTTPS port (absent = 443); the
|
|
71
|
+
# mode isn't recorded, so turn off both — "not found" is a no-op.
|
|
72
|
+
def stop_url(url)
|
|
73
|
+
return unless url && Portless.which("tailscale")
|
|
74
|
+
|
|
75
|
+
port = begin
|
|
76
|
+
URI(url).port || 443
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
return unless port
|
|
81
|
+
|
|
82
|
+
off("serve", port)
|
|
83
|
+
off("funnel", port)
|
|
84
|
+
end
|
|
85
|
+
|
|
68
86
|
# Turn off ONLY the registration we created (scoped to our port).
|
|
69
87
|
def off(mode, port)
|
|
70
88
|
system("tailscale", mode, "--yes", "--https=#{port}", "off", out: File::NULL, err: File::NULL)
|
|
@@ -87,8 +105,7 @@ module Portless
|
|
|
87
105
|
end
|
|
88
106
|
|
|
89
107
|
# HTTPS ports the user's current serve config already occupies.
|
|
90
|
-
def used_serve_ports
|
|
91
|
-
config = JSON.parse(`tailscale serve status --json 2>/dev/null`)
|
|
108
|
+
def used_serve_ports(config = serve_status_json)
|
|
92
109
|
ports = []
|
|
93
110
|
(config["Web"] || {}).each_key { |host_port| ports << Regexp.last_match(1).to_i if host_port =~ /:(\d+)\z/ }
|
|
94
111
|
(config["TCP"] || {}).each_key { |port| ports << port.to_i }
|
|
@@ -97,6 +114,12 @@ module Portless
|
|
|
97
114
|
[]
|
|
98
115
|
end
|
|
99
116
|
|
|
117
|
+
def serve_status_json
|
|
118
|
+
JSON.parse(`tailscale serve status --json 2>/dev/null`)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
{}
|
|
121
|
+
end
|
|
122
|
+
|
|
100
123
|
def status_json
|
|
101
124
|
json = JSON.parse(`tailscale status --json 2>/dev/null`)
|
|
102
125
|
json.is_a?(Hash) ? json : nil
|
data/lib/portless/state.rb
CHANGED
|
@@ -26,6 +26,8 @@ module Portless
|
|
|
26
26
|
def routes_lock = path("routes.lock")
|
|
27
27
|
def proxy_pid_file = path("proxy.pid")
|
|
28
28
|
def proxy_port_file = path("proxy.port")
|
|
29
|
+
def proxy_lan_file = path("proxy.lan")
|
|
30
|
+
def proxy_tls_file = path("proxy.tls")
|
|
29
31
|
def proxy_log = path("proxy.log")
|
|
30
32
|
def ca_cert = path("ca.pem")
|
|
31
33
|
def ca_key = path("ca-key.pem")
|
data/lib/portless/trust.rb
CHANGED
|
@@ -40,6 +40,13 @@ module Portless
|
|
|
40
40
|
uninstall_nss
|
|
41
41
|
if Constants::MACOS
|
|
42
42
|
system("security", "remove-trusted-cert", State.ca_cert)
|
|
43
|
+
# remove-trusted-cert clears the trust setting but leaves the cert in
|
|
44
|
+
# the keychain — repeated clean/reinstall cycles pile up CAs. Delete by
|
|
45
|
+
# CN (bounded loop: one entry per pass, tolerate several stale ones).
|
|
46
|
+
10.times do
|
|
47
|
+
break unless system("security", "delete-certificate", "-c", Certs::CA_SUBJECT.delete_prefix("/CN="),
|
|
48
|
+
out: File::NULL, err: File::NULL)
|
|
49
|
+
end
|
|
43
50
|
elsif linux?
|
|
44
51
|
uninstall_linux
|
|
45
52
|
end
|
data/lib/portless/version.rb
CHANGED
data/lib/portless/worktree.rb
CHANGED
|
@@ -58,12 +58,14 @@ module Portless
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# Last `/`-segment of the branch, sanitized
|
|
61
|
+
# Last `/`-segment of the branch, sanitized and clamped to the 63-char DNS
|
|
62
|
+
# label maximum; nil for default/detached HEAD.
|
|
62
63
|
def branch_to_prefix(branch)
|
|
63
64
|
return nil if branch.nil? || branch.empty? || branch == "HEAD"
|
|
64
65
|
return nil if DEFAULT_BRANCHES.include?(branch)
|
|
65
66
|
|
|
66
67
|
label = branch.split("/").last.to_s.downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
68
|
+
label = label[0, 63].to_s.gsub(/-+\z/, "")
|
|
67
69
|
label.empty? ? nil : label
|
|
68
70
|
end
|
|
69
71
|
|
data/lib/rb-portless.rb
CHANGED
|
@@ -45,6 +45,9 @@ module Portless
|
|
|
45
45
|
# the bypass portless documents for CI or one-off plain runs.
|
|
46
46
|
def self.skip_proxy? = %w[0 false skip].include?(ENV["PORTLESS"].to_s.downcase)
|
|
47
47
|
|
|
48
|
+
# Truthy env toggle ("1"/"true"), for the PORTLESS_* flag equivalents.
|
|
49
|
+
def self.env_true?(name) = %w[1 true].include?(ENV[name].to_s.downcase)
|
|
50
|
+
|
|
48
51
|
# Is an executable on PATH? (For optional external tools: dns-sd, ngrok, …)
|
|
49
52
|
def self.which(bin)
|
|
50
53
|
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
|
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
|
+
version: 0.5.0
|
|
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-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|