@crvouga/dev-fleet 0.1.0 → 0.3.0
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 +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import type { Check } from '../types';
|
|
3
|
+
import { canonical, object, read, write } from './files';
|
|
4
|
+
|
|
5
|
+
/** What happens to one declared setting. `release` stops managing a key the
|
|
6
|
+
* fleet no longer declares and leaves its value to the machine.
|
|
7
|
+
*/
|
|
8
|
+
export type ProfileAction =
|
|
9
|
+
'create' | 'update' | 'delete' | 'release' | 'unchanged' | 'conflict';
|
|
10
|
+
export type ProfileChange = { key: string; action: ProfileAction };
|
|
11
|
+
|
|
12
|
+
/** The values the fleet last wrote, kept beside T3's settings: the base of
|
|
13
|
+
* the three-way merge between what is declared, what T3 holds and what was
|
|
14
|
+
* applied before.
|
|
15
|
+
*/
|
|
16
|
+
export const profileRecord = (settings: string): string =>
|
|
17
|
+
join(dirname(settings), 'personal-profile.json');
|
|
18
|
+
|
|
19
|
+
const same = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b);
|
|
20
|
+
|
|
21
|
+
/** Managed keys converge, keys nobody declared are left alone, and a key
|
|
22
|
+
* changed on the machine since the fleet last wrote it (or that existed
|
|
23
|
+
* before the fleet declared it) is a conflict, never overwritten.
|
|
24
|
+
*/
|
|
25
|
+
export function planProfile(
|
|
26
|
+
settings: Record<string, unknown>,
|
|
27
|
+
applied: Record<string, unknown>,
|
|
28
|
+
declared: Record<string, unknown>
|
|
29
|
+
) {
|
|
30
|
+
const next = { ...settings };
|
|
31
|
+
const record = { ...applied };
|
|
32
|
+
const changes: ProfileChange[] = [];
|
|
33
|
+
const keys = [
|
|
34
|
+
...new Set([...Object.keys(declared), ...Object.keys(applied)]),
|
|
35
|
+
].sort();
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
const present = Object.hasOwn(settings, key);
|
|
38
|
+
const managed = Object.hasOwn(applied, key);
|
|
39
|
+
const action = actionFor(key, declared, {
|
|
40
|
+
present,
|
|
41
|
+
managed,
|
|
42
|
+
current: settings[key],
|
|
43
|
+
base: applied[key],
|
|
44
|
+
});
|
|
45
|
+
changes.push({ key, action });
|
|
46
|
+
if (action === 'create' || action === 'update') {
|
|
47
|
+
next[key] = declared[key];
|
|
48
|
+
record[key] = declared[key];
|
|
49
|
+
} else if (action === 'unchanged') record[key] = declared[key];
|
|
50
|
+
else if (action === 'delete') {
|
|
51
|
+
delete next[key];
|
|
52
|
+
delete record[key];
|
|
53
|
+
} else if (action === 'release') delete record[key];
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
settings: next,
|
|
57
|
+
record,
|
|
58
|
+
changes,
|
|
59
|
+
settingsChanged: !same(next, settings),
|
|
60
|
+
recordChanged: !same(record, applied),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function actionFor(
|
|
65
|
+
key: string,
|
|
66
|
+
declared: Record<string, unknown>,
|
|
67
|
+
{
|
|
68
|
+
present,
|
|
69
|
+
managed,
|
|
70
|
+
current,
|
|
71
|
+
base,
|
|
72
|
+
}: { present: boolean; managed: boolean; current: unknown; base: unknown }
|
|
73
|
+
): ProfileAction {
|
|
74
|
+
if (!Object.hasOwn(declared, key)) {
|
|
75
|
+
if (present && same(current, base)) return 'delete';
|
|
76
|
+
return 'release';
|
|
77
|
+
}
|
|
78
|
+
const wanted = declared[key];
|
|
79
|
+
if (present && same(current, wanted)) return 'unchanged';
|
|
80
|
+
if (!present) return managed ? 'conflict' : 'create';
|
|
81
|
+
return managed && same(current, base) ? 'update' : 'conflict';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ProfileOptions {
|
|
85
|
+
apply: boolean;
|
|
86
|
+
/** Tries before a settings file T3 keeps rewriting is reported busy. */
|
|
87
|
+
attempts?: number;
|
|
88
|
+
pause?: () => Promise<void>;
|
|
89
|
+
/** Runs between the read and the write: where T3 may write too. */
|
|
90
|
+
beforeWrite?: () => Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Plans, and with `apply` writes, the declared profile. T3 writes the same
|
|
94
|
+
* file and offers no compare-and-swap, so a write happens only when the file
|
|
95
|
+
* is still what was read; otherwise the merge is planned again, a bounded
|
|
96
|
+
* number of times.
|
|
97
|
+
*/
|
|
98
|
+
export async function reconcileProfile(
|
|
99
|
+
settingsPath: string,
|
|
100
|
+
declared: Record<string, unknown>,
|
|
101
|
+
options: ProfileOptions
|
|
102
|
+
) {
|
|
103
|
+
const recordPath = profileRecord(settingsPath);
|
|
104
|
+
const attempts = options.attempts ?? 3;
|
|
105
|
+
const pause =
|
|
106
|
+
options.pause ?? (() => new Promise((done) => setTimeout(done, 500)));
|
|
107
|
+
for (let attempt = 1; ; attempt++) {
|
|
108
|
+
const raw = await read(settingsPath);
|
|
109
|
+
const recordRaw = await read(recordPath);
|
|
110
|
+
const plan = planProfile(
|
|
111
|
+
object(raw),
|
|
112
|
+
readRecord(recordRaw, settingsPath),
|
|
113
|
+
declared
|
|
114
|
+
);
|
|
115
|
+
if (!options.apply || (!plan.settingsChanged && !plan.recordChanged))
|
|
116
|
+
return { ...plan, applied: false };
|
|
117
|
+
await options.beforeWrite?.();
|
|
118
|
+
if (!plan.settingsChanged || (await read(settingsPath)) === raw) {
|
|
119
|
+
if (plan.settingsChanged)
|
|
120
|
+
await write(
|
|
121
|
+
settingsPath,
|
|
122
|
+
JSON.stringify(plan.settings, null, 2) + '\n'
|
|
123
|
+
);
|
|
124
|
+
await write(
|
|
125
|
+
recordPath,
|
|
126
|
+
canonical({ version: 1, target: settingsPath, settings: plan.record }) +
|
|
127
|
+
'\n'
|
|
128
|
+
);
|
|
129
|
+
return { ...plan, applied: true };
|
|
130
|
+
}
|
|
131
|
+
if (attempt >= attempts) throw new Error('T3_SETTINGS_BUSY');
|
|
132
|
+
await pause();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readRecord(
|
|
137
|
+
raw: string | null,
|
|
138
|
+
target: string
|
|
139
|
+
): Record<string, unknown> {
|
|
140
|
+
if (raw === null) return {};
|
|
141
|
+
const record = object(raw);
|
|
142
|
+
if (record['version'] !== 1 || record['target'] !== target)
|
|
143
|
+
throw new Error('T3_PROFILE_RECORD_INVALID');
|
|
144
|
+
return object(JSON.stringify(record['settings'] ?? {}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `t3:profile`: the declared settings on this machine. A report names the
|
|
148
|
+
* keys and what happens to them, never a value: a tool server's settings may
|
|
149
|
+
* carry a credential.
|
|
150
|
+
*/
|
|
151
|
+
export async function profileCheck(
|
|
152
|
+
settingsPath: string,
|
|
153
|
+
declared: Record<string, unknown>,
|
|
154
|
+
options: ProfileOptions
|
|
155
|
+
): Promise<Check> {
|
|
156
|
+
const remedy = (action: string) => ({ remedy: { action } });
|
|
157
|
+
try {
|
|
158
|
+
const result = await reconcileProfile(settingsPath, declared, options);
|
|
159
|
+
const conflicts = result.changes.filter(
|
|
160
|
+
(change) => change.action === 'conflict'
|
|
161
|
+
);
|
|
162
|
+
if (conflicts.length)
|
|
163
|
+
return {
|
|
164
|
+
id: 't3:profile',
|
|
165
|
+
status: 'warn',
|
|
166
|
+
summary: 'T3_PROFILE_CONFLICT',
|
|
167
|
+
...remedy(
|
|
168
|
+
`These T3 settings were changed on this machine since dev-fleet last wrote them, or existed before they were declared, and were left as they are: ${conflicts.map((change) => change.key).join(', ')}. Put the value every machine should hold under t3.profile in fleet.json, or remove the setting on this machine, then run fleet-sync.`
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
const pending = result.changes.filter(
|
|
172
|
+
(change) => !['unchanged', 'conflict'].includes(change.action)
|
|
173
|
+
);
|
|
174
|
+
if (!options.apply && pending.length)
|
|
175
|
+
return {
|
|
176
|
+
id: 't3:profile',
|
|
177
|
+
status: 'warn',
|
|
178
|
+
summary: 'T3_PROFILE_PENDING',
|
|
179
|
+
...remedy(
|
|
180
|
+
`fleet-sync would change these T3 settings: ${pending.map((change) => `${change.key} (${change.action})`).join(', ')}.`
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
id: 't3:profile',
|
|
185
|
+
status: 'pass',
|
|
186
|
+
summary: Object.keys(declared).length
|
|
187
|
+
? 'T3 on this machine holds every setting of the declared profile; settings nobody declared are its own.'
|
|
188
|
+
: 'No T3 profile is declared; every setting stays local.',
|
|
189
|
+
};
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const busy = error instanceof Error && error.message === 'T3_SETTINGS_BUSY';
|
|
192
|
+
return {
|
|
193
|
+
id: 't3:profile',
|
|
194
|
+
status: 'fail',
|
|
195
|
+
summary: busy ? 'T3_SETTINGS_BUSY' : 'T3_PROFILE_UNVERIFIED',
|
|
196
|
+
...remedy(
|
|
197
|
+
busy
|
|
198
|
+
? 'T3 kept rewriting its settings while dev-fleet merged the profile. Run fleet-sync again; nothing was overwritten.'
|
|
199
|
+
: 'Repair T3’s settings.json (or restore personal-profile.json from a backup) on this machine, then run fleet-sync.'
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sshArgs } from '../ssh';
|
|
4
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
5
|
+
|
|
6
|
+
/** The T3 release every machine runs: `t3_version` in mise.toml. */
|
|
7
|
+
export async function pinnedT3Version(workspace: string): Promise<string> {
|
|
8
|
+
const toml = await readFile(join(workspace, 'mise.toml'), 'utf8');
|
|
9
|
+
const version = /^t3_version = "([^"]+)"$/m.exec(toml)?.[1];
|
|
10
|
+
if (!version) throw new Error('mise.toml pins no T3 version.');
|
|
11
|
+
return version;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Where T3 notes the version it last started as, under its home. */
|
|
15
|
+
export const STARTED_VERSION = 'userdata/desktop-last-started-version';
|
|
16
|
+
|
|
17
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
|
|
18
|
+
|
|
19
|
+
/** `t3:version`: the T3 a machine runs is the one the fleet pins, so the
|
|
20
|
+
* manager and the workers it drives never run different releases.
|
|
21
|
+
*/
|
|
22
|
+
export function versionCheck(
|
|
23
|
+
running: string | null,
|
|
24
|
+
pinned: string,
|
|
25
|
+
where: string,
|
|
26
|
+
install: string
|
|
27
|
+
): Check {
|
|
28
|
+
const observed = running?.trim() ?? '';
|
|
29
|
+
if (!VERSION.test(observed))
|
|
30
|
+
return {
|
|
31
|
+
id: 't3:version',
|
|
32
|
+
status: 'warn',
|
|
33
|
+
summary: 'T3_VERSION_UNKNOWN',
|
|
34
|
+
remedy: {
|
|
35
|
+
action: `T3 on ${where} has not recorded the version it runs. Start T3 there once, then run fleet-sync.`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
if (observed !== pinned)
|
|
39
|
+
return {
|
|
40
|
+
id: 't3:version',
|
|
41
|
+
status: 'warn',
|
|
42
|
+
summary: 'T3_VERSION_SKEW',
|
|
43
|
+
remedy: {
|
|
44
|
+
action: `T3 on ${where} runs ${observed}; the fleet pins ${pinned}. ${install}`,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
id: 't3:version',
|
|
49
|
+
status: 'pass',
|
|
50
|
+
summary: `T3 on ${where} runs ${pinned}, the release every machine runs.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function managerVersion(
|
|
55
|
+
t3Home: string,
|
|
56
|
+
workspace: string
|
|
57
|
+
): Promise<Check> {
|
|
58
|
+
const running = await readFile(join(t3Home, STARTED_VERSION), 'utf8').catch(
|
|
59
|
+
() => null
|
|
60
|
+
);
|
|
61
|
+
return versionCheck(
|
|
62
|
+
running,
|
|
63
|
+
await pinnedT3Version(workspace),
|
|
64
|
+
'the manager',
|
|
65
|
+
'Install that release of T3 Code on the manager. With the fleet policy staged (ansible-playbook -K ansible/macos/manager.yml) it no longer updates itself.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function workerVersion(
|
|
70
|
+
node: FleetNode,
|
|
71
|
+
run: Runner,
|
|
72
|
+
workspace: string
|
|
73
|
+
): Promise<Check> {
|
|
74
|
+
const result = await run(
|
|
75
|
+
[...sshArgs(node), `cat "$HOME/.t3/${STARTED_VERSION}"`],
|
|
76
|
+
{ timeoutMs: 15_000 }
|
|
77
|
+
);
|
|
78
|
+
return versionCheck(
|
|
79
|
+
result.ok ? result.stdout : null,
|
|
80
|
+
await pinnedT3Version(workspace),
|
|
81
|
+
`worker ${node.id}`,
|
|
82
|
+
'Run fleet-sync for that worker: it installs the pinned desktop app and restarts T3 when its policy changed.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where T3 on macOS reads its administrator policy. */
|
|
87
|
+
export const MACOS_POLICY = '/Library/Application Support/T3Code/policy.json';
|
|
88
|
+
|
|
89
|
+
/** `t3:updates`: T3's own updater is off by the fleet's policy, so it changes
|
|
90
|
+
* only when the pin does.
|
|
91
|
+
*/
|
|
92
|
+
export async function updatesCheck(
|
|
93
|
+
policyPath: string,
|
|
94
|
+
stage: string
|
|
95
|
+
): Promise<Check> {
|
|
96
|
+
let mode: unknown;
|
|
97
|
+
try {
|
|
98
|
+
const policy = JSON.parse(await readFile(policyPath, 'utf8')) as {
|
|
99
|
+
limits?: { updates?: { mode?: unknown } };
|
|
100
|
+
};
|
|
101
|
+
mode = policy.limits?.updates?.mode;
|
|
102
|
+
} catch {
|
|
103
|
+
mode = undefined;
|
|
104
|
+
}
|
|
105
|
+
return mode === 'off'
|
|
106
|
+
? {
|
|
107
|
+
id: 't3:updates',
|
|
108
|
+
status: 'pass',
|
|
109
|
+
summary: 'T3’s own updater is off by the fleet’s policy.',
|
|
110
|
+
}
|
|
111
|
+
: {
|
|
112
|
+
id: 't3:updates',
|
|
113
|
+
status: 'warn',
|
|
114
|
+
summary: 'T3_UPDATES_UNMANAGED',
|
|
115
|
+
remedy: {
|
|
116
|
+
action: `T3 on this machine may update itself away from the pinned release. Stage the fleet's policy once, as an administrator: ${stage}`,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/t3-endpoint.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { discoverTailnet } from './tailscale';
|
|
2
2
|
import type { Check, FleetNode, Runner } from './types';
|
|
3
3
|
|
|
4
|
+
/** An origin no browser of the fleet uses: the private route must not let
|
|
5
|
+
* a page served from it read the worker's T3.
|
|
6
|
+
*/
|
|
7
|
+
export const FOREIGN_ORIGIN = 'https://origin-check.invalid';
|
|
8
|
+
|
|
4
9
|
/** A fresh manager HTTPS request is transport evidence only. Never read
|
|
5
10
|
* pairing material or treat HTTP health as an authenticated tool session.
|
|
11
|
+
* A second request, from a foreign origin, must not be granted cross-origin
|
|
12
|
+
* access.
|
|
6
13
|
*/
|
|
7
14
|
export async function verifyT3Endpoint(
|
|
8
15
|
node: FleetNode,
|
|
@@ -21,17 +28,66 @@ export async function verifyT3Endpoint(
|
|
|
21
28
|
});
|
|
22
29
|
await response.body?.cancel();
|
|
23
30
|
if (response.status !== 200) return pending('T3_MANAGER_ENDPOINT_REJECTED');
|
|
24
|
-
return [
|
|
25
|
-
{
|
|
26
|
-
id: 't3:manager-endpoint',
|
|
27
|
-
status: 'pass',
|
|
28
|
-
summary:
|
|
29
|
-
'A fresh HTTPS request from the manager reached the enrolled private endpoint with valid TLS and HTTP 200. Pairing, server compatibility and tool execution remain unverified.',
|
|
30
|
-
},
|
|
31
|
-
];
|
|
32
31
|
} catch {
|
|
33
32
|
return pending('T3_MANAGER_ENDPOINT_UNREACHABLE');
|
|
34
33
|
}
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
id: 't3:manager-endpoint',
|
|
37
|
+
status: 'pass',
|
|
38
|
+
summary:
|
|
39
|
+
'A fresh HTTPS request from the manager reached the enrolled private endpoint with valid TLS and HTTP 200. Pairing, server compatibility and tool execution remain unverified.',
|
|
40
|
+
},
|
|
41
|
+
await originCheck('https://' + peer.dns, request),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Grants cross-origin reads: it echoes the foreign origin, or allows any
|
|
46
|
+
* origin together with credentials.
|
|
47
|
+
*/
|
|
48
|
+
function permissive(headers: Headers): boolean {
|
|
49
|
+
const allowed = headers.get('access-control-allow-origin')?.trim();
|
|
50
|
+
return (
|
|
51
|
+
allowed === FOREIGN_ORIGIN ||
|
|
52
|
+
(allowed === '*' &&
|
|
53
|
+
headers.get('access-control-allow-credentials')?.trim() === 'true')
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function originCheck(
|
|
58
|
+
url: string,
|
|
59
|
+
request: (url: string, options: RequestInit) => Promise<Response>
|
|
60
|
+
): Promise<Check> {
|
|
61
|
+
const check = (status: Check['status'], summary: string): Check => ({
|
|
62
|
+
id: 't3:manager-origin',
|
|
63
|
+
status,
|
|
64
|
+
summary,
|
|
65
|
+
...(status === 'pass'
|
|
66
|
+
? {}
|
|
67
|
+
: {
|
|
68
|
+
remedy: {
|
|
69
|
+
action:
|
|
70
|
+
'The private T3 route must not grant cross-origin access to pages from other sites. Restore the stock T3 server and its private Serve route from source; never add a proxy that rewrites CORS headers.',
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
const response = await request(url, {
|
|
76
|
+
method: 'GET',
|
|
77
|
+
redirect: 'manual',
|
|
78
|
+
headers: { Origin: FOREIGN_ORIGIN },
|
|
79
|
+
signal: AbortSignal.timeout(10_000),
|
|
80
|
+
});
|
|
81
|
+
await response.body?.cancel();
|
|
82
|
+
return permissive(response.headers)
|
|
83
|
+
? check('fail', 'T3_MANAGER_ORIGIN_UNRESTRICTED')
|
|
84
|
+
: check(
|
|
85
|
+
'pass',
|
|
86
|
+
'A request from a foreign origin was not granted cross-origin access to the private T3 route.'
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
return check('warn', 'T3_MANAGER_ORIGIN_UNVERIFIED');
|
|
90
|
+
}
|
|
35
91
|
}
|
|
36
92
|
function pending(code: string): Check[] {
|
|
37
93
|
return [
|
package/src/t3-linked-check.ts
CHANGED
|
@@ -22,7 +22,29 @@ export async function verifyT3Linked(
|
|
|
22
22
|
)();
|
|
23
23
|
check = await linkedOnce(node, run, now());
|
|
24
24
|
}
|
|
25
|
-
return check;
|
|
25
|
+
return check.status === 'fail' ? explained(node, run, check) : check;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A worker enrolled with one T3 environment that now runs another lost
|
|
29
|
+
* its identity (a reset data directory, a reinstall): the old link cannot
|
|
30
|
+
* reach it, and a new pairing is the remedy, not a reconnect.
|
|
31
|
+
*/
|
|
32
|
+
async function explained(
|
|
33
|
+
node: FleetNode,
|
|
34
|
+
run: Runner,
|
|
35
|
+
check: Check
|
|
36
|
+
): Promise<Check> {
|
|
37
|
+
if (!node.t3EnvironmentId) return check;
|
|
38
|
+
const observed = await observedIdentity(node, run).catch(() => undefined);
|
|
39
|
+
if (!observed || observed === node.t3EnvironmentId) return check;
|
|
40
|
+
return {
|
|
41
|
+
...check,
|
|
42
|
+
summary: 'T3_ENVIRONMENT_IDENTITY_CHANGED',
|
|
43
|
+
remedy: {
|
|
44
|
+
action:
|
|
45
|
+
'This worker now runs a different T3 environment than the one enrolled in the roster, so the manager’s link cannot reach it. Find out why its T3 data was replaced before anything else; then pair the new environment through T3 Connections and enroll it again. Fleet never copies or rewrites a T3 identity.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
async function linkedOnce(
|
|
@@ -81,7 +103,7 @@ function linkedEvidence(
|
|
|
81
103
|
};
|
|
82
104
|
}
|
|
83
105
|
|
|
84
|
-
async function observedIdentity(
|
|
106
|
+
export async function observedIdentity(
|
|
85
107
|
node: FleetNode,
|
|
86
108
|
run: Runner
|
|
87
109
|
): Promise<string | undefined> {
|
package/src/toolchain-checks.ts
CHANGED
|
@@ -41,6 +41,29 @@ export async function toolchainChecks(
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Checks of the doctor that are not a tool version, with their own words. */
|
|
45
|
+
const OWN: Record<string, { pass: string; fail: string; action: string }> = {
|
|
46
|
+
'global-mise-config': {
|
|
47
|
+
pass: 'The global mise configuration is the one fleet-sync publishes.',
|
|
48
|
+
fail: 'The global mise configuration is missing, was replaced by another project or tool, or is not the one this revision publishes.',
|
|
49
|
+
action:
|
|
50
|
+
'Run devfleet fleet-sync from the project that holds fleet.json. On a fleet machine the global mise configuration belongs to dev-fleet: a project pins its own tools in a project mise.toml and never publishes a global one.',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A doctor check with its own words, or null for a tool version. */
|
|
55
|
+
function ownCheck(id: string, status: Check['status']): Check | null {
|
|
56
|
+
if (!Object.hasOwn(OWN, id)) return null;
|
|
57
|
+
const own = OWN[id]!;
|
|
58
|
+
const ok = status === 'pass';
|
|
59
|
+
return {
|
|
60
|
+
id: `tool:${id}`,
|
|
61
|
+
status,
|
|
62
|
+
summary: ok ? own.pass : own.fail,
|
|
63
|
+
...(!ok ? { remedy: { action: own.action } } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
function toolCheck(value: unknown): Check | null {
|
|
45
68
|
if (
|
|
46
69
|
!record(value) ||
|
|
@@ -49,6 +72,8 @@ function toolCheck(value: unknown): Check | null {
|
|
|
49
72
|
!['pass', 'fail', 'warn', 'skip'].includes(String(value['status']))
|
|
50
73
|
)
|
|
51
74
|
return null;
|
|
75
|
+
const own = ownCheck(value['id'], value['status'] as Check['status']);
|
|
76
|
+
if (own) return own;
|
|
52
77
|
const ok = value['status'] === 'pass';
|
|
53
78
|
const version =
|
|
54
79
|
typeof value['detail'] === 'string' &&
|
package/src/transport.ts
CHANGED
|
@@ -21,7 +21,7 @@ export async function bundleProbe(): Promise<string> {
|
|
|
21
21
|
export interface RemoteOptions {
|
|
22
22
|
source: string;
|
|
23
23
|
timeoutMs: number;
|
|
24
|
-
action: 'probe' | 'sync' | 'repair' | 'configure';
|
|
24
|
+
action: 'probe' | 'sync' | 'repair' | 'configure' | 'accounts';
|
|
25
25
|
configuration?: ConfigurationRequest;
|
|
26
26
|
repairs?: AutomatedRepair[];
|
|
27
27
|
expected?: Fingerprint;
|
|
@@ -41,7 +41,8 @@ export async function inspectRemote(
|
|
|
41
41
|
return { node: node.id, checks: [sshFailure(preflight)] };
|
|
42
42
|
}
|
|
43
43
|
const target =
|
|
44
|
-
options.action === 'probe'
|
|
44
|
+
(options.action === 'probe' || options.action === 'accounts') &&
|
|
45
|
+
options.expected
|
|
45
46
|
? await managedProbeTarget(node, options.expected.head, run)
|
|
46
47
|
: { node, checks: [] };
|
|
47
48
|
const command = remoteCommand(target.node, {
|
package/src/types.ts
CHANGED
|
@@ -80,6 +80,10 @@ export interface CommandOptions {
|
|
|
80
80
|
input?: string;
|
|
81
81
|
timeoutMs?: number;
|
|
82
82
|
env?: NodeJS.ProcessEnv;
|
|
83
|
+
/** Run with `env` alone instead of on top of this process's environment:
|
|
84
|
+
* for a launch that must see only what a service would.
|
|
85
|
+
*/
|
|
86
|
+
isolatedEnv?: boolean;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
export interface CommandResult {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Check, Runner } from './types';
|
|
2
|
-
import { fleet
|
|
2
|
+
import { fleet } from './fleet-config';
|
|
3
|
+
import { secretsProvider } from './secrets';
|
|
3
4
|
import { registry } from './agents/registry';
|
|
4
5
|
import { resolveVaultConfig, usesVault } from './vault-config';
|
|
5
6
|
import { record } from './config';
|
|
@@ -26,7 +27,29 @@ export async function workerAccessChecks(run: Runner): Promise<Check[]> {
|
|
|
26
27
|
...(usesVault() ? [await vaultRenewalCheck(run)] : []),
|
|
27
28
|
];
|
|
28
29
|
}
|
|
30
|
+
/** Vault refuses a read the token's policy does not allow with a 403. */
|
|
31
|
+
const PERMISSION_DENIED = /permission denied|Code: 403/i;
|
|
32
|
+
|
|
29
33
|
async function proxyAuthentication(run: Runner): Promise<Check> {
|
|
34
|
+
// Read through the declared provider, remembering only whether Vault said
|
|
35
|
+
// the policy forbids it: never what it printed.
|
|
36
|
+
let denied = false;
|
|
37
|
+
const secret = secretsProvider(fleet.secrets, {
|
|
38
|
+
run: async (argv) => {
|
|
39
|
+
const result = await run(argv, { timeoutMs: 20_000 });
|
|
40
|
+
if (!result.ok && PERMISSION_DENIED.test(result.stderr)) denied = true;
|
|
41
|
+
return result.ok ? result.stdout : null;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const forbidden: Check = {
|
|
45
|
+
id: 'worker:proxy-authentication',
|
|
46
|
+
status: 'fail',
|
|
47
|
+
summary: 'VAULT_POLICY_PERMISSION_REQUIRED',
|
|
48
|
+
remedy: {
|
|
49
|
+
action:
|
|
50
|
+
'This worker’s Vault identity was refused the proxy client key by its policy. Grant that identity read access to the declared location in the Vault policy you manage. fleet-sync seeds no secret, writes no policy and adds no secret store.',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
30
53
|
const unavailable: Check = {
|
|
31
54
|
id: 'worker:proxy-authentication',
|
|
32
55
|
status: 'fail',
|
|
@@ -37,7 +60,8 @@ async function proxyAuthentication(run: Runner): Promise<Check> {
|
|
|
37
60
|
},
|
|
38
61
|
};
|
|
39
62
|
try {
|
|
40
|
-
const key = (await
|
|
63
|
+
const key = (await secret('proxy-client-key')) ?? '';
|
|
64
|
+
if (denied) return forbidden;
|
|
41
65
|
if (!/^[\x21-\x7e]{16,4096}$/.test(key)) return unavailable;
|
|
42
66
|
const result = await run(
|
|
43
67
|
[
|
|
@@ -103,13 +127,42 @@ async function vaultRenewalCheck(run: Runner): Promise<Check> {
|
|
|
103
127
|
token['ttl'] <= 1200
|
|
104
128
|
)
|
|
105
129
|
return unavailable;
|
|
130
|
+
if (!(await lastRenewalSucceeded(run)))
|
|
131
|
+
return {
|
|
132
|
+
...unavailable,
|
|
133
|
+
summary: 'VAULT_RENEWAL_FAILING',
|
|
134
|
+
remedy: {
|
|
135
|
+
action:
|
|
136
|
+
'The identity is renewable, but the last run of the renewal job failed. Inspect it on this worker with: journalctl --user -u personal-worker-auth.service. Its token expires unless a renewal succeeds; no credentials are copied between machines.',
|
|
137
|
+
},
|
|
138
|
+
};
|
|
106
139
|
return {
|
|
107
140
|
...unavailable,
|
|
108
141
|
status: 'pass',
|
|
109
142
|
summary:
|
|
110
|
-
'This worker has a renewable periodic Vault identity with sufficient lifetime for the renewal timer.',
|
|
143
|
+
'This worker has a renewable periodic Vault identity with sufficient lifetime for the renewal timer, and its last renewal succeeded.',
|
|
111
144
|
};
|
|
112
145
|
} catch {
|
|
113
146
|
return unavailable;
|
|
114
147
|
}
|
|
115
148
|
}
|
|
149
|
+
|
|
150
|
+
/** An active timer is not evidence that renewal works: the result of the
|
|
151
|
+
* job's last run is. A job that has not run yet has nothing to say.
|
|
152
|
+
*/
|
|
153
|
+
async function lastRenewalSucceeded(run: Runner): Promise<boolean> {
|
|
154
|
+
const shown = await run([
|
|
155
|
+
'systemctl',
|
|
156
|
+
'--user',
|
|
157
|
+
'show',
|
|
158
|
+
'personal-worker-auth.service',
|
|
159
|
+
'--property=Result,ExecMainStatus',
|
|
160
|
+
]);
|
|
161
|
+
if (!shown.ok) return true;
|
|
162
|
+
const result = /^Result=(.*)$/m.exec(shown.stdout)?.[1];
|
|
163
|
+
const status = /^ExecMainStatus=(.*)$/m.exec(shown.stdout)?.[1];
|
|
164
|
+
return (
|
|
165
|
+
(result === undefined || result === 'success') &&
|
|
166
|
+
(status === undefined || status === '0')
|
|
167
|
+
);
|
|
168
|
+
}
|