From fb782cb9fe4474625590b5cc688e2a83c5260a0f Mon Sep 17 00:00:00 2001 From: erik Date: Mon, 13 Jul 2026 19:13:11 +0200 Subject: [PATCH] refactor: split services into reusable services/ modules - services/{samba,avahi,audiobookshelf,containers,caddy,tailscale}.nix - common.nix grows firewall base + timezone; hosts import what they need - jupiter/vm/vps import service modules; drop the jupiter/services.nix monolith - each module opens its own firewall ports; caddy/tailscale shared by hosts - verified: jupiter/vps/vbox eval + jupiter builds, config equivalent --- common.nix | 7 ++ jupiter/configuration.nix | 41 ++++----- jupiter/services.nix | 174 ------------------------------------ jupiter/vm.nix | 24 +++-- services/audiobookshelf.nix | 16 ++++ services/avahi.nix | 13 +++ services/caddy.nix | 9 ++ services/containers.nix | 22 +++++ services/samba.nix | 53 +++++++++++ services/tailscale.nix | 16 ++++ vps/configuration.nix | 34 +++---- 11 files changed, 187 insertions(+), 222 deletions(-) delete mode 100644 jupiter/services.nix create mode 100644 services/audiobookshelf.nix create mode 100644 services/avahi.nix create mode 100644 services/caddy.nix create mode 100644 services/containers.nix create mode 100644 services/samba.nix create mode 100644 services/tailscale.nix diff --git a/common.nix b/common.nix index b4674b6..ca274b2 100644 --- a/common.nix +++ b/common.nix @@ -37,5 +37,12 @@ environment.systemPackages = with pkgs; [ vim git htop tmux curl ]; + # ---- Locale / firewall base ---- + time.timeZone = "Europe/Berlin"; i18n.defaultLocale = "en_US.UTF-8"; + + # Firewall on, ssh always allowed. Service modules add their own ports + # (samba via openFirewall, caddy 80/443, tailscale trusts tailscale0). + networking.firewall.enable = true; + networking.firewall.allowedTCPPorts = [ 22 ]; } diff --git a/jupiter/configuration.nix b/jupiter/configuration.nix index 495572e..ef604ac 100644 --- a/jupiter/configuration.nix +++ b/jupiter/configuration.nix @@ -1,14 +1,25 @@ { config, pkgs, lib, ... }: -# Real-host config: hardware + disk layout + bootloader + shared services. +# ZimaBlade NAS host: hardware + disk + the services it runs. { imports = [ ./hardware-configuration.nix ./disk-config.nix # disko: OS-disk partitions + filesystems - ./secrets.nix # sops-nix: samba password etc. - ./services.nix + ./secrets.nix # sops-nix: samba password, tailscale key, ... + ../common.nix # shared base: user / ssh / nix / firewall + ../services/samba.nix + ../services/avahi.nix + ../services/audiobookshelf.nix + ../services/containers.nix + ../services/caddy.nix + ../services/tailscale.nix ]; + # ---- Host identity ---- + networking.hostName = "jupiter"; + networking.networkmanager.enable = true; + users.users.darman.extraGroups = [ "docker" ]; # merges with common.nix + # ---- Boot ---- # systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub. boot.loader.systemd-boot.enable = true; @@ -19,10 +30,6 @@ # hardware-configuration.nix) or stage-1 can't mount root and the box panics. boot.initrd.availableKernelModules = [ "mmc_block" "sdhci_pci" "sdhci_acpi" ]; - # Trust wheel users so `nixos-rebuild --target-host darman@…` can push a - # laptop-built (unsigned) closure without a signature error. - nix.settings.trusted-users = [ "root" "@wheel" ]; - # Warm reboot hangs at firmware reset on this board (cold power-cycle works). # Force the PCI-chipset reset method. If a warm `reboot` still hangs, try the # next value: acpi -> bios -> cold -> efi. @@ -40,18 +47,12 @@ options = [ "nofail" ]; # don't block boot if the array is degraded/absent }; - # ---- Tailscale (via self-hosted headscale) ---- - # Auto-registers on boot using a headscale pre-auth key from sops. - # Generate the key on the headscale server, e.g.: - # headscale preauthkeys create --user --expiration 1h - # then put it in secrets/jupiter.yaml (./edit_secrets, key: tailscale_authkey). - services.tailscale = { - enable = true; - openFirewall = true; # UDP 41641 for direct connections - authKeyFile = config.sops.secrets.tailscale_authkey.path; - extraUpFlags = [ "--login-server=https://vpn.mgaction.town" ]; + # ---- Caddy vhosts (LAN) ---- + # whoami on :80, audiobookshelf via a pihole local-DNS name -> jupiter IP. + services.caddy.virtualHosts = { + "http://localhost".extraConfig = "reverse_proxy localhost:8080"; + "http://audiobookshelf.jupiter.sol".extraConfig = "reverse_proxy localhost:8000"; }; - # Reach jupiter's services (ssh, samba, audiobookshelf) over the tailnet - # without opening those ports on the LAN. - networking.firewall.trustedInterfaces = [ "tailscale0" ]; + + system.stateVersion = "26.05"; } diff --git a/jupiter/services.nix b/jupiter/services.nix deleted file mode 100644 index 6698b26..0000000 --- a/jupiter/services.nix +++ /dev/null @@ -1,174 +0,0 @@ -{ config, pkgs, lib, ... }: - -# Portable system + service config. Contains NO bootloader or filesystem -# settings, so it can be reused by both the real host (configuration.nix) -# and the VirtualBox test image (see flake.nix). - -{ - # ---- Networking ---- - networking.hostName = "jupiter"; - networking.networkmanager.enable = true; - - networking.firewall = { - enable = true; - allowedTCPPorts = [ - 22 # ssh - 445 139 # samba - 80 443 # reverse proxy (caddy) - ]; - }; - - # ---- Locale / time ---- - time.timeZone = "Europe/Berlin"; - i18n.defaultLocale = "en_US.UTF-8"; - - # ---- Users ---- - users.users.darman = { - isNormalUser = true; - description = "darman"; - extraGroups = [ "wheel" "networkmanager" "docker" ]; - # Password login for ssh is disabled below — these keys are the only way in. - openssh.authorizedKeys.keys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop" - ]; - }; - - security.sudo.wheelNeedsPassword = false; - - # ---- SSH ---- - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - PermitRootLogin = "no"; - }; - }; - - # ---- Storage / NAS ---- - services.samba = { - enable = true; - openFirewall = true; - settings = { - global = { - "workgroup" = "WORKGROUP"; - "server string" = "jupiter"; - "security" = "user"; - }; - data = { - "path" = "/mnt/data"; - "browseable" = "yes"; - "read only" = "no"; - "guest ok" = "no"; - "valid users" = "darman"; - }; - }; - }; - - # Samba keeps its own NTLM password DB, separate from the system password. - # `services.samba` never sets it, so logins fail until provisioned. - # This runs AFTER samba-smbd so its state dir (/var/lib/samba/private) exists - # — an activation script runs too early and smbpasswd fails to init the passdb. - # Reads a single-line password from the first file that exists: - # Real host: /run/secrets/samba_password (sops-nix, see secrets.nix) - # VM test: /etc/samba/smb-password (plaintext, see vm.nix) - # smbpasswd prompts new + confirm, so the value is fed twice. - systemd.services.samba-smbpasswd = { - description = "Provision Samba password for darman"; - after = [ "samba-smbd.service" ]; - requires = [ "samba-smbd.service" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - script = '' - for f in /run/secrets/samba_password /etc/samba/smb-password; do - if [ -f "$f" ]; then - pw=$(head -n1 "$f") - printf '%s\n%s\n' "$pw" "$pw" | ${pkgs.samba}/bin/smbpasswd -a -s darman - exit 0 - fi - done - echo "no samba password source found" >&2 - ''; - }; - services.avahi = { - enable = true; - nssmdns4 = true; - publish = { - enable = true; - userServices = true; - }; - }; - - # ---- Audiobookshelf ---- - # Native module. Listens on localhost only; reached through caddy. - # Library/media paths are set in the web UI — point them at /mnt/data/... - # The service runs as user `audiobookshelf`, which must be able to READ those - # dirs on the RAID (chown/chmod or add it to the owning group as needed). - services.audiobookshelf = { - enable = true; - host = "0.0.0.0"; # reachable over tailscale0 (trusted) + localhost - port = 8000; - # openFirewall stays OFF: :8000 is blocked on the LAN, but tailscale0 is a - # trusted interface, so the VPS reaches it via jupiter.hosts.mgaction.town:8000. - # Local caddy (below) still serves LAN clients via localhost:8000. - }; - - users.users.audiobookshelf.extraGroups = [ "users" ]; - - # ---- Containers ---- - virtualisation.podman = { - enable = true; - dockerCompat = true; - defaultNetwork.settings.dns_enabled = true; - }; - - virtualisation.oci-containers = { - backend = "podman"; - containers = { - whoami = { - image = "traefik/whoami:latest"; - ports = [ "8080:80" ]; - autoStart = true; - }; - }; - }; - - # ---- Reverse proxy ---- - # Caddy binds nothing unless it has a vhost. This proxies the whoami - # container so :80 actually serves. Add one block per service. - # Real host: swap `http://localhost` for your domain to get automatic HTTPS, - # e.g. `services.caddy.virtualHosts."jelly.example.com".extraConfig`. - services.caddy = { - enable = true; - virtualHosts."http://localhost".extraConfig = '' - reverse_proxy localhost:8080 - ''; - # Reach at http://audiobookshelf.sol — add a pihole local-DNS record - # audiobookshelf.sol -> jupiter's IP. (Or browse jupiter.sol directly if you - # set services.audiobookshelf.host = "0.0.0.0" + openFirewall instead.) - virtualHosts."http://audiobookshelf.jupiter.sol".extraConfig = '' - reverse_proxy localhost:8000 - ''; - }; - - # ---- System packages ---- - environment.systemPackages = with pkgs; [ - vim - git - htop - tmux - curl - ]; - - # ---- Nix settings ---- - nix.settings.experimental-features = [ "nix-command" "flakes" ]; - nix.gc = { - automatic = true; - dates = "weekly"; - options = "--delete-older-than 30d"; - }; - - system.stateVersion = "26.05"; -} diff --git a/jupiter/vm.nix b/jupiter/vm.nix index 29d5754..1b09afd 100644 --- a/jupiter/vm.nix +++ b/jupiter/vm.nix @@ -1,15 +1,23 @@ { config, pkgs, lib, modulesPath, ... }: -# VirtualBox test image. Reuses services.nix but adds console/SSH login -# credentials so you can actually get into the VM. Disk + bootloader are -# provided by the virtualbox-image module, so hardware-configuration.nix -# is intentionally NOT imported here. +# VirtualBox test image — mirrors jupiter's services (minus tailscale/sops/disk) +# and adds console/SSH login credentials. Disk + bootloader come from the +# virtualbox-image module, so hardware-configuration.nix is NOT imported. { imports = [ (modulesPath + "/virtualisation/virtualbox-image.nix") - ./services.nix + ../common.nix # shared base: user / ssh / nix / firewall + ../services/samba.nix + ../services/avahi.nix + ../services/audiobookshelf.nix + ../services/containers.nix + ../services/caddy.nix ]; + networking.hostName = "jupiter"; + networking.networkmanager.enable = true; + users.users.darman.extraGroups = [ "docker" ]; + # Allow password login for testing (real host is key-only). services.openssh.settings.PasswordAuthentication = lib.mkForce true; @@ -26,6 +34,12 @@ mode = "0600"; }; + # Caddy vhosts (same as the LAN host, so the proxy path is testable). + services.caddy.virtualHosts = { + "http://localhost".extraConfig = "reverse_proxy localhost:8080"; + "http://audiobookshelf.jupiter.sol".extraConfig = "reverse_proxy localhost:8000"; + }; + # Guest additions for clipboard/resize (optional). virtualisation.virtualbox.guest.enable = true; diff --git a/services/audiobookshelf.nix b/services/audiobookshelf.nix new file mode 100644 index 0000000..a7177f9 --- /dev/null +++ b/services/audiobookshelf.nix @@ -0,0 +1,16 @@ +{ ... }: + +# Audiobookshelf audiobook/podcast server. +# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall), +# but reachable over the trusted tailscale0 interface and via localhost (caddy). +# Library/media paths are set in the web UI — point them at /mnt/data/... +# Runs as user `audiobookshelf`; added to `users` so it can read group-owned +# library dirs on the RAID. +{ + services.audiobookshelf = { + enable = true; + host = "0.0.0.0"; + port = 8000; + }; + users.users.audiobookshelf.extraGroups = [ "users" ]; +} diff --git a/services/avahi.nix b/services/avahi.nix new file mode 100644 index 0000000..3f15778 --- /dev/null +++ b/services/avahi.nix @@ -0,0 +1,13 @@ +{ ... }: + +# mDNS/DNS-SD (advertise the host + shares on the LAN). +{ + services.avahi = { + enable = true; + nssmdns4 = true; + publish = { + enable = true; + userServices = true; + }; + }; +} diff --git a/services/caddy.nix b/services/caddy.nix new file mode 100644 index 0000000..42e33de --- /dev/null +++ b/services/caddy.nix @@ -0,0 +1,9 @@ +{ ... }: + +# Caddy reverse proxy — base enable + open the web ports. +# Each host adds its own `services.caddy.virtualHosts.` (LAN names on +# jupiter, public domains with automatic HTTPS on the vps). +{ + services.caddy.enable = true; + networking.firewall.allowedTCPPorts = [ 80 443 ]; +} diff --git a/services/containers.nix b/services/containers.nix new file mode 100644 index 0000000..9d5a0e6 --- /dev/null +++ b/services/containers.nix @@ -0,0 +1,22 @@ +{ ... }: + +# Podman (Docker-compatible) + declarative OCI containers. +# Copy the whoami block per app; publish ports and mount /mnt/data volumes. +{ + virtualisation.podman = { + enable = true; + dockerCompat = true; + defaultNetwork.settings.dns_enabled = true; + }; + + virtualisation.oci-containers = { + backend = "podman"; + containers = { + whoami = { + image = "traefik/whoami:latest"; + ports = [ "8080:80" ]; + autoStart = true; + }; + }; + }; +} diff --git a/services/samba.nix b/services/samba.nix new file mode 100644 index 0000000..9622338 --- /dev/null +++ b/services/samba.nix @@ -0,0 +1,53 @@ +{ pkgs, ... }: + +# Samba file server for the /mnt/data share + password provisioning. +# Import on any host that should serve the NAS. +{ + services.samba = { + enable = true; + openFirewall = true; + settings = { + global = { + "workgroup" = "WORKGROUP"; + "server string" = "jupiter"; + "security" = "user"; + }; + data = { + "path" = "/mnt/data"; + "browseable" = "yes"; + "read only" = "no"; + "guest ok" = "no"; + "valid users" = "darman"; + }; + }; + }; + + # Samba keeps its own NTLM password DB, separate from the system password. + # `services.samba` never sets it, so logins fail until provisioned. + # This runs AFTER samba-smbd so its state dir (/var/lib/samba/private) exists + # — an activation script runs too early and smbpasswd fails to init the passdb. + # Reads a single-line password from the first file that exists: + # Real host: /run/secrets/samba_password (sops-nix, see secrets.nix) + # VM test: /etc/samba/smb-password (plaintext, see vm.nix) + # smbpasswd prompts new + confirm, so the value is fed twice. + systemd.services.samba-smbpasswd = { + description = "Provision Samba password for darman"; + after = [ "samba-smbd.service" ]; + requires = [ "samba-smbd.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + for f in /run/secrets/samba_password /etc/samba/smb-password; do + if [ -f "$f" ]; then + pw=$(head -n1 "$f") + printf '%s\n%s\n' "$pw" "$pw" | ${pkgs.samba}/bin/smbpasswd -a -s darman + exit 0 + fi + done + echo "no samba password source found" >&2 + ''; + }; +} diff --git a/services/tailscale.nix b/services/tailscale.nix new file mode 100644 index 0000000..57a11ba --- /dev/null +++ b/services/tailscale.nix @@ -0,0 +1,16 @@ +{ config, ... }: + +# Tailscale node joined to the self-hosted headscale control server. +# Auto-registers on boot from a sops pre-auth key. Requires the importing host +# to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix). +# Not for the VM (no sops). +{ + services.tailscale = { + enable = true; + openFirewall = true; # UDP 41641 for direct connections + authKeyFile = config.sops.secrets.tailscale_authkey.path; + extraUpFlags = [ "--login-server=https://vpn.mgaction.town" ]; + }; + # Reach the host's services over the tailnet without opening LAN ports. + networking.firewall.trustedInterfaces = [ "tailscale0" ]; +} diff --git a/vps/configuration.nix b/vps/configuration.nix index 9a9c401..1fa1b96 100644 --- a/vps/configuration.nix +++ b/vps/configuration.nix @@ -6,7 +6,9 @@ ./hardware-configuration.nix ./disk-config.nix # disko: vda partitions + filesystems ./secrets.nix # sops-nix: tailscale authkey - ../common.nix # shared user / ssh / nix settings + ../common.nix # shared base: user / ssh / nix / firewall + ../services/caddy.nix + ../services/tailscale.nix ]; # ---- Boot (UEFI) ---- @@ -34,31 +36,17 @@ # Public resolvers for early boot; tailscale MagicDNS overrides once up. networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ]; - networking.firewall = { - enable = true; - allowedTCPPorts = [ 22 80 443 ]; - trustedInterfaces = [ "tailscale0" ]; # reach admin services over the tailnet - }; + # firewall (enable + 22), caddy (80/443), tailscale (trust tailscale0 + join + # headscale) come from ../common.nix and ../services/{caddy,tailscale}.nix. - # ---- Tailscale (via headscale) ---- - services.tailscale = { - enable = true; - authKeyFile = config.sops.secrets.tailscale_authkey.path; - extraUpFlags = [ "--login-server=https://vpn.mgaction.town" ]; - }; - - # ---- Public reverse proxy ---- + # ---- Public reverse proxy vhosts ---- # Caddy gets automatic public HTTPS (Let's Encrypt) for real domains. # Proxies to jupiter's audiobookshelf over the tailnet (MagicDNS name). - # Add one block per public service. Needs a public A record -> this VPS IP - # and ports 80/443 open (they are, above). - services.caddy = { - enable = true; - virtualHosts."audiobookshelf.mgaction.town".extraConfig = '' - reverse_proxy http://jupiter.hosts.mgaction.town:8000 - ''; - # TODO: port your other VPS services' vhosts here before deploying. - }; + # Needs a public A record -> this VPS IP (ports 80/443 opened by the module). + services.caddy.virtualHosts."audiobookshelf.mgaction.town".extraConfig = '' + reverse_proxy http://jupiter.hosts.mgaction.town:8000 + ''; + # TODO: port your other VPS services' vhosts here before deploying. system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades }