@crvouga/dev-fleet 0.8.0 → 0.9.1

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.
Files changed (205) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capability-checks.ts +55 -16
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-exposure.ts +388 -0
  195. package/src/worker-features.ts +201 -0
  196. package/src/worker-host-checks.ts +71 -20
  197. package/src/worker-manifest.ts +27 -4
  198. package/src/worker-parking.ts +70 -0
  199. package/src/worker-policy.ts +17 -1
  200. package/src/worker-readiness.ts +15 -3
  201. package/src/worker-reboot.ts +129 -0
  202. package/src/worker-records.ts +305 -0
  203. package/src/worker-seed.ts +16 -11
  204. package/src/worker-sizing.ts +76 -0
  205. package/src/cli/schedule-logs.ts +0 -36
@@ -0,0 +1,388 @@
1
+ import { isIP } from 'node:net';
2
+ import policy from '../ansible/ubuntu/policy.json';
3
+ import { record } from './config';
4
+ import type { PublicPort, WorkerExposure } from './exposure';
5
+ import type { Check, Runner, Status } from './types';
6
+
7
+ /** The beszel agent's port: it opens no listener (DISABLE_SSH), so one
8
+ * listening on it is never the fleet's intention. */
9
+ export const MONITORING_AGENT_PORT = 45876;
10
+ /** The fleet's own services: a listener of theirs beyond loopback and the
11
+ * tailnet always fails, whatever the policy says of other listeners. T3 is
12
+ * the desktop app on a desktop worker and the server on a headless one. */
13
+ const FLEET_UNITS = [
14
+ 't3code-desktop.service',
15
+ 't3code-headless.service',
16
+ 'beszel-agent.service',
17
+ ];
18
+ /** The table tasks/firewall.yml loads. */
19
+ export const FIREWALL_TABLE = ['inet', 'personal_fleet'];
20
+
21
+ export interface Listener {
22
+ protocol: 'tcp' | 'udp';
23
+ /** The bound address without its port: `0.0.0.0`, `::`, `127.0.0.1`. */
24
+ address: string;
25
+ /** The interface the socket is bound to (`%lo`), when it is. */
26
+ device?: string;
27
+ port: number;
28
+ pids: number[];
29
+ }
30
+
31
+ /** `0.0.0.0:22`, `[::]:22`, `127.0.0.53%lo:53`, `*:68`: the address, the
32
+ * interface it is bound to and the port. */
33
+ function localAddress(
34
+ local: string
35
+ ): { address: string; device?: string; port: number } | null {
36
+ const colon = local.lastIndexOf(':');
37
+ const port = Number(local.slice(colon + 1));
38
+ if (colon < 1 || !Number.isInteger(port) || port < 1 || port > 65535)
39
+ return null;
40
+ const [bound = '', device] = local.slice(0, colon).split('%');
41
+ const address = bound.replace(/^\[|\]$/g, '');
42
+ return {
43
+ address: address === '*' ? '0.0.0.0' : address,
44
+ ...(device && /^[\w.@*-]{1,32}$/.test(device) ? { device } : {}),
45
+ port,
46
+ };
47
+ }
48
+
49
+ /** Listening TCP and UDP sockets from `ss -H -l -n -t -u -p`. */
50
+ export function parseListeners(text: string): Listener[] {
51
+ const listeners: Listener[] = [];
52
+ for (const line of text.split('\n')) {
53
+ const fields = line.trim().split(/\s+/);
54
+ const protocol = fields[0];
55
+ if (protocol !== 'tcp' && protocol !== 'udp') continue;
56
+ const local = localAddress(fields[4] ?? '');
57
+ if (!local) continue;
58
+ const pids = [
59
+ ...fields
60
+ .slice(6)
61
+ .join(' ')
62
+ .matchAll(/pid=(\d+)/g),
63
+ ].map((match) => Number(match[1]));
64
+ listeners.push({ protocol, ...local, pids: [...new Set(pids)] });
65
+ }
66
+ return listeners;
67
+ }
68
+
69
+ interface Interface {
70
+ name: string;
71
+ addresses: { address: string; scope: string }[];
72
+ }
73
+
74
+ function parseInterfaces(text: string): Interface[] | null {
75
+ try {
76
+ const value: unknown = JSON.parse(text);
77
+ if (!Array.isArray(value)) return null;
78
+ return value.filter(record).map((entry) => ({
79
+ name: typeof entry['ifname'] === 'string' ? entry['ifname'] : '',
80
+ addresses: (Array.isArray(entry['addr_info']) ? entry['addr_info'] : [])
81
+ .filter(record)
82
+ .map((info) => ({
83
+ address: typeof info['local'] === 'string' ? info['local'] : '',
84
+ scope: typeof info['scope'] === 'string' ? info['scope'] : '',
85
+ })),
86
+ }));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** `docker0`, `br-*`: an interface pattern of the policy, with `*` at its end. */
93
+ function matchesInterface(name: string, patterns: string[]): boolean {
94
+ return patterns.some((pattern) =>
95
+ pattern.endsWith('*')
96
+ ? name.startsWith(pattern.slice(0, -1))
97
+ : name === pattern
98
+ );
99
+ }
100
+
101
+ const exposure = policy.exposure;
102
+ const internal = (name: string) =>
103
+ name === 'lo' ||
104
+ name === exposure.tailnetInterface ||
105
+ matchesInterface(name, exposure.containerInterfaces);
106
+
107
+ export function isLoopback(address: string): boolean {
108
+ return (
109
+ /^127\./.test(address) ||
110
+ address === '::1' ||
111
+ /^::ffff:127\./i.test(address)
112
+ );
113
+ }
114
+
115
+ /** Tailscale's ranges: 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
116
+ export function isTailnet(address: string): boolean {
117
+ const v4 = /^(?:::ffff:)?100\.(\d+)\./i.exec(address);
118
+ if (v4) {
119
+ const second = Number(v4[1]);
120
+ return second >= 64 && second <= 127;
121
+ }
122
+ return /^fd7a:115c:a1e0:/i.test(address);
123
+ }
124
+
125
+ /** The addresses this worker has outside loopback, the tailnet and its
126
+ * container bridges: where traffic from outside the tailnet arrives. */
127
+ export function outsideAddresses(interfaces: Interface[]): string[] {
128
+ return interfaces
129
+ .filter((entry) => !internal(entry.name))
130
+ .flatMap((entry) => entry.addresses)
131
+ .filter(
132
+ (entry) =>
133
+ entry.scope === 'global' &&
134
+ isIP(entry.address) !== 0 &&
135
+ !isTailnet(entry.address)
136
+ )
137
+ .map((entry) => entry.address);
138
+ }
139
+
140
+ /** The systemd unit a process runs in, read from its cgroup; never its
141
+ * command line. */
142
+ export function unitOf(cgroup: string): string | null {
143
+ const path = /^0::(\S+)$/m.exec(cgroup)?.[1] ?? '';
144
+ const unit = path
145
+ .split('/')
146
+ .reverse()
147
+ .find((part) => /\.(service|scope|socket)$/.test(part));
148
+ if (!unit || !/^[\w@.:-]{1,128}$/.test(unit)) return null;
149
+ return unit === 'init.scope' ? 'systemd' : unit;
150
+ }
151
+
152
+ interface Firewall {
153
+ active: boolean;
154
+ publicSsh: boolean;
155
+ }
156
+
157
+ /** What `nft -j list table inet personal_fleet` says of the rules. */
158
+ export function parseFirewall(text: string): Firewall {
159
+ try {
160
+ const value: unknown = JSON.parse(text);
161
+ const items =
162
+ record(value) && Array.isArray(value['nftables'])
163
+ ? value['nftables'].filter(record)
164
+ : [];
165
+ const active = items.some(
166
+ (item) =>
167
+ record(item['chain']) &&
168
+ item['chain']['hook'] === 'input' &&
169
+ item['chain']['policy'] === 'drop'
170
+ );
171
+ const publicSsh = items.some(
172
+ (item) => record(item['rule']) && item['rule']['comment'] === 'public-ssh'
173
+ );
174
+ return { active, publicSsh };
175
+ } catch {
176
+ return { active: false, publicSsh: false };
177
+ }
178
+ }
179
+
180
+ const describe = (listener: Listener, unit: string | null): string => {
181
+ const host = listener.address.includes(':')
182
+ ? `[${listener.address}]`
183
+ : listener.address;
184
+ return `${listener.protocol} ${host}:${listener.port} (${unit ?? 'unknown unit'})`;
185
+ };
186
+
187
+ const allowed = (ports: PublicPort[], listener: Listener) =>
188
+ ports.some(
189
+ (entry) =>
190
+ entry.protocol === listener.protocol && entry.port === listener.port
191
+ );
192
+
193
+ /** The listeners the declared policy allows on every interface: sshd and
194
+ * tailscaled (the firewall decides who reaches them), the system's own, and
195
+ * the ports the policy opens. */
196
+ export function allowList(rules: WorkerExposure): PublicPort[] {
197
+ return [
198
+ { protocol: 'tcp', port: rules.sshPort },
199
+ { protocol: 'udp', port: exposure.tailscalePort },
200
+ ...(exposure.systemListeners as PublicPort[]),
201
+ ...rules.policy.publicPorts,
202
+ ];
203
+ }
204
+
205
+ const UNRESTRICTED = 'WORKER_LISTENER_UNRESTRICTED';
206
+
207
+ const REMEDY: Record<string, string> = {
208
+ WORKER_FIREWALL_INACTIVE:
209
+ 'The fleet firewall is not loaded: run devfleet fleet-sync, which applies ansible/ubuntu/tasks/firewall.yml. Until then every listener below is reachable from every network the worker is on.',
210
+ WORKER_PUBLIC_SSH_UNDECLARED:
211
+ 'SSH is still open beyond the tailnet because fleet-sync could not prove the manager reaches the worker over the tailnet. Point the SSH alias of this worker at its tailnet name, then run devfleet fleet-sync; declare exposure.publicSsh only for break-glass access.',
212
+ WORKER_LISTENER_UNRESTRICTED:
213
+ 'Bind the named service to 127.0.0.1 or the tailnet address, or declare its port in exposure.publicPorts of fleet.json. A fleet service on a public address is a fault of its unit: run devfleet fleet-sync.',
214
+ WORKER_EXPOSURE_UNOBSERVED:
215
+ 'The listening sockets or the firewall could not be read with sudo -n; check worker:sudo, then rerun devfleet fleet-status --workers.',
216
+ };
217
+
218
+ function result(status: Status, codes: string[], summary: string): Check {
219
+ return {
220
+ id: 'worker:exposure',
221
+ status,
222
+ summary,
223
+ ...(status === 'pass' || status === 'not-applicable'
224
+ ? {}
225
+ : {
226
+ remedy: {
227
+ action: codes.map((code) => REMEDY[code]).join(' '),
228
+ },
229
+ }),
230
+ };
231
+ }
232
+
233
+ /** What was read on the worker. */
234
+ interface Observed {
235
+ interfaces: Interface[];
236
+ listeners: Listener[];
237
+ firewall: Firewall;
238
+ }
239
+
240
+ /** What the worker shows, or the check itself when exposure is not
241
+ * applicable or could not be read. */
242
+ async function observe(run: Runner): Promise<Observed | Check> {
243
+ const container = await run(['systemd-detect-virt', '--container']);
244
+ if (container.ok)
245
+ return result(
246
+ 'not-applicable',
247
+ [],
248
+ 'This worker runs in a container: its network and firewall belong to its host.'
249
+ );
250
+ const addresses = await run(['ip', '-j', 'address', 'show']);
251
+ const interfaces = addresses.ok ? parseInterfaces(addresses.stdout) : null;
252
+ if (interfaces === null)
253
+ return result(
254
+ 'fail',
255
+ ['WORKER_EXPOSURE_UNOBSERVED'],
256
+ 'WORKER_EXPOSURE_UNOBSERVED (addresses)'
257
+ );
258
+ if (outsideAddresses(interfaces).length === 0)
259
+ return result(
260
+ 'not-applicable',
261
+ [],
262
+ 'This worker has no address outside the tailnet: nothing beyond it can connect.'
263
+ );
264
+ const sockets = await run([
265
+ 'sudo',
266
+ '-n',
267
+ 'ss',
268
+ '-H',
269
+ '-l',
270
+ '-n',
271
+ '-t',
272
+ '-u',
273
+ '-p',
274
+ ]);
275
+ if (!sockets.ok)
276
+ return result(
277
+ 'fail',
278
+ ['WORKER_EXPOSURE_UNOBSERVED'],
279
+ 'WORKER_EXPOSURE_UNOBSERVED (sockets)'
280
+ );
281
+ const table = await run([
282
+ 'sudo',
283
+ '-n',
284
+ 'nft',
285
+ '-j',
286
+ 'list',
287
+ 'table',
288
+ ...FIREWALL_TABLE,
289
+ ]);
290
+ return {
291
+ interfaces,
292
+ listeners: parseListeners(sockets.stdout),
293
+ firewall: table.ok
294
+ ? parseFirewall(table.stdout)
295
+ : { active: false, publicSsh: false },
296
+ };
297
+ }
298
+
299
+ /** The listeners beyond loopback, the tailnet and the container bridges. */
300
+ function exposedListeners(observed: Observed): Listener[] {
301
+ const bridged = new Set(
302
+ observed.interfaces
303
+ .filter((entry) => internal(entry.name))
304
+ .flatMap((entry) => entry.addresses.map((info) => info.address))
305
+ );
306
+ return observed.listeners.filter(
307
+ (listener) =>
308
+ !(listener.device && internal(listener.device)) &&
309
+ !isLoopback(listener.address) &&
310
+ !isTailnet(listener.address) &&
311
+ !bridged.has(listener.address)
312
+ );
313
+ }
314
+
315
+ /** The exposed listeners outside the allow-list, by who owns them: a fleet
316
+ * service, or anything else. Each names its port and unit. */
317
+ async function unrestricted(
318
+ rules: WorkerExposure,
319
+ exposed: Listener[],
320
+ run: Runner
321
+ ): Promise<{ fleet: string[]; other: string[] }> {
322
+ const units = new Map<number, string | null>();
323
+ for (const pid of new Set(exposed.flatMap((listener) => listener.pids))) {
324
+ const cgroup = await run(['cat', `/proc/${pid}/cgroup`]);
325
+ units.set(pid, cgroup.ok ? unitOf(cgroup.stdout) : null);
326
+ }
327
+ const permitted = allowList(rules);
328
+ const fleetPorts = [rules.t3Port, MONITORING_AGENT_PORT];
329
+ const found = { fleet: new Set<string>(), other: new Set<string>() };
330
+ for (const listener of exposed) {
331
+ if (allowed(permitted, listener)) continue;
332
+ const unit =
333
+ listener.pids.map((pid) => units.get(pid)).find(Boolean) ?? null;
334
+ const owned =
335
+ fleetPorts.includes(listener.port) ||
336
+ (unit !== null && FLEET_UNITS.includes(unit));
337
+ (owned ? found.fleet : found.other).add(describe(listener, unit));
338
+ }
339
+ return { fleet: [...found.fleet], other: [...found.other] };
340
+ }
341
+
342
+ /** The firewall's own fault, when it has one. */
343
+ function firewallFault(rules: WorkerExposure, firewall: Firewall): string[] {
344
+ if (!firewall.active) return ['WORKER_FIREWALL_INACTIVE'];
345
+ if (firewall.publicSsh && !rules.policy.publicSsh)
346
+ return ['WORKER_PUBLIC_SSH_UNDECLARED'];
347
+ return [];
348
+ }
349
+
350
+ const listed = (listeners: string[]): string[] =>
351
+ listeners.length ? [`${UNRESTRICTED} ${listeners.join(', ')}`] : [];
352
+
353
+ /** worker:exposure: nothing listens beyond loopback and the tailnet but what
354
+ * the declared policy allows, and the firewall that closes the rest is
355
+ * loaded. Not applicable without an address outside the tailnet. Reports a
356
+ * port and the unit that owns it, never a process's arguments.
357
+ */
358
+ export async function exposureCheck(
359
+ rules: WorkerExposure,
360
+ run: Runner
361
+ ): Promise<Check> {
362
+ const observed = await observe(run);
363
+ if ('id' in observed) return observed;
364
+ const { fleet, other } = await unrestricted(
365
+ rules,
366
+ exposedListeners(observed),
367
+ run
368
+ );
369
+ const strict = rules.policy.otherListeners === 'fail';
370
+ const failures = [
371
+ ...firewallFault(rules, observed.firewall),
372
+ ...listed(strict ? [...fleet, ...other] : fleet),
373
+ ];
374
+ const problems = [...failures, ...listed(strict ? [] : other)];
375
+ if (problems.length)
376
+ return result(
377
+ failures.length ? 'fail' : 'warn',
378
+ [...new Set(problems.map((problem) => problem.split(' ')[0]!))],
379
+ problems.join('; ')
380
+ );
381
+ return result(
382
+ 'pass',
383
+ [],
384
+ rules.policy.publicSsh
385
+ ? 'Only loopback, the tailnet and the declared allow-list listen beyond this worker, and the fleet firewall is loaded; SSH stays open beyond the tailnet by the declared break-glass opt-in.'
386
+ : 'Only loopback, the tailnet and the declared allow-list listen beyond this worker, and the fleet firewall admits inbound traffic only over the tailnet.'
387
+ );
388
+ }
@@ -0,0 +1,201 @@
1
+ import policy from '../ansible/ubuntu/policy.json';
2
+ import {
3
+ DEFAULT_FEATURES,
4
+ workerFeatures,
5
+ type WorkerFeatures,
6
+ } from '../bootstrap/worker-features';
7
+ import type { Check, Runner } from './types';
8
+
9
+ export { DEFAULT_FEATURES, workerFeatures, type WorkerFeatures };
10
+
11
+ /** What dev-fleet installs for a feature, as `features` in
12
+ * ansible/ubuntu/policy.json declares it. */
13
+ export interface FeatureParts {
14
+ packages: string[];
15
+ services: string[];
16
+ groups: string[];
17
+ tools: string[];
18
+ }
19
+
20
+ const NONE: FeatureParts = {
21
+ packages: [],
22
+ services: [],
23
+ groups: [],
24
+ tools: [],
25
+ };
26
+
27
+ function parts(name: keyof typeof policy.features): FeatureParts {
28
+ return { ...NONE, ...policy.features[name] };
29
+ }
30
+
31
+ /** What a worker with these features does not get: the parts of each
32
+ * feature that is off. The same lists `tasks/features.yml` leaves out. */
33
+ export function partsOff(features: WorkerFeatures): FeatureParts {
34
+ const off = [
35
+ ...(features.containers ? [] : [parts('containers')]),
36
+ ...(features.session === 'headless' ? [parts('desktop')] : []),
37
+ ];
38
+ return {
39
+ packages: off.flatMap((entry) => entry.packages),
40
+ services: off.flatMap((entry) => entry.services),
41
+ groups: off.flatMap((entry) => entry.groups),
42
+ tools: off.flatMap((entry) => entry.tools),
43
+ };
44
+ }
45
+
46
+ /** Why a part is not on this worker, for the summary of its check. */
47
+ export function offReason(features: WorkerFeatures, part: string): string {
48
+ const containers = parts('containers');
49
+ if (
50
+ !features.containers &&
51
+ Object.values(containers).some((list: string[]) => list.includes(part))
52
+ )
53
+ return 'containers are off for this worker (features.containers)';
54
+ return 'this worker runs a headless session (features.session), which has no desktop';
55
+ }
56
+
57
+ /** A check that does not apply to this worker, with the reason. */
58
+ export function notApplicable(id: string, reason: string): Check {
59
+ return {
60
+ id,
61
+ status: 'not-applicable',
62
+ summary: `Not applicable: ${reason}.`,
63
+ };
64
+ }
65
+
66
+ /** The packages and always-on services a worker with these features is
67
+ * measured against: the policy's, less the parts of the features it lacks. */
68
+ export function declaredFor<
69
+ T extends { packages: Record<string, string>; alwaysOnServices: string[] },
70
+ >(declared: T, features: WorkerFeatures): T {
71
+ const off = partsOff(features);
72
+ return {
73
+ ...declared,
74
+ packages: Object.fromEntries(
75
+ Object.entries(declared.packages).filter(
76
+ ([name]) => !off.packages.includes(name)
77
+ )
78
+ ),
79
+ alwaysOnServices: declared.alwaysOnServices.filter(
80
+ (name) => !off.services.includes(name)
81
+ ),
82
+ };
83
+ }
84
+
85
+ /** What switching a worker from one set of features to another removes,
86
+ * in the words `fleet-sync --dry-run` prints. Turning a feature on removes
87
+ * nothing; turning the session to headless retires the desktop unit and keeps
88
+ * ~/.t3. */
89
+ export function featureRemovals(
90
+ before: WorkerFeatures,
91
+ after: WorkerFeatures,
92
+ user: string
93
+ ): string[] {
94
+ const removed: string[] = [];
95
+ if (before.containers && !after.containers) {
96
+ const off = parts('containers');
97
+ removed.push(
98
+ ...off.packages.map((name) => `package ${name}`),
99
+ ...off.services.map(
100
+ (name) => `service ${name}.service (with its package)`
101
+ ),
102
+ ...off.groups.map((name) => `${user}'s membership of the ${name} group`),
103
+ ...off.tools.map((name) => `tool ${name}`),
104
+ 'the Docker step of the disk GC'
105
+ );
106
+ }
107
+ if (before.session !== after.session)
108
+ removed.push(
109
+ after.session === 'headless'
110
+ ? 'user unit t3code-desktop.service (the T3 desktop app stops; ~/.t3 is kept)'
111
+ : 'user unit t3code-headless.service (the T3 server stops; ~/.t3 is kept)'
112
+ );
113
+ return removed;
114
+ }
115
+
116
+ /** What of a feature's parts is on this machine now. */
117
+ export interface PresentParts {
118
+ packages: string[];
119
+ groups: string[];
120
+ }
121
+
122
+ /** Reads which parts of a `containers` feature that is off are still
123
+ * installed: the packages dpkg holds as installed, and the developer's
124
+ * groups. A headless worker installs no desktop package but removes none
125
+ * either: a desktop the machine came with may depend on them. */
126
+ export async function presentParts(
127
+ run: Runner,
128
+ user: string,
129
+ features: WorkerFeatures
130
+ ): Promise<PresentParts> {
131
+ const off = features.containers ? NONE : parts('containers');
132
+ if (!off.packages.length && !off.groups.length)
133
+ return { packages: [], groups: [] };
134
+ const listed = off.packages.length
135
+ ? await run([
136
+ 'dpkg-query',
137
+ '-W',
138
+ '-f=${Package}\t${db:Status-Abbrev}\n',
139
+ ...off.packages,
140
+ ])
141
+ : { stdout: '' };
142
+ const installed = listed.stdout
143
+ .split('\n')
144
+ .map((line) => line.split('\t'))
145
+ .filter(
146
+ ([name, status]) =>
147
+ off.packages.includes(name ?? '') && status?.startsWith('ii')
148
+ )
149
+ .map(([name]) => name!);
150
+ const groups = off.groups.length ? await run(['id', '-nG', user]) : null;
151
+ const member = groups?.ok ? groups.stdout.trim().split(/\s+/) : [];
152
+ return {
153
+ packages: installed,
154
+ groups: off.groups.filter((group) => member.includes(group)),
155
+ };
156
+ }
157
+
158
+ const REMOVAL_INCOMPLETE = 'WORKER_FEATURE_REMOVAL_INCOMPLETE';
159
+
160
+ /** `worker:features`: the worker has the features fleet.json declares, and
161
+ * nothing dev-fleet installed for a feature it turned off remains. `before`,
162
+ * when given, is what was present before this run: the difference is what
163
+ * the run removed, and it is reported. */
164
+ export function featuresCheck(
165
+ features: WorkerFeatures,
166
+ present: PresentParts,
167
+ before?: PresentParts
168
+ ): Check {
169
+ const declared = `containers ${features.containers ? 'on' : 'off'}, ${features.session} session`;
170
+ const remaining = [
171
+ ...present.packages.map((name) => `package ${name}`),
172
+ ...present.groups.map((name) => `membership of the ${name} group`),
173
+ ];
174
+ if (remaining.length)
175
+ return {
176
+ id: 'worker:features',
177
+ status: 'fail',
178
+ summary: `${REMOVAL_INCOMPLETE}: ${declared}; still present: ${remaining.join(', ')}.`,
179
+ remedy: {
180
+ action:
181
+ 'Rerun fleet-sync, which removes what dev-fleet installed for a feature that is off, and read the ubuntu:baseline output. A package another package depends on stays until a person removes that one.',
182
+ },
183
+ };
184
+ const removed = before
185
+ ? [
186
+ ...before.packages
187
+ .filter((name) => !present.packages.includes(name))
188
+ .map((name) => `package ${name}`),
189
+ ...before.groups
190
+ .filter((name) => !present.groups.includes(name))
191
+ .map((name) => `membership of the ${name} group`),
192
+ ]
193
+ : [];
194
+ return {
195
+ id: 'worker:features',
196
+ status: 'pass',
197
+ summary: removed.length
198
+ ? `Declared features (${declared}); this run removed ${removed.join(', ')}.`
199
+ : `Declared features (${declared}); nothing of a feature that is off is installed.`,
200
+ };
201
+ }