@openmeet/snowglobe 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
- # Snowglobe
1
+ # <img src="assets/snowglobe.svg" width="40" alt=""> Snowglobe
2
2
 
3
3
  Your own isolated [AT Protocol](https://atproto.com) network, for testing, research, and demos. A snowglobe has its own
4
- PDS, PLC directory and Jetstream (and a relay, once there is more than one PDS), and nothing in it reaches Bluesky's network. It uses
4
+ PDS, PLC directory, relay and Jetstream, and nothing in it reaches Bluesky's network. It uses
5
5
  https and the real network's sign-in (OAuth), so an app built for the real network runs against it
6
6
  with no test-only code.
7
7
 
@@ -19,9 +19,9 @@ One file, `snowglobe.yml`, says what a snowglobe runs. [`snowglobe.default.yml`]
19
19
  is the one to start from. It gives you:
20
20
 
21
21
  - one current PDS (personal data server, where an account's records live and where it signs in), at
22
- `https://main.local.snowglobe.at`, with two test accounts, `alice` and `bob`;
23
- - a PLC directory, the phone book that says which PDS holds each `did:plc` account; it also answers as
24
- `plc.directory` for your app;
22
+ `https://main.snowglobe.local.snowglobe.at`, with two test accounts, `alice` and `bob`;
23
+ - a PLC directory, the phone book that says which PDS holds each `did:plc` account; your app is told
24
+ its address;
25
25
  - Jetstream, a simple stream of every write on the network, for apps that follow it;
26
26
  - Slingshot, which looks up a handle's DID and PDS inside the network;
27
27
  - PDSls, to browse the network's records;
@@ -39,36 +39,32 @@ The file also holds commented examples for adding more, up to a full setup:
39
39
  [`docs/examples/atmo.yml`](docs/examples/atmo.yml) is a larger file, with three PDSes, a relay and a
40
40
  lexicon authority, that [Example: atmo](docs/examples/atmo.md) builds on.
41
41
 
42
- Every name is under one domain, `local.snowglobe.at`, served by nginx with a certificate from a
43
- local CA that the snowglobe makes for itself. There is no AppView and no feed generator.
42
+ Every name is under the snowglobe's own domain, `<globe>.local.snowglobe.at`, served by nginx with a
43
+ certificate from a local CA that the snowglobe makes for itself. There is no AppView and no feed generator.
44
44
  [The snowglobe](docs/snowglobe.md) shows the whole file and what each part does.
45
45
 
46
46
  ## What you need
47
47
 
48
- Every service in a snowglobe has its own https name under `local.snowglobe.at`, such as
49
- `alice.local.snowglobe.at`. Handles, sign-in (OAuth) and DID documents all carry those names, so
50
- your computer has to send the names to the snowglobe, and trust its certificate. Here is what to do.
48
+ Every service in a snowglobe has its own https name under the snowglobe's domain, such as
49
+ `alice.snowglobe.local.snowglobe.at` for the default file. Handles, sign-in (OAuth) and DID documents
50
+ all carry those names, so your computer has to send the names to the snowglobe, and trust its
51
+ certificate. Here is what to do.
51
52
 
52
- **Before you start it:**
53
-
54
- 1. Install Docker with Compose, and Node 20 or newer. Use Linux, or Windows with WSL 2.
55
- 2. To run a command against the snowglobe from your machine (`snowglobe run`: your own app, its
56
- tests, or the `curl` example below), also install
57
- [bubblewrap](https://github.com/containers/bubblewrap): `sudo apt install bubblewrap` on Debian or
58
- Ubuntu. You do not need it to bring a snowglobe up or to look at one in a browser.
53
+ **Before you start it:** install Docker with Compose, and Node 20 or newer. Use Linux, or Windows with
54
+ WSL 2.
59
55
 
60
56
  **After it is up, to open it in a browser or reach it from an app:**
61
57
 
62
- 3. Make the names reach the snowglobe.
63
- - Docker and your browser on the same computer: you usually do nothing, because public DNS answers
64
- every name under `local.snowglobe.at` with `127.0.0.1`. If the browser says "server not found",
65
- your router is dropping that answer. See
58
+ 1. The names reach the snowglobe on their own. Public DNS answers every name under
59
+ `local.snowglobe.at` with `127.0.0.1`, where `snowglobe up` starts a name router that passes each
60
+ connection to the snowglobe the name is under, so every snowglobe on the machine answers at once.
61
+ - If the browser says "server not found", your router is dropping that answer. See
66
62
  [When the browser cannot open it](docs/browser.md#when-the-browser-cannot-open-it).
67
- - Docker in a dev container, or on another machine: the snowglobe's proxy runs with every `up`, on
68
- `127.0.0.1:1080`; point your browser at it ([From a browser on your own computer](docs/browser.md)).
69
- - A command started with `snowglobe run`: you do nothing, it sets the names up itself.
70
- 4. Trust the snowglobe's certificate authority (CA). Run `snowglobe trust` on the machine that runs
71
- Docker. For another machine, see [From a browser on your own computer](docs/browser.md).
63
+ - With Docker in a dev container, or on another machine, forward its port 443 to your computer
64
+ ([From a browser on your own computer](docs/browser.md)).
65
+ 2. Trust the snowglobe's certificate authority (CA). Run `snowglobe trust` on the machine that runs
66
+ Docker. For another machine, see [From a browser on your own computer](docs/browser.md). A
67
+ command started with `snowglobe run` trusts it without this.
72
68
 
73
69
  **Why not just use ports?** The PDSes are not published on ports at all: only nginx (443), the proxy,
74
70
  the PLC, Jetstream and MailDev are. A PDS's address is its name, and that name is what a DID document, a
@@ -93,25 +89,25 @@ To keep the command with one project instead, run `npm install --save-dev @openm
93
89
  `snowglobe.yml` (copy it from `node_modules/@openmeet/snowglobe/snowglobe.default.yml`). Either
94
90
  command also fits in a `package.json` script.
95
91
 
96
- Now try it. `snowglobe run` runs a command that can reach the snowglobe by name, and `--cacert`
97
- gives curl the snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
92
+ Now try it. The snowglobe's names work for any program on the machine, and `--cacert` gives curl the
93
+ snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
98
94
 
99
95
  ```bash
100
96
  # Sign up a new account, then look up alice's account through Slingshot
101
97
  snowglobe account carol CAROL # CAROL: the prefix of her login variables, such as CAROL_PASSWORD
102
- snowglobe run curl -s --cacert ~/.local/share/snowglobe/ca/ca.crt \
103
- 'https://slingshot.local.snowglobe.at/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=alice.local.snowglobe.at'
98
+ curl -s --cacert ~/.local/share/snowglobe/ca/ca.crt \
99
+ 'https://slingshot.snowglobe.local.snowglobe.at/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=alice.snowglobe.local.snowglobe.at'
104
100
  ```
105
101
 
106
102
  To look around in a browser, you need two things: the browser has to trust the snowglobe's
107
103
  certificate, and it has to be able to find the snowglobe. If Docker runs on the same computer as your
108
- browser, run `snowglobe trust` and open `https://local.snowglobe.at/`, the network's page,
104
+ browser, run `snowglobe trust` and open `https://snowglobe.local.snowglobe.at/`, the network's page,
109
105
  which lists every service, account and write. If Docker runs in a dev container or on another
110
- machine, your browser also needs the snowglobe's small proxy, which sends the snowglobe's names to
111
- it; [From a browser on your own computer](docs/browser.md) walks through both.
106
+ machine, also forward its port 443 to your computer;
107
+ [From a browser on your own computer](docs/browser.md) walks through both.
112
108
 
113
109
  The default snowglobe comes with one app to look at, [PDSls](docs/snowglobe.md#pdsls-browsing-the-networks-records),
114
- the record browser. `https://pdsls.local.snowglobe.at/at://alice.local.snowglobe.at` shows alice's repo,
110
+ the record browser. `https://pdsls.snowglobe.local.snowglobe.at/at://alice.snowglobe.local.snowglobe.at` shows alice's repo,
115
111
  once your browser can reach the snowglobe as above.
116
112
 
117
113
  Stop the snowglobe with `snowglobe down`. Add `-v` to delete its data too, every account
@@ -123,10 +119,10 @@ To run your own app inside of a snowglobe, see [Your app in a snowglobe](docs/yo
123
119
 
124
120
  ## Platforms
125
121
 
126
- Snowglobe runs on Linux, and on Windows under WSL 2. Both are tested. macOS is not supported, but we'd love to.
122
+ Snowglobe runs on Linux, and on Windows under WSL 2. Both are tested. macOS is not supported yet.
127
123
  If you try it on a Mac, please
128
124
  [open an issue](https://github.com/OpenMeet-Team/snowglobe/issues) with what broke, or that it
129
- worked, and we'd love to experiment with you.
125
+ worked.
130
126
 
131
127
  ## Documentation
132
128
 
@@ -134,11 +130,13 @@ worked, and we'd love to experiment with you.
134
130
  down, and what it writes.
135
131
  - [Your app in a snowglobe](docs/your-app.md): `snowglobe run`, and the tools that make an app's
136
132
  accounts, invite codes, lexicons and routes.
137
- - [From a browser on your own computer](docs/browser.md): the proxy, launch flags, and trusting the
138
- CA.
133
+ - [From a browser on your own computer](docs/browser.md): the name router, Docker on another
134
+ machine, and trusting the CA.
139
135
  - [How it works](docs/how-it-works.md): what runs and how it is wired, Slingshot, and the relay build.
140
136
  - [Configuration](docs/configuration.md): the settings.
141
137
  - [Testing Snowglobe](docs/testing.md): the test suite and the smoke run.
138
+ - [Example: Statusphere](docs/examples/statusphere.md): the atproto example app on a snowglobe, with
139
+ two small changes. CI runs it end to end.
142
140
  - [Example: atmo](docs/examples/atmo.md): an events app on a local snowglobe.
143
141
  - The older plain-http stack (`npm run up`) is not in this version. Its code and README are in the
144
142
  [v0.2.0 tag](https://github.com/OpenMeet-Team/snowglobe/tree/v0.2.0) (v0.1.0 has the first version of it).
package/bin/snowglobe.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // app routes https://<name>.app.<domain> to a port on this machine
14
14
  // up brings the globe up, as the compose project named for its file (or <name>, -f <file>)
15
15
  // down stops it (the same ways)
16
- // run runs a command with the app's settings, and plc.directory pointed at the snowglobe
16
+ // run runs a command with the app's settings and the snowglobe's CA
17
17
  //
18
18
  // A snowglobe is the snowglobe.yml in the folder you are in or the nearest one above (else
19
19
  // SG_FILE, else the default). Its state is kept per user, not in a checkout: ~/.local/share/
package/lib/certs.mjs CHANGED
@@ -9,14 +9,15 @@
9
9
  // Delete the CA's ca.* to start over, and then trust the new ca.crt again everywhere the old one
10
10
  // was trusted.
11
11
  //
12
- // The CA can only vouch for servers under the default domain, the domain it was made for
12
+ // The CA can only vouch for servers under local.snowglobe.at (the zone every snowglobe's own
13
+ // domain is under, <globe>.local.snowglobe.at), a domain outside it that it was made for,
13
14
  // and plc.directory (name constraints, serverAuth only, no IP addresses), so
14
15
  // trusting it on a host cannot put any other site at risk, even if ca.key leaks. A name outside
15
- // those is refused before the leaf is touched; widening the list, or a snowglobe on another
16
- // domain made after the CA, means a new CA, trusted again.
16
+ // those is refused before the leaf is touched; widening the list, or a snowglobe on a domain
17
+ // outside the zone made after the CA, means a new CA, trusted again.
17
18
  //
18
- // A TLS wildcard covers one label, so *.local.snowglobe.at does not cover a handle on the PDS
19
- // named regular (<name>.regular.local.snowglobe.at); each handle domain has its own.
19
+ // A TLS wildcard covers one label, so *.mine.local.snowglobe.at does not cover a handle on the PDS
20
+ // named regular (<name>.regular.mine.local.snowglobe.at); each handle domain has its own.
20
21
 
21
22
  import 'reflect-metadata';
22
23
  import { AsnConvert } from '@peculiar/asn1-schema';
@@ -25,7 +26,7 @@ import * as x509 from '@peculiar/x509';
25
26
  import { X509Certificate as NodeCertificate, createPrivateKey, randomBytes, webcrypto } from 'node:crypto';
26
27
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
28
  import { join } from 'node:path';
28
- import { DEFAULT_DOMAIN } from './config.mjs';
29
+ import { LOCAL_ZONE } from './config.mjs';
29
30
 
30
31
  x509.cryptoProvider.set(webcrypto);
31
32
 
@@ -104,9 +105,11 @@ async function makeCa(domain, now) {
104
105
  const name = `snowglobe local CA ${now.toISOString().slice(0, 10)} ${randomBytes(3).toString('hex')}`;
105
106
  const subtree = (gn) => new GeneralSubtree({ base: new GeneralName(gn) });
106
107
  const constraints = new NameConstraints({
107
- // One CA serves every snowglobe, so the default domain is always in, whatever domain the
108
- // snowglobe that makes it is on.
109
- permittedSubtrees: new GeneralSubtrees([...new Set([DEFAULT_DOMAIN, domain, ...FIXED])].map((d) => subtree({ dNSName: d }))),
108
+ // One CA serves every snowglobe, so the local zone is always in, whatever domain the
109
+ // snowglobe that makes it is on; a domain inside the zone needs no subtree of its own.
110
+ permittedSubtrees: new GeneralSubtrees(
111
+ [...new Set([LOCAL_ZONE, ...(within(domain, [LOCAL_ZONE]) ? [] : [domain]), ...FIXED])].map((d) => subtree({ dNSName: d })),
112
+ ),
110
113
  excludedSubtrees: new GeneralSubtrees([subtree({ iPAddress: '0.0.0.0/0' }), subtree({ iPAddress: '::/0' })]),
111
114
  });
112
115
  const cert = await x509.X509CertificateGenerator.createSelfSigned({
@@ -2,13 +2,13 @@
2
2
  //
3
3
  // account creates or finds <name> on <pds> (the PDS with bare handles, else the seed PDS):
4
4
  // <name>.<domain> there, <name>.<pds>.<domain> on the others. Its login goes into ACCOUNTS_FILE
5
- // (accounts.env in the snowglobe's state folder by default, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS, its
6
- // handle into the names bin/snowglobe.mjs run maps (restart a process under it to pick one up), and
7
- // its DID alone to stdout. A rerun finds it. It reads the rendered globe, and prints no secret
5
+ // (accounts.env in the snowglobe's state folder by default, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS, and
6
+ // its DID alone to stdout. The handle resolves at once: it is under the snowglobe's domain, which
7
+ // the name router serves. A rerun finds it. It reads the rendered globe, and prints no secret
8
8
  // (lib/xrpc.mjs).
9
9
 
10
10
  import { randomBytes } from 'node:crypto';
11
- import { ROOT, UsageError, addName, envGet, envPut, globeFacts, loginsFile, paths } from '../env.mjs';
11
+ import { ROOT, UsageError, envGet, envPut, globeFacts, loginsFile, paths } from '../env.mjs';
12
12
  import { session } from '../state.mjs';
13
13
  import { mintInvite, why, xrpc } from '../xrpc.mjs';
14
14
 
@@ -31,7 +31,7 @@ export async function run({ positionals: [name, prefix, pds] }) {
31
31
  /**
32
32
  * Create, or find, the account <name> on the globe's PDS `pds` (the one with bare handles, else
33
33
  * the seed PDS, by default), and keep its login in `file` as <prefix>_HANDLE, _DID, _PASSWORD and
34
- * _PDS. Its handle is added to the names snowglobe run maps. Resolves to the DID. A rerun signs in
34
+ * _PDS. Resolves to the DID. A rerun signs in
35
35
  * with the kept password and finds the account; a PDS that takes invite codes is sent one
36
36
  * (SG_INVITE_CODE, else a single-use code minted for this account), the others none.
37
37
  */
@@ -64,7 +64,6 @@ export async function account({ name, prefix, pds, file, root = ROOT, state, env
64
64
  const res = await xrpc(pdsUrl, 'com.atproto.server.createSession', { body: { identifier: handle, password: kept }, ...opts });
65
65
  if (res.ok && typeof res.json.did === 'string') {
66
66
  if (envGet(`${prefix}_DID`, accounts) !== res.json.did) save(res.json.did, kept);
67
- addName(handle, { root, state });
68
67
  log(`Found ${handle} -> ${res.json.did}`);
69
68
  return res.json.did;
70
69
  }
@@ -79,7 +78,6 @@ export async function account({ name, prefix, pds, file, root = ROOT, state, env
79
78
  const res = await xrpc(pdsUrl, 'com.atproto.server.createAccount', { body, ...opts });
80
79
  if (!res.ok || typeof res.json.did !== 'string') throw new Error(`createAccount ${handle} on ${pdsUrl} failed: ${why(res)}`);
81
80
  save(res.json.did, password);
82
- addName(handle, { root, state });
83
81
  log(`Created ${handle} on ${pdsUrl} -> ${res.json.did}`);
84
82
  return res.json.did;
85
83
  }
@@ -11,9 +11,14 @@
11
11
  // alice and bob and rewrites accounts.env from scratch, dropping the logins the tools wrote there
12
12
  // (their accounts are gone too), and the lexicon authority is made again. The fixed secrets an
13
13
  // older snowglobe kept for its data (secrets.env) are dropped with it, so up makes random ones. The CA, the leaf and
14
- // the app routes stay either way. The project is guarded as up guards it.
14
+ // the app routes stay either way. The project is guarded as up guards it. Once the project is
15
+ // down, the name router stops routing to it, and is removed when no snowglobe is left to route
16
+ // (lib/router.mjs).
15
17
 
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
16
20
  import { ROOT, dc, dropLegacySecrets, guardProject } from '../env.mjs';
21
+ import { dropRoute, routerDir, syncRouter } from '../router.mjs';
17
22
  import { byName, pointedAt, session } from '../state.mjs';
18
23
 
19
24
  export const command = {
@@ -27,7 +32,7 @@ export function run({ values, positionals: [word] }) {
27
32
  return down({ volumes: values.volumes, ...pointedAt({ file: values.file, word }) });
28
33
  }
29
34
 
30
- export function down({ volumes = false, file, name, root = ROOT, env = process.env } = {}) {
35
+ export async function down({ volumes = false, file, name, root = ROOT, env = process.env, log = (m) => console.error(m) } = {}) {
31
36
  // The shared devnet is refused before anything is looked up, even by a file that is not there.
32
37
  if (env.SG_PROJECT) guardProject(env);
33
38
  const found = name ? byName(name, { env }) : session({ file, env });
@@ -36,5 +41,9 @@ export function down({ volumes = false, file, name, root = ROOT, env = process.e
36
41
  if (res.error) throw res.error;
37
42
  // The data the fixed secrets were kept for is gone only once compose says it removed it.
38
43
  if (volumes && res.status === 0) dropLegacySecrets({ root, state: found.state });
44
+ // Only a snowglobe the router routes to touches it. The route is dropped under the router's
45
+ // lock; when another down got there first, the router is already in line.
46
+ const rdir = routerDir(env);
47
+ if (res.status === 0 && existsSync(join(rdir, 'routes', project))) await syncRouter({ dir: rdir, env, log, change: () => dropRoute(rdir, project) });
39
48
  return res.status ?? 1;
40
49
  }
@@ -1,16 +1,19 @@
1
1
  // bin/snowglobe.mjs ls
2
2
  //
3
3
  // ls lists the snowglobes on this machine: every folder in the state root (lib/env.mjs stateRoot;
4
- // ca/, the one CA, is not one) with its name, the domain it was rendered for, whether its compose
5
- // project is running, and the file that owns it. Take the name to `up`, `down`, or a file to -f.
4
+ // ca/, the one CA, and dot folders such as the name router's .router are not ones) with its name,
5
+ // the domain it was rendered for, whether its compose project is running, and the file that owns
6
+ // it. Take the name to `up`, `down`, or a file to -f.
6
7
  // "up" is a project with a container running (the one-shot init and relay-init have exited by
7
8
  // then), "down" is none, or no project yet; "unknown" is what it says when Docker did not answer.
9
+ // When the name router routes any snowglobe, a last line says whether it is running and to which.
8
10
  // Reads only; nothing is started.
9
11
 
10
12
  import { spawnSync } from 'node:child_process';
11
13
  import { existsSync, readdirSync } from 'node:fs';
12
14
  import { join } from 'node:path';
13
- import { ROOT, envGet, paths, stateRoot } from '../env.mjs';
15
+ import { ROOT, ROUTER_ADDRESS, envGet, paths, stateRoot } from '../env.mjs';
16
+ import { readRoutes, routerDir, routerState } from '../router.mjs';
14
17
  import { ownerOf } from '../state.mjs';
15
18
 
16
19
  export const command = {
@@ -44,7 +47,7 @@ export function list({ root = ROOT, env = process.env, statuses = projectStatuse
44
47
  const base = stateRoot(env);
45
48
  if (!existsSync(base)) return [];
46
49
  return readdirSync(base, { withFileTypes: true })
47
- .filter((e) => e.isDirectory() && e.name !== 'ca')
50
+ .filter((e) => e.isDirectory() && e.name !== 'ca' && !e.name.startsWith('.'))
48
51
  .map((e) => {
49
52
  const state = join(base, e.name);
50
53
  const owner = ownerOf(state);
@@ -58,15 +61,26 @@ export function list({ root = ROOT, env = process.env, statuses = projectStatuse
58
61
  .sort((a, b) => a.name.localeCompare(b.name));
59
62
  }
60
63
 
64
+ // The last line: whether the name router runs and what it routes, when it routes anything. Shown
65
+ // with no snowglobe listed too, since a route can outlive its state folder.
66
+ function routerLine() {
67
+ const routes = readRoutes(routerDir());
68
+ if (!routes.length) return;
69
+ const word = { running: 'running', stopped: 'stopped', missing: 'not running' }[routerState().state];
70
+ console.log(`\nName router on ${ROUTER_ADDRESS}:443: ${word}, routing ${routes.map((r) => `${r.domain} to ${r.globe}`).join(', ')}`);
71
+ }
72
+
61
73
  export function run() {
62
74
  const rows = list();
63
75
  if (!rows.length) {
64
76
  console.log(`No snowglobes in ${stateRoot()}. snowglobe up in a folder with a snowglobe.yml makes one.`);
77
+ routerLine();
65
78
  return 0;
66
79
  }
67
80
  const head = { name: 'NAME', domain: 'DOMAIN', state: 'STATE', file: 'FILE' };
68
81
  const cols = Object.keys(head);
69
82
  const width = Object.fromEntries(cols.map((c) => [c, Math.max(head[c].length, ...rows.map((r) => r[c].length))]));
70
83
  for (const r of [head, ...rows]) console.log(cols.map((c, i) => (i === cols.length - 1 ? r[c] : r[c].padEnd(width[c]))).join(' '));
84
+ routerLine();
71
85
  return 0;
72
86
  }
@@ -1,14 +1,10 @@
1
1
  // bin/snowglobe.mjs run [--app <name>] [--] <cmd> [args...]
2
2
  //
3
- // run runs a command with the snowglobe around it, for that command's process tree only:
3
+ // run runs a command with the snowglobe's settings:
4
4
  // - it is handed the app's settings, the apps[].env of the snowglobe.yml (lib/commands/env.mjs;
5
5
  // the same as snowglobe env, secrets included, none printed). They win over a value already in
6
6
  // the environment, and one line names the ones they replaced. --app picks the app when the
7
7
  // file lists several;
8
- // - the globe's names (its page, each PDS, its services and plc.directory, from the rendered
9
- // globe), every handle in https/names (account and up add them) and every app in
10
- // https/apps, as <name>.app.<domain>, resolve to SG_BIND, where nginx answers on
11
- // 443 (lib/hosts.mjs). HTTPS_RUN_EXTRA_NAMES adds names (space-separated);
12
8
  // - Node trusts the globe's CA (NODE_EXTRA_CA_CERTS=<state root>/ca/ca.crt), on top of its own
13
9
  // roots. Miniflare passes the same file on to workerd.
14
10
  //
@@ -17,20 +13,18 @@
17
13
  // e.g. bin/snowglobe.mjs run node scripts/https-probe.mjs
18
14
  // (cd ../myapp && ../snowglobe/bin/snowglobe.mjs run npm run dev)
19
15
  //
20
- // How: bubblewrap gives the command its own mount namespace with a generated hosts file bound
21
- // over /etc/hosts. Nothing else changes: same filesystem, same network, same user. Outside,
22
- // every name resolves as usual. The names are read when the command starts, so a handle or app
23
- // added later needs a restart of the command. Linux only: a Mac has no bubblewrap, and reaches
24
- // the globe through snowglobe env instead (the names resolve through public DNS, and the CA is in
25
- // NODE_EXTRA_CA_CERTS).
16
+ // Nothing maps names. Each snowglobe has a domain of its own under local.snowglobe.at, whose
17
+ // public DNS answers every name with 127.0.0.1, where the name router passes each connection to
18
+ // the snowglobe the name is under (lib/router.mjs). So every name resolves as it does anywhere on
19
+ // the machine, a handle made after the command started included. plc.directory is the real one:
20
+ // an app takes the snowglobe's PLC directory as a setting (${services.plc.url}), as it must on a
21
+ // hosted globe. A domain of your own, outside local.snowglobe.at, has to resolve to 127.0.0.1 the
22
+ // same way.
26
23
 
27
24
  import { spawn } from 'node:child_process';
28
- import { accessSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
+ import { existsSync } from 'node:fs';
29
26
  import { constants as os } from 'node:os';
30
- import { delimiter, join } from 'node:path';
31
- import { readApps } from '../apps.mjs';
32
- import { ROOT, paths, resolveBind, resolveDomain } from '../env.mjs';
33
- import { hostsFile, runNames } from '../hosts.mjs';
27
+ import { ROOT, paths, resolveDomain } from '../env.mjs';
34
28
  import { session } from '../state.mjs';
35
29
  import { appSettings } from './env.mjs';
36
30
 
@@ -48,48 +42,25 @@ export const command = {
48
42
  },
49
43
  };
50
44
 
51
- // The first `name` on PATH that can be run, or null.
52
- function onPath(name, env) {
53
- for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
54
- try {
55
- accessSync(join(dir, name), constants.X_OK);
56
- return join(dir, name);
57
- } catch {}
58
- }
59
- return null;
60
- }
61
-
62
45
  export const run = ({ cmd, app }) => runUnder(cmd, { settings: appSettings({ app, secrets: true }).vars });
63
46
 
64
47
  /**
65
- * Run `cmd` (a word list) under the globe's names with `settings` ([{ name, value }], from
66
- * appSettings) in its environment; resolves to its exit code. A setting replaces a value of the same
67
- * name already in `env`, and `log` is told which.
48
+ * Run `cmd` (a word list) with `settings` ([{ name, value }], from appSettings) and the globe's CA
49
+ * in its environment; resolves to its exit code. A setting replaces a value of the same name
50
+ * already in `env`, and `log` is told which.
68
51
  */
69
52
  export function runUnder(cmd, { root = ROOT, env = process.env, state = session({ env }).state, settings = [], log = (m) => console.error(m) } = {}) {
70
- if (process.platform !== 'linux')
71
- throw new Error('run needs Linux (bubblewrap) for now; on this system eval "$(snowglobe env)" hands the app the same settings');
72
53
  const p = paths(root, state);
73
54
  if (!existsSync(p.ca)) throw new Error(`no ${p.ca}; run snowglobe up (or certs) first`);
74
- if (!existsSync(p.leafNames)) throw new Error(`no ${p.leafNames}; run snowglobe up first`);
75
- const bwrap = onPath('bwrap', env);
76
- if (!bwrap) throw new Error('bwrap (bubblewrap) is not installed, or not on PATH');
77
- // Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
78
- const { domain } = resolveDomain({ root, state, env });
79
- const bind = resolveBind({ root, state, env });
80
-
81
- const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
82
- const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
83
- // Written aside and renamed, so a run already holding the old file is undisturbed.
84
- const hosts = join(p.https, 'hosts');
85
- writeFileSync(`${hosts}.tmp.${process.pid}`, hostsFile(read('/etc/hosts'), names, bind), { mode: 0o644 });
86
- renameSync(`${hosts}.tmp.${process.pid}`, hosts);
55
+ if (!existsSync(p.globeEnv)) throw new Error(`no ${p.globeEnv}; run snowglobe up first`);
56
+ // Refuses a SG_DOMAIN the render is not for.
57
+ resolveDomain({ root, state, env });
87
58
 
88
59
  // Never the values: a setting may be a login.
89
60
  const replaced = settings.filter((x) => !x.auto && env[x.name] !== undefined && env[x.name] !== x.value).map((x) => x.name);
90
61
  if (replaced.length) log(`snowglobe run: the snowglobe's settings replaced ${replaced.join(', ')} from the environment`);
91
62
 
92
- const child = spawn(bwrap, ['--dev-bind', '/', '/', '--ro-bind', hosts, '/etc/hosts', '--die-with-parent', '--', ...cmd], {
63
+ const child = spawn(cmd[0], cmd.slice(1), {
93
64
  stdio: 'inherit',
94
65
  env: { ...env, NODE_EXTRA_CA_CERTS: p.ca, ...Object.fromEntries(settings.map((x) => [x.name, x.value])) },
95
66
  });
@@ -23,7 +23,9 @@
23
23
  // group host built from a folder is built again (Docker's cache keeps that quick)
24
24
  // 4. pass 1: up. The base init seeds alice, bob and SG_INVITE_CODE into
25
25
  // data/accounts.env on the PDS with bare handles (else the first), as it does for the
26
- // http stacks.
26
+ // http stacks. Then the name router (lib/router.mjs) sends the globe's domain and every name
27
+ // under it to the globe's own address, and is started if it is not running. A domain another
28
+ // running snowglobe is routed at is refused before anything starts.
27
29
  // 5. the lexicon authority, lex-authority on the file's lexicons.authority PDS
28
30
  // (LEX_AUTHORITY_* in data/accounts.env), then pass 2: up with
29
31
  // SG_LEXICON_AUTHORITY_DID, which recreates the PDSes with it
@@ -41,16 +43,18 @@
41
43
  // two snowglobes need no port settings; a port variable moves one, in the environment or .env. devnet-spaces, the shared devnet, is refused (exit 3) as
42
44
  // the project unless SG_RESET_OK=1, before anything else is done.
43
45
 
46
+ import { spawnSync } from 'node:child_process';
44
47
  import { createHash } from 'node:crypto';
45
48
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
46
49
  import { dirname, join, resolve } from 'node:path';
47
50
  import { setTimeout as sleep } from 'node:timers/promises';
48
51
  import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
49
52
  import { loadGlobe } from '../config.mjs';
50
- import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
53
+ import { ROOT, ROUTER_ADDRESS, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, ownNginxUp, paths, renamedDefaultNotice, resolveBind, resolveDomain, routerRuns } from '../env.mjs';
51
54
  import { prebuilt } from '../prebuilt.mjs';
55
+ import { claimDomain, readRoutes, routerDir, syncRouter } from '../router.mjs';
52
56
  import { byName, claimState, pointedAt, session } from '../state.mjs';
53
- import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
57
+ import { accountPrefixes, endingClash, mergeRoutes, startedAfterRelay } from '../up.mjs';
54
58
  import { account } from './account.mjs';
55
59
  import { lexicons } from './lexicons.mjs';
56
60
  import { checkedGlobe } from './check.mjs';
@@ -125,7 +129,23 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
125
129
  if (took) log(took);
126
130
  // Its own loopback address for every published port, chosen once, so a second snowglobe starts beside this one.
127
131
  const bind = await chooseBind({ root, state, project, env });
128
- if (bind !== '127.0.0.1') log(`${found.name} publishes its ports on ${bind}`);
132
+ if (bind !== ROUTER_ADDRESS) log(`${found.name} publishes its ports on ${bind}`);
133
+ // Routed by name unless it holds the router's own address (SG_BIND, or kept while it runs).
134
+ const routed = routerRuns({ root, env }) && bind !== ROUTER_ADDRESS;
135
+ if (routerRuns({ root, env }) && !routed)
136
+ log(`${found.name} is on ${ROUTER_ADDRESS}, the name router's address, so the router is not started and no other snowglobe is reached by name while it runs. To move it: snowglobe down, then up${env.SG_BIND ? ' without SG_BIND' : ''}`);
137
+ const rdir = routerDir(env);
138
+ const taken = (other) =>
139
+ new Error(
140
+ `${other.globe} (compose project ${other.project}) is up at ${globe.domain} already, and the name router sends a name to one snowglobe only: take it down first, or leave the domain line out of ${path} so ${found.name} gets its own`,
141
+ );
142
+ // Asked before anything starts; asked again under the router's lock when the route is written,
143
+ // for another up at the same domain in between.
144
+ const isUp = (other) => ownNginxUp(other, env);
145
+ if (routed) {
146
+ const other = readRoutes(rdir).find((r) => r.project !== project && r.domain === globe.domain && isUp(r.project));
147
+ if (other) throw taken(other);
148
+ }
129
149
  // Checked before the render changes, so a refused file leaves the running globe's render.
130
150
  const kept = readApps(p);
131
151
  const badEnding = appEnding(kept);
@@ -171,10 +191,12 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
171
191
  throw new Error(`docker compose ${args.join(' ')} failed (exit ${res.status}). If a container crash-loops after you updated snowglobe, an image built earlier may be stale (the slingshot image is tagged by its commit, so a changed stack/slingshot/ folder alone does not rebuild it): run snowglobe down (without -v, which keeps your data), remove the image with docker rmi, then run up again`);
172
192
  return res;
173
193
  };
174
- // The nginx template as the running nginx last started with it (stamped after a restart here).
175
- const templateFile = join(root, 'stack/https/nginx.conf.template');
194
+ // The files nginx mounts one by one from the checkout, as the running nginx last started with
195
+ // them (stamped after a restart here): its template, and the network page and its config. A
196
+ // mounted file keeps the content it had when nginx started, so a new one needs a restart.
197
+ const templateFiles = ['stack/https/nginx.conf.template', 'stack/page/snowglobe.conf', 'stack/page/index.html'].map((f) => join(root, f));
176
198
  const templateStamp = join(p.https, 'nginx-template.sha256');
177
- const templateHash = createHash('sha256').update(existsSync(templateFile) ? readFileSync(templateFile) : '').digest('hex');
199
+ const templateHash = templateFiles.reduce((h, f) => h.update(existsSync(f) ? readFileSync(f) : '').update('\0'), createHash('sha256')).digest('hex');
178
200
  const templateChanged = !existsSync(templateStamp) || readFileSync(templateStamp, 'utf8').trim() !== templateHash;
179
201
  const put = (file, text) => {
180
202
  writeFileSync(`${file}.tmp`, `${text}\n`);
@@ -203,10 +225,6 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
203
225
  waitServices();
204
226
  }
205
227
 
206
- // What runs before this run: the relay, and every container, to notice a PDS this run makes.
207
- const relayBefore = ids(['-q', 'relay']);
208
- const before = new Set(ids(['-aq']));
209
-
210
228
  // Every image the file builds is built before anything starts, so `up` never builds while
211
229
  // containers are being made (on a runner with no images, the relay and the other built services
212
230
  // were made, then made again by the next `up`, and Jetstream lost the relay between the two).
@@ -234,8 +252,16 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
234
252
  composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
235
253
  log(`Pass 1: up (project ${project})`);
236
254
  await upStack();
237
- // A running nginx keeps the certificate files it started with, and the template it was started
238
- // with (the image fills it in at start); new ones need a restart, whether or not the leaf was
255
+ if (routed) {
256
+ const change = () => {
257
+ const other = claimDomain(rdir, { project, domain, bind, globe: found.name }, isUp, log);
258
+ if (other) throw taken(other);
259
+ };
260
+ await syncRouter({ dir: rdir, env, log, change });
261
+ log(`The name router sends ${domain} and every name under it to ${bind}`);
262
+ }
263
+ // A running nginx keeps the certificate files it started with, and the template and page files
264
+ // it was started with (the image fills the template in at start); new ones need a restart, whether or not the leaf was
239
265
  // reissued. The globe's servers may have changed with the render, so a running nginx reloads them.
240
266
  if (newCerts || templateChanged) {
241
267
  compose(['restart', 'nginx'], { stdio: 'ignore' });
@@ -250,34 +276,32 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
250
276
  if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
251
277
  composeEnv.SG_LEXICON_AUTHORITY_DID = did;
252
278
  log(`Pass 2: up with the lexicon authority ${did}`);
253
- // Pass 2 recreates every PDS. Jetstream with no relay reads the first PDS itself and exits
254
- // while it is gone; on a slow machine (a CI runner) it was still restarting when compose
255
- // checked it, and up failed with "jetstream is unhealthy". Stopped first, it is started
256
- // again only once the PDS is healthy.
257
- const running = facts('SG_SERVICES');
258
- if (running.includes('jetstream') && !running.includes('relay')) compose(['stop', 'jetstream'], { stdio: 'ignore' });
279
+ // Pass 2 recreates every PDS, so the relay restarts after it (below).
259
280
  await upStack();
260
281
  }
261
282
 
262
- // A relay that was running when a PDS went away redials it with a growing backoff; a PDS
263
- // added back under that name was not crawled again until the relay restarted (2026-10-20: no
264
- // dial in the nine minutes after it came back). So when this run made a PDS container beside a
265
- // relay that was already running, the relay restarts, and the services wait for it again.
266
- if (relayBefore.length) {
267
- const fresh = facts('SG_PDS').find((x) => ids(['-q', `pds-${x}`]).some((id) => !before.has(id)));
268
- if (fresh) {
269
- log(`Restarting the relay: pds-${fresh} is new beside it`);
283
+ // A relay that was running when a PDS, or nginx in front of it, went away redials it, and after
284
+ // ten quick failures it waits 30 s between dials: what the PDS writes meanwhile reaches it half a
285
+ // minute late, or not for minutes. A PDS added back was not dialed in the nine minutes after it
286
+ // came back (2026-10-20), and a record written right after a first up in CI had not reached Tap
287
+ // 20 s later (2026-10-30). Pass 2 recreates every PDS, new certificates restart nginx, and a rerun
288
+ // can make a PDS. So when one of them started after the relay did, the relay restarts, and the
289
+ // services wait for it again.
290
+ if (ids(['-q', 'relay']).length) {
291
+ const started = (service) => {
292
+ const containers = ids(['-q', service]);
293
+ if (!containers.length) return [];
294
+ return (spawnSync('docker', ['inspect', '-f', '{{.State.StartedAt}}', ...containers], { encoding: 'utf8', env }).stdout ?? '').split('\n').filter(Boolean);
295
+ };
296
+ const dialed = Object.fromEntries([...facts('SG_PDS').map((x) => `pds-${x}`), 'nginx'].map((s) => [s, started(s)]));
297
+ const later = startedAfterRelay(started('relay'), dialed);
298
+ if (later) {
299
+ log(`Restarting the relay: ${later} started after it`);
270
300
  compose(['restart', 'relay'], { stdio: 'ignore' });
271
301
  waitServices();
272
302
  }
273
303
  }
274
304
 
275
- // init's accounts are on the seed PDS; run maps their handles.
276
- for (const prefix of ['ALICE', 'BOB']) {
277
- const handle = envGet(`${prefix}_HANDLE`, p.accounts);
278
- if (handle) addName(handle, { root, state });
279
- }
280
-
281
305
  // What the file seeds. A rerun finds each account by its kept login.
282
306
  for (const a of seedAccounts) await account({ ...a, file: p.accounts, root, state, env, log });
283
307
  if (globe.lexicons.publish) await lexicons({ dir: resolve(dirname(path), globe.lexicons.publish), root, state, env, log });