Add a calibre.m7.rs vhost proxying the calibre-server content server,
restricted to localhost and the headscale tailnet prefixes, and enable
calibre-server auth so the endpoint is not anonymous.
calibre-web wrote metadata.db directly, which desynced calibre-server's
in-memory library cache: books uploaded through books.m7.rs stayed
invisible on the content server until it was restarted, and two writers
on one library risk clobbering each other's metadata.
Drop calibre-web from the readarr and calibre-server groups and force
ReadWritePaths down to its state directory, so the pre-existing
ProtectSystem=strict leaves the library read-only. The 0002 umask only
existed to keep those shared-library writes group-writable, so it goes
too. Readarr already writes through the content server API, so
calibre-server is now the sole writer.
Note that services.calibre-web.options.enableBookUploading does not
cover this on its own: config_uploading only drives the templates, while
POST /upload stays gated on the per-user ROLE_UPLOAD bit alone.
Assisted-by: pi (claude-opus-5)
Mirror the NixOS openssh config on system-manager: no password auth, no
root login, ed25519 host key only, and gabriel's authorized keys. Its
module runs Ubuntu's /usr/sbin/sshd, so PAM keeps working.
Also enable programs.ssh for system-wide known hosts, and teach the NixOS
side about system-manager hosts so they know electra.
Raise the Axios response limit to 64 MiB only for Lidarr so large album libraries can complete SeerrNG scans without weakening the cap for other external APIs.
Assisted-by: pi (gpt-5.6-sol)
Point SeerrNG directly at systemd's private StateDirectory target so its log
path security check does not reject the public DynamicUser symlink.
Assisted-by: pi (gpt-5.6-sol)
Move Kavita to port 5001 because JellySearch already occupies its default
port 5000, which left Kavita restarting against JellySearch's listener.
Assisted-by: pi (gpt-5.6-sol)
Serve the Readarr-owned library through read-only Kavita, with a persisted
machine-local token key and the existing public books.m7.rs endpoint.
Assisted-by: pi (gpt-5.6-sol)
The generic ethernet rule makes an unplugged enp44s0 managed, so
networkd-wait-online blocked on it for the full 120s timeout and delayed
wg-quick@wg0. NixOS pairs its generic DHCP networks with --any for the same
reason.
Assisted-by: pi (claude-opus-5)
Its control socket never gets ctrl_interface_group applied (upstream never
copies the field when creating the P2P device), so with the daemon running as
root the socket is root-only. wpa_gui scans the directory, hits p2p-dev-*
first and gives up before reaching the real interface.
Assisted-by: pi (claude-opus-5)
The tree is heavily Ubuntu-coupled: masking netplan's generator and Ubuntu's
own wpa_supplicant units, apt packages in cloud-init, /etc/dbus-1 policy.
Assisted-by: pi (claude-opus-5)
The sandbox binds secretsFile into the unit's namespace, so a secret that
can't be decrypted kills the daemon along with the control socket needed to
connect by hand and fix it. It also breaks wpa_gui (nixpkgs#475438).
Tradeoff: the daemon runs as root again, as it does on most distros.
Assisted-by: pi (claude-opus-5)
Run the upstream NixOS wpa_supplicant module under system-manager through a
compat shim. system-manager already reuses nixpkgs' systemdUtils,
environment.etc and userborn, so the unit, the generated config and the
wpa_supplicant user come out unchanged; only four NixOS-only options needed
stubbing, two of which are re-expressed on top of environment.etc.
Extract the wireless config to hosts/wireless.nix so both trees share one
source: they sit at the same depth, so every path resolves identically.
electra's generated wpa_supplicant.conf is now byte-identical to atlas'
except for the country line.
Take over networkd from netplan on electra with the same generic match rules
NixOS uses, which also covers ethernet and USB tethering, neither of which
was configured before. netplan is neutered by masking its generator rather
than uninstalling it, since purging netplan.io would take cloud-init,
ubuntu-minimal and ubuntu-server-minimal with it.
Assisted-by: pi (claude-opus-5)
MemoryHigh is enforced by reclaim, and reclaiming anonymous pages means
writing them to swap. Holding sabnzbd near 4G resident therefore pushed 5.2G
into the 8G swapfile: the limit did not contain the growth, it converted a
RAM problem into swap exhaustion, which is the failure that actually
threatens the box. Without it sabnzbd grows in RAM, where oomd's pressure
rule can see it stall.
ManagedOOMSwap=kill registers this unit alone as a swap-monitoring
candidate. oomd ranks swap candidates by usage, so scoping it here means the
early trigger at 90% swap can only ever select sabnzbd, while jellyfin's
cold pages stay ineligible. The pressure rule on system.slice remains the
broader net for anything else.
Assisted-by: pi (claude-opus-5)
earlyoom only acts when available memory AND free swap are both under
threshold. During the 2026-08-11 swap exhaustion, swap crossed 5% at 02:14
but RAM did not until 06:10, so it watched the swapfile drain to zero for
four hours. It then sent 88 SIGTERMs without landing a kill: SIGKILL
escalation is gated at half the threshold (2.5%), RAM bottomed at 4.74%,
and sabnzbd's graceful shutdown took 12 minutes under thrash.
systemd-oomd was already running but monitoring zero cgroups, since every
unit defaults to ManagedOOMSwap=auto. It acts on PSI stall time, which does
not distinguish waiting on reclaim from waiting on swap I/O, and it SIGKILLs
the cgroup rather than negotiating. Candidates are ranked by reclaim
activity, so a service holding cold pages in swap is picked last rather
than first.
Set on system.slice directly because systemd.oomd.enableSystemSlice
hardcodes ManagedOOMSwap=kill, which would rank jellyfin's ~1.2G of cold
swap ahead of whatever is actually thrashing.
Assisted-by: pi (claude-opus-5)
earlyoom SIGTERMed sabnzbd at 06:10 after it grew to 4.2G resident plus
5.2G of swap, exhausting the box's 8G swapfile. sabnzbd caught the signal,
saved its queue and exited 0 twelve minutes later, and with Restart=no it
stayed dead for the next three and a half hours.
Restart=on-failure would not have helped: the graceful shutdown path is a
clean exit.
Assisted-by: pi (claude-opus-5)
Every server was on defaults: timeout=60, and max_art_tries=3 across six
servers. That is up to eighteen rounds, each willing to wait a minute,
before SAB concludes an article is gone. Nothing legitimate takes that
long -- TLS handshakes to all six hosts complete in under a second.
Drop timeout to 20s and max_art_tries to 2. This is a straightforward
reduction in the cost of the failure path, not a fix for any particular
incident.
Explicitly not addressed: a stall found while investigating, where the
queue sat at ~0.13 MB/s with every server idle -- eweka and blocknews
holding ten established connections each with no article assigned to any
of them, and frugal at zero of thirty. Nothing was requesting articles at
all. An earlier draft of this commit blamed connections blocked on
timeouts and marked the blocknews pair optional; the connection dump
showed those sockets were idle rather than busy, so both the mechanism and
the remedy were wrong and have been dropped. Root cause still unknown.
Assisted-by: pi (claude-opus-5)
par2/unrar routinely take a full core each and the media disk hits 83%
utilisation during a grab, both competing with jellyfin direct play and
library scans. sabnzbd was still at the default weight of 100, tied with
jellyfin, while the *arrs sit at 50.
Drop it to 20 so its pipeline yields under contention and keeps the whole
box when nothing else wants it. Children inherit the cgroup weight, so
this covers par2 and unrar without touching sabnzbd's own nice setting.
Assisted-by: pi (claude-opus-5)