diff --git a/Makefile b/Makefile index 7554245..6c3986b 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,8 @@ endif .PHONY: help setup collections lint test test-all check deploy encrypt decrypt \ edit-vault check-vault new-role \ tf-init tf-plan tf-apply tf-output tf-inventory tf-inventory-offsite \ - molecule-image molecule-image-push + molecule-image molecule-image-push \ + caddy-image help: @echo "" @@ -63,6 +64,7 @@ help: @echo "" @echo " make molecule-image Build the Molecule test image locally" @echo " make molecule-image-push Push the test image to the Forgejo registry" + @echo " make caddy-image Build the custom Caddy image (caddy-dns/gandi) locally" @echo "" # ── Environment setup ───────────────────────────────────────────────────────── @@ -143,6 +145,9 @@ molecule-image: molecule-image-push: molecule-image docker push $(MOLECULE_IMAGE) +caddy-image: + docker build -t boma/caddy-gandi:latest -f roles/reverse_proxy/files/Dockerfile roles/reverse_proxy/files/ + # ── Terraform ───────────────────────────────────────────────────────────────── tf-init: diff --git a/inventories/production/group_vars/all/reverse_proxy.yml b/inventories/production/group_vars/all/reverse_proxy.yml new file mode 100644 index 0000000..f087c4c --- /dev/null +++ b/inventories/production/group_vars/all/reverse_proxy.yml @@ -0,0 +1,6 @@ +--- +# Caddy reverse proxy on askari (ADR-024). Custom image built on-host (caddy-dns/gandi). +# TLS: ACME DNS-01 against Gandi using vault.gandi.pat. Routes appended as services land. +reverse_proxy__acme_domain: askari.wingu.me # wildcard *.askari.wingu.me +reverse_proxy__acme_email: admin@wingu.me +reverse_proxy__routes: [] # M4b appends: {host: netbird.askari.wingu.me, upstream: "..."} diff --git a/requirements.yml b/requirements.yml index 7bd458e..23c6f07 100644 --- a/requirements.yml +++ b/requirements.yml @@ -16,3 +16,8 @@ collections: # LiveDNS). PAT auth requires >= 9.0.0. - name: community.general version: ">=9.0.0" + + # community.docker — docker_image (build the Caddy image on-host) + docker_compose_v2 + # (reverse_proxy role). + - name: community.docker + version: ">=3.0.0" diff --git a/roles/reverse_proxy/README.md b/roles/reverse_proxy/README.md new file mode 100644 index 0000000..0e69f28 --- /dev/null +++ b/roles/reverse_proxy/README.md @@ -0,0 +1,82 @@ +# reverse_proxy + +Boma's standard Caddy reverse proxy (ADR-024). Runs on `askari` (the off-site +Hetzner host) and terminates TLS for all public-facing services via ACME DNS-01 +against Gandi LiveDNS. The custom Caddy image (with the `caddy-dns/gandi` plugin) +is built directly on the host because `askari` cannot reach the internal registry +before the mesh VPN is established. + +## How TLS works + +Caddy obtains wildcard certificates for `*.{{ reverse_proxy__acme_domain }}` using +the ACME DNS-01 challenge. The Gandi PAT (`vault.gandi.pat`) is injected into +the container at runtime via an `.env` file as `GANDI_BEARER_TOKEN`. Caddy reads +it with `{env.GANDI_BEARER_TOKEN}` in the Caddyfile so the secret never lands in a +config file on disk. + +## Route catalog — `reverse_proxy__routes` + +Services register themselves as routes by appending an entry to +`reverse_proxy__routes` in `group_vars/all/reverse_proxy.yml`: + +```yaml +reverse_proxy__routes: + - host: netbird.askari.wingu.me + upstream: "netbird-management:443" + - host: dashboard.askari.wingu.me + upstream: "dashboard:8080" +``` + +Each entry renders a named matcher and a `handle` block in the Caddyfile: + +``` +@netbird_askari_wingu_me host netbird.askari.wingu.me +handle @netbird_askari_wingu_me { + reverse_proxy netbird-management:443 +} +``` + +Requests that match no route receive a plain `200 "boma reverse proxy"` response. + +## On-host image build + +The Dockerfile at `roles/reverse_proxy/files/Dockerfile` builds a two-stage image: + +1. `caddy:2-builder` — uses `xcaddy` to compile Caddy with `caddy-dns/gandi`. +2. `caddy:2` — copies the compiled binary into the slim runtime image. + +The Ansible role copies the Dockerfile to `{{ reverse_proxy__base_dir }}` and +calls `community.docker.docker_image` to build `boma/caddy-gandi:latest` on the +host. The build is only re-triggered when the Dockerfile changes +(`force_source: "{{ _caddy_dockerfile.changed }}"`). + +For local dev or CI: `make caddy-image`. + +## Variables + +| Variable | Default | Description | +|---|---|---| +| `reverse_proxy__image` | `boma/caddy-gandi:latest` | Docker image name (built on-host) | +| `reverse_proxy__base_dir` | `/opt/services/reverse_proxy` | Working directory for Compose project | +| `reverse_proxy__acme_domain` | `example.test` | Wildcard domain for ACME cert | +| `reverse_proxy__acme_email` | `admin@example.test` | ACME registration email | +| `reverse_proxy__routes` | `[]` | List of `{host, upstream}` route entries | +| `reverse_proxy__manage` | `true` | Set `false` in Molecule to skip Docker tasks | + +Production overrides live in +`inventories/production/group_vars/all/reverse_proxy.yml`. + +## `reverse_proxy__manage` toggle + +Docker operations (image build, `docker compose up`) are gated on +`reverse_proxy__manage | bool`. Set it to `false` in Molecule so the role can be +tested (template rendering, directory creation) without a Docker daemon. + +## Secrets + +| Secret path | Usage | +|---|---| +| `vault.gandi.pat` | Gandi PAT injected as `GANDI_BEARER_TOKEN` | + +Stored encrypted in `inventories/production/group_vars/all/vault.yml`. Decrypt +with `make decrypt FILE=...` before editing; re-encrypt after. diff --git a/roles/reverse_proxy/defaults/main.yml b/roles/reverse_proxy/defaults/main.yml new file mode 100644 index 0000000..1cba589 --- /dev/null +++ b/roles/reverse_proxy/defaults/main.yml @@ -0,0 +1,8 @@ +--- +# Caddy reverse proxy (ADR-024). TLS via ACME DNS-01 against Gandi. +reverse_proxy__image: "boma/caddy-gandi:latest" +reverse_proxy__base_dir: /opt/services/reverse_proxy +reverse_proxy__acme_domain: example.test +reverse_proxy__acme_email: admin@example.test +reverse_proxy__routes: [] +reverse_proxy__manage: true # set false in Molecule to render templates without Docker diff --git a/roles/reverse_proxy/files/Dockerfile b/roles/reverse_proxy/files/Dockerfile new file mode 100644 index 0000000..87f9e8b --- /dev/null +++ b/roles/reverse_proxy/files/Dockerfile @@ -0,0 +1,8 @@ +# Custom Caddy with the Gandi DNS-01 provider (ADR-024). Built locally (make caddy-image) +# and on askari by the reverse_proxy role (askari can't reach the internal registry +# pre-mesh, so the image is built on the host). +FROM caddy:2-builder AS build +RUN xcaddy build --with github.com/caddy-dns/gandi + +FROM caddy:2 +COPY --from=build /usr/bin/caddy /usr/bin/caddy diff --git a/roles/reverse_proxy/handlers/main.yml b/roles/reverse_proxy/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/reverse_proxy/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/reverse_proxy/meta/main.yml b/roles/reverse_proxy/meta/main.yml new file mode 100644 index 0000000..2f5c7bb --- /dev/null +++ b/roles/reverse_proxy/meta/main.yml @@ -0,0 +1,13 @@ +--- +galaxy_info: + author: sjat + description: >- + Caddy reverse proxy with ACME DNS-01 TLS via Gandi (ADR-024). Builds the + custom image on-host (caddy-dns/gandi) and manages it via Docker Compose. + license: MIT + min_ansible_version: "2.17" + platforms: + - name: Debian + versions: + - trixie +dependencies: [] diff --git a/roles/reverse_proxy/molecule/default/converge.yml b/roles/reverse_proxy/molecule/default/converge.yml new file mode 100644 index 0000000..3deb177 --- /dev/null +++ b/roles/reverse_proxy/molecule/default/converge.yml @@ -0,0 +1,18 @@ +--- +- name: Converge + hosts: all + gather_facts: true + + vars: + reverse_proxy__manage: false + reverse_proxy__acme_domain: example.test + reverse_proxy__acme_email: admin@example.test + reverse_proxy__routes: + - host: app.example.test + upstream: "app:80" + vault: + gandi: + pat: "molecule-dummy" + + roles: + - role: reverse_proxy diff --git a/roles/reverse_proxy/molecule/default/molecule.yml b/roles/reverse_proxy/molecule/default/molecule.yml new file mode 100644 index 0000000..b23d8da --- /dev/null +++ b/roles/reverse_proxy/molecule/default/molecule.yml @@ -0,0 +1,31 @@ +--- +dependency: + name: galaxy + options: + requirements-file: ../../requirements.yml + +driver: + name: docker + +platforms: + - name: instance + # Project-owned image built from .docker/molecule-debian13/Dockerfile + # and hosted in the Forgejo container registry. + # Build/push with: make molecule-image / make molecule-image-push + image: forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest + pre_build_image: true + privileged: true # required for systemd + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + command: /lib/systemd/systemd + +provisioner: + name: ansible + inventory: + host_vars: + instance: + ansible_user: root + +verifier: + name: ansible diff --git a/roles/reverse_proxy/molecule/default/verify.yml b/roles/reverse_proxy/molecule/default/verify.yml new file mode 100644 index 0000000..a6f2799 --- /dev/null +++ b/roles/reverse_proxy/molecule/default/verify.yml @@ -0,0 +1,22 @@ +--- +- name: Verify + hosts: all + gather_facts: false + + tasks: + - name: Slurp the rendered Caddyfile + ansible.builtin.slurp: + src: /opt/services/reverse_proxy/Caddyfile + register: _caddyfile + tags: [verify] + + - name: Assert Caddyfile exists and contains expected content + ansible.builtin.assert: + that: + - _caddyfile.content | b64decode | length > 0 + - "'dns gandi' in (_caddyfile.content | b64decode)" + - "'respond \"boma reverse proxy\"' in (_caddyfile.content | b64decode)" + - "'app.example.test' in (_caddyfile.content | b64decode)" + fail_msg: "Caddyfile is missing expected content" + success_msg: "Caddyfile rendered correctly" + tags: [verify] diff --git a/roles/reverse_proxy/tasks/main.yml b/roles/reverse_proxy/tasks/main.yml new file mode 100644 index 0000000..f4f05ae --- /dev/null +++ b/roles/reverse_proxy/tasks/main.yml @@ -0,0 +1,54 @@ +--- +- name: Ensure the service directory exists + ansible.builtin.file: + path: "{{ reverse_proxy__base_dir }}" + state: directory + mode: "0750" + tags: [config] + +- name: Copy the Caddy image Dockerfile + ansible.builtin.copy: + src: Dockerfile + dest: "{{ reverse_proxy__base_dir }}/Dockerfile" + mode: "0644" + register: _caddy_dockerfile + tags: [config] + +- name: Render the Caddyfile + ansible.builtin.template: + src: Caddyfile.j2 + dest: "{{ reverse_proxy__base_dir }}/Caddyfile" + mode: "0644" + tags: [config] + +- name: Render the env file (Gandi token) + ansible.builtin.template: + src: env.j2 + dest: "{{ reverse_proxy__base_dir }}/.env" + mode: "0600" + tags: [config] + +- name: Render the compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ reverse_proxy__base_dir }}/docker-compose.yml" + mode: "0644" + tags: [config] + +- name: Build the custom Caddy image (caddy-dns/gandi) on the host + community.docker.docker_image: + name: "{{ reverse_proxy__image }}" + source: build + build: + path: "{{ reverse_proxy__base_dir }}" + state: present + force_source: "{{ _caddy_dockerfile.changed }}" + when: reverse_proxy__manage | bool + tags: [deploy] + +- name: Bring the reverse proxy up + community.docker.docker_compose_v2: + project_src: "{{ reverse_proxy__base_dir }}" + state: present + when: reverse_proxy__manage | bool + tags: [deploy] diff --git a/roles/reverse_proxy/templates/Caddyfile.j2 b/roles/reverse_proxy/templates/Caddyfile.j2 new file mode 100644 index 0000000..30bbb15 --- /dev/null +++ b/roles/reverse_proxy/templates/Caddyfile.j2 @@ -0,0 +1,18 @@ +{ + email {{ reverse_proxy__acme_email }} +} + +*.{{ reverse_proxy__acme_domain }} { + tls { + dns gandi {env.GANDI_BEARER_TOKEN} + } +{% for r in reverse_proxy__routes %} + @{{ r.host | replace('.', '_') }} host {{ r.host }} + handle @{{ r.host | replace('.', '_') }} { + reverse_proxy {{ r.upstream }} + } +{% endfor %} + handle { + respond "boma reverse proxy" 200 + } +} diff --git a/roles/reverse_proxy/templates/docker-compose.yml.j2 b/roles/reverse_proxy/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..b3d50d9 --- /dev/null +++ b/roles/reverse_proxy/templates/docker-compose.yml.j2 @@ -0,0 +1,23 @@ +services: + caddy: + image: {{ reverse_proxy__image }} + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + env_file: ./.env + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - boma + +volumes: + caddy_data: + caddy_config: + +networks: + boma: + name: boma diff --git a/roles/reverse_proxy/templates/env.j2 b/roles/reverse_proxy/templates/env.j2 new file mode 100644 index 0000000..a41f0f3 --- /dev/null +++ b/roles/reverse_proxy/templates/env.j2 @@ -0,0 +1 @@ +GANDI_BEARER_TOKEN={{ vault.gandi.pat }}