From 408b5d8652571d8d5eac5c0f91593787dbb22f9a Mon Sep 17 00:00:00 2001 From: Chewbaccalakis Date: Wed, 1 Jul 2026 17:14:50 -0700 Subject: [PATCH] added caddy provider support --- ansible/plays/machine_setup/caddy.yml | 82 ++++++++++++++++++- .../machine_setup/templates/Caddyfile.j2 | 14 ++++ main.tf | 12 +++ providers.tf | 24 ++++++ terraform.tfvars | 36 ++++++++ vars.tf | 29 +++++++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 ansible/plays/machine_setup/templates/Caddyfile.j2 diff --git a/ansible/plays/machine_setup/caddy.yml b/ansible/plays/machine_setup/caddy.yml index 9eac2d4..5d368d6 100644 --- a/ansible/plays/machine_setup/caddy.yml +++ b/ansible/plays/machine_setup/caddy.yml @@ -2,6 +2,18 @@ - name: Set up caddy LXC hosts: caddy become: yes + vars: + # Where the Caddy admin API listens. OpenTofu manages reverse-proxy routes + # through this endpoint (caddy_host / caddy_ssh_host in terraform.tfvars). + caddy_admin_listen: "0.0.0.0:2019" + + handlers: + - name: restart caddy + ansible.builtin.systemd: + name: caddy + state: restarted + daemon_reload: yes + tasks: - name: default_packages ansible.builtin.include_tasks: ../../templates/default_packages.yml @@ -12,9 +24,52 @@ username: nick github_user: Chewbaccalakis + # Config is deployed BEFORE the caddy package is installed: dpkg keeps a + # pre-existing /etc/caddy/Caddyfile instead of the package default (which + # serves a static page on :80 as server "srv0" — that would get autosaved + # and collide with the OpenTofu-managed :80 server forever after). + - name: configure caddy + block: + - name: create /etc/caddy + ansible.builtin.file: + path: /etc/caddy + state: directory + mode: '0755' + + - name: deploy Caddyfile (seeds the admin API listener) + ansible.builtin.template: + src: templates/Caddyfile.j2 + dest: /etc/caddy/Caddyfile + owner: root + group: root + mode: '0644' + notify: restart caddy + + - name: create caddy systemd override directory + ansible.builtin.file: + path: /etc/systemd/system/caddy.service.d + state: directory + mode: '0755' + + - name: override caddy service to resume OpenTofu-applied config + ansible.builtin.copy: + dest: /etc/systemd/system/caddy.service.d/resume.conf + content: | + # Managed by Ansible. --resume makes Caddy prefer its autosaved + # JSON config (written whenever OpenTofu applies changes via the + # admin API) over the Caddyfile, so managed routes survive + # restarts. The Caddyfile only seeds the initial config; to + # re-seed from it, delete + # /var/lib/caddy/.config/caddy/autosave.json and restart caddy. + [Service] + ExecStart= + ExecStart=/usr/bin/caddy run --environ --resume --config /etc/caddy/Caddyfile --adapter caddyfile + mode: '0644' + notify: restart caddy + - name: install caddy block: - - name: install xcaddy prerequisites + - name: install apt prerequisites ansible.builtin.apt: name: - debian-keyring @@ -22,6 +77,16 @@ - apt-transport-https state: present + - name: download caddy gpg key + ansible.builtin.shell: + cmd: curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + creates: /usr/share/keyrings/caddy-stable-archive-keyring.gpg + + - name: add caddy apt repository + ansible.builtin.shell: + cmd: curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list + creates: /etc/apt/sources.list.d/caddy-stable.list + - name: download xcaddy gpg key ansible.builtin.shell: cmd: curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-xcaddy-archive-keyring.gpg @@ -32,8 +97,17 @@ cmd: curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-xcaddy.list creates: /etc/apt/sources.list.d/caddy-xcaddy.list - - name: install xcaddy + - name: install caddy and xcaddy ansible.builtin.apt: - name: xcaddy + name: + - caddy + - xcaddy state: present - update_cache: yes \ No newline at end of file + update_cache: yes + + - name: enable and start caddy + ansible.builtin.systemd: + name: caddy + enabled: yes + state: started + daemon_reload: yes diff --git a/ansible/plays/machine_setup/templates/Caddyfile.j2 b/ansible/plays/machine_setup/templates/Caddyfile.j2 new file mode 100644 index 0000000..d9e2508 --- /dev/null +++ b/ansible/plays/machine_setup/templates/Caddyfile.j2 @@ -0,0 +1,14 @@ +# {{ ansible_managed }} +# +# This file only seeds Caddy's initial configuration: the admin API listener +# that OpenTofu talks to. Reverse-proxy routes are managed by the caddy module +# via the admin API (caddy_proxies in terraform.tfvars); Caddy autosaves those +# changes and the systemd unit starts with --resume, so API-applied config +# survives restarts and this file is ignored once an autosave exists. +# +# To re-seed from this file (e.g. after changing the admin listener), delete +# /var/lib/caddy/.config/caddy/autosave.json and restart caddy. + +{ + admin {{ caddy_admin_listen }} +} diff --git a/main.tf b/main.tf index 935ec28..07551d5 100644 --- a/main.tf +++ b/main.tf @@ -17,6 +17,7 @@ locals { technitium_enabled = try(data.external.infisical.result["technitium_api_token"], "") != "" ipam_enabled = try(data.external.infisical.result["ipam_username"], "") != "" + caddy_enabled = var.caddy_host != "" # Shared inputs passed to every proxmox_node module call. node_common = { @@ -102,6 +103,17 @@ module "technitium" { records = var.dns_records } +module "caddy" { + source = "./opentofu/modules/caddy" + + enabled = local.caddy_enabled + proxies = var.caddy_proxies + + providers = { + caddy = caddy + } +} + resource "local_file" "ansible_inventory" { filename = "${path.module}/${var.ansible_inventory_path}" content = templatefile("${path.module}/opentofu/inventory.tpl", { diff --git a/providers.tf b/providers.tf index 489ba32..df4724f 100644 --- a/providers.tf +++ b/providers.tf @@ -12,6 +12,10 @@ terraform { source = "kenske/technitium" version = "~> 0.2" } + caddy = { + source = "conradludgate/caddy" + version = "~> 0.2" + } # phpipam = { # source = "lord-kyron/phpipam" # version = "1.6.2" @@ -27,6 +31,26 @@ provider "technitium" { token = coalesce(try(data.external.infisical.result["technitium_api_token"], ""), "token-not-yet-provisioned") } +provider "caddy" { + # Endpoint as seen from wherever the request lands: with the SSH tunnel below + # this is the caddy host's own loopback admin API; without it, a directly + # reachable admin endpoint. Falls back to the Caddy default so the provider + # configures cleanly before caddy_host is set (no resources use it until then). + host = coalesce(var.caddy_host, "http://localhost:2019") + + # Tunnel to the admin API over SSH when it only listens on loopback (the + # default, recommended setup). Reuses the ansible key generated above. Leave + # caddy_ssh_host empty to talk to caddy_host directly. + dynamic "ssh" { + for_each = var.caddy_ssh_host != "" ? [1] : [] + content { + host = var.caddy_ssh_host + key_file = local_sensitive_file.ssh_private_key.filename + host_key = var.caddy_ssh_host_key + } + } +} + data "external" "infisical" { program = ["bash", "-c", "infisical export --template=./secrets.tmpl"] } diff --git a/terraform.tfvars b/terraform.tfvars index 8c5a773..dd97e31 100644 --- a/terraform.tfvars +++ b/terraform.tfvars @@ -40,8 +40,44 @@ dns_records = [ { zone = "oswaldtheotter.lan", name = "proxmox", type = "A", value = "192.168.0.240" }, { zone = "oswaldtheotter.lan", name = "nas", type = "CNAME", value = "portanas.oswaldtheotter.lan" }, { zone = "oswaldtheotter.lan", name = "s3", type = "CNAME", value="portanas.oswaldtheotter.lan"}, + { zone = "oswaldtheotter.lan", name = "hass", type = "A", value="192.168.0.70"}, + { zone = "oswaldtheotter.lan", name = "homeassistant", type = "CNAME", value="hass.oswaldtheotter.lan"}, + { zone = "oswaldtheotter.lan", name = "bookstack", type = "A", value="192.168.0.5"}, ] + +# ── Reverse proxy (Caddy) ─────────────────────────────────────────────────────── +# Managed on the caddy LXC (192.168.0.5). The admin API listens on loopback, so +# reach it by tunneling SSH to the box as the ansible user. Clear caddy_host to +# disable Caddy management entirely. + +caddy_host = "http://localhost:2019" +caddy_ssh_host = "ansible@192.168.0.5:22" +# From: ssh-keyscan 192.168.0.5 — must be refreshed if the LXC is rebuilt. +# Must be the ECDSA key: the provider's (old) Go SSH client prefers +# ecdsa-sha2-nistp256 during negotiation and pins the exact key it receives. +caddy_ssh_host_key = "192.168.0.5 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLjluBg9JCITeqG4NPad4VNTkAcZDY3y9FU9CP5BAuKIr3TDhTFYzuAdNnROCEhW1ddJI9ZFKI5y/3xqNTmkHbA=" + +# Each proxy serves plain HTTP on :80 by default; set protocol = "https" to +# serve it on :443 instead. Note: .lan hostnames can't get public certificates, +# so HTTPS sites use Caddy's internal CA — trust its root cert on your devices +# to avoid browser warnings. +caddy_proxies = { + # dns = { + # host = "dns.oswaldtheotter.lan" + # upstreams = ["192.168.0.2:5380"] + # } + # secure-example = { + # host = "secure.oswaldtheotter.lan" + # upstreams = ["192.168.0.202:80"] + # protocol = "https" + # } + bookstack = { + host = "bookstack.oswaldtheotter.lan" + upstreams = ["192.168.0.4:30214"] + } +} + # ── Node definitions ─────────────────────────────────────────────────────────── nodes = { diff --git a/vars.tf b/vars.tf index 47a4949..befaec8 100644 --- a/vars.tf +++ b/vars.tf @@ -62,6 +62,35 @@ variable "dns_records" { default = [] } +variable "caddy_host" { + description = "Caddy admin API endpoint. When using the SSH tunnel this is the endpoint as seen from the caddy host (e.g. http://localhost:2019). Leave empty to disable Caddy management." + type = string + default = "" +} + +variable "caddy_ssh_host" { + description = "SSH target (user@host:port) used to tunnel to the Caddy admin API. Empty to reach caddy_host directly." + type = string + default = "" +} + +variable "caddy_ssh_host_key" { + description = "SSH host key of the caddy host, known_hosts format (get it with: ssh-keyscan ). Required when caddy_ssh_host is set." + type = string + default = "" +} + +variable "caddy_proxies" { + description = "Reverse-proxy sites keyed by name. Each maps a hostname to one or more upstream backends (Caddy dial targets, e.g. \"192.168.0.40:32400\"). protocol picks plain HTTP on :80 (default) or HTTPS on :443 with automatic certificates. Set path to restrict the route to specific request paths." + type = map(object({ + host = string + upstreams = list(string) + protocol = optional(string, "http") + path = optional(list(string)) + })) + default = {} +} + variable "ansible_inventory_path" { description = "Path where the generated Ansible inventory file is written. Defaults to ../ansible/inventories/generated.yml relative to this module, which points to the parent repo when used as a git submodule." type = string