From 39a4ea98abf41b08bd3fbbed8c2392d111be1f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 6 Feb 2026 00:12:16 +0100 Subject: [PATCH 1/5] system: add nixos-rebuild-test helper script Adds a helper script deployed to all hosts for testing feature branches. Usage: nixos-rebuild-test Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 ++ system/nix.nix | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3566bad..8396ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -402,6 +402,8 @@ This means: **Firewall**: Disabled on most hosts (trusted network). Enable selectively in host configuration if needed. +**Shell scripts**: Use `pkgs.writeShellApplication` instead of `pkgs.writeShellScriptBin` for creating shell scripts. `writeShellApplication` provides automatic shellcheck validation, sets strict bash options (`set -euo pipefail`), and allows declaring `runtimeInputs` for dependencies. + ### Monitoring Stack All hosts ship metrics and logs to `monitoring01`: diff --git a/system/nix.nix b/system/nix.nix index 3ab9094..f50d35c 100644 --- a/system/nix.nix +++ b/system/nix.nix @@ -1,5 +1,25 @@ -{ lib, ... }: +{ lib, pkgs, ... }: +let + nixos-rebuild-test = pkgs.writeShellApplication { + name = "nixos-rebuild-test"; + runtimeInputs = [ pkgs.nixos-rebuild ]; + text = '' + if [ $# -lt 2 ]; then + echo "Usage: nixos-rebuild-test " + echo "Example: nixos-rebuild-test boot my-feature-branch" + exit 1 + fi + + action="$1" + branch="$2" + shift 2 + + exec nixos-rebuild "$action" --flake "git+https://git.t-juice.club/torjus/nixos-servers.git?ref=$branch" "$@" + ''; + }; +in { + environment.systemPackages = [ nixos-rebuild-test ]; nix = { gc = { automatic = true; -- 2.49.1 From 879e7aba603f6dc9efec06c56ed9b2af0736ecf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 6 Feb 2026 00:14:05 +0100 Subject: [PATCH 2/5] templates: use writeShellApplication for prepare-host script Co-Authored-By: Claude Opus 4.5 --- hosts/template/scripts.nix | 9 ++++++--- hosts/template2/scripts.nix | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/hosts/template/scripts.nix b/hosts/template/scripts.nix index 9ee1e75..f6209e6 100644 --- a/hosts/template/scripts.nix +++ b/hosts/template/scripts.nix @@ -1,7 +1,9 @@ { pkgs, ... }: let - prepare-host-script = pkgs.writeShellScriptBin "prepare-host.sh" - '' + prepare-host-script = pkgs.writeShellApplication { + name = "prepare-host.sh"; + runtimeInputs = [ pkgs.age ]; + text = '' echo "Removing machine-id" rm -f /etc/machine-id || true @@ -24,8 +26,9 @@ let echo "Generate age key" rm -rf /var/lib/sops-nix || true mkdir -p /var/lib/sops-nix - ${pkgs.age}/bin/age-keygen -o /var/lib/sops-nix/key.txt + age-keygen -o /var/lib/sops-nix/key.txt ''; + }; in { environment.systemPackages = [ prepare-host-script ]; diff --git a/hosts/template2/scripts.nix b/hosts/template2/scripts.nix index 9ee1e75..f6209e6 100644 --- a/hosts/template2/scripts.nix +++ b/hosts/template2/scripts.nix @@ -1,7 +1,9 @@ { pkgs, ... }: let - prepare-host-script = pkgs.writeShellScriptBin "prepare-host.sh" - '' + prepare-host-script = pkgs.writeShellApplication { + name = "prepare-host.sh"; + runtimeInputs = [ pkgs.age ]; + text = '' echo "Removing machine-id" rm -f /etc/machine-id || true @@ -24,8 +26,9 @@ let echo "Generate age key" rm -rf /var/lib/sops-nix || true mkdir -p /var/lib/sops-nix - ${pkgs.age}/bin/age-keygen -o /var/lib/sops-nix/key.txt + age-keygen -o /var/lib/sops-nix/key.txt ''; + }; in { environment.systemPackages = [ prepare-host-script ]; -- 2.49.1 From bbb22e588e89ef764cec531531e6c4c19f7e3d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 6 Feb 2026 00:17:24 +0100 Subject: [PATCH 3/5] system: replace writeShellScript with writeShellApplication Convert remaining writeShellScript usages to writeShellApplication for shellcheck validation and strict bash options. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- hosts/vaulttest01/configuration.nix | 48 +++++++++-------- services/monitoring/prometheus.nix | 62 +++++++++++----------- system/vault-secrets.nix | 81 +++++++++++++++-------------- 4 files changed, 103 insertions(+), 90 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8396ca0..7eb92c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -402,7 +402,7 @@ This means: **Firewall**: Disabled on most hosts (trusted network). Enable selectively in host configuration if needed. -**Shell scripts**: Use `pkgs.writeShellApplication` instead of `pkgs.writeShellScriptBin` for creating shell scripts. `writeShellApplication` provides automatic shellcheck validation, sets strict bash options (`set -euo pipefail`), and allows declaring `runtimeInputs` for dependencies. +**Shell scripts**: Use `pkgs.writeShellApplication` instead of `pkgs.writeShellScript` or `pkgs.writeShellScriptBin` for creating shell scripts. `writeShellApplication` provides automatic shellcheck validation, sets strict bash options (`set -euo pipefail`), and allows declaring `runtimeInputs` for dependencies. ### Monitoring Stack diff --git a/hosts/vaulttest01/configuration.nix b/hosts/vaulttest01/configuration.nix index 96baf1e..b315e09 100644 --- a/hosts/vaulttest01/configuration.nix +++ b/hosts/vaulttest01/configuration.nix @@ -5,6 +5,32 @@ ... }: +let + vault-test-script = pkgs.writeShellApplication { + name = "vault-test"; + text = '' + echo "=== Vault Secret Test ===" + echo "Secret path: hosts/vaulttest01/test-service" + + if [ -f /run/secrets/test-service/password ]; then + echo "✓ Password file exists" + echo "Password length: $(wc -c < /run/secrets/test-service/password)" + else + echo "✗ Password file missing!" + exit 1 + fi + + if [ -d /var/lib/vault/cache/test-service ]; then + echo "✓ Cache directory exists" + else + echo "✗ Cache directory missing!" + exit 1 + fi + + echo "Test successful!" + ''; + }; +in { imports = [ ../template2/hardware-configuration.nix @@ -79,27 +105,7 @@ Type = "oneshot"; RemainAfterExit = true; - ExecStart = pkgs.writeShellScript "vault-test" '' - echo "=== Vault Secret Test ===" - echo "Secret path: hosts/vaulttest01/test-service" - - if [ -f /run/secrets/test-service/password ]; then - echo "✓ Password file exists" - echo "Password length: $(wc -c < /run/secrets/test-service/password)" - else - echo "✗ Password file missing!" - exit 1 - fi - - if [ -d /var/lib/vault/cache/test-service ]; then - echo "✓ Cache directory exists" - else - echo "✗ Cache directory missing!" - exit 1 - fi - - echo "Test successful!" - ''; + ExecStart = lib.getExe vault-test-script; StandardOutput = "journal+console"; }; diff --git a/services/monitoring/prometheus.nix b/services/monitoring/prometheus.nix index e0fe2c2..b95033c 100644 --- a/services/monitoring/prometheus.nix +++ b/services/monitoring/prometheus.nix @@ -7,42 +7,44 @@ let autoScrapeConfigs = monLib.generateScrapeConfigs self externalTargets; # Script to fetch AppRole token for Prometheus to use when scraping OpenBao metrics - fetchOpenbaoToken = pkgs.writeShellScript "fetch-openbao-token" '' - set -euo pipefail + fetchOpenbaoToken = pkgs.writeShellApplication { + name = "fetch-openbao-token"; + runtimeInputs = [ pkgs.curl pkgs.jq ]; + text = '' + VAULT_ADDR="https://vault01.home.2rjus.net:8200" + APPROLE_DIR="/var/lib/vault/approle" + OUTPUT_FILE="/run/secrets/prometheus/openbao-token" - VAULT_ADDR="https://vault01.home.2rjus.net:8200" - APPROLE_DIR="/var/lib/vault/approle" - OUTPUT_FILE="/run/secrets/prometheus/openbao-token" + # Read AppRole credentials + if [ ! -f "$APPROLE_DIR/role-id" ] || [ ! -f "$APPROLE_DIR/secret-id" ]; then + echo "AppRole credentials not found at $APPROLE_DIR" >&2 + exit 1 + fi - # Read AppRole credentials - if [ ! -f "$APPROLE_DIR/role-id" ] || [ ! -f "$APPROLE_DIR/secret-id" ]; then - echo "AppRole credentials not found at $APPROLE_DIR" >&2 - exit 1 - fi + ROLE_ID=$(cat "$APPROLE_DIR/role-id") + SECRET_ID=$(cat "$APPROLE_DIR/secret-id") - ROLE_ID=$(cat "$APPROLE_DIR/role-id") - SECRET_ID=$(cat "$APPROLE_DIR/secret-id") + # Authenticate to Vault + AUTH_RESPONSE=$(curl -sf -k -X POST \ + -d "{\"role_id\":\"$ROLE_ID\",\"secret_id\":\"$SECRET_ID\"}" \ + "$VAULT_ADDR/v1/auth/approle/login") - # Authenticate to Vault - AUTH_RESPONSE=$(${pkgs.curl}/bin/curl -sf -k -X POST \ - -d "{\"role_id\":\"$ROLE_ID\",\"secret_id\":\"$SECRET_ID\"}" \ - "$VAULT_ADDR/v1/auth/approle/login") + # Extract token + VAULT_TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.auth.client_token') + if [ -z "$VAULT_TOKEN" ] || [ "$VAULT_TOKEN" = "null" ]; then + echo "Failed to extract Vault token from response" >&2 + exit 1 + fi - # Extract token - VAULT_TOKEN=$(echo "$AUTH_RESPONSE" | ${pkgs.jq}/bin/jq -r '.auth.client_token') - if [ -z "$VAULT_TOKEN" ] || [ "$VAULT_TOKEN" = "null" ]; then - echo "Failed to extract Vault token from response" >&2 - exit 1 - fi + # Write token to file + mkdir -p "$(dirname "$OUTPUT_FILE")" + echo -n "$VAULT_TOKEN" > "$OUTPUT_FILE" + chown prometheus:prometheus "$OUTPUT_FILE" + chmod 0400 "$OUTPUT_FILE" - # Write token to file - mkdir -p "$(dirname "$OUTPUT_FILE")" - echo -n "$VAULT_TOKEN" > "$OUTPUT_FILE" - chown prometheus:prometheus "$OUTPUT_FILE" - chmod 0400 "$OUTPUT_FILE" - - echo "Successfully fetched OpenBao token" - ''; + echo "Successfully fetched OpenBao token" + ''; + }; in { # Systemd service to fetch AppRole token for Prometheus OpenBao scraping diff --git a/system/vault-secrets.nix b/system/vault-secrets.nix index 4b8c3c2..60466dd 100644 --- a/system/vault-secrets.nix +++ b/system/vault-secrets.nix @@ -8,6 +8,48 @@ let # Import vault-fetch package vault-fetch = pkgs.callPackage ../scripts/vault-fetch { }; + # Helper to create fetch scripts using writeShellApplication + mkFetchScript = name: secretCfg: pkgs.writeShellApplication { + name = "fetch-${name}"; + runtimeInputs = [ vault-fetch ]; + text = '' + # Set Vault environment variables + export VAULT_ADDR="${cfg.vaultAddress}" + export VAULT_SKIP_VERIFY="${if cfg.skipTlsVerify then "1" else "0"}" + '' + (if secretCfg.extractKey != null then '' + # Fetch to temporary directory, then extract single key + TMPDIR=$(mktemp -d) + trap 'rm -rf $TMPDIR' EXIT + + vault-fetch \ + "${secretCfg.secretPath}" \ + "$TMPDIR" \ + "${secretCfg.cacheDir}" + + # Extract the specified key and write as a single file + if [ ! -f "$TMPDIR/${secretCfg.extractKey}" ]; then + echo "ERROR: Key '${secretCfg.extractKey}' not found in secret" >&2 + exit 1 + fi + + # Ensure parent directory exists + mkdir -p "$(dirname "${secretCfg.outputDir}")" + cp "$TMPDIR/${secretCfg.extractKey}" "${secretCfg.outputDir}" + chown ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}" + chmod ${secretCfg.mode} "${secretCfg.outputDir}" + '' else '' + # Fetch secret as directory of files + vault-fetch \ + "${secretCfg.secretPath}" \ + "${secretCfg.outputDir}" \ + "${secretCfg.cacheDir}" + + # Set ownership and permissions + chown -R ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}" + chmod ${secretCfg.mode} "${secretCfg.outputDir}"/* + ''); + }; + # Secret configuration type secretType = types.submodule ({ name, config, ... }: { options = { @@ -162,44 +204,7 @@ in RemainAfterExit = true; # Fetch the secret - ExecStart = pkgs.writeShellScript "fetch-${name}" ('' - set -euo pipefail - - # Set Vault environment variables - export VAULT_ADDR="${cfg.vaultAddress}" - export VAULT_SKIP_VERIFY="${if cfg.skipTlsVerify then "1" else "0"}" - '' + (if secretCfg.extractKey != null then '' - # Fetch to temporary directory, then extract single key - TMPDIR=$(mktemp -d) - trap "rm -rf $TMPDIR" EXIT - - ${vault-fetch}/bin/vault-fetch \ - "${secretCfg.secretPath}" \ - "$TMPDIR" \ - "${secretCfg.cacheDir}" - - # Extract the specified key and write as a single file - if [ ! -f "$TMPDIR/${secretCfg.extractKey}" ]; then - echo "ERROR: Key '${secretCfg.extractKey}' not found in secret" >&2 - exit 1 - fi - - # Ensure parent directory exists - mkdir -p "$(dirname "${secretCfg.outputDir}")" - cp "$TMPDIR/${secretCfg.extractKey}" "${secretCfg.outputDir}" - chown ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}" - chmod ${secretCfg.mode} "${secretCfg.outputDir}" - '' else '' - # Fetch secret as directory of files - ${vault-fetch}/bin/vault-fetch \ - "${secretCfg.secretPath}" \ - "${secretCfg.outputDir}" \ - "${secretCfg.cacheDir}" - - # Set ownership and permissions - chown -R ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}" - chmod ${secretCfg.mode} "${secretCfg.outputDir}"/* - '')); + ExecStart = lib.getExe (mkFetchScript name secretCfg); # Logging StandardOutput = "journal"; -- 2.49.1 From eba195c192c2b220757a54ba3f6ce6fba1625211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 6 Feb 2026 00:19:49 +0100 Subject: [PATCH 4/5] docs: add nixos-rebuild-test usage to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7eb92c6..b32b5d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,21 @@ nix build .#create-host Do not automatically deploy changes. Deployments are usually done by updating the master branch, and then triggering the auto update on the specific host. +### Testing Feature Branches on Hosts + +All hosts have the `nixos-rebuild-test` helper script for testing feature branches before merging: + +```bash +# On the target host, test a feature branch +nixos-rebuild-test boot +nixos-rebuild-test switch + +# Additional arguments are passed through to nixos-rebuild +nixos-rebuild-test boot my-feature --show-trace +``` + +When working on a feature branch that requires testing on a live host, suggest using this command instead of the full flake URL syntax. + ### Flake Management ```bash -- 2.49.1 From 258e350b898ef0fb6203b584aeec4fc1ce31835a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 6 Feb 2026 00:26:01 +0100 Subject: [PATCH 5/5] system: add MOTD banner with hostname and commit info Displays FQDN and flake commit hash with timestamp on login. Templates can override with their own MOTD via mkDefault. Co-Authored-By: Claude Opus 4.5 --- system/default.nix | 1 + system/motd.nix | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 system/motd.nix diff --git a/system/default.nix b/system/default.nix index c5aedc3..d440db3 100644 --- a/system/default.nix +++ b/system/default.nix @@ -4,6 +4,7 @@ ./acme.nix ./autoupgrade.nix ./monitoring + ./motd.nix ./packages.nix ./nix.nix ./root-user.nix diff --git a/system/motd.nix b/system/motd.nix new file mode 100644 index 0000000..29e7cd1 --- /dev/null +++ b/system/motd.nix @@ -0,0 +1,28 @@ +{ config, lib, self, ... }: + +let + hostname = config.networking.hostName; + domain = config.networking.domain or ""; + fqdn = if domain != "" then "${hostname}.${domain}" else hostname; + + # Get commit hash (handles both clean and dirty trees) + shortRev = self.shortRev or self.dirtyShortRev or "unknown"; + + # Format timestamp from lastModified (Unix timestamp) + # lastModifiedDate is in format "YYYYMMDDHHMMSS" + dateStr = self.sourceInfo.lastModifiedDate or "unknown"; + formattedDate = if dateStr != "unknown" then + "${builtins.substring 0 4 dateStr}-${builtins.substring 4 2 dateStr}-${builtins.substring 6 2 dateStr} ${builtins.substring 8 2 dateStr}:${builtins.substring 10 2 dateStr}" + else + "unknown"; + + banner = '' + #################################### + ${fqdn} + Commit: ${shortRev} (${formattedDate}) + #################################### + ''; +in +{ + users.motd = lib.mkDefault banner; +} -- 2.49.1