nixamp 0.5.2 → 0.5.4
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/daemon.d.ts +30 -0
- package/dist/daemon.js +38 -0
- package/dist/main.js +4 -7
- package/dist/server.d.ts +6 -0
- package/dist/server.js +45 -9
- package/dist/share.d.ts +14 -0
- package/dist/share.js +35 -0
- package/package.json +1 -1
- package/src/daemon.ts +75 -0
- package/src/main.ts +3 -7
- package/src/server.ts +56 -8
- package/src/share.ts +35 -0
- package/web/dist/sw.js +1 -1
package/dist/daemon.d.ts
CHANGED
|
@@ -8,6 +8,25 @@ export interface DaemonState {
|
|
|
8
8
|
startedAt: number;
|
|
9
9
|
/** Where its output went, for when it died and you want to know why. */
|
|
10
10
|
log: string;
|
|
11
|
+
/**
|
|
12
|
+
* The labelled addresses the server itself worked out, share key not applied.
|
|
13
|
+
*
|
|
14
|
+
* Recorded rather than recomputed because only the server knows them: a
|
|
15
|
+
* daemon bound to every interface has no single host to print, and the
|
|
16
|
+
* public one may be a tunnel it was told about rather than an interface
|
|
17
|
+
* anybody here can see. Absent on a state file written by an older nixamp.
|
|
18
|
+
*/
|
|
19
|
+
urls?: {
|
|
20
|
+
label: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}[];
|
|
23
|
+
/** The firewall standing between this port and the rest of the network. */
|
|
24
|
+
firewall?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* The public address was asked of an outside service rather than found on an
|
|
27
|
+
* interface, so it names the router and not this port.
|
|
28
|
+
*/
|
|
29
|
+
guessedPublic?: boolean;
|
|
11
30
|
}
|
|
12
31
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
13
32
|
export declare function stateDir(): string;
|
|
@@ -29,6 +48,17 @@ export declare function status(): {
|
|
|
29
48
|
};
|
|
30
49
|
/** The URL an admin client should talk to. */
|
|
31
50
|
export declare function daemonUrl(state: DaemonState): string;
|
|
51
|
+
/**
|
|
52
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
53
|
+
* starting a daemon.
|
|
54
|
+
*
|
|
55
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
56
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
57
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
58
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
59
|
+
* typed the command.
|
|
60
|
+
*/
|
|
61
|
+
export declare function daemonLines(state: DaemonState): string[];
|
|
32
62
|
/**
|
|
33
63
|
* Start one, detached, and wait until it is actually answering before saying
|
|
34
64
|
* it started. Reporting success and leaving the user to discover a crash in a
|
package/dist/daemon.js
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
+
import { portCommands } from "./share.js";
|
|
13
14
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
14
15
|
export function stateDir() {
|
|
15
16
|
const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
|
|
@@ -63,6 +64,39 @@ export function daemonUrl(state) {
|
|
|
63
64
|
const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
|
|
64
65
|
return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
69
|
+
* starting a daemon.
|
|
70
|
+
*
|
|
71
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
72
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
73
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
74
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
75
|
+
* typed the command.
|
|
76
|
+
*/
|
|
77
|
+
export function daemonLines(state) {
|
|
78
|
+
const link = (url) => (state.key ? `${url}/s/${state.key}` : url);
|
|
79
|
+
// A state file written by an older nixamp has no list, so host and port
|
|
80
|
+
// still stand in rather than printing nothing at all.
|
|
81
|
+
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
82
|
+
const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
|
|
83
|
+
const lines = [`nixamp daemon running (pid ${state.pid})`];
|
|
84
|
+
for (const { label, url } of addresses)
|
|
85
|
+
lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
86
|
+
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
87
|
+
if (state.guessedPublic) {
|
|
88
|
+
lines.push("", " That internet address is this machine's router, not this port.", ` Nothing outside reaches it until ${state.port} is forwarded here.`);
|
|
89
|
+
}
|
|
90
|
+
if (state.firewall) {
|
|
91
|
+
const { open } = portCommands(state.firewall, state.port);
|
|
92
|
+
lines.push("", ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`, ` sudo ${open.join(" ")}`, " or `nixamp daemon stop` and start again with --open-port.");
|
|
93
|
+
}
|
|
94
|
+
if (!addresses.some((a) => a.label === "on the internet")) {
|
|
95
|
+
lines.push("", " None of those work from outside this network. If it should:", " nixamp daemon start ... --public-url https://your-tunnel.example.com");
|
|
96
|
+
}
|
|
97
|
+
lines.push("", " nixamp attach the player, in front of it", " nixamp admin who is connected", " nixamp daemon stop when you are done");
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
66
100
|
/**
|
|
67
101
|
* Start one, detached, and wait until it is actually answering before saying
|
|
68
102
|
* it started. Reporting success and leaving the user to discover a crash in a
|
|
@@ -119,11 +153,15 @@ async function waitForAnnounce(log, timeoutMs) {
|
|
|
119
153
|
try {
|
|
120
154
|
const parsed = JSON.parse(line);
|
|
121
155
|
if (parsed["nixamp"] === "listening") {
|
|
156
|
+
const urls = parsed["urls"];
|
|
122
157
|
return {
|
|
123
158
|
host: String(parsed["host"]),
|
|
124
159
|
port: Number(parsed["port"]),
|
|
125
160
|
key: parsed["key"] ?? null,
|
|
126
161
|
source: String(parsed["source"]),
|
|
162
|
+
...(Array.isArray(urls) ? { urls: urls } : {}),
|
|
163
|
+
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
|
164
|
+
...(parsed["guessedPublic"] === true ? { guessedPublic: true } : {}),
|
|
127
165
|
};
|
|
128
166
|
}
|
|
129
167
|
}
|
package/dist/main.js
CHANGED
|
@@ -70,6 +70,8 @@ Options for serve:
|
|
|
70
70
|
--public-url URL the address this server is reachable at from outside,
|
|
71
71
|
when that is a tunnel or a forwarded port rather than one of
|
|
72
72
|
its own interfaces. Also NIXAMP_PUBLIC_URL
|
|
73
|
+
--no-lookup do not ask ipinfo.io what this machine's public address is
|
|
74
|
+
when nothing local looks public
|
|
73
75
|
--ingest accept a live stream in at POST /api/ingest
|
|
74
76
|
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
75
77
|
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
@@ -195,13 +197,8 @@ async function runDaemon(argv) {
|
|
|
195
197
|
if (action === "start") {
|
|
196
198
|
try {
|
|
197
199
|
const state = await d.start(rest, entry);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
201
|
-
console.log(` ${state.source}`);
|
|
202
|
-
console.log("");
|
|
203
|
-
console.log(" nixamp admin who is connected");
|
|
204
|
-
console.log(" nixamp daemon stop when you are done");
|
|
200
|
+
for (const line of d.daemonLines(state))
|
|
201
|
+
console.log(line);
|
|
205
202
|
return 0;
|
|
206
203
|
}
|
|
207
204
|
catch (error) {
|
package/dist/server.d.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface ServeOptions {
|
|
|
56
56
|
* listing is skipped and the printed links only work inside the house.
|
|
57
57
|
*/
|
|
58
58
|
publicUrl: string;
|
|
59
|
+
/**
|
|
60
|
+
* Ask an outside service what this machine's public address is, when no
|
|
61
|
+
* interface holds one and none was given. Behind NAT that is the only way to
|
|
62
|
+
* learn it, and it is one short request at startup.
|
|
63
|
+
*/
|
|
64
|
+
lookup: boolean;
|
|
59
65
|
/**
|
|
60
66
|
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
61
67
|
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
package/dist/server.js
CHANGED
|
@@ -34,7 +34,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
|
34
34
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
35
35
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
36
36
|
import { isRemote, playsInBrowser } from "./sources.js";
|
|
37
|
-
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
37
|
+
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
38
38
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -67,6 +67,7 @@ export function parseServeArgs(argv) {
|
|
|
67
67
|
publish: "ask",
|
|
68
68
|
name: "",
|
|
69
69
|
publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
|
|
70
|
+
lookup: true,
|
|
70
71
|
x402: false,
|
|
71
72
|
owner: "",
|
|
72
73
|
ingest: false,
|
|
@@ -127,6 +128,9 @@ export function parseServeArgs(argv) {
|
|
|
127
128
|
}
|
|
128
129
|
options.publicUrl = given.replace(/\/+$/, "");
|
|
129
130
|
}
|
|
131
|
+
else if (arg === "--no-lookup") {
|
|
132
|
+
options.lookup = false;
|
|
133
|
+
}
|
|
130
134
|
else if (arg === "--name") {
|
|
131
135
|
options.name = value();
|
|
132
136
|
}
|
|
@@ -2085,8 +2089,42 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2085
2089
|
return { status: done.status, stdout: done.stdout ?? "" };
|
|
2086
2090
|
},
|
|
2087
2091
|
};
|
|
2092
|
+
// The link, not the address. Without the key the address is a 401, so
|
|
2093
|
+
// printing a bare host:port would be printing something that does not work.
|
|
2094
|
+
//
|
|
2095
|
+
// Behind NAT no interface holds the public address, so if nobody said what it
|
|
2096
|
+
// is and nothing local looks public, ask. What comes back is a fact about the
|
|
2097
|
+
// router and not about this port -- the port still has to be forwarded -- so
|
|
2098
|
+
// it is marked as a guess and everything that prints it says so.
|
|
2099
|
+
const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2100
|
+
const guessedPublic = options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
|
|
2101
|
+
? await lookupPublicIp()
|
|
2102
|
+
: "";
|
|
2103
|
+
const addresses = guessedPublic
|
|
2104
|
+
? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
|
|
2105
|
+
: localAddresses;
|
|
2106
|
+
// Listening on every interface proves the socket is open here and nothing
|
|
2107
|
+
// about the path between here and the phone.
|
|
2108
|
+
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2109
|
+
const firewall = listening ? firewallInUse(io) : null;
|
|
2088
2110
|
if (options.announce) {
|
|
2089
|
-
|
|
2111
|
+
// Everything `nixamp daemon start` needs to print what this would have
|
|
2112
|
+
// printed. Without the addresses it could only reconstruct host and port,
|
|
2113
|
+
// which for a server bound to every interface means it printed 127.0.0.1 --
|
|
2114
|
+
// an address that works on exactly the machine you are already sitting at.
|
|
2115
|
+
// The firewall matters for the same reason: the warning was going into a
|
|
2116
|
+
// log file nobody reads instead of to the person who just typed the
|
|
2117
|
+
// command.
|
|
2118
|
+
console.log(JSON.stringify({
|
|
2119
|
+
nixamp: "listening",
|
|
2120
|
+
host: options.host,
|
|
2121
|
+
port,
|
|
2122
|
+
key,
|
|
2123
|
+
source: root,
|
|
2124
|
+
urls: addresses,
|
|
2125
|
+
firewall,
|
|
2126
|
+
guessedPublic: guessedPublic !== "",
|
|
2127
|
+
}));
|
|
2090
2128
|
}
|
|
2091
2129
|
// The other half of "names now, tags later". It runs while the banner is
|
|
2092
2130
|
// printed and while the publish prompt waits, and it is deliberately not
|
|
@@ -2102,13 +2140,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2102
2140
|
}
|
|
2103
2141
|
console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
|
|
2104
2142
|
console.log("");
|
|
2105
|
-
// The link, not the address. Without the key the address is a 401, so
|
|
2106
|
-
// printing a bare host:port would be printing something that does not work.
|
|
2107
|
-
const addresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2108
2143
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2109
2144
|
for (const { label, url } of addresses) {
|
|
2110
2145
|
console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
|
|
2111
2146
|
}
|
|
2147
|
+
if (guessedPublic) {
|
|
2148
|
+
console.log("");
|
|
2149
|
+
console.log(` That internet address is this machine's router, not this port.`);
|
|
2150
|
+
console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
|
|
2151
|
+
}
|
|
2112
2152
|
console.log("");
|
|
2113
2153
|
if (key === null) {
|
|
2114
2154
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
@@ -2161,10 +2201,6 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2161
2201
|
}
|
|
2162
2202
|
if (web === null)
|
|
2163
2203
|
console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
2164
|
-
// Listening on every interface proves the socket is open here and nothing
|
|
2165
|
-
// about the path between here and the phone.
|
|
2166
|
-
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2167
|
-
const firewall = listening ? firewallInUse(io) : null;
|
|
2168
2204
|
let closePort = null;
|
|
2169
2205
|
if (firewall !== null) {
|
|
2170
2206
|
const { open, close } = portCommands(firewall, port);
|
package/dist/share.d.ts
CHANGED
|
@@ -25,6 +25,20 @@ export declare function keyFrom(request: IncomingMessage, url: URL): string | nu
|
|
|
25
25
|
export declare function keyCookie(key: string): string;
|
|
26
26
|
/** Where an address actually goes, which is not always where you would like. */
|
|
27
27
|
export declare function classify(address: string): "private" | "cgnat" | "public";
|
|
28
|
+
/** Looks like an address, rather than an error page or a rate-limit notice. */
|
|
29
|
+
export declare function isIpAddress(value: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The address the rest of the world sees this machine as, by asking.
|
|
32
|
+
*
|
|
33
|
+
* Behind NAT no interface holds it, so there is nobody to ask but somebody
|
|
34
|
+
* outside. It is a claim about the router, not about this port: an address
|
|
35
|
+
* discovered this way is only reachable once the router forwards the port to
|
|
36
|
+
* this machine, which is why what prints it says so.
|
|
37
|
+
*
|
|
38
|
+
* Empty string for every failure. A player must not spend its startup waiting
|
|
39
|
+
* on somebody else's web service.
|
|
40
|
+
*/
|
|
41
|
+
export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number): Promise<string>;
|
|
28
42
|
/**
|
|
29
43
|
* The addresses another device could actually reach this machine on, nearest
|
|
30
44
|
* first. On a server the public one is the point: it is the address a phone
|
package/dist/share.js
CHANGED
|
@@ -74,6 +74,41 @@ export function classify(address) {
|
|
|
74
74
|
return "cgnat";
|
|
75
75
|
return "public";
|
|
76
76
|
}
|
|
77
|
+
/** Looks like an address, rather than an error page or a rate-limit notice. */
|
|
78
|
+
export function isIpAddress(value) {
|
|
79
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) {
|
|
80
|
+
return value.split(".").every((part) => Number(part) <= 255);
|
|
81
|
+
}
|
|
82
|
+
// Enough of v6 to reject prose: hex groups and colons, nothing else. This is
|
|
83
|
+
// not a validator, it is a guard against printing an error page as an address.
|
|
84
|
+
return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The address the rest of the world sees this machine as, by asking.
|
|
88
|
+
*
|
|
89
|
+
* Behind NAT no interface holds it, so there is nobody to ask but somebody
|
|
90
|
+
* outside. It is a claim about the router, not about this port: an address
|
|
91
|
+
* discovered this way is only reachable once the router forwards the port to
|
|
92
|
+
* this machine, which is why what prints it says so.
|
|
93
|
+
*
|
|
94
|
+
* Empty string for every failure. A player must not spend its startup waiting
|
|
95
|
+
* on somebody else's web service.
|
|
96
|
+
*/
|
|
97
|
+
export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
|
|
98
|
+
try {
|
|
99
|
+
const answer = await send("https://ipinfo.io/ip", {
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
headers: { accept: "text/plain", "user-agent": "nixamp" },
|
|
102
|
+
});
|
|
103
|
+
if (!answer.ok)
|
|
104
|
+
return "";
|
|
105
|
+
const found = (await answer.text()).trim();
|
|
106
|
+
return isIpAddress(found) ? found : "";
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
77
112
|
/**
|
|
78
113
|
* The addresses another device could actually reach this machine on, nearest
|
|
79
114
|
* first. On a server the public one is the point: it is the address a phone
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
+
import { type Firewall, portCommands } from "./share.ts";
|
|
13
14
|
|
|
14
15
|
export interface DaemonState {
|
|
15
16
|
pid: number;
|
|
@@ -21,6 +22,22 @@ export interface DaemonState {
|
|
|
21
22
|
startedAt: number;
|
|
22
23
|
/** Where its output went, for when it died and you want to know why. */
|
|
23
24
|
log: string;
|
|
25
|
+
/**
|
|
26
|
+
* The labelled addresses the server itself worked out, share key not applied.
|
|
27
|
+
*
|
|
28
|
+
* Recorded rather than recomputed because only the server knows them: a
|
|
29
|
+
* daemon bound to every interface has no single host to print, and the
|
|
30
|
+
* public one may be a tunnel it was told about rather than an interface
|
|
31
|
+
* anybody here can see. Absent on a state file written by an older nixamp.
|
|
32
|
+
*/
|
|
33
|
+
urls?: { label: string; url: string }[];
|
|
34
|
+
/** The firewall standing between this port and the rest of the network. */
|
|
35
|
+
firewall?: string | null;
|
|
36
|
+
/**
|
|
37
|
+
* The public address was asked of an outside service rather than found on an
|
|
38
|
+
* interface, so it names the router and not this port.
|
|
39
|
+
*/
|
|
40
|
+
guessedPublic?: boolean;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
@@ -82,6 +99,60 @@ export function daemonUrl(state: DaemonState): string {
|
|
|
82
99
|
return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
83
100
|
}
|
|
84
101
|
|
|
102
|
+
/**
|
|
103
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
104
|
+
* starting a daemon.
|
|
105
|
+
*
|
|
106
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
107
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
108
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
109
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
110
|
+
* typed the command.
|
|
111
|
+
*/
|
|
112
|
+
export function daemonLines(state: DaemonState): string[] {
|
|
113
|
+
const link = (url: string): string => (state.key ? `${url}/s/${state.key}` : url);
|
|
114
|
+
// A state file written by an older nixamp has no list, so host and port
|
|
115
|
+
// still stand in rather than printing nothing at all.
|
|
116
|
+
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
117
|
+
const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
|
|
118
|
+
|
|
119
|
+
const lines = [`nixamp daemon running (pid ${state.pid})`];
|
|
120
|
+
for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
121
|
+
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
122
|
+
|
|
123
|
+
if (state.guessedPublic) {
|
|
124
|
+
lines.push(
|
|
125
|
+
"",
|
|
126
|
+
" That internet address is this machine's router, not this port.",
|
|
127
|
+
` Nothing outside reaches it until ${state.port} is forwarded here.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (state.firewall) {
|
|
131
|
+
const { open } = portCommands(state.firewall as Firewall, state.port);
|
|
132
|
+
lines.push(
|
|
133
|
+
"",
|
|
134
|
+
` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`,
|
|
135
|
+
` sudo ${open.join(" ")}`,
|
|
136
|
+
" or `nixamp daemon stop` and start again with --open-port.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!addresses.some((a) => a.label === "on the internet")) {
|
|
140
|
+
lines.push(
|
|
141
|
+
"",
|
|
142
|
+
" None of those work from outside this network. If it should:",
|
|
143
|
+
" nixamp daemon start ... --public-url https://your-tunnel.example.com",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push(
|
|
148
|
+
"",
|
|
149
|
+
" nixamp attach the player, in front of it",
|
|
150
|
+
" nixamp admin who is connected",
|
|
151
|
+
" nixamp daemon stop when you are done",
|
|
152
|
+
);
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
85
156
|
/**
|
|
86
157
|
* Start one, detached, and wait until it is actually answering before saying
|
|
87
158
|
* it started. Reporting success and leaving the user to discover a crash in a
|
|
@@ -142,11 +213,15 @@ async function waitForAnnounce(
|
|
|
142
213
|
try {
|
|
143
214
|
const parsed = JSON.parse(line) as { nixamp?: string } & Record<string, unknown>;
|
|
144
215
|
if (parsed["nixamp"] === "listening") {
|
|
216
|
+
const urls = parsed["urls"];
|
|
145
217
|
return {
|
|
146
218
|
host: String(parsed["host"]),
|
|
147
219
|
port: Number(parsed["port"]),
|
|
148
220
|
key: (parsed["key"] as string | null) ?? null,
|
|
149
221
|
source: String(parsed["source"]),
|
|
222
|
+
...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
|
|
223
|
+
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
|
224
|
+
...(parsed["guessedPublic"] === true ? { guessedPublic: true } : {}),
|
|
150
225
|
};
|
|
151
226
|
}
|
|
152
227
|
} catch {
|
package/src/main.ts
CHANGED
|
@@ -94,6 +94,8 @@ Options for serve:
|
|
|
94
94
|
--public-url URL the address this server is reachable at from outside,
|
|
95
95
|
when that is a tunnel or a forwarded port rather than one of
|
|
96
96
|
its own interfaces. Also NIXAMP_PUBLIC_URL
|
|
97
|
+
--no-lookup do not ask ipinfo.io what this machine's public address is
|
|
98
|
+
when nothing local looks public
|
|
97
99
|
--ingest accept a live stream in at POST /api/ingest
|
|
98
100
|
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
99
101
|
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
@@ -224,13 +226,7 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
224
226
|
if (action === "start") {
|
|
225
227
|
try {
|
|
226
228
|
const state = await d.start(rest, entry);
|
|
227
|
-
|
|
228
|
-
const url = d.daemonUrl(state);
|
|
229
|
-
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
230
|
-
console.log(` ${state.source}`);
|
|
231
|
-
console.log("");
|
|
232
|
-
console.log(" nixamp admin who is connected");
|
|
233
|
-
console.log(" nixamp daemon stop when you are done");
|
|
229
|
+
for (const line of d.daemonLines(state)) console.log(line);
|
|
234
230
|
return 0;
|
|
235
231
|
} catch (error) {
|
|
236
232
|
console.error((error as Error).message);
|
package/src/server.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
keyCookie,
|
|
62
62
|
keyFrom,
|
|
63
63
|
keysMatch,
|
|
64
|
+
lookupPublicIp,
|
|
64
65
|
newKey,
|
|
65
66
|
portCommands,
|
|
66
67
|
reachableAddresses,
|
|
@@ -128,6 +129,12 @@ export interface ServeOptions {
|
|
|
128
129
|
* listing is skipped and the printed links only work inside the house.
|
|
129
130
|
*/
|
|
130
131
|
publicUrl: string;
|
|
132
|
+
/**
|
|
133
|
+
* Ask an outside service what this machine's public address is, when no
|
|
134
|
+
* interface holds one and none was given. Behind NAT that is the only way to
|
|
135
|
+
* learn it, and it is one short request at startup.
|
|
136
|
+
*/
|
|
137
|
+
lookup: boolean;
|
|
131
138
|
/**
|
|
132
139
|
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
133
140
|
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
@@ -178,6 +185,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
178
185
|
publish: "ask",
|
|
179
186
|
name: "",
|
|
180
187
|
publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
|
|
188
|
+
lookup: true,
|
|
181
189
|
x402: false,
|
|
182
190
|
owner: "",
|
|
183
191
|
ingest: false,
|
|
@@ -226,6 +234,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
226
234
|
throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
|
|
227
235
|
}
|
|
228
236
|
options.publicUrl = given.replace(/\/+$/, "");
|
|
237
|
+
} else if (arg === "--no-lookup") {
|
|
238
|
+
options.lookup = false;
|
|
229
239
|
} else if (arg === "--name") {
|
|
230
240
|
options.name = value();
|
|
231
241
|
} else if (arg === "--owner") {
|
|
@@ -2390,8 +2400,47 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2390
2400
|
},
|
|
2391
2401
|
};
|
|
2392
2402
|
|
|
2403
|
+
// The link, not the address. Without the key the address is a 401, so
|
|
2404
|
+
// printing a bare host:port would be printing something that does not work.
|
|
2405
|
+
//
|
|
2406
|
+
// Behind NAT no interface holds the public address, so if nobody said what it
|
|
2407
|
+
// is and nothing local looks public, ask. What comes back is a fact about the
|
|
2408
|
+
// router and not about this port -- the port still has to be forwarded -- so
|
|
2409
|
+
// it is marked as a guess and everything that prints it says so.
|
|
2410
|
+
const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2411
|
+
const guessedPublic =
|
|
2412
|
+
options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
|
|
2413
|
+
? await lookupPublicIp()
|
|
2414
|
+
: "";
|
|
2415
|
+
const addresses = guessedPublic
|
|
2416
|
+
? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
|
|
2417
|
+
: localAddresses;
|
|
2418
|
+
|
|
2419
|
+
// Listening on every interface proves the socket is open here and nothing
|
|
2420
|
+
// about the path between here and the phone.
|
|
2421
|
+
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2422
|
+
const firewall = listening ? firewallInUse(io) : null;
|
|
2423
|
+
|
|
2393
2424
|
if (options.announce) {
|
|
2394
|
-
|
|
2425
|
+
// Everything `nixamp daemon start` needs to print what this would have
|
|
2426
|
+
// printed. Without the addresses it could only reconstruct host and port,
|
|
2427
|
+
// which for a server bound to every interface means it printed 127.0.0.1 --
|
|
2428
|
+
// an address that works on exactly the machine you are already sitting at.
|
|
2429
|
+
// The firewall matters for the same reason: the warning was going into a
|
|
2430
|
+
// log file nobody reads instead of to the person who just typed the
|
|
2431
|
+
// command.
|
|
2432
|
+
console.log(
|
|
2433
|
+
JSON.stringify({
|
|
2434
|
+
nixamp: "listening",
|
|
2435
|
+
host: options.host,
|
|
2436
|
+
port,
|
|
2437
|
+
key,
|
|
2438
|
+
source: root,
|
|
2439
|
+
urls: addresses,
|
|
2440
|
+
firewall,
|
|
2441
|
+
guessedPublic: guessedPublic !== "",
|
|
2442
|
+
}),
|
|
2443
|
+
);
|
|
2395
2444
|
}
|
|
2396
2445
|
|
|
2397
2446
|
// The other half of "names now, tags later". It runs while the banner is
|
|
@@ -2410,14 +2459,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2410
2459
|
console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
|
|
2411
2460
|
console.log("");
|
|
2412
2461
|
|
|
2413
|
-
// The link, not the address. Without the key the address is a 401, so
|
|
2414
|
-
// printing a bare host:port would be printing something that does not work.
|
|
2415
|
-
const addresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2416
2462
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2417
2463
|
for (const { label, url } of addresses) {
|
|
2418
2464
|
console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
|
|
2419
2465
|
}
|
|
2420
2466
|
|
|
2467
|
+
if (guessedPublic) {
|
|
2468
|
+
console.log("");
|
|
2469
|
+
console.log(` That internet address is this machine's router, not this port.`);
|
|
2470
|
+
console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2421
2473
|
console.log("");
|
|
2422
2474
|
if (key === null) {
|
|
2423
2475
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
@@ -2469,10 +2521,6 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2469
2521
|
}
|
|
2470
2522
|
if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
2471
2523
|
|
|
2472
|
-
// Listening on every interface proves the socket is open here and nothing
|
|
2473
|
-
// about the path between here and the phone.
|
|
2474
|
-
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2475
|
-
const firewall = listening ? firewallInUse(io) : null;
|
|
2476
2524
|
let closePort: (() => void) | null = null;
|
|
2477
2525
|
|
|
2478
2526
|
if (firewall !== null) {
|
package/src/share.ts
CHANGED
|
@@ -87,6 +87,41 @@ export function classify(address: string): "private" | "cgnat" | "public" {
|
|
|
87
87
|
return "public";
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Looks like an address, rather than an error page or a rate-limit notice. */
|
|
91
|
+
export function isIpAddress(value: string): boolean {
|
|
92
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) {
|
|
93
|
+
return value.split(".").every((part) => Number(part) <= 255);
|
|
94
|
+
}
|
|
95
|
+
// Enough of v6 to reject prose: hex groups and colons, nothing else. This is
|
|
96
|
+
// not a validator, it is a guard against printing an error page as an address.
|
|
97
|
+
return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The address the rest of the world sees this machine as, by asking.
|
|
102
|
+
*
|
|
103
|
+
* Behind NAT no interface holds it, so there is nobody to ask but somebody
|
|
104
|
+
* outside. It is a claim about the router, not about this port: an address
|
|
105
|
+
* discovered this way is only reachable once the router forwards the port to
|
|
106
|
+
* this machine, which is why what prints it says so.
|
|
107
|
+
*
|
|
108
|
+
* Empty string for every failure. A player must not spend its startup waiting
|
|
109
|
+
* on somebody else's web service.
|
|
110
|
+
*/
|
|
111
|
+
export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 2500): Promise<string> {
|
|
112
|
+
try {
|
|
113
|
+
const answer = await send("https://ipinfo.io/ip", {
|
|
114
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
115
|
+
headers: { accept: "text/plain", "user-agent": "nixamp" },
|
|
116
|
+
});
|
|
117
|
+
if (!answer.ok) return "";
|
|
118
|
+
const found = (await answer.text()).trim();
|
|
119
|
+
return isIpAddress(found) ? found : "";
|
|
120
|
+
} catch {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
/**
|
|
91
126
|
* The addresses another device could actually reach this machine on, nearest
|
|
92
127
|
* first. On a server the public one is the point: it is the address a phone
|
package/web/dist/sw.js
CHANGED