nixamp 0.7.7 → 0.7.9
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/owner.js +10 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +137 -39
- package/dist/share.d.ts +25 -0
- package/dist/share.js +67 -0
- package/package.json +1 -1
- package/src/owner.ts +10 -0
- package/src/server.ts +130 -14
- package/src/share.ts +79 -0
- package/web/dist/assets/{hls-3VKVEQE3-BM7Le4NN.js → hls-3VKVEQE3-pB7DqkTk.js} +1 -1
- package/web/dist/assets/index-CnneSQil.js +1 -0
- package/web/dist/assets/{mpegts-C6TLllAG.js → mpegts-DrJgcRo4.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-B_K_M921.js → mpegts-LO6RVLD6-Ckppbviz.js} +1 -1
- package/web/dist/index.html +5 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CGXkk7_z.js +0 -1
package/dist/owner.js
CHANGED
|
@@ -85,7 +85,17 @@ export const ADMIN_PATHS = [
|
|
|
85
85
|
"/api/ingest",
|
|
86
86
|
"/api/admin",
|
|
87
87
|
];
|
|
88
|
+
/**
|
|
89
|
+
* Going live and coming back off, which is administering a server.
|
|
90
|
+
*
|
|
91
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
92
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
93
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
94
|
+
*/
|
|
95
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
88
96
|
export function needsAdmin(path, method = "GET") {
|
|
97
|
+
if (LIVE_CONTROL.includes(path))
|
|
98
|
+
return true;
|
|
89
99
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
|
|
90
100
|
return true;
|
|
91
101
|
// Publishing to a channel, or ending one, is administering the server.
|
package/dist/server.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface ServeOptions {
|
|
|
29
29
|
* port, which is what the public deployment wants and no private one does.
|
|
30
30
|
*/
|
|
31
31
|
key: boolean;
|
|
32
|
+
/** Mint a new share key rather than reusing the one this port had. */
|
|
33
|
+
newKey: boolean;
|
|
32
34
|
/**
|
|
33
35
|
* Ask the local firewall to let the port through, and put it back on the way
|
|
34
36
|
* out. Off by default because it changes the machine, not just this process.
|
|
@@ -357,6 +359,31 @@ export interface HandlerOptions {
|
|
|
357
359
|
destinations: Destination[];
|
|
358
360
|
settings: EncoderSettings;
|
|
359
361
|
};
|
|
362
|
+
/**
|
|
363
|
+
* Going live: whether this server is listed, and how to change that.
|
|
364
|
+
*
|
|
365
|
+
* Listing used to be a question asked once at startup and never again, so a
|
|
366
|
+
* server started with --no-publish had no listing, no phone code, and no
|
|
367
|
+
* link to hand anybody -- and no way to change its mind short of stopping
|
|
368
|
+
* and starting it. It is an action now, because that is what it is.
|
|
369
|
+
*/
|
|
370
|
+
live?: {
|
|
371
|
+
status: () => {
|
|
372
|
+
live: boolean;
|
|
373
|
+
code: string;
|
|
374
|
+
name: string;
|
|
375
|
+
url: string;
|
|
376
|
+
possible: boolean;
|
|
377
|
+
};
|
|
378
|
+
start: () => Promise<{
|
|
379
|
+
live: boolean;
|
|
380
|
+
code: string;
|
|
381
|
+
name: string;
|
|
382
|
+
url: string;
|
|
383
|
+
error?: string;
|
|
384
|
+
}>;
|
|
385
|
+
stop: () => Promise<void>;
|
|
386
|
+
};
|
|
360
387
|
/**
|
|
361
388
|
* Where OBS should point, one entry per stream this server will accept.
|
|
362
389
|
*
|
package/dist/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { DeviceGrants } from "./device.js";
|
|
|
29
29
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
30
30
|
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
31
31
|
import { needsAdmin, Owner } from "./owner.js";
|
|
32
|
+
import { stateDir } from "./daemon.js";
|
|
32
33
|
import { readSession } from "./session.js";
|
|
33
34
|
import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
34
35
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
@@ -41,7 +42,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
41
42
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
42
43
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
43
44
|
import { codecsOf, videoArgs } from "./audio.js";
|
|
44
|
-
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp,
|
|
45
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
46
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
46
47
|
import { fileURLToPath } from "node:url";
|
|
47
48
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -68,6 +69,7 @@ export function parseServeArgs(argv) {
|
|
|
68
69
|
web: null,
|
|
69
70
|
media: true,
|
|
70
71
|
key: true,
|
|
72
|
+
newKey: false,
|
|
71
73
|
openPort: false,
|
|
72
74
|
announce: false,
|
|
73
75
|
directory: false,
|
|
@@ -157,6 +159,9 @@ export function parseServeArgs(argv) {
|
|
|
157
159
|
else if (arg === "--owner") {
|
|
158
160
|
options.owner = value();
|
|
159
161
|
}
|
|
162
|
+
else if (arg === "--new-key") {
|
|
163
|
+
options.newKey = true;
|
|
164
|
+
}
|
|
160
165
|
else if (arg === "--ingest") {
|
|
161
166
|
options.ingest = true;
|
|
162
167
|
}
|
|
@@ -1814,6 +1819,35 @@ export function createHandler(engine, options) {
|
|
|
1814
1819
|
}
|
|
1815
1820
|
// Everything the admin view draws, in one request: who is connected, and
|
|
1816
1821
|
// what this server is.
|
|
1822
|
+
// Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
|
|
1823
|
+
// listing somebody's machine in a public directory is not a thing a
|
|
1824
|
+
// listener gets to do.
|
|
1825
|
+
if (path === "/api/live/state") {
|
|
1826
|
+
if (!options.live) {
|
|
1827
|
+
json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
json(response, 200, options.live.status());
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (path === "/api/live/start" || path === "/api/live/stop") {
|
|
1834
|
+
if (request.method !== "POST") {
|
|
1835
|
+
json(response, 405, { error: "POST only" });
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (!options.live) {
|
|
1839
|
+
json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
if (path === "/api/live/stop") {
|
|
1843
|
+
await options.live.stop();
|
|
1844
|
+
json(response, 200, options.live.status());
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const started = await options.live.start();
|
|
1848
|
+
json(response, started.error ? 502 : 200, started);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1817
1851
|
if (path === "/api/connections") {
|
|
1818
1852
|
json(response, 200, {
|
|
1819
1853
|
connections: tracker.list(),
|
|
@@ -2373,10 +2407,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2373
2407
|
// the slowest part of starting and nothing about it needs to happen first.
|
|
2374
2408
|
const engine = new PlayerEngine([], root, tools);
|
|
2375
2409
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2376
|
-
|
|
2377
|
-
//
|
|
2410
|
+
// The same keys this port used last time, so a link somebody was given
|
|
2411
|
+
// still works after a restart -- and a server is restarted to pick up a new
|
|
2412
|
+
// version, which is to say often. `--new-key` mints a fresh pair and forgets
|
|
2413
|
+
// the old one, which is the way to revoke a link that got out.
|
|
2414
|
+
const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
|
|
2415
|
+
const key = remembered?.key ?? null;
|
|
2416
|
+
// Kept whether or not it is published, so `nixamp admin` and the operator
|
|
2378
2417
|
// both have a link they can hand out without handing over the controls.
|
|
2379
|
-
const listenKey =
|
|
2418
|
+
const listenKey = remembered?.listenKey ?? null;
|
|
2380
2419
|
// Configuration can arrive from the directory later, so it is a box the
|
|
2381
2420
|
// paywall reads rather than a value it was handed once.
|
|
2382
2421
|
let paywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
@@ -2574,12 +2613,55 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2574
2613
|
// function so the handler sees the list rather than the empty array it was
|
|
2575
2614
|
// built with.
|
|
2576
2615
|
let publishUrls = [];
|
|
2616
|
+
// Declared up here, not beside the publishing below: the port opens before
|
|
2617
|
+
// that code runs, so an admin asking to go live in the first moments would
|
|
2618
|
+
// otherwise reach a binding that has not been initialised.
|
|
2619
|
+
let publisher = null;
|
|
2620
|
+
let listing = null;
|
|
2621
|
+
let publishable_;
|
|
2577
2622
|
const server = createServer(engine, {
|
|
2578
2623
|
web,
|
|
2579
2624
|
media: options.media,
|
|
2580
2625
|
owner,
|
|
2581
2626
|
channels,
|
|
2582
2627
|
publishUrls: () => publishUrls,
|
|
2628
|
+
live: {
|
|
2629
|
+
status: () => ({
|
|
2630
|
+
live: publisher !== null,
|
|
2631
|
+
code: listing?.code ?? "",
|
|
2632
|
+
name: listing?.name ?? (options.name || hostname()),
|
|
2633
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
2634
|
+
// Whether going live is even possible here. A laptop behind a router
|
|
2635
|
+
// with no address the world can reach cannot be listed, and a button
|
|
2636
|
+
// that could only fail is worse than one that is not offered.
|
|
2637
|
+
possible: publishable_ !== undefined,
|
|
2638
|
+
}),
|
|
2639
|
+
start: async () => {
|
|
2640
|
+
if (publisher === null)
|
|
2641
|
+
publisher = makePublisher();
|
|
2642
|
+
if (publisher === null) {
|
|
2643
|
+
return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
|
|
2644
|
+
}
|
|
2645
|
+
const first = await publisher.start();
|
|
2646
|
+
if (first === null) {
|
|
2647
|
+
// Nothing was listed, so nothing should claim to be: a publisher
|
|
2648
|
+
// left running here would heartbeat at a directory that refused it.
|
|
2649
|
+
await publisher.stop();
|
|
2650
|
+
publisher = null;
|
|
2651
|
+
return {
|
|
2652
|
+
live: false, code: "", name: "", url: "",
|
|
2653
|
+
error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
listing = first;
|
|
2657
|
+
return { live: true, code: first.code, name: first.name, url: first.url };
|
|
2658
|
+
},
|
|
2659
|
+
stop: async () => {
|
|
2660
|
+
await publisher?.stop();
|
|
2661
|
+
publisher = null;
|
|
2662
|
+
listing = null;
|
|
2663
|
+
},
|
|
2664
|
+
},
|
|
2583
2665
|
...(ingest ? { ingest } : {}),
|
|
2584
2666
|
broadcaster,
|
|
2585
2667
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -2811,51 +2893,67 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2811
2893
|
}
|
|
2812
2894
|
// The listing carries the listen link, and only ever a public address: an
|
|
2813
2895
|
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
2814
|
-
|
|
2896
|
+
// Never a bare IP over https: a certificate is issued for a name, so a
|
|
2897
|
+
// listing pointing at one is a listing nobody can open.
|
|
2898
|
+
publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
|
|
2899
|
+
?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
|
|
2900
|
+
?? addresses.find((a) => a.label === "on the internet")
|
|
2815
2901
|
?? addresses.find((a) => a.label === "on tailscale");
|
|
2816
|
-
|
|
2817
|
-
|
|
2902
|
+
/**
|
|
2903
|
+
* Make a publisher for this server. Called at startup when the operator says
|
|
2904
|
+
* yes, and again whenever somebody goes live from the admin panel.
|
|
2905
|
+
*
|
|
2906
|
+
* A declaration rather than an assignment, so it exists from the moment the
|
|
2907
|
+
* function is entered -- the port is open well before this line is reached.
|
|
2908
|
+
*/
|
|
2909
|
+
function makePublisher() {
|
|
2910
|
+
if (!publishable_)
|
|
2911
|
+
return null;
|
|
2818
2912
|
const listen = shareLink(publishable_.url, listenKey);
|
|
2819
2913
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
2820
2914
|
// with a browser, the other for the phone line and anything else that is
|
|
2821
2915
|
// handed one address and expected to play it.
|
|
2822
2916
|
const audio = audioLink(publishable_.url, listenKey);
|
|
2917
|
+
return new Publisher({
|
|
2918
|
+
directory: DEFAULT_DIRECTORY,
|
|
2919
|
+
name: options.name || hostname(),
|
|
2920
|
+
url: listen,
|
|
2921
|
+
audio,
|
|
2922
|
+
// Asked of the engine rather than a variable, because the library is
|
|
2923
|
+
// now read after the port opens and may still be arriving.
|
|
2924
|
+
tracks: engine.snapshot(false).trackCount,
|
|
2925
|
+
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2926
|
+
// attribute to somebody, because a listing is now a phone code that
|
|
2927
|
+
// costs money to answer.
|
|
2928
|
+
...(session?.token ? { token: session.token } : {}),
|
|
2929
|
+
onRefused: () => {
|
|
2930
|
+
console.log("");
|
|
2931
|
+
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2932
|
+
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2933
|
+
},
|
|
2934
|
+
nowPlaying: () => {
|
|
2935
|
+
const snapshot = engine.snapshot();
|
|
2936
|
+
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2937
|
+
},
|
|
2938
|
+
onConfig: (remote) => {
|
|
2939
|
+
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2940
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2941
|
+
return;
|
|
2942
|
+
paywallConfig = next;
|
|
2943
|
+
console.log(next.enabled
|
|
2944
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2945
|
+
: " nixamp.com turned paid listening off.");
|
|
2946
|
+
},
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
if (options.publish !== "no" && publishable_) {
|
|
2950
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
2823
2951
|
const wanted = options.publish === "yes"
|
|
2824
2952
|
? true
|
|
2825
2953
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
2826
2954
|
if (wanted) {
|
|
2827
|
-
publisher =
|
|
2828
|
-
|
|
2829
|
-
name: options.name || hostname(),
|
|
2830
|
-
url: listen,
|
|
2831
|
-
audio,
|
|
2832
|
-
// Asked of the engine rather than a variable, because the library is
|
|
2833
|
-
// now read after the port opens and may still be arriving.
|
|
2834
|
-
tracks: engine.snapshot(false).trackCount,
|
|
2835
|
-
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2836
|
-
// attribute to somebody, because a listing is now a phone code that
|
|
2837
|
-
// costs money to answer.
|
|
2838
|
-
...(session?.token ? { token: session.token } : {}),
|
|
2839
|
-
onRefused: () => {
|
|
2840
|
-
console.log("");
|
|
2841
|
-
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2842
|
-
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2843
|
-
},
|
|
2844
|
-
nowPlaying: () => {
|
|
2845
|
-
const snapshot = engine.snapshot();
|
|
2846
|
-
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2847
|
-
},
|
|
2848
|
-
onConfig: (remote) => {
|
|
2849
|
-
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2850
|
-
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2851
|
-
return;
|
|
2852
|
-
paywallConfig = next;
|
|
2853
|
-
console.log(next.enabled
|
|
2854
|
-
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2855
|
-
: " nixamp.com turned paid listening off.");
|
|
2856
|
-
},
|
|
2857
|
-
});
|
|
2858
|
-
const listing = await publisher.start();
|
|
2955
|
+
publisher = makePublisher();
|
|
2956
|
+
listing = (await publisher?.start()) ?? null;
|
|
2859
2957
|
console.log("");
|
|
2860
2958
|
console.log(listing
|
|
2861
2959
|
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
package/dist/share.d.ts
CHANGED
|
@@ -17,6 +17,31 @@ export declare const KEY_HEADER = "x-nixamp-key";
|
|
|
17
17
|
* enough to read down a phone screen when someone types it by hand.
|
|
18
18
|
*/
|
|
19
19
|
export declare function newKey(): string;
|
|
20
|
+
/** The pair of keys a server hands out: one that drives, one that only hears. */
|
|
21
|
+
export interface KeyPair {
|
|
22
|
+
key: string;
|
|
23
|
+
listenKey: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The keys this port used last time, or a new pair remembered for next time.
|
|
27
|
+
*
|
|
28
|
+
* Keys used to be minted on every start, so every link anybody had been given
|
|
29
|
+
* died the moment the server was restarted -- and a server gets restarted to
|
|
30
|
+
* pick up a new version, which is to say often. A link you cannot rely on is
|
|
31
|
+
* not a link you can share, which was most of why sharing did not feel like it
|
|
32
|
+
* worked.
|
|
33
|
+
*
|
|
34
|
+
* Kept per port, because two servers on one machine are two different
|
|
35
|
+
* audiences, and a single remembered key would hand each of them the other's.
|
|
36
|
+
*
|
|
37
|
+
* The trade is that a key which never changes is a key that stays valid if it
|
|
38
|
+
* leaks, so `fresh` mints a new pair and forgets the old one -- which is what
|
|
39
|
+
* `--new-key` is for.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rememberedKeys(dir: string, port: number, fresh?: boolean, io?: {
|
|
42
|
+
read: (path: string) => string | null;
|
|
43
|
+
write: (path: string, body: string) => void;
|
|
44
|
+
}): KeyPair;
|
|
20
45
|
/** Compare without leaking where two keys first differ. */
|
|
21
46
|
export declare function keysMatch(a: string, b: string): boolean;
|
|
22
47
|
/** Every place a key is accepted from, in the order they are looked for. */
|
package/dist/share.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* every EventSource and every `<audio src>` on its own.
|
|
12
12
|
*/
|
|
13
13
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
14
16
|
import { networkInterfaces } from "node:os";
|
|
15
17
|
/** The cookie, and the query parameter that sets it. */
|
|
16
18
|
export const KEY_COOKIE = "nixamp_key";
|
|
@@ -23,6 +25,66 @@ export const KEY_HEADER = "x-nixamp-key";
|
|
|
23
25
|
export function newKey() {
|
|
24
26
|
return randomBytes(16).toString("base64url");
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The keys this port used last time, or a new pair remembered for next time.
|
|
30
|
+
*
|
|
31
|
+
* Keys used to be minted on every start, so every link anybody had been given
|
|
32
|
+
* died the moment the server was restarted -- and a server gets restarted to
|
|
33
|
+
* pick up a new version, which is to say often. A link you cannot rely on is
|
|
34
|
+
* not a link you can share, which was most of why sharing did not feel like it
|
|
35
|
+
* worked.
|
|
36
|
+
*
|
|
37
|
+
* Kept per port, because two servers on one machine are two different
|
|
38
|
+
* audiences, and a single remembered key would hand each of them the other's.
|
|
39
|
+
*
|
|
40
|
+
* The trade is that a key which never changes is a key that stays valid if it
|
|
41
|
+
* leaks, so `fresh` mints a new pair and forgets the old one -- which is what
|
|
42
|
+
* `--new-key` is for.
|
|
43
|
+
*/
|
|
44
|
+
export function rememberedKeys(dir, port, fresh = false, io = defaultKeyStore) {
|
|
45
|
+
const path = `${dir}/keys.json`;
|
|
46
|
+
let all = {};
|
|
47
|
+
const existing = io.read(path);
|
|
48
|
+
if (existing !== null) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(existing);
|
|
51
|
+
if (parsed && typeof parsed === "object")
|
|
52
|
+
all = parsed;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A file we cannot read is a file we replace. Losing a key costs a link;
|
|
56
|
+
// refusing to start costs the whole server.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const held = all[String(port)];
|
|
60
|
+
if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string")
|
|
61
|
+
return held;
|
|
62
|
+
const minted = { key: newKey(), listenKey: newKey() };
|
|
63
|
+
all[String(port)] = minted;
|
|
64
|
+
try {
|
|
65
|
+
io.write(path, JSON.stringify(all, null, 2));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Unwritable state is a key that will not survive a restart, which is how
|
|
69
|
+
// it behaved before this existed. Not a reason to refuse to serve.
|
|
70
|
+
}
|
|
71
|
+
return minted;
|
|
72
|
+
}
|
|
73
|
+
const defaultKeyStore = {
|
|
74
|
+
read: (path) => {
|
|
75
|
+
try {
|
|
76
|
+
return readFileSync(path, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
write: (path, body) => {
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
// Readable only by its owner: it is the password to this server.
|
|
85
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
26
88
|
/** Compare without leaking where two keys first differ. */
|
|
27
89
|
export function keysMatch(a, b) {
|
|
28
90
|
const left = Buffer.from(a);
|
|
@@ -217,6 +279,11 @@ export function allowedForListening(path) {
|
|
|
217
279
|
// album out of somebody else's playlist.
|
|
218
280
|
if (path === "/api/source" || path.startsWith("/api/source/"))
|
|
219
281
|
return false;
|
|
282
|
+
// Listing this machine in a public directory is not listening to it. The
|
|
283
|
+
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
284
|
+
// key is for.
|
|
285
|
+
if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop")
|
|
286
|
+
return false;
|
|
220
287
|
return true;
|
|
221
288
|
}
|
|
222
289
|
/**
|
package/package.json
CHANGED
package/src/owner.ts
CHANGED
|
@@ -104,7 +104,17 @@ export const ADMIN_PATHS = [
|
|
|
104
104
|
"/api/admin",
|
|
105
105
|
];
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Going live and coming back off, which is administering a server.
|
|
109
|
+
*
|
|
110
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
111
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
112
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
113
|
+
*/
|
|
114
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
115
|
+
|
|
107
116
|
export function needsAdmin(path: string, method = "GET"): boolean {
|
|
117
|
+
if (LIVE_CONTROL.includes(path)) return true;
|
|
108
118
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return true;
|
|
109
119
|
// Publishing to a channel, or ending one, is administering the server.
|
|
110
120
|
// Listening to a channel is not: that is what the share link is for.
|