caddy: add the caddy_site role
Replaces the four-task Caddy vhost block currently copy-pasted into 10
playbooks. Nothing calls it yet; this commit only adds the role.
Verified by rendering all 10 sites through the template and diffing against
what the current playbooks produce: 9 of 10 byte-identical. The tenth is
datum-gateway, where the resolvers comment is standardised, rewriting one
comment line Caddy ignores.
Then dry-run against the live hosts (--check, nothing written):
- vipy: forgejo, vaultwarden, lnbits, personal-blog, ntfy-emergency-app
all report ok/unchanged against the real files
- watchtower: ntfy renders identical via caddy_site_body, blank line and
{host}{uri} placeholders intact
- spacey: headscale renders identical when given the config that is
actually running
- memos, mempool, datum-gateway report changed - the comment, as expected
All 14 site files on all 3 hosts confirmed unchanged afterwards.
Two things the build turned up:
- Ansible does not template dict *keys*, so caddy_site_basic_auth is a list
of {user, hash}. As a dict, a Jinja username passes through literally.
The assert refuses a mapping.
- `caddy validate` does accept a single site fragment - rc=0 on a good one,
rc=1 with a line number on a broken one. This was the plan's one untested
claim. A failed validate leaves the live file untouched.
The reload is now a handler, so it fires once at end of play rather than
immediately; anything needing the new config live mid-play must
flush_handlers first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b07ed72a92
commit
cc9340b7cc
5 changed files with 205 additions and 0 deletions
94
ansible/roles/caddy_site/README.md
Normal file
94
ansible/roles/caddy_site/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# `caddy_site`
|
||||||
|
|
||||||
|
Writes one Caddy site file into `{{ caddy_sites_dir }}`, makes sure the main
|
||||||
|
Caddyfile imports that directory, validates the result, and reloads Caddy once.
|
||||||
|
|
||||||
|
Replaces the four-task block that was copy-pasted into 10 playbooks.
|
||||||
|
|
||||||
|
Runs on any host in the `[caddy]` group — `edge` (vipy), `monitoring`
|
||||||
|
(watchtower) and `vpn_control` (spacey).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- ansible.builtin.include_role:
|
||||||
|
name: caddy_site
|
||||||
|
vars:
|
||||||
|
caddy_site_name: forgejo # -> forgejo.conf
|
||||||
|
caddy_site_domain: "{{ forgejo_domain }}"
|
||||||
|
caddy_site_upstream: "localhost:{{ forgejo_port }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `include_role`, not a `roles:` block, so the call stays in task order next
|
||||||
|
to the tasks it depends on. Variables passed this way are scoped to the include
|
||||||
|
and do not leak into later calls — so **every call must pass everything it
|
||||||
|
needs**; nothing carries over.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
Pick exactly one of `caddy_site_upstream`, `caddy_site_root`, `caddy_site_body`.
|
||||||
|
|
||||||
|
| Want | Set |
|
||||||
|
|---|---|
|
||||||
|
| `reverse_proxy host:port` | `caddy_site_upstream` |
|
||||||
|
| static `root *` + `file_server` | `caddy_site_root` |
|
||||||
|
| anything else | `caddy_site_body` (raw, indented 4 for you) |
|
||||||
|
|
||||||
|
`caddy_site_upstream` accepts two modifiers, which add a block to the
|
||||||
|
`reverse_proxy`:
|
||||||
|
|
||||||
|
- `caddy_site_headers_up: {"X-Forwarded-Host": "..."}`
|
||||||
|
- `caddy_site_resolvers: "100.100.100.100"` — Tailscale MagicDNS
|
||||||
|
|
||||||
|
and `caddy_site_basic_auth` wraps the site in a `basic_auth` block.
|
||||||
|
|
||||||
|
## `caddy_site_basic_auth` is a LIST, not a dict
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
caddy_site_basic_auth:
|
||||||
|
- user: "{{ datum_dashboard_username }}"
|
||||||
|
hash: "{{ datum_dashboard_password_hash }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ansible does not template dictionary keys.** With `{ "{{ user }}": "hash" }`
|
||||||
|
the value is rendered and the key is not, so the literal string
|
||||||
|
`{{ datum_dashboard_username }}` lands in the config file. Found while building
|
||||||
|
this role; the `assert` refuses a mapping so it cannot happen again.
|
||||||
|
|
||||||
|
## Secrets and `--diff`
|
||||||
|
|
||||||
|
Rendered site files can carry credentials — `datum-gateway.conf` holds a bcrypt
|
||||||
|
hash — and `--diff` prints rendered content. The template task therefore sets
|
||||||
|
`diff: "{{ caddy_site_reveal | bool }}"`, default `false`, so `--diff` runs are
|
||||||
|
safe everywhere. Pass `-e caddy_site_reveal=true` to see what moved on a site
|
||||||
|
you know is not secret.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`validate: "caddy validate --adapter caddyfile --config %s"` runs against the
|
||||||
|
rendered temp file before it is moved into place. Verified on vipy that a single
|
||||||
|
site fragment validates cleanly (rc=0, `Valid configuration`) and that a
|
||||||
|
malformed one is rejected (rc=1, with the syntax error and line number). A
|
||||||
|
failed validate leaves the live file untouched, so a broken config can no longer
|
||||||
|
reach a running Caddy.
|
||||||
|
|
||||||
|
What it cannot catch is a conflict with the global `/etc/caddy/Caddyfile`.
|
||||||
|
|
||||||
|
## The reload is a handler
|
||||||
|
|
||||||
|
`Reload caddy` fires **once, at the end of the play**, however many sites
|
||||||
|
notified it. The code this replaced ran `command: systemctl reload caddy`
|
||||||
|
immediately, mid-play. If a later task in the same play needs the new config to
|
||||||
|
be live, flush first:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- ansible.builtin.meta: flush_handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known intentional difference
|
||||||
|
|
||||||
|
The `resolvers` block is commented `# Use Tailscale MagicDNS to resolve the
|
||||||
|
upstream hostname` in every case. `datum-gateway` previously said `# Resolve via
|
||||||
|
Tailscale MagicDNS`. Migrating it therefore rewrites one comment line, which
|
||||||
|
Caddy ignores. Every other site renders byte-identical to what its playbook
|
||||||
|
produced.
|
||||||
23
ansible/roles/caddy_site/defaults/main.yml
Normal file
23
ansible/roles/caddy_site/defaults/main.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
# Required
|
||||||
|
caddy_site_name: "" # file basename -> <name>.conf
|
||||||
|
caddy_site_domain: "" # site address line; may hold several, comma separated
|
||||||
|
|
||||||
|
# Pick exactly one shape
|
||||||
|
caddy_site_upstream: "" # "localhost:3000" -> reverse_proxy
|
||||||
|
caddy_site_root: "" # filesystem path -> root * + file_server
|
||||||
|
caddy_site_body: "" # raw escape hatch for one-off sites; wins over both
|
||||||
|
|
||||||
|
# reverse_proxy modifiers
|
||||||
|
caddy_site_resolvers: "" # "100.100.100.100" for Tailscale MagicDNS
|
||||||
|
caddy_site_headers_up: {} # {"X-Forwarded-Host": "wallet.example.com"}
|
||||||
|
# A LIST, not a dict: Ansible does not template dict *keys*, so a Jinja
|
||||||
|
# expression for the username silently passes through as literal text.
|
||||||
|
caddy_site_basic_auth: [] # [{user: "{{ x_user }}", hash: "{{ x_hash }}"}]
|
||||||
|
|
||||||
|
# Placement. caddy_sites_dir comes from services_config.yml; this is the fallback.
|
||||||
|
caddy_sites_dir: /etc/caddy/sites-enabled
|
||||||
|
|
||||||
|
# Rendered site files can carry credentials (basic_auth hashes), so --diff is
|
||||||
|
# suppressed by default. Pass -e caddy_site_reveal=true to see what moved.
|
||||||
|
caddy_site_reveal: false
|
||||||
8
ansible/roles/caddy_site/handlers/main.yml
Normal file
8
ansible/roles/caddy_site/handlers/main.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
# Fires once at the end of the play, however many sites notified it.
|
||||||
|
# Anything later in the same play that needs the new config live must be
|
||||||
|
# preceded by `- ansible.builtin.meta: flush_handlers`.
|
||||||
|
- name: Reload caddy
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: caddy
|
||||||
|
state: reloaded
|
||||||
46
ansible/roles/caddy_site/tasks/main.yml
Normal file
46
ansible/roles/caddy_site/tasks/main.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
- name: Assert caddy_site parameters are sane
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- caddy_site_name | length > 0
|
||||||
|
- caddy_site_domain | length > 0
|
||||||
|
- (caddy_site_upstream | length > 0) or (caddy_site_root | length > 0) or (caddy_site_body | length > 0)
|
||||||
|
- caddy_site_basic_auth is not mapping
|
||||||
|
fail_msg: >-
|
||||||
|
caddy_site: '{{ caddy_site_name | default("<unnamed>") }}' needs a name, a domain and
|
||||||
|
one of caddy_site_upstream / caddy_site_root / caddy_site_body.
|
||||||
|
caddy_site_basic_auth must be a LIST of {user, hash} — Ansible does not template dict keys.
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Ensure Caddy sites-enabled directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ caddy_sites_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Ensure Caddyfile imports sites-enabled
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/caddy/Caddyfile
|
||||||
|
line: 'import sites-enabled/*'
|
||||||
|
insertafter: EOF
|
||||||
|
state: present
|
||||||
|
create: yes
|
||||||
|
mode: '0644'
|
||||||
|
backup: yes
|
||||||
|
|
||||||
|
# `validate` runs `caddy validate` against the rendered temp file before it is
|
||||||
|
# moved into place: verified on vipy that a single site fragment validates
|
||||||
|
# cleanly (rc=0, "Valid configuration") and that a malformed one is rejected
|
||||||
|
# (rc=1). A failed validate leaves the live file untouched.
|
||||||
|
- name: "Write Caddy site '{{ caddy_site_name }}'"
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: site.conf.j2
|
||||||
|
dest: "{{ caddy_sites_dir }}/{{ caddy_site_name }}.conf"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: '0644'
|
||||||
|
validate: "caddy validate --adapter caddyfile --config %s"
|
||||||
|
diff: "{{ caddy_site_reveal | bool }}"
|
||||||
|
notify: Reload caddy
|
||||||
34
ansible/roles/caddy_site/templates/site.conf.j2
Normal file
34
ansible/roles/caddy_site/templates/site.conf.j2
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{{ caddy_site_domain }} {
|
||||||
|
{% if caddy_site_body %}
|
||||||
|
{{ caddy_site_body | trim | indent(4, first=True) }}
|
||||||
|
{% else %}
|
||||||
|
{% if caddy_site_basic_auth %}
|
||||||
|
basic_auth {
|
||||||
|
{% for cred in caddy_site_basic_auth %}
|
||||||
|
{{ cred.user }} {{ cred.hash }}
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
{% if caddy_site_root %}
|
||||||
|
root * {{ caddy_site_root }}
|
||||||
|
file_server
|
||||||
|
{% endif %}
|
||||||
|
{% if caddy_site_upstream %}
|
||||||
|
{% if caddy_site_headers_up or caddy_site_resolvers %}
|
||||||
|
reverse_proxy {{ caddy_site_upstream }} {
|
||||||
|
{% for key, value in caddy_site_headers_up.items() %}
|
||||||
|
header_up {{ key }} {{ value }}
|
||||||
|
{% endfor %}
|
||||||
|
{% if caddy_site_resolvers %}
|
||||||
|
# Use Tailscale MagicDNS to resolve the upstream hostname
|
||||||
|
transport http {
|
||||||
|
resolvers {{ caddy_site_resolvers }}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
reverse_proxy {{ caddy_site_upstream }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue