@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 +36 -38
- package/bin/snowglobe.mjs +1 -1
- package/lib/certs.mjs +12 -9
- package/lib/commands/account.mjs +5 -7
- package/lib/commands/down.mjs +11 -2
- package/lib/commands/ls.mjs +18 -4
- package/lib/commands/run.mjs +17 -46
- package/lib/commands/up.mjs +57 -33
- package/lib/config.mjs +25 -10
- package/lib/env.mjs +39 -27
- package/lib/render.mjs +13 -9
- package/lib/router.mjs +214 -0
- package/lib/up.mjs +14 -1
- package/package.json +3 -3
- package/snowglobe.default.yml +9 -10
- package/stack/docker-compose.test.yml +2 -1
- package/stack/docker-compose.yml +17 -9
- package/stack/https/app-names.cjs +6 -5
- package/stack/jetstream/Dockerfile +12 -0
- package/stack/page/snowglobe.conf +1 -1
- package/lib/hosts.mjs +0 -36
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
|
|
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;
|
|
24
|
-
|
|
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
|
|
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
|
|
49
|
-
`alice.local.snowglobe.at
|
|
50
|
-
your computer has to send the names to the snowglobe, and trust its
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
97
|
-
|
|
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
|
-
|
|
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,
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
16
|
-
//
|
|
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 {
|
|
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
|
|
108
|
-
// snowglobe that makes it is on.
|
|
109
|
-
permittedSubtrees: new GeneralSubtrees(
|
|
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({
|
package/lib/commands/account.mjs
CHANGED
|
@@ -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,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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,
|
|
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.
|
|
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
|
}
|
package/lib/commands/down.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/commands/ls.mjs
CHANGED
|
@@ -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,
|
|
5
|
-
//
|
|
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
|
}
|
package/lib/commands/run.mjs
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// bin/snowglobe.mjs run [--app <name>] [--] <cmd> [args...]
|
|
2
2
|
//
|
|
3
|
-
// run runs a command with the snowglobe
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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 {
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
29
26
|
import { constants as os } from 'node:os';
|
|
30
|
-
import {
|
|
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)
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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.
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
});
|
package/lib/commands/up.mjs
CHANGED
|
@@ -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,
|
|
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 !==
|
|
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
|
|
175
|
-
|
|
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 =
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 });
|