From 11a34aa04c39521a70dc8ed3dbaffd61ed66e7cc Mon Sep 17 00:00:00 2001 From: Dominik Polakovics Date: Fri, 25 Apr 2025 23:34:04 +0200 Subject: [PATCH] initial commit --- modules/cloonar-assistant/default.nix | 128 +++++++++ .../home-assistant/default.nix | 249 ++++++++++++++++++ .../cloonar-assistant/networking/default.nix | 7 + modules/cloonar-assistant/networking/dhcp.nix | 187 +++++++++++++ .../cloonar-assistant/networking/firewall.nix | 150 +++++++++++ .../networking/interfaces.nix | 112 ++++++++ modules/cloonar-assistant/updns/default.nix | 88 +++++++ 7 files changed, 921 insertions(+) create mode 100644 modules/cloonar-assistant/default.nix create mode 100644 modules/cloonar-assistant/home-assistant/default.nix create mode 100644 modules/cloonar-assistant/networking/default.nix create mode 100644 modules/cloonar-assistant/networking/dhcp.nix create mode 100644 modules/cloonar-assistant/networking/firewall.nix create mode 100644 modules/cloonar-assistant/networking/interfaces.nix create mode 100644 modules/cloonar-assistant/updns/default.nix diff --git a/modules/cloonar-assistant/default.nix b/modules/cloonar-assistant/default.nix new file mode 100644 index 0000000..55fc5f0 --- /dev/null +++ b/modules/cloonar-assistant/default.nix @@ -0,0 +1,128 @@ +{ config, options, lib, pkgs, ... }: +let + cfg = config.cloonar-assistant; + + vpn-client-opts = peerOpts = self: { + + options = { + + publicKey = mkOption { + example = "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg="; + type = types.singleLineStr; + description = "The base64 public key of the peer."; + }; + + presharedKey = mkOption { + default = null; + example = "rVXs/Ni9tu3oDBLS4hOyAUAa1qTWVA3loR8eL20os3I="; + type = with types; nullOr str; + description = '' + Base64 preshared key generated by {command}`wg genpsk`. + Optional, and may be omitted. This option adds an additional layer of + symmetric-key cryptography to be mixed into the already existing + public-key cryptography, for post-quantum resistance. + + Warning: Consider using presharedKeyFile instead if you do not + want to store the key in the world-readable Nix store. + ''; + }; + + presharedKeyFile = mkOption { + default = null; + example = "/private/wireguard_psk"; + type = with types; nullOr str; + description = '' + File pointing to preshared key as generated by {command}`wg genpsk`. + Optional, and may be omitted. This option adds an additional layer of + symmetric-key cryptography to be mixed into the already existing + public-key cryptography, for post-quantum resistance. + ''; + }; + + allowedIPs = mkOption { + example = [ + "10.192.122.3/32" + "10.192.124.1/24" + ]; + type = with types; listOf str; + description = '' + List of IP (v4 or v6) addresses with CIDR masks from + which this peer is allowed to send incoming traffic and to which + outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may + be specified for matching all IPv4 addresses, and ::/0 may be specified + for matching all IPv6 addresses.''; + }; + }; + }; + +in { + options.cloonar-assistant = { + networkPrefix = lib.mkOption { + type = lib.types.str; + example = "10.42"; + description = "First two octets of the network"; + }; + domain = lib.mkOption { + type = lib.types.str; + example = "example.smart.cloonar.com"; + description = "domain of the network"; + }; + updns = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable updns"; + }; + key = lib.mkOption { + type = lib.types.str; + example = "example"; + description = "key for updns"; + }; + }; + vpn = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable VPN"; + }; + clients = mkOption { + default = [ ]; + description = "VPN Clients"; + type = with types; listOf (submodule vpn-client-opts); + }; + }; + multiroom-audio = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable multiroom audio"; + }; + }; + firewall = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable firewall"; + }; + interfaces = { + wan = lib.mkOption { + type = lib.types.str; + example = "enp2s0"; + description = "Network interface for WAN"; + }; + internal = lib.mkOption { + type = lib.types.str; + example = "enp3s0"; + description = "Internal network interface"; + }; + }; + }; + }; + imports = [ + # Include the results of the hardware scan. + ./networking + ./updns + ./home-assistant + ./multiroom-audio + ]; +} diff --git a/modules/cloonar-assistant/home-assistant/default.nix b/modules/cloonar-assistant/home-assistant/default.nix new file mode 100644 index 0000000..8ec44af --- /dev/null +++ b/modules/cloonar-assistant/home-assistant/default.nix @@ -0,0 +1,249 @@ +{ config, pkgs, ... }: +let + domain = "home-assistant.${config.cloonar-assistant.domain}"; + pkgs-with-home-assistant = import (builtins.fetchGit { + name = "new-home-assistant"; + url = "https://github.com/nixos/nixpkgs/"; + rev = "18dd725c29603f582cf1900e0d25f9f1063dbf11"; + }) {}; + networkPrefix = config.networkPrefix; + home-assistant-config = config.home-assistant; + home-assistant-config.package = pkgs-with-home-assistant.home-assistant; + + + certDir = "/var/lib/ssl/home-assistant"; + certFile = "${certDir}/selfsigned.crt"; + keyFile = "${certDir}/selfsigned.key"; +in +{ + users.users.hass = { + home = "/var/lib/hass"; + createHome = true; + group = "hass"; + uid = config.ids.uids.hass; + extraGroups = [ "dialout" ]; + }; + users.groups.hass.gid = config.ids.gids.hass; + + security.acme.certs."${domain}" = { + group = "nginx"; + }; + + sops.secrets."home-assistant-secrets.yaml" = { + owner = "hass"; + restartUnits = [ "container@hass.service" ]; + }; + + containers.hass = { + autoStart = true; + ephemeral = false; + privateNetwork = true; + hostBridge = "server"; + hostAddress = "${networkPrefix}.97.1"; + localAddress = "${networkPrefix}.97.20/24"; + extraFlags = [ + "--capability=CAP_NET_ADMIN" + "--capability=CAP_MKNOD" + ]; + bindMounts = { + "/etc/localtime" = { + hostPath = "/etc/localtime"; + }; + "/var/lib/hass" = { + hostPath = "/var/lib/hass/"; + isReadOnly = false; + }; + "/var/lib/acme/hass/" = { + hostPath = "${config.security.acme.certs.${domain}.directory}"; + }; + "/var/lib/hass/secrets.yaml" = { + hostPath = config.sops.secrets."home-assistant-secrets.yaml".path; + }; + }; + config = { lib, config, pkgs, ... }: { + networkPrefix = networkPrefix; + imports = [ + ]; + + networking = { + hostName = "home-assistant"; + useHostResolvConf = false; + defaultGateway = { + address = "${networkPrefix}.96.1"; + interface = "eth0"; + }; + firewall.enable = false; + nameservers = [ "${networkPrefix}.97.1" ]; + }; + + environment.systemPackages = [ + pkgs.mariadb + ]; + + systemd.services.generate-selfsigned-cert = { + description = "Generate/renew self-signed SSL certificate"; + wantedBy = [ "nginx.service" ]; + path = [ pkgs.openssl pkgs.gnugrep ]; + + script = '' + if [ -f ${certFile} ]; then + expiry=$(openssl x509 -enddate -noout -in ${certFile} | cut -d= -f2) + expiry_epoch=$(date -d "$expiry" +%s) + current_epoch=$(date +%s) + days_left=$(( (expiry_epoch - current_epoch) / 86400 )) + + if [ $days_left -lt 30 ]; then # Regenerate if expiring in <30 days + echo "Certificate expiring soon, regenerating..." + rm ${certFile} ${keyFile} + fi + fi + + if [ ! -f ${certFile} ] || [ ! -f ${keyFile} ]; then + openssl req -x509 -nodes -days 365 \ + -newkey rsa:2048 \ + -keyout ${keyFile} \ + -out ${certFile} \ + -subj "/CN=${domain}" + fi + ''; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts."${domain}" = { + sslCertificate = certFile; + sslCertificateKey = keyFile; + forceSSL = true; + extraConfig = '' + proxy_buffering off; + ''; + locations."/".extraConfig = '' + proxy_pass http://127.0.0.1:8123; + proxy_set_header Host $host; + proxy_redirect http:// https://; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + ''; + }; + + services.home-assistant = home-assistant-config; + + services.home-assistant.extraComponents = [ + "mobile_app" + "backup" + ]; + + systemd.services.install-hacs = { + description = "Install HACS"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + set -e + HACS_VERSION="2.0.5" # Replace with the latest version + HACS_DIR="/var/lib/hass/custom_components/hacs" + + mkdir -p "$HACS_DIR" + ${pkgs.curl}/bin/curl -L "https://github.com/hacs/integration/releases/download/$HACS_VERSION/hacs.zip" -o /tmp/hacs.zip + ${pkgs.unzip}/bin/unzip -o /tmp/hacs.zip -d "$HACS_DIR" + rm /tmp/hacs.zip + chown -R hass:hass "$HACS_DIR" + ''; + }; + + services.home-assistant.extraPackages = ps: with ps; [ + mysqlclient + ]; + + services.mysql = { + enable = true; + package = pkgs.mariadb; + ensureDatabases = [ "hass" ]; + ensureUsers = [ + { + name = "hass"; + ensurePermissions = { + "hass.*" = "ALL PRIVILEGES"; + }; + } + ]; + + }; + + services.mysqlBackup = { + enable = true; + databases = [ "hass" ]; + }; + + services.home-assistant.config = + let + hiddenEntities = [ + "sensor.last_boot" + "sensor.date" + ]; + in + { + recorder = { + db_url = "mysql://hass@localhost/hass?unix_socket=/var/run/mysqld/mysqld.sock"; + }; + homeassistant = { + name = "Home"; + latitude = "!secret home_latitude"; + longitude = "!secret home_longitude"; + elevation = "!secret home_elevation"; + unit_system = "metric"; + currency = "EUR"; + country = "AT"; + time_zone = "Europe/Vienna"; + external_url = "https://${domain}"; + }; + zone = { + name = "Home"; + latitude = "!secret home_latitude"; + longitude = "!secret home_longitude"; + radius = 35; + icon = "mdi:account-multiple"; + + }; + automation = "!include automations.yaml"; + frontend = { }; + http = { + use_x_forwarded_for = true; + trusted_proxies = [ + "127.0.0.1" + "::1" + ]; + }; + api = { }; + history.exclude = { + entities = hiddenEntities; + domains = [ + "automation" + "updater" + ]; + }; + "map" = { }; + # logbook.exclude.entities = "hiddenEntities"; + logger = { + default = "warning"; + }; + + network = { }; + zeroconf = { }; + system_health = { }; + default_config = { }; + system_log = { }; + }; + + users.users.hass.extraGroups = [ "dialout" ]; + system.stateVersion = "23.05"; + }; + }; +} diff --git a/modules/cloonar-assistant/networking/default.nix b/modules/cloonar-assistant/networking/default.nix new file mode 100644 index 0000000..10558bd --- /dev/null +++ b/modules/cloonar-assistant/networking/default.nix @@ -0,0 +1,7 @@ +{}: { + imports = [ + ./interfaces.nix + ./dhcp.nix + ./firewall.nix + ]; +} diff --git a/modules/cloonar-assistant/networking/dhcp.nix b/modules/cloonar-assistant/networking/dhcp.nix new file mode 100644 index 0000000..85f0d2f --- /dev/null +++ b/modules/cloonar-assistant/networking/dhcp.nix @@ -0,0 +1,187 @@ +{ config, lib, ... }: +{ + services.kea.dhcp4 = lib.mkIf config.cloonar-assistant.firewall.enable { + enable = true; + settings = { + interfaces-config = { + interfaces = [ + "lan" + "server" + "infrastructure" + "multimedia" + "smart" + "guest" + ]; + }; + lease-database = { + name = "/var/lib/kea/dhcp4.leases"; + persist = true; + type = "memfile"; + }; + rebind-timer = 2000; + renew-timer = 1000; + subnet4 = [ + { + id = 96; + pools = [ + { + pool = "${config.networkPrefix}.96.100 - ${config.networkPrefix}.96.240"; + } + ]; + subnet = "${config.networkPrefix}.96.0/24"; + interface = "lan"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.96.1"; + } + { + name = "domain-name"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-search"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-name-servers"; + data = "${config.networkPrefix}.96.1"; + } + ]; + reservations = [ + ]; + } + { + id = 97; + pools = [ + { + pool = "${config.networkPrefix}.97.100 - ${config.networkPrefix}.97.240"; + } + ]; + subnet = "${config.networkPrefix}.97.0/24"; + interface = "server"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.97.1"; + } + { + name = "domain-name"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-name-servers"; + data = "${config.networkPrefix}.97.1"; + } + ]; + reservations = [ + ]; + } + { + id = 101; + pools = [ + { + pool = "${config.networkPrefix}.101.100 - ${config.networkPrefix}.101.240"; + } + ]; + subnet = "${config.networkPrefix}.101.0/24"; + interface = "infrastructure"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.101.1"; + } + { + name = "domain-name"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-name-servers"; + data = "${config.networkPrefix}.101.1"; + } + { + name = "capwap-ac-v4"; + code = 138; + data = "${config.networkPrefix}.97.2"; + } + ]; + reservations = [ + ]; + } + { + id = 99; + pools = [ + { + pool = "${config.networkPrefix}.99.100 - ${config.networkPrefix}.99.240"; + } + ]; + subnet = "${config.networkPrefix}.99.0/24"; + interface = "multimedia"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.99.1"; + } + { + name = "domain-name"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-name-servers"; + data = "${config.networkPrefix}.99.1"; + } + ]; + reservations = [ + ]; + } + { + id = 254; + pools = [ + { + pool = "${config.networkPrefix}.254.10 - ${config.networkPrefix}.254.254"; + } + ]; + subnet = "${config.networkPrefix}.254.0/24"; + interface = "guest"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.254.1"; + } + { + name = "domain-name-servers"; + data = "9.9.9.9"; + } + ]; + } + { + id = 100; + pools = [ + { + pool = "${config.networkPrefix}.100.100 - ${config.networkPrefix}.100.240"; + } + ]; + subnet = "${config.networkPrefix}.100.0/24"; + interface = "smart"; + option-data = [ + { + name = "routers"; + data = "${config.networkPrefix}.100.1"; + } + { + name = "domain-name"; + data = config.cloonar-assistant.domain; + } + { + name = "domain-name-servers"; + data = "${config.networkPrefix}.100.1"; + } + ]; + reservations = [ + ]; + } + ]; + valid-lifetime = 4000; + }; + }; +} diff --git a/modules/cloonar-assistant/networking/firewall.nix b/modules/cloonar-assistant/networking/firewall.nix new file mode 100644 index 0000000..2cb4aa1 --- /dev/null +++ b/modules/cloonar-assistant/networking/firewall.nix @@ -0,0 +1,150 @@ +{ config, lib, pkgs, ... }: +let + forward-chain = '' + + ''; +in { + networking = { + firewall.checkReversePath = false; + nat.enable = false; + nftables = { + enable = true; + tables = { + "cloonar-fw" = { + family = "inet"; + content = '' + chain output { + type filter hook output priority 100; policy accept; + } + + chain rpfilter { + type filter hook prerouting priority mangle + 10; policy drop; + meta nfproto ipv4 udp sport . udp dport { 68 . 67, 67 . 68 } accept comment "DHCPv4 client/server" + fib saddr . mark . iif oif exists accept + } + + chain input { + type filter hook input priority filter; policy drop; + iifname "lo" accept comment "trusted interfaces" + iifname "lan" counter accept comment "Spice" + ct state vmap { invalid : drop, established : accept, related : accept, new : jump input-allow, untracked : jump input-allow } + tcp flags syn / fin,syn,rst,ack log prefix "refused connection: " level info + } + + chain input-allow { + udp dport != { 53, 5353 } ct state new limit rate over 1/second burst 10 packets drop comment "rate limit for new connections" + iifname lo accept + ${lib.optionalString config.cloonar-assistant.vpn.enable '' + iifname "wan" udp dport 51820 counter accept comment "Wireguard traffic" + ''} + ${lib.optionalString config.cloonar-assistant.firewall.enable '' + iifname "wan" tcp dport 9273 counter accept comment "Prometheus traffic" + ''} + ${lib.optionalString config.cloonar-assistant.firewall.enable '' + iifname { "server", "vserver", "vm-*", "lan", "wg_cloonar" } counter accept comment "allow trusted to router" + iifname { "multimedia", "smart", "infrastructure", "podman0", "setup" } udp dport { 53, 5353 } counter accept comment "DNS" + ''} + + iifname { "wan", "multimedia" } icmp type { echo-request, destination-unreachable, time-exceeded } counter accept comment "Allow select ICMP" + + # Accept mDNS for avahi reflection + ${lib.optionalString config.cloonar-assistant.multiroom-audio.enable '' + iifname "server" ip saddr ${config.networkPrefix}.97.20/32 tcp dport { llmnr } counter accept + iifname "server" ip saddr ${config.networkPrefix}.97.20/32 udp dport { mdns, llmnr } counter accept + ''} + + # Allow all returning traffic + ct state { established, related } counter accept + + # Allow returning traffic from wan and drop everthing else + iifname "wan" ct state { established, related } accept comment "Allow established traffic" + iifname "wan" icmp type { echo-request, destination-unreachable, time-exceeded } counter accept comment "Allow select ICMP" + iifname "wan" counter drop comment "Drop all other unsolicited traffic from wan" + + limit rate 60/minute burst 100 packets log prefix "Input - Drop: " comment "Log any unmatched traffic" + } + + + chain forward { + type filter hook forward priority filter; policy drop; + + iifname "wg_cloonar" counter accept comment "test wireguard" + + ${lib.optionalString config.cloonar-assistant.vpn.enable '' + iifname "wg_cloonar" oifname lo counter accept comment "wireguard to server" + ''} + + # enable flow offloading for better throughput + # ip protocol { tcp, udp } flow offload @f + + # multimedia airplay + ${lib.optionalString config.cloonar-assistant.multiroom-audio.enable '' + iifname "multimedia" oifname { "lan" } counter accept + iifname "multimedia" oifname "server" tcp dport { 1704, 1705 } counter accept + iifname "lan" oifname "server" udp dport { 5000, 5353, 6001 - 6011 } counter accept + # avahi + iifname "server" ip saddr ${config.networkPrefix}.97.20/32 oifname { "lan" } counter accept + ''} + + ${lib.optionalString config.cloonar-assistant.firewall.enable '' + # smart home coap + iifname "smart" oifname "server" ip daddr ${config.networkPrefix}.97.20/32 udp dport { 5683 } counter accept + iifname "smart" oifname "server" ip daddr ${config.networkPrefix}.97.20/32 tcp dport { 1883 } counter accept + + # lan and vpn to any + iifname { "lan", "server", "vserver", "wg_cloonar" } oifname { "lan", "vb-*", "vm-*", "server", "vserver", "infrastructure", "multimedia", "smart", "wg_cloonar", "guest", "setup" } counter accept + iifname { "infrastructure", "setup" } oifname { "server", "vserver" } counter accept + iifname { "lan", "wan" } udp dport { 8211, 27015 } counter accept comment "palworld" + ''} + + + # allow all established, related + ct state { established, related } accept comment "Allow established traffic" + + # Allow trusted network WAN access + ${lib.optionalString config.cloonar-assistant.firewall.enable '' + iifname { + "lan", + "infrastructure", + "server", + "vserver", + "multimedia", + "smart", + "wg_cloonar", + "podman*", + "guest", + "setup", + "vb-*", + "vm-*", + } oifname { + "wan", + } counter accept comment "Allow trusted LAN to WAN" + ''} + + limit rate 60/minute burst 100 packets log prefix "Forward - Drop: " comment "Log any unmatched traffic" + } + ''; + }; + "cloonar-nat" = { + family = "ip"; + content = '' + chain prerouting { + type nat hook prerouting priority filter; policy accept; + iifname "server" ip daddr ${config.networkPrefix}.96.255 udp dport { 9 } dnat to ${config.networkPrefix}.96.255 + } + + # Setup NAT masquerading on external interfaces + chain postrouting { + type nat hook postrouting priority filter; policy accept; + oifname { "wan" } masquerade + + ${lib.optionalString config.cloonar-assistant.vpn.enable '' + oifname { "wg_cloonar" } masquerade + ''} + } + ''; + }; + }; + }; + }; +} diff --git a/modules/cloonar-assistant/networking/interfaces.nix b/modules/cloonar-assistant/networking/interfaces.nix new file mode 100644 index 0000000..ea76dc4 --- /dev/null +++ b/modules/cloonar-assistant/networking/interfaces.nix @@ -0,0 +1,112 @@ +{ config, lib, ... }: +{ + boot.kernel.sysctl = { + # if you use ipv4, this is all you need + "net.ipv4.conf.all.forwarding" = true; + # If you want to use it for ipv6 + "net.ipv6.conf.all.forwarding" = false; + }; + + systemd.network = { + enable = true; + wait-online.anyInterface = true; + links = { + "10-wan" = { + matchConfig.Name = cloonar-assistant.interfaces.wan; + linkConfig.Name = "wan"; + }; + }; + netdevs = { + "30-server".netdevConfig = { + Kind = "bridge"; + Name = "server"; + }; + }; + networks = { + "31-server" = { + matchConfig.Name = [ "vserver" ]; + # Attach to the bridge that was configured above + networkConfig.Bridge = "server"; + }; + }; + }; + + networking = if config.cloonar-assistant.firewall.enable then { + useDHCP = false; + # Define VLANS + nameservers = [ "${config.networkPrefix}.97.1" ]; + # resolvconf.enable = false; + vlans = { + infrastructure = { + id = 101; + interface = config.cloonar-assistant.interfaces.internal; + }; + lan = { + id = 96; + interface = config.cloonar-assistant.interfaces.internal; + }; + vserver = { + id = 97; + interface = config.cloonar-assistant.interfaces.internal; + }; + multimedia = { + id = 99; + interface = config.cloonar-assistant.interfaces.internal; + }; + smart = { + id = 100; + interface = config.cloonar-assistant.interfaces.internal; + }; + guest = { + id = 254; + interface = config.cloonar-assistant.interfaces.internal; + }; + }; + + interfaces = { + # Don't request DHCP on the physical interfaces + internal.useDHCP = false; + + # Handle the VLANs + wan.useDHCP = true; + lan = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.96.1"; + prefixLength = 24; + }]; + }; + server = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.97.1"; + prefixLength = 24; + }]; + }; + infrastructure = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.101.1"; + prefixLength = 24; + }]; + }; + multimedia = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.99.1"; + prefixLength = 24; + }]; + }; + smart = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.100.1"; + prefixLength = 24; + }]; + }; + guest = { + ipv4.addresses = [{ + address = "${config.networkPrefix}.254.1"; + prefixLength = 24; + }]; + }; + }; + } else { + useDHCP = true; + }; +} diff --git a/modules/cloonar-assistant/updns/default.nix b/modules/cloonar-assistant/updns/default.nix new file mode 100644 index 0000000..c17f5e1 --- /dev/null +++ b/modules/cloonar-assistant/updns/default.nix @@ -0,0 +1,88 @@ +{ config, pkgs, lib, ... }: { + ### 1) Make sure we have the tools we need + environment.systemPackages = with pkgs; [ + curl + jq + ]; + + sops.secrets.updns-client-token = { + owner = "updns-client"; + restartUnits = [ "updns-client.service" ]; + }; + + ### 3) Write the check‐script into /etc/external-ip/check.sh (0400, executable) + environment.etc."updns-client/run.sh".text = lib.mkIf config.cloonar-assistant.updns-client.enable lib.concatStringsSep "\n" [ + "#!/usr/bin/env bash" + "set -euo pipefail" + "" + "# Where our secret lives (encrypted)" + "SECRET=${config.sops.secrets.updns-client.path}" + "# Where we record the last‐seen IP" + "LAST_IP_FILE=/var/lib/updns-client/last-ip" + "" + "# Decrypt the API key at runtime" + "API_KEY=$(cat \"$SECRET\")" + "" + "# Fetch current external IP" + "IP=$(curl -fsSL https://ifconfig.me)" + "" + "# Ensure state directory exists" + "mkdir -p \"$(dirname \"$LAST_IP_FILE\")\"" + "" + "# Read old IP (if any)" + "if [[ -f \"$LAST_IP_FILE\" ]]; then" + " OLD_IP=$(< \"$LAST_IP_FILE\")" + "else" + " OLD_IP=\"\"" + "fi" + "" + "# If it's changed, notify the API and update the file" + "if [[ \"$IP\" != \"$OLD_IP\" ]]; then" + "" + " PAYLOAD=$(jq -n \\" + " --arg key \"${config.cloonar-assistant.updns-client.key}\" \\" + " --arg secret \"$SECRET\" \\" + " --arg host \"${config.cloonar-assistant.domain}\" \\" + " --arg ip \"$IP\" \\" + " '{key: $key, secret: $secret, host: $host, ip: $ip}')" + "" + " curl -fsS -X POST https://updns-client.cloonar.com/update \\" + " -H \"Content-Type: application/json\" \\" + " -d \"$PAYLOAD\"" + "" + " echo \"$IP\" > \"$LAST_IP_FILE\"" + "fi" + ]; + environment.etc."updns-client/run.sh".mode = "0500"; + + ### 4) Ensure /var/lib/external-ip exists on boot + systemd.tmpfiles.rules = [ + # path mode owner group age + "d /var/lib/updns-client 0755 root root -" + ]; + + ### 5) Define the oneshot service + systemd.services.updns-client = lib.mkIf config.cloonar-assistant.updns-client.enable { + description = "Check external IP and notify API on change"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + WorkingDirectory = "/var/lib/updns-client"; + ExecStart = "${pkgs.bash}/bin/bash /etc/updns-client/run.sh"; + }; + install.WantedBy = [ "multi-user.target" ]; + }; + + ### 6) Define the timer (runs at boot + every 5 minutes) + systemd.timers.updns-client = lib.mkIf config.cloonar-assistant.updns-client.enable { + description = "Run updns-client.service every 5 minutes"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "1min"; + OnUnitActiveSec = "5min"; + Persistent = true; + }; + unit = "updns-client.service"; + }; +}