rb-portless 0.5.0 → 0.5.1.dev.20260805.5cf596b

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