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/src/names.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
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 { isReserved } from "./handles.ts";
|
|
16
|
+
import { isIPv4, isIPv6, type DnsZone } from "./dns.ts";
|
|
17
|
+
|
|
18
|
+
export interface NameRecord {
|
|
19
|
+
label: string;
|
|
20
|
+
host: string;
|
|
21
|
+
a: string;
|
|
22
|
+
aaaa: string;
|
|
23
|
+
ttl: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An error with the HTTP status it deserves, so a route can pass it straight on. */
|
|
29
|
+
export class NameError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
public status: number,
|
|
32
|
+
message: string,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Names nobody may give a server, over and above the handles nobody may take:
|
|
40
|
+
* the mail and discovery names a client resolves on its own, anything an
|
|
41
|
+
* underscore marks as a protocol record, and anything punycode-shaped.
|
|
42
|
+
*/
|
|
43
|
+
const RESERVED_LABELS = new Set([
|
|
44
|
+
"www", "mail", "mx", "smtp", "imap", "pop", "ns1", "ns2", "ftp",
|
|
45
|
+
"_dmarc", "_acme-challenge", "autoconfig", "autodiscover",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The label a server may have: two to thirty of [a-z0-9-], not starting or
|
|
50
|
+
* ending with a hyphen, lowercased for the caller who typed it in capitals.
|
|
51
|
+
* "" when it is not one, so a route can say so in one sentence.
|
|
52
|
+
*/
|
|
53
|
+
export function validLabel(value: unknown): string {
|
|
54
|
+
if (typeof value !== "string") return "";
|
|
55
|
+
const label = value.trim().toLowerCase();
|
|
56
|
+
if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(label)) return "";
|
|
57
|
+
if (label.startsWith("_") || label.startsWith("xn--")) return "";
|
|
58
|
+
if (RESERVED_LABELS.has(label) || isReserved(label)) return "";
|
|
59
|
+
return label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TABLE = "dns_names";
|
|
63
|
+
|
|
64
|
+
const SCHEMA = `
|
|
65
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
66
|
+
account_id TEXT NOT NULL,
|
|
67
|
+
handle TEXT NOT NULL,
|
|
68
|
+
label TEXT NOT NULL,
|
|
69
|
+
a TEXT NOT NULL DEFAULT '',
|
|
70
|
+
aaaa TEXT NOT NULL DEFAULT '',
|
|
71
|
+
ttl INTEGER NOT NULL DEFAULT 600,
|
|
72
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
73
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
74
|
+
PRIMARY KEY (handle, label)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_account ON ${TABLE} (account_id);
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
export const MIN_TTL = 600;
|
|
80
|
+
export const MAX_TTL = 86_400;
|
|
81
|
+
/** Enough for a rack, not enough for a squatter. */
|
|
82
|
+
export const DEFAULT_PER_ACCOUNT = 20;
|
|
83
|
+
|
|
84
|
+
interface Row {
|
|
85
|
+
account_id: string;
|
|
86
|
+
handle: string;
|
|
87
|
+
label: string;
|
|
88
|
+
a: string;
|
|
89
|
+
aaaa: string;
|
|
90
|
+
ttl: number;
|
|
91
|
+
created_at: unknown;
|
|
92
|
+
updated_at: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function when(value: unknown): number {
|
|
96
|
+
if (value instanceof Date) return value.getTime();
|
|
97
|
+
const parsed = new Date(String(value ?? 0)).getTime();
|
|
98
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Names {
|
|
102
|
+
private ready: Promise<void> | null = null;
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
private readonly db: Queryable,
|
|
106
|
+
private readonly dns: DnsZone,
|
|
107
|
+
private readonly limits: { perAccount?: number } = {},
|
|
108
|
+
) {}
|
|
109
|
+
|
|
110
|
+
private async ensure(): Promise<void> {
|
|
111
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
112
|
+
await this.ready;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private host(handle: string, label: string): string {
|
|
116
|
+
return `${label}.${handle}.${this.dns.zone}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private record(handle: string, row: Row): NameRecord {
|
|
120
|
+
return {
|
|
121
|
+
label: row.label,
|
|
122
|
+
host: this.host(handle, row.label),
|
|
123
|
+
a: row.a ?? "",
|
|
124
|
+
aaaa: row.aaaa ?? "",
|
|
125
|
+
ttl: Number(row.ttl) || MIN_TTL,
|
|
126
|
+
createdAt: when(row.created_at),
|
|
127
|
+
updatedAt: when(row.updated_at),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async list(accountId: string, handle: string): Promise<NameRecord[]> {
|
|
132
|
+
await this.ensure();
|
|
133
|
+
const { rows } = await this.db.query(
|
|
134
|
+
`SELECT account_id, handle, label, a, aaaa, ttl, created_at, updated_at
|
|
135
|
+
FROM ${TABLE} WHERE account_id = $1 AND handle = $2 ORDER BY created_at`,
|
|
136
|
+
[accountId, handle],
|
|
137
|
+
);
|
|
138
|
+
return (rows as unknown as Row[]).map((row) => this.record(handle, row));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The row for one name, whoever owns it. */
|
|
142
|
+
private async find(handle: string, label: string): Promise<Row | null> {
|
|
143
|
+
const { rows } = await this.db.query(
|
|
144
|
+
`SELECT account_id, handle, label, a, aaaa, ttl, created_at, updated_at
|
|
145
|
+
FROM ${TABLE} WHERE handle = $1 AND label = $2`,
|
|
146
|
+
[handle, label],
|
|
147
|
+
);
|
|
148
|
+
return (rows[0] as unknown as Row | undefined) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Make or change a name. `null` for a family takes that record away,
|
|
153
|
+
* `undefined` leaves it as it was, so a server that has only ever had an
|
|
154
|
+
* IPv4 address can be given an IPv6 one without restating the first.
|
|
155
|
+
*/
|
|
156
|
+
async set(
|
|
157
|
+
accountId: string,
|
|
158
|
+
handle: string,
|
|
159
|
+
wanted: unknown,
|
|
160
|
+
want: { a?: string | null; aaaa?: string | null; ttl?: number },
|
|
161
|
+
): Promise<NameRecord> {
|
|
162
|
+
const label = validLabel(wanted);
|
|
163
|
+
if (label === "") throw new NameError(400, "that is not a name a server can have");
|
|
164
|
+
if (want.a !== undefined && want.a !== null && !isIPv4(want.a)) {
|
|
165
|
+
throw new NameError(400, "that is not an IPv4 address");
|
|
166
|
+
}
|
|
167
|
+
if (want.aaaa !== undefined && want.aaaa !== null && !isIPv6(want.aaaa)) {
|
|
168
|
+
throw new NameError(400, "that is not an IPv6 address");
|
|
169
|
+
}
|
|
170
|
+
await this.ensure();
|
|
171
|
+
|
|
172
|
+
const existing = await this.find(handle, label);
|
|
173
|
+
if (existing !== null && existing.account_id !== accountId) {
|
|
174
|
+
throw new NameError(409, "that name belongs to somebody else");
|
|
175
|
+
}
|
|
176
|
+
if (existing === null) {
|
|
177
|
+
const { rows } = await this.db.query(
|
|
178
|
+
`SELECT count(*)::int AS n FROM ${TABLE} WHERE account_id = $1`,
|
|
179
|
+
[accountId],
|
|
180
|
+
);
|
|
181
|
+
const have = Number((rows[0] as { n?: unknown } | undefined)?.n ?? 0);
|
|
182
|
+
const limit = this.limits.perAccount ?? DEFAULT_PER_ACCOUNT;
|
|
183
|
+
if (have >= limit) throw new NameError(422, `an account may have ${limit} names, and this one has ${have}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// What the name will point at once this is done.
|
|
187
|
+
const a = want.a === undefined ? (existing?.a ?? "") : (want.a ?? "");
|
|
188
|
+
const aaaa = want.aaaa === undefined ? (existing?.aaaa ?? "") : (want.aaaa ?? "");
|
|
189
|
+
if (a === "" && aaaa === "") throw new NameError(400, "give an address");
|
|
190
|
+
const ttl = Math.min(MAX_TTL, Math.max(MIN_TTL, Math.floor(want.ttl ?? existing?.ttl ?? MIN_TTL)));
|
|
191
|
+
|
|
192
|
+
// The zone first. A registrar that says no is the caller's problem to hear
|
|
193
|
+
// about now, not a row that claims a name the world cannot resolve.
|
|
194
|
+
const host = this.host(handle, label);
|
|
195
|
+
try {
|
|
196
|
+
if (a !== "") await this.dns.set(host, "A", a, ttl);
|
|
197
|
+
else if (existing !== null && existing.a !== "") await this.dns.remove(host, "A");
|
|
198
|
+
if (aaaa !== "") await this.dns.set(host, "AAAA", aaaa, ttl);
|
|
199
|
+
else if (existing !== null && existing.aaaa !== "") await this.dns.remove(host, "AAAA");
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new NameError(502, `the zone did not take that record: ${(error as Error).message}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { rows } = await this.db.query(
|
|
205
|
+
`INSERT INTO ${TABLE} (account_id, handle, label, a, aaaa, ttl)
|
|
206
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
207
|
+
ON CONFLICT (handle, label) DO UPDATE
|
|
208
|
+
SET a = EXCLUDED.a, aaaa = EXCLUDED.aaaa, ttl = EXCLUDED.ttl, updated_at = now()
|
|
209
|
+
RETURNING account_id, handle, label, a, aaaa, ttl, created_at, updated_at`,
|
|
210
|
+
[accountId, handle, label, a, aaaa, ttl],
|
|
211
|
+
);
|
|
212
|
+
const row = rows[0] as unknown as Row | undefined;
|
|
213
|
+
return this.record(handle, row ?? {
|
|
214
|
+
account_id: accountId, handle, label, a, aaaa, ttl,
|
|
215
|
+
created_at: existing?.created_at ?? new Date(), updated_at: new Date(),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Take a name away. False when it is not this account's to take. */
|
|
220
|
+
async remove(accountId: string, handle: string, wanted: unknown): Promise<boolean> {
|
|
221
|
+
const label = validLabel(wanted);
|
|
222
|
+
if (label === "") return false;
|
|
223
|
+
await this.ensure();
|
|
224
|
+
const existing = await this.find(handle, label);
|
|
225
|
+
if (existing === null || existing.account_id !== accountId) return false;
|
|
226
|
+
|
|
227
|
+
const host = this.host(handle, label);
|
|
228
|
+
try {
|
|
229
|
+
await this.dns.remove(host, "A");
|
|
230
|
+
await this.dns.remove(host, "AAAA");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw new NameError(502, `the zone did not let go of that record: ${(error as Error).message}`);
|
|
233
|
+
}
|
|
234
|
+
await this.db.query(`DELETE FROM ${TABLE} WHERE account_id = $1 AND handle = $2 AND label = $3`, [
|
|
235
|
+
accountId, handle, label,
|
|
236
|
+
]);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A name and a certificate for this machine, from nixamp.com.
|
|
3
|
+
*
|
|
4
|
+
* The DNS keys never leave nixamp.com: a server signed in to an account asks
|
|
5
|
+
* for `<label>.<handle>.nixamp.com` and the site makes the record for the
|
|
6
|
+
* address the request came from, A and AAAA both. The certificate is one
|
|
7
|
+
* wildcard per handle, issued and renewed by the site, handed to the account's
|
|
8
|
+
* own servers over the authenticated API and kept on disk here so a restart
|
|
9
|
+
* serves https at once rather than after a round trip.
|
|
10
|
+
*/
|
|
11
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface Named {
|
|
15
|
+
host: string;
|
|
16
|
+
a: string;
|
|
17
|
+
aaaa: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CertFiles {
|
|
21
|
+
cert: string;
|
|
22
|
+
key: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
/** The name on the certificate, e.g. `*.chovy.nixamp.com`. */
|
|
25
|
+
host: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A certificate this close to expiry is not worth serving: fetch a fresh one. */
|
|
29
|
+
const TOO_CLOSE_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
function bearer(token: string): Record<string, string> {
|
|
32
|
+
return { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Claim, or refresh, this machine's name.
|
|
37
|
+
*
|
|
38
|
+
* "auto" for both families: the site records whichever addresses this request
|
|
39
|
+
* arrived from, which is the only honest answer to "what is my public
|
|
40
|
+
* address" from behind a router. Null when refused or unreachable, and the
|
|
41
|
+
* reason is said rather than thrown: a server without a name still serves.
|
|
42
|
+
*/
|
|
43
|
+
export async function claimName(
|
|
44
|
+
site: string,
|
|
45
|
+
token: string,
|
|
46
|
+
label: string,
|
|
47
|
+
say: (line: string) => void,
|
|
48
|
+
fetcher: typeof fetch = fetch,
|
|
49
|
+
): Promise<Named | null> {
|
|
50
|
+
try {
|
|
51
|
+
const answer = await fetcher(`${site}/api/v1/dns/${encodeURIComponent(label)}`, {
|
|
52
|
+
method: "PUT",
|
|
53
|
+
headers: bearer(token),
|
|
54
|
+
body: JSON.stringify({ a: "auto", aaaa: "auto" }),
|
|
55
|
+
});
|
|
56
|
+
const body = (await answer.json().catch(() => ({}))) as {
|
|
57
|
+
name?: { host?: string; a?: string | null; aaaa?: string | null };
|
|
58
|
+
error?: string;
|
|
59
|
+
};
|
|
60
|
+
if (!answer.ok || !body.name?.host) {
|
|
61
|
+
say(`nixamp: ${site} would not name this machine: ${body.error ?? `answered ${answer.status}`}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { host: body.name.host, a: body.name.a ?? "", aaaa: body.name.aaaa ?? "" };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
say(`nixamp: could not reach ${site} to claim a name: ${(error as Error).message}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The handle's certificate.
|
|
73
|
+
*
|
|
74
|
+
* The first one is issued while we wait: a wildcard by DNS challenge takes a
|
|
75
|
+
* couple of minutes, which is said once so the pause reads as work rather
|
|
76
|
+
* than a hang. After that it is a cached answer on the site's side.
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchCert(
|
|
79
|
+
site: string,
|
|
80
|
+
token: string,
|
|
81
|
+
opts: {
|
|
82
|
+
waitMs?: number;
|
|
83
|
+
everyMs?: number;
|
|
84
|
+
sleep?: (ms: number) => Promise<void>;
|
|
85
|
+
fetcher?: typeof fetch;
|
|
86
|
+
},
|
|
87
|
+
say: (line: string) => void,
|
|
88
|
+
): Promise<CertFiles | null> {
|
|
89
|
+
const waitMs = opts.waitMs ?? 240_000;
|
|
90
|
+
const everyMs = opts.everyMs ?? 10_000;
|
|
91
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
|
|
92
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
93
|
+
let waited = 0;
|
|
94
|
+
let announced = false;
|
|
95
|
+
|
|
96
|
+
for (;;) {
|
|
97
|
+
let answer: Response;
|
|
98
|
+
try {
|
|
99
|
+
answer = await fetcher(`${site}/api/v1/certs`, { headers: bearer(token) });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
say(`nixamp: could not reach ${site} for a certificate: ${(error as Error).message}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const body = (await answer.json().catch(() => ({}))) as {
|
|
105
|
+
status?: string;
|
|
106
|
+
cert?: string;
|
|
107
|
+
key?: string;
|
|
108
|
+
expiresAt?: number;
|
|
109
|
+
host?: string;
|
|
110
|
+
error?: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (answer.ok && body.status === "ready" && body.cert && body.key && body.host) {
|
|
114
|
+
return { cert: body.cert, key: body.key, expiresAt: body.expiresAt ?? 0, host: body.host };
|
|
115
|
+
}
|
|
116
|
+
if (answer.status === 202 || body.status === "issuing") {
|
|
117
|
+
if (!announced) {
|
|
118
|
+
announced = true;
|
|
119
|
+
const host = body.host ?? "your handle";
|
|
120
|
+
say(`Getting a certificate for ${host}… the first one takes a couple of minutes.`);
|
|
121
|
+
}
|
|
122
|
+
if (waited >= waitMs) {
|
|
123
|
+
say(`nixamp: ${site} is still issuing the certificate; serving http until it is ready.`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
await sleep(everyMs);
|
|
127
|
+
waited += everyMs;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
say(`nixamp: ${site} could not issue a certificate: ${body.error ?? `answered ${answer.status}`}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `*.chovy.nixamp.com` becomes `chovy.nixamp.com`, which is a filename. */
|
|
136
|
+
function fileStem(host: string): string {
|
|
137
|
+
return host.replace(/^\*\./, "").replace(/[^A-Za-z0-9.-]/g, "_");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Keep a certificate beside the keys. Private, because the key is what lets
|
|
142
|
+
* anybody be this server.
|
|
143
|
+
*/
|
|
144
|
+
export function writeCertFiles(stateDir: string, files: CertFiles): { cert: string; key: string } {
|
|
145
|
+
const dir = join(stateDir, "tls");
|
|
146
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
147
|
+
chmodSync(dir, 0o700);
|
|
148
|
+
const stem = join(dir, fileStem(files.host));
|
|
149
|
+
const cert = `${stem}.cert.pem`;
|
|
150
|
+
const key = `${stem}.key.pem`;
|
|
151
|
+
writeFileSync(cert, files.cert, { mode: 0o600 });
|
|
152
|
+
writeFileSync(key, files.key, { mode: 0o600 });
|
|
153
|
+
chmodSync(cert, 0o600);
|
|
154
|
+
chmodSync(key, 0o600);
|
|
155
|
+
writeFileSync(`${stem}.json`, JSON.stringify({ host: files.host, expiresAt: files.expiresAt }), { mode: 0o600 });
|
|
156
|
+
return { cert, key };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The certificate from last time, if it is still good for more than a day.
|
|
161
|
+
* Anything closer to expiry is treated as absent so the next start fetches
|
|
162
|
+
* a fresh one rather than serving one that lapses overnight.
|
|
163
|
+
*/
|
|
164
|
+
export function readCertFiles(
|
|
165
|
+
stateDir: string,
|
|
166
|
+
host: string,
|
|
167
|
+
): { cert: string; key: string; expiresAt: number } | null {
|
|
168
|
+
const stem = join(stateDir, "tls", fileStem(host));
|
|
169
|
+
try {
|
|
170
|
+
const meta = JSON.parse(readFileSync(`${stem}.json`, "utf8")) as { expiresAt?: number };
|
|
171
|
+
const expiresAt = typeof meta.expiresAt === "number" ? meta.expiresAt : 0;
|
|
172
|
+
if (expiresAt - Date.now() < TOO_CLOSE_MS) return null;
|
|
173
|
+
const cert = readFileSync(`${stem}.cert.pem`, "utf8");
|
|
174
|
+
const key = readFileSync(`${stem}.key.pem`, "utf8");
|
|
175
|
+
if (!cert || !key) return null;
|
|
176
|
+
return { cert, key, expiresAt };
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What this machine calls itself in DNS: the name it was given, else the
|
|
184
|
+
* first label of its hostname, made safe for a subdomain.
|
|
185
|
+
*/
|
|
186
|
+
export function labelFor(name: string, hostname: string): string {
|
|
187
|
+
for (const candidate of [name, hostname.split(".")[0] ?? ""]) {
|
|
188
|
+
const label = candidate
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
191
|
+
.replace(/^-+|-+$/g, "")
|
|
192
|
+
.slice(0, 30)
|
|
193
|
+
.replace(/-+$/g, "");
|
|
194
|
+
if (label.length >= 2) return label;
|
|
195
|
+
}
|
|
196
|
+
return "server";
|
|
197
|
+
}
|
package/src/owner.ts
CHANGED
|
@@ -119,5 +119,8 @@ export function needsAdmin(path: string, method = "GET"): boolean {
|
|
|
119
119
|
// Publishing to a channel, or ending one, is administering the server.
|
|
120
120
|
// Listening to a channel is not: that is what the share link is for.
|
|
121
121
|
if (path.startsWith("/api/channels/") && method !== "GET") return true;
|
|
122
|
+
// Adding, refreshing or removing a catalog is administering; browsing one,
|
|
123
|
+
// and picking something in it to play, is what the link is for.
|
|
124
|
+
if (path.startsWith("/api/catalogs") && method !== "GET" && !path.endsWith("/play")) return true;
|
|
122
125
|
return false;
|
|
123
126
|
}
|
package/src/publish.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface PublishTarget {
|
|
|
30
30
|
*/
|
|
31
31
|
tracks: () => number;
|
|
32
32
|
nowPlaying: () => string;
|
|
33
|
+
/** The admin share link, kept by the directory for the owner alone. */
|
|
34
|
+
admin?: string;
|
|
33
35
|
/** Whether the player is actually running, so a stopped server is not listed as live. */
|
|
34
36
|
playing?: () => boolean;
|
|
35
37
|
/** The live channels on this server, by name, for the listing to show. */
|
|
@@ -103,6 +105,9 @@ export class Publisher {
|
|
|
103
105
|
name: this.target.name,
|
|
104
106
|
url: this.target.url,
|
|
105
107
|
...(this.target.audio ? { audio: this.target.audio } : {}),
|
|
108
|
+
// The link that drives this server. The directory keeps it for the
|
|
109
|
+
// account that owns the listing and shows it to nobody else.
|
|
110
|
+
...(this.target.admin ? { admin: this.target.admin } : {}),
|
|
106
111
|
tracks: this.target.tracks(),
|
|
107
112
|
nowPlaying: this.target.nowPlaying(),
|
|
108
113
|
...(this.target.playing ? { playing: this.target.playing() } : {}),
|