From 45f2a9a073faaa7633db735dbd5e0cbe77361189 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 29 Apr 2026 16:39:00 -0500 Subject: [PATCH] infra, ansible, and required container changes --- .github/workflows/deploy.yml | 277 ++++++++++++++++++ ansible/README.md | 90 ++++++ ansible/ansible.cfg | 11 + ansible/group_vars/all.yml | 40 +++ ansible/inventory.yml.example | 9 + .../roles/common/files/auto-reboot.service | 10 + ansible/roles/common/files/auto-reboot.timer | 12 + ansible/roles/common/handlers/main.yml | 9 + ansible/roles/common/tasks/main.yml | 73 +++++ .../roles/container_host/handlers/main.yml | 19 ++ ansible/roles/container_host/tasks/main.yml | 63 ++++ .../roles/fail2ban/files/caddy-coraza.filter | 26 ++ ansible/roles/fail2ban/handlers/main.yml | 5 + ansible/roles/fail2ban/tasks/main.yml | 52 ++++ .../roles/fail2ban/templates/jail.local.j2 | 37 +++ ansible/roles/nftables/handlers/main.yml | 5 + ansible/roles/nftables/tasks/main.yml | 29 ++ .../roles/nftables/templates/nftables.conf.j2 | 54 ++++ ansible/roles/registry/handlers/main.yml | 19 ++ ansible/roles/registry/tasks/main.yml | 78 +++++ ansible/site.yml | 20 ++ caddy/Caddyfile | 14 +- caddy/coraza.conf | 10 + docs/networking.md | 140 +++++++++ infra/.gitignore | 14 + infra/README.md | 71 +++++ infra/backend.hcl.example | 16 + infra/main.tf | 51 ++++ infra/outputs.tf | 27 ++ infra/terraform.tfvars.example | 6 + infra/variables.tf | 47 +++ infra/versions.tf | 32 ++ quadlet/dev-blog-app.container | 7 +- quadlet/dev-blog-caddy.container | 15 +- quadlet/dev-blog.network | 3 + 35 files changed, 1379 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 ansible/README.md create mode 100644 ansible/ansible.cfg create mode 100644 ansible/group_vars/all.yml create mode 100644 ansible/inventory.yml.example create mode 100644 ansible/roles/common/files/auto-reboot.service create mode 100644 ansible/roles/common/files/auto-reboot.timer create mode 100644 ansible/roles/common/handlers/main.yml create mode 100644 ansible/roles/common/tasks/main.yml create mode 100644 ansible/roles/container_host/handlers/main.yml create mode 100644 ansible/roles/container_host/tasks/main.yml create mode 100644 ansible/roles/fail2ban/files/caddy-coraza.filter create mode 100644 ansible/roles/fail2ban/handlers/main.yml create mode 100644 ansible/roles/fail2ban/tasks/main.yml create mode 100644 ansible/roles/fail2ban/templates/jail.local.j2 create mode 100644 ansible/roles/nftables/handlers/main.yml create mode 100644 ansible/roles/nftables/tasks/main.yml create mode 100644 ansible/roles/nftables/templates/nftables.conf.j2 create mode 100644 ansible/roles/registry/handlers/main.yml create mode 100644 ansible/roles/registry/tasks/main.yml create mode 100644 ansible/site.yml create mode 100644 docs/networking.md create mode 100644 infra/.gitignore create mode 100644 infra/README.md create mode 100644 infra/backend.hcl.example create mode 100644 infra/main.tf create mode 100644 infra/outputs.tf create mode 100644 infra/terraform.tfvars.example create mode 100644 infra/variables.tf create mode 100644 infra/versions.tf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5acbd06 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,277 @@ +name: deploy + +# ---------------------------------------------------------------------------- +# Pipeline shape: +# +# build-app ─┐ +# build-caddy├──► gate ──► push-app ─┐ +# infra ─┘ push-caddy ─┴──► deploy (Ansible) +# +# - build-app / build-caddy: build container images, save as tar artifacts. +# - infra: tofu fmt/validate/plan (PR) or apply (push/dispatch); emits +# instance_ip as a job output. +# - gate: fan-in checkpoint, fails fast if any upstream failed. +# - push-app / push-caddy: load tar artifact, tag, and push to GHCR. +# - deploy: run Ansible against the Vultr host, pulling images from GHCR. +# ---------------------------------------------------------------------------- + +on: + workflow_dispatch: + inputs: + action: + description: "Pipeline action" + required: true + default: deploy + type: choice + options: [plan, deploy, destroy] + push: + branches: [main] + pull_request: + +permissions: + contents: read + packages: write + pull-requests: write + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_APP: ${{ github.repository }}/app + IMAGE_CADDY: ${{ github.repository }}/caddy + TAG: ${{ github.sha }} + +jobs: + # --------------------------------------------------------------------------- + # Parallel build / plan stage + # --------------------------------------------------------------------------- + build-app: + name: Build app image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image with Podman + run: | + podman build \ + -t "app:${TAG}" \ + -f Containerfile \ + . + + - name: Save image as OCI tar + run: podman save -o /tmp/app.tar "app:${TAG}" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: image-app + path: /tmp/app.tar + retention-days: 1 + if-no-files-found: error + + build-caddy: + name: Build caddy image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image with Podman + run: | + podman build \ + -t "caddy:${TAG}" \ + -f caddy/Containerfile \ + caddy + + - name: Save image as OCI tar + run: podman save -o /tmp/caddy.tar "caddy:${TAG}" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: image-caddy + path: /tmp/caddy.tar + retention-days: 1 + if-no-files-found: error + + infra: + name: OpenTofu (Vultr / R2) + runs-on: ubuntu-latest + defaults: + run: + working-directory: infra + outputs: + instance_ip: ${{ steps.outputs.outputs.instance_ip }} + env: + TF_VAR_vultr_api_key: ${{ secrets.VULTR_API_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + steps: + - uses: actions/checkout@v4 + + - uses: opentofu/setup-opentofu@v1 + with: + tofu_version: "1.8.5" + + - name: tofu fmt + run: tofu fmt -check -recursive + + - name: tofu init (R2 backend) + run: | + tofu init \ + -backend-config="bucket=${R2_BUCKET}" \ + -backend-config="endpoints={ s3 = \"https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com\" }" + + - name: tofu validate + run: tofu validate + + - name: tofu plan + if: github.event_name == 'pull_request' || inputs.action == 'plan' + run: tofu plan -input=false -no-color + + - name: tofu apply + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.action == 'deploy') + run: tofu apply -input=false -auto-approve + + - name: tofu destroy + if: github.event_name == 'workflow_dispatch' && inputs.action == 'destroy' + run: tofu destroy -input=false -auto-approve + + - name: Export instance IP + id: outputs + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.action == 'deploy') + run: echo "instance_ip=$(tofu output -raw main_ip)" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # Quality gate (fan-in) + # --------------------------------------------------------------------------- + gate: + name: Quality gate + needs: [build-app, build-caddy, infra] + runs-on: ubuntu-latest + steps: + - run: echo "build-app, build-caddy, and infra all succeeded." + + # --------------------------------------------------------------------------- + # Push to GHCR (parallel, post-gate) + # --------------------------------------------------------------------------- + push-app: + name: Push app → GHCR + needs: gate + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.action == 'deploy') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: image-app + path: /tmp + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login "${REGISTRY}" -u "${{ github.actor }}" --password-stdin + + - name: Tag and push + run: | + podman load -i /tmp/app.tar + podman tag "app:${TAG}" "${REGISTRY}/${IMAGE_APP}:${TAG}" + podman tag "app:${TAG}" "${REGISTRY}/${IMAGE_APP}:latest" + podman push "${REGISTRY}/${IMAGE_APP}:${TAG}" + podman push "${REGISTRY}/${IMAGE_APP}:latest" + + push-caddy: + name: Push caddy → GHCR + needs: gate + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.action == 'deploy') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: image-caddy + path: /tmp + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login "${REGISTRY}" -u "${{ github.actor }}" --password-stdin + + - name: Tag and push + run: | + podman load -i /tmp/caddy.tar + podman tag "caddy:${TAG}" "${REGISTRY}/${IMAGE_CADDY}:${TAG}" + podman tag "caddy:${TAG}" "${REGISTRY}/${IMAGE_CADDY}:latest" + podman push "${REGISTRY}/${IMAGE_CADDY}:${TAG}" + podman push "${REGISTRY}/${IMAGE_CADDY}:latest" + + # --------------------------------------------------------------------------- + # Deploy with Ansible + # --------------------------------------------------------------------------- + deploy: + name: Ansible deploy + needs: [push-app, push-caddy, infra] + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.action == 'deploy') + runs-on: ubuntu-latest + defaults: + run: + working-directory: ansible + env: + ANSIBLE_HOST_KEY_CHECKING: "False" + ANSIBLE_FORCE_COLOR: "1" + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + Ansible + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible + collections + run: | + python -m pip install --upgrade pip + pip install ansible + ansible-galaxy collection install ansible.posix containers.podman + + - name: Configure SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + INSTANCE_IP: ${{ needs.infra.outputs.instance_ip }} + run: | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$INSTANCE_IP" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Render inventory + env: + INSTANCE_IP: ${{ needs.infra.outputs.instance_ip }} + run: | + cat > inventory.yml <`, so once a client crosses `maxretry` within `findtime`, fail2ban +drops it into the `f2b-table` nft set for `bantime`. + +## Usage + +```sh +cd ansible +cp inventory.yml.example inventory.yml # set ansible_host = your Vultr IP + +# Quick connectivity / fact gathering: +ansible -m ping blog + +# Full provision: +ansible-playbook site.yml + +# Just one role: +ansible-playbook site.yml --tags fail2ban # (add `tags:` to roles if needed) +``` + +## Tunables (group_vars/all.yml) + +| Var | Default | Notes | +| ------------------------------ | -------------- | ------------------------------- | +| `ssh_port` | `22` | | +| `http_port` / `https_port` | `80` / `443` | | +| `caddy_log_dir` | `/var/log/caddy` | host path bind-mounted into Caddy | +| `dnf_automatic_apply_updates` | `true` | | +| `dnf_automatic_upgrade_type` | `security` | `default` for full updates | +| `f2b_findtime` / `f2b_bantime` / `f2b_maxretry` | `10m / 1h / 5` | Caddy/Coraza jail thresholds | +| `ghcr_owner` / `ghcr_repo` | `OWNER / dev_blog` | GHCR namespace | +| `image_tag` | `latest` | Tag to pull (CI sets to commit SHA) | + +## Notes + +- Don't enable both `firewalld` and the `nftables` service — this playbook + masks `firewalld`. Podman's `netavark` writes to its own nft tables and + doesn't conflict. +- The `synchronize` task uses `ansible.posix.synchronize`. Install + `ansible-galaxy collection install ansible.posix` once on the controller. +- After first run, verify with: + - `systemctl status dnf-automatic.timer nftables fail2ban` + - `nft list ruleset` + - `fail2ban-client status caddy-coraza` diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..62bbe07 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory.yml +roles_path = roles +host_key_checking = True +retry_files_enabled = False +stdout_callback = yaml +forks = 5 +interpreter_python = auto_silent + +[ssh_connection] +pipelining = True diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000..240f9ab --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,40 @@ +--- +# ---------------------------------------------------------------------------- +# Shared variables for the dev_blog production host. +# ---------------------------------------------------------------------------- + +# SSH access +ssh_port: 22 + +# Public web ports terminated by Caddy. +http_port: 80 +https_port: 443 + +# Where Caddy + Coraza write logs on the host. This directory is bind-mounted +# into the Caddy container, and is the file path that fail2ban tails. +caddy_log_dir: /var/log/caddy + +# Automated updates: only security errata are applied automatically. +dnf_automatic_apply_updates: true +dnf_automatic_upgrade_type: security + +# fail2ban thresholds for the Caddy/Coraza jail. +f2b_findtime: 10m +f2b_bantime: 1h +f2b_maxretry: 5 + +# Quadlet units shipped from the repo. +quadlet_src_dir: "{{ playbook_dir }}/../quadlet" +quadlet_dest_dir: /etc/containers/systemd + +# ---------------------------------------------------------------------------- +# Container images on GHCR. Set `image_tag`, `ghcr_owner`, `ghcr_repo` from +# CI extra-vars (`-e image_tag= -e ghcr_owner=...`). Defaults below let +# the playbook syntax-check without those being provided. +# ---------------------------------------------------------------------------- +ghcr_owner: "OWNER" +ghcr_repo: "dev_blog" +image_tag: "latest" + +image_app: "ghcr.io/{{ ghcr_owner }}/{{ ghcr_repo }}/app:{{ image_tag }}" +image_caddy: "ghcr.io/{{ ghcr_owner }}/{{ ghcr_repo }}/caddy:{{ image_tag }}" diff --git a/ansible/inventory.yml.example b/ansible/inventory.yml.example new file mode 100644 index 0000000..ee16392 --- /dev/null +++ b/ansible/inventory.yml.example @@ -0,0 +1,9 @@ +all: + children: + blog: + hosts: + dev-blog-prod: + # Public IP of the Vultr instance (set me). + ansible_host: 203.0.113.10 + ansible_user: root + ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/roles/common/files/auto-reboot.service b/ansible/roles/common/files/auto-reboot.service new file mode 100644 index 0000000..e64fe6c --- /dev/null +++ b/ansible/roles/common/files/auto-reboot.service @@ -0,0 +1,10 @@ +[Unit] +Description=Reboot host if pending updates require it +Documentation=man:dnf-needs-restarting(1) +ConditionPathExists=!/run/nologin + +[Service] +Type=oneshot +# `dnf needs-restarting -r` exits 1 when a reboot is required, 0 otherwise. +# Only reboot in that case. +ExecStart=/bin/sh -c '/usr/bin/dnf -q needs-restarting -r; if [ $? -eq 1 ]; then /usr/bin/logger -t auto-reboot "Rebooting: pending updates require it"; /usr/bin/systemctl reboot; fi' diff --git a/ansible/roles/common/files/auto-reboot.timer b/ansible/roles/common/files/auto-reboot.timer new file mode 100644 index 0000000..386cfa6 --- /dev/null +++ b/ansible/roles/common/files/auto-reboot.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Daily 02:00 America/Chicago reboot check (if updates require it) + +[Timer] +# 02:00 Central time, DST-aware. +OnCalendar=*-*-* 02:00:00 America/Chicago +Persistent=true +RandomizedDelaySec=5m +Unit=auto-reboot.service + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/common/handlers/main.yml b/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..506e46a --- /dev/null +++ b/ansible/roles/common/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Restart dnf-automatic timer + ansible.builtin.systemd: + name: dnf-automatic.timer + state: restarted + +- name: Reload systemd for auto-reboot + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..0f4877a --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,73 @@ +--- +- name: Install EPEL release + ansible.builtin.dnf: + name: epel-release + state: present + +- name: Install base packages + ansible.builtin.dnf: + name: + - dnf-automatic + - chrony + - curl + - tar + - vim-enhanced + - policycoreutils-python-utils + - logrotate + - rsync + state: present + +- name: Ensure chrony is running + ansible.builtin.systemd: + name: chronyd + enabled: true + state: started + +- name: Configure dnf-automatic for security updates + ansible.builtin.copy: + dest: /etc/dnf/automatic.conf + owner: root + group: root + mode: "0644" + content: | + # Managed by Ansible (roles/common). + [commands] + upgrade_type = {{ dnf_automatic_upgrade_type }} + random_sleep = 360 + network_online_timeout = 60 + download_updates = yes + apply_updates = {{ 'yes' if dnf_automatic_apply_updates else 'no' }} + reboot = never + reboot_command = "shutdown -r now" + + [emitters] + emit_via = stdio + + [base] + debuglevel = 1 + notify: Restart dnf-automatic timer + +- name: Enable dnf-automatic timer + ansible.builtin.systemd: + name: dnf-automatic.timer + enabled: true + state: started + +- name: Install auto-reboot service + timer (02:00 America/Chicago) + ansible.builtin.copy: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - auto-reboot.service + - auto-reboot.timer + notify: Reload systemd for auto-reboot + +- name: Enable auto-reboot timer + ansible.builtin.systemd: + name: auto-reboot.timer + enabled: true + state: started + daemon_reload: true diff --git a/ansible/roles/container_host/handlers/main.yml b/ansible/roles/container_host/handlers/main.yml new file mode 100644 index 0000000..511a3db --- /dev/null +++ b/ansible/roles/container_host/handlers/main.yml @@ -0,0 +1,19 @@ +--- +- name: Reload systemd and restart dev_blog + ansible.builtin.systemd: + daemon_reload: true + listen: Reload systemd and restart dev_blog + +- name: Restart Caddy service + ansible.builtin.systemd: + name: dev-blog-caddy.service + state: restarted + listen: Reload systemd and restart dev_blog + failed_when: false + +- name: Restart app service + ansible.builtin.systemd: + name: dev-blog-app.service + state: restarted + listen: Reload systemd and restart dev_blog + failed_when: false diff --git a/ansible/roles/container_host/tasks/main.yml b/ansible/roles/container_host/tasks/main.yml new file mode 100644 index 0000000..5e85c9b --- /dev/null +++ b/ansible/roles/container_host/tasks/main.yml @@ -0,0 +1,63 @@ +--- +# ---------------------------------------------------------------------------- +# Container runtime + Podman Quadlet deployment for the dev_blog stack. +# ---------------------------------------------------------------------------- + +- name: Install Podman + container runtime dependencies + ansible.builtin.dnf: + name: + - podman + - containers-common + - crun + - netavark + - aardvark-dns + - slirp4netns + - fuse-overlayfs + - passt + - skopeo + - buildah + - container-selinux + state: present + +- name: Ensure /etc/containers/systemd exists for Quadlet units + ansible.builtin.file: + path: "{{ quadlet_dest_dir }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: Sync static Quadlet units (network + volumes) + ansible.posix.synchronize: + src: "{{ quadlet_src_dir }}/" + dest: "{{ quadlet_dest_dir }}/" + delete: false + rsync_opts: + - "--exclude=*.container" + - "--chmod=F0644,D0755" + - "--chown=root:root" + notify: Reload systemd and restart dev_blog + +- name: Render app Quadlet (Image= → GHCR ref) + ansible.builtin.copy: + dest: "{{ quadlet_dest_dir }}/dev-blog-app.container" + owner: root + group: root + mode: "0644" + content: "{{ lookup('file', quadlet_src_dir ~ '/dev-blog-app.container') | regex_replace('(?m)^Image=.*$', 'Image=' ~ image_app) }}" + notify: Reload systemd and restart dev_blog + +- name: Render Caddy Quadlet (Image= → GHCR ref) + ansible.builtin.copy: + dest: "{{ quadlet_dest_dir }}/dev-blog-caddy.container" + owner: root + group: root + mode: "0644" + content: "{{ lookup('file', quadlet_src_dir ~ '/dev-blog-caddy.container') | regex_replace('(?m)^Image=.*$', 'Image=' ~ image_caddy) }}" + notify: Reload systemd and restart dev_blog + +- name: Ensure podman.socket is available (for healthchecks / API) + ansible.builtin.systemd: + name: podman.socket + enabled: true + state: started diff --git a/ansible/roles/fail2ban/files/caddy-coraza.filter b/ansible/roles/fail2ban/files/caddy-coraza.filter new file mode 100644 index 0000000..e247623 --- /dev/null +++ b/ansible/roles/fail2ban/files/caddy-coraza.filter @@ -0,0 +1,26 @@ +# Managed by Ansible (roles/fail2ban). +# +# Matches two distinct sources written by the Caddy + Coraza container: +# +# 1. Caddy JSON access log entries with HTTP 403 (Coraza denies with 403): +# {"level":"info","ts":...,"logger":"http.log.access", +# "request":{"remote_ip":"203.0.113.42",...},"status":403, ...} +# +# 2. Coraza serial audit-log "A" section data line (the line that follows +# the `--xxxx-A--` boundary): +# [29/Apr/2026:16:00:00 +0000] unique-id 203.0.113.42 54321 10.0.0.1 80 +# +# Either pattern produces a hit on . + +[INCLUDES] +before = common.conf + +[Definition] + +failregex = ^.*"remote_ip":""[^\n]*"status":403[^\n]*$ + ^\[[^\]]+\] \S+ \d+ \S+ \d+\s*$ + +ignoreregex = + +datepattern = ^\[%%d/%%b/%%Y:%%H:%%M:%%S %%z\] + {^LN-BEG} diff --git a/ansible/roles/fail2ban/handlers/main.yml b/ansible/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..83fad83 --- /dev/null +++ b/ansible/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted diff --git a/ansible/roles/fail2ban/tasks/main.yml b/ansible/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..97f75d5 --- /dev/null +++ b/ansible/roles/fail2ban/tasks/main.yml @@ -0,0 +1,52 @@ +--- +- name: Install fail2ban (from EPEL) + ansible.builtin.dnf: + name: fail2ban + state: present + +- name: Ensure Caddy log directory exists (host-side, bind-mounted into container) + ansible.builtin.file: + path: "{{ caddy_log_dir }}" + state: directory + owner: root + group: root + mode: "0755" + setype: container_file_t + +- name: Ensure log files exist so fail2ban can start tailing them + ansible.builtin.file: + path: "{{ caddy_log_dir }}/{{ item }}" + state: touch + owner: root + group: root + mode: "0644" + setype: container_file_t + modification_time: preserve + access_time: preserve + loop: + - access.log + - coraza-audit.log + +- name: Deploy Caddy/Coraza fail2ban filter + ansible.builtin.copy: + src: caddy-coraza.filter + dest: /etc/fail2ban/filter.d/caddy-coraza.conf + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Deploy fail2ban jail.local + ansible.builtin.template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.local + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Enable fail2ban + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started diff --git a/ansible/roles/fail2ban/templates/jail.local.j2 b/ansible/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..b491d2c --- /dev/null +++ b/ansible/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,37 @@ +# Managed by Ansible (roles/fail2ban). + +[DEFAULT] +banaction = nftables-multiport +banaction_allports = nftables-allports +backend = auto +findtime = {{ f2b_findtime }} +bantime = {{ f2b_bantime }} +maxretry = {{ f2b_maxretry }} +# Add private ranges and your own admin IPs here. +ignoreip = 127.0.0.1/8 ::1 + +# ---------------------------------------------------------------------------- +# SSH brute-force protection. +# ---------------------------------------------------------------------------- +[sshd] +enabled = true +port = {{ ssh_port }} + +# ---------------------------------------------------------------------------- +# Caddy + Coraza WAF. +# +# Two log sources are watched: +# - access.log : every Caddy response. We ban on repeated 4xx / 403. +# - coraza-audit.log : Coraza serial audit log; any entry here means a rule +# fired in blocking mode, so a single hit is enough on top of the access +# log threshold. +# ---------------------------------------------------------------------------- +[caddy-coraza] +enabled = true +port = http,https +filter = caddy-coraza +logpath = {{ caddy_log_dir }}/access.log + {{ caddy_log_dir }}/coraza-audit.log +maxretry = {{ f2b_maxretry }} +findtime = {{ f2b_findtime }} +bantime = {{ f2b_bantime }} diff --git a/ansible/roles/nftables/handlers/main.yml b/ansible/roles/nftables/handlers/main.yml new file mode 100644 index 0000000..69e8f72 --- /dev/null +++ b/ansible/roles/nftables/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nftables + ansible.builtin.systemd: + name: nftables + state: reloaded diff --git a/ansible/roles/nftables/tasks/main.yml b/ansible/roles/nftables/tasks/main.yml new file mode 100644 index 0000000..530d648 --- /dev/null +++ b/ansible/roles/nftables/tasks/main.yml @@ -0,0 +1,29 @@ +--- +- name: Install nftables + ansible.builtin.dnf: + name: nftables + state: present + +- name: Disable firewalld (we manage nftables directly) + ansible.builtin.systemd: + name: firewalld + enabled: false + state: stopped + masked: true + failed_when: false + +- name: Deploy base nftables ruleset + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/sysconfig/nftables.conf + owner: root + group: root + mode: "0644" + validate: "/usr/sbin/nft -c -f %s" + notify: Reload nftables + +- name: Enable nftables + ansible.builtin.systemd: + name: nftables + enabled: true + state: started diff --git a/ansible/roles/nftables/templates/nftables.conf.j2 b/ansible/roles/nftables/templates/nftables.conf.j2 new file mode 100644 index 0000000..e055b1c --- /dev/null +++ b/ansible/roles/nftables/templates/nftables.conf.j2 @@ -0,0 +1,54 @@ +#!/usr/sbin/nft -f +# Managed by Ansible (roles/nftables). +# +# Base ruleset for the dev_blog host. fail2ban dynamically adds a separate +# table (`f2b-table`) with sets of banned addresses; we don't need to manage +# those here. + +flush ruleset + +table inet filter { + set blackhole_v4 { + type ipv4_addr + flags interval + } + set blackhole_v6 { + type ipv6_addr + flags interval + } + + chain input { + type filter hook input priority filter; policy drop; + + # Loopback + established traffic. + iif "lo" accept + ct state established,related accept + ct state invalid drop + + # Manual blackhole sets (for ops use). + ip saddr @blackhole_v4 drop + ip6 saddr @blackhole_v6 drop + + # ICMP / ICMPv6 (rate-limited). + ip protocol icmp limit rate 5/second accept + ip6 nexthdr icmpv6 accept + + # Public services. + tcp dport { {{ ssh_port }} } ct state new limit rate 10/minute accept + tcp dport { {{ http_port }}, {{ https_port }} } accept + udp dport { {{ https_port }} } accept # HTTP/3 (QUIC) + + # Log a small sample of everything else, then drop (policy drop). + limit rate 5/minute log prefix "nft_drop: " + } + + chain forward { + type filter hook forward priority filter; policy drop; + # Container traffic is handled by podman/netavark in its own tables. + ct state established,related accept + } + + chain output { + type filter hook output priority filter; policy accept; + } +} diff --git a/ansible/roles/registry/handlers/main.yml b/ansible/roles/registry/handlers/main.yml new file mode 100644 index 0000000..8a8611b --- /dev/null +++ b/ansible/roles/registry/handlers/main.yml @@ -0,0 +1,19 @@ +--- +- name: Restart dev_blog services + ansible.builtin.systemd: + daemon_reload: true + listen: Restart dev_blog services + +- name: Restart Caddy + ansible.builtin.systemd: + name: dev-blog-caddy.service + state: restarted + listen: Restart dev_blog services + failed_when: false + +- name: Restart app + ansible.builtin.systemd: + name: dev-blog-app.service + state: restarted + listen: Restart dev_blog services + failed_when: false diff --git a/ansible/roles/registry/tasks/main.yml b/ansible/roles/registry/tasks/main.yml new file mode 100644 index 0000000..c106f16 --- /dev/null +++ b/ansible/roles/registry/tasks/main.yml @@ -0,0 +1,78 @@ +--- +# ---------------------------------------------------------------------------- +# GHCR registry login + image pull. +# +# Required extra-vars (typically set by CI): +# ghcr_pat - GitHub PAT with `read:packages` scope +# ghcr_user - GitHub username that owns the PAT +# image_tag - container tag to pull (e.g. the commit SHA) +# ---------------------------------------------------------------------------- + +- name: Assert GHCR variables are set + ansible.builtin.assert: + that: + - ghcr_pat | default('') | length > 0 + - ghcr_user | default('') | length > 0 + - image_tag | default('') | length > 0 + fail_msg: "ghcr_pat, ghcr_user, and image_tag must be provided as extra-vars." + +- name: Add GitHub CLI dnf repo + ansible.builtin.yum_repository: + name: gh-cli + description: GitHub CLI + baseurl: https://cli.github.com/packages/rpm + gpgcheck: true + gpgkey: https://cli.github.com/packages/rpm/gh-cli.repo.gpg.key + enabled: true + +- name: Install gh CLI + ansible.builtin.dnf: + name: gh + state: present + +- name: Authenticate gh with the provided PAT + ansible.builtin.shell: + cmd: | + set -euo pipefail + printf '%s' "$GHCR_PAT" | gh auth login --hostname github.com --with-token + gh auth status --hostname github.com + environment: + GHCR_PAT: "{{ ghcr_pat }}" + register: gh_login + changed_when: false + no_log: true + +- name: Ensure podman auth dir exists + ansible.builtin.file: + path: /root/.config/containers + state: directory + owner: root + group: root + mode: "0700" + +- name: Log podman in to GHCR (uses the same PAT) + ansible.builtin.shell: + cmd: | + set -euo pipefail + printf '%s' "$GHCR_PAT" | podman login ghcr.io \ + --username "{{ ghcr_user }}" \ + --password-stdin \ + --authfile /root/.config/containers/auth.json + environment: + GHCR_PAT: "{{ ghcr_pat }}" + changed_when: false + no_log: true + +- name: Pull the application image from GHCR + containers.podman.podman_image: + name: "{{ image_app }}" + auth_file: /root/.config/containers/auth.json + force: true + notify: Restart dev_blog services + +- name: Pull the Caddy image from GHCR + containers.podman.podman_image: + name: "{{ image_caddy }}" + auth_file: /root/.config/containers/auth.json + force: true + notify: Restart dev_blog services diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..6d70da5 --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,20 @@ +--- +- name: Configure dev_blog production host (AlmaLinux 10) + hosts: blog + become: true + gather_facts: true + + pre_tasks: + - name: Assert AlmaLinux 10 + ansible.builtin.assert: + that: + - ansible_facts['distribution'] == 'AlmaLinux' + - ansible_facts['distribution_major_version'] == '10' + fail_msg: "This playbook targets AlmaLinux 10, found {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}" + + roles: + - role: common + - role: nftables + - role: fail2ban + - role: registry + - role: container_host diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 4ef8d8c..803ce39 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -47,9 +47,11 @@ } # ------------------------------------------------------------------ - # Reverse-proxy to the Astro container on the internal network + # Reverse-proxy to the Astro container. Caddy is on the host network + # namespace, so we connect over loopback to the port the app container + # publishes on 127.0.0.1 / [::1]:4321. # ------------------------------------------------------------------ - reverse_proxy app:4321 { + reverse_proxy 127.0.0.1:4321 { header_up X-Real-IP {remote_host} header_up X-Forwarded-Proto {scheme} } @@ -64,7 +66,11 @@ } log { - output stdout - format console + output file /var/log/caddy/access.log { + roll_size 10MiB + roll_keep 5 + roll_keep_for 168h + } + format json } } diff --git a/caddy/coraza.conf b/caddy/coraza.conf index 1c11704..767c834 100644 --- a/caddy/coraza.conf +++ b/caddy/coraza.conf @@ -21,3 +21,13 @@ SecRule REQUEST_URI "@beginsWith /_astro/" \ "id:1000,phase:1,pass,nolog,ctl:ruleEngine=Off" SecRule REQUEST_URI "@beginsWith /fonts/" \ "id:1001,phase:1,pass,nolog,ctl:ruleEngine=Off" + +# --------------------------------------------------------------------------- +# Audit log — every blocked request gets an entry in serial format that +# fail2ban tails on the host (mounted at /var/log/caddy/coraza-audit.log). +# --------------------------------------------------------------------------- +SecAuditEngine RelevantOnly +SecAuditLogRelevantStatus "^(?:5|4(?!04))" +SecAuditLogParts ABIJDEFHZ +SecAuditLogType Serial +SecAuditLog /var/log/caddy/coraza-audit.log diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..0cc2df7 --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,140 @@ +# Caddy on host networking + loopback-published app + +This doc captures *why* the production deployment puts Caddy in the host +network namespace and reaches the app over loopback, and *exactly what* +that requires in the Quadlet units and the Caddyfile. + +> Note: this file lives under `docs/` at the repo root. It is **not** +> part of the Astro site (`src/pages/` + the `blog` content collection +> are what get built and served). Do not move it under `src/`. + +## Why + +With Podman's default port-publish path (`PublishPort=80:80` on a +user-defined bridge), inbound packets are NAT'd by netavark before they +hit the container. Caddy then sees the bridge gateway as the source +address instead of the real client. That breaks two things we care about: + +- **fail2ban / nftables bans** — the `nftables-multiport` action would + populate the f2b set with the gateway's address, banning nothing + useful. +- **Coraza's audit log** — every "blocked" entry would record the same + internal address, so manual triage is impossible. + +Switching Caddy to the host network namespace removes that NAT hop: +Caddy binds 80/443 directly on the host's interfaces and sees the real +client IP for both v4 and v6. + +## What changed + +### 1. `quadlet/dev-blog-caddy.container` + +Caddy now lives in the host network namespace. + +```diff +-Network=dev-blog.network +-NetworkAlias=caddy +- +-PublishPort=80:80 +-PublishPort=443:443 +-PublishPort=443:443/udp +-PublishPort=[::]:80:80 +-PublishPort=[::]:443:443 +-PublishPort=[::]:443:443/udp ++Network=host +``` + +`Network=host` is mutually exclusive with both `Network=dev-blog.network` +and any `PublishPort=` directives — Caddy binds 80/443 directly on the +host's interfaces, so the user-defined network and port-forwarding are +gone for this container. `CAP_NET_BIND_SERVICE` is still kept because +the container's non-root user still has to bind privileged ports. + +### 2. `quadlet/dev-blog-app.container` + +App stays on the user-defined network *and* publishes on host loopback +only. + +```diff +-# Not exposed publicly – Caddy reverse-proxies in over the internal network. +-# PublishPort=4321:4321 ++# Caddy now runs on the host network namespace, so it cannot reach the app ++# via podman DNS (`app:4321`). Publish the app port on the host's *loopback* ++# only -- it is reachable to Caddy on the host but not from outside. ++PublishPort=127.0.0.1:4321:4321 ++PublishPort=[::1]:4321:4321 +``` + +The app keeps `Network=dev-blog.network` (so it could still talk to +future sidecars on that bridge), but now also surfaces on +`127.0.0.1:4321` and `[::1]:4321`. nftables already accepts +`iif "lo" accept`, so loopback traffic is unfiltered. + +### 3. `caddy/Caddyfile` + +Reverse-proxy target updated. + +```diff +-reverse_proxy app:4321 { ++reverse_proxy 127.0.0.1:4321 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} + } +``` + +`{remote_host}` is now the **real** client IP (v4 or v6) because Caddy +sees the connection unmodified, instead of the bridge gateway. That +value flows into the JSON access log (`"remote_ip"`) and Coraza's serial +audit log, which is exactly what the fail2ban filter keys on — so the +`nftables-multiport` action populates the right addresses in +`f2b-table` and bans actually work. + +## Topology, before vs. after + +**Before** + +``` +Internet ─┐ + (host nft) ─► podman PublishPort NAT ─► dev-blog (bridge, dual-stack) + ├─ caddy (real IP lost) + └─ app (Caddy → app:4321 via podman DNS) +``` + +**After** + +``` +Internet ─┐ + (host nft) ─► caddy in host netns (real client v4/v6 preserved) + │ reverse_proxy + ▼ + 127.0.0.1:4321 / [::1]:4321 (loopback publish) + │ + dev-blog (bridge, dual-stack) + └─ app +``` + +## Things that did *not* need to change + +- `dev-blog.network` — still a dual-stack bridge; the app still uses it + (and any future sidecar can join it). +- `nftables.conf.j2` — already accepted `tcp dport { 22, 80, 443 }` and + `udp dport { 443 }` in the `inet filter` table, plus + `iif "lo" accept`, so both Caddy's public listeners and the loopback + hop to the app are covered. +- fail2ban filter regexes, Coraza config, Ansible roles, Quadlet + `[Install]` lines. + +## Operational heads-up + +- `compose.yaml` (local-dev convenience) was deliberately left alone — + it still wires Caddy through the bridge with `PublishPort`, which is + fine for local iteration where preserving real client IPs doesn't + matter. +- If you ever add another container that Caddy needs to reach, follow + the same pattern: have *that* container publish to `127.0.0.1:` + (and `[::1]:` for v6) and reference it from the Caddyfile via + loopback. +- Because the app's loopback publish is bound to `127.0.0.1` / `[::1]` + only, it is **not** reachable from any other host on the network even + if nftables were ever flushed — the kernel itself drops non-loopback + traffic destined to those addresses. diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..b5093cd --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1,14 @@ +# Local state & secrets +*.tfstate +*.tfstate.* +*.tfstate.backup +.terraform/ +.terraform.lock.hcl.bak +crash.log +crash.*.log + +# User-specific configs (may contain secrets) +backend.hcl +*.auto.tfvars +terraform.tfvars +!terraform.tfvars.example diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..2d4355b --- /dev/null +++ b/infra/README.md @@ -0,0 +1,71 @@ +# Infra (OpenTofu → Vultr) + +Provisions a single Vultr instance for the dev_blog: + +| Setting | Value | +| -------------- | ---------------------------------- | +| Region | `sea` (Seattle) | +| Plan | `vc2-1c-2gb` | +| OS | AlmaLinux 10 (looked up via `data "vultr_os"`) | +| Backups | Automated, daily | +| IPv6 | Enabled | + +State is stored in **Cloudflare R2** via OpenTofu's S3-compatible backend. + +## One-time setup + +### 1. R2 bucket + +In the Cloudflare dashboard: + +1. Create an R2 bucket, e.g. `dev-blog-tfstate`. +2. Create an R2 API token (Account → R2 → Manage API tokens) with **Object Read & Write** scoped to that bucket. Note the access key ID + secret. +3. Note your Cloudflare **Account ID** (R2 endpoint host). + +### 2. GitHub repository secrets + +Add these in *Settings → Secrets and variables → Actions*: + +| Secret | Value | +| ----------------------- | ------------------------------------ | +| `VULTR_API_KEY` | Vultr API key | +| `R2_ACCESS_KEY_ID` | R2 token access key ID | +| `R2_SECRET_ACCESS_KEY` | R2 token secret access key | +| `R2_ACCOUNT_ID` | Cloudflare account ID | +| `R2_BUCKET` | `dev-blog-tfstate` | + +## Running locally + +```sh +cd infra +cp backend.hcl.example backend.hcl # fill in bucket + endpoint +cp terraform.tfvars.example terraform.tfvars + +export AWS_ACCESS_KEY_ID= +export AWS_SECRET_ACCESS_KEY= +export TF_VAR_vultr_api_key= + +tofu init -backend-config=backend.hcl +tofu plan +tofu apply +``` + +## CI/CD + +The workflow [`.github/workflows/infra.yml`](../.github/workflows/infra.yml) runs: + +- **`pull_request`** touching `infra/**` → `tofu plan` (read-only). +- **`workflow_dispatch`** → choose `plan`, `apply`, or `destroy`. + +Backend init uses `-backend-config` flags so the bucket and R2 endpoint are +injected from secrets at runtime — no account-specific values are committed. + +## Notes + +- The Vultr provider's `vultr_instance` resource enables daily backups via + `backups = "enabled"` and a `backups_schedule { type = "daily" }` block. +- AlmaLinux 10 is resolved by name through `data "vultr_os"` so we don't have + to hard-code an OS ID that may change. Adjust `os_name_filter` in + `variables.tf` if Vultr renames it. +- The R2 backend uses `region = "auto"` and skips AWS-specific validations, + which is the standard configuration for R2 as an OpenTofu/Terraform S3 backend. diff --git a/infra/backend.hcl.example b/infra/backend.hcl.example new file mode 100644 index 0000000..3d15d2c --- /dev/null +++ b/infra/backend.hcl.example @@ -0,0 +1,16 @@ +# Example backend configuration for Cloudflare R2. +# Copy to `backend.hcl` (gitignored) and fill in your values, then run: +# tofu init -backend-config=backend.hcl +# +# In CI, these are passed as -backend-config=... flags from secrets instead. + +bucket = "dev-blog-tfstate" + +endpoints = { + s3 = "https://.r2.cloudflarestorage.com" +} + +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars supply credentials. +# Alternatively, uncomment: +# access_key = "" +# secret_key = "" diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..93ef0fd --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,51 @@ +data "vultr_os" "alma" { + filter { + name = "name" + values = [var.os_name_filter] + } +} + +resource "vultr_instance" "blog" { + region = var.region + plan = var.plan + os_id = data.vultr_os.alma.id + hostname = var.hostname + label = var.hostname + tags = var.tags + ssh_key_ids = var.ssh_key_ids + + backups = "enabled" + backups_schedule { + type = "daily" + hour = var.backup_hour_utc + } + + enable_ipv6 = true + ddos_protection = false + activation_email = false +} + +# --------------------------------------------------------------------------- +# Static (Reserved) IPs +# +# Reserved IPs survive instance replacement, so DNS records stay valid even +# if `vultr_instance.blog` is destroyed and recreated. +# +# - v4 reservation is a single /32, so `subnet` is the address itself. +# - v6 reservation is a /64; `subnet` is the network prefix and the instance +# takes an address inside it (exposed as `vultr_instance.blog.v6_main_ip`). +# --------------------------------------------------------------------------- + +resource "vultr_reserved_ip" "v4" { + region = var.region + ip_type = "v4" + label = "${var.hostname}-v4" + instance_id = vultr_instance.blog.id +} + +resource "vultr_reserved_ip" "v6" { + region = var.region + ip_type = "v6" + label = "${var.hostname}-v6" + instance_id = vultr_instance.blog.id +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..f6c5d70 --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,27 @@ +output "instance_id" { + value = vultr_instance.blog.id +} + +# Static IPv4 (Vultr Reserved IP /32). +output "main_ip" { + value = vultr_reserved_ip.v4.subnet +} + +# Static IPv6 prefix (Vultr Reserved IP /64). Use a host address inside this +# subnet for AAAA records (the instance's primary v6 is `ipv6_address`). +output "ipv6_subnet" { + value = "${vultr_reserved_ip.v6.subnet}/${vultr_reserved_ip.v6.subnet_size}" +} + +output "ipv6_address" { + value = vultr_instance.blog.v6_main_ip +} + +output "default_password" { + value = vultr_instance.blog.default_password + sensitive = true +} + +output "os" { + value = data.vultr_os.alma.name +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..49bc551 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,6 @@ +vultr_api_key = "REPLACE_ME" +region = "sea" +plan = "vc2-1c-2gb" +os_name_filter = "AlmaLinux 10" +hostname = "dev-blog" +ssh_key_ids = [] diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..02d5e17 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,47 @@ +variable "vultr_api_key" { + description = "Vultr API key. Provide via TF_VAR_vultr_api_key env var." + type = string + sensitive = true +} + +variable "region" { + description = "Vultr region code." + type = string + default = "sea" # Seattle, WA +} + +variable "plan" { + description = "Vultr instance plan." + type = string + default = "vc2-1c-2gb" +} + +variable "os_name_filter" { + description = "Substring to match an OS name in the Vultr OS catalog." + type = string + default = "AlmaLinux 10" +} + +variable "hostname" { + description = "Hostname / label for the instance." + type = string + default = "dev-blog" +} + +variable "ssh_key_ids" { + description = "List of pre-existing Vultr SSH key IDs to inject." + type = list(string) + default = [] +} + +variable "backup_hour_utc" { + description = "Hour of day (UTC, 0-23) for the daily automated backup." + type = number + default = 8 +} + +variable "tags" { + description = "Tags to apply to the instance." + type = list(string) + default = ["dev_blog", "managed-by=opentofu"] +} diff --git a/infra/versions.tf b/infra/versions.tf new file mode 100644 index 0000000..f282b1c --- /dev/null +++ b/infra/versions.tf @@ -0,0 +1,32 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + vultr = { + source = "vultr/vultr" + version = "~> 2.21" + } + } + + # Cloudflare R2 is S3-compatible, so we use the s3 backend with a custom + # endpoint. Backend values that depend on secrets/account-specific data are + # supplied at init time via `-backend-config=backend.hcl` (see README). + backend "s3" { + key = "dev_blog/terraform.tfstate" + region = "auto" + + # R2 quirks: skip AWS-specific validations and use path-style URLs. + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + use_path_style = true + } +} + +provider "vultr" { + api_key = var.vultr_api_key + rate_limit = 700 + retry_limit = 3 +} diff --git a/quadlet/dev-blog-app.container b/quadlet/dev-blog-app.container index e5551e1..2e46353 100644 --- a/quadlet/dev-blog-app.container +++ b/quadlet/dev-blog-app.container @@ -23,8 +23,11 @@ ReadOnly=true DropCapability=ALL Tmpfs=/tmp:rw,size=64m,mode=1777 -# Not exposed publicly – Caddy reverse-proxies in over the internal network. -# PublishPort=4321:4321 +# Caddy now runs on the host network namespace, so it cannot reach the app +# via podman DNS (`app:4321`). Publish the app port on the host's *loopback* +# only -- it is reachable to Caddy on the host but not from outside. +PublishPort=127.0.0.1:4321:4321 +PublishPort=[::1]:4321:4321 [Service] Restart=on-failure diff --git a/quadlet/dev-blog-caddy.container b/quadlet/dev-blog-caddy.container index 0cd892a..98f1b71 100644 --- a/quadlet/dev-blog-caddy.container +++ b/quadlet/dev-blog-caddy.container @@ -9,12 +9,11 @@ ContainerName=dev-blog-caddy # Build with: podman build -t localhost/dev-blog-caddy:latest ./caddy Image=localhost/dev-blog-caddy:latest -Network=dev-blog.network -NetworkAlias=caddy - -PublishPort=80:80 -PublishPort=443:443 -PublishPort=443:443/udp +# Caddy uses the *host* network namespace so it sees real client IP addresses +# (both v4 and v6). Required for fail2ban / Coraza to act on actual sources +# instead of the bridge gateway. With Network=host, PublishPort= is a no-op +# and is intentionally omitted -- Caddy binds 80/443 directly on the host. +Network=host # --- Configuration --- Environment=SITE_ADDRESS=https://example.com @@ -33,6 +32,10 @@ Secret=gcp-dns-sa,type=mount,target=gcp-dns.json,mode=0400 Volume=caddy-data.volume:/data Volume=caddy-config.volume:/config +# Bind-mount the host log directory so fail2ban can tail Caddy access logs +# and Coraza audit logs. The :Z suffix asks Podman to relabel for SELinux. +Volume=/var/log/caddy:/var/log/caddy:Z + # Hardening NoNewPrivileges=true DropCapability=ALL diff --git a/quadlet/dev-blog.network b/quadlet/dev-blog.network index 121cd66..6230bf5 100644 --- a/quadlet/dev-blog.network +++ b/quadlet/dev-blog.network @@ -5,6 +5,9 @@ Description=Internal network for the dev-blog stack NetworkName=dev-blog Driver=bridge DisableDNS=false +IPv6=true +Subnet=10.89.0.0/24 +Subnet=fd00:dead:beef::/64 [Install] WantedBy=multi-user.target default.target