nixamp 0.8.0 → 0.9.3

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/dist/naming.js ADDED
@@ -0,0 +1,150 @@
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
+ /** A certificate this close to expiry is not worth serving: fetch a fresh one. */
14
+ const TOO_CLOSE_MS = 24 * 60 * 60 * 1000;
15
+ function bearer(token) {
16
+ return { authorization: `Bearer ${token}`, "content-type": "application/json" };
17
+ }
18
+ /**
19
+ * Claim, or refresh, this machine's name.
20
+ *
21
+ * "auto" for both families: the site records whichever addresses this request
22
+ * arrived from, which is the only honest answer to "what is my public
23
+ * address" from behind a router. Null when refused or unreachable, and the
24
+ * reason is said rather than thrown: a server without a name still serves.
25
+ */
26
+ export async function claimName(site, token, label, say, fetcher = fetch) {
27
+ try {
28
+ const answer = await fetcher(`${site}/api/v1/dns/${encodeURIComponent(label)}`, {
29
+ method: "PUT",
30
+ headers: bearer(token),
31
+ body: JSON.stringify({ a: "auto", aaaa: "auto" }),
32
+ });
33
+ const body = (await answer.json().catch(() => ({})));
34
+ if (!answer.ok || !body.name?.host) {
35
+ say(`nixamp: ${site} would not name this machine: ${body.error ?? `answered ${answer.status}`}`);
36
+ return null;
37
+ }
38
+ return { host: body.name.host, a: body.name.a ?? "", aaaa: body.name.aaaa ?? "" };
39
+ }
40
+ catch (error) {
41
+ say(`nixamp: could not reach ${site} to claim a name: ${error.message}`);
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * The handle's certificate.
47
+ *
48
+ * The first one is issued while we wait: a wildcard by DNS challenge takes a
49
+ * couple of minutes, which is said once so the pause reads as work rather
50
+ * than a hang. After that it is a cached answer on the site's side.
51
+ */
52
+ export async function fetchCert(site, token, opts, say) {
53
+ const waitMs = opts.waitMs ?? 240_000;
54
+ const everyMs = opts.everyMs ?? 10_000;
55
+ const sleep = opts.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
56
+ const fetcher = opts.fetcher ?? fetch;
57
+ let waited = 0;
58
+ let announced = false;
59
+ for (;;) {
60
+ let answer;
61
+ try {
62
+ answer = await fetcher(`${site}/api/v1/certs`, { headers: bearer(token) });
63
+ }
64
+ catch (error) {
65
+ say(`nixamp: could not reach ${site} for a certificate: ${error.message}`);
66
+ return null;
67
+ }
68
+ const body = (await answer.json().catch(() => ({})));
69
+ if (answer.ok && body.status === "ready" && body.cert && body.key && body.host) {
70
+ return { cert: body.cert, key: body.key, expiresAt: body.expiresAt ?? 0, host: body.host };
71
+ }
72
+ if (answer.status === 202 || body.status === "issuing") {
73
+ if (!announced) {
74
+ announced = true;
75
+ const host = body.host ?? "your handle";
76
+ say(`Getting a certificate for ${host}… the first one takes a couple of minutes.`);
77
+ }
78
+ if (waited >= waitMs) {
79
+ say(`nixamp: ${site} is still issuing the certificate; serving http until it is ready.`);
80
+ return null;
81
+ }
82
+ await sleep(everyMs);
83
+ waited += everyMs;
84
+ continue;
85
+ }
86
+ say(`nixamp: ${site} could not issue a certificate: ${body.error ?? `answered ${answer.status}`}`);
87
+ return null;
88
+ }
89
+ }
90
+ /** `*.chovy.nixamp.com` becomes `chovy.nixamp.com`, which is a filename. */
91
+ function fileStem(host) {
92
+ return host.replace(/^\*\./, "").replace(/[^A-Za-z0-9.-]/g, "_");
93
+ }
94
+ /**
95
+ * Keep a certificate beside the keys. Private, because the key is what lets
96
+ * anybody be this server.
97
+ */
98
+ export function writeCertFiles(stateDir, files) {
99
+ const dir = join(stateDir, "tls");
100
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
101
+ chmodSync(dir, 0o700);
102
+ const stem = join(dir, fileStem(files.host));
103
+ const cert = `${stem}.cert.pem`;
104
+ const key = `${stem}.key.pem`;
105
+ writeFileSync(cert, files.cert, { mode: 0o600 });
106
+ writeFileSync(key, files.key, { mode: 0o600 });
107
+ chmodSync(cert, 0o600);
108
+ chmodSync(key, 0o600);
109
+ writeFileSync(`${stem}.json`, JSON.stringify({ host: files.host, expiresAt: files.expiresAt }), { mode: 0o600 });
110
+ return { cert, key };
111
+ }
112
+ /**
113
+ * The certificate from last time, if it is still good for more than a day.
114
+ * Anything closer to expiry is treated as absent so the next start fetches
115
+ * a fresh one rather than serving one that lapses overnight.
116
+ */
117
+ export function readCertFiles(stateDir, host) {
118
+ const stem = join(stateDir, "tls", fileStem(host));
119
+ try {
120
+ const meta = JSON.parse(readFileSync(`${stem}.json`, "utf8"));
121
+ const expiresAt = typeof meta.expiresAt === "number" ? meta.expiresAt : 0;
122
+ if (expiresAt - Date.now() < TOO_CLOSE_MS)
123
+ return null;
124
+ const cert = readFileSync(`${stem}.cert.pem`, "utf8");
125
+ const key = readFileSync(`${stem}.key.pem`, "utf8");
126
+ if (!cert || !key)
127
+ return null;
128
+ return { cert, key, expiresAt };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * What this machine calls itself in DNS: the name it was given, else the
136
+ * first label of its hostname, made safe for a subdomain.
137
+ */
138
+ export function labelFor(name, hostname) {
139
+ for (const candidate of [name, hostname.split(".")[0] ?? ""]) {
140
+ const label = candidate
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9-]+/g, "-")
143
+ .replace(/^-+|-+$/g, "")
144
+ .slice(0, 30)
145
+ .replace(/-+$/g, "");
146
+ if (label.length >= 2)
147
+ return label;
148
+ }
149
+ return "server";
150
+ }
package/dist/publish.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface PublishTarget {
21
21
  */
22
22
  tracks: () => number;
23
23
  nowPlaying: () => string;
24
+ /** The admin share link, kept by the directory for the owner alone. */
25
+ admin?: string;
24
26
  /** Whether the player is actually running, so a stopped server is not listed as live. */
25
27
  playing?: () => boolean;
26
28
  /** The live channels on this server, by name, for the listing to show. */
package/dist/publish.js CHANGED
@@ -58,6 +58,9 @@ export class Publisher {
58
58
  name: this.target.name,
59
59
  url: this.target.url,
60
60
  ...(this.target.audio ? { audio: this.target.audio } : {}),
61
+ // The link that drives this server. The directory keeps it for the
62
+ // account that owns the listing and shows it to nobody else.
63
+ ...(this.target.admin ? { admin: this.target.admin } : {}),
61
64
  tracks: this.target.tracks(),
62
65
  nowPlaying: this.target.nowPlaying(),
63
66
  ...(this.target.playing ? { playing: this.target.playing() } : {}),
package/dist/server.d.ts CHANGED
@@ -14,6 +14,9 @@ import { PartyLine } from "./partyline.ts";
14
14
  import { Follows } from "./follows.ts";
15
15
  import { Favorites } from "./favorites.ts";
16
16
  import { Catalogs } from "./catalogs.ts";
17
+ import { Names } from "./names.ts";
18
+ import { Certs } from "./certs.ts";
19
+ import { type Throttle } from "@profullstack/throttle";
17
20
  import { type Tools, type Track } from "./audio.ts";
18
21
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
19
22
  export declare const SERVE_BAND_COUNT = 24;
@@ -35,6 +38,8 @@ export interface ServeOptions {
35
38
  newKey: boolean;
36
39
  /** Start without the noise it makes when it wakes up. */
37
40
  noJingle: boolean;
41
+ /** Do not ask nixamp.com for a name and a certificate, even when signed in. */
42
+ noName: boolean;
38
43
  /**
39
44
  * Ask the local firewall to let the port through, and put it back on the way
40
45
  * out. Off by default because it changes the machine, not just this process.
@@ -338,6 +343,17 @@ export declare class PlayerEngine implements Engine {
338
343
  * looking at what is on wants "that album from the web", not every track in
339
344
  * it. An entry names where to start, so clicking it plays.
340
345
  */
346
+ /**
347
+ * A fetch-shaped Request for the throttle, built from the Node one.
348
+ *
349
+ * @profullstack/throttle is written against the web Request so it runs at an
350
+ * edge; this server is Node's http. Only what the throttle reads is carried
351
+ * across: method, URL and headers. The body is not, because metering is
352
+ * decided before anybody reads it.
353
+ */
354
+ export declare function requestFor(request: IncomingMessage, origin?: string): Request;
355
+ /** Write a refusal the throttle produced back through the Node response. */
356
+ export declare function answerWith(response: ServerResponse, refused: Response): Promise<void>;
341
357
  /**
342
358
  * How many channels a server will start on demand at once. Each is an ffmpeg,
343
359
  * and a catalog has thousands of entries; this is what keeps a room full of
@@ -520,6 +536,18 @@ export interface HandlerOptions {
520
536
  follows?: Follows;
521
537
  /** The servers an account hearted. nixamp.com only, like follows. */
522
538
  favorites?: Favorites;
539
+ /** Names under `<handle>.<zone>` for an account's servers. nixamp.com only. */
540
+ names?: Names;
541
+ /** One wildcard certificate per handle, issued and renewed here. nixamp.com only. */
542
+ certs?: Certs;
543
+ /** The zone the names live in, e.g. "nixamp.com". */
544
+ dnsZone?: string;
545
+ /**
546
+ * The rate limit over everything, from @profullstack/throttle. Fetch-shaped,
547
+ * so the handler builds a Request from the Node one and writes back the
548
+ * Response it is refused with.
549
+ */
550
+ throttle?: Throttle;
523
551
  /** The VAPID public key a browser needs before it can subscribe. */
524
552
  vapidPublicKey?: string;
525
553
  }
package/dist/server.js CHANGED
@@ -39,6 +39,12 @@ import pg from "pg";
39
39
  import { Follows, phoneFrom } from "./follows.js";
40
40
  import { Favorites, favoriteUrl } from "./favorites.js";
41
41
  import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
42
+ import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
43
+ import { NameError, Names } from "./names.js";
44
+ import { AcmeIssuer, Certs } from "./certs.js";
45
+ import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
46
+ import { forbiddenLibrary, readLibrary } from "./library.js";
47
+ import { createThrottle, presentedCredential } from "@profullstack/throttle";
42
48
  import { Durable } from "./durable.js";
43
49
  import { notifyAll, resendEmail, webPush } from "./notify.js";
44
50
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
@@ -63,7 +69,9 @@ export function parseServeArgs(argv) {
63
69
  // A platform that hands out the port does it through PORT; a flag still wins.
64
70
  const fromEnv = Number(process.env.PORT);
65
71
  const options = {
66
- root: ".",
72
+ // Empty, not ".": nothing about a server should depend on where it was
73
+ // started from. The saved library fills it in, or the start refuses.
74
+ root: "",
67
75
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
68
76
  // Every interface, because a player nobody else can reach is not much of a
69
77
  // remote. The key in the link is what makes that safe; --no-key gives up
@@ -74,6 +82,7 @@ export function parseServeArgs(argv) {
74
82
  key: true,
75
83
  newKey: false,
76
84
  noJingle: false,
85
+ noName: false,
77
86
  openPort: false,
78
87
  announce: false,
79
88
  directory: false,
@@ -169,6 +178,9 @@ export function parseServeArgs(argv) {
169
178
  else if (arg === "--no-jingle") {
170
179
  options.noJingle = true;
171
180
  }
181
+ else if (arg === "--no-name") {
182
+ options.noName = true;
183
+ }
172
184
  else if (arg === "--ingest") {
173
185
  options.ingest = true;
174
186
  }
@@ -709,6 +721,37 @@ export class PlayerEngine {
709
721
  * looking at what is on wants "that album from the web", not every track in
710
722
  * it. An entry names where to start, so clicking it plays.
711
723
  */
724
+ /**
725
+ * A fetch-shaped Request for the throttle, built from the Node one.
726
+ *
727
+ * @profullstack/throttle is written against the web Request so it runs at an
728
+ * edge; this server is Node's http. Only what the throttle reads is carried
729
+ * across: method, URL and headers. The body is not, because metering is
730
+ * decided before anybody reads it.
731
+ */
732
+ export function requestFor(request, origin = "http://localhost") {
733
+ const headers = new Headers();
734
+ for (const [name, value] of Object.entries(request.headers)) {
735
+ if (typeof value === "string")
736
+ headers.set(name, value);
737
+ else if (Array.isArray(value))
738
+ headers.set(name, value.join(", "));
739
+ }
740
+ // The address, for a throttle that has no socket to ask.
741
+ if (!headers.has("x-forwarded-for") && request.socket?.remoteAddress) {
742
+ headers.set("x-forwarded-for", request.socket.remoteAddress);
743
+ }
744
+ return new Request(`${origin}${request.url ?? "/"}`, { method: request.method ?? "GET", headers });
745
+ }
746
+ /** Write a refusal the throttle produced back through the Node response. */
747
+ export async function answerWith(response, refused) {
748
+ const headers = { ...CORS };
749
+ refused.headers.forEach((value, name) => {
750
+ headers[name] = value;
751
+ });
752
+ response.writeHead(refused.status, headers);
753
+ response.end(Buffer.from(await refused.arrayBuffer()));
754
+ }
712
755
  /**
713
756
  * How many channels a server will start on demand at once. Each is an ffmpeg,
714
757
  * and a catalog has thousands of entries; this is what keeps a room full of
@@ -784,7 +827,7 @@ const CORS = {
784
827
  // control API has to be reachable cross-origin. It exposes no filesystem
785
828
  // paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
786
829
  "access-control-allow-origin": "*",
787
- "access-control-allow-methods": "GET, POST, OPTIONS",
830
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
788
831
  "access-control-allow-headers": "content-type",
789
832
  "access-control-max-age": "86400",
790
833
  };
@@ -878,6 +921,16 @@ export function createHandler(engine, options) {
878
921
  response.end();
879
922
  return;
880
923
  }
924
+ // Metered before anything is done for the request, so a caller over its
925
+ // allowance costs nothing but this check. The throttle decides; this only
926
+ // carries its refusal back through Node's response.
927
+ if (options.throttle) {
928
+ const refused = await options.throttle.handle(requestFor(request));
929
+ if (refused) {
930
+ await answerWith(response, refused);
931
+ return;
932
+ }
933
+ }
881
934
  // Opening a share link is what hands a browser its key. It comes back as a
882
935
  // cookie, so every later fetch, EventSource and <audio src> carries it
883
936
  // without the page knowing anything about keys. Either key works here, and
@@ -918,6 +971,101 @@ export function createHandler(engine, options) {
918
971
  // Behind the sign-in rather than the share key: a follow belongs to an
919
972
  // account, and an account is the only thing that makes "notify me on my
920
973
  // other device" mean anything.
974
+ // --- names and certificates for an account's servers --------------------
975
+ //
976
+ // A server that is signed in becomes `<label>.<handle>.<zone>`, with A and
977
+ // AAAA records nixamp.com writes with keys only nixamp.com holds, and it
978
+ // serves https with the one wildcard certificate its handle has. Nothing
979
+ // about DNS or ACME ever reaches the box; it asks, and is answered.
980
+ if ((path === "/api/v1/dns" || path.startsWith("/api/v1/dns/")) && options.names && options.accounts && options.handles) {
981
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
982
+ if (me === null) {
983
+ json(response, 401, { error: "sign in to name a server" });
984
+ return;
985
+ }
986
+ const handle = await options.handles.of(me.id);
987
+ if (!handle) {
988
+ json(response, 422, { error: "this account has no handle yet" });
989
+ return;
990
+ }
991
+ const names = options.names;
992
+ const zone = `${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
993
+ if (path === "/api/v1/dns" && request.method === "GET") {
994
+ json(response, 200, { zone, names: await names.list(me.id, handle) });
995
+ return;
996
+ }
997
+ const label = decodeURIComponent(path.slice("/api/v1/dns/".length));
998
+ if (!label) {
999
+ json(response, 404, { error: "no such endpoint" });
1000
+ return;
1001
+ }
1002
+ if (request.method === "PUT" || request.method === "POST") {
1003
+ let body = {};
1004
+ try {
1005
+ body = JSON.parse((await readBody(request)) || "{}");
1006
+ }
1007
+ catch {
1008
+ json(response, 400, { error: "bad JSON" });
1009
+ return;
1010
+ }
1011
+ // "auto" is the address this request came from, for whichever family
1012
+ // it came in on: a server names itself without knowing its address.
1013
+ const caller = callerOf(request.headers, request.socket.remoteAddress, options.behindProxy ?? false);
1014
+ const family = (value, is) => {
1015
+ if (value === null)
1016
+ return null;
1017
+ if (value === undefined)
1018
+ return undefined;
1019
+ if (value === "auto")
1020
+ return is(caller) ? caller : undefined;
1021
+ return String(value);
1022
+ };
1023
+ try {
1024
+ const name = await names.set(me.id, handle, label, {
1025
+ a: family(body.a, isIPv4),
1026
+ aaaa: family(body.aaaa, isIPv6),
1027
+ ...(typeof body.ttl === "number" ? { ttl: body.ttl } : {}),
1028
+ });
1029
+ json(response, 200, { name });
1030
+ }
1031
+ catch (error) {
1032
+ const status = error instanceof NameError ? error.status : 500;
1033
+ json(response, status, { error: error.message });
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (request.method === "DELETE") {
1038
+ const gone = await names.remove(me.id, handle, label);
1039
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such name of yours" });
1040
+ return;
1041
+ }
1042
+ json(response, 405, { error: "GET, PUT or DELETE" });
1043
+ return;
1044
+ }
1045
+ if (path === "/api/v1/certs" && options.certs && options.accounts && options.handles) {
1046
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
1047
+ if (me === null) {
1048
+ json(response, 401, { error: "sign in to get a certificate" });
1049
+ return;
1050
+ }
1051
+ const handle = await options.handles.of(me.id);
1052
+ if (!handle) {
1053
+ json(response, 422, { error: "this account has no handle yet" });
1054
+ return;
1055
+ }
1056
+ const state = await options.certs.forHandle(handle);
1057
+ const host = `*.${handle}.${options.dnsZone ?? ""}`.replace(/\.$/, "");
1058
+ if (state.status === "ready") {
1059
+ json(response, 200, { status: "ready", cert: state.cert, key: state.key, expiresAt: state.expiresAt, host, renewing: state.renewing });
1060
+ return;
1061
+ }
1062
+ if (state.status === "failed") {
1063
+ json(response, 503, { status: "failed", error: state.error, host });
1064
+ return;
1065
+ }
1066
+ json(response, 202, { status: "issuing", host });
1067
+ return;
1068
+ }
921
1069
  // Favourites: the servers you hearted, kept against your account. Reading
922
1070
  // the directory and listening need no account; remembering where you
923
1071
  // listened does, because there has to be somebody to remember it for.
@@ -1695,9 +1843,14 @@ export function createHandler(engine, options) {
1695
1843
  // phone for it. The code is published on purpose: it is a public
1696
1844
  // call-in line, and a listing you cannot dial is a listing of nothing.
1697
1845
  const onThePhone = options.partyLine;
1698
- const streams = options.directory.list().map((stream) => ({
1846
+ // The admin link goes only to the account that owns the listing. A
1847
+ // directory that handed out control links would be a directory of
1848
+ // machines anyone could take over.
1849
+ const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)) : null;
1850
+ const streams = options.directory.list().map(({ admin, ...stream }) => ({
1699
1851
  ...stream,
1700
1852
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
1853
+ ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
1701
1854
  }));
1702
1855
  // Recently ended too, because following exists to hear about
1703
1856
  // broadcasts you would otherwise miss -- and a list of only what is on
@@ -2976,7 +3129,18 @@ export function createServer(engine, options) {
2976
3129
  }
2977
3130
  export async function serve(argv, version = "0.1.0") {
2978
3131
  const options = parseServeArgs(argv);
2979
- const root = isRemote(options.root) ? options.root : resolve(options.root);
3132
+ // Told which folder, or the one that was saved. Never the directory this
3133
+ // happens to be running in: a daemon restarted from a home directory served
3134
+ // the home directory, keys and all, under a public listing.
3135
+ const chosen = options.root || readLibrary();
3136
+ if (!chosen) {
3137
+ throw new Error("nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.");
3138
+ }
3139
+ const root = isRemote(chosen) ? chosen : resolve(chosen);
3140
+ const why = isRemote(root) ? "" : forbiddenLibrary(root);
3141
+ if (why) {
3142
+ throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
3143
+ }
2980
3144
  const tools = detectTools();
2981
3145
  // Names now, tags later.
2982
3146
  //
@@ -3066,6 +3230,53 @@ export async function serve(argv, version = "0.1.0") {
3066
3230
  : undefined;
3067
3231
  const follows = pool ? new Follows(pool) : undefined;
3068
3232
  const favorites = pool ? new Favorites(pool) : undefined;
3233
+ // Names and certificates for signed-in servers, and the rate limit over
3234
+ // everything. All of it is nixamp.com's business: the DNS keys live only
3235
+ // here, the certificates are issued here, and a personal nixamp has neither
3236
+ // a database nor strangers to meter. Without the registrar's keys the names
3237
+ // are simply not offered, rather than written into a zone that does not
3238
+ // exist.
3239
+ const zoneName = (() => {
3240
+ try {
3241
+ return new URL(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).hostname;
3242
+ }
3243
+ catch {
3244
+ return "nixamp.com";
3245
+ }
3246
+ })();
3247
+ const porkbunKey = process.env["PORKBUN_API_KEY"] ?? "";
3248
+ const porkbunSecret = process.env["PORKBUN_SECRET_API_KEY"] ?? "";
3249
+ const zone = porkbunKey && porkbunSecret ? new Porkbun(zoneName, porkbunKey, porkbunSecret) : null;
3250
+ const names = pool && zone ? new Names(pool, zone) : undefined;
3251
+ let certs;
3252
+ if (pool && zone) {
3253
+ const issuer = new AcmeIssuer({
3254
+ directoryUrl: process.env["NIXAMP_ACME_DIRECTORY"] ?? "https://acme-v02.api.letsencrypt.org/directory",
3255
+ email: process.env["NIXAMP_ACME_EMAIL"] ?? `hostmaster@${zoneName}`,
3256
+ // The key is kept by the store, so the issuer asks for it each time
3257
+ // rather than holding one that a second instance would not share.
3258
+ accountKey: () => certs.accountKey(),
3259
+ });
3260
+ certs = new Certs(pool, zone, issuer, { log: (line) => console.log(` ${line}`) });
3261
+ }
3262
+ const throttle = pool
3263
+ ? createThrottle({
3264
+ rules: [
3265
+ // Sign-in stays address-bucketed however the request is dressed, or
3266
+ // a guess with an Authorization header buys itself the bigger budget.
3267
+ { path: "/api/v1/auth/", limit: 20, credential: false },
3268
+ { path: "/api/v1/dns/", limit: 30 },
3269
+ { path: "/api/v1/dns", limit: 30 },
3270
+ { path: "/api/v1/certs", limit: 30 },
3271
+ { path: "/api/health", open: true },
3272
+ { path: "/api/directory", limit: 120 },
3273
+ ],
3274
+ // A signed-in browser carries its session as a cookie, and is a
3275
+ // credential the same as a bearer token: a person on a dashboard is
3276
+ // not an anonymous scraper.
3277
+ credentialFrom: (request) => presentedCredential(request.headers) ?? (tokenFrom(Object.fromEntries(request.headers)) || null),
3278
+ })
3279
+ : undefined;
3069
3280
  // The two things that were promises kept only in memory: a caller who was
3070
3281
  // told they would be texted, and the ended stream a code still points at.
3071
3282
  const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
@@ -3206,7 +3417,7 @@ export async function serve(argv, version = "0.1.0") {
3206
3417
  }
3207
3418
  // Read before listening, so a missing or unreadable certificate is a sentence
3208
3419
  // now rather than a connection that resets later.
3209
- const tls = options.tlsCert
3420
+ let tls = options.tlsCert
3210
3421
  ? (() => {
3211
3422
  try {
3212
3423
  return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
@@ -3216,6 +3427,42 @@ export async function serve(argv, version = "0.1.0") {
3216
3427
  }
3217
3428
  })()
3218
3429
  : undefined;
3430
+ // A signed-in server names itself.
3431
+ //
3432
+ // Nothing about DNS or certificates reaches this machine: it asks nixamp.com
3433
+ // for `<label>.<handle>.<zone>` pointing at the address it is calling from,
3434
+ // and for the handle's wildcard certificate, and serves https under that
3435
+ // name. The registrar's keys stay on nixamp.com. Skipped when the operator
3436
+ // named or certified the server by hand, when it listens on one interface
3437
+ // only, or with --no-name.
3438
+ let certExpiresAt = 0;
3439
+ let namedHost = "";
3440
+ const namedSession = readSession();
3441
+ if (!options.noName && !options.publicUrl && !options.tlsCert &&
3442
+ (options.host === "0.0.0.0" || options.host === "::") && namedSession?.token) {
3443
+ const say = (line) => console.log(` ${line}`);
3444
+ const named = await claimName(namedSession.site, namedSession.token, labelFor(options.name, hostname()), say);
3445
+ if (named) {
3446
+ namedHost = named.host;
3447
+ // The certificate is the handle's, so the cache is keyed by the handle's
3448
+ // wildcard rather than by this machine's label.
3449
+ const wildcard = `*.${named.host.split(".").slice(1).join(".")}`;
3450
+ let files = readCertFiles(stateDir(), wildcard);
3451
+ if (!files) {
3452
+ const got = await fetchCert(namedSession.site, namedSession.token, {}, say);
3453
+ if (got) {
3454
+ writeCertFiles(stateDir(), got);
3455
+ files = { cert: got.cert, key: got.key, expiresAt: got.expiresAt };
3456
+ }
3457
+ }
3458
+ if (files) {
3459
+ tls = { cert: files.cert, key: files.key };
3460
+ certExpiresAt = files.expiresAt;
3461
+ }
3462
+ options.publicUrl = `${tls ? "https" : "http"}://${named.host}:${options.port}`;
3463
+ console.log(` This server is ${named.host}${tls ? "" : " -- no certificate yet, so http for now"}.`);
3464
+ }
3465
+ }
3219
3466
  // Filled in below, when the RTMP listeners are opened. Read through a
3220
3467
  // function so the handler sees the list rather than the empty array it was
3221
3468
  // built with.
@@ -3291,6 +3538,10 @@ export async function serve(argv, version = "0.1.0") {
3291
3538
  ...(directory ? { directory } : {}),
3292
3539
  ...(follows ? { follows, vapidPublicKey } : {}),
3293
3540
  ...(favorites ? { favorites } : {}),
3541
+ ...(names ? { names } : {}),
3542
+ ...(certs ? { certs } : {}),
3543
+ dnsZone: zoneName,
3544
+ ...(throttle ? { throttle } : {}),
3294
3545
  ...(partyLine ? { partyLine } : {}),
3295
3546
  // Accounts live where the directory lives, and only there: a nixamp on a
3296
3547
  // laptop has nobody to be an account of.
@@ -3333,6 +3584,23 @@ export async function serve(argv, version = "0.1.0") {
3333
3584
  });
3334
3585
  const bound = server.address();
3335
3586
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
3587
+ // A named server keeps its certificate fresh without a restart: once a day
3588
+ // it asks for the handle's certificate again and, when a newer one has been
3589
+ // issued, swaps it into the running listener.
3590
+ if (namedHost && namedSession?.token) {
3591
+ const renew = setInterval(() => {
3592
+ void fetchCert(namedSession.site, namedSession.token, { waitMs: 0 }, () => undefined).then((got) => {
3593
+ if (!got || got.expiresAt <= certExpiresAt)
3594
+ return;
3595
+ writeCertFiles(stateDir(), got);
3596
+ certExpiresAt = got.expiresAt;
3597
+ const secure = server;
3598
+ secure.setSecureContext?.({ cert: got.cert, key: got.key });
3599
+ console.log(` Renewed the certificate for ${namedHost}.`);
3600
+ });
3601
+ }, 24 * 60 * 60 * 1000);
3602
+ renew.unref();
3603
+ }
3336
3604
  const io = {
3337
3605
  read: readIfPossible,
3338
3606
  run: (command, args) => {
@@ -3538,6 +3806,10 @@ export async function serve(argv, version = "0.1.0") {
3538
3806
  name: options.name || hostname(),
3539
3807
  url: listen,
3540
3808
  audio,
3809
+ // The control link, for the owner to open this machine as its
3810
+ // administrator from the directory. The directory shows it to the
3811
+ // owning account and strips it for everyone else.
3812
+ ...(key ? { admin: shareLink(publishable_.url, key) } : {}),
3541
3813
  // Asked at every heartbeat rather than once, because the library is
3542
3814
  // read after the port opens and is still arriving when this is made.
3543
3815
  tracks: () => engine.snapshot(false).trackCount,
package/dist/session.d.ts CHANGED
@@ -95,6 +95,14 @@ export declare function chooseWay(ways: SiteWays, options: LoginOptions): Promis
95
95
  * server keeps only its hash and has nothing to show a second time.
96
96
  */
97
97
  export declare function tokens(argv: string[], fetcher?: typeof fetch): Promise<number>;
98
+ /**
99
+ * `nixamp dns list|set|rm`: names under your handle, for your servers.
100
+ *
101
+ * The DNS keys stay on nixamp.com; this only says which label should point
102
+ * where. "auto" is the address the request arrives from, which is what a
103
+ * server naming itself wants and what nobody behind a router can type.
104
+ */
105
+ export declare function dns(argv: string[], fetcher?: typeof fetch): Promise<number>;
98
106
  export declare function logout(): number;
99
107
  /** `nixamp whoami`, which asks the server rather than trusting the file. */
100
108
  export declare function whoami(fetcher?: typeof fetch): Promise<number>;