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 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, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
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
- const key = options.key ? newKey() : null;
2377
- // Minted whether or not it is published, so `nixamp admin` and the operator
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 = key === null ? null : newKey();
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
- const publishable_ = addresses.find((a) => a.label === "on the internet")
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
- let publisher = null;
2817
- if (options.publish !== "no" && publishable_) {
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 = new Publisher({
2828
- directory: DEFAULT_DIRECTORY,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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",
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.