@saadnvd1/agent-os 0.3.807 → 0.3.815
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 +3 -2
- package/app/api/reach/probe/route.test.ts +22 -0
- package/app/api/reach/probe/route.ts +12 -0
- package/app/api/reach/route.ts +30 -0
- package/app/api/sessions/list-size.test.ts +77 -0
- package/app/api/sessions/route.ts +21 -10
- package/app/api/tasks/list-size.test.ts +50 -0
- package/app/api/tasks/route.ts +20 -3
- package/components/Chat/ViewSwitch.tsx +2 -1
- package/components/Devices/NetworkSection.tsx +2 -0
- package/components/Media/MediaViewer.tsx +111 -0
- package/components/Media/viewer.test.ts +26 -0
- package/components/Media/viewer.ts +23 -0
- package/components/Pane/index.tsx +4 -2
- package/components/Reach/ReachRow.tsx +137 -0
- package/components/Settings/SetupSection.tsx +2 -0
- package/components/Tips/TipDialog.tsx +1 -1
- package/components/Tips/TipMedia.tsx +38 -3
- package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
- package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
- package/components/Updates/index.ts +1 -1
- package/components/Welcome/ConnectBlock.tsx +2 -0
- package/components/views/DesktopView.tsx +2 -2
- package/components/views/MobileView.tsx +2 -2
- package/content/tips.json +10 -0
- package/content/whats-new.json +48 -0
- package/data/devices/queries.ts +6 -1
- package/data/reach/index.ts +33 -0
- package/lib/connect/serve.ts +22 -3
- package/lib/connect/supervise.ts +33 -9
- package/lib/db/queries.ts +31 -4
- package/lib/db/types.ts +3 -1
- package/lib/guides.ts +6 -0
- package/lib/hosts/remote-menu.ts +4 -2
- package/lib/reach/check-cli.ts +117 -0
- package/lib/reach/checkReach.ts +62 -0
- package/lib/reach/classify.ts +283 -0
- package/lib/reach/cli.ts +38 -0
- package/lib/reach/gatherFacts.ts +167 -0
- package/lib/reach/index.ts +4 -0
- package/lib/reach/probeConnect.ts +122 -0
- package/lib/reach/types.ts +96 -0
- package/lib/security/auth.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/sessions/has-prompt.ts +7 -0
- package/lib/sessions/omit-null.ts +8 -0
- package/lib/tasks/contract/views.ts +12 -0
- package/lib/tasks/index.ts +9 -5
- package/lib/tasks/session.ts +15 -1
- package/lib/tips/view.ts +2 -4
- package/package.json +1 -1
- package/scripts/agent-os +3 -0
- package/scripts/install.sh +12 -1
- package/scripts/lib/commands.sh +59 -65
- package/scripts/lib/service.sh +230 -0
- package/scripts/lib/setup.sh +303 -0
- package/scripts/lib/setup.test.ts +389 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns reachability facts into findings. Pure: no I/O, no clock of its own.
|
|
3
|
+
* Every title and fix line lives in CAUSES, which the guide quotes word for
|
|
4
|
+
* word (guide-sync.test.ts), so the guide can't drift from what's printed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ReachCause,
|
|
9
|
+
ReachFacts,
|
|
10
|
+
ReachFinding,
|
|
11
|
+
ReachReport,
|
|
12
|
+
ReachRoute,
|
|
13
|
+
ReachStatus,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import { slug } from "@/scripts/lib/heading-slugs";
|
|
16
|
+
import { guideUrl } from "@/lib/guides";
|
|
17
|
+
|
|
18
|
+
export const REACH_GUIDE = "reachability";
|
|
19
|
+
|
|
20
|
+
/** How long a cause AgentOS retries by itself stays "nothing to do". */
|
|
21
|
+
export const SELF_HEAL_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
interface CauseText {
|
|
24
|
+
title: string;
|
|
25
|
+
fix: string;
|
|
26
|
+
/** Other fixes for the same cause, picked by what the facts say. */
|
|
27
|
+
alt?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const CAUSES: Record<ReachCause, CauseText> = {
|
|
31
|
+
"no-route": {
|
|
32
|
+
title: "No way in from other devices yet",
|
|
33
|
+
fix: "agent-os setup",
|
|
34
|
+
},
|
|
35
|
+
"service-down": {
|
|
36
|
+
title: "AgentOS isn't running",
|
|
37
|
+
fix: "agent-os start",
|
|
38
|
+
alt: { "no-service": "agent-os enable" },
|
|
39
|
+
},
|
|
40
|
+
"service-stuck": {
|
|
41
|
+
title: "AgentOS holds its port but isn't answering",
|
|
42
|
+
fix: "agent-os restart",
|
|
43
|
+
},
|
|
44
|
+
"port-taken": {
|
|
45
|
+
title: "Another program holds AgentOS's port",
|
|
46
|
+
fix: "Stop that program, or move AgentOS: AGENT_OS_PORT=3012 agent-os restart",
|
|
47
|
+
},
|
|
48
|
+
"lan-off": {
|
|
49
|
+
title: "Wi-Fi access is off",
|
|
50
|
+
fix: "agent-os setup --via lan",
|
|
51
|
+
},
|
|
52
|
+
firewall: {
|
|
53
|
+
title: "The firewall may block devices on your Wi-Fi",
|
|
54
|
+
fix: "Allow incoming connections for node in this computer's firewall settings",
|
|
55
|
+
},
|
|
56
|
+
"tailscale-missing": {
|
|
57
|
+
title: "Tailscale isn't installed",
|
|
58
|
+
fix: "Install it from tailscale.com/download, then run agent-os check",
|
|
59
|
+
},
|
|
60
|
+
"tailscale-logged-out": {
|
|
61
|
+
title: "Tailscale is logged out",
|
|
62
|
+
fix: "sudo tailscale up",
|
|
63
|
+
},
|
|
64
|
+
"connect-not-linked": {
|
|
65
|
+
title: "Connect isn't linked",
|
|
66
|
+
fix: "agent-os connect",
|
|
67
|
+
},
|
|
68
|
+
"connect-off": {
|
|
69
|
+
title: "Connect is turned off",
|
|
70
|
+
fix: "agent-os connect --on",
|
|
71
|
+
},
|
|
72
|
+
"connect-not-here": {
|
|
73
|
+
title: "Connect can't run in this server",
|
|
74
|
+
fix: "Start AgentOS without AGENTOS_CONNECT=0",
|
|
75
|
+
alt: {
|
|
76
|
+
lock: "Another AgentOS on this machine holds the tunnel: stop it, then agent-os restart",
|
|
77
|
+
"dev-build": "Connect only runs from a production build: agent-os start",
|
|
78
|
+
"wrong-port": "Connect follows AgentOS's own port: agent-os restart",
|
|
79
|
+
"auth-off":
|
|
80
|
+
"Connect never runs with pairing off: remove AGENTOS_AUTH=off",
|
|
81
|
+
other: "agent-os logs",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
"connect-denied": {
|
|
85
|
+
title: "Connect refused this machine",
|
|
86
|
+
fix: "agent-os connect",
|
|
87
|
+
},
|
|
88
|
+
"connector-not-ready": {
|
|
89
|
+
title: "The connector isn't connected yet",
|
|
90
|
+
fix: "agent-os logs",
|
|
91
|
+
},
|
|
92
|
+
"relay-down": {
|
|
93
|
+
title: "The Connect service isn't answering",
|
|
94
|
+
fix: "agent-os logs",
|
|
95
|
+
},
|
|
96
|
+
"origin-down": {
|
|
97
|
+
title: "Connect reaches this machine but AgentOS doesn't answer",
|
|
98
|
+
fix: "agent-os restart",
|
|
99
|
+
},
|
|
100
|
+
"wrong-origin": {
|
|
101
|
+
title: "Another AgentOS answers this address",
|
|
102
|
+
fix: "Stop the other one, or run agent-os connect here",
|
|
103
|
+
},
|
|
104
|
+
dns: {
|
|
105
|
+
title: "The address doesn't resolve",
|
|
106
|
+
fix: "Wait a minute for it to publish, then agent-os check",
|
|
107
|
+
alt: {
|
|
108
|
+
all: "This computer can't look up names: check its network or DNS settings",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
offline: {
|
|
112
|
+
title: "This computer is offline",
|
|
113
|
+
fix: "Connect it to the internet, then agent-os check",
|
|
114
|
+
},
|
|
115
|
+
certificate: {
|
|
116
|
+
title: "The certificate didn't check out",
|
|
117
|
+
fix: "Something on this network intercepts HTTPS: try another network",
|
|
118
|
+
alt: { clock: "Fix this computer's clock: it's more than 5 minutes off" },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const guideAnchor = (cause: ReachCause) => slug(CAUSES[cause].title);
|
|
123
|
+
|
|
124
|
+
/** The finding for a cause, with its fix picked from CAUSES. */
|
|
125
|
+
export function finding(
|
|
126
|
+
cause: ReachCause,
|
|
127
|
+
status: ReachStatus,
|
|
128
|
+
opts: {
|
|
129
|
+
route?: ReachRoute;
|
|
130
|
+
alt?: string;
|
|
131
|
+
needs?: "you" | "nothing";
|
|
132
|
+
detail?: string;
|
|
133
|
+
} = {}
|
|
134
|
+
): ReachFinding {
|
|
135
|
+
const text = CAUSES[cause];
|
|
136
|
+
const needs = opts.needs ?? "you";
|
|
137
|
+
const fix = (opts.alt && text.alt?.[opts.alt]) || text.fix;
|
|
138
|
+
return {
|
|
139
|
+
...(opts.route && { route: opts.route }),
|
|
140
|
+
status,
|
|
141
|
+
cause,
|
|
142
|
+
needs,
|
|
143
|
+
title: opts.detail ? `${text.title} (${opts.detail})` : text.title,
|
|
144
|
+
fix: needs === "nothing" ? "" : fix,
|
|
145
|
+
guide: guideUrl({ file: REACH_GUIDE, anchor: guideAnchor(cause) }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ROUTE_TITLE: Record<ReachRoute, string> = {
|
|
150
|
+
connect: "Reachable through Connect",
|
|
151
|
+
tailscale: "Reachable on your Tailscale network",
|
|
152
|
+
lan: "Reachable on this Wi-Fi",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const ok = (route: ReachRoute, url: string): ReachFinding => ({
|
|
156
|
+
route,
|
|
157
|
+
status: "ok",
|
|
158
|
+
needs: "nothing",
|
|
159
|
+
title: ROUTE_TITLE[route],
|
|
160
|
+
fix: "",
|
|
161
|
+
url,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A cause AgentOS keeps retrying: nothing to do for a while, then it's yours.
|
|
165
|
+
function retrying(cause: ReachCause, facts: ReachFacts): ReachFinding {
|
|
166
|
+
const since = facts.connect.downSince ?? facts.now;
|
|
167
|
+
return facts.now - since < SELF_HEAL_MS
|
|
168
|
+
? finding(cause, "warn", { route: "connect", needs: "nothing" })
|
|
169
|
+
: finding(cause, "fail", { route: "connect" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function connectFinding(facts: ReachFacts): ReachFinding {
|
|
173
|
+
const c = facts.connect;
|
|
174
|
+
const url = c.hostname ? `https://${c.hostname}` : "";
|
|
175
|
+
if (c.state === "refused")
|
|
176
|
+
return finding("connect-not-here", "fail", {
|
|
177
|
+
route: "connect",
|
|
178
|
+
alt: c.notHere,
|
|
179
|
+
});
|
|
180
|
+
if (c.state === "denied")
|
|
181
|
+
return finding("connect-denied", "fail", { route: "connect" });
|
|
182
|
+
if (c.state === "off")
|
|
183
|
+
return finding("connect-off", "fail", { route: "connect" });
|
|
184
|
+
if (c.state !== "up")
|
|
185
|
+
return retrying(
|
|
186
|
+
c.stage === "service" ? "relay-down" : "connector-not-ready",
|
|
187
|
+
facts
|
|
188
|
+
);
|
|
189
|
+
const p = facts.probe;
|
|
190
|
+
if (!p || p.kind === "ok")
|
|
191
|
+
return url ? ok("connect", url) : retrying("connector-not-ready", facts);
|
|
192
|
+
switch (p.kind) {
|
|
193
|
+
case "wrong-origin":
|
|
194
|
+
return finding("wrong-origin", "fail", { route: "connect" });
|
|
195
|
+
case "edge":
|
|
196
|
+
if (p.tunnel) return retrying("connector-not-ready", facts);
|
|
197
|
+
if (p.status === 502 || p.status === 504)
|
|
198
|
+
return finding("origin-down", "fail", { route: "connect" });
|
|
199
|
+
return retrying("relay-down", facts);
|
|
200
|
+
case "dns":
|
|
201
|
+
// This computer can't look anything up: others may still get in.
|
|
202
|
+
return p.scope === "all"
|
|
203
|
+
? finding("dns", "warn", { route: "connect", alt: "all" })
|
|
204
|
+
: retrying("dns", facts);
|
|
205
|
+
case "tls":
|
|
206
|
+
return p.skewed
|
|
207
|
+
? finding("certificate", "fail", { route: "connect", alt: "clock" })
|
|
208
|
+
: finding("certificate", "warn", { route: "connect" });
|
|
209
|
+
case "offline":
|
|
210
|
+
return finding("offline", "fail", { route: "connect" });
|
|
211
|
+
case "timeout":
|
|
212
|
+
return retrying("relay-down", facts);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The routes this machine is set up for: each one gets a finding. */
|
|
217
|
+
export function routesInUse(
|
|
218
|
+
facts: ReachFacts,
|
|
219
|
+
want: ReachRoute[] = []
|
|
220
|
+
): ReachRoute[] {
|
|
221
|
+
const out: ReachRoute[] = [];
|
|
222
|
+
if (
|
|
223
|
+
(facts.connect.linked && facts.connect.enabled) ||
|
|
224
|
+
want.includes("connect")
|
|
225
|
+
)
|
|
226
|
+
out.push("connect");
|
|
227
|
+
if (facts.tailscale.state === "running" || want.includes("tailscale"))
|
|
228
|
+
out.push("tailscale");
|
|
229
|
+
if (facts.lan.on || want.includes("lan")) out.push("lan");
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function routeFinding(route: ReachRoute, facts: ReachFacts): ReachFinding {
|
|
234
|
+
if (route === "connect")
|
|
235
|
+
return facts.connect.linked
|
|
236
|
+
? connectFinding(facts)
|
|
237
|
+
: finding("connect-not-linked", "fail", { route });
|
|
238
|
+
if (route === "tailscale") {
|
|
239
|
+
const t = facts.tailscale;
|
|
240
|
+
if (t.state === "running" && t.url) return ok(route, t.url);
|
|
241
|
+
return t.state === "logged-out" || t.state === "running"
|
|
242
|
+
? finding("tailscale-logged-out", "fail", { route })
|
|
243
|
+
: finding("tailscale-missing", "fail", { route });
|
|
244
|
+
}
|
|
245
|
+
if (!facts.lan.on) return finding("lan-off", "fail", { route });
|
|
246
|
+
// A firewall reading is a guess from inside: never more than a warning.
|
|
247
|
+
if (facts.firewall === "blocks")
|
|
248
|
+
return finding("firewall", "warn", { route });
|
|
249
|
+
const ip = facts.lan.addresses[0];
|
|
250
|
+
return ip
|
|
251
|
+
? ok(route, `http://${ip}:${facts.port}`)
|
|
252
|
+
: finding("lan-off", "warn", { route });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The report: one finding per route in use. Ok when every route works, warn
|
|
257
|
+
* when at least one does (or one is only retrying), fail when none does.
|
|
258
|
+
*/
|
|
259
|
+
export function classifyReach(
|
|
260
|
+
facts: ReachFacts,
|
|
261
|
+
want: ReachRoute[] = []
|
|
262
|
+
): ReachReport {
|
|
263
|
+
const checkedAt = new Date(facts.now).toISOString();
|
|
264
|
+
const routes = routesInUse(facts, want);
|
|
265
|
+
if (!routes.length) {
|
|
266
|
+
// Linked but switched off is a clearer story than "no route".
|
|
267
|
+
const only =
|
|
268
|
+
facts.connect.linked && !facts.connect.enabled
|
|
269
|
+
? finding("connect-off", "fail", { route: "connect" })
|
|
270
|
+
: finding("no-route", "fail");
|
|
271
|
+
return { status: "fail", findings: [only], checkedAt };
|
|
272
|
+
}
|
|
273
|
+
const findings = routes.map((r) => routeFinding(r, facts));
|
|
274
|
+
const url = findings.find((f) => f.url)?.url;
|
|
275
|
+
const working = findings.some((f) => f.status === "ok");
|
|
276
|
+
const retryingOnly = findings.every((f) => f.status !== "fail");
|
|
277
|
+
const status: ReachStatus = findings.every((f) => f.status === "ok")
|
|
278
|
+
? "ok"
|
|
279
|
+
: working || retryingOnly
|
|
280
|
+
? "warn"
|
|
281
|
+
: "fail";
|
|
282
|
+
return { status, ...(url && { url }), findings, checkedAt };
|
|
283
|
+
}
|
package/lib/reach/cli.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Entry for agent-os check (lib/reach/check-cli.ts). Argv: port, then flags. */
|
|
2
|
+
|
|
3
|
+
import { execFile } from "child_process";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { runCheckCli } from "./check-cli";
|
|
8
|
+
|
|
9
|
+
const [port, ...args] = process.argv.slice(2);
|
|
10
|
+
const p = Number(port) || 3011;
|
|
11
|
+
|
|
12
|
+
const SERVICE_FILES = [
|
|
13
|
+
"Library/LaunchAgents/com.agent-os.plist",
|
|
14
|
+
".config/systemd/user/agent-os.service",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
runCheckCli(args, {
|
|
18
|
+
base: `http://127.0.0.1:${p}`,
|
|
19
|
+
fetch,
|
|
20
|
+
out: (line) => console.log(line),
|
|
21
|
+
holder: () =>
|
|
22
|
+
new Promise((resolve) =>
|
|
23
|
+
execFile(
|
|
24
|
+
"lsof",
|
|
25
|
+
["-nP", `-iTCP:${p}`, "-sTCP:LISTEN", "-Fc"],
|
|
26
|
+
{ timeout: 3000 },
|
|
27
|
+
(_err, stdout) =>
|
|
28
|
+
resolve(
|
|
29
|
+
String(stdout ?? "")
|
|
30
|
+
.split("\n")
|
|
31
|
+
.find((l) => l.startsWith("c"))
|
|
32
|
+
?.slice(1) || null
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
),
|
|
36
|
+
serviceInstalled: () =>
|
|
37
|
+
SERVICE_FILES.some((f) => fs.existsSync(path.join(os.homedir(), f))),
|
|
38
|
+
}).then((code) => process.exit(code));
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects what the classifier reads. Every tool's output is reduced to a
|
|
3
|
+
* label here: raw text can carry keys, tokens or account names, so it never
|
|
4
|
+
* reaches a finding.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "child_process";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import { promisify } from "util";
|
|
10
|
+
import { connectStatus, type ConnectStatus } from "@/lib/connect/serve";
|
|
11
|
+
import { connectDir, readConnect } from "@/lib/connect/config";
|
|
12
|
+
import { tailscaleStatus, type TailscaleState } from "@/lib/tailscale";
|
|
13
|
+
import { lanAddresses } from "@/lib/security/net";
|
|
14
|
+
import { lanEnabled } from "@/lib/security/network-settings";
|
|
15
|
+
import { reachFrom } from "@/lib/security/reach";
|
|
16
|
+
import { tailnetHttpsUrl } from "@/lib/security/tailnet-https";
|
|
17
|
+
import { probeConnect } from "./probeConnect";
|
|
18
|
+
import type { ConnectProbe, NotHere, ReachFacts } from "./types";
|
|
19
|
+
|
|
20
|
+
type Exec = (file: string, args: string[]) => Promise<string>;
|
|
21
|
+
|
|
22
|
+
export interface GatherDeps {
|
|
23
|
+
connect: () => ConnectStatus;
|
|
24
|
+
connectFiles: () => { enabled: boolean; controlHost?: string };
|
|
25
|
+
tailscale: () => Promise<TailscaleState>;
|
|
26
|
+
tailnetHttps: () => string | null;
|
|
27
|
+
lan: () => { on: boolean; addresses: string[] };
|
|
28
|
+
exec: Exec;
|
|
29
|
+
readFile: (p: string) => string | null;
|
|
30
|
+
probe: (hostname: string, controlHost: string) => Promise<ConnectProbe>;
|
|
31
|
+
platform: NodeJS.Platform;
|
|
32
|
+
nodePath: string;
|
|
33
|
+
port: number;
|
|
34
|
+
now: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const run = promisify(execFile);
|
|
38
|
+
|
|
39
|
+
export const defaultGatherDeps = (): GatherDeps => ({
|
|
40
|
+
connect: connectStatus,
|
|
41
|
+
connectFiles: () => {
|
|
42
|
+
const r = readConnect(connectDir());
|
|
43
|
+
if (!r.ok) return { enabled: false };
|
|
44
|
+
let controlHost: string | undefined;
|
|
45
|
+
try {
|
|
46
|
+
controlHost = new URL(r.files.config.apiUrl).hostname;
|
|
47
|
+
} catch {}
|
|
48
|
+
return { enabled: r.enabled, controlHost };
|
|
49
|
+
},
|
|
50
|
+
tailscale: tailscaleStatus,
|
|
51
|
+
tailnetHttps: tailnetHttpsUrl,
|
|
52
|
+
lan: () => {
|
|
53
|
+
const on = lanEnabled();
|
|
54
|
+
return { on, addresses: on ? lanAddresses() : [] };
|
|
55
|
+
},
|
|
56
|
+
exec: async (file, args) =>
|
|
57
|
+
(await run(file, args, { timeout: 3000 })).stdout.toString(),
|
|
58
|
+
readFile: (p) => {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readFileSync(p, "utf8");
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
probe: (hostname, controlHost) => probeConnect(hostname, controlHost),
|
|
66
|
+
platform: process.platform,
|
|
67
|
+
nodePath: process.execPath,
|
|
68
|
+
port: Number(process.env.AGENTOS_PORT || 3011),
|
|
69
|
+
now: Date.now,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** The server's own words for why Connect doesn't run here, as a label. */
|
|
73
|
+
export function notHereLabel(reason = ""): NotHere {
|
|
74
|
+
if (reason === "AGENTOS_CONNECT=0") return "env-off";
|
|
75
|
+
if (reason === "not a production build") return "dev-build";
|
|
76
|
+
if (reason.startsWith("port ")) return "wrong-port";
|
|
77
|
+
if (reason.includes("AGENTOS_AUTH=off")) return "auth-off";
|
|
78
|
+
if (reason.includes("holds the tunnel")) return "lock";
|
|
79
|
+
return "other";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SOCKETFILTERFW = "/usr/libexec/ApplicationFirewall/socketfilterfw";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the firewall would stop devices on the Wi-Fi reaching node, read
|
|
86
|
+
* without sudo. A guess from inside, so the classifier only ever warns.
|
|
87
|
+
*/
|
|
88
|
+
export async function readFirewall(
|
|
89
|
+
deps: Pick<GatherDeps, "exec" | "readFile" | "platform" | "nodePath" | "port">
|
|
90
|
+
): Promise<ReachFacts["firewall"]> {
|
|
91
|
+
try {
|
|
92
|
+
if (deps.platform === "darwin") {
|
|
93
|
+
const all = await deps.exec(SOCKETFILTERFW, ["--getblockall"]);
|
|
94
|
+
if (
|
|
95
|
+
/block all/i.test(all) &&
|
|
96
|
+
/enabled/i.test(all) &&
|
|
97
|
+
!/disabled/i.test(all)
|
|
98
|
+
)
|
|
99
|
+
return "blocks";
|
|
100
|
+
const app = await deps.exec(SOCKETFILTERFW, [
|
|
101
|
+
"--getappblocked",
|
|
102
|
+
deps.nodePath,
|
|
103
|
+
]);
|
|
104
|
+
return /\bblocked\b/i.test(app) && !/not blocked|permitted/i.test(app)
|
|
105
|
+
? "blocks"
|
|
106
|
+
: "off";
|
|
107
|
+
}
|
|
108
|
+
if (deps.platform === "linux") {
|
|
109
|
+
const conf = deps.readFile("/etc/ufw/ufw.conf");
|
|
110
|
+
if (!conf) return "off";
|
|
111
|
+
if (!/^\s*ENABLED\s*=\s*yes\s*$/m.test(conf)) return "off";
|
|
112
|
+
// The rules file is often root-only: then it may block.
|
|
113
|
+
const rules = deps.readFile("/etc/ufw/user.rules");
|
|
114
|
+
return rules && new RegExp(`--dport ${deps.port}\\b`).test(rules)
|
|
115
|
+
? "off"
|
|
116
|
+
: "blocks";
|
|
117
|
+
}
|
|
118
|
+
return "unknown";
|
|
119
|
+
} catch {
|
|
120
|
+
return "unknown";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function gatherFacts(
|
|
125
|
+
deps: GatherDeps = defaultGatherDeps()
|
|
126
|
+
): Promise<ReachFacts> {
|
|
127
|
+
const c = deps.connect();
|
|
128
|
+
const files = deps.connectFiles();
|
|
129
|
+
const lan = deps.lan();
|
|
130
|
+
const [ts, firewall] = await Promise.all([
|
|
131
|
+
deps.tailscale(),
|
|
132
|
+
lan.on ? readFirewall(deps) : Promise.resolve("off" as const),
|
|
133
|
+
]);
|
|
134
|
+
const tsUrl = reachFrom(ts, [], deps.port, null, deps.tailnetHttps()).find(
|
|
135
|
+
(r) => r.kind === "tailscale"
|
|
136
|
+
)?.url;
|
|
137
|
+
const linked = !!c.enrolled || !!c.hostname;
|
|
138
|
+
// Off while switched on: the service stopped it (as good as refused), or
|
|
139
|
+
// it hasn't started yet.
|
|
140
|
+
const state =
|
|
141
|
+
c.state === "off" && linked && files.enabled
|
|
142
|
+
? c.reason
|
|
143
|
+
? "denied"
|
|
144
|
+
: "connecting"
|
|
145
|
+
: c.state;
|
|
146
|
+
const probe =
|
|
147
|
+
state === "up" && c.hostname && files.controlHost
|
|
148
|
+
? await deps.probe(c.hostname, files.controlHost)
|
|
149
|
+
: undefined;
|
|
150
|
+
return {
|
|
151
|
+
now: deps.now(),
|
|
152
|
+
port: deps.port,
|
|
153
|
+
lan,
|
|
154
|
+
firewall,
|
|
155
|
+
tailscale: { state: ts.state, ...(tsUrl && { url: tsUrl }) },
|
|
156
|
+
connect: {
|
|
157
|
+
state,
|
|
158
|
+
linked,
|
|
159
|
+
enabled: files.enabled,
|
|
160
|
+
...(c.hostname && { hostname: c.hostname }),
|
|
161
|
+
...(state === "refused" && { notHere: notHereLabel(c.reason) }),
|
|
162
|
+
...(c.stage && { stage: c.stage }),
|
|
163
|
+
...(c.downSince && { downSince: c.downSince }),
|
|
164
|
+
},
|
|
165
|
+
...(probe && { probe }),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches this machine's own Connect address from here, to prove the whole
|
|
3
|
+
* path works: DNS, the certificate, the edge, the connector and this server.
|
|
4
|
+
* /api/reach/probe answers with this process's boot id, so an answer from a
|
|
5
|
+
* cache or another AgentOS can't pass for ours.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "crypto";
|
|
9
|
+
import { lookup } from "dns/promises";
|
|
10
|
+
import type { ConnectProbe } from "./types";
|
|
11
|
+
|
|
12
|
+
const g = globalThis as unknown as { __agentosBoot?: string };
|
|
13
|
+
/** Random per process; the route and the probe must see the same one. */
|
|
14
|
+
export const bootId = () =>
|
|
15
|
+
(g.__agentosBoot ??= randomBytes(8).toString("hex"));
|
|
16
|
+
|
|
17
|
+
export const PROBE_PATH = "/api/reach/probe";
|
|
18
|
+
const PROBE_MS = 8_000;
|
|
19
|
+
const SKEW_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
export interface ProbeDeps {
|
|
22
|
+
fetch: typeof fetch;
|
|
23
|
+
/** Whether a name resolves at all. */
|
|
24
|
+
resolves: (name: string) => Promise<boolean>;
|
|
25
|
+
now: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultDeps: ProbeDeps = {
|
|
29
|
+
fetch: (...a) => fetch(...a),
|
|
30
|
+
resolves: (name) =>
|
|
31
|
+
lookup(name).then(
|
|
32
|
+
() => true,
|
|
33
|
+
() => false
|
|
34
|
+
),
|
|
35
|
+
now: Date.now,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DNS = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "EAI_NONAME"]);
|
|
39
|
+
const OFFLINE = new Set([
|
|
40
|
+
"ENETUNREACH",
|
|
41
|
+
"ENETDOWN",
|
|
42
|
+
"EHOSTUNREACH",
|
|
43
|
+
"ECONNREFUSED",
|
|
44
|
+
]);
|
|
45
|
+
const TLS = /^(CERT_|ERR_TLS_|ERR_SSL_)|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)/;
|
|
46
|
+
// Both names come from Connect's own config; only a plain DNS name is fetched,
|
|
47
|
+
// so nothing in them can add a port, a path or a userinfo part to the URL.
|
|
48
|
+
const DNS_NAME =
|
|
49
|
+
/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/i;
|
|
50
|
+
|
|
51
|
+
function errorCode(err: unknown): string {
|
|
52
|
+
const e = err as { name?: string; code?: string; cause?: { code?: string } };
|
|
53
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") return "TIMEOUT";
|
|
54
|
+
return String(e?.cause?.code ?? e?.code ?? "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The clock is off when a server's Date header disagrees by minutes. Over
|
|
58
|
+
// plain HTTP, so a broken certificate can't hide the answer.
|
|
59
|
+
async function clockSkewed(
|
|
60
|
+
deps: ProbeDeps,
|
|
61
|
+
controlHost: string
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
if (!controlHost) return false;
|
|
64
|
+
try {
|
|
65
|
+
const res = await deps.fetch(`http://${controlHost}/`, {
|
|
66
|
+
method: "HEAD",
|
|
67
|
+
redirect: "manual",
|
|
68
|
+
signal: AbortSignal.timeout(PROBE_MS),
|
|
69
|
+
});
|
|
70
|
+
const date = Date.parse(res.headers.get("date") ?? "");
|
|
71
|
+
return Number.isFinite(date) && Math.abs(date - deps.now()) > SKEW_MS;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function probeConnect(
|
|
78
|
+
hostname: string,
|
|
79
|
+
controlHost: string,
|
|
80
|
+
boot = bootId(),
|
|
81
|
+
deps: ProbeDeps = defaultDeps
|
|
82
|
+
): Promise<ConnectProbe> {
|
|
83
|
+
if (!DNS_NAME.test(hostname)) return { kind: "dns", scope: "host" };
|
|
84
|
+
if (!DNS_NAME.test(controlHost)) controlHost = "";
|
|
85
|
+
let res: Response;
|
|
86
|
+
try {
|
|
87
|
+
res = await deps.fetch(`https://${hostname}${PROBE_PATH}`, {
|
|
88
|
+
redirect: "manual",
|
|
89
|
+
cache: "no-store",
|
|
90
|
+
signal: AbortSignal.timeout(PROBE_MS),
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const code = errorCode(err);
|
|
94
|
+
if (code === "TIMEOUT") return { kind: "timeout" };
|
|
95
|
+
if (DNS.has(code))
|
|
96
|
+
return {
|
|
97
|
+
kind: "dns",
|
|
98
|
+
scope:
|
|
99
|
+
controlHost && !(await deps.resolves(controlHost)) ? "all" : "host",
|
|
100
|
+
};
|
|
101
|
+
if (TLS.test(code))
|
|
102
|
+
return {
|
|
103
|
+
kind: "tls",
|
|
104
|
+
skewed:
|
|
105
|
+
code === "CERT_NOT_YET_VALID" ||
|
|
106
|
+
(await clockSkewed(deps, controlHost)),
|
|
107
|
+
};
|
|
108
|
+
if (OFFLINE.has(code)) return { kind: "offline" };
|
|
109
|
+
return { kind: "timeout" };
|
|
110
|
+
}
|
|
111
|
+
if (res.status >= 500) {
|
|
112
|
+
const body = await res.text().catch(() => "");
|
|
113
|
+
// 530 with error 1033: the edge has no connector for this address.
|
|
114
|
+
const tunnel = res.status === 530 || /\b1033\b/.test(body);
|
|
115
|
+
return { kind: "edge", status: res.status, tunnel };
|
|
116
|
+
}
|
|
117
|
+
if (res.status !== 200) return { kind: "wrong-origin" };
|
|
118
|
+
const body = (await res.json().catch(() => null)) as {
|
|
119
|
+
boot?: unknown;
|
|
120
|
+
} | null;
|
|
121
|
+
return body?.boot === boot ? { kind: "ok" } : { kind: "wrong-origin" };
|
|
122
|
+
}
|