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/session.js
CHANGED
|
@@ -453,6 +453,109 @@ export async function tokens(argv, fetcher = fetch) {
|
|
|
453
453
|
return 69;
|
|
454
454
|
}
|
|
455
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* `nixamp dns list|set|rm`: names under your handle, for your servers.
|
|
458
|
+
*
|
|
459
|
+
* The DNS keys stay on nixamp.com; this only says which label should point
|
|
460
|
+
* where. "auto" is the address the request arrives from, which is what a
|
|
461
|
+
* server naming itself wants and what nobody behind a router can type.
|
|
462
|
+
*/
|
|
463
|
+
export async function dns(argv, fetcher = fetch) {
|
|
464
|
+
const session = readSession();
|
|
465
|
+
if (session === null) {
|
|
466
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
const [command = "list", ...rest] = argv;
|
|
470
|
+
const where = `${session.site}/api/v1/dns`;
|
|
471
|
+
const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
|
|
472
|
+
const flag = (name) => {
|
|
473
|
+
const at = rest.indexOf(name);
|
|
474
|
+
return at === -1 ? undefined : rest[at + 1];
|
|
475
|
+
};
|
|
476
|
+
// "off" is a word a person types; null is what the API takes it to mean.
|
|
477
|
+
const family = (value) => value === undefined ? undefined : value === "off" || value === "none" ? null : value;
|
|
478
|
+
try {
|
|
479
|
+
if (command === "set" || command === "add" || command === "claim") {
|
|
480
|
+
const label = rest.find((a) => !a.startsWith("-")) ?? "";
|
|
481
|
+
if (!label) {
|
|
482
|
+
console.error("nixamp: which name? `nixamp dns set server2` names this machine server2.");
|
|
483
|
+
return 64;
|
|
484
|
+
}
|
|
485
|
+
let a = family(flag("--a"));
|
|
486
|
+
let aaaa = family(flag("--aaaa"));
|
|
487
|
+
// Neither named: a server naming itself, both families from where it is.
|
|
488
|
+
if (a === undefined && aaaa === undefined) {
|
|
489
|
+
a = "auto";
|
|
490
|
+
aaaa = "auto";
|
|
491
|
+
}
|
|
492
|
+
const ttlText = flag("--ttl");
|
|
493
|
+
const ttl = ttlText === undefined ? undefined : Number(ttlText);
|
|
494
|
+
if (ttl !== undefined && (!Number.isInteger(ttl) || ttl < 60)) {
|
|
495
|
+
console.error("nixamp: --ttl is seconds, 60 or more.");
|
|
496
|
+
return 64;
|
|
497
|
+
}
|
|
498
|
+
const answer = await fetcher(`${where}/${encodeURIComponent(label)}`, {
|
|
499
|
+
method: "PUT",
|
|
500
|
+
headers,
|
|
501
|
+
body: JSON.stringify({
|
|
502
|
+
...(a !== undefined ? { a } : {}),
|
|
503
|
+
...(aaaa !== undefined ? { aaaa } : {}),
|
|
504
|
+
...(ttl !== undefined ? { ttl } : {}),
|
|
505
|
+
}),
|
|
506
|
+
});
|
|
507
|
+
const body = (await answer.json().catch(() => ({})));
|
|
508
|
+
if (!answer.ok || !body.name) {
|
|
509
|
+
console.error(`nixamp: ${body.error ?? `could not set it (${answer.status})`}`);
|
|
510
|
+
return 1;
|
|
511
|
+
}
|
|
512
|
+
console.log(`${body.name.host}`);
|
|
513
|
+
console.log(` A ${body.name.a || "—"}`);
|
|
514
|
+
console.log(` AAAA ${body.name.aaaa || "—"}`);
|
|
515
|
+
console.log(` ttl ${body.name.ttl}`);
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
if (command === "rm" || command === "remove" || command === "delete") {
|
|
519
|
+
const label = rest.find((a) => !a.startsWith("-")) ?? "";
|
|
520
|
+
if (!label) {
|
|
521
|
+
console.error("nixamp: which name? `nixamp dns list` shows them.");
|
|
522
|
+
return 64;
|
|
523
|
+
}
|
|
524
|
+
const answer = await fetcher(`${where}/${encodeURIComponent(label)}`, { method: "DELETE", headers });
|
|
525
|
+
if (!answer.ok) {
|
|
526
|
+
const body = (await answer.json().catch(() => ({})));
|
|
527
|
+
console.error(`nixamp: ${answer.status === 404 ? "no name like that under your handle" : (body.error ?? "could not remove it")}`);
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
console.log(`Removed ${label}.`);
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
if (command === "list" || command === "ls") {
|
|
534
|
+
const answer = await fetcher(where, { headers });
|
|
535
|
+
const body = (await answer.json().catch(() => ({})));
|
|
536
|
+
if (!answer.ok) {
|
|
537
|
+
console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
|
|
538
|
+
return 1;
|
|
539
|
+
}
|
|
540
|
+
const names = body.names ?? [];
|
|
541
|
+
if (names.length === 0) {
|
|
542
|
+
console.log("No names yet. `nixamp dns set <name>` gives this machine one.");
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
const width = Math.max(...names.map((n) => n.host.length));
|
|
546
|
+
for (const name of names) {
|
|
547
|
+
console.log(`${name.host.padEnd(width)} A ${(name.a || "—").padEnd(15)} AAAA ${(name.aaaa || "—").padEnd(39)} ttl ${String(name.ttl).padStart(5)} ${day(name.updatedAt)}`);
|
|
548
|
+
}
|
|
549
|
+
return 0;
|
|
550
|
+
}
|
|
551
|
+
console.error(`nixamp: no such dns command: ${command}`);
|
|
552
|
+
return 64;
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.error(`nixamp: could not reach ${session.site}: ${error.message}`);
|
|
556
|
+
return 69;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
456
559
|
export function logout() {
|
|
457
560
|
const session = readSession();
|
|
458
561
|
clearSession();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@profullstack/auth-system": "^0.6.0",
|
|
43
43
|
"@profullstack/hqtui": "^0.3.0",
|
|
44
|
-
"@profullstack/
|
|
44
|
+
"@profullstack/throttle": "0.2.2",
|
|
45
|
+
"@profullstack/x402-gateway": "0.6.0",
|
|
46
|
+
"acme-client": "5.4.0",
|
|
45
47
|
"pg": "^8.23.0",
|
|
46
48
|
"web-push": "^3.6.7"
|
|
47
49
|
},
|
|
@@ -67,5 +69,9 @@
|
|
|
67
69
|
"repository": {
|
|
68
70
|
"type": "git",
|
|
69
71
|
"url": "git+https://github.com/profullstack/nixamp.git"
|
|
72
|
+
},
|
|
73
|
+
"overrides": {
|
|
74
|
+
"https-proxy-agent": "7.0.6",
|
|
75
|
+
"tslib": "2.8.1"
|
|
70
76
|
}
|
|
71
77
|
}
|
package/src/catalogs.ts
ADDED
|
Binary file
|
package/src/certs.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Certificates: one wildcard per handle, issued and renewed by nixamp.com.
|
|
3
|
+
*
|
|
4
|
+
* A server that has been given `server2.chovy.nixamp.com` still answers over
|
|
5
|
+
* plain http until it has a certificate, and getting one by hand is exactly
|
|
6
|
+
* the manual step this exists to remove. So nixamp.com, which already holds
|
|
7
|
+
* the zone, does the whole thing: it orders `*.chovy.nixamp.com` and
|
|
8
|
+
* `chovy.nixamp.com` from Let's Encrypt, answers the DNS-01 challenge by
|
|
9
|
+
* writing the `_acme-challenge` TXT record itself, keeps the result in the
|
|
10
|
+
* database, renews it when it gets close to expiry, and hands cert and key
|
|
11
|
+
* to that account's servers over the authenticated API.
|
|
12
|
+
*
|
|
13
|
+
* One wildcard per handle, not one certificate per server. Let's Encrypt
|
|
14
|
+
* rate-limits per registered domain -- nixamp.com, for all of us -- and every
|
|
15
|
+
* server an account starts is covered by the wildcard it already has.
|
|
16
|
+
*
|
|
17
|
+
* The ACME dance takes minutes, mostly waiting for DNS to propagate, so it is
|
|
18
|
+
* never done inside a request. `forHandle` starts it in the background and
|
|
19
|
+
* answers "issuing"; the server asks again in a while.
|
|
20
|
+
*/
|
|
21
|
+
import acme from "acme-client";
|
|
22
|
+
import type { DnsZone } from "./dns.ts";
|
|
23
|
+
import type { Queryable } from "./follows.ts";
|
|
24
|
+
|
|
25
|
+
export interface Issued {
|
|
26
|
+
/** PEM, the full chain. */
|
|
27
|
+
cert: string;
|
|
28
|
+
/** PEM. */
|
|
29
|
+
key: string;
|
|
30
|
+
/** Epoch milliseconds. */
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where a DNS-01 challenge gets written, and unwritten. */
|
|
35
|
+
export interface Challenge {
|
|
36
|
+
set(host: string, value: string): Promise<void>;
|
|
37
|
+
clear(host: string, value: string): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Something that turns a list of names into a certificate. ACME, in production. */
|
|
41
|
+
export interface Issuer {
|
|
42
|
+
issue(names: string[], challenge: Challenge): Promise<Issued>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AcmeIssuerOptions {
|
|
46
|
+
/** acme.directory.letsencrypt.production, or .staging while trying things out. */
|
|
47
|
+
directoryUrl: string;
|
|
48
|
+
/** The account contact Let's Encrypt writes to about expiry. */
|
|
49
|
+
email: string;
|
|
50
|
+
/** The account's private key, PEM. Kept by the caller; see Certs.accountKey. */
|
|
51
|
+
accountKey: () => Promise<string>;
|
|
52
|
+
/**
|
|
53
|
+
* How long to wait after writing the TXT before letting the CA look for it.
|
|
54
|
+
* A registrar's API answers at once; the world's resolvers do not.
|
|
55
|
+
*/
|
|
56
|
+
propagationMs?: number;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A minute is what Porkbun's 600-second floor tends to cost in practice. */
|
|
61
|
+
export const DEFAULT_PROPAGATION_MS = 60_000;
|
|
62
|
+
/** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
|
|
63
|
+
export const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
64
|
+
/** A failed order is worth trying again, but not every time somebody asks. */
|
|
65
|
+
export const RETRY_AFTER_MS = 60 * 60 * 1000;
|
|
66
|
+
|
|
67
|
+
const wait = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
|
|
71
|
+
* proved any other way, and the zone is the one thing nixamp.com does hold.
|
|
72
|
+
*/
|
|
73
|
+
export class AcmeIssuer implements Issuer {
|
|
74
|
+
constructor(private readonly options: AcmeIssuerOptions) {}
|
|
75
|
+
|
|
76
|
+
async issue(names: string[], challenge: Challenge): Promise<Issued> {
|
|
77
|
+
const [first, ...rest] = names;
|
|
78
|
+
if (!first) throw new Error("nothing to issue a certificate for");
|
|
79
|
+
const propagationMs = this.options.propagationMs ?? DEFAULT_PROPAGATION_MS;
|
|
80
|
+
const sleep = this.options.sleep ?? wait;
|
|
81
|
+
|
|
82
|
+
const client = new acme.Client({
|
|
83
|
+
directoryUrl: this.options.directoryUrl,
|
|
84
|
+
accountKey: await this.options.accountKey(),
|
|
85
|
+
});
|
|
86
|
+
// A fresh key per certificate. Reusing the account key for the
|
|
87
|
+
// certificate would make one leak two.
|
|
88
|
+
const [key, csr] = await acme.crypto.createCsr({ commonName: first, altNames: [first, ...rest] });
|
|
89
|
+
|
|
90
|
+
const cert = await client.auto({
|
|
91
|
+
csr,
|
|
92
|
+
email: this.options.email,
|
|
93
|
+
termsOfServiceAgreed: true,
|
|
94
|
+
challengePriority: ["dns-01"],
|
|
95
|
+
// acme-client would otherwise resolve the TXT itself before telling the
|
|
96
|
+
// CA to, and from wherever nixamp.com runs that lookup can lag the CA's.
|
|
97
|
+
skipChallengeVerification: true,
|
|
98
|
+
challengeCreateFn: async (authz, _challenge, keyAuthorization) => {
|
|
99
|
+
await challenge.set(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
|
|
100
|
+
await sleep(propagationMs);
|
|
101
|
+
},
|
|
102
|
+
challengeRemoveFn: async (authz, _challenge, keyAuthorization) => {
|
|
103
|
+
await challenge.clear(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const info = acme.crypto.readCertificateInfo(cert);
|
|
108
|
+
return { cert, key: key.toString(), expiresAt: info.notAfter.getTime() };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type CertState =
|
|
113
|
+
| { status: "ready"; cert: string; key: string; expiresAt: number; renewing: boolean }
|
|
114
|
+
| { status: "issuing"; since: number }
|
|
115
|
+
| { status: "failed"; error: string; at: number }
|
|
116
|
+
| { status: "none" };
|
|
117
|
+
|
|
118
|
+
export interface CertsOptions {
|
|
119
|
+
renewBeforeMs?: number;
|
|
120
|
+
retryAfterMs?: number;
|
|
121
|
+
now?: () => number;
|
|
122
|
+
log?: (line: string) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SCHEMA = `
|
|
126
|
+
CREATE TABLE IF NOT EXISTS certs (
|
|
127
|
+
handle TEXT PRIMARY KEY,
|
|
128
|
+
cert TEXT NOT NULL,
|
|
129
|
+
key TEXT NOT NULL,
|
|
130
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
131
|
+
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
132
|
+
);
|
|
133
|
+
CREATE TABLE IF NOT EXISTS acme_account (
|
|
134
|
+
id INTEGER PRIMARY KEY,
|
|
135
|
+
key_pem TEXT NOT NULL
|
|
136
|
+
);
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
interface Stored {
|
|
140
|
+
cert: string;
|
|
141
|
+
key: string;
|
|
142
|
+
expiresAt: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What is happening to a handle's certificate right now, in this process. */
|
|
146
|
+
type Work =
|
|
147
|
+
| { kind: "issuing"; since: number; done: Promise<void> }
|
|
148
|
+
| { kind: "failed"; error: string; at: number };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The certificates, one per handle, and the work of getting them.
|
|
152
|
+
*
|
|
153
|
+
* The rows are the truth and survive a restart; what is in flight lives in
|
|
154
|
+
* memory, because nixamp.com is one process and an order interrupted by a
|
|
155
|
+
* deploy is simply started again when next asked for.
|
|
156
|
+
*/
|
|
157
|
+
export class Certs {
|
|
158
|
+
private ready: Promise<void> | null = null;
|
|
159
|
+
private readonly work = new Map<string, Work>();
|
|
160
|
+
private readonly renewBeforeMs: number;
|
|
161
|
+
private readonly retryAfterMs: number;
|
|
162
|
+
private readonly now: () => number;
|
|
163
|
+
private readonly log: (line: string) => void;
|
|
164
|
+
|
|
165
|
+
constructor(
|
|
166
|
+
private readonly db: Queryable,
|
|
167
|
+
private readonly zone: DnsZone,
|
|
168
|
+
private readonly issuer: Issuer,
|
|
169
|
+
options: CertsOptions = {},
|
|
170
|
+
) {
|
|
171
|
+
this.renewBeforeMs = options.renewBeforeMs ?? RENEW_BEFORE_MS;
|
|
172
|
+
this.retryAfterMs = options.retryAfterMs ?? RETRY_AFTER_MS;
|
|
173
|
+
this.now = options.now ?? Date.now;
|
|
174
|
+
this.log = options.log ?? (() => undefined);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async ensure(): Promise<void> {
|
|
178
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
179
|
+
await this.ready;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The ACME account key, made once and kept. Losing it would not lose any
|
|
184
|
+
* certificate, but every renewal would register a new account, and Let's
|
|
185
|
+
* Encrypt rate-limits those too.
|
|
186
|
+
*/
|
|
187
|
+
async accountKey(): Promise<string> {
|
|
188
|
+
await this.ensure();
|
|
189
|
+
const { rows } = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
|
|
190
|
+
const kept = rows[0]?.["key_pem"];
|
|
191
|
+
if (typeof kept === "string" && kept !== "") return kept;
|
|
192
|
+
const made = (await acme.crypto.createPrivateKey()).toString();
|
|
193
|
+
await this.db.query(
|
|
194
|
+
"INSERT INTO acme_account (id, key_pem) VALUES (1, $1) ON CONFLICT (id) DO NOTHING",
|
|
195
|
+
[made],
|
|
196
|
+
);
|
|
197
|
+
// Somebody else may have won the race to insert; theirs is the account now.
|
|
198
|
+
const again = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
|
|
199
|
+
const winner = again.rows[0]?.["key_pem"];
|
|
200
|
+
return typeof winner === "string" && winner !== "" ? winner : made;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The names one handle's certificate covers: every server it has, and the handle itself. */
|
|
204
|
+
namesFor(handle: string): string[] {
|
|
205
|
+
return [`*.${handle}.${this.zone.zone}`, `${handle}.${this.zone.zone}`];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async stored(handle: string): Promise<Stored | null> {
|
|
209
|
+
await this.ensure();
|
|
210
|
+
const { rows } = await this.db.query(
|
|
211
|
+
"SELECT cert, key, expires_at FROM certs WHERE handle = $1",
|
|
212
|
+
[handle],
|
|
213
|
+
);
|
|
214
|
+
const row = rows[0];
|
|
215
|
+
if (!row) return null;
|
|
216
|
+
const cert = row["cert"];
|
|
217
|
+
const key = row["key"];
|
|
218
|
+
const expires = row["expires_at"];
|
|
219
|
+
if (typeof cert !== "string" || typeof key !== "string") return null;
|
|
220
|
+
const expiresAt = expires instanceof Date ? expires.getTime() : new Date(String(expires)).getTime();
|
|
221
|
+
if (!Number.isFinite(expiresAt)) return null;
|
|
222
|
+
return { cert, key, expiresAt };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A handle's certificate, or what is being done about the lack of one.
|
|
227
|
+
*
|
|
228
|
+
* Never blocks on the CA. With nothing stored, or something failed long
|
|
229
|
+
* enough ago to be worth another go, an order starts in the background and
|
|
230
|
+
* the answer is "issuing". With one stored that is near its end, the answer
|
|
231
|
+
* is still the old one -- it is valid -- and a renewal starts alongside.
|
|
232
|
+
*/
|
|
233
|
+
async forHandle(handle: string): Promise<CertState> {
|
|
234
|
+
const kept = await this.stored(handle);
|
|
235
|
+
const inFlight = this.work.get(handle);
|
|
236
|
+
|
|
237
|
+
if (kept && kept.expiresAt > this.now()) {
|
|
238
|
+
const renewing = kept.expiresAt - this.now() < this.renewBeforeMs;
|
|
239
|
+
if (renewing && !(inFlight?.kind === "issuing")) this.begin(handle, "renewal");
|
|
240
|
+
return {
|
|
241
|
+
status: "ready",
|
|
242
|
+
cert: kept.cert,
|
|
243
|
+
key: kept.key,
|
|
244
|
+
expiresAt: kept.expiresAt,
|
|
245
|
+
renewing: renewing || inFlight?.kind === "issuing",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (inFlight?.kind === "issuing") return { status: "issuing", since: inFlight.since };
|
|
250
|
+
if (inFlight?.kind === "failed" && this.now() - inFlight.at < this.retryAfterMs) {
|
|
251
|
+
return { status: "failed", error: inFlight.error, at: inFlight.at };
|
|
252
|
+
}
|
|
253
|
+
const started = this.begin(handle, kept ? "replacement of an expired certificate" : "first certificate");
|
|
254
|
+
return { status: "issuing", since: started.since };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Order in the background, and remember how it went. */
|
|
258
|
+
private begin(handle: string, why: string): { since: number } {
|
|
259
|
+
const since = this.now();
|
|
260
|
+
const names = this.namesFor(handle);
|
|
261
|
+
this.log(` Ordering a certificate for ${names.join(" and ")} (${why}).`);
|
|
262
|
+
const done = this.issuer
|
|
263
|
+
.issue(names, {
|
|
264
|
+
set: (host, value) => this.zone.add(host, "TXT", value, 600),
|
|
265
|
+
clear: (host, value) => this.zone.remove(host, "TXT", value),
|
|
266
|
+
})
|
|
267
|
+
.then(async (issued) => {
|
|
268
|
+
await this.ensure();
|
|
269
|
+
await this.db.query(
|
|
270
|
+
`INSERT INTO certs (handle, cert, key, expires_at, issued_at) VALUES ($1, $2, $3, $4, now())
|
|
271
|
+
ON CONFLICT (handle) DO UPDATE SET cert = EXCLUDED.cert, key = EXCLUDED.key,
|
|
272
|
+
expires_at = EXCLUDED.expires_at, issued_at = now()`,
|
|
273
|
+
[handle, issued.cert, issued.key, new Date(issued.expiresAt).toISOString()],
|
|
274
|
+
);
|
|
275
|
+
const days = Math.max(0, Math.round((issued.expiresAt - this.now()) / (24 * 60 * 60 * 1000)));
|
|
276
|
+
this.log(` Certificate for *.${handle}.${this.zone.zone} issued, good for ${days} days.`);
|
|
277
|
+
this.work.delete(handle);
|
|
278
|
+
})
|
|
279
|
+
.catch((error: unknown) => {
|
|
280
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
281
|
+
this.log(` Certificate for *.${handle}.${this.zone.zone} failed: ${message}`);
|
|
282
|
+
this.work.set(handle, { kind: "failed", error: message, at: this.now() });
|
|
283
|
+
});
|
|
284
|
+
const work: Work = { kind: "issuing", since, done };
|
|
285
|
+
this.work.set(handle, work);
|
|
286
|
+
return work;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** For tests and shutdown: whatever order is in flight for a handle. */
|
|
290
|
+
async settle(handle: string): Promise<void> {
|
|
291
|
+
const inFlight = this.work.get(handle);
|
|
292
|
+
if (inFlight?.kind === "issuing") await inFlight.done;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/channels.ts
CHANGED
|
@@ -64,6 +64,8 @@ export const GIVE_UP = 5;
|
|
|
64
64
|
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
65
65
|
*/
|
|
66
66
|
export const STALL = 30_000;
|
|
67
|
+
/** How long an on-demand channel stays up with nobody watching. */
|
|
68
|
+
export const IDLE = 60_000;
|
|
67
69
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
68
70
|
const TAIL = 2000;
|
|
69
71
|
/**
|
|
@@ -116,6 +118,8 @@ export interface ChannelOptions {
|
|
|
116
118
|
ffmpeg: string[];
|
|
117
119
|
onStart?: (info: ChannelInfo) => void;
|
|
118
120
|
onEnd?: (info: ChannelInfo) => void;
|
|
121
|
+
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
122
|
+
idleMs?: number;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/**
|
|
@@ -151,6 +155,13 @@ export class Channel {
|
|
|
151
155
|
*/
|
|
152
156
|
private recent: Buffer[] = [];
|
|
153
157
|
private recentBytes = 0;
|
|
158
|
+
/**
|
|
159
|
+
* Started for whoever asked and stopped when nobody is left. A catalog
|
|
160
|
+
* channel is one of thousands; keeping every one that was ever clicked
|
|
161
|
+
* running would be a decoder per click, for ever.
|
|
162
|
+
*/
|
|
163
|
+
ephemeral = false;
|
|
164
|
+
private idle: ReturnType<typeof setTimeout> | null = null;
|
|
154
165
|
|
|
155
166
|
constructor(
|
|
156
167
|
readonly info: ChannelInfo,
|
|
@@ -466,12 +477,25 @@ export class Channel {
|
|
|
466
477
|
}
|
|
467
478
|
this.listeners.add(listener);
|
|
468
479
|
this.info.listeners = this.listeners.size;
|
|
480
|
+
if (this.idle) clearTimeout(this.idle);
|
|
481
|
+
this.idle = null;
|
|
469
482
|
return () => {
|
|
470
483
|
this.listeners.delete(listener);
|
|
471
484
|
this.info.listeners = this.listeners.size;
|
|
485
|
+
if (this.ephemeral && this.listeners.size === 0) this.idleOut();
|
|
472
486
|
};
|
|
473
487
|
}
|
|
474
488
|
|
|
489
|
+
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
490
|
+
private idleOut(): void {
|
|
491
|
+
if (this.idle) clearTimeout(this.idle);
|
|
492
|
+
this.idle = setTimeout(() => {
|
|
493
|
+
this.idle = null;
|
|
494
|
+
if (this.ephemeral && this.listeners.size === 0) this.close();
|
|
495
|
+
}, this.options.idleMs ?? IDLE);
|
|
496
|
+
this.idle.unref?.();
|
|
497
|
+
}
|
|
498
|
+
|
|
475
499
|
close(): void {
|
|
476
500
|
if (this.closing) return;
|
|
477
501
|
this.closing = true;
|
|
@@ -480,6 +504,8 @@ export class Channel {
|
|
|
480
504
|
this.timer = null;
|
|
481
505
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
482
506
|
this.watchdog = null;
|
|
507
|
+
if (this.idle) clearTimeout(this.idle);
|
|
508
|
+
this.idle = null;
|
|
483
509
|
const said = lastLine(this.stderr);
|
|
484
510
|
if (said && !this.info.error) this.info.error = said;
|
|
485
511
|
const child = this.child;
|
|
@@ -615,6 +641,21 @@ export class Channels {
|
|
|
615
641
|
return this.open.get(id)?.info.via === "pull";
|
|
616
642
|
}
|
|
617
643
|
|
|
644
|
+
/** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
|
|
645
|
+
ephemeral(id: string): void {
|
|
646
|
+
const channel = this.open.get(id);
|
|
647
|
+
if (!channel) return;
|
|
648
|
+
channel.ephemeral = true;
|
|
649
|
+
if (channel.listeners.size === 0) channel.listen({ write: () => true, end: () => undefined })();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** How many on-demand channels are up, for a ceiling on decoders. */
|
|
653
|
+
get ephemeralCount(): number {
|
|
654
|
+
let total = 0;
|
|
655
|
+
for (const channel of this.open.values()) if (channel.ephemeral) total += 1;
|
|
656
|
+
return total;
|
|
657
|
+
}
|
|
658
|
+
|
|
618
659
|
/** What a listener should be told this channel is. */
|
|
619
660
|
contentType(id: string): string {
|
|
620
661
|
return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
|
package/src/directory.ts
CHANGED
|
@@ -55,6 +55,14 @@ export interface Listing {
|
|
|
55
55
|
* an older nixamp that only knows about `url`.
|
|
56
56
|
*/
|
|
57
57
|
audio: string;
|
|
58
|
+
/**
|
|
59
|
+
* The link that administers the server, as the publisher announced it.
|
|
60
|
+
*
|
|
61
|
+
* Kept so the owner can open their own machine as its administrator from
|
|
62
|
+
* the directory, and handed out to nobody else: the listing route strips it
|
|
63
|
+
* for anyone but the account that owns the listing.
|
|
64
|
+
*/
|
|
65
|
+
admin: string;
|
|
58
66
|
tracks: number;
|
|
59
67
|
nowPlaying: string;
|
|
60
68
|
/**
|
|
@@ -103,6 +111,8 @@ export interface Announcement {
|
|
|
103
111
|
url: string;
|
|
104
112
|
/** Where the audio actually is. See `Listing.audio`. */
|
|
105
113
|
audio?: string;
|
|
114
|
+
/** The admin share link, same origin as `url`. Kept for the owner alone. */
|
|
115
|
+
admin?: string;
|
|
106
116
|
tracks: number;
|
|
107
117
|
nowPlaying: string;
|
|
108
118
|
/** Absent from an older publisher, which is read as "unknown, say playing". */
|
|
@@ -160,6 +170,10 @@ export function parseAnnouncement(input: unknown): Announcement | null {
|
|
|
160
170
|
const offered = typeof record["audio"] === "string" ? record["audio"] : "";
|
|
161
171
|
const parsed = offered ? publishable(offered) : null;
|
|
162
172
|
const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
|
|
173
|
+
// The admin link is held to the same rule: it names this server or nothing.
|
|
174
|
+
const adminOffered = typeof record["admin"] === "string" ? record["admin"] : "";
|
|
175
|
+
const adminParsed = adminOffered ? publishable(adminOffered) : null;
|
|
176
|
+
const admin = adminParsed !== null && adminParsed.origin === listen.origin ? adminOffered : "";
|
|
163
177
|
|
|
164
178
|
const name = clean(record["name"], MAX_NAME);
|
|
165
179
|
const tracks = Number(record["tracks"]);
|
|
@@ -168,6 +182,7 @@ export function parseAnnouncement(input: unknown): Announcement | null {
|
|
|
168
182
|
name: name || "a nixamp",
|
|
169
183
|
url,
|
|
170
184
|
...(audio ? { audio } : {}),
|
|
185
|
+
...(admin ? { admin } : {}),
|
|
171
186
|
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
172
187
|
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
173
188
|
...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
|
|
@@ -261,6 +276,7 @@ export class Directory {
|
|
|
261
276
|
// older publisher renewing an entry should not blank the address the
|
|
262
277
|
// phone line is playing from.
|
|
263
278
|
audio: announcement.audio ?? existing?.audio ?? "",
|
|
279
|
+
admin: announcement.admin ?? existing?.admin ?? "",
|
|
264
280
|
tracks: announcement.tracks,
|
|
265
281
|
nowPlaying: announcement.nowPlaying,
|
|
266
282
|
// An older publisher says nothing about either; "playing" keeps what a
|