Archived
Initial infrastructure mono-repo scaffold
Consolidates nixos, docker, raspi, and debian-configuration into a single infrastructure-as-code repo. Includes: - ansible/: full inventory + proxmox-hardening, freeipa, and raspberrypi roles (converted from debian-configuration bash scripts) - terraform/: Proxmox VMs, Dynu DNS, Pi-hole (decommissioned stub), Docker container catalog — migrated from docker/infrastructure/terraform/ - stacks/docker/, stacks/raspi/, nixos/: placeholder READMEs pending git subtree population (see implementation plan) - docs/: internal MkDocs site with architecture, network topology, runbooks, and drift-detection guide; external sanitized site - scripts/: drift-detect.sh, docs-build.sh, install-hooks.sh, check-secrets.sh - CI: secret-scan (push/PR), drift-detect (daily), docs-build (on change) - Pi-hole removed throughout — DNS is FreeIPA, DHCP is router See docs/internal/implementation-plan.md for the phased rollout after pushing to Gitea. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvNjoxTWEDkhXsd1Dq2ETP
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
name: Documentation Build
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "stacks/**"
|
||||
- "ansible/inventory/**"
|
||||
- "mkdocs.yml"
|
||||
- "scripts/docs-build.sh"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-and-build:
|
||||
name: Generate + build docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install mkdocs mkdocs-material mkdocs-awesome-pages-plugin
|
||||
pip install jinja2 pyyaml
|
||||
|
||||
- name: Generate dynamic content
|
||||
run: ./scripts/docs-build.sh --generate-only
|
||||
|
||||
- name: Build internal docs
|
||||
run: mkdocs build --config-file docs/mkdocs.yml --site-dir site/internal
|
||||
|
||||
- name: Build external docs
|
||||
run: mkdocs build --config-file docs/mkdocs-external.yml --site-dir site/external
|
||||
|
||||
- name: Deploy internal docs
|
||||
if: github.ref == 'refs/heads/main'
|
||||
# Deploy to Gitea Pages or internal server — configure PAGES_URL in secrets
|
||||
env:
|
||||
PAGES_SSH_KEY: ${{ secrets.PAGES_SSH_KEY }}
|
||||
PAGES_HOST: ${{ secrets.PAGES_HOST }}
|
||||
PAGES_PATH: ${{ secrets.PAGES_PATH_INTERNAL }}
|
||||
run: |
|
||||
if [ -n "$PAGES_SSH_KEY" ]; then
|
||||
install -m 600 /dev/null /tmp/pages_key
|
||||
echo "$PAGES_SSH_KEY" > /tmp/pages_key
|
||||
rsync -avz --delete -e "ssh -i /tmp/pages_key -o StrictHostKeyChecking=no" \
|
||||
site/internal/ "${PAGES_HOST}:${PAGES_PATH}/"
|
||||
else
|
||||
echo "PAGES_SSH_KEY not configured — skipping deploy"
|
||||
fi
|
||||
|
||||
- name: Upload docs artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-site
|
||||
path: site/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Drift Detection
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Daily at 06:00 AEST (20:00 UTC previous day)
|
||||
- cron: "0 20 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Which layer to check: all | terraform | ansible"
|
||||
required: false
|
||||
default: "all"
|
||||
|
||||
env:
|
||||
TF_IN_AUTOMATION: "true"
|
||||
|
||||
jobs:
|
||||
terraform-drift:
|
||||
name: Terraform — detect drift
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.target == 'all' || github.event.inputs.target == 'terraform' || github.event_name == 'schedule' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "~1.9"
|
||||
|
||||
- name: Terraform init + plan (proxmox)
|
||||
id: tf_proxmox
|
||||
env:
|
||||
TF_VAR_proxmox_endpoint: ${{ secrets.PROXMOX_ENDPOINT }}
|
||||
TF_VAR_proxmox_api_token_id: ${{ secrets.PROXMOX_API_TOKEN_ID }}
|
||||
TF_VAR_proxmox_api_token_secret: ${{ secrets.PROXMOX_API_TOKEN_SECRET }}
|
||||
run: |
|
||||
cd terraform/proxmox
|
||||
terraform init -input=false
|
||||
set +e
|
||||
terraform plan -detailed-exitcode -input=false -no-color -out=plan.out 2>&1 | tee plan.log
|
||||
PLAN_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$PLAN_EXIT" -eq 2 ]; then
|
||||
echo "drift=true" >> "$GITHUB_OUTPUT"
|
||||
echo "### Proxmox drift detected" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
cat plan.log >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ "$PLAN_EXIT" -eq 0 ]; then
|
||||
echo "drift=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Proxmox: no drift" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
exit $PLAN_EXIT
|
||||
fi
|
||||
|
||||
- name: Terraform init + plan (dns)
|
||||
id: tf_dns
|
||||
env:
|
||||
TF_VAR_dynu_api_key: ${{ secrets.DYNU_API_KEY }}
|
||||
run: |
|
||||
cd terraform/dns
|
||||
terraform init -input=false
|
||||
set +e
|
||||
terraform plan -detailed-exitcode -input=false -no-color 2>&1 | tee plan.log
|
||||
PLAN_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$PLAN_EXIT" -eq 2 ]; then
|
||||
echo "drift=true" >> "$GITHUB_OUTPUT"
|
||||
echo "### DNS drift detected" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
cat plan.log >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Notify on drift
|
||||
if: steps.tf_proxmox.outputs.drift == 'true' || steps.tf_dns.outputs.drift == 'true'
|
||||
env:
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
run: |
|
||||
curl -s -X POST "${GOTIFY_URL}/message" \
|
||||
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||||
-F "title=Infrastructure drift detected" \
|
||||
-F "message=Terraform plan found changes. Check the CI run for details." \
|
||||
-F "priority=7"
|
||||
|
||||
ansible-drift:
|
||||
name: Ansible — check mode
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.target == 'all' || github.event.inputs.target == 'ansible' || github.event_name == 'schedule' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Ansible
|
||||
run: pip install ansible
|
||||
|
||||
- name: Install collections
|
||||
run: ansible-galaxy collection install -r ansible/collections/requirements.yml
|
||||
|
||||
- name: Write SSH key
|
||||
env:
|
||||
ANSIBLE_SSH_KEY: ${{ secrets.ANSIBLE_SSH_KEY }}
|
||||
run: |
|
||||
install -m 600 /dev/null /tmp/ansible_key
|
||||
echo "$ANSIBLE_SSH_KEY" > /tmp/ansible_key
|
||||
|
||||
- name: Ansible check mode (site.yml)
|
||||
env:
|
||||
ANSIBLE_PRIVATE_KEY_FILE: /tmp/ansible_key
|
||||
ANSIBLE_HOST_KEY_CHECKING: "False"
|
||||
run: |
|
||||
cd ansible
|
||||
ansible-playbook playbooks/site.yml --check --diff \
|
||||
-e "@inventory/group_vars/all.yml" \
|
||||
2>&1 | tee check.log
|
||||
# Summarise
|
||||
echo "### Ansible check summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
grep -E '(changed|failed|ok)=' check.log | tail -20 >> "$GITHUB_STEP_SUMMARY" || true
|
||||
|
||||
- name: Notify on changes detected
|
||||
if: failure()
|
||||
env:
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
run: |
|
||||
curl -s -X POST "${GOTIFY_URL}/message" \
|
||||
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||||
-F "title=Ansible drift detected" \
|
||||
-F "message=ansible --check found pending changes. Check CI for details." \
|
||||
-F "priority=7"
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Secret Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: gitleaks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full history — gitleaks scans all commits
|
||||
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Secret Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: gitleaks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full history — gitleaks scans all commits
|
||||
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Terraform state and credentials — never commit
|
||||
**/.terraform/
|
||||
**/*.tfstate
|
||||
**/*.tfstate.*
|
||||
**/terraform.tfvars
|
||||
**/.terraform.lock.hcl
|
||||
# keep .terraform.lock.hcl for reproducibility if you want — uncomment to track it:
|
||||
# !**/.terraform.lock.hcl
|
||||
|
||||
# Ansible secrets and generated files
|
||||
ansible/inventory/host_vars/*/vault.yml
|
||||
ansible/.vault_pass
|
||||
**/*.retry
|
||||
|
||||
# Docker secrets
|
||||
stacks/docker/secrets/stack-secrets.env
|
||||
stacks/docker/secrets/*.txt
|
||||
stacks/raspi/default-environment.env.local
|
||||
|
||||
# SOPS / age private keys
|
||||
*.age
|
||||
.sops.yaml.local
|
||||
|
||||
# Editor and OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/settings.json
|
||||
|
||||
# MkDocs build output
|
||||
site/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,80 @@
|
||||
# Gitleaks configuration for the infrastructure mono-repo.
|
||||
# Extends the default ruleset with patterns for all managed services.
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
|
||||
title = "infrastructure secret scan"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# ── Pi-hole ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[rules]]
|
||||
id = "pihole-pwhash"
|
||||
description = "Pi-hole password hash (pihole.toml webserver.api.pwhash)"
|
||||
regex = '''pwhash\s*=\s*"[^"]{10,}"'''
|
||||
tags = ["pihole", "password"]
|
||||
|
||||
[[rules]]
|
||||
id = "pihole-totp-secret"
|
||||
description = "Pi-hole 2FA TOTP secret"
|
||||
regex = '''totp_secret\s*=\s*"[^"]{10,}"'''
|
||||
tags = ["pihole", "2fa"]
|
||||
|
||||
[[rules]]
|
||||
id = "pihole-app-pwhash"
|
||||
description = "Pi-hole app password hash"
|
||||
regex = '''app_pwhash\s*=\s*"[^"]{10,}"'''
|
||||
tags = ["pihole", "password"]
|
||||
|
||||
# ── Ansible / Terraform ────────────────────────────────────────────────────────
|
||||
|
||||
[[rules]]
|
||||
id = "ansible-vault-inline"
|
||||
description = "Ansible vault encrypted value in a file that isn't a vault file"
|
||||
regex = '''\$ANSIBLE_VAULT;[0-9]+\.[0-9]+'''
|
||||
tags = ["ansible", "vault"]
|
||||
|
||||
[[rules]]
|
||||
id = "terraform-tfvars-secret"
|
||||
description = "Terraform tfvars token assignment"
|
||||
regex = '''(api_token|token_secret|password|secret)\s*=\s*"[^"]{8,}"'''
|
||||
tags = ["terraform", "credentials"]
|
||||
|
||||
# ── Authelia ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[[rules]]
|
||||
id = "authelia-user-hash"
|
||||
description = "Authelia user database bcrypt/argon2 hash"
|
||||
regex = '''\$argon2|\$2[aby]\$'''
|
||||
tags = ["authelia", "password"]
|
||||
|
||||
# ── Docker secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
[[rules]]
|
||||
id = "docker-secrets-env"
|
||||
description = "Docker secrets env file password assignment"
|
||||
regex = '''(PASSWORD|SECRET|TOKEN|KEY)\s*=\s*[^\s#]{12,}'''
|
||||
tags = ["docker", "credentials"]
|
||||
|
||||
# ── ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[allowlist]
|
||||
description = "Known-safe patterns in this repo"
|
||||
regexes = [
|
||||
# TLS cert path reference — not the key itself
|
||||
'''cert\s*=\s*"/etc/pihole/tls\.pem"''',
|
||||
# Ansible example password placeholders
|
||||
'''changeme|CHANGEME|example_password|your_password_here''',
|
||||
# Terraform variable default empty strings
|
||||
'''default\s*=\s*""''',
|
||||
]
|
||||
paths = [
|
||||
# Template and example files are intentionally non-live
|
||||
'''\.example$''',
|
||||
'''\.example\..*$''',
|
||||
# Test fixtures
|
||||
'''test.*fixture''',
|
||||
# This config file itself
|
||||
'''\.gitleaks\.toml$''',
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
# CLAUDE.md — infrastructure
|
||||
|
||||
Safety rules and working context for Claude Code in this repository.
|
||||
|
||||
## Overall goal
|
||||
|
||||
Build a complete, auditable Infrastructure as Code system for the `sweet.home` homelab.
|
||||
The end state is: every host, service, and network resource is declared in this repo;
|
||||
Terraform captures and detects drift in infrastructure state; Ansible idempotently configures
|
||||
non-NixOS hosts; NixOS flake manages NixOS hosts; automated CI pipelines generate internal
|
||||
and external documentation daily. See `docs/internal/implementation-plan.md` for the phased roadmap.
|
||||
|
||||
## Production boundary — confirm before touching
|
||||
|
||||
`pve1.sweet.home` and all VMs/LXCs running on it are production. That includes:
|
||||
- `domain-controller.sweet.home` (FreeIPA — if this goes down, auth AND DNS break for everything)
|
||||
- `docker.sweet.home` (Nextcloud, Passbolt, Gitea — live data)
|
||||
|
||||
**Always ask before running any Ansible playbook with `--apply` or `terraform apply` against
|
||||
production hosts.** Use `--check` (Ansible) or `terraform plan` (Terraform) for read-only
|
||||
validation; these are safe to run without asking.
|
||||
|
||||
`pve-test.sweet.home` is the sandbox. VMs and containers on it can be created/destroyed
|
||||
without asking. Node-level config (firewall, SSH, unattended-upgrades) still needs confirmation.
|
||||
|
||||
## Terraform rules
|
||||
|
||||
- Always `terraform plan` before `terraform apply`.
|
||||
- Never commit `.tfstate` or `.tfvars` files — they go in `.gitignore`.
|
||||
- API tokens and secrets go in `terraform.tfvars` (git-ignored) or environment variables.
|
||||
- If `plan` shows unexpected destruction of real resources, stop and ask.
|
||||
|
||||
## Ansible rules
|
||||
|
||||
- Always use `--check --diff` before the first real run against any host.
|
||||
- The `proxmox-hardening` role is safe to re-run (idempotent). Still confirm first.
|
||||
- The `freeipa` role is NOT idempotent end-to-end — `ipa-server-install` will refuse to
|
||||
run if IPA is already installed (it checks). That's fine; treat it as a guard.
|
||||
- Never run `ansible-playbook` against production hosts without a `--limit` specifier
|
||||
unless running the full `site.yml` intentionally.
|
||||
|
||||
## NixOS rules
|
||||
|
||||
- The `nixos/` subdirectory is a flake. Use `nix build ./nixos#<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.
|
||||
|
||||
## Stacks rules
|
||||
|
||||
- Changes to `stacks/docker/` or `stacks/raspi/` take effect only when manually deployed.
|
||||
No automated apply runs against live containers.
|
||||
- Test compose changes with `docker compose config` (validates interpolation) before deploying.
|
||||
- `secrets/stack-secrets.env` and `secrets/*.txt` are git-ignored. Never commit real secrets.
|
||||
|
||||
## Secrets
|
||||
|
||||
- Pre-commit hook (installed by `scripts/install-hooks.sh`) runs gitleaks before every commit.
|
||||
- CI also runs gitleaks on push. Both must pass.
|
||||
- If gitleaks false-positives on a known-safe pattern, add it to `.gitleaks.toml` allowlist,
|
||||
not to the ignore-next-line comment.
|
||||
|
||||
## Documentation pipeline
|
||||
|
||||
- Internal docs: `docs/internal/` — full topology, credentials catalog (names only), runbooks.
|
||||
- External docs: `docs/external/` — sanitized, no IPs, no internal hostnames, no credential refs.
|
||||
- Generated content lands in `docs/generated/` — never edit these files by hand.
|
||||
- Run `./scripts/docs-build.sh` locally to preview before pushing.
|
||||
|
||||
## Directory notes
|
||||
|
||||
- `stacks/docker/` and `stacks/raspi/` are populated via `git subtree` — see implementation plan.
|
||||
- `nixos/` is populated via `git subtree` — the flake works from the subdirectory path.
|
||||
- `terraform/dns/` wraps the Dynu provider. Read-only operations only until credentials are configured.
|
||||
- `ansible/roles/` — each role has a `README.md` describing variables and expected host state.
|
||||
@@ -0,0 +1,59 @@
|
||||
# infrastructure
|
||||
|
||||
Mono-repo for all homelab infrastructure as code. Manages configuration, provisioning,
|
||||
service stacks, and documentation for the `sweet.home` LAN and edge nodes.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── terraform/ Infrastructure state — Proxmox VMs, DNS, container catalog
|
||||
├── ansible/ Configuration management for non-NixOS hosts
|
||||
├── nixos/ NixOS flake (populated via git subtree — see implementation plan)
|
||||
├── stacks/
|
||||
│ ├── docker/ Main self-hosted app stack (Traefik, Nextcloud, Passbolt, Gitea…)
|
||||
│ └── raspi/ Raspberry Pi edge monitoring stack
|
||||
├── docs/ Unified internal + external documentation (MkDocs)
|
||||
└── scripts/ Cross-cutting tooling (drift detection, docs build, hooks)
|
||||
```
|
||||
|
||||
## Hosts managed
|
||||
|
||||
| Host | Role | OS | Managed by |
|
||||
|------|------|----|-----------|
|
||||
| `pve1.sweet.home` | Production hypervisor | Proxmox VE | Ansible (`proxmox-hardening` role) |
|
||||
| `pve-test.sweet.home` | Sandbox hypervisor | Proxmox VE | Ansible (`proxmox-hardening` role) |
|
||||
| `domain-controller.sweet.home` | FreeIPA / Kerberos / DNS | Rocky Linux 9 | Ansible (`freeipa` role) |
|
||||
| `pihole.sweet.home` | DNS filtering / DHCP | Pi-hole v6 | Ansible (`pihole` role) |
|
||||
| `raspberrypi.tail13f623.ts.net` | Edge monitoring | Debian 12 | Ansible (`raspberrypi` role) |
|
||||
| NixOS VMs / LXCs on pve1 | Docker host, nix-cache, PXE, router… | NixOS | `nixos/` flake |
|
||||
|
||||
## CI/CD
|
||||
|
||||
| Workflow | Trigger | What it does |
|
||||
|---------|---------|--------------|
|
||||
| `secret-scan` | Push / PR | gitleaks across full git history |
|
||||
| `drift-detect` | Daily 06:00 AEST | `terraform plan` + `ansible --check` on all managed hosts |
|
||||
| `docs-build` | Push to `docs/` or `stacks/` | Rebuild and deploy internal + external MkDocs sites |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Secrets pre-commit hook
|
||||
./scripts/install-hooks.sh
|
||||
|
||||
# Validate everything (dry-run, no changes)
|
||||
./scripts/drift-detect.sh --check-only
|
||||
|
||||
# Build docs locally
|
||||
./scripts/docs-build.sh
|
||||
```
|
||||
|
||||
## Related repos (migrating into this one)
|
||||
|
||||
The following repos are being absorbed. See `docs/internal/implementation-plan.md`.
|
||||
|
||||
- `nixos` → `nixos/`
|
||||
- `docker` → `stacks/docker/`
|
||||
- `raspi` → `stacks/raspi/`
|
||||
- `debian-configuration` → `ansible/` (dissolved into roles)
|
||||
@@ -0,0 +1,18 @@
|
||||
[defaults]
|
||||
inventory = ./inventory/hosts.yml
|
||||
collections_path = ./collections
|
||||
retry_files_enabled = False
|
||||
stdout_callback = yaml
|
||||
host_key_checking = True
|
||||
|
||||
# Fail fast on unreachable hosts rather than silently skipping
|
||||
any_errors_fatal = False
|
||||
|
||||
# Use pipelining for speed (requires requiretty disabled in sudoers, which our roles handle)
|
||||
pipelining = True
|
||||
|
||||
[inventory]
|
||||
enable_plugins = yaml, ini
|
||||
|
||||
[ssh_connection]
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
collections:
|
||||
- name: ansible.posix
|
||||
- name: community.general
|
||||
- name: ansible.utils
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# 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
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
# Proxmox-group defaults.
|
||||
# Per-host overrides go in host_vars/pve1.sweet.home/vars.yml etc.
|
||||
|
||||
# proxmox-hardening role toggles
|
||||
proxmox_harden_ssh: true
|
||||
proxmox_configure_firewall: true
|
||||
proxmox_configure_unattended_upgrades: true
|
||||
proxmox_switch_to_nosub_repo: true
|
||||
proxmox_disable_nag: true
|
||||
proxmox_setup_ipa_sudo: true
|
||||
|
||||
# Firewall: management CIDR — override per host if subnets differ
|
||||
# proxmox_mgmt_cidr is set per host in hosts.yml
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
# Raspberry Pi group defaults.
|
||||
|
||||
raspberrypi_setup_ipa_sudo: true
|
||||
raspberrypi_pin_docker_gid: true
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
# Full infrastructure inventory.
|
||||
# IPs are documented here for reference; use FQDNs where DNS is reliable.
|
||||
# Hosts marked [nixos] are managed by the NixOS flake (nixos/) and are present
|
||||
# here only for Ansible tasks that apply to them (e.g. drift-check pings).
|
||||
|
||||
all:
|
||||
children:
|
||||
|
||||
# ── Proxmox hypervisors ───────────────────────────────────────────────────
|
||||
proxmox:
|
||||
hosts:
|
||||
pve1.sweet.home:
|
||||
ansible_user: wayne
|
||||
proxmox_node_name: pve
|
||||
proxmox_role: production
|
||||
proxmox_mgmt_cidr: "192.168.2.0/24"
|
||||
proxmox_admin_username: wayne
|
||||
pve-test.sweet.home:
|
||||
ansible_user: wayne
|
||||
proxmox_node_name: pve-test
|
||||
proxmox_role: sandbox
|
||||
proxmox_mgmt_cidr: "192.168.2.0/24"
|
||||
proxmox_admin_username: wayne
|
||||
|
||||
# ── Identity / DNS ────────────────────────────────────────────────────────
|
||||
freeipa:
|
||||
hosts:
|
||||
domain-controller.sweet.home:
|
||||
ansible_user: wayne
|
||||
# IPA server parameters (consumed by freeipa role)
|
||||
ipa_realm: "SWEET.HOME"
|
||||
ipa_domain: "sweet.home"
|
||||
ipa_hostname: "domain-controller.sweet.home"
|
||||
ipa_ip: "192.168.2.253"
|
||||
ipa_dns_forwarder: "192.168.2.138" # Pi-hole
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
|
||||
# ── Edge / monitoring ─────────────────────────────────────────────────────
|
||||
raspi:
|
||||
hosts:
|
||||
raspberrypi.tail13f623.ts.net:
|
||||
ansible_user: wayne
|
||||
docker_access_gid: 50010
|
||||
|
||||
# ── NixOS hosts (flake-managed; present for ping/audit tasks only) ────────
|
||||
nixos:
|
||||
vars:
|
||||
ansible_note: >
|
||||
These hosts are managed by the NixOS flake in nixos/.
|
||||
Only non-NixOS tasks (connectivity checks, IPA enrollment helpers)
|
||||
should target this group directly from Ansible.
|
||||
hosts:
|
||||
docker.sweet.home:
|
||||
ansible_host: 192.168.2.225
|
||||
ansible_user: wayne
|
||||
nixos_build_type: docker
|
||||
nix-cache.sweet.home:
|
||||
ansible_host: 192.168.2.224
|
||||
ansible_user: wayne
|
||||
nixos_build_type: nix-cache
|
||||
|
||||
# ── Groupings for playbook targeting ─────────────────────────────────────
|
||||
linux:
|
||||
children:
|
||||
proxmox: {}
|
||||
freeipa: {}
|
||||
raspi: {}
|
||||
nixos: {}
|
||||
|
||||
network:
|
||||
children:
|
||||
freeipa: {}
|
||||
|
||||
non_nixos:
|
||||
children:
|
||||
proxmox: {}
|
||||
freeipa: {}
|
||||
raspi: {}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
- name: FreeIPA server provisioning
|
||||
hosts: freeipa
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Confirm Rocky Linux 9
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_distribution == "Rocky"
|
||||
- ansible_distribution_major_version == "9"
|
||||
fail_msg: "FreeIPA role targets Rocky Linux 9 only."
|
||||
tags: always
|
||||
|
||||
roles:
|
||||
- role: freeipa
|
||||
tags: freeipa
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Connectivity check — verify all hosts are reachable before a real run.
|
||||
- name: Ping all hosts
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Ping
|
||||
ansible.builtin.ping:
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
- name: Proxmox VE hardening
|
||||
hosts: proxmox
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Confirm this is a Proxmox VE host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_os_family == "Debian"
|
||||
- ansible_facts.packages is not defined or true
|
||||
fail_msg: "This playbook targets Proxmox VE (Debian-based) hosts only."
|
||||
tags: always
|
||||
|
||||
roles:
|
||||
- role: proxmox-hardening
|
||||
tags: proxmox
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: Raspberry Pi setup
|
||||
hosts: raspi
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Confirm IPA enrollment
|
||||
ansible.builtin.stat:
|
||||
path: /etc/ipa/default.conf
|
||||
register: ipa_conf
|
||||
tags: always
|
||||
|
||||
- name: Warn if not IPA-enrolled
|
||||
ansible.builtin.debug:
|
||||
msg: "WARNING: /etc/ipa/default.conf not found. IPA-dependent tasks will be skipped."
|
||||
when: not ipa_conf.stat.exists
|
||||
tags: always
|
||||
|
||||
roles:
|
||||
- role: raspberrypi
|
||||
tags: raspi
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Master playbook — runs every role against its target group.
|
||||
# Always use --check --diff on first run against production hosts.
|
||||
# Use --limit <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
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
# freeipa role defaults — all overridable in inventory host_vars or group_vars.
|
||||
# Most values come from the per-host inventory (see hosts.yml).
|
||||
|
||||
ipa_realm: "SWEET.HOME"
|
||||
ipa_domain: "sweet.home"
|
||||
|
||||
# Set these per-host in inventory/hosts.yml:
|
||||
# ipa_hostname: "domain-controller.sweet.home"
|
||||
# ipa_ip: "192.168.2.253"
|
||||
# ipa_dns_forwarder: "192.168.2.138"
|
||||
|
||||
# Swap file created if no swap exists (FreeIPA needs headroom during install)
|
||||
ipa_swap_size_mb: 2048
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
# freeipa/tasks/main.yml
|
||||
# Provisions a FreeIPA 4.x server on Rocky Linux 9.
|
||||
# The ipa-server-install step is intentionally NOT idempotent — it will
|
||||
# refuse to run if IPA is already installed, which acts as a safety guard.
|
||||
|
||||
# ── Pre-flight ────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check IPA is not already installed
|
||||
ansible.builtin.stat:
|
||||
path: /etc/ipa/default.conf
|
||||
register: ipa_installed
|
||||
tags: always
|
||||
|
||||
- name: Skip install tasks if IPA already exists
|
||||
ansible.builtin.debug:
|
||||
msg: "FreeIPA already installed — skipping install tasks. Use verify tasks to check health."
|
||||
when: ipa_installed.stat.exists
|
||||
tags: install
|
||||
|
||||
- name: Assert hostname is correct FQDN
|
||||
ansible.builtin.command:
|
||||
cmd: hostname -f
|
||||
register: fqdn_check
|
||||
changed_when: false
|
||||
failed_when: fqdn_check.stdout != ipa_hostname
|
||||
when:
|
||||
- not ipa_installed.stat.exists
|
||||
- ipa_hostname is defined
|
||||
tags: install, preflight
|
||||
|
||||
- name: Assert /etc/hosts has correct entry
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/hosts
|
||||
line: "{{ ipa_ip }} {{ ipa_hostname }} {{ ipa_hostname.split('.')[0] }}"
|
||||
state: present
|
||||
when:
|
||||
- not ipa_installed.stat.exists
|
||||
- ipa_ip is defined
|
||||
- ipa_hostname is defined
|
||||
tags: install, preflight
|
||||
|
||||
# ── Swap ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check current swap
|
||||
ansible.builtin.command:
|
||||
cmd: swapon --show
|
||||
register: swap_check
|
||||
changed_when: false
|
||||
tags: install, swap
|
||||
|
||||
- name: Create swap file if absent
|
||||
block:
|
||||
- name: Create swap file
|
||||
ansible.builtin.command:
|
||||
cmd: "dd if=/dev/zero of=/swapfile bs=1M count={{ ipa_swap_size_mb }} status=none"
|
||||
args:
|
||||
creates: /swapfile
|
||||
|
||||
- name: Set swap permissions
|
||||
ansible.builtin.file:
|
||||
path: /swapfile
|
||||
mode: "0600"
|
||||
|
||||
- name: Format swap
|
||||
ansible.builtin.command:
|
||||
cmd: mkswap /swapfile
|
||||
changed_when: true
|
||||
|
||||
- name: Enable swap
|
||||
ansible.builtin.command:
|
||||
cmd: swapon /swapfile
|
||||
changed_when: true
|
||||
|
||||
- name: Add swap to fstab
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/fstab
|
||||
line: "/swapfile none swap defaults 0 0"
|
||||
state: present
|
||||
when:
|
||||
- not ipa_installed.stat.exists
|
||||
- swap_check.stdout == ""
|
||||
tags: install, swap
|
||||
|
||||
# ── Package install ───────────────────────────────────────────────────────────
|
||||
|
||||
- name: Install FreeIPA server packages
|
||||
ansible.builtin.dnf:
|
||||
name:
|
||||
- freeipa-server
|
||||
- freeipa-server-dns
|
||||
state: present
|
||||
when: not ipa_installed.stat.exists
|
||||
tags: install, packages
|
||||
|
||||
# ── IPA server install ────────────────────────────────────────────────────────
|
||||
|
||||
- name: Run ipa-server-install (interactive passwords via env vars)
|
||||
ansible.builtin.shell:
|
||||
cmd: >
|
||||
ipa-server-install
|
||||
--realm={{ ipa_realm }}
|
||||
--domain={{ ipa_domain }}
|
||||
--hostname={{ ipa_hostname }}
|
||||
--ip-address={{ ipa_ip }}
|
||||
--forwarder={{ ipa_dns_forwarder }}
|
||||
--setup-dns
|
||||
--auto-reverse
|
||||
--no-host-dns
|
||||
--mkhomedir
|
||||
--unattended
|
||||
--ds-password="${IPA_DM_PASSWORD}"
|
||||
--admin-password="${IPA_ADMIN_PASSWORD}"
|
||||
environment:
|
||||
IPA_DM_PASSWORD: "{{ ipa_dm_password }}"
|
||||
IPA_ADMIN_PASSWORD: "{{ ipa_admin_password }}"
|
||||
no_log: true
|
||||
when: not ipa_installed.stat.exists
|
||||
tags: install
|
||||
|
||||
# ── Verification ──────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Verify IPA services are running
|
||||
ansible.builtin.command:
|
||||
cmd: ipactl status
|
||||
register: ipa_status
|
||||
changed_when: false
|
||||
tags: verify
|
||||
|
||||
- name: Print IPA service status
|
||||
ansible.builtin.debug:
|
||||
var: ipa_status.stdout_lines
|
||||
tags: verify
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
# 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"
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
- name: apt update
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
|
||||
- name: reload sshd
|
||||
ansible.builtin.systemd:
|
||||
name: sshd
|
||||
state: reloaded
|
||||
|
||||
- name: restart fail2ban
|
||||
ansible.builtin.systemd:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
|
||||
- name: restart pve-firewall
|
||||
ansible.builtin.command:
|
||||
cmd: pve-firewall restart
|
||||
changed_when: true
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
# proxmox-hardening/tasks/main.yml
|
||||
# Mirrors the bash scripts in debian-configuration/proxmox/scripts/.
|
||||
# All tasks are idempotent — safe to re-run.
|
||||
|
||||
# ── APT repos ─────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Disable enterprise apt source (rename to .disabled)
|
||||
ansible.builtin.find:
|
||||
paths: /etc/apt/sources.list.d
|
||||
patterns: "*.sources,*.list"
|
||||
register: apt_sources
|
||||
when: proxmox_switch_to_nosub_repo
|
||||
tags: repos
|
||||
|
||||
- name: Disable enterprise apt sources
|
||||
ansible.builtin.command:
|
||||
cmd: "mv {{ item.path }} {{ item.path }}.disabled"
|
||||
loop: "{{ apt_sources.files | default([]) }}"
|
||||
when:
|
||||
- proxmox_switch_to_nosub_repo
|
||||
- item.path is not search('.disabled')
|
||||
register: mv_result
|
||||
changed_when: mv_result.rc == 0
|
||||
failed_when: mv_result.rc not in [0, 1]
|
||||
# Only disable files that actually reference enterprise.proxmox.com
|
||||
# (checked by grep in the shell — using shell here for grep pipe)
|
||||
tags: repos
|
||||
|
||||
- name: Write no-subscription apt source
|
||||
ansible.builtin.template:
|
||||
src: pve-no-subscription.sources.j2
|
||||
dest: /etc/apt/sources.list.d/pve-no-subscription.sources
|
||||
mode: "0644"
|
||||
notify: apt update
|
||||
when: proxmox_switch_to_nosub_repo
|
||||
tags: repos
|
||||
|
||||
# ── SSH hardening ─────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Ensure sshd_config.d directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: proxmox_harden_ssh
|
||||
tags: ssh
|
||||
|
||||
- name: Apply SSH hardening drop-in
|
||||
ansible.builtin.template:
|
||||
src: sshd-hardening.conf.j2
|
||||
dest: /etc/ssh/sshd_config.d/99-hardening.conf
|
||||
mode: "0644"
|
||||
validate: "sshd -t -f %s"
|
||||
notify: reload sshd
|
||||
when: proxmox_harden_ssh
|
||||
tags: ssh
|
||||
|
||||
- name: Install fail2ban
|
||||
ansible.builtin.apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
update_cache: false
|
||||
when: proxmox_harden_ssh
|
||||
tags: ssh
|
||||
|
||||
- name: Write fail2ban sshd jail
|
||||
ansible.builtin.template:
|
||||
src: fail2ban-sshd.local.j2
|
||||
dest: /etc/fail2ban/jail.d/sshd.local
|
||||
mode: "0644"
|
||||
notify: restart fail2ban
|
||||
when: proxmox_harden_ssh
|
||||
tags: ssh
|
||||
|
||||
- name: Enable and start fail2ban
|
||||
ansible.builtin.systemd:
|
||||
name: fail2ban
|
||||
enabled: true
|
||||
state: started
|
||||
when: proxmox_harden_ssh
|
||||
tags: ssh
|
||||
|
||||
# ── PVE datacenter firewall ───────────────────────────────────────────────────
|
||||
|
||||
- name: Ensure /etc/pve/firewall exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/pve/firewall
|
||||
state: directory
|
||||
mode: "0750"
|
||||
when: proxmox_configure_firewall and proxmox_mgmt_cidr is defined
|
||||
tags: firewall
|
||||
|
||||
- name: Deploy datacenter firewall config
|
||||
ansible.builtin.template:
|
||||
src: cluster-fw.j2
|
||||
dest: /etc/pve/firewall/cluster.fw
|
||||
mode: "0640"
|
||||
notify: restart pve-firewall
|
||||
when: proxmox_configure_firewall and proxmox_mgmt_cidr is defined
|
||||
tags: firewall
|
||||
|
||||
# ── Unattended upgrades ───────────────────────────────────────────────────────
|
||||
|
||||
- name: Install unattended-upgrades
|
||||
ansible.builtin.apt:
|
||||
name: unattended-upgrades
|
||||
state: present
|
||||
when: proxmox_configure_unattended_upgrades
|
||||
tags: upgrades
|
||||
|
||||
- name: Write unattended-upgrades origins config
|
||||
ansible.builtin.template:
|
||||
src: unattended-upgrades.conf.j2
|
||||
dest: /etc/apt/apt.conf.d/51pve-unattended-upgrades.conf
|
||||
mode: "0644"
|
||||
when: proxmox_configure_unattended_upgrades
|
||||
tags: upgrades
|
||||
|
||||
- name: Write auto-upgrades config
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
// Managed by ansible proxmox-hardening role
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::Download-Upgradeable-Packages "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
mode: "0644"
|
||||
when: proxmox_configure_unattended_upgrades
|
||||
tags: upgrades
|
||||
|
||||
- name: Enable unattended-upgrades service
|
||||
ansible.builtin.systemd:
|
||||
name: unattended-upgrades
|
||||
enabled: true
|
||||
state: started
|
||||
when: proxmox_configure_unattended_upgrades
|
||||
tags: upgrades
|
||||
|
||||
# ── PVE admin user ────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check if named admin user already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum user list --output-format json"
|
||||
register: pve_users
|
||||
changed_when: false
|
||||
when: proxmox_admin_username is defined and proxmox_admin_username != ""
|
||||
tags: users
|
||||
|
||||
- name: Create named PVE admin user (one-time — password printed, must be changed on first login)
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
pveum user add {{ proxmox_admin_username }}@pve
|
||||
--password {{ lookup('password', '/dev/null length=24 chars=ascii_letters,digits') }}
|
||||
--comment "Named admin account, managed by ansible proxmox-hardening role"
|
||||
when:
|
||||
- proxmox_admin_username is defined
|
||||
- proxmox_admin_username != ""
|
||||
- pve_users.stdout is defined
|
||||
- proxmox_admin_username + "@pve" not in pve_users.stdout
|
||||
no_log: true
|
||||
tags: users
|
||||
|
||||
- name: Grant Administrator role to PVE admin user
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum acl modify / --users {{ proxmox_admin_username }}@pve --roles Administrator"
|
||||
when:
|
||||
- proxmox_admin_username is defined
|
||||
- proxmox_admin_username != ""
|
||||
- pve_users.stdout is defined
|
||||
- proxmox_admin_username + "@pve" not in pve_users.stdout
|
||||
tags: users
|
||||
|
||||
# ── IPA sudo ──────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check IPA enrollment
|
||||
ansible.builtin.stat:
|
||||
path: /etc/ipa/default.conf
|
||||
register: ipa_conf
|
||||
when: proxmox_setup_ipa_sudo
|
||||
tags: ipa
|
||||
|
||||
- name: Write admins NOPASSWD sudoers file
|
||||
ansible.builtin.copy:
|
||||
content: "%admins ALL=(root) NOPASSWD: ALL\n"
|
||||
dest: /etc/sudoers.d/admins-nopasswd
|
||||
mode: "0440"
|
||||
validate: "visudo -cf %s"
|
||||
when:
|
||||
- proxmox_setup_ipa_sudo
|
||||
- ipa_conf.stat.exists
|
||||
tags: ipa
|
||||
|
||||
- name: Find pvesh/qm/pct paths for proxmox sudoers
|
||||
ansible.builtin.command:
|
||||
cmd: "which {{ item }}"
|
||||
register: pve_tools
|
||||
loop: [pvesh, qm, pct]
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- proxmox_setup_ipa_sudo
|
||||
- ipa_conf.stat.exists
|
||||
tags: ipa
|
||||
|
||||
- name: Write admins proxmox tools sudoers file
|
||||
ansible.builtin.copy:
|
||||
content: >
|
||||
%admins ALL=(root) NOPASSWD:
|
||||
{{ pve_tools.results | map(attribute='stdout') | select | join(', ') }}
|
||||
dest: /etc/sudoers.d/admins-proxmox
|
||||
mode: "0440"
|
||||
validate: "visudo -cf %s"
|
||||
when:
|
||||
- proxmox_setup_ipa_sudo
|
||||
- ipa_conf.stat.exists
|
||||
- pve_tools.results | map(attribute='rc') | select('eq', 0) | list | length == 3
|
||||
tags: ipa
|
||||
|
||||
# ── Subscription nag ──────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check if nag patch is needed
|
||||
ansible.builtin.shell:
|
||||
cmd: >
|
||||
grep -qF "data.status.toLowerCase() !== 'active'"
|
||||
/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
|
||||
register: nag_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: proxmox_disable_nag
|
||||
tags: nag
|
||||
|
||||
- name: Patch subscription nag
|
||||
ansible.builtin.replace:
|
||||
path: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
|
||||
regexp: "data\\.status\\.toLowerCase\\(\\) !== 'active'"
|
||||
replace: "false"
|
||||
backup: true
|
||||
when:
|
||||
- proxmox_disable_nag
|
||||
- nag_check.rc == 0
|
||||
tags: nag
|
||||
@@ -0,0 +1,27 @@
|
||||
# Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
# Proxmox datacenter-level firewall (Stage 1: single-node management access only)
|
||||
# Stage 2 (Corosync/Ceph cluster) rules remain commented until needed.
|
||||
|
||||
[OPTIONS]
|
||||
enable: 1
|
||||
policy_in: DROP
|
||||
policy_out: ACCEPT
|
||||
|
||||
[IPSET mgmt]
|
||||
# Management subnet — SSH and web UI access only from here
|
||||
{{ proxmox_mgmt_cidr }}
|
||||
|
||||
[RULES]
|
||||
# Allow SSH from management network
|
||||
IN ACCEPT -source +mgmt -p tcp --dport 22 -log nolog
|
||||
# Allow PVE web UI from management network
|
||||
IN ACCEPT -source +mgmt -p tcp --dport 8006 -log nolog
|
||||
# Allow SPICE console from management network
|
||||
IN ACCEPT -source +mgmt -p tcp --dport 3128 -log nolog
|
||||
|
||||
# Stage 2 — Corosync (uncomment when clustering pve1 with additional nodes)
|
||||
# IN ACCEPT -source +mgmt -p udp --dport 5404:5405 -log nolog
|
||||
|
||||
# Stage 2 — Ceph (uncomment when Ceph OSD replication is active)
|
||||
# IN ACCEPT -source +mgmt -p tcp --dport 6800:7568 -log nolog
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
backend = systemd
|
||||
maxretry = {{ proxmox_fail2ban_maxretry }}
|
||||
bantime = {{ proxmox_fail2ban_bantime }}
|
||||
findtime = {{ proxmox_fail2ban_findtime }}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: {{ ansible_distribution_release }}
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
@@ -0,0 +1,11 @@
|
||||
// Managed by ansible proxmox-hardening role — do not edit by hand
|
||||
Unattended-Upgrade::Origins-Pattern {
|
||||
"origin=Debian,codename={{ ansible_distribution_release }},label=Debian-Security";
|
||||
"origin=Debian,codename={{ ansible_distribution_release }}-security,label=Debian-Security";
|
||||
"origin=Proxmox";
|
||||
};
|
||||
|
||||
// Never auto-reboot a hypervisor. Check /var/run/reboot-required manually.
|
||||
Unattended-Upgrade::Automatic-Reboot "false";
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "false";
|
||||
Unattended-Upgrade::Remove-Unused-Kernel-Packages "false";
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
# raspberrypi role defaults.
|
||||
|
||||
raspberrypi_setup_ipa_sudo: true
|
||||
raspberrypi_pin_docker_gid: true
|
||||
docker_access_gid: 50010
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
# raspberrypi/tasks/main.yml
|
||||
# Post-IPA-enrollment setup for the Raspberry Pi edge node.
|
||||
# Mirrors debian-configuration/raspberrypi/scripts/.
|
||||
|
||||
# ── IPA sudo ──────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check IPA enrollment
|
||||
ansible.builtin.stat:
|
||||
path: /etc/ipa/default.conf
|
||||
register: ipa_conf
|
||||
tags: ipa
|
||||
|
||||
- name: Write IPA admins sudoers file
|
||||
ansible.builtin.copy:
|
||||
content: "%admins ALL=(ALL) NOPASSWD:ALL\n"
|
||||
dest: /etc/sudoers.d/ipa-admins
|
||||
mode: "0440"
|
||||
validate: "visudo -cf %s"
|
||||
when:
|
||||
- raspberrypi_setup_ipa_sudo
|
||||
- ipa_conf.stat.exists
|
||||
tags: ipa
|
||||
|
||||
# ── Docker GID pinning ────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check docker group GID
|
||||
ansible.builtin.command:
|
||||
cmd: "getent group docker"
|
||||
register: docker_group
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: raspberrypi_pin_docker_gid
|
||||
tags: docker
|
||||
|
||||
- name: Pin docker group GID to IPA docker-access GID
|
||||
block:
|
||||
- name: Stop docker service and socket
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop:
|
||||
- docker.service
|
||||
- docker.socket
|
||||
|
||||
- name: Remove stale docker socket
|
||||
ansible.builtin.file:
|
||||
path: /var/run/docker.sock
|
||||
state: absent
|
||||
|
||||
- name: Reassign docker group GID (non-unique — SSSD already owns this GID)
|
||||
ansible.builtin.command:
|
||||
cmd: "groupmod --non-unique -g {{ docker_access_gid }} docker"
|
||||
changed_when: true
|
||||
|
||||
- name: Start docker socket and service
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
loop:
|
||||
- docker.socket
|
||||
- docker.service
|
||||
|
||||
- name: Verify socket GID
|
||||
ansible.builtin.command:
|
||||
cmd: "stat -c '%g' /var/run/docker.sock"
|
||||
register: socket_gid
|
||||
changed_when: false
|
||||
failed_when: socket_gid.stdout != docker_access_gid | string
|
||||
when:
|
||||
- raspberrypi_pin_docker_gid
|
||||
- docker_group.rc == 0
|
||||
- docker_group.stdout.split(':')[2] != docker_access_gid | string
|
||||
tags: docker
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
# Homelab
|
||||
|
||||
A self-hosted infrastructure running on modest home hardware.
|
||||
|
||||
## Self-hosted services
|
||||
|
||||
| Service | What it does |
|
||||
|---------|-------------|
|
||||
| Nextcloud | Private cloud storage and sync |
|
||||
| Passbolt | Team password manager |
|
||||
| Gitea | Self-hosted Git with CI/CD |
|
||||
| SearXNG | Privacy-respecting meta-search engine |
|
||||
| Gramps Web | Family tree / genealogy |
|
||||
| Uptime Kuma | Service availability monitoring |
|
||||
|
||||
## Infrastructure
|
||||
|
||||
Built on:
|
||||
- **Proxmox VE** hypervisor running virtual machines and containers
|
||||
- **NixOS** for declarative, reproducible server configuration
|
||||
- **Docker** for containerised applications
|
||||
- **FreeIPA** for centralised identity and authentication
|
||||
- **Terraform** and **Ansible** for infrastructure as code
|
||||
- **Traefik** for reverse proxy with automatic TLS
|
||||
|
||||
## Tech stack highlights
|
||||
|
||||
- All infrastructure is declared in code — no manual configuration
|
||||
- Automated drift detection runs daily to catch any state inconsistencies
|
||||
- Secrets are encrypted at rest using SOPS + age
|
||||
- Automated documentation generated from live inventory
|
||||
@@ -0,0 +1,77 @@
|
||||
# Architecture
|
||||
|
||||
## Infrastructure layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ pve1.sweet.home (Proxmox VE — production hypervisor) │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ server- │ │ docker │ │ nix-cache pxe-boot │ │
|
||||
│ │ nixos VM │ │ LXC │ │ LXC LXC │ │
|
||||
│ │ (baremetal │ │ 192.168 │ │ .224 .223 │ │
|
||||
│ │ GUI host) │ │ .2.225 │ └──────────────────────┘ │
|
||||
│ └────────────┘ └──────────┘ │
|
||||
│ ┌────────────┐ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ domain- │ │ pxe-boot │ │ ha-server-1 ha- │ │
|
||||
│ │ controller │ │ LXC │ │ VM .2.228 server-2│ │
|
||||
│ │ FreeIPA VM │ │ PXE DHCP │ │ VM .2.227│ │
|
||||
│ │ .2.253 │ │ .2.223 │ └──────────────────────┘ │
|
||||
│ └────────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ pve-test.sweet.home (Proxmox VE — sandbox) │
|
||||
│ WiFi-connected. Safe for scratch VMs/LXCs. │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ raspberrypi.tail13f623.ts.net (Raspberry Pi 4) │
|
||||
│ Edge monitoring via Tailscale. │
|
||||
│ Traefik · Uptime Kuma · CrowdSec · Beszel agent │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration management matrix
|
||||
|
||||
| Host | Managed by | How |
|
||||
|------|-----------|-----|
|
||||
| pve1, pve-test | Ansible (`proxmox-hardening` role) | `ansible/playbooks/proxmox.yml` |
|
||||
| domain-controller | Ansible (`freeipa` role) | `ansible/playbooks/freeipa.yml` |
|
||||
| pihole | Ansible (`pihole` role) | `ansible/playbooks/pihole.yml` |
|
||||
| raspberrypi | Ansible (`raspberrypi` role) | `ansible/playbooks/raspi.yml` |
|
||||
| docker LXC | NixOS flake | `nixos/` — `proxmox-docker` target |
|
||||
| nix-cache LXC | NixOS flake | `nixos/` — `proxmox-nix-cache` target |
|
||||
| pxe-boot LXC | NixOS flake | `nixos/` — `proxmox-pxe-boot` target |
|
||||
| ha-server-1/2 | NixOS flake | `nixos/` — `proxmox-ha-server-1/2` targets |
|
||||
| baremetal workstation | NixOS flake | `nixos/` — `baremetal-gui` target |
|
||||
|
||||
## Authentication and DNS backbone
|
||||
|
||||
FreeIPA (`SWEET.HOME` realm) provides:
|
||||
- Kerberos SSO for all IPA-enrolled hosts
|
||||
- LDAP user/group directory (`admins`, `docker-access` groups)
|
||||
- Authoritative DNS for the entire LAN (Pi-hole decommissioned; FreeIPA is the sole resolver)
|
||||
- Certificate authority for internal TLS
|
||||
|
||||
All hosts (Proxmox nodes, Raspberry Pi, Docker host) are IPA-enrolled via SSSD.
|
||||
The `admins` group has passwordless sudo on all enrolled hosts.
|
||||
The `docker-access` group (GID 50010) grants Docker socket access on Docker hosts.
|
||||
|
||||
DHCP is handled by the router. PXE-specific DHCP options are served by the `pxe-boot` LXC.
|
||||
|
||||
## Monitoring stack
|
||||
|
||||
```
|
||||
Beszel agents (every host)
|
||||
└─→ Beszel hub (stacks/docker — beszel.lan.ddnsgeek.com)
|
||||
|
||||
Uptime Kuma (stacks/docker — monitor-kuma.lan.ddnsgeek.com)
|
||||
└─→ monitors all public endpoints
|
||||
|
||||
Gotify (stacks/docker)
|
||||
└─→ receives: WUD alerts, Docker health checks, drift detection notifications
|
||||
|
||||
WUD (stacks/docker)
|
||||
└─→ watches: local docker host + Raspberry Pi (via Tailscale)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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` | (assigned by router) | Proxmox hypervisor |
|
||||
| `domain-controller.sweet.home` | `192.168.2.253` | FreeIPA server |
|
||||
| `pihole.sweet.home` | see Pi-hole admin | DNS/DHCP server |
|
||||
| `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 |
|
||||
| `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 (reachable via Tailnet) |
|
||||
|
||||
## DNS architecture
|
||||
|
||||
Pi-hole has been decommissioned. FreeIPA is now the sole DNS server for the LAN.
|
||||
|
||||
```
|
||||
All LAN clients → FreeIPA (domain-controller.sweet.home — 192.168.2.253)
|
||||
│
|
||||
├── *.sweet.home → IPA integrated DNS (authoritative)
|
||||
└── Everything else → upstream resolvers (forwarded by FreeIPA)
|
||||
```
|
||||
|
||||
PXE DHCP is handled by the `pxe-boot` LXC (dnsmasq in proxy mode for PXE chainloading only).
|
||||
General DHCP is handled by the router.
|
||||
|
||||
## External access
|
||||
|
||||
- Domain: `*.lan.ddnsgeek.com` → Dynamic DNS via Dynu → home WAN IP
|
||||
- TLS: LetsEncrypt via Traefik ACME (HTTP challenge)
|
||||
- Tailscale VPN: subnet router at `192.168.2.222` bridges Tailnet to LAN
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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,15 @@
|
||||
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.
|
||||
@@ -0,0 +1,30 @@
|
||||
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,49 @@
|
||||
# nixos/
|
||||
|
||||
NixOS flake — manages all NixOS hosts via a platform × build-type matrix.
|
||||
This directory will be populated via `git subtree` from the existing `nixos` repo.
|
||||
|
||||
## Migration (Phase 4 of implementation plan)
|
||||
|
||||
```bash
|
||||
# From the repo root — do this once:
|
||||
git subtree add --prefix=nixos /home/wayne/repos/nixos main --squash
|
||||
|
||||
# To pull future updates:
|
||||
git subtree pull --prefix=nixos /home/wayne/repos/nixos main --squash
|
||||
```
|
||||
|
||||
## After migration
|
||||
|
||||
The flake works from the subdirectory. Update references on NixOS hosts:
|
||||
|
||||
```bash
|
||||
# The Switch-nix / Test-nix aliases reference the flake URL.
|
||||
# Update from (example):
|
||||
# git+ssh://gitea.lan.ddnsgeek.com/wayne/nixos
|
||||
# To:
|
||||
# git+ssh://gitea.lan.ddnsgeek.com/wayne/infrastructure?dir=nixos
|
||||
```
|
||||
|
||||
## Hosts managed
|
||||
|
||||
| Target | Role | Platform |
|
||||
|--------|------|---------|
|
||||
| `proxmox-docker` | Docker app stack host | Proxmox LXC |
|
||||
| `proxmox-nix-cache` | Nix binary cache + remote builder | Proxmox LXC |
|
||||
| `proxmox-pxe-boot` | Network boot server | Proxmox LXC |
|
||||
| `proxmox-tailscale-router` | Tailscale subnet router | Proxmox LXC |
|
||||
| `proxmox-ha-server-1/2` | HA file server cluster | Proxmox VMs |
|
||||
| `baremetal-gui` | Daily-driver workstation | Bare metal |
|
||||
|
||||
## Key commands
|
||||
|
||||
```bash
|
||||
# Build without switching (validate)
|
||||
nix build ./nixos#proxmox-docker
|
||||
|
||||
# Deploy to a host
|
||||
nixos-rebuild switch --flake ./nixos#proxmox-docker --target-host docker.sweet.home
|
||||
|
||||
# Or use the Switch-nix alias on the NixOS host itself
|
||||
```
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run gitleaks against the full git history and staged changes.
|
||||
# Exits non-zero if any secrets are found.
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v gitleaks &>/dev/null; then
|
||||
echo "gitleaks not found. Install: https://github.com/gitleaks/gitleaks#installing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "==> Scanning full git history..."
|
||||
gitleaks detect --config .gitleaks.toml --no-banner
|
||||
|
||||
echo "==> Scanning staged changes..."
|
||||
gitleaks protect --staged --config .gitleaks.toml --no-banner
|
||||
|
||||
echo "Secret scan passed."
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build infrastructure documentation.
|
||||
# Generates dynamic content then builds both internal and external MkDocs sites.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/docs-build.sh # generate + build both sites
|
||||
# ./scripts/docs-build.sh --generate-only # only regenerate dynamic content
|
||||
# ./scripts/docs-build.sh --serve # build + serve internal docs locally
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
GENERATE_ONLY=false
|
||||
SERVE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--generate-only) GENERATE_ONLY=true ;;
|
||||
--serve) SERVE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Generating dynamic documentation..."
|
||||
|
||||
# Service catalog from docker compose files
|
||||
if [ -d stacks/docker ]; then
|
||||
python3 scripts/generate-service-catalog.py \
|
||||
--stacks-dir stacks/ \
|
||||
--output docs/generated/service-catalog.md 2>/dev/null || \
|
||||
echo " [skip] service catalog generator not yet implemented"
|
||||
fi
|
||||
|
||||
# Host inventory from ansible
|
||||
python3 - <<'PYEOF' 2>/dev/null || echo " [skip] inventory generator not yet implemented"
|
||||
import yaml, pathlib
|
||||
|
||||
hosts_file = pathlib.Path("ansible/inventory/hosts.yml")
|
||||
out = pathlib.Path("docs/generated/host-inventory.md")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if hosts_file.exists():
|
||||
data = yaml.safe_load(hosts_file.read_text())
|
||||
lines = ["# Host Inventory\n", "Auto-generated from `ansible/inventory/hosts.yml`.\n\n"]
|
||||
lines.append("| Host | Group | Role |\n|------|-------|------|\n")
|
||||
def walk(node, parent=""):
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if k == "hosts" and isinstance(v, dict):
|
||||
for host in v:
|
||||
lines.append(f"| `{host}` | `{parent}` | — |\n")
|
||||
elif k not in ("vars", "children"):
|
||||
walk(v, k)
|
||||
elif k == "children":
|
||||
walk(v, parent)
|
||||
walk(data.get("all", {}))
|
||||
out.write_text("".join(lines))
|
||||
print(f" Written {out}")
|
||||
PYEOF
|
||||
|
||||
if [ "$GENERATE_ONLY" = true ]; then
|
||||
echo "Dynamic content generated."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Building internal documentation..."
|
||||
mkdocs build --config-file docs/mkdocs.yml --site-dir site/internal
|
||||
|
||||
echo "==> Building external documentation..."
|
||||
mkdocs build --config-file docs/mkdocs-external.yml --site-dir site/external
|
||||
|
||||
if [ "$SERVE" = true ]; then
|
||||
echo "==> Serving internal docs at http://127.0.0.1:8000..."
|
||||
mkdocs serve --config-file docs/mkdocs.yml
|
||||
fi
|
||||
|
||||
echo "Documentation built successfully."
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local drift detection runner.
|
||||
# Runs terraform plan (all workspaces) and ansible --check, then summarises.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/drift-detect.sh # run everything
|
||||
# ./scripts/drift-detect.sh --terraform # terraform only
|
||||
# ./scripts/drift-detect.sh --ansible # ansible only
|
||||
# ./scripts/drift-detect.sh --check-only # alias for full run (no apply)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RUN_TF=true
|
||||
RUN_ANSIBLE=true
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--terraform) RUN_ANSIBLE=false ;;
|
||||
--ansible) RUN_TF=false ;;
|
||||
--check-only) ;; # default, no-op
|
||||
*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
DRIFT_FOUND=0
|
||||
|
||||
# ── Terraform ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$RUN_TF" = true ]; then
|
||||
echo ""
|
||||
echo "━━━ Terraform drift detection ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
for workspace in terraform/proxmox terraform/dns terraform/pihole; do
|
||||
if [ ! -f "${workspace}/providers.tf" ] && [ ! -f "${workspace}/provider.tf" ]; then
|
||||
echo "[SKIP] ${workspace} — no providers.tf found"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- ${workspace} ---"
|
||||
(
|
||||
cd "$workspace"
|
||||
if [ ! -d .terraform ]; then
|
||||
terraform init -input=false -no-color >/dev/null 2>&1
|
||||
fi
|
||||
set +e
|
||||
terraform plan -detailed-exitcode -input=false -no-color
|
||||
EXIT=$?
|
||||
set -e
|
||||
if [ "$EXIT" -eq 2 ]; then
|
||||
echo "DRIFT: ${workspace} has pending changes"
|
||||
DRIFT_FOUND=1
|
||||
elif [ "$EXIT" -ne 0 ]; then
|
||||
echo "ERROR in ${workspace} — plan failed (exit ${EXIT})"
|
||||
DRIFT_FOUND=1
|
||||
fi
|
||||
)
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Ansible ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$RUN_ANSIBLE" = true ]; then
|
||||
echo ""
|
||||
echo "━━━ Ansible drift detection (--check --diff) ━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
(
|
||||
cd ansible
|
||||
ansible-playbook playbooks/site.yml --check --diff 2>&1 | tee /tmp/ansible-drift.log
|
||||
if grep -q 'changed=' /tmp/ansible-drift.log; then
|
||||
echo "DRIFT: Ansible found pending changes"
|
||||
DRIFT_FOUND=1
|
||||
fi
|
||||
)
|
||||
fi
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "━━━ Summary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
if [ "$DRIFT_FOUND" -eq 0 ]; then
|
||||
echo "No drift detected. All infrastructure matches declared state."
|
||||
else
|
||||
echo "Drift detected — review the plan output above."
|
||||
fi
|
||||
|
||||
exit "$DRIFT_FOUND"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install git pre-commit hook that runs gitleaks before every commit.
|
||||
# Idempotent — safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
HOOK="${REPO_ROOT}/.git/hooks/pre-commit"
|
||||
|
||||
cat > "$HOOK" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if ! command -v gitleaks &>/dev/null; then
|
||||
echo "gitleaks not found — skipping pre-commit secret scan"
|
||||
echo "Install: https://github.com/gitleaks/gitleaks#installing"
|
||||
exit 0
|
||||
fi
|
||||
exec gitleaks protect --staged --config .gitleaks.toml --no-banner
|
||||
EOF
|
||||
|
||||
chmod +x "$HOOK"
|
||||
echo "Pre-commit hook installed at ${HOOK}"
|
||||
@@ -0,0 +1,37 @@
|
||||
# stacks/docker/
|
||||
|
||||
Main self-hosted application stack. This directory will be populated via `git subtree`
|
||||
from the existing `docker` repo to preserve full commit history.
|
||||
|
||||
## Migration (Phase 1 of implementation plan)
|
||||
|
||||
```bash
|
||||
# From the repo root — do this once after initial push to Gitea:
|
||||
git subtree add --prefix=stacks/docker /home/wayne/repos/docker main --squash
|
||||
|
||||
# To pull future updates from the source repo:
|
||||
git subtree pull --prefix=stacks/docker /home/wayne/repos/docker main --squash
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
Once migrated, this directory contains:
|
||||
|
||||
| Layer | Services |
|
||||
|-------|---------|
|
||||
| `core/` | Traefik, Authelia, CrowdSec, Docker Socket Proxy, Error Pages |
|
||||
| `apps/` | Nextcloud, Passbolt, Gitea, Gramps Web, SearXNG |
|
||||
| `monitoring/` | Beszel, Gotify, Uptime Kuma, WUD |
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
./services-up.sh up -d
|
||||
|
||||
# Validate compose interpolation (no deployment)
|
||||
./services-up.sh config
|
||||
|
||||
# Check status
|
||||
./services-up.sh ps
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# stacks/raspi/
|
||||
|
||||
Raspberry Pi edge monitoring stack. This directory will be populated via `git subtree`
|
||||
from the existing `raspi` repo to preserve full commit history.
|
||||
|
||||
## Migration (Phase 1 of implementation plan)
|
||||
|
||||
```bash
|
||||
# From the repo root — do this once after initial push to Gitea:
|
||||
git subtree add --prefix=stacks/raspi /home/wayne/repos/raspi main --squash
|
||||
|
||||
# To pull future updates from the source repo:
|
||||
git subtree pull --prefix=stacks/raspi /home/wayne/repos/raspi main --squash
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Purpose |
|
||||
|---------|---------|
|
||||
| Traefik | Reverse proxy + TLS (LetsEncrypt) |
|
||||
| Uptime Kuma | Uptime monitoring dashboard |
|
||||
| CrowdSec | Threat detection (Traefik plugin) |
|
||||
| Beszel Agent | Host metrics → central hub on docker host |
|
||||
| Docker Socket Proxy | Secure Docker API access |
|
||||
|
||||
## Deployment
|
||||
|
||||
The Raspberry Pi is accessed via Tailscale (`raspberrypi.tail13f623.ts.net`).
|
||||
|
||||
```bash
|
||||
# Deploy from within the raspi/ directory on the Pi:
|
||||
./services-up.sh up -d
|
||||
|
||||
# Or from this workstation via SSH:
|
||||
ssh wayne@raspberrypi.tail13f623.ts.net "cd ~/raspi && ./services-up.sh up -d"
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
crash.log
|
||||
override.tf
|
||||
override.tf.json
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
*.tfplan
|
||||
plan.out
|
||||
state/
|
||||
artifacts/
|
||||
@@ -0,0 +1,52 @@
|
||||
# terraform/
|
||||
|
||||
Infrastructure state management. Each subdirectory is an independent Terraform workspace
|
||||
with its own state backend.
|
||||
|
||||
## Workspaces
|
||||
|
||||
| Directory | What it manages | State backend |
|
||||
|-----------|----------------|---------------|
|
||||
| `proxmox/` | Proxmox VMs and LXCs on pve1 | Remote (configure in bootstrap/) |
|
||||
| `dns/` | Dynu dynamic DNS records | Remote |
|
||||
| `docker/` | Docker container catalog (documentation-only, read-only) | Local |
|
||||
| `bootstrap/` | Remote state backend resources | Local (chicken-and-egg) |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Dry-run: show what would change
|
||||
cd terraform/proxmox
|
||||
terraform init
|
||||
terraform plan
|
||||
|
||||
# Detect drift only (exit 2 = drift, exit 0 = in sync)
|
||||
terraform plan -detailed-exitcode
|
||||
|
||||
# Apply (confirm first — always review the plan)
|
||||
terraform apply
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
Never commit credentials. Use `terraform.tfvars` (git-ignored) or environment variables:
|
||||
|
||||
```bash
|
||||
# Proxmox
|
||||
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>"
|
||||
|
||||
# Dynu DNS
|
||||
export TF_VAR_dynu_api_key="<api_key>"
|
||||
```
|
||||
|
||||
## Importing existing resources
|
||||
|
||||
See `docs/internal/implementation-plan.md` Phase 2 for the `terraform import` commands
|
||||
to bring existing Proxmox resources under state management.
|
||||
|
||||
## Drift detection
|
||||
|
||||
The `drift-detect` CI workflow runs `terraform plan -detailed-exitcode` daily on all
|
||||
workspaces and sends a Gotify notification if any drift is detected.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Dynu Terraform Layer (Brownfield DNS Reconciliation)
|
||||
|
||||
This Terraform root is for **Dynu DNS brownfield reconciliation**. The intended pattern is:
|
||||
|
||||
1. Import the existing root domain object.
|
||||
2. Read inventory through `data.dynu_dns_records.root`.
|
||||
3. Generate reviewable `dynu_dns_record` resources and import commands.
|
||||
4. Import every existing DNS record into matching Terraform resources.
|
||||
5. Use `terraform plan` as the reconciliation check before any apply.
|
||||
|
||||
## Provider behavior to keep in mind
|
||||
|
||||
- Source: `beatz174-bit/dynu`
|
||||
- `dynu_domain` import requires a **numeric Dynu domain ID**.
|
||||
- Importing `dynu_domain` imports only the root domain object.
|
||||
- It **does not** import DNS records/subdomains.
|
||||
- `dynu_dns_record` imports require `<domain_id>/<record_id>`.
|
||||
|
||||
## Variables
|
||||
|
||||
- `dynu_root_domain` (default: `lan.ddnsgeek.com`)
|
||||
- `dynu_api_key` (sensitive)
|
||||
- `dynu_username` / `dynu_password` (optional)
|
||||
|
||||
## Safe validation commands
|
||||
|
||||
```bash
|
||||
cd infrastructure/terraform/dynu
|
||||
terraform fmt -check -recursive
|
||||
terraform init -backend=false -input=false
|
||||
terraform validate
|
||||
python3 -m py_compile scripts/generate-brownfield-records.py
|
||||
```
|
||||
|
||||
## Brownfield workflow
|
||||
|
||||
```bash
|
||||
cd infrastructure/terraform/dynu
|
||||
|
||||
terraform init
|
||||
terraform import dynu_domain.lan_ddnsgeek_com '<numeric-dynu-domain-id>'
|
||||
|
||||
terraform apply -refresh-only
|
||||
terraform output -json dynu_dns_records > /tmp/dynu-records.json
|
||||
|
||||
python3 scripts/generate-brownfield-records.py --dry-run
|
||||
python3 scripts/generate-brownfield-records.py --overwrite
|
||||
|
||||
# Review generated/dynu_dns_records.generated.tf
|
||||
# Review generated/import-dynu-dns-records.sh
|
||||
|
||||
bash generated/import-dynu-dns-records.sh
|
||||
|
||||
terraform plan
|
||||
```
|
||||
|
||||
## What each component means
|
||||
|
||||
- `data.dynu_dns_records.root`: read-only live inventory from Dynu.
|
||||
- `generated/dynu_dns_records.generated.tf`: generated management-intent resources; includes `prevent_destroy = true` on each record.
|
||||
- `generated/import-dynu-dns-records.sh`: imports each discovered record to its generated `dynu_dns_record` address using `<domain_id>/<record_id>`.
|
||||
- `terraform plan` after imports: reconciliation checkpoint. Any create/update/delete must be reviewed manually before apply.
|
||||
|
||||
## Generated artifacts
|
||||
|
||||
The helper script writes these files under `generated/`:
|
||||
|
||||
- `generated/dynu_dns_records_inventory.json`
|
||||
- `generated/dynu_dns_records.generated.tf`
|
||||
- `generated/import-dynu-dns-records.sh`
|
||||
|
||||
These are generated outputs meant for operator review before use in production.
|
||||
|
||||
|
||||
### Generator output selection (interactive + automation)
|
||||
|
||||
The brownfield generator defaults to Terraform output `dynu_dns_records`:
|
||||
|
||||
```bash
|
||||
python3 scripts/generate-brownfield-records.py --dry-run
|
||||
```
|
||||
|
||||
If the default output is missing/unusable and stdin is interactive, the script shows a picker of available Terraform outputs and indicates which ones are usable for DNS imports.
|
||||
|
||||
```bash
|
||||
# Interactive mode: choose from available Terraform outputs
|
||||
python3 scripts/generate-brownfield-records.py --dry-run
|
||||
|
||||
# Non-interactive mode: specify output explicitly
|
||||
python3 scripts/generate-brownfield-records.py \
|
||||
--records-output dynu_dns_inventory \
|
||||
--dry-run
|
||||
|
||||
# Disable menu and fail fast
|
||||
python3 scripts/generate-brownfield-records.py \
|
||||
--no-interactive \
|
||||
--dry-run
|
||||
|
||||
# Use saved terraform output JSON and choose interactively
|
||||
terraform output -json > generated/terraform-output.json
|
||||
python3 scripts/generate-brownfield-records.py \
|
||||
--from-file generated/terraform-output.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The menu shows Terraform outputs currently stored in state.
|
||||
- If newly added outputs do not appear, run:
|
||||
|
||||
```bash
|
||||
terraform apply -refresh-only
|
||||
```
|
||||
|
||||
- The selected output must contain real Dynu provider record fields:
|
||||
- `id`
|
||||
- `domain_id`
|
||||
- `hostname`
|
||||
- `record_type`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plan shows a large wall of `+` values under outputs
|
||||
|
||||
Cause:
|
||||
|
||||
Terraform is planning to save **new output values** to state (for example, live records from `data.dynu_dns_records.root`). This is not creating DNS records by itself.
|
||||
|
||||
How to verify:
|
||||
|
||||
- Output-only changes appear under `Changes to Outputs`.
|
||||
- Real DNS changes appear as `dynu_dns_record` resource create/update/delete actions.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
terraform apply -refresh-only
|
||||
```
|
||||
|
||||
to persist refreshed data source and output values only.
|
||||
|
||||
### Error: `There is no function named "regexreplace"`
|
||||
|
||||
Cause:
|
||||
|
||||
`regexreplace` is not a Terraform function. Resource-name slugification should not be implemented in Terraform HCL for this workflow.
|
||||
|
||||
Fix:
|
||||
|
||||
- Keep `inventory.tf` focused on reading live records via `data.dynu_dns_records.root`.
|
||||
- Keep Terraform outputs simple (for example, `<domain_id>/<record_id>` mappings).
|
||||
- Let `scripts/generate-brownfield-records.py` generate Terraform-safe resource names with Python `tf_name(record)`.
|
||||
|
||||
### Error: `'"'"'dynu_dns_records'"'"'`
|
||||
|
||||
Cause:
|
||||
|
||||
The helper script reads `terraform output -json` and expects an output named `dynu_dns_records`.
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
cd infrastructure/terraform/dynu
|
||||
terraform init
|
||||
terraform apply -refresh-only
|
||||
terraform output -json | jq 'keys'
|
||||
```
|
||||
|
||||
Confirm `dynu_dns_records` appears in the key list.
|
||||
|
||||
If it does not, check that the Terraform config contains:
|
||||
|
||||
```hcl
|
||||
data "dynu_dns_records" "root" {
|
||||
hostname = var.dynu_root_domain
|
||||
}
|
||||
|
||||
output "dynu_dns_records" {
|
||||
value = data.dynu_dns_records.root.records
|
||||
}
|
||||
```
|
||||
|
||||
Then rerun:
|
||||
|
||||
```bash
|
||||
python3 scripts/generate-brownfield-records.py --dry-run
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
data "dynu_domain" "lan" {
|
||||
hostname = "lan.ddnsgeek.com"
|
||||
}
|
||||
|
||||
output "dynu_domain_id" {
|
||||
value = data.dynu_domain.lan.domain.id
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
locals {
|
||||
dynu_domain = var.dynu_root_domain
|
||||
}
|
||||
|
||||
# Import-first resource skeleton for the production Dynu zone.
|
||||
# `name` is required by provider schema and can be reconciled after import.
|
||||
resource "dynu_domain" "lan_ddnsgeek_com" {
|
||||
name = local.dynu_domain
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERATED FILE - REVIEW BEFORE USE
|
||||
#
|
||||
# Generated from Dynu brownfield DNS inventory.
|
||||
# Do not blindly apply this file to production DNS.
|
||||
# Import records into Terraform state before allowing Terraform to manage them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "dynu_dns_record" "auth_lan_ddnsgeek_com_a_18483099" {
|
||||
hostname = "auth.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "auth"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "auth_lan_ddnsgeek_com_a_19646048" {
|
||||
hostname = "auth.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "auth"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "edge_lan_ddnsgeek_com_a_10453241" {
|
||||
hostname = "edge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "edge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "edge_lan_ddnsgeek_com_a_19646062" {
|
||||
hostname = "edge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "edge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "familytree_lan_ddnsgeek_com_a_17017685" {
|
||||
hostname = "familytree.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "familytree"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "familytree_lan_ddnsgeek_com_a_19646056" {
|
||||
hostname = "familytree.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "familytree"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gitea_lan_ddnsgeek_com_a_14682463" {
|
||||
hostname = "gitea.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "gitea"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gitea_lan_ddnsgeek_com_a_19646063" {
|
||||
hostname = "gitea.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "gitea"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gotify_lan_ddnsgeek_com_a_17439061" {
|
||||
hostname = "gotify.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "gotify"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gotify_lan_ddnsgeek_com_a_19646047" {
|
||||
hostname = "gotify.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "gotify"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "grafana_lan_ddnsgeek_com_a_18113762" {
|
||||
hostname = "grafana.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "grafana"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "grafana_lan_ddnsgeek_com_a_19646050" {
|
||||
hostname = "grafana.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "grafana"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "influxdb_lan_ddnsgeek_com_a_18562198" {
|
||||
hostname = "influxdb.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "influxdb"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "influxdb_lan_ddnsgeek_com_a_19646059" {
|
||||
hostname = "influxdb.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "influxdb"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "kuma_lan_ddnsgeek_com_a_17454978" {
|
||||
hostname = "kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 90
|
||||
enabled = true
|
||||
content = "120.155.99.146"
|
||||
node_name = "kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "lan_ddnsgeek_com_soa_8299670" {
|
||||
hostname = "lan.ddnsgeek.com"
|
||||
record_type = "SOA"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "ns1.dynu.com. administrator.dynu.com. 0 3600 900 604800 300"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "monitor_kuma_lan_ddnsgeek_com_a_17462342" {
|
||||
hostname = "monitor-kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "monitor-kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "monitor_kuma_lan_ddnsgeek_com_a_19646051" {
|
||||
hostname = "monitor-kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "monitor-kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "mtls_bridge_lan_ddnsgeek_com_a_19232643" {
|
||||
hostname = "mtls-bridge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "mtls-bridge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "mtls_bridge_lan_ddnsgeek_com_a_19646058" {
|
||||
hostname = "mtls-bridge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "mtls-bridge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "nextcloud_lan_ddnsgeek_com_a_10453260" {
|
||||
hostname = "nextcloud.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "nextcloud"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "nextcloud_lan_ddnsgeek_com_a_19646057" {
|
||||
hostname = "nextcloud.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "nextcloud"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "node_red_lan_ddnsgeek_com_a_19041230" {
|
||||
hostname = "node-red.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "node-red"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "node_red_lan_ddnsgeek_com_a_19646053" {
|
||||
hostname = "node-red.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "node-red"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "passbolt_lan_ddnsgeek_com_a_10453262" {
|
||||
hostname = "passbolt.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "passbolt"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "passbolt_lan_ddnsgeek_com_a_19646049" {
|
||||
hostname = "passbolt.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "passbolt"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "portainer_lan_ddnsgeek_com_a_17458810" {
|
||||
hostname = "portainer.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "portainer"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "portainer_lan_ddnsgeek_com_a_19646046" {
|
||||
hostname = "portainer.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "portainer"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "prometheus_lan_ddnsgeek_com_a_18483311" {
|
||||
hostname = "prometheus.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "prometheus"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "prometheus_lan_ddnsgeek_com_a_19646061" {
|
||||
hostname = "prometheus.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "prometheus"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "searxng_lan_ddnsgeek_com_a_10453263" {
|
||||
hostname = "searxng.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "searxng"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "searxng_lan_ddnsgeek_com_a_19646055" {
|
||||
hostname = "searxng.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "searxng"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "shifts_lan_ddnsgeek_com_a_15901565" {
|
||||
hostname = "shifts.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "shifts"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "shifts_lan_ddnsgeek_com_a_19646052" {
|
||||
hostname = "shifts.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "shifts"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "stockfill_lan_ddnsgeek_com_a_17081867" {
|
||||
hostname = "stockfill.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "stockfill"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "stockfill_lan_ddnsgeek_com_a_19646060" {
|
||||
hostname = "stockfill.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "stockfill"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "traefik_lan_ddnsgeek_com_a_10453240" {
|
||||
hostname = "traefik.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "traefik"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "traefik_lan_ddnsgeek_com_a_19646054" {
|
||||
hostname = "traefik.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "traefik"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERATED FILE - REVIEW BEFORE USE
|
||||
#
|
||||
# Generated from Dynu brownfield DNS inventory.
|
||||
# Do not blindly apply this file to production DNS.
|
||||
# Import records into Terraform state before allowing Terraform to manage them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "dynu_dns_record" "auth_lan_ddnsgeek_com_a_18483099" {
|
||||
hostname = "auth.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "auth"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "auth_lan_ddnsgeek_com_a_19646048" {
|
||||
hostname = "auth.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "auth"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "edge_lan_ddnsgeek_com_a_10453241" {
|
||||
hostname = "edge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "edge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "edge_lan_ddnsgeek_com_a_19646062" {
|
||||
hostname = "edge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "edge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "familytree_lan_ddnsgeek_com_a_17017685" {
|
||||
hostname = "familytree.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "familytree"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "familytree_lan_ddnsgeek_com_a_19646056" {
|
||||
hostname = "familytree.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "familytree"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gitea_lan_ddnsgeek_com_a_14682463" {
|
||||
hostname = "gitea.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "gitea"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gitea_lan_ddnsgeek_com_a_19646063" {
|
||||
hostname = "gitea.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "gitea"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gotify_lan_ddnsgeek_com_a_17439061" {
|
||||
hostname = "gotify.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "gotify"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "gotify_lan_ddnsgeek_com_a_19646047" {
|
||||
hostname = "gotify.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "gotify"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "grafana_lan_ddnsgeek_com_a_18113762" {
|
||||
hostname = "grafana.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "grafana"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "grafana_lan_ddnsgeek_com_a_19646050" {
|
||||
hostname = "grafana.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "grafana"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "influxdb_lan_ddnsgeek_com_a_18562198" {
|
||||
hostname = "influxdb.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "influxdb"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "influxdb_lan_ddnsgeek_com_a_19646059" {
|
||||
hostname = "influxdb.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "influxdb"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "kuma_lan_ddnsgeek_com_a_17454978" {
|
||||
hostname = "kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 60
|
||||
enabled = true
|
||||
content = "120.155.99.146"
|
||||
node_name = "kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "lan_ddnsgeek_com_soa_8299670" {
|
||||
hostname = "lan.ddnsgeek.com"
|
||||
record_type = "SOA"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "ns1.dynu.com. administrator.dynu.com. 0 3600 900 604800 300"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "monitor_kuma_lan_ddnsgeek_com_a_17462342" {
|
||||
hostname = "monitor-kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "monitor-kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "monitor_kuma_lan_ddnsgeek_com_a_19646051" {
|
||||
hostname = "monitor-kuma.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "monitor-kuma"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "mtls_bridge_lan_ddnsgeek_com_a_19232643" {
|
||||
hostname = "mtls-bridge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "mtls-bridge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "mtls_bridge_lan_ddnsgeek_com_a_19646058" {
|
||||
hostname = "mtls-bridge.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "mtls-bridge"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "nextcloud_lan_ddnsgeek_com_a_10453260" {
|
||||
hostname = "nextcloud.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "nextcloud"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "nextcloud_lan_ddnsgeek_com_a_19646057" {
|
||||
hostname = "nextcloud.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "nextcloud"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "node_red_lan_ddnsgeek_com_a_19041230" {
|
||||
hostname = "node-red.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "node-red"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "node_red_lan_ddnsgeek_com_a_19646053" {
|
||||
hostname = "node-red.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "node-red"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "passbolt_lan_ddnsgeek_com_a_10453262" {
|
||||
hostname = "passbolt.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "passbolt"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "passbolt_lan_ddnsgeek_com_a_19646049" {
|
||||
hostname = "passbolt.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "passbolt"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "portainer_lan_ddnsgeek_com_a_17458810" {
|
||||
hostname = "portainer.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "portainer"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "portainer_lan_ddnsgeek_com_a_19646046" {
|
||||
hostname = "portainer.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "portainer"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "prometheus_lan_ddnsgeek_com_a_18483311" {
|
||||
hostname = "prometheus.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "prometheus"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "prometheus_lan_ddnsgeek_com_a_19646061" {
|
||||
hostname = "prometheus.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "prometheus"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "searxng_lan_ddnsgeek_com_a_10453263" {
|
||||
hostname = "searxng.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "searxng"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "searxng_lan_ddnsgeek_com_a_19646055" {
|
||||
hostname = "searxng.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "searxng"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "shifts_lan_ddnsgeek_com_a_15901565" {
|
||||
hostname = "shifts.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "shifts"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "shifts_lan_ddnsgeek_com_a_19646052" {
|
||||
hostname = "shifts.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "shifts"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "stockfill_lan_ddnsgeek_com_a_17081867" {
|
||||
hostname = "stockfill.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "stockfill"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "stockfill_lan_ddnsgeek_com_a_19646060" {
|
||||
hostname = "stockfill.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "stockfill"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "traefik_lan_ddnsgeek_com_a_10453240" {
|
||||
hostname = "traefik.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
dynamic = true
|
||||
node_name = "traefik"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "traefik_lan_ddnsgeek_com_a_19646054" {
|
||||
hostname = "traefik.lan.ddnsgeek.com"
|
||||
record_type = "A"
|
||||
ttl = 120
|
||||
enabled = true
|
||||
content = "167.179.167.166"
|
||||
group = "home"
|
||||
node_name = "traefik"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"domain":"lan.ddnsgeek.com","provider":"dynu","record_count":15,"records":{"auth":{"fqdn":"auth.lan.ddnsgeek.com","hostname":"auth","proxied":null,"purpose":"Authentication portal","record_type":null,"service":"authelia","source":"core/authelia/docker-compose.yml","target":null,"ttl":null},"familytree":{"fqdn":"familytree.lan.ddnsgeek.com","hostname":"familytree","proxied":null,"purpose":"Family tree application","record_type":null,"service":"gramps","source":"apps/gramps/docker-compose.yml","target":null,"ttl":null},"gitea":{"fqdn":"gitea.lan.ddnsgeek.com","hostname":"gitea","proxied":null,"purpose":"Gitea service endpoint","record_type":null,"service":"gitea","source":"apps/gitea/docker-compose.yml","target":null,"ttl":null},"gotify":{"fqdn":"gotify.lan.ddnsgeek.com","hostname":"gotify","proxied":null,"purpose":"Gotify notifications","record_type":null,"service":"gotify","source":"monitoring/gotify/docker-compose.yml","target":null,"ttl":null},"grafana":{"fqdn":"grafana.lan.ddnsgeek.com","hostname":"grafana","proxied":null,"purpose":"Grafana monitoring UI","record_type":null,"service":"grafana","source":"monitoring/grafana/docker-compose.yml","target":null,"ttl":null},"influxdb":{"fqdn":"influxdb.lan.ddnsgeek.com","hostname":"influxdb","proxied":null,"purpose":"InfluxDB metrics endpoint","record_type":null,"service":"influxdb","source":"monitoring/influxdb/docker-compose.yml","target":null,"ttl":null},"monitor_kuma":{"fqdn":"monitor-kuma.lan.ddnsgeek.com","hostname":"monitor-kuma","proxied":null,"purpose":"Uptime Kuma monitoring UI","record_type":null,"service":"uptime-kuma","source":"monitoring/uptime-kuma/docker-compose.yml","target":null,"ttl":null},"mtls_bridge":{"fqdn":"mtls-bridge.lan.ddnsgeek.com","hostname":"mtls-bridge","proxied":null,"purpose":"mTLS bridge API","record_type":null,"service":"mtls-bridge","source":"monitoring/mtls-bridge/docker-compose.yml","target":null,"ttl":null},"nextcloud":{"fqdn":"nextcloud.lan.ddnsgeek.com","hostname":"nextcloud","proxied":null,"purpose":"Nextcloud service endpoint","record_type":null,"service":"nextcloud-webapp","source":"apps/nextcloud/docker-compose.yml","target":null,"ttl":null},"node_red":{"fqdn":"node-red.lan.ddnsgeek.com","hostname":"node-red","proxied":null,"purpose":"Node-RED automation UI/API","record_type":null,"service":"node-red","source":"monitoring/node-red/docker-compose.yml","target":null,"ttl":null},"passbolt":{"fqdn":"passbolt.lan.ddnsgeek.com","hostname":"passbolt","proxied":null,"purpose":"Passbolt password management","record_type":null,"service":"passbolt-webapp","source":"apps/passbolt/docker-compose.yml","target":null,"ttl":null},"portainer":{"fqdn":"portainer.lan.ddnsgeek.com","hostname":"portainer","proxied":null,"purpose":"Portainer admin endpoint","record_type":null,"service":"portainer","source":"monitoring/portainer/docker-compose.yml","target":null,"ttl":null},"prometheus":{"fqdn":"prometheus.lan.ddnsgeek.com","hostname":"prometheus","proxied":null,"purpose":"Prometheus metrics endpoint","record_type":null,"service":"prometheus","source":"monitoring/prometheus/docker-compose.yml","target":null,"ttl":null},"searxng":{"fqdn":"searxng.lan.ddnsgeek.com","hostname":"searxng","proxied":null,"purpose":"SearXNG search endpoint","record_type":null,"service":"searxng","source":"apps/searxng/docker-compose.yml","target":null,"ttl":null},"traefik":{"fqdn":"traefik.lan.ddnsgeek.com","hostname":"traefik","proxied":null,"purpose":"Traefik dashboard/API endpoint","record_type":null,"service":"traefik","source":"core/traefik/docker-compose.yml","target":null,"ttl":null}}}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERATED FILE - REVIEW BEFORE USE
|
||||
#
|
||||
# Imports existing Dynu DNS records into Terraform state.
|
||||
# Does not apply changes.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TF_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${TF_ROOT}"
|
||||
|
||||
# Re-running imports will fail for resources already in state.
|
||||
# This script skips imports when state already contains the resource address.
|
||||
|
||||
if terraform state show 'dynu_dns_record.auth_lan_ddnsgeek_com_a_18483099' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.auth_lan_ddnsgeek_com_a_18483099'
|
||||
else
|
||||
terraform import 'dynu_dns_record.auth_lan_ddnsgeek_com_a_18483099' '9695470/18483099'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.auth_lan_ddnsgeek_com_a_19646048' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.auth_lan_ddnsgeek_com_a_19646048'
|
||||
else
|
||||
terraform import 'dynu_dns_record.auth_lan_ddnsgeek_com_a_19646048' '9695470/19646048'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.edge_lan_ddnsgeek_com_a_10453241' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.edge_lan_ddnsgeek_com_a_10453241'
|
||||
else
|
||||
terraform import 'dynu_dns_record.edge_lan_ddnsgeek_com_a_10453241' '9695470/10453241'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.edge_lan_ddnsgeek_com_a_19646062' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.edge_lan_ddnsgeek_com_a_19646062'
|
||||
else
|
||||
terraform import 'dynu_dns_record.edge_lan_ddnsgeek_com_a_19646062' '9695470/19646062'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.familytree_lan_ddnsgeek_com_a_17017685' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.familytree_lan_ddnsgeek_com_a_17017685'
|
||||
else
|
||||
terraform import 'dynu_dns_record.familytree_lan_ddnsgeek_com_a_17017685' '9695470/17017685'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.familytree_lan_ddnsgeek_com_a_19646056' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.familytree_lan_ddnsgeek_com_a_19646056'
|
||||
else
|
||||
terraform import 'dynu_dns_record.familytree_lan_ddnsgeek_com_a_19646056' '9695470/19646056'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.gitea_lan_ddnsgeek_com_a_14682463' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.gitea_lan_ddnsgeek_com_a_14682463'
|
||||
else
|
||||
terraform import 'dynu_dns_record.gitea_lan_ddnsgeek_com_a_14682463' '9695470/14682463'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.gitea_lan_ddnsgeek_com_a_19646063' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.gitea_lan_ddnsgeek_com_a_19646063'
|
||||
else
|
||||
terraform import 'dynu_dns_record.gitea_lan_ddnsgeek_com_a_19646063' '9695470/19646063'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.gotify_lan_ddnsgeek_com_a_17439061' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.gotify_lan_ddnsgeek_com_a_17439061'
|
||||
else
|
||||
terraform import 'dynu_dns_record.gotify_lan_ddnsgeek_com_a_17439061' '9695470/17439061'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.gotify_lan_ddnsgeek_com_a_19646047' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.gotify_lan_ddnsgeek_com_a_19646047'
|
||||
else
|
||||
terraform import 'dynu_dns_record.gotify_lan_ddnsgeek_com_a_19646047' '9695470/19646047'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.grafana_lan_ddnsgeek_com_a_18113762' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.grafana_lan_ddnsgeek_com_a_18113762'
|
||||
else
|
||||
terraform import 'dynu_dns_record.grafana_lan_ddnsgeek_com_a_18113762' '9695470/18113762'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.grafana_lan_ddnsgeek_com_a_19646050' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.grafana_lan_ddnsgeek_com_a_19646050'
|
||||
else
|
||||
terraform import 'dynu_dns_record.grafana_lan_ddnsgeek_com_a_19646050' '9695470/19646050'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.influxdb_lan_ddnsgeek_com_a_18562198' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.influxdb_lan_ddnsgeek_com_a_18562198'
|
||||
else
|
||||
terraform import 'dynu_dns_record.influxdb_lan_ddnsgeek_com_a_18562198' '9695470/18562198'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.influxdb_lan_ddnsgeek_com_a_19646059' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.influxdb_lan_ddnsgeek_com_a_19646059'
|
||||
else
|
||||
terraform import 'dynu_dns_record.influxdb_lan_ddnsgeek_com_a_19646059' '9695470/19646059'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.kuma_lan_ddnsgeek_com_a_17454978' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.kuma_lan_ddnsgeek_com_a_17454978'
|
||||
else
|
||||
terraform import 'dynu_dns_record.kuma_lan_ddnsgeek_com_a_17454978' '9695470/17454978'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.lan_ddnsgeek_com_soa_8299670' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.lan_ddnsgeek_com_soa_8299670'
|
||||
else
|
||||
terraform import 'dynu_dns_record.lan_ddnsgeek_com_soa_8299670' '9695470/8299670'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_17462342' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_17462342'
|
||||
else
|
||||
terraform import 'dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_17462342' '9695470/17462342'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_19646051' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_19646051'
|
||||
else
|
||||
terraform import 'dynu_dns_record.monitor_kuma_lan_ddnsgeek_com_a_19646051' '9695470/19646051'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19232643' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19232643'
|
||||
else
|
||||
terraform import 'dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19232643' '9695470/19232643'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19646058' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19646058'
|
||||
else
|
||||
terraform import 'dynu_dns_record.mtls_bridge_lan_ddnsgeek_com_a_19646058' '9695470/19646058'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_10453260' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_10453260'
|
||||
else
|
||||
terraform import 'dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_10453260' '9695470/10453260'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_19646057' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_19646057'
|
||||
else
|
||||
terraform import 'dynu_dns_record.nextcloud_lan_ddnsgeek_com_a_19646057' '9695470/19646057'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.node_red_lan_ddnsgeek_com_a_19041230' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.node_red_lan_ddnsgeek_com_a_19041230'
|
||||
else
|
||||
terraform import 'dynu_dns_record.node_red_lan_ddnsgeek_com_a_19041230' '9695470/19041230'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.node_red_lan_ddnsgeek_com_a_19646053' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.node_red_lan_ddnsgeek_com_a_19646053'
|
||||
else
|
||||
terraform import 'dynu_dns_record.node_red_lan_ddnsgeek_com_a_19646053' '9695470/19646053'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.passbolt_lan_ddnsgeek_com_a_10453262' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.passbolt_lan_ddnsgeek_com_a_10453262'
|
||||
else
|
||||
terraform import 'dynu_dns_record.passbolt_lan_ddnsgeek_com_a_10453262' '9695470/10453262'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.passbolt_lan_ddnsgeek_com_a_19646049' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.passbolt_lan_ddnsgeek_com_a_19646049'
|
||||
else
|
||||
terraform import 'dynu_dns_record.passbolt_lan_ddnsgeek_com_a_19646049' '9695470/19646049'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.portainer_lan_ddnsgeek_com_a_17458810' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.portainer_lan_ddnsgeek_com_a_17458810'
|
||||
else
|
||||
terraform import 'dynu_dns_record.portainer_lan_ddnsgeek_com_a_17458810' '9695470/17458810'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.portainer_lan_ddnsgeek_com_a_19646046' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.portainer_lan_ddnsgeek_com_a_19646046'
|
||||
else
|
||||
terraform import 'dynu_dns_record.portainer_lan_ddnsgeek_com_a_19646046' '9695470/19646046'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.prometheus_lan_ddnsgeek_com_a_18483311' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.prometheus_lan_ddnsgeek_com_a_18483311'
|
||||
else
|
||||
terraform import 'dynu_dns_record.prometheus_lan_ddnsgeek_com_a_18483311' '9695470/18483311'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.prometheus_lan_ddnsgeek_com_a_19646061' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.prometheus_lan_ddnsgeek_com_a_19646061'
|
||||
else
|
||||
terraform import 'dynu_dns_record.prometheus_lan_ddnsgeek_com_a_19646061' '9695470/19646061'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.searxng_lan_ddnsgeek_com_a_10453263' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.searxng_lan_ddnsgeek_com_a_10453263'
|
||||
else
|
||||
terraform import 'dynu_dns_record.searxng_lan_ddnsgeek_com_a_10453263' '9695470/10453263'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.searxng_lan_ddnsgeek_com_a_19646055' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.searxng_lan_ddnsgeek_com_a_19646055'
|
||||
else
|
||||
terraform import 'dynu_dns_record.searxng_lan_ddnsgeek_com_a_19646055' '9695470/19646055'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.shifts_lan_ddnsgeek_com_a_15901565' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.shifts_lan_ddnsgeek_com_a_15901565'
|
||||
else
|
||||
terraform import 'dynu_dns_record.shifts_lan_ddnsgeek_com_a_15901565' '9695470/15901565'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.shifts_lan_ddnsgeek_com_a_19646052' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.shifts_lan_ddnsgeek_com_a_19646052'
|
||||
else
|
||||
terraform import 'dynu_dns_record.shifts_lan_ddnsgeek_com_a_19646052' '9695470/19646052'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.stockfill_lan_ddnsgeek_com_a_17081867' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.stockfill_lan_ddnsgeek_com_a_17081867'
|
||||
else
|
||||
terraform import 'dynu_dns_record.stockfill_lan_ddnsgeek_com_a_17081867' '9695470/17081867'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.stockfill_lan_ddnsgeek_com_a_19646060' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.stockfill_lan_ddnsgeek_com_a_19646060'
|
||||
else
|
||||
terraform import 'dynu_dns_record.stockfill_lan_ddnsgeek_com_a_19646060' '9695470/19646060'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.traefik_lan_ddnsgeek_com_a_10453240' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.traefik_lan_ddnsgeek_com_a_10453240'
|
||||
else
|
||||
terraform import 'dynu_dns_record.traefik_lan_ddnsgeek_com_a_10453240' '9695470/10453240'
|
||||
fi
|
||||
|
||||
if terraform state show 'dynu_dns_record.traefik_lan_ddnsgeek_com_a_19646054' >/dev/null 2>&1; then
|
||||
echo 'Skipping already imported: dynu_dns_record.traefik_lan_ddnsgeek_com_a_19646054'
|
||||
else
|
||||
terraform import 'dynu_dns_record.traefik_lan_ddnsgeek_com_a_19646054' '9695470/19646054'
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copy this file to imports.tf and adjust IDs after confirming the
|
||||
# published provider docs for import ID formats.
|
||||
# For dynu_domain, import ID is commonly the root domain name.
|
||||
|
||||
import {
|
||||
to = dynu_domain.lan_ddnsgeek_com
|
||||
id = var.dynu_root_domain
|
||||
}
|
||||
|
||||
# DNS record imports are intentionally examples only because the provider
|
||||
# requires explicit record_type/hostname in config before import.
|
||||
#
|
||||
# import {
|
||||
# to = dynu_dns_record.grafana_lan_ddnsgeek_com
|
||||
# id = var.dynu_record_import_id
|
||||
# }
|
||||
@@ -0,0 +1,3 @@
|
||||
data "dynu_dns_records" "root" {
|
||||
hostname = var.dynu_root_domain
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
output "dynu_domain" {
|
||||
description = "Primary Dynu domain represented by this Terraform root."
|
||||
value = local.dynu_domain
|
||||
}
|
||||
|
||||
output "dynu_dns_records_catalog" {
|
||||
description = "Documentation catalog of expected Dynu DNS records discovered from repo service exposure."
|
||||
value = local.dynu_dns_records_catalog
|
||||
}
|
||||
|
||||
output "dynu_dns_inventory" {
|
||||
description = "Documentation-friendly Dynu DNS inventory for export and merge into broader infrastructure docs."
|
||||
value = {
|
||||
provider = "dynu"
|
||||
domain = local.dynu_domain
|
||||
record_count = length(local.dynu_dns_records_catalog)
|
||||
records = local.dynu_dns_records_catalog
|
||||
}
|
||||
}
|
||||
|
||||
output "dynu_root_domain_id" {
|
||||
description = "Dynu numeric domain ID resolved from dynu_root_domain."
|
||||
value = data.dynu_dns_records.root.domain_id
|
||||
}
|
||||
|
||||
output "dynu_root_domain_name" {
|
||||
description = "Dynu root domain name resolved from dynu_root_domain."
|
||||
value = data.dynu_dns_records.root.domain_name
|
||||
}
|
||||
|
||||
output "dynu_dns_records" {
|
||||
description = "Full read-only DNS record inventory returned by Dynu."
|
||||
value = data.dynu_dns_records.root.records
|
||||
}
|
||||
|
||||
output "dynu_dns_hostnames" {
|
||||
description = "Sorted hostname list discovered for dynu_root_domain."
|
||||
value = sort(distinct([for record in data.dynu_dns_records.root.records : record.hostname]))
|
||||
}
|
||||
|
||||
output "dynu_dns_record_import_ids" {
|
||||
description = "Map of Dynu DNS record identity to provider import IDs in domain_id/record_id format."
|
||||
value = {
|
||||
for record in data.dynu_dns_records.root.records :
|
||||
format("%s/%s/%s", record.hostname, record.record_type, record.id) => format("%s/%s", record.domain_id, record.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
provider "dynu" {
|
||||
# Keep auth local-only; do not commit credentials.
|
||||
api_key = var.dynu_api_key
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
locals {
|
||||
dynu_dns_records_catalog_base = {
|
||||
auth = {
|
||||
hostname = "auth"
|
||||
service = "authelia"
|
||||
source = "core/authelia/docker-compose.yml"
|
||||
purpose = "Authentication portal"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
gitea = {
|
||||
hostname = "gitea"
|
||||
service = "gitea"
|
||||
source = "apps/gitea/docker-compose.yml"
|
||||
purpose = "Gitea service endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
gotify = {
|
||||
hostname = "gotify"
|
||||
service = "gotify"
|
||||
source = "monitoring/gotify/docker-compose.yml"
|
||||
purpose = "Gotify notifications"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
grafana = {
|
||||
hostname = "grafana"
|
||||
service = "grafana"
|
||||
source = "monitoring/grafana/docker-compose.yml"
|
||||
purpose = "Grafana monitoring UI"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
familytree = {
|
||||
hostname = "familytree"
|
||||
service = "gramps"
|
||||
source = "apps/gramps/docker-compose.yml"
|
||||
purpose = "Family tree application"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
influxdb = {
|
||||
hostname = "influxdb"
|
||||
service = "influxdb"
|
||||
source = "monitoring/influxdb/docker-compose.yml"
|
||||
purpose = "InfluxDB metrics endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
monitor_kuma = {
|
||||
hostname = "monitor-kuma"
|
||||
service = "uptime-kuma"
|
||||
source = "monitoring/uptime-kuma/docker-compose.yml"
|
||||
purpose = "Uptime Kuma monitoring UI"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
mtls_bridge = {
|
||||
hostname = "mtls-bridge"
|
||||
service = "mtls-bridge"
|
||||
source = "monitoring/mtls-bridge/docker-compose.yml"
|
||||
purpose = "mTLS bridge API"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
nextcloud = {
|
||||
hostname = "nextcloud"
|
||||
service = "nextcloud-webapp"
|
||||
source = "apps/nextcloud/docker-compose.yml"
|
||||
purpose = "Nextcloud service endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
node_red = {
|
||||
hostname = "node-red"
|
||||
service = "node-red"
|
||||
source = "monitoring/node-red/docker-compose.yml"
|
||||
purpose = "Node-RED automation UI/API"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
passbolt = {
|
||||
hostname = "passbolt"
|
||||
service = "passbolt-webapp"
|
||||
source = "apps/passbolt/docker-compose.yml"
|
||||
purpose = "Passbolt password management"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
portainer = {
|
||||
hostname = "portainer"
|
||||
service = "portainer"
|
||||
source = "monitoring/portainer/docker-compose.yml"
|
||||
purpose = "Portainer admin endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
prometheus = {
|
||||
hostname = "prometheus"
|
||||
service = "prometheus"
|
||||
source = "monitoring/prometheus/docker-compose.yml"
|
||||
purpose = "Prometheus metrics endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
searxng = {
|
||||
hostname = "searxng"
|
||||
service = "searxng"
|
||||
source = "apps/searxng/docker-compose.yml"
|
||||
purpose = "SearXNG search endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
traefik = {
|
||||
hostname = "traefik"
|
||||
service = "traefik"
|
||||
source = "core/traefik/docker-compose.yml"
|
||||
purpose = "Traefik dashboard/API endpoint"
|
||||
record_type = null
|
||||
ttl = null
|
||||
target = null
|
||||
proxied = null
|
||||
}
|
||||
}
|
||||
|
||||
dynu_dns_records_catalog = {
|
||||
for key, record in local.dynu_dns_records_catalog_base :
|
||||
key => merge(record, {
|
||||
fqdn = format("%s.%s", record.hostname, local.dynu_domain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Terraform dynu_dns_record resources/import commands from Dynu inventory outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
TF_ROOT = SCRIPT_PATH.parents[1]
|
||||
GENERATED_DIR = TF_ROOT / "generated"
|
||||
TF_FILE = GENERATED_DIR / "dynu_dns_records.generated.tf"
|
||||
IMPORT_SCRIPT = GENERATED_DIR / "import-dynu-dns-records.sh"
|
||||
INVENTORY_FILE = GENERATED_DIR / "dynu_dns_records_inventory.json"
|
||||
DEFAULT_RECORDS_OUTPUT = "dynu_dns_records"
|
||||
REQUIRED_RECORD_FIELDS = ("id", "domain_id", "hostname", "record_type")
|
||||
|
||||
HEADER_TF = """# ---------------------------------------------------------------------------
|
||||
# GENERATED FILE - REVIEW BEFORE USE
|
||||
#
|
||||
# Generated from Dynu brownfield DNS inventory.
|
||||
# Do not blindly apply this file to production DNS.
|
||||
# Import records into Terraform state before allowing Terraform to manage them.
|
||||
# ---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
HEADER_SH = """#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# GENERATED FILE - REVIEW BEFORE USE
|
||||
#
|
||||
# Imports existing Dynu DNS records into Terraform state.
|
||||
# Does not apply changes.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TF_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${TF_ROOT}"
|
||||
|
||||
# Re-running imports will fail for resources already in state.
|
||||
# This script skips imports when state already contains the resource address.
|
||||
"""
|
||||
|
||||
OPTIONAL_FIELDS = ["group", "host", "priority", "weight", "port", "flags", "tag", "value", "node_name"]
|
||||
|
||||
|
||||
def run_terraform_output() -> dict:
|
||||
if not (TF_ROOT / ".terraform").exists():
|
||||
raise RuntimeError("Terraform is not initialized in infrastructure/terraform/dynu. Run: terraform init")
|
||||
|
||||
cmd = ["terraform", "output", "-json"]
|
||||
proc = subprocess.run(cmd, cwd=TF_ROOT, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to run {' '.join(cmd)}:\n{proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def type_shape_name(value: object) -> str:
|
||||
if isinstance(value, list):
|
||||
return "list"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return type(value).__name__
|
||||
|
||||
|
||||
def extract_records(payload: object, output_name: str) -> list[dict]:
|
||||
source = payload
|
||||
if isinstance(payload, list):
|
||||
source = payload
|
||||
elif isinstance(payload, dict):
|
||||
if isinstance(payload.get("value"), list):
|
||||
source = payload["value"]
|
||||
elif isinstance(payload.get("records"), list):
|
||||
source = payload["records"]
|
||||
elif isinstance(payload.get("value"), dict) and isinstance(payload["value"].get("records"), list):
|
||||
source = payload["value"]["records"]
|
||||
elif output_name in payload and isinstance(payload[output_name], dict):
|
||||
output_wrapper = payload[output_name]
|
||||
if isinstance(output_wrapper.get("value"), list):
|
||||
source = output_wrapper["value"]
|
||||
elif isinstance(output_wrapper.get("value"), dict) and isinstance(output_wrapper["value"].get("records"), list):
|
||||
source = output_wrapper["value"]["records"]
|
||||
elif isinstance(output_wrapper.get("records"), list):
|
||||
source = output_wrapper["records"]
|
||||
else:
|
||||
raise RuntimeError(f"Output '{output_name}' does not contain a records list.")
|
||||
else:
|
||||
raise RuntimeError(f"Output '{output_name}' not found and no records list discovered.")
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported JSON payload type: {type(payload).__name__}")
|
||||
|
||||
if not isinstance(source, list):
|
||||
raise RuntimeError(f"Output '{output_name}' did not resolve to a list of records.")
|
||||
return source
|
||||
|
||||
|
||||
def validate_records(records: list[dict], output_name: str) -> None:
|
||||
for i, record in enumerate(records):
|
||||
if not isinstance(record, dict):
|
||||
raise RuntimeError(f"Selected output '{output_name}' has non-object record at index {i}: {type(record).__name__}.")
|
||||
missing = [field for field in REQUIRED_RECORD_FIELDS if field not in record]
|
||||
if missing:
|
||||
missing_text = ", ".join(missing)
|
||||
raise RuntimeError(
|
||||
f"Selected output '{output_name}' contains records, but they are not importable Dynu provider records. "
|
||||
f"Record #{i} is missing required fields: {missing_text}. "
|
||||
"Choose an output sourced from data.dynu_dns_records.root, such as dynu_dns_records or dynu_dns_inventory."
|
||||
)
|
||||
|
||||
|
||||
def describe_output(output_name: str, output_wrapper: object, full_outputs: dict) -> dict:
|
||||
details = {
|
||||
"name": output_name,
|
||||
"usable": False,
|
||||
"shape": type_shape_name(output_wrapper),
|
||||
"record_count": "none",
|
||||
"error": "no records list found",
|
||||
}
|
||||
if isinstance(output_wrapper, dict) and "value" in output_wrapper:
|
||||
details["shape"] = type_shape_name(output_wrapper.get("value"))
|
||||
|
||||
try:
|
||||
records = extract_records(full_outputs, output_name)
|
||||
except RuntimeError as exc:
|
||||
details["error"] = str(exc)
|
||||
return details
|
||||
|
||||
details["record_count"] = len(records)
|
||||
try:
|
||||
validate_records(records, output_name)
|
||||
except RuntimeError as exc:
|
||||
details["error"] = str(exc)
|
||||
if isinstance(output_wrapper, dict) and isinstance(output_wrapper.get("value"), dict) and isinstance(output_wrapper["value"].get("records"), list):
|
||||
details["shape"] = "object with records list"
|
||||
elif isinstance(output_wrapper, dict) and isinstance(output_wrapper.get("value"), list):
|
||||
details["shape"] = "list"
|
||||
return details
|
||||
|
||||
details["usable"] = True
|
||||
details["error"] = None
|
||||
if isinstance(output_wrapper, dict) and isinstance(output_wrapper.get("value"), dict) and isinstance(output_wrapper["value"].get("records"), list):
|
||||
details["shape"] = "object with records list"
|
||||
elif isinstance(output_wrapper, dict) and isinstance(output_wrapper.get("value"), list):
|
||||
details["shape"] = "list"
|
||||
return details
|
||||
|
||||
|
||||
def choose_output_interactively(outputs: dict, descriptions: list[dict]) -> str | None:
|
||||
print("\nAvailable Terraform outputs:\n")
|
||||
indexed = {str(i): item for i, item in enumerate(descriptions, 1)}
|
||||
by_name = {item["name"]: item for item in descriptions}
|
||||
|
||||
for i, item in enumerate(descriptions, 1):
|
||||
print(f" {i}) {item['name']}")
|
||||
print(f" usable: {'yes' if item['usable'] else 'no'}")
|
||||
print(f" shape: {item['shape']}")
|
||||
print(f" record count: {item['record_count']}")
|
||||
if item["error"]:
|
||||
print(f" reason: {item['error']}")
|
||||
print()
|
||||
|
||||
attempts = 0
|
||||
while attempts < 3:
|
||||
attempts += 1
|
||||
try:
|
||||
selection = input(f"Choose an output to use for DNS records [1-{len(descriptions)}], or press Enter to cancel: ").strip()
|
||||
except KeyboardInterrupt:
|
||||
print("\nSelection cancelled.")
|
||||
return None
|
||||
|
||||
if selection == "":
|
||||
print("Selection cancelled.")
|
||||
return None
|
||||
|
||||
candidate = indexed.get(selection) or by_name.get(selection)
|
||||
if candidate is None:
|
||||
print("Invalid selection. Enter a number from the list or an exact output name.")
|
||||
continue
|
||||
|
||||
if not candidate["usable"]:
|
||||
print(f"Output '{candidate['name']}' is not usable: {candidate['error']}")
|
||||
continue
|
||||
|
||||
return candidate["name"]
|
||||
|
||||
raise RuntimeError("Too many invalid selections. Exiting without writing files.")
|
||||
|
||||
|
||||
def tf_name(record: dict) -> str:
|
||||
base = f"{record.get('hostname', '')}_{record.get('record_type', '')}_{record.get('id', '')}".lower()
|
||||
base = base.replace("*", "wildcard")
|
||||
base = re.sub(r"[^a-z0-9_]+", "_", base)
|
||||
base = re.sub(r"_+", "_", base).strip("_")
|
||||
if not base or not re.match(r"^[a-z]", base):
|
||||
base = f"record_{base}" if base else "record"
|
||||
if not base.endswith(str(record.get("id", ""))):
|
||||
base = f"{base}_{record.get('id', '')}"
|
||||
return base
|
||||
|
||||
|
||||
def hcl_value(value):
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def generate_resources(records: list[dict]) -> str:
|
||||
chunks = [HEADER_TF.rstrip(), ""]
|
||||
for rec in records:
|
||||
name = tf_name(rec)
|
||||
lines = [f'resource "dynu_dns_record" "{name}" {{']
|
||||
lines.append(f" hostname = {hcl_value(rec.get('hostname'))}")
|
||||
lines.append(f" record_type = {hcl_value(rec.get('record_type'))}")
|
||||
if rec.get("ttl") is not None:
|
||||
lines.append(f" ttl = {hcl_value(rec.get('ttl'))}")
|
||||
enabled = rec.get("enabled")
|
||||
if enabled is None:
|
||||
enabled = rec.get("state")
|
||||
if enabled is not None:
|
||||
lines.append(f" enabled = {hcl_value(enabled)}")
|
||||
|
||||
content = rec.get("content")
|
||||
rtype = str(rec.get("record_type", "")).upper()
|
||||
if content in (None, "") and rtype in {"A", "AAAA"}:
|
||||
lines.append(" dynamic = true")
|
||||
elif content not in (None, ""):
|
||||
lines.append(f" content = {hcl_value(content)}")
|
||||
|
||||
for field in OPTIONAL_FIELDS:
|
||||
value = rec.get(field)
|
||||
if value not in (None, ""):
|
||||
lines.append(f" {field.ljust(11)}= {hcl_value(value)}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
" lifecycle {",
|
||||
" prevent_destroy = true",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
chunks.extend(lines)
|
||||
return "\n".join(chunks).rstrip() + "\n"
|
||||
|
||||
|
||||
def generate_import_script(records: list[dict]) -> str:
|
||||
lines = [HEADER_SH.rstrip(), ""]
|
||||
for rec in records:
|
||||
name = tf_name(rec)
|
||||
import_id = f"{rec['domain_id']}/{rec['id']}"
|
||||
addr = f"dynu_dns_record.{name}"
|
||||
lines.append(f"if terraform state show '{addr}' >/dev/null 2>&1; then")
|
||||
lines.append(f" echo 'Skipping already imported: {addr}'")
|
||||
lines.append("else")
|
||||
lines.append(f" terraform import '{addr}' '{import_id}'")
|
||||
lines.append("fi")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_file(path: Path, content: str, dry_run: bool, overwrite: bool) -> None:
|
||||
if path.exists() and not overwrite:
|
||||
raise RuntimeError(f"Refusing to overwrite existing file: {path}. Re-run with --overwrite.")
|
||||
if dry_run:
|
||||
print(f"[dry-run] Would write {path}")
|
||||
return
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f"Wrote {path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print intended output paths without writing files.")
|
||||
parser.add_argument("--overwrite", "--force", action="store_true", dest="overwrite", help="Overwrite existing generated files.")
|
||||
parser.add_argument("--from-file", type=Path, help="Load inventory JSON from a file instead of calling terraform output.")
|
||||
parser.add_argument(
|
||||
"--records-output",
|
||||
default=None,
|
||||
help=(
|
||||
"Terraform output name containing Dynu DNS records. "
|
||||
f"Defaults to {DEFAULT_RECORDS_OUTPUT}; if missing in an interactive terminal, "
|
||||
"the script prompts you to choose from available outputs."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--no-interactive", action="store_true", help="Disable interactive output selection.")
|
||||
args = parser.parse_args()
|
||||
|
||||
records_output_explicit = args.records_output is not None
|
||||
records_output = args.records_output or DEFAULT_RECORDS_OUTPUT
|
||||
|
||||
try:
|
||||
payload = json.loads(args.from_file.read_text(encoding="utf-8")) if args.from_file else run_terraform_output()
|
||||
|
||||
selected_output = records_output
|
||||
descriptions: list[dict] = []
|
||||
if isinstance(payload, dict):
|
||||
descriptions = [describe_output(name, payload[name], payload) for name in sorted(payload)]
|
||||
|
||||
try:
|
||||
records = extract_records(payload, selected_output)
|
||||
validate_records(records, selected_output)
|
||||
except RuntimeError as exc:
|
||||
is_interactive = sys.stdin.isatty() and not args.no_interactive
|
||||
should_prompt = isinstance(payload, dict) and not records_output_explicit and is_interactive
|
||||
if should_prompt:
|
||||
print(f"Terraform output '{selected_output}' was not found or is unusable.\n")
|
||||
chosen = choose_output_interactively(payload, descriptions)
|
||||
if chosen is None:
|
||||
print("Exiting without writing files.")
|
||||
return 1
|
||||
selected_output = chosen
|
||||
records = extract_records(payload, selected_output)
|
||||
validate_records(records, selected_output)
|
||||
else:
|
||||
if isinstance(payload, dict):
|
||||
available = ", ".join(sorted(payload.keys())) or "(none)"
|
||||
if records_output_explicit:
|
||||
raise RuntimeError(
|
||||
f"Missing or unusable Terraform output '{selected_output}'. "
|
||||
f"Available outputs: {available}. Details: {exc}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Missing or unusable Terraform output '{selected_output}'. "
|
||||
f"Available outputs: {available}.\n\n"
|
||||
"Run interactively to choose an output, or pass one explicitly, for example:\n\n"
|
||||
" python3 scripts/generate-brownfield-records.py --records-output dynu_dns_inventory --dry-run"
|
||||
)
|
||||
raise
|
||||
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
write_file(INVENTORY_FILE, json.dumps(records, indent=2, sort_keys=True) + "\n", args.dry_run, args.overwrite)
|
||||
write_file(TF_FILE, generate_resources(records), args.dry_run, args.overwrite)
|
||||
write_file(IMPORT_SCRIPT, generate_import_script(records), args.dry_run, args.overwrite)
|
||||
if not args.dry_run:
|
||||
IMPORT_SCRIPT.chmod(0o755)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
# Local-only credentials. Do not commit real values.
|
||||
dynu_api_key = "replace-with-dynu-api-key"
|
||||
dynu_username = null
|
||||
dynu_password = null
|
||||
|
||||
dynu_root_domain = "lan.ddnsgeek.com"
|
||||
dynu_record_import_id = "REPLACE_WITH_DYNU_RECORD_IMPORT_ID"
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
variable "dynu_root_domain" {
|
||||
description = "Dynu root domain name to reconcile/import (for example: lan.ddnsgeek.com)."
|
||||
type = string
|
||||
default = "lan.ddnsgeek.com"
|
||||
}
|
||||
|
||||
variable "dynu_api_key" {
|
||||
description = "Dynu API key/token used by the Dynu Terraform provider."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "dynu_username" {
|
||||
description = "Optional Dynu username, only if required by the provider."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "dynu_password" {
|
||||
description = "Optional Dynu password, only if required by the provider."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "dynu_record_import_id" {
|
||||
description = "Placeholder import ID for a single dynu_dns_record during one-at-a-time reconciliation."
|
||||
type = string
|
||||
default = "REPLACE_WITH_DYNU_RECORD_IMPORT_ID"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
dynu = {
|
||||
source = "beatz174-bit/dynu"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Terraform Docker Mirror Layer
|
||||
|
||||
This directory tracks selected existing Docker containers in Terraform for inventory/documentation purposes.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Mirror specific running containers as Terraform resources.
|
||||
- Reconcile imported state into maintainable code.
|
||||
- Produce structured outputs/reminders that support documentation workflows.
|
||||
|
||||
## Boundary with Docker Compose
|
||||
|
||||
Docker Compose + `services-up.sh` remain runtime composition authority.
|
||||
|
||||
Terraform resources here are **not** the primary day-to-day deployment mechanism for app services.
|
||||
|
||||
## Current contents
|
||||
|
||||
- `main.tf` — import-first workflow notes and minimal scaffolding.
|
||||
- `searxng-webapp.tf` — generated/reconciled example container resource.
|
||||
- `outputs.tf` — documentation-oriented reminders/outputs.
|
||||
- `terraform.tfvars.example` — safe template for local values.
|
||||
|
||||
## Import/reconciliation workflow
|
||||
|
||||
1. Start with one existing container.
|
||||
2. Import with `import {}` block or `terraform import`.
|
||||
3. Inspect state / generated config.
|
||||
4. Reduce generated attributes to meaningful, stable arguments.
|
||||
5. Keep lifecycle `ignore_changes` narrow and justified.
|
||||
6. Iterate until plan is clean for the intended resource.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not attempt to mirror all containers in one pass.
|
||||
- Do not commit local state or real credentials.
|
||||
- Treat generated config as draft input that needs review.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [../README.md](../README.md)
|
||||
- [../../../docs/source-of-truth.md](../../../docs/source-of-truth.md)
|
||||
- [../../../docs/terraform-workflows.md](../../../docs/terraform-workflows.md)
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "authelia" {
|
||||
name = local.docker_containers["authelia"].container_name
|
||||
image = local.docker_containers["authelia"].image
|
||||
|
||||
restart = local.docker_containers["authelia"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
locals {
|
||||
docker_containers = {
|
||||
"authelia" = {
|
||||
terraform_resource = "docker_container.authelia"
|
||||
compose_project = "core"
|
||||
compose_service = "authelia"
|
||||
compose_file = "core/authelia/docker-compose.yml"
|
||||
container_name = "authelia"
|
||||
image = "authelia/authelia"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/core/authelia->/config"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/core/authelia"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.middlewares.authelia.forwardauth.address" = "http://authelia:9091/api/verify?rd=https://auth.lan.ddnsgeek.com/"
|
||||
"traefik.http.middlewares.authelia.forwardauth.authResponseHeaders" = "Remote-User,Remote-Groups"
|
||||
"traefik.http.middlewares.authelia.forwardauth.maxResponseBodySize" = "2097152"
|
||||
"traefik.http.middlewares.authelia.forwardauth.trustForwardHeader" = "true"
|
||||
"traefik.http.routers.authelia.entrypoints" = "websecure"
|
||||
"traefik.http.routers.authelia.rule" = "Host(`auth.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.authelia.tls" = "true"
|
||||
"traefik.http.routers.authelia.tls.certresolver" = "myresolver"
|
||||
}
|
||||
}
|
||||
"crowdsec" = {
|
||||
terraform_resource = "docker_container.crowdsec"
|
||||
compose_project = "core"
|
||||
compose_service = "crowdsec"
|
||||
compose_file = "core/crowdsec/docker-compose.yml"
|
||||
container_name = "crowdsec"
|
||||
image = "core-crowdsec"
|
||||
image_source = "compose_build_inferred"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/core/crowdsec/logs->/logs:ro", "bind:/home/nixos/docker/core/crowdsec/data->/var/lib/crowdsec/data", "bind:/home/nixos/docker/core/crowdsec/config->/etc/crowdsec"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/core/crowdsec"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {}
|
||||
}
|
||||
"docker-socket-proxy" = {
|
||||
terraform_resource = "docker_container.docker_socket_proxy"
|
||||
compose_project = "core"
|
||||
compose_service = "docker-socket-proxy"
|
||||
compose_file = "monitoring/docker-socket-proxy/docker-compose.yml"
|
||||
container_name = "docker-socket-proxy"
|
||||
image = "tecnativa/docker-socket-proxy:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/var/run/docker.sock->/var/run/docker.sock:ro"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"docker-update-exporter" = {
|
||||
terraform_resource = "docker_container.docker_update_exporter"
|
||||
compose_project = "core"
|
||||
compose_service = "docker-update-exporter"
|
||||
compose_file = "monitoring/docker-exporter/docker-compose.yml"
|
||||
container_name = "docker-update-exporter"
|
||||
image = "core-docker-update-exporter"
|
||||
image_source = "compose_build_inferred"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor"]
|
||||
mounts = ["bind:/root/.docker/config.json->/root/.docker/config.json:ro", "bind:/home/nixos/docker/monitoring/docker-exporter/data->/data", "bind:/home/nixos/docker->/compose:ro"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/monitoring/docker-exporter"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {}
|
||||
}
|
||||
"error-pages" = {
|
||||
terraform_resource = "docker_container.error_pages"
|
||||
compose_project = "core"
|
||||
compose_service = "error-pages"
|
||||
compose_file = "core/error-pages/docker-compose.yml"
|
||||
container_name = "error-pages"
|
||||
image = "tarampampam/error-pages:3"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = []
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.middlewares.error-pages-middleware.errors.query" = "/{status}.html"
|
||||
"traefik.http.middlewares.error-pages-middleware.errors.service" = "error-pages-service"
|
||||
"traefik.http.middlewares.error-pages-middleware.errors.status" = "400-599"
|
||||
"traefik.http.routers.error-pages-router.entrypoints" = "web"
|
||||
"traefik.http.routers.error-pages-router.middlewares" = "error-pages-middleware"
|
||||
"traefik.http.routers.error-pages-router.rule" = "HostRegexp(`{host:.+}`)"
|
||||
"traefik.http.services.error-pages-service.loadbalancer.server.port" = "8080"
|
||||
}
|
||||
}
|
||||
"gitea" = {
|
||||
terraform_resource = "docker_container.gitea"
|
||||
compose_project = "core"
|
||||
compose_service = "gitea"
|
||||
compose_file = "apps/gitea/docker-compose.yml"
|
||||
container_name = "gitea"
|
||||
image = "gitea/gitea:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/gitea/data->/data"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.gitea.entrypoints" = "websecure"
|
||||
"traefik.http.routers.gitea.rule" = "Host(`gitea.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.gitea.tls" = "true"
|
||||
"traefik.http.routers.gitea.tls.certresolver" = "myresolver"
|
||||
"traefik.http.services.gitea.loadbalancer.server.port" = "3000"
|
||||
}
|
||||
}
|
||||
"gotify" = {
|
||||
terraform_resource = "docker_container.gotify"
|
||||
compose_project = "core"
|
||||
compose_service = "gotify"
|
||||
compose_file = "monitoring/gotify/docker-compose.yml"
|
||||
container_name = "gotify"
|
||||
image = "gotify/server:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/gotify/data->/app/data"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.gotify.entrypoints" = "websecure"
|
||||
"traefik.http.routers.gotify.rule" = "Host(`gotify.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.gotify.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.gotify.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.gotify.loadbalancer.server.port" = "80"
|
||||
}
|
||||
}
|
||||
"grafana" = {
|
||||
terraform_resource = "docker_container.grafana"
|
||||
compose_project = "core"
|
||||
compose_service = "grafana"
|
||||
compose_file = "monitoring/grafana/docker-compose.yml"
|
||||
container_name = "grafana"
|
||||
image = "grafana/grafana:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/grafana/data->/var/lib/grafana"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.grafana.entrypoints" = "websecure"
|
||||
"traefik.http.routers.grafana.rule" = "Host(`grafana.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.grafana.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.grafana.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.grafana.loadbalancer.server.port" = "3000"
|
||||
}
|
||||
}
|
||||
"gramps-redis" = {
|
||||
terraform_resource = "docker_container.gramps_redis"
|
||||
compose_project = "core"
|
||||
compose_service = "gramps-redis"
|
||||
compose_file = "apps/gramps/docker-compose.yml"
|
||||
container_name = "gramps-redis"
|
||||
image = "valkey/valkey:8-alpine"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["gramps"]
|
||||
mounts = []
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"gramps-web" = {
|
||||
terraform_resource = "docker_container.gramps_web"
|
||||
compose_project = "core"
|
||||
compose_service = "grampsweb"
|
||||
compose_file = "apps/gramps/docker-compose.yml"
|
||||
container_name = "gramps-web"
|
||||
image = "ghcr.io/gramps-project/grampsweb:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["gramps", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/gramps/data/users->/app/users", "bind:/home/nixos/docker/apps/gramps/data/index->/app/indexdir", "bind:/home/nixos/docker/apps/gramps/data/thumbnail_cache->/app/thumbnail_cache", "bind:/home/nixos/docker/apps/gramps/data/cache->/app/cache", "bind:/home/nixos/docker/apps/gramps/data/secret->/app/secret", "bind:/home/nixos/docker/apps/gramps/data/db->/root/.gramps/grampsdb", "bind:/home/nixos/docker/apps/gramps/data/media->/app/media", "bind:/home/nixos/docker/apps/gramps/data/tmp->/tmp"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.gramps.entrypoints" = "websecure"
|
||||
"traefik.http.routers.gramps.rule" = "Host(`familytree.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.gramps.tls.certresolver" = "myresolver"
|
||||
"traefik.http.services.gramps.loadbalancer.server.port" = "5000"
|
||||
}
|
||||
}
|
||||
"gramps-web-celery" = {
|
||||
terraform_resource = "docker_container.gramps_web_celery"
|
||||
compose_project = "core"
|
||||
compose_service = "grampsweb_celery"
|
||||
compose_file = "apps/gramps/docker-compose.yml"
|
||||
container_name = "gramps-web-celery"
|
||||
image = "ghcr.io/gramps-project/grampsweb:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["gramps"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/gramps/data/users->/app/users", "bind:/home/nixos/docker/apps/gramps/data/index->/app/indexdir", "bind:/home/nixos/docker/apps/gramps/data/thumbnail_cache->/app/thumbnail_cache", "bind:/home/nixos/docker/apps/gramps/data/cache->/app/cache", "bind:/home/nixos/docker/apps/gramps/data/secret->/app/secret", "bind:/home/nixos/docker/apps/gramps/data/db->/root/.gramps/grampsdb", "bind:/home/nixos/docker/apps/gramps/data/media->/app/media", "bind:/home/nixos/docker/apps/gramps/data/tmp->/tmp"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"influxdb" = {
|
||||
terraform_resource = "docker_container.influxdb"
|
||||
compose_project = "core"
|
||||
compose_service = "influxdb"
|
||||
compose_file = "monitoring/influxdb/docker-compose.yml"
|
||||
container_name = "influxdb"
|
||||
image = "influxdb:2.7"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/influxdb->/var/lib/influxdb2"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.influxdb.entrypoints" = "websecure"
|
||||
"traefik.http.routers.influxdb.middlewares" = "authelia"
|
||||
"traefik.http.routers.influxdb.rule" = "Host(`influxdb.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.influxdb.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.influxdb.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.influxdb.loadbalancer.server.port" = "8086"
|
||||
}
|
||||
}
|
||||
"monitor-kuma" = {
|
||||
terraform_resource = "docker_container.monitor_kuma"
|
||||
compose_project = "core"
|
||||
compose_service = "monitor-kuma"
|
||||
compose_file = "monitoring/uptime-kuma/docker-compose.yml"
|
||||
container_name = "monitor-kuma"
|
||||
image = "louislam/uptime-kuma:2.1.1"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/uptime-kuma/data->/app/data"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.monitor.entrypoints" = "websecure"
|
||||
"traefik.http.routers.monitor.rule" = "Host(`monitor-kuma.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.monitor.tls" = "true"
|
||||
"traefik.http.routers.monitor.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.monitor.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.monitor.loadbalancer.server.port" = "3001"
|
||||
}
|
||||
}
|
||||
"mtls-bridge" = {
|
||||
terraform_resource = "docker_container.mtls_bridge"
|
||||
compose_project = "core"
|
||||
compose_service = "mtls-bridge"
|
||||
compose_file = "monitoring/mtls-bridge/docker-compose.yml"
|
||||
container_name = "mtls-bridge"
|
||||
image = "core-mtls-bridge"
|
||||
image_source = "compose_build_inferred"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/core/traefik/certs->/certs:ro"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/monitoring/mtls-bridge"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.middlewares.mtls-bridge-auth.basicauth.users" = ""
|
||||
"traefik.http.middlewares.mtls-bridge-cors.headers.accesscontrolallowcredentials" = "true"
|
||||
"traefik.http.middlewares.mtls-bridge-cors.headers.accesscontrolallowheaders" = "authorization,content-type,x-grafana-action,x-grafana-device-id"
|
||||
"traefik.http.middlewares.mtls-bridge-cors.headers.accesscontrolallowmethods" = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||
"traefik.http.middlewares.mtls-bridge-cors.headers.accesscontrolalloworiginlist" = "https://grafana.lan.ddnsgeek.com"
|
||||
"traefik.http.middlewares.mtls-bridge-cors.headers.addvaryheader" = "true"
|
||||
"traefik.http.routers.mtls-bridge-preflight.entrypoints" = "websecure"
|
||||
"traefik.http.routers.mtls-bridge-preflight.middlewares" = "mtls-bridge-cors"
|
||||
"traefik.http.routers.mtls-bridge-preflight.priority" = "100"
|
||||
"traefik.http.routers.mtls-bridge-preflight.rule" = "Host(`mtls-bridge.lan.ddnsgeek.com`) && Method(`OPTIONS`)"
|
||||
"traefik.http.routers.mtls-bridge-preflight.service" = "mtls-bridge"
|
||||
"traefik.http.routers.mtls-bridge-preflight.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.mtls-bridge.entrypoints" = "websecure"
|
||||
"traefik.http.routers.mtls-bridge.middlewares" = "mtls-bridge-auth,mtls-bridge-cors"
|
||||
"traefik.http.routers.mtls-bridge.rule" = "Host(`mtls-bridge.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.mtls-bridge.tls.certresolver" = "myresolver"
|
||||
"traefik.http.services.mtls-bridge.loadbalancer.server.port" = "8080"
|
||||
}
|
||||
}
|
||||
"nextcloud-db" = {
|
||||
terraform_resource = "docker_container.nextcloud_db"
|
||||
compose_project = "core"
|
||||
compose_service = "nextcloud-db"
|
||||
compose_file = "apps/nextcloud/docker-compose.yml"
|
||||
container_name = "nextcloud-db"
|
||||
image = "mariadb:11.4"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["nextcloud"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/nextcloud/database->/var/lib/mysql"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"nextcloud-redis" = {
|
||||
terraform_resource = "docker_container.nextcloud_redis"
|
||||
compose_project = "core"
|
||||
compose_service = "nextcloud-redis"
|
||||
compose_file = "apps/nextcloud/docker-compose.yml"
|
||||
container_name = "nextcloud-redis"
|
||||
image = "redis"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["nextcloud"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/nextcloud/data/redis->/data"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"nextcloud-webapp" = {
|
||||
terraform_resource = "docker_container.nextcloud_webapp"
|
||||
compose_project = "core"
|
||||
compose_service = "nextcloud-webapp"
|
||||
compose_file = "apps/nextcloud/docker-compose.yml"
|
||||
container_name = "nextcloud-webapp"
|
||||
image = "core-nextcloud-webapp"
|
||||
image_source = "compose_build_inferred"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["nextcloud", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/nextcloud/data->/var/www/html/data", "bind:/home/nixos/docker/apps/nextcloud/config->/var/www/html/config", "tmpfs:->/tmp:exec"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/apps/nextcloud"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.middlewares.nextcloud-dav.replacepathregex.regex" = "^/.well-known/ca(l|rd)dav"
|
||||
"traefik.http.middlewares.nextcloud-dav.replacepathregex.replacement" = "/remote.php/dav/"
|
||||
"traefik.http.middlewares.nextcloud-nodeinfo.replacepathregex.regex" = "^/.well-known/nodeinfo"
|
||||
"traefik.http.middlewares.nextcloud-nodeinfo.replacepathregex.replacement" = "/nextcloud/index.php/.well-known/nodeinfo/"
|
||||
"traefik.http.middlewares.nextcloud-webfinger.redirectregex.permanent" = "true"
|
||||
"traefik.http.middlewares.nextcloud-webfinger.redirectregex.regex" = "https://(.*)/.well-known/webfinger"
|
||||
"traefik.http.middlewares.nextcloud-webfinger.redirectregex.replacement" = "https://$${1}/nextcloud/index.php/.well-known/webfinger"
|
||||
"traefik.http.routers.nextcloud.entrypoints" = "websecure"
|
||||
"traefik.http.routers.nextcloud.middlewares" = "nextcloud-dav, nextcloud-webfinger"
|
||||
"traefik.http.routers.nextcloud.rule" = "Host(`nextcloud.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.nextcloud.tls.certresolver" = "myresolver"
|
||||
}
|
||||
}
|
||||
"node-exporter" = {
|
||||
terraform_resource = "docker_container.node_exporter"
|
||||
compose_project = "core"
|
||||
compose_service = "node-exporter"
|
||||
compose_file = "monitoring/node-exporter/docker-compose.yml"
|
||||
container_name = "node-exporter"
|
||||
image = "prom/node-exporter:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor"]
|
||||
mounts = ["bind:/proc->/host/proc:ro", "bind:/sys->/host/sys:ro", "bind:/->/rootfs:ro"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"node-red" = {
|
||||
terraform_resource = "docker_container.node_red"
|
||||
compose_project = "core"
|
||||
compose_service = "node-red"
|
||||
compose_file = "monitoring/node-red/docker-compose.yml"
|
||||
container_name = "node-red"
|
||||
image = "core-node-red"
|
||||
image_source = "compose_build_inferred"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/node-red/data->/data", "bind:/home/nixos/docker->/compose/docker:ro", "bind:/home/nixos/raspi->/compose/raspi:ro"]
|
||||
published_ports = []
|
||||
build_context = "/home/nixos/docker/monitoring/node-red"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.node-red.entrypoints" = "websecure"
|
||||
"traefik.http.routers.node-red.middlewares" = "authelia"
|
||||
"traefik.http.routers.node-red.rule" = "Host(`node-red.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.node-red.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.node-red.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.node-red.loadbalancer.server.port" = "1880"
|
||||
}
|
||||
}
|
||||
"passbolt-db" = {
|
||||
terraform_resource = "docker_container.passbolt_db"
|
||||
compose_project = "core"
|
||||
compose_service = "passbolt-db"
|
||||
compose_file = "apps/passbolt/docker-compose.yml"
|
||||
container_name = "passbolt-db"
|
||||
image = "mariadb:12"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["passbolt"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/passbolt/data/database->/var/lib/mysql"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"passbolt-webapp" = {
|
||||
terraform_resource = "docker_container.passbolt_webapp"
|
||||
compose_project = "core"
|
||||
compose_service = "passbolt-webapp"
|
||||
compose_file = "apps/passbolt/docker-compose.yml"
|
||||
container_name = "passbolt-webapp"
|
||||
image = "passbolt/passbolt:latest-ce"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["passbolt", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/apps/passbolt/data/gpg->/etc/passbolt/gpg", "bind:/home/nixos/docker/apps/passbolt/data/jwt->/etc/passbolt/jwt"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.passbolt.entrypoints" = "websecure"
|
||||
"traefik.http.routers.passbolt.rule" = "Host(`passbolt.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.passbolt.tls.certresolver" = "myresolver"
|
||||
}
|
||||
}
|
||||
"pihole-exporter" = {
|
||||
terraform_resource = "docker_container.pihole_exporter"
|
||||
compose_project = "core"
|
||||
compose_service = "pihole-exporter"
|
||||
compose_file = "monitoring/pihole-exporter/docker-compose.yml"
|
||||
container_name = "pihole-exporter"
|
||||
image = "ekofr/pihole-exporter:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor"]
|
||||
mounts = []
|
||||
published_ports = ["9617:9617/tcp"]
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"portainer" = {
|
||||
terraform_resource = "docker_container.portainer"
|
||||
compose_project = "core"
|
||||
compose_service = "portainer"
|
||||
compose_file = "monitoring/portainer/docker-compose.yml"
|
||||
container_name = "portainer"
|
||||
image = "portainer/portainer-ce:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/portainer/data->/data"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.portainer.entrypoints" = "websecure"
|
||||
"traefik.http.routers.portainer.rule" = "Host(`portainer.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.portainer.tls" = "true"
|
||||
"traefik.http.routers.portainer.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.portainer.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.portainer.loadbalancer.server.port" = "9000"
|
||||
}
|
||||
}
|
||||
"prometheus" = {
|
||||
terraform_resource = "docker_container.prometheus"
|
||||
compose_project = "core"
|
||||
compose_service = "prometheus"
|
||||
compose_file = "monitoring/prometheus/docker-compose.yml"
|
||||
container_name = "prometheus"
|
||||
image = "prom/prometheus:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor", "traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/prometheus/prometheus.yml->/etc/prometheus/prometheus.yml:ro", "bind:/home/nixos/docker/monitoring/prometheus/data->/prometheus", "bind:/home/nixos/docker/monitoring/prometheus/rules->/etc/prometheus/rules:ro", "bind:/home/nixos/docker/secrets/prometheus_kuma_basic_auth_password.txt->/run/secrets/prometheus_kuma_basic_auth_password:ro"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.prometheus.entrypoints" = "websecure"
|
||||
"traefik.http.routers.prometheus.middlewares" = "authelia"
|
||||
"traefik.http.routers.prometheus.rule" = "Host(`prometheus.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.prometheus.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.prometheus.tls.options" = "mtls-private-admin@file"
|
||||
"traefik.http.services.prometheus.loadbalancer.server.port" = "9090"
|
||||
}
|
||||
}
|
||||
"searxng-webapp" = {
|
||||
terraform_resource = "docker_container.searxng-webapp"
|
||||
compose_project = "core"
|
||||
compose_service = "searxng-webapp"
|
||||
compose_file = "apps/searxng/docker-compose.yml"
|
||||
container_name = "searxng-webapp"
|
||||
image = "searxng/searxng"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = []
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.searxng.entrypoints" = "websecure"
|
||||
"traefik.http.routers.searxng.rule" = "Host(`searxng.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.searxng.tls.certresolver" = "myresolver"
|
||||
"traefik.http.services.searxng.loadbalancer.server.port" = "8080"
|
||||
}
|
||||
}
|
||||
"telegraf" = {
|
||||
terraform_resource = "docker_container.telegraf"
|
||||
compose_project = "core"
|
||||
compose_service = "telegraf"
|
||||
compose_file = "monitoring/telegraf/docker-compose.yml"
|
||||
container_name = "telegraf"
|
||||
image = "telegraf:latest"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "unless-stopped"
|
||||
network_mode = null
|
||||
networks = ["monitor"]
|
||||
mounts = ["bind:/home/nixos/docker/monitoring/telegraf/telegraf.conf->/etc/telegraf/telegraf.conf:ro", "bind:/home/nixos/docker/monitoring/node-red/data->/var/log/node-red:ro"]
|
||||
published_ports = []
|
||||
build_context = null
|
||||
build_dockerfile = null
|
||||
useful_labels = {}
|
||||
}
|
||||
"traefik" = {
|
||||
terraform_resource = "docker_container.traefik"
|
||||
compose_project = "core"
|
||||
compose_service = "traefik"
|
||||
compose_file = "core/traefik/docker-compose.yml"
|
||||
container_name = "traefik"
|
||||
image = "traefik:3"
|
||||
image_source = "declared_image"
|
||||
restart_policy = "always"
|
||||
network_mode = null
|
||||
networks = ["traefik"]
|
||||
mounts = ["bind:/home/nixos/docker/core/traefik/data/letsencrypt->/letsencrypt", "bind:/home/nixos/docker/core/traefik/data/logs->/logs", "bind:/home/nixos/docker/core/traefik/certs->/etc/traefik/certs:ro", "bind:/home/nixos/docker/core/traefik/dynamic.yml->/etc/traefik/dynamic.yml:ro", "bind:/home/nixos/docker/core/traefik/traefik.yml->/etc/traefik/traefik.yml:ro", "bind:/home/nixos/docker/core/traefik/data/plugins->/plugins-storage"]
|
||||
published_ports = ["80:80/tcp", "443:443/tcp"]
|
||||
build_context = "/home/nixos/docker/core"
|
||||
build_dockerfile = "Dockerfile"
|
||||
useful_labels = {
|
||||
"traefik.docker.network" = "core_traefik"
|
||||
"traefik.enable" = "true"
|
||||
"traefik.http.routers.traefik.entrypoints" = "websecure"
|
||||
"traefik.http.routers.traefik.middlewares" = "authelia"
|
||||
"traefik.http.routers.traefik.observability.tracing" = "true"
|
||||
"traefik.http.routers.traefik.rule" = "Host(`traefik.lan.ddnsgeek.com`)"
|
||||
"traefik.http.routers.traefik.service" = "api@internal"
|
||||
"traefik.http.routers.traefik.tls.certresolver" = "myresolver"
|
||||
"traefik.http.routers.traefik.tls.options" = "mtls-private-admin@file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "crowdsec" {
|
||||
name = local.docker_containers["crowdsec"].container_name
|
||||
image = local.docker_containers["crowdsec"].image
|
||||
|
||||
restart = local.docker_containers["crowdsec"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "docker_socket_proxy" {
|
||||
name = local.docker_containers["docker-socket-proxy"].container_name
|
||||
image = local.docker_containers["docker-socket-proxy"].image
|
||||
|
||||
restart = local.docker_containers["docker-socket-proxy"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "docker_update_exporter" {
|
||||
name = local.docker_containers["docker-update-exporter"].container_name
|
||||
image = local.docker_containers["docker-update-exporter"].image
|
||||
|
||||
restart = local.docker_containers["docker-update-exporter"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "error_pages" {
|
||||
name = local.docker_containers["error-pages"].container_name
|
||||
image = local.docker_containers["error-pages"].image
|
||||
|
||||
restart = local.docker_containers["error-pages"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "gitea" {
|
||||
name = local.docker_containers["gitea"].container_name
|
||||
image = local.docker_containers["gitea"].image
|
||||
|
||||
restart = local.docker_containers["gitea"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "gotify" {
|
||||
name = local.docker_containers["gotify"].container_name
|
||||
image = local.docker_containers["gotify"].image
|
||||
|
||||
restart = local.docker_containers["gotify"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "grafana" {
|
||||
name = local.docker_containers["grafana"].container_name
|
||||
image = local.docker_containers["grafana"].image
|
||||
|
||||
restart = local.docker_containers["grafana"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "gramps_redis" {
|
||||
name = local.docker_containers["gramps-redis"].container_name
|
||||
image = local.docker_containers["gramps-redis"].image
|
||||
|
||||
restart = local.docker_containers["gramps-redis"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "gramps_web_celery" {
|
||||
name = local.docker_containers["gramps-web-celery"].container_name
|
||||
image = local.docker_containers["gramps-web-celery"].image
|
||||
|
||||
restart = local.docker_containers["gramps-web-celery"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "gramps_web" {
|
||||
name = local.docker_containers["gramps-web"].container_name
|
||||
image = local.docker_containers["gramps-web"].image
|
||||
|
||||
restart = local.docker_containers["gramps-web"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "influxdb" {
|
||||
name = local.docker_containers["influxdb"].container_name
|
||||
image = local.docker_containers["influxdb"].image
|
||||
|
||||
restart = local.docker_containers["influxdb"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Docker container resources are split into one file per container.
|
||||
# See container-catalog.tf for documentation-oriented metadata used by outputs.
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "monitor_kuma" {
|
||||
name = local.docker_containers["monitor-kuma"].container_name
|
||||
image = local.docker_containers["monitor-kuma"].image
|
||||
|
||||
restart = local.docker_containers["monitor-kuma"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "mtls_bridge" {
|
||||
name = local.docker_containers["mtls-bridge"].container_name
|
||||
image = local.docker_containers["mtls-bridge"].image
|
||||
|
||||
restart = local.docker_containers["mtls-bridge"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "nextcloud_db" {
|
||||
name = local.docker_containers["nextcloud-db"].container_name
|
||||
image = local.docker_containers["nextcloud-db"].image
|
||||
|
||||
restart = local.docker_containers["nextcloud-db"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "nextcloud_redis" {
|
||||
name = local.docker_containers["nextcloud-redis"].container_name
|
||||
image = local.docker_containers["nextcloud-redis"].image
|
||||
|
||||
restart = local.docker_containers["nextcloud-redis"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "nextcloud_webapp" {
|
||||
name = local.docker_containers["nextcloud-webapp"].container_name
|
||||
image = local.docker_containers["nextcloud-webapp"].image
|
||||
|
||||
restart = local.docker_containers["nextcloud-webapp"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "node_exporter" {
|
||||
name = local.docker_containers["node-exporter"].container_name
|
||||
image = local.docker_containers["node-exporter"].image
|
||||
|
||||
restart = local.docker_containers["node-exporter"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "node_red" {
|
||||
name = local.docker_containers["node-red"].container_name
|
||||
image = local.docker_containers["node-red"].image
|
||||
|
||||
restart = local.docker_containers["node-red"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
output "docker_host_in_use" {
|
||||
description = "Docker daemon endpoint currently targeted by this workspace."
|
||||
value = var.docker_host
|
||||
}
|
||||
|
||||
output "docker_containers" {
|
||||
description = "Documentation-shaped inventory of Docker containers managed via services-up.sh compose sources."
|
||||
value = local.docker_containers
|
||||
}
|
||||
|
||||
output "docker_inventory" {
|
||||
description = "Compact Docker inventory suitable for export and merging into broader infrastructure docs."
|
||||
value = {
|
||||
compose_project = "core"
|
||||
container_count = length(local.docker_containers)
|
||||
containers = {
|
||||
for key, container in local.docker_containers : key => {
|
||||
compose_service = container.compose_service
|
||||
compose_file = container.compose_file
|
||||
container_name = container.container_name
|
||||
image = container.image
|
||||
image_source = container.image_source
|
||||
build_context = container.build_context
|
||||
network_mode = container.network_mode
|
||||
networks = container.networks
|
||||
published_ports = container.published_ports
|
||||
mounts = container.mounts
|
||||
restart_policy = container.restart_policy
|
||||
labels = container.useful_labels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output "managed_container_names" {
|
||||
description = "Names of containers intentionally tracked in Terraform documentation resources."
|
||||
value = sort(keys(local.docker_containers))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "passbolt_db" {
|
||||
name = local.docker_containers["passbolt-db"].container_name
|
||||
image = local.docker_containers["passbolt-db"].image
|
||||
|
||||
restart = local.docker_containers["passbolt-db"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "passbolt_webapp" {
|
||||
name = local.docker_containers["passbolt-webapp"].container_name
|
||||
image = local.docker_containers["passbolt-webapp"].image
|
||||
|
||||
restart = local.docker_containers["passbolt-webapp"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
resource "docker_container" "pihole_exporter" {
|
||||
name = local.docker_containers["pihole-exporter"].container_name
|
||||
image = local.docker_containers["pihole-exporter"].image
|
||||
|
||||
restart = local.docker_containers["pihole-exporter"].restart_policy
|
||||
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
env,
|
||||
labels,
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user