rb-portless 0.3.0.dev.20260630.053bdf1 → 0.3.0.dev.20260630.68c9c34

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: e6a25f0f7f6a0e308c80900ba3e5851b5c17a3faca8dfa7777bceec1b66f678f
4
- data.tar.gz: e46d73d384930fa15d5b7ab26936e1b06a424d8d22255e523858f6e507078d71
3
+ metadata.gz: 749216011c6c43a8048dcbfaed840ea189a5fd62a99b2744d7e11ac482ba6f9f
4
+ data.tar.gz: c5f29b73ecb5ddcfe726b8db6cbf95863b9da45bd41e29dae51e98eb56cc91e9
5
5
  SHA512:
6
- metadata.gz: fa2c6162f82e037036be8ea6ced48dadff8292e2b73c90abc88c7fdfeff78076e2d76eecd0cf8641b7a43fa380f84af2e72b27effe634a389c291bf1dfa0b713
7
- data.tar.gz: c36ab3c5f0fcba87e3fb516cd5cf770ca4bdd7906653a75d81ff793ae0fe3b8331ef30ac437d9ff8378c2e8c97f5e2dacb9b49bbfc71259c339cb66e3b452a93
6
+ metadata.gz: 5cc9a46f38311264470ceb33b83406200f52b7aa693597309ec0d77d835beea88b30325a64cff7a47d7c9ab7cf3041109371f8e1b04115f2810850c623f76725
7
+ data.tar.gz: d5183f6f9d86daff7023528a93cc9a150a7732c3d5977a96b63134a7eac6e0ddb391fb4deee4952683adaf86c84bde678493afe65d78aa88f6953f87f0bd49fb
data/CHANGELOG.md CHANGED
@@ -6,23 +6,8 @@ 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
-
14
9
  ### Added
15
10
 
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
-
26
11
  - **Startup banner.** Running a dev server through rb-portless now prints a clear
27
12
  banner with the named URL(s) it's reachable at — not just `127.0.0.1:port`.
28
13
  - **Monorepo / multi-app.** A `portless.json` `apps` map runs several apps under
@@ -31,10 +16,8 @@ All notable changes to this project are documented here. The format follows
31
16
  detects the LAN IP, registers `<name>.local`, and publishes it over mDNS
32
17
  (`dns-sd`/`avahi-publish`). `--ip` overrides the detected address.
33
18
  - **Public sharing (experimental).** `--ngrok`, `--tailscale`, `--funnel` expose
34
- the app via ngrok / your tailnet (their CLIs, installed separately). When a
35
- tool is missing or unconfigured, print an **actionable** message (install link,
36
- `ngrok config add-authtoken`, "enable HTTPS in your tailnet DNS settings",
37
- "run `tailscale up`") rather than failing silently — mirroring portless. Tailscale is **non-destructive**: it reads
19
+ the app via ngrok / your tailnet (their CLIs, installed separately). Degrade
20
+ gracefully when the tool is absent. Tailscale is **non-destructive**: it reads
38
21
  `tailscale serve status`, picks a free HTTPS port (never clobbering your
39
22
  existing serve/funnel config), registers with `--yes`, and removes only the
40
23
  port it created on exit — mirroring portless's port-conflict handling.
data/lib/portless/cli.rb CHANGED
@@ -205,6 +205,11 @@ 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
+
208
213
  def rest(command)
209
214
  @argv.first == command ? @argv[1..] : @argv
210
215
  end
@@ -7,7 +7,9 @@ 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
- attr_reader :name, :tld, :app_port, :tls, :apps
10
+ DEFAULT_SCRIPT = "dev"
11
+
12
+ attr_reader :name, :tld, :script, :app_port, :tls, :apps
11
13
 
12
14
  def self.load(dir = Dir.pwd)
13
15
  new(read_file(dir), dir)
@@ -17,6 +19,7 @@ module Portless
17
19
  @dir = dir
18
20
  @name = sanitize_label(data["name"] || infer_name(dir))
19
21
  @tld = (data["tld"] || Constants::DEFAULT_TLD).to_s
22
+ @script = data["script"] || DEFAULT_SCRIPT
20
23
  @app_port = data["appPort"] || data["app_port"]
21
24
  @tls = data.fetch("tls", true)
22
25
  # Monorepo: { "apps": { "web": "bin/rails server", "api": "node api.js" } }.
@@ -29,17 +32,6 @@ module Portless
29
32
  tld.split(".").include?(name) ? tld : "#{name}.#{tld}"
30
33
  end
31
34
 
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
-
43
35
  def self.read_file(dir)
44
36
  json = File.join(dir, "portless.json")
45
37
  return JSON.parse(File.read(json)) if File.exist?(json)
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "socket"
4
4
  require "openssl"
5
- require "timeout"
6
5
 
7
6
  module Portless
8
7
  # "Is *our* proxy on this port?" — every proxied response carries the
@@ -27,8 +26,7 @@ module Portless
27
26
  ssl.sync_close = true
28
27
  ssl.connect
29
28
  ssl.write(REQUEST)
30
- # Read timeout too — a port that accepts but never answers must not hang us.
31
- marker?(Timeout.timeout(timeout) { ssl.read(4096) })
29
+ marker?(ssl.read(4096))
32
30
  rescue StandardError
33
31
  false
34
32
  ensure
@@ -40,7 +38,7 @@ module Portless
40
38
  Socket.tcp("127.0.0.1", port, connect_timeout: timeout) do |sock|
41
39
  sock.write(REQUEST)
42
40
  sock.close_write
43
- marker?(Timeout.timeout(timeout) { sock.read(4096) })
41
+ marker?(sock.read(4096))
44
42
  end
45
43
  rescue StandardError
46
44
  false
@@ -24,6 +24,11 @@ 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
+
27
32
  def build_block(hostnames)
28
33
  return "" if hostnames.empty?
29
34
  lines = hostnames.uniq.map { |h| "127.0.0.1\t#{h}" }
@@ -7,8 +7,6 @@ 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
-
12
10
  App = Struct.new(:name, :hostname, :port, :url, :pid, keyword_init: true)
13
11
 
14
12
  def initialize(config: Config.load, route_store: RouteStore.new)
@@ -20,7 +18,7 @@ module Portless
20
18
  def run
21
19
  raise Error, "no apps defined — add an \"apps\" map to portless.json" if @config.apps.empty?
22
20
 
23
- ensure_trusted
21
+ ensure_trusted if @config.tls
24
22
  proxy_port = Daemon.ensure_running(tls: @config.tls)
25
23
  @apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
26
24
 
@@ -59,5 +57,27 @@ module Portless
59
57
  @apps.each { |app| @route_store.remove(app.hostname, owner_pid: Process.pid) }
60
58
  kill_all("TERM")
61
59
  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
62
82
  end
63
83
  end
@@ -51,10 +51,13 @@ module Portless
51
51
  routes.find { |r| host.end_with?(".#{r.hostname}") }
52
52
  end
53
53
 
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)
54
+ private
55
+
56
+ def make_server(endpoint)
57
+ Async::HTTP::Server.for(endpoint) { |request| handle(request) }
58
+ end
59
+
60
+ def handle(request)
58
61
  host = request_host(request)
59
62
  route = route_for(host)
60
63
  return error(404, "No app is registered for #{host}.") unless route
@@ -69,12 +72,6 @@ module Portless
69
72
  error(502, "Backend for #{host} is not responding (#{e.class}).")
70
73
  end
71
74
 
72
- private
73
-
74
- def make_server(endpoint)
75
- Async::HTTP::Server.for(endpoint) { |request| call(request) }
76
- end
77
-
78
75
  def build_forward(request, host, hops)
79
76
  headers = Protocol::HTTP::Headers.new
80
77
  request.headers.each do |key, value|
@@ -6,8 +6,6 @@ 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
-
11
9
  def initialize(command:, config: Config.load, route_store: RouteStore.new, options: {})
12
10
  @command = Array(command)
13
11
  @config = config
@@ -23,8 +21,7 @@ module Portless
23
21
  command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
24
22
  hostname = @config.hostname
25
23
 
26
- warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
27
- ensure_trusted
24
+ ensure_trusted if @config.tls
28
25
  proxy_port = Daemon.ensure_running(tls: @config.tls)
29
26
  @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
30
27
 
@@ -43,6 +40,13 @@ module Portless
43
40
 
44
41
  private
45
42
 
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
+
46
50
  # ── LAN mode (--lan) ──────────────────────────────────────────────────
47
51
  # Register a `<name>.local` route, publish it over mDNS, and surface the URL
48
52
  # so phones/tablets on the Wi-Fi can reach the app.
@@ -79,6 +83,24 @@ module Portless
79
83
  Share::Tailscale.stop(@tailscale) if @tailscale
80
84
  end
81
85
 
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
+
82
104
  # Run the child in its own process group so we can signal the whole tree,
83
105
  # forwarding INT/TERM and propagating its exit status.
84
106
  def supervise(command, port, url)
@@ -100,6 +122,16 @@ module Portless
100
122
  nil
101
123
  end
102
124
 
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
+
103
135
  # Explicit command wins; bare `rb-portless` falls back to the project's dev
104
136
  # runner (bin/dev, then bin/rails server).
105
137
  def resolved_command
@@ -16,10 +16,7 @@ module Portless
16
16
  API = "http://127.0.0.1:4040/api/tunnels"
17
17
 
18
18
  def start(hostname:, backend_port:)
19
- unless Portless.which("ngrok")
20
- warn "rb-portless: ngrok not found — install it (https://ngrok.com/download) to use --ngrok"
21
- return nil
22
- end
19
+ return nil unless Portless.which("ngrok")
23
20
 
24
21
  pid = Process.spawn("ngrok", "http", backend_port.to_s, "--host-header=#{hostname}",
25
22
  out: File::NULL, err: File::NULL)
@@ -29,11 +26,9 @@ module Portless
29
26
  { pid: pid, url: url }
30
27
  else
31
28
  stop(pid: pid)
32
- warn "rb-portless: ngrok didn't produce a public URL — is your authtoken set? (`ngrok config add-authtoken <token>`)"
33
29
  nil
34
30
  end
35
- rescue StandardError => e
36
- warn "rb-portless: ngrok failed (#{e.message})"
31
+ rescue StandardError
37
32
  nil
38
33
  end
39
34
 
@@ -18,44 +18,21 @@ module Portless
18
18
  FUNNEL_PORTS = [ 443, 8443, 10_000 ].freeze # Funnel supports only these
19
19
 
20
20
  def start(backend_port:, funnel: false)
21
- unless Portless.which("tailscale")
22
- warn "rb-portless: tailscale not found — install it (https://tailscale.com/download) to use --tailscale"
23
- return nil
24
- end
25
-
26
- status = status_json
27
- unless status
28
- warn "rb-portless: tailscale isn't connected — run `tailscale up`, then retry"
29
- return nil
30
- end
31
- unless capability?(status, "https")
32
- warn "rb-portless: tailscale HTTPS certs aren't enabled — turn on HTTPS in your tailnet DNS settings"
33
- return nil
34
- end
35
- if funnel && !capability?(status, "funnel")
36
- warn "rb-portless: tailscale Funnel isn't enabled for this node — enable it, then retry --funnel"
37
- return nil
38
- end
21
+ return nil unless Portless.which("tailscale")
39
22
 
40
23
  mode = funnel ? "funnel" : "serve"
41
24
  port = available_port(funnel: funnel)
42
- unless port
43
- warn "rb-portless: all tailscale Funnel ports are in use (443/8443/10000)"
44
- return nil
45
- end
46
-
47
- unless system("tailscale", mode, "--bg", "--yes", "--https=#{port}",
48
- "http://127.0.0.1:#{backend_port}", out: File::NULL, err: File::NULL)
49
- warn "rb-portless: `tailscale #{mode}` failed to register"
50
- return nil
51
- end
52
-
53
- base = dns_name(status)
25
+ return nil unless port # all preferred ports already in use → don't fight it
26
+
27
+ ok = system("tailscale", mode, "--bg", "--yes", "--https=#{port}",
28
+ "http://127.0.0.1:#{backend_port}", out: File::NULL, err: File::NULL)
29
+ return nil unless ok
30
+
31
+ base = magic_dns_url
54
32
  return (off(mode, port) and nil) unless base
55
33
 
56
34
  { mode: mode, port: port, url: format_url(base, port) }
57
- rescue StandardError => e
58
- warn "rb-portless: tailscale sharing failed (#{e.message})"
35
+ rescue StandardError
59
36
  nil
60
37
  end
61
38
 
@@ -97,25 +74,14 @@ module Portless
97
74
  []
98
75
  end
99
76
 
100
- def status_json
77
+ def magic_dns_url
101
78
  json = JSON.parse(`tailscale status --json 2>/dev/null`)
102
- json.is_a?(Hash) ? json : nil
79
+ dns = json.dig("Self", "DNSName").to_s.chomp(".")
80
+ dns.empty? ? nil : "https://#{dns}"
103
81
  rescue StandardError
104
82
  nil
105
83
  end
106
84
 
107
- def dns_name(status)
108
- dns = status.dig("Self", "DNSName").to_s.chomp(".")
109
- dns.empty? ? nil : "https://#{dns}"
110
- end
111
-
112
- # Does this node have the HTTPS / Funnel capability? (mirrors portless)
113
- def capability?(status, name)
114
- node = status["Self"] || {}
115
- names = Array(node["Capabilities"]) + (node["CapMap"] || {}).keys
116
- names.any? { |cap| down = cap.to_s.downcase; down == name || down.end_with?("/#{name}") }
117
- end
118
-
119
85
  def format_url(base, port)
120
86
  base = base.chomp("/")
121
87
  port == 443 ? base : "#{base}:#{port}"
@@ -29,6 +29,7 @@ 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")
32
33
  def ca_trusted_marker = path("ca.trusted")
33
34
  def host_certs_dir = path("host-certs")
34
35
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.3.0.dev.20260630.053bdf1"
4
+ VERSION = "0.3.0.dev.20260630.68c9c34"
5
5
  end
data/lib/rb-portless.rb CHANGED
@@ -22,7 +22,6 @@ 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"
26
25
  require_relative "portless/runner"
27
26
  require_relative "portless/multi"
28
27
  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.053bdf1
4
+ version: 0.3.0.dev.20260630.68c9c34
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso
@@ -74,7 +74,6 @@ 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
78
77
  - lib/portless/runner.rb
79
78
  - lib/portless/service.rb
80
79
  - lib/portless/share/ngrok.rb
@@ -1,44 +0,0 @@
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