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/README.md +54 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/connections.d.ts +66 -0
- package/dist/connections.js +115 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/main.js +82 -6
- package/dist/manage.js +28 -6
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/server.d.ts +38 -4
- package/dist/server.js +323 -23
- package/dist/share.d.ts +58 -0
- package/dist/share.js +153 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +1 -1
- package/src/admin.ts +243 -0
- package/src/connections.ts +145 -0
- package/src/daemon.ts +193 -0
- package/src/main.ts +86 -6
- package/src/manage.ts +33 -6
- package/src/playlist.ts +68 -2
- package/src/server.ts +393 -21
- package/src/share.ts +166 -0
- package/src/sources.ts +136 -0
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +1 -1
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 {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
585
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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("
|
|
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.
|
package/dist/share.d.ts
ADDED
|
@@ -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
|
+
}
|