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 +4 -4
- data/CHANGELOG.md +22 -1
- data/README.md +11 -1
- data/lib/portless/config.rb +11 -0
- data/lib/portless/health.rb +4 -2
- data/lib/portless/proxy.rb +10 -7
- data/lib/portless/runner.rb +1 -0
- data/lib/portless/share/ngrok.rb +7 -2
- data/lib/portless/share/tailscale.rb +94 -14
- data/lib/portless/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e49a039bffaf418a826c61337253e8cf3f46c54b500f30936fba6e8153f96804
|
|
4
|
+
data.tar.gz: b4c6fad1e96ac1c2fd11a9366ad36273af95976e2abcdb1387d4b1b60dd1bd27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/portless/config.rb
CHANGED
|
@@ -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)
|
data/lib/portless/health.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/portless/proxy.rb
CHANGED
|
@@ -51,13 +51,10 @@ module Portless
|
|
|
51
51
|
routes.find { |r| host.end_with?(".#{r.hostname}") }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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|
|
data/lib/portless/runner.rb
CHANGED
|
@@ -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)
|
data/lib/portless/share/ngrok.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
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
|
-
|
|
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
|
data/lib/portless/version.rb
CHANGED