rb-portless 0.3.0.dev.20260630.8a76e8f → 0.3.0.dev.20260630.053bdf1

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: dda2037a7c7e36ac84158c7c47c2a49de2006452255f552d87547e513a17f8f9
4
- data.tar.gz: cf0b838ff151ed00b72ec48579719de79b0e6f37e46368cd3358595dff9482c2
3
+ metadata.gz: e6a25f0f7f6a0e308c80900ba3e5851b5c17a3faca8dfa7777bceec1b66f678f
4
+ data.tar.gz: e46d73d384930fa15d5b7ab26936e1b06a424d8d22255e523858f6e507078d71
5
5
  SHA512:
6
- metadata.gz: 382f71a9be4a4a9b7b71b36e4d23d98d69831e3f17174c534cab283371357cdcb7586ee55435b5c723a6a74b6382a7a79a68fe7e56b28a26a5f2e64d5ffba8e2
7
- data.tar.gz: 425b0f7286a54d778b0cfedae39e74fb4bc07415f1c93ba1243153fa4ecf55614fd4e5d09e22cd99419185db7c70f034036554df871655cf611608e2e8e236d1
6
+ metadata.gz: fa2c6162f82e037036be8ea6ced48dadff8292e2b73c90abc88c7fdfeff78076e2d76eecd0cf8641b7a43fa380f84af2e72b27effe634a389c291bf1dfa0b713
7
+ data.tar.gz: c36ab3c5f0fcba87e3fb516cd5cf770ca4bdd7906653a75d81ff793ae0fe3b8331ef30ac437d9ff8378c2e8c97f5e2dacb9b49bbfc71259c339cb66e3b452a93
data/CHANGELOG.md CHANGED
@@ -6,8 +6,23 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [0.3.0]
8
8
 
9
+ ### Fixed / hardened
10
+
11
+ - **Health probes can't hang.** Added a read timeout to the TLS and plain probes
12
+ so a port that accepts but never answers no longer blocks `discover_port`.
13
+
9
14
  ### Added
10
15
 
16
+ - **Risky-TLD warning.** Warn when the configured `tld` ends in a real/reserved
17
+ TLD (`dev`, `app`, `local`, …) that could intercept live traffic.
18
+ - **More tests** — `Proxy#call` is now public, so the proxy's routing + error
19
+ logic is unit-tested (404 / 508 loop guard / 502 dead-backend, all stamped with
20
+ the health header) plus health probes and privilege logic (42 tests). The
21
+ successful byte-forward + **WebSocket upgrade relay** (ActionCable) need a live
22
+ reactor and are verified end-to-end manually — async-http servers can't be torn
23
+ down in-process without deadlock.
24
+
25
+
11
26
  - **Startup banner.** Running a dev server through rb-portless now prints a clear
12
27
  banner with the named URL(s) it's reachable at — not just `127.0.0.1:port`.
13
28
  - **Monorepo / multi-app.** A `portless.json` `apps` map runs several apps under
data/lib/portless/cli.rb CHANGED
@@ -205,11 +205,6 @@ module Portless
205
205
  i ? Integer(args[i + 1], exception: false) : nil
206
206
  end
207
207
 
208
- def todo(name, desc, _args = nil)
209
- warn "rb-portless #{name}: #{desc} — not yet implemented (#{Portless::VERSION})"
210
- exit 1
211
- end
212
-
213
208
  def rest(command)
214
209
  @argv.first == command ? @argv[1..] : @argv
215
210
  end
@@ -7,9 +7,7 @@ module Portless
7
7
  # defaults. Mirrors portless's config.ts/auto.ts: name is inferred from the
8
8
  # config, the git root, or the directory; tld defaults to "localhost".
9
9
  class Config
10
- DEFAULT_SCRIPT = "dev"
11
-
12
- attr_reader :name, :tld, :script, :app_port, :tls, :apps
10
+ attr_reader :name, :tld, :app_port, :tls, :apps
13
11
 
14
12
  def self.load(dir = Dir.pwd)
15
13
  new(read_file(dir), dir)
@@ -19,7 +17,6 @@ module Portless
19
17
  @dir = dir
20
18
  @name = sanitize_label(data["name"] || infer_name(dir))
21
19
  @tld = (data["tld"] || Constants::DEFAULT_TLD).to_s
22
- @script = data["script"] || DEFAULT_SCRIPT
23
20
  @app_port = data["appPort"] || data["app_port"]
24
21
  @tls = data.fetch("tls", true)
25
22
  # Monorepo: { "apps": { "web": "bin/rails server", "api": "node api.js" } }.
@@ -32,6 +29,17 @@ module Portless
32
29
  tld.split(".").include?(name) ? tld : "#{name}.#{tld}"
33
30
  end
34
31
 
32
+ # Real/reserved TLDs that can intercept live traffic or clash with mDNS.
33
+ RISKY_TLDS = %w[dev app page zip mov local].freeze
34
+
35
+ # A warning string if the tld looks risky, else nil. (.localhost / .test are safe.)
36
+ def tld_warning
37
+ last = tld.split(".").last
38
+ return unless RISKY_TLDS.include?(last)
39
+
40
+ "tld \".#{last}\" is a real/reserved TLD — prefer \".localhost\" so you don't intercept real traffic"
41
+ end
42
+
35
43
  def self.read_file(dir)
36
44
  json = File.join(dir, "portless.json")
37
45
  return JSON.parse(File.read(json)) if File.exist?(json)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "socket"
4
4
  require "openssl"
5
+ require "timeout"
5
6
 
6
7
  module Portless
7
8
  # "Is *our* proxy on this port?" — every proxied response carries the
@@ -26,7 +27,8 @@ module Portless
26
27
  ssl.sync_close = true
27
28
  ssl.connect
28
29
  ssl.write(REQUEST)
29
- marker?(ssl.read(4096))
30
+ # Read timeout too — a port that accepts but never answers must not hang us.
31
+ marker?(Timeout.timeout(timeout) { ssl.read(4096) })
30
32
  rescue StandardError
31
33
  false
32
34
  ensure
@@ -38,7 +40,7 @@ module Portless
38
40
  Socket.tcp("127.0.0.1", port, connect_timeout: timeout) do |sock|
39
41
  sock.write(REQUEST)
40
42
  sock.close_write
41
- marker?(sock.read(4096))
43
+ marker?(Timeout.timeout(timeout) { sock.read(4096) })
42
44
  end
43
45
  rescue StandardError
44
46
  false
@@ -24,11 +24,6 @@ module Portless
24
24
  write(strip_block(read))
25
25
  end
26
26
 
27
- def managed_hostnames
28
- read[/#{Regexp.escape(Constants::HOSTS_BEGIN)}\n(.*?)#{Regexp.escape(Constants::HOSTS_END)}/m, 1]
29
- .to_s.scan(/^\s*127\.0\.0\.1\s+(\S+)/).flatten
30
- end
31
-
32
27
  def build_block(hostnames)
33
28
  return "" if hostnames.empty?
34
29
  lines = hostnames.uniq.map { |h| "127.0.0.1\t#{h}" }
@@ -7,6 +7,8 @@ module Portless
7
7
  # supervised + torn down together. Ruby sets env per-spawn, so there's no
8
8
  # NODE_OPTIONS loader hack (cf. portless's turbo.ts).
9
9
  class Multi
10
+ include RunSupport
11
+
10
12
  App = Struct.new(:name, :hostname, :port, :url, :pid, keyword_init: true)
11
13
 
12
14
  def initialize(config: Config.load, route_store: RouteStore.new)
@@ -18,7 +20,7 @@ module Portless
18
20
  def run
19
21
  raise Error, "no apps defined — add an \"apps\" map to portless.json" if @config.apps.empty?
20
22
 
21
- ensure_trusted if @config.tls
23
+ ensure_trusted
22
24
  proxy_port = Daemon.ensure_running(tls: @config.tls)
23
25
  @apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
24
26
 
@@ -57,27 +59,5 @@ module Portless
57
59
  @apps.each { |app| @route_store.remove(app.hostname, owner_pid: Process.pid) }
58
60
  kill_all("TERM")
59
61
  end
60
-
61
- def child_env(port, url)
62
- {
63
- "PORT" => port.to_s,
64
- "HOST" => "127.0.0.1",
65
- "PORTLESS_URL" => url,
66
- "SSL_CERT_FILE" => (File.exist?(State.ca_cert) ? State.ca_cert : nil)
67
- }.compact
68
- end
69
-
70
- def display_url(hostname, proxy_port)
71
- scheme = @config.tls ? "https" : "http"
72
- default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
73
- suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
74
- "#{scheme}://#{hostname}#{suffix}"
75
- end
76
-
77
- def ensure_trusted
78
- Trust.install! if Privilege.interactive? && !Trust.trusted?
79
- rescue Portless::Error
80
- nil
81
- end
82
62
  end
83
63
  end
@@ -51,13 +51,10 @@ module Portless
51
51
  routes.find { |r| host.end_with?(".#{r.hostname}") }
52
52
  end
53
53
 
54
- private
55
-
56
- def make_server(endpoint)
57
- Async::HTTP::Server.for(endpoint) { |request| handle(request) }
58
- end
59
-
60
- def handle(request)
54
+ # The reverse-proxy app: resolve the request's host to a backend, forward it,
55
+ # stamp the health header. Public so it can be mounted in a test reactor
56
+ # (Async::HTTP::Server.for(endpoint, &proxy.method(:call))).
57
+ def call(request)
61
58
  host = request_host(request)
62
59
  route = route_for(host)
63
60
  return error(404, "No app is registered for #{host}.") unless route
@@ -72,6 +69,12 @@ module Portless
72
69
  error(502, "Backend for #{host} is not responding (#{e.class}).")
73
70
  end
74
71
 
72
+ private
73
+
74
+ def make_server(endpoint)
75
+ Async::HTTP::Server.for(endpoint) { |request| call(request) }
76
+ end
77
+
75
78
  def build_forward(request, host, hops)
76
79
  headers = Protocol::HTTP::Headers.new
77
80
  request.headers.each do |key, value|
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # Shared bits of the two run paths (single-app Runner + multi-app Multi):
5
+ # the child env, the display URL, and first-run CA trust. Both expect a
6
+ # `@config`.
7
+ module RunSupport
8
+ private
9
+
10
+ def child_env(port, url)
11
+ {
12
+ "PORT" => port.to_s,
13
+ "HOST" => "127.0.0.1",
14
+ "PORTLESS_URL" => url,
15
+ # Let the app's own server-side TLS verification trust our CA.
16
+ "SSL_CERT_FILE" => (File.exist?(State.ca_cert) ? State.ca_cert : nil)
17
+ }.compact
18
+ end
19
+
20
+ def display_url(hostname, proxy_port)
21
+ scheme = @config.tls ? "https" : "http"
22
+ default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
23
+ suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
24
+ "#{scheme}://#{hostname}#{suffix}"
25
+ end
26
+
27
+ # Trust the local CA on first run (HTTPS only, interactive; never blocks the
28
+ # run), so HTTPS works without browser warnings — like portless.
29
+ def ensure_trusted
30
+ return unless @config.tls
31
+ return if Trust.trusted?
32
+
33
+ unless Privilege.interactive?
34
+ warn "rb-portless: CA not trusted — run `rb-portless trust` (HTTPS shows warnings until then)"
35
+ return
36
+ end
37
+
38
+ warn "rb-portless: trusting the local CA (first run)…"
39
+ Trust.install!
40
+ rescue Portless::Error => e
41
+ warn "rb-portless: couldn't auto-trust the CA (#{e.message}) — run `rb-portless trust`"
42
+ end
43
+ end
44
+ end
@@ -6,6 +6,8 @@ module Portless
6
6
  # group (forwarding signals), and deregister on exit. Rails/puma respect PORT
7
7
  # natively. Mirrors portless's runApp/spawnCommand.
8
8
  class Runner
9
+ include RunSupport
10
+
9
11
  def initialize(command:, config: Config.load, route_store: RouteStore.new, options: {})
10
12
  @command = Array(command)
11
13
  @config = config
@@ -21,7 +23,8 @@ module Portless
21
23
  command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
22
24
  hostname = @config.hostname
23
25
 
24
- ensure_trusted if @config.tls
26
+ warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
27
+ ensure_trusted
25
28
  proxy_port = Daemon.ensure_running(tls: @config.tls)
26
29
  @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
27
30
 
@@ -40,13 +43,6 @@ module Portless
40
43
 
41
44
  private
42
45
 
43
- def display_url(hostname, proxy_port)
44
- scheme = @config.tls ? "https" : "http"
45
- default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
46
- suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
47
- "#{scheme}://#{hostname}#{suffix}"
48
- end
49
-
50
46
  # ── LAN mode (--lan) ──────────────────────────────────────────────────
51
47
  # Register a `<name>.local` route, publish it over mDNS, and surface the URL
52
48
  # so phones/tablets on the Wi-Fi can reach the app.
@@ -83,24 +79,6 @@ module Portless
83
79
  Share::Tailscale.stop(@tailscale) if @tailscale
84
80
  end
85
81
 
86
- # Trust the local CA on first run (like portless), so HTTPS works without
87
- # browser warnings out of the box. Interactive only — macOS prompts for
88
- # keychain auth; in CI/no-TTY we skip with a hint rather than hang. Never
89
- # blocks the run if trusting fails.
90
- def ensure_trusted
91
- return if Trust.trusted?
92
-
93
- unless Privilege.interactive?
94
- warn "rb-portless: CA not trusted — run `rb-portless trust` (HTTPS shows warnings until then)"
95
- return
96
- end
97
-
98
- warn "rb-portless: trusting the local CA (first run)…"
99
- Trust.install!
100
- rescue Portless::Error => e
101
- warn "rb-portless: couldn't auto-trust the CA (#{e.message}) — run `rb-portless trust`"
102
- end
103
-
104
82
  # Run the child in its own process group so we can signal the whole tree,
105
83
  # forwarding INT/TERM and propagating its exit status.
106
84
  def supervise(command, port, url)
@@ -122,16 +100,6 @@ module Portless
122
100
  nil
123
101
  end
124
102
 
125
- def child_env(port, url)
126
- {
127
- "PORT" => port.to_s,
128
- "HOST" => "127.0.0.1",
129
- "PORTLESS_URL" => url,
130
- # Let the app's own server-side TLS verification trust our CA.
131
- "SSL_CERT_FILE" => (File.exist?(State.ca_cert) ? State.ca_cert : nil)
132
- }.compact
133
- end
134
-
135
103
  # Explicit command wins; bare `rb-portless` falls back to the project's dev
136
104
  # runner (bin/dev, then bin/rails server).
137
105
  def resolved_command
@@ -29,7 +29,6 @@ module Portless
29
29
  def proxy_log = path("proxy.log")
30
30
  def ca_cert = path("ca.pem")
31
31
  def ca_key = path("ca-key.pem")
32
- def ca_serial = path("ca.srl")
33
32
  def ca_trusted_marker = path("ca.trusted")
34
33
  def host_certs_dir = path("host-certs")
35
34
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.3.0.dev.20260630.8a76e8f"
4
+ VERSION = "0.3.0.dev.20260630.053bdf1"
5
5
  end
data/lib/rb-portless.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "portless/lan_ip"
22
22
  require_relative "portless/mdns"
23
23
  require_relative "portless/share/ngrok"
24
24
  require_relative "portless/share/tailscale"
25
+ require_relative "portless/run_support"
25
26
  require_relative "portless/runner"
26
27
  require_relative "portless/multi"
27
28
  require_relative "portless/cli"
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.3.0.dev.20260630.8a76e8f
4
+ version: 0.3.0.dev.20260630.053bdf1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso
@@ -74,6 +74,7 @@ files:
74
74
  - lib/portless/rails.rb
75
75
  - lib/portless/rails_hosts.rb
76
76
  - lib/portless/route_store.rb
77
+ - lib/portless/run_support.rb
77
78
  - lib/portless/runner.rb
78
79
  - lib/portless/service.rb
79
80
  - lib/portless/share/ngrok.rb