nixamp 0.7.41 → 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.
Files changed (48) hide show
  1. package/dist/catalogs.d.ts +100 -0
  2. package/dist/catalogs.js +0 -0
  3. package/dist/certs.d.ts +112 -0
  4. package/dist/certs.js +217 -0
  5. package/dist/channels.d.ts +17 -0
  6. package/dist/channels.js +45 -0
  7. package/dist/directory.d.ts +10 -0
  8. package/dist/directory.js +6 -0
  9. package/dist/dns.d.ts +63 -0
  10. package/dist/dns.js +171 -0
  11. package/dist/library.d.ts +36 -0
  12. package/dist/library.js +167 -0
  13. package/dist/main.js +92 -4
  14. package/dist/names.d.ts +66 -0
  15. package/dist/names.js +184 -0
  16. package/dist/naming.d.ts +57 -0
  17. package/dist/naming.js +150 -0
  18. package/dist/owner.js +4 -0
  19. package/dist/publish.d.ts +2 -0
  20. package/dist/publish.js +3 -0
  21. package/dist/server.d.ts +37 -0
  22. package/dist/server.js +418 -5
  23. package/dist/session.d.ts +8 -0
  24. package/dist/session.js +103 -0
  25. package/package.json +8 -2
  26. package/src/catalogs.ts +0 -0
  27. package/src/certs.ts +294 -0
  28. package/src/channels.ts +41 -0
  29. package/src/directory.ts +16 -0
  30. package/src/dns.ts +207 -0
  31. package/src/library.ts +175 -0
  32. package/src/main.ts +88 -4
  33. package/src/names.ts +239 -0
  34. package/src/naming.ts +197 -0
  35. package/src/owner.ts +3 -0
  36. package/src/publish.ts +5 -0
  37. package/src/server.ts +446 -5
  38. package/src/session.ts +117 -0
  39. package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-C88rYdXy.js} +1 -1
  40. package/web/dist/assets/index-BB3VT0Ks.js +1 -0
  41. package/web/dist/assets/index-DrXbwjOa.css +1 -0
  42. package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-BMDK3Ac9.js} +1 -1
  43. package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-C06vXzyy.js} +1 -1
  44. package/web/dist/index.html +23 -2
  45. package/web/dist/install.sh +3 -1
  46. package/web/dist/sw.js +6 -6
  47. package/web/dist/assets/index-ComwKkzf.js +0 -1
  48. package/web/dist/assets/index-D3xGDAOd.css +0 -1
package/dist/server.js CHANGED
@@ -38,6 +38,13 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
38
38
  import pg from "pg";
39
39
  import { Follows, phoneFrom } from "./follows.js";
40
40
  import { Favorites, favoriteUrl } from "./favorites.js";
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";
41
48
  import { Durable } from "./durable.js";
42
49
  import { notifyAll, resendEmail, webPush } from "./notify.js";
43
50
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
@@ -62,7 +69,9 @@ export function parseServeArgs(argv) {
62
69
  // A platform that hands out the port does it through PORT; a flag still wins.
63
70
  const fromEnv = Number(process.env.PORT);
64
71
  const options = {
65
- 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: "",
66
75
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
67
76
  // Every interface, because a player nobody else can reach is not much of a
68
77
  // remote. The key in the link is what makes that safe; --no-key gives up
@@ -73,6 +82,7 @@ export function parseServeArgs(argv) {
73
82
  key: true,
74
83
  newKey: false,
75
84
  noJingle: false,
85
+ noName: false,
76
86
  openPort: false,
77
87
  announce: false,
78
88
  directory: false,
@@ -168,6 +178,9 @@ export function parseServeArgs(argv) {
168
178
  else if (arg === "--no-jingle") {
169
179
  options.noJingle = true;
170
180
  }
181
+ else if (arg === "--no-name") {
182
+ options.noName = true;
183
+ }
171
184
  else if (arg === "--ingest") {
172
185
  options.ingest = true;
173
186
  }
@@ -708,6 +721,43 @@ export class PlayerEngine {
708
721
  * looking at what is on wants "that album from the web", not every track in
709
722
  * it. An entry names where to start, so clicking it plays.
710
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
+ }
755
+ /**
756
+ * How many channels a server will start on demand at once. Each is an ffmpeg,
757
+ * and a catalog has thousands of entries; this is what keeps a room full of
758
+ * curious people from becoming a room full of decoders.
759
+ */
760
+ export const MAX_ON_DEMAND = 4;
711
761
  /**
712
762
  * Probe a source and start carrying it as a channel of its own.
713
763
  *
@@ -777,7 +827,7 @@ const CORS = {
777
827
  // control API has to be reachable cross-origin. It exposes no filesystem
778
828
  // paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
779
829
  "access-control-allow-origin": "*",
780
- "access-control-allow-methods": "GET, POST, OPTIONS",
830
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
781
831
  "access-control-allow-headers": "content-type",
782
832
  "access-control-max-age": "86400",
783
833
  };
@@ -871,6 +921,16 @@ export function createHandler(engine, options) {
871
921
  response.end();
872
922
  return;
873
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
+ }
874
934
  // Opening a share link is what hands a browser its key. It comes back as a
875
935
  // cookie, so every later fetch, EventSource and <audio src> carries it
876
936
  // without the page knowing anything about keys. Either key works here, and
@@ -911,6 +971,101 @@ export function createHandler(engine, options) {
911
971
  // Behind the sign-in rather than the share key: a follow belongs to an
912
972
  // account, and an account is the only thing that makes "notify me on my
913
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
+ }
914
1069
  // Favourites: the servers you hearted, kept against your account. Reading
915
1070
  // the directory and listening need no account; remembering where you
916
1071
  // listened does, because there has to be somebody to remember it for.
@@ -1688,9 +1843,14 @@ export function createHandler(engine, options) {
1688
1843
  // phone for it. The code is published on purpose: it is a public
1689
1844
  // call-in line, and a listing you cannot dial is a listing of nothing.
1690
1845
  const onThePhone = options.partyLine;
1691
- 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 }) => ({
1692
1851
  ...stream,
1693
1852
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
1853
+ ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
1694
1854
  }));
1695
1855
  // Recently ended too, because following exists to hear about
1696
1856
  // broadcasts you would otherwise miss -- and a list of only what is on
@@ -1876,6 +2036,134 @@ export function createHandler(engine, options) {
1876
2036
  }
1877
2037
  // --- several streams at once ------------------------------------------
1878
2038
  //
2039
+ // --- catalogs: m3u lists you can browse ---------------------------------
2040
+ //
2041
+ // An IPTV list is thousands of entries with groups and logos. Kept as a
2042
+ // catalog it stays browsable; poured into the playlist it was three
2043
+ // thousand flat rows. Anyone with the link browses and plays; adding,
2044
+ // refreshing and removing is administering (see needsAdmin).
2045
+ if ((path === "/api/catalogs" || path.startsWith("/api/catalogs/")) && options.catalogs) {
2046
+ const catalogs = options.catalogs;
2047
+ if (path === "/api/catalogs" && request.method === "GET") {
2048
+ // Where a list is read from is the administrator's business, not a
2049
+ // listener's: it can carry a provider's credentials in the URL.
2050
+ const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
2051
+ const admin = options.owner
2052
+ ? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
2053
+ : holdsControl;
2054
+ json(response, 200, { catalogs: catalogs.list().map((one) => shownCatalog(one, admin)) });
2055
+ return;
2056
+ }
2057
+ if (path === "/api/catalogs" && request.method === "POST") {
2058
+ let body = {};
2059
+ try {
2060
+ body = JSON.parse(await readBody(request));
2061
+ }
2062
+ catch {
2063
+ json(response, 400, { error: "bad JSON" });
2064
+ return;
2065
+ }
2066
+ try {
2067
+ const added = await catalogs.add(String(body.source ?? ""), String(body.name ?? ""));
2068
+ json(response, added.error ? 422 : 200, {
2069
+ ok: !added.error,
2070
+ catalog: shownCatalog(added, true),
2071
+ ...(added.error ? { error: added.error } : {}),
2072
+ });
2073
+ }
2074
+ catch (error) {
2075
+ json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
2076
+ }
2077
+ return;
2078
+ }
2079
+ const [rawId = "", action = "", entryId = "", sub = ""] = path.slice("/api/catalogs/".length).split("/");
2080
+ const id = decodeURIComponent(rawId);
2081
+ if (!catalogs.get(id)) {
2082
+ json(response, 404, { error: "no such catalog" });
2083
+ return;
2084
+ }
2085
+ if (action === "" && request.method === "DELETE") {
2086
+ json(response, 200, { ok: catalogs.remove(id) });
2087
+ return;
2088
+ }
2089
+ if (action === "refresh" && request.method === "POST") {
2090
+ const refreshed = await catalogs.refresh(id);
2091
+ json(response, refreshed && !refreshed.error ? 200 : 422, {
2092
+ ok: refreshed !== null && !refreshed.error,
2093
+ ...(refreshed ? { catalog: shownCatalog(refreshed, true) } : {}),
2094
+ ...(refreshed?.error ? { error: refreshed.error } : {}),
2095
+ });
2096
+ return;
2097
+ }
2098
+ if (action === "groups" && request.method === "GET") {
2099
+ json(response, 200, { groups: catalogs.groups(id) ?? [] });
2100
+ return;
2101
+ }
2102
+ if (action === "entries" && entryId === "" && request.method === "GET") {
2103
+ const page = catalogs.entries_(id, {
2104
+ group: url.searchParams.get("group") ?? "",
2105
+ q: url.searchParams.get("q") ?? "",
2106
+ offset: Number(url.searchParams.get("offset") ?? "0") || 0,
2107
+ limit: Number(url.searchParams.get("limit") ?? "200") || 200,
2108
+ }) ?? { total: 0, entries: [] };
2109
+ json(response, 200, { total: page.total, entries: page.entries.map(shownEntry) });
2110
+ return;
2111
+ }
2112
+ const entry = action === "entries" && entryId !== "" ? catalogs.entry(id, decodeURIComponent(entryId)) : null;
2113
+ if (!entry) {
2114
+ json(response, 404, { error: "no such entry" });
2115
+ return;
2116
+ }
2117
+ // Play. A live entry becomes a channel, started for whoever asked and
2118
+ // stopped a minute after the last viewer leaves; a film is played on
2119
+ // its own, straight from the source through ffmpeg.
2120
+ if (sub === "play" && request.method === "POST") {
2121
+ if (!entry.live) {
2122
+ json(response, 200, {
2123
+ kind: "vod",
2124
+ url: `/api/catalogs/${encodeURIComponent(id)}/entries/${encodeURIComponent(entry.id)}/stream`,
2125
+ name: entry.title,
2126
+ });
2127
+ return;
2128
+ }
2129
+ if (!options.channels) {
2130
+ json(response, 503, { error: "this server cannot carry channels" });
2131
+ return;
2132
+ }
2133
+ const channelId = cleanId(`cat-${entry.id}`);
2134
+ if (!options.channels.has(channelId)) {
2135
+ if (options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2136
+ json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2137
+ return;
2138
+ }
2139
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
2140
+ if (!started) {
2141
+ json(response, 409, { error: "that channel is already starting" });
2142
+ return;
2143
+ }
2144
+ options.channels.ephemeral(channelId);
2145
+ }
2146
+ json(response, 200, { kind: "live", channel: channelId, name: entry.title });
2147
+ return;
2148
+ }
2149
+ if (sub === "stream" && request.method === "GET") {
2150
+ if (!options.media) {
2151
+ json(response, 403, { error: "media streaming is off" });
2152
+ return;
2153
+ }
2154
+ watch(request, response, "stream", entry.title);
2155
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
2156
+ if (codecs.video !== "") {
2157
+ pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
2158
+ }
2159
+ else {
2160
+ transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
2161
+ }
2162
+ return;
2163
+ }
2164
+ json(response, 404, { error: "no such endpoint" });
2165
+ return;
2166
+ }
1879
2167
  // A channel is one publisher and everybody listening to them. Two or three
1880
2168
  // devices can publish at once, each to their own channel, and a listener
1881
2169
  // picks which to hear.
@@ -2841,7 +3129,18 @@ export function createServer(engine, options) {
2841
3129
  }
2842
3130
  export async function serve(argv, version = "0.1.0") {
2843
3131
  const options = parseServeArgs(argv);
2844
- 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
+ }
2845
3144
  const tools = detectTools();
2846
3145
  // Names now, tags later.
2847
3146
  //
@@ -2906,6 +3205,11 @@ export async function serve(argv, version = "0.1.0") {
2906
3205
  console.log(` "${one.id}" is already on.`);
2907
3206
  });
2908
3207
  }
3208
+ // The m3u catalogs kept here: read from disk now, and any that were never
3209
+ // read are fetched in the background so browsing does not wait on a provider.
3210
+ const catalogs = new Catalogs(stateDir(), options.port);
3211
+ catalogs.load();
3212
+ void catalogs.warm().catch(() => undefined);
2909
3213
  const destinations = parseDestinations(options.rtmp);
2910
3214
  const broadcaster = new Broadcaster(tools.ffmpeg);
2911
3215
  const ingest = options.ingest
@@ -2926,6 +3230,53 @@ export async function serve(argv, version = "0.1.0") {
2926
3230
  : undefined;
2927
3231
  const follows = pool ? new Follows(pool) : undefined;
2928
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;
2929
3280
  // The two things that were promises kept only in memory: a caller who was
2930
3281
  // told they would be texted, and the ended stream a code still points at.
2931
3282
  const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
@@ -3066,7 +3417,7 @@ export async function serve(argv, version = "0.1.0") {
3066
3417
  }
3067
3418
  // Read before listening, so a missing or unreadable certificate is a sentence
3068
3419
  // now rather than a connection that resets later.
3069
- const tls = options.tlsCert
3420
+ let tls = options.tlsCert
3070
3421
  ? (() => {
3071
3422
  try {
3072
3423
  return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
@@ -3076,6 +3427,42 @@ export async function serve(argv, version = "0.1.0") {
3076
3427
  }
3077
3428
  })()
3078
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
+ }
3079
3466
  // Filled in below, when the RTMP listeners are opened. Read through a
3080
3467
  // function so the handler sees the list rather than the empty array it was
3081
3468
  // built with.
@@ -3092,6 +3479,7 @@ export async function serve(argv, version = "0.1.0") {
3092
3479
  owner,
3093
3480
  channels,
3094
3481
  rememberChannels: remembering,
3482
+ catalogs,
3095
3483
  publishUrls: () => publishUrls,
3096
3484
  serverName: options.name || hostname(),
3097
3485
  homeSource: root,
@@ -3150,6 +3538,10 @@ export async function serve(argv, version = "0.1.0") {
3150
3538
  ...(directory ? { directory } : {}),
3151
3539
  ...(follows ? { follows, vapidPublicKey } : {}),
3152
3540
  ...(favorites ? { favorites } : {}),
3541
+ ...(names ? { names } : {}),
3542
+ ...(certs ? { certs } : {}),
3543
+ dnsZone: zoneName,
3544
+ ...(throttle ? { throttle } : {}),
3153
3545
  ...(partyLine ? { partyLine } : {}),
3154
3546
  // Accounts live where the directory lives, and only there: a nixamp on a
3155
3547
  // laptop has nobody to be an account of.
@@ -3192,6 +3584,23 @@ export async function serve(argv, version = "0.1.0") {
3192
3584
  });
3193
3585
  const bound = server.address();
3194
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
+ }
3195
3604
  const io = {
3196
3605
  read: readIfPossible,
3197
3606
  run: (command, args) => {
@@ -3397,6 +3806,10 @@ export async function serve(argv, version = "0.1.0") {
3397
3806
  name: options.name || hostname(),
3398
3807
  url: listen,
3399
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) } : {}),
3400
3813
  // Asked at every heartbeat rather than once, because the library is
3401
3814
  // read after the port opens and is still arriving when this is made.
3402
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>;