@crvouga/dev-fleet 0.7.0 → 0.8.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.
@@ -0,0 +1,138 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parse, stringify } from 'yaml';
5
+ import type { MachineDefinition } from './definition';
6
+ import { FleetUsageError } from './errors';
7
+ import { PACKAGE_ROOT } from './package-info';
8
+ import type { Runner } from './types';
9
+
10
+ /** What a new worker's first boot needs, and nothing that outlives it: the
11
+ * worker account with the manager's public key, the privilege rule
12
+ * fleet-sync expects, a host key generated here so the manager pins it before
13
+ * the machine ever answers (no trust on first use), and a short-lived,
14
+ * single-use Tailscale auth key that is deleted once the machine has joined.
15
+ */
16
+ export interface SeedInput {
17
+ worker: MachineDefinition;
18
+ /** The manager's SSH public key: one line of a `.pub` file. */
19
+ managerKey: string;
20
+ /** From the secrets provider, by purpose (`tailnet-auth-key`). */
21
+ authKey: string;
22
+ /** Generated with ssh-keygen for this seed. */
23
+ hostKey: { private: string; public: string };
24
+ /** `tailscale` in ansible/ubuntu/policy.json: version and key digest. */
25
+ tailscale: { version: string; keySha256: string };
26
+ /** The codename of the fleet's Ubuntu release, from the Ubuntu policy. */
27
+ codename: string;
28
+ }
29
+
30
+ const PUBLIC_KEY =
31
+ /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}( [^\n]*)?$/;
32
+
33
+ /** The user data of one worker: bootstrap/worker-seed.yaml with every
34
+ * `${NAME}` filled. Values replace whole placeholders inside parsed strings,
35
+ * so none of them can change the document's structure.
36
+ */
37
+ export async function seedUserData(
38
+ input: SeedInput,
39
+ root = PACKAGE_ROOT
40
+ ): Promise<Record<string, unknown>> {
41
+ const { worker } = input;
42
+ const user = worker.network?.user;
43
+ if (!user)
44
+ throw new FleetUsageError(
45
+ `${worker.id} declares no network block; the seed needs its user and Tailscale name.`
46
+ );
47
+ if (!PUBLIC_KEY.test(input.managerKey.trim()))
48
+ throw new FleetUsageError(
49
+ 'The manager public key is not one OpenSSH public key line.'
50
+ );
51
+ if (!/^tskey-auth-[A-Za-z0-9-]{8,200}$/.test(input.authKey))
52
+ throw new FleetUsageError(
53
+ 'The tailnet-auth-key secret is not a Tailscale auth key (tskey-auth-…). Use a short-lived, single-use, tagged key.'
54
+ );
55
+ const values: Record<string, string> = {
56
+ HOSTNAME: worker.network!.tailscaleName.split('.')[0]!,
57
+ USER: user,
58
+ MANAGER_KEY: input.managerKey.trim(),
59
+ HOST_KEY_PRIVATE: input.hostKey.private,
60
+ HOST_KEY_PUBLIC: input.hostKey.public,
61
+ AUTH_KEY: input.authKey,
62
+ TAILSCALE_VERSION: input.tailscale.version,
63
+ TAILSCALE_KEY_SHA256: input.tailscale.keySha256,
64
+ CODENAME: input.codename,
65
+ };
66
+ const template = parse(
67
+ await readFile(join(root, 'bootstrap/worker-seed.yaml'), 'utf8')
68
+ ) as unknown;
69
+ return fill(template, values) as Record<string, unknown>;
70
+ }
71
+
72
+ function fill(value: unknown, values: Record<string, string>): unknown {
73
+ if (typeof value === 'string')
74
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name: string) => {
75
+ const found = values[name];
76
+ if (found === undefined)
77
+ throw new Error(`The seed template names an unknown value ${name}.`);
78
+ return found;
79
+ });
80
+ if (Array.isArray(value)) return value.map((item) => fill(item, values));
81
+ if (value && typeof value === 'object')
82
+ return Object.fromEntries(
83
+ Object.entries(value).map(([key, item]) => [key, fill(item, values)])
84
+ );
85
+ return value;
86
+ }
87
+
88
+ /** Cloud-init user data, or an Ubuntu autoinstall file that carries it. */
89
+ export function renderSeed(
90
+ userData: Record<string, unknown>,
91
+ autoinstall: boolean
92
+ ): string {
93
+ const document = autoinstall
94
+ ? {
95
+ autoinstall: {
96
+ version: 1,
97
+ ssh: { 'install-server': true, 'allow-pw': false },
98
+ 'user-data': userData,
99
+ },
100
+ }
101
+ : userData;
102
+ return `#cloud-config\n${stringify(document, { lineWidth: 0 })}`;
103
+ }
104
+
105
+ /** The version and key digest the worker baseline pins for Tailscale. */
106
+ export async function tailscalePins(
107
+ root = PACKAGE_ROOT
108
+ ): Promise<SeedInput['tailscale']> {
109
+ const policy = JSON.parse(
110
+ await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
111
+ ) as { tailscale: SeedInput['tailscale'] };
112
+ return policy.tailscale;
113
+ }
114
+
115
+ /** A new ed25519 host key, generated in a private directory that is removed
116
+ * afterwards.
117
+ */
118
+ export async function generateHostKey(
119
+ id: string,
120
+ run: Runner
121
+ ): Promise<SeedInput['hostKey']> {
122
+ const directory = await mkdtemp(join(tmpdir(), 'devfleet-seed-'));
123
+ try {
124
+ const path = join(directory, 'host');
125
+ const made = await run(
126
+ ['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', id, '-f', path],
127
+ { timeoutMs: 20_000 }
128
+ );
129
+ if (!made.ok)
130
+ throw new FleetUsageError('ssh-keygen could not make a host key.');
131
+ return {
132
+ private: await readFile(path, 'utf8'),
133
+ public: (await readFile(`${path}.pub`, 'utf8')).trim(),
134
+ };
135
+ } finally {
136
+ await rm(directory, { recursive: true, force: true });
137
+ }
138
+ }