Nix-native (no Docker, unlike alfo's radiopi reference), streaming a shuffled folder of MP3s from /var/lib/radio/music. Bypasses the upstream services.icecast module since it bakes the admin/source password into a world-readable store path; both units instead render their config from a template at service start, reading the password live from the agenix runtime path. Runs as a dedicated icecast-radio system user rather than root -- icecast segfaults instead of exiting cleanly when it detects EUID 0 without a <changeowner> configured. Liquidsoap's playlist() needed mksafe() to satisfy output.icecast()'s infallible-source requirement. Icecast binds 0.0.0.0 (not just the tailnet IP) since Liquidsoap's source connection is a loopback connection on the same host. Traefik route added on blunix (sound.0ff.space -> pinix:8032, same pattern as the other pinix-backed public services). DNS record still needs to be created manually (no broadly-scoped Cloudflare credential in this repo).
133 lines
5.4 KiB
Nix
133 lines
5.4 KiB
Nix
# sound.${domain} -- Icecast + Liquidsoap internet radio, streaming MP3s
|
|
# dropped into /var/lib/radio/music (shuffled, watched for changes; no
|
|
# upload UI, files land there via scp/rsync). Modeled loosely on alfo's
|
|
# radiopi (Docker + Liquidsoap + HLS), but Nix-native and Icecast instead
|
|
# of HLS so the launcher frontend can be a plain <audio> tag.
|
|
#
|
|
# NOT using the upstream `services.icecast` module: it takes
|
|
# `admin.password` as a plain `str` option and interpolates it straight
|
|
# into a `pkgs.writeText` config file, i.e. a world-readable Nix store
|
|
# path -- exactly what secrets/README.md says not to do ("Secret-Werte
|
|
# niemals in Nix-Strings interpolieren"). Both units below instead render
|
|
# their config from a template at service start, substituting the
|
|
# password read live from the agenix runtime path, so it never touches
|
|
# the store.
|
|
{ config, pkgs, ... }:
|
|
let
|
|
icecastPort = 8032;
|
|
musicDir = "/var/lib/radio/music";
|
|
|
|
# bind-address 0.0.0.0, not just the tailnet IP: Liquidsoap's source
|
|
# connection below goes to 127.0.0.1 (same host), which a
|
|
# tailnet-IP-only bind refuses ("Connection refused", confirmed by
|
|
# testing) -- external reachability is still tailnet-only in practice,
|
|
# gated by the firewall rule further down plus Traefik only proxying
|
|
# from blunix over the tailnet.
|
|
icecastConfigTemplate = pkgs.writeText "icecast.xml.template" ''
|
|
<icecast>
|
|
<hostname>sound.${config.domain}</hostname>
|
|
<authentication>
|
|
<source-password>@PASSWORD@</source-password>
|
|
<admin-user>admin</admin-user>
|
|
<admin-password>@PASSWORD@</admin-password>
|
|
</authentication>
|
|
<listen-socket>
|
|
<port>${toString icecastPort}</port>
|
|
<bind-address>0.0.0.0</bind-address>
|
|
</listen-socket>
|
|
<paths>
|
|
<logdir>/var/log/icecast-radio</logdir>
|
|
<webroot>${pkgs.icecast}/share/icecast/web</webroot>
|
|
<adminroot>${pkgs.icecast}/share/icecast/admin</adminroot>
|
|
<alias source="/" dest="/status.xsl"/>
|
|
</paths>
|
|
<security>
|
|
<chroot>0</chroot>
|
|
</security>
|
|
</icecast>
|
|
'';
|
|
|
|
# Same shared secret authenticates both Icecast's source login (above)
|
|
# and Liquidsoap's outgoing source connection (below) -- one password,
|
|
# not a separate admin/source pair, to keep this minimal.
|
|
liquidsoapScriptTemplate = pkgs.writeText "radio.liq.template" ''
|
|
# mksafe(): output.icecast() requires an infallible source (one that's
|
|
# type-guaranteed to always have a track ready), but playlist() is
|
|
# fallible (e.g. between tracks, or if the directory is briefly
|
|
# empty) -- mksafe() inserts silence on demand to bridge that,
|
|
# confirmed necessary by testing without it: liquidsoap refused to
|
|
# even start ("Error 7: Invalid value: That source is fallible").
|
|
radio = mksafe(playlist(mode="randomize", reload_mode="watch", "${musicDir}"))
|
|
output.icecast(
|
|
%mp3(bitrate=128),
|
|
host="127.0.0.1", port=${toString icecastPort},
|
|
password="@PASSWORD@",
|
|
mount="stream.mp3",
|
|
radio
|
|
)
|
|
'';
|
|
in
|
|
{
|
|
# Icecast segfaults instead of exiting cleanly if it detects it's
|
|
# running as root without a <changeowner> configured (confirmed by
|
|
# running it by hand: "ERROR: You should not run icecast2 as root" ->
|
|
# signal 11). Simpler to never start as root at all than to lean on
|
|
# icecast's own privilege-drop directive -- one shared system user for
|
|
# both units, consistent with how the rest of this repo avoids running
|
|
# services as root.
|
|
users.groups.icecast-radio = { };
|
|
users.users.icecast-radio = {
|
|
isSystemUser = true;
|
|
group = "icecast-radio";
|
|
};
|
|
|
|
systemd.tmpfiles.rules = [
|
|
"d ${musicDir} 0755 drone users - -"
|
|
];
|
|
|
|
systemd.services.icecast-radio = {
|
|
description = "Icecast server for sound.${config.domain}";
|
|
after = [ "network.target" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
restartTriggers = [ config.age.secrets.icecastPw.file ];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
User = "icecast-radio";
|
|
Group = "icecast-radio";
|
|
LogsDirectory = "icecast-radio";
|
|
RuntimeDirectory = "icecast-radio";
|
|
ExecStartPre = pkgs.writeShellScript "icecast-radio-render-config" ''
|
|
set -eu
|
|
password="$(cat ${config.age.secrets.icecastPw.path})"
|
|
sed "s/@PASSWORD@/$password/g" ${icecastConfigTemplate} > /run/icecast-radio/icecast.xml
|
|
'';
|
|
ExecStart = "${pkgs.icecast}/bin/icecast -c /run/icecast-radio/icecast.xml";
|
|
Restart = "always";
|
|
RestartSec = "5s";
|
|
};
|
|
};
|
|
|
|
systemd.services.liquidsoap-radio = {
|
|
description = "Liquidsoap source client for sound.${config.domain}";
|
|
after = [ "icecast-radio.service" ];
|
|
wants = [ "icecast-radio.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
restartTriggers = [ config.age.secrets.icecastPw.file ];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
User = "icecast-radio";
|
|
Group = "icecast-radio";
|
|
RuntimeDirectory = "liquidsoap-radio";
|
|
ExecStartPre = pkgs.writeShellScript "liquidsoap-radio-render-script" ''
|
|
set -eu
|
|
password="$(cat ${config.age.secrets.icecastPw.path})"
|
|
sed "s/@PASSWORD@/$password/g" ${liquidsoapScriptTemplate} > /run/liquidsoap-radio/radio.liq
|
|
'';
|
|
ExecStart = "${pkgs.liquidsoap}/bin/liquidsoap /run/liquidsoap-radio/radio.liq";
|
|
Restart = "always";
|
|
RestartSec = "10s";
|
|
};
|
|
};
|
|
|
|
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ icecastPort ];
|
|
}
|