@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
package/FAQ.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Running Podbay Relay on a headless host
|
|
2
|
+
|
|
3
|
+
The relay can run on any NAS, Raspberry Pi, or other always-on machine that
|
|
4
|
+
supports Node. This guide covers the common headless setup issues: finding
|
|
5
|
+
`npx`, starting the relay at boot, and opening the dashboard remotely.
|
|
6
|
+
|
|
7
|
+
## `npx: command not found`, even though I just installed Node
|
|
8
|
+
|
|
9
|
+
Some NAS packages install `node` and `npm` without putting `npx` on your `PATH`.
|
|
10
|
+
Synology's DSM Node.js package is one example. Check the directory containing
|
|
11
|
+
`node`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
NODE_BIN="$(dirname "$(which node)")"
|
|
15
|
+
ls "$NODE_BIN" | grep npx
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
If `npx` appears, add that directory to `PATH` in `~/.bashrc`, `~/.profile`, or
|
|
19
|
+
the equivalent file for your shell.
|
|
20
|
+
|
|
21
|
+
If it is genuinely absent, search for another copy:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
find / -iname npx 2>/dev/null
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If you find another copy, add its directory to `PATH`. You can also install it
|
|
28
|
+
separately with `npm install -g npx`.
|
|
29
|
+
|
|
30
|
+
Boot schedulers and one-command SSH sessions often use a shorter `PATH` than a
|
|
31
|
+
normal login shell. If `npx` works after you log in but fails in a script, set
|
|
32
|
+
`PATH` explicitly at the top of that script.
|
|
33
|
+
|
|
34
|
+
## How do I make the relay survive a reboot?
|
|
35
|
+
|
|
36
|
+
The relay itself does not need root. Use whichever startup mechanism your host
|
|
37
|
+
provides:
|
|
38
|
+
|
|
39
|
+
- **Synology DSM**: Go to Control Panel → Task Scheduler → Create →
|
|
40
|
+
Triggered Task → User-defined script, then choose **Boot-up**. Run:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export PATH=/path/to/node/bin:$PATH # wherever npx actually lives, see above
|
|
44
|
+
npx @podway/relay@latest start --gateway <url> --accept
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **A systemd host**: If you have root, create a service that runs the same
|
|
48
|
+
command at boot.
|
|
49
|
+
- **A host with a user crontab**: Run `crontab -e` and add the same command as
|
|
50
|
+
an `@reboot` entry. Not every NAS includes a user crontab.
|
|
51
|
+
|
|
52
|
+
The scheduler only needs to run the command once. `start` moves the relay into
|
|
53
|
+
the background and exits.
|
|
54
|
+
|
|
55
|
+
## Do I need to pass `--code` again every time it starts?
|
|
56
|
+
|
|
57
|
+
No. `--code` is only used for the first pairing. The relay saves a reconnect
|
|
58
|
+
token locally, so later `start` commands reconnect automatically:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx @podway/relay@latest start --gateway <url> --accept
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Do not save the original pairing code in your boot script; it expires and is
|
|
65
|
+
not needed after pairing. To pair again, run
|
|
66
|
+
`npx @podway/relay@latest reset`. This also clears the relay's other saved data.
|
|
67
|
+
|
|
68
|
+
## How do I open the dashboard remotely?
|
|
69
|
+
|
|
70
|
+
The dashboard listens only on `127.0.0.1`, so it is not exposed to your network.
|
|
71
|
+
Create an SSH tunnel from your computer:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ssh -L 7373:127.0.0.1:7373 user@your-nas
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Keep the tunnel open, run `npx @podway/relay@latest status` on the relay host,
|
|
78
|
+
and open the printed `http://127.0.0.1:7373/...` URL on your computer.
|
|
79
|
+
|
|
80
|
+
If the tunnel immediately resets, check whether the SSH server disables
|
|
81
|
+
forwarding. Synology commonly sets `AllowTcpForwarding no`. Enabling it affects
|
|
82
|
+
every authorized SSH user, not only Podbay, and requires administrator access:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
sudo sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config
|
|
86
|
+
sudo systemctl restart sshd # or: synosystemctl restart sshd, on some DSM versions
|
|
87
|
+
```
|
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Podbay
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Podbay Relay
|
|
2
|
+
|
|
3
|
+
The Podbay relay lets your pods reach websites through your network instead of
|
|
4
|
+
a datacenter. This helps with sites that block cloud IPs. For sites you choose,
|
|
5
|
+
the relay can also use a separate browser session that you sign into.
|
|
6
|
+
|
|
7
|
+
One relay handles two kinds of traffic:
|
|
8
|
+
|
|
9
|
+
| mode | what the pod gets | used by |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **fetch** | a page loaded by this machine, optionally with a session you provided | the agent's `podbay fetch get` command |
|
|
12
|
+
| **tunnel** | a proxy connection through your IP, without your cookies | crawlers, scripts, Playwright, and other tools that load pages themselves |
|
|
13
|
+
|
|
14
|
+
The tunnel lets a pod run a real browser while the connection comes from your
|
|
15
|
+
network. Pods receive it through `$PODBAY_RELAY_PROXY`. It **fails closed**: if
|
|
16
|
+
the relay is not running, those connections are refused instead of quietly
|
|
17
|
+
leaving from the datacenter.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Your pod prints the exact command, pinned to a version and pairing code:
|
|
21
|
+
npx @podway/relay@<version> start --code <code> --accept
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Once started, the relay runs in the background and handles both modes.
|
|
25
|
+
|
|
26
|
+
## What it does — and what it does not do
|
|
27
|
+
|
|
28
|
+
- **Public pages are clean by default.** They load in a fresh browser without
|
|
29
|
+
your cookies or accounts.
|
|
30
|
+
- **Signed-in access is explicit.** `relay login <domain>` opens a browser so
|
|
31
|
+
you can sign in. Only that domain may use the saved session. The relay returns
|
|
32
|
+
page content, never cookies, credentials, or session tokens.
|
|
33
|
+
- **The tunnel shares your IP, not your accounts.** Tools using the proxy get a
|
|
34
|
+
clean connection without your signed-in sessions.
|
|
35
|
+
- **Your private network stays blocked.** Pods cannot reach localhost, your LAN,
|
|
36
|
+
private IPs, routers, or other internal services through the relay.
|
|
37
|
+
- **You stay in control.** `relay dashboard` shows activity and lets you pause a
|
|
38
|
+
pod, block a site, revoke signed-in access, manage history, or stop the relay.
|
|
39
|
+
- **Requests are rate-limited per site**, so one pod cannot hammer a source
|
|
40
|
+
through your connection.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
| command | description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `relay start --code <code> --gateway <url> --accept` | start the relay in the background |
|
|
47
|
+
| `relay login <domain>` | allow signed-in fetches for one domain |
|
|
48
|
+
| `relay dashboard` | open the local command center |
|
|
49
|
+
| `relay status` | show the relay, gateway, and signed-in site status |
|
|
50
|
+
| `relay stop` | stop it |
|
|
51
|
+
| `relay reset` | remove saved sessions, logins, and pairing |
|
|
52
|
+
|
|
53
|
+
## Trust
|
|
54
|
+
|
|
55
|
+
The relay stores browser sessions for sites you sign into, so treat it as
|
|
56
|
+
sensitive software. It is open source under Apache-2.0 and uses its own browser
|
|
57
|
+
profile; it never touches your everyday browser, history, or cookies. Automating
|
|
58
|
+
a signed-in session may violate a site's terms, and the account risk is yours.
|
|
59
|
+
|
|
60
|
+
## Local command center and history
|
|
61
|
+
|
|
62
|
+
`relay dashboard` shows the connection state, recent activity, and access
|
|
63
|
+
controls. While the relay runs, the dashboard is live. When stopped, it shows
|
|
64
|
+
saved history in read-only mode. The CLI always prints its local URL, so it also
|
|
65
|
+
works on headless machines without `xdg-open`.
|
|
66
|
+
|
|
67
|
+
Activity is stored in owner-only, daily files under `~/.podway/relay/events`.
|
|
68
|
+
Events include the pod ID when available, mode, sanitized target, timing,
|
|
69
|
+
outcome, HTTP status, signed-in use, and tunnel byte counts. History is kept for
|
|
70
|
+
30 days by default; the dashboard offers 7, 30, or 90 days and enforces a
|
|
71
|
+
storage limit.
|
|
72
|
+
|
|
73
|
+
Before an event is written, URLs are stripped of usernames, passwords, query
|
|
74
|
+
strings, and fragments. Logs never include cookies, authorization headers,
|
|
75
|
+
browser storage, request or response bodies, or tunnel content. Detailed
|
|
76
|
+
history and exports stay on your machine; Podbay receives only coarse,
|
|
77
|
+
domain-level telemetry. Clearing history does not clear pairing, sessions,
|
|
78
|
+
paused pods, or blocked sites.
|
|
79
|
+
|
|
80
|
+
## Running headless
|
|
81
|
+
|
|
82
|
+
Running the relay on a NAS, Raspberry Pi, or another always-on machine? See
|
|
83
|
+
[FAQ.md](FAQ.md) for help with missing `npx`, starting at boot, and opening the
|
|
84
|
+
dashboard remotely.
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type RelayMode = "fetch" | "tunnel";
|
|
2
|
+
export type RelayOutcome = "ok" | "site-refused" | "owner-blocked" | "safety-blocked" | "rate-limited" | "network-error";
|
|
3
|
+
export type RelaySource = {
|
|
4
|
+
podId: string;
|
|
5
|
+
podName?: string;
|
|
6
|
+
} | {
|
|
7
|
+
system: "health-check";
|
|
8
|
+
};
|
|
9
|
+
/** The only detailed event shape allowed across the local persistence boundary. */
|
|
10
|
+
export interface RelayEventV2 {
|
|
11
|
+
v: 2;
|
|
12
|
+
id: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
finishedAt: string;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
source?: RelaySource;
|
|
17
|
+
mode: RelayMode;
|
|
18
|
+
outcome: RelayOutcome;
|
|
19
|
+
target: string;
|
|
20
|
+
finalTarget?: string;
|
|
21
|
+
host: string;
|
|
22
|
+
httpStatus?: number;
|
|
23
|
+
session: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
bytesUp?: number;
|
|
26
|
+
bytesDown?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Rows written by pb <= 0.1.6. */
|
|
29
|
+
export interface AuditEntry {
|
|
30
|
+
host: string;
|
|
31
|
+
status: number;
|
|
32
|
+
ms: number;
|
|
33
|
+
session: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
kind?: RelayMode;
|
|
36
|
+
bytes?: number;
|
|
37
|
+
at?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SafeFetchTarget {
|
|
40
|
+
target: string;
|
|
41
|
+
host: string;
|
|
42
|
+
}
|
|
43
|
+
/** Strip the credential-bearing and high-entropy portions before an event exists. */
|
|
44
|
+
export declare function safeFetchTarget(value: string): SafeFetchTarget | null;
|
|
45
|
+
export declare function safeTunnelTarget(hostValue: string, port: number): {
|
|
46
|
+
target: string;
|
|
47
|
+
host: string;
|
|
48
|
+
} | null;
|
|
49
|
+
export declare function fetchOutcome(status: number, error?: string): RelayOutcome;
|
|
50
|
+
/** Rebuild a record from allowed fields, sanitizing targets again at the write boundary. */
|
|
51
|
+
export declare function sanitizeEvent(input: RelayEventV2): RelayEventV2 | null;
|
|
52
|
+
export interface EventQuery {
|
|
53
|
+
since?: string;
|
|
54
|
+
podId?: string;
|
|
55
|
+
mode?: RelayMode;
|
|
56
|
+
outcome?: RelayOutcome;
|
|
57
|
+
site?: string;
|
|
58
|
+
offset?: number;
|
|
59
|
+
limit?: number;
|
|
60
|
+
}
|
|
61
|
+
/** Bounded, day-partitioned, owner-local JSONL with an in-memory retained index. */
|
|
62
|
+
export declare class RelayEventStore {
|
|
63
|
+
private readonly options;
|
|
64
|
+
private index;
|
|
65
|
+
private lastPrunedDay;
|
|
66
|
+
private selectedRetentionDays;
|
|
67
|
+
readonly root: string;
|
|
68
|
+
constructor(options?: {
|
|
69
|
+
root?: string;
|
|
70
|
+
legacyFile?: string;
|
|
71
|
+
retentionDays?: 7 | 30 | 90;
|
|
72
|
+
maxBytes?: number;
|
|
73
|
+
now?: () => number;
|
|
74
|
+
});
|
|
75
|
+
private now;
|
|
76
|
+
private retentionDays;
|
|
77
|
+
setRetentionDays(days: 7 | 30 | 90): void;
|
|
78
|
+
init(): this;
|
|
79
|
+
private eventFiles;
|
|
80
|
+
private parseLines;
|
|
81
|
+
reload(): void;
|
|
82
|
+
append(input: RelayEventV2): boolean;
|
|
83
|
+
query(query?: EventQuery): RelayEventV2[];
|
|
84
|
+
exportJson(): string;
|
|
85
|
+
clear(): void;
|
|
86
|
+
storageBytes(): number;
|
|
87
|
+
prune(force?: boolean): void;
|
|
88
|
+
}
|
|
89
|
+
/** Compatibility writer for callers not yet migrated to v2. */
|
|
90
|
+
export declare function record(entry: AuditEntry): void;
|
|
91
|
+
export interface AuditSummary {
|
|
92
|
+
total: number;
|
|
93
|
+
ok: number;
|
|
94
|
+
failed: number;
|
|
95
|
+
sessionFetches: number;
|
|
96
|
+
tunnelConnections: number;
|
|
97
|
+
tunnelBytes: number;
|
|
98
|
+
byHost: {
|
|
99
|
+
host: string;
|
|
100
|
+
count: number;
|
|
101
|
+
failed: number;
|
|
102
|
+
session: boolean;
|
|
103
|
+
tunnel: number;
|
|
104
|
+
bytes: number;
|
|
105
|
+
}[];
|
|
106
|
+
}
|
|
107
|
+
/** Legacy summary kept for compatibility with older integrations and tests. */
|
|
108
|
+
export declare function summarize(text: string): AuditSummary;
|
|
109
|
+
export declare function readSummary(): AuditSummary;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { auditFile, eventsDir } from "./config.js";
|
|
4
|
+
/** Strip the credential-bearing and high-entropy portions before an event exists. */
|
|
5
|
+
export function safeFetchTarget(value) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
9
|
+
return null;
|
|
10
|
+
url.username = "";
|
|
11
|
+
url.password = "";
|
|
12
|
+
url.search = "";
|
|
13
|
+
url.hash = "";
|
|
14
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
15
|
+
if (!host)
|
|
16
|
+
return null;
|
|
17
|
+
return { target: url.toString(), host };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function safeTunnelTarget(hostValue, port) {
|
|
24
|
+
const host = hostValue.trim().toLowerCase().replace(/\.$/, "");
|
|
25
|
+
if (!host || /[\s/@?#]/.test(host) || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
26
|
+
return null;
|
|
27
|
+
return { host, target: host.includes(":") ? `[${host}]:${port}` : `${host}:${port}` };
|
|
28
|
+
}
|
|
29
|
+
export function fetchOutcome(status, error) {
|
|
30
|
+
if (error)
|
|
31
|
+
return "network-error";
|
|
32
|
+
if (status === 403)
|
|
33
|
+
return "site-refused";
|
|
34
|
+
if (status === 429)
|
|
35
|
+
return "rate-limited";
|
|
36
|
+
if (status >= 200 && status < 400)
|
|
37
|
+
return "ok";
|
|
38
|
+
return "network-error";
|
|
39
|
+
}
|
|
40
|
+
function sourceOf(value) {
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return undefined;
|
|
43
|
+
const source = value;
|
|
44
|
+
if (typeof source.podId === "string" && source.podId.trim()) {
|
|
45
|
+
const out = { podId: source.podId.slice(0, 160) };
|
|
46
|
+
// Gateway-authoritative display name, bounded like the id. Optional — absent means
|
|
47
|
+
// the dashboard shows the id. (Old rows / old gateways simply omit it.)
|
|
48
|
+
if (typeof source.podName === "string" && source.podName.trim())
|
|
49
|
+
out.podName = source.podName.slice(0, 160);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
if (source.system === "health-check")
|
|
53
|
+
return { system: "health-check" };
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
function boundedText(value, max = 500) {
|
|
57
|
+
return typeof value === "string" && value ? value.slice(0, max) : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Rebuild a record from allowed fields, sanitizing targets again at the write boundary. */
|
|
60
|
+
export function sanitizeEvent(input) {
|
|
61
|
+
const safe = input.mode === "fetch"
|
|
62
|
+
? safeFetchTarget(input.target)
|
|
63
|
+
: (() => {
|
|
64
|
+
const match = input.target.match(/^\[?([^\]]+?)\]?:([0-9]+)$/);
|
|
65
|
+
return match ? safeTunnelTarget(match[1], Number(match[2])) : null;
|
|
66
|
+
})();
|
|
67
|
+
if (!safe)
|
|
68
|
+
return null;
|
|
69
|
+
const final = input.mode === "fetch" && input.finalTarget ? safeFetchTarget(input.finalTarget)?.target : undefined;
|
|
70
|
+
return {
|
|
71
|
+
v: 2,
|
|
72
|
+
id: boundedText(input.id, 200) ?? crypto.randomUUID(),
|
|
73
|
+
startedAt: new Date(input.startedAt).toISOString(),
|
|
74
|
+
finishedAt: new Date(input.finishedAt).toISOString(),
|
|
75
|
+
durationMs: Math.max(0, Math.round(Number(input.durationMs) || 0)),
|
|
76
|
+
source: sourceOf(input.source),
|
|
77
|
+
mode: input.mode,
|
|
78
|
+
outcome: input.outcome,
|
|
79
|
+
target: safe.target,
|
|
80
|
+
...(final ? { finalTarget: final } : {}),
|
|
81
|
+
host: safe.host,
|
|
82
|
+
...(Number.isInteger(input.httpStatus) ? { httpStatus: input.httpStatus } : {}),
|
|
83
|
+
session: input.mode === "fetch" && input.session === true,
|
|
84
|
+
...(boundedText(input.reason) ? { reason: boundedText(input.reason) } : {}),
|
|
85
|
+
...(input.mode === "tunnel" ? {
|
|
86
|
+
bytesUp: Math.max(0, Math.round(Number(input.bytesUp) || 0)),
|
|
87
|
+
bytesDown: Math.max(0, Math.round(Number(input.bytesDown) || 0)),
|
|
88
|
+
} : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function legacyToV2(row, index, fallbackAt) {
|
|
92
|
+
const mode = row.kind === "tunnel" ? "tunnel" : "fetch";
|
|
93
|
+
const at = typeof row.at === "string" && Number.isFinite(Date.parse(row.at)) ? row.at : fallbackAt;
|
|
94
|
+
const target = mode === "tunnel"
|
|
95
|
+
? safeTunnelTarget(String(row.host ?? "unknown.invalid"), 443)?.target
|
|
96
|
+
: safeFetchTarget(`https://${String(row.host ?? "unknown.invalid")}/`)?.target;
|
|
97
|
+
if (!target)
|
|
98
|
+
return null;
|
|
99
|
+
return sanitizeEvent({
|
|
100
|
+
v: 2,
|
|
101
|
+
id: `legacy-${index}-${Date.parse(at) || 0}`,
|
|
102
|
+
startedAt: at,
|
|
103
|
+
finishedAt: new Date((Date.parse(at) || Date.now()) + Math.max(0, Number(row.ms) || 0)).toISOString(),
|
|
104
|
+
durationMs: Math.max(0, Number(row.ms) || 0),
|
|
105
|
+
mode,
|
|
106
|
+
outcome: fetchOutcome(Number(row.status) || 0, row.error),
|
|
107
|
+
target,
|
|
108
|
+
host: String(row.host ?? "unknown.invalid"),
|
|
109
|
+
...(mode === "fetch" ? { httpStatus: Number(row.status) || 0 } : {}),
|
|
110
|
+
session: row.session === true,
|
|
111
|
+
...(row.error ? { reason: row.error } : {}),
|
|
112
|
+
...(mode === "tunnel" ? { bytesUp: 0, bytesDown: Math.max(0, Number(row.bytes) || 0) } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Bounded, day-partitioned, owner-local JSONL with an in-memory retained index. */
|
|
116
|
+
export class RelayEventStore {
|
|
117
|
+
options;
|
|
118
|
+
index = [];
|
|
119
|
+
lastPrunedDay = "";
|
|
120
|
+
selectedRetentionDays;
|
|
121
|
+
root;
|
|
122
|
+
constructor(options = {}) {
|
|
123
|
+
this.options = options;
|
|
124
|
+
this.root = options.root ?? eventsDir();
|
|
125
|
+
this.selectedRetentionDays = options.retentionDays ?? 30;
|
|
126
|
+
}
|
|
127
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
128
|
+
retentionDays() { return this.selectedRetentionDays; }
|
|
129
|
+
setRetentionDays(days) { this.selectedRetentionDays = days; }
|
|
130
|
+
init() {
|
|
131
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
132
|
+
chmodSync(this.root, 0o700);
|
|
133
|
+
this.prune(true);
|
|
134
|
+
this.reload();
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
eventFiles() {
|
|
138
|
+
if (!existsSync(this.root))
|
|
139
|
+
return [];
|
|
140
|
+
return readdirSync(this.root)
|
|
141
|
+
.filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))
|
|
142
|
+
.sort()
|
|
143
|
+
.map((name) => path.join(this.root, name));
|
|
144
|
+
}
|
|
145
|
+
parseLines(text) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const line of text.split("\n")) {
|
|
148
|
+
if (!line)
|
|
149
|
+
continue;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(line);
|
|
152
|
+
if (parsed.v === 2) {
|
|
153
|
+
const safe = sanitizeEvent(parsed);
|
|
154
|
+
if (safe)
|
|
155
|
+
out.push(safe);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch { /* one corrupt row does not hide its siblings */ }
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
reload() {
|
|
163
|
+
const rows = [];
|
|
164
|
+
for (const file of this.eventFiles()) {
|
|
165
|
+
try {
|
|
166
|
+
rows.push(...this.parseLines(readFileSync(file, "utf8")));
|
|
167
|
+
}
|
|
168
|
+
catch { /* unreadable partition */ }
|
|
169
|
+
}
|
|
170
|
+
const legacy = this.options.legacyFile ?? auditFile();
|
|
171
|
+
try {
|
|
172
|
+
const fallback = new Date(statSync(legacy).mtimeMs).toISOString();
|
|
173
|
+
readFileSync(legacy, "utf8").split("\n").forEach((line, index) => {
|
|
174
|
+
if (!line)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
const event = legacyToV2(JSON.parse(line), index, fallback);
|
|
178
|
+
if (event)
|
|
179
|
+
rows.push(event);
|
|
180
|
+
}
|
|
181
|
+
catch { /* malformed legacy row */ }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch { /* no legacy log */ }
|
|
185
|
+
const cutoff = this.now() - this.retentionDays() * 86_400_000;
|
|
186
|
+
this.index = rows
|
|
187
|
+
.filter((event) => Date.parse(event.finishedAt) >= cutoff)
|
|
188
|
+
.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
|
|
189
|
+
}
|
|
190
|
+
append(input) {
|
|
191
|
+
try {
|
|
192
|
+
const event = sanitizeEvent(input);
|
|
193
|
+
if (!event)
|
|
194
|
+
return false;
|
|
195
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
196
|
+
const day = event.startedAt.slice(0, 10);
|
|
197
|
+
const file = path.join(this.root, `${day}.jsonl`);
|
|
198
|
+
appendFileSync(file, JSON.stringify(event) + "\n", { mode: 0o600 });
|
|
199
|
+
chmodSync(file, 0o600);
|
|
200
|
+
this.index.unshift(event);
|
|
201
|
+
this.prune();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false; // audit must never fail relay transport
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
query(query = {}) {
|
|
209
|
+
const since = query.since ? Date.parse(query.since) : Number.NEGATIVE_INFINITY;
|
|
210
|
+
const site = query.site?.toLowerCase();
|
|
211
|
+
const rows = this.index.filter((event) => Date.parse(event.startedAt) >= since &&
|
|
212
|
+
(!query.podId || (!!event.source && "podId" in event.source && event.source.podId === query.podId)) &&
|
|
213
|
+
(!query.mode || event.mode === query.mode) &&
|
|
214
|
+
(!query.outcome || event.outcome === query.outcome) &&
|
|
215
|
+
(!site || event.host.includes(site)));
|
|
216
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
217
|
+
return rows.slice(offset, offset + Math.min(10_000, Math.max(1, query.limit ?? 500)));
|
|
218
|
+
}
|
|
219
|
+
exportJson() { return JSON.stringify(this.index, null, 2); }
|
|
220
|
+
clear() {
|
|
221
|
+
for (const file of this.eventFiles())
|
|
222
|
+
rmSync(file, { force: true });
|
|
223
|
+
rmSync(this.options.legacyFile ?? auditFile(), { force: true });
|
|
224
|
+
this.index = [];
|
|
225
|
+
}
|
|
226
|
+
storageBytes() {
|
|
227
|
+
let total = 0;
|
|
228
|
+
for (const file of this.eventFiles()) {
|
|
229
|
+
try {
|
|
230
|
+
total += statSync(file).size;
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
total += statSync(this.options.legacyFile ?? auditFile()).size;
|
|
236
|
+
}
|
|
237
|
+
catch { }
|
|
238
|
+
return total;
|
|
239
|
+
}
|
|
240
|
+
prune(force = false) {
|
|
241
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
242
|
+
if (!force && this.lastPrunedDay === today)
|
|
243
|
+
return;
|
|
244
|
+
this.lastPrunedDay = today;
|
|
245
|
+
const cutoffDay = new Date(this.now() - this.retentionDays() * 86_400_000).toISOString().slice(0, 10);
|
|
246
|
+
for (const file of this.eventFiles()) {
|
|
247
|
+
if (path.basename(file, ".jsonl") < cutoffDay)
|
|
248
|
+
rmSync(file, { force: true });
|
|
249
|
+
}
|
|
250
|
+
const legacy = this.options.legacyFile ?? auditFile();
|
|
251
|
+
try {
|
|
252
|
+
if (statSync(legacy).mtimeMs < this.now() - this.retentionDays() * 86_400_000)
|
|
253
|
+
rmSync(legacy, { force: true });
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
const ceiling = this.options.maxBytes ?? 50 * 1024 * 1024;
|
|
257
|
+
const files = this.eventFiles();
|
|
258
|
+
let total = files.reduce((sum, file) => { try {
|
|
259
|
+
return sum + statSync(file).size;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return sum;
|
|
263
|
+
} }, 0);
|
|
264
|
+
try {
|
|
265
|
+
total += statSync(legacy).size;
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
const candidates = [legacy, ...files];
|
|
269
|
+
for (const file of candidates) {
|
|
270
|
+
if (total <= ceiling)
|
|
271
|
+
break;
|
|
272
|
+
try {
|
|
273
|
+
const size = statSync(file).size;
|
|
274
|
+
rmSync(file, { force: true });
|
|
275
|
+
total -= size;
|
|
276
|
+
}
|
|
277
|
+
catch { }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Compatibility writer for callers not yet migrated to v2. */
|
|
282
|
+
export function record(entry) {
|
|
283
|
+
try {
|
|
284
|
+
const at = new Date().toISOString();
|
|
285
|
+
const converted = legacyToV2({ ...entry, at }, 0, at);
|
|
286
|
+
if (converted)
|
|
287
|
+
new RelayEventStore().init().append(converted);
|
|
288
|
+
}
|
|
289
|
+
catch { /* best effort */ }
|
|
290
|
+
}
|
|
291
|
+
function isOk(row) {
|
|
292
|
+
return !row.error && row.status >= 200 && row.status < 400;
|
|
293
|
+
}
|
|
294
|
+
/** Legacy summary kept for compatibility with older integrations and tests. */
|
|
295
|
+
export function summarize(text) {
|
|
296
|
+
const rows = text.split("\n").filter(Boolean).flatMap((line) => {
|
|
297
|
+
try {
|
|
298
|
+
return [JSON.parse(line)];
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const byHost = new Map();
|
|
305
|
+
let ok = 0, failed = 0, sessionFetches = 0, tunnelConnections = 0, tunnelBytes = 0;
|
|
306
|
+
for (const row of rows) {
|
|
307
|
+
const host = byHost.get(row.host) ?? { count: 0, failed: 0, session: false, tunnel: 0, bytes: 0 };
|
|
308
|
+
const good = isOk(row);
|
|
309
|
+
if (row.kind === "tunnel") {
|
|
310
|
+
host.tunnel++;
|
|
311
|
+
host.bytes += row.bytes ?? 0;
|
|
312
|
+
tunnelConnections++;
|
|
313
|
+
tunnelBytes += row.bytes ?? 0;
|
|
314
|
+
}
|
|
315
|
+
else
|
|
316
|
+
host.count++;
|
|
317
|
+
if (!good)
|
|
318
|
+
host.failed++;
|
|
319
|
+
if (row.session) {
|
|
320
|
+
host.session = true;
|
|
321
|
+
sessionFetches++;
|
|
322
|
+
}
|
|
323
|
+
byHost.set(row.host, host);
|
|
324
|
+
if (good)
|
|
325
|
+
ok++;
|
|
326
|
+
else
|
|
327
|
+
failed++;
|
|
328
|
+
}
|
|
329
|
+
return { total: rows.length, ok, failed, sessionFetches, tunnelConnections, tunnelBytes,
|
|
330
|
+
byHost: [...byHost.entries()].map(([host, value]) => ({ host, ...value })).sort((a, b) => b.count + b.tunnel - a.count - a.tunnel) };
|
|
331
|
+
}
|
|
332
|
+
export function readSummary() {
|
|
333
|
+
try {
|
|
334
|
+
return summarize(readFileSync(auditFile(), "utf8"));
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return summarize("");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Fetcher } from "./relay-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fetch a page on the OWNER's machine, choosing the session per the login policy.
|
|
4
|
+
*
|
|
5
|
+
* - A host the owner explicitly signed into (`isSessionDomain`) is fetched in the
|
|
6
|
+
* PERSISTENT profile — as them, with their cookies. That is the deliberate,
|
|
7
|
+
* opt-in "fetch as me".
|
|
8
|
+
* - Every other host is fetched in a fresh EPHEMERAL context with no cookies — the
|
|
9
|
+
* residential IP, but not the owner's accounts. So a prompt-injected pod fetching
|
|
10
|
+
* gmail.com gets a logged-out page, not the owner's inbox.
|
|
11
|
+
*
|
|
12
|
+
* Both contexts: one page per request, always closed; warm and recycled after N pages;
|
|
13
|
+
* scrape and return bytes, never an eval channel or a live handle.
|
|
14
|
+
*/
|
|
15
|
+
export interface BrowserOptions {
|
|
16
|
+
profileDir: string;
|
|
17
|
+
channel?: string;
|
|
18
|
+
navTimeoutMs?: number;
|
|
19
|
+
/** Hard ceiling on a WHOLE fetch (launch + nav + read). A browser that stalls past
|
|
20
|
+
* this is aborted and torn down so it can't hang the relay — see `fetch`. */
|
|
21
|
+
fetchDeadlineMs?: number;
|
|
22
|
+
recycleAfter?: number;
|
|
23
|
+
headless?: boolean;
|
|
24
|
+
/** Does this host use the owner's signed-in session? Read from config per fetch. */
|
|
25
|
+
isSessionDomain: (host: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare class BrowserFetcher {
|
|
28
|
+
private readonly opts;
|
|
29
|
+
private browser;
|
|
30
|
+
private session;
|
|
31
|
+
private served;
|
|
32
|
+
constructor(opts: BrowserOptions);
|
|
33
|
+
private pw;
|
|
34
|
+
/**
|
|
35
|
+
* Launch, preferring the owner's installed Chrome (no download) and falling back to
|
|
36
|
+
* Playwright's bundled Chromium. A missing browser entirely is a clear, actionable
|
|
37
|
+
* error, not a stack trace.
|
|
38
|
+
*/
|
|
39
|
+
private launch;
|
|
40
|
+
private sessionCtx;
|
|
41
|
+
private cleanCtx;
|
|
42
|
+
/**
|
|
43
|
+
* WATCHDOG: a fetch that stalls (a site refusing a headless browser, a hung context,
|
|
44
|
+
* a wedged persistent profile) must NEVER hang the relay — that was the whole
|
|
45
|
+
* "relay running but nothing comes back" failure (live-caught on reddit, 2026-08-03).
|
|
46
|
+
* The nav timeout only bounds `goto`; launch, `content()`, and context creation could
|
|
47
|
+
* hang unbounded. So the whole fetch races a hard deadline, and on a blown deadline we
|
|
48
|
+
* TEAR DOWN the browser so the NEXT fetch relaunches clean rather than inheriting a
|
|
49
|
+
* wedged one.
|
|
50
|
+
*/
|
|
51
|
+
readonly fetch: Fetcher;
|
|
52
|
+
private doFetch;
|
|
53
|
+
/** Drop the wedged context so the next fetch launches a fresh one. */
|
|
54
|
+
private resetBrowser;
|
|
55
|
+
/** Remove only one site's relay-profile cookies; clean contexts and siblings stay intact. */
|
|
56
|
+
revokeDomain(domain: string): Promise<void>;
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
}
|