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/dist/certs.d.ts +112 -0
- package/dist/certs.js +217 -0
- package/dist/directory.d.ts +10 -0
- package/dist/directory.js +6 -0
- package/dist/dns.d.ts +63 -0
- package/dist/dns.js +171 -0
- package/dist/library.d.ts +36 -0
- package/dist/library.js +167 -0
- package/dist/main.js +92 -4
- package/dist/names.d.ts +66 -0
- package/dist/names.js +184 -0
- package/dist/naming.d.ts +57 -0
- package/dist/naming.js +150 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +3 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +277 -5
- package/dist/session.d.ts +8 -0
- package/dist/session.js +103 -0
- package/package.json +8 -2
- package/src/certs.ts +294 -0
- package/src/directory.ts +16 -0
- package/src/dns.ts +207 -0
- package/src/library.ts +175 -0
- package/src/main.ts +88 -4
- package/src/names.ts +239 -0
- package/src/naming.ts +197 -0
- package/src/publish.ts +5 -0
- package/src/server.ts +295 -5
- package/src/session.ts +117 -0
- package/web/dist/assets/{hls-3VKVEQE3-DIpul2Yc.js → hls-3VKVEQE3-zZ82pQ4L.js} +1 -1
- package/web/dist/assets/index-BY4kBf__.js +1 -0
- package/web/dist/assets/index-Cxa8e1Gt.css +1 -0
- package/web/dist/assets/{mpegts-DQ14PwSl.js → mpegts-Dp4wqPb2.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cq2NKawM.js → mpegts-LO6RVLD6-CSVpSwIL.js} +1 -1
- package/web/dist/index.html +5 -2
- package/web/dist/install.sh +3 -1
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BqirNVEK.js +0 -1
- package/web/dist/assets/index-C-gJFEFf.css +0 -1
package/dist/library.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the media is.
|
|
3
|
+
*
|
|
4
|
+
* A server that is not told serves the directory it was started in, and a
|
|
5
|
+
* daemon restarted from a home directory served the home directory: every
|
|
6
|
+
* key, session and download on the machine, listed in a public directory
|
|
7
|
+
* under a link anybody could be handed. That is not a default anyone chose,
|
|
8
|
+
* so it is not a default any more.
|
|
9
|
+
*
|
|
10
|
+
* The library is a setting. It is asked for once -- at sign-in, or the first
|
|
11
|
+
* time the daemon is started without being told -- and kept beside the keys,
|
|
12
|
+
* so `nixamp daemon start` on its own means the same folder every time. And
|
|
13
|
+
* some folders are refused however they are asked for: the whole filesystem,
|
|
14
|
+
* the home directory, anything above it, and the hidden folders where keys
|
|
15
|
+
* and sessions live.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
21
|
+
import { stateDir } from "./daemon.js";
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return join(stateDir(), "config.json");
|
|
24
|
+
}
|
|
25
|
+
export function readConfig() {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
28
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
29
|
+
return {};
|
|
30
|
+
const record = parsed;
|
|
31
|
+
return { ...(typeof record["library"] === "string" ? { library: record["library"] } : {}) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function writeConfig(patch) {
|
|
38
|
+
const next = { ...readConfig(), ...patch };
|
|
39
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
40
|
+
writeFileSync(configPath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
41
|
+
}
|
|
42
|
+
/** The saved library, or "" when nobody has said yet. */
|
|
43
|
+
export function readLibrary() {
|
|
44
|
+
return readConfig().library ?? "";
|
|
45
|
+
}
|
|
46
|
+
export function writeLibrary(path) {
|
|
47
|
+
writeConfig({ library: resolve(path) });
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Why a folder must not be served, or "" when it may be.
|
|
51
|
+
*
|
|
52
|
+
* Judged on the resolved path, so `~/..` and `/home/me/./` do not slip past.
|
|
53
|
+
* A hidden folder inside home is refused because that is where `.ssh`,
|
|
54
|
+
* `.config` and nixamp's own keys are; a hidden folder elsewhere is somebody's
|
|
55
|
+
* deliberate choice.
|
|
56
|
+
*/
|
|
57
|
+
export function forbiddenLibrary(path, home = homedir()) {
|
|
58
|
+
const full = resolve(path);
|
|
59
|
+
const house = resolve(home);
|
|
60
|
+
if (full === "/" || /^[A-Za-z]:\\?$/.test(full))
|
|
61
|
+
return "the whole filesystem";
|
|
62
|
+
if (full === house)
|
|
63
|
+
return "your whole home directory";
|
|
64
|
+
if (house.startsWith(`${full}/`))
|
|
65
|
+
return "a folder above your home directory";
|
|
66
|
+
if (full.startsWith(`${house}/`)) {
|
|
67
|
+
// Any dotted segment on the way, not only the last: ~/.local/state is
|
|
68
|
+
// where nixamp's own keys are, and its basename says nothing.
|
|
69
|
+
const hidden = full.slice(house.length + 1).split("/").find((segment) => segment.startsWith("."));
|
|
70
|
+
if (hidden)
|
|
71
|
+
return `a hidden folder (${hidden}), which is where keys and sessions live`;
|
|
72
|
+
}
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
/** Folders a person is likely to mean, in the order they are likely to mean them. */
|
|
76
|
+
export function suggestedLibraries(home = homedir()) {
|
|
77
|
+
return ["Music", "Videos", "Movies", "Downloads"]
|
|
78
|
+
.map((name) => join(home, name))
|
|
79
|
+
.filter((path) => {
|
|
80
|
+
try {
|
|
81
|
+
return statSync(path).isDirectory();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** One line from the terminal. Split out so the question can be tested. */
|
|
89
|
+
export async function askLine(question) {
|
|
90
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
91
|
+
try {
|
|
92
|
+
return (await rl.question(question)).trim();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rl.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Ask where the media is, refusing the answers that must be refused, and
|
|
100
|
+
* keep the answer. Empty when the person gave up or gave nothing usable
|
|
101
|
+
* three times.
|
|
102
|
+
*/
|
|
103
|
+
export async function askLibrary(ask = askLine, home = homedir(), say = (line) => console.log(line)) {
|
|
104
|
+
const suggestions = suggestedLibraries(home);
|
|
105
|
+
say("Where is your media? nixamp serves one folder and nothing outside it.");
|
|
106
|
+
for (const path of suggestions)
|
|
107
|
+
say(` ${path}`);
|
|
108
|
+
const fallback = suggestions[0] ?? "";
|
|
109
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
110
|
+
const typed = await ask(fallback ? `Folder [${fallback}]: ` : "Folder: ");
|
|
111
|
+
const chosen = (typed || fallback).replace(/^~(?=$|\/)/, home);
|
|
112
|
+
if (!chosen)
|
|
113
|
+
continue;
|
|
114
|
+
const full = resolve(chosen);
|
|
115
|
+
const why = forbiddenLibrary(full, home);
|
|
116
|
+
if (why) {
|
|
117
|
+
say(` nixamp will not serve ${why}. Pick a folder with your media in it.`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!existsSync(full) || !statSync(full).isDirectory()) {
|
|
121
|
+
say(` ${full} is not a folder here.`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
writeLibrary(full);
|
|
125
|
+
say(` Serving ${full}. Change it any time with \`nixamp library <folder>\`.`);
|
|
126
|
+
return full;
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The library to use: the saved one, or -- when there is somebody to ask --
|
|
132
|
+
* the one they name now. "" when there is neither.
|
|
133
|
+
*/
|
|
134
|
+
export async function chooseLibrary(interactive, ask) {
|
|
135
|
+
const saved = readLibrary();
|
|
136
|
+
if (saved)
|
|
137
|
+
return saved;
|
|
138
|
+
if (!interactive)
|
|
139
|
+
return "";
|
|
140
|
+
return askLibrary(ask);
|
|
141
|
+
}
|
|
142
|
+
/** `nixamp library [folder]`: say where the media is, or set it. */
|
|
143
|
+
export async function libraryCommand(argv) {
|
|
144
|
+
const [given] = argv;
|
|
145
|
+
if (!given) {
|
|
146
|
+
const saved = readLibrary();
|
|
147
|
+
if (!saved) {
|
|
148
|
+
console.log("nixamp: no library set. `nixamp library ~/Music` sets it, and `nixamp daemon start` serves it.");
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
console.log(saved);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const full = resolve(given.replace(/^~(?=$|\/)/, homedir()));
|
|
155
|
+
const why = forbiddenLibrary(full);
|
|
156
|
+
if (why) {
|
|
157
|
+
console.error(`nixamp: will not serve ${why}. Pick a folder with your media in it.`);
|
|
158
|
+
return 64;
|
|
159
|
+
}
|
|
160
|
+
if (!existsSync(full) || !statSync(full).isDirectory()) {
|
|
161
|
+
console.error(`nixamp: ${full} is not a folder here.`);
|
|
162
|
+
return 66;
|
|
163
|
+
}
|
|
164
|
+
writeLibrary(full);
|
|
165
|
+
console.log(`nixamp will serve ${full}. Start it with \`nixamp daemon start\`.`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -53,6 +53,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
53
53
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
54
54
|
nixamp logout / whoami forget it, or check it
|
|
55
55
|
nixamp token create|list|revoke tokens for a machine that cannot sign in
|
|
56
|
+
nixamp dns [set|rm] names under your handle, for your servers
|
|
57
|
+
nixamp library [folder] where the media is; the daemon serves this and nothing outside it
|
|
56
58
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
57
59
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
58
60
|
nixamp update [version] re-run the installer, keeping your choices
|
|
@@ -164,6 +166,18 @@ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all.
|
|
|
164
166
|
A token is shown once because the server keeps only its hash. Put it in the
|
|
165
167
|
environment as NIXAMP_TOKEN, or keep it here with \`nixamp login --token\`.
|
|
166
168
|
Signing out does not touch it: that is what it is for.
|
|
169
|
+
`,
|
|
170
|
+
dns: `nixamp dns — names under your handle, for your servers.
|
|
171
|
+
|
|
172
|
+
nixamp dns every name on your account, and where it points
|
|
173
|
+
nixamp dns set NAME NAME.<handle>.nixamp.com, pointed at this machine
|
|
174
|
+
nixamp dns set NAME --a IP --aaaa IP pointed somewhere you name; "off" clears one
|
|
175
|
+
nixamp dns set NAME --ttl N how long resolvers may keep it (seconds)
|
|
176
|
+
nixamp dns rm NAME take the name away
|
|
177
|
+
|
|
178
|
+
The DNS keys stay on nixamp.com. A signed-in \`nixamp serve\` names itself
|
|
179
|
+
this way on start and picks up the handle's certificate, so a server is
|
|
180
|
+
https://NAME.<handle>.nixamp.com with nothing typed here.
|
|
167
181
|
`,
|
|
168
182
|
daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
|
|
169
183
|
|
|
@@ -221,9 +235,45 @@ async function runDaemon(argv) {
|
|
|
221
235
|
const d = await import("./daemon.js");
|
|
222
236
|
const [action = "status", ...rest] = argv;
|
|
223
237
|
const entry = fileURLToPath(new URL("./main.js", import.meta.url));
|
|
238
|
+
// The daemon has to know where the media is. Told a folder, that folder is
|
|
239
|
+
// kept as the library so the next start needs no telling; told nothing, the
|
|
240
|
+
// saved library is used, or asked for when there is somebody to ask.
|
|
241
|
+
const withLibrary = async (args) => {
|
|
242
|
+
const { forbiddenLibrary, chooseLibrary, writeLibrary } = await import("./library.js");
|
|
243
|
+
const { parseServeArgs } = await import("./server.js");
|
|
244
|
+
const { isRemote } = await import("./sources.js");
|
|
245
|
+
let given = "";
|
|
246
|
+
try {
|
|
247
|
+
given = parseServeArgs(args).root;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// A bad flag is the daemon's to report, in its own words.
|
|
251
|
+
return args;
|
|
252
|
+
}
|
|
253
|
+
if (given) {
|
|
254
|
+
if (!isRemote(given)) {
|
|
255
|
+
const why = forbiddenLibrary(given);
|
|
256
|
+
if (why) {
|
|
257
|
+
console.error(`nixamp: will not serve ${why}. Pick a folder with your media in it.`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
writeLibrary(given);
|
|
261
|
+
}
|
|
262
|
+
return args;
|
|
263
|
+
}
|
|
264
|
+
const library = await chooseLibrary(process.stdin.isTTY === true);
|
|
265
|
+
if (!library) {
|
|
266
|
+
console.error("nixamp: which folder? `nixamp library ~/Music` once, then `nixamp daemon start`.");
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return [library, ...args];
|
|
270
|
+
};
|
|
224
271
|
if (action === "start") {
|
|
272
|
+
const args = await withLibrary(rest);
|
|
273
|
+
if (args === null)
|
|
274
|
+
return 64;
|
|
225
275
|
try {
|
|
226
|
-
const state = await d.start(
|
|
276
|
+
const state = await d.start(args, entry);
|
|
227
277
|
for (const line of d.daemonLines(state))
|
|
228
278
|
console.log(line);
|
|
229
279
|
return 0;
|
|
@@ -239,8 +289,13 @@ async function runDaemon(argv) {
|
|
|
239
289
|
return 0;
|
|
240
290
|
}
|
|
241
291
|
if (action === "restart") {
|
|
292
|
+
// With no arguments the flags it was started with are replayed, library
|
|
293
|
+
// included; with arguments, the library rule applies as for start.
|
|
294
|
+
const args = rest.length === 0 ? rest : await withLibrary(rest);
|
|
295
|
+
if (args === null)
|
|
296
|
+
return 64;
|
|
242
297
|
try {
|
|
243
|
-
const state = await d.restart(
|
|
298
|
+
const state = await d.restart(args, entry);
|
|
244
299
|
for (const line of d.daemonLines(state))
|
|
245
300
|
console.log(line);
|
|
246
301
|
return 0;
|
|
@@ -298,7 +353,14 @@ export async function main() {
|
|
|
298
353
|
}
|
|
299
354
|
if (first === "serve") {
|
|
300
355
|
const { serve } = await import("./server.js");
|
|
301
|
-
|
|
356
|
+
try {
|
|
357
|
+
await serve(rest, version());
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
// A refusal is a sentence, not a stack trace.
|
|
361
|
+
console.error(error.message);
|
|
362
|
+
process.exitCode = 64;
|
|
363
|
+
}
|
|
302
364
|
return;
|
|
303
365
|
}
|
|
304
366
|
if (first === "daemon") {
|
|
@@ -313,6 +375,14 @@ export async function main() {
|
|
|
313
375
|
if (first === "login" || first === "signup") {
|
|
314
376
|
const { login } = await import("./session.js");
|
|
315
377
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
378
|
+
// Signed in is the moment a machine is about to be a server, so it is
|
|
379
|
+
// the moment to ask where the media is -- once, kept beside the keys.
|
|
380
|
+
if (process.exitCode === 0 && process.stdin.isTTY) {
|
|
381
|
+
const { chooseLibrary } = await import("./library.js");
|
|
382
|
+
const library = await chooseLibrary(true);
|
|
383
|
+
if (library)
|
|
384
|
+
console.log(" Start serving it with `nixamp daemon start`.");
|
|
385
|
+
}
|
|
316
386
|
return;
|
|
317
387
|
}
|
|
318
388
|
if (first === "opendir" || first === "opendirs") {
|
|
@@ -330,6 +400,16 @@ export async function main() {
|
|
|
330
400
|
process.exitCode = await tokens(rest);
|
|
331
401
|
return;
|
|
332
402
|
}
|
|
403
|
+
if (first === "dns") {
|
|
404
|
+
const { dns } = await import("./session.js");
|
|
405
|
+
process.exitCode = await dns(rest);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (first === "library") {
|
|
409
|
+
const { libraryCommand } = await import("./library.js");
|
|
410
|
+
process.exitCode = await libraryCommand(rest);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
333
413
|
if (first === "logout" || first === "whoami") {
|
|
334
414
|
const session = await import("./session.js");
|
|
335
415
|
process.exitCode = first === "logout" ? session.logout() : await session.whoami();
|
|
@@ -346,8 +426,16 @@ export async function main() {
|
|
|
346
426
|
}
|
|
347
427
|
// resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
|
|
348
428
|
// left exactly as it was typed.
|
|
349
|
-
|
|
429
|
+
// The saved library before the current directory, and never a folder the
|
|
430
|
+
// server would refuse: `d` hands this same folder to a daemon.
|
|
431
|
+
const { forbiddenLibrary, readLibrary } = await import("./library.js");
|
|
432
|
+
const asked = first ?? (readLibrary() || ".");
|
|
350
433
|
const target = isRemote(asked) ? asked : resolve(asked);
|
|
434
|
+
const why = isRemote(target) ? "" : forbiddenLibrary(target);
|
|
435
|
+
if (why) {
|
|
436
|
+
console.error(`nixamp: will not play ${why}. Pick a folder with your media in it, or set one: nixamp library ~/Music`);
|
|
437
|
+
process.exit(64);
|
|
438
|
+
}
|
|
351
439
|
const tools = detectTools();
|
|
352
440
|
// The noise it makes when it wakes up. Started before the library is walked
|
|
353
441
|
// so it plays over the wait rather than after it, and never awaited: a
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's names: the servers it runs, each with a hostname under its handle.
|
|
3
|
+
*
|
|
4
|
+
* `server2.chovy.nixamp.com` used to be a record somebody typed into the
|
|
5
|
+
* registrar by hand, which is why the second machine came up as a bare IP
|
|
6
|
+
* over http. A name is now a row against the account, mirrored into the zone
|
|
7
|
+
* as an A and an AAAA record, and the account may make, change and drop as
|
|
8
|
+
* many as it reasonably needs without anybody holding a registrar key.
|
|
9
|
+
*
|
|
10
|
+
* The database row is the truth and the zone follows it: the zone is written
|
|
11
|
+
* first, so a registrar that refuses is an error to the caller and nothing is
|
|
12
|
+
* stored, rather than a row that claims a name the world cannot resolve.
|
|
13
|
+
*/
|
|
14
|
+
import type { Queryable } from "./follows.ts";
|
|
15
|
+
import { type DnsZone } from "./dns.ts";
|
|
16
|
+
export interface NameRecord {
|
|
17
|
+
label: string;
|
|
18
|
+
host: string;
|
|
19
|
+
a: string;
|
|
20
|
+
aaaa: string;
|
|
21
|
+
ttl: number;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
/** An error with the HTTP status it deserves, so a route can pass it straight on. */
|
|
26
|
+
export declare class NameError extends Error {
|
|
27
|
+
status: number;
|
|
28
|
+
constructor(status: number, message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The label a server may have: two to thirty of [a-z0-9-], not starting or
|
|
32
|
+
* ending with a hyphen, lowercased for the caller who typed it in capitals.
|
|
33
|
+
* "" when it is not one, so a route can say so in one sentence.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validLabel(value: unknown): string;
|
|
36
|
+
export declare const MIN_TTL = 600;
|
|
37
|
+
export declare const MAX_TTL = 86400;
|
|
38
|
+
/** Enough for a rack, not enough for a squatter. */
|
|
39
|
+
export declare const DEFAULT_PER_ACCOUNT = 20;
|
|
40
|
+
export declare class Names {
|
|
41
|
+
private readonly db;
|
|
42
|
+
private readonly dns;
|
|
43
|
+
private readonly limits;
|
|
44
|
+
private ready;
|
|
45
|
+
constructor(db: Queryable, dns: DnsZone, limits?: {
|
|
46
|
+
perAccount?: number;
|
|
47
|
+
});
|
|
48
|
+
private ensure;
|
|
49
|
+
private host;
|
|
50
|
+
private record;
|
|
51
|
+
list(accountId: string, handle: string): Promise<NameRecord[]>;
|
|
52
|
+
/** The row for one name, whoever owns it. */
|
|
53
|
+
private find;
|
|
54
|
+
/**
|
|
55
|
+
* Make or change a name. `null` for a family takes that record away,
|
|
56
|
+
* `undefined` leaves it as it was, so a server that has only ever had an
|
|
57
|
+
* IPv4 address can be given an IPv6 one without restating the first.
|
|
58
|
+
*/
|
|
59
|
+
set(accountId: string, handle: string, wanted: unknown, want: {
|
|
60
|
+
a?: string | null;
|
|
61
|
+
aaaa?: string | null;
|
|
62
|
+
ttl?: number;
|
|
63
|
+
}): Promise<NameRecord>;
|
|
64
|
+
/** Take a name away. False when it is not this account's to take. */
|
|
65
|
+
remove(accountId: string, handle: string, wanted: unknown): Promise<boolean>;
|
|
66
|
+
}
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { isReserved } from "./handles.js";
|
|
2
|
+
import { isIPv4, isIPv6 } from "./dns.js";
|
|
3
|
+
/** An error with the HTTP status it deserves, so a route can pass it straight on. */
|
|
4
|
+
export class NameError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Names nobody may give a server, over and above the handles nobody may take:
|
|
13
|
+
* the mail and discovery names a client resolves on its own, anything an
|
|
14
|
+
* underscore marks as a protocol record, and anything punycode-shaped.
|
|
15
|
+
*/
|
|
16
|
+
const RESERVED_LABELS = new Set([
|
|
17
|
+
"www", "mail", "mx", "smtp", "imap", "pop", "ns1", "ns2", "ftp",
|
|
18
|
+
"_dmarc", "_acme-challenge", "autoconfig", "autodiscover",
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* The label a server may have: two to thirty of [a-z0-9-], not starting or
|
|
22
|
+
* ending with a hyphen, lowercased for the caller who typed it in capitals.
|
|
23
|
+
* "" when it is not one, so a route can say so in one sentence.
|
|
24
|
+
*/
|
|
25
|
+
export function validLabel(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return "";
|
|
28
|
+
const label = value.trim().toLowerCase();
|
|
29
|
+
if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(label))
|
|
30
|
+
return "";
|
|
31
|
+
if (label.startsWith("_") || label.startsWith("xn--"))
|
|
32
|
+
return "";
|
|
33
|
+
if (RESERVED_LABELS.has(label) || isReserved(label))
|
|
34
|
+
return "";
|
|
35
|
+
return label;
|
|
36
|
+
}
|
|
37
|
+
const TABLE = "dns_names";
|
|
38
|
+
const SCHEMA = `
|
|
39
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
40
|
+
account_id TEXT NOT NULL,
|
|
41
|
+
handle TEXT NOT NULL,
|
|
42
|
+
label TEXT NOT NULL,
|
|
43
|
+
a TEXT NOT NULL DEFAULT '',
|
|
44
|
+
aaaa TEXT NOT NULL DEFAULT '',
|
|
45
|
+
ttl INTEGER NOT NULL DEFAULT 600,
|
|
46
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
47
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
48
|
+
PRIMARY KEY (handle, label)
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_account ON ${TABLE} (account_id);
|
|
51
|
+
`;
|
|
52
|
+
export const MIN_TTL = 600;
|
|
53
|
+
export const MAX_TTL = 86_400;
|
|
54
|
+
/** Enough for a rack, not enough for a squatter. */
|
|
55
|
+
export const DEFAULT_PER_ACCOUNT = 20;
|
|
56
|
+
function when(value) {
|
|
57
|
+
if (value instanceof Date)
|
|
58
|
+
return value.getTime();
|
|
59
|
+
const parsed = new Date(String(value ?? 0)).getTime();
|
|
60
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
61
|
+
}
|
|
62
|
+
export class Names {
|
|
63
|
+
db;
|
|
64
|
+
dns;
|
|
65
|
+
limits;
|
|
66
|
+
ready = null;
|
|
67
|
+
constructor(db, dns, limits = {}) {
|
|
68
|
+
this.db = db;
|
|
69
|
+
this.dns = dns;
|
|
70
|
+
this.limits = limits;
|
|
71
|
+
}
|
|
72
|
+
async ensure() {
|
|
73
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
74
|
+
await this.ready;
|
|
75
|
+
}
|
|
76
|
+
host(handle, label) {
|
|
77
|
+
return `${label}.${handle}.${this.dns.zone}`;
|
|
78
|
+
}
|
|
79
|
+
record(handle, row) {
|
|
80
|
+
return {
|
|
81
|
+
label: row.label,
|
|
82
|
+
host: this.host(handle, row.label),
|
|
83
|
+
a: row.a ?? "",
|
|
84
|
+
aaaa: row.aaaa ?? "",
|
|
85
|
+
ttl: Number(row.ttl) || MIN_TTL,
|
|
86
|
+
createdAt: when(row.created_at),
|
|
87
|
+
updatedAt: when(row.updated_at),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async list(accountId, handle) {
|
|
91
|
+
await this.ensure();
|
|
92
|
+
const { rows } = await this.db.query(`SELECT account_id, handle, label, a, aaaa, ttl, created_at, updated_at
|
|
93
|
+
FROM ${TABLE} WHERE account_id = $1 AND handle = $2 ORDER BY created_at`, [accountId, handle]);
|
|
94
|
+
return rows.map((row) => this.record(handle, row));
|
|
95
|
+
}
|
|
96
|
+
/** The row for one name, whoever owns it. */
|
|
97
|
+
async find(handle, label) {
|
|
98
|
+
const { rows } = await this.db.query(`SELECT account_id, handle, label, a, aaaa, ttl, created_at, updated_at
|
|
99
|
+
FROM ${TABLE} WHERE handle = $1 AND label = $2`, [handle, label]);
|
|
100
|
+
return rows[0] ?? null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Make or change a name. `null` for a family takes that record away,
|
|
104
|
+
* `undefined` leaves it as it was, so a server that has only ever had an
|
|
105
|
+
* IPv4 address can be given an IPv6 one without restating the first.
|
|
106
|
+
*/
|
|
107
|
+
async set(accountId, handle, wanted, want) {
|
|
108
|
+
const label = validLabel(wanted);
|
|
109
|
+
if (label === "")
|
|
110
|
+
throw new NameError(400, "that is not a name a server can have");
|
|
111
|
+
if (want.a !== undefined && want.a !== null && !isIPv4(want.a)) {
|
|
112
|
+
throw new NameError(400, "that is not an IPv4 address");
|
|
113
|
+
}
|
|
114
|
+
if (want.aaaa !== undefined && want.aaaa !== null && !isIPv6(want.aaaa)) {
|
|
115
|
+
throw new NameError(400, "that is not an IPv6 address");
|
|
116
|
+
}
|
|
117
|
+
await this.ensure();
|
|
118
|
+
const existing = await this.find(handle, label);
|
|
119
|
+
if (existing !== null && existing.account_id !== accountId) {
|
|
120
|
+
throw new NameError(409, "that name belongs to somebody else");
|
|
121
|
+
}
|
|
122
|
+
if (existing === null) {
|
|
123
|
+
const { rows } = await this.db.query(`SELECT count(*)::int AS n FROM ${TABLE} WHERE account_id = $1`, [accountId]);
|
|
124
|
+
const have = Number(rows[0]?.n ?? 0);
|
|
125
|
+
const limit = this.limits.perAccount ?? DEFAULT_PER_ACCOUNT;
|
|
126
|
+
if (have >= limit)
|
|
127
|
+
throw new NameError(422, `an account may have ${limit} names, and this one has ${have}`);
|
|
128
|
+
}
|
|
129
|
+
// What the name will point at once this is done.
|
|
130
|
+
const a = want.a === undefined ? (existing?.a ?? "") : (want.a ?? "");
|
|
131
|
+
const aaaa = want.aaaa === undefined ? (existing?.aaaa ?? "") : (want.aaaa ?? "");
|
|
132
|
+
if (a === "" && aaaa === "")
|
|
133
|
+
throw new NameError(400, "give an address");
|
|
134
|
+
const ttl = Math.min(MAX_TTL, Math.max(MIN_TTL, Math.floor(want.ttl ?? existing?.ttl ?? MIN_TTL)));
|
|
135
|
+
// The zone first. A registrar that says no is the caller's problem to hear
|
|
136
|
+
// about now, not a row that claims a name the world cannot resolve.
|
|
137
|
+
const host = this.host(handle, label);
|
|
138
|
+
try {
|
|
139
|
+
if (a !== "")
|
|
140
|
+
await this.dns.set(host, "A", a, ttl);
|
|
141
|
+
else if (existing !== null && existing.a !== "")
|
|
142
|
+
await this.dns.remove(host, "A");
|
|
143
|
+
if (aaaa !== "")
|
|
144
|
+
await this.dns.set(host, "AAAA", aaaa, ttl);
|
|
145
|
+
else if (existing !== null && existing.aaaa !== "")
|
|
146
|
+
await this.dns.remove(host, "AAAA");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
throw new NameError(502, `the zone did not take that record: ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
const { rows } = await this.db.query(`INSERT INTO ${TABLE} (account_id, handle, label, a, aaaa, ttl)
|
|
152
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
153
|
+
ON CONFLICT (handle, label) DO UPDATE
|
|
154
|
+
SET a = EXCLUDED.a, aaaa = EXCLUDED.aaaa, ttl = EXCLUDED.ttl, updated_at = now()
|
|
155
|
+
RETURNING account_id, handle, label, a, aaaa, ttl, created_at, updated_at`, [accountId, handle, label, a, aaaa, ttl]);
|
|
156
|
+
const row = rows[0];
|
|
157
|
+
return this.record(handle, row ?? {
|
|
158
|
+
account_id: accountId, handle, label, a, aaaa, ttl,
|
|
159
|
+
created_at: existing?.created_at ?? new Date(), updated_at: new Date(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** Take a name away. False when it is not this account's to take. */
|
|
163
|
+
async remove(accountId, handle, wanted) {
|
|
164
|
+
const label = validLabel(wanted);
|
|
165
|
+
if (label === "")
|
|
166
|
+
return false;
|
|
167
|
+
await this.ensure();
|
|
168
|
+
const existing = await this.find(handle, label);
|
|
169
|
+
if (existing === null || existing.account_id !== accountId)
|
|
170
|
+
return false;
|
|
171
|
+
const host = this.host(handle, label);
|
|
172
|
+
try {
|
|
173
|
+
await this.dns.remove(host, "A");
|
|
174
|
+
await this.dns.remove(host, "AAAA");
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new NameError(502, `the zone did not let go of that record: ${error.message}`);
|
|
178
|
+
}
|
|
179
|
+
await this.db.query(`DELETE FROM ${TABLE} WHERE account_id = $1 AND handle = $2 AND label = $3`, [
|
|
180
|
+
accountId, handle, label,
|
|
181
|
+
]);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/naming.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface Named {
|
|
2
|
+
host: string;
|
|
3
|
+
a: string;
|
|
4
|
+
aaaa: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CertFiles {
|
|
7
|
+
cert: string;
|
|
8
|
+
key: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
/** The name on the certificate, e.g. `*.chovy.nixamp.com`. */
|
|
11
|
+
host: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Claim, or refresh, this machine's name.
|
|
15
|
+
*
|
|
16
|
+
* "auto" for both families: the site records whichever addresses this request
|
|
17
|
+
* arrived from, which is the only honest answer to "what is my public
|
|
18
|
+
* address" from behind a router. Null when refused or unreachable, and the
|
|
19
|
+
* reason is said rather than thrown: a server without a name still serves.
|
|
20
|
+
*/
|
|
21
|
+
export declare function claimName(site: string, token: string, label: string, say: (line: string) => void, fetcher?: typeof fetch): Promise<Named | null>;
|
|
22
|
+
/**
|
|
23
|
+
* The handle's certificate.
|
|
24
|
+
*
|
|
25
|
+
* The first one is issued while we wait: a wildcard by DNS challenge takes a
|
|
26
|
+
* couple of minutes, which is said once so the pause reads as work rather
|
|
27
|
+
* than a hang. After that it is a cached answer on the site's side.
|
|
28
|
+
*/
|
|
29
|
+
export declare function fetchCert(site: string, token: string, opts: {
|
|
30
|
+
waitMs?: number;
|
|
31
|
+
everyMs?: number;
|
|
32
|
+
sleep?: (ms: number) => Promise<void>;
|
|
33
|
+
fetcher?: typeof fetch;
|
|
34
|
+
}, say: (line: string) => void): Promise<CertFiles | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Keep a certificate beside the keys. Private, because the key is what lets
|
|
37
|
+
* anybody be this server.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeCertFiles(stateDir: string, files: CertFiles): {
|
|
40
|
+
cert: string;
|
|
41
|
+
key: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The certificate from last time, if it is still good for more than a day.
|
|
45
|
+
* Anything closer to expiry is treated as absent so the next start fetches
|
|
46
|
+
* a fresh one rather than serving one that lapses overnight.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readCertFiles(stateDir: string, host: string): {
|
|
49
|
+
cert: string;
|
|
50
|
+
key: string;
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
} | null;
|
|
53
|
+
/**
|
|
54
|
+
* What this machine calls itself in DNS: the name it was given, else the
|
|
55
|
+
* first label of its hostname, made safe for a subdomain.
|
|
56
|
+
*/
|
|
57
|
+
export declare function labelFor(name: string, hostname: string): string;
|