@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.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 (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -2
  4. package/bin/snowglobe.mjs +71 -0
  5. package/lib/apps.mjs +97 -0
  6. package/lib/args.mjs +29 -0
  7. package/lib/certs.mjs +211 -0
  8. package/lib/commands/account.mjs +85 -0
  9. package/lib/commands/app.mjs +86 -0
  10. package/lib/commands/certs.mjs +36 -0
  11. package/lib/commands/check.mjs +37 -0
  12. package/lib/commands/down.mjs +40 -0
  13. package/lib/commands/env.mjs +146 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/ls.mjs +72 -0
  17. package/lib/commands/render.mjs +63 -0
  18. package/lib/commands/run.mjs +109 -0
  19. package/lib/commands/trust.mjs +45 -0
  20. package/lib/commands/up.mjs +301 -0
  21. package/lib/config.mjs +514 -0
  22. package/lib/env.mjs +526 -0
  23. package/lib/hosts.mjs +36 -0
  24. package/lib/prebuilt.mjs +56 -0
  25. package/lib/render.mjs +601 -0
  26. package/lib/settings.mjs +156 -0
  27. package/lib/state.mjs +109 -0
  28. package/lib/trust.mjs +288 -0
  29. package/lib/up.mjs +54 -0
  30. package/lib/xrpc.mjs +49 -0
  31. package/package.json +55 -4
  32. package/snowglobe.default.yml +54 -0
  33. package/stack/docker-compose.relay.yml +105 -0
  34. package/stack/docker-compose.test.yml +41 -0
  35. package/stack/docker-compose.yml +179 -0
  36. package/stack/https/app-names.cjs +31 -0
  37. package/stack/https/dns/Dockerfile +10 -0
  38. package/stack/https/dns/dns.sh +40 -0
  39. package/stack/https/nginx.conf.template +104 -0
  40. package/stack/https/proxy/Dockerfile +8 -0
  41. package/stack/https/proxy/server.mjs +213 -0
  42. package/stack/init/init.sh +118 -0
  43. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  44. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  45. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  46. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  47. package/stack/page/index.html +409 -0
  48. package/stack/page/snowglobe.conf +59 -0
  49. package/stack/pdsls/Dockerfile +27 -0
  50. package/stack/pdsls/network.patch +294 -0
  51. package/stack/pdsls/server.mjs +192 -0
  52. package/stack/relay/Dockerfile +30 -0
  53. package/stack/relay/allow-private-hosts.patch +55 -0
  54. package/stack/slingshot/Dockerfile +36 -0
package/lib/env.mjs ADDED
@@ -0,0 +1,526 @@
1
+ // What every snowglobe subcommand shares: where the files are, the domain and the address the
2
+ // globe is under (read as compose reads them), env files that hold logins, docker compose on the
3
+ // rendered globe, and https to a globe's names. Nothing here prints a secret.
4
+ //
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { request } from 'node:https';
10
+ import net from 'node:net';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { DEFAULT_DOMAIN } from './config.mjs';
15
+
16
+ // A mistake in how a command was called, as opposed to one the globe answered with.
17
+ export class UsageError extends Error {}
18
+
19
+ // A refusal with its own exit code (3: the shared devnet, guarded).
20
+ export class Refused extends Error {
21
+ constructor(message, exitCode) {
22
+ super(message);
23
+ this.exitCode = exitCode;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The compose project up and down act on: SG_PROJECT if set, else `name`, the globe's name in
29
+ * its snowglobe.yml. Refused before anything else is done: none (a usage mistake), or
30
+ * devnet-spaces, the shared devnet, without SG_RESET_OK=1 (exit 3).
31
+ */
32
+ export function guardProject(env = process.env, name) {
33
+ const project = env.SG_PROJECT || name;
34
+ if (!project) throw new UsageError('no compose project: name one with SG_PROJECT, or run in a folder with a snowglobe.yml');
35
+ if (project === 'devnet-spaces' && env.SG_RESET_OK !== '1')
36
+ throw new Refused('refusing devnet-spaces, the shared devnet; set SG_RESET_OK=1 only for a planned reset', 3);
37
+ return project;
38
+ }
39
+
40
+ // The repo root, which is also the compose project directory, where compose reads .env.
41
+ export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
42
+
43
+ /**
44
+ * The folder all snowglobe state is under, for this user and not this checkout:
45
+ * $XDG_DATA_HOME/snowglobe, else %LOCALAPPDATA%\\snowglobe on Windows, else
46
+ * ~/.local/share/snowglobe. Each snowglobe has a folder of its own in it, named for the file's
47
+ * `globe:`, and ca/ holds the one certificate authority they all use.
48
+ */
49
+ export function stateRoot(env = process.env, { platform = process.platform, home = homedir() } = {}) {
50
+ // XDG's rule: a relative XDG_DATA_HOME is ignored.
51
+ if (env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME)) return join(env.XDG_DATA_HOME, 'snowglobe');
52
+ if (platform === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'snowglobe');
53
+ return join(home, '.local', 'share', 'snowglobe');
54
+ }
55
+
56
+ // The settings file compose reads beside the compose files (the repo root's .env).
57
+ export const dotenvFile = (root = ROOT) => join(root, '.env');
58
+
59
+ /**
60
+ * The files a snowglobe keeps in `state`, its folder under stateRoot(), and the user's CA beside
61
+ * it (<stateRoot>/ca). Nothing is kept in the checkout.
62
+ */
63
+ export function paths(root = ROOT, state) {
64
+ if (!state) throw new Error('no state folder given (a snowglobe keeps its files in one: lib/state.mjs session)');
65
+ const data = state;
66
+ const https = join(data, 'https');
67
+ const globe = join(data, 'globe');
68
+ const caDir = join(dirname(state), 'ca');
69
+ return {
70
+ root,
71
+ data,
72
+ https,
73
+ // The CA, one per user, which run's processes and snowglobeRequest trust.
74
+ caDir,
75
+ ca: join(caDir, 'ca.crt'),
76
+ // The snowglobe's own logins: the base init's alice, bob and invite code, the lexicon
77
+ // authority, and accounts made by snowglobe account.
78
+ accounts: join(data, 'accounts.env'),
79
+ // The secrets the services start with (SECRETS below), made by up, once, and read by account
80
+ // and invite. A file of its own: the base init rewrites accounts.env.
81
+ secrets: join(data, 'secrets.env'),
82
+ // The loopback address this snowglobe publishes its ports on (chooseBind), one line.
83
+ bind: join(data, 'bind'),
84
+ // Where the admin password was kept before secrets.env (read, never written, for a snowglobe made then).
85
+ admin: join(data, 'admin.env'),
86
+ // App routes, "<name> <port> [<ending>]" per line (snowglobe app).
87
+ apps: join(https, 'apps'),
88
+ // Handles run maps to nginx, one per line (snowglobe account and up).
89
+ names: join(https, 'names'),
90
+ // The rendered globe (bin/snowglobe.mjs render). Nothing in it is secret.
91
+ globe,
92
+ globeEnv: join(globe, 'globe.env'),
93
+ leafNames: join(globe, 'leaf-names'),
94
+ compose: join(globe, 'compose.yml'),
95
+ dotenv: dotenvFile(root),
96
+ };
97
+ }
98
+
99
+ // Files an older snowglobe kept in <root>/data, before state moved to the user's folder.
100
+ const OLD_FILES = ['accounts.env', 'accounts.json', 'devnet.env', 'globe', 'https/apps', 'https/names'];
101
+
102
+ /**
103
+ * The text that says a checkout still holds a snowglobe's files from before state moved to the
104
+ * user's folder (<root>/data/globe or data/devnet.env) while `state` has none, with the command
105
+ * that moves them; null when it does not. Nothing is moved by snowglobe itself.
106
+ */
107
+ export function oldDataNotice(root, state, platform = process.platform) {
108
+ const data = join(root, 'data');
109
+ if (!(existsSync(join(data, 'globe', 'compose.yml')) || existsSync(join(data, 'devnet.env')))) return null;
110
+ if (existsSync(join(state, 'globe', 'compose.yml'))) return null;
111
+ const project = envGet('SG_PROJECT', join(data, 'devnet.env'));
112
+ const found = OLD_FILES.filter((f) => existsSync(join(data, f)));
113
+ const win = platform === 'win32';
114
+ const q = (x) => `"${x}"`;
115
+ const move = win
116
+ ? [`New-Item -ItemType Directory -Force ${q(join(state, 'https'))}`, ...found.map((f) => `Move-Item ${q(join(data, f))} ${q(join(state, f))}`)]
117
+ : [`mkdir -p ${q(join(state, 'https'))}`, ...found.map((f) => `mv ${q(join(data, f))} ${q(join(state, f))}`)];
118
+ return [
119
+ `${data} holds a snowglobe from before state moved out of the checkout (into ${state}). Nothing was changed.`,
120
+ `To keep it, move its files${project ? ` and keep its compose project (its containers and volumes belong to ${project})` : ''}:`,
121
+ ...move.map((l) => ` ${l}`),
122
+ project ? ` ${win ? '$env:SG_PROJECT' : 'SG_PROJECT'}=${project} snowglobe up` : ' snowglobe up',
123
+ "Either way it trusts a new CA, once: snowglobe now makes one for you in place of the checkout's old one.",
124
+ ].join('\n');
125
+ }
126
+
127
+ /**
128
+ * The text that says the default snowglobe was named devnet before it was named snowglobe, and an
129
+ * install made under the old name is still in the state root while `state` has none, with the
130
+ * commands to keep it or drop it; null when it does not. Nothing is moved by snowglobe itself.
131
+ * A snowglobe made from a file of your own is not affected: it keeps the file's `globe:` name.
132
+ */
133
+ export function renamedDefaultNotice(root, state, platform = process.platform) {
134
+ const old = join(dirname(state), 'devnet');
135
+ if (!existsSync(join(old, 'globe', 'compose.yml')) || existsSync(join(state, 'globe', 'compose.yml'))) return null;
136
+ const win = platform === 'win32';
137
+ const q = (x) => `"${x}"`;
138
+ const keep = win
139
+ ? [`Move-Item ${q(old)} ${q(state)}`, "$env:SG_PROJECT='devnet'", 'snowglobe up --default']
140
+ : [`mv ${q(old)} ${q(state)}`, 'SG_PROJECT=devnet snowglobe up --default'];
141
+ return [
142
+ `${old} holds the default snowglobe from before it was renamed (it was called devnet, and is called snowglobe now). Nothing was changed.`,
143
+ 'To keep it, with its accounts, move its folder and keep its compose project (its containers and volumes belong to devnet; their network and images are recreated under the new names):',
144
+ ...keep.map((l) => ` ${l}`),
145
+ 'To drop it, remove its containers and volumes, then delete the folder:',
146
+ ` docker compose -p devnet --project-directory ${q(root)} -f ${q(join(old, 'globe', 'compose.yml'))} down -v`,
147
+ ` ${win ? 'Remove-Item -Recurse' : 'rm -r'} ${q(old)}`,
148
+ ].join('\n');
149
+ }
150
+
151
+ const lines = (file) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n') : []);
152
+
153
+ // Until 2026-10-20 the settings were DEVNET_* and SNOWGLOBE_*; every one is SG_* now. The old
154
+ // names still work for a while: adoptOldNames copies each one to its SG_ name, and envGet falls
155
+ // back to them in a file written before the change.
156
+ const OLD_NAME = /^(?:DEVNET|SNOWGLOBE)_([A-Za-z0-9_]+)$/;
157
+ const oldNames = (name) => (name.startsWith('SG_') ? [`DEVNET_${name.slice(3)}`, `SNOWGLOBE_${name.slice(3)}`] : []);
158
+
159
+ // The last value of NAME in an env file the scripts write (NAME=value, nothing else), or ''.
160
+ // An SG_ name the file lacks is looked up under its old names.
161
+ export function envGet(name, file) {
162
+ const ls = lines(file);
163
+ for (const n of [name, ...oldNames(name)]) {
164
+ let value;
165
+ for (const l of ls) if (l.startsWith(`${n}=`)) value = l.slice(n.length + 1);
166
+ if (value !== undefined) return value;
167
+ }
168
+ return '';
169
+ }
170
+
171
+ /**
172
+ * Each old setting (DEVNET_*, SNOWGLOBE_*) in the environment or .env whose SG_ name neither sets
173
+ * is copied into env under the SG_ name, so the commands and compose see it; one warning names
174
+ * them. Returns the old names taken.
175
+ */
176
+ export function adoptOldNames({ root = ROOT, env = process.env, warn = (m) => console.error(m) } = {}) {
177
+ const dotenv = dotenvFile(root);
178
+ const taken = new Map();
179
+ for (const [k, v] of Object.entries(env)) {
180
+ const m = k.match(OLD_NAME);
181
+ if (m && env[`SG_${m[1]}`] === undefined) taken.set(`SG_${m[1]}`, { old: k, value: v });
182
+ }
183
+ for (const l of lines(dotenv)) {
184
+ const k = l.match(/^\s*(?:export\s+)?([A-Za-z0-9_]+)\s*=/)?.[1];
185
+ const m = k?.match(OLD_NAME);
186
+ const name = m && `SG_${m[1]}`;
187
+ if (m && env[name] === undefined && !taken.has(name) && envCompose(name, dotenv) === undefined)
188
+ taken.set(name, { old: `${k} (in .env)`, value: envCompose(k, dotenv) });
189
+ }
190
+ for (const [name, { value }] of taken) env[name] = value;
191
+ if (taken.size)
192
+ warn(
193
+ `snowglobe: old setting names, still read for now: ${[...taken].map(([name, { old }]) => `${old} is now ${name}`).join('; ')}`,
194
+ );
195
+ return [...taken.values()].map((t) => t.old);
196
+ }
197
+
198
+ // NAME's last value in an env file read as compose reads it: an export prefix, spaces around
199
+ // the =, quotes, a trailing " # comment" and a CRLF line end are allowed. undefined when the
200
+ // file does not set it.
201
+ export function envCompose(name, file) {
202
+ const re = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`);
203
+ let raw;
204
+ for (const l of lines(file)) {
205
+ const m = l.replace(/\r$/, '').match(re);
206
+ if (m) raw = m[1];
207
+ }
208
+ if (raw === undefined) return undefined;
209
+ const quoted = raw.match(/^"([^"]*)"/) ?? raw.match(/^'([^']*)'/);
210
+ if (quoted) return quoted[1];
211
+ return raw.replace(/\s+#.*/, '').replace(/\s+$/, '');
212
+ }
213
+
214
+ // A setting from the environment, where set (even empty, which then wins), else from .env, as
215
+ // compose reads it; '' when neither sets it.
216
+ export function setting(name, { root = ROOT, env = process.env } = {}) {
217
+ return env[name] !== undefined ? env[name] : (envCompose(name, dotenvFile(root)) ?? '');
218
+ }
219
+
220
+ // The domain every globe name sits under: SG_DOMAIN from the environment or .env; else the
221
+ // rendered globe's, which is the file's unless one of those replaced it; empty means DEFAULT_DOMAIN.
222
+ // A setting that differs from the rendered globe's means the render is stale: refused, unless
223
+ // this run is about to render again (rendering). `set` is the setting alone, for render --domain.
224
+ export function resolveDomain({ root = ROOT, state, env = process.env, rendering = false } = {}) {
225
+ const p = paths(root, state);
226
+ const set = setting('SG_DOMAIN', { root, env });
227
+ const rendered = envGet('SG_DOMAIN', p.globeEnv);
228
+ if (set && rendered && set !== rendered && !rendering)
229
+ throw new Error(`SG_DOMAIN=${set}, but the globe in ${p.globe} was rendered for ${rendered}; run snowglobe up`);
230
+ const domain = set || rendered || DEFAULT_DOMAIN;
231
+ // A domain name and nothing else. This also refuses ${...} in .env, which compose would
232
+ // expand and these scripts do not.
233
+ if (/[^A-Za-z0-9.-]/.test(domain) || domain.startsWith('.') || domain.endsWith('.') || domain.includes('..'))
234
+ throw new Error(`SG_DOMAIN=${domain} is not a domain name (set in the environment or .env)`);
235
+ return { domain, set };
236
+ }
237
+
238
+ // The rendered globe's facts (globe.env): its domain, its PDSes by name, the one with bare
239
+ // handles (<name>.<domain>, '' if none), those that take an invite code, the seed and authority
240
+ // PDSes, and the names under the domain a bare handle would take.
241
+ export function globeFacts({ root = ROOT, state, env = process.env } = {}) {
242
+ const p = paths(root, state);
243
+ if (!existsSync(p.globeEnv)) throw new Error(`No ${p.globeEnv}: run snowglobe up, which renders it`);
244
+ const list = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
245
+ return {
246
+ domain: resolveDomain({ root, state, env }).domain,
247
+ pds: list('SG_PDS'),
248
+ bare: envGet('SG_BARE_PDS', p.globeEnv),
249
+ invites: list('SG_INVITE_PDS'),
250
+ seed: envGet('SG_SEED_PDS', p.globeEnv),
251
+ authority: envGet('SG_AUTHORITY_PDS', p.globeEnv),
252
+ taken: list('SG_TAKEN'),
253
+ };
254
+ }
255
+
256
+ // The loopback address nginx and every other published port use, worked out as compose works out
257
+ // ${SG_BIND:-127.0.0.1}: SG_BIND where set, else the one `up` chose for this snowglobe (the bind
258
+ // file in its state folder), else 127.0.0.1. A loopback address only (127.x.y.z, one per globe run
259
+ // side by side): the globe's logins are throwaway, so it is never published beyond this machine.
260
+ export function resolveBind({ root = ROOT, state, env = process.env } = {}) {
261
+ const saved = state && existsSync(paths(root, state).bind) ? readFileSync(paths(root, state).bind, 'utf8').trim() : '';
262
+ const bind = setting('SG_BIND', { root, env }) || saved || '127.0.0.1';
263
+ const parts = bind.split('.');
264
+ if (parts.length !== 4 || parts[0] !== '127' || parts.slice(1).some((x) => !/^[0-9]+$/.test(x) || Number(x) > 255))
265
+ throw new Error(`SG_BIND=${bind} is not a loopback address 127.x.y.z (set in the environment or .env)`);
266
+ return bind;
267
+ }
268
+
269
+ // The ports a snowglobe publishes on its address: nginx, and the tools (each can be moved with its SG_..._PORT).
270
+ // Tap's is probed whether or not this globe runs Tap: an address another globe's Tap answers on is not free.
271
+ const PUBLISHED = [['', 443], ['SG_PROXY_PORT', 1080], ['SG_MAILDEV_WEB_PORT', 1081], ['SG_MAILDEV_SMTP_PORT', 1026], ['SG_PLC_PORT', 2582], ['SG_JETSTREAM_PORT', 6008], ['SG_JETSTREAM_METRICS_PORT', 6009], ['SG_TAP_PORT', 2480]];
272
+
273
+ // Whether something already answers on any port a snowglobe would publish on a loopback address.
274
+ export async function addressBusy(address, { root = ROOT, env = process.env } = {}) {
275
+ const answers = (port) =>
276
+ new Promise((done) => {
277
+ const s = net.connect({ host: address, port, timeout: 500 });
278
+ s.on('connect', () => (s.destroy(), done(true)));
279
+ s.on('timeout', () => (s.destroy(), done(false)));
280
+ s.on('error', () => done(false));
281
+ });
282
+ const ports = PUBLISHED.map(([name, port]) => Number((name && setting(name, { root, env })) || port));
283
+ return (await Promise.all(ports.map(answers))).some(Boolean);
284
+ }
285
+
286
+ // Whether the compose project's own nginx is running now.
287
+ export function ownNginxUp(project, env = process.env) {
288
+ const res = spawnSync('docker', ['ps', '-q', '--filter', `label=com.docker.compose.project=${project}`, '--filter', 'label=com.docker.compose.service=nginx'], { encoding: 'utf8', env });
289
+ return !res.error && res.status === 0 && res.stdout.trim() !== '';
290
+ }
291
+
292
+ /**
293
+ * The loopback address this snowglobe publishes on, chosen once and kept in its state folder, so a
294
+ * second snowglobe on the machine starts beside the first with no settings: it takes the first
295
+ * 127.0.0.N that no other snowglobe in the same user folder has kept and nothing answers on 443 or any other port it publishes.
296
+ * SG_BIND, where set, wins and nothing is written. A snowglobe that is already running with no
297
+ * address kept was made before this and is on 127.0.0.1, so it keeps that. Returns the address.
298
+ */
299
+ export async function chooseBind({ root = ROOT, state, project, env = process.env, listening = (a) => addressBusy(a, { root, env }), ownUp = ownNginxUp } = {}) {
300
+ const p = paths(root, state);
301
+ if (setting('SG_BIND', { root, env }) || existsSync(p.bind)) return resolveBind({ root, state, env });
302
+ // Choosing reads the other bind files, waits on probes, then writes ours, so two first starts at
303
+ // once would both pick the same address and keep it. One at a time, under a lock beside the state folders.
304
+ return withLock(join(dirname(state), '.bind-lock'), async () => {
305
+ if (existsSync(p.bind)) return resolveBind({ root, state, env });
306
+ return pickBind({ p, state, project, env, listening, ownUp });
307
+ });
308
+ }
309
+
310
+ // Run `fn` holding a lock directory (mkdir is atomic). A lock older than `stale` ms was left by a
311
+ // process that died. It is not taken over, since two starts taking it over at once could both
312
+ // hold it; up stops and names the folder to remove.
313
+ async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
314
+ mkdirSync(dirname(dir), { recursive: true });
315
+ const until = Date.now() + wait;
316
+ for (;;) {
317
+ try {
318
+ mkdirSync(dir);
319
+ break;
320
+ } catch (e) {
321
+ if (e.code !== 'EEXIST') throw e;
322
+ let age;
323
+ try {
324
+ age = Date.now() - statSync(dir).mtimeMs;
325
+ } catch {
326
+ continue; // released between the two calls
327
+ }
328
+ if (age > stale) throw new Error(`a snowglobe that stopped while choosing an address left ${dir}; remove that folder, then up again`);
329
+ if (Date.now() > until) throw new Error(`another snowglobe is choosing an address (${dir}); remove that folder if none is`);
330
+ await new Promise((r) => setTimeout(r, 25));
331
+ }
332
+ }
333
+ try {
334
+ return await fn();
335
+ } finally {
336
+ rmSync(dir, { recursive: true, force: true });
337
+ }
338
+ }
339
+
340
+ async function pickBind({ p, state, project, env, listening, ownUp }) {
341
+ const used = new Set();
342
+ const top = dirname(state);
343
+ for (const d of existsSync(top) ? readdirSync(top) : []) {
344
+ const f = join(top, d, 'bind');
345
+ if (join(top, d) !== state && existsSync(f)) used.add(readFileSync(f, 'utf8').trim());
346
+ }
347
+ let bind = '127.0.0.1';
348
+ if (!ownUp(project, env)) {
349
+ bind = '';
350
+ for (let n = 1; n <= 50 && !bind; n++) {
351
+ const a = `127.0.0.${n}`;
352
+ if (!used.has(a) && !(await listening(a))) bind = a;
353
+ }
354
+ if (!bind) throw new Error('every loopback address from 127.0.0.1 to 127.0.0.50 is in use; set SG_BIND to one that is free');
355
+ }
356
+ writeFileSync(p.bind, `${bind}\n`);
357
+ return bind;
358
+ }
359
+
360
+ // Replace the lines of an env file matching `drop` with `add` (lines, no newlines). The file is
361
+ // mode 600, and never readable by anyone else, even briefly: it is written aside at 600, then
362
+ // renamed over the old one.
363
+ export function envPut(file, drop, add = []) {
364
+ const kept = lines(file).filter((l) => l !== '' && !drop.test(l));
365
+ const text = [...kept, ...add].map((l) => `${l}\n`).join('');
366
+ const tmp = `${file}.tmp.${process.pid}`;
367
+ try {
368
+ writeFileSync(tmp, text, { mode: 0o600, flag: 'wx' });
369
+ chmodSync(tmp, 0o600);
370
+ renameSync(tmp, file);
371
+ } catch (e) {
372
+ if (existsSync(tmp)) unlinkSync(tmp);
373
+ throw e;
374
+ }
375
+ }
376
+
377
+ // Mode 600, even if a container wrote the file: it then belongs to root, and only a rewrite
378
+ // makes it ours.
379
+ export function makePrivate(file) {
380
+ if (!existsSync(file)) return;
381
+ if (statSync(file).uid === process.getuid?.()) chmodSync(file, 0o600);
382
+ else envPut(file, /^$/);
383
+ }
384
+
385
+ // The env file for logins: made if missing, and private either way.
386
+ export function loginsFile(file) {
387
+ if (!existsSync(file)) envPut(file, /^$/);
388
+ makePrivate(file);
389
+ return file;
390
+ }
391
+
392
+ // Add a handle to the names run maps to nginx; a process already running under it picks a
393
+ // new one up when it restarts.
394
+ export function addName(host, { root = ROOT, state } = {}) {
395
+ const p = paths(root, state);
396
+ mkdirSync(p.https, { recursive: true });
397
+ if (!lines(p.names).includes(host)) appendFileSync(p.names, `${host}\n`);
398
+ }
399
+
400
+ // The secrets a snowglobe's services start with: the PDSes' admin password, JWT secret and PLC
401
+ // rotation key, the relay's admin password and the PLC database's. `up` makes each one random,
402
+ // once, per snowglobe (ensureSecrets); the compose files take them as ${SG_...:?}, so none has a
403
+ // default. `bytes` is how many random bytes (the rotation key is a 32-byte secp256k1 key).
404
+ // `legacy` is the fixed value every snowglobe used before they were random: a snowglobe whose data
405
+ // was made under it keeps it, because Postgres keeps the password it was first started with and
406
+ // a DID's PLC record names the rotation key that made it. The admin password was never fixed.
407
+ export const SECRETS = {
408
+ SG_PDS_ADMIN_PASSWORD: { bytes: 24 },
409
+ SG_PDS_JWT_SECRET: { bytes: 24, legacy: 'devnet-jwt-secret-not-for-production' },
410
+ SG_PLC_ROTATION_KEY: { bytes: 32, legacy: '0123456789abcdef'.repeat(4) },
411
+ SG_RELAY_ADMIN_PASSWORD: { bytes: 24, legacy: 'devnet-relay-admin' },
412
+ SG_DB_PASSWORD: { bytes: 24, legacy: 'postgres' },
413
+ };
414
+
415
+ // A secret's value: the environment's or .env's (a globe on the cluster has its own), else the
416
+ // one up made for this snowglobe in its secrets.env (for the admin password, else its older admin.env); '' when none.
417
+ export function secret(name, { root = ROOT, state, env = process.env } = {}) {
418
+ const set = setting(name, { root, env });
419
+ if (set || !state) return set || '';
420
+ const p = paths(root, state);
421
+ // The admin password up made before secrets.env existed is still the snowglobe's: its PDSes
422
+ // were started with it, so it is kept, not replaced by a new one.
423
+ return envGet(name, p.secrets) || (name === 'SG_PDS_ADMIN_PASSWORD' ? envGet(name, p.admin) : '');
424
+ }
425
+
426
+ export const adminPassword = (opts) => secret('SG_PDS_ADMIN_PASSWORD', opts);
427
+
428
+ /**
429
+ * Every secret in SECRETS, made first where there is none: random, and kept in secrets.env (mode
430
+ * 600) so the same one is used on every later up. Where the snowglobe already has data (`hasData()`
431
+ * is true, asked only when a secret that used to be fixed is missing; null, Docker could not say, is refused) a secret that used to be fixed keeps its fixed
432
+ * value instead. Returns { values, kept }: each secret's value, and the names that kept a fixed
433
+ * one. A value set in the environment or .env is used and not written. Never prints a value.
434
+ */
435
+ export function ensureSecrets({ root = ROOT, state, env = process.env, hasData = () => false } = {}) {
436
+ const values = {};
437
+ const kept = [];
438
+ const made = [];
439
+ let data;
440
+ for (const [name, { bytes, legacy }] of Object.entries(SECRETS)) {
441
+ let value = secret(name, { root, state, env });
442
+ if (!value) {
443
+ // Only a secret that used to be fixed depends on whether there is data; the answer is asked once.
444
+ if (legacy !== undefined && data === undefined) {
445
+ data = hasData();
446
+ if (data === null) throw new Error('Docker did not say whether this snowglobe already has data (docker volume ls failed), so up will not choose its secrets yet: start Docker, then run up again');
447
+ }
448
+ if (legacy !== undefined && data) {
449
+ value = legacy;
450
+ kept.push(name);
451
+ } else value = randomBytes(bytes).toString('hex');
452
+ made.push(name);
453
+ }
454
+ values[name] = value;
455
+ }
456
+ if (made.length) {
457
+ const p = paths(root, state);
458
+ mkdirSync(p.data, { recursive: true });
459
+ envPut(loginsFile(p.secrets), new RegExp(`^(${made.join('|')})=`), made.map((n) => `${n}=${values[n]}`));
460
+ }
461
+ return { values, kept };
462
+ }
463
+
464
+ /** Whether Docker holds volumes for the compose project: null when Docker cannot say, which is not an answer to act on. */
465
+ export function projectHasData(project, env = process.env) {
466
+ const res = spawnSync('docker', ['volume', 'ls', '-q', '--filter', `label=com.docker.compose.project=${project}`], { encoding: 'utf8', env });
467
+ return res.error || res.status !== 0 ? null : res.stdout.trim() !== '';
468
+ }
469
+
470
+ /**
471
+ * After `down -v`, the fixed secrets (SECRETS' legacy values) are dropped from secrets.env: the
472
+ * data they were kept for is gone, so the next up makes random ones. Random ones stay, and a
473
+ * secret set in the environment or .env is not in the file.
474
+ */
475
+ export function dropLegacySecrets({ root = ROOT, state } = {}) {
476
+ const file = paths(root, state).secrets;
477
+ if (!existsSync(file)) return;
478
+ const old = Object.entries(SECRETS).filter(([n, { legacy }]) => legacy !== undefined && envGet(n, file) === legacy).map(([n]) => n);
479
+ if (old.length) envPut(file, new RegExp(`^(${old.join('|')})=`));
480
+ }
481
+
482
+ // docker compose on `project` with the rendered globe. Its paths are from the repo root, which is
483
+ // also where compose reads .env. Returns spawnSync's result; stdio as given (inherited by default),
484
+ // env as given (this process's by default), for compose to fill the file's ${...} from.
485
+ export function dc(project, args, { root = ROOT, state, stdio = 'inherit', encoding, env } = {}) {
486
+ const p = paths(root, state);
487
+ if (!existsSync(p.compose)) {
488
+ const old = oldDataNotice(root, state);
489
+ throw new Error(`No ${p.compose}: run snowglobe up, which renders it${old ? `\n${old}` : ''}`);
490
+ }
491
+ // compose fills the file's ${SG_...:?} secrets from these, and refuses to read the file without
492
+ // them. up makes the real ones first; down and ps on a snowglobe that up has not made them for
493
+ // need only a value for compose to read the file.
494
+ const base = env ?? process.env;
495
+ const secrets = Object.fromEntries(Object.keys(SECRETS).map((n) => [n, secret(n, { root, state, env: base }) || 'unset-run-snowglobe-up']));
496
+ return spawnSync('docker', ['compose', '-p', project, '--project-directory', root, '-f', p.compose, ...args], {
497
+ stdio,
498
+ encoding,
499
+ env: { ...base, ...secrets, SG_BIND: resolveBind({ root, state, env: base }) },
500
+ });
501
+ }
502
+
503
+ // An https request to a globe's name: the name goes to nginx on SG_BIND, and TLS is verified
504
+ // against the globe's CA. With SG_PUBLIC=1 the names are public ones with a real certificate
505
+ // (a globe on the cluster), so the URL is used as it stands, through public DNS and the system's
506
+ // CAs. Resolves to { status, body } (body a string); never logs the request or the answer.
507
+ export function snowglobeRequest(url, { method = 'GET', headers = {}, body, root = ROOT, state, env = process.env } = {}) {
508
+ const pub = env.SG_PUBLIC === '1';
509
+ const options = { method, headers };
510
+ if (!pub) {
511
+ const bind = resolveBind({ root, state, env });
512
+ options.ca = readFileSync(paths(root, state).ca);
513
+ options.lookup = (_host, opts, cb) =>
514
+ opts?.all ? cb(null, [{ address: bind, family: 4 }]) : cb(null, bind, 4);
515
+ }
516
+ return new Promise((done, fail) => {
517
+ const req = request(url, options, (res) => {
518
+ const chunks = [];
519
+ res.on('data', (c) => chunks.push(c));
520
+ res.on('end', () => done({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
521
+ res.on('error', fail);
522
+ });
523
+ req.on('error', fail);
524
+ req.end(body);
525
+ });
526
+ }
package/lib/hosts.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // The names snowglobe run maps to nginx, and the hosts file it binds over /etc/hosts. Pure:
2
+ // lib/commands/run.mjs reads the files and runs the command.
3
+
4
+ const NAME_RE = /^[A-Za-z0-9.-]+$/;
5
+ const words = (text) => (text ?? '').split(/\s+/).filter(Boolean);
6
+
7
+ /**
8
+ * Every name a process under run should reach nginx at: the rendered globe's leaf names that are
9
+ * not wildcards (its page, each PDS, its services, plc.directory), each handle in `handles`
10
+ * (data/https/names), <name>.app.<domain> for each app in `apps`, and `extra`
11
+ * (HTTPS_RUN_EXTRA_NAMES, space-separated). In that order, each once.
12
+ */
13
+ export function runNames({ leafNames, handles, apps, domain, extra }) {
14
+ return [
15
+ ...new Set([
16
+ ...words(leafNames).filter((n) => !n.startsWith('*')),
17
+ // One per line; a line that is not a name is skipped whole.
18
+ ...(handles ?? '').split('\n').filter((n) => NAME_RE.test(n)),
19
+ ...apps.map((a) => `${a.name}.app.${domain}`),
20
+ ...words(extra),
21
+ ]),
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * The hosts file: `etcHosts` (the machine's) without any line naming one of `names`, then each
27
+ * name on `bind`.
28
+ */
29
+ export function hostsFile(etcHosts, names, bind) {
30
+ const ours = new Set(names);
31
+ const kept = etcHosts
32
+ .split('\n')
33
+ .filter((l, i, all) => i < all.length - 1 || l !== '')
34
+ .filter((l) => !l.trim().split(/\s+/).slice(1).some((n) => ours.has(n)));
35
+ return [...kept, '# snowglobe (bin/snowglobe.mjs run)', ...names.map((n) => `${bind} ${n}`), ''].join('\n');
36
+ }
@@ -0,0 +1,56 @@
1
+ // The images snowglobe builds from its own folders that are also published (scripts/publish-images.mjs),
2
+ // so `up` pulls them where it can and builds only what it cannot. A pulled image is used only when
3
+ // its source label equals the hash of the folder's files in this checkout, so an edited Dockerfile
4
+ // or patch is built, never silently served from the registry.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import { contextHash } from './render.mjs';
8
+
9
+ export const REGISTRY = 'ghcr.io/openmeet-team';
10
+ export const SOURCE_LABEL = 'at.snowglobe.source';
11
+
12
+ // Image name (the part of the compose image before the tag): its folder, and the files its Dockerfile uses.
13
+ export const PUBLISHED = {
14
+ 'snowglobe-slingshot': ['stack/slingshot', ['Dockerfile']],
15
+ 'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
16
+ 'snowglobe-relay': ['stack/relay', ['Dockerfile', 'allow-private-hosts.patch']],
17
+ };
18
+
19
+ /** The source label an image of this name should carry for the files in `root`. */
20
+ export const sourceHash = (root, name) => contextHash(root, ...PUBLISHED[name]);
21
+
22
+ const docker = (args) => spawnSync('docker', args, { encoding: 'utf8', stdio: 'pipe' });
23
+
24
+ /**
25
+ * Of the rendered compose file's services, those whose image is already here or can be pulled
26
+ * from the registry with a matching source label. Returns their names; the caller builds the rest.
27
+ * SG_PREBUILT=0 pulls nothing and uses nothing (an image is then built, as it always was).
28
+ */
29
+ export function prebuilt({ root, services, env = process.env, run = docker, log = () => {} }) {
30
+ if (env.SG_PREBUILT === '0') return [];
31
+ const registry = (env.SG_IMAGE_REGISTRY ?? REGISTRY).replace(/\/$/, '');
32
+ const label = (ref) => {
33
+ const r = run(['image', 'inspect', '-f', `{{index .Config.Labels "${SOURCE_LABEL}"}}`, ref]);
34
+ return r.status === 0 ? r.stdout.trim() : null;
35
+ };
36
+ const ready = [];
37
+ const pulled = [];
38
+ for (const [service, def] of Object.entries(services)) {
39
+ const ref = def.build && def.image;
40
+ const name = ref?.split(':')[0];
41
+ if (!(name in PUBLISHED)) continue;
42
+ const want = sourceHash(root, name);
43
+ if (label(ref) === want) {
44
+ ready.push(service);
45
+ continue;
46
+ }
47
+ if (!registry) continue;
48
+ if (run(['pull', '-q', `${registry}/${ref}`]).status !== 0) continue;
49
+ if (run(['tag', `${registry}/${ref}`, ref]).status === 0 && label(ref) === want) {
50
+ ready.push(service);
51
+ pulled.push(service);
52
+ }
53
+ }
54
+ if (pulled.length) log(`Pulled prebuilt images (${pulled.join(' ')})`);
55
+ return ready;
56
+ }