@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
package/lib/connect/serve.ts
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serve AgentOS through Connect
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Serve AgentOS through Connect. Cloudflare's edge ends TLS for
|
|
3
|
+
* m-<16 hex>.runagentos.com and cloudflared delivers each request to a
|
|
4
|
+
* listener of its own on 127.0.0.1:<tunnel port>. That listener:
|
|
5
|
+
*
|
|
6
|
+
* - marks every socket it accepts, so the gate never treats it as this
|
|
7
|
+
* machine, the tailnet or AGENTOS_AUTH=off: a paired device or nothing;
|
|
8
|
+
* - takes only the machine's own Host (cloudflared pins it) and strips
|
|
9
|
+
* X-Forwarded-Host, Forwarded and X-Original-Host before any handler.
|
|
6
10
|
*
|
|
7
11
|
* Connect follows connect.json while running: `agent-os disconnect` or the
|
|
8
|
-
* switch in Devices
|
|
12
|
+
* switch in Devices stops cloudflared and closes every tunnel connection at
|
|
9
13
|
* once. Nothing here may stop AgentOS from starting.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import fs from "fs";
|
|
13
|
-
import
|
|
17
|
+
import http from "http";
|
|
14
18
|
import path from "path";
|
|
15
19
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
20
|
+
import type { Socket } from "net";
|
|
16
21
|
import type { Duplex } from "stream";
|
|
17
|
-
import {
|
|
22
|
+
import { markTunnelSocket } from "@/lib/security/auth";
|
|
23
|
+
import {
|
|
24
|
+
connectDir,
|
|
25
|
+
connectTunnelPort,
|
|
26
|
+
readConnect,
|
|
27
|
+
saveConnect,
|
|
28
|
+
type ConnectConfig,
|
|
29
|
+
} from "./config";
|
|
18
30
|
import { shouldStartConnect, takeConnectLock } from "./guard";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
31
|
+
import { checkApiUrl, requestTunnel, type Tunnel } from "./control-api";
|
|
32
|
+
import { ensureCloudflared } from "./cloudflared";
|
|
33
|
+
import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
|
|
21
34
|
|
|
22
35
|
export interface Handlers {
|
|
23
36
|
onRequest: (req: IncomingMessage, res: ServerResponse) => void;
|
|
@@ -26,63 +39,108 @@ export interface Handlers {
|
|
|
26
39
|
|
|
27
40
|
export interface ConnectStatus {
|
|
28
41
|
state: "off" | "refused" | "error" | "connecting" | "up" | "denied";
|
|
42
|
+
/** Linked to the Connect service (it may not have a name yet). */
|
|
43
|
+
enrolled?: boolean;
|
|
29
44
|
hostname?: string;
|
|
30
45
|
reason?: string;
|
|
31
|
-
cert?: CertStatus;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Running {
|
|
35
|
-
server: https.Server;
|
|
36
|
-
tunnel: ReturnType<typeof startTunnel>;
|
|
37
|
-
sockets: Set<Duplex>;
|
|
38
|
-
hostname: string;
|
|
39
|
-
release: () => void;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
const g = globalThis as unknown as { __agentosConnect?: () => ConnectStatus };
|
|
43
49
|
export const connectStatus = (): ConnectStatus =>
|
|
44
50
|
g.__agentosConnect?.() ?? { state: "off" };
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
// Headers that would let a caller name a different host to Next or to us.
|
|
53
|
+
const HOST_OVERRIDES = ["x-forwarded-host", "forwarded", "x-original-host"];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The listener cloudflared talks to. `hostname()` is the only Host it
|
|
57
|
+
* serves; until the service has named the machine, nothing is served.
|
|
58
|
+
*/
|
|
59
|
+
export function tunnelListener(
|
|
60
|
+
hostname: () => string,
|
|
61
|
+
handlers: Handlers
|
|
62
|
+
): http.Server {
|
|
63
|
+
const pinned = (req: IncomingMessage) => {
|
|
64
|
+
for (const h of HOST_OVERRIDES) delete req.headers[h];
|
|
65
|
+
const want = hostname();
|
|
66
|
+
return !!want && (req.headers.host ?? "").toLowerCase() === want;
|
|
67
|
+
};
|
|
68
|
+
const server = http.createServer((req, res) => {
|
|
69
|
+
if (!pinned(req)) {
|
|
70
|
+
res.statusCode = 421;
|
|
71
|
+
return res.end();
|
|
72
|
+
}
|
|
73
|
+
handlers.onRequest(req, res);
|
|
74
|
+
});
|
|
75
|
+
server.on("connection", (s: Socket) => markTunnelSocket(s));
|
|
76
|
+
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
77
|
+
if (!pinned(req)) {
|
|
78
|
+
socket.write("HTTP/1.1 421 Misdirected Request\r\n\r\n");
|
|
79
|
+
return socket.destroy();
|
|
80
|
+
}
|
|
81
|
+
handlers.onUpgrade(req, socket, head);
|
|
82
|
+
});
|
|
83
|
+
server.on("clientError", (_err, socket) => socket.destroy());
|
|
84
|
+
return server;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ConnectDeps {
|
|
88
|
+
ensureBinary?: (dir: string) => Promise<string>;
|
|
89
|
+
requestTunnel?: (
|
|
90
|
+
config: ConnectConfig,
|
|
91
|
+
privateKey: string,
|
|
92
|
+
port: number
|
|
93
|
+
) => Promise<Tunnel>;
|
|
94
|
+
spawn?: Spawn;
|
|
95
|
+
delays?: Parameters<typeof superviseTunnel>[0]["delays"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Running {
|
|
99
|
+
server: http.Server;
|
|
100
|
+
sockets: Set<Socket>;
|
|
101
|
+
supervisor: ReturnType<typeof superviseTunnel>;
|
|
102
|
+
release: () => void;
|
|
103
|
+
}
|
|
47
104
|
|
|
48
105
|
export function startConnect(
|
|
49
106
|
handlers: Handlers,
|
|
50
107
|
port: number,
|
|
51
|
-
log = console.log
|
|
108
|
+
log = console.log,
|
|
109
|
+
deps: ConnectDeps = {},
|
|
110
|
+
env: NodeJS.ProcessEnv = process.env
|
|
52
111
|
) {
|
|
53
112
|
const dir = connectDir();
|
|
54
|
-
const decision = shouldStartConnect(
|
|
113
|
+
const decision = shouldStartConnect(env, port);
|
|
114
|
+
const tunnelPort = connectTunnelPort(port, env);
|
|
55
115
|
let base: ConnectStatus = { state: "off" };
|
|
116
|
+
let hostname = "";
|
|
56
117
|
let running: Running | null = null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
118
|
+
// Set while the tunnel listener can't bind; it keeps trying.
|
|
119
|
+
let bindError: string | undefined;
|
|
120
|
+
g.__agentosConnect = () => {
|
|
121
|
+
if (!running) return base;
|
|
122
|
+
const s: TunnelState = running.supervisor.state();
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
hostname: hostname || base.hostname,
|
|
126
|
+
state: bindError ? "error" : s === "stopped" ? "off" : s,
|
|
127
|
+
reason: bindError ?? running.supervisor.reason(),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
61
130
|
|
|
62
|
-
if (!decision.start) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
131
|
+
if (!decision.start || !tunnelPort) {
|
|
132
|
+
const reason = decision.start
|
|
133
|
+
? "AGENTOS_CONNECT_PORT isn't a usable port"
|
|
134
|
+
: decision.reason;
|
|
135
|
+
if (readConnect(dir).ok) log(`connect: not starting: ${reason}`);
|
|
136
|
+
base = { state: "refused", reason };
|
|
137
|
+
return { hostname: () => null as string | null, close: () => {} };
|
|
66
138
|
}
|
|
67
139
|
|
|
68
|
-
// Only the machine's own name: anything else through the tunnel is refused.
|
|
69
|
-
const pinned = (hostname: string): Handlers => ({
|
|
70
|
-
onRequest: (req, res) => {
|
|
71
|
-
if (hostOf(req.headers.host) !== hostname) {
|
|
72
|
-
res.statusCode = 421;
|
|
73
|
-
return res.end();
|
|
74
|
-
}
|
|
75
|
-
handlers.onRequest(req, res);
|
|
76
|
-
},
|
|
77
|
-
onUpgrade: (req, socket, head) => {
|
|
78
|
-
if (hostOf(req.headers.host) !== hostname) return socket.destroy();
|
|
79
|
-
handlers.onUpgrade(req, socket, head);
|
|
80
|
-
},
|
|
81
|
-
});
|
|
82
|
-
|
|
83
140
|
const down = (why: string) => {
|
|
84
141
|
if (!running) return;
|
|
85
|
-
|
|
142
|
+
bindError = undefined;
|
|
143
|
+
running.supervisor.stop();
|
|
86
144
|
running.sockets.forEach((s) => s.destroy());
|
|
87
145
|
running.server.close();
|
|
88
146
|
running.release();
|
|
@@ -100,13 +158,17 @@ export function startConnect(
|
|
|
100
158
|
if (read.reason !== "not enrolled") log(`connect: ${read.reason}`);
|
|
101
159
|
return;
|
|
102
160
|
}
|
|
103
|
-
const {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
hostname: files.config.hostname,
|
|
107
|
-
cert: certStatus(files.tls.cert),
|
|
108
|
-
};
|
|
161
|
+
const { config, machineKey } = read.files;
|
|
162
|
+
hostname = config.hostname;
|
|
163
|
+
base = { state: "off", enrolled: true, hostname: hostname || undefined };
|
|
109
164
|
if (!read.enabled) return;
|
|
165
|
+
try {
|
|
166
|
+
checkApiUrl(config.apiUrl, env.AGENTOS_CONNECT_OPERATOR === "1");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
base = { ...base, state: "error", reason: (err as Error).message };
|
|
169
|
+
log(`connect: ${base.reason}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
110
172
|
const lock = takeConnectLock(dir);
|
|
111
173
|
if ("heldBy" in lock) {
|
|
112
174
|
base = {
|
|
@@ -117,62 +179,83 @@ export function startConnect(
|
|
|
117
179
|
log(`connect: ${base.reason}`);
|
|
118
180
|
return;
|
|
119
181
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
server
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
182
|
+
const server = tunnelListener(() => hostname, handlers);
|
|
183
|
+
const sockets = new Set<Socket>();
|
|
184
|
+
server.on("connection", (s: Socket) => {
|
|
185
|
+
sockets.add(s);
|
|
186
|
+
s.once("close", () => sockets.delete(s));
|
|
187
|
+
});
|
|
188
|
+
// Something else may hold the port for a while (an AgentOS shutting
|
|
189
|
+
// down, say): keep trying with backoff rather than giving up.
|
|
190
|
+
const bindDelays = deps.delays ?? { min: 1000, max: 60_000 };
|
|
191
|
+
let bindDelay = bindDelays.min;
|
|
192
|
+
server.on("error", (err) => {
|
|
193
|
+
if (running?.server !== server) return;
|
|
194
|
+
bindError = `can't listen on 127.0.0.1:${tunnelPort}: ${err.message}`;
|
|
195
|
+
log(`connect: ${bindError}; trying again`);
|
|
196
|
+
const t = setTimeout(() => {
|
|
197
|
+
if (running?.server !== server) return;
|
|
198
|
+
server.close();
|
|
199
|
+
server.listen(tunnelPort, "127.0.0.1");
|
|
200
|
+
}, bindDelay);
|
|
201
|
+
t.unref?.();
|
|
202
|
+
bindDelay = Math.min(bindDelay * 2, bindDelays.max);
|
|
203
|
+
});
|
|
204
|
+
server.on("listening", () => {
|
|
205
|
+
bindError = undefined;
|
|
206
|
+
bindDelay = bindDelays.min;
|
|
207
|
+
});
|
|
208
|
+
const ensure =
|
|
209
|
+
deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
|
|
210
|
+
const ask = deps.requestTunnel ?? requestTunnel;
|
|
211
|
+
const supervisor = superviseTunnel({
|
|
212
|
+
dir,
|
|
213
|
+
log,
|
|
214
|
+
spawn: deps.spawn,
|
|
215
|
+
delays: deps.delays,
|
|
216
|
+
prepare: async () => {
|
|
217
|
+
const bin = await ensure(dir);
|
|
218
|
+
// Fresh every start; the token lives only in this call and the child's env.
|
|
219
|
+
const tunnel = await ask(config, machineKey, tunnelPort);
|
|
220
|
+
if (tunnel.hostname !== hostname) {
|
|
221
|
+
hostname = tunnel.hostname;
|
|
222
|
+
saveConnect({ ...config, hostname }, dir);
|
|
223
|
+
log(`connect: reachable at https://${hostname}`);
|
|
224
|
+
}
|
|
225
|
+
return { bin, token: tunnel.token };
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
running = { server, sockets, supervisor, release: lock.release };
|
|
229
|
+
server.listen(tunnelPort, "127.0.0.1");
|
|
147
230
|
};
|
|
148
231
|
|
|
149
|
-
// Runs from file
|
|
232
|
+
// Runs from a file watcher and a timer in the live server: never throws.
|
|
150
233
|
const sync = () => {
|
|
151
234
|
try {
|
|
152
|
-
|
|
235
|
+
const read = readConnect(dir);
|
|
236
|
+
const want = read.ok && read.enabled;
|
|
237
|
+
if (running && !want) down(read.ok ? "turned off" : read.reason);
|
|
238
|
+
else if (!running && want) up();
|
|
239
|
+
else if (!running) up(); // refreshes what the status shows
|
|
153
240
|
} catch (err) {
|
|
154
241
|
log(`connect: ${(err as Error).message}`);
|
|
155
242
|
}
|
|
156
243
|
};
|
|
157
|
-
const syncNow = () => {
|
|
158
|
-
const read = readConnect(dir);
|
|
159
|
-
const want = read.ok && read.enabled;
|
|
160
|
-
if (running && !want) down(read.ok ? "turned off" : read.reason);
|
|
161
|
-
else if (!running && want) up();
|
|
162
|
-
else if (running && read.ok) {
|
|
163
|
-
base.cert = certStatus(read.files.tls.cert);
|
|
164
|
-
running.server.setSecureContext(read.files.tls); // a renewed certificate
|
|
165
|
-
} else if (!running) up();
|
|
166
|
-
};
|
|
167
244
|
|
|
168
245
|
sync();
|
|
169
246
|
try {
|
|
170
247
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
171
248
|
} catch {}
|
|
172
|
-
|
|
173
|
-
fs.watchFile(path.join(dir, f), { interval: 2000 }, sync);
|
|
174
|
-
setInterval(sync, 24 * 60 * 60 * 1000).unref();
|
|
249
|
+
fs.watchFile(path.join(dir, "connect.json"), { interval: 2000 }, sync);
|
|
175
250
|
const stop = () => down("shutting down");
|
|
176
251
|
process.once("exit", stop);
|
|
177
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
hostname: () => (running ? hostname || null : (base.hostname ?? null)),
|
|
254
|
+
/** Tests: stop watching and close everything. */
|
|
255
|
+
close() {
|
|
256
|
+
fs.unwatchFile(path.join(dir, "connect.json"), sync);
|
|
257
|
+
process.removeListener("exit", stop);
|
|
258
|
+
down("closed");
|
|
259
|
+
},
|
|
260
|
+
};
|
|
178
261
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps `cloudflared tunnel run` going while Connect is on. Each start asks
|
|
3
|
+
* the Connect service for a fresh connector token, which goes to the child
|
|
4
|
+
* in TUNNEL_TOKEN only: never argv, never a file, never a log line. The
|
|
5
|
+
* child exits → it's started again with backoff; stop() ends it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile, spawn as nodeSpawn } from "child_process";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import readline from "readline";
|
|
12
|
+
import type { Readable } from "stream";
|
|
13
|
+
import { ApiError, clean } from "./control-api";
|
|
14
|
+
|
|
15
|
+
export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
|
|
16
|
+
|
|
17
|
+
export interface Prepared {
|
|
18
|
+
bin: string;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Child {
|
|
23
|
+
pid?: number;
|
|
24
|
+
stdout: Readable | null;
|
|
25
|
+
stderr: Readable | null;
|
|
26
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
27
|
+
on(event: "exit", fn: (code: number | null) => void): unknown;
|
|
28
|
+
on(event: "error", fn: (err: Error) => void): unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Spawn = (
|
|
32
|
+
bin: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
opts: { env: Record<string, string>; stdio: ["ignore", "pipe", "pipe"] }
|
|
35
|
+
) => Child;
|
|
36
|
+
|
|
37
|
+
export interface SuperviseOptions {
|
|
38
|
+
/** Finds the binary and fetches a fresh token; throws to retry later. */
|
|
39
|
+
prepare: () => Promise<Prepared>;
|
|
40
|
+
/** The Connect folder: cloudflared's config, home and pid file live here. */
|
|
41
|
+
dir: string;
|
|
42
|
+
log?: (line: string) => void;
|
|
43
|
+
spawn?: Spawn;
|
|
44
|
+
delays?: { min: number; max: number; setupMax: number; denied: number };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DELAYS = {
|
|
48
|
+
min: 1000,
|
|
49
|
+
max: 60_000,
|
|
50
|
+
setupMax: 5 * 60_000,
|
|
51
|
+
denied: 5 * 60_000,
|
|
52
|
+
};
|
|
53
|
+
// cloudflared can be chatty while the network flaps.
|
|
54
|
+
const LOG_LINES_PER_MINUTE = 20;
|
|
55
|
+
|
|
56
|
+
/** A cloudflared left running by an AgentOS that died without stopping it. */
|
|
57
|
+
const commandOf = (pid: number) =>
|
|
58
|
+
new Promise<string>((resolve) =>
|
|
59
|
+
execFile(
|
|
60
|
+
"ps",
|
|
61
|
+
["-o", "command=", "-p", String(pid)],
|
|
62
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
63
|
+
(err, out) => resolve(err ? "" : out)
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
async function stopStale(pidFile: string, binDir: string) {
|
|
68
|
+
let pid = 0;
|
|
69
|
+
try {
|
|
70
|
+
pid = Number(fs.readFileSync(pidFile, "utf8")) || 0;
|
|
71
|
+
} catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (pid > 1) {
|
|
75
|
+
const command = await commandOf(pid);
|
|
76
|
+
try {
|
|
77
|
+
if (command.startsWith(binDir + path.sep)) process.kill(pid, "SIGTERM");
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
fs.rmSync(pidFile, { force: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function superviseTunnel(opts: SuperviseOptions) {
|
|
84
|
+
const log = opts.log ?? (() => {});
|
|
85
|
+
const spawn = opts.spawn ?? (nodeSpawn as unknown as Spawn);
|
|
86
|
+
const delays = opts.delays ?? DELAYS;
|
|
87
|
+
const pidFile = path.join(opts.dir, "cloudflared.pid");
|
|
88
|
+
const home = path.join(opts.dir, "cloudflared-home");
|
|
89
|
+
const configFile = path.join(opts.dir, "cloudflared.yml");
|
|
90
|
+
|
|
91
|
+
let state: TunnelState = "connecting";
|
|
92
|
+
let reason: string | undefined;
|
|
93
|
+
let stopped = false;
|
|
94
|
+
let child: Child | null = null;
|
|
95
|
+
let timer: NodeJS.Timeout | null = null;
|
|
96
|
+
let delay = delays.min;
|
|
97
|
+
const connections = new Set<string>();
|
|
98
|
+
let logged = { at: 0, n: 0 };
|
|
99
|
+
|
|
100
|
+
const say = (line: string) => {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
if (now - logged.at > 60_000) logged = { at: now, n: 0 };
|
|
103
|
+
if (++logged.n <= LOG_LINES_PER_MINUTE) log(line);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const retry = (wait: number) => {
|
|
107
|
+
if (stopped) return;
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
timer = null;
|
|
110
|
+
tick();
|
|
111
|
+
}, wait);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Nothing here may reject: an unhandled rejection takes AgentOS down.
|
|
116
|
+
const tick = () =>
|
|
117
|
+
void attempt().catch(() => {
|
|
118
|
+
state = "error";
|
|
119
|
+
retry(delays.max);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const attempt = async () => {
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
state = "connecting";
|
|
125
|
+
try {
|
|
126
|
+
const prepared = await opts.prepare();
|
|
127
|
+
if (stopped) return;
|
|
128
|
+
await run(prepared);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (stopped) return;
|
|
131
|
+
const status = err instanceof ApiError ? err.status : 0;
|
|
132
|
+
reason = clean((err as Error)?.message ?? String(err));
|
|
133
|
+
log(`connect: ${reason}`);
|
|
134
|
+
if (status === 401 || status === 403) {
|
|
135
|
+
// Revoked or disabled: it won't fix itself in seconds.
|
|
136
|
+
state = "denied";
|
|
137
|
+
retry(delays.denied);
|
|
138
|
+
} else {
|
|
139
|
+
state = "error";
|
|
140
|
+
retry(delay);
|
|
141
|
+
delay = Math.min(delay * 2, delays.setupMax);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const run = async ({ bin, token }: Prepared) => {
|
|
147
|
+
const redact = (line: string) => clean(line.split(token).join("[token]"));
|
|
148
|
+
await stopStale(pidFile, path.dirname(bin));
|
|
149
|
+
if (stopped) return;
|
|
150
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
151
|
+
// Its own config and home, so no config.yml elsewhere on the machine
|
|
152
|
+
// changes what it does.
|
|
153
|
+
fs.writeFileSync(configFile, "no-autoupdate: true\n", { mode: 0o600 });
|
|
154
|
+
let proc: Child;
|
|
155
|
+
try {
|
|
156
|
+
proc = spawn(
|
|
157
|
+
bin,
|
|
158
|
+
[
|
|
159
|
+
"tunnel",
|
|
160
|
+
"--config",
|
|
161
|
+
configFile,
|
|
162
|
+
"--no-autoupdate",
|
|
163
|
+
// Never debug: it logs every request's headers, device tokens included.
|
|
164
|
+
"--loglevel",
|
|
165
|
+
"info",
|
|
166
|
+
"--output",
|
|
167
|
+
"json",
|
|
168
|
+
"run",
|
|
169
|
+
],
|
|
170
|
+
{
|
|
171
|
+
env: {
|
|
172
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
173
|
+
HOME: home,
|
|
174
|
+
TUNNEL_TOKEN: token,
|
|
175
|
+
},
|
|
176
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
state = "error";
|
|
181
|
+
reason = `cloudflared could not start: ${redact((err as Error).message)}`;
|
|
182
|
+
log(`connect: ${reason}`);
|
|
183
|
+
retry(delay);
|
|
184
|
+
delay = Math.min(delay * 2, delays.max);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
child = proc;
|
|
188
|
+
if (proc.pid) {
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(pidFile, String(proc.pid), { mode: 0o600 });
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const onLine = (line: string) => {
|
|
195
|
+
if (!line.trim()) return;
|
|
196
|
+
let entry: {
|
|
197
|
+
level?: string;
|
|
198
|
+
message?: string;
|
|
199
|
+
error?: string;
|
|
200
|
+
connIndex?: number;
|
|
201
|
+
} | null = null;
|
|
202
|
+
if (line.startsWith("{")) {
|
|
203
|
+
try {
|
|
204
|
+
entry = JSON.parse(line);
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
if (!entry) return say(`connect: cloudflared: ${redact(line)}`);
|
|
208
|
+
const message = String(entry.message ?? "");
|
|
209
|
+
const conn = String(entry.connIndex ?? "0");
|
|
210
|
+
if (message.startsWith("Registered tunnel connection")) {
|
|
211
|
+
connections.add(conn);
|
|
212
|
+
if (state !== "up") log("connect: tunnel up");
|
|
213
|
+
state = "up";
|
|
214
|
+
reason = undefined;
|
|
215
|
+
delay = delays.min;
|
|
216
|
+
} else if (
|
|
217
|
+
message.startsWith("Unregistered tunnel connection") ||
|
|
218
|
+
((entry.level === "error" || entry.level === "fatal") &&
|
|
219
|
+
entry.connIndex !== undefined)
|
|
220
|
+
) {
|
|
221
|
+
connections.delete(conn);
|
|
222
|
+
if (state === "up" && connections.size === 0) state = "connecting";
|
|
223
|
+
}
|
|
224
|
+
if (["warn", "error", "fatal"].includes(entry.level ?? "")) {
|
|
225
|
+
const text = entry.error ? `${message}: ${entry.error}` : message;
|
|
226
|
+
reason = redact(text);
|
|
227
|
+
say(`connect: cloudflared: ${reason}`);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
for (const stream of [proc.stdout, proc.stderr]) {
|
|
231
|
+
if (!stream) continue;
|
|
232
|
+
stream.on("error", () => {});
|
|
233
|
+
readline.createInterface({ input: stream }).on("line", onLine);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let ended = false;
|
|
237
|
+
const end = (why: string) => {
|
|
238
|
+
if (ended) return;
|
|
239
|
+
ended = true;
|
|
240
|
+
if (child === proc) child = null;
|
|
241
|
+
connections.clear();
|
|
242
|
+
try {
|
|
243
|
+
if (fs.readFileSync(pidFile, "utf8") === String(proc.pid))
|
|
244
|
+
fs.rmSync(pidFile, { force: true });
|
|
245
|
+
} catch {}
|
|
246
|
+
if (stopped) return;
|
|
247
|
+
state = "connecting";
|
|
248
|
+
reason = why;
|
|
249
|
+
log(`connect: ${why}; starting it again`);
|
|
250
|
+
retry(delay);
|
|
251
|
+
delay = Math.min(delay * 2, delays.max);
|
|
252
|
+
};
|
|
253
|
+
proc.on("error", (err) =>
|
|
254
|
+
end(`cloudflared failed: ${redact(err.message)}`)
|
|
255
|
+
);
|
|
256
|
+
proc.on("exit", (code) => end(`cloudflared exited (${code ?? "signal"})`));
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const killNow = () => child?.kill("SIGTERM");
|
|
260
|
+
process.once("exit", killNow);
|
|
261
|
+
tick();
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
state: () => state,
|
|
265
|
+
reason: () => reason,
|
|
266
|
+
stop() {
|
|
267
|
+
if (stopped) return;
|
|
268
|
+
stopped = true;
|
|
269
|
+
state = "stopped";
|
|
270
|
+
process.removeListener("exit", killNow);
|
|
271
|
+
if (timer) clearTimeout(timer);
|
|
272
|
+
const proc = child;
|
|
273
|
+
if (!proc) return;
|
|
274
|
+
proc.kill("SIGTERM");
|
|
275
|
+
// It drains connections for a while on SIGTERM; don't wait forever.
|
|
276
|
+
const hard = setTimeout(() => proc.kill("SIGKILL"), 5000);
|
|
277
|
+
hard.unref?.();
|
|
278
|
+
proc.on("exit", () => clearTimeout(hard));
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|