nixos/hosts/fw/modules/nas-wake-on-access.nix

110 lines
3.9 KiB
Nix

# NAS wake-on-access (fw side)
#
# Detects traffic aimed at the NAS (10.42.97.11) and sends a WOL magic
# packet so the machine comes back up on demand after it has powered itself
# off (see hosts/nas/modules/auto-shutdown.nix).
#
# Traffic reaches the NAS via two paths, so we need two detectors that feed
# the same wake script:
#
# 1. Cross-VLAN traffic is routed through fw and hits nftables' forward
# chain. A logging rule tags these packets and a journal follower
# translates the log line into a wake invocation.
#
# 2. Same-VLAN (server) traffic stays on the bridge and never reaches
# nftables. A tcpdump follower watches ARP-who-has for 10.42.97.11 on
# the server interface and triggers the wake from there.
{ config, lib, pkgs, ... }:
let
nasIp = "${config.networkPrefix}.97.11";
nasMac = "6c:1f:f7:8e:a9:86";
serverBroadcast = "${config.networkPrefix}.97.255";
serverIface = "server";
stateDir = "/run/nas-wake-on-access";
lastWakeFile = "${stateDir}/last-wake";
cooldownSeconds = 30;
wakeScript = pkgs.writeShellScript "nas-wake" ''
set -euo pipefail
mkdir -p "${stateDir}"
now=$(date +%s)
# Cooldown gate: at most one WOL every ${toString cooldownSeconds}s.
if [[ -f "${lastWakeFile}" ]]; then
last=$(cat "${lastWakeFile}" 2>/dev/null || echo 0)
if (( now - last < ${toString cooldownSeconds} )); then
exit 0
fi
fi
# If the NAS answers ping it is already up; skip WOL but refresh
# the cooldown so repeated probes don't spin the CPU.
if ${pkgs.iputils}/bin/ping -c1 -W1 -n ${nasIp} >/dev/null 2>&1; then
echo "nas-wake: NAS already up, not sending WOL"
echo "$now" > "${lastWakeFile}"
exit 0
fi
echo "nas-wake: sending WOL to ${nasMac} via ${serverBroadcast}"
${pkgs.wol}/bin/wol -i ${serverBroadcast} ${nasMac} || true
echo "$now" > "${lastWakeFile}"
'';
# Journal follower for cross-VLAN (routed) traffic. nftables logs a line
# prefixed with "nas-wake: " into the kernel ring buffer for every new
# packet headed to the NAS (rate-limited kernel-side).
journalFollowerScript = pkgs.writeShellScript "nas-wake-journal-follower" ''
set -euo pipefail
${pkgs.systemd}/bin/journalctl -kf -o cat --since now \
| ${pkgs.gnugrep}/bin/grep --line-buffered -F "nas-wake:" \
| while IFS= read -r _line; do
${wakeScript} || true
done
'';
# ARP follower for same-VLAN traffic. Clients on the server VLAN talk to
# the NAS directly via the bridge, so their packets never hit nftables.
# An ARP "who-has 10.42.97.11" is the reliable early signal that someone
# wants to reach the NAS.
arpFollowerScript = pkgs.writeShellScript "nas-wake-arp-follower" ''
set -euo pipefail
${pkgs.tcpdump}/bin/tcpdump -i ${serverIface} -l -n -p -Q in \
'arp and host ${nasIp}' \
| while IFS= read -r _line; do
${wakeScript} || true
done
'';
in
{
systemd.services.nas-wake-journal = {
description = "Wake NAS on cross-VLAN traffic (nftables log follower)";
after = [ "nftables.service" "systemd-journald.service" ];
requires = [ "systemd-journald.service" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ coreutils iputils wol systemd gnugrep ];
serviceConfig = {
Type = "simple";
ExecStart = "${journalFollowerScript}";
Restart = "always";
RestartSec = "5s";
};
};
systemd.services.nas-wake-arp = {
description = "Wake NAS on same-VLAN ARP (server bridge)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ coreutils iputils wol tcpdump ];
serviceConfig = {
Type = "simple";
ExecStart = "${arpFollowerScript}";
Restart = "always";
RestartSec = "5s";
AmbientCapabilities = [ "CAP_NET_RAW" "CAP_NET_ADMIN" ];
};
};
}