This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/pinix/localServices/radio.nix
pijukka 32c7ada1e7 Add sound.0ff.space: Icecast + Liquidsoap internet radio on pinix
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).
2026-08-04 08:42:36 +02:00

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 ];
}