commit f85c65870f3cba94d38f8aae60ab87c8eeb2130c Author: beatzaplenty Date: Thu Jul 30 07:07:47 2026 +1000 Initial infrastructure mono-repo scaffold Consolidates nixos, docker, raspi, and debian-configuration into a single infrastructure-as-code repo. Includes: - ansible/: full inventory + proxmox-hardening, freeipa, and raspberrypi roles (converted from debian-configuration bash scripts) - terraform/: Proxmox VMs, Dynu DNS, Pi-hole (decommissioned stub), Docker container catalog — migrated from docker/infrastructure/terraform/ - stacks/docker/, stacks/raspi/, nixos/: placeholder READMEs pending git subtree population (see implementation plan) - docs/: internal MkDocs site with architecture, network topology, runbooks, and drift-detection guide; external sanitized site - scripts/: drift-detect.sh, docs-build.sh, install-hooks.sh, check-secrets.sh - CI: secret-scan (push/PR), drift-detect (daily), docs-build (on change) - Pi-hole removed throughout — DNS is FreeIPA, DHCP is router See docs/internal/implementation-plan.md for the phased rollout after pushing to Gitea. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01UvNjoxTWEDkhXsd1Dq2ETP diff --git a/.gitea/workflows/docs-build.yml b/.gitea/workflows/docs-build.yml new file mode 100644 index 0000000..a373b53 --- /dev/null +++ b/.gitea/workflows/docs-build.yml @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..ca8011b --- /dev/null +++ b/.gitea/workflows/drift-detect.yml @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000..ddb2ffc --- /dev/null +++ b/.gitea/workflows/secret-scan.yml @@ -0,0 +1,22 @@ +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/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..ddb2ffc --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,22 @@ +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/.gitignore b/.gitignore new file mode 100644 index 0000000..7ea50a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# 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 + +# Ansible secrets and generated files +ansible/inventory/host_vars/*/vault.yml +ansible/.vault_pass +**/*.retry + +# Docker secrets +stacks/docker/secrets/stack-secrets.env +stacks/docker/secrets/*.txt +stacks/raspi/default-environment.env.local + +# SOPS / age private keys +*.age +.sops.yaml.local + +# Editor and OS +.DS_Store +*.swp +*.swo +*~ +.idea/ +.vscode/settings.json + +# MkDocs build output +site/ + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Logs +*.log diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..c49709c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,80 @@ +# 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d5ed8d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md — infrastructure + +Safety rules and working context for Claude Code in this repository. + +## Overall goal + +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. + +## Production boundary — confirm before touching + +`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) + +**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. + +`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. + +## Terraform rules + +- 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. + +## Ansible rules + +- 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. + +## NixOS rules + +- 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. + +## Stacks rules + +- 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. + +## Secrets + +- 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. + +## Documentation pipeline + +- 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. + +## Directory notes + +- `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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6d7873 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# infrastructure + +Mono-repo for all homelab infrastructure as code. Manages configuration, provisioning, +service stacks, and documentation for the `sweet.home` LAN and edge nodes. + +## 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) +``` + +## Hosts managed + +| 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 + +```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 +``` + +## Related repos (migrating into this one) + +The following repos are being absorbed. See `docs/internal/implementation-plan.md`. + +- `nixos` → `nixos/` +- `docker` → `stacks/docker/` +- `raspi` → `stacks/raspi/` +- `debian-configuration` → `ansible/` (dissolved into roles) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..8609345 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 0000000..826f625 --- /dev/null +++ b/ansible/collections/requirements.yml @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 0000000..51456ec --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,20 @@ +--- +# 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 new file mode 100644 index 0000000..f3efd7f --- /dev/null +++ b/ansible/inventory/group_vars/proxmox.yml @@ -0,0 +1,14 @@ +--- +# 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 new file mode 100644 index 0000000..b3b45a9 --- /dev/null +++ b/ansible/inventory/group_vars/raspi.yml @@ -0,0 +1,5 @@ +--- +# 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 new file mode 100644 index 0000000..e69de29 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml new file mode 100644 index 0000000..b41776b --- /dev/null +++ b/ansible/inventory/hosts.yml @@ -0,0 +1,79 @@ +--- +# 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: "192.168.2.138" # Pi-hole + 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 new file mode 100644 index 0000000..645809b --- /dev/null +++ b/ansible/playbooks/freeipa.yml @@ -0,0 +1,18 @@ +--- +- 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 new file mode 100644 index 0000000..52291e9 --- /dev/null +++ b/ansible/playbooks/ping.yml @@ -0,0 +1,8 @@ +--- +# 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 new file mode 100644 index 0000000..f2b2f2b --- /dev/null +++ b/ansible/playbooks/proxmox.yml @@ -0,0 +1,18 @@ +--- +- 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 new file mode 100644 index 0000000..d3a4195 --- /dev/null +++ b/ansible/playbooks/raspi.yml @@ -0,0 +1,22 @@ +--- +- 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 new file mode 100644 index 0000000..5d24bc7 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,13 @@ +--- +# 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 new file mode 100644 index 0000000..dc555d3 --- /dev/null +++ b/ansible/roles/freeipa/defaults/main.yml @@ -0,0 +1,14 @@ +--- +# 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: "192.168.2.138" + +# 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 new file mode 100644 index 0000000..4bd78aa --- /dev/null +++ b/ansible/roles/freeipa/tasks/main.yml @@ -0,0 +1,133 @@ +--- +# 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 new file mode 100644 index 0000000..94dfa88 --- /dev/null +++ b/ansible/roles/proxmox-hardening/defaults/main.yml @@ -0,0 +1,22 @@ +--- +# 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 new file mode 100644 index 0000000..89cfe62 --- /dev/null +++ b/ansible/roles/proxmox-hardening/handlers/main.yml @@ -0,0 +1,19 @@ +--- +- 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 new file mode 100644 index 0000000..dcff922 --- /dev/null +++ b/ansible/roles/proxmox-hardening/tasks/main.yml @@ -0,0 +1,243 @@ +--- +# 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 new file mode 100644 index 0000000..7357a16 --- /dev/null +++ b/ansible/roles/proxmox-hardening/templates/cluster-fw.j2 @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 0000000..3ddc591 --- /dev/null +++ b/ansible/roles/proxmox-hardening/templates/fail2ban-sshd.local.j2 @@ -0,0 +1,8 @@ +# 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 new file mode 100644 index 0000000..3dc247c --- /dev/null +++ b/ansible/roles/proxmox-hardening/templates/pve-no-subscription.sources.j2 @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 0000000..9971940 --- /dev/null +++ b/ansible/roles/proxmox-hardening/templates/sshd-hardening.conf.j2 @@ -0,0 +1,3 @@ +# 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 new file mode 100644 index 0000000..f599da7 --- /dev/null +++ b/ansible/roles/proxmox-hardening/templates/unattended-upgrades.conf.j2 @@ -0,0 +1,11 @@ +// 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 new file mode 100644 index 0000000..2be377f --- /dev/null +++ b/ansible/roles/raspberrypi/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# 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 new file mode 100644 index 0000000..121b3dd --- /dev/null +++ b/ansible/roles/raspberrypi/tasks/main.yml @@ -0,0 +1,74 @@ +--- +# 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/docs/external/index.md b/docs/external/index.md new file mode 100644 index 0000000..683e741 --- /dev/null +++ b/docs/external/index.md @@ -0,0 +1,31 @@ +# 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/generated/.gitkeep b/docs/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/internal/architecture.md b/docs/internal/architecture.md new file mode 100644 index 0000000..769d4dd --- /dev/null +++ b/docs/internal/architecture.md @@ -0,0 +1,77 @@ +# 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) +- 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. + +## 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 new file mode 100644 index 0000000..1dd17f1 --- /dev/null +++ b/docs/internal/decommissioned/pihole.toml.historical @@ -0,0 +1,1767 @@ +# 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 "