rb-portless 0.3.0.dev.20260630.7305b58 → 0.3.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: 77efce157b70a56f16f260b04bfad49a83b55d1aa1c5367b8b24f4d596ce1541
4
- data.tar.gz: 17f9a98ff52119a61fc2d770e7c3df451b3bdc39547f897f0e432b7b9fb2b57d
3
+ metadata.gz: e49a039bffaf418a826c61337253e8cf3f46c54b500f30936fba6e8153f96804
4
+ data.tar.gz: b4c6fad1e96ac1c2fd11a9366ad36273af95976e2abcdb1387d4b1b60dd1bd27
5
5
  SHA512:
6
- metadata.gz: f4e976fc65b4887516767d64b8d273066e8b03cfccebca66e762e473d8f77b6a441dd78b24ff4fe3a5485628e845ce904e8898a75e0e4db80e5d3852b9767869
7
- data.tar.gz: e320411a2f3104a3ab3ab4f5c61cd7f41c423d4978a0e4b001fbf67554c008c7ea396da8950a0cd09928facaa192cb518d16cda6f97391b87a8f5387f7483b41
6
+ metadata.gz: 7754996324bcc3c53dac67a3c2999cc2c57d25d0bc6126ac4041d6dfb2936c49ca675b63ddb1fb24d86ad5b3a49b76b76e7397ae20f32f0d637f3b3f63e4f640
7
+ data.tar.gz: ec5a8e7ff23b18f7766015e11627938776d88eb672fe174a3902e273af14d044f69db69a146de48284799e08de5425544837a94160cf3c2ddf6eff7faa447673
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
@@ -16,7 +31,13 @@ All notable changes to this project are documented here. The format follows
16
31
  detects the LAN IP, registers `<name>.local`, and publishes it over mDNS
17
32
  (`dns-sd`/`avahi-publish`). `--ip` overrides the detected address.
18
33
  - **Public sharing (experimental).** `--ngrok`, `--tailscale`, `--funnel` expose
19
- the app via ngrok / your tailnet. Degrade gracefully when the tool is absent.
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
38
+ `tailscale serve status`, picks a free HTTPS port (never clobbering your
39
+ existing serve/funnel config), registers with `--yes`, and removes only the
40
+ port it created on exit — mirroring portless's port-conflict handling.
20
41
 
21
42
  ## [0.2.0]
22
43
 
data/README.md CHANGED
@@ -95,7 +95,17 @@ rb-portless run --ngrok bin/dev # https://xxxx.ngrok.app
95
95
  rb-portless run --tailscale bin/dev # your-machine.tailnet.ts.net
96
96
  rb-portless run --funnel bin/dev # tailscale Funnel (public)
97
97
  ```
98
- Each degrades gracefully if the tool isn't installed.
98
+
99
+ These use the tools' own CLIs — **install them separately** (we don't bundle
100
+ anything, same as portless), and each degrades gracefully if the tool is absent:
101
+ - **ngrok** — install the `ngrok` CLI and configure an authtoken (free account).
102
+ - **tailscale** — install `tailscale`, be logged into a tailnet, and enable HTTPS
103
+ certificates (and Funnel, for `--funnel`) in your tailnet settings.
104
+
105
+ > **Your tailnet config is safe.** rb-portless reads `tailscale serve status`,
106
+ > picks a **free** HTTPS port (never one you're already serving on), registers
107
+ > with `--yes`, and on exit turns off **only the port it created** — it never
108
+ > touches your existing `serve`/`funnel` setup.
99
109
 
100
110
  ## Commands
101
111
 
@@ -32,6 +32,17 @@ module Portless
32
32
  tld.split(".").include?(name) ? tld : "#{name}.#{tld}"
33
33
  end
34
34
 
35
+ # Real/reserved TLDs that can intercept live traffic or clash with mDNS.
36
+ RISKY_TLDS = %w[dev app page zip mov local].freeze
37
+
38
+ # A warning string if the tld looks risky, else nil. (.localhost / .test are safe.)
39
+ def tld_warning
40
+ last = tld.split(".").last
41
+ return unless RISKY_TLDS.include?(last)
42
+
43
+ "tld \".#{last}\" is a real/reserved TLD — prefer \".localhost\" so you don't intercept real traffic"
44
+ end
45
+
35
46
  def self.read_file(dir)
36
47
  json = File.join(dir, "portless.json")
37
48
  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
@@ -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|
@@ -21,6 +21,7 @@ module Portless
21
21
  command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
22
22
  hostname = @config.hostname
23
23
 
24
+ warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
24
25
  ensure_trusted if @config.tls
25
26
  proxy_port = Daemon.ensure_running(tls: @config.tls)
26
27
  @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
@@ -16,7 +16,10 @@ module Portless
16
16
  API = "http://127.0.0.1:4040/api/tunnels"
17
17
 
18
18
  def start(hostname:, backend_port:)
19
- return nil unless Portless.which("ngrok")
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
20
23
 
21
24
  pid = Process.spawn("ngrok", "http", backend_port.to_s, "--host-header=#{hostname}",
22
25
  out: File::NULL, err: File::NULL)
@@ -26,9 +29,11 @@ module Portless
26
29
  { pid: pid, url: url }
27
30
  else
28
31
  stop(pid: pid)
32
+ warn "rb-portless: ngrok didn't produce a public URL — is your authtoken set? (`ngrok config add-authtoken <token>`)"
29
33
  nil
30
34
  end
31
- rescue StandardError
35
+ rescue StandardError => e
36
+ warn "rb-portless: ngrok failed (#{e.message})"
32
37
  nil
33
38
  end
34
39
 
@@ -5,41 +5,121 @@ require "json"
5
5
  module Portless
6
6
  module Share
7
7
  # Expose the local app on your tailnet (`serve`) or publicly (`funnel`) via
8
- # the tailscale CLI. Returns { mode:, url: } or nil. EXPERIMENTAL. Mirrors
9
- # portless's tailscale.ts.
8
+ # the tailscale CLI. Returns { mode:, port:, url: } or nil. EXPERIMENTAL.
9
+ #
10
+ # SAFETY (mirrors portless's tailscale.ts): we never clobber your existing
11
+ # serve config — we read `tailscale serve status` for ports already in use
12
+ # and pick the first FREE one from the preferred list, register with `--yes`
13
+ # (no prompt), and on teardown turn off ONLY the port we registered.
10
14
  module Tailscale
11
15
  module_function
12
16
 
17
+ PREFERRED_SERVE_PORTS = [ 443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450 ].freeze
18
+ FUNNEL_PORTS = [ 443, 8443, 10_000 ].freeze # Funnel supports only these
19
+
13
20
  def start(backend_port:, funnel: false)
14
- return nil unless Portless.which("tailscale")
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
15
39
 
16
40
  mode = funnel ? "funnel" : "serve"
17
- ok = system("tailscale", mode, "--bg", "--https=443", "http://127.0.0.1:#{backend_port}",
18
- out: File::NULL, err: File::NULL)
19
- return nil unless ok
41
+ 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
20
46
 
21
- url = magic_dns_url
22
- url ? { mode: mode, url: url } : nil
23
- rescue StandardError
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)
54
+ return (off(mode, port) and nil) unless base
55
+
56
+ { mode: mode, port: port, url: format_url(base, port) }
57
+ rescue StandardError => e
58
+ warn "rb-portless: tailscale sharing failed (#{e.message})"
24
59
  nil
25
60
  end
26
61
 
27
62
  def stop(handle)
28
63
  return unless handle && Portless.which("tailscale")
29
64
 
30
- system("tailscale", handle[:mode], "--https=443", "off", out: File::NULL, err: File::NULL)
65
+ off(handle[:mode], handle[:port])
66
+ end
67
+
68
+ # Turn off ONLY the registration we created (scoped to our port).
69
+ def off(mode, port)
70
+ system("tailscale", mode, "--yes", "--https=#{port}", "off", out: File::NULL, err: File::NULL)
31
71
  rescue StandardError
32
72
  nil
33
73
  end
34
74
 
35
- # The machine's MagicDNS name its tailnet HTTPS URL.
36
- def magic_dns_url
75
+ # First free HTTPS port from the preferred pool, never one already in use by
76
+ # the user's existing serve/funnel config. nil if the funnel pool is full.
77
+ def available_port(funnel:)
78
+ used = used_serve_ports
79
+ pool = funnel ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS
80
+ free = pool.find { |port| !used.include?(port) }
81
+ return free if free
82
+ return nil if funnel
83
+
84
+ port = PREFERRED_SERVE_PORTS.last + 1
85
+ port += 1 while used.include?(port)
86
+ port
87
+ end
88
+
89
+ # 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`)
92
+ ports = []
93
+ (config["Web"] || {}).each_key { |host_port| ports << Regexp.last_match(1).to_i if host_port =~ /:(\d+)\z/ }
94
+ (config["TCP"] || {}).each_key { |port| ports << port.to_i }
95
+ ports
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def status_json
37
101
  json = JSON.parse(`tailscale status --json 2>/dev/null`)
38
- dns = json.dig("Self", "DNSName").to_s.chomp(".")
39
- dns.empty? ? nil : "https://#{dns}"
102
+ json.is_a?(Hash) ? json : nil
40
103
  rescue StandardError
41
104
  nil
42
105
  end
106
+
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
+ def format_url(base, port)
120
+ base = base.chomp("/")
121
+ port == 443 ? base : "#{base}:#{port}"
122
+ end
43
123
  end
44
124
  end
45
125
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.3.0.dev.20260630.7305b58"
4
+ VERSION = "0.3.0"
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.3.0.dev.20260630.7305b58
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso