fedipod-server 0.11.0 → 0.13.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 +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// run.mjs — starting and stopping: up, start, stop, status, https.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { identityHomes, rootOf, profileHome, recordLastUsed } from '../../home.mjs';
|
|
7
|
+
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
8
|
+
import { localFetch } from '../../../client/localapi.mjs';
|
|
9
|
+
import { flag, has, portFlag, PROFILE, AP_ROOT, DEFAULT_ISSUE, HOME, PORT, recordedAgent, recordedPort, recordAgent, useProfile, requireHandle, requireIdentity, ask, endAsking, agentOn, openBrowser } from '../context.mjs';
|
|
10
|
+
|
|
11
|
+
export async function up() {
|
|
12
|
+
// A signup page may have arranged an identity already: the installer records
|
|
13
|
+
// its parameters in first-run.json beside this script's package, and the
|
|
14
|
+
// carry-over outranks the last-used identity — whether this machine is fresh
|
|
15
|
+
// or already full of accounts, `npm start` right after the installer brings
|
|
16
|
+
// up the identity the signup arranged, beside any that exist. An explicitly
|
|
17
|
+
// named profile or home still means exactly what it says.
|
|
18
|
+
const firstRunFile = process.env.AP_FIRST_RUN
|
|
19
|
+
|| new URL('../../../../first-run.json', import.meta.url).pathname;
|
|
20
|
+
const explicitHome = !!(PROFILE || flag('home', null) || process.env.AP_HOME);
|
|
21
|
+
let firstRun = null;
|
|
22
|
+
if (!explicitHome) {
|
|
23
|
+
try { firstRun = JSON.parse(fs.readFileSync(firstRunFile, 'utf8')); } catch { /* none */ }
|
|
24
|
+
}
|
|
25
|
+
if (firstRun?.handle) {
|
|
26
|
+
const carried = String(firstRun.handle);
|
|
27
|
+
requireHandle(carried);
|
|
28
|
+
if (fs.existsSync(path.join(profileHome(AP_ROOT, carried), 'credential.json'))) {
|
|
29
|
+
console.error(`the signup carried the name "${carried}", but an identity with that name already exists here.`);
|
|
30
|
+
console.error('To attach the existing identity instead, use the signup page\'s Manage → Attach —');
|
|
31
|
+
console.error(`then remove ${firstRunFile} and run this again.`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
useProfile(carried);
|
|
35
|
+
process.env.AP_FIRST_RUN = firstRunFile;
|
|
36
|
+
} else if (DEFAULT_ISSUE && !identityHomes(AP_ROOT).some(h => fs.existsSync(path.join(h.dir, 'credential.json')))) {
|
|
37
|
+
// A fresh machine has no identity, and an identity is named after its
|
|
38
|
+
// handle, so there is no home to start in until that is asked. One
|
|
39
|
+
// question, the same one `setup` opens with — `npm start` stays the
|
|
40
|
+
// single command it was.
|
|
41
|
+
if (!process.stdin.isTTY) {
|
|
42
|
+
console.error('no identities yet — fedipod setup');
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
const first = await ask('handle (the name in your address; permanent)');
|
|
46
|
+
endAsking();
|
|
47
|
+
if (!first) { console.error('a handle is required'); process.exit(2); }
|
|
48
|
+
requireHandle(first);
|
|
49
|
+
useProfile(first);
|
|
50
|
+
} else {
|
|
51
|
+
requireIdentity();
|
|
52
|
+
}
|
|
53
|
+
if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
54
|
+
const preferred = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
|
|
55
|
+
const configured = fs.existsSync(path.join(HOME, 'credential.json'));
|
|
56
|
+
const { hostLabel } = await import(new URL('../../../../lib/shared/guard.mjs', import.meta.url));
|
|
57
|
+
|
|
58
|
+
let port = preferred;
|
|
59
|
+
let already = null;
|
|
60
|
+
if (!await portFree(preferred)) {
|
|
61
|
+
already = await agentOn(preferred);
|
|
62
|
+
// Ours already — nothing to start. The directory door yields to a real
|
|
63
|
+
// owner of its port. Anything else is simply not this port; walking on is
|
|
64
|
+
// what "occupied" has always meant here.
|
|
65
|
+
if (!already) {
|
|
66
|
+
const { yieldDirectory } = await import(new URL('../../../../lib/gateway/directory.mjs', import.meta.url));
|
|
67
|
+
if (!await yieldDirectory(preferred, { portFree })) {
|
|
68
|
+
port = await freePortFrom(preferred + 1);
|
|
69
|
+
// The shared helper returns null; the message is this command's to write.
|
|
70
|
+
if (port == null) throw new Error(`no free port between ${preferred + 1} and ${preferred + 51}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Both branches take the named origin: setup at the shared one would file the
|
|
76
|
+
// first login under localhost:<port>, and the identity is stuck with it.
|
|
77
|
+
// The profile name is the handle — that is what naming identities after them
|
|
78
|
+
// bought — so the named origin works on the very first run, before anything
|
|
79
|
+
// has been recorded.
|
|
80
|
+
const label = hostLabel(recordedAgent().handle || path.basename(HOME));
|
|
81
|
+
// One listener, one scheme: the port the agent was given is the port you
|
|
82
|
+
// browse, over https.
|
|
83
|
+
const originNow = () => `https://${label ? label + '.' : ''}localhost:${port}`;
|
|
84
|
+
|
|
85
|
+
if (already) {
|
|
86
|
+
console.log(`already running on port ${port}`);
|
|
87
|
+
} else {
|
|
88
|
+
// The handle too, not just the port: the agent seeds its allowed hosts
|
|
89
|
+
// from agent.json, and without it the named origin this command is about
|
|
90
|
+
// to open would be refused on the very first run.
|
|
91
|
+
recordAgent(recordedAgent().handle ? { port } : { port, handle: path.basename(HOME) });
|
|
92
|
+
const child = spawn(process.execPath, [new URL('../../../../run-agent.mjs', import.meta.url).pathname], {
|
|
93
|
+
detached: true, stdio: 'ignore',
|
|
94
|
+
env: { ...process.env, AP_HOME: HOME, AP_PORT: String(port) },
|
|
95
|
+
});
|
|
96
|
+
child.on('error', (e) => { console.error(`could not start the agent: ${e.message}`); process.exit(1); });
|
|
97
|
+
child.unref();
|
|
98
|
+
// Wait for it to answer before pointing a browser at it, or the first
|
|
99
|
+
// load races the listen and shows a connection error.
|
|
100
|
+
let up = false;
|
|
101
|
+
for (let i = 0; i < 60 && !up; i++) {
|
|
102
|
+
await new Promise(r => setTimeout(r, 250));
|
|
103
|
+
up = !!await agentOn(port);
|
|
104
|
+
}
|
|
105
|
+
if (!up) {
|
|
106
|
+
console.error(`the agent did not come up on port ${port} — see ${path.join(HOME, 'agent.log')}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
console.log(`agent running on port ${port} (pid in ${path.join(HOME, 'agent.pid')})`);
|
|
110
|
+
if (port !== preferred) console.log(`port ${preferred} was taken, so it moved to ${port}`);
|
|
111
|
+
}
|
|
112
|
+
// The client pinned to this actor, not the bare root — root serves vendored
|
|
113
|
+
// Phanpy with no account bound, which reads as the wrong app entirely.
|
|
114
|
+
const url = configured ? `${originNow()}/admin/client/` : `${originNow()}/admin/setup/`;
|
|
115
|
+
console.log(`\n ${url}\n`);
|
|
116
|
+
console.log(configured ? 'stop it with: fedipod stop'
|
|
117
|
+
: 'setup continues in the browser. Stop it with: fedipod stop');
|
|
118
|
+
if (!has('no-open')) openBrowser(url);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function start() {
|
|
123
|
+
requireIdentity();
|
|
124
|
+
// Starting one is what makes it the default — so `--profile group start`
|
|
125
|
+
// today is what a plain command means tomorrow. Recorded before the agent
|
|
126
|
+
// comes up, because a start that fails still expressed the intent.
|
|
127
|
+
if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
128
|
+
// This flag is read only by setup; it silently did nothing here, while the
|
|
129
|
+
// key guard's own error message told people to use it.
|
|
130
|
+
if (has('rotate-key')) {
|
|
131
|
+
console.error('start does not rotate keys — use: fedipod rotate-key');
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
if (portFlag()) recordAgent({ port: PORT }); // `start --port N` (or bare N) moves it for good
|
|
135
|
+
|
|
136
|
+
// Something already on the port? Offer to take it over rather than dying
|
|
137
|
+
// with "address in use" and leaving the user to hunt the process down.
|
|
138
|
+
const answering = await localFetch(HOME, PORT, `/status`)
|
|
139
|
+
.then(r => r.status).catch(() => null);
|
|
140
|
+
if (answering !== null) {
|
|
141
|
+
const pidFile = path.join(HOME, 'agent.pid');
|
|
142
|
+
let pid = null;
|
|
143
|
+
try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()) || null; } catch {}
|
|
144
|
+
const who = pid ? `pid ${pid}` : 'started elsewhere';
|
|
145
|
+
if (!has('replace') && !process.stdin.isTTY) {
|
|
146
|
+
console.error(`an agent is already running on port ${PORT} (${who}).`);
|
|
147
|
+
console.error('Stop it with `fedipod stop`, or start this one with --replace.');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
const ans = has('replace')
|
|
151
|
+
? 'y'
|
|
152
|
+
: await ask(`an agent is already running on port ${PORT} (${who}) — stop it and start this one? (y/n)`, 'y');
|
|
153
|
+
endAsking();
|
|
154
|
+
if (!/^y/i.test(ans)) { console.log('left the running agent alone'); process.exit(0); }
|
|
155
|
+
|
|
156
|
+
await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' }).catch(() => {});
|
|
157
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
|
|
158
|
+
const freed = await (async () => { // give it a few seconds
|
|
159
|
+
for (let i = 0; i < 30; i++) {
|
|
160
|
+
await new Promise(r => setTimeout(r, 300));
|
|
161
|
+
const still = await localFetch(HOME, PORT, `/status`).then(() => true).catch(() => false);
|
|
162
|
+
if (!still) return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
})();
|
|
166
|
+
if (!freed && pid) { try { process.kill(pid, 'SIGKILL'); } catch {} await new Promise(r => setTimeout(r, 500)); }
|
|
167
|
+
const clear = await localFetch(HOME, PORT, `/status`).then(() => false).catch(() => true);
|
|
168
|
+
if (!clear) {
|
|
169
|
+
console.error(`could not stop whatever is on port ${PORT}. Find it with: ss -tlnp | grep :${PORT}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log('previous agent stopped');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { startAgent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
176
|
+
const startHandle = recordedAgent().handle || null;
|
|
177
|
+
await startAgent({
|
|
178
|
+
home: HOME, port: PORT, name: flag('name') || null,
|
|
179
|
+
takeover: has('takeover'), // claim a lease whose holder is gone
|
|
180
|
+
handle: startHandle, // the named origin, before pod state is read
|
|
181
|
+
});
|
|
182
|
+
{
|
|
183
|
+
const { hostLabel } = await import(new URL('../../../../lib/shared/guard.mjs', import.meta.url));
|
|
184
|
+
const label = hostLabel(startHandle);
|
|
185
|
+
const named = label ? `https://${label}.localhost:${PORT}/` : null;
|
|
186
|
+
const plain = `https://localhost:${PORT}/`;
|
|
187
|
+
// One origin per identity is the point of the named form: a browser keeps
|
|
188
|
+
// its storage per origin, so two agents stop sharing one Phanpy login.
|
|
189
|
+
if (named) {
|
|
190
|
+
const pad = Math.max(named.length, plain.length);
|
|
191
|
+
console.log(`\n ${named.padEnd(pad)} <- browse it here`);
|
|
192
|
+
console.log(` ${plain.padEnd(pad)} <- the same agent, if that name will not resolve\n`);
|
|
193
|
+
} else {
|
|
194
|
+
console.log(`\n ${plain}\n`);
|
|
195
|
+
}
|
|
196
|
+
// Printed, not opened. `start` is run by supervisors and on every restart;
|
|
197
|
+
// a window arriving unasked over whatever you were doing is not a feature.
|
|
198
|
+
// `setup` opens one because that is the whole point of `setup`.
|
|
199
|
+
if (has('open')) openBrowser(named || plain);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function stop() {
|
|
204
|
+
requireIdentity();
|
|
205
|
+
const pidFile = path.join(HOME, 'agent.pid');
|
|
206
|
+
let pid = null;
|
|
207
|
+
try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); } catch {}
|
|
208
|
+
if (!pid) {
|
|
209
|
+
// The agent may still be listening even with no pidfile — an older
|
|
210
|
+
// build, a deleted file, or one started detached from any terminal
|
|
211
|
+
// (where Ctrl-C can never reach it). Ask it to stop over the API.
|
|
212
|
+
const asked = await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' })
|
|
213
|
+
.then(r => r.ok).catch(() => false);
|
|
214
|
+
if (asked) { console.log(`agent on port ${PORT} asked to stop`); process.exit(0); }
|
|
215
|
+
console.error(`no agent found: no pidfile at ${pidFile}, nothing answering on port ${PORT}.`);
|
|
216
|
+
console.error('If it is on another port, add --port N; a service install stops with:');
|
|
217
|
+
console.error(' systemctl --user stop fedipod-<name>');
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
try { process.kill(pid, 'SIGTERM'); } catch {
|
|
221
|
+
// Stale pidfile, but something may still hold the port.
|
|
222
|
+
const asked = await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' })
|
|
223
|
+
.then(r => r.ok).catch(() => false);
|
|
224
|
+
fs.rmSync(pidFile, { force: true });
|
|
225
|
+
console.log(asked ? `agent on port ${PORT} asked to stop (pidfile was stale)` : 'agent was not running (stale pidfile)');
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
const t0 = Date.now();
|
|
229
|
+
while (Date.now() - t0 < 10_000) {
|
|
230
|
+
await new Promise(r => setTimeout(r, 300));
|
|
231
|
+
try { process.kill(pid, 0); } catch { console.log('agent stopped'); process.exit(0); }
|
|
232
|
+
}
|
|
233
|
+
console.error('agent did not exit within 10s — kill it with: kill -9 ' + pid);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function status() {
|
|
238
|
+
requireIdentity();
|
|
239
|
+
try {
|
|
240
|
+
const res = await localFetch(HOME, PORT, `/status`);
|
|
241
|
+
console.log(JSON.stringify(await res.json(), null, 2));
|
|
242
|
+
} catch (e) {
|
|
243
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function https() {
|
|
249
|
+
// Agents serve https beside http with a certificate minted on this machine
|
|
250
|
+
// (never packaged — a shipped key would be one key for every install).
|
|
251
|
+
// Plain: self-signed, a client may ask once. --trust: a local CA signs it,
|
|
252
|
+
// and the CA certificate is what a trust store accepts — for clients that
|
|
253
|
+
// refuse self-signed outright.
|
|
254
|
+
const { ensureLocalTls, enableTrust, certPaths } = await import(new URL('../../../../lib/device/certs.mjs', import.meta.url));
|
|
255
|
+
const certDir = path.join(rootOf(HOME), 'certs'); // same resolution the agent uses
|
|
256
|
+
if (has('trust')) {
|
|
257
|
+
const t = enableTrust(certDir, { log: console.log }); // mints, signs, installs in NSS
|
|
258
|
+
console.log(`CA certificate: ${t.caCertPath}`);
|
|
259
|
+
if (process.platform === 'linux') {
|
|
260
|
+
console.log('for the system store, run:');
|
|
261
|
+
console.log(` sudo cp ${t.caCertPath} /usr/local/share/ca-certificates/fedipod-local-ca.crt && sudo update-ca-certificates`);
|
|
262
|
+
} else if (process.platform === 'darwin') {
|
|
263
|
+
console.log('to trust it system-wide, run:');
|
|
264
|
+
console.log(` sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${t.caCertPath}`);
|
|
265
|
+
} else if (process.platform === 'win32') {
|
|
266
|
+
console.log('to trust it, run:');
|
|
267
|
+
console.log(` certutil -addstore -user Root "${t.caCertPath}"`);
|
|
268
|
+
}
|
|
269
|
+
console.log('restart your agents to serve the CA-signed certificate');
|
|
270
|
+
} else {
|
|
271
|
+
const tls = ensureLocalTls(certDir, { log: console.log });
|
|
272
|
+
const paths2 = certPaths(certDir);
|
|
273
|
+
console.log(`certificate: ${paths2.cert}`);
|
|
274
|
+
console.log(` ${tls.trust ? 'signed by the local CA' : 'self-signed'}, ${tls.expiresInDays} days left`);
|
|
275
|
+
console.log('agents serve https on the port they were given — e.g. https://localhost:8030');
|
|
276
|
+
console.log('strict clients: `fedipod https --trust` mints a local CA your trust store can accept');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// service.mjs — the machine's service manager and the update: update,
|
|
2
|
+
// install-service, uninstall-service.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { identityHomes } from '../../home.mjs';
|
|
8
|
+
import { portFree } from '../../ports.mjs';
|
|
9
|
+
import { localFetch } from '../../../client/localapi.mjs';
|
|
10
|
+
import { cmd, has, AP_ROOT, HOME, agentOn } from '../context.mjs';
|
|
11
|
+
|
|
12
|
+
export async function update() {
|
|
13
|
+
// Pull the latest published FediPod into this checkout and restart the
|
|
14
|
+
// agents — what re-running the installer does, as one command.
|
|
15
|
+
const { checkLatest, runUpdate, restartAgents } = await import(new URL('../../../../lib/device/update.mjs', import.meta.url));
|
|
16
|
+
const u = await checkLatest();
|
|
17
|
+
if (u && !u.available) console.log(`already current: ${u.current}`);
|
|
18
|
+
const r = runUpdate({ log: console.log });
|
|
19
|
+
if (!r.ok) { console.error(r.note); process.exit(1); }
|
|
20
|
+
console.log(r.note);
|
|
21
|
+
if (restartAgents({ log: console.log }) === 'self') {
|
|
22
|
+
console.log('no managed agents found — restart any running agent to serve the new version');
|
|
23
|
+
}
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function service() {
|
|
28
|
+
const { execFileSync } = await import('node:child_process');
|
|
29
|
+
const runAgentPath = new URL('../../../../run-agent.mjs', import.meta.url).pathname;
|
|
30
|
+
const sh = (file, a) => { try { execFileSync(file, a, { stdio: 'pipe' }); return true; } catch { return false; } };
|
|
31
|
+
// Every identity with a credential gets a service of its own: a stopped
|
|
32
|
+
// actor's pod goes on collecting deliveries, so "installed" means ALL of
|
|
33
|
+
// them start at boot, each on its recorded port.
|
|
34
|
+
const identities = [];
|
|
35
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
36
|
+
if (!fs.existsSync(path.join(dir, 'credential.json'))) continue;
|
|
37
|
+
let rec = {};
|
|
38
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch { /* no record yet */ }
|
|
39
|
+
if (!Number(rec.port)) {
|
|
40
|
+
console.log(`${name}: no recorded port — start it once (\`up --profile ${name}\`), then re-run install-service`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
identities.push({ name, dir, port: Number(rec.port), handle: rec.handle || name });
|
|
44
|
+
}
|
|
45
|
+
if (cmd === 'install-service' && !identities.length) {
|
|
46
|
+
console.error('no identities with a credential and a recorded port — nothing to install');
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
// Graceful handover: an identity already running outside the service is
|
|
50
|
+
// stopped through its own /shutdown so the unit can take the port.
|
|
51
|
+
const handOver = async ({ name, port }) => {
|
|
52
|
+
if (!await agentOn(port)) return;
|
|
53
|
+
await localFetch(HOME, port, `/shutdown`, { method: 'POST' }).catch(() => {});
|
|
54
|
+
for (let i = 0; i < 20 && !await portFree(port); i++) await new Promise(r => setTimeout(r, 250));
|
|
55
|
+
console.log(`${name}: was running detached — stopped for the service to take over`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
const unitDir = path.join(os.homedir(), '.config/systemd/user');
|
|
60
|
+
const unitOf = (name) => `fedipod-${name}.service`;
|
|
61
|
+
// Old shapes are cleared on both paths: units under the pre-rename names
|
|
62
|
+
// (activitypod, solid-activitypub), single-identity units, and any
|
|
63
|
+
// per-identity unit for an identity that no longer exists here.
|
|
64
|
+
const dropOld = () => {
|
|
65
|
+
const keep = new Set(cmd === 'install-service' ? identities.map(i => unitOf(i.name)) : []);
|
|
66
|
+
let units = [];
|
|
67
|
+
try { units = fs.readdirSync(unitDir).filter(u => /^(activitypod|solid-activitypub|fedipod)(-.+)?\.service$/.test(u)); } catch { /* no unit dir */ }
|
|
68
|
+
for (const u of units) {
|
|
69
|
+
if (keep.has(u)) continue;
|
|
70
|
+
sh('systemctl', ['--user', 'disable', '--now', u]);
|
|
71
|
+
fs.rmSync(path.join(unitDir, u), { force: true });
|
|
72
|
+
console.log(`removed ${u}`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
if (cmd === 'uninstall-service') {
|
|
76
|
+
dropOld();
|
|
77
|
+
sh('systemctl', ['--user', 'daemon-reload']);
|
|
78
|
+
console.log('service(s) removed');
|
|
79
|
+
} else {
|
|
80
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
81
|
+
dropOld();
|
|
82
|
+
for (const id of identities) {
|
|
83
|
+
fs.writeFileSync(path.join(unitDir, unitOf(id.name)), `[Unit]
|
|
84
|
+
Description=FediPod agent — ${id.handle}
|
|
85
|
+
After=network-online.target
|
|
86
|
+
|
|
87
|
+
[Service]
|
|
88
|
+
ExecStart=${process.execPath} ${runAgentPath}
|
|
89
|
+
Environment=AP_HOME=${id.dir}
|
|
90
|
+
Environment=AP_PORT=${id.port}
|
|
91
|
+
Restart=on-failure
|
|
92
|
+
RestartSec=30
|
|
93
|
+
# A crash loop must not become a request loop against the pod.
|
|
94
|
+
StartLimitIntervalSec=600
|
|
95
|
+
StartLimitBurst=5
|
|
96
|
+
|
|
97
|
+
[Install]
|
|
98
|
+
WantedBy=default.target
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
sh('systemctl', ['--user', 'daemon-reload']);
|
|
102
|
+
sh('loginctl', ['enable-linger', os.userInfo().username]); // keep running while logged out
|
|
103
|
+
for (const id of identities) {
|
|
104
|
+
sh('systemctl', ['--user', 'enable', unitOf(id.name)]);
|
|
105
|
+
await handOver(id);
|
|
106
|
+
if (await portFree(id.port)) {
|
|
107
|
+
sh('systemctl', ['--user', 'start', unitOf(id.name)]);
|
|
108
|
+
console.log(`${id.handle}: installed, enabled and started on port ${id.port}`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(`${id.handle}: installed + enabled (starts at next boot). Port ${id.port} is held by something that is not ours — free it, then: systemctl --user start ${unitOf(id.name)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.log('logs: journalctl --user -u fedipod-<name> -f');
|
|
114
|
+
}
|
|
115
|
+
} else if (process.platform === 'darwin') {
|
|
116
|
+
const agents = path.join(os.homedir(), 'Library/LaunchAgents');
|
|
117
|
+
const plistOf = (name) => path.join(agents, `net.fedipod.${name}.agent.plist`);
|
|
118
|
+
// Old shapes are cleared on both paths: plists under the pre-rename names
|
|
119
|
+
// (net.activitypod, net.solid-activitypub), single-identity plists, and any
|
|
120
|
+
// per-identity plist for an identity that no longer exists here.
|
|
121
|
+
const dropOld = () => {
|
|
122
|
+
const keep = new Set(cmd === 'install-service' ? identities.map(i => plistOf(i.name)) : []);
|
|
123
|
+
let plists = [];
|
|
124
|
+
try {
|
|
125
|
+
plists = fs.readdirSync(agents)
|
|
126
|
+
.filter(f => /^net\.(activitypod|solid-activitypub|fedipod)(\..+)?\.agent\.plist$/.test(f))
|
|
127
|
+
.map(f => path.join(agents, f));
|
|
128
|
+
} catch { /* no LaunchAgents dir */ }
|
|
129
|
+
for (const p of plists) {
|
|
130
|
+
if (keep.has(p)) continue;
|
|
131
|
+
sh('launchctl', ['unload', p]);
|
|
132
|
+
fs.rmSync(p, { force: true });
|
|
133
|
+
console.log(`removed ${path.basename(p)}`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
if (cmd === 'uninstall-service') {
|
|
137
|
+
dropOld();
|
|
138
|
+
console.log('service(s) removed');
|
|
139
|
+
} else {
|
|
140
|
+
fs.mkdirSync(agents, { recursive: true });
|
|
141
|
+
dropOld();
|
|
142
|
+
for (const id of identities) {
|
|
143
|
+
const plist = plistOf(id.name);
|
|
144
|
+
sh('launchctl', ['unload', plist]); // replacing our own older copy
|
|
145
|
+
fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
146
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
147
|
+
<plist version="1.0"><dict>
|
|
148
|
+
<key>Label</key><string>net.fedipod.${id.name}.agent</string>
|
|
149
|
+
<key>ProgramArguments</key><array>
|
|
150
|
+
<string>${process.execPath}</string><string>${runAgentPath}</string>
|
|
151
|
+
</array>
|
|
152
|
+
<key>EnvironmentVariables</key><dict>
|
|
153
|
+
<key>AP_HOME</key><string>${id.dir}</string>
|
|
154
|
+
<key>AP_PORT</key><string>${id.port}</string>
|
|
155
|
+
</dict>
|
|
156
|
+
<key>RunAtLoad</key><true/>
|
|
157
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
158
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
159
|
+
</dict></plist>
|
|
160
|
+
`);
|
|
161
|
+
await handOver(id);
|
|
162
|
+
sh('launchctl', ['load', plist]);
|
|
163
|
+
console.log(`${id.handle}: installed and loaded (starts at login)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (process.platform === 'win32') {
|
|
167
|
+
// schtasks is scriptable, but this path is UNTESTED here (no Windows
|
|
168
|
+
// machine); the equivalent command is printed either way so a failure
|
|
169
|
+
// is actionable rather than mysterious.
|
|
170
|
+
const taskOf = (name) => `fedipod-${name}`;
|
|
171
|
+
// Pre-rename names, single-identity and per-identity, cleared on both paths.
|
|
172
|
+
const dropOld = () => {
|
|
173
|
+
sh('schtasks', ['/delete', '/tn', 'activitypod', '/f']);
|
|
174
|
+
sh('schtasks', ['/delete', '/tn', 'solid-activitypub', '/f']);
|
|
175
|
+
for (const id of identities) sh('schtasks', ['/delete', '/tn', `solid-activitypub-${id.name}`, '/f']);
|
|
176
|
+
};
|
|
177
|
+
if (cmd === 'uninstall-service') {
|
|
178
|
+
dropOld();
|
|
179
|
+
for (const id of identities) sh('schtasks', ['/delete', '/tn', taskOf(id.name), '/f']);
|
|
180
|
+
console.log('scheduled task(s) removed');
|
|
181
|
+
} else {
|
|
182
|
+
dropOld();
|
|
183
|
+
for (const id of identities) {
|
|
184
|
+
const tr = `"${process.execPath}" "${runAgentPath}"`;
|
|
185
|
+
const made = sh('schtasks', ['/create', '/tn', taskOf(id.name), '/tr', tr, '/sc', 'onlogon', '/rl', 'limited', '/f']);
|
|
186
|
+
if (made) {
|
|
187
|
+
console.log(`${id.handle}: scheduled task created — starts at log on (untested on Windows; please report)`);
|
|
188
|
+
console.log(` set AP_HOME=${id.dir} and AP_PORT=${id.port} in the task's environment`);
|
|
189
|
+
} else {
|
|
190
|
+
console.log(`${id.handle}: could not create the task automatically. Run this in an elevated prompt:`);
|
|
191
|
+
console.log(` schtasks /create /tn ${taskOf(id.name)} /tr ${tr} /sc onlogon /rl limited /f`);
|
|
192
|
+
console.log(` with AP_HOME=${id.dir} AP_PORT=${id.port}.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else if (process.platform === 'android' || process.env.PREFIX?.includes('com.termux')) {
|
|
197
|
+
// Android has no user service manager: running at boot needs the
|
|
198
|
+
// separate termux-boot app, supervision needs the termux-services
|
|
199
|
+
// package. Neither can be installed from here, so print the recipe.
|
|
200
|
+
// The agent is designed for this: whatever Android kills, the pod
|
|
201
|
+
// buffered, and the next start catches up.
|
|
202
|
+
if (cmd === 'uninstall-service') {
|
|
203
|
+
console.log('Termux: remove ~/.termux/boot/fedipod.sh (solid-activitypub.sh or activitypod.sh on an older install, and `sv-disable` the matching service if you used termux-services).');
|
|
204
|
+
} else {
|
|
205
|
+
const boot = path.join(os.homedir(), '.termux/boot');
|
|
206
|
+
console.log('Android/Termux has no service manager. To start at boot:');
|
|
207
|
+
console.log(' 1. install the Termux:Boot app (F-Droid), open it once');
|
|
208
|
+
console.log(` 2. mkdir -p ${boot} && cat > ${boot}/fedipod.sh <<'EOF'`);
|
|
209
|
+
console.log('#!/data/data/com.termux/files/usr/bin/sh');
|
|
210
|
+
console.log('termux-wake-lock');
|
|
211
|
+
for (const id of identities) console.log(`AP_HOME=${id.dir} AP_PORT=${id.port} ${process.execPath} ${runAgentPath} &`);
|
|
212
|
+
console.log('EOF');
|
|
213
|
+
console.log(` 3. chmod +x ${boot}/fedipod.sh`);
|
|
214
|
+
console.log('\nWithout Termux:Boot, run `termux-wake-lock` then `fedipod run` —');
|
|
215
|
+
console.log('anything Android kills is buffered on the pod and catches up next start.');
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`no service integration for platform "${process.platform}". Run each yourself with:`);
|
|
219
|
+
for (const id of identities) console.log(` AP_HOME=${id.dir} AP_PORT=${id.port} ${process.execPath} ${runAgentPath}`);
|
|
220
|
+
}
|
|
221
|
+
}
|