rb-portless 0.3.0.dev.20260630.68c9c34 → 0.3.0.dev.20260630.fb5fc36

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: 749216011c6c43a8048dcbfaed840ea189a5fd62a99b2744d7e11ac482ba6f9f
4
- data.tar.gz: c5f29b73ecb5ddcfe726b8db6cbf95863b9da45bd41e29dae51e98eb56cc91e9
3
+ metadata.gz: ad245d30c0c47ba8ac0be15c5483524cedc6d2a6fffd30b4df35ad0904287af6
4
+ data.tar.gz: 64e421e608545dc03fa7ad75506cd473ae86cece15b90bc38395c03fa8db0bba
5
5
  SHA512:
6
- metadata.gz: 5cc9a46f38311264470ceb33b83406200f52b7aa693597309ec0d77d835beea88b30325a64cff7a47d7c9ab7cf3041109371f8e1b04115f2810850c623f76725
7
- data.tar.gz: d5183f6f9d86daff7023528a93cc9a150a7732c3d5977a96b63134a7eac6e0ddb391fb4deee4952683adaf86c84bde678493afe65d78aa88f6953f87f0bd49fb
6
+ metadata.gz: cf6f98facf2bc05067f832f1f865b18977a00b9cbd608189289d5c89284a2b887f1d056cbcf3c664ef5a5a4762808e30dea9c1b8f582e430750562ce8b3c3b4b
7
+ data.tar.gz: '02658fcdf8c2ad85e58a3d5cf71148dcbec8819adb2d0bc439ec06cb44aad599d5e84a0055804ce910374f72012720e758ed418a7ded1e25033f57760f433d13'
data/CHANGELOG.md CHANGED
@@ -6,8 +6,20 @@ 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** — health probes and privilege logic. (Verified manually, since
19
+ the Async proxy can't be driven in-process without deadlock: HTTP/HTTPS/HTTP-2
20
+ forwarding, wildcard routing, and the **WebSocket upgrade relay** / ActionCable.)
21
+
22
+
11
23
  - **Startup banner.** Running a dev server through rb-portless now prints a clear
12
24
  banner with the named URL(s) it's reachable at — not just `127.0.0.1:port`.
13
25
  - **Monorepo / multi-app.** A `portless.json` `apps` map runs several apps under
@@ -16,8 +28,10 @@ All notable changes to this project are documented here. The format follows
16
28
  detects the LAN IP, registers `<name>.local`, and publishes it over mDNS
17
29
  (`dns-sd`/`avahi-publish`). `--ip` overrides the detected address.
18
30
  - **Public sharing (experimental).** `--ngrok`, `--tailscale`, `--funnel` expose
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
31
+ the app via ngrok / your tailnet (their CLIs, installed separately). When a
32
+ tool is missing or unconfigured, print an **actionable** message (install link,
33
+ `ngrok config add-authtoken`, "enable HTTPS in your tailnet DNS settings",
34
+ "run `tailscale up`") rather than failing silently — mirroring portless. Tailscale is **non-destructive**: it reads
21
35
  `tailscale serve status`, picks a free HTTPS port (never clobbering your
22
36
  existing serve/funnel config), registers with `--yes`, and removes only the
23
37
  port it created on exit — mirroring portless's port-conflict handling.
@@ -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
@@ -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
 
@@ -18,21 +18,44 @@ 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
- 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
22
39
 
23
40
  mode = funnel ? "funnel" : "serve"
24
41
  port = available_port(funnel: funnel)
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
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)
32
54
  return (off(mode, port) and nil) unless base
33
55
 
34
56
  { mode: mode, port: port, url: format_url(base, port) }
35
- rescue StandardError
57
+ rescue StandardError => e
58
+ warn "rb-portless: tailscale sharing failed (#{e.message})"
36
59
  nil
37
60
  end
38
61
 
@@ -74,14 +97,25 @@ module Portless
74
97
  []
75
98
  end
76
99
 
77
- def magic_dns_url
100
+ def status_json
78
101
  json = JSON.parse(`tailscale status --json 2>/dev/null`)
79
- dns = json.dig("Self", "DNSName").to_s.chomp(".")
80
- dns.empty? ? nil : "https://#{dns}"
102
+ json.is_a?(Hash) ? json : nil
81
103
  rescue StandardError
82
104
  nil
83
105
  end
84
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
+
85
119
  def format_url(base, port)
86
120
  base = base.chomp("/")
87
121
  port == 443 ? base : "#{base}:#{port}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.3.0.dev.20260630.68c9c34"
4
+ VERSION = "0.3.0.dev.20260630.fb5fc36"
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.68c9c34
4
+ version: 0.3.0.dev.20260630.fb5fc36
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso