diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33321da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Least-privilege by default; jobs opt into what they need. +permissions: {} + +# Cancel superseded runs on the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + check: + name: Flake check (${{ matrix.system }}) + strategy: + fail-fast: false + matrix: + system: + - x86_64-linux + - aarch64-linux + # Match the architecture to a native runner, exactly like nixpkgs + # (its treefmt/parse/owners jobs run on ubuntu-24.04-arm). + runs-on: ${{ matrix.system == 'aarch64-linux' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + # Evaluate the flake for this system (incl. nixosConfigurations) without + # building. Catches the class of break we hit with the duplicate module block. + - name: Flake check (${{ matrix.system }}) + run: nix flake check --no-build --system ${{ matrix.system }} + + format: + name: Formatting check (${{ matrix.system }}) + strategy: + fail-fast: false + matrix: + system: + - x86_64-linux + - aarch64-linux + runs-on: ${{ matrix.system == 'aarch64-linux' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + # alejandra is the configured formatter (flake.nix perSystem.formatter). + # Checked per-system so both architectures stay formatted. + - name: Formatting check (${{ matrix.system }}) + run: nix run .#formatter.${{ matrix.system }} -- --check . diff --git a/flake.lock b/flake.lock index 4501106..f36b4f8 100644 --- a/flake.lock +++ b/flake.lock @@ -47,6 +47,26 @@ "type": "github" } }, + "disko": { + "inputs": { + "nixpkgs": [ + "nixos-24-05" + ] + }, + "locked": { + "lastModified": 1781152676, + "narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=", + "owner": "nix-community", + "repo": "disko", + "rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "disko", + "type": "github" + } + }, "flake-compat": { "locked": { "lastModified": 1767039857, @@ -179,6 +199,22 @@ "type": "github" } }, + "nixos-24-05": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1777268161, @@ -195,20 +231,60 @@ "type": "github" } }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1783522502, + "narHash": "sha256-iffAls3iaNTyJC2faYcUXSI+Gp02cDjYl+MygxKl2GI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0bb7ec54c8483066ec9d7720e780a5caa71f8612", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "agenix": "agenix", + "disko": "disko", "flake-compat": "flake-compat", "flake-parts": "flake-parts", "flake-utils": "flake-utils", "helium": "helium", "import-tree": "import-tree", "nix-index-db": "nix-index-db", + "nixos-24-05": "nixos-24-05", "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable", + "sops-nix": "sops-nix", "systems": "systems_2", "zen-browser": "zen-browser" } }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixos-24-05" + ] + }, + "locked": { + "lastModified": 1783174389, + "narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index 3e8ec10..6fc768e 100644 --- a/flake.nix +++ b/flake.nix @@ -67,5 +67,25 @@ url = "github:Mic92/nix-index-database"; inputs.nixpkgs.follows = "nixpkgs"; }; + + # --- Server (hetzner) inputs --- + # Pinned to 24.05 to match the deployed Hetzner server exactly. + nixos-24-05 = { + url = "github:NixOS/nixpkgs/nixos-24.05"; + }; + + nixpkgs-unstable = { + url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + disko = { + url = "github:nix-community/disko"; + inputs.nixpkgs.follows = "nixos-24-05"; + }; + + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixos-24-05"; + }; }; } diff --git a/home/editors/nvim/default.nix b/home/editors/nvim/default.nix index 7892ec3..82da455 100644 --- a/home/editors/nvim/default.nix +++ b/home/editors/nvim/default.nix @@ -1,9 +1,11 @@ -{ config, lib, pkgs, ... }: - -let - username = "someone"; -in { + config, + lib, + pkgs, + ... +}: let + username = "someone"; +in { users.users.${username}.packages = with pkgs; [ neovim ]; diff --git a/home/packages/gtk.nix b/home/packages/gtk.nix index 37fa235..fc9038b 100644 --- a/home/packages/gtk.nix +++ b/home/packages/gtk.nix @@ -8,16 +8,18 @@ gsettings-desktop-schemas ]; -programs.dconf.enable = true; + programs.dconf.enable = true; - programs.dconf.profiles.user.databases = [{ - settings = { - "org/gnome/desktop/interface" = { - color-scheme = "prefer-dark"; - gtk-application-prefer-dark-theme = true; + programs.dconf.profiles.user.databases = [ + { + settings = { + "org/gnome/desktop/interface" = { + color-scheme = "prefer-dark"; + gtk-application-prefer-dark-theme = true; + }; }; - }; - }]; + } + ]; environment.sessionVariables = { XDG_ICON_DIR = "${pkgs.whitesur-icon-theme}/share/icons/WhiteSur"; diff --git a/home/packages/media/mpv.nix b/home/packages/media/mpv.nix index 3c6250e..bfb1f0c 100644 --- a/home/packages/media/mpv.nix +++ b/home/packages/media/mpv.nix @@ -2,13 +2,10 @@ pkgs, lib, ... -}: - -let - configFile = "mpv/mpv.conf"; +}: let + configFile = "mpv/mpv.conf"; mpvInputFile = "mpv/input.conf"; -in -{ +in { users.users.someone.packages = with pkgs; [ mpv mpvScripts.mpris @@ -16,12 +13,11 @@ in xdg.configFile."${configFile}".text = lib.generators.toKeyValue { - mkKeyValue = k: v: - "${lib.escapeShellArg k}=${lib.escapeShellArg v}"; + mkKeyValue = k: v: "${lib.escapeShellArg k}=${lib.escapeShellArg v}"; listsAsDuplicateKeys = true; } { profile = "gpu-hq"; - volume = "100"; + volume = "100"; }; xdg.configFile."${mpvInputFile}".text = '' diff --git a/home/packages/media/rnnoise.nix b/home/packages/media/rnnoise.nix index 3cb6850..c03d3ac 100644 --- a/home/packages/media/rnnoise.nix +++ b/home/packages/media/rnnoise.nix @@ -18,7 +18,7 @@ in { { "type" = "ladspa"; "name" = "rnnoise"; - "plugin" = "librnnoise_ladspa"; + "plugin" = "librnnoise_ladspa"; "label" = "noise_suppressor_stereo"; "control" = {"VAD Threshold (%)" = 50.0;}; } diff --git a/home/packages/wayland/niri/_binds.nix b/home/packages/wayland/niri/_binds.nix index d1f2c53..ef92015 100644 --- a/home/packages/wayland/niri/_binds.nix +++ b/home/packages/wayland/niri/_binds.nix @@ -71,5 +71,5 @@ "Mod+Shift+Up".move-column-to-workspace-up = {}; "Mod+Shift+Down".move-column-to-workspace-down = {}; "Mod+P" = {spawn-sh = "wl-mirror $(niri msg --json focused-output | jq -r .name)";}; -# "Mod+Alt".toggle-overview = { repeat=false; }; + # "Mod+Alt".toggle-overview = { repeat=false; }; } diff --git a/home/packages/wayland/niri/_rules.nix b/home/packages/wayland/niri/_rules.nix index 92c1394..67db126 100644 --- a/home/packages/wayland/niri/_rules.nix +++ b/home/packages/wayland/niri/_rules.nix @@ -3,7 +3,7 @@ { geometry-corner-radius._args = [8.0 8.0 12.0 12.0]; clip-to-geometry = true; - draw-border-with-background = false; + draw-border-with-background = false; } { match._props = {is-floating = true;}; diff --git a/home/packages/wayland/niri/_settings.nix b/home/packages/wayland/niri/_settings.nix index 436f667..901aab6 100644 --- a/home/packages/wayland/niri/_settings.nix +++ b/home/packages/wayland/niri/_settings.nix @@ -48,7 +48,7 @@ in { screenshot-path = "~/Pictures/Screenshots/Screenshot-from-%Y-%m-%d-%H-%M-%S.png"; - overview = { + overview = { workspace-shadow.off = {}; backdrop-color = "transparent"; }; diff --git a/home/services/wayland/ashell.nix b/home/services/wayland/ashell.nix index 72ca0cd..5ad88d6 100644 --- a/home/services/wayland/ashell.nix +++ b/home/services/wayland/ashell.nix @@ -7,40 +7,39 @@ users.users.someone.packages = [pkgs.ashell]; xdg.configFile."ashell/config.toml".text = '' -[modules] -left = [ [ "appLauncher", "Updates", "Workspaces", "ScreenMirror" ], "MediaPlayer" ] -center = [ "WindowTitle" ] -right = [ "SystemInfo", [ "Tray", "Clock", "Privacy", "Settings" ] ] + [modules] + left = [ [ "appLauncher", "Updates", "Workspaces", "ScreenMirror" ], "MediaPlayer" ] + center = [ "WindowTitle" ] + right = [ "SystemInfo", [ "Tray", "Clock", "Privacy", "Settings" ] ] -[system_info] -indicators = [ "Cpu", "Memory", "Temperature" ] + [system_info] + indicators = [ "Cpu", "Memory", "Temperature" ] -[system_info.cpu] -warn_threshold = 60 -alert_threshold = 80 + [system_info.cpu] + warn_threshold = 60 + alert_threshold = 80 -[system_info.memory] -warn_threshold = 70 -alert_threshold = 85 + [system_info.memory] + warn_threshold = 70 + alert_threshold = 85 -[system_info.temperature] -warn_threshold = 60 -alert_threshold = 80 + [system_info.temperature] + warn_threshold = 60 + alert_threshold = 80 -[tempo] -clock_format = "%a %d %b %R" -weather_location = { City = "Amsterdam" } + [tempo] + clock_format = "%a %d %b %R" + weather_location = { City = "Amsterdam" } -[appearance] -style = "Islands" + [appearance] + style = "Islands" -[[ScreenMirror]] -name = "ScreenMirror" -icon = "" -command = "bash /etc/nixos/scripts/screen-mirror.sh" + [[ScreenMirror]] + name = "ScreenMirror" + icon = "" + command = "bash /etc/nixos/scripts/screen-mirror.sh" ''; - } diff --git a/home/services/wayland/mako.nix b/home/services/wayland/mako.nix index f9f88c5..4b106e9 100644 --- a/home/services/wayland/mako.nix +++ b/home/services/wayland/mako.nix @@ -11,7 +11,7 @@ text-color=#FAB387 border-color=#ebdbb2 progress-color=#FAB387 - + font=SauceCodePro Nerd Font 10 width=300 height=100 @@ -23,13 +23,13 @@ max-icon-size=48 default-timeout=5000 group-by=category - + [urgency=low] border-color=#928374 - + [urgency=normal] border-color=#b8bb26 - + [urgency=critical] border-color=#fb4934 default-timeout=0 diff --git a/home/terminal/shell/fish.nix b/home/terminal/shell/fish.nix index 03054c3..aeeb824 100644 --- a/home/terminal/shell/fish.nix +++ b/home/terminal/shell/fish.nix @@ -67,61 +67,61 @@ }; "fish/functions/extract.fish" = { text = '' - function extract - if test -f "$argv[1]" - set file "$argv[1]" - set filename (basename "$file") - set dirname (string replace -r '\.tar\.bz2$|\.tar\.gz$|\.tbz2$|\.tgz$|\.tar$|\.bz2$|\.gz$|\.zip$|\.Z$|\.7z$|\.xz$|\.rar$' "" "$filename") - mkdir -p "$dirname" - switch $file - case "*.tar.bz2" - tar xjf "$file" -C "$dirname" - case "*.tar.gz" - tar xzf "$file" -C "$dirname" - case "*.tbz2" - tar xjf "$file" -C "$dirname" - case "*.tgz" - tar xzf "$file" -C "$dirname" - case "*.tar" - tar xf "$file" -C "$dirname" - case "*.bz2" - bunzip2 -c "$file" >"$dirname/$dirname" - case "*.gz" - gunzip -c "$file" >"$dirname/$dirname" - case "*.zip" - unzip "$file" -d "$dirname" - case "*.Z" - uncompress -c "$file" >"$dirname/$dirname" - case "*.7z" - 7z x "$file" -o"$dirname" - case "*.xz" - xz -dc "$file" >"$dirname/$dirname" - case "*.rar" - unrar x "$file" "$dirname" - case "*" - echo "'$file' cannot be extracted via extract()" - end - else - echo "'$argv[1]' is not a valid file" - end - end - ''; -}; + function extract + if test -f "$argv[1]" + set file "$argv[1]" + set filename (basename "$file") + set dirname (string replace -r '\.tar\.bz2$|\.tar\.gz$|\.tbz2$|\.tgz$|\.tar$|\.bz2$|\.gz$|\.zip$|\.Z$|\.7z$|\.xz$|\.rar$' "" "$filename") + mkdir -p "$dirname" + switch $file + case "*.tar.bz2" + tar xjf "$file" -C "$dirname" + case "*.tar.gz" + tar xzf "$file" -C "$dirname" + case "*.tbz2" + tar xjf "$file" -C "$dirname" + case "*.tgz" + tar xzf "$file" -C "$dirname" + case "*.tar" + tar xf "$file" -C "$dirname" + case "*.bz2" + bunzip2 -c "$file" >"$dirname/$dirname" + case "*.gz" + gunzip -c "$file" >"$dirname/$dirname" + case "*.zip" + unzip "$file" -d "$dirname" + case "*.Z" + uncompress -c "$file" >"$dirname/$dirname" + case "*.7z" + 7z x "$file" -o"$dirname" + case "*.xz" + xz -dc "$file" >"$dirname/$dirname" + case "*.rar" + unrar x "$file" "$dirname" + case "*" + echo "'$file' cannot be extracted via extract()" + end + else + echo "'$argv[1]' is not a valid file" + end + end + ''; + }; "fish/functions/weather.fish" = { text = '' - function weather - if test "$argv[1]" = week - wthrr -f w $argv[2] - else if test -z "$argv[1]" - wthrr -f d - wthrr -f t - else - wthrr -f d $argv[1] - wthrr -f t $argv[1] - end -end - ''; -}; + function weather + if test "$argv[1]" = week + wthrr -f w $argv[2] + else if test -z "$argv[1]" + wthrr -f d + wthrr -f t + else + wthrr -f d $argv[1] + wthrr -f t $argv[1] + end + end + ''; + }; "fish/functions/fcd.fish" = { text = '' function fcd @@ -168,44 +168,44 @@ end }; "fish/conf.d/aliases.fish" = { text = '' - alias cleanup="sudo nix-collect-garbage --delete-older-than 1d" - alias listgen="sudo nix-env -p /nix/var/nix/profiles/system --list-generations" - alias nixremove="nix-store --gc" - alias bloat="nix path-info -Sh /run/current-system" - alias cleanram="sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'" - alias trimall="sudo fstrim -va" - alias c="clear" - alias add="git add ." - alias commit="git commit" - alias push="git push" - alias pull="git pull" - alias diff="git diff --staged" - alias gcld="git clone --depth 1" - alias koji="meteor" - alias gitui="lazygit" - alias ls="eza -lah --grid -s modified --smart-group --group-directories-first --icons" - alias l="eza -ah --grid -s modified --smart-group --group-directories-first --icons" - alias tree="eza --tree --icons --tree" - alias cat="${pkgs.bat}/bin/bat --paging=never --theme gruvbox-dark" - alias us="systemctl --user" - alias rs="sudo systemctl" - alias zed="zeditor" - alias nl="nl -ba" - alias cp="cp -riv" - alias mv="mv -iv" - alias mkdir="mkdir -pv" - alias less="bat -p --theme gruvbox-dark" - alias myip="curl ip.severijnse.eu" - alias pscpu="ps -eo pid,ppid,cmd,%mem,%cpu,etime --sort=-%cpu | head -n 21" - alias psmem="ps -eo pid,ppid,cmd,%mem,%cpu,etime --sort=-%mem | head -n 21" - alias weer="weather" - alias v="nvim" - alias copy="wl-copy" - alias send="croc" - alias img="loupe" - alias cat-md="glow" - alias du="dust" - alias df="duf" + alias cleanup="sudo nix-collect-garbage --delete-older-than 1d" + alias listgen="sudo nix-env -p /nix/var/nix/profiles/system --list-generations" + alias nixremove="nix-store --gc" + alias bloat="nix path-info -Sh /run/current-system" + alias cleanram="sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'" + alias trimall="sudo fstrim -va" + alias c="clear" + alias add="git add ." + alias commit="git commit" + alias push="git push" + alias pull="git pull" + alias diff="git diff --staged" + alias gcld="git clone --depth 1" + alias koji="meteor" + alias gitui="lazygit" + alias ls="eza -lah --grid -s modified --smart-group --group-directories-first --icons" + alias l="eza -ah --grid -s modified --smart-group --group-directories-first --icons" + alias tree="eza --tree --icons --tree" + alias cat="${pkgs.bat}/bin/bat --paging=never --theme gruvbox-dark" + alias us="systemctl --user" + alias rs="sudo systemctl" + alias zed="zeditor" + alias nl="nl -ba" + alias cp="cp -riv" + alias mv="mv -iv" + alias mkdir="mkdir -pv" + alias less="bat -p --theme gruvbox-dark" + alias myip="curl ip.severijnse.eu" + alias pscpu="ps -eo pid,ppid,cmd,%mem,%cpu,etime --sort=-%cpu | head -n 21" + alias psmem="ps -eo pid,ppid,cmd,%mem,%cpu,etime --sort=-%mem | head -n 21" + alias weer="weather" + alias v="nvim" + alias copy="wl-copy" + alias send="croc" + alias img="loupe" + alias cat-md="glow" + alias du="dust" + alias df="duf" ''; }; }; diff --git a/home/terminal/shell/starship.nix b/home/terminal/shell/starship.nix index 080e5f9..925b914 100644 --- a/home/terminal/shell/starship.nix +++ b/home/terminal/shell/starship.nix @@ -1,20 +1,20 @@ -{ config, pkgs, ... }: - -let +{ + config, + pkgs, + ... +}: let configFile = "starship/starship.toml"; toTOML = (pkgs.formats.toml {}).generate; -in -{ +in { environment.sessionVariables = { STARSHIP_CONFIG = "${config.xdg.configHome}/${configFile}"; STARSHIP_LOG = "error"; }; - users.users.someone.packages = [ pkgs.starship ]; + users.users.someone.packages = [pkgs.starship]; xdg.configFile = { "${configFile}".source = toTOML "starship.toml" { - "$schema" = "https://starship.rs/config-schema.json"; add_newline = true; @@ -137,7 +137,6 @@ in starship init fish | source ''; - "fish/completions/starship.fish".source = - "${pkgs.starship}/share/fish/vendor_completions.d/starship.fish"; + "fish/completions/starship.fish".source = "${pkgs.starship}/share/fish/vendor_completions.d/starship.fish"; }; } diff --git a/home/terminal/software/opencode/default.nix b/home/terminal/software/opencode/default.nix index a05830c..c953bf6 100644 --- a/home/terminal/software/opencode/default.nix +++ b/home/terminal/software/opencode/default.nix @@ -1,8 +1,7 @@ -{ pkgs, ... }: -let - languages = import ./_languages.nix { inherit pkgs; }; +{pkgs, ...}: let + languages = import ./_languages.nix {inherit pkgs;}; providers = import ./_providers.nix; - skills = import ./_skills.nix { inherit pkgs; }; + skills = import ./_skills.nix {inherit pkgs;}; inherit (pkgs) opencode; @@ -23,7 +22,7 @@ let ''; opencodeWrapped = pkgs.runCommand "opencode-wrapped" { - buildInputs = [ pkgs.makeWrapper ]; + buildInputs = [pkgs.makeWrapper]; } '' mkdir -p $out/bin makeWrapper ${opencodeInitScript} $out/bin/opencode \ @@ -36,110 +35,110 @@ in { users.users.someone.packages = [ opencodeWrapped ]; - xdg.configFile = { - "${configFile}".text = builtins.toJSON { - "$schema" = "https://opencode.ai/config.json"; - plugin = [ - "opencode-antigravity-auth@latest" - "@tarquinen/opencode-dcp@latest" - "oh-my-openagent@latest" - ]; - model = "google/antigravity-gemini-3-flash"; - small_model = "opencode/big-pickle"; - autoupdate = false; - agent = { - build = { model = "opencode/nemotron-3-super-free"; }; - plan = { model = "opencode/big-pickle"; }; - task = { model = "opencode/nemotron-3-super-free"; }; - "sisyphus-junior" = { model = "opencode/nemotron-3-super-free"; }; - explore = { model = "opencode/minimax-m2.5-free"; }; - general = { model = "opencode/big-pickle"; }; - oracle = { model = "opencode/nemotron-3-super-free"; }; - "ultrabrain" = { model = "opencode/nemotron-3-super-free"; }; - "deep" = { model = "opencode/nemotron-3-super-free"; }; - "quick" = { model = "opencode/gpt-5-nano"; }; - "artistry" = { model = "opencode/nemotron-3-super-free"; }; - "visual-engineering" = { model = "opencode/hy3-preview-free"; }; - "writing" = { model = "opencode/hy3-preview-free"; }; - }; - share = "disabled"; - disabled_providers = [ - "amazon-bedrock" - "anthropic" - "azure-openai" - "azure-cognitive-services" - "baseten" - "cerebras" - "cloudflare-ai-gateway" - "cortecs" - "deep-infra" - "fireworks-ai" - "google-vertex-ai" - "groq" - "hugging-face" - "helicone" - "llama.cpp" - "io-net" - "lmstudio" - "moonshot-ai" - "nebius-token-factory" - "ollama" - "ollama-cloud" - "openai" - "sap-ai-core" - "ovhcloud-ai-endpoints" - "together-ai" - "venice-ai" - "xai" - "zai" - "zenmux" - ]; - enabled_providers = [ "opencode" "google" "openrouter" "deepseek" "nvidia" "alibaba" "mistral" "cloudflare-workers-ai" ]; - mcp = { - gh_grep = { - type = "remote"; - url = "https://mcp.grep.app/"; - enabled = true; - timeout = 10000; - }; - deepwiki = { - type = "remote"; - url = "https://mcp.deepwiki.com/mcp"; - enabled = true; - timeout = 10000; - }; - context7 = { - type = "remote"; - url = "https://mcp.context7.com/mcp"; - enabled = true; - timeout = 10000; - }; - }; - inherit (languages) formatter lsp; - provider = providers.config; + xdg.configFile = { + "${configFile}".text = builtins.toJSON { + "$schema" = "https://opencode.ai/config.json"; + plugin = [ + "opencode-antigravity-auth@latest" + "@tarquinen/opencode-dcp@latest" + "oh-my-openagent@latest" + ]; + model = "google/antigravity-gemini-3-flash"; + small_model = "opencode/big-pickle"; + autoupdate = false; + agent = { + build = {model = "opencode/nemotron-3-super-free";}; + plan = {model = "opencode/big-pickle";}; + task = {model = "opencode/nemotron-3-super-free";}; + "sisyphus-junior" = {model = "opencode/nemotron-3-super-free";}; + explore = {model = "opencode/minimax-m2.5-free";}; + general = {model = "opencode/big-pickle";}; + oracle = {model = "opencode/nemotron-3-super-free";}; + "ultrabrain" = {model = "opencode/nemotron-3-super-free";}; + "deep" = {model = "opencode/nemotron-3-super-free";}; + "quick" = {model = "opencode/gpt-5-nano";}; + "artistry" = {model = "opencode/nemotron-3-super-free";}; + "visual-engineering" = {model = "opencode/hy3-preview-free";}; + "writing" = {model = "opencode/hy3-preview-free";}; }; - - "opencode/oh-my-openagent.json".text = builtins.toJSON { - "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/master/assets/oh-my-opencode.schema.json"; - agents = { - "sisyphus-junior" = { model = "opencode/nemotron-3-super-free"; }; + share = "disabled"; + disabled_providers = [ + "amazon-bedrock" + "anthropic" + "azure-openai" + "azure-cognitive-services" + "baseten" + "cerebras" + "cloudflare-ai-gateway" + "cortecs" + "deep-infra" + "fireworks-ai" + "google-vertex-ai" + "groq" + "hugging-face" + "helicone" + "llama.cpp" + "io-net" + "lmstudio" + "moonshot-ai" + "nebius-token-factory" + "ollama" + "ollama-cloud" + "openai" + "sap-ai-core" + "ovhcloud-ai-endpoints" + "together-ai" + "venice-ai" + "xai" + "zai" + "zenmux" + ]; + enabled_providers = ["opencode" "google" "openrouter" "deepseek" "nvidia" "alibaba" "mistral" "cloudflare-workers-ai"]; + mcp = { + gh_grep = { + type = "remote"; + url = "https://mcp.grep.app/"; + enabled = true; + timeout = 10000; }; - categories = { - "quick" = { model = "opencode/gpt-5-nano"; }; - "ultrabrain" = { model = "opencode/nemotron-3-super-free"; }; - "deep" = { model = "opencode/nemotron-3-super-free"; }; - "visual-engineering" = { model = "opencode/hy3-preview-free"; }; - "writing" = { model = "opencode/hy3-preview-free"; }; - "unspecified-low" = { model = "opencode/gpt-5-nano"; }; - "unspecified-high" = { model = "opencode/nemotron-3-super-free"; }; + deepwiki = { + type = "remote"; + url = "https://mcp.deepwiki.com/mcp"; + enabled = true; + timeout = 10000; + }; + context7 = { + type = "remote"; + url = "https://mcp.context7.com/mcp"; + enabled = true; + timeout = 10000; }; }; - - "${tuiFile}".text = builtins.toJSON { - "$schema" = "https://opencode.ai/tui.json"; - theme = "gruvbox"; - }; - - "opencode/skill".source = skills.skillsSource + "/skill"; + inherit (languages) formatter lsp; + provider = providers.config; }; + + "opencode/oh-my-openagent.json".text = builtins.toJSON { + "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/master/assets/oh-my-opencode.schema.json"; + agents = { + "sisyphus-junior" = {model = "opencode/nemotron-3-super-free";}; + }; + categories = { + "quick" = {model = "opencode/gpt-5-nano";}; + "ultrabrain" = {model = "opencode/nemotron-3-super-free";}; + "deep" = {model = "opencode/nemotron-3-super-free";}; + "visual-engineering" = {model = "opencode/hy3-preview-free";}; + "writing" = {model = "opencode/hy3-preview-free";}; + "unspecified-low" = {model = "opencode/gpt-5-nano";}; + "unspecified-high" = {model = "opencode/nemotron-3-super-free";}; + }; + }; + + "${tuiFile}".text = builtins.toJSON { + "$schema" = "https://opencode.ai/tui.json"; + theme = "gruvbox"; + }; + + "opencode/skill".source = skills.skillsSource + "/skill"; + }; } diff --git a/home/terminal/software/tui.nix b/home/terminal/software/tui.nix index c8f22ae..e0e3e6b 100644 --- a/home/terminal/software/tui.nix +++ b/home/terminal/software/tui.nix @@ -3,38 +3,37 @@ pkgs, ... }: { - users.users.someone.packages = with pkgs; - [ - # archives - zip - unzip - unrar - ouch + users.users.someone.packages = with pkgs; [ + # archives + zip + unzip + unrar + ouch - # misc - mako - libnotify - fontconfig + # misc + mako + libnotify + fontconfig - # utils - dust - duf - fd - file - killall - jq - ps_mem - inshellisense + # utils + dust + duf + fd + file + killall + jq + ps_mem + inshellisense - fum - gtt - meteor-git - nix-search-tv - scope-tui - tuicam - wiremix - zfxtop - opencode - loupe - ]; + fum + gtt + meteor-git + nix-search-tv + scope-tui + tuicam + wiremix + zfxtop + opencode + loupe + ]; } diff --git a/hosts/default.nix b/hosts/default.nix index 2d018ef..82860ba 100644 --- a/hosts/default.nix +++ b/hosts/default.nix @@ -5,6 +5,9 @@ }: let # shorten paths inherit (inputs.nixpkgs.lib) nixosSystem; + # Server uses its own pinned 24.05 nixpkgs (kept isolated from the laptop's unstable) + nixosSystem24 = inputs.nixos-24-05.lib.nixosSystem; + unstablePkgs = import inputs.nixpkgs-unstable {system = "x86_64-linux";}; mod = "${self}/system"; home = "${self}/home"; @@ -39,8 +42,27 @@ in { # Laptop: laptop with GUI + battery + bluetooth laptop = nixosSystem { inherit specialArgs; - modules = laptop ++ sharedModules ++ [ - "${mod}/services/location.nix" + modules = + laptop + ++ sharedModules + ++ [ + "${mod}/services/location.nix" + ]; + }; + + # Server: severijnse.eu (Hetzner) — fully isolated under servers/hetzner/. + # Uses nixos-24.05 + disko + sops-nix and does NOT inherit the laptop's shared modules. + hetzner = nixosSystem24 { + system = "x86_64-linux"; + specialArgs = { + inherit inputs self; + unstablePkgs = unstablePkgs; + }; + modules = [ + inputs.disko.nixosModules.disko + inputs.sops-nix.nixosModules.sops + "${self}/servers/hetzner/hosts/hetzner/hardware-configuration.nix" + "${self}/servers/hetzner/hosts/hetzner/default.nix" ]; }; }; diff --git a/servers/hetzner/.sops.yaml b/servers/hetzner/.sops.yaml new file mode 100644 index 0000000..d0cfc4d --- /dev/null +++ b/servers/hetzner/.sops.yaml @@ -0,0 +1,11 @@ +keys: + - &admin age1yd59qp5km4cxt99rlfjehnsucrjn9lmj0su4h3avhf6vrtjvnyjqstldl5 + - &local age15rhqdpwejyf3r6ww70qgv6hqmkpsqraakn26kc49wlauhaceaeqsmuwrdd + - &server age1xekdrkjfu82hkxltydm72tllzgayyvfavvudeks3xjuujm5wt5hq6g55v0 +creation_rules: + - path_regex: secrets/.*\.yaml + key_groups: + - age: + - *admin + - *local + - *server diff --git a/servers/hetzner/deploy.sh b/servers/hetzner/deploy.sh new file mode 100644 index 0000000..1f6f6b3 --- /dev/null +++ b/servers/hetzner/deploy.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy NixOS to a Hetzner VPS using nixos-anywhere +# Usage: ./deploy.sh [user@host] +# +# IMPORTANT: Run this from a SEPARATE machine with Nix installed +# (laptop, another VPS, etc.), NOT from the target VPS itself. +# nixos-anywhere uses kexec to reboot the target; you can't reboot +# the machine you're running on. +# +# Steps: +# 1. git clone && cd nixos-config +# 2. Add your SSH pubkey to ./hosts/hetzner/users.nix +# 3. ./deploy.sh root@ +# +# This wipes the target disk and installs NixOS fresh. + +HOST=${1:-root@localhost} + +echo "=== Deploying NixOS to $HOST ===" +echo "WARNING: This will WIPE the target disk!" +read -rp "Continue? [y/N] " confirm +[[ "$confirm" =~ ^[Yy]$ ]] || exit 1 + +nix run github:nix-community/nixos-anywhere -- \ + --generate-hardware-config nixos-generate-config ./hosts/hetzner/hardware-configuration.nix \ + --flake ".#hetzner" "$HOST" + +# After deploy, the generated hw config MUST be imported to flake.nix +echo "" +echo "=== CRITICAL: Post-deploy steps! ===" +echo "1. Import the generated hw config into flake.nix:" +echo " git add hosts/hetzner/hardware-configuration.nix" +echo " # In flake.nix, add to modules array:" +echo " ./hosts/hetzner/hardware-configuration.nix" +echo "" +echo "2. Copy the age key to the new system for sops-nix:" +echo " # From the deploy machine:" +echo " ssh root@ 'mkdir -p /etc/age'" +echo " scp /home/admin/age/keys.txt root@:/etc/age/keys.txt" +echo "" +echo "3. IMPORTANT: The old gitea_db Docker Postgres needs migration." +echo " The pg_dump is at /tmp/gitea-db-dump-YYYY-MM-DD.sql.gz" +echo " Restore with:" +echo " sudo -u postgres psql -c \"CREATE DATABASE gitea;\"" +echo " sudo -u postgres psql -c \"CREATE USER gitea WITH PASSWORD 'gitea';\"" +echo " sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE gitea TO gitea;\"" +echo " zcat /tmp/gitea-db-dump-YYYY-MM-DD.sql.gz | sudo -u postgres psql gitea" +echo "" +echo "4. Restore Caddy certs for the mailserver:" +echo " # The old Debian Caddy certs were backed up at /home/admin/backups/caddy-certs-mail.tar.gz" +echo " # On NixOS Caddy stores at /var/lib/caddy/certificates/..." +echo "" diff --git a/servers/hetzner/hosts/hetzner/default.nix b/servers/hetzner/hosts/hetzner/default.nix new file mode 100644 index 0000000..13d01bf --- /dev/null +++ b/servers/hetzner/hosts/hetzner/default.nix @@ -0,0 +1,31 @@ +{ + config, + pkgs, + lib, + ... +}: { + imports = [ + ./networking.nix + ./users.nix + ./hardware.nix + ./disko-config.nix + ../../modules/system/env.nix + ../../modules/security/acme.nix + ../../modules/services/caddy.nix + ../../modules/services/coredns.nix + ../../modules/services/gitea.nix + ../../modules/services/wireguard.nix + ../../modules/services/fail2ban.nix + ../../modules/services/vaultwarden.nix + ../../modules/services/mailserver.nix + ../../modules/services/snappymail.nix + ../../modules/services/rustdesk.nix + ../../modules/services/wrxproxy.nix + ../../modules/services/watchtower.nix + ../../modules/services/tlsa-updater.nix + ../../modules/services/shkeeper.nix + ../../modules/services/backup.nix + ]; + + system.stateVersion = "24.05"; +} diff --git a/servers/hetzner/hosts/hetzner/disko-config.nix b/servers/hetzner/hosts/hetzner/disko-config.nix new file mode 100644 index 0000000..0ed18b8 --- /dev/null +++ b/servers/hetzner/hosts/hetzner/disko-config.nix @@ -0,0 +1,37 @@ +{lib, ...}: { + disko.devices = { + disk.disk1 = { + device = lib.mkDefault "/dev/sda"; + type = "disk"; + content = { + type = "gpt"; + partitions = { + boot = { + name = "boot"; + size = "1M"; + type = "EF02"; + }; + esp = { + name = "ESP"; + size = "256M"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot/efi"; + }; + }; + root = { + name = "root"; + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/servers/hetzner/hosts/hetzner/dns-zone.nix b/servers/hetzner/hosts/hetzner/dns-zone.nix new file mode 100644 index 0000000..78fc58c --- /dev/null +++ b/servers/hetzner/hosts/hetzner/dns-zone.nix @@ -0,0 +1,20 @@ +# Zone file for severijnse.eu +# This is the authoritative DNS zone served by CoreDNS +# Update serial number on changes +{ + serial = "2026071003"; + adminEmail = "abuse.severijnse.eu"; + nameservers = ["ns1.severijnse.eu" "ns2.severijnse.eu"]; + ipv4 = "49.13.92.205"; + ipv6 = "2a01:4f8:c014:2585::1"; + mailIpv4 = "49.13.92.205"; + mailIpv6 = "2a01:4f8:c014:2585::1"; + dkimRecord = '' + v=DKIM1; k=rsa; + p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAskpG7m4kninxRE4JF5KzpnBhLbOlGJL2RO/iDfzdz6sHEvxe78q9c5UnZ0OQddOSuQo4q48dJkXR/XzqY7Ak109lhoAx+Kr1neYsi8/ + JaoTC8OURk365+/aBSmWXUCCBphCx43QWfC9h8GMQ6PUIqawkz5CcGPT7X7hPdwHQcd5Vn3CqmABptxdwshdkBjZs + oi79BOo9ZrQSTY7iiLcOP7hVVC9Ad+ydlZ4MWGfy5BxgyTGrrtuSuLcM219oqdovIvr2EtXs8AMx5fyXplKE3R/ + YlwF2Jcy50Gmb5y/E9pOaFjVv8HXUmKsvuhA2b8K+rt0WVHNc3dvbgZUl8bGAQIDAQAB + ''; + dnssecKeyTag = "20930"; +} diff --git a/servers/hetzner/hosts/hetzner/hardware-configuration.nix b/servers/hetzner/hosts/hetzner/hardware-configuration.nix new file mode 100644 index 0000000..3dcb9bc --- /dev/null +++ b/servers/hetzner/hosts/hetzner/hardware-configuration.nix @@ -0,0 +1,21 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ + config, + lib, + pkgs, + modulesPath, + ... +}: { + imports = [ + (modulesPath + "/profiles/qemu-guest.nix") + ]; + + boot.initrd.availableKernelModules = ["ahci" "xhci_pci" "virtio_pci" "virtio_scsi" "sd_mod" "sr_mod"]; + boot.initrd.kernelModules = []; + boot.kernelModules = []; + boot.extraModulePackages = []; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; +} diff --git a/servers/hetzner/hosts/hetzner/hardware.nix b/servers/hetzner/hosts/hetzner/hardware.nix new file mode 100644 index 0000000..0b1eba4 --- /dev/null +++ b/servers/hetzner/hosts/hetzner/hardware.nix @@ -0,0 +1,51 @@ +{ + config, + pkgs, + lib, + ... +}: { + boot = { + loader = { + grub = { + enable = true; + device = "/dev/sda"; + efiSupport = true; + efiInstallAsRemovable = true; + }; + efi = { + efiSysMountPoint = "/boot/efi"; + }; + }; + initrd.availableKernelModules = ["ata_piix" "virtio_pci" "virtio_scsi" "virtio_blk"]; + }; + + swapDevices = [ + { + device = "/swap"; + size = 2048; + } + ]; + + # Enable Podman OCI container support + virtualisation = { + podman = { + enable = true; + dockerCompat = true; + defaultNetwork.settings = { + dns_enabled = true; + ipv6_enabled = true; + subnets = [ + { + subnet = "10.88.0.0/16"; + gateway = "10.88.0.1"; + } + { + subnet = "fd00:dead:beef::/48"; + gateway = "fd00:dead:beef::1"; + } + ]; + }; + }; + oci-containers.backend = "podman"; + }; +} diff --git a/servers/hetzner/hosts/hetzner/networking.nix b/servers/hetzner/hosts/hetzner/networking.nix new file mode 100644 index 0000000..36f883e --- /dev/null +++ b/servers/hetzner/hosts/hetzner/networking.nix @@ -0,0 +1,65 @@ +{ + config, + pkgs, + lib, + ... +}: { + networking = { + hostName = "debian-4gb-fsn1-1"; + domain = "severijnse.eu"; + useDHCP = true; + dhcpcd.enable = true; + nameservers = [ + "2a01:4ff:ff00::add:2" + "2a01:4ff:ff00::add:1" + "185.12.64.2" + ]; + search = ["severijnse.eu"]; + interfaces."enp1s0" = { + ipv6.addresses = [ + { + address = "2a01:4f8:c014:2585::1"; + prefixLength = 64; + } + ]; + ipv6.routes = [ + { + address = "::"; + prefixLength = 0; + via = "fe80::1"; + } + ]; + }; + firewall = { + enable = true; + allowedTCPPorts = [ + 22 # SSH + 53 # DNS (CoreDNS) + 80 # HTTP (Caddy) + 443 # HTTPS (Caddy) + 465 # SMTP over TLS + 587 # SMTP (STARTTLS) + 993 # IMAP over TLS + 143 # IMAP + 25 # SMTP + 222 # Gitea SSH + 51821 # WG-Easy web UI + ]; + allowedUDPPorts = [ + 53 # DNS + 51820 # WireGuard + ]; + }; + nat = { + enable = true; + externalInterface = "enp1s0"; + internalInterfaces = ["wg0"]; + }; + }; + + boot.kernel.sysctl = { + "net.ipv4.ip_forward" = 1; + "net.ipv4.conf.all.src_valid_mark" = 1; + "net.ipv6.conf.all.forwarding" = 1; + }; +} diff --git a/servers/hetzner/hosts/hetzner/users.nix b/servers/hetzner/hosts/hetzner/users.nix new file mode 100644 index 0000000..e01d14f --- /dev/null +++ b/servers/hetzner/hosts/hetzner/users.nix @@ -0,0 +1,44 @@ +{ + config, + pkgs, + lib, + ... +}: { + users.users = { + root = { + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGYboWHsP50CXzMpz4FogC3ecFchjZARCdY4wSTc/Et0 someone@archlinux" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKAlpdPnqihMxE6XPBUQ9Kb6MrvWUWw/BYx6kf2kTPD6 admin@severijnse.eu" + ]; + }; + + admin = { + isNormalUser = true; + uid = 1000; + description = "Jory"; + extraGroups = ["wheel" "docker" "podman"]; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGYboWHsP50CXzMpz4FogC3ecFchjZARCdY4wSTc/Et0 someone@archlinux" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKAlpdPnqihMxE6XPBUQ9Kb6MrvWUWw/BYx6kf2kTPD6 admin@severijnse.eu" + ]; + shell = pkgs.fish; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "prohibit-password"; + PasswordAuthentication = false; + PubkeyAuthentication = true; + }; + hostKeys = [ + { + path = "/etc/ssh/ssh_host_ed25519_key"; + type = "ed25519"; + } + ]; + }; + + security.sudo.wheelNeedsPassword = false; +} diff --git a/servers/hetzner/modules/security/acme.nix b/servers/hetzner/modules/security/acme.nix new file mode 100644 index 0000000..482a912 --- /dev/null +++ b/servers/hetzner/modules/security/acme.nix @@ -0,0 +1,17 @@ +{ + config, + pkgs, + lib, + ... +}: { + # NOTE: Caddy handles all TLS natively via its ACME integration. + # This module is kept as a fallback for non-Caddy services. + # Currently NOT imported in default.nix — uncomment there to activate. + security.acme = { + acceptTerms = true; + defaults = { + email = "jory@severijnse.eu"; + group = "caddy"; + }; + }; +} diff --git a/servers/hetzner/modules/services/backup.nix b/servers/hetzner/modules/services/backup.nix new file mode 100644 index 0000000..dd82d65 --- /dev/null +++ b/servers/hetzner/modules/services/backup.nix @@ -0,0 +1,46 @@ +{ + config, + pkgs, + lib, + ... +}: let + backupScript = pkgs.writeShellScript "weekly-backup" '' + BACKUP_DIR="/home/admin/backups" + SRC="/home/admin" + DATE=$(date +%Y-%m-%dT%H-%M-%S) + FILENAME="weekly-backup-$DATE.tar.gz" + + mkdir -p "$BACKUP_DIR" + # Backup everything under /home/admin EXCEPT: + # - The backups dir itself (infinite loop) + # - DMS mail data (GBs of email, backed up separately) + # - NixOS-managed service data (at their own paths below) + tar czf "$BACKUP_DIR/$FILENAME" \ + --exclude="$BACKUP_DIR" \ + --exclude="/home/admin/backups" \ + --exclude="/home/admin/dms/mail-data" \ + --exclude="/home/admin/dms/mail-state" \ + "$SRC" + + # Prune backups older than 14 days + find "$BACKUP_DIR" -name "weekly-backup-*" -mtime +14 -delete + ''; +in { + systemd.services.weekly-backup = { + description = "Weekly backup of home directory"; + path = with pkgs; [coreutils gnutar findutils]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${backupScript}"; + User = "root"; + }; + }; + + systemd.timers.weekly-backup = { + wantedBy = ["timers.target"]; + timerConfig = { + OnCalendar = "Mon *-*-* 03:00:00"; + Persistent = true; + }; + }; +} diff --git a/servers/hetzner/modules/services/caddy.nix b/servers/hetzner/modules/services/caddy.nix new file mode 100644 index 0000000..a1a1641 --- /dev/null +++ b/servers/hetzner/modules/services/caddy.nix @@ -0,0 +1,248 @@ +{ + config, + pkgs, + lib, + ... +}: let + domain = "severijnse.eu"; + antiScrape = '' + @bad_bot { + header_regexp User-Agent "(?i)(scrapy|cpython-requests|python-requests|curl|wget|go-http-client|ltx71|petalbot|bytespider|dotbot|ahrefsbot|semrushbot|mj12bot|dataforseo|facebookexternalhit|claudebot|anthropic-ai|perplexity|gptbot|chatgpt-user|omnisci|imgproxy|ccbot|exabot|360spider|baiduspider|sogou|duckduckgo|amazonbot|cohere-ai|diffbot|imagesiftbot).*" + } + respond @bad_bot "" 444 + header { + X-Robots-Tag "noindex, nofollow, noai, noimageai" + } + ''; +in { + services.caddy = { + enable = true; + group = "caddy"; + dataDir = "/var/lib/caddy"; + logDir = "/var/log/caddy"; + globalConfig = '' + email jory@severijnse.eu + servers { + trusted_proxies static private_ranges + } + ''; + # Global Caddyfile snippets (shared across all virtual hosts). + extraConfig = '' + (admin_gate) { + @notvpn not remote_ip 10.8.0.0/24 + respond @notvpn "Forbidden" 403 + } + # Security headers applied to every response of every site that + # imports this snippet (covers all current and future hosts). + # For proxied hosts whose upstream sets its own copies, strip them + # with `header_down` inside the reverse_proxy block instead. + (security_headers) { + header { + X-Content-Type-Options "nosniff" + X-XSS-Protection "0" + X-Frame-Options "SAMEORIGIN" + } + } + (csp) { + header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; connect-src 'self' wss: ws:" + } + ''; + virtualHosts = { + "severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + @logo path /logo.svg + handle @logo { + root * /srv + file_server + header Content-Type image/svg+xml + header Cache-Control "public, immutable, max-age=31536000" + header X-Content-Type-Options nosniff + } + handle { + redir https://jory.severijnse.eu{uri} permanent + } + ''; + }; + + "www.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + redir https://jory.severijnse.eu{uri} permanent + ''; + }; + + "jory.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + root * /srv/jory + file_server + try_files {path} /index.html + encode zstd gzip + header Strict-Transport-Security "max-age=31536000;" + @robots path /robots.txt + handle @robots { + header Content-Type text/plain + respond `User-agent: * + Allow: / + + User-agent: Googlebot + Allow: / + + User-agent: Bingbot + Allow: / + + User-agent: Twitterbot + Allow: / + + User-agent: facebookexternalhit + Allow: / + + User-agent: GPTBot + User-agent: ChatGPT-User + User-agent: OAI-SearchBot + User-agent: ClaudeBot + User-agent: Claude-Web + User-agent: anthropic-ai + User-agent: PerplexityBot + User-agent: Bytespider + User-agent: Amazonbot + User-agent: CCBot + User-agent: Google-Extended + User-agent: Applebot-Extended + Disallow: / + ` 200 + } + ''; + }; + + "mta-sts.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + root * /srv + file_server + header Content-Type text/plain + header Cache-Control "public, max-age=300" + header X-Content-Type-Options nosniff + ''; + }; + + "vault.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + import admin_gate + header Strict-Transport-Security "max-age=31536000;" + reverse_proxy 127.0.0.1:1001 + encode zstd gzip + ''; + }; + + "git.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + reverse_proxy 127.0.0.1:3000 + encode zstd gzip + ''; + }; + + "mail.severijnse.eu" = { + extraConfig = '' + import security_headers + ${antiScrape} + reverse_proxy 127.0.0.1:8888 { + # Strip copies set by the upstream SnappyMail container so we + # emit exactly one correct value of each security header. + header_down -X-Frame-Options + header_down -X-XSS-Protection + header_down -X-Content-Type-Options + } + encode zstd gzip + ''; + }; + + "vpn.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + reverse_proxy 127.0.0.1:51821 + encode zstd gzip + ''; + }; + + "mine.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + reverse_proxy 127.0.0.1:81 + ''; + }; + + "music.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + reverse_proxy 127.0.0.1:4321 + ''; + }; + + "fail2ban.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + import admin_gate + reverse_proxy 127.0.0.1:8080 + ''; + }; + + "automate.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + import admin_gate + reverse_proxy 127.0.0.1:5678 + encode zstd gzip + ''; + }; + + "pay.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + ${antiScrape} + import admin_gate + reverse_proxy 127.0.0.1:5000 + encode zstd gzip + ''; + }; + + "http://ip.severijnse.eu" = { + extraConfig = '' + import security_headers + import csp + header Content-Type text/plain + respond {client_ip} 200 + ''; + }; + }; + }; + + systemd.tmpfiles.rules = [ + "d /srv 0755 caddy caddy -" + "d /srv/jory 0755 caddy caddy -" + ]; +} diff --git a/servers/hetzner/modules/services/coredns.nix b/servers/hetzner/modules/services/coredns.nix new file mode 100644 index 0000000..db78aa8 --- /dev/null +++ b/servers/hetzner/modules/services/coredns.nix @@ -0,0 +1,144 @@ +{ + config, + pkgs, + lib, + ... +}: let + zoneFile = pkgs.writeText "severijnse.eu.db" '' + $ORIGIN severijnse.eu. + $TTL 3600 + severijnse.eu. 3600 IN SOA ns1.severijnse.eu. abuse.severijnse.eu. 2026071003 3600 1800 1209600 86400 + IN NS ns1.severijnse.eu. + IN NS ns2.severijnse.eu. + + @ IN A 49.13.92.205 + www IN A 49.13.92.205 + ns1 IN A 49.13.92.205 + ns2 IN A 49.13.92.205 + mail IN A 49.13.92.205 + + @ IN AAAA 2a01:4f8:c014:2585::1 + www IN AAAA 2a01:4f8:c014:2585::1 + ns1 IN AAAA 2a01:4f8:c014:2585::1 + ns2 IN AAAA 2a01:4f8:c014:2585::1 + mail IN AAAA 2a01:4f8:c014:2585::1 + + *.severijnse.eu. IN A 49.13.92.205 + *.severijnse.eu. IN AAAA 2a01:4f8:c014:2585::1 + + @ IN MX 10 mail.severijnse.eu. + + mail._domainkey.severijnse.eu. 300 IN TXT ( + "v=DKIM1; k=rsa; " + "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAskpG7m4kninxRE4JF5KzpnBhLbOlGJL2RO/iDfzdz6sHEvxe78q9c5UnZ0OQddOSuQo4q48dJkXR/XzqY7Ak109lhoAx+Kr1neYsi8/" + "JaoTC8OURk365+/aBSmWXUCCBphCx43QWfC9h8GMQ6PUIqawkz5CcGPT7X7hPdwHQcd5Vn3CqmABptxdwshdkBjZs" + "oi79BOo9ZrQSTY7iiLcOP7hVVC9Ad+ydlZ4MWGfy5BxgyTGrrtuSuLcM219oqdovIvr2EtXs8AMx5fyXplKE3R/" + "YlwF2Jcy50Gmb5y/E9pOaFjVv8HXUmKsvuhA2b8K+rt0WVHNc3dvbgZUl8bGAQIDAQAB" + ) + _dmarc.severijnse.eu. IN TXT "v=DMARC1; p=reject; rua=mailto:abuse@severijnse.eu" + severijnse.eu. IN TXT "v=spf1 mx ip4:49.13.92.205 -all" + + default._bimi.severijnse.eu. IN TXT "v=BIMI1; l=https://severijnse.eu/logo.svg; avp=personal;" + + severijnse.eu. IN CAA 0 issue "letsencrypt.org" + severijnse.eu. IN CAA 0 issuewild "letsencrypt.org" + severijnse.eu. IN CAA 0 iodef "mailto:abuse@severijnse.eu" + + _smtp._tls.severijnse.eu. IN TXT "v=TLSRPTv1; rua=mailto:abuse@severijnse.eu" + _mta-sts.severijnse.eu. IN TXT "v=STSv1; id=2024120501" + + severijnse.eu. 300 IN TXT "google-site-verification=H0HHB7zNQ10uom1zH5f8CEtHcVVcWiuu41ZQv348T5U" + + ; TLSA records updated dynamically by mail-cert-sync service. + ; The placeholders below mirror the current live certificate so the zone is + ; correct even if the sync service has not yet run (e.g. a failed boot). + _25._tcp.mail.severijnse.eu. 3600 IN TLSA 3 1 1 15ec4d8823874c8363af004188338f76e6e6c5878faf25cdf40556b44b26677d + _465._tcp.mail.severijnse.eu. 3600 IN TLSA 3 1 1 15ec4d8823874c8363af004188338f76e6e6c5878faf25cdf40556b44b26677d + _993._tcp.mail.severijnse.eu. 3600 IN TLSA 3 1 1 15ec4d8823874c8363af004188338f76e6e6c5878faf25cdf40556b44b26677d + + severijnse.eu. IN DS 20930 13 2 B0D9B13DCE5FA0D41589239EB5166D124B0C9D3060A538726DDCCBFC91E8DBD8 + + @ IN DNSKEY 257 3 13 kOc88RGHKdWa7YLjNs7mljux7cT9/9wVNgcp+8jaVe6zle4cHAk6+Sub9wKEVa7Q4FlQYzS1KFL2HOW05Azq1A== + @ IN DNSKEY 256 3 13 QTFplRlXes5NExKLvuCJmX0l8FhulFNS+sJw+pvcAq4+0T3cCzT6hmcJPjd4BVl6KvvyS78Vij2f7HjrcjyHfw== + @ IN CDS 20930 13 2 B0D9B13DCE5FA0D41589239EB5166D124B0C9D3060A538726DDCCBFC91E8DBD8 + @ IN CDNSKEY 257 3 13 kOc88RGHKdWa7YLjNs7mljux7cT9/9wVNgcp+8jaVe6zle4cHAk6+Sub9wKEVa7Q4FlQYzS1KFL2HOW05Azq1A== + + severijnse.eu IN SSHFP 1 1 7f5235b2997e0c621fe773acaaae45b9551049ce + severijnse.eu IN SSHFP 1 2 2ec1d37df094915253c0b059762052ae19e66cd97e82d7a58ac1a0d1bd5d5e52 + severijnse.eu IN SSHFP 3 1 240418eb7cc2a3969257c30363d16103dbfd5822 + severijnse.eu IN SSHFP 3 2 43dbfdd8b131efda4c98844e1434e4f35e773f472bdb355ede94f8a9f7001c35 + severijnse.eu IN SSHFP 4 1 efbd7836208684d110f3a72543eba74ad8e54b44 + severijnse.eu IN SSHFP 4 2 c02f4b13aef78579f3466851fcc741c0169eaa63237463ceacc9bb72a2be0519 + ''; +in { + # Decrypt DNSSEC keys from sops-encrypted file using the age key at /etc/age/keys.txt + # Uses sops CLI directly instead of sops-nix's sops-install-secrets (avoids Go 1.25 build dep) + systemd.services.decrypt-coredns-keys = { + description = "Decrypt CoreDNS DNSSEC signing keys"; + before = ["coredns.service"]; + wantedBy = ["coredns.service"]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Environment = "SOPS_AGE_KEY_FILE=/etc/age/keys.txt"; + }; + script = '' + SOPS_FILE=${../../secrets/coredns-keys.yaml} + KEYS_DIR=/var/lib/coredns/keys + mkdir -p "$KEYS_DIR" + + ${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.key" + ${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.private" + ${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.key" + ${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.private" + + chmod 644 "$KEYS_DIR/"* + ''; + }; + + # Ensure coredns waits for key decryption + systemd.services.coredns = { + after = ["decrypt-coredns-keys.service"]; + requires = ["decrypt-coredns-keys.service"]; + }; + + services.coredns = { + enable = true; + config = '' + severijnse.eu { + bind 127.0.0.1 49.13.92.205 2a01:4f8:c014:2585::1 + file /var/lib/coredns/zones/severijnse.eu.db { + reload 300s + } + dnssec { + key file /var/lib/coredns/keys/Kseverijnse.eu.+013+38678 + key file /var/lib/coredns/keys/Kseverijnse.eu.+013+20930 + } + log + errors + } + + . { + bind 127.0.0.1 + forward . 1.1.1.1 8.8.8.8 + log + errors + } + ''; + }; + + # Copy zone file from Nix store to writable location on service start + # TLSA updater will modify the writable copy at runtime + systemd.services.coredns = { + preStart = '' + cp -f ${zoneFile} /var/lib/coredns/zones/severijnse.eu.db + chown coredns:coredns /var/lib/coredns/zones/severijnse.eu.db + ''; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/coredns 0750 coredns coredns -" + "d /var/lib/coredns/zones 0750 coredns coredns -" + "d /var/lib/coredns/keys 0750 coredns coredns -" + ]; +} diff --git a/servers/hetzner/modules/services/fail2ban.nix b/servers/hetzner/modules/services/fail2ban.nix new file mode 100644 index 0000000..eaa9fbf --- /dev/null +++ b/servers/hetzner/modules/services/fail2ban.nix @@ -0,0 +1,27 @@ +{ + config, + pkgs, + lib, + ... +}: { + # Keep fail2ban as OCI container to preserve the web UI + virtualisation.oci-containers.containers.fail2ban = { + image = "crazymax/fail2ban:latest"; + autoStart = true; + volumes = [ + "/home/admin/dms/mail-logs:/var/log/mail:ro" + "/home/admin/fail2ban/data:/data:Z" + ]; + environment = { + TZ = "Europe/Amsterdam"; + }; + extraOptions = [ + "--network=host" + "--cap-add=NET_ADMIN" + ]; + }; + + systemd.tmpfiles.rules = [ + "d /var/log/mail 0755 root root -" + ]; +} diff --git a/servers/hetzner/modules/services/gitea.nix b/servers/hetzner/modules/services/gitea.nix new file mode 100644 index 0000000..f1cfbcd --- /dev/null +++ b/servers/hetzner/modules/services/gitea.nix @@ -0,0 +1,64 @@ +{ + config, + pkgs, + lib, + unstablePkgs, + ... +}: { + services.postgresql = { + enable = true; + package = pkgs.postgresql_14; + ensureDatabases = ["gitea"]; + ensureUsers = [ + { + name = "gitea"; + ensureDBOwnership = true; + } + ]; + }; + + # Gitea connects to local Postgres via Unix socket (peer auth). + # No password needed — the socket is at /run/postgresql by default. + # createDatabase = true ensures the DB + user are set up automatically. + services.gitea = { + enable = true; + package = unstablePkgs.gitea; + database.type = "postgres"; + database.name = "gitea"; + database.user = "gitea"; + appName = "Jory's Git"; + lfs.enable = true; + settings = { + server = { + DOMAIN = "git.severijnse.eu"; + ROOT_URL = "https://git.severijnse.eu/"; + HTTP_PORT = 3000; + SSH_PORT = 222; + SSH_LISTEN_PORT = 2222; + START_SSH_SERVER = true; + SSH_USER = "git"; + BUILTIN_SSH_SERVER_USER = "git"; + LANDING_PAGE = "explore"; + }; + service = { + DISABLE_REGISTRATION = true; + REQUIRE_SIGNIN_VIEW = false; + }; + repository = { + DEFAULT_BRANCH = "main"; + }; + }; + }; + + # Gitea built-in SSH server: listens on high port 2222 (no privileged-cap needed), + # while clone URLs advertise port 222. Firewall redirects 222 -> 2222. + networking.firewall.allowedTCPPorts = [222 2222]; + + # Redirect external git SSH (222) to Gitea's internal listener (2222) + networking.firewall.extraCommands = '' + ${pkgs.nftables}/bin/nft add table inet gitea-redirect 2>/dev/null || true + ${pkgs.nftables}/bin/nft flush chain inet gitea-redirect prerouting 2>/dev/null || true + ${pkgs.nftables}/bin/nft add chain inet gitea-redirect prerouting '{ type nat hook prerouting priority dstnat; }' 2>/dev/null || true + ${pkgs.nftables}/bin/nft add rule inet gitea-redirect prerouting tcp dport 222 redirect to :2222 2>/dev/null || true + ''; +} diff --git a/servers/hetzner/modules/services/mailserver.nix b/servers/hetzner/modules/services/mailserver.nix new file mode 100644 index 0000000..fe1aebc --- /dev/null +++ b/servers/hetzner/modules/services/mailserver.nix @@ -0,0 +1,46 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.mailserver = { + image = "ghcr.io/docker-mailserver/docker-mailserver:latest"; + autoStart = true; + hostname = "mail.severijnse.eu"; + ports = [ + "25:25" + "[::]:25:25" + "143:143" + "[::]:143:143" + "465:465" + "[::]:465:465" + "587:587" + "[::]:587:587" + "993:993" + "[::]:993:993" + ]; + volumes = [ + "/home/admin/dms/mail-data/:/var/mail/:Z" + "/home/admin/dms/mail-state/:/var/mail-state/:Z" + "/home/admin/dms/mail-logs/:/var/log/mail/:Z" + "/home/admin/dms/config/:/tmp/docker-mailserver/:Z" + "/etc/localtime:/etc/localtime:ro" + # On the running Debian, Caddy v2 stores certs at /home/admin/caddy/data/caddy/certificates/... + # On NixOS, Caddy stores certs at /var/lib/caddy/certificates/... + "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu/:/etc/letsencrypt/live/mail.severijnse.eu/:ro" + ]; + environment = { + OVERRIDE_HOSTNAME = ""; + LOG_LEVEL = "info"; + SPOOF_PROTECTION = "1"; + MOVE_SPAM_TO_JUNK = "1"; + }; + environmentFiles = ["/home/admin/mailserver.env"]; + extraOptions = [ + "--label=com.centurylinklabs.watchtower.enable=true" + "--dns=1.1.1.1" + "--dns=8.8.8.8" + ]; + }; +} diff --git a/servers/hetzner/modules/services/rustdesk.nix b/servers/hetzner/modules/services/rustdesk.nix new file mode 100644 index 0000000..27cd46c --- /dev/null +++ b/servers/hetzner/modules/services/rustdesk.nix @@ -0,0 +1,32 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers = { + hbbr = { + image = "rustdesk/rustdesk-server:latest"; + cmd = ["hbbr"]; + autoStart = true; + volumes = [ + "/home/admin/rustdesk:/root:Z" + ]; + extraOptions = [ + "--network=host" + ]; + }; + hbbs = { + image = "rustdesk/rustdesk-server:latest"; + cmd = ["hbbs"]; + autoStart = true; + volumes = [ + "/home/admin/rustdesk:/root:Z" + ]; + dependsOn = ["hbbr"]; + extraOptions = [ + "--network=host" + ]; + }; + }; +} diff --git a/servers/hetzner/modules/services/shkeeper.nix b/servers/hetzner/modules/services/shkeeper.nix new file mode 100644 index 0000000..d85d822 --- /dev/null +++ b/servers/hetzner/modules/services/shkeeper.nix @@ -0,0 +1,23 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.shkeeper = { + image = "vsyshost/shkeeper:2.5.29"; + autoStart = true; + ports = ["5000:5000"]; + volumes = [ + "/home/admin/shkeeper-data:/app/data:Z" + ]; + environment = { + SHKEEPER_HOST = "0.0.0.0"; + SHKEEPER_PORT = "5000"; + }; + extraOptions = [ + "--env-file=/home/admin/shkeeper.env" + "--label=com.centurylinklabs.watchtower.enable=true" + ]; + }; +} diff --git a/servers/hetzner/modules/services/snappymail.nix b/servers/hetzner/modules/services/snappymail.nix new file mode 100644 index 0000000..e41bda2 --- /dev/null +++ b/servers/hetzner/modules/services/snappymail.nix @@ -0,0 +1,28 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.snappymail = { + image = "djmaze/snappymail:latest"; + autoStart = true; + ports = ["127.0.0.1:8888:8888"]; + volumes = [ + "/home/admin/snappymail-data:/var/lib/snappymail:Z" + ]; + environment = { + TZ = "Europe/Berlin"; + }; + extraOptions = [ + "--label=com.centurylinklabs.watchtower.enable=true" + ]; + }; + + # Ensure the persistent data dir exists so podman's :Z relabel (statfs) succeeds on first boot. + # Owned by 82:82 (www-data) because the container's PHP worker runs as UID 82 and must be + # able to write to /var/lib/snappymail (SnappyMail checks is_writable on that path). + systemd.tmpfiles.rules = [ + "d /home/admin/snappymail-data 0755 82 82 - -" + ]; +} diff --git a/servers/hetzner/modules/services/tlsa-updater.nix b/servers/hetzner/modules/services/tlsa-updater.nix new file mode 100644 index 0000000..7e4f257 --- /dev/null +++ b/servers/hetzner/modules/services/tlsa-updater.nix @@ -0,0 +1,99 @@ +{ + config, + pkgs, + lib, + ... +}: let + # Caddy's canonical certificate storage (XDG data dir). Renewals land here, + # owned caddy:caddy 0600 — the mail server's non-root Postfix/Dovecot cannot + # read it directly, so we copy it into a world-readable distribution dir. + caddyCertDir = "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu"; + # World-readable distribution dir mounted (RO) into the mail server container. + distCertDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu"; + zoneFile = "/var/lib/coredns/zones/severijnse.eu.db"; + + syncScript = pkgs.writeShellScript "tlsa-update" '' + set -euo pipefail + OPENSSL="${pkgs.openssl}/bin/openssl" + + SRC_CERT="${caddyCertDir}/mail.severijnse.eu.crt" + SRC_KEY="${caddyCertDir}/mail.severijnse.eu.key" + DST_CERT="${distCertDir}/mail.severijnse.eu.crt" + DST_KEY="${distCertDir}/mail.severijnse.eu.key" + + # Caddy has not obtained the certificate yet (e.g. first boot before HTTP-01). + if [ ! -f "$SRC_CERT" ] || [ ! -f "$SRC_KEY" ]; then + echo "tlsa-update: certificate not found at $caddyCertDir, skipping" >&2 + exit 0 + fi + + # 1) Propagate Caddy's renewed certificate into the distribution dir the + # mail server mounts. Caddy stores certs 0600 caddy:caddy. The cert is + # world-readable (Postfix/Dovecot read it as root before dropping + # privileges); the private key is restricted to root (0640) so it is not + # exposed to other local users. + install -D -m 0644 "$SRC_CERT" "$DST_CERT" + install -D -m 0640 "$SRC_KEY" "$DST_KEY" + + # 2) TLSA 3 1 1 = SHA-256 of the certificate's SubjectPublicKeyInfo (SPKI), + # NOT the whole certificate. Matching type 1 = SHA-256 of the SPKI DER. + HEX=$("$OPENSSL" x509 -in "$DST_CERT" -noout -pubkey 2>/dev/null | "$OPENSSL" pkey -pubin -outform DER 2>/dev/null | "$OPENSSL" dgst -sha256 | cut -d' ' -f2) + + # 3) Update the CoreDNS zone. coredns.service regenerates this file from the + # Nix store on every start, so this unit (which is partOf coredns and runs + # after it) re-applies the correct TLSA after each rebuild/restart. + if [ -f "${zoneFile}" ]; then + sed -i -E "s/^(_25\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}" + sed -i -E "s/^(_465\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}" + sed -i -E "s/^(_993\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}" + chown coredns:coredns "${zoneFile}" + chmod 0640 "${zoneFile}" + fi + + # 4) Reload services so the changes take effect immediately. + systemctl reload coredns.service || true + podman exec mailserver postfix reload || true + podman exec mailserver dovecot reload || true + + echo "tlsa-update: TLSA set to $HEX" + ''; +in { + # Ensure the distribution dir exists (Caddy does not write here). + systemd.tmpfiles.rules = [ + "d ${distCertDir} 0755 root root - -" + ]; + + systemd.services.tlsa-update = { + description = "Sync Caddy TLS certificate to mail server and update DANE/TLSA records"; + after = ["caddy.service" "coredns.service"]; + partOf = ["coredns.service"]; + wantedBy = ["multi-user.target"]; + path = with pkgs; [openssl coreutils gnused podman systemd]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${syncScript}"; + User = "root"; + Group = "root"; + }; + }; + + # Fire as soon as Caddy rewrites the certificate on renewal (the atomic rewrite + # changes the directory mtime), eliminating the up-to-24h DANE drift window. + systemd.paths.tlsa-update = { + description = "Watch Caddy certificate directory for renewal"; + wantedBy = ["paths.target"]; + pathConfig = { + PathModified = [caddyCertDir]; + Unit = "tlsa-update.service"; + }; + }; + + # Fallback in case a renewal event is missed (e.g. inotify overflow). + systemd.timers.tlsa-update = { + wantedBy = ["timers.target"]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + }; +} diff --git a/servers/hetzner/modules/services/vaultwarden.nix b/servers/hetzner/modules/services/vaultwarden.nix new file mode 100644 index 0000000..dfae4f3 --- /dev/null +++ b/servers/hetzner/modules/services/vaultwarden.nix @@ -0,0 +1,22 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.vaultwarden = { + image = "vaultwarden/server:latest"; + autoStart = true; + ports = ["1001:80"]; + volumes = [ + "/home/admin/vw-data:/data:Z" + ]; + environment = { + DOMAIN = "https://vault.severijnse.eu"; + LOG_FILE = "/data/vaultwarden.log"; + }; + extraOptions = [ + "--label=com.centurylinklabs.watchtower.enable=true" + ]; + }; +} diff --git a/servers/hetzner/modules/services/watchtower.nix b/servers/hetzner/modules/services/watchtower.nix new file mode 100644 index 0000000..a938ff9 --- /dev/null +++ b/servers/hetzner/modules/services/watchtower.nix @@ -0,0 +1,22 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.watchtower = { + image = "ghcr.io/nicholas-fedor/watchtower:latest"; + autoStart = true; + volumes = [ + "/var/run/podman/podman.sock:/var/run/docker.sock:ro" + ]; + environment = { + WATCHTOWER_CLEANUP = "true"; + WATCHTOWER_POLL_INTERVAL = "86400"; + WATCHTOWER_INCLUDE_STOPPED = "true"; + WATCHTOWER_REVIVE_STOPPED = "true"; + TZ = "Europe/Amsterdam"; + }; + cmd = ["--label-enable"]; + }; +} diff --git a/servers/hetzner/modules/services/wireguard.nix b/servers/hetzner/modules/services/wireguard.nix new file mode 100644 index 0000000..a1b7bb4 --- /dev/null +++ b/servers/hetzner/modules/services/wireguard.nix @@ -0,0 +1,27 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.wg-easy = { + image = "ghcr.io/wg-easy/wg-easy:latest"; + autoStart = true; + volumes = [ + "/home/admin/config:/etc/wireguard:Z" + ]; + environment = { + WG_HOST = "severijnse.eu"; + PASSWORD_HASH = "$2a$12$b3n4drXgS3B6ubMZxxjPUOQ1XktZ1EuDwm4AIdVulhtoD7b1.WQGC"; + WG_DEFAULT_ADDRESS = "10.8.0.x"; + WG_DEFAULT_DNS = "1.1.1.1"; + WG_PORT = "51820"; + }; + extraOptions = [ + "--cap-add=NET_ADMIN" + "--cap-add=SYS_MODULE" + "--cap-add=NET_RAW" + "--network=host" + ]; + }; +} diff --git a/servers/hetzner/modules/services/wrxproxy.nix b/servers/hetzner/modules/services/wrxproxy.nix new file mode 100644 index 0000000..3cd8478 --- /dev/null +++ b/servers/hetzner/modules/services/wrxproxy.nix @@ -0,0 +1,18 @@ +{ + config, + pkgs, + lib, + ... +}: { + virtualisation.oci-containers.containers.wrxproxy = { + image = "localhost/wrxproxy:latest"; + autoStart = false; + ports = ["81:81"]; + volumes = [ + "/home/admin/WRXproxy/config.json:/app/config.json:ro" + ]; + extraOptions = [ + "--label=com.centurylinklabs.watchtower.enable=true" + ]; + }; +} diff --git a/servers/hetzner/modules/system/env.nix b/servers/hetzner/modules/system/env.nix new file mode 100644 index 0000000..2c88d9f --- /dev/null +++ b/servers/hetzner/modules/system/env.nix @@ -0,0 +1,28 @@ +{ + config, + pkgs, + lib, + ... +}: { + time.timeZone = "Europe/Amsterdam"; + + i18n.defaultLocale = "en_US.UTF-8"; + + environment.systemPackages = with pkgs; [ + vim + git + curl + wget + htop + iotop + btop + bind.dnsutils # provides dig, nslookup, host + jq + yq + fish + podman-compose + ]; + + programs.fish.enable = true; + programs.bash.enableCompletion = true; +} diff --git a/servers/hetzner/secrets/.sops.yaml b/servers/hetzner/secrets/.sops.yaml new file mode 100644 index 0000000..d0cfc4d --- /dev/null +++ b/servers/hetzner/secrets/.sops.yaml @@ -0,0 +1,11 @@ +keys: + - &admin age1yd59qp5km4cxt99rlfjehnsucrjn9lmj0su4h3avhf6vrtjvnyjqstldl5 + - &local age15rhqdpwejyf3r6ww70qgv6hqmkpsqraakn26kc49wlauhaceaeqsmuwrdd + - &server age1xekdrkjfu82hkxltydm72tllzgayyvfavvudeks3xjuujm5wt5hq6g55v0 +creation_rules: + - path_regex: secrets/.*\.yaml + key_groups: + - age: + - *admin + - *local + - *server diff --git a/servers/hetzner/secrets/README.md b/servers/hetzner/secrets/README.md new file mode 100644 index 0000000..86cc91e --- /dev/null +++ b/servers/hetzner/secrets/README.md @@ -0,0 +1,26 @@ +# Secrets + +This directory contains encrypted secrets managed with sops-nix. + +## Setup + +1. Generate an age key on the target machine: + ``` + mkdir -p /etc/age + age-keygen -o /etc/age/keys.txt + ``` + +2. Add the public key to `.sops.yaml` + +3. Create/edit secrets: + ``` + sops secrets/mail-accounts.yaml + sops secrets/env.yaml + ``` + +## Files + +- `env.yaml` - Environment variables (WG_HOST, PASSWORD_HASH, etc.) +- `mail-accounts.yaml` - Mail server account credentials +- `wireguard-keys.yaml` - WireGuard private keys +- `ssh-keys.yaml` - SSH private keys (mail.private, etc.) diff --git a/servers/hetzner/secrets/coredns-keys.yaml b/servers/hetzner/secrets/coredns-keys.yaml new file mode 100644 index 0000000..ba34dbf --- /dev/null +++ b/servers/hetzner/secrets/coredns-keys.yaml @@ -0,0 +1,37 @@ +coredns_ksk_key: ENC[AES256_GCM,data:mPjyv74ujSVnbJ6cjFdO7XtkThDv4clF+K+d485njvvx7IBrC4t+DMMtJxqL7qad8Cf4g/vx3cO0dsB8UbsbuyVAEPpgmGnqTJ+Ec4zbP73g7u4W/HcgT+ZcGY2fzuz/K7uETRN852KfE6RIKkTZkOfBfo1YsOglpRTiSQMh4Jnso9Nihdh7hfbQQ0yXjhQMhGFTUGYR4AQM3ZUFkjY2hN0Ys34Tbwg4HnHwcmWMnwGj7Ab29HU/UP//AAb1rlUNcde6MuWAGlObj6P/poxjeVyWL7lfAveAEqZiHyUDUE2939cCROT1TLZ+f3t2hinK4bzTfp3n9fxhVt4LG7WnDDms97+7M9rI2dVSxDnDTAElyU8NGlYmDcUOOzeTBccxnVegUuRNHMhT2Hrut3K+TGgpTdDNA93PXNB/cCodvNDWtZGrZuRtANZPVMTe7E/3fiw6urrxR59G,iv:fByY/Z4kzxAFXobtrV8Y59IdqzN4W/HsvEPGOhBGI+E=,tag:FoB4jrxsLXNKnhFykX6F/g==,type:str] +coredns_ksk_private: ENC[AES256_GCM,data:yaVhVVR1QHXshePY2x9qbOJszl3tj3ne71W7TP+JG2gF1BiU+EBp8QJNQBcj58sFwun88RPIUK5OUgJ/n9dyEWOCf3jfDO4yRD7XLqJVp3tf15Ou2FytHd7hg52id+1gJxSRmhxHWw5of1v9Jo6cocET6tnVnygsPmxJEufoW3DyFZrPscba7rsJhpjJNINEcvgcD44ySy47gisA2s1oIgXp/UJmoZ/H6IXm9/HL66bvfl0ubZ0rxa0Rlg==,iv:pXl8ppb1Pxl5mRBY2sa595ChSAD/zdbKK+H4xeeMG+I=,tag:ez8xqxXfzf3U15YHbaxUZg==,type:str] +coredns_zsk_key: ENC[AES256_GCM,data:AdGcUU1qjdRSlI1j6LJGsrqXgUqMfHJGETaVJETPN8+JEjdHvLXCpuiqnJE20hqXDi6gIrBaUQMruJG2KfSZTfxSPoqIYoAlbBQBpTsGwpFazWIsggMJNlqUYKSnBr4QsBTj6A/IZYR4KzcJX+vo7pL0nLz+bpH1/o6ry7lghqt7sIjS9Nx1CKW6WpMPePjBViGp8i5jQWijsUCj+DRjQatycBKhpQWCqlYqn+CM99/QWjy/qXWFmgBiiFjQ8lT1AjOIIL0Icger06ReWCl2387xB/JqHbtsq65WOLuJiyxBBw02wOQz09UaKsDsoM59w3SINKqK+YyCkonHXycLuMAuE7tasHVRPEdWsr6os18cystLQO8qp1oVSGtUSVM07TkM8OowMlOmN44veIkgON4s8pN7S9V9N46N0BSC6w7A3u2VqCfAgPLUjPWNieIfir8P3p+gQSPWuw==,iv:Kvc8N+fp2rVd1TTsZHbk67fL2nOlGhqDE+TvH+aU0VE=,tag:1wkgkV1gqddhkkOs1NoztA==,type:str] +coredns_zsk_private: ENC[AES256_GCM,data:BZxC5iYY8wh6kXbXpNheVrvBh6TB0968ivw06sJ1driytCt3RK6ZtNcr7PMxjxbKCdZKllLIdCABJDrj8eQCLLLMRspYVXLWquJvLHCCGqteY+Ha4b5bdsUO64I+HkDGagZSj7mIsaD7+aGOI2FN4bFhd364qGc1hGACePBUWlUi8AHSQY9KgadXBX2+ud6BIsoyoEN8AhjNK6zSADZYeA3JMJ1HTP1MbAB6owbmQMieunMHwudi6fi6eQ==,iv:lSjLOF6u1gLUv2IQ6rqG9nNCkFa6Yruknx0Lc+E6vzY=,tag:QQv1+47UtX+ZxrYwsrHLSg==,type:str] +sops: + age: + - recipient: age1yd59qp5km4cxt99rlfjehnsucrjn9lmj0su4h3avhf6vrtjvnyjqstldl5 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSUVZKYWFmSU5xbWo2N0Qy + c0VPeVIxQ1lNMTB5VkI4dVdsTXlkSEo3azN3CjJtR0lqclZOUjFkVjFWSDM2SGF1 + OWNKcFNFSGpzd3A3MUZyS09CUWUyMlEKLS0tIG5VdmU1b0YwdWxUTXBzaGR2d2tR + UjI1K1pYaC9pTnNNcldXQ3B3endUTDgKHISJEvZrn6VO/voH2ORzeJbpVjpX8MxP + qGJ+G7sMM8trCOpkR3wjqSaZVU1hric+0R5oHiMKDLcb3hGN9mgJFA== + -----END AGE ENCRYPTED FILE----- + - recipient: age15rhqdpwejyf3r6ww70qgv6hqmkpsqraakn26kc49wlauhaceaeqsmuwrdd + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5RzlaWXU3ZVo0Y0JiV0xk + RlBYR3RHdklSdVhoY0UzYmFxeGl3YkFITW5BCmhITk9DMjRmekFyd0hQbnRCWEY5 + NFRlUkdRT2lyZzdxbFJxelAyY1ZLYm8KLS0tIEdWc0lOQWU1ZFdHQjVIeTFVdGpH + WWxTeW96TC9UWTRMOXdkNVNzd1Rra0kKzkam9SYaIbkSOE+g2FIzIV7nyDHHt+gk + Uvhb6dvKW3omRYlh5ln7xDRcHiX83rz/TfPh2GdtXs/J0Oxkeo7drg== + -----END AGE ENCRYPTED FILE----- + - recipient: age1xekdrkjfu82hkxltydm72tllzgayyvfavvudeks3xjuujm5wt5hq6g55v0 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWWTc0ZUJrL0dmallFb2Nv + TVR0VU9ZdGpzK3FNdjlaR1pmc3RucGZLcXhrClhmWHNjQk5kZ1ZBT2xYek9JM0x1 + M0krWE8yUVVZMmVCbk83TGhMSUoyVWsKLS0tIDRocWI3L1ZVMmppSU1rUytDTDJz + dm5SYXc0VTdWSE1hQ2JtczdUenF1RUkKNfLtmhzZBFqGJKT4MMMVdKG/MmqNqUgF + DFPkw2uLlPTW1Wws37ac9u53G7AfcE1zRHiIzGYz9O2Cqc+xKjRpDQ== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-07-10T13:18:54Z" + mac: ENC[AES256_GCM,data:La/qXs87taoMI4KT3Mnb/MMyVshP3c+sgS5jzSZSGhsT02pOsE3AvG8UFlgcz2gZvD0aTsm3P54B3bHjq61OCcYOKAcS9dJjqDwC91utRf3CL+1hezIJUUjXNMUMTwVbRmNPuP6gZMKsdqztkAqvCxRwqXQWkC10ds2IfzKCQD4=,iv:e/LTO8wzKDw2DP4b/HdSTakcz7lPkLGqcdeU6N5dDDA=,tag:ASw3jQj7mrcVrOdhjlbwng==,type:str] + unencrypted_suffix: _unencrypted + version: 3.12.2 diff --git a/system/core/boot.nix b/system/core/boot.nix index a00b5a8..b7cf286 100644 --- a/system/core/boot.nix +++ b/system/core/boot.nix @@ -1,12 +1,15 @@ -{ config, pkgs, lib, ... }: - { + config, + pkgs, + lib, + ... +}: { boot = { bootspec.enable = true; initrd.systemd.enable = true; - supportedFilesystems = [ "ntfs" ]; + supportedFilesystems = ["ntfs"]; kernelPackages = pkgs.linuxPackages_latest; diff --git a/system/core/limine.nix b/system/core/limine.nix index 8d1ae6f..109e066 100644 --- a/system/core/limine.nix +++ b/system/core/limine.nix @@ -1,6 +1,8 @@ -{ lib, pkgs, ... }: - { + lib, + pkgs, + ... +}: { boot.loader = { limine = { enable = true; diff --git a/system/default.nix b/system/default.nix index 1c6cf95..704585e 100644 --- a/system/default.nix +++ b/system/default.nix @@ -15,10 +15,10 @@ let # GUI-specific modules (display manager, GPU, pipewire) gui = [ - ./core/boot.nix # plymouth boot splash - ./hardware/graphics.nix # GPU drivers - ./services/greetd.nix # display manager - ./services/pipewire.nix # audio + ./core/boot.nix # plymouth boot splash + ./hardware/graphics.nix # GPU drivers + ./services/greetd.nix # display manager + ./services/pipewire.nix # audio ]; # Laptop-specific modules (battery, bluetooth) diff --git a/system/network/avahi.nix b/system/network/avahi.nix index fe67571..8f810aa 100644 --- a/system/network/avahi.nix +++ b/system/network/avahi.nix @@ -3,7 +3,7 @@ services.avahi = { enable = true; nssmdns4 = true; - denyInterfaces = [ "docker0" "veth*" "virbr*" ]; + denyInterfaces = ["docker0" "veth*" "virbr*"]; publish = { enable = true; domain = true; diff --git a/system/programs/xdg.nix b/system/programs/xdg.nix index 223706a..87407a8 100644 --- a/system/programs/xdg.nix +++ b/system/programs/xdg.nix @@ -5,10 +5,12 @@ }: let # Patch termfilechooser .portal to add Niri to UseIn termfilechooser-niri = pkgs.xdg-desktop-portal-termfilechooser.overrideAttrs (old: { - postFixup = (old.postFixup or "") + '' - substituteInPlace $out/share/xdg-desktop-portal/portals/termfilechooser.portal \ - --replace-fail "wlroots;" "wlroots;Niri;" - ''; + postFixup = + (old.postFixup or "") + + '' + substituteInPlace $out/share/xdg-desktop-portal/portals/termfilechooser.portal \ + --replace-fail "wlroots;" "wlroots;Niri;" + ''; }); in { xdg.portal = { diff --git a/system/services/pipewire.nix b/system/services/pipewire.nix index 09d5f38..6493cb3 100644 --- a/system/services/pipewire.nix +++ b/system/services/pipewire.nix @@ -5,7 +5,7 @@ alsa.support32Bit = true; jack.enable = true; pulse.enable = true; - extraLadspaPackages = [ pkgs.rnnoise-plugin ]; + extraLadspaPackages = [pkgs.rnnoise-plugin]; wireplumber = { enable = true;