@saadnvd1/agent-os 0.3.499 → 0.3.502
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 +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- 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/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -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/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -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/enrol.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Turning Connect on for this machine.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* phones complete TLS with. Neither is ever sent anywhere; the certificate
|
|
6
|
-
* authority only sees a signing request.
|
|
2
|
+
* Turning Connect on for this machine. The Ed25519 key that proves the
|
|
3
|
+
* machine to the Connect service is generated here and written only to the
|
|
4
|
+
* Connect folder (owner-only); only its public half is ever sent.
|
|
7
5
|
*/
|
|
8
6
|
|
|
9
7
|
import crypto from "crypto";
|
|
10
8
|
import fs from "fs";
|
|
11
9
|
import path from "path";
|
|
12
|
-
import
|
|
13
|
-
import { connectDir, type ConnectConfig } from "./config";
|
|
14
|
-
import { generateMachineKey } from "./identity";
|
|
15
|
-
|
|
16
|
-
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
17
|
-
|
|
18
|
-
export function newMachineId(): string {
|
|
19
|
-
return Array.from(crypto.randomBytes(8), (b) => ID_ALPHABET[b % 36]).join("");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const SECRETS = ["machine.key", "tls.key", "acme-account.key"];
|
|
10
|
+
import { connectDir, saveConnect, type ConnectConfig } from "./config";
|
|
23
11
|
|
|
24
12
|
const write = (dir: string, file: string, data: string) => {
|
|
25
13
|
fs.writeFileSync(path.join(dir, file), data, { mode: 0o600 });
|
|
@@ -30,110 +18,48 @@ const write = (dir: string, file: string, data: string) => {
|
|
|
30
18
|
function lockDown(dir: string) {
|
|
31
19
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
20
|
fs.chmodSync(dir, 0o700);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
fs.chmodSync(path.join(dir, f), 0o600);
|
|
36
|
-
}
|
|
21
|
+
if (fs.existsSync(path.join(dir, "machine.key")))
|
|
22
|
+
fs.chmodSync(path.join(dir, "machine.key"), 0o600);
|
|
37
23
|
}
|
|
38
24
|
|
|
39
|
-
export interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
csr: Buffer;
|
|
25
|
+
export interface MachineKeys {
|
|
26
|
+
publicKey: string;
|
|
27
|
+
privateKey: string;
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
/**
|
|
46
|
-
export type Register = (
|
|
30
|
+
/** Registers the public key with the Connect service; answers id and name. */
|
|
31
|
+
export type Register = (keys: MachineKeys) => Promise<ConnectConfig>;
|
|
47
32
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
* The machine key comes first: the Connect service registers its public
|
|
51
|
-
* half and answers with the machine's id and address.
|
|
52
|
-
*/
|
|
53
|
-
export async function enrol(opts: {
|
|
54
|
-
domain: string;
|
|
55
|
-
relayUrl: string;
|
|
56
|
-
dir?: string;
|
|
57
|
-
register?: Register;
|
|
58
|
-
}): Promise<Enrolment> {
|
|
59
|
-
const dir = opts.dir ?? connectDir();
|
|
33
|
+
/** The machine key, made on first use and reused after. */
|
|
34
|
+
export function machineKeys(dir = connectDir()): MachineKeys {
|
|
60
35
|
lockDown(dir);
|
|
61
36
|
if (!fs.existsSync(path.join(dir, "machine.key"))) {
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
const publicKey = crypto
|
|
65
|
-
.createPublicKey(fs.readFileSync(path.join(dir, "machine.key"), "utf8"))
|
|
66
|
-
.export({ type: "spki", format: "pem" })
|
|
67
|
-
.toString();
|
|
68
|
-
const configPath = path.join(dir, "connect.json");
|
|
69
|
-
let config: ConnectConfig;
|
|
70
|
-
if (fs.existsSync(configPath)) {
|
|
71
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8")) as ConnectConfig;
|
|
72
|
-
} else {
|
|
73
|
-
if (opts.register) {
|
|
74
|
-
config = await opts.register(publicKey);
|
|
75
|
-
} else {
|
|
76
|
-
const machineId = newMachineId();
|
|
77
|
-
config = {
|
|
78
|
-
machineId,
|
|
79
|
-
hostname: `${machineId}.${opts.domain}`,
|
|
80
|
-
relayUrl: opts.relayUrl,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
// Saved before anything else can fail: the link code is single-use, so
|
|
84
|
-
// a rerun must resume with this id rather than ask for a new code.
|
|
85
|
-
write(dir, "connect.json", JSON.stringify(config, null, 2));
|
|
86
|
-
}
|
|
87
|
-
if (!fs.existsSync(path.join(dir, "tls.key"))) {
|
|
37
|
+
const { privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
88
38
|
write(
|
|
89
39
|
dir,
|
|
90
|
-
"
|
|
91
|
-
(
|
|
40
|
+
"machine.key",
|
|
41
|
+
privateKey.export({ type: "pkcs8", format: "pem" }).toString()
|
|
92
42
|
);
|
|
93
43
|
}
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Where DNS-01 TXT records get written: the control plane's broker, or (dev) a DNS API. */
|
|
103
|
-
export interface DnsChallenge {
|
|
104
|
-
set(name: string, value: string): Promise<void>;
|
|
105
|
-
clear(name: string, value: string): Promise<void>;
|
|
44
|
+
const privateKey = fs.readFileSync(path.join(dir, "machine.key"), "utf8");
|
|
45
|
+
const publicKey = crypto
|
|
46
|
+
.createPublicKey(privateKey)
|
|
47
|
+
.export({ type: "spki", format: "pem" })
|
|
48
|
+
.toString();
|
|
49
|
+
return { publicKey, privateKey };
|
|
106
50
|
}
|
|
107
51
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Creates the machine's identity (or reuses it) and registers it. The
|
|
54
|
+
* service's answer is saved before anything else can fail: the link code
|
|
55
|
+
* is single-use, so a rerun must resume with this id.
|
|
56
|
+
*/
|
|
57
|
+
export async function enrol(opts: {
|
|
58
|
+
register: Register;
|
|
112
59
|
dir?: string;
|
|
113
|
-
|
|
114
|
-
}): Promise<void> {
|
|
60
|
+
}): Promise<ConnectConfig> {
|
|
115
61
|
const dir = opts.dir ?? connectDir();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
write(
|
|
120
|
-
dir,
|
|
121
|
-
"acme-account.key",
|
|
122
|
-
(await acme.crypto.createPrivateKey()).toString()
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const client = new acme.Client({
|
|
126
|
-
directoryUrl: opts.directoryUrl ?? acme.directory.letsencrypt.production,
|
|
127
|
-
accountKey: fs.readFileSync(accountKeyPath),
|
|
128
|
-
});
|
|
129
|
-
const cert = await client.auto({
|
|
130
|
-
csr: opts.csr,
|
|
131
|
-
termsOfServiceAgreed: true,
|
|
132
|
-
challengePriority: ["dns-01"],
|
|
133
|
-
challengeCreateFn: async (authz, _c, keyAuth) =>
|
|
134
|
-
opts.dns.set(`_acme-challenge.${authz.identifier.value}`, keyAuth),
|
|
135
|
-
challengeRemoveFn: async (authz, _c, keyAuth) =>
|
|
136
|
-
opts.dns.clear(`_acme-challenge.${authz.identifier.value}`, keyAuth),
|
|
137
|
-
});
|
|
138
|
-
write(dir, "tls.crt", cert);
|
|
62
|
+
const config = await opts.register(machineKeys(dir));
|
|
63
|
+
saveConnect(config, dir);
|
|
64
|
+
return config;
|
|
139
65
|
}
|
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
|
}
|