10 Commits

Author SHA1 Message Date
3b71f4b1fd dedupe
All checks were successful
Check Flake / check-flake (push) Successful in 6m4s
2026-02-26 19:42:38 -08:00
dc3c2194ab use port 8080 instead
All checks were successful
Check Flake / check-flake (push) Successful in 3m21s
2026-02-26 00:26:49 -08:00
39009cbc18 use container hostname alias for script
All checks were successful
Check Flake / check-flake (push) Successful in 3m17s
2026-02-26 00:17:47 -08:00
3365a1652c restore port option 2026-02-26 00:16:39 -08:00
6466406975 fix transmission port forwarding
All checks were successful
Check Flake / check-flake (push) Successful in 3m25s
2026-02-26 00:08:40 -08:00
4eb0401263 disable services which don't work in nixos containers
All checks were successful
Check Flake / check-flake (push) Successful in 3m17s
2026-02-25 23:37:26 -08:00
f4a4edf478 fix networking online target + ntfy notifications
All checks were successful
Check Flake / check-flake (push) Successful in 3m36s
2026-02-25 23:24:23 -08:00
1ac3f05e3e define vpn container hosts within containers too 2026-02-25 23:23:49 -08:00
c1030c1dfe remove debugging messages
All checks were successful
Check Flake / check-flake (push) Successful in 3m28s
2026-02-25 00:31:31 -08:00
52469693e3 maybe fix
All checks were successful
Check Flake / check-flake (push) Successful in 3m17s
2026-02-25 00:25:15 -08:00
8 changed files with 92 additions and 48 deletions

View File

@@ -96,7 +96,7 @@
{ groups = [ "wheel" ]; persist = true; } { groups = [ "wheel" ]; persist = true; }
]; ];
nix.gc.automatic = true; nix.gc.automatic = !config.boot.isContainer;
security.acme.acceptTerms = true; security.acme.acceptTerms = true;
security.acme.defaults.email = "zuckerberg@neet.dev"; security.acme.defaults.email = "zuckerberg@neet.dev";

View File

@@ -52,8 +52,13 @@ let
type = types.nullOr (types.submodule { type = types.nullOr (types.submodule {
options = { options = {
port = mkOption { port = mkOption {
type = types.port; type = types.nullOr types.port;
description = "Target port to forward PIA-assigned port to"; default = null;
description = ''
Target port to forward to. If null, forwards to the same PIA-assigned port.
PIA-assigned ports below 10000 are rejected to avoid accidentally
forwarding traffic to other services.
'';
}; };
protocol = mkOption { protocol = mkOption {
type = types.enum [ "tcp" "udp" "both" ]; type = types.enum [ "tcp" "udp" "both" ];
@@ -153,6 +158,13 @@ in
description = "Prefix length derived from subnet CIDR"; description = "Prefix length derived from subnet CIDR";
readOnly = true; readOnly = true;
}; };
# Shared host entries for all containers (host + VPN + service containers)
containerHosts = mkOption {
type = types.attrsOf (types.listOf types.str);
internal = true;
readOnly = true;
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@@ -252,10 +264,12 @@ in
# Host entries for container hostnames — NixOS only auto-creates these for # Host entries for container hostnames — NixOS only auto-creates these for
# hostAddress/localAddress containers, not hostBridge. Use the standard # hostAddress/localAddress containers, not hostBridge. Use the standard
# {name}.containers convention. # {name}.containers convention.
networking.hosts = pia-vpn.containerHosts =
{ ${cfg.vpnAddress} = [ "pia-vpn.containers" ]; } { ${cfg.vpnAddress} = [ "pia-vpn.containers" ]; }
// mapAttrs' (name: ctr: nameValuePair ctr.ip [ "${name}.containers" ]) cfg.containers; // mapAttrs' (name: ctr: nameValuePair ctr.ip [ "${name}.containers" ]) cfg.containers;
networking.hosts = cfg.containerHosts;
# PIA login secret # PIA login secret
age.secrets."pia-login.conf".file = ../../../secrets/pia-login.age; age.secrets."pia-login.conf".file = ../../../secrets/pia-login.age;

View File

@@ -135,28 +135,32 @@ in
echo "Loaded server info from $serverFile: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)" echo "Loaded server info from $serverFile: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)"
} }
# Reset WG interface and tear down NAT/forwarding rules.
# Called on startup (clear stale state) and on exit via trap.
cleanupVpn() {
local interfaceName=$1
wg set "$interfaceName" listen-port 0 2>/dev/null || true
ip -4 address flush dev "$interfaceName" 2>/dev/null || true
ip route del default dev "$interfaceName" 2>/dev/null || true
iptables -t nat -F 2>/dev/null || true
iptables -F FORWARD 2>/dev/null || true
}
connectToServer() { connectToServer() {
local wgFile=$1 local wgFile=$1
local interfaceName=$2 local interfaceName=$2
echo "Applying WireGuard config to $interfaceName..." echo "Applying WireGuard config to $interfaceName..."
echo "Running: wg setconf $interfaceName $wgFile"
wg setconf "$interfaceName" "$wgFile" wg setconf "$interfaceName" "$wgFile"
echo "Running: ip -4 address add $MY_IP dev $interfaceName"
ip -4 address add "$MY_IP" dev "$interfaceName" ip -4 address add "$MY_IP" dev "$interfaceName"
echo "Running: ip link set mtu 1420 up dev $interfaceName"
ip link set mtu 1420 up dev "$interfaceName" ip link set mtu 1420 up dev "$interfaceName"
echo "WireGuard interface $interfaceName is up with IP $MY_IP" echo "WireGuard interface $interfaceName is up with IP $MY_IP"
} }
reservePortForward() { reservePortForward() {
local payload_and_signature local payload_and_signature
if [[ -z "''${PIA_TOKEN:-}" ]]; then echo "Requesting port forward signature from $WG_HOSTNAME..."
echo "ERROR: PIA_TOKEN is empty" >&2 payload_and_signature=$(curl -s -m 5 \
return 1
fi
echo "Requesting port forward signature from $WG_HOSTNAME (token length: ''${#PIA_TOKEN})..."
payload_and_signature=$(curl -s -m 5 $(proxy_args) \
--connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \ --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \
--cacert "${caPath}" \ --cacert "${caPath}" \
-G --data-urlencode "token=$PIA_TOKEN" \ -G --data-urlencode "token=$PIA_TOKEN" \

View File

@@ -42,9 +42,29 @@ let
}; };
}; };
networking.hosts = cfg.containerHosts;
# DNS through VPN container (queries go through WG tunnel = no DNS leak) # DNS through VPN container (queries go through WG tunnel = no DNS leak)
networking.nameservers = [ cfg.vpnAddress ]; networking.nameservers = [ cfg.vpnAddress ];
# Wait for actual VPN connectivity before network-online.target.
# Without this, services start before the VPN tunnel is ready and failures
# can't be reported to ntfy (no outbound connectivity yet).
systemd.services.wait-for-vpn = {
description = "Wait for VPN connectivity";
before = [ "network-online.target" ];
wantedBy = [ "network-online.target" ];
after = [ "systemd-networkd-wait-online.service" ];
serviceConfig.Type = "oneshot";
path = [ pkgs.iputils ];
script = ''
until ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; do
echo "Waiting for VPN connectivity..."
sleep 1
done
'';
};
# Trust the bridge interface (host reaches us directly for nginx) # Trust the bridge interface (host reaches us directly for nginx)
networking.firewall.trustedInterfaces = [ "eth0" ]; networking.firewall.trustedInterfaces = [ "eth0" ];

View File

@@ -24,15 +24,16 @@ let
let let
fwd = forwardingContainer.receiveForwardedPort; fwd = forwardingContainer.receiveForwardedPort;
targetIp = forwardingContainer.ip; targetIp = forwardingContainer.ip;
targetPort = toString fwd.port; dnatTarget = if fwd.port != null then "${targetIp}:${toString fwd.port}" else targetIp;
targetPort = if fwd.port != null then toString fwd.port else "$PORT";
tcpRules = optionalString (fwd.protocol == "tcp" || fwd.protocol == "both") '' tcpRules = optionalString (fwd.protocol == "tcp" || fwd.protocol == "both") ''
echo "Setting up TCP DNAT: port $PORT ${targetIp}:${targetPort}" echo "Setting up TCP DNAT: port $PORT ${targetIp}:${targetPort}"
iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p tcp --dport $PORT -j DNAT --to ${targetIp}:${targetPort} iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p tcp --dport $PORT -j DNAT --to ${dnatTarget}
iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p tcp --dport ${targetPort} -j ACCEPT iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p tcp --dport ${targetPort} -j ACCEPT
''; '';
udpRules = optionalString (fwd.protocol == "udp" || fwd.protocol == "both") '' udpRules = optionalString (fwd.protocol == "udp" || fwd.protocol == "both") ''
echo "Setting up UDP DNAT: port $PORT ${targetIp}:${targetPort}" echo "Setting up UDP DNAT: port $PORT ${targetIp}:${targetPort}"
iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p udp --dport $PORT -j DNAT --to ${targetIp}:${targetPort} iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p udp --dport $PORT -j DNAT --to ${dnatTarget}
iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p udp --dport ${targetPort} -j ACCEPT iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p udp --dport ${targetPort} -j ACCEPT
''; '';
onPortForwarded = optionalString (forwardingContainer.onPortForwarded != null) '' onPortForwarded = optionalString (forwardingContainer.onPortForwarded != null) ''
@@ -43,9 +44,13 @@ let
''; '';
in in
'' ''
${tcpRules} if [ "$PORT" -lt 10000 ]; then
${udpRules} echo "ERROR: PIA assigned low port $PORT (< 10000), refusing to set up DNAT" >&2
${onPortForwarded} else
${tcpRules}
${udpRules}
${onPortForwarded}
fi
'' ''
); );
in in
@@ -73,6 +78,8 @@ in
{ {
imports = allModules; imports = allModules;
networking.hosts = cfg.containerHosts;
# Static IP on bridge — no gateway (VPN container routes via WG only) # Static IP on bridge — no gateway (VPN container routes via WG only)
networking.useNetworkd = true; networking.useNetworkd = true;
systemd.network.enable = true; systemd.network.enable = true;
@@ -89,6 +96,9 @@ in
# Ignore WG interface for wait-online (it's configured manually, not by networkd) # Ignore WG interface for wait-online (it's configured manually, not by networkd)
systemd.network.wait-online.ignoredInterfaces = [ cfg.interfaceName ]; systemd.network.wait-online.ignoredInterfaces = [ cfg.interfaceName ];
# Route ntfy alerts through the host proxy (VPN container has no gateway on eth0)
ntfy-alerts.curlExtraArgs = "--proxy http://${cfg.hostAddress}:${toString cfg.proxyPort}";
# Enable forwarding so bridge traffic can go through WG # Enable forwarding so bridge traffic can go through WG
boot.kernel.sysctl."net.ipv4.ip_forward" = 1; boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
@@ -131,17 +141,8 @@ in
set -euo pipefail set -euo pipefail
${scripts.scriptCommon} ${scripts.scriptCommon}
# Clean up stale state from previous attempts trap 'cleanupVpn ${cfg.interfaceName}' EXIT
echo "Cleanup: resetting WG listen port..." cleanupVpn ${cfg.interfaceName}
wg set ${cfg.interfaceName} listen-port 0 2>/dev/null || true
echo "Cleanup: flushing addresses on ${cfg.interfaceName}..."
ip -4 address flush dev ${cfg.interfaceName} 2>/dev/null || true
echo "Cleanup: removing default route..."
ip route del default dev ${cfg.interfaceName} 2>/dev/null || true
echo "Cleanup: flushing iptables..."
iptables -t nat -F 2>/dev/null || true
iptables -F FORWARD 2>/dev/null || true
echo "Cleanup done"
proxy="${proxy}" proxy="${proxy}"
@@ -195,16 +196,6 @@ in
exec sleep infinity exec sleep infinity
''; '';
preStop = ''
echo "Tearing down PIA VPN..."
ip -4 address flush dev ${cfg.interfaceName} 2>/dev/null || true
ip route del default dev ${cfg.interfaceName} 2>/dev/null || true
iptables -t nat -F POSTROUTING 2>/dev/null || true
iptables -F FORWARD 2>/dev/null || true
${optionalString portForwarding ''
iptables -t nat -F PREROUTING 2>/dev/null || true
''}
'';
}; };
# Port refresh timer (every 10 min) — keeps PIA port forwarding alive # Port refresh timer (every 10 min) — keeps PIA port forwarding alive

View File

@@ -16,6 +16,12 @@ in
default = "service-failures"; default = "service-failures";
description = "ntfy topic to publish alerts to."; description = "ntfy topic to publish alerts to.";
}; };
curlExtraArgs = lib.mkOption {
type = lib.types.str;
default = "";
description = "Extra arguments to pass to curl (e.g. --proxy http://host:port).";
};
}; };
config = lib.mkIf config.thisMachine.hasRole."ntfy" { config = lib.mkIf config.thisMachine.hasRole."ntfy" {
@@ -33,6 +39,7 @@ in
${lib.getExe pkgs.curl} \ ${lib.getExe pkgs.curl} \
--fail --silent --show-error \ --fail --silent --show-error \
--max-time 30 --retry 3 \ --max-time 30 --retry 3 \
${cfg.curlExtraArgs} \
-H "Authorization: Bearer $NTFY_TOKEN" \ -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: Service failure on ${config.networking.hostName}" \ -H "Title: Service failure on ${config.networking.hostName}" \
-H "Priority: high" \ -H "Priority: high" \

View File

@@ -133,8 +133,15 @@ let
}; };
in in
{ {
config = mkIf (cfg.enable && vmWorkspaces != { }) { config = mkMerge [
# Convert VM workspace configs to microvm.nix format (mkIf (cfg.enable && vmWorkspaces != { }) {
microvm.vms = mapAttrs mkVmConfig vmWorkspaces; # Convert VM workspace configs to microvm.nix format
}; microvm.vms = mapAttrs mkVmConfig vmWorkspaces;
})
# microvm.nixosModules.host enables KSM, but /sys is read-only in containers
(mkIf config.boot.isContainer {
hardware.ksm.enable = false;
})
];
} }

View File

@@ -63,17 +63,17 @@
ip = "10.100.0.10"; ip = "10.100.0.10";
mounts."/var/lib".hostPath = "/var/lib"; mounts."/var/lib".hostPath = "/var/lib";
mounts."/data/samba/Public".hostPath = "/data/samba/Public"; mounts."/data/samba/Public".hostPath = "/data/samba/Public";
receiveForwardedPort = { port = 51413; protocol = "both"; }; receiveForwardedPort = { protocol = "both"; };
onPortForwarded = '' onPortForwarded = ''
# Notify Transmission of the PIA-assigned peer port via RPC # Notify Transmission of the PIA-assigned peer port via RPC
for i in $(seq 1 30); do for i in $(seq 1 30); do
curlout=$(curl -s "http://$TARGET_IP:9091/transmission/rpc" 2>/dev/null) && break curlout=$(curl -s "http://transmission.containers:8080/transmission/rpc" 2>/dev/null) && break
sleep 2 sleep 2
done done
regex='X-Transmission-Session-Id: (\w*)' regex='X-Transmission-Session-Id: (\w*)'
if [[ $curlout =~ $regex ]]; then if [[ $curlout =~ $regex ]]; then
sessionId=''${BASH_REMATCH[1]} sessionId=''${BASH_REMATCH[1]}
curl -s "http://$TARGET_IP:9091/transmission/rpc" \ curl -s "http://transmission.containers:8080/transmission/rpc" \
-d "{\"method\":\"session-set\",\"arguments\":{\"peer-port\":$PORT}}" \ -d "{\"method\":\"session-set\",\"arguments\":{\"peer-port\":$PORT}}" \
-H "X-Transmission-Session-Id: $sessionId" -H "X-Transmission-Session-Id: $sessionId"
fi fi
@@ -91,6 +91,7 @@
"incomplete-dir-enabled" = true; "incomplete-dir-enabled" = true;
"rpc-enabled" = true; "rpc-enabled" = true;
"rpc-port" = 8080;
"rpc-bind-address" = "0.0.0.0"; "rpc-bind-address" = "0.0.0.0";
"rpc-whitelist" = "127.0.0.1,10.100.*.*,192.168.*.*"; "rpc-whitelist" = "127.0.0.1,10.100.*.*,192.168.*.*";
"rpc-host-whitelist-enabled" = false; "rpc-host-whitelist-enabled" = false;
@@ -231,7 +232,7 @@
(mkVirtualHost "lidarr.s0.neet.dev" "http://servarr.containers:8686") (mkVirtualHost "lidarr.s0.neet.dev" "http://servarr.containers:8686")
(mkVirtualHost "sonarr.s0.neet.dev" "http://servarr.containers:8989") (mkVirtualHost "sonarr.s0.neet.dev" "http://servarr.containers:8989")
(mkVirtualHost "prowlarr.s0.neet.dev" "http://servarr.containers:9696") (mkVirtualHost "prowlarr.s0.neet.dev" "http://servarr.containers:9696")
(mkVirtualHost "transmission.s0.neet.dev" "http://transmission.containers:9091") (mkVirtualHost "transmission.s0.neet.dev" "http://transmission.containers:8080")
(mkVirtualHost "unifi.s0.neet.dev" "https://localhost:8443") (mkVirtualHost "unifi.s0.neet.dev" "https://localhost:8443")
(mkVirtualHost "music.s0.neet.dev" "http://localhost:4533") (mkVirtualHost "music.s0.neet.dev" "http://localhost:4533")
(mkVirtualHost "jellyfin.s0.neet.dev" "http://localhost:8096") (mkVirtualHost "jellyfin.s0.neet.dev" "http://localhost:8096")