rb-portless 0.1.0 → 0.3.0.dev.20260630.03ed07d
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/AGENTS.md +9 -3
- data/CHANGELOG.md +43 -0
- data/README.md +69 -6
- data/lib/portless/banner.rb +43 -0
- data/lib/portless/cli.rb +32 -7
- data/lib/portless/config.rb +14 -1
- data/lib/portless/health.rb +4 -2
- data/lib/portless/lan_ip.rb +19 -0
- data/lib/portless/mdns.rb +43 -0
- data/lib/portless/multi.rb +83 -0
- data/lib/portless/proxy.rb +10 -7
- data/lib/portless/runner.rb +73 -8
- data/lib/portless/share/ngrok.rb +69 -0
- data/lib/portless/share/tailscale.rb +125 -0
- data/lib/portless/version.rb +1 -1
- data/lib/rb-portless.rb +11 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7430efcc1364552ff11c18a40fb81497f6adb231e9b70de8dac525209baa0140
|
|
4
|
+
data.tar.gz: a7e8a061d587ae4df39feb5c419cdc4ded1f56e25c371d65e04daf39c88bc0ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 479a942f43878da3fd6c9fc3ef51f45de6548aef51d69ec66442e87ae235378544b7165d070aeef22b19a4c5a57d9d60e1b1bca17d1324a2a50ddbf38e6bcd67
|
|
7
|
+
data.tar.gz: ebf5b7ba3081de166182d8548235f27b7af0cd462008f458e82b786dc87db352b4cb5e82b3f1b60c350d4ba7c2748d6494ad482a5a3217d8fb0dce65ebed8e42
|
data/AGENTS.md
CHANGED
|
@@ -46,6 +46,10 @@ injected as `PORT`; the proxy routes the named host to it.
|
|
|
46
46
|
| `daemon.rb` | proxy start/stop, sudo bind, 1355 fallback | cli.ts handleProxy |
|
|
47
47
|
| `service.rb` | launchd / systemd boot service | service.ts |
|
|
48
48
|
| `frameworks.rb` | --port/--host injection (vite/astro/…) | cli-utils.ts |
|
|
49
|
+
| `banner.rb` | the "where's my app" startup banner | cli.ts (run output) |
|
|
50
|
+
| `multi.rb` | monorepo: run many apps under one proxy | workspace.ts/turbo.ts |
|
|
51
|
+
| `lan_ip.rb` / `mdns.rb` | LAN IP detect + `<name>.local` mDNS publish | lan-ip.ts/mdns.ts |
|
|
52
|
+
| `share/ngrok.rb` / `share/tailscale.rb` | public/tailnet sharing | ngrok.ts/tailscale.ts |
|
|
49
53
|
| `runner.rb` | run cmd: port→env→spawn→register→supervise | cli.ts runApp |
|
|
50
54
|
| `rails.rb` | opt-in railtie (whitelist *.localhost in dev) | — |
|
|
51
55
|
| `cli.rb` | command dispatch | cli.ts main |
|
|
@@ -62,13 +66,15 @@ injected as `PORT`; the proxy routes the named host to it.
|
|
|
62
66
|
- **Phase 3 ✅ (partial)** framework `--port`/`--host` injection, Linux CA trust,
|
|
63
67
|
optional `portless/rails` railtie.
|
|
64
68
|
|
|
69
|
+
- **Phase 4 ✅** startup banner; monorepo multi-app (`apps` map); LAN mode
|
|
70
|
+
(`--lan`: LanIp + mDNS `<name>.local`); public sharing (`--ngrok`/`--tailscale`/
|
|
71
|
+
`--funnel`, experimental).
|
|
72
|
+
|
|
65
73
|
### Roadmap (not yet built)
|
|
66
74
|
|
|
67
|
-
- LAN mode (mDNS `.local` publishing) for phones/tablets.
|
|
68
|
-
- Public sharing via `tailscale serve|funnel` and `ngrok`.
|
|
69
|
-
- Monorepo multi-app (one proxy, many named apps).
|
|
70
75
|
- Windows CA trust + Task Scheduler service.
|
|
71
76
|
- WebSocket upgrade relay hardening + HTTP/2 to the backend.
|
|
77
|
+
- mDNS LAN-IP change monitoring (re-publish on network switch).
|
|
72
78
|
|
|
73
79
|
## Conventions
|
|
74
80
|
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0]
|
|
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
|
+
### Added
|
|
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
|
+
|
|
26
|
+
- **Startup banner.** Running a dev server through rb-portless now prints a clear
|
|
27
|
+
banner with the named URL(s) it's reachable at — not just `127.0.0.1:port`.
|
|
28
|
+
- **Monorepo / multi-app.** A `portless.json` `apps` map runs several apps under
|
|
29
|
+
one proxy, each at its own `<name>.<tld>` (`rb-portless run` with no command).
|
|
30
|
+
- **LAN mode (`--lan`).** Reach the app from phones/tablets on the same Wi-Fi:
|
|
31
|
+
detects the LAN IP, registers `<name>.local`, and publishes it over mDNS
|
|
32
|
+
(`dns-sd`/`avahi-publish`). `--ip` overrides the detected address.
|
|
33
|
+
- **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
|
|
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.
|
|
41
|
+
|
|
42
|
+
## [0.2.0]
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Auto-trust on first run.** `run` now trusts the local CA automatically the
|
|
47
|
+
first time (interactive only; skipped with a hint in CI), matching portless —
|
|
48
|
+
HTTPS works with no browser warnings without a separate `trust` step.
|
|
49
|
+
|
|
7
50
|
## [0.1.0] — first release
|
|
8
51
|
|
|
9
52
|
The full portless workflow for Ruby, validated end-to-end against a real Rails
|
data/README.md
CHANGED
|
@@ -41,12 +41,12 @@ rb-portless run -- npm run dev # Vite/Astro/etc. get --port injected
|
|
|
41
41
|
|
|
42
42
|
A random port (4000–4999) is injected as `PORT` (and `HOST=127.0.0.1`);
|
|
43
43
|
Rails/puma respect it natively. The proxy **auto-starts** on first run: it
|
|
44
|
-
generates a local CA,
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
generates a local CA, **trusts it** (one keychain/sudo prompt, like portless),
|
|
45
|
+
and binds 443 — another one-time `sudo` on macOS/Linux (falls back to `:1355` if
|
|
46
|
+
you decline). After that, HTTPS just works with no browser warnings.
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
rb-portless trust # trust
|
|
49
|
+
rb-portless trust # re-trust manually if ever needed
|
|
50
50
|
rb-portless service install # bind 443 at boot — never prompt for sudo again
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -66,6 +66,47 @@ rb-portless service install # bind 443 at boot — never prompt for sudo
|
|
|
66
66
|
With a custom `tld`, every `*.shirabe.org.localhost` subdomain wildcard-routes to
|
|
67
67
|
the one app — ideal for subdomain-per-tenant apps.
|
|
68
68
|
|
|
69
|
+
## Multiple apps, LAN & sharing
|
|
70
|
+
|
|
71
|
+
**Monorepo / multi-app** — define an `apps` map and `rb-portless run` (no command)
|
|
72
|
+
starts them all, each at its own name:
|
|
73
|
+
|
|
74
|
+
```jsonc
|
|
75
|
+
// portless.json
|
|
76
|
+
{ "apps": { "web": "bin/rails server", "api": "node api/server.js" } }
|
|
77
|
+
// → https://web.localhost, https://api.localhost
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**LAN mode** — reach the app from your phone on the same Wi-Fi. It detects the
|
|
81
|
+
LAN IP, registers `<name>.local`, and publishes it over mDNS:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
rb-portless run --lan bin/dev # also → https://<name>.local
|
|
85
|
+
rb-portless run --lan --ip 10.0.0.5 bin/dev # override the detected IP
|
|
86
|
+
```
|
|
87
|
+
> Devices won't trust your local CA without installing it — use `--lan` with
|
|
88
|
+
> `--no-tls` (set `"tls": false`) for plain HTTP, or install `~/.rb-portless/ca.pem`
|
|
89
|
+
> on the device.
|
|
90
|
+
|
|
91
|
+
**Public sharing** (experimental) — expose the app via ngrok or your tailnet:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
rb-portless run --ngrok bin/dev # https://xxxx.ngrok.app
|
|
95
|
+
rb-portless run --tailscale bin/dev # your-machine.tailnet.ts.net
|
|
96
|
+
rb-portless run --funnel bin/dev # tailscale Funnel (public)
|
|
97
|
+
```
|
|
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.
|
|
109
|
+
|
|
69
110
|
## Commands
|
|
70
111
|
|
|
71
112
|
| Command | Does |
|
|
@@ -104,8 +145,7 @@ end
|
|
|
104
145
|
```
|
|
105
146
|
|
|
106
147
|
```bash
|
|
107
|
-
rb-portless
|
|
108
|
-
rb-portless run bin/dev # → https://myapp.localhost
|
|
148
|
+
rb-portless run bin/dev # → https://myapp.localhost (CA auto-trusted on first run)
|
|
109
149
|
```
|
|
110
150
|
|
|
111
151
|
That's it. The railtie **auto-detects when you're running under `rb-portless`**
|
|
@@ -164,6 +204,29 @@ boundary). For ports < 1024 it re-execs under `sudo` so the elevated process can
|
|
|
164
204
|
bind the socket, then hands ownership of state files back to you. See
|
|
165
205
|
[`AGENTS.md`](AGENTS.md) for the full architecture.
|
|
166
206
|
|
|
207
|
+
## Compared to portless (Node)
|
|
208
|
+
|
|
209
|
+
The mental model is identical — `run` wraps your dev command, the proxy
|
|
210
|
+
auto-starts, named `.localhost` URLs replace ports. The only Ruby-world addition
|
|
211
|
+
is the one-line `require "portless/rails"` to satisfy Rails' host authorization.
|
|
212
|
+
|
|
213
|
+
| | **portless (Node)** | **rb-portless (Ruby/Rails)** |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| Install | `npm i -g portless` | `gem install rb-portless` (or Gemfile dev group) |
|
|
216
|
+
| Run a server | `portless run next dev` | `rb-portless run bin/rails server` |
|
|
217
|
+
| Run the dev orchestrator | `portless` (runs `"dev"` script) | `rb-portless run bin/dev` (wraps Foreman) |
|
|
218
|
+
| Bake into the project | `"dev": "portless run next dev"` → `npm run dev` | put `rb-portless run` in `bin/dev`, or use the binstub |
|
|
219
|
+
| Name the URL | `portless myapp …` / `portless.json` | `portless.json` `{ "name": "myapp" }` (else dir/git root) |
|
|
220
|
+
| Wildcard tenant subdomains | `tld` config | `portless.json` `{ "tld": "myapp.localhost" }` → `*.myapp.localhost` |
|
|
221
|
+
| Pin the backend port | `--app-port` / `appPort` | `appPort` in `portless.json` |
|
|
222
|
+
| Framework port injection | vite/astro/etc. auto | same (Rails/puma respect `PORT` natively) |
|
|
223
|
+
| HTTPS trust | auto on first run (+ `portless trust`) | auto on first run (+ `rb-portless trust`) |
|
|
224
|
+
| **Host allowlist** | not needed | **`gem "rb-portless", require: "portless/rails"`** (Rails-only) |
|
|
225
|
+
| Privileged 443 bind | sudo re-exec (auto) | sudo re-exec (auto), `:1355` fallback |
|
|
226
|
+
| Bind at boot (no sudo) | `portless service install` | `rb-portless service install` |
|
|
227
|
+
| Inspect / manage | `portless list / doctor / clean` | `rb-portless list / doctor / clean` |
|
|
228
|
+
| Static route (DB, etc.) | `portless alias pg 5432` | `rb-portless alias pg 5432` |
|
|
229
|
+
|
|
167
230
|
## Contributing
|
|
168
231
|
|
|
169
232
|
```bash
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portless
|
|
4
|
+
# The "where is my app" banner printed to stderr when a dev server starts
|
|
5
|
+
# through rb-portless — so you see the named URL, not just 127.0.0.1:port.
|
|
6
|
+
# Vite-ish layout; colours are stripped when stderr isn't a TTY. Mirrors the
|
|
7
|
+
# spirit of portless's run output.
|
|
8
|
+
module Banner
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# rows: ordered [label, value, color] for the reachable URLs (Local,
|
|
12
|
+
# Network, Public, …); backend is the real 127.0.0.1:port behind the proxy.
|
|
13
|
+
def app(rows:, backend_port:)
|
|
14
|
+
out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
|
|
15
|
+
rows.each { |label, value, paint| out << row(label, send(paint || :cyan, value)) }
|
|
16
|
+
out << row("Backend", dim("127.0.0.1:#{backend_port}"))
|
|
17
|
+
out << ""
|
|
18
|
+
out << " #{dim('ready — press Ctrl-C to stop')}"
|
|
19
|
+
out << ""
|
|
20
|
+
warn out.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Multi-app: one row per app (name → URL).
|
|
24
|
+
def multi(apps:)
|
|
25
|
+
out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
|
|
26
|
+
apps.each { |app| out << row(app.name, cyan(app.url)) }
|
|
27
|
+
out << ""
|
|
28
|
+
out << " #{dim('ready — press Ctrl-C to stop')}"
|
|
29
|
+
out << ""
|
|
30
|
+
warn out.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def row(label, value) = " #{green('➜')} #{label.to_s.ljust(8)}#{value}"
|
|
34
|
+
|
|
35
|
+
# ── colours (no-op unless stderr is a TTY) ──
|
|
36
|
+
def paint(code, str) = tty? ? "\e[#{code}m#{str}\e[0m" : str
|
|
37
|
+
def bold(str) = paint("1", str)
|
|
38
|
+
def dim(str) = paint("90", str)
|
|
39
|
+
def cyan(str) = paint("36", str)
|
|
40
|
+
def green(str) = paint("32", str)
|
|
41
|
+
def tty? = $stderr.tty?
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/portless/cli.rb
CHANGED
|
@@ -34,7 +34,32 @@ module Portless
|
|
|
34
34
|
|
|
35
35
|
# ── Commands ────────────────────────────────────────────────────────────
|
|
36
36
|
def cmd_run(args)
|
|
37
|
-
|
|
37
|
+
options, command = parse_run(args)
|
|
38
|
+
if command.empty? && Config.load.apps.any?
|
|
39
|
+
Multi.new.run # monorepo: portless.json `apps` map
|
|
40
|
+
else
|
|
41
|
+
Runner.new(command: command, options: options).run
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Consume leading rb-portless flags (--lan/--ip/--ngrok/--tailscale/--funnel),
|
|
46
|
+
# stopping at the first non-flag, an unknown flag, or `--`; the rest is the
|
|
47
|
+
# command to run.
|
|
48
|
+
def parse_run(args)
|
|
49
|
+
options = {}
|
|
50
|
+
rest = args.dup
|
|
51
|
+
while (flag = rest.first)&.start_with?("--")
|
|
52
|
+
case flag
|
|
53
|
+
when "--lan" then options[:lan] = true; rest.shift
|
|
54
|
+
when "--ip" then rest.shift; options[:ip] = rest.shift
|
|
55
|
+
when "--ngrok" then options[:ngrok] = true; rest.shift
|
|
56
|
+
when "--tailscale" then options[:tailscale] = true; rest.shift
|
|
57
|
+
when "--funnel" then options[:funnel] = true; rest.shift
|
|
58
|
+
when "--" then rest.shift; break
|
|
59
|
+
else break
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
[ options, rest ]
|
|
38
63
|
end
|
|
39
64
|
|
|
40
65
|
def cmd_proxy(args)
|
|
@@ -180,11 +205,6 @@ module Portless
|
|
|
180
205
|
i ? Integer(args[i + 1], exception: false) : nil
|
|
181
206
|
end
|
|
182
207
|
|
|
183
|
-
def strip_flags(args)
|
|
184
|
-
# everything after `run`, minus our own flags
|
|
185
|
-
args.reject { |a| a.start_with?("--portless") }
|
|
186
|
-
end
|
|
187
|
-
|
|
188
208
|
def todo(name, desc, _args = nil)
|
|
189
209
|
warn "rb-portless #{name}: #{desc} — not yet implemented (#{Portless::VERSION})"
|
|
190
210
|
exit 1
|
|
@@ -206,7 +226,7 @@ module Portless
|
|
|
206
226
|
|
|
207
227
|
Usage:
|
|
208
228
|
rb-portless run <command> run a dev server through the proxy
|
|
209
|
-
rb-portless
|
|
229
|
+
rb-portless run run the `apps` map, or the dev script
|
|
210
230
|
rb-portless proxy start|stop manage the proxy daemon
|
|
211
231
|
rb-portless trust trust the local CA (HTTPS)
|
|
212
232
|
rb-portless hosts sync|clean manage /etc/hosts (Safari fallback)
|
|
@@ -215,6 +235,11 @@ module Portless
|
|
|
215
235
|
rb-portless clean | prune tear down / reap orphans
|
|
216
236
|
rb-portless service install bind the privileged port at boot
|
|
217
237
|
|
|
238
|
+
run flags:
|
|
239
|
+
--lan [--ip <addr>] also serve on the LAN (<name>.local)
|
|
240
|
+
--ngrok share publicly via ngrok
|
|
241
|
+
--tailscale | --funnel share via your tailnet / Funnel
|
|
242
|
+
|
|
218
243
|
HTTPS is the default (https://<name>.localhost). Config: portless.json.
|
|
219
244
|
HELP
|
|
220
245
|
end
|
data/lib/portless/config.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Portless
|
|
|
9
9
|
class Config
|
|
10
10
|
DEFAULT_SCRIPT = "dev"
|
|
11
11
|
|
|
12
|
-
attr_reader :name, :tld, :script, :app_port, :tls
|
|
12
|
+
attr_reader :name, :tld, :script, :app_port, :tls, :apps
|
|
13
13
|
|
|
14
14
|
def self.load(dir = Dir.pwd)
|
|
15
15
|
new(read_file(dir), dir)
|
|
@@ -22,6 +22,8 @@ module Portless
|
|
|
22
22
|
@script = data["script"] || DEFAULT_SCRIPT
|
|
23
23
|
@app_port = data["appPort"] || data["app_port"]
|
|
24
24
|
@tls = data.fetch("tls", true)
|
|
25
|
+
# Monorepo: { "apps": { "web": "bin/rails server", "api": "node api.js" } }.
|
|
26
|
+
@apps = (data["apps"] || {}).transform_keys { |k| sanitize_label(k) }
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# The full hostname an app registers (e.g. "shirabe.org.localhost" when tld is
|
|
@@ -30,6 +32,17 @@ module Portless
|
|
|
30
32
|
tld.split(".").include?(name) ? tld : "#{name}.#{tld}"
|
|
31
33
|
end
|
|
32
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
|
+
|
|
33
46
|
def self.read_file(dir)
|
|
34
47
|
json = File.join(dir, "portless.json")
|
|
35
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
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Portless
|
|
6
|
+
# The machine's primary private LAN IPv4 — so phones/tablets on the same Wi-Fi
|
|
7
|
+
# can reach the dev app. Pure stdlib. Mirrors portless's lan-ip.ts.
|
|
8
|
+
module LanIp
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def detect(override = nil)
|
|
12
|
+
return override if override.to_s.strip != ""
|
|
13
|
+
|
|
14
|
+
Socket.ip_address_list
|
|
15
|
+
.find { |addr| addr.ipv4? && addr.ipv4_private? && !addr.ipv4_loopback? }
|
|
16
|
+
&.ip_address
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portless
|
|
4
|
+
# Publish `<name>.local → LAN IP` over mDNS so phones/tablets resolve it on the
|
|
5
|
+
# local network (`.localhost` only works on the dev machine). Shells out to the
|
|
6
|
+
# OS responder — `dns-sd` on macOS, `avahi-publish` on Linux — and returns the
|
|
7
|
+
# publisher pid (kill it to unpublish). A no-op (with a hint) if neither tool
|
|
8
|
+
# is present. Mirrors portless's mdns.ts.
|
|
9
|
+
module Mdns
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def publish(hostname, ip)
|
|
13
|
+
return nil unless ip
|
|
14
|
+
|
|
15
|
+
cmd = command_for(hostname, ip)
|
|
16
|
+
unless cmd
|
|
17
|
+
warn "rb-portless: no mDNS responder (dns-sd / avahi-publish) — `#{hostname}` won't resolve on the LAN"
|
|
18
|
+
return nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
|
|
22
|
+
Process.detach(pid)
|
|
23
|
+
pid
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unpublish(pid)
|
|
29
|
+
Process.kill("TERM", pid) if pid
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def command_for(hostname, ip)
|
|
35
|
+
if Portless.which("dns-sd")
|
|
36
|
+
# Proxy-register an A record: name type domain port host ip.
|
|
37
|
+
[ "dns-sd", "-P", hostname.sub(/\.local\z/, ""), "_http._tcp", "local", "80", hostname, ip ]
|
|
38
|
+
elsif Portless.which("avahi-publish")
|
|
39
|
+
[ "avahi-publish", "-a", "-R", hostname, ip ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portless
|
|
4
|
+
# Run several apps under one proxy, each at its own `<name>.<tld>`, from the
|
|
5
|
+
# `apps` map in portless.json. Every app gets a free backend port, a route, and
|
|
6
|
+
# injected PORT/HOST/PORTLESS_URL; all run in their own process groups and are
|
|
7
|
+
# supervised + torn down together. Ruby sets env per-spawn, so there's no
|
|
8
|
+
# NODE_OPTIONS loader hack (cf. portless's turbo.ts).
|
|
9
|
+
class Multi
|
|
10
|
+
App = Struct.new(:name, :hostname, :port, :url, :pid, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(config: Config.load, route_store: RouteStore.new)
|
|
13
|
+
@config = config
|
|
14
|
+
@route_store = route_store
|
|
15
|
+
@apps = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
raise Error, "no apps defined — add an \"apps\" map to portless.json" if @config.apps.empty?
|
|
20
|
+
|
|
21
|
+
ensure_trusted if @config.tls
|
|
22
|
+
proxy_port = Daemon.ensure_running(tls: @config.tls)
|
|
23
|
+
@apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
|
|
24
|
+
|
|
25
|
+
Banner.multi(apps: @apps)
|
|
26
|
+
install_signal_handlers
|
|
27
|
+
Process.waitall
|
|
28
|
+
ensure
|
|
29
|
+
teardown
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def start_app(name, command, proxy_port)
|
|
35
|
+
port = FreePort.find
|
|
36
|
+
hostname = "#{name}.#{@config.tld}"
|
|
37
|
+
url = display_url(hostname, proxy_port)
|
|
38
|
+
@route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
|
|
39
|
+
# A bare command string runs through the shell (handles "bin/rails server").
|
|
40
|
+
pid = Process.spawn(child_env(port, url), command, pgroup: true)
|
|
41
|
+
App.new(name: name, hostname: hostname, port: port, url: url, pid: pid)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def install_signal_handlers
|
|
45
|
+
%w[INT TERM].each { |sig| trap(sig) { kill_all(sig) } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def kill_all(sig)
|
|
49
|
+
@apps.each do |app|
|
|
50
|
+
Process.kill(sig, -Process.getpgid(app.pid))
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def teardown
|
|
57
|
+
@apps.each { |app| @route_store.remove(app.hostname, owner_pid: Process.pid) }
|
|
58
|
+
kill_all("TERM")
|
|
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
|
|
82
|
+
end
|
|
83
|
+
end
|
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
|
@@ -6,10 +6,11 @@ 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
|
-
def initialize(command:, config: Config.load, route_store: RouteStore.new)
|
|
9
|
+
def initialize(command:, config: Config.load, route_store: RouteStore.new, options: {})
|
|
10
10
|
@command = Array(command)
|
|
11
11
|
@config = config
|
|
12
12
|
@route_store = route_store
|
|
13
|
+
@options = options # :lan, :ip, :ngrok, :tailscale, :funnel
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
@@ -19,20 +20,88 @@ module Portless
|
|
|
19
20
|
port = @config.app_port&.to_i || FreePort.find
|
|
20
21
|
command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
|
|
21
22
|
hostname = @config.hostname
|
|
22
|
-
url = "#{@config.tls ? 'https' : 'http'}://#{hostname}"
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
|
|
25
|
+
ensure_trusted if @config.tls
|
|
26
|
+
proxy_port = Daemon.ensure_running(tls: @config.tls)
|
|
25
27
|
@route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
url = display_url(hostname, proxy_port)
|
|
30
|
+
rows = [ [ "Local", url, :cyan ] ]
|
|
31
|
+
rows.concat(lan_rows(port, proxy_port))
|
|
32
|
+
rows.concat(share_rows(hostname, port))
|
|
33
|
+
Banner.app(rows: rows, backend_port: port)
|
|
34
|
+
|
|
28
35
|
status = supervise(command, port, url)
|
|
29
36
|
exit(status)
|
|
30
37
|
ensure
|
|
38
|
+
teardown
|
|
31
39
|
@route_store.remove(hostname, owner_pid: Process.pid) if hostname
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
private
|
|
35
43
|
|
|
44
|
+
def display_url(hostname, proxy_port)
|
|
45
|
+
scheme = @config.tls ? "https" : "http"
|
|
46
|
+
default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
|
|
47
|
+
suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
|
|
48
|
+
"#{scheme}://#{hostname}#{suffix}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── LAN mode (--lan) ──────────────────────────────────────────────────
|
|
52
|
+
# Register a `<name>.local` route, publish it over mDNS, and surface the URL
|
|
53
|
+
# so phones/tablets on the Wi-Fi can reach the app.
|
|
54
|
+
def lan_rows(backend_port, proxy_port)
|
|
55
|
+
return [] unless @options[:lan]
|
|
56
|
+
|
|
57
|
+
ip = LanIp.detect(@options[:ip])
|
|
58
|
+
return [ [ "Network", "no LAN IPv4 found", :dim ] ] unless ip
|
|
59
|
+
|
|
60
|
+
@lan_host = "#{@config.name}.local"
|
|
61
|
+
@route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid, force: true)
|
|
62
|
+
@mdns_pid = Mdns.publish(@lan_host, ip)
|
|
63
|
+
warn "rb-portless: trust #{State.ca_cert} on the device for HTTPS over the LAN" if @config.tls
|
|
64
|
+
[ [ "Network", display_url(@lan_host, proxy_port), :green ] ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ── Public sharing (--ngrok / --tailscale / --funnel) ─────────────────
|
|
68
|
+
def share_rows(hostname, backend_port)
|
|
69
|
+
rows = []
|
|
70
|
+
if @options[:ngrok] && (@ngrok = Share::Ngrok.start(hostname: hostname, backend_port: backend_port))
|
|
71
|
+
rows << [ "Public", @ngrok[:url], :green ]
|
|
72
|
+
end
|
|
73
|
+
if (@options[:tailscale] || @options[:funnel]) &&
|
|
74
|
+
(@tailscale = Share::Tailscale.start(backend_port: backend_port, funnel: @options[:funnel]))
|
|
75
|
+
rows << [ @options[:funnel] ? "Funnel" : "Tailnet", @tailscale[:url], :green ]
|
|
76
|
+
end
|
|
77
|
+
rows
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def teardown
|
|
81
|
+
Mdns.unpublish(@mdns_pid)
|
|
82
|
+
@route_store.remove(@lan_host, owner_pid: Process.pid) if @lan_host
|
|
83
|
+
Share::Ngrok.stop(@ngrok) if @ngrok
|
|
84
|
+
Share::Tailscale.stop(@tailscale) if @tailscale
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Trust the local CA on first run (like portless), so HTTPS works without
|
|
88
|
+
# browser warnings out of the box. Interactive only — macOS prompts for
|
|
89
|
+
# keychain auth; in CI/no-TTY we skip with a hint rather than hang. Never
|
|
90
|
+
# blocks the run if trusting fails.
|
|
91
|
+
def ensure_trusted
|
|
92
|
+
return if Trust.trusted?
|
|
93
|
+
|
|
94
|
+
unless Privilege.interactive?
|
|
95
|
+
warn "rb-portless: CA not trusted — run `rb-portless trust` (HTTPS shows warnings until then)"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
warn "rb-portless: trusting the local CA (first run)…"
|
|
100
|
+
Trust.install!
|
|
101
|
+
rescue Portless::Error => e
|
|
102
|
+
warn "rb-portless: couldn't auto-trust the CA (#{e.message}) — run `rb-portless trust`"
|
|
103
|
+
end
|
|
104
|
+
|
|
36
105
|
# Run the child in its own process group so we can signal the whole tree,
|
|
37
106
|
# forwarding INT/TERM and propagating its exit status.
|
|
38
107
|
def supervise(command, port, url)
|
|
@@ -73,9 +142,5 @@ module Portless
|
|
|
73
142
|
|
|
74
143
|
[]
|
|
75
144
|
end
|
|
76
|
-
|
|
77
|
-
def announce(url, port)
|
|
78
|
-
warn "rb-portless → #{url} (backend :#{port})"
|
|
79
|
-
end
|
|
80
145
|
end
|
|
81
146
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Portless
|
|
8
|
+
module Share
|
|
9
|
+
# Expose the local app publicly via ngrok. We point ngrok at the *backend*
|
|
10
|
+
# port directly (with the app's Host header) rather than tunnelling through
|
|
11
|
+
# our self-signed TLS proxy — simpler and avoids cert-trust issues. Returns
|
|
12
|
+
# { pid:, url: } or nil. EXPERIMENTAL. Mirrors portless's ngrok.ts.
|
|
13
|
+
module Ngrok
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
API = "http://127.0.0.1:4040/api/tunnels"
|
|
17
|
+
|
|
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
|
|
23
|
+
|
|
24
|
+
pid = Process.spawn("ngrok", "http", backend_port.to_s, "--host-header=#{hostname}",
|
|
25
|
+
out: File::NULL, err: File::NULL)
|
|
26
|
+
Process.detach(pid)
|
|
27
|
+
|
|
28
|
+
if (url = poll_public_url)
|
|
29
|
+
{ pid: pid, url: url }
|
|
30
|
+
else
|
|
31
|
+
stop(pid: pid)
|
|
32
|
+
warn "rb-portless: ngrok didn't produce a public URL — is your authtoken set? (`ngrok config add-authtoken <token>`)"
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
warn "rb-portless: ngrok failed (#{e.message})"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop(handle)
|
|
41
|
+
pid = handle.is_a?(Hash) ? handle[:pid] : handle
|
|
42
|
+
Process.kill("TERM", pid) if pid
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def poll_public_url(tries: 25)
|
|
48
|
+
tries.times do
|
|
49
|
+
sleep 0.3
|
|
50
|
+
body = fetch(API)
|
|
51
|
+
next unless body
|
|
52
|
+
|
|
53
|
+
tunnels = JSON.parse(body)["tunnels"] || []
|
|
54
|
+
url = tunnels.map { |t| t["public_url"] }.compact.find { |u| u.start_with?("https") }
|
|
55
|
+
return url if url
|
|
56
|
+
end
|
|
57
|
+
nil
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fetch(url)
|
|
63
|
+
Net::HTTP.get(URI(url))
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Portless
|
|
6
|
+
module Share
|
|
7
|
+
# Expose the local app on your tailnet (`serve`) or publicly (`funnel`) via
|
|
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.
|
|
14
|
+
module Tailscale
|
|
15
|
+
module_function
|
|
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
|
+
|
|
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
|
|
39
|
+
|
|
40
|
+
mode = funnel ? "funnel" : "serve"
|
|
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
|
|
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)
|
|
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})"
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop(handle)
|
|
63
|
+
return unless handle && Portless.which("tailscale")
|
|
64
|
+
|
|
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)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
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
|
|
101
|
+
json = JSON.parse(`tailscale status --json 2>/dev/null`)
|
|
102
|
+
json.is_a?(Hash) ? json : nil
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
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
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/portless/version.rb
CHANGED
data/lib/rb-portless.rb
CHANGED
|
@@ -17,7 +17,13 @@ require_relative "portless/proxy"
|
|
|
17
17
|
require_relative "portless/daemon"
|
|
18
18
|
require_relative "portless/service"
|
|
19
19
|
require_relative "portless/frameworks"
|
|
20
|
+
require_relative "portless/banner"
|
|
21
|
+
require_relative "portless/lan_ip"
|
|
22
|
+
require_relative "portless/mdns"
|
|
23
|
+
require_relative "portless/share/ngrok"
|
|
24
|
+
require_relative "portless/share/tailscale"
|
|
20
25
|
require_relative "portless/runner"
|
|
26
|
+
require_relative "portless/multi"
|
|
21
27
|
require_relative "portless/cli"
|
|
22
28
|
|
|
23
29
|
module Portless
|
|
@@ -25,4 +31,9 @@ module Portless
|
|
|
25
31
|
|
|
26
32
|
# Raised when a privileged action can't run non-interactively (no TTY / CI).
|
|
27
33
|
class NonInteractiveError < Error; end
|
|
34
|
+
|
|
35
|
+
# Is an executable on PATH? (For optional external tools: dns-sd, ngrok, …)
|
|
36
|
+
def self.which(bin)
|
|
37
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
|
|
38
|
+
end
|
|
28
39
|
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.
|
|
4
|
+
version: 0.3.0.dev.20260630.03ed07d
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Afonso
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- LICENSE
|
|
57
57
|
- README.md
|
|
58
58
|
- exe/rb-portless
|
|
59
|
+
- lib/portless/banner.rb
|
|
59
60
|
- lib/portless/certs.rb
|
|
60
61
|
- lib/portless/cli.rb
|
|
61
62
|
- lib/portless/config.rb
|
|
@@ -65,6 +66,9 @@ files:
|
|
|
65
66
|
- lib/portless/free_port.rb
|
|
66
67
|
- lib/portless/health.rb
|
|
67
68
|
- lib/portless/hosts.rb
|
|
69
|
+
- lib/portless/lan_ip.rb
|
|
70
|
+
- lib/portless/mdns.rb
|
|
71
|
+
- lib/portless/multi.rb
|
|
68
72
|
- lib/portless/privilege.rb
|
|
69
73
|
- lib/portless/proxy.rb
|
|
70
74
|
- lib/portless/rails.rb
|
|
@@ -72,6 +76,8 @@ files:
|
|
|
72
76
|
- lib/portless/route_store.rb
|
|
73
77
|
- lib/portless/runner.rb
|
|
74
78
|
- lib/portless/service.rb
|
|
79
|
+
- lib/portless/share/ngrok.rb
|
|
80
|
+
- lib/portless/share/tailscale.rb
|
|
75
81
|
- lib/portless/state.rb
|
|
76
82
|
- lib/portless/trust.rb
|
|
77
83
|
- lib/portless/version.rb
|