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.
- package/dist/catalogs.d.ts +100 -0
- package/dist/catalogs.js +0 -0
- package/dist/certs.d.ts +112 -0
- package/dist/certs.js +217 -0
- package/dist/channels.d.ts +17 -0
- package/dist/channels.js +45 -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/owner.js +4 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +3 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.js +418 -5
- package/dist/session.d.ts +8 -0
- package/dist/session.js +103 -0
- package/package.json +8 -2
- package/src/catalogs.ts +0 -0
- package/src/certs.ts +294 -0
- package/src/channels.ts +41 -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/owner.ts +3 -0
- package/src/publish.ts +5 -0
- package/src/server.ts +446 -5
- package/src/session.ts +117 -0
- package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-C88rYdXy.js} +1 -1
- package/web/dist/assets/index-BB3VT0Ks.js +1 -0
- package/web/dist/assets/index-DrXbwjOa.css +1 -0
- package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-BMDK3Ac9.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-C06vXzyy.js} +1 -1
- package/web/dist/index.html +23 -2
- package/web/dist/install.sh +3 -1
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-ComwKkzf.js +0 -1
- package/web/dist/assets/index-D3xGDAOd.css +0 -1
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;
|
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/owner.js
CHANGED
|
@@ -102,5 +102,9 @@ export function needsAdmin(path, method = "GET") {
|
|
|
102
102
|
// Listening to a channel is not: that is what the share link is for.
|
|
103
103
|
if (path.startsWith("/api/channels/") && method !== "GET")
|
|
104
104
|
return true;
|
|
105
|
+
// Adding, refreshing or removing a catalog is administering; browsing one,
|
|
106
|
+
// and picking something in it to play, is what the link is for.
|
|
107
|
+
if (path.startsWith("/api/catalogs") && method !== "GET" && !path.endsWith("/play"))
|
|
108
|
+
return true;
|
|
105
109
|
return false;
|
|
106
110
|
}
|
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
|
@@ -13,6 +13,10 @@ import { Directory } from "./directory.ts";
|
|
|
13
13
|
import { PartyLine } from "./partyline.ts";
|
|
14
14
|
import { Follows } from "./follows.ts";
|
|
15
15
|
import { Favorites } from "./favorites.ts";
|
|
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";
|
|
16
20
|
import { type Tools, type Track } from "./audio.ts";
|
|
17
21
|
import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
|
|
18
22
|
export declare const SERVE_BAND_COUNT = 24;
|
|
@@ -34,6 +38,8 @@ export interface ServeOptions {
|
|
|
34
38
|
newKey: boolean;
|
|
35
39
|
/** Start without the noise it makes when it wakes up. */
|
|
36
40
|
noJingle: boolean;
|
|
41
|
+
/** Do not ask nixamp.com for a name and a certificate, even when signed in. */
|
|
42
|
+
noName: boolean;
|
|
37
43
|
/**
|
|
38
44
|
* Ask the local firewall to let the port through, and put it back on the way
|
|
39
45
|
* out. Off by default because it changes the machine, not just this process.
|
|
@@ -337,6 +343,23 @@ export declare class PlayerEngine implements Engine {
|
|
|
337
343
|
* looking at what is on wants "that album from the web", not every track in
|
|
338
344
|
* it. An entry names where to start, so clicking it plays.
|
|
339
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>;
|
|
357
|
+
/**
|
|
358
|
+
* How many channels a server will start on demand at once. Each is an ffmpeg,
|
|
359
|
+
* and a catalog has thousands of entries; this is what keeps a room full of
|
|
360
|
+
* curious people from becoming a room full of decoders.
|
|
361
|
+
*/
|
|
362
|
+
export declare const MAX_ON_DEMAND = 4;
|
|
340
363
|
/**
|
|
341
364
|
* Probe a source and start carrying it as a channel of its own.
|
|
342
365
|
*
|
|
@@ -414,6 +437,8 @@ export interface HandlerOptions {
|
|
|
414
437
|
channels?: Channels;
|
|
415
438
|
/** Write down the channels this server pulls, so a restart puts them back. */
|
|
416
439
|
rememberChannels?: (list: RememberedChannel[]) => void;
|
|
440
|
+
/** The m3u catalogs this server keeps, browsable by group. */
|
|
441
|
+
catalogs?: Catalogs;
|
|
417
442
|
/** Live audio going out to RTMP. */
|
|
418
443
|
broadcaster?: Broadcaster;
|
|
419
444
|
/** Where a broadcast should send, and what it should look like. */
|
|
@@ -511,6 +536,18 @@ export interface HandlerOptions {
|
|
|
511
536
|
follows?: Follows;
|
|
512
537
|
/** The servers an account hearted. nixamp.com only, like follows. */
|
|
513
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;
|
|
514
551
|
/** The VAPID public key a browser needs before it can subscribe. */
|
|
515
552
|
vapidPublicKey?: string;
|
|
516
553
|
}
|