nixamp 0.1.0 → 0.2.0

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/server.js CHANGED
@@ -11,11 +11,15 @@
11
11
  */
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer } from "node:http";
14
- import { networkInterfaces } from "node:os";
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
16
+ import { Connections } from "./connections.js";
17
+ import { isRemote } from "./sources.js";
18
+ import { elevate, firewallInUse, keyCookie, keyFrom, keysMatch, newKey, portCommands, reachableAddresses, shareLink, } from "./share.js";
15
19
  import { extname, join, normalize, resolve, sep } from "node:path";
16
20
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
17
21
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
18
- import { loadPlaylist } from "./playlist.js";
22
+ import { loadSource } from "./playlist.js";
19
23
  import { emptySnapshot, parseCommand, } from "./protocol.js";
20
24
  const FFT_SIZE = 2048;
21
25
  export const SERVE_BAND_COUNT = 24;
@@ -30,9 +34,15 @@ export function parseServeArgs(argv) {
30
34
  const options = {
31
35
  root: ".",
32
36
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
33
- host: "127.0.0.1",
37
+ // Every interface, because a player nobody else can reach is not much of a
38
+ // remote. The key in the link is what makes that safe; --no-key gives up
39
+ // both at once, and --host pins it back to one address.
40
+ host: "0.0.0.0",
34
41
  web: null,
35
42
  media: true,
43
+ key: true,
44
+ openPort: false,
45
+ announce: false,
36
46
  };
37
47
  let sawRoot = false;
38
48
  for (let i = 0; i < argv.length; i++) {
@@ -60,6 +70,15 @@ export function parseServeArgs(argv) {
60
70
  else if (arg === "--no-media") {
61
71
  options.media = false;
62
72
  }
73
+ else if (arg === "--no-key") {
74
+ options.key = false;
75
+ }
76
+ else if (arg === "--open-port") {
77
+ options.openPort = true;
78
+ }
79
+ else if (arg === "--announce") {
80
+ options.announce = true;
81
+ }
63
82
  else if (arg.startsWith("-")) {
64
83
  throw new Error(`nixamp serve: unknown option ${arg}`);
65
84
  }
@@ -80,6 +99,7 @@ const TYPES = {
80
99
  // The installer, so `curl https://nixamp.com/install.sh` is readable rather
81
100
  // than a download prompt.
82
101
  ".sh": "text/x-shellscript; charset=utf-8",
102
+ ".ps1": "text/plain; charset=utf-8",
83
103
  ".svg": "image/svg+xml",
84
104
  ".png": "image/png",
85
105
  ".ico": "image/x-icon",
@@ -343,6 +363,15 @@ export class PlayerEngine {
343
363
  }
344
364
  this.listeners.clear();
345
365
  }
366
+ replace(tracks, root) {
367
+ this.stop();
368
+ this.tracks = tracks;
369
+ this.root = root;
370
+ this.state.index = 0;
371
+ this.state.position = 0;
372
+ this.state.note = "";
373
+ this.push();
374
+ }
346
375
  }
347
376
  /** An engine with no library behind it, for the hosted PWA. */
348
377
  export class EmptyEngine {
@@ -361,6 +390,7 @@ export class EmptyEngine {
361
390
  trackPath() {
362
391
  return undefined;
363
392
  }
393
+ replace() { }
364
394
  stop() { }
365
395
  }
366
396
  const CORS = {
@@ -399,14 +429,54 @@ async function readBody(request, limit = 64 * 1024) {
399
429
  * drive it with a real socket and no ffmpeg in sight.
400
430
  */
401
431
  export function createHandler(engine, options) {
432
+ const tracker = options.connections ?? new Connections();
433
+ const started = Date.now();
434
+ /** Count a request in, count its bytes, and close it out exactly once. */
435
+ const watch = (request, response, kind, track) => {
436
+ const { id } = tracker.open(request, kind, track);
437
+ const write = response.write.bind(response);
438
+ response.write = ((chunk, ...rest) => {
439
+ if (typeof chunk === "string" || chunk instanceof Uint8Array) {
440
+ tracker.add(id, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength);
441
+ }
442
+ return write(chunk, ...rest);
443
+ });
444
+ // 'close' fires for a finished response and for a listener that walked
445
+ // away, which are the same thing as far as "is it still going" goes.
446
+ response.once("close", () => tracker.close(id));
447
+ };
402
448
  return async function handle(request, response) {
403
449
  const url = new URL(request.url ?? "/", "http://localhost");
404
450
  const path = url.pathname;
451
+ const key = options.key ?? null;
405
452
  if (request.method === "OPTIONS") {
406
453
  response.writeHead(204, CORS);
407
454
  response.end();
408
455
  return;
409
456
  }
457
+ // Opening the share link is what hands a browser its key. It comes back as
458
+ // a cookie, so every later fetch, EventSource and <audio src> carries it
459
+ // without the page knowing anything about keys.
460
+ if (key !== null && path.startsWith("/s/")) {
461
+ const offered = decodeURIComponent(path.slice("/s/".length));
462
+ if (!keysMatch(offered, key)) {
463
+ json(response, 404, { error: "not found" });
464
+ return;
465
+ }
466
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(key), location: "/" });
467
+ response.end();
468
+ return;
469
+ }
470
+ // /api/health answers unauthenticated on purpose: it is how you check the
471
+ // port is open from another device before wondering whether the link is
472
+ // wrong, and it says nothing about the library.
473
+ if (key !== null && path !== "/api/health") {
474
+ const offered = keyFrom(request, url);
475
+ if (offered === null || !keysMatch(offered, key)) {
476
+ json(response, 401, { error: "this nixamp needs the key from its share link" });
477
+ return;
478
+ }
479
+ }
410
480
  if (path === "/api/health") {
411
481
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
412
482
  return;
@@ -415,7 +485,19 @@ export function createHandler(engine, options) {
415
485
  json(response, 200, engine.snapshot());
416
486
  return;
417
487
  }
488
+ // Everything the admin view draws, in one request: who is connected, and
489
+ // what this server is.
490
+ if (path === "/api/connections") {
491
+ json(response, 200, {
492
+ connections: tracker.list(),
493
+ active: tracker.active,
494
+ startedAt: started,
495
+ now: Date.now(),
496
+ });
497
+ return;
498
+ }
418
499
  if (path === "/api/events") {
500
+ watch(request, response, "events", "");
419
501
  response.writeHead(200, {
420
502
  ...CORS,
421
503
  "content-type": "text/event-stream; charset=utf-8",
@@ -461,6 +543,39 @@ export function createHandler(engine, options) {
461
543
  json(response, 200, engine.snapshot());
462
544
  return;
463
545
  }
546
+ // Re-stream: hand the running server a different source. The listeners
547
+ // stay connected; what they are listening to changes under them.
548
+ if (path === "/api/source") {
549
+ if (request.method !== "POST") {
550
+ json(response, 405, { error: "POST only" });
551
+ return;
552
+ }
553
+ let source = "";
554
+ try {
555
+ source = String(JSON.parse(await readBody(request)).source ?? "");
556
+ }
557
+ catch {
558
+ json(response, 400, { error: "bad JSON" });
559
+ return;
560
+ }
561
+ if (!source) {
562
+ json(response, 400, { error: "no source given" });
563
+ return;
564
+ }
565
+ try {
566
+ const tracks = await options.load(source);
567
+ if (tracks.length === 0) {
568
+ json(response, 422, { error: `nothing to play at ${source}` });
569
+ return;
570
+ }
571
+ engine.replace(tracks, source);
572
+ json(response, 200, engine.snapshot());
573
+ }
574
+ catch (error) {
575
+ json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
576
+ }
577
+ return;
578
+ }
464
579
  if (path.startsWith("/api/media/")) {
465
580
  if (!options.media) {
466
581
  json(response, 403, { error: "media streaming is off" });
@@ -472,9 +587,29 @@ export function createHandler(engine, options) {
472
587
  json(response, 404, { error: "no such track" });
473
588
  return;
474
589
  }
590
+ watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
475
591
  sendFile(request, response, file);
476
592
  return;
477
593
  }
594
+ // Whatever the source is, this comes back as MP3 a browser will play:
595
+ // a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
596
+ // the bytes on as they arrive, so a live stream starts immediately rather
597
+ // than after it ends, which for a live stream is never.
598
+ if (path.startsWith("/api/stream/")) {
599
+ const index = Number(path.slice("/api/stream/".length));
600
+ const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
601
+ if (source === undefined) {
602
+ json(response, 404, { error: "no such track" });
603
+ return;
604
+ }
605
+ if (!options.media) {
606
+ json(response, 403, { error: "media streaming is off" });
607
+ return;
608
+ }
609
+ watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
610
+ transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
611
+ return;
612
+ }
478
613
  if (path.startsWith("/api/")) {
479
614
  json(response, 404, { error: "no such endpoint" });
480
615
  return;
@@ -501,6 +636,91 @@ export function createHandler(engine, options) {
501
636
  json(response, 404, { error: "not found" });
502
637
  };
503
638
  }
639
+ /** Read a file, or null. The firewall probe asks about files it may not have. */
640
+ function readIfPossible(path) {
641
+ try {
642
+ return readFileSync(path, "utf8");
643
+ }
644
+ catch {
645
+ return null;
646
+ }
647
+ }
648
+ /**
649
+ * Decode anything and hand back MP3, as it is produced.
650
+ *
651
+ * No seeking: this is a pipe, and the length is not known until it ends. The
652
+ * player falls back to /api/media for a local file it can seek, and uses this
653
+ * for everything else.
654
+ */
655
+ function transcode(request, response, source, ffmpeg) {
656
+ const [command, ...prefix] = ffmpeg;
657
+ const child = spawn(command, [
658
+ ...prefix,
659
+ "-hide_banner",
660
+ "-loglevel", "error",
661
+ // Reconnect through the sort of hiccup a long stream runs into. These
662
+ // belong to the http protocol, and ffmpeg rejects the whole command
663
+ // when they are handed to it for a file on disk.
664
+ ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
665
+ "-i", source,
666
+ "-vn",
667
+ "-f", "mp3",
668
+ "-b:a", "192k",
669
+ "-",
670
+ ], { stdio: ["ignore", "pipe", "pipe"] });
671
+ let failed = "";
672
+ child.stderr.on("data", (chunk) => {
673
+ // Keep the tail: ffmpeg says what went wrong on its last line.
674
+ failed = (failed + chunk.toString()).slice(-2000);
675
+ });
676
+ let started = false;
677
+ const begin = () => {
678
+ if (started)
679
+ return;
680
+ started = true;
681
+ response.writeHead(200, {
682
+ ...CORS,
683
+ "content-type": "audio/mpeg",
684
+ "cache-control": "no-store",
685
+ // Length is unknowable up front, and a browser is happy without it.
686
+ "transfer-encoding": "chunked",
687
+ });
688
+ };
689
+ // Wait for a first byte before promising success. ffmpeg rejects a bad option
690
+ // or a missing input immediately, and answering 200 with nothing looks the
691
+ // same from a player as a track that is simply silent.
692
+ child.stdout.once("data", begin);
693
+ // Both ends can fail: a listener closing the tab breaks the socket under the
694
+ // pipe, and an EPIPE nobody is listening for takes the process down.
695
+ child.stdout.on("error", () => child.kill("SIGKILL"));
696
+ response.on("error", () => child.kill("SIGKILL"));
697
+ child.stdout.pipe(response);
698
+ child.on("error", (error) => {
699
+ console.error(`nixamp: ffmpeg could not start: ${error.message}`);
700
+ if (!response.headersSent)
701
+ json(response, 500, { error: "ffmpeg could not start" });
702
+ else
703
+ response.end();
704
+ });
705
+ child.on("close", (code) => {
706
+ const message = failed.trim();
707
+ if (code !== 0 && code !== null)
708
+ console.error(`nixamp: ffmpeg exited ${code}: ${message}`);
709
+ if (!started) {
710
+ // Nothing was ever produced, so the status can still tell the truth.
711
+ json(response, 502, { error: "could not decode that source", detail: message.split("\n").pop() ?? "" });
712
+ return;
713
+ }
714
+ response.end();
715
+ });
716
+ // A listener that closes the tab should not leave an ffmpeg decoding into
717
+ // nothing for the rest of the album.
718
+ const stop = () => {
719
+ child.kill("SIGKILL");
720
+ };
721
+ request.on("close", stop);
722
+ response.on("close", stop);
723
+ }
504
724
  function isFile(path) {
505
725
  try {
506
726
  return statSync(path).isFile();
@@ -559,38 +779,118 @@ export function createServer(engine, options) {
559
779
  });
560
780
  });
561
781
  }
562
- /** Where a remote on another device should point its browser. */
563
- export function addressesFor(host, port) {
564
- if (host !== "0.0.0.0" && host !== "::")
565
- return [`http://${host}:${port}`];
566
- const out = [`http://localhost:${port}`];
567
- for (const entries of Object.values(networkInterfaces())) {
568
- for (const entry of entries ?? []) {
569
- if (entry.family === "IPv4" && !entry.internal)
570
- out.push(`http://${entry.address}:${port}`);
571
- }
572
- }
573
- return out;
574
- }
575
782
  export async function serve(argv, version = "0.1.0") {
576
783
  const options = parseServeArgs(argv);
577
- const root = resolve(options.root);
784
+ const root = isRemote(options.root) ? options.root : resolve(options.root);
578
785
  const tools = detectTools();
579
- const tracks = loadPlaylist(tools, root);
786
+ const tracks = await loadSource(tools, root);
580
787
  const engine = tracks.length > 0
581
788
  ? new PlayerEngine(tracks, root, tools)
582
789
  : new EmptyEngine(`No audio files under ${root}.`);
583
790
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
584
- const server = createServer(engine, { web, media: options.media, version });
585
- await new Promise((done) => server.listen(options.port, options.host, done));
791
+ const key = options.key ? newKey() : null;
792
+ const server = createServer(engine, {
793
+ web,
794
+ media: options.media,
795
+ version,
796
+ key,
797
+ ffmpeg: tools.ffmpeg,
798
+ load: (next) => loadSource(tools, next),
799
+ });
800
+ // A port already in use is the most ordinary failure there is, and it
801
+ // arrives as an unhandled 'error' event that takes the process down with a
802
+ // stack trace nobody reads.
803
+ await new Promise((done, fail) => {
804
+ server.once("error", (error) => {
805
+ fail(new Error(error.code === "EADDRINUSE"
806
+ ? `nixamp: port ${options.port} is already in use. Pass --port to pick another.`
807
+ : error.code === "EACCES"
808
+ ? `nixamp: not allowed to listen on port ${options.port}. Ports below 1024 need root.`
809
+ : `nixamp: could not listen on ${options.host}:${options.port}: ${error.message}`));
810
+ });
811
+ server.listen(options.port, options.host, done);
812
+ });
586
813
  const bound = server.address();
587
814
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
815
+ const io = {
816
+ read: readIfPossible,
817
+ run: (command, args) => {
818
+ const done = spawnSync(command, args, { encoding: "utf8" });
819
+ return { status: done.status, stdout: done.stdout ?? "" };
820
+ },
821
+ };
822
+ if (options.announce) {
823
+ console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
824
+ }
588
825
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
589
- for (const address of addressesFor(options.host, port))
590
- console.log(` ${address}`);
826
+ console.log("");
827
+ // The link, not the address. Without the key the address is a 401, so
828
+ // printing a bare host:port would be printing something that does not work.
829
+ const addresses = reachableAddresses(options.host, port);
830
+ const width = Math.max(...addresses.map((a) => a.label.length));
831
+ for (const { label, url } of addresses) {
832
+ console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
833
+ }
834
+ console.log("");
835
+ if (key === null) {
836
+ console.log(" No key: anyone who can reach this port can drive it and hear it.");
837
+ }
838
+ else {
839
+ console.log(" Open that link once on a phone or a laptop and it stays signed in.");
840
+ console.log(` Anything without the key gets a 401. Key: ${key}`);
841
+ }
842
+ if (addresses.some((a) => a.label === "on the internet")) {
843
+ console.log("");
844
+ console.log(key === null
845
+ ? " The public address is open to anyone: --no-key means no key. --host 127.0.0.1 keeps it here."
846
+ : " The public address works from anywhere, for anyone with the key. --host 127.0.0.1 keeps it here.");
847
+ }
848
+ if (!options.media)
849
+ console.log(" Audio stays on this machine: --no-media is set.");
591
850
  if (web === null)
592
- console.log(" (no built PWA found run `bun run web:build` to serve one)");
851
+ console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
852
+ // Listening on every interface proves the socket is open here and nothing
853
+ // about the path between here and the phone.
854
+ const listening = options.host === "0.0.0.0" || options.host === "::";
855
+ const firewall = listening ? firewallInUse(io) : null;
856
+ let closePort = null;
857
+ if (firewall !== null) {
858
+ const { open, close } = portCommands(firewall, port);
859
+ if (!options.openPort) {
860
+ console.log("");
861
+ console.log(` ${firewall} is running, so other devices cannot reach this port yet:`);
862
+ console.log(` sudo ${open.join(" ")}`);
863
+ console.log(" or start with --open-port and nixamp will do it, and undo it on exit.");
864
+ }
865
+ else {
866
+ const elevated = elevate(io, open);
867
+ if (elevated === null) {
868
+ console.log("");
869
+ console.log(` --open-port needs root or passwordless sudo. Run this yourself:`);
870
+ console.log(` sudo ${open.join(" ")}`);
871
+ }
872
+ else {
873
+ const done = spawnSync(elevated[0], elevated.slice(1), { encoding: "utf8" });
874
+ if (done.status === 0) {
875
+ console.log("");
876
+ console.log(` Opened ${port}/tcp in ${firewall}. It closes again when this exits.`);
877
+ // Leave the machine as it was found. A player should not be the
878
+ // reason a port is still open next week.
879
+ closePort = () => {
880
+ const undo = elevate(io, close);
881
+ if (undo)
882
+ spawnSync(undo[0], undo.slice(1), { stdio: "ignore" });
883
+ };
884
+ }
885
+ else {
886
+ console.log("");
887
+ console.log(` Could not open the port: ${(done.stderr || done.stdout || "").trim() || "unknown error"}`);
888
+ }
889
+ }
890
+ }
891
+ }
593
892
  const shutdown = () => {
893
+ closePort?.();
594
894
  engine.stop();
595
895
  server.close(() => process.exit(0));
596
896
  // A hung keep-alive should not outlive a ctrl-c.
@@ -0,0 +1,58 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ /** The cookie, and the query parameter that sets it. */
3
+ export declare const KEY_COOKIE = "nixamp_key";
4
+ export declare const KEY_QUERY = "k";
5
+ export declare const KEY_HEADER = "x-nixamp-key";
6
+ /**
7
+ * 128 bits, base64url. Long enough that guessing is not a strategy, short
8
+ * enough to read down a phone screen when someone types it by hand.
9
+ */
10
+ export declare function newKey(): string;
11
+ /** Compare without leaking where two keys first differ. */
12
+ export declare function keysMatch(a: string, b: string): boolean;
13
+ /** Every place a key is accepted from, in the order they are looked for. */
14
+ export declare function keyFrom(request: IncomingMessage, url: URL): string | null;
15
+ /** The Set-Cookie for a browser that just opened the link. */
16
+ export declare function keyCookie(key: string): string;
17
+ /** Where an address actually goes, which is not always where you would like. */
18
+ export declare function classify(address: string): "private" | "cgnat" | "public";
19
+ /**
20
+ * The addresses another device could actually reach this machine on, nearest
21
+ * first. On a server the public one is the point: it is the address a phone
22
+ * somewhere else can open. It is labelled for what it is, because the key in
23
+ * the link is then the only thing between a stranger and the library.
24
+ */
25
+ export declare function reachableAddresses(host: string, port: number): {
26
+ label: string;
27
+ url: string;
28
+ }[];
29
+ /** The full link, key and all. */
30
+ export declare function shareLink(base: string, key: string | null): string;
31
+ /** How to run a command, so the tests never touch a real firewall. */
32
+ export interface Runner {
33
+ read(path: string): string | null;
34
+ run(command: string, args: string[]): {
35
+ status: number | null;
36
+ stdout: string;
37
+ };
38
+ }
39
+ /** Which firewall is in the way, if any. */
40
+ export type Firewall = "ufw" | "firewalld";
41
+ /**
42
+ * Whether a firewall is running that would keep the port closed to other
43
+ * devices. Listening on 0.0.0.0 proves the socket is open on this machine and
44
+ * nothing more, so this is the difference between "it works" and "it works
45
+ * here".
46
+ */
47
+ export declare function firewallInUse(io: Runner): Firewall | null;
48
+ /** The commands that open and close a port, for each firewall we know. */
49
+ export declare function portCommands(firewall: Firewall, port: number): {
50
+ open: string[];
51
+ close: string[];
52
+ };
53
+ /**
54
+ * Root runs it directly; anyone else goes through sudo, and only when sudo
55
+ * will not stop to ask. A server that hangs on an invisible password prompt is
56
+ * worse than one that tells you the command to run yourself.
57
+ */
58
+ export declare function elevate(io: Runner, command: string[]): string[] | null;
package/dist/share.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The share link.
3
+ *
4
+ * `nixamp serve` listens on every interface so the phone in your pocket can
5
+ * reach it, and that is only reasonable because the address is not enough on
6
+ * its own: every request has to carry a key that is printed once, in the link.
7
+ * Someone else on the coffee shop wifi can find the port and gets nothing.
8
+ *
9
+ * The key travels in a cookie, set by opening the link. Nothing in the PWA had
10
+ * to change for that: a browser sends a same-origin cookie with every fetch,
11
+ * every EventSource and every `<audio src>` on its own.
12
+ */
13
+ import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { networkInterfaces } from "node:os";
15
+ /** The cookie, and the query parameter that sets it. */
16
+ export const KEY_COOKIE = "nixamp_key";
17
+ export const KEY_QUERY = "k";
18
+ export const KEY_HEADER = "x-nixamp-key";
19
+ /**
20
+ * 128 bits, base64url. Long enough that guessing is not a strategy, short
21
+ * enough to read down a phone screen when someone types it by hand.
22
+ */
23
+ export function newKey() {
24
+ return randomBytes(16).toString("base64url");
25
+ }
26
+ /** Compare without leaking where two keys first differ. */
27
+ export function keysMatch(a, b) {
28
+ const left = Buffer.from(a);
29
+ const right = Buffer.from(b);
30
+ // timingSafeEqual throws on a length mismatch, which is itself the answer.
31
+ return left.length === right.length && timingSafeEqual(left, right);
32
+ }
33
+ /** Every place a key is accepted from, in the order they are looked for. */
34
+ export function keyFrom(request, url) {
35
+ const query = url.searchParams.get(KEY_QUERY);
36
+ if (query)
37
+ return query;
38
+ const header = request.headers[KEY_HEADER];
39
+ if (typeof header === "string" && header)
40
+ return header;
41
+ for (const part of (request.headers.cookie ?? "").split(";")) {
42
+ const [name, ...rest] = part.trim().split("=");
43
+ if (name === KEY_COOKIE && rest.length > 0)
44
+ return decodeURIComponent(rest.join("="));
45
+ }
46
+ return null;
47
+ }
48
+ /** The Set-Cookie for a browser that just opened the link. */
49
+ export function keyCookie(key) {
50
+ // HttpOnly because nothing in the page reads it: the browser attaches it to
51
+ // every same-origin request by itself. No Secure, because the whole point is
52
+ // a plain-http address on your own network.
53
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
54
+ }
55
+ /**
56
+ * Interfaces that exist for containers and virtual machines. An address on one
57
+ * of these reaches a bridge, not the phone on the sofa, and listing six of them
58
+ * buries the one line someone actually needed.
59
+ */
60
+ const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|lo)/;
61
+ /** Where an address actually goes, which is not always where you would like. */
62
+ export function classify(address) {
63
+ const [a, b] = address.split(".").map(Number);
64
+ if (a === 10)
65
+ return "private";
66
+ if (a === 192 && b === 168)
67
+ return "private";
68
+ if (a === 172 && b >= 16 && b <= 31)
69
+ return "private";
70
+ if (a === 169 && b === 254)
71
+ return "private";
72
+ // 100.64/10 is carrier-grade NAT, which in practice means Tailscale here.
73
+ if (a === 100 && b >= 64 && b <= 127)
74
+ return "cgnat";
75
+ return "public";
76
+ }
77
+ /**
78
+ * The addresses another device could actually reach this machine on, nearest
79
+ * first. On a server the public one is the point: it is the address a phone
80
+ * somewhere else can open. It is labelled for what it is, because the key in
81
+ * the link is then the only thing between a stranger and the library.
82
+ */
83
+ export function reachableAddresses(host, port) {
84
+ const link = (address) => {
85
+ // A bare IPv6 address needs brackets before it is a URL.
86
+ const authority = address.includes(":") ? `[${address}]` : address;
87
+ return `http://${authority}:${port}`;
88
+ };
89
+ if (host !== "0.0.0.0" && host !== "::")
90
+ return [{ label: "here", url: link(host) }];
91
+ const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" };
92
+ const found = [];
93
+ for (const [name, entries] of Object.entries(networkInterfaces())) {
94
+ if (VIRTUAL.test(name))
95
+ continue;
96
+ for (const entry of entries ?? []) {
97
+ // Link-local v6 needs a scope id to be usable, and nobody types those in.
98
+ if (entry.internal || entry.family !== "IPv4")
99
+ continue;
100
+ const kind = classify(entry.address);
101
+ found.push({ label: LABELS[kind], url: link(entry.address), kind });
102
+ }
103
+ }
104
+ const order = { private: 0, cgnat: 1, public: 2 };
105
+ found.sort((x, y) => order[x.kind] - order[y.kind]);
106
+ return [
107
+ { label: "here", url: `http://localhost:${port}` },
108
+ ...found.map(({ label, url }) => ({ label, url })),
109
+ ];
110
+ }
111
+ /** The full link, key and all. */
112
+ export function shareLink(base, key) {
113
+ return key === null ? base : `${base}/s/${key}`;
114
+ }
115
+ /**
116
+ * Whether a firewall is running that would keep the port closed to other
117
+ * devices. Listening on 0.0.0.0 proves the socket is open on this machine and
118
+ * nothing more, so this is the difference between "it works" and "it works
119
+ * here".
120
+ */
121
+ export function firewallInUse(io) {
122
+ if (process.platform !== "linux")
123
+ return null;
124
+ // ufw keeps its state in a file, so asking needs no privileges.
125
+ const ufw = io.read("/etc/ufw/ufw.conf");
126
+ if (ufw && /^ENABLED=yes/im.test(ufw))
127
+ return "ufw";
128
+ const firewalld = io.run("systemctl", ["is-active", "firewalld"]);
129
+ if (firewalld.status === 0 && firewalld.stdout.trim() === "active")
130
+ return "firewalld";
131
+ return null;
132
+ }
133
+ /** The commands that open and close a port, for each firewall we know. */
134
+ export function portCommands(firewall, port) {
135
+ return firewall === "ufw"
136
+ ? { open: ["ufw", "allow", `${port}/tcp`], close: ["ufw", "delete", "allow", `${port}/tcp`] }
137
+ : {
138
+ open: ["firewall-cmd", `--add-port=${port}/tcp`],
139
+ close: ["firewall-cmd", `--remove-port=${port}/tcp`],
140
+ };
141
+ }
142
+ /**
143
+ * Root runs it directly; anyone else goes through sudo, and only when sudo
144
+ * will not stop to ask. A server that hangs on an invisible password prompt is
145
+ * worse than one that tells you the command to run yourself.
146
+ */
147
+ export function elevate(io, command) {
148
+ const [head, ...rest] = command;
149
+ if (typeof process.getuid === "function" && process.getuid() === 0)
150
+ return [head, ...rest];
151
+ const canSudo = io.run("sudo", ["-n", "true"]);
152
+ return canSudo.status === 0 ? ["sudo", "-n", head, ...rest] : null;
153
+ }