mesh/merge-gate head fe2040c65ea2 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head fe2040c65ea2 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery-group group fix/342-a-secret-is-never-a-setting ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
A setting outranked the vault: baserow, grafana, letta and unifi merged a layer's
password over the ${secret:...} in their runtime config, and the gitea,
home-assistant, icecast, influxdb, mailu and nextcloud clients read the settings
file before the vault's file. A secret in a setting cannot be rotated and is read
back by the settings verb.
The four files now keep their password protected, so the controller refuses a
setting of it. The six clients ignore a secret in the settings file and say so
once, by key and never with the value; icecast reads its admin password from the
vault's file. checks/secrets refuses a mergeable file whose secret a layer could
replace; searxng's nested server.secret_key is listed as known until the
controller protects a nested key.
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0039). Icecast is an
|
|
// audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl`
|
|
// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and
|
|
// the basis for both the status tool and the stream started/stopped events.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export interface IcecastMount {
|
|
/** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */
|
|
mount: string;
|
|
listeners: number;
|
|
name?: string;
|
|
description?: string;
|
|
streamStart?: string;
|
|
bitrate?: number;
|
|
serverType?: string;
|
|
}
|
|
|
|
export interface IcecastStatus {
|
|
mounts: IcecastMount[];
|
|
totalListeners: number;
|
|
mountCount: number;
|
|
}
|
|
|
|
// The raw shape of one <source> in status-json.xsl. `source` is absent with no mounts, a lone object
|
|
// with one, and an array with several — normalised below.
|
|
interface RawSource {
|
|
listenurl?: string;
|
|
listeners?: number;
|
|
server_name?: string;
|
|
server_description?: string;
|
|
stream_start_iso8601?: string;
|
|
stream_start?: string;
|
|
bitrate?: number;
|
|
server_type?: string;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
/**
|
|
* A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of
|
|
* them in the settings file is ignored, and that is said once — by its key, never with its value.
|
|
*/
|
|
const saidIgnored = new Set<string>();
|
|
function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
|
|
if (cfg[key] === undefined || saidIgnored.has(key)) return;
|
|
saidIgnored.add(key);
|
|
console.error(`the setting ${key} is ignored: it comes from the vault`);
|
|
}
|
|
|
|
/** A secret delivered as a file, trimmed; undefined when there is none. */
|
|
function readSecret(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try { return readFileSync(path, "utf8").trim() || undefined; }
|
|
catch { return undefined; }
|
|
}
|
|
|
|
export class IcecastClient {
|
|
readonly baseUrl: string;
|
|
private readonly authHeader?: string;
|
|
|
|
constructor(url: string, adminUser?: string, adminPassword?: string) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
// status-json.xsl is public on most instances; basic auth is used only where admin locked it down.
|
|
if (adminUser && adminPassword) {
|
|
this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64");
|
|
}
|
|
}
|
|
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient {
|
|
const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE);
|
|
const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`);
|
|
ignoreSecretSetting(cfg, "password");
|
|
const password = readSecret(env.MESH_ICECAST_ADMIN_PASSWORD_FILE) ?? env.MESH_ICECAST_ADMIN_PASSWORD;
|
|
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER ?? "admin", password);
|
|
}
|
|
|
|
async getStatus(): Promise<IcecastStatus> {
|
|
const headers: Record<string, string> = { Accept: "application/json" };
|
|
if (this.authHeader) headers.Authorization = this.authHeader;
|
|
const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers });
|
|
if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`);
|
|
const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } };
|
|
|
|
const raw = data.icestats?.source;
|
|
const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw];
|
|
const mounts = sources.map((s) => ({
|
|
mount: this.mountFromUrl(s.listenurl),
|
|
listeners: s.listeners ?? 0,
|
|
name: s.server_name,
|
|
description: s.server_description,
|
|
streamStart: s.stream_start_iso8601 ?? s.stream_start,
|
|
bitrate: s.bitrate,
|
|
serverType: s.server_type,
|
|
}));
|
|
return {
|
|
mounts,
|
|
totalListeners: mounts.reduce((n, m) => n + m.listeners, 0),
|
|
mountCount: mounts.length,
|
|
};
|
|
}
|
|
|
|
/** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */
|
|
private mountFromUrl(listenurl?: string): string {
|
|
if (!listenurl) return "unknown";
|
|
try {
|
|
return new URL(listenurl).pathname;
|
|
} catch {
|
|
return listenurl;
|
|
}
|
|
}
|
|
}
|