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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83441b74f46feaa32453bf004366f02a2b76e126994080f10fc4b8bfa654e93a
4
- data.tar.gz: b1e86fecfc2575267871f74a92d011528db7a274997e0d6731dffa2bd12a5a19
3
+ metadata.gz: 47bfd748cef02443448a74938054d6cd57db229d76f286df305fbae28e4a2b7c
4
+ data.tar.gz: ba03fd513085d3d6cc56395792367a7211da79791dff2e2a8a017890af55ee5e
5
5
  SHA512:
6
- metadata.gz: 2a9bb387e46841dfdaaa307575536736105df8709e49b82adccb0db1a4fbe1f7a8d295dcedca6a39c391d021cd54a2142c4c85f285c3bd4ad20190f307da3901
7
- data.tar.gz: cbb9f4d7bd7cac01ba312e753a4b27dea8c23a632e1c7e8008f057caf2986499090e65e9eb8a7ff7e75c082a775826edb3b7daea22a9ab4603dffef287332349
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
- - Windows CA trust + Task Scheduler service.
77
- - WebSocket upgrade relay hardening + HTTP/2 to the backend.
78
- - mDNS LAN-IP change monitoring (re-publish on network switch).
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`, and publishes it over mDNS:
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"), foreground: flag?("--foreground"))
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: tls_flag(args), port: int_flag(args, "--port"))
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).",
@@ -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
- @tld = (data["tld"] || Constants::DEFAULT_TLD).to_s
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
@@ -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:, port: nil)
63
- port ||= Health.discover_port
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
- warn "rb-portless: can't bind :#{port} without a terminalusing :#{Constants::FALLBACK_PROXY_PORT}"
114
- return spawn_detached(port: Constants::FALLBACK_PROXY_PORT, tls: tls)
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, tls ? "--tls" : "--no-tls" ])
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, tls ? "--tls" : "--no-tls" ]
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
@@ -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) }
@@ -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
 
@@ -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
- @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
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)
@@ -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(listen_endpoint).run
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
- # Exact host match, then wildcard fallback so *.name.localhost all reach the
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
- host = host.to_s.split(":").first.to_s.downcase
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 error(404, "No app is registered for #{host}.") unless route
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 = client_for(route.port).call(build_forward(request, host, hops))
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"
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
- headers.set("x-forwarded-host", host.split(":").first)
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
- @clients[port] ||= Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("http://127.0.0.1:#{port}"))
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 listen_endpoint
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}://0.0.0.0:#{@port}", **options)
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
- endpoint = Async::HTTP::Endpoint.parse("http://0.0.0.0:#{Constants::HTTP_PORT}")
158
- Async::HTTP::Server.for(endpoint) do |request|
159
- host = request_host(request).split(":").first
160
- Protocol::HTTP::Response[302, { "location" => "https://#{host}#{request.path}", Constants::HEALTH_HEADER => VERSION }, []]
161
- end.run
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
- def error(status, message)
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></body>")
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
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 ].each { |f| File.delete(f) if File.exist?(f) }
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
@@ -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
- app.routes.default_url_options.merge!(options)
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 = default_url_options.merge(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
- app.config.action_cable.allowed_request_origins ||= []
63
- app.config.action_cable.allowed_request_origins.concat(origins)
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
@@ -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
- # Support a custom, non-.localhost tld (e.g. *.myapp.test) too.
25
- patterns.push(host, ".#{host}") if host && !host.end_with?(".localhost")
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
- host = Regexp.escape(options[:host])
57
- [ %r{\A#{options[:protocol]}://([\w-]+\.)*#{host}(:\d+)?\z} ]
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
@@ -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
- load.map do |h|
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
@@ -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
@@ -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)
@@ -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
@@ -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")
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -58,12 +58,14 @@ module Portless
58
58
  end
59
59
  end
60
60
 
61
- # Last `/`-segment of the branch, sanitized; nil for default/detached HEAD.
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.1
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-07-07 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async