diff --git a/.claude/worktrees/scripts-dedup b/.claude/worktrees/scripts-dedup new file mode 160000 index 0000000..e578443 --- /dev/null +++ b/.claude/worktrees/scripts-dedup @@ -0,0 +1 @@ +Subproject commit e578443914c356841a26694f5cf713241d2cd51b diff --git a/.gitea/workflows/check-nixos.yml b/.gitea/workflows/check-nixos.yml new file mode 100644 index 0000000..a16ddf2 --- /dev/null +++ b/.gitea/workflows/check-nixos.yml @@ -0,0 +1,29 @@ +name: Check NixOS configurations + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + eval-hosts: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v19 + + # Scoped to files changed since the PR base / previous push -- see + # scripts/codex-maintenance.sh. CI never passes --full-check: that + # full sweep is for local/manual use, since it's slow enough to time + # out this runner. + - name: Run maintenance checks (secrets, fmt, lint, eval -- changed files only) + env: + MAINT_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: bash scripts/codex-maintenance.sh diff --git a/.gitea/workflows/docs-build.yml b/.gitea/workflows/docs-build.yml deleted file mode 100644 index a373b53..0000000 --- a/.gitea/workflows/docs-build.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Documentation Build - -on: - push: - paths: - - "docs/**" - - "stacks/**" - - "ansible/inventory/**" - - "mkdocs.yml" - - "scripts/docs-build.sh" - workflow_dispatch: - -jobs: - generate-and-build: - name: Generate + build docs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - pip install mkdocs mkdocs-material mkdocs-awesome-pages-plugin - pip install jinja2 pyyaml - - - name: Generate dynamic content - run: ./scripts/docs-build.sh --generate-only - - - name: Build internal docs - run: mkdocs build --config-file docs/mkdocs.yml --site-dir site/internal - - - name: Build external docs - run: mkdocs build --config-file docs/mkdocs-external.yml --site-dir site/external - - - name: Deploy internal docs - if: github.ref == 'refs/heads/main' - # Deploy to Gitea Pages or internal server — configure PAGES_URL in secrets - env: - PAGES_SSH_KEY: ${{ secrets.PAGES_SSH_KEY }} - PAGES_HOST: ${{ secrets.PAGES_HOST }} - PAGES_PATH: ${{ secrets.PAGES_PATH_INTERNAL }} - run: | - if [ -n "$PAGES_SSH_KEY" ]; then - install -m 600 /dev/null /tmp/pages_key - echo "$PAGES_SSH_KEY" > /tmp/pages_key - rsync -avz --delete -e "ssh -i /tmp/pages_key -o StrictHostKeyChecking=no" \ - site/internal/ "${PAGES_HOST}:${PAGES_PATH}/" - else - echo "PAGES_SSH_KEY not configured — skipping deploy" - fi - - - name: Upload docs artifacts - uses: actions/upload-artifact@v4 - with: - name: docs-site - path: site/ - retention-days: 7 diff --git a/.gitea/workflows/drift-detect.yml b/.gitea/workflows/drift-detect.yml deleted file mode 100644 index ca8011b..0000000 --- a/.gitea/workflows/drift-detect.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Drift Detection - -on: - schedule: - # Daily at 06:00 AEST (20:00 UTC previous day) - - cron: "0 20 * * *" - workflow_dispatch: - inputs: - target: - description: "Which layer to check: all | terraform | ansible" - required: false - default: "all" - -env: - TF_IN_AUTOMATION: "true" - -jobs: - terraform-drift: - name: Terraform — detect drift - runs-on: ubuntu-latest - if: ${{ github.event.inputs.target == 'all' || github.event.inputs.target == 'terraform' || github.event_name == 'schedule' }} - steps: - - uses: actions/checkout@v4 - - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: "~1.9" - - - name: Terraform init + plan (proxmox) - id: tf_proxmox - env: - TF_VAR_proxmox_endpoint: ${{ secrets.PROXMOX_ENDPOINT }} - TF_VAR_proxmox_api_token_id: ${{ secrets.PROXMOX_API_TOKEN_ID }} - TF_VAR_proxmox_api_token_secret: ${{ secrets.PROXMOX_API_TOKEN_SECRET }} - run: | - cd terraform/proxmox - terraform init -input=false - set +e - terraform plan -detailed-exitcode -input=false -no-color -out=plan.out 2>&1 | tee plan.log - PLAN_EXIT=${PIPESTATUS[0]} - set -e - if [ "$PLAN_EXIT" -eq 2 ]; then - echo "drift=true" >> "$GITHUB_OUTPUT" - echo "### Proxmox drift detected" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - cat plan.log >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - elif [ "$PLAN_EXIT" -eq 0 ]; then - echo "drift=false" >> "$GITHUB_OUTPUT" - echo "Proxmox: no drift" >> "$GITHUB_STEP_SUMMARY" - else - exit $PLAN_EXIT - fi - - - name: Terraform init + plan (dns) - id: tf_dns - env: - TF_VAR_dynu_api_key: ${{ secrets.DYNU_API_KEY }} - run: | - cd terraform/dns - terraform init -input=false - set +e - terraform plan -detailed-exitcode -input=false -no-color 2>&1 | tee plan.log - PLAN_EXIT=${PIPESTATUS[0]} - set -e - if [ "$PLAN_EXIT" -eq 2 ]; then - echo "drift=true" >> "$GITHUB_OUTPUT" - echo "### DNS drift detected" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - cat plan.log >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Notify on drift - if: steps.tf_proxmox.outputs.drift == 'true' || steps.tf_dns.outputs.drift == 'true' - env: - GOTIFY_URL: ${{ secrets.GOTIFY_URL }} - GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} - run: | - curl -s -X POST "${GOTIFY_URL}/message" \ - -H "X-Gotify-Key: ${GOTIFY_TOKEN}" \ - -F "title=Infrastructure drift detected" \ - -F "message=Terraform plan found changes. Check the CI run for details." \ - -F "priority=7" - - ansible-drift: - name: Ansible — check mode - runs-on: ubuntu-latest - if: ${{ github.event.inputs.target == 'all' || github.event.inputs.target == 'ansible' || github.event_name == 'schedule' }} - steps: - - uses: actions/checkout@v4 - - - name: Install Ansible - run: pip install ansible - - - name: Install collections - run: ansible-galaxy collection install -r ansible/collections/requirements.yml - - - name: Write SSH key - env: - ANSIBLE_SSH_KEY: ${{ secrets.ANSIBLE_SSH_KEY }} - run: | - install -m 600 /dev/null /tmp/ansible_key - echo "$ANSIBLE_SSH_KEY" > /tmp/ansible_key - - - name: Ansible check mode (site.yml) - env: - ANSIBLE_PRIVATE_KEY_FILE: /tmp/ansible_key - ANSIBLE_HOST_KEY_CHECKING: "False" - run: | - cd ansible - ansible-playbook playbooks/site.yml --check --diff \ - -e "@inventory/group_vars/all.yml" \ - 2>&1 | tee check.log - # Summarise - echo "### Ansible check summary" >> "$GITHUB_STEP_SUMMARY" - grep -E '(changed|failed|ok)=' check.log | tail -20 >> "$GITHUB_STEP_SUMMARY" || true - - - name: Notify on changes detected - if: failure() - env: - GOTIFY_URL: ${{ secrets.GOTIFY_URL }} - GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} - run: | - curl -s -X POST "${GOTIFY_URL}/message" \ - -H "X-Gotify-Key: ${GOTIFY_TOKEN}" \ - -F "title=Ansible drift detected" \ - -F "message=ansible --check found pending changes. Check CI for details." \ - -F "priority=7" diff --git a/.gitea/workflows/secret-scan.yml b/.gitea/workflows/secret-scan.yml deleted file mode 100644 index ddb2ffc..0000000 --- a/.gitea/workflows/secret-scan.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Secret Scan - -on: - push: - pull_request: - -jobs: - gitleaks: - name: gitleaks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # full history — gitleaks scans all commits - - - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - with: - config-path: .gitleaks.toml diff --git a/.gitea/workflows/update-flake-lock.yml b/.gitea/workflows/update-flake-lock.yml new file mode 100644 index 0000000..a9ab97f --- /dev/null +++ b/.gitea/workflows/update-flake-lock.yml @@ -0,0 +1,32 @@ +name: Update flake.lock + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + update-flake-lock: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v19 + + - name: Update and commit flake.lock + run: | + set -euo pipefail + nix --extra-experimental-features 'nix-command flakes' flake update + + if git diff --quiet -- flake.lock; then + echo "No flake.lock changes detected" + exit 0 + fi + + git config user.name "gitea-actions" + git config user.email "gitea-actions@nix-cache.local" + git add flake.lock + git commit -m "chore: update flake.lock" + git push diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..8936fd2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Blocks commits containing secrets. Installed via: +# git config core.hooksPath .githooks +# (scripts/codex-setup.sh does this automatically in Codex sessions.) +set -euo pipefail + +if command -v gitleaks >/dev/null 2>&1; then + gitleaks protect --staged -v +else + nix-shell -p gitleaks --run "gitleaks protect --staged -v" +fi diff --git a/.github/workflows/check-nixos.yml b/.github/workflows/check-nixos.yml new file mode 100644 index 0000000..a16ddf2 --- /dev/null +++ b/.github/workflows/check-nixos.yml @@ -0,0 +1,29 @@ +name: Check NixOS configurations + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + eval-hosts: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v19 + + # Scoped to files changed since the PR base / previous push -- see + # scripts/codex-maintenance.sh. CI never passes --full-check: that + # full sweep is for local/manual use, since it's slow enough to time + # out this runner. + - name: Run maintenance checks (secrets, fmt, lint, eval -- changed files only) + env: + MAINT_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: bash scripts/codex-maintenance.sh diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml deleted file mode 100644 index ddb2ffc..0000000 --- a/.github/workflows/secret-scan.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Secret Scan - -on: - push: - pull_request: - -jobs: - gitleaks: - name: gitleaks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # full history — gitleaks scans all commits - - - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - with: - config-path: .gitleaks.toml diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml new file mode 100644 index 0000000..ba4acc3 --- /dev/null +++ b/.github/workflows/update-flake-lock.yml @@ -0,0 +1,37 @@ +name: Update flake.lock + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-flake-lock: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v19 + + - name: Update flake.lock + run: | + nix --extra-experimental-features 'nix-command flakes' flake update + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + add-paths: flake.lock + branch: chore/update-flake-lock + title: "chore: update flake.lock" + commit-message: "chore: update flake.lock" + body: | + This is an automated update of `flake.lock` generated by the scheduled workflow. + + It updates pinned flake inputs so dependency updates can be reviewed and merged via PR. diff --git a/.gitignore b/.gitignore index 7ea50a8..5ecc165 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,27 @@ -# Terraform state and credentials — never commit -**/.terraform/ -**/*.tfstate -**/*.tfstate.* -**/terraform.tfvars -**/.terraform.lock.hcl -# keep .terraform.lock.hcl for reproducibility if you want — uncomment to track it: -# !**/.terraform.lock.hcl +# ---> Nix +# Ignore build outputs from performing a nix-build or `nix build` command +result +result-* -# Ansible secrets and generated files -ansible/inventory/host_vars/*/vault.yml -ansible/.vault_pass -**/*.retry +# Disko's proxmox-* image-builder writes the finished .raw disk image +# directly into the current directory, not into a result-* symlink (see +# docs/proxmox-images.md, scripts/create-proxmox-resource.sh) — several GB +# each, never meant to be committed. +*.raw -# Docker secrets -stacks/docker/secrets/stack-secrets.env -stacks/docker/secrets/*.txt -stacks/raspi/default-environment.env.local +# Ignore automatically generated direnv output +.direnv -# SOPS / age private keys -*.age -.sops.yaml.local - -# Editor and OS -.DS_Store -*.swp -*.swo -*~ -.idea/ -.vscode/settings.json - -# MkDocs build output -site/ - -# Python +# Python bytecode cache (scripts/lib/*.py) __pycache__/ *.pyc -.venv/ -venv/ -# Logs -*.log +# Locally-generated SSH host keys staged for transfer to a new machine +# during install (see scripts/prepare-host-key.sh) — never commit these. +host-keys/ + +# Temporary Milestone 1 audit checklist (remove-sensetive-info-refactor.md) +# - working notes only, never committed, deleted once every row is rotated. +secrets-inventory.md +.claude/worktrees/ +.claude/settings.local.json \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index c49709c..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,80 +0,0 @@ -# Gitleaks configuration for the infrastructure mono-repo. -# Extends the default ruleset with patterns for all managed services. -# https://github.com/gitleaks/gitleaks - -title = "infrastructure secret scan" - -[extend] - useDefault = true - -# ── Pi-hole ──────────────────────────────────────────────────────────────────── - -[[rules]] - id = "pihole-pwhash" - description = "Pi-hole password hash (pihole.toml webserver.api.pwhash)" - regex = '''pwhash\s*=\s*"[^"]{10,}"''' - tags = ["pihole", "password"] - -[[rules]] - id = "pihole-totp-secret" - description = "Pi-hole 2FA TOTP secret" - regex = '''totp_secret\s*=\s*"[^"]{10,}"''' - tags = ["pihole", "2fa"] - -[[rules]] - id = "pihole-app-pwhash" - description = "Pi-hole app password hash" - regex = '''app_pwhash\s*=\s*"[^"]{10,}"''' - tags = ["pihole", "password"] - -# ── Ansible / Terraform ──────────────────────────────────────────────────────── - -[[rules]] - id = "ansible-vault-inline" - description = "Ansible vault encrypted value in a file that isn't a vault file" - regex = '''\$ANSIBLE_VAULT;[0-9]+\.[0-9]+''' - tags = ["ansible", "vault"] - -[[rules]] - id = "terraform-tfvars-secret" - description = "Terraform tfvars token assignment" - regex = '''(api_token|token_secret|password|secret)\s*=\s*"[^"]{8,}"''' - tags = ["terraform", "credentials"] - -# ── Authelia ─────────────────────────────────────────────────────────────────── - -[[rules]] - id = "authelia-user-hash" - description = "Authelia user database bcrypt/argon2 hash" - regex = '''\$argon2|\$2[aby]\$''' - tags = ["authelia", "password"] - -# ── Docker secrets ───────────────────────────────────────────────────────────── - -[[rules]] - id = "docker-secrets-env" - description = "Docker secrets env file password assignment" - regex = '''(PASSWORD|SECRET|TOKEN|KEY)\s*=\s*[^\s#]{12,}''' - tags = ["docker", "credentials"] - -# ── ───────────────────────────────────────────────────────────────────────────── - -[allowlist] - description = "Known-safe patterns in this repo" - regexes = [ - # TLS cert path reference — not the key itself - '''cert\s*=\s*"/etc/pihole/tls\.pem"''', - # Ansible example password placeholders - '''changeme|CHANGEME|example_password|your_password_here''', - # Terraform variable default empty strings - '''default\s*=\s*""''', - ] - paths = [ - # Template and example files are intentionally non-live - '''\.example$''', - '''\.example\..*$''', - # Test fixtures - '''test.*fixture''', - # This config file itself - '''\.gitleaks\.toml$''', - ] diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..dc4ec9b --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,201 @@ +keys: + - &admin age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad + - &proxmox-minimal age19m0m7vdfg86yqy8l5mmle5jdd0unrn3f55t232w8h5ey42cqw34sfpt32n + - &lxc-gui age1rrxqea6q6pn39sw8y5te63h2py8jgjl9v0jyper86w3ggtn67upqg3ah39 + - &baremetal-gui age1adur9g330gua4l6ndk8cqjg35qc8yxwgme6wrl2hpylcc7vxm38q05ejuy + - &linode-docker age17e89ty6p0fw24daanen57wg8uald9s025t3wwxsw269svwpmgvrshfvfvt + - &linode-gui age1hrx8qj02fj2ea6d4g9vqhyj9hl7fppkjqfdx2l37py3h6pdkr95s8n8rvs + - &linode-minimal age1e7l8dusgmgfzd2cxrrzwepzjxt69hzqj4epee0cs27u6yg4kxcuqm34ncx + - &linode-nix-cache age1jcx3yajjhghn8qh8za3yeu8nxykzlg3p4nrv03vnfvzl0mzayg2qmg940e + - &linode-tailscale-router age1f7usptjx9rv4rxauasve200gxtdt9jkqhhdqstlf20wvlm7u75rsjfw50m + - &lxc-docker age17jqc66x9yeshfgd9v78mj483r4zzarqdtuxtrkxe4x5mw679gphshd94th + - &lxc-minimal age1px0h5l9zp2dww0m8fncrc82kfdmzplsfv2ltat7sna28xpg09pqqcl3s2k + - &lxc-nix-cache age1ufg390ydrmma849t9xfkxxl5xvdkk6mngnlzhmy7mvuaje8sgcmsmnq6l7 + - &lxc-pxe-boot age16j42pdc5dr6wnj7xayhkqdj2rny9u68fcqejs50hqq42scssh4gsnrrnlt + - &lxc-tailscale-router age1k7d2du5mejsmv5rzavm4xwgpthqvcfsehduquv28nzs53zppa3kqngfxq2 + - &lxc-tor-relay age16kqfmvz4e23hmdlqresnyw69ej604s320mmd49h4hm3fhqchtgyqrws0k2 + - &proxmox-docker age1arhf2q45zw6wf2uevju4savp575x3m2tfvved5zzq3ay92ynua9s3cm92c + - &proxmox-gui age19mn8zrxl8zpps9yvrh4euquvygpp4fp8queg7xc6qhtnl4ng8c9qx02qwn + - &proxmox-nix-cache age1jlltcv5jcnm40z5k0q6hv053k2rqpqvemtuecdwn527uw8uqz4es3x7m68 + - &proxmox-pxe-boot age1ug787sgt6st6k82fgkrug2lzltw4qsukrrqqs3w27ewwqj8rg4hsxcmylz + - &proxmox-tailscale-router age1zhfyuzlq40reuqlr34gf77852nhs3t6mqfzrqmas8z6sxk7tcfhsungrm0 + - &proxmox-ha-server-1 age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6 + - &proxmox-ha-server-2 age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r + +creation_rules: + # Shared across every currently-deployed host: root/nixos password hash, + # GitHub access token. Same value on every host today, so every live host's + # key can decrypt it (matches current risk profile — narrow further in + # Milestone 4 if hosts should diverge). + - path_regex: secrets/common\.yaml$ + key_groups: + - age: + - *admin + - *proxmox-minimal + - *lxc-gui + - *baremetal-gui + - *linode-docker + - *linode-gui + - *linode-minimal + - *linode-nix-cache + - *linode-tailscale-router + - *lxc-docker + - *lxc-minimal + - *lxc-nix-cache + - *lxc-pxe-boot + - *lxc-tailscale-router + - *lxc-tor-relay + - *proxmox-docker + - *proxmox-gui + - *proxmox-nix-cache + - *proxmox-pxe-boot + - *proxmox-tailscale-router + - *proxmox-ha-server-1 + - *proxmox-ha-server-2 + + - path_regex: secrets/nix-cache\.yaml$ + key_groups: + - age: + - *admin + - *linode-nix-cache + - *lxc-nix-cache + - *proxmox-nix-cache + + - path_regex: secrets/tor-relay\.yaml$ + key_groups: + - age: + - *admin + - *lxc-tor-relay + + - path_regex: secrets/tailscale-router\.yaml$ + key_groups: + - age: + - *admin + - *linode-tailscale-router + - *lxc-tailscale-router + - *proxmox-tailscale-router + + # HA file server per-node secrets (beszel-token). + # proxmox-ha-server-1 / proxmox-ha-server-2 keys are added automatically + # by scripts/secrets/sync-host-keys.sh once the hosts are provisioned; + # until then only the admin key can decrypt these files. + - path_regex: secrets/ha-server-1\.yaml$ + key_groups: + - age: + - *admin + - *proxmox-ha-server-1 + # proxmox-ha-server-1 added by sync-host-keys.sh + + - path_regex: secrets/ha-server-2\.yaml$ + key_groups: + - age: + - *admin + - *proxmox-ha-server-2 + # proxmox-ha-server-2 added by sync-host-keys.sh + + # Shared HA cluster corosync authkey (binary sops file). + # Encrypted for both HA nodes so either can decrypt on boot. + # Both host keys added by sync-host-keys.sh; admin key allows initial creation. + - path_regex: secrets/ha-corosync-authkey$ + key_groups: + - age: + - *admin + - *proxmox-ha-server-1 + - *proxmox-ha-server-2 + # proxmox-ha-server-1 added by sync-host-keys.sh + # proxmox-ha-server-2 added by sync-host-keys.sh + + # gui-host-specific secrets (currently: wifi-password, see + # modules/networking/wifi.nix). Only *lxc-gui has a registered key today + # -- proxmox-gui/linode-gui/baremetal-gui haven't been provisioned via + # scripts/secrets/sync-host-keys.sh yet, so whichever variant is actually + # deployed next needs its recipient added here (and `sops updatekeys` rerun) + # before it can decrypt this. + - path_regex: secrets/gui\.yaml$ + key_groups: + - age: + - *admin + - *lxc-gui + - *baremetal-gui + - *linode-gui + - *proxmox-gui + + # IPA host keytabs (binary sops files). + # Each keytab is encrypted for all platform variants of that host so any + # deployed variant can decrypt it at boot. Run + # scripts/ipa/create-nixos-ipa-host-account.sh to enroll a new + # host and produce the keytab; this section is updated by that script. + + - path_regex: secrets/nix-cache\.keytab$ + key_groups: + - age: + - *admin + - *linode-nix-cache + - *lxc-nix-cache + - *proxmox-nix-cache + + - path_regex: secrets/tailscale-router\.keytab$ + key_groups: + - age: + - *admin + - *linode-tailscale-router + - *lxc-tailscale-router + - *proxmox-tailscale-router + + - path_regex: secrets/pxe-boot\.keytab$ + key_groups: + - age: + - *admin + - *lxc-pxe-boot + - *proxmox-pxe-boot + + # nixos = the workstation (hosts/nixos/host.nix). All gui platform variants + # share the hostname "nixos" and must be able to decrypt at boot. + - path_regex: secrets/nixos\.keytab$ + key_groups: + - age: + - *admin + - *baremetal-gui + - *lxc-gui + - *proxmox-gui + - *linode-gui + + - path_regex: secrets/docker\.keytab$ + key_groups: + - age: + - *admin + - *linode-docker + - *lxc-docker + - *proxmox-docker + + - path_regex: secrets/tor-relay\.keytab$ + key_groups: + - age: + - *admin + - *lxc-tor-relay + + - path_regex: secrets/nix-minimal\.keytab$ + key_groups: + - age: + - *admin + - *lxc-minimal + - *proxmox-minimal + - *linode-minimal + + # Host keytab for ha-server-1 FreeIPA enrollment (binary sops file). + # Generated by scripts/ipa/create-nixos-ipa-host-account.sh. + - path_regex: secrets/ha-server-1\.keytab$ + key_groups: + - age: + - *admin + - *proxmox-ha-server-1 + # proxmox-ha-server-1 added by sync-host-keys.sh + + # Host keytab for ha-server-2 FreeIPA enrollment (binary sops file). + # Generated by scripts/ipa/create-nixos-ipa-host-account.sh. + - path_regex: secrets/ha-server-2\.keytab$ + key_groups: + - age: + - *admin + - *proxmox-ha-server-2 + # proxmox-ha-server-2 added by sync-host-keys.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..017434b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +## Repo purpose + +This repository contains flake-based NixOS configurations for Wayne's LAN +servers and workstation. + +The flake exposes NixOS configurations named `-` +(platforms: `linode`, `proxmox`, `lxc`, `baremetal`; build types: `minimal`, +`nix-cache`, `docker`, `gui`, `pxe-boot`, `tailscale-router`, +`tor-relay`, `ha-server`), generated from `modules/platforms/*` and +`modules/build-types/*` by the `mkTarget` function in `flake.nix`. Not every +combination is built — `pxe-boot` has no `linode` variant, `ha-server` only +exists on `proxmox`, and `tor-relay` only exists on `lxc`. See `README.md` +for the full current target list; treat `flake.nix` as the source of truth +since this list can drift. + +Do not deploy, switch, reboot, repartition, format disks, or run destructive +install commands from this repository unless explicitly asked. + +## Safety rules + +- Never run `nixos-rebuild switch`, `boot`, `test`, `nixos-install`, `parted`, + `mkfs`, `mkswap`, `swapon`, `mount`, or destructive disk commands in Codex. +- Validation work should be limited to evaluation, linting, formatting checks, + and `nix build --dry-run --no-link`. +- Do not add secrets, tokens, private keys, password hashes, or live credentials + to the repo. +- Treat `flake.nix`, Home Manager config, and Nix config files as public. +- If you find committed tokens or hashes, flag them immediately and recommend + rotation/removal. + +## Expected commands + +Use these commands when validating changes: + +```bash +bash scripts/codex-setup.sh +bash scripts/codex-maintenance.sh +``` + +With no flags, `codex-maintenance.sh` scopes fmt-check/statix/eval to files +changed against a base ref — this is what CI runs on every push/PR. For the +full sweep (every host, every package — slow; CI never runs this), use +`bash scripts/codex-maintenance.sh --full-check` (add `--dry-run` for build +planning on top of whichever scope is active). + +Host evaluation is safe when limited to drvPath checks: + +```bash +nix eval .#nixosConfigurations..config.system.build.toplevel.drvPath --raw +``` diff --git a/CLAUDE.md b/CLAUDE.md index d5ed8d0..c80ed8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,76 +1,556 @@ -# CLAUDE.md — infrastructure +# CLAUDE.md -Safety rules and working context for Claude Code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Overall goal +## Repo purpose -Build a complete, auditable Infrastructure as Code system for the `sweet.home` homelab. -The end state is: every host, service, and network resource is declared in this repo; -Terraform captures and detects drift in infrastructure state; Ansible idempotently configures -non-NixOS hosts; NixOS flake manages NixOS hosts; automated CI pipelines generate internal -and external documentation daily. See `docs/internal/implementation-plan.md` for the phased roadmap. +Flake-based NixOS configuration for Wayne's LAN servers and workstation. There is +no application code here — changes are Nix module edits that affect real +machines when deployed. -## Production boundary — confirm before touching +## Safety rules (read before touching anything) -`pve1.sweet.home` and all VMs/LXCs running on it are production. That includes: -- `domain-controller.sweet.home` (FreeIPA — if this goes down, auth AND DNS break for everything) -- `docker.sweet.home` (Nextcloud, Passbolt, Gitea — live data) +- **Never** run `nixos-rebuild switch|boot|test`, `nixos-install`, `parted`, + `mkfs`, `mkswap`, `swapon`, `mount`, or any other destructive disk/deploy + command from an agent session, even if asked indirectly. Deployment is done + manually by the operator on the target host. +- Validation is limited to evaluation, linting, formatting checks, and + `nix build --dry-run --no-link`. +- Do not add secrets, tokens, private keys, or new password hashes to the repo. +- This repo currently contains **committed password hashes** in + `modules/installer/common.nix` (the auto-installer's own root/nixos login — + a deliberate, documented choice, see `docs/auto-installer.md`, not + accidental tech debt) and **SSH public keys** in `variables.nix` + (`vars.adminSshKey`, `vars.remoteBuilderAuthorizedKeys`, `vars.beszelHubKey`). Don't use the installer's hardcoded hash as a + template for a *real* host — every other host uses sops-nix + (`hashedPasswordFile`, see "Security Notes" in `README.md`). Flag any *new* + secret-like string you encounter instead of committing it. +- `host-keys/` is gitignored — used only by the auto-installer's own + environment for pre-seeding non-LXC host keys before first boot (see + `docs/auto-installer.md`). Never commit its contents; if `git status` + ever shows it as trackable, something is wrong. All deployed hosts use + clan vars (`vars/per-machine//openssh/`, committed and + sops-encrypted) for their SSH host keys — those ARE tracked by git and + belong in the repo. -**Always ask before running any Ansible playbook with `--apply` or `terraform apply` against -production hosts.** Use `--check` (Ansible) or `terraform plan` (Terraform) for read-only -validation; these are safe to run without asking. +### Two Proxmox nodes: `pve1.sweet.home` (production) and `pve-test.sweet.home` (sandbox) -`pve-test.sweet.home` is the sandbox. VMs and containers on it can be created/destroyed -without asking. Node-level config (firewall, SSH, unattended-upgrades) still needs confirmation. +There are two SSH-reachable Proxmox nodes on the LAN, both defined in +`scripts/env.sh` (`PVE1_HOST` / `PVE_TEST_HOST`), individually targetable +via `scripts/proxmox/create-proxmox-resource.sh --node ` or by +overriding `PROXMOX_HOST`. `PROXMOX_HOST` itself still defaults to +`PVE1_HOST` (production) — that default, and every other script behavior, +is unchanged from before `pve-test` existed; the only thing new is that +`pve-test` can now be reached at all. They are **not interchangeable** — +one is real production infrastructure, the other exists specifically so +there's somewhere safe to test. The restriction below is a policy for +Claude specifically, not a change to the tooling's own default or +anything the operator needs to opt into. -## Terraform rules +#### `pve1.sweet.home` (production — off-limits to Claude) -- Always `terraform plan` before `terraform apply`. -- Never commit `.tfstate` or `.tfvars` files — they go in `.gitignore`. -- API tokens and secrets go in `terraform.tfvars` (git-ignored) or environment variables. -- If `plan` shows unexpected destruction of real resources, stop and ask. +A real, live Proxmox node hosting production VMs/containers — not a +sandbox, and not Claude's to touch by default. -## Ansible rules +- **Off-limits at all times unless the operator has given explicit, + same-session instructions to act on this specific host.** That + authorization is scoped to the task it was given for — don't carry it + forward to unrelated later work in the same conversation, and never + assume it from a previous session. +- **Read-only for existing state is always fine, authorization or not.** + You may SSH in (or use `pvesm`, `qm list`, `pct list`, `qm config`, `pct + config`, the Proxmox API, etc.) to inspect the node's config, storage, + and any existing VM/container — including ones this repo didn't create. +- **Never** modify, stop, restart, delete, reconfigure, or create anything + on this node (`qm set`, `pct set`, `qm destroy`, `pct destroy`, `qm + stop`, `pct stop`, `qm create`, `pct create`, snapshot operations, + storage changes, etc.) — including scratch/test resources — without + that explicit go-ahead. Use `pve-test.sweet.home` for anything + exploratory instead; it exists precisely so `pve1` never has to be the + answer to "where do I test this." +- **This is a Claude-specific policy, not something the scripts enforce.** + `scripts/env.sh`/`create-proxmox-resource.sh` default to `pve1` exactly + as they did before `pve-test` existed, with no extra flag or prompt + required — that's deliberate, so the operator's own existing workflows + don't change. Claude, however, must never rely on that default: every + Proxmox action Claude takes on its own initiative — not explicitly + pointed at `pve1` by the operator this session — targets `pve-test` + instead (e.g. `--node "$PVE_TEST_HOST"`, or `PROXMOX_HOST=$PVE_TEST_HOST`). + Claude's own default is `pve-test`, full stop, regardless of what the + tooling's own unqualified default happens to be. -- Always use `--check --diff` before the first real run against any host. -- The `proxmox-hardening` role is safe to re-run (idempotent). Still confirm first. -- The `freeipa` role is NOT idempotent end-to-end — `ipa-server-install` will refuse to - run if IPA is already installed (it checks). That's fine; treat it as a guard. -- Never run `ansible-playbook` against production hosts without a `--limit` specifier - unless running the full `site.yml` intentionally. +#### `pve-test.sweet.home` (sandbox — Claude's default target) -## NixOS rules +A separate Proxmox node set aside for testing. The *tooling's* default is +still production (`PROXMOX_HOST` → `PVE1_HOST`, see above) — but +**Claude's own default is this node**: absent an explicit, same-session +instruction to use `pve1`, every Proxmox action Claude initiates targets +`pve-test`. Once targeted, it's safe to create, interrogate, and destroy +resources on without asking first. -- The `nixos/` subdirectory is a flake. Use `nix build ./nixos#` to validate - before `nixos-rebuild switch`. -- Secrets are SOPS-encrypted with age. Never decrypt secrets into plaintext in the repo. -- The flake remote URL (used by the `Switch-nix` alias on NixOS hosts) must be updated - to point at the new Gitea URL after migration. See implementation plan Phase 4. +- **Test VMs/containers are allowed, but must be torn down.** Create a + scratch VM or container here (e.g. via + `scripts/proxmox/create-proxmox-resource.sh` or raw `qm`/`pct create`) + to validate something. Anything created this way must be destroyed + again in the same session, before ending the task — never leave a test + resource running. Use a VMID/name that's obviously scratch (and doesn't + collide with a real flake target) so it's unambiguous what's safe to + remove. +- **Node-level config is still not yours to change.** Creating/destroying + your own scratch guests is fine; Proxmox host config, storage pools, and + networking on `pve-test` itself are still the operator's call to make + manually, same as on `pve1`. -## Stacks rules +## Commands -- Changes to `stacks/docker/` or `stacks/raspi/` take effect only when manually deployed. - No automated apply runs against live containers. -- Test compose changes with `docker compose config` (validates interpolation) before deploying. -- `secrets/stack-secrets.env` and `secrets/*.txt` are git-ignored. Never commit real secrets. +```bash +# One-time environment bootstrap (installs Nix if missing, prints hosts) +bash scripts/codex-setup.sh -## Secrets +# Changed-files-only validation: secret grep (whole repo), nixpkgs-fmt --check +# and statix on changed *.nix files, eval of the hosts/packages those changes +# can affect. This is what CI runs on every push/PR. +bash scripts/codex-maintenance.sh -- Pre-commit hook (installed by `scripts/install-hooks.sh`) runs gitleaks before every commit. -- CI also runs gitleaks on push. Both must pass. -- If gitleaks false-positives on a known-safe pattern, add it to `.gitleaks.toml` allowlist, - not to the ignore-next-line comment. +# Full sweep: nixpkgs-fmt --check/statix over the whole tree, eval every host +# and package. Slow (minutes) -- CI never runs this; use it locally before a +# release or after touching modules/common/*, flake.nix, or variables.nix for +# extra confidence beyond the automatic full-fallback those paths already +# trigger in the default mode (see below). +bash scripts/codex-maintenance.sh --full-check -## Documentation pipeline +# Either mode, plus a dry-run build (no result symlink) of every host/package +# in whichever scope is active +bash scripts/codex-maintenance.sh --dry-run +bash scripts/codex-maintenance.sh --full-check --dry-run -- Internal docs: `docs/internal/` — full topology, credentials catalog (names only), runbooks. -- External docs: `docs/external/` — sanitized, no IPs, no internal hostnames, no credential refs. -- Generated content lands in `docs/generated/` — never edit these files by hand. -- Run `./scripts/docs-build.sh` locally to preview before pushing. +# List the hosts the flake currently exposes +nix eval --json .#nixosConfigurations --apply builtins.attrNames | jq -r '.[]' -## Directory notes +# Evaluate a single host without building (fast sanity check) +nix eval .#nixosConfigurations..config.system.build.toplevel.drvPath --raw -- `stacks/docker/` and `stacks/raspi/` are populated via `git subtree` — see implementation plan. -- `nixos/` is populated via `git subtree` — the flake works from the subdirectory path. -- `terraform/dns/` wraps the Dynu provider. Read-only operations only until credentials are configured. -- `ansible/roles/` — each role has a `README.md` describing variables and expected host state. +# Dry-run build a single host +nix build --dry-run --no-link .#nixosConfigurations..config.system.build.toplevel +``` + +Formatting/lint tools (`nixpkgs-fmt`, `statix`) are not installed locally; the +maintenance script pulls them via `nix run github:NixOS/nixpkgs/nixos-25.11#`. +There is no test suite — "correctness" here means the flake evaluates and +`nixpkgs-fmt`/`statix` are clean. + +With no flags, `codex-maintenance.sh` diffs against a base ref (env +`MAINT_BASE_SHA`, else the PR base SHA in CI, else `HEAD^` locally) and scopes +fmt-check/statix to the changed `*.nix` files and eval to the hosts/packages +those changes can affect — a `hosts//host.nix` edit only evals that +host's targets, a `modules/platforms/.nix` edit only evals that +platform's hosts, and so on. A change to `flake.nix`, `flake.lock`, +`variables.nix`, `modules/common/*`, or any other `modules/*.nix` file outside +`platforms/`/`build-types/` (whose blast radius isn't safely inferable from +the path alone) falls back to evaluating every host and package, same as +`--full-check` would, just without the whole-tree fmt/statix sweep. This +exists because the whole-tree sweep is what was timing out CI; **CI always +runs the plain, no-flag form and never passes `--full-check`.** + +The default mode's diff is against the working tree (uncommitted and staged +edits included, not just committed ones), so it's already the right tool for +an interactive session too: after editing one or two hosts/modules, plain +`bash scripts/codex-maintenance.sh` naturally scopes to just what you +touched. Reserve `--full-check` for changes that plausibly affect every host +(`modules/common/*`, `flake.nix`, `variables.nix` — though the default mode +already falls back to evaluating everything for those paths, `--full-check` +additionally re-checks fmt/statix over the whole tree) or as a final check +before committing. + +## Scripts + +Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` is +organized by purpose: `scripts/secrets/` (sops/age + SSH host-key +management), `scripts/proxmox/` (Proxmox deployment), `scripts/installer/` +(the auto-installer's own shell script, templated into the image — see +below), `scripts/lib/` (shared helpers, sourced by the scripts below — not +run directly), and a handful of repo-wide scripts left at the top level +(`env.sh`, `bump-nixpkgs-release.sh`, plus `codex-setup.sh`/ +`codex-maintenance.sh` above). When adding a new script, put it in the +matching subfolder rather than the top level, and if it duplicates logic +another script already has, lift the shared part into `scripts/lib/` +instead of copying it. + +### `scripts/installer/` + +- `scripts/installer/auto-install.sh` — the interactive install script + baked into the auto-installer image (see `docs/auto-installer.md`), kept + as a real, version-controlled shell file rather than inline in + `modules/installer/common.nix`'s Nix. It sources `scripts/env.sh` itself + for `LAN_DOMAIN` (`export LAN_DOMAIN`/`: "${LAN_DOMAIN:=...}"`, matching + `variables.nix`'s `lanDomain` — manually kept in sync, same pattern as + `NIX_CACHE_HOST` mirroring `nixCacheHost`), rather than Nix-level string + substitution — that's what makes it work identically whether run + straight from a git checkout or from inside the built installer image. + `common.nix` bakes `scripts/env.sh` in alongside it at a matching + relative path (`/etc/nixos-installer/env.sh` next to + `/etc/nixos-installer/installer/auto-install.sh`) so the script's own + `source "$(dirname ...)/../env.sh"` line resolves the same way in both + contexts — this is also why it's invoked from + `/etc/nixos-installer/installer/auto-install.sh` rather than a flat + `/etc/auto-install.sh`. `#!/usr/bin/env bash`, not + `#!/run/current-system/sw/bin/bash`: the latter only resolves on an + already-activated NixOS system, breaking the checked-out-file case + entirely (confirmed live: "cannot execute: required file not found" on + a non-NixOS box); `/usr/bin/env` is reliably present on both NixOS + (`environment.usrbinenv`'s own default) and any normal Linux distro. + +### `scripts/secrets/` + +- `scripts/secrets/sync-host-keys.sh` — generates/registers SSH host keys + and their `.sops.yaml`/`secrets/*.yaml` recipients for flake targets, + idempotently (`--all`, ``, `--remove`, `--regenerate-all-keys`, + all with `--dry-run`). Stores keys as clan vars + (`vars/per-machine//openssh/`, committed and sops-encrypted) for + all flake targets. The primary tool for provisioning a new host's + secrets access — see "Creating a new machine" in + `docs/auto-installer.md`. +- `scripts/secrets/prepare-host-key.sh` — narrower predecessor: generates a + key by an arbitrary name without touching `.sops.yaml`. Still useful to + pre-generate a key before its flake target exists yet, since + `sync-host-keys.sh` can only act on targets `nixosConfigurations` already + has. +- `scripts/secrets/rotate-admin-key.sh [--new-key-file + ] [--dry-run]` — rotates `.sops.yaml`'s `&admin` age key: decrypts + with a backed-up copy of the key currently trusted as `&admin` (verified + by deriving its public key and comparing, not taken on faith), replaces + the `&admin` line with a new key already present in the environment + (defaults to wherever sops/age itself would look), and runs + `sops updatekeys` on every `secrets/*.yaml`. One-way: the old key can no + longer decrypt anything re-encrypted this way. This is the automation + for the manual steps `sync-host-keys.sh`/`create-proxmox-resource.sh` + print when they bootstrap a brand-new, not-yet-trusted key on a machine + with no prior admin access. +- `scripts/secrets/backup-admin-key.sh [--key-file ] + [--force] [--dry-run]` — copies the local sops age key (source + resolution matches sops/age itself: `$SOPS_AGE_KEY` inline, then + `--key-file`, then `$SOPS_AGE_KEY_FILE`, then the XDG default) to an + arbitrary destination path with `0600` permissions, validating it's a + real age identity and round-tripping the public key before and after the + write. Refuses to overwrite an existing `` without `--force`. + Purely a local filesystem copy — never touches `.sops.yaml`/ + `secrets/*.yaml` or the repo at all. The resulting file is exactly what + `rotate-admin-key.sh` expects as its backup-key argument. +- `scripts/secrets/sync-nix-cache-host-key.sh [--check] [--dry-run] + [--host ]` — detects drift between the ed25519 SSH host key + nix-cache is actually serving right now (via `ssh-keyscan`) and + `vars.nixCacheHostKey` (`variables.nix`), the value + `modules/nix-cache/remote-builder-client.nix` bakes into every real + client's declarative `programs.ssh.knownHosts` and + `configure-nix-cache-client.sh` hardcodes as its own default for + non-NixOS clients. That value has no automatic source of truth — it's + set once from whatever nix-cache's host key happened to be at the time, + and silently goes stale if the host is ever rebuilt/recreated with a new + key, breaking every client's distributed-build SSH trust with no error + that points back here. `--check` (used by `codex-maintenance.sh`, which + treats an unreachable nix-cache — e.g. from a non-LAN CI runner — as a + silent skip rather than a failure) only reports drift; the no-flags form + updates both files in place. Declarative clients still need a rebuild to + pick up the fix. +- `scripts/secrets/push-host-keys.sh [--all | ] [--dry-run] + [--skip-git-check]` — pushes newly-generated SSH host keys from + `host-keys/` to already-running NixOS hosts, so they can decrypt sops + secrets after a rebuild following `sync-host-keys.sh + --regenerate-all-keys`. Verifies that `.sops.yaml` and `secrets/*.yaml` + are committed and pushed to the remote first (hosts rebuild from the + remote Gitea flake, so recipient changes must land there before any key + push). + +### `scripts/proxmox/` + +- `scripts/proxmox/create-proxmox-resource.sh` — builds a `lxc-*`/ + `proxmox-*` target's tarball/disk image and creates it on a real Proxmox + node (`pct create` against the tarball as a CT template / `qm create`+ + `importdisk`), or reconfigures an existing resource's cores/memory/disk + size (`--modify`, always requires typing the VMID back to confirm). + Checks for an already-uploaded image on the node before building + (`--force-rebuild` to skip that and always rebuild), and probes + nix-cache's substituter/remote-builder reachability once up front rather + than letting every `nix build` call retry against it individually. + Refuses to create a target whose host identity already exists live on + the node (checked directly via `qm`/`pct`, not any file in this repo) + unless `--allow-duplicate-host` is passed. `--dry-run` throughout both + modes. The first time it has to bootstrap build tooling on a node (i.e. + `nix` wasn't already on its `PATH`), it also runs + `scripts/proxmox/configure-nix-cache-client.sh` there (non-fatally — a + failure just falls back to building from source / `cache.nixos.org`) so + the node substitutes from and can offload builds to nix-cache on every + subsequent run, not just this one. +- `scripts/proxmox/clone-pve1-to-pve-test.sh [--new-vmid ] + [--mode snapshot|suspend|stop] [--dry-run]` — ad-hoc clone of a single + VM or CT from pve1 (production) to pve-test (sandbox) via vzdump + + qmrestore/pct restore. Streams the archive directly between nodes (no + local staging copy). Always restores with `--unique 1` (fresh MAC + addresses) since the original is still running on the LAN. Cleans up + the vzdump archive from both nodes after a successful restore. The + script's own default is pve1 → pve-test, matching CLAUDE.md's policy + (unlike `create-proxmox-resource.sh`, which defaults to production for + the operator's own unqualified use). +- `scripts/proxmox/configure-nix-cache-client.sh [--dry-run] + [--no-remote-builder] [--no-restart]` — the non-NixOS equivalent of + `modules/nix-cache/client.nix`/`remote-builder-client.nix`, for a plain + Debian machine with the Nix package manager (not NixOS) already + installed: run as root *on that machine* to add nix-cache as a + substituter in `/etc/nix/nix.conf` (`https://cache.nixos.org/` kept as + fallback) via `extra-substituters`/`extra-trusted-public-keys` so it + layers on top of whatever's already there instead of clobbering it, and, + if `/root/.ssh/nixremote` is already present (see docs/nix-cache.md + "Remote builder SSH keys"), configures it as a distributed-build + machine too and trusts nix-cache's SSH host key in + `/etc/ssh/ssh_known_hosts`. Idempotent (re-running replaces its own + marked block rather than duplicating it); restarts `nix-daemon` by + default so the change takes effect immediately. + +### `scripts/ha/` + +HA cluster lifecycle and operational scripts. All mutate real cluster state +when run for real — always run against pve-test first unless the operator +explicitly targets pve1. + +- `scripts/ha/deploy.sh [--skip-*] [--destroy] [--dry-run]` — full + lifecycle manager: phases through bridge creation, key sync, VM creation + (via `create-proxmox-resource.sh`), NIC/disk attachment, and cluster + initialisation. `--destroy` tears it back down. Safe to rerun + idempotently; each phase can be individually skipped. +- `scripts/ha/cluster-init.sh` — one-time cluster bootstrap run **as root + on ha-server-1** after both VMs are booted. Generates/distributes the + Corosync authkey, initialises DRBD metadata, creates XFS on `/dev/drbd0`, + configures LIO iSCSI, and registers all Pacemaker resources (DRBD → XFS + → iSCSI → NFS → VIPs). +- `scripts/ha/health.sh` — read-only cluster health snapshot: SSH + reachability, quorum, DRBD state, Pacemaker resources, and VIP port + reachability. Safe to run from the workstation at any time. +- `scripts/ha/failover.sh [--to node1|node2] [--force] [--timeout ] + [--dry-run]` — graceful failover by putting the active node into + Pacemaker standby and waiting for resources to appear on the target. +- `scripts/ha/acceptance-tests.sh` — T1–T7 acceptance tests (failover, + NFS/iSCSI connectivity, DRBD sync, etc.) that must all pass before the + cluster is considered production-ready. +- `scripts/ha/resize-data-disk.sh --size +NNg [--force] [--dry-run]` — + online data-disk resize: `qm resize` on both VMs, guest block-device + rescan, `drbdadm resize`, `xfs_growfs`. No downtime required. +- `scripts/ha/cluster-enable-stonith.sh` — enables the `fence_pve_ssh` + STONITH resource after the fence SSH key is deployed to both nodes and + authorised on the Proxmox host. Run once after `cluster-init.sh`. +- `scripts/ha/fence-pve-ssh.py` — Python STONITH fence agent for Pacemaker. + Deploy to `/etc/pacemaker/fence_pve_ssh` on both HA nodes (`chmod +x`). + SSHes to the Proxmox host and runs `qm stop/start `. + +### `scripts/ipa/` + +- `scripts/ipa/create-nixos-ipa-host-account.sh [options] ` — + adds a NixOS host to the FreeIPA domain and produces a sops-encrypted + keytab at `secrets/.keytab`, ready for `modules/ipa/client.nix`. + Replaces three error-prone manual steps: `ipa host-add`, `ipa-getkeytab` + (run on the DC, SCP'd back), and `sops encrypt` in the correct location + (must be at `secrets/.keytab` for the creation rule to match). + +### `scripts/lib/` + +Sourced by the scripts above, never run directly: + +- `nix-bootstrap.sh` — `NIX_CONFIG`/`ensure_nix_profile`, shared by + `codex-setup.sh`/`codex-maintenance.sh` and the remote build commands + `create-proxmox-resource.sh` runs over SSH. +- `nix-eval.sh` — `NIX_EVAL_FLAGS` plus `list_flake_targets`/ + `flake_target_hostname` flake-introspection helpers. +- `nix-parallel.sh` — `run_nix_parallel`: fans out independent `nix eval`/ + `nix build --dry-run` calls across up to `NIX_PARALLEL_JOBS` processes, + capped by available memory (~1 GB/job) rather than raw `nproc` to avoid + OOM on constrained CI runners. Used by `codex-maintenance.sh`. +- `clan-vars.sh` — helpers for reading/writing SSH host keys stored as clan + vars (`vars/per-machine//openssh/`, sops-encrypted) instead of + the gitignored `host-keys/` directory. Sourced by + `create-proxmox-resource.sh` and `sync-host-keys.sh`; depends on + `sops-age.sh` and `ssh-host-keys.sh` being sourced first. +- `ssh-host-keys.sh` — `generate_host_ed25519_key`/`ssh_pubkey_to_age`, + shared by `sync-host-keys.sh` and `prepare-host-key.sh`. +- `sops-age.sh` — `age_pubkey_from_identity_file`/`sops_yaml_admin_pubkey`/ + `sops_updatekeys` plus the shared sops/age default key-file resolution, + shared by `backup-admin-key.sh`, `rotate-admin-key.sh`, and + `sync-host-keys.sh`. +- `confirm.sh` — `confirm_typed`, the "type X back to confirm" destructive- + action prompt shared by `create-proxmox-resource.sh` and + `sync-host-keys.sh`. +- `sync-host-keys-edit-sops.py` — the `.sops.yaml` anchor/key_groups editor + `sync-host-keys.sh` shells out to (see that script for why: precise, + idempotent YAML edits are impractical in bash). + +### Top level + +- `scripts/env.sh` — shared config (`PROXMOX_HOST`, storage pool, bridge, + default cores/memory, `NIX_CACHE_HOST`, `LAN_DOMAIN`) sourced by + `create-proxmox-resource.sh` and `scripts/installer/auto-install.sh`. Add + new cross-script config here instead of duplicating it per-script. +- `scripts/recover-hosts.sh [ ...]` — fixes sops/SSH-key/GitHub-token + issues on deployed NixOS hosts and triggers a `Switch-nix` rebuild on each. + With no args discovers every known hostname; with args checks only those. + Fixes applied automatically (prompts before rebuilding): SSH host key drift + (restores the registered key) and stale GitHub access tokens (empties the + rendered `nix-github-token.conf` so Nix falls back to unauthenticated requests + until sops-nix re-renders the correct token after the next successful rebuild). +- `scripts/gc-hosts.sh [--dry-run]` — runs `nix-collect-garbage -d` on all live + NixOS hosts (workstation first, then pve1, then all Proxmox guests). Excludes + `nix-cache` (gc-ing the shared binary cache evicts store paths other hosts + depend on). Uses passwordless sudo where available; falls back to user-level gc. +- `scripts/bump-nixpkgs-release.sh` — bumps `flake.nix`'s `nixpkgs.url`/ + `home-manager.url` in place. Exists because flake input URLs can't + reference `variables.nix` (confirmed empirically — `nix flake metadata` + errors on it), so this is the closest equivalent to a single source of + truth for the tracked release. + +`sync-host-keys.sh`, `create-proxmox-resource.sh`, and +`rotate-admin-key.sh` genuinely mutate real state when run for real (not +`--dry-run`): real `secrets/*.yaml` recipients, real Proxmox VMs/ +containers, real revocation of decrypt access. They require the +operator's own SSH/sops access, which an agent session doesn't have — but +don't suggest running any of them non-dry-run without the operator's +explicit go-ahead even if it becomes technically reachable. +`backup-admin-key.sh` only writes a key copy to a path the operator gives +it — lower-stakes than the others, but it still handles a real private +key, so treat its destination path choice as the operator's call too. + +## Architecture + +`flake.nix` is the single entry point. It generates one +`nixosConfigurations.-` attribute per target via the +`mkTarget` function, composed from: + +``` +nixosSystem { + modules = [ + disko.nixosModules.disko + sops-nix.nixosModules.sops + ./modules/common/configuration.nix + ./modules/platforms/${platform}.nix # what it runs on + ./modules/build-types/${buildType}.nix # what it's for + hostPath # hosts//host.nix — per-machine identity + home-manager.nixosModules.home-manager { ... } + ] ++ (client-only modules, for every buildType except "nix-cache" itself) +} +``` + +Platforms: `linode`, `proxmox`, `lxc`, `baremetal`. Build types: `minimal`, +`nix-cache`, `docker`, `gui`, `pxe-boot`, `tailscale-router`, `tor-relay`, +`ha-server`. Not every combination is built — e.g. `pxe-boot` has no `linode` +variant (PXE/DHCP/TFTP need LAN L2 adjacency a Linode VPS doesn't have), +`tor-relay` only exists as `lxc-tor-relay`, `ha-server` only exists as +`proxmox-ha-server-{1,2}`, and `baremetal` only exists as `baremetal-gui` +(the real gui-host hardware — see `hosts/nixos/host.nix` and +`modules/platforms/baremetal.nix`). Treat +`flake.nix`'s +`generatedTargets` as the source +of truth for which hosts exist — `README.md`, `AGENTS.md`, +`docs/flake-lock-automation.md`, and the CI eval workflows +(`.github/workflows/check-nixos.yml`, `.gitea/workflows/check-nixos.yml`) list +hosts by hand (or, for the CI workflows, evaluate the flake dynamically) and +can drift from it, so re-check them against `flake.nix` when adding or +removing a host. + +### Composition pattern + +- `hosts//host.nix` — per-machine identity **only**: hostname, hostId, + per-machine secrets, `system.stateVersion`. These files carry no `imports` + of their own — all shared behavior comes from the platform/build-type modules + composed in `flake.nix`, not from the host file. +- `modules/platforms/{linode,proxmox,lxc,baremetal}.nix` — platform-specific + config: boot method, guest tooling, and the hardware config, imported + directly by the platform module itself — **not** wired in from + `flake.nix`. VM platforms use `../hardware-configuration/vm/{proxmox,linode}.nix`; + `baremetal.nix` uses `../hardware-configuration/baremetal.nix` (adapted + from a real `nixos-generate-config` run on the actual hardware, not a + vm/ file, since it isn't a VM) plus `hardware.enableRedistributableFirmware + = true` for real wifi/GPU/microcode firmware that VMs never needed. + `lxc.nix` has no hardware-configuration counterpart since containers + share the host kernel; instead it imports nixpkgs' own + `virtualisation/proxmox-lxc.nix`, which gives every `lxc-*` host a + `config.system.build.tarball` output — a plain rootfs tarball, used as a + `pct create ... vztmpl` CT template (**not** `pct restore`, which expects + `vzdump` backup-archive metadata this doesn't have), no install step — + see `docs/auto-installer.md`. +- `modules/build-types/*.nix` — what a system is for: + minimal/docker/gui/pxe-boot/nix-cache/tailscale-router/tor-relay/ha-server. +- `modules/common/configuration.nix` — base NixOS config imported by every + host: locale, users, nix settings, git. +- `modules/common/home.nix` / `hosts/nixos/home.nix` — Home Manager config for + the `nixos` user; the `nixos` workstation (`gui` build type) has its own, + other hosts share `modules/common/home.nix`. +- `modules/disko/proxmox.nix` — declarative disk layout (GPT: ESP + swap + + ext4 root) via disko, used by all Proxmox-VM hosts (`proxmox-*`, not + `lxc-*`). Also carries `imageSize`/`imageName`, letting every `proxmox-*` + host be built as a standalone, `qm importdisk`-ready `.raw` image with no + install step — see `docs/proxmox-images.md`. +- `modules/disko/linode.nix` — `linode-*`'s disko config, deliberately + different in kind from the Proxmox one: Linode provisions and sizes + `/dev/sda`/`/dev/sdb` itself as whole, unpartitioned devices before the OS + boots, so this declares them with `destroy = false` (disko never wipes + them) and a bare `filesystem`/`swap` content type instead of a partition + table — idempotent against an already-provisioned disk, never destructive. +- `modules/disko/baremetal.nix` — `baremetal-gui`'s disko config: a ZFS + RAID0 (striped, no redundancy — disko's zpool `mode` defaults to `""`, + which is a plain stripe rather than `"mirror"`/`"raidz"`) root pool + across two disks, ESP + systemd-boot on the first. Device paths + (`vars.guiRootDisk1`/`guiRootDisk2`) are placeholders — fill in stable + `/dev/disk/by-id/...` paths before running disko for real. + `modules/platforms/baremetal.nix` also imports + `modules/services/zfs/enable-service.nix` for this (the `zfs_unstable` + package, autoScrub/autoSnapshot/trim) — the only other importer today is + `ha-server`'s NFS data pool, an unrelated non-root ZFS use. +- `modules/boot/efi.nix` — systemd-boot + EFI vars, paired with the disko module. +- `modules/installer/` — the auto-installer environment (ISO, also served as + PXE netboot): `common.nix` (shared config + the generated + `auto-install.sh`), `iso.nix`, `host-keys.nix` (optionally bakes + `host-keys/` into the image under `--impure`). See + `docs/auto-installer.md`. +- `modules/pxe-boot/stage-installer-artifacts.nix` — builds the installer's + netboot image and stages it on the `pxe-boot` host so its iPXE menu can + chain straight to it. See `docs/pxe-boot.md`. +- `modules/nix-cache/{client,server,remote-builder-client}.nix` — binary cache + substituter + SSH remote-builder wiring; see `docs/nix-cache.md` for the + full design (per-host local stores, no shared `/nix/store`, and how the + `nixremote` signing/SSH keys fit together). +- `modules/ha/` — HA cluster NixOS modules: `cluster-config.nix` (DRBD, + Corosync, Pacemaker, firewall rules, cluster-wide NFS/iSCSI port + authorisation — shared by both ha-server nodes), `pacemaker-stack.nix` + (Pacemaker + Corosync service enablement), and supporting modules. See + `docs/ha.md` for the cluster operational guide. +- `modules/ipa/client.nix` — FreeIPA client enrollment: sssd, Kerberos keytab, + and IPA host registration; imported by every real host via + `modules/common/configuration.nix`. +- `modules/beszel/enable-agent.nix` — enables beszel-agent, sets `HUB_URL`, + fixes the upstream `StateDirectory` bug, and wires the universal + `beszel-token` sops secret (from `secrets/common.yaml`) into the agent's + `environmentFile`; see `docs/beszel.md` for the full setup guide. +- `modules/tailscale/`, `modules/docker/`, `modules/networking/`, + `modules/traefik/`, `modules/tor/`, `modules/services/*` — single-purpose, + single-host feature modules (e.g. `docker/enable-service.nix`, + `services/zfs/enable-service.nix`). Grep `modules/build-types/*.nix` for + each build type's `imports` list to see which modules apply where. + +New host = new `hosts//host.nix` + a matching +`mkTarget { platform; buildType; hostPath; }` entry added to `flake.nix`'s +`generatedTargets`, composed from existing `modules/*` pieces rather than +duplicating config. + +### Other docs worth reading before touching these areas + +- `docs/nix-cache.md` — nix-cache binary cache/remote-builder design and key + handling. +- `docs/pxe-boot.md` — the `pxe-boot` host's iPXE/TFTP/HTTP boot chain and + directory layout under `/srv/pxe`. +- `docs/auto-installer.md` — the installer environment (ISO/netboot/Proxmox + LXC), `host-keys/` and the sops-nix pre-seeding problem it solves, and why + `lxc-*` hosts are deliberately excluded from its menu. +- `docs/proxmox-images.md` — building `proxmox-*` hosts as standalone `.raw` + disk images (disko's image builder) instead of installing, and deploying + the result to Proxmox. +- `docs/flake-lock-automation.md` — how `flake.lock` updates flow through CI + (scheduled `nix flake update` PR + host-eval-on-PR workflow) and why hosts + should track the committed lock file rather than `nixos-rebuild --upgrade-all`. +- `docs/ha.md` — HA file-server cluster: DRBD + XFS + LIO iSCSI + NFS managed + by Corosync + Pacemaker; network topology; lifecycle scripts in `scripts/ha/`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e474881 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 beatzaplenty + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index d6d7873..b7f58d8 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,171 @@ -# infrastructure +# NixOS LAN Configurations -Mono-repo for all homelab infrastructure as code. Manages configuration, provisioning, -service stacks, and documentation for the `sweet.home` LAN and edge nodes. +Flake-based NixOS configuration repository for Wayne's LAN servers and +workstation. + +## Hosts + +Targets are named `-`, generated from two orthogonal +pieces composed in `flake.nix`: + +- **Platforms** (what it runs on): `linode`, `proxmox`, `lxc`, `baremetal` +- **Build types** (what it's for): `minimal`, `nix-cache`, `docker`, `gui`, + `pxe-boot`, `tailscale-router`, `tor-relay`, `ha-server` + +Not every combination exists — `pxe-boot` has no `linode` variant, since +PXE/DHCP/TFTP need LAN L2 adjacency that a Linode VPS doesn't have, +`tor-relay` and `ha-server` currently only exist on `lxc`/`proxmox`, and +`baremetal` currently only exists as `baremetal-gui` (the real gui-host +hardware). The full list: + +| Target | Purpose | +| --- | --- | +| `linode-minimal` | Minimal NixOS host profile on a Linode VPS | +| `proxmox-minimal` | Minimal NixOS host profile on Proxmox — previously the flat `nix-minimal` target | +| `lxc-minimal` | Minimal NixOS host profile in a Proxmox LXC container | +| `linode-nix-cache` / `proxmox-nix-cache` / `lxc-nix-cache` | Local Nix binary cache and remote builder — previously the flat `nix-cache` target | +| `linode-docker` / `proxmox-docker` / `lxc-docker` | Docker host for the main container stack — previously the flat `docker` target | +| `linode-gui` / `proxmox-gui` / `lxc-gui` | Cinnamon desktop workstation — previously the flat `nixos` target | +| `baremetal-gui` | Same Cinnamon desktop workstation, on the real gui-host hardware — ZFS RAID0 root, systemd-boot | +| `proxmox-pxe-boot` / `lxc-pxe-boot` | HTTP/iPXE boot asset host — previously the flat `pxe-boot` target | +| `linode-tailscale-router` / `proxmox-tailscale-router` / `lxc-tailscale-router` | Tailscale subnet router + MagicDNS forwarder for the LAN | +| `lxc-tor-relay` | Tor middle relay | +| `proxmox-ha-server-1` / `proxmox-ha-server-2` | HA file-server cluster nodes — DRBD + XFS + iSCSI + NFS, managed by Corosync + Pacemaker | + +Which variant of a given buildtype is actually deployed isn't tracked +anywhere in this repo — that's live infrastructure state, not something a +committed file can keep accurate, and it changes independently of the code. +Check the Proxmox node itself, or `/etc/flake-target` on a running host (see +below), if you need to know what's really out there right now. +`scripts/proxmox/create-proxmox-resource.sh`'s duplicate-host guard works the same +way: it checks the Proxmox node directly rather than any file here. +Real, production deployments live on `pve1.sweet.home`; there's a second +node, `pve-test.sweet.home`, set aside purely for scratch/test resources — +see `scripts/env.sh` (`PVE1_HOST` / `PVE_TEST_HOST`, and the +`--node`/`PROXMOX_HOST` targeting they feed into) and CLAUDE.md's Proxmox +section for which is which. + +Each buildtype's `hosts//host.nix` carries the per-machine identity +(hostname, hostId, per-machine secrets, `system.stateVersion`) that must stay +fixed regardless of which platform it's built for. Every deployed host +stamps its own active target name into `/etc/flake-target` at build time, so +`nixos-rebuild switch --flake .#$(cat /etc/flake-target)` always picks up the +right one even after a platform migration changes the flake attribute name. + +List hosts with: + +```bash +nix eval --json .#nixosConfigurations --apply builtins.attrNames | jq -r '.[]' +``` ## Layout -``` -infrastructure/ -├── terraform/ Infrastructure state — Proxmox VMs, DNS, container catalog -├── ansible/ Configuration management for non-NixOS hosts -├── nixos/ NixOS flake (populated via git subtree — see implementation plan) -├── stacks/ -│ ├── docker/ Main self-hosted app stack (Traefik, Nextcloud, Passbolt, Gitea…) -│ └── raspi/ Raspberry Pi edge monitoring stack -├── docs/ Unified internal + external documentation (MkDocs) -└── scripts/ Cross-cutting tooling (drift detection, docs build, hooks) -``` +| Path | Purpose | +| --- | --- | +| `flake.nix` | Flake inputs, the `mkTarget` platform × build-type generator, and `nixosConfigurations` outputs | +| `variables.nix` | Single source of truth for shared values (LAN domain/CIDR, hostnames, timezone, primary username, storage root, NFS share subpaths/mountpoints, service ports, ...) — passed to every module and Home Manager config as the `vars` argument via `specialArgs`/`extraSpecialArgs` | +| `hosts//host.nix` | Per-machine identity: hostname, hostId, per-machine secrets, `system.stateVersion` | +| `hosts/nixos/home.nix` | Workstation-specific Home Manager config (used by the `gui` build type) | +| `modules/platforms/` | Platform-specific config: virtualisation guest tools, boot method, hardware config (`linode.nix`, `proxmox.nix`, `lxc.nix`, `baremetal.nix`) | +| `modules/build-types/` | Build-type-specific config: what makes a system minimal/server/docker/gui/pxe-boot/nix-cache | +| `modules/common/` | Shared NixOS config, Home Manager, aliases imported by every host | +| `modules/nix-cache/` | Binary cache and remote builder client/server modules | +| `modules/installer/` | Auto-installer environment (ISO, also served as PXE netboot) — see `docs/auto-installer.md` | +| `host-keys/` | Gitignored; only used by the auto-installer environment for pre-seeding SSH host keys before first boot — see `docs/auto-installer.md`. All deployed hosts use clan vars (`vars/per-machine//openssh/`) instead | +| `vars/per-machine/` | Clan vars: committed, sops-encrypted SSH host keys for all deployed hosts; read by `create-proxmox-resource.sh` at deploy time | +| `docs/` | Operational notes for cache, builders, lock updates, boot services, the auto-installer, and Proxmox image builds | +| `scripts/` | Codex setup, validation, host-key, release-bump, and Proxmox resource helpers | -## Hosts managed +## Validation -| Host | Role | OS | Managed by | -|------|------|----|-----------| -| `pve1.sweet.home` | Production hypervisor | Proxmox VE | Ansible (`proxmox-hardening` role) | -| `pve-test.sweet.home` | Sandbox hypervisor | Proxmox VE | Ansible (`proxmox-hardening` role) | -| `domain-controller.sweet.home` | FreeIPA / Kerberos / DNS | Rocky Linux 9 | Ansible (`freeipa` role) | -| `pihole.sweet.home` | DNS filtering / DHCP | Pi-hole v6 | Ansible (`pihole` role) | -| `raspberrypi.tail13f623.ts.net` | Edge monitoring | Debian 12 | Ansible (`raspberrypi` role) | -| NixOS VMs / LXCs on pve1 | Docker host, nix-cache, PXE, router… | NixOS | `nixos/` flake | - -## CI/CD - -| Workflow | Trigger | What it does | -|---------|---------|--------------| -| `secret-scan` | Push / PR | gitleaks across full git history | -| `drift-detect` | Daily 06:00 AEST | `terraform plan` + `ansible --check` on all managed hosts | -| `docs-build` | Push to `docs/` or `stacks/` | Rebuild and deploy internal + external MkDocs sites | - -## Quick start +Safe validation commands for Codex and local review: ```bash -# Secrets pre-commit hook -./scripts/install-hooks.sh - -# Validate everything (dry-run, no changes) -./scripts/drift-detect.sh --check-only - -# Build docs locally -./scripts/docs-build.sh +bash scripts/codex-setup.sh +bash scripts/codex-maintenance.sh ``` -## Related repos (migrating into this one) +`codex-maintenance.sh` with no flags (what CI runs on every push/PR) scopes +fmt-check/statix/eval to files changed against a base ref — fast, but only +as thorough as the diff. For the full sweep (every host, every package, +fmt-check and statix over the whole tree — slow, CI never runs this): -The following repos are being absorbed. See `docs/internal/implementation-plan.md`. +```bash +bash scripts/codex-maintenance.sh --full-check +bash scripts/codex-maintenance.sh --full-check --dry-run +``` -- `nixos` → `nixos/` -- `docker` → `stacks/docker/` -- `raspi` → `stacks/raspi/` -- `debian-configuration` → `ansible/` (dissolved into roles) +For individual host evaluation: + +```bash +nix eval .#nixosConfigurations..config.system.build.toplevel.drvPath --raw +``` + +Use `nix build --dry-run --no-link` when build planning is needed. Do not run +deployment, install, disk formatting, mount, or reboot commands from automated +review sessions. + +## Operations + +- Host rebuilds should consume the committed `flake.lock`. +- Routine dependency updates should happen through the flake lock automation + described in `docs/flake-lock-automation.md`. +- `nix-cache` serves substitutes over HTTP and can act as a remote builder for + client hosts. +- `pxe-boot` serves iPXE boot files over HTTP from `/srv/pxe`. + +### Deploying a new host + +Three different paths depending on target, none of them involving a manual +`nixos-rebuild switch` from this repo: + +- Most hosts: boot the auto-installer, pick the target from its menu — see + `docs/auto-installer.md`. Every menu target has a Disko config the + installer formats unconditionally (`docs/auto-installer.md`'s "Storage" + section covers how this stays non-destructive for `linode-*`, whose disks + Linode itself provisions ahead of time). +- `lxc-*` targets: not installed at all — build a ready-to-run container + tarball and `pct create` it as a CT template directly. `docs/auto-installer.md` + covers why (and the installer's menu excludes them for the same reason). +- `proxmox-*` targets: can alternatively be built as a standalone `.raw` + disk image and attached to a new VM with no install step — see + `docs/proxmox-images.md`. + +`scripts/proxmox/create-proxmox-resource.sh --type lxc|vm --host ` automates +either of the last two end to end (host-key registration, building the +image directly on the Proxmox node itself, `pct create`/`qm create`), with +`--dry-run` and a guard against duplicating an already-deployed host's +identity. See its `--help`. + +## Security Notes + +Do not commit tokens, private keys, live credentials, or new password hashes +as plaintext. Secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix): +encrypted files live under `secrets/`, recipients (per-host age keys derived +from each host's existing SSH host key, plus an admin key) are declared in +`.sops.yaml`. To add or edit a secret: + +```bash +nix-shell -p sops --run "sops secrets/.yaml" +``` + +then reference it from a module via `config.sops.secrets."".path` +(or `sops.templates` for values that need to be embedded in a rendered +config file, e.g. `nix.conf`'s `access-tokens`). Never write a secret value +directly into a tracked `.nix` file. A pre-commit hook (`.githooks/`, +enabled via `git config core.hooksPath .githooks`, done automatically by +`scripts/codex-setup.sh`) runs `gitleaks protect --staged` to catch mistakes +before they're committed. + +The auto-installer environment is the one deliberate exception to +sops-nix-everywhere: it has a hardcoded login password instead (no stable +per-boot host key for sops-nix to derive from on ephemeral media) — see +"Host keys" in `docs/auto-installer.md` for why, and how the private keys it +*does* pre-seed for target hosts stay out of git via the gitignored +`host-keys/` directory. All deployed hosts use clan vars +(`vars/per-machine//openssh/`, committed and sops-encrypted) for +their SSH host keys. + +This repository's git *history* still contains secrets committed before the +sops-nix migration — those are being scrubbed and rotated separately; don't +treat the repo as safe to make public until that's finished. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg deleted file mode 100644 index 8609345..0000000 --- a/ansible/ansible.cfg +++ /dev/null @@ -1,18 +0,0 @@ -[defaults] -inventory = ./inventory/hosts.yml -collections_path = ./collections -retry_files_enabled = False -stdout_callback = yaml -host_key_checking = True - -# Fail fast on unreachable hosts rather than silently skipping -any_errors_fatal = False - -# Use pipelining for speed (requires requiretty disabled in sudoers, which our roles handle) -pipelining = True - -[inventory] -enable_plugins = yaml, ini - -[ssh_connection] -ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30 diff --git a/ansible/collections/requirements.yml b/ansible/collections/requirements.yml deleted file mode 100644 index 826f625..0000000 --- a/ansible/collections/requirements.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -collections: - - name: ansible.posix - - name: community.general - - name: ansible.utils diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml deleted file mode 100644 index 51456ec..0000000 --- a/ansible/inventory/group_vars/all.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# Variables applied to every host. -# Override per-group in group_vars/.yml or per-host in host_vars//vars.yml. - -ansible_python_interpreter: /usr/bin/python3 - -# LAN domain -lan_domain: sweet.home -tailnet_domain: tail13f623.ts.net - -# DNS: FreeIPA is the authoritative resolver for sweet.home (Pi-hole decommissioned). -# All LAN clients point directly to domain-controller.sweet.home for DNS. -ipa_realm: SWEET.HOME -ipa_server: domain-controller.sweet.home - -# Docker access GID — must match FreeIPA docker-access group GID -docker_access_gid: 50010 - -# IPA admins group granted passwordless sudo on all enrolled hosts -ipa_admin_group: admins diff --git a/ansible/inventory/group_vars/proxmox.yml b/ansible/inventory/group_vars/proxmox.yml deleted file mode 100644 index f3efd7f..0000000 --- a/ansible/inventory/group_vars/proxmox.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -# Proxmox-group defaults. -# Per-host overrides go in host_vars/pve1.sweet.home/vars.yml etc. - -# proxmox-hardening role toggles -proxmox_harden_ssh: true -proxmox_configure_firewall: true -proxmox_configure_unattended_upgrades: true -proxmox_switch_to_nosub_repo: true -proxmox_disable_nag: true -proxmox_setup_ipa_sudo: true - -# Firewall: management CIDR — override per host if subnets differ -# proxmox_mgmt_cidr is set per host in hosts.yml diff --git a/ansible/inventory/group_vars/raspi.yml b/ansible/inventory/group_vars/raspi.yml deleted file mode 100644 index b3b45a9..0000000 --- a/ansible/inventory/group_vars/raspi.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -# Raspberry Pi group defaults. - -raspberrypi_setup_ipa_sudo: true -raspberrypi_pin_docker_gid: true diff --git a/ansible/inventory/host_vars/.gitkeep b/ansible/inventory/host_vars/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml deleted file mode 100644 index 9b4795b..0000000 --- a/ansible/inventory/hosts.yml +++ /dev/null @@ -1,81 +0,0 @@ ---- -# Full infrastructure inventory. -# IPs are documented here for reference; use FQDNs where DNS is reliable. -# Hosts marked [nixos] are managed by the NixOS flake (nixos/) and are present -# here only for Ansible tasks that apply to them (e.g. drift-check pings). - -all: - children: - - # ── Proxmox hypervisors ─────────────────────────────────────────────────── - proxmox: - hosts: - pve1.sweet.home: - ansible_user: wayne - proxmox_node_name: pve - proxmox_role: production - proxmox_mgmt_cidr: "192.168.2.0/24" - proxmox_admin_username: wayne - pve-test.sweet.home: - ansible_user: wayne - proxmox_node_name: pve-test - proxmox_role: sandbox - proxmox_mgmt_cidr: "192.168.2.0/24" - proxmox_admin_username: wayne - - # ── Identity / DNS ──────────────────────────────────────────────────────── - freeipa: - hosts: - domain-controller.sweet.home: - ansible_user: wayne - # IPA server parameters (consumed by freeipa role) - ipa_realm: "SWEET.HOME" - ipa_domain: "sweet.home" - ipa_hostname: "domain-controller.sweet.home" - ipa_ip: "192.168.2.253" - # ipa_dns_forwarder: set to router gateway IP before running freeipa role - # Post-install: add ts.net conditional forwarder manually: - # ipa dnsforwardzone-add tail13f623.ts.net --forwarder=192.168.2.222 - ansible_python_interpreter: /usr/bin/python3 - - # ── Edge / monitoring ───────────────────────────────────────────────────── - raspi: - hosts: - raspberrypi.tail13f623.ts.net: - ansible_user: wayne - docker_access_gid: 50010 - - # ── NixOS hosts (flake-managed; present for ping/audit tasks only) ──────── - nixos: - vars: - ansible_note: > - These hosts are managed by the NixOS flake in nixos/. - Only non-NixOS tasks (connectivity checks, IPA enrollment helpers) - should target this group directly from Ansible. - hosts: - docker.sweet.home: - ansible_host: 192.168.2.225 - ansible_user: wayne - nixos_build_type: docker - nix-cache.sweet.home: - ansible_host: 192.168.2.224 - ansible_user: wayne - nixos_build_type: nix-cache - - # ── Groupings for playbook targeting ───────────────────────────────────── - linux: - children: - proxmox: {} - freeipa: {} - raspi: {} - nixos: {} - - network: - children: - freeipa: {} - - non_nixos: - children: - proxmox: {} - freeipa: {} - raspi: {} diff --git a/ansible/playbooks/freeipa.yml b/ansible/playbooks/freeipa.yml deleted file mode 100644 index 645809b..0000000 --- a/ansible/playbooks/freeipa.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -- name: FreeIPA server provisioning - hosts: freeipa - become: true - gather_facts: true - - pre_tasks: - - name: Confirm Rocky Linux 9 - ansible.builtin.assert: - that: - - ansible_distribution == "Rocky" - - ansible_distribution_major_version == "9" - fail_msg: "FreeIPA role targets Rocky Linux 9 only." - tags: always - - roles: - - role: freeipa - tags: freeipa diff --git a/ansible/playbooks/ping.yml b/ansible/playbooks/ping.yml deleted file mode 100644 index 52291e9..0000000 --- a/ansible/playbooks/ping.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Connectivity check — verify all hosts are reachable before a real run. -- name: Ping all hosts - hosts: all - gather_facts: false - tasks: - - name: Ping - ansible.builtin.ping: diff --git a/ansible/playbooks/proxmox.yml b/ansible/playbooks/proxmox.yml deleted file mode 100644 index f2b2f2b..0000000 --- a/ansible/playbooks/proxmox.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -- name: Proxmox VE hardening - hosts: proxmox - become: true - gather_facts: true - - pre_tasks: - - name: Confirm this is a Proxmox VE host - ansible.builtin.assert: - that: - - ansible_os_family == "Debian" - - ansible_facts.packages is not defined or true - fail_msg: "This playbook targets Proxmox VE (Debian-based) hosts only." - tags: always - - roles: - - role: proxmox-hardening - tags: proxmox diff --git a/ansible/playbooks/raspi.yml b/ansible/playbooks/raspi.yml deleted file mode 100644 index d3a4195..0000000 --- a/ansible/playbooks/raspi.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -- name: Raspberry Pi setup - hosts: raspi - become: true - gather_facts: true - - pre_tasks: - - name: Confirm IPA enrollment - ansible.builtin.stat: - path: /etc/ipa/default.conf - register: ipa_conf - tags: always - - - name: Warn if not IPA-enrolled - ansible.builtin.debug: - msg: "WARNING: /etc/ipa/default.conf not found. IPA-dependent tasks will be skipped." - when: not ipa_conf.stat.exists - tags: always - - roles: - - role: raspberrypi - tags: raspi diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml deleted file mode 100644 index 5d24bc7..0000000 --- a/ansible/playbooks/site.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Master playbook — runs every role against its target group. -# Always use --check --diff on first run against production hosts. -# Use --limit to target a subset. -# -# Examples: -# ansible-playbook site.yml --check --diff # dry-run everything -# ansible-playbook site.yml --limit pve1.sweet.home # production proxmox only -# ansible-playbook site.yml --limit proxmox --tags ssh # only SSH tasks on all PVE nodes - -- import_playbook: proxmox.yml -- import_playbook: freeipa.yml -- import_playbook: raspi.yml diff --git a/ansible/roles/freeipa/defaults/main.yml b/ansible/roles/freeipa/defaults/main.yml deleted file mode 100644 index 0307fdd..0000000 --- a/ansible/roles/freeipa/defaults/main.yml +++ /dev/null @@ -1,19 +0,0 @@ ---- -# freeipa role defaults — all overridable in inventory host_vars or group_vars. -# Most values come from the per-host inventory (see hosts.yml). - -ipa_realm: "SWEET.HOME" -ipa_domain: "sweet.home" - -# Set these per-host in inventory/hosts.yml: -# ipa_hostname: "domain-controller.sweet.home" -# ipa_ip: "192.168.2.253" -# ipa_dns_forwarder: "" # upstream resolver for non-sweet.home queries -# -# Note: the conditional forwarder for *.ts.net → tailscale-router (192.168.2.222) -# is configured post-install via `ipa dnsforwardzone-add tail13f623.ts.net --forwarder=192.168.2.222`. -# This is what allows LAN clients to resolve Tailscale hostnames through FreeIPA. -# The NixOS tailscale-router build type configures the other direction automatically. - -# Swap file created if no swap exists (FreeIPA needs headroom during install) -ipa_swap_size_mb: 2048 diff --git a/ansible/roles/freeipa/tasks/main.yml b/ansible/roles/freeipa/tasks/main.yml deleted file mode 100644 index 4bd78aa..0000000 --- a/ansible/roles/freeipa/tasks/main.yml +++ /dev/null @@ -1,133 +0,0 @@ ---- -# freeipa/tasks/main.yml -# Provisions a FreeIPA 4.x server on Rocky Linux 9. -# The ipa-server-install step is intentionally NOT idempotent — it will -# refuse to run if IPA is already installed, which acts as a safety guard. - -# ── Pre-flight ──────────────────────────────────────────────────────────────── - -- name: Check IPA is not already installed - ansible.builtin.stat: - path: /etc/ipa/default.conf - register: ipa_installed - tags: always - -- name: Skip install tasks if IPA already exists - ansible.builtin.debug: - msg: "FreeIPA already installed — skipping install tasks. Use verify tasks to check health." - when: ipa_installed.stat.exists - tags: install - -- name: Assert hostname is correct FQDN - ansible.builtin.command: - cmd: hostname -f - register: fqdn_check - changed_when: false - failed_when: fqdn_check.stdout != ipa_hostname - when: - - not ipa_installed.stat.exists - - ipa_hostname is defined - tags: install, preflight - -- name: Assert /etc/hosts has correct entry - ansible.builtin.lineinfile: - path: /etc/hosts - line: "{{ ipa_ip }} {{ ipa_hostname }} {{ ipa_hostname.split('.')[0] }}" - state: present - when: - - not ipa_installed.stat.exists - - ipa_ip is defined - - ipa_hostname is defined - tags: install, preflight - -# ── Swap ────────────────────────────────────────────────────────────────────── - -- name: Check current swap - ansible.builtin.command: - cmd: swapon --show - register: swap_check - changed_when: false - tags: install, swap - -- name: Create swap file if absent - block: - - name: Create swap file - ansible.builtin.command: - cmd: "dd if=/dev/zero of=/swapfile bs=1M count={{ ipa_swap_size_mb }} status=none" - args: - creates: /swapfile - - - name: Set swap permissions - ansible.builtin.file: - path: /swapfile - mode: "0600" - - - name: Format swap - ansible.builtin.command: - cmd: mkswap /swapfile - changed_when: true - - - name: Enable swap - ansible.builtin.command: - cmd: swapon /swapfile - changed_when: true - - - name: Add swap to fstab - ansible.builtin.lineinfile: - path: /etc/fstab - line: "/swapfile none swap defaults 0 0" - state: present - when: - - not ipa_installed.stat.exists - - swap_check.stdout == "" - tags: install, swap - -# ── Package install ─────────────────────────────────────────────────────────── - -- name: Install FreeIPA server packages - ansible.builtin.dnf: - name: - - freeipa-server - - freeipa-server-dns - state: present - when: not ipa_installed.stat.exists - tags: install, packages - -# ── IPA server install ──────────────────────────────────────────────────────── - -- name: Run ipa-server-install (interactive passwords via env vars) - ansible.builtin.shell: - cmd: > - ipa-server-install - --realm={{ ipa_realm }} - --domain={{ ipa_domain }} - --hostname={{ ipa_hostname }} - --ip-address={{ ipa_ip }} - --forwarder={{ ipa_dns_forwarder }} - --setup-dns - --auto-reverse - --no-host-dns - --mkhomedir - --unattended - --ds-password="${IPA_DM_PASSWORD}" - --admin-password="${IPA_ADMIN_PASSWORD}" - environment: - IPA_DM_PASSWORD: "{{ ipa_dm_password }}" - IPA_ADMIN_PASSWORD: "{{ ipa_admin_password }}" - no_log: true - when: not ipa_installed.stat.exists - tags: install - -# ── Verification ────────────────────────────────────────────────────────────── - -- name: Verify IPA services are running - ansible.builtin.command: - cmd: ipactl status - register: ipa_status - changed_when: false - tags: verify - -- name: Print IPA service status - ansible.builtin.debug: - var: ipa_status.stdout_lines - tags: verify diff --git a/ansible/roles/proxmox-hardening/defaults/main.yml b/ansible/roles/proxmox-hardening/defaults/main.yml deleted file mode 100644 index 94dfa88..0000000 --- a/ansible/roles/proxmox-hardening/defaults/main.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -# proxmox-hardening role defaults. -# Override in inventory/group_vars/proxmox.yml or host_vars//vars.yml. - -proxmox_harden_ssh: true -proxmox_configure_firewall: true -proxmox_configure_unattended_upgrades: true -proxmox_switch_to_nosub_repo: true -proxmox_disable_nag: true -proxmox_setup_ipa_sudo: true - -# Management CIDR for the PVE datacenter firewall (inbound allow-list). -# Must be set per-host in inventory (hosts.yml) — no default here to force explicit assignment. -# proxmox_mgmt_cidr: "192.168.2.0/24" - -# Named PVE admin username to create (set to "" to skip user creation). -# proxmox_admin_username: "wayne" - -# fail2ban sshd jail settings -proxmox_fail2ban_maxretry: 5 -proxmox_fail2ban_bantime: "1h" -proxmox_fail2ban_findtime: "10m" diff --git a/ansible/roles/proxmox-hardening/handlers/main.yml b/ansible/roles/proxmox-hardening/handlers/main.yml deleted file mode 100644 index 89cfe62..0000000 --- a/ansible/roles/proxmox-hardening/handlers/main.yml +++ /dev/null @@ -1,19 +0,0 @@ ---- -- name: apt update - ansible.builtin.apt: - update_cache: true - -- name: reload sshd - ansible.builtin.systemd: - name: sshd - state: reloaded - -- name: restart fail2ban - ansible.builtin.systemd: - name: fail2ban - state: restarted - -- name: restart pve-firewall - ansible.builtin.command: - cmd: pve-firewall restart - changed_when: true diff --git a/ansible/roles/proxmox-hardening/tasks/main.yml b/ansible/roles/proxmox-hardening/tasks/main.yml deleted file mode 100644 index dcff922..0000000 --- a/ansible/roles/proxmox-hardening/tasks/main.yml +++ /dev/null @@ -1,243 +0,0 @@ ---- -# proxmox-hardening/tasks/main.yml -# Mirrors the bash scripts in debian-configuration/proxmox/scripts/. -# All tasks are idempotent — safe to re-run. - -# ── APT repos ───────────────────────────────────────────────────────────────── - -- name: Disable enterprise apt source (rename to .disabled) - ansible.builtin.find: - paths: /etc/apt/sources.list.d - patterns: "*.sources,*.list" - register: apt_sources - when: proxmox_switch_to_nosub_repo - tags: repos - -- name: Disable enterprise apt sources - ansible.builtin.command: - cmd: "mv {{ item.path }} {{ item.path }}.disabled" - loop: "{{ apt_sources.files | default([]) }}" - when: - - proxmox_switch_to_nosub_repo - - item.path is not search('.disabled') - register: mv_result - changed_when: mv_result.rc == 0 - failed_when: mv_result.rc not in [0, 1] - # Only disable files that actually reference enterprise.proxmox.com - # (checked by grep in the shell — using shell here for grep pipe) - tags: repos - -- name: Write no-subscription apt source - ansible.builtin.template: - src: pve-no-subscription.sources.j2 - dest: /etc/apt/sources.list.d/pve-no-subscription.sources - mode: "0644" - notify: apt update - when: proxmox_switch_to_nosub_repo - tags: repos - -# ── SSH hardening ───────────────────────────────────────────────────────────── - -- name: Ensure sshd_config.d directory exists - ansible.builtin.file: - path: /etc/ssh/sshd_config.d - state: directory - mode: "0755" - when: proxmox_harden_ssh - tags: ssh - -- name: Apply SSH hardening drop-in - ansible.builtin.template: - src: sshd-hardening.conf.j2 - dest: /etc/ssh/sshd_config.d/99-hardening.conf - mode: "0644" - validate: "sshd -t -f %s" - notify: reload sshd - when: proxmox_harden_ssh - tags: ssh - -- name: Install fail2ban - ansible.builtin.apt: - name: fail2ban - state: present - update_cache: false - when: proxmox_harden_ssh - tags: ssh - -- name: Write fail2ban sshd jail - ansible.builtin.template: - src: fail2ban-sshd.local.j2 - dest: /etc/fail2ban/jail.d/sshd.local - mode: "0644" - notify: restart fail2ban - when: proxmox_harden_ssh - tags: ssh - -- name: Enable and start fail2ban - ansible.builtin.systemd: - name: fail2ban - enabled: true - state: started - when: proxmox_harden_ssh - tags: ssh - -# ── PVE datacenter firewall ─────────────────────────────────────────────────── - -- name: Ensure /etc/pve/firewall exists - ansible.builtin.file: - path: /etc/pve/firewall - state: directory - mode: "0750" - when: proxmox_configure_firewall and proxmox_mgmt_cidr is defined - tags: firewall - -- name: Deploy datacenter firewall config - ansible.builtin.template: - src: cluster-fw.j2 - dest: /etc/pve/firewall/cluster.fw - mode: "0640" - notify: restart pve-firewall - when: proxmox_configure_firewall and proxmox_mgmt_cidr is defined - tags: firewall - -# ── Unattended upgrades ─────────────────────────────────────────────────────── - -- name: Install unattended-upgrades - ansible.builtin.apt: - name: unattended-upgrades - state: present - when: proxmox_configure_unattended_upgrades - tags: upgrades - -- name: Write unattended-upgrades origins config - ansible.builtin.template: - src: unattended-upgrades.conf.j2 - dest: /etc/apt/apt.conf.d/51pve-unattended-upgrades.conf - mode: "0644" - when: proxmox_configure_unattended_upgrades - tags: upgrades - -- name: Write auto-upgrades config - ansible.builtin.copy: - content: | - // Managed by ansible proxmox-hardening role - APT::Periodic::Update-Package-Lists "1"; - APT::Periodic::Unattended-Upgrade "1"; - APT::Periodic::Download-Upgradeable-Packages "1"; - APT::Periodic::AutocleanInterval "7"; - dest: /etc/apt/apt.conf.d/20auto-upgrades - mode: "0644" - when: proxmox_configure_unattended_upgrades - tags: upgrades - -- name: Enable unattended-upgrades service - ansible.builtin.systemd: - name: unattended-upgrades - enabled: true - state: started - when: proxmox_configure_unattended_upgrades - tags: upgrades - -# ── PVE admin user ──────────────────────────────────────────────────────────── - -- name: Check if named admin user already exists - ansible.builtin.command: - cmd: "pveum user list --output-format json" - register: pve_users - changed_when: false - when: proxmox_admin_username is defined and proxmox_admin_username != "" - tags: users - -- name: Create named PVE admin user (one-time — password printed, must be changed on first login) - ansible.builtin.command: - cmd: > - pveum user add {{ proxmox_admin_username }}@pve - --password {{ lookup('password', '/dev/null length=24 chars=ascii_letters,digits') }} - --comment "Named admin account, managed by ansible proxmox-hardening role" - when: - - proxmox_admin_username is defined - - proxmox_admin_username != "" - - pve_users.stdout is defined - - proxmox_admin_username + "@pve" not in pve_users.stdout - no_log: true - tags: users - -- name: Grant Administrator role to PVE admin user - ansible.builtin.command: - cmd: "pveum acl modify / --users {{ proxmox_admin_username }}@pve --roles Administrator" - when: - - proxmox_admin_username is defined - - proxmox_admin_username != "" - - pve_users.stdout is defined - - proxmox_admin_username + "@pve" not in pve_users.stdout - tags: users - -# ── IPA sudo ────────────────────────────────────────────────────────────────── - -- name: Check IPA enrollment - ansible.builtin.stat: - path: /etc/ipa/default.conf - register: ipa_conf - when: proxmox_setup_ipa_sudo - tags: ipa - -- name: Write admins NOPASSWD sudoers file - ansible.builtin.copy: - content: "%admins ALL=(root) NOPASSWD: ALL\n" - dest: /etc/sudoers.d/admins-nopasswd - mode: "0440" - validate: "visudo -cf %s" - when: - - proxmox_setup_ipa_sudo - - ipa_conf.stat.exists - tags: ipa - -- name: Find pvesh/qm/pct paths for proxmox sudoers - ansible.builtin.command: - cmd: "which {{ item }}" - register: pve_tools - loop: [pvesh, qm, pct] - changed_when: false - failed_when: false - when: - - proxmox_setup_ipa_sudo - - ipa_conf.stat.exists - tags: ipa - -- name: Write admins proxmox tools sudoers file - ansible.builtin.copy: - content: > - %admins ALL=(root) NOPASSWD: - {{ pve_tools.results | map(attribute='stdout') | select | join(', ') }} - dest: /etc/sudoers.d/admins-proxmox - mode: "0440" - validate: "visudo -cf %s" - when: - - proxmox_setup_ipa_sudo - - ipa_conf.stat.exists - - pve_tools.results | map(attribute='rc') | select('eq', 0) | list | length == 3 - tags: ipa - -# ── Subscription nag ────────────────────────────────────────────────────────── - -- name: Check if nag patch is needed - ansible.builtin.shell: - cmd: > - grep -qF "data.status.toLowerCase() !== 'active'" - /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js - register: nag_check - changed_when: false - failed_when: false - when: proxmox_disable_nag - tags: nag - -- name: Patch subscription nag - ansible.builtin.replace: - path: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js - regexp: "data\\.status\\.toLowerCase\\(\\) !== 'active'" - replace: "false" - backup: true - when: - - proxmox_disable_nag - - nag_check.rc == 0 - tags: nag diff --git a/ansible/roles/proxmox-hardening/templates/cluster-fw.j2 b/ansible/roles/proxmox-hardening/templates/cluster-fw.j2 deleted file mode 100644 index 7357a16..0000000 --- a/ansible/roles/proxmox-hardening/templates/cluster-fw.j2 +++ /dev/null @@ -1,27 +0,0 @@ -# Managed by ansible proxmox-hardening role — do not edit by hand -# Proxmox datacenter-level firewall (Stage 1: single-node management access only) -# Stage 2 (Corosync/Ceph cluster) rules remain commented until needed. - -[OPTIONS] -enable: 1 -policy_in: DROP -policy_out: ACCEPT - -[IPSET mgmt] -# Management subnet — SSH and web UI access only from here -{{ proxmox_mgmt_cidr }} - -[RULES] -# Allow SSH from management network -IN ACCEPT -source +mgmt -p tcp --dport 22 -log nolog -# Allow PVE web UI from management network -IN ACCEPT -source +mgmt -p tcp --dport 8006 -log nolog -# Allow SPICE console from management network -IN ACCEPT -source +mgmt -p tcp --dport 3128 -log nolog - -# Stage 2 — Corosync (uncomment when clustering pve1 with additional nodes) -# IN ACCEPT -source +mgmt -p udp --dport 5404:5405 -log nolog - -# Stage 2 — Ceph (uncomment when Ceph OSD replication is active) -# IN ACCEPT -source +mgmt -p tcp --dport 6800:7568 -log nolog - diff --git a/ansible/roles/proxmox-hardening/templates/fail2ban-sshd.local.j2 b/ansible/roles/proxmox-hardening/templates/fail2ban-sshd.local.j2 deleted file mode 100644 index 3ddc591..0000000 --- a/ansible/roles/proxmox-hardening/templates/fail2ban-sshd.local.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Managed by ansible proxmox-hardening role — do not edit by hand -[sshd] -enabled = true -port = ssh -backend = systemd -maxretry = {{ proxmox_fail2ban_maxretry }} -bantime = {{ proxmox_fail2ban_bantime }} -findtime = {{ proxmox_fail2ban_findtime }} diff --git a/ansible/roles/proxmox-hardening/templates/pve-no-subscription.sources.j2 b/ansible/roles/proxmox-hardening/templates/pve-no-subscription.sources.j2 deleted file mode 100644 index 3dc247c..0000000 --- a/ansible/roles/proxmox-hardening/templates/pve-no-subscription.sources.j2 +++ /dev/null @@ -1,6 +0,0 @@ -# Managed by ansible proxmox-hardening role — do not edit by hand -Types: deb -URIs: http://download.proxmox.com/debian/pve -Suites: {{ ansible_distribution_release }} -Components: pve-no-subscription -Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg diff --git a/ansible/roles/proxmox-hardening/templates/sshd-hardening.conf.j2 b/ansible/roles/proxmox-hardening/templates/sshd-hardening.conf.j2 deleted file mode 100644 index 9971940..0000000 --- a/ansible/roles/proxmox-hardening/templates/sshd-hardening.conf.j2 +++ /dev/null @@ -1,3 +0,0 @@ -# Managed by ansible proxmox-hardening role — do not edit by hand -PermitRootLogin prohibit-password -PasswordAuthentication no diff --git a/ansible/roles/proxmox-hardening/templates/unattended-upgrades.conf.j2 b/ansible/roles/proxmox-hardening/templates/unattended-upgrades.conf.j2 deleted file mode 100644 index f599da7..0000000 --- a/ansible/roles/proxmox-hardening/templates/unattended-upgrades.conf.j2 +++ /dev/null @@ -1,11 +0,0 @@ -// Managed by ansible proxmox-hardening role — do not edit by hand -Unattended-Upgrade::Origins-Pattern { - "origin=Debian,codename={{ ansible_distribution_release }},label=Debian-Security"; - "origin=Debian,codename={{ ansible_distribution_release }}-security,label=Debian-Security"; - "origin=Proxmox"; -}; - -// Never auto-reboot a hypervisor. Check /var/run/reboot-required manually. -Unattended-Upgrade::Automatic-Reboot "false"; -Unattended-Upgrade::Remove-Unused-Dependencies "false"; -Unattended-Upgrade::Remove-Unused-Kernel-Packages "false"; diff --git a/ansible/roles/raspberrypi/defaults/main.yml b/ansible/roles/raspberrypi/defaults/main.yml deleted file mode 100644 index 2be377f..0000000 --- a/ansible/roles/raspberrypi/defaults/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -# raspberrypi role defaults. - -raspberrypi_setup_ipa_sudo: true -raspberrypi_pin_docker_gid: true -docker_access_gid: 50010 diff --git a/ansible/roles/raspberrypi/tasks/main.yml b/ansible/roles/raspberrypi/tasks/main.yml deleted file mode 100644 index 121b3dd..0000000 --- a/ansible/roles/raspberrypi/tasks/main.yml +++ /dev/null @@ -1,74 +0,0 @@ ---- -# raspberrypi/tasks/main.yml -# Post-IPA-enrollment setup for the Raspberry Pi edge node. -# Mirrors debian-configuration/raspberrypi/scripts/. - -# ── IPA sudo ────────────────────────────────────────────────────────────────── - -- name: Check IPA enrollment - ansible.builtin.stat: - path: /etc/ipa/default.conf - register: ipa_conf - tags: ipa - -- name: Write IPA admins sudoers file - ansible.builtin.copy: - content: "%admins ALL=(ALL) NOPASSWD:ALL\n" - dest: /etc/sudoers.d/ipa-admins - mode: "0440" - validate: "visudo -cf %s" - when: - - raspberrypi_setup_ipa_sudo - - ipa_conf.stat.exists - tags: ipa - -# ── Docker GID pinning ──────────────────────────────────────────────────────── - -- name: Check docker group GID - ansible.builtin.command: - cmd: "getent group docker" - register: docker_group - changed_when: false - failed_when: false - when: raspberrypi_pin_docker_gid - tags: docker - -- name: Pin docker group GID to IPA docker-access GID - block: - - name: Stop docker service and socket - ansible.builtin.systemd: - name: "{{ item }}" - state: stopped - loop: - - docker.service - - docker.socket - - - name: Remove stale docker socket - ansible.builtin.file: - path: /var/run/docker.sock - state: absent - - - name: Reassign docker group GID (non-unique — SSSD already owns this GID) - ansible.builtin.command: - cmd: "groupmod --non-unique -g {{ docker_access_gid }} docker" - changed_when: true - - - name: Start docker socket and service - ansible.builtin.systemd: - name: "{{ item }}" - state: started - loop: - - docker.socket - - docker.service - - - name: Verify socket GID - ansible.builtin.command: - cmd: "stat -c '%g' /var/run/docker.sock" - register: socket_gid - changed_when: false - failed_when: socket_gid.stdout != docker_access_gid | string - when: - - raspberrypi_pin_docker_gid - - docker_group.rc == 0 - - docker_group.stdout.split(':')[2] != docker_access_gid | string - tags: docker diff --git a/certs/ipa-ca.crt b/certs/ipa-ca.crt new file mode 100644 index 0000000..614814a --- /dev/null +++ b/certs/ipa-ca.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESDCCArCgAwIBAgIBATANBgkqhkiG9w0BAQsFADA1MRMwEQYDVQQKDApTV0VF +VC5IT01FMR4wHAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjYwNzI2 +MjExMzQxWhcNNDYwNzI2MjExMzQxWjA1MRMwEQYDVQQKDApTV0VFVC5IT01FMR4w +HAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggGiMA0GCSqGSIb3DQEBAQUA +A4IBjwAwggGKAoIBgQCzljYktbHdMGVJ6Wq0XQJuHLN6dkCSOgtoIzQtriPQkkNI +uo28LwobaiQQ8sX4kGRH/BTKnH8QlId/jug4Uc+sDHnABYu++AiOhPbBX8gCpRQ0 +hebBjZiktHSBUEJR31siWOVdBoKBDJEoxehx7XUXvcxIJcaRN+LHYjO86nJN55HB +VwFU2JcYDk98c+144dFJxXdr++MjWe4Z/oVVU8JHIOtNtKhVhvij6oOSWxcYoJO/ +S80LRj1vx/o6o/3G6bYug7PjY7JjZk/Oj61whijZkcsoO1MXSYI6UywJZGflv+ZB +7HyufdYAsK3WhE8O2FX3/kq64Ol83HNtoR8Dt68rTg1xpW6K45jS6iDPKueYGkb0 +oSx7e++90VAW2PDhj6QQ3JJ4O5VQwrrecekJzUrAean0FOEbmgyi4PsEp1Vk6LDQ +SsIn1x0euyxVivQMlzNX2XrZL3urn1BNPqAdntXQMkR0Wl8sbUiJPe0kxG52CGXs +6yfNEXbPmVGcC0TBdGECAwEAAaNjMGEwHQYDVR0OBBYEFLh5QbI1UWMH0WR4z8bG +lhrOX3X5MB8GA1UdIwQYMBaAFLh5QbI1UWMH0WR4z8bGlhrOX3X5MA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgHGMA0GCSqGSIb3DQEBCwUAA4IBgQCVodVN +owwo53OQe02QhtEbIur2PL7zIfvhvCTRD4J8gwpbMIqT7JQK0tV6Mvsg2L8yTb2O +KjrWeLKHGWaZZlhGSPTbkMFdb/Ls8M9FSnkc2bwcdWW3Z1lOiCjBYYqwLCG6JhvB +5SXVwWNJwXeasL2m7oFTSwhsqPpARJ2t25u2N35o+tqIoCjijKwkmEOT66N9EAbu +2VQjtYZWPkBtP4YCe0Ey6u4oy7sy8ThNAjOylZok+J4JW7QEFjK4Q/emhA4aQq5H +gg9qgMuG+5oi6D1g2Wy+fMTRBaukJtLYZbBpQMQhMYWg44uPp/2bbNPTID/nV1KB +GcPyHaskcVxPdYWxAPMwk3AeJXWyOq7atAPTF5sbk0kQQf2m+vyOqcli5CxRMUgV +rcyi9l6+dZW4U+38Q0ET5M3OuxNI4hA7kVY2cfTakXWNqh97+TIHnstblDhAxECK +6ZLMJQYUy7LqJTX84H27CBWLexEMjXwdr5HCV88Fj6mAK0fRufnIw5FeneA= +-----END CERTIFICATE----- diff --git a/docs/auto-installer.md b/docs/auto-installer.md new file mode 100644 index 0000000..a4ff1b8 --- /dev/null +++ b/docs/auto-installer.md @@ -0,0 +1,313 @@ +# Auto-installer + +This flake builds a self-contained NixOS installer environment that can +install any host exposed by its own `nixosConfigurations`. It was migrated +from a formerly-separate `nix-auto-installer` repo — everything it did now +lives here. + +The installer provides a small NixOS install environment (ISO, or the same +image netbooted via PXE) with SSH access, Git support, and an interactive +installation script. +Logging in as any user (root or `nixos`) runs +`/etc/nixos-installer/installer/auto-install.sh` (the same file as +`scripts/installer/auto-install.sh` in this repo — see "Installer process" +below for why it's baked in at that path rather than a flat +`/etc/auto-install.sh`), discovers available hosts from this same flake, +lets the operator choose a target, applies that host's Disko storage +configuration, installs NixOS, and reboots. + +**This applies to every `nixosConfigurations` target except `lxc-*` hosts — +see "LXC hosts" immediately below for why those are different.** + +## LXC hosts + +`lxc-*` targets (`lxc-minimal`, `lxc-nix-cache`, `lxc-server`, `lxc-docker`, +`lxc-gui`, `lxc-pxe-boot`, `lxc-tailscale-router`, `lxc-tor-relay`) are **not** installed via `auto-install.sh` — the +interactive menu deliberately excludes them. Don't try to select one there; +`nixos-install` would bind-mount `/` onto `/mnt` (LXC containers have no raw +disk to partition) and then refuse to touch the filesystem it's currently +running on — it's designed to protect exactly this case, so it just fails. + +`modules/platforms/lxc.nix` imports nixpkgs' own +`virtualisation/proxmox-lxc.nix` module, which gives every `lxc-*` host a +`config.system.build.tarball` output — a complete, directly Proxmox-importable +container image, no install step at all: + +```sh +nix build .#nixosConfigurations.lxc-minimal.config.system.build.tarball +``` + +This is a plain rootfs tarball, not a `vzdump` backup archive — restoring it +with `pct restore` fails ("archive contains no configuration file"), since +that command expects backup-archive metadata this tarball doesn't have. Use +it as a CT *template* instead: drop it under Proxmox's template storage +(conventionally `/var/lib/vz/template/cache/` for the `local` storage, or +the GUI's "Create CT" → upload-as-template flow) and create a container +from it, supplying all config on the command line since a template has none +of its own: + +```sh +pct create local:vztmpl/.tar.xz \ + --unprivileged 1 --features nesting=1,keyctl=1 \ + --rootfs local-lvm:8 --hostname --cores 2 --memory 2048 --swap 2048 \ + --net0 name=eth0,bridge=vmbr0,ip=dhcp +pct start +``` + +Every one of those extra flags is load-bearing, confirmed by actually +booting one: + +- `--unprivileged 1` — `modules/platforms/lxc.nix` sets + `proxmoxLXC.privileged = false`, so the image assumes it's running + unprivileged. `pct create`'s own CLI default for this flag is + privileged (unlike the web UI, whose checkbox defaults the other way) + — omit it and you get a privileged container running a NixOS config + that assumes unprivileged, a real mismatch. +- `--features nesting=1,keyctl=1` — required for a modern (v247+) + systemd guest to boot unprivileged at all. Without it, AppArmor denies + the nested user namespaces and credential mounts systemd routinely + uses (even plain getty units) — every getty crash-loops on a denied + `/run/credentials/*` mount every ~3s (this is what garbage on the + console turns out to be) while core services like `nsncd` fail the + same way, and the system never finishes activating. +- `--swap 2048` — `--memory` doesn't touch swap; it silently stays at + Proxmox's own 512M default otherwise. Match it to `--memory` unless + you deliberately want otherwise. + +First boot runs `boot.postBootCommands` (registers the Nix store DB and +system profile) — there's no separate activation step to run yourself. +`scripts/proxmox/create-proxmox-resource.sh --type lxc --host ` automates all +of this (host-key handling, building the tarball directly on the Proxmox +node itself, `pct create` with the flags above) — see its `--help`. + +Host keys still need pre-seeding the same way as any other host — the +sops-nix activation-vs-first-boot race is identical regardless of how the +image reaches the machine. Unlike the ISO/PXE installer (where +`modules/installer/host-keys.nix` bakes *every* `host-keys/` entry into +`/etc/host-keys/` for `auto-install.sh` to pick from and copy at install +time — see "Host keys" below), an `lxc-*` tarball has no install step to +copy anything during, so `modules/platforms/lxc.nix` bakes this *one* +target's key straight into `/etc/ssh/ssh_host_ed25519_key(.pub)` directly, +keyed by its own exact flake target name (`config.environment.etc` can't +be read back from within a module still contributing to it, so this comes +in via `specialArgs.flakeTarget`, set by `flake.nix`'s `mkTarget`): + +```sh +NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" \ + nix build .#nixosConfigurations.lxc-nix-cache.config.system.build.tarball --impure +``` + +Confirmed the hard way: without this, the tarball's own built-in system +just generates a fresh host key at first boot like any host would, which +can never match whatever `.sops.yaml` actually trusts for that target — +`sops-install-secrets` fails with `Error getting data key: 0 successful +groups required, got 0`, and *every* secret (including this host's own +login) permanently fails to decrypt, silently — no error in the boot log +at all, since the activation step that would install secrets only runs on +a from-scratch first activation and skips silently once `/run/current-system` +already exists. `scripts/proxmox/create-proxmox-resource.sh` always builds with +`NIXOS_HOST_KEYS_DIR` set for this reason. + +## Layout + +- `modules/installer/common.nix` — shared by every installer target: SSH + access, users, the generated `/etc/auto-install.sh` script, and the + `programs.bash.loginShellInit` hook that runs it on login. +- `modules/installer/iso.nix` — ISO/netboot-specific: imports the stock + `installation-cd-minimal.nix` module plus `common.nix`. Also used, paired + with `netboot-minimal.nix`, to build the PXE netboot variant (see + `docs/pxe-boot.md`). +- `modules/installer/host-keys.nix` — optionally bakes pre-generated SSH + host keys into the image; see "Host keys" below. +- `scripts/secrets/sync-host-keys.sh` — admin-workstation tool that generates, + registers, and (via `--remove`/`--regenerate-all-keys`) retires host + keys; see "Creating a New Machine" below. +- `scripts/secrets/prepare-host-key.sh` — narrower predecessor: generates a single + key by an arbitrary name without touching `.sops.yaml`. Still useful for + pre-generating a key *before* its flake target exists (`sync-host-keys.sh` + can only act on targets `nixosConfigurations` already has); otherwise + `sync-host-keys.sh` does the same thing and more. + +Flake outputs: + +```nix +nixosConfigurations.installer # ISO/netboot installer image + +packages.x86_64-linux.iso # installer ISO/netboot image +packages.x86_64-linux.pxe # auto-installer netboot bundle (kernel + initrd + ipxe script) +packages.x86_64-linux.pxe-minimal # vanilla NixOS minimal netboot bundle (no installer wiring) +``` + +```sh +nix build .#iso +nix build .#pxe +nix build .#pxe-minimal +``` + +There's no `nixosConfigurations.proxmox-lxc` (installer-boots-as-an-LXC- +container) or `packages.x86_64-linux.lxc`/`.all` anymore. Both existed only +to let the installer itself run as an LXC container so you could +`nixos-install` some *other* host from within it — but LXC targets are +excluded from the install menu (same bind-mount problem as any LXC +`nixos-install`), and now have their own direct tarball path anyway (see +"LXC hosts" above), which left the installer's own LXC form with no real +use case. + +The `pxe` variant is also built automatically as part of the `pxe-boot` host +itself (`modules/pxe-boot/stage-installer-artifacts.nix`) and served over +iPXE as the menu's "NixOS Auto-Installer" entry — see `docs/pxe-boot.md`. +That same host also builds and serves `packages.x86_64-linux.pxe-minimal`, +a vanilla NixOS minimal netboot image with none of this auto-installer's +wiring, as a separate "NixOS Minimal" menu entry — also documented in +`docs/pxe-boot.md`, not covered further here since it's not this installer. + +## Host keys + +`sops-nix` derives each host's decryption key from its own +`/etc/ssh/ssh_host_ed25519_key`, generated at **activation** time — before +systemd would otherwise generate one on first boot. Without pre-seeding this +key, secrets (including the root/nixos login password) fail to decrypt on a +genuinely fresh install. + +Generated host keys live in `host-keys/` at the repo root (`ssh_host_ed25519_key` ++ `.pub` pairs per hostname). This directory is **gitignored on purpose** — +private key material must never be committed — which also means flakes can't +see it through a normal relative path. `modules/installer/host-keys.nix` +reads it through `builtins.getEnv`, which Nix silently returns as an empty +string under normal (non-`--impure`) evaluation, so the module is a no-op — +safe by default, including in CI — unless explicitly opted into: + +```sh +NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build .#iso --impure +``` + +When built this way, every key currently in `host-keys/` is baked into the +image at `/etc/host-keys/_ssh_host_ed25519_key(.pub)`, and +`auto-install.sh` automatically installs whichever one matches the flake +target selected at install time — no manual per-host scp step needed. + +**Trade-off, accepted deliberately for this LAN-only setup:** baking keys in +means every key present in `host-keys/` at build time becomes readable by +anyone who can reach the built image — including, for the PXE variant, anyone +who can reach the `pxe-boot` host's unauthenticated HTTP server. This is +considered acceptable here because `pxe-boot` sits behind LAN-only network +infrastructure, not the open internet. If that ever changes, reconsider this +default. + +`auto-install.sh` still supports the older manual path as a fallback: if a +host's key isn't baked in (`/etc/host-keys`), it checks `/root/host-keys` +next, where you can `scp` a key in after boot, same as before this migration. +If neither has it and the script is running interactively (an actual +operator at the other end of stdin, not an unattended run), it prompts for +an arbitrary directory to check (a mounted USB stick, another filesystem, +etc.) and copies the key pair into `/root/host-keys` from there if found. + +## Storage + +Disk partitioning is handled by Disko — the installer has no hardcoded +`parted`/`mkfs`/`mkswap`/`mount` commands, and `auto-install.sh` runs +`disko --mode destroy,format,mount` unconditionally, no branching on whether +the target has a Disko config. Every host reachable through this menu has +one: + +- `proxmox-*` (`modules/disko/proxmox.nix`): a real GPT partition table + (ESP + swap + root) on `/dev/sda`. +- `linode-*` (`modules/disko/linode.nix`): Linode provisions and sizes + `/dev/sda`/`/dev/sdb` itself as whole, unpartitioned block devices before + the OS ever boots, so this declares them with `destroy = false` (skips + disko's wipe stage for these disks entirely — see the option's own docs) + and a bare `filesystem`/`swap` content type with no partition table, and + the format step it does run only calls `mkfs`/`mkswap` if `blkid` shows + the device isn't already formatted — a re-run against an + already-provisioned Linode disk is a no-op, not a wipe. + +`lxc-*` is the only category without one — it's excluded from this menu +entirely (see "LXC hosts" above), so it never reaches this code path. + +## Installer process + +`scripts/installer/auto-install.sh` is a real, version-controlled shell +script — not an inline Nix string. It sources `scripts/env.sh` for +`LAN_DOMAIN` itself (same as every other script in `scripts/`), so it +behaves identically whether it's run straight from a git checkout (e.g. +manually, from a stock NixOS ISO that isn't this repo's own installer +image) or from inside the built installer image. That's also why it's +baked in at `/etc/nixos-installer/installer/auto-install.sh` rather than a +flat `/etc/auto-install.sh` — `modules/installer/common.nix` bakes +`scripts/env.sh` in alongside it at `/etc/nixos-installer/env.sh`, +preserving the same relative layout (`installer/auto-install.sh` -> +`../env.sh`) the checked-out repo has, so the script's own +`source ".../env.sh"` line resolves correctly in both places without any +Nix-level templating. + +Once running, it: + +1. Queries `nixosConfigurations` from this flake over the network (`git+https:///beatzaplenty/nixos.git`) — this happens at *install* time, not build time, so a generic installer image always sees whatever hosts are currently committed, without needing a rebuild. +2. Presents them as a menu; confirms the choice. +3. Skips the `nix-cache` substituter when installing a `nix-cache` host itself (consistent with that host's own runtime config). +4. Runs `disko --mode destroy,format,mount` (see "Storage" above — every host reachable through this menu has a Disko config, so this is unconditional). +5. Installs the target's SSH host key from `/etc/host-keys` or `/root/host-keys` (see "Host keys" above). +6. Runs `nixos-install --flake # --no-root-password`. +7. Cleans up and reboots. + +## Creating a new machine + +Do this instead of jumping straight to a plain install whenever the target +host consumes any sops-nix secret — as of this writing, that's every host +(`modules/common/configuration.nix` puts the root/nixos password hash and the +GitHub token behind sops-nix for all of them). + +1. **Add the flake target** — `hosts//host.nix` plus the matching + `mkTarget { ... }` entry in `flake.nix`'s `generatedTargets` (see + "Composition pattern" in `CLAUDE.md`). No secrets involved yet, so this + is safe to commit on its own if you want a clean history. + +2. **On your admin workstation, generate and register its host key:** + + ```sh + ./scripts/secrets/sync-host-keys.sh + ``` + + This generates `host-keys/_ssh_host_ed25519_key(.pub)`, + adds it as a new `.sops.yaml` anchor, works out which `secrets/*.yaml` + files this specific host actually references (from its own + `config.sops.secrets`, not guessed), adds it to each one's + `key_groups`, and re-encrypts them with `sops updatekeys` — no manual + YAML editing. Safe to re-run; it only fills in what's missing. + + Doing this for every host that needs one at once — after adding several + new targets, or just to catch up any that were missed — is + `./scripts/secrets/sync-host-keys.sh --all`. See `scripts/secrets/sync-host-keys.sh --help` + for its other modes (`--remove`, `--regenerate-all-keys`). + +3. **Commit and push.** The flake build the installer uses has to see the + new recipient before you install, or decryption fails on first boot + regardless of the next step. + +4. **Build the installer image with keys baked in** (or reuse an already-serving `pxe-boot` host, which does this automatically once redeployed): + + ```sh + NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build .#iso --impure + ``` + +5. **Boot it on the target machine**, log in, select the new host's flake + target from the menu, confirm. `auto-install.sh` finds the baked-in key, + runs Disko + `nixos-install`, and reboots. + +6. **Verify after reboot:** + + ```sh + ssh ls /run/secrets/ + ``` + + If that's empty or login fails, the host's age key most likely wasn't in + `.sops.yaml` (or wasn't re-encrypted into the secrets file it needs) when + `nixos-install` ran — fix `.sops.yaml`/`secrets/*.yaml`, push, then re-run + `nixos-install --flake .# --no-root-password` from a rescue + environment against the existing `/mnt`, or just redo the install. + +## Safety + +This installer is destructive: `disko --mode destroy,format,mount` erases +any disk defined by the selected host's Disko configuration. Always verify +the selected host profile and target machine before confirming. diff --git a/docs/beszel.md b/docs/beszel.md new file mode 100644 index 0000000..fc9f6d0 --- /dev/null +++ b/docs/beszel.md @@ -0,0 +1,104 @@ +# Beszel agent + +[Beszel](https://github.com/henrygd/beszel) is the monitoring dashboard used +in this LAN. The hub runs as a Docker container on `docker.sweet.home` (port +`vars.ports.beszelHub`, 8090). Each monitored NixOS host runs a +`beszel-agent` that connects back to the hub. + +--- + +## How it works + +Everything is handled by a single module: + +**`modules/beszel/enable-agent.nix`** — imported by a build type. It: +- Enables `beszel-agent` +- Sets `HUB_URL` to `docker.sweet.home:8090` +- Sets `KEY` from `vars.beszelHubKey` (`variables.nix`) — the hub's SSH + public key, shared by every agent. Update `beszelHubKey` if the docker + host is ever rebuilt and the hub generates a new keypair. +- Reads the universal `beszel-token` from `secrets/common.yaml` via sops + and passes it to the agent as `TOKEN` in an env file +- Fixes an upstream bug where the agent couldn't persist its hub-pairing + fingerprint across restarts (adds a real `StateDirectory`) + +A host file needs no beszel configuration at all — just import the module +in the build type and add the system in the hub UI. + +--- + +## Adding beszel to a new build type + +Add `../beszel/enable-agent.nix` to the `imports` list in +`modules/build-types/.nix`: + +```nix +imports = [ + ../beszel/enable-agent.nix + # ... other imports +]; +``` + +That's the only change required. The host file needs nothing. + +--- + +## Adding a new system to the hub + +1. Rebuild and deploy the host with its build type importing `enable-agent.nix`. +2. Open the beszel hub (`http://docker.sweet.home:8090`). +3. Go to **Systems → Add system**, enter the host's IP and the default port + (45876). The agent will connect and the system will appear as active. + +--- + +## One-time setup: add the token to `secrets/common.yaml` + +The universal token is stored once in the common secrets file, shared by all +agents. Only needed once, not per-host: + +```sh +sops secrets/common.yaml +``` + +Add: +```yaml +beszel-token: +``` + +`secrets/common.yaml` is already a sops recipient for every host via their +SSH host keys, so no additional sops recipient setup is needed. + +--- + +## Optional: monitoring extra filesystems + +To report disk usage for a mount beyond the root filesystem, add +`EXTRA_FILESYSTEMS` in the host file: + +```nix +services.beszel.agent.environment = { + EXTRA_FILESYSTEMS = "/mnt/data"; # colon-separated for multiple paths +}; +``` + +--- + +## Optional: monitoring Docker containers + +`enable-agent.nix` has a commented-out line for Docker monitoring: + +```nix +#DOCKER_HOST = "tcp://docker-socket-proxy:2375"; +``` + +Uncomment it if the host runs docker-socket-proxy and you want per-container +stats. Hosts without Docker should leave it commented out. + +--- + +## If the hub key changes + +If the docker host is ever rebuilt and beszel generates a new SSH keypair, +update `beszelHubKey` in `variables.nix` and rebuild all beszel-enabled hosts. +The new key is visible in the beszel hub under **Settings → Keys**. diff --git a/docs/external/index.md b/docs/external/index.md deleted file mode 100644 index 683e741..0000000 --- a/docs/external/index.md +++ /dev/null @@ -1,31 +0,0 @@ -# Homelab - -A self-hosted infrastructure running on modest home hardware. - -## Self-hosted services - -| Service | What it does | -|---------|-------------| -| Nextcloud | Private cloud storage and sync | -| Passbolt | Team password manager | -| Gitea | Self-hosted Git with CI/CD | -| SearXNG | Privacy-respecting meta-search engine | -| Gramps Web | Family tree / genealogy | -| Uptime Kuma | Service availability monitoring | - -## Infrastructure - -Built on: -- **Proxmox VE** hypervisor running virtual machines and containers -- **NixOS** for declarative, reproducible server configuration -- **Docker** for containerised applications -- **FreeIPA** for centralised identity and authentication -- **Terraform** and **Ansible** for infrastructure as code -- **Traefik** for reverse proxy with automatic TLS - -## Tech stack highlights - -- All infrastructure is declared in code — no manual configuration -- Automated drift detection runs daily to catch any state inconsistencies -- Secrets are encrypted at rest using SOPS + age -- Automated documentation generated from live inventory diff --git a/docs/flake-lock-automation.md b/docs/flake-lock-automation.md new file mode 100644 index 0000000..d003ad0 --- /dev/null +++ b/docs/flake-lock-automation.md @@ -0,0 +1,83 @@ +# flake.lock automation + +This repository uses CI workflows to keep `flake.lock` up to date on a schedule +and to verify that declared NixOS hosts still evaluate after dependency updates. + +## What this automation does + +- A scheduled workflow runs `nix flake update` once per week. +- On GitHub, any resulting `flake.lock` change is proposed through a pull request. +- On Gitea, the workflow can commit and push `flake.lock` directly when PR automation is not configured. +- A separate CI workflow runs `scripts/codex-maintenance.sh` before merge. + Its default mode scopes eval to the hosts/packages a change can affect, + determined from a git diff against the PR base — but a `flake.lock` change + is treated as repo-wide and always falls back to evaluating every host, so + a lock-file update PR still gets full coverage. Hosts are still listed + dynamically via + `nix eval --json .#nixosConfigurations --apply builtins.attrNames` rather + than hand-enumerated, so that fallback can't drift as `-` + targets are added or removed. See `README.md` for the current target list. + +## Why hosts should stop using `--upgrade-all` + +`flake.lock` is the source of truth for pinned dependency versions in a flake-based workflow. Normal host rebuilds should consume the committed lock file instead of upgrading dependencies ad-hoc on each machine. + +Recommended rebuild command: + +```bash +sudo nixos-rebuild switch --flake git+https://gitea.lan.ddnsgeek.com/beatzaplenty/nixos.git#$(cat /etc/flake-target) +``` + +Flake attribute names are `-` (e.g. `proxmox-docker`) +and no longer match `hostname`, since a host's hostname stays fixed while +the platform backing it can change. Each `nixosConfiguration` stamps its own +active target name into `/etc/flake-target` at build time, which is what the +command above reads. + +Using the committed lock file keeps all hosts aligned and makes updates auditable through CI and code review. + +Codex and automated review sessions must not run rebuilds. Limit checks to +evaluation, linting, formatting, and dry-run builds. + +## Command differences + +- `nix flake update` + - Updates flake input pins in `flake.lock`. + - Should be run in CI or in a dedicated update PR workflow. +- `nixos-rebuild --upgrade` + - Primarily for channel-based workflows; not the normal path for flake-pinned deployments. +- `nixos-rebuild --upgrade-all` + - Aggressively updates package sources and bypasses coordinated lock-file updates. + - Avoid for routine flake-based host rebuilds. + +## nix-cache and remote builder fit + +With `nix-cache` acting as a binary cache and remote builder, lock-file updates become safer and more reproducible: + +- CI verifies host evaluations against the updated lock file. +- Builds can be performed once on the remote builder. +- Built artifacts can be served via `nix-cache` to other hosts, reducing rebuild time and drift. + +## Token and secret handling + +Do **not** commit access tokens into `flake.nix`, `flake.lock`, or any other tracked file. + +If private source access is needed: + +- configure tokens locally in `~/.config/nix/nix.conf` or equivalent machine-local config, or +- provide tokens through CI secrets/environment variables. + +## GitHub Actions setup notes + +- Ensure `GITHUB_TOKEN` has permission to create branches and pull requests (workflow sets `contents: write` and `pull-requests: write`). +- The update workflow uses `peter-evans/create-pull-request` with branch `chore/update-flake-lock`. +- The evaluation workflow runs on pull requests, pushes to `main`, and manual dispatch. + +## Gitea Actions runner setup notes + +- Ensure the runner image includes Git and can execute the Nix installer action. +- For direct push mode, grant workflow push permission to the repository. +- The workflow sets commit identity to: + - `user.name = gitea-actions` + - `user.email = gitea-actions@nix-cache.local` +- Commits are only created when `flake.lock` actually changes. diff --git a/docs/generated/.gitkeep b/docs/generated/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/ha.md b/docs/ha.md new file mode 100644 index 0000000..92d4cfa --- /dev/null +++ b/docs/ha.md @@ -0,0 +1,160 @@ +# HA File-Server Cluster + +Two `proxmox-ha-server-{1,2}` VMs form an active/passive file-server cluster: +DRBD replicates a block device between nodes; Corosync + Pacemaker manage +failover; XFS, LIO iSCSI, and NFS are brought up as a collocated resource +group on whichever node holds the DRBD Primary role. + +NixOS modules: `modules/ha/`. Lifecycle scripts: `scripts/ha/`. +Cluster-wide constants: `variables.nix` (`haServer*` vars). + +--- + +## Network layout + +Three subnets — all internal to pve1 (`vmbr0`/`vmbr1`/`vmbr2`): + +| Subnet | VLAN | CIDR | Bridge | Purpose | +|---|---|---|---|---| +| LAN | 2 | `192.168.2.0/24` | `vmbr0` | Management, LAN NFS | +| Cluster | 10 | `192.168.10.224/29` | `vmbr1` | Corosync ring0 + DRBD replication | +| Storage-client | 20 | `192.168.20.0/24` | `vmbr2` | NFS + iSCSI for docker/swarm | + +Each HA VM has three NICs: `ens18` (LAN/vmbr0), `ens19` (cluster/vmbr1), +`ens20` (storage-client/vmbr2). See `docs/ip-addressing.md` for all IPs. + +Corosync ring0 uses the cluster NIC; ring1 (backup heartbeat) uses the LAN +NIC. DRBD replicates over the cluster NIC. No storage traffic crosses the LAN. + +--- + +## Pacemaker resources + +All resources run collocated on whichever node is Primary, in this order: + +``` +ms-drbd0 (promotable DRBD clone) + → xfs-data (XFS mount on /dev/drbd0 → /srv/ha-data) + → iscsi-target (targetctl) + → nfs-server (nfs-server.service) + → vip-lan (192.168.2.229/24 on vmbr0 — NFS for LAN clients) + → vip-storage (192.168.20.229/24 on vmbr2 — NFS + iSCSI for VLAN 20) +``` + +`vip-lan` serves pxe-boot and other LAN-only NFS clients. +`vip-storage` serves docker and any future swarm nodes; iSCSI is available on +VLAN 20 but NFS is preferred for multi-host volume sharing. + +--- + +## DRBD fencing + +`fencing resource-only` with `crm-fence-peer.sh`/`crm-unfence-peer.sh` +wrappers (`modules/ha/cluster-config.nix`). The DRBD kernel module invokes +these via the User Mode Helper with a minimal PATH; the wrappers prepend +`/run/current-system/sw/bin` before exec-ing the real handlers so Pacemaker +tools (`cibadmin`, `crm_mon`, etc.) are found. + +STONITH is initially disabled (`stonith-enabled: false`, +`no-quorum-policy: ignore`). Enable it once the `fence_pve_ssh` fence agent +(`scripts/ha/fence-pve-ssh.py`) is deployed and authorised: + +```bash +scripts/ha/cluster-enable-stonith.sh # run as root on ha-server-1 +``` + +--- + +## Deploying the cluster from scratch + +Use `scripts/ha/deploy.sh` — it orchestrates all phases: + +```bash +# Against pve-test (safe — Claude's default target): +scripts/ha/deploy.sh --node "$PVE_TEST_HOST" [--dry-run] + +# Against pve1 (production — requires explicit operator go-ahead): +scripts/ha/deploy.sh --node "$PVE1_HOST" +``` + +Phases (each skippable with `--skip-`): +1. `ensure-bridge` — creates `vmbr1`/`vmbr2` on the Proxmox node if absent +2. `sync-keys` — generates SSH host keys for both nodes; registers sops recipients +3. `create-vms` — builds disk images, creates VMs via `create-proxmox-resource.sh` +4. `add-hardware` — attaches storage NIC and DRBD data disk to each VM +5. `init-cluster` — runs `scripts/ha/cluster-init.sh` on ha-server-1 + +`--destroy` runs the teardown sequence. + +--- + +## Day-to-day operations + +```bash +# Read-only health check (safe from workstation): +scripts/ha/health.sh + +# Graceful failover (prompts for confirmation): +scripts/ha/failover.sh [--to node1|node2] + +# Online data-disk growth (no downtime): +scripts/ha/resize-data-disk.sh --size +20G + +# Acceptance tests (run after any significant change): +scripts/ha/acceptance-tests.sh +``` + +--- + +## Adding FreeIPA host accounts + +IPA host registration is automated: + +```bash +scripts/ipa/create-nixos-ipa-host-account.sh +``` + +This runs `ipa host-add`, fetches a keytab from the domain controller, and +writes a sops-encrypted `secrets/.keytab` in one step. The module +`modules/ipa/client.nix` (imported by every host via +`modules/common/configuration.nix`) consumes the keytab via sops-nix. + +--- + +## Storage layout + +``` +/srv/ha-data/ + docker/ + config/ NFS → docker:/mnt/docker/config + databases/ NFS → docker:/mnt/docker/databases + volumes/ NFS → docker:/mnt/docker/volumes + nextcloud-data/ NFS → docker:/mnt/docker/nextcloud-data + proxmox/ + iso/ NFS → pve1 ISO storage + lxc/ NFS → pve1 CT template storage + pxe-boot/ + images/ NFS → pxe-boot:/srv/pxe/http/images (PXE assets) + raspi/ + volumes/ NFS → raspi NFS mounts + iscsi-lun.img iSCSI fileio backstore (VLAN 20 only, not in active use) +``` + +All shares are defined in `variables.nix` (`vars.nfsShares.*`). The NFS +export list lives in `modules/ha/nfs-exports.nix`. + +--- + +## Key variables + +| Variable | Description | +|---|---| +| `vars.haServer1Ip` / `vars.haServer2Ip` | LAN management IPs | +| `vars.haServer1StorageIp` / `vars.haServer2StorageIp` | Cluster NIC IPs (DRBD/Corosync ring0) | +| `vars.haServerLanVip` | Pacemaker `vip-lan` — NFS for LAN (192.168.2.229) | +| `vars.haServerVip` | Pacemaker `vip-storage` — NFS + iSCSI for VLAN 20 (192.168.20.229) | +| `vars.haLanNfsFqdn` | FQDN of `vip-lan`: `ha-vip-lan.sweet.home` | +| `vars.haStorageRoot` | XFS mount point: `/srv/ha-data` | +| `vars.haServerDrbdDisk` | Block device for DRBD backing store | +| `vars.haStorageCidr` | Cluster subnet CIDR (`192.168.10.224/29`) | +| `vars.haClientCidr` | Storage-client subnet CIDR (`192.168.20.0/24`) | diff --git a/docs/internal/architecture.md b/docs/internal/architecture.md deleted file mode 100644 index f7a8fac..0000000 --- a/docs/internal/architecture.md +++ /dev/null @@ -1,92 +0,0 @@ -# Architecture - -## Infrastructure layers - -``` -┌─────────────────────────────────────────────────────────┐ -│ pve1.sweet.home (Proxmox VE — production hypervisor) │ -│ │ -│ ┌────────────┐ ┌──────────┐ ┌──────────────────────┐ │ -│ │ server- │ │ docker │ │ nix-cache pxe-boot │ │ -│ │ nixos VM │ │ LXC │ │ LXC LXC │ │ -│ │ (baremetal │ │ 192.168 │ │ .224 .223 │ │ -│ │ GUI host) │ │ .2.225 │ └──────────────────────┘ │ -│ └────────────┘ └──────────┘ │ -│ ┌────────────┐ ┌──────────┐ ┌──────────────────────┐ │ -│ │ domain- │ │ pxe-boot │ │ ha-server-1 ha- │ │ -│ │ controller │ │ LXC │ │ VM .2.228 server-2│ │ -│ │ FreeIPA VM │ │ PXE DHCP │ │ VM .2.227│ │ -│ │ .2.253 │ │ .2.223 │ └──────────────────────┘ │ -│ └────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ pve-test.sweet.home (Proxmox VE — sandbox) │ -│ WiFi-connected. Safe for scratch VMs/LXCs. │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ raspberrypi.tail13f623.ts.net (Raspberry Pi 4) │ -│ Edge monitoring via Tailscale. │ -│ Traefik · Uptime Kuma · CrowdSec · Beszel agent │ -└─────────────────────────────────────────────────────────┘ -``` - -## Configuration management matrix - -| Host | Managed by | How | -|------|-----------|-----| -| pve1, pve-test | Ansible (`proxmox-hardening` role) | `ansible/playbooks/proxmox.yml` | -| domain-controller | Ansible (`freeipa` role) | `ansible/playbooks/freeipa.yml` | -| pihole | Ansible (`pihole` role) | `ansible/playbooks/pihole.yml` | -| raspberrypi | Ansible (`raspberrypi` role) | `ansible/playbooks/raspi.yml` | -| docker LXC | NixOS flake | `nixos/` — `proxmox-docker` target | -| nix-cache LXC | NixOS flake | `nixos/` — `proxmox-nix-cache` target | -| pxe-boot LXC | NixOS flake | `nixos/` — `proxmox-pxe-boot` target | -| ha-server-1/2 | NixOS flake | `nixos/` — `proxmox-ha-server-1/2` targets | -| baremetal workstation | NixOS flake | `nixos/` — `baremetal-gui` target | - -## Authentication and DNS backbone - -FreeIPA (`SWEET.HOME` realm) provides: -- Kerberos SSO for all IPA-enrolled hosts -- LDAP user/group directory (`admins`, `docker-access` groups) -- Authoritative DNS for the entire LAN (Pi-hole decommissioned; FreeIPA is the sole resolver) -- Conditional forwarder: `*.ts.net` → `tailscale-router` (192.168.2.222) → Tailscale MagicDNS -- Certificate authority for internal TLS - -All hosts (Proxmox nodes, Raspberry Pi, Docker host) are IPA-enrolled via SSSD. -The `admins` group has passwordless sudo on all enrolled hosts. -The `docker-access` group (GID 50010) grants Docker socket access on Docker hosts. - -DHCP is handled by the router. PXE-specific DHCP options are served by the `pxe-boot` LXC. - -## Tailscale ↔ LAN bridge - -`tailscale-router.sweet.home` (192.168.2.222) is a NixOS LXC running the `tailscale-router` -build type. It provides full bidirectional connectivity between the LAN and the Tailnet: - -- **LAN → Tailscale:** Router has a static route for `100.64.0.0/10` (Tailscale CGNAT) → 192.168.2.222. - DNS for `*.ts.net` flows through FreeIPA's conditional forwarder → tailscale-router → MagicDNS. -- **Tailscale → LAN:** tailscale-router advertises `192.168.2.0/24` as a subnet route. - Tailscale clients use tailscale-router as a split-horizon DNS forwarder for `sweet.home`, - which proxies those queries to FreeIPA. - -This means any Tailscale-connected device can resolve and reach `*.sweet.home` hosts, and -any LAN host can resolve and reach `*.ts.net` hosts — without needing a Tailscale client installed. - -## Monitoring stack - -``` -Beszel agents (every host) - └─→ Beszel hub (stacks/docker — beszel.lan.ddnsgeek.com) - -Uptime Kuma (stacks/docker — monitor-kuma.lan.ddnsgeek.com) - └─→ monitors all public endpoints - -Gotify (stacks/docker) - └─→ receives: WUD alerts, Docker health checks, drift detection notifications - -WUD (stacks/docker) - └─→ watches: local docker host + Raspberry Pi (via Tailscale) -``` diff --git a/docs/internal/decommissioned/pihole.toml.historical b/docs/internal/decommissioned/pihole.toml.historical deleted file mode 100644 index 1dd17f1..0000000 --- a/docs/internal/decommissioned/pihole.toml.historical +++ /dev/null @@ -1,1767 +0,0 @@ -# Pi-hole configuration file (v6.7) -# Encoding: UTF-8 -# This file is managed by pihole-FTL -# Last updated on 2026-07-23 02:11:01 UTC - -[dns] - # Upstream DNS Servers to be used by Pi-hole. If this is not set, Pi-hole will not - # resolve any non-local queries. - # - # Example: [ "8.8.8.8", "127.0.0.1#5335", "docker-resolver" ] - # - # Allowed values are: - # Array of IP addresses and/or hostnames, optionally with a port (#...) - upstreams = [ - "45.90.28.142", - "45.90.30.142" - ] ### CHANGED, default = [] - - # Use this option to control deep CNAME inspection. Disabling it might be beneficial - # for very low-end devices - # - # Allowed values are: - # true or false - CNAMEdeepInspect = true - - # Should _esni. subdomains of blocked domains also be blocked by default? Encrypted - # Server Name Indication (ESNI) is certainly a good step into the right direction to - # enhance privacy on the web. It prevents on-path observers, including ISPs, coffee - # shop owners and firewalls, from intercepting the TLS Server Name Indication (SNI) - # extension by encrypting it. This prevents the SNI from being used to determine which - # websites users are visiting. - # - # ESNI will obviously cause issues for pixelserv-tls which will be unable to generate - # matching certificates on-the-fly when it cannot read the SNI. According to the IETF - # draft (link above), we can easily restore pixelserv-tls's operation by replying - # NXDOMAIN to _esni. subdomains of blocked domains as this mimics a "not configured - # for this domain" behavior. - # - # ESNI is mostly obsolete. It was previously rolled out by Cloudflare and Firefox, but - # they, as well as almost every client and server, are now using Encrypted Client - # Hello (ECH) instead of ESNI. ECH is served via the HTTPS record on the same RRname, - # so it will automatically be blocked. - # - # Allowed values are: - # true or false - blockESNI = true - - # Should we overwrite the query source when client information is provided through - # EDNS0 client subnet (ECS) information? This allows Pi-hole to obtain client IPs even - # if they are hidden behind the NAT of a router. This feature has been requested and - # discussed on Discourse where further information how to use it can be found: - # https://discourse.pi-hole.net/t/support-for-add-subnet-option-from-dnsmasq-ecs-edns0-client-subnet/35940 - # - # Allowed values are: - # true or false - EDNS0ECS = true - - # Should FTL hide queries made by localhost? - # - # Allowed values are: - # true or false - ignoreLocalhost = false - - # Should FTL analyze and show internally generated DNSSEC queries? - # - # Allowed values are: - # true or false - showDNSSEC = true - - # Should FTL analyze *only* A and AAAA queries? - # - # Allowed values are: - # true or false - analyzeOnlyAandAAAA = false - - # Controls whether and how FTL will reply with for address for which a local interface - # exists. Changing this setting causes FTL to restart. - # - # Allowed values are: - # - "NONE" - # Pi-hole will not respond automatically on PTR requests to local interface - # addresses. Ensure pi.hole and/or hostname records exist elsewhere. - # - "HOSTNAME" - # Serve the machine's hostname. The hostname is queried from the kernel through - # uname(2)->nodename. If the machine has multiple network interfaces, it can - # also have multiple nodenames. In this case, it is unspecified and up to the - # kernel which one will be returned. On Linux, the returned string is what has - # been set using sethostname(2) which is typically what has been set in - # /etc/hostname. - # - "HOSTNAMEFQDN" - # Serve the machine's hostname (see limitations above) as fully qualified domain - # by adding the local domain. If no local domain has been defined (config option - # dns.domain.name), FTL tries to query the domain name from the kernel using - # getdomainname(2). If this fails, FTL appends ".no_fqdn_available" to the - # hostname. - # - "PI.HOLE" - # Respond with "pi.hole". - piholePTR = "HOSTNAME" ### CHANGED, default = "PI.HOLE" - - # How should FTL handle queries when the gravity database is not available? - # - # Allowed values are: - # - "BLOCK" - # Block all queries when the database is busy. - # - "ALLOW" - # Allow all queries when the database is busy. - # - "REFUSE" - # Refuse all queries which arrive while the database is busy. - # - "DROP" - # Just drop the queries, i.e., never reply to them at all. Despite "REFUSE" - # sounding similar to "DROP", it turned out that many clients will just - # immediately retry, causing up to several thousands of queries per second. This - # does not happen in "DROP" mode. - replyWhenBusy = "ALLOW" - - # FTL's internal TTL to be handed out for blocked queries in seconds. This settings - # allows users to select a value different from the dnsmasq config option local-ttl. - # This is useful in context of locally used hostnames that are known to stay constant - # over long times (printers, etc.). - # - # Note that large values may render whitelisting ineffective due to client-side - # caching of blocked queries. - # - # Allowed values are: - # A positive integer value in seconds - blockTTL = 2 - - # Array of custom DNS records - # - # Example: [ "127.0.0.1 mylocal", "192.168.0.1 therouter" ] - # - # Allowed values are: - # Array of custom DNS records each one in HOSTS form: "IP HOSTNAME [HOSTNAME ...]" - hosts = [ - "192.168.2.253 pihole", - "192.168.2.254 router", - "192.168.2.252 server", - "192.168.2.250 win11", - "192.168.2.249 docker", - "192.168.2.108 pbs", - "192.168.2.108 pbs.sweet.home", - "192.168.2.250 pve1", - "192.168.2.250 pve1.sweet.home", - "192.168.2.248 pdm", - "192.168.2.248 pdm.sweet.home", - "192.168.2.251 pve-test", - "192.168.2.251 pve-test.sweet.home" - ] ### CHANGED, default = [] - - # If set, queries for plain names, without dots or domain parts, are never forwarded to - # upstream nameservers - # - # Allowed values are: - # true or false - domainNeeded = false - - # If set, the domain is added to simple names (without a period) in /etc/hosts in the - # same way as for DHCP-derived names - # - # Allowed values are: - # true or false - expandHosts = false - - # Should all reverse lookups for private IP ranges (i.e., 192.168.x.y, etc) which are - # not found in /etc/hosts or the DHCP leases file be answered with "no such domain" - # rather than being forwarded upstream? - # - # Allowed values are: - # true or false - bogusPriv = true - - # Validate DNS replies using DNSSEC? - # - # Allowed values are: - # true or false - dnssec = false - - # Interface to use for DNS (see also dns.listeningMode) and DHCP (if enabled). Leave - # empty for auto-detection. - # - # Allowed values are: - # a valid interface name - interface = "eth0" ### CHANGED, default = "" - - # Add an A, AAAA and PTR record to the DNS. This adds a singular name to the DNS with - # associated IPv4 (A) and IPv6 (AAAA) records - # - # Example: "laptop,laptop.lan,192.168.0.1,1234::100" - # - # Allowed values are: - # A string in the format - # "[,....],[],[][,]" - hostRecord = "" - - # Pi-hole interface listening modes - # - # Allowed values are: - # - "LOCAL" - # Allow only local requests. This setting accepts DNS queries only from hosts - # whose address is on a local subnet, i.e., a subnet for which an interface - # exists on the server. It is intended to be set as a default on installation, - # to allow unconfigured installations to be useful but also safe from being used - # for DNS amplification attacks if (accidentally) running public. - # - "SINGLE" - # Permit all origins, accept only on the specified interface. Respond only to - # queries arriving on the specified interface. The loopback (lo) interface is - # automatically added to the list of interfaces to use when this option is used. - # Make sure your Pi-hole is properly firewalled! - # - "BIND" - # By default, FTL binds the wildcard address. If this is not what you want, you - # can use this option as it forces FTL to really bind only the interfaces it is - # listening on. Note that this may result in issues when the interface may go - # down (cable unplugged, etc.). About the only time when this is useful is when - # running another nameserver on the same port on the same machine. This may also - # happen if you run a virtualization API such as libvirt. When this option is - # used, IP alias interface labels (e.g. enp2s0:0) are checked rather than - # interface names. - # - "ALL" - # Permit all origins, accept on all interfaces. Make sure your Pi-hole is - # properly firewalled! This truly allows any traffic to be replied to and is a - # dangerous thing to do as your Pi-hole could become an open resolver. You - # should always ask yourself if the first option doesn't work for you as well. - # - "NONE" - # Do not add any configuration concerning the listening mode to the dnsmasq - # configuration file. This is useful if you want to manually configure the - # listening mode in auxiliary configuration files. This option is really meant - # for advanced users only, support for this option may be limited. - listeningMode = "LOCAL" - - # Log DNS queries and replies to pihole.log - # - # Allowed values are: - # true or false - queryLogging = true - - # List of CNAME records which indicate that is really . If the is - # given, it overwrites the value of local-ttl - # - # Allowed values are: - # Array of CNAMEs, each one in the following form: ",[,]" - cnameRecords = [] - - # Port used by the DNS server - # - # Allowed values are: - # Any available valid (1 - 65535) port number - port = 53 - - # Enable/Disable the localise-queries option of dnsmasq. When this setting is disabled - # dnsmasq will return all possible values for local DNS Records. Enabled by default - # - # Allowed values are: - # true or false - localise = true - - # Reverse server (formerly called "conditional forwarding") - # - # If not configured as your DHCP server, Pi-hole typically won't be able to determine - # the names of devices on your local network. As a result, tables such as Top Clients - # will only show IP addresses. - # - # One solution for this is to configure Pi-hole to forward these requests to your DHCP - # server (most likely your router), but only for devices on your home network. To - # configure this we will need to know the IP address of your DHCP server and which - # addresses belong to your local network. - # - # Example: [ "true,192.168.0.0/24,192.168.0.1,fritz.box" ] - # - # Allowed values are: - # Array of reverse servers each one in the following form: - # - # ",[/],[#][,]" - # - # Explanation of individual components: - # - # - "": either "true" or "false" - # - # - "[/]": Address range for the reverse server feature in - # CIDR notation. If the prefix length is omitted, either 32 (IPv4) or 128 (IPv6) - # are substituted (exact address match). This is almost certainly not what you - # want here. - # Example: "192.168.0.0/24" for the range 192.168.0.1 - 192.168.0.255 - # - # - "[#]": Target server to be used for the reverse server feature - # Example: "192.168.0.1#53" - # - # - "": Domain used for the reverse server feature (e.g., "fritz.box") - # Example: "fritz.box" - revServers = [ - "true,100.0.0.0/8,100.100.100.100,ts.net" - ] ### CHANGED, default = [] - - [dns.domain] - # The DNS domain used by your Pi-hole. - # - # This DNS domain is purely local. FTL may answer queries from its local cache and - # configuration but *never* forwards any requests upstream *unless* you have - # configured a dns.revServer exactly for this domain. In the latter case, all queries - # for this domain are sent exclusively to this server (including reverse lookups). - # - # For DHCP, this has two effects; firstly it causes the DHCP server to return the - # domain to any hosts which request it, and secondly it sets the domain which it is - # legal for DHCP-configured hosts to claim. The intention is to constrain hostnames so - # that an untrusted host on the LAN cannot advertise its name via DHCP as e.g. - # "google.com" and capture traffic not meant for it. If no domain suffix is specified, - # then any DHCP hostname with a domain part (ie with a period) will be disallowed and - # logged. If a domain is specified, then hostnames with a domain part are allowed, - # provided the domain part matches the suffix. In addition, when a suffix is set then - # hostnames without a domain part have the suffix added as an optional domain part. - # For instance, we can set domain=mylab.com and have a machine whose DHCP hostname is - # "laptop". The IP address for that machine is available both as "laptop" and - # "laptop.mylab.com". - # - # You can disable setting a domain by setting this option to an empty string. - # - # Allowed values are: - # Any valid domain - name = "sweet.home" ### CHANGED, default = "lan" - - # If set, the domain configured by dns.domain.name is considered local and queries for - # this domain are never forwarded upstream unless a dns.revServer is configured for - # this domain. - # - # If unset, queries for this domain are forwarded upstream to (possibly public) server - # which is probably not what you want *unless* you have added extra configuration for - # this domain *or* your upstream servers are able to handle local domains (e.g., - # router). - # - # Allowed values are: - # true or false - local = true - - [dns.cache] - # Cache size of the DNS server. Note that expiring cache entries naturally make room - # for new insertions over time. - # - #Setting this number too high will have an adverse effect as not only more space is - # needed, but also lookup speed gets degraded in the 10,000+ range. dnsmasq may issue - # a warning when you go beyond 10,000+ cache entries. - # - # Allowed values are: - # A positive integer value - size = 10000 - - # Query cache optimizer: If a DNS name exists in the cache, but its time-to-live has - # expired only recently, the data will be used anyway (a refreshing from upstream is - # triggered). This can improve DNS query delays especially over unreliable Internet - # connections. This feature comes at the expense of possibly sometimes returning - # out-of-date data and less efficient cache utilization, since old data cannot be - # flushed when its TTL expires, so the cache becomes mostly least-recently-used. To - # mitigate issues caused by massively outdated DNS replies, the maximum overaging of - # cached records is limited. We strongly recommend staying below 86400 (1 day) with - # this option. - # - # Setting the TTL excess time to zero will serve stale cache data regardless how long - # it has expired. This is not recommended as it may lead to stale data being served - # for a long time. Setting this option to any negative value will disable this feature - # altogether. - # - # Allowed values are: - # A positive integer value in seconds, or any negative to disable this feature - optimizer = 3600 - - # This setting allows you to specify the TTL used for queries blocked upstream. Once - # the TTL expires, the query will be forwarded to the upstream server again to check - # if the block is still valid. Defaults to caching for one day (86400 seconds). - # Setting this value to zero disables caching of queries blocked upstream. - # - # Allowed values are: - # A positive integer value in seconds, or zero to disable caching of upstream - # blocked queries - upstreamBlockedTTL = 86400 - - # This is dnsmasq's --cache-rr option, which allows you to define which DNS record - # types should be cached by PiHole. This option can take a comma-separated list of - # RR-types as input. The default value ANY caches all record types. - # - # Allowed values are: - # Valid DNS record types in the following form: [,...] - rrtype = "ANY" - - [dns.blocking] - # Should FTL block queries? - # - # Allowed values are: - # true or false - active = true - - # How should FTL reply to blocked queries? - # - # Allowed values are: - # - "NULL" - # In NULL mode, which is both the default and recommended mode for Pi-hole - # FTLDNS, blocked queries will be answered with the "unspecified address" - # (0.0.0.0 or ::). The "unspecified address" is a reserved IP address specified - # by RFC 3513 - Internet Protocol Version 6 (IPv6) Addressing Architecture, - # section 2.5.2. - # - "IP_NODATA_AAAA" - # In IP-NODATA-AAAA mode, blocked queries will be answered with the local IPv4 - # addresses of your Pi-hole. Blocked AAAA queries will be answered with - # NODATA-IPV6 and clients will only try to reach your Pi-hole over its static - # IPv4 address. - # - "IP" - # In IP mode, blocked queries will be answered with the local IP addresses of - # your Pi-hole. - # - "NX" - # In NXDOMAIN mode, blocked queries will be answered with an empty response - # (i.e., there won't be an answer section) and status NXDOMAIN. A NXDOMAIN - # response should indicate that there is no such domain to the client making the - # query. - # - "NODATA" - # In NODATA mode, blocked queries will be answered with an empty response (no - # answer section) and status NODATA. A NODATA response indicates that the domain - # exists, but there is no record for the requested query type. - mode = "NULL" - - # Should FTL enrich blocked replies with EDNS0 information? - # - # Allowed values are: - # - "NONE" - # In NONE mode, no additional EDNS information is added to blocked queries - # - "CODE" - # In CODE mode, blocked queries will be enriched with EDNS info-code BLOCKED (15) - # - "TEXT" - # In TEXT mode, blocked queries will be enriched with EDNS info-code BLOCKED (15) - # and a text message describing the reason for the block - edns = "TEXT" - - [dns.specialDomains] - # Should Pi-hole always reply with NXDOMAIN to A and AAAA queries of - # use-application-dns.net to disable Firefox automatic DNS-over-HTTP? - # - # This follows the recommendation on - # https://support.mozilla.org/en-US/kb/configuring-networks-disable-dns-over-https - # - # Allowed values are: - # true or false - mozillaCanary = true - - # Should Pi-hole always reply with NXDOMAIN to A and AAAA queries of mask.icloud.com - # and mask-h2.icloud.com to disable Apple's iCloud Private Relay to prevent Apple - # devices from bypassing Pi-hole? - # - # This follows the recommendation on - # https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay - # - # Allowed values are: - # true or false - iCloudPrivateRelay = true - - # Should Pi-hole always reply with NODATA to all queries to zone resolver.arpa to - # prevent devices from bypassing Pi-hole using Discovery of Designated Resolvers? - # - # This is based on recommendations at the end of RFC 9462, section 4. - # - # Allowed values are: - # true or false - designatedResolver = true - - [dns.reply.host] - # Use a specific IPv4 address for the Pi-hole host? By default, FTL determines the - # address of the interface a query arrived on and uses this address for replying to A - # queries with the most suitable address for the requesting client. - # - # This setting can be used to use a fixed, rather than the dynamically obtained, - # address when Pi-hole responds to the following names: - # - "pi.hole" - # - "" - # - "pi.hole." - # - "." - # - # Allowed values are: - # true or false - force4 = false - - # Custom IPv4 address for the Pi-hole host - # - # Allowed values are: - # A valid IPv4 address or empty string ("") - IPv4 = "" - - # Use a specific IPv6 address for the Pi-hole host? See description for the IPv4 - # variant above for further details. - # - # Allowed values are: - # true or false - force6 = false - - # Custom IPv6 address for the Pi-hole host - # - # Allowed values are: - # A valid IPv6 address or empty string ("") - IPv6 = "" - - [dns.reply.blocking] - # Use a specific IPv4 address in IP blocking mode? By default, FTL determines the - # address of the interface a query arrived on and uses this address for replying to A - # queries with the most suitable address for the requesting client. - # - # This setting can be used to use a fixed, rather than the dynamically obtained, - # address when Pi-hole responds in the following cases: - # - IP blocking mode is used and this query is to be blocked - # - regular expressions with the ;reply=IP regex extension. - # - # Allowed values are: - # true or false - force4 = false - - # Custom IPv4 address for IP blocking mode - # - # Allowed values are: - # A valid IPv4 address or empty string ("") - IPv4 = "" - - # Use a specific IPv6 address in IP blocking mode? See description for the IPv4 variant - # above for further details. - # - # Allowed values are: - # true or false - force6 = false - - # Custom IPv6 address for IP blocking mode - # - # Allowed values are: - # A valid IPv6 address or empty string ("") - IPv6 = "" - - [dns.rateLimit] - # Rate-limited queries are answered with a REFUSED reply and not further processed by - # FTL. - # - # The default settings for FTL's rate-limiting are to permit no more than 1000 queries - # in 60 seconds. Both numbers can be customized independently. It is important to note - # that rate-limiting is happening on a per-client basis. Other clients can continue to - # use FTL while rate-limited clients are short-circuited at the same time. - # - # For this setting, both numbers, the maximum number of queries within a given time, - # and the length of the time interval (seconds) have to be specified. For instance, if - # you want to set a rate limit of 1 query per hour, the option should look like - # dns.rateLimit.count=1 and dns.rateLimit.interval=3600. The time interval is relative - # to when FTL has finished starting (start of the daemon + possible delay by - # DELAY_STARTUP) then it will advance in steps of the rate-limiting interval. If a - # client reaches the maximum number of queries it will be blocked until the end of the - # current interval. This will be logged to /var/log/pihole/FTL.log, e.g. Rate-limiting - # 10.0.1.39 for at least 44 seconds. If the client continues to send queries while - # being blocked already and this number of queries during the blocking exceeds the - # limit the client will continue to be blocked until the end of the next interval - # (FTL.log will contain lines like Still rate-limiting 10.0.1.39 as it made additional - # 5007 queries). As soon as the client requests less than the set limit, it will be - # unblocked (Ending rate-limitation of 10.0.1.39). - # - # Rate-limiting may be disabled altogether by setting both values to zero (this - # results in the same behavior as before FTL v5.7). - # - # How many queries are permitted... - # - # Allowed values are: - # A positive integer value - count = 1000 - - # ... in the set interval before rate-limiting? - # - # Allowed values are: - # A positive integer value in seconds - interval = 60 - -[dhcp] - # Is the embedded DHCP server enabled? - # - # Allowed values are: - # true or false - active = true ### CHANGED, default = false - - # Start address of the DHCP address pool - # - # Example: "192.168.0.10" - # - # Allowed values are: - # A valid IPv4 address, or empty string ("") - start = "192.168.2.100" ### CHANGED, default = "" - - # End address of the DHCP address pool - # - # Example: "192.168.0.250" - # - # Allowed values are: - # A valid IPv4 address, or empty string ("") - end = "192.168.2.150" ### CHANGED, default = "" - - # Address of the gateway to be used (typically the address of your router in a home - # installation) - # - # Example: "192.168.0.1" - # - # Allowed values are: - # A valid IPv4 address, or empty string ("") - router = "192.168.2.254" ### CHANGED, default = "" - - # The netmask used by your Pi-hole. For directly connected networks (i.e., networks on - # which the machine running Pi-hole has an interface) the netmask is optional and may - # be set to an empty string (""): it will then be determined from the interface - # configuration itself. - # - # For networks which receive DHCP service via a relay agent, we cannot determine the - # netmask itself, so it should explicitly be specified, otherwise Pi-hole guesses - # based on the class (A, B or C) of the network address. - # - # Example: "255.255.255.0" - # - # Allowed values are: - # Any valid netmask, or an empty string ("") for auto-discovery - netmask = "255.255.255.0" ### CHANGED, default = "" - - # If the lease time is given, then leases will be given for that length of time. If not - # given, the default lease time is one hour for IPv4 and one day for IPv6. - # - # Allowed values are: - # The lease time can be in seconds, or minutes (e.g., "45m") or hours (e.g., "1h") - # or days (like "2d") or even weeks ("1w"). You may also use "infinite" as string - # but be aware of the drawbacks - leaseTime = "24h" ### CHANGED, default = "" - - # Should Pi-hole make an attempt to also satisfy IPv6 address requests (be aware that - # IPv6 works a whole lot different than IPv4) - # - # Allowed values are: - # true or false - ipv6 = true ### CHANGED, default = false - - # Enable DHCPv4 Rapid Commit Option specified in RFC 4039. Should only be enabled if - # either the server is the only server for the subnet to avoid conflicts - # - # Allowed values are: - # true or false - rapidCommit = false - - # Advertise DNS server multiple times to clients. Some devices will add their own - # proprietary DNS servers to the list of DNS servers, which can cause issues with - # Pi-hole. This option will advertise the Pi-hole DNS server multiple times to - # clients, which should prevent this from happening. - # - # Allowed values are: - # true or false - multiDNS = false - - # Enable logging for DHCP. This will log all relevant DHCP-related activity, including, - # e.g., all the options sent to DHCP clients and the tags used to determine them (if - # any). This can be useful for debugging DHCP issues. The generated output is saved to - # the file specified by files.log.dnsmasq below. - # - # Allowed values are: - # true or false - logging = false - - # Ignore unknown DHCP clients. - # If this option is set, Pi-hole ignores all clients which are not explicitly - # configured through dhcp.hosts. This can be useful to prevent unauthorized clients - # from getting an IP address from the DHCP server. - # - # It should be noted that this option is not a security feature, as clients can still - # assign themselves an IP address and use the network. It is merely a convenience - # feature to prevent unknown clients from getting a valid IP configuration assigned - # automatically. - # - # Note that you will need to configure new clients manually in dhcp.hosts before they - # can use the network when this feature is enabled. - # - # Allowed values are: - # true or false - ignoreUnknownClients = false - - # Per host parameters for the DHCP server. This allows a machine with a particular - # hardware address to be always allocated the same hostname, IP address and lease time - # or to specify static DHCP leases - # - # Example: [ "00:20:e0:3b:13:af,192.168.0.123,laptop,24h", - # "00:20:e0:ab:cd:ef,192.168.0.124,desktop,24h"] - # - # Allowed values are: - # Array of static leases each one in the following form: - # "[][,id:|*][,set:][,tag:][,][,][,][,ignore]" - hosts = [ - "BC:24:11:D6:B6:D3,192.168.2.252,server", - "BC:24:11:32:79:DF,192.168.2.249,docker", - "bc:24:11:15:97:41,192.168.2.108,pbs", - "BC:24:11:FE:BE:C1,192.168.2.247,pxe-boot", - "bc:24:11:8b:be:21,192.168.2.107,tor-relay", - "BC:24:11:52:99:6E,192.168.2.120,nix-cache", - "BC:24:11:2C:33:56,192.168.2.248,pdm" - ] ### CHANGED, default = [] - - [ntp.ipv4] - # Should FTL act as network time protocol (NTP) server (IPv4)? - # - # Allowed values are: - # true or false - active = true - - # IPv4 address to listen on for NTP requests - # - # Allowed values are: - # A valid IPv4 address, or empty string (""). For wildcard (0.0.0.0) - address = "" - - [ntp.ipv6] - # Should FTL act as network time protocol (NTP) server (IPv6)? - # - # Allowed values are: - # true or false - active = true - - # IPv6 address to listen on for NTP requests - # - # Allowed values are: - # A valid IPv6 address, or empty string (""). For wildcard (::) - address = "" - - [ntp.sync] - # Should FTL try to synchronize the system time with an upstream NTP server? - # - # Allowed values are: - # true or false - active = true - - # NTP upstream server to sync with, e.g., "pool.ntp.org". Note that the NTP server - # should be located as close as possible to you in order to minimize the time offset - # possibly introduced by different routing paths. - # - # Allowed values are: - # A valid NTP upstream server - server = "pool.ntp.org" - - # Interval in seconds between successive synchronization attempts with the NTP server - # - # Allowed values are: - # A positive integer value in seconds - interval = 3600 - - # Number of NTP syncs to perform and average before updating the system time - # - # Allowed values are: - # A positive integer value - count = 8 - - [ntp.sync.rtc] - # Should FTL update a real-time clock (RTC) if available? - # - # Allowed values are: - # true or false - set = false - - # Path to the RTC device to update. - # - # Example: "/dev/rtc0" - # - # Allowed values are: - # A valid RTC device path, or empty string ("") for auto-discovery - device = "" - - # Should the RTC be set to UTC? - # - # Allowed values are: - # true or false - utc = true - -[resolver] - # Should FTL try to resolve IPv4 addresses to hostnames? - # - # Allowed values are: - # true or false - resolveIPv4 = true - - # Should FTL try to resolve IPv6 addresses to hostnames? - # - # Allowed values are: - # true or false - resolveIPv6 = true - - # Control whether FTL should attempt to obtain client names from the network table by - # MAC address. - # - #This can provide hostnames for devices that do not have a hostname or have multiple - # IP addresses (e.g. IPv4 and IPv6). - #However, MAC-derived names can be ambiguous (e.g., when a MAC appears for multiple - # IPs due to a router/NAT or MAC reuse), which may lead to incorrect hostnames being - # shown. Disabling this option can prevent such issues but may lead to more clients - # without hostnames. - # - # Allowed values are: - # true or false - macNames = true - - # Control whether FTL should use the fallback option to try to obtain client names from - # checking the network table. This behavior can be disabled with this option. - # - # Assume an IPv6 client without a host names. However, the network table knows - - # though the client's MAC address - that this is the same device where we have a host - # name for another IP address (e.g., a DHCP server managed IPv4 address). In this - # case, we use the host name associated to the other address as this is the same - # device. - # - # Allowed values are: - # true or false - networkNames = true - - # With this option, you can change how (and if) hourly PTR requests are made to check - # for changes in client and upstream server hostnames. - # - # Allowed values are: - # - "IPV4_ONLY" - # Do hourly PTR lookups only for IPv4 addresses. This is the new default since - # Pi-hole FTL v5.3.2. It should resolve issues with more and more very - # short-lived PE IPv6 addresses coming up in a lot of networks. - # - "ALL" - # Do hourly PTR lookups for all addresses. This was the default until FTL - # v5.3(.1). It has been replaced as it can create a lot of PTR queries for those - # with many IPv6 addresses in their networks. - # - "UNKNOWN" - # Only resolve unknown hostnames. Already existing hostnames are never refreshed, - # i.e., there will be no PTR queries made for clients where hostnames are known. - # This also means that known hostnames will not be updated once known. - # - "NONE" - # Don't do any hourly PTR lookups. This means we look host names up exactly once - # (when we first see a client) and never again. You may miss future changes of - # host names. - refreshNames = "IPV4_ONLY" - -[database] - # Should FTL load information from the database on startup to be aware of the most - # recent history? - # - # Allowed values are: - # true or false - DBimport = true - - # How long should queries be stored in the database [days]? - # - # Allowed values are: - # A positive integer value in days, or 0 to disable the database - maxDBdays = 91 - - # How often do we store queries in FTL's database [seconds]? - # - # Allowed values are: - # A positive integer value in seconds - DBinterval = 60 - - # Should FTL enable Write-Ahead Log (WAL) mode for the on-disk query database - # (configured via files.database)? - # - # It is recommended to leave this setting enabled for performance reasons. About the - # only reason to disable WAL mode is if you are experiencing specific issues with it, - # e.g., when using a database that is accessed from multiple hosts via a network - # share. When this setting is disabled, FTL will use SQLite3's default journal mode - # (rollback journal in DELETE mode). - # - # Allowed values are: - # true or false - useWAL = true - - # Should FTL force the use of disk storage for the history database? By default, FTL - # uses an in-memory database for much improved performance when browsing the history - # from the dashboard. However, on systems with very limited RAM and only occasional - # usage of the web interface, it may be useful to force the use of disk storage - # instead of holding everything in memory. - # - # Note that using disk storage *will* reduce performance, especially on systems with - # slow storage media (e.g., SD cards). - # - # Allowed values are: - # true or false - forceDisk = false - - [database.network] - # Should FTL analyze the local ARP cache? When disabled, client identification and the - # network table will stop working reliably. - # - # Allowed values are: - # true or false - parseARPcache = true - - # How long should IP addresses be kept in the network_addresses table [days]? IP - # addresses (and associated host names) older than the specified number of days are - # removed to avoid dead entries in the network overview table. - # - # Allowed values are: - # A positive integer value in days - expire = 91 - -[webserver] - # On which domain is the web interface served? - # - # Allowed values are: - # A valid domain - domain = "pi.hole" - - # Webserver access control list (ACL) allowing for restrictions to be put on the list - # of IP addresses which have access to the web server. The ACL is a comma separated - # list of IP subnets, where each subnet is prepended by either a - or a + sign. A plus - # sign means allow, where a minus sign means deny. - # - # If a subnet mask is omitted, such as -1.2.3.4, this means to deny only that single - # IP address. If this value is not set (empty string), all accesses are allowed. - # Otherwise, the default setting is to deny all accesses. On each request the full - # list is traversed, and the last (!) match wins. IPv6 addresses may be specified in - # CIDR-form [a:b::c]/64. - # - # Example 1: "+127.0.0.1,+[::1]" ---> deny all access, except from 127.0.0.1 and ::1 - # - # Example 2: "+192.168.0.0/16" ---> deny all accesses, except from the 192.168.0.0/16 - # subnet - # - # Example 3: "+[::]/0" ---> allow only IPv6 access. - # - # Allowed values are: - # A valid ACL - acl = "" - - # Ports to be used by the webserver. - # - # Comma-separated list of ports to listen on. It is possible to specify an IP address - # to bind to. In this case, an IP address and a colon must be prepended to the port - # number. For example, to bind to the loopback interface on port 80 (IPv4) and to all - # interfaces port 8080 (IPv4), use "127.0.0.1:80,8080". "[::]:80" can be used to - # listen to IPv6 connections to port 80. IPv6 addresses of network interfaces can be - # specified as well, e.g. "[::1]:80" for the IPv6 loopback interface. "[::]:80" will - # bind to port 80 IPv6 only. - # - # In order to use port 80 for all interfaces, both IPv4 and IPv6, use either the - # configuration "80,[::]:80" (create one socket for IPv4 and one for IPv6 only), or - # "+80" (create one socket for both, IPv4 and IPv6). The '+' notation to use IPv4 and - # IPv6 will only work if no network interface is specified. Depending on your - # operating system version and IPv6 network environment, some configurations might not - # work as expected, so you have to test to find the configuration most suitable for - # your needs. In case "+80" does not work for your environment, you need to use - # "80,[::]:80". - # - # If the port is TLS/SSL, a letter 's' (secure) must be appended, for example, - # "80,443s" will open port 80 and port 443, and connections on port 443 will be - # encrypted. For non-encrypted ports, it is allowed to append letter 'r' (as in - # redirect). Redirected ports will redirect all their traffic to the first configured - # SSL port. For example, if webserver.port is "80r,443s", then all HTTP traffic coming - # at port 80 will be redirected to HTTPS port 443. - # - # When specifying 'o' (optional) behind a port, inability to use this port is not - # considered an error. For instance, specifying "80o,8080o" will allow the webserver - # to listen on either 80, 8080, both or even none of the two ports. This flag may be - # combined with 'r' and 's' like "80or,443os,8080,4443s" (80 redirecting to SSL if - # available, 443 encrypted if available, 8080 mandatory and unencrypted, 4443 - # mandatory and encrypted). - # - # If this value is not set (empty string), the web server will not be started and, - # hence, the API will not be available. - # - # Allowed values are: - # A comma-separated list of <[ip_address:]port> - port = "80o,443os,[::]:80o,[::]:443os" - - # Maximum number of worker threads allowed. - # - # The Pi-hole web server handles each incoming connection in a separate thread. - # Therefore, the value of this option is effectively the number of concurrent HTTP - # connections that can be handled. Any other connections are queued until they can be - # processed by a unoccupied thread. - # - # The total number of threads you see may be lower than the configured value as - # threads are only created when needed due to incoming connections. - # - # The value 0 means the number of threads is 50 (as per default settings of CivetWeb) - # for backwards-compatible behavior. - # - # Allowed values are: - # A positive integer value, or 0 for default (50) - threads = 50 - - # Additional HTTP headers added to the web server responses. - # - # The headers are added to all responses, including those for the API. - # Note about the default additional headers: - # - X-DNS-Prefetch-Control: off: Usually browsers proactively perform domain name - # resolution on links that the user may choose to follow. We disable DNS prefetching - # here. - # - Content-Security-Policy: [...] 'unsafe-inline' is both required by Chart.js - # styling some elements directly, and index.html containing some inlined Javascript - # code. - # - X-Frame-Options: DENY: The page can not be displayed in a frame, regardless of the - # site attempting to do so. - # - X-Xss-Protection: 0: Disables XSS filtering in browsers that support it. This - # header is usually enabled by default in browsers, and is not recommended as it can - # hurt the security of the site. - # (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection). - # - X-Content-Type-Options: nosniff: Marker used by the server to indicate that the - # MIME types advertised in the Content-Type headers should not be changed and be - # followed. This allows to opt-out of MIME type sniffing, or, in other words, it is a - # way to say that the webmasters knew what they were doing. Site security testers - # usually expect this header to be set. - # - Referrer-Policy: strict-origin-when-cross-origin: A referrer will be sent for - # same-site origins, but cross-origin requests will send no referrer information. - # The latter four headers are set as expected by https://securityheaders.io - # - # Allowed values are: - # An array of HTTP headers - headers = [ - "Content-Security-Policy: default-src 'self' 'unsafe-inline';", - "X-Frame-Options: DENY", - "X-XSS-Protection: 0", - "X-Content-Type-Options: nosniff", - "Referrer-Policy: strict-origin-when-cross-origin" - ] ### CHANGED, default = [ "X-DNS-Prefetch-Control: off", "Content-Security-Policy: default-src 'none'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self'; manifest-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; form-action 'self'", "X-Frame-Options: DENY", "X-XSS-Protection: 0", "X-Content-Type-Options: nosniff", "Referrer-Policy: strict-origin-when-cross-origin" ] - - # Should the web server serve all files in webserver.paths.webroot directory? - # - # If disabled, only files within the path defined through webserver.paths.webhome and - # /api will be served. - # - # Allowed values are: - # true or false - serve_all = false - - # Additional options passed directly to the web server. - # - # This can be used to set any option supported by the underlying web server - # (CivetWeb). See the CivetWeb documentation for a list of supported options. The - # options are passed as an array of strings, where each string is an option in the - # form "