diff --git a/.sops.yaml b/.sops.yaml index 7f329f8..d93b121 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -16,6 +16,7 @@ keys: - &gpd-win4 age1ceg548u5ma6rgu3xgvd254y5xefqrdqfqhcjsjp3255q976fgd2qaua53d - &nb age1exny8unxynaw03yu8ppahu5z28uermghr8ag34e7kdqnaduq9stsyettzz - &amzebs-01 age1xcgc6u7fmc2trgxtdtf5nhrd7axzweuxlg0ya9jre3sdrg6c6easecue9w + - &nas age1x3elhtccp4u8ha5ry32juj9fkpg0qg7qqx4gduuehgwwnnhcxp8s892hek creation_rules: - path_regex: ^[^/]+\.yaml$ @@ -80,6 +81,14 @@ creation_rules: - *dominik2 - *nb - *amzebs-01 + - path_regex: hosts/nas/[^/]+\.yaml$ + key_groups: + - age: + - *bitwarden + - *dominik + - *dominik2 + - *nb + - *nas - path_regex: hosts/mail/[^/]+\.yaml$ key_groups: - age: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d745136 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This is a NixOS infrastructure repository managing multiple hosts (servers and personal machines) using a modular Nix configuration approach with SOPS for secrets management and Bento for deployment. + +## Build and Test Commands + +```bash +# Enter development shell (sets up MCP configs) +nix-shell + +# Test configuration before deployment (required before PRs) +./scripts/test-configuration +./scripts/test-configuration -v # with --show-trace + +# Edit encrypted secrets +nix-shell -p sops --run 'sops hosts//secrets.yaml' + +# Update secrets keys after adding new age keys +./scripts/update-secrets-keys + +# Format Nix files +nix run nixpkgs#nixpkgs-fmt . + +# Compute hash for new packages +nix hash to-sri --type sha256 $(nix-prefetch-url https://example.com/file.tar.gz) +``` + +## Architecture + +### Host Structure +Each host in `hosts//` contains: +- `configuration.nix` - Main entry point importing modules +- `hardware-configuration.nix` - Machine-specific hardware config +- `secrets.yaml` - SOPS-encrypted secrets +- `modules/` - Host-specific service configurations +- `fleet.nix` → symlink to root `fleet.nix` (SFTP user provisioning) +- `utils/` → symlink to root `utils/` (shared modules) + +Current hosts: `fw` (firewall/router), `nb` (notebook), `web-arm`, `mail`, `amzebs-01` + +### Shared Components (`utils/`) +- `modules/` - Reusable NixOS modules (nginx, sops, borgbackup, lego, promtail, etc.) +- `overlays/` - Nixpkgs overlays +- `pkgs/` - Custom package derivations +- `bento.nix` - Deployment helper module + +### Secrets Management +- SOPS with age encryption; keys defined in `.sops.yaml` +- Each host has its own age key derived from SSH host key +- Host secrets in `hosts//secrets.yaml` +- Shared module secrets in `utils/modules//secrets.yaml` + +**IMPORTANT: Never modify secrets files directly.** Instead, tell the user which secrets need to be added and where, so they can edit the encrypted files themselves using: +```bash +nix-shell -p sops --run 'sops hosts//secrets.yaml' +``` + +### Deployment +The Git runner handles deployment automatically when changes merge to main. A successful `./scripts/test-configuration ` dry-build is the gate before pushing. + +## Custom Packages + +When creating a new package in `utils/pkgs/`, always include an `update.sh` script to automate version updates. See `utils/pkgs/claude-code/update.sh` for the pattern: + +1. Fetch latest version from upstream (npm, GitHub, etc.) +2. Update version string in `default.nix` +3. Update source hash using `nix-prefetch-url` +4. Update dependency hashes (e.g., `npmDepsHash`) by triggering a build with a fake hash +5. Verify the final build succeeds + +Example structure: +``` +utils/pkgs// +├── default.nix +├── update.sh # Always include this +└── (other files like patches, lock files) +``` + +## Workflow + +**IMPORTANT: Always run `./scripts/test-configuration ` after making any changes** to verify the NixOS configuration builds successfully. This is required before committing. + +## Conventions + +- Nix files: two-space indentation, lower kebab-case naming +- Commits: Conventional Commits format (`fix:`, `feat:`, `chore:`), scope by host when relevant (`fix(mail):`) +- Modules import via explicit paths, not wildcards +- Comments explain non-obvious decisions (open ports, unusual service options) diff --git a/README.md b/README.md index f5be6f7..288cbe2 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ cat ~/.ssh/id_rsa.pub | ssh -p23 u149513-subx@u149513-subx.your-backup.de instal # 4. Add new Host ```console -sftp host.cloonar.com@git.cloonar.com:/config/bootstrap.sh ./ +sftp host@git.cloonar.com:/config/bootstrap.sh ./ ``` # 5. Yubikey diff --git a/fleet.nix b/fleet.nix index bc49e64..e8a8ab5 100644 --- a/fleet.nix +++ b/fleet.nix @@ -47,6 +47,11 @@ username = "gpd-win4"; key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILjfS2DtS8PQgkf86dU+EVu5t+r/QlCWmY7+RPYprQrO"; } + { + username = "nas"; + key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICS6b97LPUpr7/kWvOcI40s5e+gfbfz0I2/hAPL6zTmU"; + } + { username = "amzebs-01"; key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMkFZ60SPl8pzEtGrFq1+n6ZkDuNe3xJaccJMjr3y/q"; diff --git a/hosts/fw/configuration.nix b/hosts/fw/configuration.nix index be2f706..543f492 100644 --- a/hosts/fw/configuration.nix +++ b/hosts/fw/configuration.nix @@ -49,7 +49,7 @@ ./modules/firefox-sync.nix ./modules/fivefilters.nix - ./modules/pyload + # ./modules/pyload # home assistant ./modules/home-assistant diff --git a/hosts/fw/modules/dnsmasq.nix b/hosts/fw/modules/dnsmasq.nix index c323f0b..d2884e3 100644 --- a/hosts/fw/modules/dnsmasq.nix +++ b/hosts/fw/modules/dnsmasq.nix @@ -73,6 +73,7 @@ "02:00:00:00:00:04,${config.networkPrefix}.97.6,matrix" "ea:db:d4:c1:18:ba,${config.networkPrefix}.97.50,git" "c2:4f:64:dd:13:0c,${config.networkPrefix}.97.20,home-assistant" + "6c:1f:f7:8e:a9:86,${config.networkPrefix}.97.11,nas" "1a:c4:04:6e:29:02,${config.networkPrefix}.101.25,deconz" "c4:a7:2b:c7:ea:30,${config.networkPrefix}.99.10,metz" diff --git a/hosts/fw/modules/web/proxies.nix b/hosts/fw/modules/web/proxies.nix index 5991ea5..5e62a11 100644 --- a/hosts/fw/modules/web/proxies.nix +++ b/hosts/fw/modules/web/proxies.nix @@ -41,6 +41,7 @@ # Restrict to internal LAN only extraConfig = '' allow ${config.networkPrefix}.96.0/24; + allow ${config.networkPrefix}.97.0/24; allow ${config.networkPrefix}.98.0/24; deny all; ''; @@ -59,6 +60,7 @@ # Restrict to internal LAN only extraConfig = '' allow ${config.networkPrefix}.96.0/24; + allow ${config.networkPrefix}.97.0/24; allow ${config.networkPrefix}.98.0/24; allow ${config.networkPrefix}.99.0/24; deny all; diff --git a/hosts/nas/configuration.nix b/hosts/nas/configuration.nix new file mode 100644 index 0000000..d07c2e5 --- /dev/null +++ b/hosts/nas/configuration.nix @@ -0,0 +1,87 @@ +# NAS host configuration +{ config, lib, pkgs, ... }: +let + impermanence = builtins.fetchTarball "https://github.com/nix-community/impermanence/archive/master.tar.gz"; +in { + nixpkgs.config.allowUnfree = true; + + imports = [ + "${impermanence}/nixos.nix" + ./utils/bento.nix + ./utils/modules/sops.nix + + ./modules/pyload.nix + ./modules/jellyfin.nix + + ./hardware-configuration.nix + ]; + + nixpkgs.overlays = [ + (import ./utils/overlays/packages.nix) + ]; + + # Hostname + networking.hostName = "nas"; + + # Timezone + time.timeZone = "Europe/Vienna"; + console.keyMap = "de"; + + # SSH server + services.openssh.enable = true; + users.users.root.openssh.authorizedKeys.keys = [ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDN/2SAFm50kraB1fepAizox/QRXxB7WbqVbH+5OPalDT47VIJGNKOKhixQoqhABHxEoLxdf/C83wxlCVlPV9poLfDgVkA3Lyt5r3tSFQ6QjjOJAgchWamMsxxyGBedhKvhiEzcr/Lxytnoz3kjDG8fqQJwEpdqMmJoMUfyL2Rqp16u+FQ7d5aJtwO8EUqovhMaNO7rggjPpV/uMOg+tBxxmscliN7DLuP4EMTA/FwXVzcFNbOx3K9BdpMRAaSJt4SWcJO2cS2KHA5n/H+PQI7nz5KN3Yr/upJN5fROhi/SHvK39QOx12Pv7FCuWlc+oR68vLaoCKYhnkl3DnCfc7A7" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRQuPqH5fdX3KEw7DXzWEdO3AlUn1oSmtJtHB71ICoH Generated By Termius" + ]; + + # Firewall + networking.firewall.enable = true; + networking.firewall.allowedTCPPorts = [ 22 ]; + + # SOPS configuration + sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; + sops.defaultSopsFile = ./secrets.yaml; + + # Btrfs maintenance + services.btrfs.autoScrub = { + enable = true; + interval = "monthly"; + fileSystems = [ "/nix" ]; + }; + + # Impermanence - persist important directories + # Note: /var/lib/downloads and /var/lib/multimedia are mounted from LVM on RAID + environment.persistence."/nix/persist/system" = { + hideMounts = true; + directories = [ + "/var/lib/pyload" + "/var/lib/jellyfin" + "/var/log" + "/var/lib/nixos" + "/var/bento" + "/root/.ssh" + ]; + files = [ + "/etc/machine-id" + { file = "/etc/ssh/ssh_host_ed25519_key"; parentDirectory = { mode = "u=rwx,g=,o="; }; } + { file = "/etc/ssh/ssh_host_ed25519_key.pub"; parentDirectory = { mode = "u=rwx,g=,o="; }; } + { file = "/etc/ssh/ssh_host_rsa_key"; parentDirectory = { mode = "u=rwx,g=,o="; }; } + { file = "/etc/ssh/ssh_host_rsa_key.pub"; parentDirectory = { mode = "u=rwx,g=,o="; }; } + ]; + }; + + # Nix settings + nix = { + settings = { + auto-optimise-store = true; + experimental-features = [ "nix-command" "flakes" ]; + }; + gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 14d"; + }; + }; + + system.stateVersion = "24.05"; +} diff --git a/hosts/nas/fleet.nix b/hosts/nas/fleet.nix new file mode 120000 index 0000000..5b16de1 --- /dev/null +++ b/hosts/nas/fleet.nix @@ -0,0 +1 @@ +../../fleet.nix \ No newline at end of file diff --git a/hosts/nas/hardware-configuration.nix b/hosts/nas/hardware-configuration.nix new file mode 100644 index 0000000..a3c3edd --- /dev/null +++ b/hosts/nas/hardware-configuration.nix @@ -0,0 +1,92 @@ +# Hardware configuration for NAS +# TODO: Update disk labels/UUIDs after installation +{ config, lib, pkgs, modulesPath, ... }: +{ + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; + + boot.loader.systemd-boot = { + enable = true; + configurationLimit = 5; + }; + boot.loader.efi.canTouchEfiVariables = true; + boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "nvme" "usb_storage" "sd_mod" ]; + boot.initrd.kernelModules = [ "dm-snapshot" ]; + boot.kernelModules = [ "kvm-intel" ]; + boot.extraModulePackages = [ ]; + + # RAID 1 array for data storage + boot.swraid = { + enable = true; + mdadmConf = '' + DEVICE /dev/disk/by-id/ata-ST18000NM000J-2TV103_ZR52TBSB-part1 + DEVICE /dev/disk/by-id/ata-ST18000NM000J-2TV103_ZR52V9QX-part1 + ''; + }; + + # Tmpfs root filesystem (ephemeral - resets on reboot) + fileSystems."/" = { + device = "none"; + fsType = "tmpfs"; + options = [ "size=8G" "mode=755" ]; + }; + + # Boot partition - EFI + fileSystems."/boot" = { + device = "/dev/disk/by-partlabel/BOOT"; + fsType = "vfat"; + }; + + fileSystems."/nix" = { + device = "/dev/disk/by-partlabel/NIXOS"; + fsType = "btrfs"; + neededForBoot = true; + options = [ + "subvol=@" + "compress=zstd:1" + "noatime" + "commit=120" + ]; + }; + + fileSystems."/nix/store" = { + device = "/dev/disk/by-partlabel/NIXOS"; + fsType = "btrfs"; + neededForBoot = true; + options = [ + "subvol=@nix-store" + "compress=zstd:1" + "noatime" + "commit=120" + ]; + }; + + fileSystems."/nix/persist" = { + device = "/dev/disk/by-partlabel/NIXOS"; + fsType = "btrfs"; + neededForBoot = true; + options = [ + "subvol=@nix-persist" + "compress=zstd:1" + "noatime" + "commit=120" + ]; + }; + + # LVM volumes on RAID array + fileSystems."/var/lib/downloads" = { + device = "/dev/vg-data/lv-downloads"; + fsType = "xfs"; + }; + + fileSystems."/var/lib/multimedia" = { + device = "/dev/vg-data/lv-multimedia"; + fsType = "xfs"; + }; + + # DHCP networking + networking.useDHCP = lib.mkDefault true; + + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; +} diff --git a/hosts/nas/modules/filebot-process.nix b/hosts/nas/modules/filebot-process.nix new file mode 100644 index 0000000..dd818e9 --- /dev/null +++ b/hosts/nas/modules/filebot-process.nix @@ -0,0 +1,89 @@ +{ pkgs }: + +pkgs.writeShellScriptBin "filebot-process" '' + #!/usr/bin/env bash + set -euo pipefail + + # FileBot AMC script for automated media organization + # Called by PyLoad's package_extracted hook with parameters: + # $1 = package_id + # $2 = package_name + # $3 = download_folder (actual path to extracted files) + # $4 = password (optional) + + PACKAGE_ID="''${1:-}" + PACKAGE_NAME="''${2:-unknown}" + DOWNLOAD_DIR="''${3:-/var/lib/downloads}" + PASSWORD="''${4:-}" + + OUTPUT_DIR="/var/lib/multimedia" + LOG_FILE="/var/lib/pyload/filebot-amc.log" + EXCLUDE_LIST="/var/lib/pyload/filebot-exclude-list.txt" + + # Ensure FileBot data directory exists + mkdir -p /var/lib/pyload/.local/share/filebot/data + mkdir -p "$(dirname "$LOG_FILE")" + touch "$EXCLUDE_LIST" + + # Install FileBot license if not already installed + if [ ! -f /var/lib/pyload/.local/share/filebot/data/.license ]; then + echo "$(date): Installing FileBot license..." >> "$LOG_FILE" + ${pkgs.filebot}/bin/filebot --license /var/lib/pyload/filebot-license.psm || true + fi + + echo "===========================================" >> "$LOG_FILE" + echo "$(date): PyLoad package extracted hook triggered" >> "$LOG_FILE" + echo "Package ID: $PACKAGE_ID" >> "$LOG_FILE" + echo "Package Name: $PACKAGE_NAME" >> "$LOG_FILE" + echo "Download Directory: $DOWNLOAD_DIR" >> "$LOG_FILE" + echo "===========================================" >> "$LOG_FILE" + + # Check if download directory exists and has media files + if [ ! -d "$DOWNLOAD_DIR" ]; then + echo "$(date): Download directory does not exist: $DOWNLOAD_DIR" >> "$LOG_FILE" + exit 0 + fi + + # Check if there are any video/media files to process + if ! find "$DOWNLOAD_DIR" -type f \( -iname "*.mkv" -o -iname "*.mp4" -o -iname "*.avi" -o -iname "*.m4v" -o -iname "*.mov" \) -print -quit | grep -q .; then + echo "$(date): No media files found in: $DOWNLOAD_DIR" >> "$LOG_FILE" + echo "$(date): Skipping FileBot processing" >> "$LOG_FILE" + exit 0 + fi + + echo "$(date): Starting FileBot processing" >> "$LOG_FILE" + + # Run FileBot AMC script + set +e # Temporarily disable exit on error to capture exit code + ${pkgs.filebot}/bin/filebot \ + -script fn:amc \ + --output "$OUTPUT_DIR" \ + --action move \ + --conflict auto \ + -non-strict \ + --log-file "$LOG_FILE" \ + --def \ + excludeList="$EXCLUDE_LIST" \ + movieFormat="$OUTPUT_DIR/movies/{n} ({y})/{n} ({y}) - {vf}" \ + seriesFormat="$OUTPUT_DIR/tv-shows/{n}/Season {s.pad(2)}/{n} - {s00e00} - {t}" \ + ut_dir="$DOWNLOAD_DIR" \ + ut_kind=multi \ + clean=y \ + skipExtract=y + + FILEBOT_EXIT_CODE=$? + set -e # Re-enable exit on error + + if [ $FILEBOT_EXIT_CODE -ne 0 ]; then + echo "$(date): FileBot processing failed with exit code $FILEBOT_EXIT_CODE" >> "$LOG_FILE" + exit 0 # Don't fail the hook even if FileBot fails + fi + + echo "$(date): FileBot processing completed successfully" >> "$LOG_FILE" + + # Clean up any remaining empty directories + find "$DOWNLOAD_DIR" -type d -empty -delete 2>/dev/null || true + + echo "$(date): All processing completed" >> "$LOG_FILE" + exit 0 +'' diff --git a/hosts/nas/modules/jellyfin.nix b/hosts/nas/modules/jellyfin.nix new file mode 100644 index 0000000..5243800 --- /dev/null +++ b/hosts/nas/modules/jellyfin.nix @@ -0,0 +1,51 @@ +{ lib, pkgs, ... }: { + # Intel graphics support for hardware transcoding + hardware.graphics = { + enable = true; + extraPackages = with pkgs; [ + intel-media-driver + vpl-gpu-rt + intel-compute-runtime + ]; + }; + + # Set VA-API driver to iHD (modern Intel driver) + environment.sessionVariables = { + LIBVA_DRIVER_NAME = "iHD"; + }; + + # Jellyfin user with render/video groups for GPU access + users.users.jellyfin = { + isSystemUser = true; + group = "jellyfin"; + home = "/var/lib/jellyfin"; + createHome = true; + extraGroups = [ "render" "video" ]; + }; + users.groups.jellyfin = {}; + + # Create jellyfin directory + systemd.tmpfiles.rules = [ + "d /var/lib/jellyfin 0755 jellyfin jellyfin - -" + ]; + + services.jellyfin = { + enable = true; + openFirewall = true; + }; + + # Override systemd hardening for GPU access + systemd.services.jellyfin = { + serviceConfig = { + PrivateUsers = lib.mkForce false; # Disable user namespacing - breaks GPU device access + DeviceAllow = [ + "/dev/dri/card0 rw" + "/dev/dri/renderD128 rw" + ]; + SupplementaryGroups = [ "render" "video" ]; # Critical: Explicit group membership for GPU access + }; + environment = { + LIBVA_DRIVER_NAME = "iHD"; # Ensure service sees this variable + }; + }; +} diff --git a/hosts/nas/modules/pyload.nix b/hosts/nas/modules/pyload.nix new file mode 100644 index 0000000..ecb44b0 --- /dev/null +++ b/hosts/nas/modules/pyload.nix @@ -0,0 +1,104 @@ +{ config, pkgs, lib, ... }: +let + filebotScript = pkgs.callPackage ./filebot-process.nix {}; +in +{ + nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ + "unrar" + "filebot" + ]; + + environment.systemPackages = with pkgs; [ + unrar # Required for RAR archive extraction + p7zip # Required for 7z and other archive formats + ]; + + # Create directory structure + systemd.tmpfiles.rules = [ + "d /var/lib/downloads 0755 pyload pyload - -" + "d /var/lib/multimedia 0775 root jellyfin - -" + "d /var/lib/multimedia/movies 0775 jellyfin jellyfin - -" + "d /var/lib/multimedia/tv-shows 0775 jellyfin jellyfin - -" + "d /var/lib/multimedia/music 0755 jellyfin jellyfin - -" + + # PyLoad hook scripts directory + "d /var/lib/pyload/config 0755 pyload pyload - -" + "d /var/lib/pyload/config/scripts 0755 pyload pyload - -" + "d /var/lib/pyload/config/scripts/package_extracted 0755 pyload pyload - -" + "L+ /var/lib/pyload/config/scripts/package_extracted/filebot-process.sh - - - - ${filebotScript}/bin/filebot-process" + ]; + + # FileBot license secret (only if secrets.yaml exists) + sops.secrets.filebot-license = { + mode = "0440"; + owner = "pyload"; + group = "pyload"; + path = "/var/lib/pyload/filebot-license.psm"; + }; + + # PyLoad user with jellyfin group membership for multimedia access + users.users.pyload = { + isSystemUser = true; + group = "pyload"; + home = "/var/lib/pyload"; + createHome = true; + extraGroups = [ "jellyfin" ]; + }; + users.groups.pyload = {}; + + services.pyload = { + enable = true; + downloadDirectory = "/var/lib/downloads"; + listenAddress = "0.0.0.0"; + port = 8000; + }; + + # Configure pyload service + systemd.services.pyload = { + # Add extraction tools to service PATH + path = with pkgs; [ + unrar # For RAR extraction + p7zip # For 7z extraction + ]; + + environment = { + # Disable SSL certificate verification + PYLOAD__GENERAL__SSL_VERIFY = "0"; + + # Download speed limiting (150 Mbit/s = 19200 KiB/s) + PYLOAD__DOWNLOAD__LIMIT_SPEED = "1"; + PYLOAD__DOWNLOAD__MAX_SPEED = "19200"; + + # Enable ExtractArchive plugin + PYLOAD__EXTRACTARCHIVE__ENABLED = "1"; + PYLOAD__EXTRACTARCHIVE__DELETE = "1"; + PYLOAD__EXTRACTARCHIVE__DELTOTRASH = "0"; + PYLOAD__EXTRACTARCHIVE__REPAIR = "1"; + PYLOAD__EXTRACTARCHIVE__RECURSIVE = "1"; + PYLOAD__EXTRACTARCHIVE__FULLPATH = "1"; + + # Enable ExternalScripts plugin for hooks + PYLOAD__EXTERNALSCRIPTS__ENABLED = "1"; + PYLOAD__EXTERNALSCRIPTS__UNLOCK = "1"; # Run hooks asynchronously + }; + + serviceConfig = { + # Bind mount multimedia directory as writable for FileBot hook scripts + BindPaths = [ "/var/lib/multimedia" ]; + + # Override SystemCallFilter to allow @resources syscalls + # FileBot (Java) needs resource management syscalls like setpriority + # during cleanup operations. Still block privileged syscalls for security. + SystemCallFilter = lib.mkForce [ + "@system-service" + "@resources" # Explicitly allow resource management syscalls + "~@privileged" # Still block privileged operations + "fchown" # Re-allow fchown for FileBot file operations + "fchown32" # 32-bit compatibility + ]; + }; + }; + + # Open firewall for PyLoad web interface + networking.firewall.allowedTCPPorts = [ 8000 ]; +} diff --git a/hosts/nas/secrets.yaml b/hosts/nas/secrets.yaml new file mode 100644 index 0000000..3d36fe8 --- /dev/null +++ b/hosts/nas/secrets.yaml @@ -0,0 +1,43 @@ +filebot-license: ENC[AES256_GCM,data:7fmczgcLOp/tkdoMVGfwESam9NwhZrNJXNcnebMKfilEKc81V4fdzS3/hxlueun2At6047UAdDi+6jAO3FvTmzmpmOFnrQxhojUKX2kj9SMSuDlskqxNt04O8wpGy69T8uOeV3ZUcRASzmfzQmuVU+zPKoIV2nq34mhXeIl17V22ZvoTDtFLs2w3IzxtKMZqLtDH4GZ4dkRcQBFcGY96T9R4CLcBqyZeBYb15ZaVLN9dHpQfExtpsBMVB3NikdWgUK9IAbjff1Xtkn+RT9gpQggCyQUl6UbtGqienbCq8ATORe1f89s+q6x/KneC5bdVZKQ4IqZrzDcuQ5aEJUEslc3aVITI3THEBWLOhYANy/4mMyw7R2KRIiHLrXAiiuxi3JumBvev27S6ilKdNo2By8OsbAB/Oj2YvDc5EBTePLCU0OloHIMSxWv5VkLQ2rM94bYtqXN269Lv6w4zmpOF0QblrvtoXnhhf4BQyf9owRDuNMrbp3Iu9Xnj87GoQVnulpwsMSifFxIibd94sRxS/N/5JTTih3Twj/Y8ZpmvoR8t9vCJ14vwBbMXheBpIYzxTk1phZKK1jLfi49DpWudop6iKNOCZdMeXxVRyAfTpOZSGqmdmsqOEDZESCnOAcNYlTxXmLmTdeqJfvTzsnWUi6kbbMYzaSaUlG7UB+RYnt97mcT5NtmbwCRDuFEOdqKGiR/vi7natH8VXe6nlY7xZxei0kkKAhIwyXV+mj/xurbQP3SuyYZriO2luCfuazxmVR0FpBqv+UZ6LwldmQ5e+O7hveYd+o1IjqjT67pVgLyFo1XY95Phq4/vFNvcZKhP7dqKlddkO5RCqjxHCmLowPSUfO2G4k7NV9/UioudATZnWGBpMfSp0pSuD9GXrPg=,iv:5BP77BRudjLiIKI5973BWbQlftupAdfd/aqFeN7DYLM=,tag:q2CAzGZ8lXPS41Uf2NjJTg==,type:str] +sops: + age: + - recipient: age14grjcxaq4h55yfnjxvnqhtswxhj9sfdcvyas4lwvpa8py27pjy2sv3g6v7 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQWUI4YUsvODJtdTN4NDNn + QkFDVmFSV2Q1Q04rcWtiazZEMzFCYzRJMEJrCjM5RE41TjE0eURrNi9iQnBTR1Fy + SENYYmloSjI1c25pck5CSTJZTDhCeTQKLS0tIEs0SnFSNUdsdzZWS0loTEdBN1RD + ZnhBREtlR3o4VTVMZ1RtY1lVbG40YkUK2isPCoJSTQ6CUbHftSDoUZC8MMTqr512 + lCoeGQqnArTO8CWDJxIxRczooTo4mW7vDqD7idWdPgOdWZI8hWPE5Q== + -----END AGE ENCRYPTED FILE----- + - recipient: age1exny8unxynaw03yu8ppahu5z28uermghr8ag34e7kdqnaduq9stsyettzz + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwdnJIeWh1RkhvLzF2QUlm + ZmNHaFpzL0M3eUdCdk1GL1g2MTdtdTdjVmdjCmVRTWJsajhKT0E5STN2SEUzWHFa + ak1NelloQnNiY3FaUm9oVGg5eit2eTAKLS0tIEoxcURjUkJsRENtblZpKy9QT0gx + ME5kM1EwYUFNMVFkT3VWZmpGSzRoWFUKzGNK5FzRWiY+E1Je6l0veoN5Z3K2TFMY + pm9+FGuYs+wxSrhLwajITj+NuH0+zK81mrYsugH+6OTNb7cDbLgh/g== + -----END AGE ENCRYPTED FILE----- + - recipient: age1v6p8dan2t3w9h94fz4flldl32082j3s9x6zqq7u5j66keth9aphsd6pvch + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlWCs0a3EvZC9sWmI5S2tU + aFpRMVlrZG1zL1ZJSklJMkNQY1RLMW53YUhZCjNpQ1pHUE0yVUxleVovcVRLMFNh + amVFTnJteW8xRjlFY29HWkJrcVJiQzgKLS0tIFJrWWloc2ZWdGdPNlNQM2szTkZI + Zk42dFgrcUJOa3UwSDB3MnpMcVRLdmsKOKbF18HnowVhiEHO2B+BZqpM8Oc8vbDh + hczIpcezwMvv96L2/seX86Hv5mEAQvwN2CVA+sknnDL1XNA/2Ng9cw== + -----END AGE ENCRYPTED FILE----- + - recipient: age1x3elhtccp4u8ha5ry32juj9fkpg0qg7qqx4gduuehgwwnnhcxp8s892hek + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6L1pVc2lRYVVnMU1uR1hn + aldPODkvTVFzNDRuWWFTTU5jU0dyOHFMNkZBCnQ0ZGxUcGR5d0FqK2pOenJzSEN4 + ak12VXhQSnZlbSs1V3BxZnBIQ0xKV1EKLS0tIEkrc00wTzJzVjVDd0o4WHNQVDV6 + WGlpR1kvdXFnMkxOQVVuL3pIckdLRGcK+xoZE63l+9mlR5ufN9kEtgKEHdIUcGbI + CpNhd8RE23tPKaVa0XbQA3bMqc1J9jST3vSWWewexwdLvfjrooSFZw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2025-11-28T18:05:54Z" + mac: ENC[AES256_GCM,data:rmGDt0ZvZ8S//X1sqzkM9GdsoLBTB9dUprWdVN5M9F4/Zpq6Mpyk04VdGxYz421Gi+AsvhAkBaKi+XJjiEjRf9dYON/N18bWeRe3mJMLVOOoxGz+PQOeAuCyphZEKsCkae79WtbRZqONkU+kSqT5ED6iLjhOpLn1h6Cuw4wV1Xc=,iv:XWjRyxlGP4a14eUaJvZpizy2UiCSIi/PIUyaZg6GCJY=,tag:ZNp/U1O3wkcb8o5s1USrsw==,type:str] + unencrypted_suffix: _unencrypted + version: 3.11.0 diff --git a/hosts/nas/utils b/hosts/nas/utils new file mode 120000 index 0000000..6b18391 --- /dev/null +++ b/hosts/nas/utils @@ -0,0 +1 @@ +../../utils \ No newline at end of file diff --git a/hosts/nb/modules/development/nvim/config/sops.lua b/hosts/nb/modules/development/nvim/config/sops.lua index e35d153..d11a388 100644 --- a/hosts/nb/modules/development/nvim/config/sops.lua +++ b/hosts/nb/modules/development/nvim/config/sops.lua @@ -44,7 +44,7 @@ vim.api.nvim_create_autocmd("BufReadPre", { pattern = secrets_patterns, callback = function(args) -- Set filetype to yaml before the file is read so syntax highlighting works - vim.bo.filetype = "yaml" + vim.bo[args.buf].filetype = "yaml" end, }) @@ -53,7 +53,7 @@ vim.api.nvim_create_autocmd("BufReadPost", { group = sops_group, pattern = secrets_patterns, callback = function(args) - local filepath = vim.fn.expand("%:p") + local filepath = vim.api.nvim_buf_get_name(args.buf) -- Only decrypt if file exists and has content if vim.fn.filereadable(filepath) == 1 and vim.fn.getfsize(filepath) > 0 then @@ -98,26 +98,27 @@ vim.api.nvim_create_autocmd("BufReadPost", { return end + -- Detach LSP clients BEFORE replacing buffer to prevent sync errors + detach_lsp_clients(args.buf) + -- Replace buffer content with decrypted content - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(result, "\n")) + -- Strip trailing newline to avoid adding extra empty line + vim.api.nvim_buf_set_lines(args.buf, 0, -1, false, vim.split(result:gsub("\n$", ""), "\n")) -- Mark buffer as not modified (since we just loaded it) - vim.bo.modified = false + vim.bo[args.buf].modified = false -- Restore cursor position pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) -- Disable swap, backup, and undo files for security - vim.bo.swapfile = false - vim.bo.backup = false - vim.bo.writebackup = false - vim.bo.undofile = false + vim.bo[args.buf].swapfile = false + vim.bo[args.buf].backup = false + vim.bo[args.buf].writebackup = false + vim.bo[args.buf].undofile = false -- Ensure filetype is set to yaml for syntax highlighting - vim.bo.filetype = "yaml" - - -- Detach LSP clients to prevent sync errors when buffer content is replaced - detach_lsp_clients(0) + vim.bo[args.buf].filetype = "yaml" vim.notify("SOPS: File decrypted successfully", vim.log.levels.INFO) else @@ -132,17 +133,22 @@ vim.api.nvim_create_autocmd("BufWriteCmd", { group = sops_group, pattern = secrets_patterns, callback = function(args) - local filepath = vim.fn.expand("%:p") + local filepath = vim.api.nvim_buf_get_name(args.buf) - if is_secrets_file(filepath) then - -- Guard against double-execution - if currently_saving[filepath] then - return - end - currently_saving[filepath] = true + if not is_secrets_file(filepath) then + return + end + -- Guard against double-execution + if currently_saving[filepath] then + return + end + currently_saving[filepath] = true + + -- Use pcall to ensure guard is always cleared, even on unexpected errors + local ok, err = pcall(function() -- Get current buffer content - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local lines = vim.api.nvim_buf_get_lines(args.buf, 0, -1, false) local content = table.concat(lines, "\n") -- Check buffer content size before encrypting @@ -153,8 +159,6 @@ vim.api.nvim_create_autocmd("BufWriteCmd", { string.format("SOPS: Buffer content too large (%sMB > %sMB limit). Cannot encrypt.", size_mb, limit_mb), vim.log.levels.ERROR ) - -- Don't write anything, leave buffer marked as modified - currently_saving[filepath] = nil return end @@ -162,22 +166,21 @@ vim.api.nvim_create_autocmd("BufWriteCmd", { -- This avoids /dev/stdin issues while keeping secrets secure (not in /tmp) local dir = vim.fn.fnamemodify(filepath, ":h") local filename = vim.fn.fnamemodify(filepath, ":t") - local temp_file = string.format("%s/.%s.sops_tmp_%d", dir, filename, os.time()) + local temp_file = string.format("%s/.%s.sops_tmp_%d_%d", dir, filename, os.time(), vim.loop.hrtime() % 1000000) -- Write plaintext content to temp file local temp_f, temp_err = io.open(temp_file, "w") if not temp_f then vim.notify("SOPS: Failed to create temp file: " .. (temp_err or "unknown error"), vim.log.levels.ERROR) - -- Don't write anything, leave buffer marked as modified - currently_saving[filepath] = nil return end - temp_f:write(content) + temp_f:write(content .. "\n") temp_f:close() -- Encrypt temp file with filename override so SOPS matches .sops.yaml rules -- Uses real filepath for rule matching, temp file for content - local cmd = string.format("sops --encrypt --filename-override %s %s", + local cmd = string.format("timeout %d sops --encrypt --filename-override %s %s", + SOPS_TIMEOUT, vim.fn.shellescape(filepath), vim.fn.shellescape(temp_file)) local encrypted = vim.fn.system(cmd) @@ -186,16 +189,25 @@ vim.api.nvim_create_autocmd("BufWriteCmd", { -- Always clean up temp file, even on error os.remove(temp_file) + -- Check for timeout (exit code 124 from timeout command) + if sops_exit_code == 124 then + vim.notify( + string.format("SOPS: Encryption timed out after %d seconds.", SOPS_TIMEOUT), + vim.log.levels.ERROR + ) + return + end + if sops_exit_code == 0 then -- Write encrypted content directly to file - local file, err = io.open(filepath, "w") + local file, file_err = io.open(filepath, "w") if file then local success, write_err = file:write(encrypted) file:close() if success then -- Mark buffer as saved - vim.bo.modified = false + vim.bo[args.buf].modified = false vim.notify("SOPS: File encrypted and saved successfully", vim.log.levels.INFO) -- Re-decrypt to show plaintext in buffer @@ -207,37 +219,38 @@ vim.api.nvim_create_autocmd("BufWriteCmd", { -- Save cursor position local cursor_pos = vim.api.nvim_win_get_cursor(0) + -- Detach LSP clients BEFORE replacing buffer to prevent sync errors + detach_lsp_clients(args.buf) + -- Replace buffer with decrypted content - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(decrypted, "\n")) + -- Strip trailing newline to avoid adding extra empty line + vim.api.nvim_buf_set_lines(args.buf, 0, -1, false, vim.split(decrypted:gsub("\n$", ""), "\n")) -- Mark as not modified since we just saved - vim.bo.modified = false + vim.bo[args.buf].modified = false -- Restore cursor position pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) - - -- Detach LSP clients to prevent sync errors when buffer content is replaced - detach_lsp_clients(0) else vim.notify("SOPS: Could not re-decrypt after save. Buffer may show encrypted content.", vim.log.levels.WARN) end - -- Clear guard after successful save - currently_saving[filepath] = nil else vim.notify("SOPS: Failed to write encrypted content: " .. (write_err or "unknown error"), vim.log.levels.ERROR) - -- Don't mark as saved, keep buffer marked as modified - currently_saving[filepath] = nil end else - vim.notify("SOPS: Failed to open file for writing: " .. (err or "unknown error"), vim.log.levels.ERROR) - -- Don't mark as saved, keep buffer marked as modified - currently_saving[filepath] = nil + vim.notify("SOPS: Failed to open file for writing: " .. (file_err or "unknown error"), vim.log.levels.ERROR) end else vim.notify("SOPS: Failed to encrypt file - NOT SAVED! Error: " .. encrypted, vim.log.levels.ERROR) - -- Don't write anything, leave buffer marked as modified - currently_saving[filepath] = nil end + end) + + -- Always clear guard, even if pcall caught an error + currently_saving[filepath] = nil + + -- Re-throw unexpected errors so they're visible + if not ok then + vim.notify("SOPS: Unexpected error during save: " .. tostring(err), vim.log.levels.ERROR) end end, }) @@ -247,7 +260,7 @@ vim.api.nvim_create_autocmd("BufLeave", { group = sops_group, pattern = secrets_patterns, callback = function(args) - if vim.bo.modified then + if vim.bo[args.buf].modified then vim.notify("Warning: Unsaved changes in secrets file!", vim.log.levels.WARN) end end,