@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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +148 -2
- package/bin/snowglobe.mjs +71 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +40 -0
- package/lib/commands/env.mjs +146 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/ls.mjs +72 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +301 -0
- package/lib/config.mjs +514 -0
- package/lib/env.mjs +526 -0
- package/lib/hosts.mjs +36 -0
- package/lib/prebuilt.mjs +56 -0
- package/lib/render.mjs +601 -0
- package/lib/settings.mjs +156 -0
- package/lib/state.mjs +109 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +179 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The names an app's settings are built from: `${pds.alpha.url}` in snowglobe.yml's apps[].env.
|
|
2
|
+
// Pure: a checked globe and a name in, a value out, or the reason the file has no such name. The
|
|
3
|
+
// names and the reasons for them: OKF projects/atmo-events/2026-10-21-snowglobe-app-settings-design.md.
|
|
4
|
+
//
|
|
5
|
+
// The rule: the file's own section, then the name you gave it, then the field. Most values come from
|
|
6
|
+
// the file alone. A few exist only once the globe is up (an account's DID and password, the lexicon
|
|
7
|
+
// authority's DID, the CA, the mail page): `later` says what they are, and check, which has no state
|
|
8
|
+
// to look in, passes a stand-in.
|
|
9
|
+
|
|
10
|
+
const LATER = 'a value known only after snowglobe up';
|
|
11
|
+
|
|
12
|
+
// The secrets: left out of snowglobe env unless asked for.
|
|
13
|
+
export const isSecret = (name) => /^accounts\.[^.]+\.password$/.test(name);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `text` split at its ${name} placeholders: { parts } where a part is { text } or { name }, or
|
|
17
|
+
* { error }. `$$` is a literal `$`; a `$` that starts neither passes through as written.
|
|
18
|
+
*/
|
|
19
|
+
export function parseValue(text) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
let lit = '';
|
|
22
|
+
for (let i = 0; i < text.length; ) {
|
|
23
|
+
if (text[i] === '$' && text[i + 1] === '$') {
|
|
24
|
+
lit += '$';
|
|
25
|
+
i += 2;
|
|
26
|
+
} else if (text[i] === '$' && text[i + 1] === '{') {
|
|
27
|
+
const end = text.indexOf('}', i + 2);
|
|
28
|
+
if (end === -1) return { error: 'a ${ with no closing } (write $${ for a literal ${)' };
|
|
29
|
+
const name = text.slice(i + 2, end).trim();
|
|
30
|
+
if (!name) return { error: 'an empty ${}' };
|
|
31
|
+
if (lit) parts.push({ text: lit });
|
|
32
|
+
lit = '';
|
|
33
|
+
parts.push({ name });
|
|
34
|
+
i = end + 1;
|
|
35
|
+
} else lit += text[i++];
|
|
36
|
+
}
|
|
37
|
+
if (lit) parts.push({ text: lit });
|
|
38
|
+
return { parts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const list = (xs) => (xs.length ? xs.join(', ') : 'none');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The value of `name` in `globe` (a checked file): { value }, or { error } saying what is wrong
|
|
45
|
+
* with the name. `later(name)` gives the names known only after up (it returns undefined when the
|
|
46
|
+
* state has no value yet); without it they come back as { later: true }, which is all check needs.
|
|
47
|
+
*/
|
|
48
|
+
export function lookup(globe, name, later) {
|
|
49
|
+
const D = globe.domain;
|
|
50
|
+
const [head, ...rest] = name.split('.');
|
|
51
|
+
const fail = (error) => ({ error });
|
|
52
|
+
const after = (what = name) => {
|
|
53
|
+
if (!later) return { later: true };
|
|
54
|
+
const value = later(what);
|
|
55
|
+
return value === undefined ? fail(`${name} is ${LATER}; run snowglobe up first`) : { value };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A server of a section, by the name the file gave it: <section>.<name>.<field>.
|
|
59
|
+
const server = (kind, items, fields) => {
|
|
60
|
+
const [n, field, ...more] = rest;
|
|
61
|
+
if (n === undefined) return fail(`${head} needs a name: ${head}.<name>.<field> (${list(items.map((i) => i.name))})`);
|
|
62
|
+
const item = items.find((i) => i.name === n);
|
|
63
|
+
if (!item) return fail(`${head}.${n} is not ${kind} in this file: ${list(items.map((i) => i.name))}`);
|
|
64
|
+
if (field === undefined || more.length || !Object.hasOwn(fields, field))
|
|
65
|
+
return fail(`${name} is not a value of ${head}.${n}; it has ${list(Object.keys(fields).map((f) => `${head}.${n}.${f}`))}`);
|
|
66
|
+
return { value: fields[field](item) };
|
|
67
|
+
};
|
|
68
|
+
const host = (i) => `${i.name}.${D}`;
|
|
69
|
+
const web = {
|
|
70
|
+
url: (i) => `https://${host(i)}`,
|
|
71
|
+
host,
|
|
72
|
+
did: (i) => `did:web:${host(i)}`,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
switch (head) {
|
|
76
|
+
case 'domain':
|
|
77
|
+
case 'globe':
|
|
78
|
+
return rest.length ? fail(`${name} is not a name; ${head} has no fields`) : { value: head === 'domain' ? D : globe.globe };
|
|
79
|
+
case 'page':
|
|
80
|
+
return rest.join('.') === 'url' ? { value: `https://${D}/` } : fail(`${name} is not a name; the page has page.url`);
|
|
81
|
+
case 'pds':
|
|
82
|
+
return server('a PDS', globe.pds, web);
|
|
83
|
+
case 'grouphosts':
|
|
84
|
+
return server('a group host', globe.grouphosts, web);
|
|
85
|
+
case 'apps':
|
|
86
|
+
return server('an app', globe.apps, { url: (a) => `https://${a.name}.app.${D}`, host: (a) => `${a.name}.app.${D}` });
|
|
87
|
+
case 'services': {
|
|
88
|
+
const [svc, field, ...more] = rest;
|
|
89
|
+
const svcs = {
|
|
90
|
+
plc: { url: `https://plc.${D}` },
|
|
91
|
+
relay: { url: `https://relay.${D}`, ws: `wss://relay.${D}` },
|
|
92
|
+
jetstream: { url: `https://jetstream.${D}`, ws: `wss://jetstream.${D}/subscribe` },
|
|
93
|
+
slingshot: { url: `https://slingshot.${D}` },
|
|
94
|
+
pdsls: { url: `https://pdsls.${D}` },
|
|
95
|
+
mail: { url: null },
|
|
96
|
+
tap: { url: null },
|
|
97
|
+
};
|
|
98
|
+
if (!svc || !Object.hasOwn(svcs, svc)) return fail(`${svc ? `services.${svc}` : name} is not a service with an address: ${list(Object.keys(svcs))}`);
|
|
99
|
+
if (field === undefined || more.length || !Object.hasOwn(svcs[svc], field))
|
|
100
|
+
return fail(`${name} is not a value of services.${svc}; it has ${list(Object.keys(svcs[svc]).map((f) => `services.${svc}.${f}`))}`);
|
|
101
|
+
if (svc !== 'plc' && svc !== 'mail' && !globe.services[svc]) return fail(`services.${svc} is off in this file, so it has no address`);
|
|
102
|
+
return svc === 'mail' || svc === 'tap' ? after() : { value: svcs[svc][field] };
|
|
103
|
+
}
|
|
104
|
+
case 'accounts': {
|
|
105
|
+
if (rest.length === 1 && rest[0] === 'file') return after();
|
|
106
|
+
const [n, field, ...more] = rest;
|
|
107
|
+
if (n === undefined) return fail(`accounts needs a name: accounts.<name>.<field> (${list(globe.accounts.map((a) => a.name))})`);
|
|
108
|
+
const a = globe.accounts.find((x) => x.name === n);
|
|
109
|
+
if (!a) return fail(`accounts.${n} is not an account in this file: ${list(globe.accounts.map((x) => x.name))}`);
|
|
110
|
+
const fields = ['handle', 'pds', 'did', 'password'];
|
|
111
|
+
if (!fields.includes(field) || more.length) return fail(`${name} is not a value of accounts.${n}; it has ${list(fields.map((f) => `accounts.${n}.${f}`))}`);
|
|
112
|
+
if (field === 'handle') return { value: a.handle };
|
|
113
|
+
if (field === 'pds') return { value: `https://${a.pds}.${D}` };
|
|
114
|
+
return after();
|
|
115
|
+
}
|
|
116
|
+
case 'lexicons': {
|
|
117
|
+
if (rest[0] !== 'authority' || rest.length !== 2 || !['did', 'handle'].includes(rest[1]))
|
|
118
|
+
return fail(`${name} is not a name; the lexicon authority has lexicons.authority.did and lexicons.authority.handle`);
|
|
119
|
+
if (rest[1] === 'did') return after();
|
|
120
|
+
const p = globe.pds.find((x) => x.name === globe.lexicons.authority);
|
|
121
|
+
return { value: p?.bare_handles ? `lex-authority.${D}` : `lex-authority.${p?.name}.${D}` };
|
|
122
|
+
}
|
|
123
|
+
case 'ca':
|
|
124
|
+
return rest.join('.') === 'file' ? after() : fail(`${name} is not a name; the certificate authority has ca.file`);
|
|
125
|
+
default:
|
|
126
|
+
return fail(`${name} is not a name. Sections: domain, globe, page.url, pds.<name>, grouphosts.<name>, apps.<name>, services.<name>, accounts.<name>, accounts.file, lexicons.authority, ca.file`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The problems with the names in every apps[].env of `globe`, as [where, message] pairs. */
|
|
131
|
+
export function checkSettings(globe) {
|
|
132
|
+
const problems = [];
|
|
133
|
+
if (globe.tap?.webhook) {
|
|
134
|
+
const { parts, error } = parseValue(globe.tap.webhook);
|
|
135
|
+
if (error) problems.push(['services.tap.webhook', error]);
|
|
136
|
+
else
|
|
137
|
+
for (const part of parts) {
|
|
138
|
+
const found = part.name && lookup(globe, part.name);
|
|
139
|
+
if (found?.error) problems.push(['services.tap.webhook', found.error]);
|
|
140
|
+
else if (found?.later) problems.push(['services.tap.webhook', `${part.name} is known only after snowglobe up, so Tap cannot be given it`]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
globe.apps.forEach((app, i) => {
|
|
144
|
+
for (const [key, text] of Object.entries(app.env)) {
|
|
145
|
+
const at = `apps[${i}].env.${key}`;
|
|
146
|
+
const { parts, error } = parseValue(text);
|
|
147
|
+
if (error) problems.push([at, error]);
|
|
148
|
+
else
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
const found = part.name && lookup(globe, part.name);
|
|
151
|
+
if (found?.error) problems.push([at, found.error]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return problems;
|
|
156
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Which snowglobe a command acts on, and where its state is: the snowglobe.yml in the folder you
|
|
2
|
+
// are in or the nearest one above (else SG_FILE, else the default globe at the repo root), the
|
|
3
|
+
// `globe:` name in it, and from that name the state folder (<state root>/<name>, lib/env.mjs
|
|
4
|
+
// stateRoot) and the compose project (the same name, unless SG_PROJECT is set). The state
|
|
5
|
+
// folder records which file owns it, so two files that say `globe: t1` cannot share one folder,
|
|
6
|
+
// or one compose project.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { globeName } from './config.mjs';
|
|
11
|
+
import { ROOT, stateRoot } from './env.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The file a command reads when it is given none: SG_FILE, else ./snowglobe.yml, else the first
|
|
15
|
+
* snowglobe.yml in a folder above this one, else the default globe at the repo root. A file in
|
|
16
|
+
* the folder itself is named as 'snowglobe.yml'; one above, by its absolute path. With
|
|
17
|
+
* allowDefault false (a command that starts something), finding none is an error.
|
|
18
|
+
*/
|
|
19
|
+
export function findFile({ file, env = process.env, cwd = process.cwd(), allowDefault = true } = {}) {
|
|
20
|
+
if (file) return file;
|
|
21
|
+
if (env.SG_FILE) return env.SG_FILE;
|
|
22
|
+
const here = resolve(cwd);
|
|
23
|
+
for (let dir = here; ; dir = dirname(dir)) {
|
|
24
|
+
if (existsSync(join(dir, 'snowglobe.yml'))) return dir === here ? 'snowglobe.yml' : join(dir, 'snowglobe.yml');
|
|
25
|
+
if (dirname(dir) === dir) break;
|
|
26
|
+
}
|
|
27
|
+
if (!allowDefault)
|
|
28
|
+
throw new Error(
|
|
29
|
+
`no snowglobe.yml in ${here} or any folder above it, and SG_FILE is not set. ` +
|
|
30
|
+
'To run the default snowglobe on purpose: snowglobe up --default',
|
|
31
|
+
);
|
|
32
|
+
return join(ROOT, 'snowglobe.default.yml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The file that owns a state folder is recorded here, as an absolute path.
|
|
36
|
+
const OWNER = 'owner';
|
|
37
|
+
|
|
38
|
+
/** The file recorded as owning the state folder `state`, or '' when none is. */
|
|
39
|
+
export function ownerOf(state) {
|
|
40
|
+
const record = join(state, OWNER);
|
|
41
|
+
return existsSync(record) ? readFileSync(record, 'utf8').trim() : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The file `up` or `down` was pointed at, from its -f option and its one word, which is a file
|
|
46
|
+
* or the name of a snowglobe (snowglobe ls shows them): { file } for a file, { name } for a
|
|
47
|
+
* snowglobe, {} for neither given (the file found from the folder you are in). A word that is a
|
|
48
|
+
* file that exists is that file; else it is a name when a state folder is called that. Throws
|
|
49
|
+
* when it is neither, or both -f and a word are given.
|
|
50
|
+
*/
|
|
51
|
+
export function pointedAt({ file, word, env = process.env } = {}) {
|
|
52
|
+
if (file && word) throw new Error(`give the snowglobe once: -f ${file} or ${word}, not both`);
|
|
53
|
+
if (file) return { file };
|
|
54
|
+
if (!word) return {};
|
|
55
|
+
if (existsSync(word) && statSync(word).isFile()) return { file: word };
|
|
56
|
+
if (!/[\\/]/.test(word) && existsSync(join(stateRoot(env), word))) return { name: word };
|
|
57
|
+
throw new Error(`${word} is not a file, and no snowglobe is called that (snowglobe ls lists them)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The snowglobe called `name`, as session returns it, for a command that stops or reads one
|
|
62
|
+
* without its file: `path` is the file recorded as its owner, '' when that file is gone. Throws
|
|
63
|
+
* when no state folder has the name.
|
|
64
|
+
*/
|
|
65
|
+
export function byName(name, { env = process.env } = {}) {
|
|
66
|
+
const state = join(stateRoot(env), name);
|
|
67
|
+
if (!existsSync(state)) throw new Error(`no snowglobe is called ${name} (snowglobe ls lists them)`);
|
|
68
|
+
const owner = ownerOf(state);
|
|
69
|
+
return { path: owner && existsSync(owner) ? owner : '', name, state, project: env.SG_PROJECT || name };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Refuse a state folder whose recorded owner is another file that still exists. With `claim`, a
|
|
74
|
+
* folder with no owner, or whose owner is gone (moved or deleted), is recorded as this file's.
|
|
75
|
+
*/
|
|
76
|
+
function owned({ name, state }, file, claim) {
|
|
77
|
+
// Returns a line to tell the user when a file took over a name whose first file is gone.
|
|
78
|
+
const mine = realpathSync(resolve(file));
|
|
79
|
+
const record = join(state, OWNER);
|
|
80
|
+
const first = ownerOf(state);
|
|
81
|
+
if (first && first !== mine && existsSync(first))
|
|
82
|
+
throw new Error(
|
|
83
|
+
`the snowglobe ${name} belongs to ${first} (its state is in ${state}), and ${mine} also says globe: ${name}. ` +
|
|
84
|
+
`Give one of them another name in its globe: line, or delete ${record} to hand ${name} to the second file`,
|
|
85
|
+
);
|
|
86
|
+
if (claim && first !== mine) {
|
|
87
|
+
mkdirSync(state, { recursive: true });
|
|
88
|
+
writeFileSync(record, `${mine}\n`);
|
|
89
|
+
if (first) return `the file that owned the snowglobe ${name} (${first}) is gone; ${mine} owns its state now`;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The snowglobe a command acts on: { path (as given, or found), name, state, project }. Throws
|
|
96
|
+
* when the file has no usable `globe:` name or another file owns that name's state. `claim`
|
|
97
|
+
* records this file as the owner (up and render do, once the file checks out; the commands that
|
|
98
|
+
* only use a snowglobe do not).
|
|
99
|
+
*/
|
|
100
|
+
export function session({ file, env = process.env, cwd = process.cwd(), claim = false, allowDefault = true } = {}) {
|
|
101
|
+
const path = findFile({ file, env, cwd, allowDefault });
|
|
102
|
+
const name = globeName(resolve(path));
|
|
103
|
+
const found = { path, name, state: join(stateRoot(env), name), project: env.SG_PROJECT || name };
|
|
104
|
+
owned(found, path, claim);
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Record the session's file as the owner of its state folder (made if need be); a line to show when it took over from a file that is gone, else null. */
|
|
109
|
+
export const claimState = (found) => owned(found, found.path, true);
|
package/lib/trust.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Trusting the per-user CA (<state root>/ca/ca.crt) in the places a browser or a program looks, and
|
|
2
|
+
// undoing it. One function per platform builds the steps for it, and the command runs them the same
|
|
3
|
+
// way on every platform (lib/commands/trust.mjs).
|
|
4
|
+
//
|
|
5
|
+
// A step is { label, sudo, present(), add: [argv...], remove: [argv...], missing?, manual?, note? }:
|
|
6
|
+
// present() true when the CA is already in that store, false when not, null when it can't be told
|
|
7
|
+
// add/remove the commands that put the CA there or take it out, as argument lists (printed as
|
|
8
|
+
// they run, so nothing is hidden)
|
|
9
|
+
// sudo the commands need root; run through `sudo` unless we are root already
|
|
10
|
+
// missing a tool the step needs that is not installed (what to install); the step is printed,
|
|
11
|
+
// not run
|
|
12
|
+
// manual the step happens outside this machine's reach (Windows from WSL); it is printed
|
|
13
|
+
//
|
|
14
|
+
// The CA is limited by name constraints (lib/certs.mjs): it can only vouch for the snowglobe's
|
|
15
|
+
// domain, devnet.internal and plc.directory, and for no IP address. Linux (OpenSSL, NSS), Windows and
|
|
16
|
+
// macOS all enforce them, so trusting the CA machine-wide does not let it vouch for any other site.
|
|
17
|
+
// They stay as they are.
|
|
18
|
+
|
|
19
|
+
import { X509Certificate } from 'node:crypto';
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
// The CA's own name, from its subject: each CA has one of its own, which is also the nickname it
|
|
25
|
+
// goes by in the NSS and Windows stores.
|
|
26
|
+
export function caName(crt) {
|
|
27
|
+
const subject = new X509Certificate(readFileSync(crt)).subject;
|
|
28
|
+
const cn = subject.split('\n').find((l) => l.startsWith('CN='));
|
|
29
|
+
if (!cn) throw new Error(`${crt} has no common name`);
|
|
30
|
+
return cn.slice(3);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the steps run on: real by default; tests pass their own `exec`, `onPath`, `isRoot` and `home`. */
|
|
34
|
+
export function realContext(env = process.env) {
|
|
35
|
+
const onPath = (cmd) => {
|
|
36
|
+
for (const dir of (env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) if (dir && existsSync(join(dir, cmd))) return true;
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
platform: process.platform,
|
|
41
|
+
env,
|
|
42
|
+
home: env.HOME || env.USERPROFILE || '',
|
|
43
|
+
isRoot: typeof process.getuid === 'function' && process.getuid() === 0,
|
|
44
|
+
onPath,
|
|
45
|
+
// Runs argv; { status, stdout }. `capture` keeps the output (a check); otherwise the command talks to the terminal (a password prompt).
|
|
46
|
+
exec: (argv, { capture = false } = {}) => {
|
|
47
|
+
const r = spawnSync(argv[0], argv.slice(1), { env, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
|
|
48
|
+
return { status: r.error ? 127 : r.status, stdout: r.stdout ?? '' };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ok = (ctx, argv) => ctx.exec(argv, { capture: true }).status === 0;
|
|
54
|
+
|
|
55
|
+
/** True when the machine is Windows Subsystem for Linux (a Linux whose browser usually runs on Windows). */
|
|
56
|
+
export function isWsl(ctx) {
|
|
57
|
+
if (ctx.platform !== 'linux') return false;
|
|
58
|
+
if (ctx.wsl !== undefined) return ctx.wsl;
|
|
59
|
+
if (ctx.env.WSL_DISTRO_NAME || ctx.env.WSL_INTEROP) return true;
|
|
60
|
+
try {
|
|
61
|
+
return /microsoft|wsl/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- Linux ----------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// The system store: Debian's update-ca-certificates or Fedora's update-ca-trust. Read by curl,
|
|
70
|
+
// Node with --use-openssl-ca, Python, Go and the like.
|
|
71
|
+
function linuxOsStep(ca, ctx) {
|
|
72
|
+
const same = (file) => existsSync(file) && readFileSync(file, 'utf8') === readFileSync(ca, 'utf8');
|
|
73
|
+
if (ctx.onPath('update-ca-certificates')) {
|
|
74
|
+
const file = '/usr/local/share/ca-certificates/snowglobe-ca.crt';
|
|
75
|
+
return {
|
|
76
|
+
label: 'system store (update-ca-certificates)',
|
|
77
|
+
sudo: true,
|
|
78
|
+
present: () => same(file),
|
|
79
|
+
add: [['install', '-m', '644', ca, file], ['update-ca-certificates']],
|
|
80
|
+
remove: [['rm', '-f', file], ['update-ca-certificates', '--fresh']],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (ctx.onPath('update-ca-trust')) {
|
|
84
|
+
const file = '/etc/pki/ca-trust/source/anchors/snowglobe-ca.crt';
|
|
85
|
+
return {
|
|
86
|
+
label: 'system store (update-ca-trust)',
|
|
87
|
+
sudo: true,
|
|
88
|
+
present: () => same(file),
|
|
89
|
+
add: [['install', '-m', '644', ca, file], ['update-ca-trust', 'extract']],
|
|
90
|
+
remove: [['rm', '-f', file], ['update-ca-trust', 'extract']],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
label: 'system store',
|
|
95
|
+
sudo: true,
|
|
96
|
+
present: () => null,
|
|
97
|
+
add: [],
|
|
98
|
+
remove: [],
|
|
99
|
+
manual: true,
|
|
100
|
+
note: 'neither update-ca-certificates nor update-ca-trust is installed; add the CA to this system\'s store by its own tool',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One NSS database (Chrome, Edge and Chromium read ~/.pki/nssdb; each Firefox profile has its own).
|
|
105
|
+
// `create` makes an empty one when there is none (only the shared one is ever made).
|
|
106
|
+
function nssStep(label, dir, ca, nick, ctx, { create = false } = {}) {
|
|
107
|
+
const db = `sql:${dir}`;
|
|
108
|
+
const have = existsSync(join(dir, 'cert9.db'));
|
|
109
|
+
const step = {
|
|
110
|
+
label,
|
|
111
|
+
sudo: false,
|
|
112
|
+
present: () => (!ctx.onPath('certutil') ? null : have && ok(ctx, ['certutil', '-L', '-d', db, '-n', nick])),
|
|
113
|
+
add: [
|
|
114
|
+
...(have || !create ? [] : [['mkdir', '-p', dir], ['certutil', '-N', '-d', db, '--empty-password']]),
|
|
115
|
+
['certutil', '-A', '-d', db, '-t', 'C,,', '-n', nick, '-i', ca],
|
|
116
|
+
],
|
|
117
|
+
remove: [['certutil', '-D', '-d', db, '-n', nick]],
|
|
118
|
+
};
|
|
119
|
+
if (!ctx.onPath('certutil')) step.missing = 'certutil (Debian and Ubuntu: apt install libnss3-tools; Fedora: dnf install nss-tools)';
|
|
120
|
+
return step;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Where Firefox keeps profiles on Linux: its own folder, and the snap and flatpak ones.
|
|
124
|
+
function firefoxProfiles(home) {
|
|
125
|
+
const roots = [join(home, '.mozilla/firefox'), join(home, 'snap/firefox/common/.mozilla/firefox'), join(home, '.var/app/org.mozilla.firefox/.mozilla/firefox')];
|
|
126
|
+
return roots.flatMap((root) => {
|
|
127
|
+
if (!existsSync(root)) return [];
|
|
128
|
+
return readdirSync(root, { withFileTypes: true })
|
|
129
|
+
.filter((e) => e.isDirectory() && existsSync(join(root, e.name, 'cert9.db')))
|
|
130
|
+
.map((e) => join(root, e.name));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function linuxSteps(ca, nick, ctx) {
|
|
135
|
+
const steps = [linuxOsStep(ca, ctx), nssStep('Chrome, Edge, Chromium (~/.pki/nssdb)', join(ctx.home, '.pki/nssdb'), ca, nick, ctx, { create: true })];
|
|
136
|
+
// A snap Chromium keeps its own copy of the shared database.
|
|
137
|
+
const snap = join(ctx.home, 'snap/chromium/current/.pki/nssdb');
|
|
138
|
+
if (existsSync(join(snap, 'cert9.db'))) steps.push(nssStep('Chromium snap', snap, ca, nick, ctx));
|
|
139
|
+
for (const dir of firefoxProfiles(ctx.home)) steps.push(nssStep(`Firefox profile ${dir}`, dir, ca, nick, ctx));
|
|
140
|
+
return steps;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- WSL ------------------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
// The browser runs on Windows, whose user store a Linux program can't change silently (Windows
|
|
146
|
+
// asks you to confirm), so these are printed for you to run in PowerShell. The Linux steps still
|
|
147
|
+
// run, for what runs inside WSL.
|
|
148
|
+
function wslSteps(ca, nick, ctx) {
|
|
149
|
+
let winPath = ctx.exec(['wslpath', '-w', ca], { capture: true });
|
|
150
|
+
winPath = winPath.status === 0 ? winPath.stdout.trim() : '<the ca.crt above, as a Windows path (wslpath -w)>';
|
|
151
|
+
return [
|
|
152
|
+
...linuxSteps(ca, nick, ctx),
|
|
153
|
+
{
|
|
154
|
+
label: 'Windows user store (Chrome, Edge, Firefox on Windows)',
|
|
155
|
+
sudo: false,
|
|
156
|
+
manual: true,
|
|
157
|
+
present: () => null,
|
|
158
|
+
add: [['certutil', '-user', '-addstore', 'Root', winPath]],
|
|
159
|
+
remove: [['certutil', '-user', '-delstore', 'Root', nick]],
|
|
160
|
+
note: 'run this in PowerShell on Windows; Windows asks you to confirm',
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- macOS (not tried: no Mac was available when this was written) ------------------------------
|
|
166
|
+
|
|
167
|
+
function macSteps(ca, nick, ctx) {
|
|
168
|
+
const keychain = join(ctx.home, 'Library/Keychains/login.keychain-db');
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
label: 'login keychain (Chrome, Edge, Safari)',
|
|
172
|
+
sudo: false,
|
|
173
|
+
present: () => ok(ctx, ['security', 'find-certificate', '-c', nick, keychain]),
|
|
174
|
+
// macOS asks for your password or Touch ID itself.
|
|
175
|
+
add: [['security', 'add-trusted-cert', '-r', 'trustRoot', '-k', keychain, ca]],
|
|
176
|
+
remove: [['security', 'delete-certificate', '-c', nick, '-t', keychain]],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
label: 'Firefox',
|
|
180
|
+
sudo: false,
|
|
181
|
+
manual: true,
|
|
182
|
+
present: () => null,
|
|
183
|
+
add: [],
|
|
184
|
+
remove: [],
|
|
185
|
+
note: 'Firefox keeps its own list: Settings, Privacy & Security, View Certificates, Authorities, Import (remove it there too)',
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- Windows ----------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
function windowsSteps(ca, nick, ctx) {
|
|
193
|
+
return [
|
|
194
|
+
{
|
|
195
|
+
label: 'Windows user store (Chrome, Edge, Firefox)',
|
|
196
|
+
sudo: false,
|
|
197
|
+
present: () => ok(ctx, ['certutil', '-user', '-store', 'Root', nick]),
|
|
198
|
+
// Windows asks you to confirm; no administrator needed.
|
|
199
|
+
add: [['certutil', '-user', '-addstore', 'Root', ca]],
|
|
200
|
+
remove: [['certutil', '-user', '-delstore', 'Root', nick]],
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The steps for this platform, in order: [{ label, sudo, present, add, remove, ... }]. */
|
|
206
|
+
export function trustSteps(ca, ctx = realContext()) {
|
|
207
|
+
const nick = caName(ca);
|
|
208
|
+
if (ctx.platform === 'darwin') return macSteps(ca, nick, ctx);
|
|
209
|
+
if (ctx.platform === 'win32') return windowsSteps(ca, nick, ctx);
|
|
210
|
+
return isWsl(ctx) ? wslSteps(ca, nick, ctx) : linuxSteps(ca, nick, ctx);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const show = (argv) => argv.map((a) => (/[^\w@%+=:,./-]/.test(a) ? `'${a}'` : a)).join(' ');
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run (or, with dryRun, only print) the steps to add the CA or, with `remove`, to take it out.
|
|
217
|
+
* Every command is printed as it is run. Returns { done, failed, manual }: counts of steps taken
|
|
218
|
+
* (or already in place), steps that could not be (a missing tool, a refused sudo, a failed
|
|
219
|
+
* command, `noSudo`), and steps left for the user to run. Idempotent: a step whose store already
|
|
220
|
+
* has the CA (or, for remove, no longer has it) is skipped.
|
|
221
|
+
*/
|
|
222
|
+
export function applySteps(steps, { remove = false, dryRun = false, noSudo = false, ctx = realContext(), say = console.log } = {}) {
|
|
223
|
+
const out = { done: 0, failed: 0, manual: 0 };
|
|
224
|
+
const wrap = (step, argv) => (step.sudo && !ctx.isRoot ? ['sudo', ...argv] : argv);
|
|
225
|
+
for (const step of steps) {
|
|
226
|
+
const cmds = remove ? step.remove : step.add;
|
|
227
|
+
const verb = remove ? 'remove' : 'add';
|
|
228
|
+
say(`${step.label}${step.sudo && !ctx.isRoot ? ' [needs sudo]' : ''}`);
|
|
229
|
+
const print = (prefix) => cmds.forEach((c) => say(` ${prefix}${show(wrap(step, c))}`));
|
|
230
|
+
if (step.manual) {
|
|
231
|
+
out.manual++;
|
|
232
|
+
say(` for you to ${verb}${step.note ? ` (${step.note})` : ''}:`);
|
|
233
|
+
if (!cmds.length) continue;
|
|
234
|
+
print('$ ');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (step.missing) {
|
|
238
|
+
// Nothing to remove from a store that was never made.
|
|
239
|
+
if (remove && !existsNss(step)) {
|
|
240
|
+
say(' nothing to remove');
|
|
241
|
+
out.done++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
out.failed++;
|
|
245
|
+
say(` not done: needs ${step.missing}. Then run snowglobe trust${remove ? ' --remove' : ''} again; it would run:`);
|
|
246
|
+
print('$ ');
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const present = step.present();
|
|
250
|
+
if (remove ? present === false : present === true) {
|
|
251
|
+
say(remove ? ' not in this store; nothing to remove' : ' already trusted; nothing to add');
|
|
252
|
+
out.done++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (noSudo && step.sudo && !ctx.isRoot) {
|
|
256
|
+
out.failed++;
|
|
257
|
+
say(' skipped (--no-sudo). To do it yourself:');
|
|
258
|
+
print('$ ');
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
say(` would ${verb}:`);
|
|
263
|
+
print('$ ');
|
|
264
|
+
out.done++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (step.sudo && !ctx.isRoot && !ctx.onPath('sudo')) {
|
|
268
|
+
out.failed++;
|
|
269
|
+
say(' not done: sudo is not installed. Run these as root:');
|
|
270
|
+
cmds.forEach((c) => say(` # ${show(c)}`));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
let good = true;
|
|
274
|
+
for (const c of cmds) {
|
|
275
|
+
say(` $ ${show(wrap(step, c))}`);
|
|
276
|
+
if (ctx.exec(wrap(step, c)).status !== 0) {
|
|
277
|
+
good = false;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
say(good ? ` ${remove ? 'removed' : 'added'}` : ' failed');
|
|
282
|
+
good ? out.done++ : out.failed++;
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A step's own database exists, so a missing certutil leaves something behind to remove.
|
|
288
|
+
const existsNss = (step) => step.remove.some((c) => c.some((a) => typeof a === 'string' && a.startsWith('sql:') && existsSync(join(a.slice(4), 'cert9.db'))));
|
package/lib/up.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What snowglobe up decides without Docker: the app routes the file adds, whether the globe's
|
|
2
|
+
// PDSes clash with an app's handle ending, the env names the file's accounts keep their logins
|
|
3
|
+
// under. Pure: inputs in, text or lists out (lib/commands/up.mjs runs the globe).
|
|
4
|
+
|
|
5
|
+
const envName = (label) => label.toUpperCase().replace(/-/g, '_');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The routes in data/https/apps (`routes`) with each app the file runs locally (`fileApps`, the
|
|
9
|
+
* parsed globe's apps) set to the file's port and handle ending. A route the file does not name is
|
|
10
|
+
* kept, as `snowglobe app` made it. Throws when the file's ending is another route's.
|
|
11
|
+
*/
|
|
12
|
+
export function mergeRoutes(routes, fileApps) {
|
|
13
|
+
const out = [...routes];
|
|
14
|
+
for (const a of fileApps.filter((x) => x.local)) {
|
|
15
|
+
const route = { name: a.name, port: a.local.port, ending: a.local.handles ?? null };
|
|
16
|
+
const owner = route.ending && out.find((r) => r.ending === route.ending && r.name !== a.name);
|
|
17
|
+
if (owner)
|
|
18
|
+
throw new Error(`the file's app ${a.name} claims the handles under ${route.ending}, which already go to ${owner.name} (data/https/apps); snowglobe app --remove ${owner.name}`);
|
|
19
|
+
const i = out.findIndex((r) => r.name === a.name);
|
|
20
|
+
if (i === -1) out.push(route);
|
|
21
|
+
else out[i] = route;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The file's accounts ({ name, pds }), each with the env prefix its login is kept under in
|
|
28
|
+
* data/accounts.env: <NAME> on `home` (the PDS snowglobe account makes accounts on by default,
|
|
29
|
+
* where the base init's ALICE and BOB are), <NAME>_<PDS> on any other. A shell name cannot start
|
|
30
|
+
* with a digit, so 1st is _1ST. Throws when two accounts would share one.
|
|
31
|
+
*/
|
|
32
|
+
export function accountPrefixes(accounts, home) {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
return accounts.map(({ name, pds }) => {
|
|
35
|
+
const v = pds === home ? envName(name) : `${envName(name)}_${envName(pds)}`;
|
|
36
|
+
const prefix = /^[0-9]/.test(v) ? `_${v}` : v;
|
|
37
|
+
if (seen.has(prefix)) throw new Error(`${name}@${pds} and ${seen.get(prefix)} would both keep their logins as ${prefix}_*; rename one`);
|
|
38
|
+
seen.set(prefix, `${name}@${pds}`);
|
|
39
|
+
return { name, pds, prefix };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The first server of `pdsNames` (the PDSes and group hosts) named like the ending an app in
|
|
45
|
+
* `apps` claimed, as { pds, app }, or null. Such a server would take that app's handles: nginx
|
|
46
|
+
* keeps the first of two servers with one name, and the globe's come before the apps'.
|
|
47
|
+
*/
|
|
48
|
+
export function endingClash(pdsNames, apps) {
|
|
49
|
+
for (const pds of pdsNames) {
|
|
50
|
+
const app = apps.find((a) => a.ending === pds);
|
|
51
|
+
if (app) return { pds, app: app.name };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
package/lib/xrpc.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// XRPC calls to the globe's PDSes, for what an app makes on the https globe for itself: accounts
|
|
2
|
+
// (snowglobe account), invite codes (snowglobe invite) and lexicons in the globe's lexicon
|
|
3
|
+
// authority (snowglobe lexicons), each in lib/commands/. Each keeps its logins in a private env
|
|
4
|
+
// file, prints only what an app reads (a DID, an NSID) on stdout, and nothing secret anywhere: a
|
|
5
|
+
// password or code goes only to the file and to the PDS, in a request body, and an admin login or
|
|
6
|
+
// access token only in a request header.
|
|
7
|
+
|
|
8
|
+
import { adminPassword, snowglobeRequest } from './env.mjs';
|
|
9
|
+
|
|
10
|
+
// An XRPC call. Resolves to { ok, status, json } (json {} when the answer is not JSON).
|
|
11
|
+
export async function xrpc(base, nsid, { body, query, auth, root, state, env }) {
|
|
12
|
+
const url = new URL(`/xrpc/${nsid}`, base);
|
|
13
|
+
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v);
|
|
14
|
+
const headers = {};
|
|
15
|
+
if (auth) headers.Authorization = auth;
|
|
16
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
17
|
+
const res = await snowglobeRequest(url.href, {
|
|
18
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
19
|
+
headers,
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
root,
|
|
22
|
+
state,
|
|
23
|
+
env,
|
|
24
|
+
});
|
|
25
|
+
let json = {};
|
|
26
|
+
try {
|
|
27
|
+
json = JSON.parse(res.body);
|
|
28
|
+
} catch {}
|
|
29
|
+
return { ok: res.status >= 200 && res.status < 300, status: res.status, json };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// What a PDS said when it refused: its error name and message, never the rest of the answer.
|
|
33
|
+
export const why = ({ status, json }) => [json.error, json.message].filter(Boolean).join(' ') || `HTTP ${status}`;
|
|
34
|
+
|
|
35
|
+
// The admin password the PDSes were started with (lib/env.mjs adminPassword); no default, so a PDS
|
|
36
|
+
// that has another password is not sent a guess.
|
|
37
|
+
const adminAuth = (root, state, env) => {
|
|
38
|
+
const password = adminPassword({ root, state, env });
|
|
39
|
+
if (!password) throw new Error('no PDS admin password: run snowglobe up, which makes one, or set SG_PDS_ADMIN_PASSWORD to the one the PDSes were started with');
|
|
40
|
+
return `Basic ${Buffer.from(`admin:${password}`).toString('base64')}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// An invite code from a PDS's admin API.
|
|
44
|
+
export async function mintInvite(pdsUrl, useCount, { root, state, env }) {
|
|
45
|
+
const res = await xrpc(pdsUrl, 'com.atproto.server.createInviteCode', { body: { useCount }, auth: adminAuth(root, state, env), root, state, env });
|
|
46
|
+
if (!res.ok || typeof res.json.code !== 'string') throw new Error(`createInviteCode on ${pdsUrl} failed: ${why(res)}`);
|
|
47
|
+
return res.json.code;
|
|
48
|
+
}
|
|
49
|
+
|