Archived
Merge commit 'e812169e521abeccf3903c0ab6ac00538de8b9dc' as 'nixos'
This commit is contained in:
Submodule
+1
Submodule .claude/worktrees/scripts-dedup added at e578443914
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+11
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+21
-36
@@ -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
|
||||
@@ -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$''',
|
||||
]
|
||||
+201
@@ -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 <hostname> 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
|
||||
@@ -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 `<platform>-<buildtype>`
|
||||
(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.<host>.config.system.build.toplevel.drvPath --raw
|
||||
```
|
||||
@@ -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/<target>/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 <host>` 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#<target>` 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.<host>.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.<host>.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#<tool>`.
|
||||
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/<name>/host.nix` edit only evals that
|
||||
host's targets, a `modules/platforms/<platform>.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`, `<target>`, `--remove`, `--regenerate-all-keys`,
|
||||
all with `--dry-run`). Stores keys as clan vars
|
||||
(`vars/per-machine/<target>/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 <backup-admin-key> [--new-key-file
|
||||
<path>] [--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 <dest-path> [--key-file <path>]
|
||||
[--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 `<dest-path>` 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 <name>]` — 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 | <target>] [--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 <vmid> [--new-vmid <id>]
|
||||
[--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 <s>]
|
||||
[--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 <vmid>`.
|
||||
|
||||
### `scripts/ipa/`
|
||||
|
||||
- `scripts/ipa/create-nixos-ipa-host-account.sh [options] <hostname>` —
|
||||
adds a NixOS host to the FreeIPA domain and produces a sops-encrypted
|
||||
keytab at `secrets/<hostname>.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/<hostname>.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/<target>/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 [<hostname> ...]` — 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.<platform>-<buildtype>` 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/<name>/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/<name>/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/<name>/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/`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 `<platform>-<buildtype>`, 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/<name>/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/<name>/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/<target>/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.<host>.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 <name>` 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/<file>.yaml"
|
||||
```
|
||||
|
||||
then reference it from a module via `config.sops.secrets."<name>".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/<target>/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.
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: ansible.posix
|
||||
- name: community.general
|
||||
- name: ansible.utils
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
# Variables applied to every host.
|
||||
# Override per-group in group_vars/<group>.yml or per-host in host_vars/<host>/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
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
# Raspberry Pi group defaults.
|
||||
|
||||
raspberrypi_setup_ipa_sudo: true
|
||||
raspberrypi_pin_docker_gid: true
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <host_pattern> 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
|
||||
@@ -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: "<router_gateway_ip>" # 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
|
||||
@@ -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
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
# proxmox-hardening role defaults.
|
||||
# Override in inventory/group_vars/proxmox.yml or host_vars/<host>/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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
# Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
@@ -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";
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
# raspberrypi role defaults.
|
||||
|
||||
raspberrypi_setup_ipa_sudo: true
|
||||
raspberrypi_pin_docker_gid: true
|
||||
docker_access_gid: 50010
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -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 <vmid> local:vztmpl/<file>.tar.xz \
|
||||
--unprivileged 1 --features nesting=1,keyctl=1 \
|
||||
--rootfs local-lvm:8 --hostname <name> --cores 2 --memory 2048 --swap 2048 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp
|
||||
pct start <vmid>
|
||||
```
|
||||
|
||||
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 <name>` 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/<hostname>_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://<lanDomain>/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 <url>#<choice> --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/<name>/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 <flake-target>
|
||||
```
|
||||
|
||||
This generates `host-keys/<flake-target>_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 <new-host> 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 .#<hostname> --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.
|
||||
+104
@@ -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/<type>.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: <token from the beszel hub Settings → Keys>
|
||||
```
|
||||
|
||||
`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**.
|
||||
Vendored
-31
@@ -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
|
||||
@@ -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 `<platform>-<buildtype>`
|
||||
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 `<platform>-<buildtype>` (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.
|
||||
+160
@@ -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-<phase>`):
|
||||
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 <hostname>
|
||||
```
|
||||
|
||||
This runs `ipa host-add`, fetches a keytab from the domain controller, and
|
||||
writes a sops-encrypted `secrets/<hostname>.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`) |
|
||||
@@ -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)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,372 +0,0 @@
|
||||
# Implementation Plan
|
||||
|
||||
Phased rollout of the `infrastructure` mono-repo. Work through these phases in order
|
||||
after the initial push to Gitea. Each phase is independently completeable — the repo
|
||||
is usable after Phase 0 even if later phases aren't done yet.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Initial push and git history migration
|
||||
|
||||
**Goal:** Get the repo to Gitea and bring in existing repos with their commit histories.
|
||||
|
||||
### 0.1 — Create Gitea repo and push
|
||||
|
||||
```bash
|
||||
cd /home/wayne/repos/infrastructure
|
||||
git add -A
|
||||
git commit -m "Initial infrastructure mono-repo scaffold"
|
||||
|
||||
# Create the repo on Gitea first (via web UI), then:
|
||||
git remote add origin git@gitea.lan.ddnsgeek.com:wayne/infrastructure.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 0.2 — Migrate existing repos via git subtree
|
||||
|
||||
`git subtree` merges another repo's history into a subdirectory. Run these in order.
|
||||
`--squash` collapses history into one merge commit (cleaner). Remove `--squash` if you
|
||||
want full per-commit history (makes the log noisier but preserves full blame).
|
||||
|
||||
```bash
|
||||
# Main docker stack → stacks/docker/
|
||||
git subtree add --prefix=stacks/docker /home/wayne/repos/docker main --squash
|
||||
git push
|
||||
|
||||
# Raspberry Pi stack → stacks/raspi/
|
||||
git subtree add --prefix=stacks/raspi /home/wayne/repos/raspi main --squash
|
||||
git push
|
||||
```
|
||||
|
||||
> **Note:** `debian-configuration` is NOT imported via subtree — it's being dissolved.
|
||||
> Its content has been converted into Ansible roles in `ansible/roles/`. The original
|
||||
> repo should be archived in Gitea (Settings → Danger Zone → Archive) after Phase 3
|
||||
> is complete.
|
||||
|
||||
### 0.3 — Install pre-commit hook locally
|
||||
|
||||
```bash
|
||||
./scripts/install-hooks.sh
|
||||
```
|
||||
|
||||
### 0.4 — Configure Gitea CI secrets
|
||||
|
||||
In the Gitea repo → Settings → Secrets, add:
|
||||
|
||||
| Secret name | Value |
|
||||
|-------------|-------|
|
||||
| `PROXMOX_ENDPOINT` | `https://pve1.sweet.home:8006/` |
|
||||
| `PROXMOX_API_TOKEN_ID` | Terraform API token ID (create in PVE if not exists) |
|
||||
| `PROXMOX_API_TOKEN_SECRET` | Terraform API token secret |
|
||||
| `DYNU_API_KEY` | Dynu API key |
|
||||
| `ANSIBLE_SSH_KEY` | Private SSH key for Ansible connections (base64 or raw PEM) |
|
||||
| `GOTIFY_URL` | `https://gotify.lan.ddnsgeek.com` |
|
||||
| `GOTIFY_TOKEN` | Gotify app token |
|
||||
|
||||
**Creating a Proxmox API token for Terraform (if not already done):**
|
||||
```bash
|
||||
# On pve1 as root:
|
||||
pveum user add terraform@pve --comment "Terraform service account"
|
||||
pveum role add TerraformRole --privs "VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Pool.Allocate Sys.Audit Sys.Console Sys.Modify"
|
||||
pveum aclmod / -user terraform@pve -role TerraformRole
|
||||
pveum user token add terraform@pve tf --privsep=0
|
||||
# Copy the token secret — it's shown only once
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ansible — verify connectivity and run check mode
|
||||
|
||||
**Goal:** Confirm Ansible can reach all managed hosts and produce clean check-mode output.
|
||||
|
||||
### 1.1 — Install Ansible and collections
|
||||
|
||||
```bash
|
||||
pip install ansible
|
||||
cd ansible
|
||||
ansible-galaxy collection install -r collections/requirements.yml
|
||||
```
|
||||
|
||||
### 1.2 — Verify inventory and connectivity
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
# List all hosts
|
||||
ansible all --list-hosts
|
||||
|
||||
# Ping everything
|
||||
ansible-playbook playbooks/ping.yml
|
||||
```
|
||||
|
||||
Fix any unreachable hosts (SSH key, hostname, `ansible_user`) before proceeding.
|
||||
|
||||
### 1.3 — Run check mode against each host group
|
||||
|
||||
```bash
|
||||
# Proxmox nodes
|
||||
ansible-playbook playbooks/proxmox.yml --check --diff --limit proxmox
|
||||
|
||||
# FreeIPA (check only — install tasks are gated by ipa_installed check)
|
||||
ansible-playbook playbooks/freeipa.yml --check --diff
|
||||
|
||||
# Raspberry Pi
|
||||
ansible-playbook playbooks/raspi.yml --check --diff
|
||||
```
|
||||
|
||||
**Expected result:** `check` mode should show either "no changes" (already hardened) or
|
||||
show exactly the diffs you'd expect. If it shows unexpected changes, review the task
|
||||
and update the role defaults before applying.
|
||||
|
||||
### 1.4 — Apply to pve-test first (sandbox validation)
|
||||
|
||||
```bash
|
||||
ansible-playbook playbooks/proxmox.yml --limit pve-test.sweet.home
|
||||
```
|
||||
|
||||
Verify the node is still reachable and the PVE web UI works before applying to pve1.
|
||||
|
||||
### 1.5 — Apply to production hosts
|
||||
|
||||
```bash
|
||||
# Confirm check mode looks clean, then:
|
||||
ansible-playbook playbooks/proxmox.yml --limit pve1.sweet.home
|
||||
ansible-playbook playbooks/raspi.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Terraform — import existing infrastructure state
|
||||
|
||||
**Goal:** Bring existing Proxmox VMs and DNS records under Terraform state management,
|
||||
enabling drift detection.
|
||||
|
||||
### 2.1 — Configure credentials
|
||||
|
||||
```bash
|
||||
export TF_VAR_proxmox_endpoint="https://pve1.sweet.home:8006/"
|
||||
export TF_VAR_proxmox_api_token_id="terraform@pve!tf"
|
||||
export TF_VAR_proxmox_api_token_secret="<token>"
|
||||
```
|
||||
|
||||
Or create `terraform/proxmox/terraform.tfvars` (this file is git-ignored):
|
||||
```hcl
|
||||
proxmox_endpoint = "https://pve1.sweet.home:8006/"
|
||||
proxmox_api_token_id = "terraform@pve!tf"
|
||||
proxmox_api_token_secret = "<token>"
|
||||
```
|
||||
|
||||
### 2.2 — Initialise Terraform workspaces
|
||||
|
||||
```bash
|
||||
cd terraform/proxmox && terraform init
|
||||
cd ../dns && terraform init
|
||||
```
|
||||
|
||||
### 2.3 — Verify existing resource blocks match live state
|
||||
|
||||
The `terraform/proxmox/` workspace already has resource blocks for existing VMs
|
||||
(`docker.tf`, `nix-cache.tf`, `server-nixos.tf`, etc.) — these were generated from
|
||||
live state previously. Verify they're accurate:
|
||||
|
||||
```bash
|
||||
cd terraform/proxmox
|
||||
# Check if state is already populated (it may not be on first run)
|
||||
terraform state list
|
||||
|
||||
# If state is empty, import each existing VM:
|
||||
terraform import proxmox_virtual_environment_vm.docker pve/qemu/103
|
||||
terraform import proxmox_virtual_environment_vm.nix-cache pve/qemu/105
|
||||
terraform import proxmox_virtual_environment_vm.server-nixos pve/qemu/104
|
||||
# Add imports for any other VMs not yet in state
|
||||
# VMID reference: check 'qm list' on pve1
|
||||
```
|
||||
|
||||
### 2.4 — Run plan and reconcile any drift
|
||||
|
||||
```bash
|
||||
terraform plan
|
||||
```
|
||||
|
||||
If plan shows changes, the `.tf` resource block differs from live state.
|
||||
Either update the `.tf` file to match (if the live state is correct) or apply
|
||||
to enforce the declared state. **Do not apply to production VMs without reviewing
|
||||
every planned change.**
|
||||
|
||||
### 2.5 — DNS workspace
|
||||
|
||||
```bash
|
||||
cd terraform/dns
|
||||
# Configure Dynu credentials if not already set:
|
||||
export TF_VAR_dynu_api_key="<api_key>"
|
||||
terraform plan
|
||||
```
|
||||
|
||||
The DNS workspace already has the existing records imported. Verify no drift.
|
||||
|
||||
### 2.6 — Verify drift detection works end-to-end
|
||||
|
||||
Manually introduce a small change in the PVE UI (e.g. add a VM note) and run:
|
||||
```bash
|
||||
./scripts/drift-detect.sh --terraform
|
||||
```
|
||||
Confirm it detects the change. Revert it or update the `.tf` to match.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: NixOS flake migration
|
||||
|
||||
**Goal:** Move the `nixos` repo into this mono-repo while keeping the flake fully functional.
|
||||
|
||||
### 3.1 — Import with git subtree
|
||||
|
||||
```bash
|
||||
git subtree add --prefix=nixos /home/wayne/repos/nixos main --squash
|
||||
git push
|
||||
```
|
||||
|
||||
### 3.2 — Update the remote flake URL on all NixOS hosts
|
||||
|
||||
The `Switch-nix` and `Test-nix` aliases on NixOS hosts reference the old Gitea URL.
|
||||
Update `nixos/variables.nix` (the `flakeUrl` variable) to point at the new path:
|
||||
|
||||
```nix
|
||||
# Old:
|
||||
flakeUrl = "git+ssh://gitea.lan.ddnsgeek.com/wayne/nixos";
|
||||
# New:
|
||||
flakeUrl = "git+ssh://gitea.lan.ddnsgeek.com/wayne/infrastructure?dir=nixos";
|
||||
```
|
||||
|
||||
Deploy the change to the `docker` LXC first (lowest risk), then remaining hosts.
|
||||
|
||||
### 3.3 — Validate flake builds from subdirectory
|
||||
|
||||
```bash
|
||||
nix build ./nixos#proxmox-docker
|
||||
nix build ./nixos#proxmox-nix-cache
|
||||
```
|
||||
|
||||
### 3.4 — Archive the old nixos repo
|
||||
|
||||
Once all hosts are rebuilding successfully from the new URL, archive the old repo:
|
||||
Gitea → `wayne/nixos` → Settings → Danger Zone → Archive Repository.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Activate drift detection CI
|
||||
|
||||
**Goal:** Daily automated drift checks running in Gitea CI with Gotify notifications.
|
||||
|
||||
### 4.1 — Verify secrets are configured (from Phase 0.4)
|
||||
|
||||
Trigger the `drift-detect` workflow manually from Gitea CI → Actions → Drift Detection.
|
||||
|
||||
### 4.2 — Confirm first successful run
|
||||
|
||||
Review the step summary in Gitea. Expected outcome on a clean run:
|
||||
- Terraform: "No changes. Infrastructure matches configuration."
|
||||
- Ansible: `changed=0` on all hosts
|
||||
|
||||
### 4.3 — Test the notification path
|
||||
|
||||
Temporarily modify a `.tf` resource (without applying) and trigger a manual run.
|
||||
Confirm the Gotify notification arrives.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Documentation pipeline
|
||||
|
||||
**Goal:** Auto-generated service catalog and host inventory, with both internal and external sites building on every push.
|
||||
|
||||
### 5.1 — Install docs dependencies
|
||||
|
||||
```bash
|
||||
pip install mkdocs mkdocs-material jinja2 pyyaml
|
||||
```
|
||||
|
||||
### 5.2 — Test local build
|
||||
|
||||
```bash
|
||||
./scripts/docs-build.sh
|
||||
./scripts/docs-build.sh --serve # preview at http://localhost:8000
|
||||
```
|
||||
|
||||
### 5.3 — Implement service catalog generator
|
||||
|
||||
Create `scripts/generate-service-catalog.py`:
|
||||
- Parses all `docker-compose.yml` files under `stacks/`
|
||||
- Extracts service name, image, Traefik domain (from labels), network memberships
|
||||
- Outputs `docs/generated/service-catalog.md`
|
||||
|
||||
Template for the output:
|
||||
```markdown
|
||||
# Service Catalog
|
||||
Auto-generated from compose files in stacks/.
|
||||
|
||||
| Service | Image | Domain | Stack |
|
||||
|---------|-------|--------|-------|
|
||||
| traefik | traefik:latest | — | stacks/docker/core/traefik |
|
||||
| nextcloud-webapp | nextcloud:production | nextcloud.lan.ddnsgeek.com | stacks/docker/apps/nextcloud |
|
||||
...
|
||||
```
|
||||
|
||||
### 5.4 — Configure docs deploy target
|
||||
|
||||
Set these Gitea secrets (from Phase 0.4):
|
||||
- `PAGES_SSH_KEY` — key for rsync to the docs server
|
||||
- `PAGES_HOST` — user@host for rsync
|
||||
- `PAGES_PATH_INTERNAL` — path on the docs server for internal site
|
||||
|
||||
Or use Gitea Pages if self-hosted Gitea supports it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Cleanup and archive
|
||||
|
||||
**Goal:** Retire the old repos cleanly.
|
||||
|
||||
### 6.1 — Verify nothing still references old repos
|
||||
|
||||
- All NixOS hosts rebuilding from `infrastructure?dir=nixos` ✓
|
||||
- Docker stack deployed from `stacks/docker/` ✓
|
||||
- Ansible roles replacing debian-configuration scripts ✓
|
||||
|
||||
### 6.2 — Archive deprecated repos
|
||||
|
||||
Archive these repos on Gitea (makes them read-only, preserves history):
|
||||
- `wayne/debian-configuration` — content dissolved into `ansible/roles/`
|
||||
- `wayne/docker` — content migrated to `stacks/docker/`
|
||||
- `wayne/raspi` — content migrated to `stacks/raspi/`
|
||||
- `wayne/nixos` — migrated to `infrastructure/nixos/`
|
||||
|
||||
Do NOT delete them — they have git history that may be useful for reference.
|
||||
|
||||
### 6.3 — Update any external references
|
||||
|
||||
- CLAUDE.md files in the old repos — add a note pointing to the new mono-repo
|
||||
- Any README links, bookmarks, or documentation that references the old repo URLs
|
||||
|
||||
---
|
||||
|
||||
## Ongoing: Adding new infrastructure
|
||||
|
||||
### Adding a new Proxmox VM
|
||||
|
||||
1. Add a resource block in `terraform/proxmox/<name>.tf`
|
||||
2. Run `terraform plan` → `terraform apply`
|
||||
3. Add the host to `ansible/inventory/hosts.yml`
|
||||
4. If NixOS: add to `nixos/hosts/<name>/host.nix`, run `./scripts/sync-host-keys.sh`
|
||||
5. Deploy: `nixos-rebuild switch --flake ./nixos#<target> --target-host <hostname>`
|
||||
|
||||
### Adding a new Docker service
|
||||
|
||||
1. Add a `docker-compose.yml` under `stacks/docker/apps/<service>/` or `stacks/docker/monitoring/<service>/`
|
||||
2. Add Traefik labels, networks, secrets (following existing service patterns)
|
||||
3. Update `stacks/docker/default-environment.env` if new env vars are needed
|
||||
4. Test: `./services-up.sh config` (validates interpolation), then `./services-up.sh up -d <service>`
|
||||
|
||||
### Rotating credentials
|
||||
|
||||
- Proxmox API token: create new, update Gitea secrets, delete old
|
||||
- Ansible SSH key: replace `ANSIBLE_SSH_KEY` secret, re-add public key to all hosts
|
||||
- Dynu API key: update `DYNU_API_KEY` secret in Gitea
|
||||
- NixOS SOPS secrets: use `nixos/scripts/rotate-admin-key.sh`
|
||||
@@ -1,25 +0,0 @@
|
||||
# Infrastructure Documentation
|
||||
|
||||
Internal documentation for the `sweet.home` homelab. Contains full topology, network
|
||||
details, credentials catalog (names only), and operational runbooks.
|
||||
|
||||
## Quick links
|
||||
|
||||
- [Architecture](architecture.md) — host map and service relationships
|
||||
- [Network Topology](network-topology.md) — VLANs, IPs, firewall rules
|
||||
- [Implementation Plan](implementation-plan.md) — phased rollout roadmap
|
||||
- [Host Inventory](generated/host-inventory.md) — auto-generated from Ansible inventory
|
||||
- [Service Catalog](generated/service-catalog.md) — auto-generated from compose files
|
||||
|
||||
## Repo structure
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── terraform/ Infrastructure state (Proxmox VMs, DNS, Pi-hole)
|
||||
├── ansible/ Configuration management (non-NixOS hosts)
|
||||
├── nixos/ NixOS flake (all NixOS hosts)
|
||||
├── stacks/
|
||||
│ ├── docker/ Main app stack (Traefik, Nextcloud, Passbolt, Gitea…)
|
||||
│ └── raspi/ Raspberry Pi edge monitoring stack
|
||||
└── docs/ This documentation
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# Network Topology
|
||||
|
||||
## LAN segments
|
||||
|
||||
| Subnet | VLAN | Purpose |
|
||||
|--------|------|---------|
|
||||
| `192.168.2.0/24` | (untagged/management) | Primary LAN — all host management interfaces |
|
||||
| `192.168.10.0/29` | VLAN 10 | HA cluster replication (Corosync ring, DRBD) — internal to pve1 |
|
||||
| `192.168.20.0/24` | VLAN 20 | HA storage-client (iSCSI/NFS) — internal to pve1 |
|
||||
|
||||
## Host IP assignments
|
||||
|
||||
| Host | IP | Role |
|
||||
|------|----|------|
|
||||
| `pve1.sweet.home` | (router DHCP / static lease) | Proxmox hypervisor |
|
||||
| `domain-controller.sweet.home` | `192.168.2.253` | FreeIPA (DNS + auth) |
|
||||
| `docker.sweet.home` | `192.168.2.225` | Docker app stack host |
|
||||
| `nix-cache.sweet.home` | `192.168.2.224` | Nix binary cache |
|
||||
| `pxe-boot.sweet.home` | `192.168.2.223` | PXE/TFTP/HTTP boot server |
|
||||
| `tailscale-router.sweet.home` | `192.168.2.222` | Tailscale subnet router + DNS proxy |
|
||||
| `tor-relay.sweet.home` | `192.168.2.221` | Tor middle relay |
|
||||
| `ha-server-1.sweet.home` | `192.168.2.228` | HA cluster node 1 |
|
||||
| `ha-server-2.sweet.home` | `192.168.2.227` | HA cluster node 2 |
|
||||
| HA LAN VIP (Pacemaker) | `192.168.2.229` | Floating NFS/service VIP |
|
||||
| HA storage VIP (Pacemaker) | `192.168.20.229` | Docker iSCSI/NFS floating VIP |
|
||||
| `raspberrypi.tail13f623.ts.net` | Tailscale | Raspberry Pi edge node |
|
||||
|
||||
## DNS architecture
|
||||
|
||||
FreeIPA is the sole DNS server for all LAN clients (Pi-hole decommissioned).
|
||||
Tailscale name resolution is handled by a conditional forwarder in FreeIPA pointing at
|
||||
the `tailscale-router` LXC, which proxies those queries into Tailscale MagicDNS.
|
||||
|
||||
```
|
||||
LAN client → FreeIPA (192.168.2.253)
|
||||
│
|
||||
├── *.sweet.home → IPA integrated DNS (authoritative)
|
||||
├── *.ts.net → conditional forwarder → tailscale-router (192.168.2.222)
|
||||
│ │
|
||||
│ └── Tailscale MagicDNS (100.100.100.100)
|
||||
└── everything else → upstream resolvers (via FreeIPA forwarders)
|
||||
|
||||
Tailscale client → tailscale-router (MagicDNS split-horizon)
|
||||
│
|
||||
├── *.sweet.home → forwarded to FreeIPA (192.168.2.253)
|
||||
└── *.ts.net → Tailscale MagicDNS (local)
|
||||
```
|
||||
|
||||
PXE DHCP options are served by the `pxe-boot` LXC (dnsmasq proxy mode for PXE chainloading only).
|
||||
General DHCP is handled by the router.
|
||||
|
||||
## Tailscale + LAN routing
|
||||
|
||||
The `tailscale-router` LXC at `192.168.2.222` bridges the Tailscale network and the LAN
|
||||
in both directions:
|
||||
|
||||
**LAN → Tailscale:**
|
||||
- Router has a static route: Tailscale CGNAT range (`100.64.0.0/10`) → `192.168.2.222`
|
||||
- LAN hosts reach Tailscale peers (e.g. `raspberrypi.tail13f623.ts.net`) via this route
|
||||
- DNS resolution for `*.ts.net` goes through FreeIPA → tailscale-router → MagicDNS (see above)
|
||||
|
||||
**Tailscale → LAN:**
|
||||
- `tailscale-router` advertises `192.168.2.0/24` as a subnet route into the Tailnet
|
||||
- Tailscale peers can reach any `192.168.2.x` host by routing through `tailscale-router`
|
||||
- DNS: Tailscale peers use `tailscale-router` as a split-horizon forwarder for `sweet.home`
|
||||
|
||||
**Result:** From any Tailscale-connected device, `docker.sweet.home` resolves and routes
|
||||
correctly without being physically on the LAN. From any LAN host,
|
||||
`raspberrypi.tail13f623.ts.net` resolves and routes without a Tailscale client.
|
||||
|
||||
## External access
|
||||
|
||||
- Domain: `*.lan.ddnsgeek.com` → Dynamic DNS via Dynu → home WAN IP
|
||||
- TLS: LetsEncrypt via Traefik ACME (HTTP challenge on port 80)
|
||||
- Tailscale: subnet router at `192.168.2.222` (see above)
|
||||
|
||||
## Proxmox firewall
|
||||
|
||||
Default-deny inbound on all Proxmox nodes.
|
||||
Management access (SSH port 22, web UI port 8006) from `192.168.2.0/24` only.
|
||||
See `ansible/roles/proxmox-hardening/templates/cluster-fw.j2` for the ruleset.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Runbook: Drift Detection
|
||||
|
||||
How to interpret and respond to drift detected by the CI pipeline or local script.
|
||||
|
||||
## What is drift?
|
||||
|
||||
**Terraform drift** — a Proxmox VM, DNS record, or Pi-hole entry exists in a different
|
||||
state than what's declared in the Terraform workspace. Common causes:
|
||||
- A VM was resized or reconfigured manually in the PVE web UI
|
||||
- A DNS record was added directly via Dynu or Pi-hole admin
|
||||
- A VM was deleted outside of Terraform
|
||||
|
||||
**Ansible drift** — a host's configuration differs from what the Ansible role declares.
|
||||
Common causes:
|
||||
- A config file was edited by hand on the host
|
||||
- A package was installed/removed manually
|
||||
- A service was stopped/disabled without updating the role
|
||||
|
||||
## CI workflow
|
||||
|
||||
The `drift-detect` workflow runs daily at 06:00 AEST. If drift is detected, a Gotify
|
||||
notification is sent to the push notification dashboard. The step summary in Gitea CI
|
||||
contains the full `terraform plan` diff.
|
||||
|
||||
## Responding to Terraform drift
|
||||
|
||||
```bash
|
||||
# See exactly what changed
|
||||
cd terraform/proxmox
|
||||
terraform plan
|
||||
|
||||
# Option A: The manual change was intentional — update the .tf file to match reality,
|
||||
# then run terraform apply to reconcile state
|
||||
vim proxmox/docker.tf # update the resource to match current state
|
||||
terraform apply
|
||||
|
||||
# Option B: The manual change was accidental — apply to restore declared state
|
||||
terraform apply # will revert the manual change
|
||||
```
|
||||
|
||||
## Responding to Ansible drift
|
||||
|
||||
```bash
|
||||
# See exactly what would change
|
||||
cd ansible
|
||||
ansible-playbook playbooks/site.yml --check --diff --limit <drifted_host>
|
||||
|
||||
# Apply to restore declared state
|
||||
ansible-playbook playbooks/site.yml --limit <drifted_host>
|
||||
```
|
||||
|
||||
## Required Gitea secrets
|
||||
|
||||
The following secrets must be configured in the Gitea repo for CI drift detection to work:
|
||||
|
||||
| Secret | Used by |
|
||||
|--------|---------|
|
||||
| `PROXMOX_ENDPOINT` | Terraform proxmox workspace |
|
||||
| `PROXMOX_API_TOKEN_ID` | Terraform proxmox workspace |
|
||||
| `PROXMOX_API_TOKEN_SECRET` | Terraform proxmox workspace |
|
||||
| `DYNU_API_KEY` | Terraform dns workspace |
|
||||
| `ANSIBLE_SSH_KEY` | Ansible drift check |
|
||||
| `GOTIFY_URL` | Drift notifications |
|
||||
| `GOTIFY_TOKEN` | Drift notifications |
|
||||
@@ -1,59 +0,0 @@
|
||||
# Runbook: Provisioning a New Host
|
||||
|
||||
Steps for adding a new machine to the infrastructure.
|
||||
|
||||
## NixOS host on Proxmox
|
||||
|
||||
```bash
|
||||
# 1. Use the PXE boot menu or installer ISO
|
||||
# The auto-installer at pxe-boot.sweet.home presents all flake targets.
|
||||
|
||||
# 2. Add to nixos/ flake if a new build-type is needed
|
||||
# Otherwise pick the closest existing target (e.g. proxmox-minimal)
|
||||
|
||||
# 3. Add SSH host key to clan vars (enables SOPS decryption)
|
||||
cd nixos
|
||||
./scripts/sync-host-keys.sh <target>
|
||||
|
||||
# 4. Create the VM/LXC via Terraform
|
||||
cd ../terraform/proxmox
|
||||
# Add a resource block in the appropriate .tf file
|
||||
terraform plan
|
||||
terraform apply
|
||||
|
||||
# 5. Deploy NixOS
|
||||
nixos-rebuild switch --flake ./nixos#<target> --target-host <hostname>
|
||||
```
|
||||
|
||||
## Non-NixOS host (Proxmox VM)
|
||||
|
||||
```bash
|
||||
# 1. Create VM in Proxmox (manually or via terraform)
|
||||
# 2. Add to ansible/inventory/hosts.yml with correct group
|
||||
# 3. Add SSH key, verify connectivity:
|
||||
ansible-playbook ansible/playbooks/ping.yml --limit <hostname>
|
||||
# 4. Apply the role:
|
||||
ansible-playbook ansible/playbooks/<role>.yml --limit <hostname> --check --diff
|
||||
ansible-playbook ansible/playbooks/<role>.yml --limit <hostname>
|
||||
```
|
||||
|
||||
## Adding Ansible inventory entry
|
||||
|
||||
```yaml
|
||||
# ansible/inventory/hosts.yml — under the appropriate group:
|
||||
new-host.sweet.home:
|
||||
ansible_user: wayne
|
||||
# any role-specific variables
|
||||
```
|
||||
|
||||
## IPA enrollment (for any new host)
|
||||
|
||||
```bash
|
||||
# On the new host (after OS install):
|
||||
sudo ipa-client-install \
|
||||
--server=domain-controller.sweet.home \
|
||||
--domain=sweet.home \
|
||||
--realm=SWEET.HOME \
|
||||
--principal=admin \
|
||||
--mkhomedir
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
# Runbook: Proxmox VE Hardening
|
||||
|
||||
Applies Stage 1 hardening to a fresh Proxmox VE node using the `proxmox-hardening` Ansible role.
|
||||
|
||||
## Pre-conditions
|
||||
|
||||
- Fresh PVE install with root SSH access
|
||||
- SSH key already added to root's `authorized_keys`
|
||||
- Host added to `ansible/inventory/hosts.yml` under the `proxmox` group
|
||||
|
||||
## Steps
|
||||
|
||||
```bash
|
||||
# 1. Verify connectivity
|
||||
cd ansible
|
||||
ansible-playbook playbooks/ping.yml --limit <hostname>
|
||||
|
||||
# 2. Dry-run to preview changes
|
||||
ansible-playbook playbooks/proxmox.yml --limit <hostname> --check --diff
|
||||
|
||||
# 3. Review the diff output, then apply
|
||||
ansible-playbook playbooks/proxmox.yml --limit <hostname>
|
||||
|
||||
# 4. Verify hardening state
|
||||
ansible-playbook playbooks/proxmox.yml --limit <hostname> --tags audit --check
|
||||
```
|
||||
|
||||
## What gets applied
|
||||
|
||||
1. **APT repos** — enterprise source disabled, no-subscription source enabled
|
||||
2. **SSH hardening** — `PermitRootLogin prohibit-password`, `PasswordAuthentication no`, fail2ban
|
||||
3. **Datacenter firewall** — default-deny inbound; SSH (22) and web UI (8006) from management CIDR only
|
||||
4. **Unattended upgrades** — security-only origins, no auto-reboot
|
||||
5. **Named admin user** — `<proxmox_admin_username>@pve` with Administrator role (one-time)
|
||||
6. **IPA sudo** — `admins` group gets NOPASSWD sudo for shell and PVE tools
|
||||
7. **Subscription nag** — cosmetic patch to remove the nag dialog
|
||||
|
||||
## Variables to set per-host
|
||||
|
||||
In `ansible/inventory/hosts.yml`:
|
||||
```yaml
|
||||
pve1.sweet.home:
|
||||
proxmox_mgmt_cidr: "192.168.2.0/24"
|
||||
proxmox_admin_username: wayne
|
||||
```
|
||||
|
||||
## After hardening
|
||||
|
||||
- Log into the PVE web UI as the named admin, change the initial password
|
||||
- Enable TOTP/2FA for both the named admin and root@pam
|
||||
- Verify with the audit script: `cd /home/wayne/repos/debian-configuration && proxmox/scripts/audit.sh`
|
||||
(will migrate to an Ansible audit task in a future iteration)
|
||||
@@ -0,0 +1,186 @@
|
||||
# IP Addressing Scheme
|
||||
|
||||
## Subnets
|
||||
|
||||
| Subnet | VLAN | CIDR | Purpose | Routed? |
|
||||
|---|---|---|---|---|
|
||||
| LAN | 2 (native/untagged) | `192.168.2.0/24` | General LAN — clients and infrastructure | Yes (gateway .254) |
|
||||
| Cluster | 10 | `192.168.10.224/29` | HA file server DRBD replication + Corosync heartbeat | No — internal `vmbr1` only, no uplink |
|
||||
| Storage client | 20 | `192.168.20.0/24` | HA file server NFS (and iSCSI if needed) — docker and swarm nodes mount from VIP here | No — internal `vmbr2` only, no uplink |
|
||||
|
||||
The cluster and storage-client subnets never leave pve1. `vmbr1` and `vmbr2` are Proxmox Linux
|
||||
bridges with no physical port attached; traffic between guests on each bridge stays in-kernel.
|
||||
|
||||
VLAN IDs match the third octet of each subnet (VLAN 2 → 192.168.**2**.x, VLAN 10 → 192.168.**10**.x,
|
||||
VLAN 20 → 192.168.**20**.x). The host octet is consistent across all subnets — e.g. ha-node1
|
||||
is always `.228`: `192.168.2.228` (LAN), `192.168.10.228` (cluster), `192.168.20.228` (storage client).
|
||||
|
||||
**Protocol separation** (enforced by firewall on HA nodes):
|
||||
- NFS (ports 111, 2049, 20048): both subnets, each restricted to its own CIDR
|
||||
- VLAN 2 only → `vip-lan` (192.168.2.229) — pxe-boot and other LAN clients
|
||||
- VLAN 20 only → `vip-storage` (192.168.20.229) — docker, future swarm nodes
|
||||
- iSCSI (port 3260): VLAN 20 only — available but not in active use; NFS is preferred
|
||||
for multi-host access (shared volumes across a Docker Swarm require a shared filesystem,
|
||||
not per-host block devices)
|
||||
|
||||
---
|
||||
|
||||
## DNS Zones
|
||||
|
||||
FreeIPA (domain-controller.sweet.home) is authoritative for all zones. Three
|
||||
zones correspond to the three subnets — one per VLAN. All zones are internal
|
||||
only; no external delegation.
|
||||
|
||||
### sweet.home — VLAN 2 (192.168.2.x)
|
||||
|
||||
General LAN zone. All infrastructure hostnames live here.
|
||||
|
||||
| Hostname | A record | Notes |
|
||||
|---|---|---|
|
||||
| `domain-controller.sweet.home` | `192.168.2.253` | FreeIPA / KDC / DNS |
|
||||
| `ha-vip-lan.sweet.home` | `192.168.2.229` | Pacemaker `vip-lan` — NFS for LAN clients |
|
||||
| `ha-server-1.sweet.home` | `192.168.2.228` | HA node 1 management NIC |
|
||||
| `ha-server-2.sweet.home` | `192.168.2.227` | HA node 2 management NIC |
|
||||
| `server.sweet.home` | `192.168.2.226` | Current ZFS/NFS server (retiring) |
|
||||
| `docker.sweet.home` | `192.168.2.225` | Docker/Traefik host |
|
||||
| `nix-cache.sweet.home` | `192.168.2.224` | Nix binary cache + remote builder |
|
||||
| `pxe-boot.sweet.home` | `192.168.2.223` | PXE / TFTP / HTTP netboot |
|
||||
| `tailscale-router.sweet.home` | `192.168.2.222` | Tailscale exit node |
|
||||
| `tor-relay.sweet.home` | `192.168.2.221` | Tor relay |
|
||||
| `pdm.sweet.home` | `192.168.2.220` | Proxmox Deploy Manager |
|
||||
| `nixos.sweet.home` | `192.168.2.39` | Bare-metal workstation (DHCP) |
|
||||
| `pve1.sweet.home` | `192.168.2.245` | Proxmox VE hypervisor |
|
||||
| `pbs.sweet.home` | `192.168.2.244` | Proxmox Backup Server |
|
||||
|
||||
PTR records exist for all static hosts. The workstation (`nixos.sweet.home`) is
|
||||
DHCP-assigned; its PTR is omitted.
|
||||
|
||||
### cluster.home — VLAN 10 (192.168.10.x)
|
||||
|
||||
Internal only — Corosync ring0 heartbeat and DRBD replication between HA nodes.
|
||||
No VIP exists on this subnet (DRBD/Corosync endpoints are static per-node IPs).
|
||||
|
||||
| Hostname | A record | Notes |
|
||||
|---|---|---|
|
||||
| `ha-server-1.cluster.home` | `192.168.10.228` | HA node 1 cluster NIC (ens19 / vmbr1) |
|
||||
| `ha-server-2.cluster.home` | `192.168.10.227` | HA node 2 cluster NIC (ens19 / vmbr1) |
|
||||
|
||||
PTR records exist for both. DNS here is for debugging convenience — DRBD and
|
||||
Corosync use the IPs from the NixOS config directly, not DNS.
|
||||
|
||||
### storage.home — VLAN 20 (192.168.20.x)
|
||||
|
||||
Internal only — NFS (and iSCSI) client access to the HA storage VIP. NFS clients
|
||||
mount from **`nfs.storage.home`** (the Pacemaker floating VIP) so mounts survive
|
||||
failover transparently without reconfiguration.
|
||||
|
||||
| Hostname | A record | Notes |
|
||||
|---|---|---|
|
||||
| `nfs.storage.home` | `192.168.20.229` | Pacemaker `vip-storage` — NFS + iSCSI VIP |
|
||||
| `ha-server-1.storage.home` | `192.168.20.228` | HA node 1 storage-client NIC (ens20 / vmbr2) |
|
||||
| `ha-server-2.storage.home` | `192.168.20.227` | HA node 2 storage-client NIC (ens20 / vmbr2) |
|
||||
| `docker.storage.home` | `192.168.20.225` | Docker host storage-client NIC (eth1 / vmbr2) |
|
||||
| `server.storage.home` | `192.168.20.226` | server VM storage-client NIC (decommissioned — remove DNS record after VM is destroyed) |
|
||||
|
||||
PTR records exist for all five. Remove `server.storage.home`, `server.sweet.home`,
|
||||
and their PTRs from FreeIPA DNS once the server VM is destroyed.
|
||||
|
||||
---
|
||||
|
||||
## LAN — 192.168.2.0/24
|
||||
|
||||
### Address map
|
||||
|
||||
| Range | Purpose |
|
||||
|---|---|
|
||||
| .1–.9 | Reserved, never assign |
|
||||
| .10–.59 | Client DHCP pool (router-assigned) |
|
||||
| .60–.219 | Unallocated buffer |
|
||||
| .220–.229 | Virtual nodes (VMs / LXC containers) |
|
||||
| .230–.239 | Expansion buffer (reserved, unallocated) |
|
||||
| .240–.249 | Physical nodes (bare-metal hosts) |
|
||||
| .250–.253 | Network services |
|
||||
| .254 | Router / gateway |
|
||||
|
||||
### Network services (.250–.253)
|
||||
|
||||
| IP | Hostname | Role |
|
||||
|---|---|---|
|
||||
| `192.168.2.254` | router | Gateway (TP-Link) |
|
||||
| `192.168.2.253` | domain-controller | FreeIPA — authoritative DNS for `sweet.home`, Kerberos, LDAP |
|
||||
| `192.168.2.250`–`.252` | — | Reserved for future network services |
|
||||
|
||||
### Physical nodes (.240–.249)
|
||||
|
||||
| IP | Hostname | Role |
|
||||
|---|---|---|
|
||||
| `192.168.2.245` | pve1 | Proxmox VE hypervisor |
|
||||
| `192.168.2.244` | pbs | Proxmox Backup Server |
|
||||
| `192.168.2.243` | nixos | Bare-metal workstation (`baremetal-gui`) |
|
||||
| `192.168.2.246`–`.249` | — | Reserved — second Proxmox node and associated services |
|
||||
| `192.168.2.240`–`.242` | — | Reserved |
|
||||
|
||||
pve1 sits mid-range deliberately so a second Proxmox node can slot in on either side.
|
||||
|
||||
### Virtual nodes (.220–.229)
|
||||
|
||||
All VMs and LXC containers run on pve1.
|
||||
|
||||
| IP | Hostname | Role | Status |
|
||||
|---|---|---|---|
|
||||
| `192.168.2.229` | ha-vip-lan | HA file server LAN floating VIP (Pacemaker `vip-lan`) — LAN iSCSI + NFS | Active |
|
||||
| `192.168.2.228` | ha-node1 | HA file server node 1 — management NIC | Active |
|
||||
| `192.168.2.227` | ha-node2 | HA file server node 2 — management NIC | Active |
|
||||
| `192.168.2.226` | server | Former NFS/ZFS file server — decommissioned | Removed from flake |
|
||||
| `192.168.2.225` | docker | Docker / Traefik stack | Active |
|
||||
| `192.168.2.224` | nix-cache | Nix binary cache + remote builder | Active |
|
||||
| `192.168.2.223` | pxe-boot | PXE / TFTP / HTTP netboot server | Active |
|
||||
| `192.168.2.222` | tailscale-router | Tailscale exit node / router | Active |
|
||||
| `192.168.2.221` | tor-relay | Tor relay | Active |
|
||||
| `192.168.2.220` | pdm | Proxmox Deploy Manager | Active |
|
||||
|
||||
### Client DHCP pool (.10–.59)
|
||||
|
||||
Assigned by the router. DNS option points to `192.168.2.253` (domain-controller).
|
||||
|
||||
Devices in this range: phones, laptops, IoT, Canon printer, any non-infrastructure host.
|
||||
No static reservations for infrastructure hosts — all infra uses static IP configuration
|
||||
on the guest itself (not DHCP reservations), so IPs survive VM recreation regardless of
|
||||
MAC address churn.
|
||||
|
||||
---
|
||||
|
||||
## Cluster network — VLAN 10 — 192.168.10.224/29
|
||||
|
||||
Internal to pve1 only. Proxmox bridge `vmbr1`, no physical NIC attached.
|
||||
|
||||
| IP | Hostname | Interface role |
|
||||
|---|---|---|
|
||||
| `192.168.10.228` | ha-node1 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||
| `192.168.10.227` | ha-node2 | DRBD replication + Corosync ring0 (primary heartbeat) |
|
||||
| — | no gateway | Isolated — not routed to LAN or internet |
|
||||
|
||||
Corosync ring1 (backup heartbeat only) uses the LAN IPs (`192.168.2.228` / `192.168.2.227`)
|
||||
over `vmbr0` — no additional bridge needed, and DRBD traffic never crosses ring1.
|
||||
|
||||
---
|
||||
|
||||
## Storage-client network — VLAN 20 — 192.168.20.0/24
|
||||
|
||||
Internal to pve1 only. Proxmox bridge `vmbr2`, no physical NIC attached.
|
||||
|
||||
| IP | Hostname | Interface / role |
|
||||
|---|---|---|
|
||||
| `192.168.20.229` | ha-vip-storage | Pacemaker floating VIP — NFS + iSCSI endpoint |
|
||||
| `192.168.20.228` | ha-node1 | Storage-client NIC (ens20 / vmbr2) |
|
||||
| `192.168.20.227` | ha-node2 | Storage-client NIC (ens20 / vmbr2) |
|
||||
| `192.168.20.226` | server | Storage-client NIC (ens19 / vmbr2) — decommissioned |
|
||||
| `192.168.20.225` | docker | Storage-client NIC (eth1 / vmbr2) — NFS client |
|
||||
| — | no gateway | Isolated — not routed to LAN or internet |
|
||||
|
||||
NFS clients mount from `192.168.20.229` (surviving failover transparently via the VIP).
|
||||
Firewall on each HA node restricts NFS and iSCSI ports to `192.168.20.0/24` — LAN hosts
|
||||
cannot reach either service on this VIP. The `vip-storage` endpoint is not reachable
|
||||
from the workstation directly (internal bridge only); health checks proxy through the
|
||||
active HA node.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
site_name: "Homelab"
|
||||
site_description: "Self-hosted infrastructure overview"
|
||||
docs_dir: external
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Services: services.md
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
scheme: default
|
||||
primary: indigo
|
||||
|
||||
# No internal IPs, hostnames, or credential references in external docs.
|
||||
@@ -1,30 +0,0 @@
|
||||
site_name: "Infrastructure — Internal Docs"
|
||||
site_description: "Internal documentation for the sweet.home homelab infrastructure"
|
||||
docs_dir: internal
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Architecture: architecture.md
|
||||
- Network Topology: network-topology.md
|
||||
- Runbooks:
|
||||
- Proxmox Hardening: runbooks/proxmox-hardening.md
|
||||
- FreeIPA Setup: runbooks/freeipa-setup.md
|
||||
- New Host Provisioning: runbooks/new-host.md
|
||||
- Drift Detection: runbooks/drift-detection.md
|
||||
- Generated:
|
||||
- Host Inventory: generated/host-inventory.md
|
||||
- Service Catalog: generated/service-catalog.md
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
scheme: slate
|
||||
primary: indigo
|
||||
|
||||
validation:
|
||||
nav:
|
||||
omitted_files: ignore
|
||||
|
||||
exclude_docs: |
|
||||
README.md
|
||||
*.example
|
||||
@@ -0,0 +1,91 @@
|
||||
# nix-cache architecture
|
||||
|
||||
This repository configures `nix-cache` as a **binary cache server** and a **remote builder** for other hosts.
|
||||
|
||||
## Important design notes
|
||||
|
||||
- This is **not** a shared `/nix/store` setup.
|
||||
- Every machine still keeps and uses its own local `/nix/store`.
|
||||
- Clients prefer `http://nix-cache` for substitutes and keep `https://cache.nixos.org/` as fallback.
|
||||
- Clients can offload builds to `nix-cache` through SSH (`nix.distributedBuilds`).
|
||||
- Client hosts import `modules/nix-cache/client.nix` and, when remote building is enabled, `modules/nix-cache/remote-builder-client.nix`.
|
||||
- The `nix-cache` host imports `modules/nix-cache/server.nix`.
|
||||
|
||||
## Binary cache signing key
|
||||
|
||||
`modules/nix-cache/client.nix` hardcodes every client's trust in one
|
||||
specific public key (`cache.local-1:usoWYanY3Kpq2+kDIS2nhWoLZiRxanmdysdzqCFBHW4=`).
|
||||
That means whichever host is currently playing the `nix-cache` role has to
|
||||
use that *exact* keypair — not a freshly generated one — or no client will
|
||||
accept substitutes from it (they'd just silently fall back to building
|
||||
from source). So unlike most per-host secrets, this one can't be
|
||||
self-generated on first boot; it's managed via sops-nix like every other
|
||||
secret in this repo, sourced from `secrets/nix-cache.yaml`'s
|
||||
`cache-priv-key` entry (`modules/nix-cache/server.nix`).
|
||||
|
||||
**Adding or rotating the value:**
|
||||
|
||||
```bash
|
||||
nix-shell -p sops --run 'sops secrets/nix-cache.yaml'
|
||||
```
|
||||
|
||||
Add (or replace) a `cache-priv-key` entry with the private key file's exact
|
||||
contents. If you don't have it yet, generate a keypair once:
|
||||
|
||||
```bash
|
||||
nix-store --generate-binary-cache-key nix-cache-1 cache-priv.pem cache-pub.pem
|
||||
```
|
||||
|
||||
— paste `cache-priv.pem`'s contents into the `cache-priv-key` entry above,
|
||||
delete both local files afterward, and update
|
||||
`trusted-public-keys` in `modules/nix-cache/client.nix` (and every already-built
|
||||
client) to match `cache-pub.pem` if this is a genuine rotation rather than
|
||||
a first-time bootstrap. Any `nixos-configurations.*-nix-cache` host picks
|
||||
the new key up automatically on next activation — no more manual
|
||||
`/etc/nix/cache-priv.pem` install step.
|
||||
|
||||
## Remote builder SSH keys
|
||||
|
||||
Each client authenticates as `nixremote` using its **own default root SSH
|
||||
identity** (`/root/.ssh/id_ed25519`) — not a separately-named or shared
|
||||
keypair. If a client doesn't have one yet:
|
||||
|
||||
```bash
|
||||
sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Then add its `.pub` contents as a new entry in `vars.remoteBuilderAuthorizedKeys`
|
||||
(`variables.nix`) and rebuild `nix-cache` to pick it up (that list is
|
||||
declarative — an imperative `ssh-copy-id nixremote@nix-cache` won't stick;
|
||||
it gets overwritten on every rebuild). Verify with:
|
||||
|
||||
```bash
|
||||
sudo ssh -i /root/.ssh/id_ed25519 nixremote@nix-cache nix-store --version
|
||||
```
|
||||
|
||||
The committed `remoteBuilderAuthorizedKeys` entries are public SSH keys
|
||||
only. Keep the matching private keys on client hosts and out of the
|
||||
repository.
|
||||
|
||||
nix-cache's own SSH *host* key is trusted declaratively via
|
||||
`programs.ssh.knownHosts` in `modules/nix-cache/remote-builder-client.nix`,
|
||||
sourced from `vars.nixCacheHostKey` (`variables.nix`) — every client rebuild
|
||||
picks it up automatically, so distributed builds don't fail with "Host key
|
||||
verification failed" on a client that has never manually SSH'd to nix-cache
|
||||
before. If nix-cache's host key is ever rotated or the host rebuilt from
|
||||
scratch, update `vars.nixCacheHostKey` to match its new
|
||||
`/etc/ssh/ssh_host_ed25519_key.pub`.
|
||||
|
||||
## Manual verification
|
||||
|
||||
After deployment:
|
||||
|
||||
```bash
|
||||
curl http://nix-cache/nix-cache-info
|
||||
nix store ping --store http://nix-cache
|
||||
nix show-config | grep -E 'substituters|trusted-public-keys|builders-use-substitutes'
|
||||
sudo ssh -i /root/.ssh/id_ed25519 nixremote@nix-cache nix-store --version
|
||||
nix build nixpkgs#hello --builders 'ssh://nixremote@nix-cache x86_64-linux /root/.ssh/id_ed25519 4 2 big-parallel,kvm,nixos-test,benchmark' -L
|
||||
nix path-info -r nixpkgs#hello
|
||||
curl -I "http://nix-cache/$(basename "$(nix path-info nixpkgs#hello)").narinfo"
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
# Proxmox VM disk images
|
||||
|
||||
`proxmox-*` hosts (VM platform, not `lxc-*`) can be built as standalone,
|
||||
ready-to-attach `.raw` disk images via disko's own image-builder — no
|
||||
`nixos-install`, no live installer boot. This uses the same `disko.devices`
|
||||
config (`modules/disko/proxmox.nix`) already used to format a real disk on
|
||||
install, so there's nothing host-specific to write; it's available for every
|
||||
`proxmox-*` target automatically.
|
||||
|
||||
`scripts/proxmox/create-proxmox-resource.sh --type vm --host <name>` automates the
|
||||
whole walkthrough below (and the equivalent LXC one) end to end, including
|
||||
host-key handling and building the image directly on the Proxmox node
|
||||
itself (no local build, no image transfer) — see its `--help`. The steps
|
||||
here are what it runs under the hood, useful for doing any of it by hand
|
||||
or understanding what it does before you trust it against real
|
||||
infrastructure.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
nix build .#nixosConfigurations.proxmox-server.config.system.build.diskoImagesScript
|
||||
sudo ./result --build-memory 2048
|
||||
```
|
||||
|
||||
This produces `<hostname>.raw` in the current directory (e.g. `server.raw`
|
||||
for `proxmox-server`, matching `networking.hostName`, not the flake attribute
|
||||
name — every `proxmox-*` host gets a distinctly named image instead of all
|
||||
of them producing an identical `main.raw`). The script builds inside a
|
||||
temporary QEMU VM and moves the finished image out to the working directory
|
||||
when done; `--build-memory` controls how much RAM that build VM gets.
|
||||
|
||||
`disko.devices.disk.main.imageSize` (currently `20G`, in
|
||||
`modules/disko/proxmox.nix`) sets the image's total size — disko doesn't
|
||||
support auto-resizing, so this needs to comfortably fit ESP + swap + root at
|
||||
build time. Grow the virtual disk (and resize the filesystem) in Proxmox
|
||||
after attaching if a host needs more than that; this is the normal way to
|
||||
size these images, not a one-time decision to get exactly right up front.
|
||||
|
||||
## Host keys
|
||||
|
||||
The disko image script runs a real activation pass inside its temporary
|
||||
build VM while constructing the image — the same sops-nix
|
||||
activation-before-first-boot problem the installer and LXC tarball workflows
|
||||
have (see `docs/auto-installer.md`) applies here too, unmodified. Disko has
|
||||
a native mechanism for it:
|
||||
|
||||
```sh
|
||||
sudo ./result \
|
||||
--pre-format-files host-keys/server_ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key \
|
||||
--pre-format-files host-keys/server_ssh_host_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub \
|
||||
--build-memory 2048
|
||||
```
|
||||
|
||||
Generate the key first with `scripts/secrets/sync-host-keys.sh <hostname>`, same
|
||||
as any other host — see `docs/auto-installer.md` for the full walkthrough
|
||||
(it registers the new key in `.sops.yaml` and re-encrypts the affected
|
||||
`secrets/*.yaml` files too, no manual editing needed).
|
||||
|
||||
## Deploying to Proxmox
|
||||
|
||||
The image needs **UEFI (OVMF)**, not Proxmox's default SeaBIOS —
|
||||
`modules/boot/efi.nix` uses `systemd-boot`, which only works with UEFI
|
||||
firmware. `virtio-scsi` is safe to use as the disk bus:
|
||||
`hardware-configuration/vm/proxmox.nix` already includes `virtio_scsi` in
|
||||
its initrd kernel modules.
|
||||
|
||||
1. Copy the image to the Proxmox host:
|
||||
|
||||
```sh
|
||||
scp server.raw root@<proxmox-host>:/var/lib/vz/import/
|
||||
```
|
||||
|
||||
2. Create an empty VM shell (no disk yet) — replace `<vmid>` with a free ID
|
||||
and `<storage>` with your storage pool's name (`pvesm status` or
|
||||
Datacenter → Storage in the web UI):
|
||||
|
||||
```sh
|
||||
qm create <vmid> --name proxmox-server --memory 2048 --cores 2 \
|
||||
--net0 virtio,bridge=vmbr0 \
|
||||
--bios ovmf --machine q35 \
|
||||
--scsihw virtio-scsi-pci \
|
||||
--efidisk0 <storage>:1,efitype=4m,pre-enrolled-keys=0
|
||||
```
|
||||
|
||||
(`--efidisk0` is required for UEFI — it's where OVMF persists boot-entry
|
||||
NVRAM; without it, systemd-boot's boot entry may not survive a reboot.)
|
||||
|
||||
3. Import the raw disk into storage:
|
||||
|
||||
```sh
|
||||
qm importdisk <vmid> /var/lib/vz/import/server.raw <storage>
|
||||
```
|
||||
|
||||
This prints the resulting disk identifier (e.g. `vm-<vmid>-disk-1`).
|
||||
|
||||
4. Attach it and set it as the boot disk:
|
||||
|
||||
```sh
|
||||
qm set <vmid> --scsi0 <storage>:vm-<vmid>-disk-1
|
||||
qm set <vmid> --boot order=scsi0
|
||||
```
|
||||
|
||||
5. Boot it:
|
||||
|
||||
```sh
|
||||
qm start <vmid>
|
||||
```
|
||||
|
||||
No install step — it boots straight into the already-activated system.
|
||||
|
||||
## Why not `nix build .#nixosConfigurations.<host>.config.system.build.vm`?
|
||||
|
||||
That's a different, unrelated feature — `system.build.vm` (`nixos-rebuild
|
||||
build-vm`) produces an ephemeral QEMU script for locally testing a
|
||||
configuration, not a distributable disk image. It's not part of this
|
||||
workflow.
|
||||
@@ -0,0 +1,214 @@
|
||||
# pxe-boot
|
||||
|
||||
The `pxe-boot` host serves HTTP boot assets for iPXE clients — including
|
||||
self-staged copies of both this flake's own auto-installer netboot image
|
||||
(see `docs/auto-installer.md` for what that image actually is and does once
|
||||
booted) and a vanilla, unmodified NixOS minimal netboot image for plain
|
||||
rescue/inspection use.
|
||||
|
||||
## Host Role
|
||||
|
||||
- Hostname: `pxe-boot`
|
||||
- Web service: nginx on TCP port 80
|
||||
- PXE root: `/srv/pxe`
|
||||
- HTTP root for scripts and images: `/srv/pxe/http`
|
||||
- TFTP root for first-stage bootloaders: `/srv/pxe/tftp`
|
||||
- iPXE entry script: `/srv/pxe/http/boot.ipxe`
|
||||
- Generated iPXE menu: `/srv/pxe/http/menu.ipxe`
|
||||
- Debian Minimal iPXE script: `/srv/pxe/http/debian.ipxe`
|
||||
- SystemRescue iPXE script: `/srv/pxe/http/systemrescue.ipxe`
|
||||
- TFTP fallback script: `/srv/pxe/tftp/autoexec.ipxe`
|
||||
- Boot binaries copied from the Nix `ipxe` package:
|
||||
- `/srv/pxe/tftp/ipxe.efi`
|
||||
- `/srv/pxe/tftp/undionly.kpxe`
|
||||
|
||||
## Directory Layout
|
||||
|
||||
The host creates these directories with systemd tmpfiles:
|
||||
|
||||
```text
|
||||
/srv/pxe
|
||||
/srv/pxe/http
|
||||
/srv/pxe/http/images -> /mnt/pxe-images (symlink to NFS share)
|
||||
/srv/pxe/http/auto-installer
|
||||
/srv/pxe/http/nixos-minimal
|
||||
/srv/pxe/http/debian
|
||||
/srv/pxe/http/systemrescue
|
||||
/srv/pxe/http/ubuntu
|
||||
/srv/pxe/http/rescue
|
||||
/srv/pxe/tftp
|
||||
```
|
||||
|
||||
`/srv/pxe/http/images` is a symlink to `/mnt/pxe-images`, which is an NFS
|
||||
mount of `server.sweet.home:/tank/pxe-boot/images`
|
||||
(`modules/pxe-boot/mount-pxe-images.nix`). Place large images there (ISOs,
|
||||
disk images) rather than on the pxe-boot host's own root disk. For an LXC
|
||||
pxe-boot container the mount uses NFSv3+nolock with `nofail` (eager,
|
||||
non-blocking on server unavailability); for a Proxmox VM it uses NFSv4.2
|
||||
with `x-systemd.automount` (lazy, triggered on first access).
|
||||
|
||||
When running as `lxc-pxe-boot`, the Proxmox container must have
|
||||
`features: nesting=1,mount=nfs` (at minimum) in its Proxmox config. `nesting=1`
|
||||
is required by systemd 260+ for credential isolation (user namespace creation
|
||||
and internal move-mounts); without it, AppArmor denies both, and every
|
||||
systemd service that uses `PrivateUsers`, `PrivateDevices`, or credential
|
||||
passing fails on boot. `mount=nfs` allows the NFSv3 mount. Both are set
|
||||
automatically by `scripts/proxmox/create-proxmox-resource.sh` (via
|
||||
`PROXMOX_DEFAULT_LXC_FEATURES` in `scripts/env.sh` which defaults to
|
||||
`nesting=1,keyctl=1,mount=nfs;nfs4`). If you ever change these features
|
||||
manually via `pct set`, be sure to include both — `pct set` replaces the
|
||||
entire features string, it does not append to it.
|
||||
|
||||
The HTTP iPXE chain is:
|
||||
|
||||
```text
|
||||
undionly.kpxe or ipxe.efi
|
||||
-> autoexec.ipxe from the TFTP root, when iPXE requests it
|
||||
-> http://192.168.2.223/boot.ipxe
|
||||
-> http://192.168.2.223/menu.ipxe
|
||||
```
|
||||
|
||||
The generated menu currently exposes entries for:
|
||||
|
||||
- NixOS Auto-Installer
|
||||
- NixOS Minimal
|
||||
- Debian Minimal
|
||||
- FreeIPA Server (Rocky Linux 9)
|
||||
- SystemRescue environment
|
||||
- iPXE shell
|
||||
- Reboot
|
||||
|
||||
Both NixOS entries chain-load a `netboot.ipxe` staged into their own
|
||||
directory (`/srv/pxe/http/auto-installer/netboot.ipxe` and
|
||||
`/srv/pxe/http/nixos-minimal/netboot.ipxe`), each nixpkgs' own generated
|
||||
netboot iPXE script (correct `init=`/`initrd=` kernel parameters included)
|
||||
rather than a hand-rolled boot line — that script in turn expects its
|
||||
kernel/initrd siblings in the same directory. Each directory's three files
|
||||
(`bzImage`, `initrd`, `netboot.ipxe`) are built from source and staged
|
||||
automatically by `modules/pxe-boot/stage-installer-artifacts.nix` via
|
||||
`systemd.tmpfiles.rules` — no manual operator step required:
|
||||
|
||||
- `auto-installer` is this flake's own `netbootSystem` (`flake.nix`) — the
|
||||
same auto-installer image `nix build .#pxe` produces. See
|
||||
`docs/auto-installer.md`.
|
||||
- `nixos-minimal` is `netbootMinimalSystem` (`flake.nix`) — nixpkgs'
|
||||
`netboot-minimal.nix` composed on its own, with none of this flake's
|
||||
auto-installer wiring (no `common.nix`, no `auto-install.sh`, no baked
|
||||
host keys or custom users). Same `nix build .#pxe-minimal` mechanism as
|
||||
the auto-installer image, just a different module composition. Useful
|
||||
as a plain rescue/inspection shell that doesn't assume anything about
|
||||
this flake.
|
||||
|
||||
Both images set `networking.hostName` to match their menu entry/staged
|
||||
directory name (`auto-installer` / `nixos-minimal`), so each one's
|
||||
generated system name (`nixos-system-<name>-*`) is self-describing rather
|
||||
than the nixpkgs default of `nixos-system-nixos-*` for both.
|
||||
|
||||
The Debian Minimal entry chains `http://<pxeServerIp>/debian.ipxe`, which loads
|
||||
the Debian bookworm netboot kernel and initrd from `/srv/pxe/http/debian/`. The
|
||||
`fetch-debian-netboot.service` oneshot downloads these files from
|
||||
`deb.debian.org` on first boot (idempotent — skips if files are already
|
||||
present):
|
||||
|
||||
```text
|
||||
/srv/pxe/http/debian/linux (Debian bookworm netboot kernel)
|
||||
/srv/pxe/http/debian/initrd.gz (Debian bookworm netboot initrd)
|
||||
```
|
||||
|
||||
The service requires outbound internet access on the pxe-boot host. To
|
||||
re-download (e.g. after a Debian point release), delete the files and restart
|
||||
the service:
|
||||
|
||||
```bash
|
||||
rm /srv/pxe/http/debian/linux /srv/pxe/http/debian/initrd.gz
|
||||
systemctl restart fetch-debian-netboot.service
|
||||
```
|
||||
|
||||
To update to a different Debian release, change `debianRelease` in
|
||||
`modules/build-types/pxe-boot.nix` and redeploy.
|
||||
|
||||
The **FreeIPA Server (Rocky Linux 9)** entry chains
|
||||
`http://<pxeServerIp>/rocky-freeipa.ipxe`, which boots the Rocky Linux 9
|
||||
Anaconda installer with a Kickstart file (`rocky-freeipa.ks`) hosted on the
|
||||
same server. The `fetch-rocky-pxeboot.service` oneshot downloads the pxeboot
|
||||
kernel and initrd from the Rocky Linux mirror on first boot (idempotent):
|
||||
|
||||
```text
|
||||
/srv/pxe/http/rocky/vmlinuz (Rocky Linux 9 Anaconda pxeboot kernel)
|
||||
/srv/pxe/http/rocky/initrd.img (Rocky Linux 9 Anaconda pxeboot initrd)
|
||||
```
|
||||
|
||||
The Kickstart file is generated from the NixOS module and staged at
|
||||
`/srv/pxe/http/rocky-freeipa.ks`. It performs a fully unattended install:
|
||||
|
||||
1. Installs Rocky Linux 9 with `ipa-server` + `ipa-server-dns` packages
|
||||
2. Configures static IP `192.168.2.138`, hostname `domain-controller.sweet.home`
|
||||
3. Creates user `wayne` with the `adminSshKey` from `variables.nix`
|
||||
4. Generates random IPA passwords and writes them to `/root/ipa-credentials.txt`
|
||||
5. Creates a `freeipa-first-boot.service` oneshot that runs `ipa-server-install`
|
||||
on first reboot (~20 minutes)
|
||||
|
||||
After the install completes:
|
||||
- SSH in as `wayne@domain-controller` using the admin key
|
||||
- Monitor FreeIPA install progress: `sudo tail -f /root/freeipa-install.log`
|
||||
- Retrieve credentials: `sudo cat /root/ipa-credentials.txt` (save to password manager)
|
||||
- Configure Pi-hole: `server=/sweet.home/192.168.2.138` in dnsmasq
|
||||
|
||||
To refresh the pxeboot files (e.g. after a Rocky point release):
|
||||
|
||||
```bash
|
||||
rm /srv/pxe/http/rocky/vmlinuz /srv/pxe/http/rocky/initrd.img
|
||||
systemctl restart fetch-rocky-pxeboot.service
|
||||
```
|
||||
|
||||
To update to a different Rocky release, change `rockyRelease` in
|
||||
`modules/build-types/pxe-boot.nix` and redeploy.
|
||||
|
||||
The SystemRescue entry expects the source ISO at:
|
||||
|
||||
```text
|
||||
/srv/pxe/http/images/systemrescue.iso
|
||||
```
|
||||
|
||||
Since `/srv/pxe/http/images` is the NFS-backed symlink, place the ISO on the
|
||||
NFS share at `server.sweet.home:/tank/pxe-boot/images/systemrescue.iso`.
|
||||
|
||||
The `stage-systemrescue.service` oneshot extracts that ISO into:
|
||||
|
||||
```text
|
||||
/srv/pxe/http/systemrescue
|
||||
```
|
||||
|
||||
The rescue menu entry then chains `http://192.168.2.223/systemrescue.ipxe`,
|
||||
which loads the SystemRescue kernel and initramfs from the extracted tree and
|
||||
uses `archiso_http_srv` to fetch the squashfs payload over HTTP.
|
||||
|
||||
## Validation
|
||||
|
||||
Safe evaluation check:
|
||||
|
||||
```bash
|
||||
nix eval .#nixosConfigurations.proxmox-pxe-boot.config.system.build.toplevel.drvPath --raw
|
||||
```
|
||||
|
||||
After deployment by an operator, basic service checks are:
|
||||
|
||||
```bash
|
||||
curl http://pxe-boot/boot.ipxe
|
||||
curl http://pxe-boot/menu.ipxe
|
||||
curl http://pxe-boot/debian.ipxe
|
||||
curl -I http://pxe-boot/debian/linux
|
||||
curl -I http://pxe-boot/debian/initrd.gz
|
||||
curl http://pxe-boot/rocky-freeipa.ipxe
|
||||
curl http://pxe-boot/rocky-freeipa.ks
|
||||
curl -I http://pxe-boot/rocky/vmlinuz
|
||||
curl -I http://pxe-boot/rocky/initrd.img
|
||||
curl http://pxe-boot/systemrescue.ipxe
|
||||
curl -I http://pxe-boot/systemrescue/sysresccd/boot/x86_64/vmlinuz
|
||||
curl -I http://pxe-boot/systemrescue/sysresccd/boot/x86_64/sysresccd.img
|
||||
```
|
||||
|
||||
During a successful BIOS chainload, TFTP should deliver `undionly.kpxe` once,
|
||||
then nginx should log requests for `/boot.ipxe` and `/menu.ipxe`. Repeated TFTP
|
||||
downloads of `undionly.kpxe` indicate the iPXE stage is still not reaching the
|
||||
HTTP chain.
|
||||
Generated
+384
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"nodes": {
|
||||
"clan-core": {
|
||||
"inputs": {
|
||||
"data-mesher": "data-mesher",
|
||||
"disko": [
|
||||
"disko"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-darwin": "nix-darwin",
|
||||
"nix-select": "nix-select",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"sops-nix": [
|
||||
"sops-nix"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783497933,
|
||||
"narHash": "sha256-TxmwEews6URFPqOWEHNychtXbFDgLZjbOfEXtvtOm6U=",
|
||||
"rev": "3dc0221ca09033599fe98055e9bbc81bdf32732a",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/3dc0221ca09033599fe98055e9bbc81bdf32732a.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz"
|
||||
}
|
||||
},
|
||||
"data-mesher": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"clan-core",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": [
|
||||
"clan-core",
|
||||
"treefmt-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778718524,
|
||||
"narHash": "sha256-pXLoI6Ax0EnUK6r34UM1vibVC7CfTu6j72R2692ZzPs=",
|
||||
"rev": "12c552ad547d87254f33f33bddd1a2cdbeac754d",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/12c552ad547d87254f33f33bddd1a2cdbeac754d.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/data-mesher/archive/main.tar.gz"
|
||||
}
|
||||
},
|
||||
"disko": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1781152676,
|
||||
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1650374568,
|
||||
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils-plus": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1715533576,
|
||||
"narHash": "sha256-fT4ppWeCJ0uR300EH3i7kmgRZnAVxrH+XtK09jQWihk=",
|
||||
"owner": "gytis-ivaskevicius",
|
||||
"repo": "flake-utils-plus",
|
||||
"rev": "3542fe9126dc492e53ddd252bb0260fe035f2c0f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "gytis-ivaskevicius",
|
||||
"repo": "flake-utils-plus",
|
||||
"rev": "3542fe9126dc492e53ddd252bb0260fe035f2c0f",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785119570,
|
||||
"narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "release-26.05",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779036909,
|
||||
"narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "56c666e108467d87d13508936aade6d567f2a501",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-select": {
|
||||
"locked": {
|
||||
"lastModified": 1763303120,
|
||||
"narHash": "sha256-yxcNOha7Cfv2nhVpz9ZXSNKk0R7wt4AiBklJ8D24rVg=",
|
||||
"rev": "3d1e3860bef36857a01a2ddecba7cdb0a14c35a9",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/3d1e3860bef36857a01a2ddecba7cdb0a14c35a9.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/clan/nix-select/archive/main.tar.gz"
|
||||
}
|
||||
},
|
||||
"nixos-conf-editor": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"snowfall-lib": "snowfall-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771149335,
|
||||
"narHash": "sha256-YPUIwyumbQOE2DUY8NIsHIUTGUQnDVhnTVUZMZDRwi4=",
|
||||
"owner": "snowfallorg",
|
||||
"repo": "nixos-conf-editor",
|
||||
"rev": "9f8b4519a2e0e8919b69b7572bc26dab54274a6f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "snowfallorg",
|
||||
"repo": "nixos-conf-editor",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771008912,
|
||||
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785133411,
|
||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"clan-core": "clan-core",
|
||||
"disko": "disko",
|
||||
"home-manager": "home-manager",
|
||||
"nixos-conf-editor": "nixos-conf-editor",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"sops-nix": "sops-nix"
|
||||
}
|
||||
},
|
||||
"snowfall-lib": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-utils-plus": "flake-utils-plus",
|
||||
"nixpkgs": [
|
||||
"nixos-conf-editor",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765361626,
|
||||
"narHash": "sha256-kX0Dp/kYSRbQ+yd9e3lmmUWdNbipufvKfL2IzbrSpnY=",
|
||||
"owner": "snowfallorg",
|
||||
"repo": "lib",
|
||||
"rev": "c566ad8b7352c30ec3763435de7c8f1c46ebb357",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "snowfallorg",
|
||||
"repo": "lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"sops-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783174389,
|
||||
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"clan-core",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780220602,
|
||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
description = "LAN NixOS configs";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
nixos-conf-editor.url = "github:snowfallorg/nixos-conf-editor";
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager/release-26.05";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
disko = {
|
||||
url = "github:nix-community/disko";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
sops-nix = {
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
clan-core = {
|
||||
url = "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz";
|
||||
# Deduplicate modules: clan-core bundles its own disko and sops-nix
|
||||
# (both imported by nixosModules.clanCore). Without follows, we'd get
|
||||
# two different versions of each, and disko's _module.args.diskoLib
|
||||
# unique option would conflict. With follows, clan-core uses the same
|
||||
# store paths as us, so NixOS deduplicates the imports.
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
disko.follows = "disko";
|
||||
sops-nix.follows = "sops-nix";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nixos-conf-editor, home-manager, sops-nix, ... } @ inputs:
|
||||
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
inherit (nixpkgs) lib;
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
vars = import ./variables.nix;
|
||||
|
||||
# Generates a nixosConfiguration from a platform (what it runs on) and
|
||||
# a build type (what it's for), plus the per-identity host.nix that
|
||||
# carries the bits that must stay fixed regardless of platform
|
||||
# (hostName, hostId, per-machine secrets). Every build type except
|
||||
# nix-cache itself consumes the nix-cache substituter and remote
|
||||
# builder.
|
||||
mkTarget = { platform, buildType, hostPath, homeFile ? ./modules/common/home.nix, nameSuffix ? "" }:
|
||||
let
|
||||
flakeTarget = "${platform}-${buildType}${nameSuffix}";
|
||||
in
|
||||
nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
inputs.disko.nixosModules.disko
|
||||
sops-nix.nixosModules.sops
|
||||
inputs.clan-core.nixosModules.clanCore
|
||||
{
|
||||
# Required clan settings. directory is the flake root (where
|
||||
# vars/ and sops/ directories live); machine.name is the flake
|
||||
# target name (matches what clan vars generate uses as the key
|
||||
# under vars/per-machine/). enableRecommendedDefaults = false
|
||||
# is mandatory: without it, clan unconditionally enables
|
||||
# networking.useNetworkd, adds packages, and tweaks nix settings
|
||||
# -- none of which belong here.
|
||||
clan.core = {
|
||||
settings.directory = self;
|
||||
settings.machine.name = flakeTarget;
|
||||
enableRecommendedDefaults = false;
|
||||
};
|
||||
}
|
||||
./modules/clan/ssh-host-key.nix
|
||||
./modules/common/configuration.nix
|
||||
./modules/platforms/${platform}.nix
|
||||
./modules/build-types/${buildType}.nix
|
||||
hostPath
|
||||
{ environment.etc."flake-target".text = flakeTarget; }
|
||||
home-manager.nixosModules.home-manager
|
||||
{
|
||||
home-manager = {
|
||||
useGlobalPkgs = true;
|
||||
useUserPackages = true;
|
||||
extraSpecialArgs = { inherit vars; };
|
||||
users.nixos = import homeFile;
|
||||
};
|
||||
}
|
||||
] ++ lib.optionals (buildType != "nix-cache") [
|
||||
./modules/nix-cache/client.nix
|
||||
./modules/nix-cache/remote-builder-client.nix
|
||||
];
|
||||
# flakeTarget is passed via specialArgs (not read back from
|
||||
# config.environment.etc."flake-target" above) specifically so
|
||||
# modules/platforms/lxc.nix can use it to select its own host key
|
||||
# file without a same-option circular dependency (a module
|
||||
# contributing to environment.etc can't read the merged
|
||||
# environment.etc it's itself contributing to).
|
||||
specialArgs = { inherit inputs vars netbootSystem netbootMinimalSystem flakeTarget; };
|
||||
};
|
||||
|
||||
# Generated platform x build-type matrix. pxe-boot has no linode
|
||||
# variant (PXE/DHCP/TFTP need LAN L2 adjacency, which a Linode VPS
|
||||
# doesn't have).
|
||||
generatedTargets = {
|
||||
linode-minimal = mkTarget { platform = "linode"; buildType = "minimal"; hostPath = ./hosts/nix-minimal/host.nix; };
|
||||
proxmox-minimal = mkTarget { platform = "proxmox"; buildType = "minimal"; hostPath = ./hosts/nix-minimal/host.nix; };
|
||||
lxc-minimal = mkTarget { platform = "lxc"; buildType = "minimal"; hostPath = ./hosts/nix-minimal/host.nix; };
|
||||
|
||||
linode-nix-cache = mkTarget { platform = "linode"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
||||
proxmox-nix-cache = mkTarget { platform = "proxmox"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
||||
lxc-nix-cache = mkTarget { platform = "lxc"; buildType = "nix-cache"; hostPath = ./hosts/nix-cache/host.nix; };
|
||||
|
||||
|
||||
linode-docker = mkTarget { platform = "linode"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||
proxmox-docker = mkTarget { platform = "proxmox"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||
lxc-docker = mkTarget { platform = "lxc"; buildType = "docker"; hostPath = ./hosts/docker/host.nix; };
|
||||
|
||||
linode-gui = mkTarget { platform = "linode"; buildType = "gui"; hostPath = ./hosts/nixos/host.nix; homeFile = ./hosts/nixos/home.nix; };
|
||||
proxmox-gui = mkTarget { platform = "proxmox"; buildType = "gui"; hostPath = ./hosts/nixos/host.nix; homeFile = ./hosts/nixos/home.nix; };
|
||||
lxc-gui = mkTarget { platform = "lxc"; buildType = "gui"; hostPath = ./hosts/nixos/host.nix; homeFile = ./hosts/nixos/home.nix; };
|
||||
baremetal-gui = mkTarget { platform = "baremetal"; buildType = "gui"; hostPath = ./hosts/nixos/host.nix; homeFile = ./hosts/nixos/home.nix; };
|
||||
|
||||
proxmox-pxe-boot = mkTarget { platform = "proxmox"; buildType = "pxe-boot"; hostPath = ./hosts/pxe-boot/host.nix; };
|
||||
lxc-pxe-boot = mkTarget { platform = "lxc"; buildType = "pxe-boot"; hostPath = ./hosts/pxe-boot/host.nix; };
|
||||
|
||||
linode-tailscale-router = mkTarget { platform = "linode"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/host.nix; };
|
||||
proxmox-tailscale-router = mkTarget { platform = "proxmox"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/host.nix; };
|
||||
lxc-tailscale-router = mkTarget { platform = "lxc"; buildType = "tailscale-router"; hostPath = ./hosts/tailscale-router/host.nix; };
|
||||
|
||||
lxc-tor-relay = mkTarget { platform = "lxc"; buildType = "tor-relay"; hostPath = ./hosts/tor-relay/host.nix; };
|
||||
|
||||
proxmox-ha-server-1 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-1/host.nix; nameSuffix = "-1"; };
|
||||
proxmox-ha-server-2 = mkTarget { platform = "proxmox"; buildType = "ha-server"; hostPath = ./hosts/ha-server-2/host.nix; nameSuffix = "-2"; };
|
||||
};
|
||||
|
||||
# Auto-install environments (migrated from the former nix-auto-installer
|
||||
# flake): a self-contained NixOS installer that boots, discovers this
|
||||
# flake's own nixosConfigurations over the network, and runs
|
||||
# nixos-install against whichever one the operator picks. These are
|
||||
# deliberately not part of the platform x build-type matrix above —
|
||||
# they're throwaway boot media, not persistent hosts, so they skip
|
||||
# disko/sops-nix/home-manager and just need `vars`.
|
||||
installerTargets = {
|
||||
installer = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [ ./modules/installer/iso.nix ];
|
||||
specialArgs = { inherit vars; };
|
||||
};
|
||||
};
|
||||
|
||||
# Same installer environment, built as netboot (kernel + initrd +
|
||||
# iPXE script) instead of an ISO — this is what packages.pxe bundles.
|
||||
#
|
||||
# Deliberately imports common.nix directly, NOT ./modules/installer/iso.nix
|
||||
# (which pulls in nixpkgs' installation-cd-minimal.nix) -- confirmed live
|
||||
# that composing the ISO module together with netboot-minimal.nix hangs
|
||||
# every boot waiting for a device that can never exist on a netboot
|
||||
# client ("A start job is running for /dev/disk/by-label/nixos-minimal-...").
|
||||
# Both installation-cd-base.nix and netboot.nix set fileSystems."/" via
|
||||
# the identical lib.mkImageMediaOverride (mkOverride 60) priority --
|
||||
# genuinely conflicting root-filesystem strategies (ISO-by-label vs.
|
||||
# netboot-tmpfs) at the same priority, and the ISO one was winning.
|
||||
# netboot-minimal.nix's own chain (netboot-base.nix) already imports
|
||||
# profiles/installation-device.nix independently, so common.nix's
|
||||
# initialHashedPassword override (which assumes that profile is
|
||||
# present) still applies correctly without iso.nix in the mix.
|
||||
#
|
||||
# networking.hostName is set explicitly (rather than left at nixpkgs'
|
||||
# own "nixos" default) so this image's generated system name
|
||||
# (nixos-system-auto-installer-*) matches its iPXE menu entry —
|
||||
# see modules/build-types/pxe-boot.nix's :auto-installer item — and
|
||||
# its staged directory, /srv/pxe/http/auto-installer.
|
||||
netbootSystem = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./modules/installer/common.nix
|
||||
({ modulesPath, ... }: {
|
||||
imports = [
|
||||
(modulesPath + "/installer/netboot/netboot-minimal.nix")
|
||||
];
|
||||
})
|
||||
{ networking.hostName = "auto-installer"; }
|
||||
];
|
||||
specialArgs = { inherit vars; };
|
||||
};
|
||||
|
||||
# A genuinely vanilla NixOS minimal netboot image: nixpkgs'
|
||||
# netboot-minimal.nix on its own, with none of this flake's
|
||||
# auto-installer wiring (no common.nix — no auto-install.sh, no
|
||||
# baked host keys, no custom users/passwords). Built from source via
|
||||
# the same nixosSystem + netboot-minimal.nix path as netbootSystem
|
||||
# above, so both go through an identical build mechanism; the only
|
||||
# difference is what's composed in. hostName again matches this
|
||||
# image's iPXE menu entry (:nixos-minimal) and staged directory
|
||||
# (/srv/pxe/http/nixos-minimal).
|
||||
netbootMinimalSystem = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
({ modulesPath, ... }: {
|
||||
imports = [
|
||||
(modulesPath + "/installer/netboot/netboot-minimal.nix")
|
||||
];
|
||||
})
|
||||
{ networking.hostName = "nixos-minimal"; }
|
||||
];
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
|
||||
nixosConfigurations = generatedTargets // installerTargets;
|
||||
|
||||
# Buildable auto-installer artifacts (`nix build .#<name>`). No `lxc`
|
||||
# variant (installer-boots-as-an-LXC-container) or `all` bundle
|
||||
# anymore — lxc-* and proxmox-* hosts deploy via their own tarball/
|
||||
# disk-image outputs instead (see docs/auto-installer.md and
|
||||
# docs/proxmox-images.md), which left the installer's own LXC form
|
||||
# with no real use case: it's excluded from the install menu (same
|
||||
# bind-mount problem as any LXC nixos-install target) and nothing
|
||||
# else needed booting the installer itself as a container.
|
||||
packages.${system} = {
|
||||
iso = installerTargets.installer.config.system.build.isoImage;
|
||||
|
||||
pxe = pkgs.linkFarm "pxe" [
|
||||
{ name = "netboot.ipxe"; path = netbootSystem.config.system.build.netbootIpxeScript; }
|
||||
{ name = "initrd"; path = netbootSystem.config.system.build.netbootRamdisk; }
|
||||
{ name = "kernel"; path = netbootSystem.config.system.build.kernel; }
|
||||
];
|
||||
|
||||
# Vanilla NixOS minimal netboot bundle — see netbootMinimalSystem
|
||||
# above. Staged onto the pxe-boot host alongside packages.pxe by
|
||||
# modules/pxe-boot/stage-installer-artifacts.nix.
|
||||
pxe-minimal = pkgs.linkFarm "pxe-minimal" [
|
||||
{ name = "netboot.ipxe"; path = netbootMinimalSystem.config.system.build.netbootIpxeScript; }
|
||||
{ name = "initrd"; path = netbootMinimalSystem.config.system.build.netbootRamdisk; }
|
||||
{ name = "kernel"; path = netbootMinimalSystem.config.system.build.kernel; }
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "docker";
|
||||
hostId = "007f0200";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.dockerIp; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.lxcStorageInterface}.ipv4.addresses = [{ address = vars.dockerStorageIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
# Only advertise the LAN interface to IPA DNS. Without this, SSSD registers
|
||||
# every Docker bridge (172.x.x.x) as an A record for docker.sweet.home —
|
||||
# the default dyndns.interface = "*" catches them all.
|
||||
security.ipa.dyndns.interface = vars.lxcLanInterface; # eth0
|
||||
|
||||
# Preserved from the pre-refactor `docker` target — stateVersion must never
|
||||
# be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haServer1Host;
|
||||
hostId = "3a4b5c6d";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer1Ip; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer1StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer1ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.haServer2Host;
|
||||
hostId = "7e8f9a0b";
|
||||
useDHCP = false;
|
||||
interfaces = {
|
||||
${vars.vmLanInterface}.ipv4.addresses = [{ address = vars.haServer2Ip; prefixLength = vars.lanPrefixLength; }];
|
||||
${vars.vmStorageInterface}.ipv4.addresses = [{ address = vars.haServer2StorageIp; prefixLength = vars.haStoragePrefixLength; }];
|
||||
${vars.vmStorageClientInterface}.ipv4.addresses = [{ address = vars.haServer2ClientIp; prefixLength = vars.haClientPrefixLength; }];
|
||||
};
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.vmLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = vars.nixCacheHost;
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.nixCacheIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Preserved from the pre-refactor `nix-cache` target — stateVersion must
|
||||
# never be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
_:
|
||||
|
||||
{
|
||||
# Preserves the hostname of the existing, already-deployed machine
|
||||
# (previously the flat `nix-minimal` target) — the flake attribute name
|
||||
# changed, the real machine's hostname did not.
|
||||
networking.hostName = "nix-minimal";
|
||||
|
||||
# Preserved from the pre-refactor `nix-minimal` target — stateVersion must
|
||||
# never be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{ config, pkgs, lib, vars, ... }:
|
||||
|
||||
{
|
||||
|
||||
imports = [
|
||||
../../modules/common/aliases.nix
|
||||
];
|
||||
|
||||
home = {
|
||||
username = vars.primaryUser;
|
||||
homeDirectory = "/home/${vars.primaryUser}";
|
||||
stateVersion = "25.05"; # match your NixOS stateVersion
|
||||
|
||||
# Optional: packages
|
||||
packages = with pkgs; [
|
||||
git
|
||||
vim
|
||||
tmux
|
||||
nextcloud-client
|
||||
# vscode
|
||||
chromium
|
||||
claude-code
|
||||
fish
|
||||
sops
|
||||
];
|
||||
|
||||
# Optional: set environment vars
|
||||
sessionVariables = {
|
||||
EDITOR = "nano";
|
||||
SOPS_AGE_KEY_FILE = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
|
||||
};
|
||||
|
||||
file = {
|
||||
".local/share/applications/proxmox-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Chromium)
|
||||
Exec=chromium --app=https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=${config.home.homeDirectory}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Chromium)
|
||||
Exec=chromium --app=https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=${config.home.homeDirectory}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
|
||||
'';
|
||||
".local/share/applications/proxmox-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Firefox)
|
||||
Exec=firefox --new-instance https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --profile ProxmoxWebApp --window-size=1920,1080 --class ProxmoxWebApp
|
||||
Icon=${config.home.homeDirectory}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Firefox)
|
||||
Exec=firefox --new-window https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --profile PbsWebApp --window-size=1920,1080 --class PbsWebApp
|
||||
Icon=${config.home.homeDirectory}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
StartupWMClass=PBS
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
programs.home-manager.enable = true; # mandatory to activate HM
|
||||
|
||||
# Optional: enable bash (or zsh, fish...)
|
||||
programs.bash.enable = true;
|
||||
services.nextcloud-client = {
|
||||
enable = true;
|
||||
# Optionally start in background directly
|
||||
startInBackground = true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
_:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../../modules/networking/wifi.nix
|
||||
];
|
||||
|
||||
networking.hostName = "nixos";
|
||||
|
||||
# Only needed now that baremetal-gui exists (ZFS root) -- harmless on the
|
||||
# ext4-rooted linode/proxmox/lxc-gui variants, so set unconditionally
|
||||
# rather than only on the baremetal platform.
|
||||
networking.hostId = "de6a9ffc";
|
||||
|
||||
# Preserved from the pre-refactor `nixos` target — stateVersion must never
|
||||
# be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "pxe-boot";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.pxeServerIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
services.beszel.agent.environment = {
|
||||
# KEY = "";
|
||||
};
|
||||
# Preserved from the pre-refactor `pxe-boot` target — stateVersion must
|
||||
# never be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "tailscale-router";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.tailscaleRouterIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts (server, docker) need one
|
||||
# for pool-import safety, and this host does neither.
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "tor-relay";
|
||||
useDHCP = false;
|
||||
interfaces.${vars.lxcLanInterface}.ipv4.addresses = [{
|
||||
address = vars.torRelayIp;
|
||||
prefixLength = vars.lanPrefixLength;
|
||||
}];
|
||||
defaultGateway = { address = vars.lanGateway; interface = vars.lxcLanInterface; };
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts need one for pool-import
|
||||
# safety, and this host does neither.
|
||||
|
||||
# A genuinely new host (not a pre-refactor carry-over), so it tracks the
|
||||
# flake's current nixpkgs release rather than being pinned to an older one.
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
# set root user password
|
||||
|
||||
$target = $1
|
||||
$configfile = $2
|
||||
|
||||
ssh-keygen -f "/home/wayne/.ssh/known_hosts" -R $target
|
||||
|
||||
|
||||
# copy ssh cert to server
|
||||
ssh-copy-id root@$target
|
||||
|
||||
# Copy cofiguration file
|
||||
scp ~/scripts/nixos/$configfile root@$target:/root/configuration.nix
|
||||
scp ~/scripts/nixos/prepare.sh root@$target:/root
|
||||
|
||||
# prepare server
|
||||
ssh root@$target 'bash /root/prepare.sh'
|
||||
@@ -0,0 +1,31 @@
|
||||
{ config, vars, ... }:
|
||||
|
||||
{
|
||||
# Universal token shared by all beszel agents. Add to secrets/common.yaml:
|
||||
# sops secrets/common.yaml
|
||||
# beszel-token: <value from the beszel hub UI>
|
||||
sops.secrets."beszel-token" = { };
|
||||
|
||||
sops.templates."beszel.env".content = ''
|
||||
TOKEN=${config.sops.placeholder."beszel-token"}
|
||||
'';
|
||||
|
||||
services.beszel.agent = {
|
||||
enable = true;
|
||||
environmentFile = config.sops.templates."beszel.env".path;
|
||||
environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
HUB_URL = "http://${vars.dockerHost}.${vars.homeDomain}:${toString vars.ports.beszelHub}";
|
||||
KEY = vars.beszelHubKey;
|
||||
};
|
||||
};
|
||||
|
||||
# The upstream module runs beszel-agent under DynamicUser with
|
||||
# ProtectSystem = "strict" and no StateDirectory, so /var/lib/beszel-agent
|
||||
# (where the agent persists its hub-pairing fingerprint, per
|
||||
# https://github.com/henrygd/beszel/discussions/1542) isn't writable --
|
||||
# every restart silently fails to save it and regenerates a fresh one in
|
||||
# memory, permanently desyncing from whatever the hub has on record after
|
||||
# the very first successful pairing. Give it real persistent storage.
|
||||
systemd.services.beszel-agent.serviceConfig.StateDirectory = "beszel-agent";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
_:
|
||||
|
||||
{
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{ pkgs, vars, ... }:
|
||||
|
||||
{
|
||||
# Pins the Docker Engine version, carried forward from the pre-refactor
|
||||
# `docker` target's inline pkgs overlay.
|
||||
nixpkgs.overlays = [
|
||||
(final: prev: {
|
||||
docker = prev.docker_29;
|
||||
docker_cli = prev.docker_29;
|
||||
})
|
||||
];
|
||||
|
||||
imports = [
|
||||
../docker/mount-data.nix
|
||||
../docker/enable-service.nix
|
||||
../docker/nextcloud-cron-job.nix
|
||||
../docker/docker-health-to-gotify.nix
|
||||
../traefik/rotate-logs.nix
|
||||
../raspi/mount-data.nix
|
||||
../services/enable-rpcbind.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
nfs-utils
|
||||
];
|
||||
|
||||
boot.supportedFilesystems = [ "nfs" ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"L+ /home/${vars.primaryUser}/docker - - - - ${vars.nfsShares.dockerConfig.mountpoint}"
|
||||
"d /mnt/docker 0755 ${vars.primaryUser} users -"
|
||||
"d ${vars.nfsShares.raspiVolumes.mountpoint} 0755 ${vars.primaryUser} users -"
|
||||
];
|
||||
|
||||
users.users.${vars.primaryUser}.extraGroups = [ "docker" ];
|
||||
services.openssh.settings.PermitRootLogin = "yes";
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
vars.ports.dockerHttp
|
||||
vars.ports.dockerExtra
|
||||
vars.ports.dockerHttps
|
||||
vars.ports.beszelHub
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{ config, pkgs, lib, inputs, vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../docker/enable-service.nix
|
||||
];
|
||||
|
||||
nixpkgs.overlays = [
|
||||
(final: prev: {
|
||||
docker = prev.docker_29;
|
||||
docker_cli = prev.docker_29;
|
||||
})
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
inputs.nixos-conf-editor.packages.${pkgs.stdenv.hostPlatform.system}.nixos-conf-editor
|
||||
nodejs
|
||||
appimage-run
|
||||
seahorse
|
||||
vscode
|
||||
p7zip
|
||||
popsicle # balena-etcher
|
||||
shotcut
|
||||
gimp
|
||||
pdfarranger
|
||||
terminator
|
||||
libreoffice-qt
|
||||
transmission_4-qt
|
||||
];
|
||||
|
||||
boot.loader.grub.useOSProber = true;
|
||||
programs.direnv.enable = true;
|
||||
services = {
|
||||
xserver = {
|
||||
enable = true;
|
||||
|
||||
displayManager = {
|
||||
lightdm.enable = true;
|
||||
sessionCommands = ''
|
||||
eval $(gnome-keyring-daemon --start --components=secrets,ssh)
|
||||
export SSH_AUTH_SOCK
|
||||
'';
|
||||
};
|
||||
|
||||
desktopManager.cinnamon.enable = true;
|
||||
|
||||
xkb = {
|
||||
layout = "au";
|
||||
variant = "";
|
||||
};
|
||||
};
|
||||
|
||||
printing.enable = true;
|
||||
|
||||
pipewire = {
|
||||
enable = true;
|
||||
alsa.enable = true;
|
||||
alsa.support32Bit = true;
|
||||
pulse.enable = true;
|
||||
};
|
||||
|
||||
xrdp = {
|
||||
enable = true;
|
||||
defaultWindowManager = "cinnamon-session";
|
||||
openFirewall = true;
|
||||
};
|
||||
|
||||
gnome.gnome-keyring.enable = true;
|
||||
};
|
||||
|
||||
security = {
|
||||
rtkit.enable = true;
|
||||
pam.services.login.enableGnomeKeyring = true;
|
||||
};
|
||||
|
||||
# The networkmanager group only exists when NM is actually enabled — the
|
||||
# lxc platform module force-disables it, so don't add the user to a group
|
||||
# that won't exist there.
|
||||
users.users.${vars.primaryUser}.extraGroups = lib.mkIf config.networking.networkmanager.enable [ "networkmanager" ];
|
||||
|
||||
programs.firefox.enable = true;
|
||||
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
# GUI-specific Home Manager additions for the IPA primary user, extending
|
||||
# the baseline in modules/ipa/client.nix with desktop apps and services
|
||||
# that only make sense on a graphical workstation.
|
||||
home-manager.users.${vars.ipaUser} = { pkgs, ... }: {
|
||||
home = {
|
||||
packages = with pkgs; [
|
||||
git
|
||||
vim
|
||||
nextcloud-client
|
||||
chromium
|
||||
claude-code
|
||||
fish
|
||||
sops
|
||||
];
|
||||
sessionVariables = {
|
||||
EDITOR = "nano";
|
||||
SOPS_AGE_KEY_FILE = "/home/${vars.ipaUser}/.config/sops/age/keys.txt";
|
||||
};
|
||||
file = {
|
||||
".local/share/applications/proxmox-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Chromium)
|
||||
Exec=chromium --app=https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-chromium-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Chromium)
|
||||
Exec=chromium --app=https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --window-size=1920,1080 --window-position=0,0
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
'';
|
||||
".local/share/applications/proxmox-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox (Firefox)
|
||||
Exec=firefox --new-instance https://pve.${vars.homeDomain}:${toString vars.ports.pveWeb} --profile ProxmoxWebApp --window-size=1920,1080 --class ProxmoxWebApp
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=Hypervisor;
|
||||
StartupWMClass=PVE
|
||||
'';
|
||||
".local/share/applications/pbs-firefox-app.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Proxmox Backup Server (Firefox)
|
||||
Exec=firefox --new-window https://${vars.pbsIp}:${toString vars.ports.pbsWeb} --profile PbsWebApp --window-size=1920,1080 --class PbsWebApp
|
||||
Icon=/home/${vars.ipaUser}/.local/share/icons/proxmox.png
|
||||
Terminal=false
|
||||
Categories=backup;
|
||||
StartupWMClass=PBS
|
||||
'';
|
||||
};
|
||||
};
|
||||
services.nextcloud-client = {
|
||||
enable = true;
|
||||
startInBackground = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# HA file server build type: DRBD + XFS + LIO iSCSI + NFS, managed by
|
||||
# Corosync + Pacemaker. Both ha-server-1 and ha-server-2 use this type.
|
||||
#
|
||||
# NFS start/stop:
|
||||
# services.nfs.server.enable = true configures /etc/exports, wires up
|
||||
# rpcbind, and loads kernel modules — but nfs-server.service.wantedBy is
|
||||
# force-cleared so systemd does NOT auto-start it at boot. Pacemaker's
|
||||
# ha-group resource group (configured by scripts/ha/cluster-init.sh)
|
||||
# starts and stops nfs-server as part of the failover sequence after the
|
||||
# XFS mount and iSCSI target are brought up on the new Active node.
|
||||
#
|
||||
# Beszel agent:
|
||||
# Enabled here via enable-agent.nix. The agent KEY (used to pair with
|
||||
# the Beszel hub) is not set yet — add it to hosts/ha-server-{1,2}/host.nix
|
||||
# under services.beszel.agent.environment.KEY once the hub accepts the
|
||||
# new agents, following the pattern in hosts/server/host.nix.
|
||||
{ lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
# Generates /etc/exports lines for all nfsShares data entries.
|
||||
# LAN (VLAN 2): NFS via vip-lan (192.168.2.229) for pxe-boot and other LAN clients.
|
||||
# Storage-client (VLAN 20): NFS via vip-storage (192.168.20.229) for docker and
|
||||
# future swarm nodes; firewall restricts these ports to haClientCidr only.
|
||||
mkNfsExports = storageRoot:
|
||||
lib.concatMapStrings
|
||||
(share:
|
||||
" ${storageRoot}/${share.subpath} ${vars.lanCidr}${vars.nfsShares.options}\n" +
|
||||
" ${storageRoot}/${share.subpath} ${vars.haClientCidr}${vars.nfsShares.options}\n")
|
||||
(lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares));
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../ha/pacemaker-stack.nix
|
||||
../ha/iscsi-target.nix
|
||||
../ha/cluster-config.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
# xfsprogs: mkfs.xfs/xfs_info needed by cluster-init.sh.
|
||||
# openiscsi: iscsiadm needed by acceptance-tests.sh T4 (iSCSI discovery check).
|
||||
environment.systemPackages = [ pkgs.xfsprogs pkgs.openiscsi ];
|
||||
|
||||
services.nfs.server = {
|
||||
enable = true;
|
||||
exports = mkNfsExports vars.haStorageRoot;
|
||||
};
|
||||
|
||||
# Pacemaker controls nfs-server — prevent systemd from starting it at boot
|
||||
# on both nodes (only the Active node should be serving NFS).
|
||||
systemd.services.nfs-server.wantedBy = lib.mkForce [ ];
|
||||
|
||||
# Same reason as server.nix: exports use standard auth, not Kerberos.
|
||||
systemd.services.rpc-svcgssd.enable = false;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{ lib, pkgs, config, vars, ... }:
|
||||
|
||||
{
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
# The networkmanager group only exists when NM is actually enabled — the
|
||||
# lxc platform module force-disables it, so don't add the user to a group
|
||||
# that won't exist there.
|
||||
users.users.${vars.primaryUser}.extraGroups = lib.mkIf config.networking.networkmanager.enable [ "networkmanager" ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
inetutils
|
||||
mtr
|
||||
sysstat
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../nix-cache/server.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
{ config, lib, pkgs, inputs, vars, ... }:
|
||||
|
||||
let
|
||||
pxeRoot = "/srv/pxe";
|
||||
httpRoot = "${pxeRoot}/http";
|
||||
tftpRoot = "${pxeRoot}/tftp";
|
||||
pxeBaseUrl = "http://${vars.pxeServerIp}";
|
||||
|
||||
# Base network address extracted from lanCidr (e.g. "192.168.2.0" from
|
||||
# "192.168.2.0/24") — used by dnsmasq's proxy DHCP range directive.
|
||||
lanBaseAddr = lib.head (lib.splitString "/" vars.lanCidr);
|
||||
|
||||
bootIpxe = pkgs.writeText "boot.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
dhcp
|
||||
echo Booting from PXE server...
|
||||
chain ${pxeBaseUrl}/menu.ipxe
|
||||
'';
|
||||
|
||||
autoexecIpxe = pkgs.writeText "autoexec.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
dhcp
|
||||
chain ${pxeBaseUrl}/boot.ipxe
|
||||
'';
|
||||
|
||||
debianRelease = "bookworm";
|
||||
debianMirror = "https://deb.debian.org/debian";
|
||||
debianNetbootBase = "${debianMirror}/dists/${debianRelease}/main/installer-amd64/current/images/netboot/debian-installer/amd64";
|
||||
|
||||
rockyRelease = "9";
|
||||
rockyArch = "x86_64";
|
||||
rockyMirror = "https://dl.rockylinux.org/pub/rocky/${rockyRelease}";
|
||||
rockyPxebootBase = "${rockyMirror}/BaseOS/${rockyArch}/os/images/pxeboot";
|
||||
|
||||
debianIpxe = pkgs.writeText "debian.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
kernel ''${base}/debian/linux
|
||||
initrd ''${base}/debian/initrd.gz
|
||||
boot
|
||||
'';
|
||||
|
||||
fetchDebianNetboot = pkgs.writeShellScript "fetch-debian-netboot" ''
|
||||
set -eu
|
||||
|
||||
dir="${httpRoot}/debian"
|
||||
mirror="${debianNetbootBase}"
|
||||
|
||||
if [ -f "$dir/linux" ] && [ -f "$dir/initrd.gz" ]; then
|
||||
echo "Debian ${debianRelease} netboot files already present; skipping download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading Debian ${debianRelease} netboot kernel and initrd from $mirror ..."
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/linux.tmp" "$mirror/linux"
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.gz.tmp" "$mirror/initrd.gz"
|
||||
mv "$dir/linux.tmp" "$dir/linux"
|
||||
mv "$dir/initrd.gz.tmp" "$dir/initrd.gz"
|
||||
echo "Debian ${debianRelease} netboot files staged."
|
||||
'';
|
||||
|
||||
# Rocky Linux 9 iPXE script — boots vmlinuz+initrd.img from the staged
|
||||
# /rocky/ directory and hands Anaconda the hosted Kickstart URL.
|
||||
# net.ifnames=0 biosdevname=0 ensures the NIC is eth0 in both the
|
||||
# installer and the installed system (matches the Kickstart NM config).
|
||||
rockyFreeIpaIpxe = pkgs.writeText "rocky-freeipa.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
kernel ''${base}/rocky/vmlinuz inst.ks=''${base}/rocky-freeipa.ks inst.repo=${rockyMirror}/BaseOS/${rockyArch}/os/ net.ifnames=0 biosdevname=0 ip=dhcp quiet
|
||||
initrd ''${base}/rocky/initrd.img
|
||||
boot
|
||||
'';
|
||||
|
||||
# Kickstart file for ${vars.ipaServer}.
|
||||
# Installs Rocky Linux 9, sets a static IP, creates ${vars.ipaUser} with
|
||||
# the admin SSH key, then on first reboot runs ipa-server-install via a
|
||||
# systemd oneshot service. Passwords are generated at %post time, written
|
||||
# to /root/ipa-credentials.txt (chmod 600), and read back by the
|
||||
# first-boot script — never hardcoded here or in the repo.
|
||||
rockyFreeIpaKs = pkgs.writeText "rocky-freeipa.ks" ''
|
||||
#version=RHEL9
|
||||
# Unattended Rocky Linux 9 + FreeIPA install
|
||||
# Target: ${vars.ipaServer} ${vars.domainControllerIp}
|
||||
|
||||
url --url=${rockyMirror}/BaseOS/${rockyArch}/os/
|
||||
repo --name=appstream --baseurl=${rockyMirror}/AppStream/${rockyArch}/os/
|
||||
|
||||
lang en_US.UTF-8
|
||||
keyboard us
|
||||
timezone UTC --utc
|
||||
|
||||
# DHCP during install; static IP configured in %post via NM config file
|
||||
network --bootproto=dhcp --device=link --activate
|
||||
network --hostname=${vars.ipaServer}
|
||||
|
||||
selinux --enforcing
|
||||
firewall --enabled --service=ssh
|
||||
|
||||
rootpw --lock
|
||||
user --name=${vars.ipaUser} --groups=wheel --shell=/bin/bash
|
||||
sshkey --username=${vars.ipaUser} "${vars.adminSshKey}"
|
||||
|
||||
zerombr
|
||||
clearpart --all --initlabel --drives=sda
|
||||
# Keep net.ifnames=0 biosdevname=0 in the installed GRUB so the NIC
|
||||
# stays eth0 after reboot (matches the NM connection file below).
|
||||
bootloader --location=mbr --boot-drive=sda --append="net.ifnames=0 biosdevname=0"
|
||||
|
||||
part /boot --fstype=xfs --size=1024 --ondisk=sda
|
||||
part swap --fstype=swap --size=2048 --ondisk=sda
|
||||
part / --fstype=xfs --grow --size=1 --ondisk=sda --asprimary
|
||||
|
||||
%packages
|
||||
@^minimal-environment
|
||||
ipa-server
|
||||
ipa-server-dns
|
||||
%end
|
||||
|
||||
reboot
|
||||
|
||||
%post --log=/root/ks-post.log
|
||||
set -euo pipefail
|
||||
|
||||
# -- Static IP: write NM connection file directly (NM not running in chroot) --
|
||||
mkdir -p /etc/NetworkManager/system-connections
|
||||
cat > /etc/NetworkManager/system-connections/eth0.nmconnection << 'NMCONN'
|
||||
[connection]
|
||||
id=eth0
|
||||
type=ethernet
|
||||
interface-name=eth0
|
||||
autoconnect=true
|
||||
|
||||
[ethernet]
|
||||
|
||||
[ipv4]
|
||||
method=manual
|
||||
addresses=${vars.domainControllerIp}/${toString vars.lanPrefixLength}
|
||||
gateway=${vars.lanGateway}
|
||||
dns=${vars.domainControllerIp};
|
||||
dns-search=${vars.homeDomain};
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
NMCONN
|
||||
chmod 600 /etc/NetworkManager/system-connections/eth0.nmconnection
|
||||
|
||||
# -- /etc/hosts: FQDN must resolve to the real IP (not loopback) for IPA --
|
||||
sed -i '/domain-controller/d' /etc/hosts
|
||||
echo '${vars.domainControllerIp} ${vars.ipaServer} domain-controller' >> /etc/hosts
|
||||
|
||||
# -- Generate IPA passwords and store securely --
|
||||
DM_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
|
||||
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
|
||||
printf 'Directory Manager: %s\nIPA Admin: %s\n' "$DM_PASS" "$ADMIN_PASS" \
|
||||
> /root/ipa-credentials.txt
|
||||
chmod 600 /root/ipa-credentials.txt
|
||||
|
||||
# -- First-boot script: reads passwords back, runs ipa-server-install --
|
||||
cat > /usr/local/sbin/freeipa-first-boot.sh << 'FIRSTBOOT'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec >> /root/freeipa-install.log 2>&1
|
||||
echo "=== FreeIPA first-boot install started at $(date) ==="
|
||||
|
||||
DM_PASS=$(grep '^Directory Manager:' /root/ipa-credentials.txt | awk '{print $NF}')
|
||||
ADMIN_PASS=$(grep '^IPA Admin:' /root/ipa-credentials.txt | awk '{print $NF}')
|
||||
|
||||
ipa-server-install \
|
||||
--realm=${lib.strings.toUpper vars.homeDomain} \
|
||||
--domain=${vars.homeDomain} \
|
||||
--hostname=${vars.ipaServer} \
|
||||
--ds-password="$DM_PASS" \
|
||||
--admin-password="$ADMIN_PASS" \
|
||||
--setup-dns \
|
||||
--forwarder=${vars.domainControllerIp} \
|
||||
--no-dnssec-validation \
|
||||
--no-ntp \
|
||||
--unattended
|
||||
|
||||
echo "=== FreeIPA install complete at $(date) ==="
|
||||
echo "Credentials: /root/ipa-credentials.txt (save to password manager)"
|
||||
echo "CA backup: /root/cacert.p12 (encrypted with Directory Manager password)"
|
||||
systemctl disable freeipa-first-boot.service
|
||||
FIRSTBOOT
|
||||
chmod 700 /usr/local/sbin/freeipa-first-boot.sh
|
||||
|
||||
# -- Systemd oneshot service: runs freeipa-first-boot.sh on first real boot --
|
||||
cat > /etc/systemd/system/freeipa-first-boot.service << 'UNIT'
|
||||
[Unit]
|
||||
Description=FreeIPA first-boot installation
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/root/ipa-credentials.txt
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/freeipa-first-boot.sh
|
||||
TimeoutStartSec=1800
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
mkdir -p /etc/systemd/system/multi-user.target.wants
|
||||
ln -sf /etc/systemd/system/freeipa-first-boot.service \
|
||||
/etc/systemd/system/multi-user.target.wants/freeipa-first-boot.service
|
||||
|
||||
echo "Kickstart %post complete. FreeIPA installs on first reboot (~20 min)."
|
||||
%end
|
||||
'';
|
||||
|
||||
fetchRockyPxeboot = pkgs.writeShellScript "fetch-rocky-pxeboot" ''
|
||||
set -eu
|
||||
|
||||
dir="${httpRoot}/rocky"
|
||||
base="${rockyPxebootBase}"
|
||||
|
||||
if [ -f "$dir/vmlinuz" ] && [ -f "$dir/initrd.img" ]; then
|
||||
echo "Rocky Linux ${rockyRelease} pxeboot files already present; skipping download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading Rocky Linux ${rockyRelease} pxeboot kernel and initrd from $base ..."
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/vmlinuz.tmp" "$base/vmlinuz"
|
||||
${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.img.tmp" "$base/initrd.img"
|
||||
mv "$dir/vmlinuz.tmp" "$dir/vmlinuz"
|
||||
mv "$dir/initrd.img.tmp" "$dir/initrd.img"
|
||||
echo "Rocky Linux ${rockyRelease} pxeboot files staged."
|
||||
'';
|
||||
|
||||
systemRescueIpxe = pkgs.writeText "systemrescue.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
kernel ''${base}/systemrescue/sysresccd/boot/x86_64/vmlinuz initrd=sysresccd.img archisobasedir=sysresccd archiso_http_srv=''${base}/systemrescue/ ip=dhcp checksum
|
||||
initrd ''${base}/systemrescue/sysresccd/boot/x86_64/sysresccd.img sysresccd.img
|
||||
boot
|
||||
'';
|
||||
|
||||
stageSystemRescue = pkgs.writeShellScript "stage-systemrescue" ''
|
||||
set -eu
|
||||
|
||||
iso="${httpRoot}/images/systemrescue.iso"
|
||||
staged="${httpRoot}/systemrescue"
|
||||
tmp="${httpRoot}/.systemrescue.tmp"
|
||||
previous="${httpRoot}/.systemrescue.previous"
|
||||
|
||||
if [ ! -e "$iso" ]; then
|
||||
echo "SystemRescue ISO not found at $iso; skipping staging."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -rf "$tmp"
|
||||
mkdir -p "$tmp"
|
||||
|
||||
${pkgs.libarchive}/bin/bsdtar -C "$tmp" -xf "$iso"
|
||||
|
||||
test -f "$tmp/sysresccd/boot/x86_64/vmlinuz"
|
||||
test -f "$tmp/sysresccd/boot/x86_64/sysresccd.img"
|
||||
chmod -R a+rX "$tmp"
|
||||
|
||||
rm -rf "$previous"
|
||||
if [ -e "$staged" ]; then
|
||||
mv "$staged" "$previous"
|
||||
fi
|
||||
|
||||
mv "$tmp" "$staged"
|
||||
rm -rf "$previous"
|
||||
'';
|
||||
|
||||
menuIpxe = pkgs.writeText "menu.ipxe" ''
|
||||
#!ipxe
|
||||
|
||||
set base ${pxeBaseUrl}
|
||||
|
||||
menu PXE Boot Menu
|
||||
item auto-installer NixOS Auto-Installer
|
||||
item nixos-minimal NixOS Minimal
|
||||
item debian Debian Minimal
|
||||
item rocky-freeipa FreeIPA Server (Rocky Linux 9)
|
||||
item rescue Rescue Environment
|
||||
item shell iPXE Shell
|
||||
item reboot Reboot
|
||||
|
||||
choose target && goto ''${target}
|
||||
|
||||
:auto-installer
|
||||
chain ''${base}/auto-installer/netboot.ipxe
|
||||
|
||||
:nixos-minimal
|
||||
chain ''${base}/nixos-minimal/netboot.ipxe
|
||||
|
||||
:debian
|
||||
chain ''${base}/debian.ipxe
|
||||
|
||||
:rocky-freeipa
|
||||
chain ''${base}/rocky-freeipa.ipxe
|
||||
|
||||
:rescue
|
||||
chain ''${base}/systemrescue.ipxe
|
||||
|
||||
:shell
|
||||
shell
|
||||
|
||||
:reboot
|
||||
reboot
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../pxe-boot/stage-installer-artifacts.nix
|
||||
../pxe-boot/mount-pxe-images.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
ipxe
|
||||
];
|
||||
|
||||
services = {
|
||||
nginx = {
|
||||
enable = true;
|
||||
|
||||
virtualHosts."pxe-boot" = {
|
||||
default = true;
|
||||
root = httpRoot;
|
||||
locations."/" = {
|
||||
extraConfig = ''
|
||||
autoindex on;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# TFTP is only used to deliver the initial iPXE bootloader. After iPXE
|
||||
# starts, all further assets are fetched via nginx over HTTP.
|
||||
atftpd = {
|
||||
enable = true;
|
||||
root = tftpRoot;
|
||||
extraOptions = [ "--verbose=5" ];
|
||||
};
|
||||
|
||||
openssh.settings.PermitRootLogin = "yes";
|
||||
};
|
||||
|
||||
systemd = {
|
||||
tmpfiles.rules = [
|
||||
"d ${pxeRoot} 0755 root root -"
|
||||
"d ${httpRoot} 0755 root root -"
|
||||
"L+ ${httpRoot}/images - - - - ${vars.nfsShares.pxebootImages.mountpoint}"
|
||||
"d ${httpRoot}/auto-installer 0755 root root -"
|
||||
"d ${httpRoot}/nixos-minimal 0755 root root -"
|
||||
"d ${httpRoot}/systemrescue 0755 root root -"
|
||||
"d ${httpRoot}/debian 0755 root root -"
|
||||
"d ${httpRoot}/ubuntu 0755 root root -"
|
||||
"d ${httpRoot}/rescue 0755 root root -"
|
||||
"d ${httpRoot}/rocky 0755 root root -"
|
||||
"d ${tftpRoot} 0755 root root -"
|
||||
"C+ ${httpRoot}/boot.ipxe 0644 root root - ${bootIpxe}"
|
||||
"C+ ${httpRoot}/menu.ipxe 0644 root root - ${menuIpxe}"
|
||||
"C+ ${httpRoot}/debian.ipxe 0644 root root - ${debianIpxe}"
|
||||
"C+ ${httpRoot}/rocky-freeipa.ipxe 0644 root root - ${rockyFreeIpaIpxe}"
|
||||
"C+ ${httpRoot}/rocky-freeipa.ks 0644 root root - ${rockyFreeIpaKs}"
|
||||
"C+ ${httpRoot}/systemrescue.ipxe 0644 root root - ${systemRescueIpxe}"
|
||||
"C+ ${tftpRoot}/autoexec.ipxe 0644 root root - ${autoexecIpxe}"
|
||||
"C+ ${tftpRoot}/ipxe.efi 0644 root root - ${pkgs.ipxe}/ipxe.efi"
|
||||
"C+ ${tftpRoot}/undionly.kpxe 0644 root root - ${pkgs.ipxe}/undionly.kpxe"
|
||||
];
|
||||
|
||||
services = {
|
||||
fetch-debian-netboot = {
|
||||
description = "Download Debian ${debianRelease} netboot kernel and initrd for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = fetchDebianNetboot;
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
};
|
||||
|
||||
fetch-rocky-pxeboot = {
|
||||
description = "Download Rocky Linux ${rockyRelease} pxeboot kernel and initrd for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = fetchRockyPxeboot;
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
};
|
||||
|
||||
stage-systemrescue = {
|
||||
description = "Stage SystemRescue ISO contents for HTTP PXE boot";
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = stageSystemRescue;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
settings = {
|
||||
# Disable DNS listener — only proxy DHCP is needed here.
|
||||
# Without this dnsmasq tries to bind port 53 which systemd-resolved
|
||||
# already owns, causing startup failure.
|
||||
port = 0;
|
||||
dhcp-range = [ "${lanBaseAddr},proxy" ];
|
||||
dhcp-match = [
|
||||
"set:ipxe,175"
|
||||
"set:efi64,option:client-arch,7"
|
||||
"set:efi64,option:client-arch,9"
|
||||
];
|
||||
dhcp-userclass = "set:ipxe,iPXE";
|
||||
dhcp-boot = [
|
||||
"tag:ipxe,tag:efi64,http://${vars.pxeServerIp}/boot.ipxe"
|
||||
"tag:ipxe,http://${vars.pxeServerIp}/boot.ipxe"
|
||||
"tag:efi64,ipxe.efi,,${vars.pxeServerIp}"
|
||||
"undionly.kpxe,,${vars.pxeServerIp}"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ vars.ports.pxeBootHttp ];
|
||||
networking.firewall.allowedUDPPorts = [ vars.ports.pxeBootTftp vars.ports.dhcp ];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../tailscale/subnet-router.nix
|
||||
../tailscale/ts-dns-forwarder.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
|
||||
# "server", not "both": this build type advertises LAN subnet routes but
|
||||
# doesn't use another tailscale exit node itself, so it doesn't need the
|
||||
# "client"-side loose reverse-path filtering that "both" would also enable.
|
||||
# Deliberately kept explicit here (not just relying on subnet-router.nix's
|
||||
# own setting) so the intent is clear at the build-type level.
|
||||
services.tailscale.useRoutingFeatures = "server";
|
||||
|
||||
# Advertise the LAN subnet so Tailscale peers can route back to LAN machines.
|
||||
# Must also be approved in the Tailscale admin console (Machines → Edit route settings).
|
||||
services.tailscale.extraUpFlags = [ "--advertise-routes=${vars.lanCidr}" ];
|
||||
|
||||
networking.firewall = {
|
||||
# Forwarded subnet-router traffic arrives on tailscale0 already
|
||||
# tailscale-authenticated -- the firewall's normal per-port allow-list
|
||||
# would otherwise drop it. Standard NixOS/Tailscale subnet-router guidance.
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
|
||||
# SNAT LAN traffic going into Tailscale so the remote peer sees it as
|
||||
# coming from this router's Tailscale IP rather than a raw LAN IP.
|
||||
# Without this, Tailscale drops forwarded packets whose source is not a
|
||||
# recognised Tailscale address.
|
||||
#
|
||||
# We target POSTROUTING directly (always-existing built-in chain) rather
|
||||
# than nixos-nat-post: extraCommands runs after the old nixos-nat-post is
|
||||
# deleted but before the new one is created, so -A nixos-nat-post silently
|
||||
# fails. The -C check makes the rule idempotent across firewall reloads.
|
||||
extraCommands = ''
|
||||
iptables -t nat -C POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE 2>/dev/null || \
|
||||
iptables -t nat -A POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE
|
||||
'';
|
||||
extraStopCommands = ''
|
||||
iptables -t nat -D POSTROUTING -s ${vars.lanCidr} -o tailscale0 -j MASQUERADE 2>/dev/null || true
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
../tor/enable-relay.nix
|
||||
../beszel/enable-agent.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{ pkgs, ... }: {
|
||||
# Defines the SSH host key as a clan vars generator so that:
|
||||
# - `clan vars generate <target>` creates and encrypts the key pair
|
||||
# - The private key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key/secret
|
||||
# (sops binary-encrypted, admin-key-only; decrypted by the build script)
|
||||
# - The public key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key.pub/value
|
||||
# (plaintext; used by sync-host-keys.sh to derive the sops age fingerprint)
|
||||
#
|
||||
# neededFor = "activation" means clan's deployment tool would upload this
|
||||
# before running nixos-rebuild/nixos-install (for VM/baremetal via
|
||||
# nixos-anywhere). For lxc-* hosts, the build script bakes it into the
|
||||
# tarball directly via NIXOS_HOST_KEYS_DIR -- the neededFor value here
|
||||
# simply ensures it is NOT mapped to sops.secrets (which would try to
|
||||
# decrypt it at runtime as a regular service secret, which is wrong: the
|
||||
# SSH host key reaches the container via the tarball, not sops).
|
||||
clan.core.vars.generators.openssh = {
|
||||
files."ssh_host_ed25519_key" = {
|
||||
secret = true;
|
||||
neededFor = "activation";
|
||||
};
|
||||
files."ssh_host_ed25519_key.pub" = {
|
||||
secret = false;
|
||||
neededFor = "activation";
|
||||
};
|
||||
runtimeInputs = [ pkgs.openssh ];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out/ssh_host_ed25519_key"
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
_:
|
||||
|
||||
{
|
||||
# Switch-nix, Test-nix, and buildImage are defined system-wide in
|
||||
# modules/common/configuration.nix so all users (including IPA accounts)
|
||||
# get them. Add any Home-Manager-only per-user shell config here.
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{ config, lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
switchCmd = ''
|
||||
sudo nixos-rebuild switch \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.giteaDomain}/${vars.giteaRepoPath}.git#$(cat /etc/flake-target)
|
||||
'';
|
||||
testCmd = ''
|
||||
sudo nixos-rebuild test \
|
||||
--no-write-lock-file \
|
||||
--refresh \
|
||||
--flake git+https://${vars.giteaDomain}/${vars.giteaRepoPath}.git#$(cat /etc/flake-target)
|
||||
'';
|
||||
buildImageFn = ''
|
||||
buildImage() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "usage: buildImage <flake-target> (e.g. lxc-docker)" >&2
|
||||
return 1
|
||||
fi
|
||||
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
|
||||
".#nixosConfigurations.$1.config.system.build.tarball"
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./set-locale.nix
|
||||
../ipa/client.nix
|
||||
];
|
||||
|
||||
# System-wide shell config so all users (including IPA accounts) get the
|
||||
# same management aliases as the local nixos user's Home Manager provides.
|
||||
programs.bash = {
|
||||
shellAliases = {
|
||||
"Switch-nix" = switchCmd;
|
||||
"Test-nix" = testCmd;
|
||||
};
|
||||
interactiveShellInit = buildImageFn;
|
||||
};
|
||||
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
# Recommended over the true default (bypasses ZFS's own import safeguards)
|
||||
# per the option's own docs; matches hosts/docker/host.nix and
|
||||
# modules/services/zfs/enable-service.nix. Harmless no-op on hosts without ZFS.
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
time.timeZone = vars.timeZone;
|
||||
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
vim
|
||||
btop
|
||||
git
|
||||
gcr
|
||||
jq
|
||||
];
|
||||
|
||||
# Secrets shared by every host, decrypted at activation via each host's
|
||||
# SSH host key (sops-nix derives the age key from
|
||||
# /etc/ssh/ssh_host_ed25519_key automatically). hashedPassword secrets need
|
||||
# neededForUsers so they're available before the normal secret-activation
|
||||
# step — user creation happens very early in boot.
|
||||
sops = {
|
||||
defaultSopsFile = ../../secrets/common.yaml;
|
||||
|
||||
secrets = {
|
||||
"root-hashedPassword".neededForUsers = true;
|
||||
"nixos-hashedPassword".neededForUsers = true;
|
||||
"nix-github-token" = { };
|
||||
};
|
||||
|
||||
# nix.conf has no *File-style option for access-tokens, so the token is
|
||||
# rendered into a runtime-only file (never touches the Nix store) and
|
||||
# pulled in via nix.conf's native !include directive.
|
||||
templates."nix-github-token.conf".content = ''
|
||||
access-tokens = github.com=${config.sops.placeholder."nix-github-token"}
|
||||
'';
|
||||
};
|
||||
|
||||
nix.extraOptions = ''
|
||||
!include ${config.sops.templates."nix-github-token.conf".path}
|
||||
'';
|
||||
|
||||
users = {
|
||||
# mutableUsers = false makes update-users-groups.pl enforce hashedPasswordFile
|
||||
# on every activation, not just on newly-created accounts. Without this, a
|
||||
# freshly-built proxmox disk image (activation runs without a usable sops key,
|
||||
# so both accounts land in shadow with '!') will never have its passwords fixed
|
||||
# by subsequent boots.
|
||||
mutableUsers = false;
|
||||
|
||||
users.root = {
|
||||
hashedPasswordFile = config.sops.secrets."root-hashedPassword".path;
|
||||
};
|
||||
|
||||
users.${vars.primaryUser} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
packages = with pkgs; [ tree ];
|
||||
hashedPasswordFile = config.sops.secrets."nixos-hashedPassword".path;
|
||||
openssh.authorizedKeys.keys = [ vars.adminSshKey ] ++ vars.extraAdminSshKeys;
|
||||
};
|
||||
};
|
||||
|
||||
services.openssh.enable = true;
|
||||
|
||||
nix.settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
auto-optimise-store = true;
|
||||
};
|
||||
|
||||
programs.git = {
|
||||
enable = true;
|
||||
package = pkgs.git;
|
||||
config.credential.helper = "store";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{ config, pkgs, lib, vars, ... }:
|
||||
|
||||
let
|
||||
remote = "root@proxmox-ip:/var/lib/vz/template/iso";
|
||||
localMount = "${config.home.homeDirectory}/proxmox-iso";
|
||||
in
|
||||
{
|
||||
|
||||
imports = [
|
||||
./aliases.nix
|
||||
];
|
||||
|
||||
home = {
|
||||
username = vars.primaryUser;
|
||||
homeDirectory = "/home/${vars.primaryUser}";
|
||||
stateVersion = "25.11"; # match your NixOS stateVersion
|
||||
|
||||
# Optional: packages
|
||||
packages = with pkgs; [
|
||||
git
|
||||
vim
|
||||
tmux
|
||||
nano
|
||||
sshfs
|
||||
];
|
||||
|
||||
# Optional: set environment vars
|
||||
sessionVariables = {
|
||||
EDITOR = "nano";
|
||||
};
|
||||
};
|
||||
|
||||
programs.home-manager.enable = true; # mandatory to activate HM
|
||||
|
||||
programs.bash.enable = true;
|
||||
|
||||
# GitHub access-tokens setting used to live here in plaintext; it's now
|
||||
# rendered system-wide from a sops-nix secret via nix.extraOptions in
|
||||
# modules/common/configuration.nix instead (covers the daemon for every
|
||||
# user, not just this one).
|
||||
|
||||
# systemd.user.services.mount-proxmox-iso = {
|
||||
# Unit = {
|
||||
# Description = "Mount Proxmox ISO dir via SSHFS";
|
||||
# After = [ "network-online.target" ];
|
||||
# Wants = [ "network-online.target" ];
|
||||
# };
|
||||
|
||||
# Service = {
|
||||
# Type = "simple";
|
||||
# ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p ${localMount}";
|
||||
# ExecStart = "${pkgs.sshfs}/bin/sshfs -o IdentityFile=${config.home.homeDirectory}/.ssh/id_ed25519,allow_other,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 root@proxmox-ip:/var/lib/vz/template/iso ${localMount}";
|
||||
# ExecStop = "${pkgs.fuse3}/bin/fusermount3 -u ${localMount}";
|
||||
# Restart = "on-failure";
|
||||
# };
|
||||
|
||||
# Install = {
|
||||
# WantedBy = [ "default.target" ];
|
||||
# };
|
||||
# };
|
||||
# Optional: enable bash (or zsh, fish...)
|
||||
# programs.bash.enable = true;
|
||||
|
||||
# Optional: manage dotfiles via symlinks
|
||||
# home.file = {
|
||||
# ".tmux.conf".source = ./dotfiles/tmux.conf;
|
||||
# ".config/nvim/init.vim".source = ./dotfiles/init.vim;
|
||||
# };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared activation-script logic to preserve the SSH host key across
|
||||
# nixos-rebuild on platforms that embed the key via environment.etc (lxc and
|
||||
# proxmox). When NIXOS_HOST_KEYS_DIR is not set the key is absent from
|
||||
# environment.etc, and NixOS's etc activation removes any /etc file not in
|
||||
# the new generation — which would destroy the live key and break sops-nix
|
||||
# decryption permanently. These scripts save the key to /run before etc
|
||||
# removes it, then restore it afterward.
|
||||
#
|
||||
# Explicit deps enforce the correct ordering: without them the topological
|
||||
# sort places preserveSshHostKey after etc (confirmed live on lxc-tor-relay:
|
||||
# position 7 vs etc's position 5), so the key is gone before it can be saved.
|
||||
_: {
|
||||
system.activationScripts = {
|
||||
preserveSshHostKey = ''
|
||||
if [ -f /etc/ssh/ssh_host_ed25519_key ]; then
|
||||
cp /etc/ssh/ssh_host_ed25519_key /run/sshd-host-key-preserve.tmp
|
||||
cp /etc/ssh/ssh_host_ed25519_key.pub /run/sshd-host-key-preserve.pub.tmp
|
||||
fi
|
||||
'';
|
||||
|
||||
restoreSshHostKey = {
|
||||
deps = [ "etc" ];
|
||||
text = ''
|
||||
if [ ! -f /etc/ssh/ssh_host_ed25519_key ] && [ -f /run/sshd-host-key-preserve.tmp ]; then
|
||||
install -m 0600 /run/sshd-host-key-preserve.tmp /etc/ssh/ssh_host_ed25519_key
|
||||
install -m 0644 /run/sshd-host-key-preserve.pub.tmp /etc/ssh/ssh_host_ed25519_key.pub
|
||||
fi
|
||||
rm -f /run/sshd-host-key-preserve.tmp /run/sshd-host-key-preserve.pub.tmp
|
||||
'';
|
||||
};
|
||||
|
||||
etc = { deps = [ "preserveSshHostKey" ]; };
|
||||
setupSecrets = { deps = [ "restoreSshHostKey" ]; };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
_:
|
||||
|
||||
{
|
||||
i18n.defaultLocale = "en_AU.UTF-8";
|
||||
|
||||
i18n.extraLocaleSettings = {
|
||||
LC_ADDRESS = "en_AU.UTF-8";
|
||||
LC_IDENTIFICATION = "en_AU.UTF-8";
|
||||
LC_MEASUREMENT = "en_AU.UTF-8";
|
||||
LC_MONETARY = "en_AU.UTF-8";
|
||||
LC_NAME = "en_AU.UTF-8";
|
||||
LC_NUMERIC = "en_AU.UTF-8";
|
||||
LC_PAPER = "en_AU.UTF-8";
|
||||
LC_TELEPHONE = "en_AU.UTF-8";
|
||||
LC_TIME = "en_AU.UTF-8";
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
# ZFS RAID0 (striped, no redundancy) root pool for the bare-metal gui
|
||||
# host — two disks, each contributing its own top-level vdev. disko's
|
||||
# zpool `mode` defaults to "" (plain stripe) when left unset, which is
|
||||
# what gives RAID0 semantics here rather than mirror/raidz.
|
||||
#
|
||||
# Device paths are placeholders until the real hardware profile lands —
|
||||
# fill in vars.guiRootDisk1/guiRootDisk2 (stable /dev/disk/by-id/...
|
||||
# paths, not /dev/sdX) before running disko against real hardware. Swap
|
||||
# is deliberately left out for now — sizing that sensibly needs the
|
||||
# box's actual RAM size, which comes with the hardware profile too.
|
||||
#
|
||||
# Not yet imported anywhere: this awaits the new bare-metal platform
|
||||
# module (alongside modules/boot/efi.nix for systemd-boot, matching
|
||||
# modules/platforms/proxmox.nix's pattern) once the hardware config is
|
||||
# in hand.
|
||||
disko.devices = {
|
||||
disk = {
|
||||
disk1 = {
|
||||
type = "disk";
|
||||
device = vars.guiRootDisk1;
|
||||
|
||||
content = {
|
||||
type = "gpt";
|
||||
|
||||
partitions = {
|
||||
esp = {
|
||||
priority = 1;
|
||||
name = "ESP";
|
||||
size = "512M";
|
||||
type = "EF00";
|
||||
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot";
|
||||
mountOptions = [ "umask=0077" ];
|
||||
};
|
||||
};
|
||||
|
||||
zfs = {
|
||||
size = "100%";
|
||||
|
||||
content = {
|
||||
type = "zfs";
|
||||
pool = "rpool";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
disk2 = {
|
||||
type = "disk";
|
||||
device = vars.guiRootDisk2;
|
||||
|
||||
content = {
|
||||
type = "gpt";
|
||||
|
||||
partitions = {
|
||||
zfs = {
|
||||
size = "100%";
|
||||
|
||||
content = {
|
||||
type = "zfs";
|
||||
pool = "rpool";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
zpool.rpool = {
|
||||
type = "zpool";
|
||||
|
||||
rootFsOptions = {
|
||||
compression = "zstd";
|
||||
"com.sun:auto-snapshot" = "false";
|
||||
};
|
||||
mountpoint = "/";
|
||||
options.ashift = "12";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
_:
|
||||
|
||||
{
|
||||
# Linode provisions and sizes these disks itself (via the Linode
|
||||
# dashboard/API) before the OS ever boots, and presents them as whole,
|
||||
# unpartitioned block devices — /dev/sda is the root filesystem directly,
|
||||
# /dev/sdb is swap directly, no partition table on either. Nothing here
|
||||
# should ever repartition or resize them:
|
||||
# - `destroy = false` skips each disk entirely during disko's destroy
|
||||
# stage (see disko's disk.destroy option) — no wipefs, ever.
|
||||
# - the filesystem content type's own create step only runs mkfs if the
|
||||
# device isn't already formatted (checked via `blkid`), so re-running
|
||||
# this against an already-provisioned Linode disk is a no-op.
|
||||
disko.devices.disk = {
|
||||
main = {
|
||||
device = "/dev/sda";
|
||||
destroy = false;
|
||||
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
};
|
||||
};
|
||||
|
||||
swap = {
|
||||
device = "/dev/sdb";
|
||||
destroy = false;
|
||||
|
||||
content = {
|
||||
type = "swap";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{ config, vars, ... }:
|
||||
|
||||
{
|
||||
disko.devices = {
|
||||
disk.main = {
|
||||
type = "disk";
|
||||
device = "/dev/sda";
|
||||
|
||||
# Only used when building a standalone disk image directly (`nix build
|
||||
# .#nixosConfigurations.<host>.config.system.build.diskoImagesScript`)
|
||||
# rather than formatting a real device — see docs/proxmox-images.md.
|
||||
# imageSize sets the .raw file's total size (root's "100%" below fills
|
||||
# whatever's left after ESP + swap within it); imageName keeps each
|
||||
# host's image distinctly named instead of every proxmox-* host
|
||||
# producing an identical "main.raw".
|
||||
imageSize = vars.proxmoxImageSize;
|
||||
imageName = config.networking.hostName;
|
||||
|
||||
content = {
|
||||
type = "gpt";
|
||||
|
||||
partitions = {
|
||||
esp = {
|
||||
priority = 1;
|
||||
name = "ESP";
|
||||
size = "512M";
|
||||
type = "EF00";
|
||||
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot";
|
||||
mountOptions = [ "umask=0077" ];
|
||||
extraArgs = [
|
||||
"-F"
|
||||
"32"
|
||||
"-n"
|
||||
"boot"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
swap = {
|
||||
size = "8G";
|
||||
|
||||
content = {
|
||||
type = "swap";
|
||||
randomEncryption = false;
|
||||
# label = "swap";
|
||||
};
|
||||
};
|
||||
|
||||
root = {
|
||||
size = "100%";
|
||||
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
extraArgs = [
|
||||
"-L"
|
||||
"nixos"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{ pkgs, vars, ... }:
|
||||
|
||||
{
|
||||
systemd.services.docker-health-to-gotify = {
|
||||
description = "Alert Gotify when Docker containers become unhealthy";
|
||||
after = [ "docker.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
# Run as root so it can read /etc/secrets and access docker socket
|
||||
# User = "root";
|
||||
#EnvironmentFile = "-/etc/secrets/docker-health-alert.env";
|
||||
ExecStart = "${pkgs.bash}/bin/bash /home/${vars.primaryUser}/docker/monitoring/gotify/docker-health-to-gotify.sh";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
};
|
||||
path = with pkgs; [ docker curl coreutils gnused ];
|
||||
};
|
||||
|
||||
systemd.timers.docker-health-to-gotify = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "1min";
|
||||
AccuracySec = "15s";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{ lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
gid = toString vars.dockerAccessGid;
|
||||
in
|
||||
{
|
||||
virtualisation.docker = {
|
||||
enable = true;
|
||||
package = pkgs.docker;
|
||||
};
|
||||
# Pin the docker group GID to match the IPA "docker-access" group so that
|
||||
# IPA group membership alone grants access to the Docker socket. Any user
|
||||
# whose supplementary groups (resolved by SSSD from IPA) include GID
|
||||
# vars.dockerAccessGid will pass the socket group-permission check without
|
||||
# any per-host users.groups.docker.members entry.
|
||||
users.groups.docker.gid = lib.mkForce vars.dockerAccessGid;
|
||||
users.users.${vars.primaryUser}.extraGroups = [ "docker" ];
|
||||
environment.systemPackages = with pkgs; [
|
||||
docker-compose
|
||||
docker-buildx
|
||||
];
|
||||
|
||||
# NixOS's group activation uses plain `groupmod` without --non-unique.
|
||||
# When SSSD is active it exposes the IPA "docker-access" group at
|
||||
# vars.dockerAccessGid via NSS, so groupmod sees that GID as already in
|
||||
# use and silently skips the change (warning: "not applying GID change").
|
||||
# This script runs after the normal "groups" step and applies the change
|
||||
# with --non-unique (which lets the local docker group share the GID with
|
||||
# the SSSD-provided IPA group). If the GID actually changed it also
|
||||
# restarts docker.socket so the socket is recreated with the new GID.
|
||||
system.activationScripts.docker-group-gid = {
|
||||
deps = [ "groups" ];
|
||||
text = ''
|
||||
current=$(grep "^docker:" /etc/group | cut -d: -f3)
|
||||
if [ "$current" != "${gid}" ]; then
|
||||
${pkgs.shadow}/bin/groupmod --non-unique -g ${gid} docker
|
||||
if ${pkgs.systemd}/bin/systemctl is-active --quiet docker.socket; then
|
||||
${pkgs.systemd}/bin/systemctl stop docker.service docker.socket
|
||||
rm -f /var/run/docker.sock
|
||||
${pkgs.systemd}/bin/systemctl start docker.socket docker.service
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{ config, lib, pkgs, vars, ... }:
|
||||
|
||||
let
|
||||
# `x-systemd.automount` never works inside a Linux container (LXC
|
||||
# included, regardless of privilege) -- confirmed live on lxc-docker:
|
||||
# systemd logs "Starting of <unit>.automount unsupported" for every
|
||||
# share and never mounts them. Mount eagerly there instead, with
|
||||
# `nofail` so a boot with the NFS server unreachable doesn't hang
|
||||
# (the VM platforms rely on automount itself to get that same
|
||||
# non-blocking behavior, so they don't need `nofail` too).
|
||||
automountOpts = if config.boot.isContainer then [ "nofail" ] else [ "x-systemd.automount" ];
|
||||
|
||||
# FQDN in the storage.home zone — resolves to the Pacemaker vip-storage
|
||||
# (192.168.20.229) on docker's eth1/vmbr2 interface. Using the DNS name
|
||||
# rather than the raw IP means a future VIP renumber only requires a DNS
|
||||
# update, not a NixOS rebuild. The storage.home zone is served by the same
|
||||
# FreeIPA nameserver (domainControllerIp) that docker already uses, so
|
||||
# resolution reaches it over eth0 without any extra routing.
|
||||
nfsServer = vars.haStorageNfsFqdn;
|
||||
storageRoot = vars.haStorageRoot;
|
||||
in
|
||||
{
|
||||
fileSystems = {
|
||||
${vars.nfsShares.dockerConfig.mountpoint} = {
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerConfig.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
"nfsvers=4.2"
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ automountOpts;
|
||||
};
|
||||
|
||||
${vars.nfsShares.dockerDatabases.mountpoint} = {
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerDatabases.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
"nfsvers=4.2"
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ automountOpts;
|
||||
};
|
||||
|
||||
${vars.nfsShares.dockerVolumes.mountpoint} = {
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
"nfsvers=4.2"
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ automountOpts;
|
||||
};
|
||||
|
||||
${vars.nfsShares.nextcloudData.mountpoint} = {
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.nextcloudData.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
"nfsvers=4.2"
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ automountOpts;
|
||||
};
|
||||
|
||||
${vars.nfsShares.raspiVolumes.mountpoint} = {
|
||||
device = "${nfsServer}:${storageRoot}/${vars.nfsShares.raspiVolumes.subpath}";
|
||||
fsType = "nfs";
|
||||
|
||||
options = [
|
||||
"nfsvers=4.2"
|
||||
"_netdev"
|
||||
"noatime"
|
||||
] ++ automountOpts;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{ pkgs, vars, ... }:
|
||||
|
||||
{
|
||||
# Create nextcloud cron scheduled task
|
||||
systemd.services.nextcloud = {
|
||||
description = "Nextcloud scheduled task";
|
||||
script = ''${pkgs.bash}/bin/bash ~/docker/services-up.sh --profile nextcloud exec -u 33 nextcloud-webapp php ./cron.php'';
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = vars.primaryUser;
|
||||
};
|
||||
path = with pkgs; [ docker docker-compose ];
|
||||
};
|
||||
|
||||
systemd.timers.nextcloud = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "*:0/5";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# Cluster-wide HA config shared by both ha-server nodes.
|
||||
#
|
||||
# Covers everything that is identical on both nodes and references cluster
|
||||
# topology (node IPs, hostnames, DRBD resource). Per-node identity
|
||||
# (hostname, static IP, stateVersion) lives in hosts/ha-server-{1,2}/host.nix.
|
||||
#
|
||||
# Corosync authkey:
|
||||
# /etc/corosync/authkey (mode 0400) is managed by sops-nix below.
|
||||
# Bootstrap: run scripts/ha/cluster-init.sh on node1 to generate the key,
|
||||
# then encrypt it with: sops -e --input-type binary /etc/corosync/authkey > secrets/ha-corosync-authkey
|
||||
# Both host keys must be registered via sync-host-keys.sh first so both nodes can decrypt it.
|
||||
#
|
||||
# DRBD fencing:
|
||||
# resource-only with crm-fence-peer.sh: DRBD calls the Pacemaker-aware
|
||||
# crm-fence-peer.sh handler before promoting. The handler checks the CIB
|
||||
# to confirm the peer's DRBD resource is stopped and returns 7 (successfully
|
||||
# fenced), allowing safe promotion without requiring power-fencing (STONITH).
|
||||
# The unfence handler crm-unfence-peer.sh clears the outdate flag when the
|
||||
# peer reconnects. This is the correct setting for Pacemaker+DRBD clusters
|
||||
# with STONITH disabled; crm-fence-peer.sh replaces the need for a separate
|
||||
# STONITH device during the testing phase. Switch to resource-and-stonith
|
||||
# once the fence_pve_ssh STONITH resource is active (see
|
||||
# scripts/ha/cluster-enable-stonith.sh).
|
||||
#
|
||||
# PATH wrapper: when the DRBD kernel module invokes the fence-peer handler
|
||||
# via the UMH (User Mode Helper) mechanism it provides a minimal PATH that
|
||||
# omits /run/current-system/sw/bin. crm-fence-peer.sh calls cibadmin,
|
||||
# crm_mon etc.; if those aren't found a pipeline in the script breaks with
|
||||
# SIGPIPE. A process killed by signal has WEXITSTATUS() == 0, so the kernel
|
||||
# sees exit code 0 and logs "fence-peer helper broken, returned 0", looping
|
||||
# forever. The writeShellScript wrappers below prepend the NixOS sw path
|
||||
# before exec-ing the real handler, giving it a working Pacemaker toolchain.
|
||||
{ lib, pkgs, vars, ... }:
|
||||
let
|
||||
fencePeerWrapper = pkgs.writeShellScript "drbd-fence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-fence-peer.sh "$@"
|
||||
'';
|
||||
unfencePeerWrapper = pkgs.writeShellScript "drbd-unfence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-unfence-peer.sh "$@"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Root SSH access — same key set as the nixos user so all admin keys can reach root.
|
||||
users.users.root.openssh.authorizedKeys.keys = [ vars.adminSshKey ] ++ vars.extraAdminSshKeys;
|
||||
|
||||
# Passwordless sudo for wheel — operator SSHes as nixos and uses sudo for
|
||||
# cluster management commands (drbdadm, crm*, pcs, etc.)
|
||||
security.sudo.wheelNeedsPassword = lib.mkForce false;
|
||||
|
||||
# DRBD lock-file directory (drbd-utils checks for it; missing → harmless but noisy warnings).
|
||||
systemd.tmpfiles.rules = [ "d /var/lib/drbd 0750 root root -" ];
|
||||
|
||||
# Prevent drbd.service from auto-starting at boot / nixos-rebuild switch.
|
||||
# Pacemaker's OCF drbd agent calls drbdadm up/down directly when managing
|
||||
# the resource. If drbd.service also runs drbdadm up all while DRBD is
|
||||
# already Primary under Pacemaker, apply-al fails with "device busy" (exit 20).
|
||||
systemd.services.drbd.wantedBy = lib.mkForce [ ];
|
||||
|
||||
services.drbd = {
|
||||
enable = true;
|
||||
config = ''
|
||||
global {
|
||||
usage-count yes;
|
||||
}
|
||||
|
||||
common {
|
||||
net {
|
||||
protocol C;
|
||||
ping-int 1;
|
||||
verify-alg sha256;
|
||||
after-sb-0pri discard-zero-changes;
|
||||
after-sb-1pri discard-secondary;
|
||||
}
|
||||
disk {
|
||||
fencing resource-only;
|
||||
}
|
||||
handlers {
|
||||
fence-peer "${fencePeerWrapper}";
|
||||
unfence-peer "${unfencePeerWrapper}";
|
||||
}
|
||||
}
|
||||
|
||||
resource ha-data {
|
||||
volume 0 {
|
||||
device /dev/drbd0;
|
||||
disk ${vars.haServerDrbdDisk};
|
||||
meta-disk internal;
|
||||
}
|
||||
|
||||
on ${vars.haServer1Host} {
|
||||
address ${vars.haServer1StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
}
|
||||
|
||||
on ${vars.haServer2Host} {
|
||||
address ${vars.haServer2StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# /etc/corosync/authkey — sops binary secret, identical on both nodes.
|
||||
# Decryptable by both ha-server host keys (added by sync-host-keys.sh).
|
||||
sops.secrets.corosync_authkey = {
|
||||
sopsFile = ../../secrets/ha-corosync-authkey;
|
||||
format = "binary";
|
||||
path = "/etc/corosync/authkey";
|
||||
mode = "0400";
|
||||
restartUnits = [ "corosync.service" ];
|
||||
};
|
||||
|
||||
# NixOS common config enables NetworkManager by default; HA cluster nodes
|
||||
# need stable static IPs with predictable interface names — NM is not suitable.
|
||||
networking.networkmanager.enable = lib.mkForce false;
|
||||
|
||||
# services.corosync.enable is set by modules/ha/pacemaker-stack.nix.
|
||||
services.corosync = {
|
||||
clusterName = "ha-cluster";
|
||||
nodelist = [
|
||||
# ring0: cluster-internal vmbr1 (primary heartbeat + DRBD path)
|
||||
# ring1: LAN vmbr0 (backup heartbeat only — never carries DRBD)
|
||||
{ nodeid = 1; name = vars.haServer1Host; ring_addrs = [ vars.haServer1StorageIp vars.haServer1Ip ]; }
|
||||
{ nodeid = 2; name = vars.haServer2Host; ring_addrs = [ vars.haServer2StorageIp vars.haServer2Ip ]; }
|
||||
];
|
||||
};
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [
|
||||
vars.ports.haServerPacemakerRemoted
|
||||
vars.ports.haServerPcsd
|
||||
vars.ports.haServerDrbd
|
||||
];
|
||||
allowedUDPPorts = [
|
||||
vars.ports.haServerCorosync1
|
||||
vars.ports.haServerCorosync2
|
||||
vars.ports.haServerCorosyncCrypto
|
||||
];
|
||||
# Protocol separation: iSCSI (VLAN 20 / storage clients only),
|
||||
# NFS (VLAN 2 / LAN only). Cluster-internal subnets accepted wholesale
|
||||
# since they are isolated bridges with no external uplink.
|
||||
extraCommands = ''
|
||||
iptables -A nixos-fw -s ${vars.haServer1Ip}/32 -j nixos-fw-accept
|
||||
iptables -A nixos-fw -s ${vars.haServer2Ip}/32 -j nixos-fw-accept
|
||||
iptables -A nixos-fw -s ${vars.haStorageCidr} -j nixos-fw-accept
|
||||
|
||||
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.haServerIscsi} -j nixos-fw-accept
|
||||
|
||||
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p tcp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.lanCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||
|
||||
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsRpcbind} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p tcp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||
iptables -A nixos-fw -p udp -s ${vars.haClientCidr} --dport ${toString vars.ports.nfsMountd} -j nixos-fw-accept
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# LIO iSCSI target service (targetctl) for NixOS HA clusters.
|
||||
#
|
||||
# Provides the targetctl.service that saves/restores LIO configuration from
|
||||
# /etc/target/saveconfig.json. Pacemaker manages this service via its
|
||||
# systemd resource agent (class="systemd" type="targetctl").
|
||||
#
|
||||
# Why ExecStop is not simply "targetctl save":
|
||||
# targetctl save writes the LIO config to JSON but does NOT remove the LIO
|
||||
# target from the kernel's configfs. As a result, any fileio backing store
|
||||
# that LIO has open (e.g. iscsi-lun.img on an XFS-over-DRBD filesystem)
|
||||
# stays referenced in the kernel. The subsequent XFS umount from the
|
||||
# Filesystem OCF resource then returns EBUSY and either hangs for the full
|
||||
# op-stop timeout or fails outright, blocking the entire failover.
|
||||
#
|
||||
# The ExecStop script here additionally tears down the kernel LIO state
|
||||
# via rtslib_fb after saving, so the backing-store file descriptor is
|
||||
# released and umount succeeds immediately.
|
||||
#
|
||||
# Empty-config guard:
|
||||
# The save step is skipped when no iSCSI targets are currently active.
|
||||
# This prevents the secondary node (where LIO was never started) from
|
||||
# overwriting a valid saveconfig.json with an empty one when Pacemaker
|
||||
# stops the iscsi-target resource as part of a failover or cleanup.
|
||||
{ pkgs, ... }:
|
||||
|
||||
let
|
||||
python3 = pkgs.python3.withPackages (ps: [ ps.rtslib-fb ]);
|
||||
targetctl = "${python3}/bin/targetctl";
|
||||
|
||||
targetctlStop = pkgs.writeScript "targetctl-stop" ''
|
||||
#!${python3}/bin/python3
|
||||
import subprocess, sys
|
||||
import rtslib_fb
|
||||
|
||||
root = rtslib_fb.RTSRoot()
|
||||
targets = list(root.targets)
|
||||
if targets:
|
||||
subprocess.run(
|
||||
["${targetctl}", "save", "/etc/target/saveconfig.json"],
|
||||
capture_output=True,
|
||||
)
|
||||
print(f"saved {len(targets)} iSCSI target(s)")
|
||||
else:
|
||||
print("no active LIO targets — saveconfig.json unchanged")
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
for tpg in list(target.tpgs):
|
||||
tpg.enable = False
|
||||
target.delete()
|
||||
except Exception as e:
|
||||
print(f"warn (target): {e}", file=sys.stderr)
|
||||
for so in list(root.storage_objects):
|
||||
try:
|
||||
so.delete()
|
||||
except Exception as e:
|
||||
print(f"warn (backstore): {e}", file=sys.stderr)
|
||||
print("LIO kernel target cleared")
|
||||
'';
|
||||
in
|
||||
{
|
||||
boot.kernelModules = [
|
||||
"target_core_mod"
|
||||
"iscsi_target_mod"
|
||||
"target_core_file"
|
||||
"target_core_pscsi"
|
||||
"target_core_user"
|
||||
"configfs"
|
||||
];
|
||||
|
||||
systemd = {
|
||||
mounts = [{
|
||||
where = "/sys/kernel/config";
|
||||
what = "configfs";
|
||||
type = "configfs";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "targetctl.service" ];
|
||||
}];
|
||||
services.targetctl = {
|
||||
description = "LIO iSCSI target config save/restore";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "sys-kernel-config.mount" "network.target" ];
|
||||
requires = [ "sys-kernel-config.mount" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${targetctl} restore /etc/target/saveconfig.json";
|
||||
ExecStop = "${targetctlStop}";
|
||||
};
|
||||
unitConfig.ConditionFileNotEmpty = "/etc/target/saveconfig.json";
|
||||
};
|
||||
tmpfiles.rules = [
|
||||
"d /etc/target 0750 root root -"
|
||||
"f /etc/target/saveconfig.json 0640 root root -"
|
||||
];
|
||||
};
|
||||
|
||||
environment.systemPackages = [ pkgs.targetcli-fb ];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user