nixamp 0.8.0 → 0.9.4

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/src/naming.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * A name and a certificate for this machine, from nixamp.com.
3
+ *
4
+ * The DNS keys never leave nixamp.com: a server signed in to an account asks
5
+ * for `<label>.<handle>.nixamp.com` and the site makes the record for the
6
+ * address the request came from, A and AAAA both. The certificate is one
7
+ * wildcard per handle, issued and renewed by the site, handed to the account's
8
+ * own servers over the authenticated API and kept on disk here so a restart
9
+ * serves https at once rather than after a round trip.
10
+ */
11
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ export interface Named {
15
+ host: string;
16
+ a: string;
17
+ aaaa: string;
18
+ }
19
+
20
+ export interface CertFiles {
21
+ cert: string;
22
+ key: string;
23
+ expiresAt: number;
24
+ /** The name on the certificate, e.g. `*.chovy.nixamp.com`. */
25
+ host: string;
26
+ }
27
+
28
+ /** A certificate this close to expiry is not worth serving: fetch a fresh one. */
29
+ const TOO_CLOSE_MS = 24 * 60 * 60 * 1000;
30
+
31
+ function bearer(token: string): Record<string, string> {
32
+ return { authorization: `Bearer ${token}`, "content-type": "application/json" };
33
+ }
34
+
35
+ /**
36
+ * Claim, or refresh, this machine's name.
37
+ *
38
+ * "auto" for both families: the site records whichever addresses this request
39
+ * arrived from, which is the only honest answer to "what is my public
40
+ * address" from behind a router. Null when refused or unreachable, and the
41
+ * reason is said rather than thrown: a server without a name still serves.
42
+ */
43
+ export async function claimName(
44
+ site: string,
45
+ token: string,
46
+ label: string,
47
+ say: (line: string) => void,
48
+ fetcher: typeof fetch = fetch,
49
+ ): Promise<Named | null> {
50
+ try {
51
+ const answer = await fetcher(`${site}/api/v1/dns/${encodeURIComponent(label)}`, {
52
+ method: "PUT",
53
+ headers: bearer(token),
54
+ body: JSON.stringify({ a: "auto", aaaa: "auto" }),
55
+ });
56
+ const body = (await answer.json().catch(() => ({}))) as {
57
+ name?: { host?: string; a?: string | null; aaaa?: string | null };
58
+ error?: string;
59
+ };
60
+ if (!answer.ok || !body.name?.host) {
61
+ say(`nixamp: ${site} would not name this machine: ${body.error ?? `answered ${answer.status}`}`);
62
+ return null;
63
+ }
64
+ return { host: body.name.host, a: body.name.a ?? "", aaaa: body.name.aaaa ?? "" };
65
+ } catch (error) {
66
+ say(`nixamp: could not reach ${site} to claim a name: ${(error as Error).message}`);
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The handle's certificate.
73
+ *
74
+ * The first one is issued while we wait: a wildcard by DNS challenge takes a
75
+ * couple of minutes, which is said once so the pause reads as work rather
76
+ * than a hang. After that it is a cached answer on the site's side.
77
+ */
78
+ export async function fetchCert(
79
+ site: string,
80
+ token: string,
81
+ opts: {
82
+ waitMs?: number;
83
+ everyMs?: number;
84
+ sleep?: (ms: number) => Promise<void>;
85
+ fetcher?: typeof fetch;
86
+ },
87
+ say: (line: string) => void,
88
+ ): Promise<CertFiles | null> {
89
+ const waitMs = opts.waitMs ?? 240_000;
90
+ const everyMs = opts.everyMs ?? 10_000;
91
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
92
+ const fetcher = opts.fetcher ?? fetch;
93
+ let waited = 0;
94
+ let announced = false;
95
+
96
+ for (;;) {
97
+ let answer: Response;
98
+ try {
99
+ answer = await fetcher(`${site}/api/v1/certs`, { headers: bearer(token) });
100
+ } catch (error) {
101
+ say(`nixamp: could not reach ${site} for a certificate: ${(error as Error).message}`);
102
+ return null;
103
+ }
104
+ const body = (await answer.json().catch(() => ({}))) as {
105
+ status?: string;
106
+ cert?: string;
107
+ key?: string;
108
+ expiresAt?: number;
109
+ host?: string;
110
+ error?: string;
111
+ };
112
+
113
+ if (answer.ok && body.status === "ready" && body.cert && body.key && body.host) {
114
+ return { cert: body.cert, key: body.key, expiresAt: body.expiresAt ?? 0, host: body.host };
115
+ }
116
+ if (answer.status === 202 || body.status === "issuing") {
117
+ if (!announced) {
118
+ announced = true;
119
+ const host = body.host ?? "your handle";
120
+ say(`Getting a certificate for ${host}… the first one takes a couple of minutes.`);
121
+ }
122
+ if (waited >= waitMs) {
123
+ say(`nixamp: ${site} is still issuing the certificate; serving http until it is ready.`);
124
+ return null;
125
+ }
126
+ await sleep(everyMs);
127
+ waited += everyMs;
128
+ continue;
129
+ }
130
+ say(`nixamp: ${site} could not issue a certificate: ${body.error ?? `answered ${answer.status}`}`);
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /** `*.chovy.nixamp.com` becomes `chovy.nixamp.com`, which is a filename. */
136
+ function fileStem(host: string): string {
137
+ return host.replace(/^\*\./, "").replace(/[^A-Za-z0-9.-]/g, "_");
138
+ }
139
+
140
+ /**
141
+ * Keep a certificate beside the keys. Private, because the key is what lets
142
+ * anybody be this server.
143
+ */
144
+ export function writeCertFiles(stateDir: string, files: CertFiles): { cert: string; key: string } {
145
+ const dir = join(stateDir, "tls");
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ chmodSync(dir, 0o700);
148
+ const stem = join(dir, fileStem(files.host));
149
+ const cert = `${stem}.cert.pem`;
150
+ const key = `${stem}.key.pem`;
151
+ writeFileSync(cert, files.cert, { mode: 0o600 });
152
+ writeFileSync(key, files.key, { mode: 0o600 });
153
+ chmodSync(cert, 0o600);
154
+ chmodSync(key, 0o600);
155
+ writeFileSync(`${stem}.json`, JSON.stringify({ host: files.host, expiresAt: files.expiresAt }), { mode: 0o600 });
156
+ return { cert, key };
157
+ }
158
+
159
+ /**
160
+ * The certificate from last time, if it is still good for more than a day.
161
+ * Anything closer to expiry is treated as absent so the next start fetches
162
+ * a fresh one rather than serving one that lapses overnight.
163
+ */
164
+ export function readCertFiles(
165
+ stateDir: string,
166
+ host: string,
167
+ ): { cert: string; key: string; expiresAt: number } | null {
168
+ const stem = join(stateDir, "tls", fileStem(host));
169
+ try {
170
+ const meta = JSON.parse(readFileSync(`${stem}.json`, "utf8")) as { expiresAt?: number };
171
+ const expiresAt = typeof meta.expiresAt === "number" ? meta.expiresAt : 0;
172
+ if (expiresAt - Date.now() < TOO_CLOSE_MS) return null;
173
+ const cert = readFileSync(`${stem}.cert.pem`, "utf8");
174
+ const key = readFileSync(`${stem}.key.pem`, "utf8");
175
+ if (!cert || !key) return null;
176
+ return { cert, key, expiresAt };
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * What this machine calls itself in DNS: the name it was given, else the
184
+ * first label of its hostname, made safe for a subdomain.
185
+ */
186
+ export function labelFor(name: string, hostname: string): string {
187
+ for (const candidate of [name, hostname.split(".")[0] ?? ""]) {
188
+ const label = candidate
189
+ .toLowerCase()
190
+ .replace(/[^a-z0-9-]+/g, "-")
191
+ .replace(/^-+|-+$/g, "")
192
+ .slice(0, 30)
193
+ .replace(/-+$/g, "");
194
+ if (label.length >= 2) return label;
195
+ }
196
+ return "server";
197
+ }
package/src/publish.ts CHANGED
@@ -30,6 +30,8 @@ export interface PublishTarget {
30
30
  */
31
31
  tracks: () => number;
32
32
  nowPlaying: () => string;
33
+ /** The admin share link, kept by the directory for the owner alone. */
34
+ admin?: string;
33
35
  /** Whether the player is actually running, so a stopped server is not listed as live. */
34
36
  playing?: () => boolean;
35
37
  /** The live channels on this server, by name, for the listing to show. */
@@ -103,6 +105,9 @@ export class Publisher {
103
105
  name: this.target.name,
104
106
  url: this.target.url,
105
107
  ...(this.target.audio ? { audio: this.target.audio } : {}),
108
+ // The link that drives this server. The directory keeps it for the
109
+ // account that owns the listing and shows it to nobody else.
110
+ ...(this.target.admin ? { admin: this.target.admin } : {}),
106
111
  tracks: this.target.tracks(),
107
112
  nowPlaying: this.target.nowPlaying(),
108
113
  ...(this.target.playing ? { playing: this.target.playing() } : {}),
package/src/server.ts CHANGED
@@ -56,6 +56,12 @@ import pg from "pg";
56
56
  import { Follows, phoneFrom } from "./follows.ts";
57
57
  import { Favorites, favoriteUrl } from "./favorites.ts";
58
58
  import { Catalogs, shownCatalog, shownEntry } from "./catalogs.ts";
59
+ import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
60
+ import { NameError, Names } from "./names.ts";
61
+ import { AcmeIssuer, Certs } from "./certs.ts";
62
+ import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
63
+ import { forbiddenLibrary, readLibrary } from "./library.ts";
64
+ import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
59
65
  import { Durable } from "./durable.ts";
60
66
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
61
67
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
@@ -120,6 +126,8 @@ export interface ServeOptions {
120
126
  newKey: boolean;
121
127
  /** Start without the noise it makes when it wakes up. */
122
128
  noJingle: boolean;
129
+ /** Do not ask nixamp.com for a name and a certificate, even when signed in. */
130
+ noName: boolean;
123
131
  /**
124
132
  * Ask the local firewall to let the port through, and put it back on the way
125
133
  * out. Off by default because it changes the machine, not just this process.
@@ -203,7 +211,9 @@ export function parseServeArgs(argv: string[]): ServeOptions {
203
211
  // A platform that hands out the port does it through PORT; a flag still wins.
204
212
  const fromEnv = Number(process.env.PORT);
205
213
  const options: ServeOptions = {
206
- root: ".",
214
+ // Empty, not ".": nothing about a server should depend on where it was
215
+ // started from. The saved library fills it in, or the start refuses.
216
+ root: "",
207
217
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
208
218
  // Every interface, because a player nobody else can reach is not much of a
209
219
  // remote. The key in the link is what makes that safe; --no-key gives up
@@ -214,6 +224,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
214
224
  key: true,
215
225
  newKey: false,
216
226
  noJingle: false,
227
+ noName: false,
217
228
  openPort: false,
218
229
  announce: false,
219
230
  directory: false,
@@ -290,6 +301,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
290
301
  options.newKey = true;
291
302
  } else if (arg === "--no-jingle") {
292
303
  options.noJingle = true;
304
+ } else if (arg === "--no-name") {
305
+ options.noName = true;
293
306
  } else if (arg === "--ingest") {
294
307
  options.ingest = true;
295
308
  } else if (arg === "--rtmp-streams") {
@@ -913,6 +926,37 @@ export class PlayerEngine implements Engine {
913
926
  * looking at what is on wants "that album from the web", not every track in
914
927
  * it. An entry names where to start, so clicking it plays.
915
928
  */
929
+ /**
930
+ * A fetch-shaped Request for the throttle, built from the Node one.
931
+ *
932
+ * @profullstack/throttle is written against the web Request so it runs at an
933
+ * edge; this server is Node's http. Only what the throttle reads is carried
934
+ * across: method, URL and headers. The body is not, because metering is
935
+ * decided before anybody reads it.
936
+ */
937
+ export function requestFor(request: IncomingMessage, origin = "http://localhost"): Request {
938
+ const headers = new Headers();
939
+ for (const [name, value] of Object.entries(request.headers)) {
940
+ if (typeof value === "string") headers.set(name, value);
941
+ else if (Array.isArray(value)) headers.set(name, value.join(", "));
942
+ }
943
+ // The address, for a throttle that has no socket to ask.
944
+ if (!headers.has("x-forwarded-for") && request.socket?.remoteAddress) {
945
+ headers.set("x-forwarded-for", request.socket.remoteAddress);
946
+ }
947
+ return new Request(`${origin}${request.url ?? "/"}`, { method: request.method ?? "GET", headers });
948
+ }
949
+
950
+ /** Write a refusal the throttle produced back through the Node response. */
951
+ export async function answerWith(response: ServerResponse, refused: Response): Promise<void> {
952
+ const headers: Record<string, string> = { ...CORS };
953
+ refused.headers.forEach((value, name) => {
954
+ headers[name] = value;
955
+ });
956
+ response.writeHead(refused.status, headers);
957
+ response.end(Buffer.from(await refused.arrayBuffer()));
958
+ }
959
+
916
960
  /**
917
961
  * How many channels a server will start on demand at once. Each is an ffmpeg,
918
962
  * and a catalog has thousands of entries; this is what keeps a room full of
@@ -993,7 +1037,7 @@ const CORS: Record<string, string> = {
993
1037
  // control API has to be reachable cross-origin. It exposes no filesystem
994
1038
  // paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
995
1039
  "access-control-allow-origin": "*",
996
- "access-control-allow-methods": "GET, POST, OPTIONS",
1040
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
997
1041
  "access-control-allow-headers": "content-type",
998
1042
  "access-control-max-age": "86400",
999
1043
  };
@@ -1181,6 +1225,18 @@ export interface HandlerOptions {
1181
1225
  follows?: Follows;
1182
1226
  /** The servers an account hearted. nixamp.com only, like follows. */
1183
1227
  favorites?: Favorites;
1228
+ /** Names under `<handle>.<zone>` for an account's servers. nixamp.com only. */
1229
+ names?: Names;
1230
+ /** One wildcard certificate per handle, issued and renewed here. nixamp.com only. */
1231
+ certs?: Certs;
1232
+ /** The zone the names live in, e.g. "nixamp.com". */
1233
+ dnsZone?: string;
1234
+ /**
1235
+ * The rate limit over everything, from @profullstack/throttle. Fetch-shaped,
1236
+ * so the handler builds a Request from the Node one and writes back the
1237
+ * Response it is refused with.
1238
+ */
1239
+ throttle?: Throttle;
1184
1240
  /** The VAPID public key a browser needs before it can subscribe. */
1185
1241
  vapidPublicKey?: string;
1186
1242
  }
@@ -1229,6 +1285,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1229
1285
  return;
1230
1286
  }
1231
1287
 
1288
+ // Metered before anything is done for the request, so a caller over its
1289
+ // allowance costs nothing but this check. The throttle decides; this only
1290
+ // carries its refusal back through Node's response.
1291
+ if (options.throttle) {
1292
+ const refused = await options.throttle.handle(requestFor(request));
1293
+ if (refused) {
1294
+ await answerWith(response, refused);
1295
+ return;
1296
+ }
1297
+ }
1298
+
1232
1299
  // Opening a share link is what hands a browser its key. It comes back as a
1233
1300
  // cookie, so every later fetch, EventSource and <audio src> carries it
1234
1301
  // without the page knowing anything about keys. Either key works here, and
@@ -1271,6 +1338,99 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1271
1338
  // Behind the sign-in rather than the share key: a follow belongs to an
1272
1339
  // account, and an account is the only thing that makes "notify me on my
1273
1340
  // other device" mean anything.
1341
+ // --- names and certificates for an account's servers --------------------
1342
+ //
1343
+ // A server that is signed in becomes `<label>.<handle>.<zone>`, with A and
1344
+ // AAAA records nixamp.com writes with keys only nixamp.com holds, and it
1345
+ // serves https with the one wildcard certificate its handle has. Nothing
1346
+ // about DNS or ACME ever reaches the box; it asks, and is answered.
1347
+ if ((path === "/api/v1/dns" || path.startsWith("/api/v1/dns/")) && options.names && options.accounts && options.handles) {
1348
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
1349
+ if (me === null) {
1350
+ json(response, 401, { error: "sign in to name a server" });
1351
+ return;
1352
+ }
1353
+ const handle = await options.handles.of(me.id);
1354
+ if (!handle) {
1355
+ json(response, 422, { error: "this account has no handle yet" });
1356
+ return;
1357
+ }
1358
+ const names = options.names;
1359
+ const zone = `${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
1360
+
1361
+ if (path === "/api/v1/dns" && request.method === "GET") {
1362
+ json(response, 200, { zone, names: await names.list(me.id, handle) });
1363
+ return;
1364
+ }
1365
+ const label = decodeURIComponent(path.slice("/api/v1/dns/".length));
1366
+ if (!label) {
1367
+ json(response, 404, { error: "no such endpoint" });
1368
+ return;
1369
+ }
1370
+ if (request.method === "PUT" || request.method === "POST") {
1371
+ let body: { a?: unknown; aaaa?: unknown; ttl?: unknown } = {};
1372
+ try {
1373
+ body = JSON.parse((await readBody(request)) || "{}") as typeof body;
1374
+ } catch {
1375
+ json(response, 400, { error: "bad JSON" });
1376
+ return;
1377
+ }
1378
+ // "auto" is the address this request came from, for whichever family
1379
+ // it came in on: a server names itself without knowing its address.
1380
+ const caller = callerOf(request.headers, request.socket.remoteAddress, options.behindProxy ?? false);
1381
+ const family = (value: unknown, is: (ip: unknown) => boolean): string | null | undefined => {
1382
+ if (value === null) return null;
1383
+ if (value === undefined) return undefined;
1384
+ if (value === "auto") return is(caller) ? caller : undefined;
1385
+ return String(value);
1386
+ };
1387
+ try {
1388
+ const name = await names.set(me.id, handle, label, {
1389
+ a: family(body.a, isIPv4),
1390
+ aaaa: family(body.aaaa, isIPv6),
1391
+ ...(typeof body.ttl === "number" ? { ttl: body.ttl } : {}),
1392
+ });
1393
+ json(response, 200, { name });
1394
+ } catch (error) {
1395
+ const status = error instanceof NameError ? error.status : 500;
1396
+ json(response, status, { error: (error as Error).message });
1397
+ }
1398
+ return;
1399
+ }
1400
+ if (request.method === "DELETE") {
1401
+ const gone = await names.remove(me.id, handle, label);
1402
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such name of yours" });
1403
+ return;
1404
+ }
1405
+ json(response, 405, { error: "GET, PUT or DELETE" });
1406
+ return;
1407
+ }
1408
+
1409
+ if (path === "/api/v1/certs" && options.certs && options.accounts && options.handles) {
1410
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
1411
+ if (me === null) {
1412
+ json(response, 401, { error: "sign in to get a certificate" });
1413
+ return;
1414
+ }
1415
+ const handle = await options.handles.of(me.id);
1416
+ if (!handle) {
1417
+ json(response, 422, { error: "this account has no handle yet" });
1418
+ return;
1419
+ }
1420
+ const state = await options.certs.forHandle(handle);
1421
+ const host = `*.${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
1422
+ if (state.status === "ready") {
1423
+ json(response, 200, { status: "ready", cert: state.cert, key: state.key, expiresAt: state.expiresAt, host, renewing: state.renewing });
1424
+ return;
1425
+ }
1426
+ if (state.status === "failed") {
1427
+ json(response, 503, { status: "failed", error: state.error, host });
1428
+ return;
1429
+ }
1430
+ json(response, 202, { status: "issuing", host });
1431
+ return;
1432
+ }
1433
+
1274
1434
  // Favourites: the servers you hearted, kept against your account. Reading
1275
1435
  // the directory and listening need no account; remembering where you
1276
1436
  // listened does, because there has to be somebody to remember it for.
@@ -2108,9 +2268,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2108
2268
  // phone for it. The code is published on purpose: it is a public
2109
2269
  // call-in line, and a listing you cannot dial is a listing of nothing.
2110
2270
  const onThePhone = options.partyLine;
2111
- const streams = options.directory.list().map((stream) => ({
2271
+ // The admin link goes only to the account that owns the listing. A
2272
+ // directory that handed out control links would be a directory of
2273
+ // machines anyone could take over.
2274
+ const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)) : null;
2275
+ const streams = options.directory.list().map(({ admin, ...stream }) => ({
2112
2276
  ...stream,
2113
2277
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
2278
+ ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
2114
2279
  }));
2115
2280
  // Recently ended too, because following exists to hear about
2116
2281
  // broadcasts you would otherwise miss -- and a list of only what is on
@@ -3467,7 +3632,20 @@ export function createServer(engine: Engine, options: HandlerOptions): Server {
3467
3632
 
3468
3633
  export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3469
3634
  const options = parseServeArgs(argv);
3470
- const root = isRemote(options.root) ? options.root : resolve(options.root);
3635
+ // Told which folder, or the one that was saved. Never the directory this
3636
+ // happens to be running in: a daemon restarted from a home directory served
3637
+ // the home directory, keys and all, under a public listing.
3638
+ const chosen = options.root || readLibrary();
3639
+ if (!chosen) {
3640
+ throw new Error(
3641
+ "nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.",
3642
+ );
3643
+ }
3644
+ const root = isRemote(chosen) ? chosen : resolve(chosen);
3645
+ const why = isRemote(root) ? "" : forbiddenLibrary(root);
3646
+ if (why) {
3647
+ throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
3648
+ }
3471
3649
  const tools = detectTools();
3472
3650
  // Names now, tags later.
3473
3651
  //
@@ -3566,6 +3744,54 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3566
3744
  : undefined;
3567
3745
  const follows = pool ? new Follows(pool) : undefined;
3568
3746
  const favorites = pool ? new Favorites(pool) : undefined;
3747
+
3748
+ // Names and certificates for signed-in servers, and the rate limit over
3749
+ // everything. All of it is nixamp.com's business: the DNS keys live only
3750
+ // here, the certificates are issued here, and a personal nixamp has neither
3751
+ // a database nor strangers to meter. Without the registrar's keys the names
3752
+ // are simply not offered, rather than written into a zone that does not
3753
+ // exist.
3754
+ const zoneName = (() => {
3755
+ try {
3756
+ return new URL(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).hostname;
3757
+ } catch {
3758
+ return "nixamp.com";
3759
+ }
3760
+ })();
3761
+ const porkbunKey = process.env["PORKBUN_API_KEY"] ?? "";
3762
+ const porkbunSecret = process.env["PORKBUN_SECRET_API_KEY"] ?? "";
3763
+ const zone: DnsZone | null = porkbunKey && porkbunSecret ? new Porkbun(zoneName, porkbunKey, porkbunSecret) : null;
3764
+ const names = pool && zone ? new Names(pool, zone) : undefined;
3765
+ let certs: Certs | undefined;
3766
+ if (pool && zone) {
3767
+ const issuer = new AcmeIssuer({
3768
+ directoryUrl: process.env["NIXAMP_ACME_DIRECTORY"] ?? "https://acme-v02.api.letsencrypt.org/directory",
3769
+ email: process.env["NIXAMP_ACME_EMAIL"] ?? `hostmaster@${zoneName}`,
3770
+ // The key is kept by the store, so the issuer asks for it each time
3771
+ // rather than holding one that a second instance would not share.
3772
+ accountKey: () => (certs as Certs).accountKey(),
3773
+ });
3774
+ certs = new Certs(pool, zone, issuer, { log: (line) => console.log(` ${line}`) });
3775
+ }
3776
+ const throttle = pool
3777
+ ? createThrottle({
3778
+ rules: [
3779
+ // Sign-in stays address-bucketed however the request is dressed, or
3780
+ // a guess with an Authorization header buys itself the bigger budget.
3781
+ { path: "/api/v1/auth/", limit: 20, credential: false },
3782
+ { path: "/api/v1/dns/", limit: 30 },
3783
+ { path: "/api/v1/dns", limit: 30 },
3784
+ { path: "/api/v1/certs", limit: 30 },
3785
+ { path: "/api/health", open: true },
3786
+ { path: "/api/directory", limit: 120 },
3787
+ ],
3788
+ // A signed-in browser carries its session as a cookie, and is a
3789
+ // credential the same as a bearer token: a person on a dashboard is
3790
+ // not an anonymous scraper.
3791
+ credentialFrom: (request) =>
3792
+ presentedCredential(request.headers) ?? (tokenFrom(Object.fromEntries(request.headers)) || null),
3793
+ })
3794
+ : undefined;
3569
3795
  // The two things that were promises kept only in memory: a caller who was
3570
3796
  // told they would be texted, and the ended stream a code still points at.
3571
3797
  const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
@@ -3717,7 +3943,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3717
3943
 
3718
3944
  // Read before listening, so a missing or unreadable certificate is a sentence
3719
3945
  // now rather than a connection that resets later.
3720
- const tls = options.tlsCert
3946
+ let tls: { cert: string; key: string } | undefined = options.tlsCert
3721
3947
  ? (() => {
3722
3948
  try {
3723
3949
  return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
@@ -3727,6 +3953,45 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3727
3953
  })()
3728
3954
  : undefined;
3729
3955
 
3956
+ // A signed-in server names itself.
3957
+ //
3958
+ // Nothing about DNS or certificates reaches this machine: it asks nixamp.com
3959
+ // for `<label>.<handle>.<zone>` pointing at the address it is calling from,
3960
+ // and for the handle's wildcard certificate, and serves https under that
3961
+ // name. The registrar's keys stay on nixamp.com. Skipped when the operator
3962
+ // named or certified the server by hand, when it listens on one interface
3963
+ // only, or with --no-name.
3964
+ let certExpiresAt = 0;
3965
+ let namedHost = "";
3966
+ const namedSession = readSession();
3967
+ if (
3968
+ !options.noName && !options.publicUrl && !options.tlsCert &&
3969
+ (options.host === "0.0.0.0" || options.host === "::") && namedSession?.token
3970
+ ) {
3971
+ const say = (line: string): void => console.log(` ${line}`);
3972
+ const named = await claimName(namedSession.site, namedSession.token, labelFor(options.name, hostname()), say);
3973
+ if (named) {
3974
+ namedHost = named.host;
3975
+ // The certificate is the handle's, so the cache is keyed by the handle's
3976
+ // wildcard rather than by this machine's label.
3977
+ const wildcard = `*.${named.host.split(".").slice(1).join(".")}`;
3978
+ let files = readCertFiles(stateDir(), wildcard);
3979
+ if (!files) {
3980
+ const got = await fetchCert(namedSession.site, namedSession.token, {}, say);
3981
+ if (got) {
3982
+ writeCertFiles(stateDir(), got);
3983
+ files = { cert: got.cert, key: got.key, expiresAt: got.expiresAt };
3984
+ }
3985
+ }
3986
+ if (files) {
3987
+ tls = { cert: files.cert, key: files.key };
3988
+ certExpiresAt = files.expiresAt;
3989
+ }
3990
+ options.publicUrl = `${tls ? "https" : "http"}://${named.host}:${options.port}`;
3991
+ console.log(` This server is ${named.host}${tls ? "" : " -- no certificate yet, so http for now"}.`);
3992
+ }
3993
+ }
3994
+
3730
3995
  // Filled in below, when the RTMP listeners are opened. Read through a
3731
3996
  // function so the handler sees the list rather than the empty array it was
3732
3997
  // built with.
@@ -3803,6 +4068,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3803
4068
  ...(directory ? { directory } : {}),
3804
4069
  ...(follows ? { follows, vapidPublicKey } : {}),
3805
4070
  ...(favorites ? { favorites } : {}),
4071
+ ...(names ? { names } : {}),
4072
+ ...(certs ? { certs } : {}),
4073
+ dnsZone: zoneName,
4074
+ ...(throttle ? { throttle } : {}),
3806
4075
  ...(partyLine ? { partyLine } : {}),
3807
4076
  // Accounts live where the directory lives, and only there: a nixamp on a
3808
4077
  // laptop has nobody to be an account of.
@@ -3855,6 +4124,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3855
4124
  const bound = server.address();
3856
4125
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
3857
4126
 
4127
+ // A named server keeps its certificate fresh without a restart: once a day
4128
+ // it asks for the handle's certificate again and, when a newer one has been
4129
+ // issued, swaps it into the running listener.
4130
+ if (namedHost && namedSession?.token) {
4131
+ const renew = setInterval(() => {
4132
+ void fetchCert(namedSession.site, namedSession.token, { waitMs: 0 }, () => undefined).then((got) => {
4133
+ if (!got || got.expiresAt <= certExpiresAt) return;
4134
+ writeCertFiles(stateDir(), got);
4135
+ certExpiresAt = got.expiresAt;
4136
+ const secure = server as unknown as { setSecureContext?: (context: { cert: string; key: string }) => void };
4137
+ secure.setSecureContext?.({ cert: got.cert, key: got.key });
4138
+ console.log(` Renewed the certificate for ${namedHost}.`);
4139
+ });
4140
+ }, 24 * 60 * 60 * 1000);
4141
+ renew.unref();
4142
+ }
4143
+
3858
4144
  const io = {
3859
4145
  read: readIfPossible,
3860
4146
  run: (command: string, args: string[]) => {
@@ -4073,6 +4359,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4073
4359
  name: options.name || hostname(),
4074
4360
  url: listen,
4075
4361
  audio,
4362
+ // The control link, for the owner to open this machine as its
4363
+ // administrator from the directory. The directory shows it to the
4364
+ // owning account and strips it for everyone else.
4365
+ ...(key ? { admin: shareLink(publishable_.url, key) } : {}),
4076
4366
  // Asked at every heartbeat rather than once, because the library is
4077
4367
  // read after the port opens and is still arriving when this is made.
4078
4368
  tracks: () => engine.snapshot(false).trackCount,