From 682a68dc9c34477f8ebb49e2916e9170dac0481b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 25 Jan 2026 09:35:06 -0800 Subject: [PATCH] Add Caddy reverse proxy for blumeops services (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add Caddy ansible role following zot pattern (manual build, ansible deploy) - Caddy built with Gandi DNS plugin for ACME DNS-01 challenges - Gandi PAT fetched from 1Password and written to secured file on indri - Configure wildcard TLS for `*.ops.eblu.me` - Initial services: forge, registry (indri-local) - Uses port 8443 during testing to avoid Tailscale serve conflicts ## Build Instructions (already done) On indri: ```bash cd ~/code/3rd/caddy && mise run build ``` ## Deployment and Testing - [ ] Review Caddyfile configuration - [ ] Run `mise run provision-indri -- --tags caddy` to deploy - [ ] Test: `curl -v https://forge.ops.eblu.me:8443` (should get TLS cert) - [ ] Test: `curl -v https://registry.ops.eblu.me:8443/v2/` (should return `{}`) - [ ] Once verified, switch to port 443 and migrate services from Tailscale serve ## Files Changed - `ansible/playbooks/indri.yml` - Add pre_task for Gandi PAT, add caddy role - `ansible/roles/caddy/` - New role with Caddyfile and LaunchAgent templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.tail8d86e.ts.net/eblume/blumeops/pulls/55 --- ansible/playbooks/indri.yml | 19 +++++ ansible/roles/caddy/defaults/main.yml | 37 +++++++++ ansible/roles/caddy/handlers/main.yml | 6 ++ ansible/roles/caddy/tasks/main.yml | 80 +++++++++++++++++++ ansible/roles/caddy/templates/Caddyfile.j2 | 38 +++++++++ .../roles/caddy/templates/caddy-wrapper.sh.j2 | 6 ++ ansible/roles/caddy/templates/caddy.plist.j2 | 36 +++++++++ 7 files changed, 222 insertions(+) create mode 100644 ansible/roles/caddy/defaults/main.yml create mode 100644 ansible/roles/caddy/handlers/main.yml create mode 100644 ansible/roles/caddy/tasks/main.yml create mode 100644 ansible/roles/caddy/templates/Caddyfile.j2 create mode 100644 ansible/roles/caddy/templates/caddy-wrapper.sh.j2 create mode 100644 ansible/roles/caddy/templates/caddy.plist.j2 diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index b12e905..6d97257 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -78,6 +78,23 @@ no_log: true tags: [forgejo_runner] + # Caddy Gandi token for ACME DNS-01 challenges + - name: Fetch Gandi PAT for Caddy + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal + delegate_to: localhost + register: _caddy_gandi_token + changed_when: false + no_log: true + check_mode: false + tags: [caddy] + + - name: Set Caddy Gandi token fact + ansible.builtin.set_fact: + caddy_gandi_token: "{{ _caddy_gandi_token.stdout }}" + no_log: true + tags: [caddy] + roles: - role: alloy tags: alloy @@ -101,3 +118,5 @@ tags: tailscale-serve - role: forgejo_runner tags: forgejo_runner + - role: caddy + tags: caddy diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml new file mode 100644 index 0000000..3e1ad0c --- /dev/null +++ b/ansible/roles/caddy/defaults/main.yml @@ -0,0 +1,37 @@ +--- +# Caddy reverse proxy configuration +# Caddy is built manually from ~/code/3rd/caddy with the Gandi DNS plugin + +caddy_repo_dir: /Users/erichblume/code/3rd/caddy +caddy_binary: "{{ caddy_repo_dir }}/bin/caddy" +caddy_config_dir: /Users/erichblume/.config/caddy +caddy_data_dir: /Users/erichblume/.local/share/caddy +caddy_log_dir: /Users/erichblume/Library/Logs + +# Gandi API token file (written by ansible, chmod 0600) +# Caddy reads this file for ACME DNS-01 challenges +caddy_gandi_token_file: /Users/erichblume/.config/caddy/gandi-token + +# Domain configuration +caddy_domain: ops.eblu.me + +# Listen on Tailscale interface only (port 443) +# Use 8443 during testing to avoid conflicts with Tailscale serve +caddy_https_port: 8443 + +# Services to proxy +# Format: { name: "service", host: "hostname", backend: "url" } +caddy_services: + # Indri-local services + - name: forge + host: "forge.{{ caddy_domain }}" + backend: "http://localhost:3001" + - name: registry + host: "registry.{{ caddy_domain }}" + backend: "http://localhost:5050" + + # K8s services (via minikube NodePort or ClusterIP) + # These will be configured once we determine the correct backend URLs + # - name: grafana + # host: "grafana.{{ caddy_domain }}" + # backend: "http://minikube-ip:nodeport" diff --git a/ansible/roles/caddy/handlers/main.yml b/ansible/roles/caddy/handlers/main.yml new file mode 100644 index 0000000..4f2b3d7 --- /dev/null +++ b/ansible/roles/caddy/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart caddy + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist + changed_when: true diff --git a/ansible/roles/caddy/tasks/main.yml b/ansible/roles/caddy/tasks/main.yml new file mode 100644 index 0000000..456474c --- /dev/null +++ b/ansible/roles/caddy/tasks/main.yml @@ -0,0 +1,80 @@ +--- +# Caddy reverse proxy deployment +# Binary is built manually - see ~/code/3rd/caddy/mise.toml + +- name: Verify caddy binary exists + ansible.builtin.stat: + path: "{{ caddy_binary }}" + register: caddy_bin + failed_when: not caddy_bin.stat.exists + changed_when: false + +- name: Create caddy config directory + ansible.builtin.file: + path: "{{ caddy_config_dir }}" + state: directory + mode: "0755" + +- name: Create caddy data directory + ansible.builtin.file: + path: "{{ caddy_data_dir }}" + state: directory + mode: "0755" + +- name: Fetch Gandi PAT (when running with --tags caddy) + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal + delegate_to: localhost + register: _caddy_gandi_token_fallback + changed_when: false + no_log: true + check_mode: false + when: caddy_gandi_token is not defined + +- name: Set Gandi token fact (fallback) + ansible.builtin.set_fact: + caddy_gandi_token: "{{ _caddy_gandi_token_fallback.stdout }}" + no_log: true + when: caddy_gandi_token is not defined + +- name: Write Gandi token file + ansible.builtin.copy: + content: "{{ caddy_gandi_token }}" + dest: "{{ caddy_gandi_token_file }}" + mode: "0600" + no_log: true + notify: Restart caddy + +- name: Deploy Caddyfile + ansible.builtin.template: + src: Caddyfile.j2 + dest: "{{ caddy_config_dir }}/Caddyfile" + mode: "0644" + notify: Restart caddy + +- name: Deploy caddy wrapper script + ansible.builtin.template: + src: caddy-wrapper.sh.j2 + dest: "{{ caddy_config_dir }}/caddy-wrapper.sh" + mode: "0755" + notify: Restart caddy + +- name: Deploy caddy LaunchAgent plist + ansible.builtin.template: + src: caddy.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.caddy.plist + mode: "0644" + notify: Restart caddy + +- name: Check if caddy LaunchAgent is loaded + ansible.builtin.command: + cmd: launchctl list mcquack.eblume.caddy + register: caddy_launchctl + changed_when: false + failed_when: false + +- name: Load caddy LaunchAgent + ansible.builtin.command: + cmd: launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist + when: caddy_launchctl.rc != 0 + changed_when: true diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 new file mode 100644 index 0000000..455b49c --- /dev/null +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -0,0 +1,38 @@ +# Caddy reverse proxy for blumeops services +# Managed by ansible - do not edit manually +# +# All *.{{ caddy_domain }} requests are proxied to backend services. +# TLS certificates are obtained via ACME DNS-01 challenge using Gandi. + +{ + # Global options + admin off +} + +# Wildcard certificate for all services +*.{{ caddy_domain }}:{{ caddy_https_port }} { + tls { + dns gandi {env.GANDI_BEARER_TOKEN} + } + +{% for service in caddy_services %} + @{{ service.name }} host {{ service.host }} + handle @{{ service.name }} { + reverse_proxy {{ service.backend }} + } + +{% endfor %} + # Fallback for unknown hosts + handle { + respond "Unknown service" 404 + } +} + +# Base domain (ops.eblu.me) +{{ caddy_domain }}:{{ caddy_https_port }} { + tls { + dns gandi {env.GANDI_BEARER_TOKEN} + } + + respond "blumeops services - use a subdomain (e.g., forge.{{ caddy_domain }})" +} diff --git a/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 b/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 new file mode 100644 index 0000000..72308f2 --- /dev/null +++ b/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 @@ -0,0 +1,6 @@ +#!/bin/bash +# Wrapper script for Caddy that loads the Gandi token from file +# Managed by ansible - do not edit manually + +export GANDI_BEARER_TOKEN=$(cat {{ caddy_gandi_token_file }}) +exec {{ caddy_binary }} run --config {{ caddy_config_dir }}/Caddyfile diff --git a/ansible/roles/caddy/templates/caddy.plist.j2 b/ansible/roles/caddy/templates/caddy.plist.j2 new file mode 100644 index 0000000..ec36c9e --- /dev/null +++ b/ansible/roles/caddy/templates/caddy.plist.j2 @@ -0,0 +1,36 @@ + + + + + Label + mcquack.eblume.caddy + + ProgramArguments + + {{ caddy_config_dir }}/caddy-wrapper.sh + + + WorkingDirectory + {{ caddy_data_dir }} + + EnvironmentVariables + + XDG_DATA_HOME + /Users/erichblume/.local/share + XDG_CONFIG_HOME + /Users/erichblume/.config + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {{ caddy_log_dir }}/mcquack.caddy.out.log + + StandardErrorPath + {{ caddy_log_dir }}/mcquack.caddy.err.log + +