@saadnvd1/agent-os 0.3.499 → 0.3.501
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/README.md +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +72 -0
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted tokens: the API's price ratios by token type and model family, as
|
|
3
|
+
* a proxy for how much of the plan's limits a call uses. Opus input = 1.
|
|
4
|
+
* An estimate, not what the plan charges.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ANTHROPIC } from "../models/roles";
|
|
8
|
+
|
|
9
|
+
export type Family = "opus" | "sonnet" | "haiku";
|
|
10
|
+
|
|
11
|
+
export interface TokenRow {
|
|
12
|
+
model: string | null;
|
|
13
|
+
provider?: string | null;
|
|
14
|
+
input_tokens: number;
|
|
15
|
+
cache_read_tokens: number;
|
|
16
|
+
cache_write_5m_tokens: number;
|
|
17
|
+
cache_write_1h_tokens: number;
|
|
18
|
+
output_tokens: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Unknown Claude ids weigh as Opus.
|
|
22
|
+
export const family = (model: string | null | undefined): Family => {
|
|
23
|
+
const m = (model ?? "").toLowerCase();
|
|
24
|
+
return m.includes("sonnet")
|
|
25
|
+
? "sonnet"
|
|
26
|
+
: m.includes("haiku")
|
|
27
|
+
? "haiku"
|
|
28
|
+
: "opus";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MULT: Record<Family, number> = { opus: 1, sonnet: 0.5, haiku: 0.25 };
|
|
32
|
+
const READ: Record<Family, number> = { opus: 0.1, sonnet: 0.1, haiku: 0.05 };
|
|
33
|
+
|
|
34
|
+
export const WEIGHTS = {
|
|
35
|
+
input: 1,
|
|
36
|
+
cacheWrite5m: 1.25,
|
|
37
|
+
cacheWrite1h: 2,
|
|
38
|
+
output: 5,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export function weighted(r: TokenRow): number {
|
|
42
|
+
if (r.provider && r.provider !== ANTHROPIC) return 0;
|
|
43
|
+
const f = family(r.model);
|
|
44
|
+
return (
|
|
45
|
+
MULT[f] *
|
|
46
|
+
(WEIGHTS.input * r.input_tokens +
|
|
47
|
+
WEIGHTS.cacheWrite5m * r.cache_write_5m_tokens +
|
|
48
|
+
WEIGHTS.cacheWrite1h * r.cache_write_1h_tokens +
|
|
49
|
+
WEIGHTS.output * r.output_tokens) +
|
|
50
|
+
READ[f] * r.cache_read_tokens
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const CTX_LIMIT = 200_000;
|
|
55
|
+
|
|
56
|
+
// What the call sent: fresh input, cache writes and cache reads.
|
|
57
|
+
export const ctxOf = (r: TokenRow): number =>
|
|
58
|
+
r.input_tokens +
|
|
59
|
+
r.cache_read_tokens +
|
|
60
|
+
r.cache_write_5m_tokens +
|
|
61
|
+
r.cache_write_1h_tokens;
|
|
62
|
+
|
|
63
|
+
export const overLimit = (ctx: number | null) =>
|
|
64
|
+
ctx !== null && ctx > CTX_LIMIT;
|
package/lib/usage/windows.ts
CHANGED
|
@@ -9,6 +9,7 @@ import os from "os";
|
|
|
9
9
|
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
10
10
|
import { InputQueue } from "../chat/queue";
|
|
11
11
|
import { readUsage } from "../orchestrator/usage";
|
|
12
|
+
import { currentWindow, type WindowReading } from "./rate-limits";
|
|
12
13
|
|
|
13
14
|
export interface WindowView {
|
|
14
15
|
pct: number;
|
|
@@ -17,6 +18,8 @@ export interface WindowView {
|
|
|
17
18
|
// Seconds until it runs out at the current rate, when before it resets.
|
|
18
19
|
capsIn?: number | null;
|
|
19
20
|
burnPerMin?: number | null;
|
|
21
|
+
// Worked out from recorded usage, not read from the account.
|
|
22
|
+
estimated?: boolean;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface UsageWindows {
|
|
@@ -108,8 +111,30 @@ export function fromPlan(
|
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
const fromReading = (
|
|
115
|
+
r: WindowReading | null,
|
|
116
|
+
nowMs: number
|
|
117
|
+
): WindowView | null =>
|
|
118
|
+
r
|
|
119
|
+
? {
|
|
120
|
+
pct: Math.round(r.pct),
|
|
121
|
+
resetsIn: r.resetsAt
|
|
122
|
+
? Math.max(0, Math.round((r.resetsAt - nowMs) / 1000))
|
|
123
|
+
: null,
|
|
124
|
+
estimated: r.estimated || undefined,
|
|
125
|
+
}
|
|
126
|
+
: null;
|
|
127
|
+
|
|
111
128
|
export async function usageWindows(): Promise<UsageWindows> {
|
|
112
129
|
const sampled = readUsage();
|
|
130
|
+
const read = (w: "five_hour" | "seven_day") => {
|
|
131
|
+
try {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
return fromReading(currentWindow(w, now), now);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
113
138
|
const plan = await planLimits();
|
|
114
139
|
const fiveHour =
|
|
115
140
|
"window" in sampled && sampled.window
|
|
@@ -119,8 +144,8 @@ export async function usageWindows(): Promise<UsageWindows> {
|
|
|
119
144
|
capsIn: sampled.window.capsIn,
|
|
120
145
|
burnPerMin: sampled.window.burnPerMin,
|
|
121
146
|
}
|
|
122
|
-
: fromPlan(plan.limits?.five_hour);
|
|
123
|
-
const sevenDay = fromPlan(plan.limits?.seven_day);
|
|
147
|
+
: (fromPlan(plan.limits?.five_hour) ?? read("five_hour"));
|
|
148
|
+
const sevenDay = fromPlan(plan.limits?.seven_day) ?? read("seven_day");
|
|
124
149
|
const note =
|
|
125
150
|
!sevenDay || !fiveHour
|
|
126
151
|
? (plan.error ??
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.501",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-os": "scripts/agent-os"
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
"data/",
|
|
13
13
|
"hooks/",
|
|
14
14
|
"lib/",
|
|
15
|
-
"!lib/
|
|
16
|
-
"!lib/connect/test-relay.ts",
|
|
15
|
+
"!lib/security/test-pki.ts",
|
|
17
16
|
"!lib/**/*.test.ts",
|
|
18
17
|
"mcp/",
|
|
19
18
|
"public/",
|
|
@@ -121,7 +120,6 @@
|
|
|
121
120
|
"@xterm/addon-webgl": "^0.19.0",
|
|
122
121
|
"@xterm/headless": "6.0.0",
|
|
123
122
|
"@xterm/xterm": "^6.0.0",
|
|
124
|
-
"acme-client": "^5.4.0",
|
|
125
123
|
"better-sqlite3": "^13.0.3",
|
|
126
124
|
"class-variance-authority": "^0.7.1",
|
|
127
125
|
"clsx": "^2.1.1",
|
package/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { stackStartGate } from "./lib/orchestrator/brakes";
|
|
|
13
13
|
import { buildId } from "./lib/build";
|
|
14
14
|
import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
|
|
15
15
|
import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
|
|
16
|
+
import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
|
|
16
17
|
import { resumePhoneOutbox } from "./lib/notify";
|
|
17
18
|
import {
|
|
18
19
|
bindAddresses,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
} from "./lib/security/next-internals";
|
|
40
41
|
import { lanEnabled } from "./lib/security/network-settings";
|
|
41
42
|
import { startConnect } from "./lib/connect/serve";
|
|
43
|
+
import { serverPort } from "./lib/connect/config";
|
|
42
44
|
import { startTailnetHttps } from "./lib/security/tailnet-https";
|
|
43
45
|
import {
|
|
44
46
|
setStatusSource,
|
|
@@ -73,9 +75,7 @@ const dev = process.env.NODE_ENV !== "production";
|
|
|
73
75
|
const hostname = "127.0.0.1";
|
|
74
76
|
|
|
75
77
|
// Support: npm run dev -- -p 3012
|
|
76
|
-
const
|
|
77
|
-
const portArg = pFlagIndex !== -1 ? process.argv[pFlagIndex + 1] : undefined;
|
|
78
|
-
const port = parseInt(portArg || process.env.PORT || "3011", 10);
|
|
78
|
+
const port = serverPort();
|
|
79
79
|
claimAgentosEnv(port);
|
|
80
80
|
// Demo mode: seeded data, nothing that runs code (lib/security/demo).
|
|
81
81
|
const demo = demoMode();
|
|
@@ -313,7 +313,8 @@ app.prepare().then(async () => {
|
|
|
313
313
|
: []),
|
|
314
314
|
];
|
|
315
315
|
};
|
|
316
|
-
// AgentOS Connect: reachable at
|
|
316
|
+
// AgentOS Connect: reachable at m-<16 hex>.runagentos.com through a
|
|
317
|
+
// Cloudflare tunnel, served on a listener of its own (lib/connect/serve).
|
|
317
318
|
const connect = demo
|
|
318
319
|
? { hostname: () => null }
|
|
319
320
|
: startConnect({ onRequest, onUpgrade }, port);
|
|
@@ -393,6 +394,8 @@ app.prepare().then(async () => {
|
|
|
393
394
|
// Schedules tick here, once a minute, and nowhere else.
|
|
394
395
|
if (schedulesEnabled(process.env)) startScheduler(realDeps);
|
|
395
396
|
resumePhoneOutbox();
|
|
397
|
+
// The usage ledger's daily rollup and its ratchet alerts.
|
|
398
|
+
if (usageRollupEnabled(process.env)) startUsageRollup();
|
|
396
399
|
// Machine load: a gauge, notes on heavy commands, one alert when red.
|
|
397
400
|
if (loadEnabled()) startLoadMonitor();
|
|
398
401
|
});
|
package/stores/usageUi.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { proxy } from "valtio";
|
|
2
2
|
|
|
3
|
-
export type UsageTab = "spend" | "models" | "compare";
|
|
3
|
+
export type UsageTab = "daily" | "spend" | "models" | "compare";
|
|
4
4
|
|
|
5
5
|
export const usageUi = proxy<{ open: boolean; tab: UsageTab }>({
|
|
6
6
|
open: false,
|
|
7
|
-
tab: "
|
|
7
|
+
tab: "daily",
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
export const usageUiActions = {
|
|
11
|
-
open: (tab: UsageTab = "
|
|
11
|
+
open: (tab: UsageTab = "daily") => {
|
|
12
12
|
usageUi.tab = tab;
|
|
13
13
|
usageUi.open = true;
|
|
14
14
|
},
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { X509Certificate } from "crypto";
|
|
2
|
-
|
|
3
|
-
export interface CertStatus {
|
|
4
|
-
expiresAt: string;
|
|
5
|
-
daysLeft: number;
|
|
6
|
-
/** Set when it needs renewing, or can't be read. */
|
|
7
|
-
warning?: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const RENEW_AT_DAYS = 30;
|
|
11
|
-
|
|
12
|
-
export function certStatus(pem: string, now = Date.now()): CertStatus {
|
|
13
|
-
try {
|
|
14
|
-
const expires = new Date(new X509Certificate(pem).validTo);
|
|
15
|
-
const daysLeft = Math.floor((expires.getTime() - now) / 86_400_000);
|
|
16
|
-
return {
|
|
17
|
-
expiresAt: expires.toISOString(),
|
|
18
|
-
daysLeft,
|
|
19
|
-
warning:
|
|
20
|
-
daysLeft <= RENEW_AT_DAYS
|
|
21
|
-
? `The Connect certificate expires in ${daysLeft} days. Run agent-os connect --renew.`
|
|
22
|
-
: undefined,
|
|
23
|
-
};
|
|
24
|
-
} catch {
|
|
25
|
-
return {
|
|
26
|
-
expiresAt: "",
|
|
27
|
-
daysLeft: 0,
|
|
28
|
-
warning:
|
|
29
|
-
"The Connect certificate can't be read. Run agent-os connect --renew.",
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev and operator path only: write ACME TXT records straight to Cloudflare
|
|
3
|
-
* with a zone-scoped token. Machines in the wild will go through the control
|
|
4
|
-
* plane's broker instead, which only lets a machine touch its own name.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { DnsChallenge } from "./enrol";
|
|
8
|
-
|
|
9
|
-
const API = "https://api.cloudflare.com/client/v4";
|
|
10
|
-
|
|
11
|
-
export function cloudflareDns(token: string, zone: string): DnsChallenge {
|
|
12
|
-
const call = async (path: string, init: RequestInit = {}) => {
|
|
13
|
-
const res = await fetch(`${API}${path}`, {
|
|
14
|
-
...init,
|
|
15
|
-
headers: {
|
|
16
|
-
Authorization: `Bearer ${token}`,
|
|
17
|
-
"Content-Type": "application/json",
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
const body = (await res.json()) as {
|
|
21
|
-
success: boolean;
|
|
22
|
-
result: unknown;
|
|
23
|
-
errors: unknown;
|
|
24
|
-
};
|
|
25
|
-
if (!body.success)
|
|
26
|
-
throw new Error(`cloudflare: ${JSON.stringify(body.errors)}`);
|
|
27
|
-
return body.result;
|
|
28
|
-
};
|
|
29
|
-
let zoneId: string | null = null;
|
|
30
|
-
const zid = async () =>
|
|
31
|
-
(zoneId ??=
|
|
32
|
-
((await call(`/zones?name=${zone}`)) as { id: string }[])[0]?.id ?? null);
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
async set(name, value) {
|
|
36
|
-
await call(`/zones/${await zid()}/dns_records`, {
|
|
37
|
-
method: "POST",
|
|
38
|
-
body: JSON.stringify({ type: "TXT", name, content: value, ttl: 60 }),
|
|
39
|
-
});
|
|
40
|
-
// Give the authoritative servers a moment before Let's Encrypt looks.
|
|
41
|
-
await new Promise((r) => setTimeout(r, 15_000));
|
|
42
|
-
},
|
|
43
|
-
async clear(name, value) {
|
|
44
|
-
const records = (await call(
|
|
45
|
-
`/zones/${await zid()}/dns_records?type=TXT&name=${encodeURIComponent(name)}`
|
|
46
|
-
)) as { id: string; content: string }[];
|
|
47
|
-
for (const r of records.filter(
|
|
48
|
-
(r) => r.content.replace(/"/g, "") === value
|
|
49
|
-
)) {
|
|
50
|
-
await call(`/zones/${await zid()}/dns_records/${r.id}`, {
|
|
51
|
-
method: "DELETE",
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
package/lib/connect/frames.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Connect tunnel's wire format: one WebSocket from a machine to the
|
|
3
|
-
* relay, carrying many TCP streams. Each binary message is one frame:
|
|
4
|
-
*
|
|
5
|
-
* type (u8) | stream id (u32 BE) | payload
|
|
6
|
-
*
|
|
7
|
-
* The relay only ever moves the TLS bytes of user connections; it never
|
|
8
|
-
* holds a key that could read them.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export const Frame = {
|
|
12
|
-
/** machine → relay: {machineId, ts, nonce, sig} */
|
|
13
|
-
HELLO: 0,
|
|
14
|
-
/** relay → machine: a phone connected; payload {remote} */
|
|
15
|
-
OPEN: 1,
|
|
16
|
-
DATA: 2,
|
|
17
|
-
/** either side: this stream is done */
|
|
18
|
-
CLOSE: 3,
|
|
19
|
-
/** relay → machine: {hostname} once HELLO checks out */
|
|
20
|
-
READY: 4,
|
|
21
|
-
/** either side: stop / start sending DATA for this stream */
|
|
22
|
-
PAUSE: 5,
|
|
23
|
-
RESUME: 6,
|
|
24
|
-
/** relay → machine: refused, payload {error}; the socket closes after */
|
|
25
|
-
DENIED: 7,
|
|
26
|
-
} as const;
|
|
27
|
-
|
|
28
|
-
export type FrameType = (typeof Frame)[keyof typeof Frame];
|
|
29
|
-
|
|
30
|
-
export interface Decoded {
|
|
31
|
-
type: FrameType;
|
|
32
|
-
stream: number;
|
|
33
|
-
payload: Buffer;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function encode(
|
|
37
|
-
type: FrameType,
|
|
38
|
-
stream = 0,
|
|
39
|
-
payload?: Buffer | string | object
|
|
40
|
-
): Buffer {
|
|
41
|
-
const body =
|
|
42
|
-
payload === undefined
|
|
43
|
-
? Buffer.alloc(0)
|
|
44
|
-
: Buffer.isBuffer(payload)
|
|
45
|
-
? payload
|
|
46
|
-
: Buffer.from(
|
|
47
|
-
typeof payload === "string" ? payload : JSON.stringify(payload)
|
|
48
|
-
);
|
|
49
|
-
const head = Buffer.alloc(5);
|
|
50
|
-
head.writeUInt8(type, 0);
|
|
51
|
-
head.writeUInt32BE(stream, 1);
|
|
52
|
-
return Buffer.concat([head, body]);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function decode(data: Buffer): Decoded | null {
|
|
56
|
-
if (data.length < 5) return null;
|
|
57
|
-
const type = data.readUInt8(0);
|
|
58
|
-
if (type > Frame.DENIED) return null;
|
|
59
|
-
return {
|
|
60
|
-
type: type as FrameType,
|
|
61
|
-
stream: data.readUInt32BE(1),
|
|
62
|
-
payload: data.subarray(5),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export const json = <T>(payload: Buffer): T | null => {
|
|
67
|
-
try {
|
|
68
|
-
return JSON.parse(payload.toString("utf8")) as T;
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
};
|
package/lib/connect/identity.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A machine proves who it is to the relay with an Ed25519 key that never
|
|
3
|
-
* leaves it. HELLO signs (machine id, time, nonce); the relay checks the
|
|
4
|
-
* signature against the public key registered for that id, the time
|
|
5
|
-
* against its clock, and refuses a nonce it has already seen.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import crypto from "crypto";
|
|
9
|
-
|
|
10
|
-
const SKEW_MS = 60_000;
|
|
11
|
-
const CONTEXT = "agentos-connect-hello";
|
|
12
|
-
|
|
13
|
-
export interface Hello {
|
|
14
|
-
machineId: string;
|
|
15
|
-
ts: number;
|
|
16
|
-
nonce: string;
|
|
17
|
-
sig: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function generateMachineKey(): {
|
|
21
|
-
publicKey: string;
|
|
22
|
-
privateKey: string;
|
|
23
|
-
} {
|
|
24
|
-
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
25
|
-
return {
|
|
26
|
-
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
27
|
-
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const message = (h: Omit<Hello, "sig">) =>
|
|
32
|
-
Buffer.from(`${CONTEXT}:${h.machineId}:${h.ts}:${h.nonce}`);
|
|
33
|
-
|
|
34
|
-
export function signHello(
|
|
35
|
-
machineId: string,
|
|
36
|
-
privateKeyPem: string,
|
|
37
|
-
now = Date.now()
|
|
38
|
-
): Hello {
|
|
39
|
-
const h = {
|
|
40
|
-
machineId,
|
|
41
|
-
ts: now,
|
|
42
|
-
nonce: crypto.randomBytes(16).toString("base64url"),
|
|
43
|
-
};
|
|
44
|
-
const sig = crypto
|
|
45
|
-
.sign(null, message(h), privateKeyPem)
|
|
46
|
-
.toString("base64url");
|
|
47
|
-
return { ...h, sig };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class NonceCache {
|
|
51
|
-
private seen = new Map<string, number>();
|
|
52
|
-
/** False if this nonce was already used inside the window. */
|
|
53
|
-
remember(nonce: string, now = Date.now()): boolean {
|
|
54
|
-
for (const [n, at] of this.seen)
|
|
55
|
-
if (at < now - 2 * SKEW_MS) this.seen.delete(n);
|
|
56
|
-
if (this.seen.has(nonce)) return false;
|
|
57
|
-
this.seen.set(nonce, now);
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export type HelloCheck =
|
|
63
|
-
| { ok: true; machineId: string }
|
|
64
|
-
| { ok: false; error: string };
|
|
65
|
-
|
|
66
|
-
export function verifyHello(
|
|
67
|
-
hello: Partial<Hello> | null,
|
|
68
|
-
publicKeyPem: string | null,
|
|
69
|
-
nonces: NonceCache,
|
|
70
|
-
now = Date.now()
|
|
71
|
-
): HelloCheck {
|
|
72
|
-
if (
|
|
73
|
-
!hello?.machineId ||
|
|
74
|
-
!hello.sig ||
|
|
75
|
-
!hello.nonce ||
|
|
76
|
-
typeof hello.ts !== "number"
|
|
77
|
-
) {
|
|
78
|
-
return { ok: false, error: "malformed hello" };
|
|
79
|
-
}
|
|
80
|
-
if (!publicKeyPem) return { ok: false, error: "unknown machine" };
|
|
81
|
-
if (Math.abs(now - hello.ts) > SKEW_MS)
|
|
82
|
-
return { ok: false, error: "clock skew" };
|
|
83
|
-
let valid = false;
|
|
84
|
-
try {
|
|
85
|
-
valid = crypto.verify(
|
|
86
|
-
null,
|
|
87
|
-
message(hello as Hello),
|
|
88
|
-
publicKeyPem,
|
|
89
|
-
Buffer.from(hello.sig, "base64url")
|
|
90
|
-
);
|
|
91
|
-
} catch {
|
|
92
|
-
valid = false;
|
|
93
|
-
}
|
|
94
|
-
if (!valid) return { ok: false, error: "bad signature" };
|
|
95
|
-
if (!nonces.remember(hello.nonce, now))
|
|
96
|
-
return { ok: false, error: "replayed" };
|
|
97
|
-
return { ok: true, machineId: hello.machineId };
|
|
98
|
-
}
|
package/lib/connect/mux.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Both ends of the tunnel: a map of TunnelStreams over one WebSocket. The
|
|
3
|
-
* relay opens streams (a phone connected); the machine receives them.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type WebSocket from "ws";
|
|
7
|
-
import { decode, encode, Frame, json, type Decoded } from "./frames";
|
|
8
|
-
import { TunnelStream } from "./stream";
|
|
9
|
-
|
|
10
|
-
// Past this much unsent data, writers wait for the socket to drain.
|
|
11
|
-
const HIGH_WATER = 1 << 20;
|
|
12
|
-
export const MAX_STREAMS = 256;
|
|
13
|
-
|
|
14
|
-
export interface OpenMeta {
|
|
15
|
-
remote?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class Mux {
|
|
19
|
-
readonly streams = new Map<number, TunnelStream>();
|
|
20
|
-
bytesIn = 0;
|
|
21
|
-
bytesOut = 0;
|
|
22
|
-
private next = 1;
|
|
23
|
-
|
|
24
|
-
constructor(
|
|
25
|
-
private readonly ws: WebSocket,
|
|
26
|
-
private readonly onOpen?: (stream: TunnelStream, meta: OpenMeta) => void
|
|
27
|
-
) {}
|
|
28
|
-
|
|
29
|
-
private send = (frame: Buffer): boolean => {
|
|
30
|
-
if (this.ws.readyState !== this.ws.OPEN) return true;
|
|
31
|
-
if (frame[0] === Frame.DATA) this.bytesOut += frame.length - 5;
|
|
32
|
-
this.ws.send(frame);
|
|
33
|
-
return this.ws.bufferedAmount < HIGH_WATER;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
private drained = (cb: () => void): void => {
|
|
37
|
-
const check = () => {
|
|
38
|
-
if (
|
|
39
|
-
this.ws.readyState !== this.ws.OPEN ||
|
|
40
|
-
this.ws.bufferedAmount < HIGH_WATER
|
|
41
|
-
)
|
|
42
|
-
cb();
|
|
43
|
-
else setTimeout(check, 20);
|
|
44
|
-
};
|
|
45
|
-
setTimeout(check, 20);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
private stream(id: number): TunnelStream {
|
|
49
|
-
const s = new TunnelStream(
|
|
50
|
-
id,
|
|
51
|
-
this.send,
|
|
52
|
-
(done) => this.streams.delete(done),
|
|
53
|
-
this.drained
|
|
54
|
-
);
|
|
55
|
-
this.streams.set(id, s);
|
|
56
|
-
return s;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Relay side: a new client connection, or null if the tunnel is going away. */
|
|
60
|
-
open(meta: OpenMeta): TunnelStream | null {
|
|
61
|
-
if (this.ws.readyState !== this.ws.OPEN) return null;
|
|
62
|
-
const id = this.next++;
|
|
63
|
-
const s = this.stream(id);
|
|
64
|
-
s.remoteAddress = meta.remote;
|
|
65
|
-
this.ws.send(encode(Frame.OPEN, id, meta));
|
|
66
|
-
return s;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Feed one WebSocket message in. Returns frames this layer doesn't own. */
|
|
70
|
-
handle(data: Buffer): Decoded | null {
|
|
71
|
-
const f = decode(data);
|
|
72
|
-
if (!f) return null;
|
|
73
|
-
const s = this.streams.get(f.stream);
|
|
74
|
-
switch (f.type) {
|
|
75
|
-
case Frame.OPEN: {
|
|
76
|
-
if (!this.onOpen || this.streams.has(f.stream)) return null;
|
|
77
|
-
// The relay doesn't get to open unlimited connections into this machine.
|
|
78
|
-
if (this.streams.size >= MAX_STREAMS) {
|
|
79
|
-
this.ws.send(encode(Frame.CLOSE, f.stream));
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
const meta = json<OpenMeta>(f.payload) ?? {};
|
|
83
|
-
const created = this.stream(f.stream);
|
|
84
|
-
created.remoteAddress = meta.remote;
|
|
85
|
-
this.onOpen(created, meta);
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
case Frame.DATA:
|
|
89
|
-
this.bytesIn += f.payload.length;
|
|
90
|
-
s?.receive(Buffer.from(f.payload));
|
|
91
|
-
return null;
|
|
92
|
-
case Frame.CLOSE:
|
|
93
|
-
s?.remoteClosed();
|
|
94
|
-
return null;
|
|
95
|
-
case Frame.PAUSE:
|
|
96
|
-
s?.setRemotePaused(true);
|
|
97
|
-
return null;
|
|
98
|
-
case Frame.RESUME:
|
|
99
|
-
s?.setRemotePaused(false);
|
|
100
|
-
return null;
|
|
101
|
-
default:
|
|
102
|
-
return f;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
closeAll(): void {
|
|
107
|
-
for (const s of this.streams.values()) s.remoteClosed();
|
|
108
|
-
this.streams.clear();
|
|
109
|
-
}
|
|
110
|
-
}
|
package/lib/connect/stream.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One tunnelled TCP connection, as a Duplex. Writes become DATA frames; DATA
|
|
3
|
-
* frames from the other side are pushed in. When our reader falls behind we
|
|
4
|
-
* tell the other side to PAUSE that stream, so one slow terminal can't stall
|
|
5
|
-
* the others sharing the WebSocket.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { Duplex } from "stream";
|
|
9
|
-
import { encode, Frame } from "./frames";
|
|
10
|
-
|
|
11
|
-
export type Send = (frame: Buffer) => boolean;
|
|
12
|
-
|
|
13
|
-
const MAX_FRAME = 64 * 1024;
|
|
14
|
-
// PAUSE goes out at the 64 KB high-water mark; this is far past it.
|
|
15
|
-
export const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
16
|
-
|
|
17
|
-
export class TunnelStream extends Duplex {
|
|
18
|
-
private paused = false;
|
|
19
|
-
private remotePaused = false;
|
|
20
|
-
private waiting: (() => void)[] = [];
|
|
21
|
-
private closedByRemote = false;
|
|
22
|
-
/** Where the far end says the connection came from; informational only. */
|
|
23
|
-
remoteAddress?: string;
|
|
24
|
-
|
|
25
|
-
constructor(
|
|
26
|
-
readonly id: number,
|
|
27
|
-
private readonly send: Send,
|
|
28
|
-
private readonly onDone: (id: number) => void,
|
|
29
|
-
private readonly drained: (cb: () => void) => void
|
|
30
|
-
) {
|
|
31
|
-
super({ allowHalfOpen: false });
|
|
32
|
-
this.once("close", () => this.onDone(this.id));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** DATA from the far end. */
|
|
36
|
-
receive(chunk: Buffer): void {
|
|
37
|
-
// A far end that ignores PAUSE can't make us buffer without limit.
|
|
38
|
-
if (this.readableLength + chunk.length > MAX_BUFFERED) {
|
|
39
|
-
// No error object: nothing listens for "error" on these streams, and an
|
|
40
|
-
// unheard error event would take the whole process down.
|
|
41
|
-
this.destroy();
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (!this.push(chunk) && !this.paused) {
|
|
45
|
-
this.paused = true;
|
|
46
|
-
this.send(encode(Frame.PAUSE, this.id));
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The far end asked us to stop or start sending. */
|
|
51
|
-
setRemotePaused(paused: boolean): void {
|
|
52
|
-
this.remotePaused = paused;
|
|
53
|
-
if (!paused) this.waiting.splice(0).forEach((go) => go());
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The far end closed. */
|
|
57
|
-
remoteClosed(): void {
|
|
58
|
-
this.closedByRemote = true;
|
|
59
|
-
this.push(null);
|
|
60
|
-
this.destroy();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
_read(): void {
|
|
64
|
-
if (this.paused) {
|
|
65
|
-
this.paused = false;
|
|
66
|
-
this.send(encode(Frame.RESUME, this.id));
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
_write(
|
|
71
|
-
chunk: Buffer,
|
|
72
|
-
_enc: BufferEncoding,
|
|
73
|
-
done: (err?: Error) => void
|
|
74
|
-
): void {
|
|
75
|
-
// Frames stay well under the relay's 1 MB message limit.
|
|
76
|
-
const pieces: Buffer[] = [];
|
|
77
|
-
for (let i = 0; i < chunk.length; i += MAX_FRAME) {
|
|
78
|
-
pieces.push(chunk.subarray(i, i + MAX_FRAME));
|
|
79
|
-
}
|
|
80
|
-
const next = (): void => {
|
|
81
|
-
const piece = pieces.shift();
|
|
82
|
-
if (!piece) return done();
|
|
83
|
-
if (this.remotePaused) return void this.waiting.push(next);
|
|
84
|
-
// send() is false while the WebSocket's buffer is full; wait it out.
|
|
85
|
-
if (this.send(encode(Frame.DATA, this.id, piece))) next();
|
|
86
|
-
else this.drained(next);
|
|
87
|
-
};
|
|
88
|
-
next();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
_destroy(err: Error | null, done: (err: Error | null) => void): void {
|
|
92
|
-
if (!this.closedByRemote) this.send(encode(Frame.CLOSE, this.id));
|
|
93
|
-
done(err);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// What http/tls expect of a socket.
|
|
97
|
-
setNoDelay(): this {
|
|
98
|
-
return this;
|
|
99
|
-
}
|
|
100
|
-
setKeepAlive(): this {
|
|
101
|
-
return this;
|
|
102
|
-
}
|
|
103
|
-
setTimeout(): this {
|
|
104
|
-
return this;
|
|
105
|
-
}
|
|
106
|
-
}
|