@podway/relay 0.2.3
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.
- package/FAQ.md +87 -0
- package/LICENSE +17 -0
- package/README.md +84 -0
- package/dist/audit.d.ts +109 -0
- package/dist/audit.js +339 -0
- package/dist/browser-fetcher.d.ts +58 -0
- package/dist/browser-fetcher.js +132 -0
- package/dist/colors.d.ts +14 -0
- package/dist/colors.js +25 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +99 -0
- package/dist/dashboard.d.ts +67 -0
- package/dist/dashboard.js +394 -0
- package/dist/disclosure.d.ts +8 -0
- package/dist/disclosure.js +22 -0
- package/dist/domain.d.ts +9 -0
- package/dist/domain.js +20 -0
- package/dist/heartbeat.d.ts +48 -0
- package/dist/heartbeat.js +77 -0
- package/dist/keep-awake.d.ts +14 -0
- package/dist/keep-awake.js +38 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +480 -0
- package/dist/net-guard.d.ts +9 -0
- package/dist/net-guard.js +47 -0
- package/dist/relay-client.d.ts +66 -0
- package/dist/relay-client.js +154 -0
- package/dist/relay-tunnel.d.ts +62 -0
- package/dist/relay-tunnel.js +138 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +51 -0
- package/package.json +42 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect a DEAD gateway link and force it closed so the relay's reconnect fires — WITHOUT
|
|
3
|
+
* false-positiving on a jittery/throttled Mac.
|
|
4
|
+
*
|
|
5
|
+
* The relay reconnects with backoff on `ws.on("close")` — but a slept laptop or a network change
|
|
6
|
+
* leaves the WebSocket HALF-OPEN: no FIN/RST, `close` never fires, the socket still reads OPEN, and
|
|
7
|
+
* the relay sits on a zombie reporting "connected" while egress silently fails. So we ping and, if
|
|
8
|
+
* the link stops answering, `terminate()` → `close` → reconnect.
|
|
9
|
+
*
|
|
10
|
+
* The SUBTLETY (root cause of the flapping reported 2026-08-18): on macOS a background Node process
|
|
11
|
+
* gets its timers THROTTLED/COALESCED (App Nap, Wi-Fi power-save, Timer Coalescing) and PAUSED during
|
|
12
|
+
* sleep. A naive "no pong in 5s → terminate" then kills a perfectly healthy link every time the OS
|
|
13
|
+
* throttles us or we wake from a nap — a self-inflicted flap. This heartbeat is hardened against that:
|
|
14
|
+
*
|
|
15
|
+
* 1. WALL-CLOCK suspension detection — if a tick lands far later than scheduled, the OS froze us
|
|
16
|
+
* (nap/sleep), NOT the link. We can't judge liveness right after a freeze, so we RESET and
|
|
17
|
+
* re-verify with a fresh ping instead of terminating.
|
|
18
|
+
* 2. N CONSECUTIVE misses (default 2) before declaring death — a single late pong (normal jitter)
|
|
19
|
+
* never kills the link; it takes a sustained silence.
|
|
20
|
+
* 3. A TOLERANT interval (default 15s) — residential RTT is sub-second, so 15s×2 ≈ 30s worst-case
|
|
21
|
+
* detection is plenty fast while leaving generous headroom for jitter.
|
|
22
|
+
*
|
|
23
|
+
* Any inbound frame (not just a pong) counts as proof-of-life.
|
|
24
|
+
*/
|
|
25
|
+
export interface HeartbeatSocket {
|
|
26
|
+
on(event: "pong" | "message" | "close", listener: (...args: unknown[]) => void): unknown;
|
|
27
|
+
off?(event: "pong" | "message" | "close", listener: (...args: unknown[]) => void): unknown;
|
|
28
|
+
ping(): void;
|
|
29
|
+
terminate(): void;
|
|
30
|
+
}
|
|
31
|
+
export interface HeartbeatOptions {
|
|
32
|
+
/** How often to ping. Default 15s (tolerant — a residential link answers in well under a second). */
|
|
33
|
+
pingIntervalMs?: number;
|
|
34
|
+
/** Consecutive unanswered pings before the link is declared dead. Default 2 (one blip never kills). */
|
|
35
|
+
maxMissedPings?: number;
|
|
36
|
+
/** A tick landing later than `pingIntervalMs × this` means the PROCESS was frozen (App Nap/sleep),
|
|
37
|
+
* not the link — reset + re-verify rather than false-terminate. Default 2.5. */
|
|
38
|
+
suspendFactor?: number;
|
|
39
|
+
/** Called on every proof-of-life (pong or any inbound frame). */
|
|
40
|
+
onAlive?: () => void;
|
|
41
|
+
/** Called once when the link is declared dead, just before `terminate()`. */
|
|
42
|
+
onDead?: () => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Attach a liveness heartbeat to an OPEN socket. Returns a cleanup function; it also self-clears on
|
|
46
|
+
* `close`, so callers can usually just call it and forget it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function attachHeartbeat(ws: HeartbeatSocket, opts?: HeartbeatOptions): () => void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach a liveness heartbeat to an OPEN socket. Returns a cleanup function; it also self-clears on
|
|
3
|
+
* `close`, so callers can usually just call it and forget it.
|
|
4
|
+
*/
|
|
5
|
+
export function attachHeartbeat(ws, opts = {}) {
|
|
6
|
+
const pingIntervalMs = opts.pingIntervalMs ?? 15_000;
|
|
7
|
+
const maxMissedPings = Math.max(1, opts.maxMissedPings ?? 2);
|
|
8
|
+
const suspendFactor = opts.suspendFactor ?? 2.5;
|
|
9
|
+
const now = () => Date.now();
|
|
10
|
+
const unref = (t) => {
|
|
11
|
+
t.unref?.();
|
|
12
|
+
};
|
|
13
|
+
let missed = 0;
|
|
14
|
+
// Did a pong / any frame arrive since the last ping? Starts true so the first tick just pings.
|
|
15
|
+
let answered = true;
|
|
16
|
+
let lastTick = now();
|
|
17
|
+
const markAlive = () => {
|
|
18
|
+
missed = 0;
|
|
19
|
+
answered = true;
|
|
20
|
+
opts.onAlive?.();
|
|
21
|
+
};
|
|
22
|
+
ws.on("pong", markAlive);
|
|
23
|
+
ws.on("message", markAlive);
|
|
24
|
+
const timer = setInterval(() => {
|
|
25
|
+
const t = now();
|
|
26
|
+
const sinceLast = t - lastTick;
|
|
27
|
+
lastTick = t;
|
|
28
|
+
// (1) The OS froze us (App Nap / sleep / heavy throttle): this tick is way overdue. A frozen
|
|
29
|
+
// process cannot tell a live link from a dead one — so DON'T terminate on the stale window.
|
|
30
|
+
// Reset and re-verify with a fresh ping; the next few ticks decide honestly.
|
|
31
|
+
if (sinceLast > pingIntervalMs * suspendFactor) {
|
|
32
|
+
missed = 0;
|
|
33
|
+
answered = false;
|
|
34
|
+
try {
|
|
35
|
+
ws.ping();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* socket closing between ticks */
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// (2) The previous ping went a full, on-time interval with no answer → a real miss.
|
|
43
|
+
if (!answered) {
|
|
44
|
+
missed += 1;
|
|
45
|
+
if (missed >= maxMissedPings) {
|
|
46
|
+
try {
|
|
47
|
+
opts.onDead?.();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* a logging callback must never block the terminate */
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
ws.terminate();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* already gone */
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
answered = false;
|
|
62
|
+
try {
|
|
63
|
+
ws.ping();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* socket closing between ticks */
|
|
67
|
+
}
|
|
68
|
+
}, pingIntervalMs);
|
|
69
|
+
unref(timer);
|
|
70
|
+
const clear = () => {
|
|
71
|
+
clearInterval(timer);
|
|
72
|
+
ws.off?.("pong", markAlive);
|
|
73
|
+
ws.off?.("message", markAlive);
|
|
74
|
+
};
|
|
75
|
+
ws.on("close", clear);
|
|
76
|
+
return clear;
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the machine from idle-sleeping the relay while it runs — OPT-IN (`relay start --keep-awake`),
|
|
3
|
+
* for a Mac meant to stay online 24/7. The hardened heartbeat already survives normal App-Nap timer
|
|
4
|
+
* throttling; this is the stronger "never let the Mac idle-sleep out from under the relay" lever a
|
|
5
|
+
* dedicated host wants.
|
|
6
|
+
*
|
|
7
|
+
* macOS: `caffeinate -is -w <our pid>` asserts prevent-idle-sleep (+ prevent-system-sleep on AC) and
|
|
8
|
+
* auto-RELEASES when this relay process exits (via `-w`), so a crash never leaves the Mac stuck awake;
|
|
9
|
+
* we also kill it on a clean stop. Other platforms: a best-effort note — idle-sleep there is the
|
|
10
|
+
* user's power settings / service manager to own, not ours to hijack.
|
|
11
|
+
*
|
|
12
|
+
* Returns a `release()` to drop the assertion early.
|
|
13
|
+
*/
|
|
14
|
+
export declare function keepAwake(log: (m: string) => void): () => void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Keep the machine from idle-sleeping the relay while it runs — OPT-IN (`relay start --keep-awake`),
|
|
4
|
+
* for a Mac meant to stay online 24/7. The hardened heartbeat already survives normal App-Nap timer
|
|
5
|
+
* throttling; this is the stronger "never let the Mac idle-sleep out from under the relay" lever a
|
|
6
|
+
* dedicated host wants.
|
|
7
|
+
*
|
|
8
|
+
* macOS: `caffeinate -is -w <our pid>` asserts prevent-idle-sleep (+ prevent-system-sleep on AC) and
|
|
9
|
+
* auto-RELEASES when this relay process exits (via `-w`), so a crash never leaves the Mac stuck awake;
|
|
10
|
+
* we also kill it on a clean stop. Other platforms: a best-effort note — idle-sleep there is the
|
|
11
|
+
* user's power settings / service manager to own, not ours to hijack.
|
|
12
|
+
*
|
|
13
|
+
* Returns a `release()` to drop the assertion early.
|
|
14
|
+
*/
|
|
15
|
+
export function keepAwake(log) {
|
|
16
|
+
if (process.platform !== "darwin") {
|
|
17
|
+
log("keep-awake: macOS only — on Linux/Windows use your power settings or a service manager");
|
|
18
|
+
return () => { };
|
|
19
|
+
}
|
|
20
|
+
let child;
|
|
21
|
+
try {
|
|
22
|
+
child = spawn("caffeinate", ["-is", "-w", String(process.pid)], { stdio: "ignore" });
|
|
23
|
+
child.on("error", () => log("keep-awake: `caffeinate` unavailable — skipped"));
|
|
24
|
+
child.unref();
|
|
25
|
+
log("keep-awake: ON — this Mac won't idle-sleep while the relay is running");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
log("keep-awake: could not start `caffeinate` — skipped");
|
|
29
|
+
}
|
|
30
|
+
return () => {
|
|
31
|
+
try {
|
|
32
|
+
child?.kill();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* already gone */
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { load, save, addLoginDomain, resetProfile, profileDir, pidFile, dashboardRuntimeFile, isDomainBlocked, isPodPaused, revokeLoginDomain, } from "./config.js";
|
|
7
|
+
import { RelayClient } from "./relay-client.js";
|
|
8
|
+
import { RelayTunnel } from "./relay-tunnel.js";
|
|
9
|
+
import { keepAwake } from "./keep-awake.js";
|
|
10
|
+
import { BrowserFetcher } from "./browser-fetcher.js";
|
|
11
|
+
import { RelayEventStore } from "./audit.js";
|
|
12
|
+
import { serveDashboard } from "./dashboard.js";
|
|
13
|
+
import { RelayRuntime } from "./runtime.js";
|
|
14
|
+
import { attachHeartbeat } from "./heartbeat.js";
|
|
15
|
+
import { DISCLOSURE } from "./disclosure.js";
|
|
16
|
+
import { c, rows } from "./colors.js";
|
|
17
|
+
import { normalizeDomain } from "./domain.js";
|
|
18
|
+
/**
|
|
19
|
+
* `relay` — one command starts a background relay; login/dashboard/stop modify it
|
|
20
|
+
* through disk state (config, audit log, pidfile). No per-request approval: once
|
|
21
|
+
* running, the pod fetches the public web freely; the owner watches via the dashboard.
|
|
22
|
+
*/
|
|
23
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
24
|
+
process.exit(0); });
|
|
25
|
+
const log = (s = "") => { try {
|
|
26
|
+
process.stdout.write(s + "\n");
|
|
27
|
+
}
|
|
28
|
+
catch { /* EPIPE */ } };
|
|
29
|
+
const die = (s) => { process.stderr.write(`relay: ${s}\n`); process.exit(1); };
|
|
30
|
+
const arg = (a, f) => { const i = a.indexOf(f); return i >= 0 ? a[i + 1] : undefined; };
|
|
31
|
+
function isRunning() {
|
|
32
|
+
try {
|
|
33
|
+
const pid = Number(readFileSync(pidFile(), "utf8").trim());
|
|
34
|
+
process.kill(pid, 0); // throws if not alive
|
|
35
|
+
return pid;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function dashboardUrl() {
|
|
42
|
+
try {
|
|
43
|
+
const record = JSON.parse(readFileSync(dashboardRuntimeFile(), "utf8"));
|
|
44
|
+
process.kill(record.pid, 0);
|
|
45
|
+
const url = new URL(record.url);
|
|
46
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !/^\/[a-f0-9]{64}$/.test(url.pathname))
|
|
47
|
+
return null;
|
|
48
|
+
return url.toString().replace(/\/$/, "");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function openBrowser(url) {
|
|
55
|
+
const opener = process.platform === "darwin"
|
|
56
|
+
? { command: "open", args: [url] }
|
|
57
|
+
: process.platform === "win32"
|
|
58
|
+
? { command: "cmd", args: ["/c", "start", "", url] }
|
|
59
|
+
: { command: "xdg-open", args: [url] };
|
|
60
|
+
const child = spawn(opener.command, opener.args, { stdio: "ignore", detached: true });
|
|
61
|
+
child.on("error", () => log(`open this URL in a browser: ${url}`));
|
|
62
|
+
child.unref();
|
|
63
|
+
}
|
|
64
|
+
async function waitForDashboardUrl(timeoutMs = 2000) {
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
const url = dashboardUrl();
|
|
68
|
+
if (url)
|
|
69
|
+
return url;
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
async function cmdStart(a) {
|
|
75
|
+
if (a.includes("__daemon"))
|
|
76
|
+
return runDaemon(a);
|
|
77
|
+
if (isRunning())
|
|
78
|
+
die("a relay is already running (relay stop to stop it).");
|
|
79
|
+
const cfg = load();
|
|
80
|
+
const gateway = arg(a, "--gateway") ?? cfg.gatewayUrl ?? die("need --gateway wss://…");
|
|
81
|
+
// A code is only needed for FIRST pairing; a stored reconnect token lets a plain
|
|
82
|
+
// `relay start` bring the relay back up (e.g. after a reboot) with no new code.
|
|
83
|
+
const code = arg(a, "--code") ?? "";
|
|
84
|
+
if (!code && !cfg.reconnectToken)
|
|
85
|
+
die("need --code (from your pod dashboard) to pair the first time");
|
|
86
|
+
if (!cfg.consentedAt) {
|
|
87
|
+
log(DISCLOSURE);
|
|
88
|
+
if (!a.includes("--accept"))
|
|
89
|
+
die("\nre-run with --accept to agree and start the relay.");
|
|
90
|
+
cfg.consentedAt = new Date().toISOString();
|
|
91
|
+
}
|
|
92
|
+
cfg.gatewayUrl = gateway;
|
|
93
|
+
// Opt-in keep-awake (persisted, so it survives `relay stop`/restart). `--no-keep-awake` turns it off.
|
|
94
|
+
if (a.includes("--keep-awake"))
|
|
95
|
+
cfg.keepAwake = true;
|
|
96
|
+
else if (a.includes("--no-keep-awake"))
|
|
97
|
+
cfg.keepAwake = false;
|
|
98
|
+
save(cfg);
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(dashboardRuntimeFile());
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
// Spawn ourselves detached: one command, then it runs in the background.
|
|
104
|
+
mkdirSync(profileDir().replace(/\/profile$/, ""), { recursive: true });
|
|
105
|
+
const self = fileURLToPath(import.meta.url);
|
|
106
|
+
const daemonArgs = [self, "start", "__daemon", "--gateway", gateway, "--code", code];
|
|
107
|
+
if (cfg.keepAwake)
|
|
108
|
+
daemonArgs.push("--keep-awake");
|
|
109
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
110
|
+
detached: true,
|
|
111
|
+
stdio: "ignore",
|
|
112
|
+
});
|
|
113
|
+
child.unref();
|
|
114
|
+
writeFileSync(pidFile(), String(child.pid), { mode: 0o600 });
|
|
115
|
+
const commandCenter = await waitForDashboardUrl();
|
|
116
|
+
log("");
|
|
117
|
+
log(` ${c.green(c.bold("✓ relay running"))} ${c.dim(`(pid ${child.pid}, in the background)`)}`);
|
|
118
|
+
log("");
|
|
119
|
+
log(rows([
|
|
120
|
+
["relay dashboard", "see what it has fetched"],
|
|
121
|
+
["relay login <site>", "let a site be fetched as you"],
|
|
122
|
+
["relay stop", "stop it"],
|
|
123
|
+
]));
|
|
124
|
+
if (commandCenter)
|
|
125
|
+
log(`\n ${c.dim("command center")} ${c.cyan(commandCenter)}`);
|
|
126
|
+
log("");
|
|
127
|
+
}
|
|
128
|
+
async function runDaemon(a) {
|
|
129
|
+
const gateway = arg(a, "--gateway");
|
|
130
|
+
const code = arg(a, "--code") ?? "";
|
|
131
|
+
const browser = new BrowserFetcher({
|
|
132
|
+
profileDir: profileDir(),
|
|
133
|
+
// Re-read config each fetch: `relay login` writes there and we pick it up live.
|
|
134
|
+
isSessionDomain: (host) => load().loginDomains.some((d) => host === d || host.endsWith(`.${d}`)),
|
|
135
|
+
});
|
|
136
|
+
const cfgAtStart = load();
|
|
137
|
+
const store = new RelayEventStore({ retentionDays: cfgAtStart.retentionDays ?? 30 }).init();
|
|
138
|
+
const runtime = new RelayRuntime({ version: "0.1.7" });
|
|
139
|
+
// Opt-in: keep the Mac from idle-sleeping the relay (a 24/7 host). Released on stop AND — via
|
|
140
|
+
// caffeinate's `-w <pid>` — automatically if we crash, so it never gets stuck holding the Mac awake.
|
|
141
|
+
const releaseKeepAwake = a.includes("--keep-awake") || cfgAtStart.keepAwake ? keepAwake(log) : () => { };
|
|
142
|
+
let stopping = false;
|
|
143
|
+
let dashboardClose;
|
|
144
|
+
const stop = async () => {
|
|
145
|
+
if (stopping)
|
|
146
|
+
return;
|
|
147
|
+
stopping = true;
|
|
148
|
+
releaseKeepAwake();
|
|
149
|
+
runtime.clearActive();
|
|
150
|
+
dashboardClose?.();
|
|
151
|
+
await browser.close();
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(pidFile());
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(dashboardRuntimeFile());
|
|
158
|
+
}
|
|
159
|
+
catch { }
|
|
160
|
+
process.exit(0);
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const dashboard = await serveDashboard(7373, {
|
|
164
|
+
store,
|
|
165
|
+
runtime,
|
|
166
|
+
config: load,
|
|
167
|
+
actions: {
|
|
168
|
+
stop: () => { setTimeout(() => void stop(), 25); },
|
|
169
|
+
revokeSession: async (domain) => {
|
|
170
|
+
revokeLoginDomain(domain);
|
|
171
|
+
await browser.revokeDomain(domain).catch(() => undefined);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
dashboardClose = dashboard.close;
|
|
176
|
+
mkdirSync(profileDir().replace(/\/profile$/, ""), { recursive: true, mode: 0o700 });
|
|
177
|
+
writeFileSync(dashboardRuntimeFile(), JSON.stringify({ pid: process.pid, url: dashboard.url, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Oversight UI is best-effort: it must never prevent the relay transport starting.
|
|
181
|
+
}
|
|
182
|
+
let backoff = 1000;
|
|
183
|
+
// Prefer a stored reconnect token (survives restarts/blips) over the one-time code,
|
|
184
|
+
// which is spent the instant the first pairing succeeds.
|
|
185
|
+
let token = load().reconnectToken;
|
|
186
|
+
const connect = () => {
|
|
187
|
+
const base = gateway.replace(/\/$/, "");
|
|
188
|
+
// A token reconnects; the code is only for the very first pairing.
|
|
189
|
+
const auth = token ? `token=${encodeURIComponent(token)}` : `code=${encodeURIComponent(code)}`;
|
|
190
|
+
const ws = new WebSocket(`${base}/relay?${auth}`);
|
|
191
|
+
const sink = { send: (j) => { try {
|
|
192
|
+
ws.send(j);
|
|
193
|
+
}
|
|
194
|
+
catch { /* closing */ } } };
|
|
195
|
+
const deny = (source, host) => {
|
|
196
|
+
const cfg = load();
|
|
197
|
+
if (source && "podId" in source && isPodPaused(cfg, source.podId))
|
|
198
|
+
return `pod ${source.podId} is paused by the relay owner`;
|
|
199
|
+
if (isDomainBlocked(cfg, host))
|
|
200
|
+
return `${host} is blocked by the relay owner`;
|
|
201
|
+
return undefined;
|
|
202
|
+
};
|
|
203
|
+
const client = new RelayClient(sink, browser.fetch, { audit: (event) => store.append(event), runtime, deny });
|
|
204
|
+
// The SAME relay also serves the egress tunnel: the pod's apps connect through here
|
|
205
|
+
// and egress from this machine. One `relay start`, both consumers.
|
|
206
|
+
const tunnel = new RelayTunnel(sink, {
|
|
207
|
+
// Tunnel connections land in the SAME local audit as fetches, so `relay
|
|
208
|
+
// dashboard` shows one history: host, whether it was allowed, and why not.
|
|
209
|
+
audit: (event) => { store.append(event); },
|
|
210
|
+
runtime,
|
|
211
|
+
deny,
|
|
212
|
+
});
|
|
213
|
+
ws.on("open", () => {
|
|
214
|
+
backoff = 1000;
|
|
215
|
+
runtime.setGateway("connected");
|
|
216
|
+
sink.send(JSON.stringify({ type: "relay-online", loginDomains: load().loginDomains }));
|
|
217
|
+
// Detect a zombie link (slept laptop / network change): without this a half-open
|
|
218
|
+
// socket never fires `close`, so the reconnect below never runs. The heartbeat
|
|
219
|
+
// terminate()s a silent link → `close` fires → reconnect. ~15s worst-case detection
|
|
220
|
+
// (10s ping + 5s pong). Self-clears on close.
|
|
221
|
+
attachHeartbeat(ws);
|
|
222
|
+
});
|
|
223
|
+
ws.on("message", (d) => {
|
|
224
|
+
const raw = String(d);
|
|
225
|
+
// The gateway hands us a durable reconnect token at pairing — persist it so every
|
|
226
|
+
// future reconnect uses it, not the now-spent code.
|
|
227
|
+
try {
|
|
228
|
+
const m = JSON.parse(raw);
|
|
229
|
+
if (m.type === "relay-hello" && typeof m.token === "string" && m.token && m.token !== token) {
|
|
230
|
+
token = m.token;
|
|
231
|
+
const cfg = load();
|
|
232
|
+
cfg.reconnectToken = token;
|
|
233
|
+
save(cfg);
|
|
234
|
+
}
|
|
235
|
+
// Tunnel frames are handled here, not by the fetch client.
|
|
236
|
+
if (m.type?.startsWith("tunnel-")) {
|
|
237
|
+
tunnel.handle(m);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch { /* not our frame */ }
|
|
242
|
+
void client.onMessage(raw);
|
|
243
|
+
});
|
|
244
|
+
ws.on("error", () => { });
|
|
245
|
+
ws.on("close", (c) => {
|
|
246
|
+
// The link is gone — no stream can be served, so close them all rather than
|
|
247
|
+
// leaving the pod's apps hanging.
|
|
248
|
+
tunnel.closeAll();
|
|
249
|
+
if (stopping)
|
|
250
|
+
return;
|
|
251
|
+
runtime.setGateway(c === 4401 ? "unavailable" : "reconnecting");
|
|
252
|
+
// 4401 = the credential was rejected. If we were using a token that expired/was
|
|
253
|
+
// revoked, fall back to the code ONCE (re-pair); if the code itself is rejected,
|
|
254
|
+
// give up — retrying is pointless.
|
|
255
|
+
if (c === 4401) {
|
|
256
|
+
if (token) {
|
|
257
|
+
token = undefined;
|
|
258
|
+
setTimeout(connect, 500);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
return void stop();
|
|
262
|
+
}
|
|
263
|
+
// Otherwise a blip or a slept laptop — reconnect with backoff so the relay
|
|
264
|
+
// survives without the owner restarting it.
|
|
265
|
+
setTimeout(connect, Math.min(backoff, 30_000));
|
|
266
|
+
backoff *= 2;
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
process.on("SIGTERM", () => void stop());
|
|
270
|
+
process.on("SIGINT", () => void stop());
|
|
271
|
+
connect();
|
|
272
|
+
}
|
|
273
|
+
async function cmdLogin(a) {
|
|
274
|
+
const domain = normalizeDomain(a[0] ?? die("usage: relay login <domain>"));
|
|
275
|
+
const pw = (await import("playwright").catch(() => die("Playwright missing — reinstall: npx @podbay/relay@latest")));
|
|
276
|
+
log(`opening a browser so you can sign in to ${domain}. This is the relay's OWN profile.`);
|
|
277
|
+
const ctx = await pw.chromium.launchPersistentContext(profileDir(), { headless: false, channel: "chrome" });
|
|
278
|
+
const first = await ctx.newPage();
|
|
279
|
+
await first.goto(`https://${domain}`, { waitUntil: "domcontentloaded" }).catch(() => undefined);
|
|
280
|
+
log("");
|
|
281
|
+
log(` ${c.bold("sign in")} ${c.dim("(2FA included), then close the browser —")} ${c.bold("or press Enter here")} ${c.dim("when done.")}`);
|
|
282
|
+
log(` ${c.dim("I'll save the session and quit the browser for you.")}`);
|
|
283
|
+
// Wait until the owner is done. Belt-and-suspenders so it CANNOT hang (live-caught
|
|
284
|
+
// 2026-08-03: closing tabs left Chrome alive with a lingering page, so pages()===0
|
|
285
|
+
// never fired and the CLI hung). We resolve on ANY of: all pages closed, the browser
|
|
286
|
+
// disconnecting, OR the owner pressing Enter — whichever comes first.
|
|
287
|
+
await new Promise((resolve) => {
|
|
288
|
+
let done = false;
|
|
289
|
+
const finish = () => {
|
|
290
|
+
if (done)
|
|
291
|
+
return;
|
|
292
|
+
done = true;
|
|
293
|
+
clearInterval(poll);
|
|
294
|
+
process.stdin.off("data", onEnter);
|
|
295
|
+
process.stdin.pause();
|
|
296
|
+
resolve();
|
|
297
|
+
};
|
|
298
|
+
const closedNow = () => ctx.pages().length === 0;
|
|
299
|
+
const check = () => setTimeout(() => closedNow() && finish(), 100);
|
|
300
|
+
ctx.on("close", finish);
|
|
301
|
+
ctx.on("page", (p) => p.on("close", check));
|
|
302
|
+
for (const p of ctx.pages())
|
|
303
|
+
p.on("close", check);
|
|
304
|
+
// Chrome fully quit / crashed — a hard "window is gone" signal the page events miss.
|
|
305
|
+
ctx.browser?.()?.on?.("disconnected", finish);
|
|
306
|
+
// Fallback poll — catches tab-close semantics the events miss, and a context that
|
|
307
|
+
// has become unusable (throwing = gone).
|
|
308
|
+
const poll = setInterval(() => {
|
|
309
|
+
try {
|
|
310
|
+
if (closedNow())
|
|
311
|
+
finish();
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
finish();
|
|
315
|
+
}
|
|
316
|
+
}, 1000);
|
|
317
|
+
// Manual escape hatch — the owner is never stuck if auto-detect misses their close.
|
|
318
|
+
const onEnter = () => finish();
|
|
319
|
+
process.stdin.resume();
|
|
320
|
+
process.stdin.once("data", onEnter);
|
|
321
|
+
});
|
|
322
|
+
// Did a session actually land? Read cookies for the domain BEFORE closing, so "saved"
|
|
323
|
+
// isn't a lie when the sign-in never completed (owner's doubt, 2026-08-03).
|
|
324
|
+
let hasSession = false;
|
|
325
|
+
try {
|
|
326
|
+
const cookies = await ctx.cookies(`https://${domain}`).catch(() => []);
|
|
327
|
+
hasSession = Array.isArray(cookies) && cookies.length > 0;
|
|
328
|
+
}
|
|
329
|
+
catch { /* context already gone — can't verify, don't overclaim */ }
|
|
330
|
+
// Fully close the context → quits the Chrome the relay launched → releases the
|
|
331
|
+
// profile lock so the daemon can open it for session fetches.
|
|
332
|
+
await ctx.close().catch(() => undefined);
|
|
333
|
+
addLoginDomain(domain);
|
|
334
|
+
log("");
|
|
335
|
+
if (hasSession) {
|
|
336
|
+
log(` ${c.green("✓ signed in")} ${c.dim(`— ${domain} will now be fetched as you; every other site stays clean.`)}`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
log(` ${c.yellow("⚠ saved, but I didn't see a session")} ${c.dim(`for ${domain}. If a fetch hits a login wall, run`)} ${c.bold(`relay login ${domain}`)} ${c.dim("again and complete the sign-in.")}`);
|
|
340
|
+
}
|
|
341
|
+
log("");
|
|
342
|
+
}
|
|
343
|
+
async function cmdDashboard(a) {
|
|
344
|
+
const port = Number(arg(a, "--port") ?? 7373);
|
|
345
|
+
const preview = a.includes("--preview");
|
|
346
|
+
if (!preview) {
|
|
347
|
+
const liveUrl = dashboardUrl();
|
|
348
|
+
if (liveUrl) {
|
|
349
|
+
log(`relay command center: ${liveUrl}`);
|
|
350
|
+
log("(served by the running relay — detailed history stays on this computer.)");
|
|
351
|
+
openBrowser(liveUrl);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const cfg = load();
|
|
356
|
+
const store = preview ? undefined : new RelayEventStore({ retentionDays: cfg.retentionDays ?? 30 }).init();
|
|
357
|
+
const runtime = preview ? undefined : new RelayRuntime({ daemon: "stopped" });
|
|
358
|
+
const { url } = await serveDashboard(port, { preview, store, runtime, readOnly: !preview });
|
|
359
|
+
log(`relay dashboard: ${url}`);
|
|
360
|
+
log(preview
|
|
361
|
+
? "(UX preview — sample multi-pod activity; controls are simulated. Ctrl-C to close.)"
|
|
362
|
+
: "(relay stopped — read-only saved history; local to this computer. Ctrl-C to close.)");
|
|
363
|
+
openBrowser(url);
|
|
364
|
+
await new Promise(() => { }); // stay up until Ctrl-C
|
|
365
|
+
}
|
|
366
|
+
async function cmdStatus() {
|
|
367
|
+
const pid = isRunning();
|
|
368
|
+
const cfg = load();
|
|
369
|
+
// Pad wider than the longest label ("dashboard", 9) so every value has a gutter —
|
|
370
|
+
// padEnd(9) left "dashboard" flush against its URL.
|
|
371
|
+
const label = (s) => c.dim(s.padEnd(11));
|
|
372
|
+
log("");
|
|
373
|
+
log(`${label("relay")}${pid ? c.green(`running`) + c.dim(` (pid ${pid})`) : c.yellow("not running")}`);
|
|
374
|
+
// The PROCESS being alive is not the same as the gateway LINK being up — a slept host
|
|
375
|
+
// leaves a dead link the process can't see. Ask the daemon's own dashboard for the
|
|
376
|
+
// live state so status stops reporting "running" over a dead link.
|
|
377
|
+
if (pid)
|
|
378
|
+
log(`${label("link")}${await gatewayLinkState()}`);
|
|
379
|
+
log(`${label("gateway")}${cfg.gatewayUrl ? c.cyan(cfg.gatewayUrl) : c.dim("(unset)")}`);
|
|
380
|
+
log(`${label("dashboard")}${dashboardUrl() ? c.cyan(dashboardUrl()) : c.dim("relay dashboard opens saved history")}`);
|
|
381
|
+
log(`${label("as-you")}${cfg.loginDomains.length ? cfg.loginDomains.join(", ") : c.dim("no sites signed in — all fetches are clean")}`);
|
|
382
|
+
log("");
|
|
383
|
+
}
|
|
384
|
+
/** Live gateway-link state, read from the running daemon's local dashboard (`/data`).
|
|
385
|
+
* The daemon owns this in memory ("never state files"), so a separate `relay status`
|
|
386
|
+
* can only learn it by asking the daemon — this is what makes status honest about a
|
|
387
|
+
* link that dropped (host asleep/offline) instead of just "the process is alive". */
|
|
388
|
+
async function gatewayLinkState() {
|
|
389
|
+
const base = dashboardUrl();
|
|
390
|
+
if (!base)
|
|
391
|
+
return c.dim("unknown (dashboard not up yet)");
|
|
392
|
+
try {
|
|
393
|
+
const ac = new AbortController();
|
|
394
|
+
const timer = setTimeout(() => ac.abort(), 1500);
|
|
395
|
+
const res = await fetch(`${base}/data`, { signal: ac.signal }).finally(() => clearTimeout(timer));
|
|
396
|
+
const data = (await res.json());
|
|
397
|
+
if (data.state === "connected")
|
|
398
|
+
return c.green("connected");
|
|
399
|
+
if (data.state === "reconnecting")
|
|
400
|
+
return c.yellow("reconnecting… (host asleep or offline)");
|
|
401
|
+
if (data.state === "stopped")
|
|
402
|
+
return c.yellow("stopped");
|
|
403
|
+
return c.dim("unknown");
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return c.yellow("unreachable — the relay daemon isn't responding (try: relay restart)");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Stop-then-start, reusing the stored gateway + reconnect token. The escape hatch for a
|
|
410
|
+
* link that won't come back on its own — though the heartbeat should now auto-recover it. */
|
|
411
|
+
async function cmdRestart(a) {
|
|
412
|
+
const pid = isRunning();
|
|
413
|
+
if (pid) {
|
|
414
|
+
process.kill(pid, "SIGTERM");
|
|
415
|
+
try {
|
|
416
|
+
unlinkSync(pidFile());
|
|
417
|
+
}
|
|
418
|
+
catch { }
|
|
419
|
+
await new Promise((r) => setTimeout(r, 600)); // let it release the pid + dashboard port
|
|
420
|
+
log(c.dim("restarting the relay…"));
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
log(c.dim("no relay was running — starting it."));
|
|
424
|
+
}
|
|
425
|
+
return cmdStart(a);
|
|
426
|
+
}
|
|
427
|
+
function cmdStop() {
|
|
428
|
+
const pid = isRunning();
|
|
429
|
+
if (!pid)
|
|
430
|
+
return log(c.dim("no relay running."));
|
|
431
|
+
process.kill(pid, "SIGTERM");
|
|
432
|
+
try {
|
|
433
|
+
unlinkSync(pidFile());
|
|
434
|
+
}
|
|
435
|
+
catch { }
|
|
436
|
+
log(`${c.green("✓")} relay stopped.`);
|
|
437
|
+
}
|
|
438
|
+
async function dispatch(a) {
|
|
439
|
+
switch (a[0]) {
|
|
440
|
+
case "start": return cmdStart(a.slice(1));
|
|
441
|
+
case "login": return cmdLogin(a.slice(1));
|
|
442
|
+
case "dashboard": return cmdDashboard(a.slice(1));
|
|
443
|
+
case "status": return cmdStatus();
|
|
444
|
+
case "stop": return cmdStop();
|
|
445
|
+
case "restart": return cmdRestart(a.slice(1));
|
|
446
|
+
case "reset":
|
|
447
|
+
resetProfile();
|
|
448
|
+
return log(`${c.green("✓")} wiped the relay's sessions, logins, and pairing.`);
|
|
449
|
+
default:
|
|
450
|
+
return usage();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function usage() {
|
|
454
|
+
log("");
|
|
455
|
+
log(`${c.bold("relay")} ${c.dim("— fetch web pages through this machine for your Podbay pods")}`);
|
|
456
|
+
log("");
|
|
457
|
+
// `start` carries flags, so it gets its own line rather than blowing out the column.
|
|
458
|
+
log(` ${c.cyan("start")} ${c.dim("run the relay in the background:")}`);
|
|
459
|
+
log(` ${c.dim("relay start --gateway <url> --code <code> --accept")}`);
|
|
460
|
+
log("");
|
|
461
|
+
log(rows([
|
|
462
|
+
["login <site>", "let one site be fetched as you (everything else stays clean)"],
|
|
463
|
+
["dashboard", "open a local page of what it has fetched"],
|
|
464
|
+
["status", "is it running, is the gateway link up, and for which sites"],
|
|
465
|
+
["stop", "stop the relay"],
|
|
466
|
+
["restart", "stop then start it (e.g. after the host slept)"],
|
|
467
|
+
["reset", "forget all sessions, logins, and pairing"],
|
|
468
|
+
]));
|
|
469
|
+
log("");
|
|
470
|
+
}
|
|
471
|
+
async function main() {
|
|
472
|
+
const [, , ...args] = process.argv;
|
|
473
|
+
// The binary IS `relay` now, so commands are top-level (`relay start`, `relay login <site>`).
|
|
474
|
+
// Tolerate a stray leading `relay` — old `relay …` muscle memory, and the daemon's own
|
|
475
|
+
// respawn — so `relay relay start` still resolves.
|
|
476
|
+
if (args[0] === "relay")
|
|
477
|
+
args.shift();
|
|
478
|
+
return dispatch(args);
|
|
479
|
+
}
|
|
480
|
+
void main();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VENDORED from `@podbay/shared/src/net-guard.ts` — keep the two IDENTICAL in behaviour.
|
|
3
|
+
*
|
|
4
|
+
* Why a copy: `pb` is published standalone to npm and builds with plain tsc, so it can
|
|
5
|
+
* carry no workspace dependency. Why it is safe: `test/net-guard-parity.test.ts` runs
|
|
6
|
+
* BOTH implementations over the same table and fails if they ever disagree, so a drift
|
|
7
|
+
* in this security control breaks the build rather than opening a quiet hole.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isDisallowedTarget(host: string): boolean;
|