fleet-manager: front the admin UI with Traefik (443 + Route 53 ACME), retire acme side-car

Management-plane ingress: a host-networked Traefik terminates the LE cert on :443
(Route 53 DNS-01, public-resolver propagation check) and forwards plaintext to
the manager running FLEET_MGR_ADMIN_BEHIND_PROXY=true on loopback :9500. Only the
admin plane rides Traefik — the agent gRPC/mTLS plane (:9600) stays direct (Traefik
can't terminate fleet-CA mTLS; hosts reach it via OPNsense L3 routing). This is the
k8s ingress-controller + cert-manager role in miniature.

- traefik/dynamic/fleet-manager.yml: file-provider router (Host rule -> manager),
  passHostHeader so the WebAuthn RP origin matches.
- compose: traefik service (static config as CLI flags so ${ACME_EMAIL} interpolates)
  + traefik-acme volume; manager switched to behind-proxy defaults, RP_ORIGIN now
  the port-less https origin.
- Retire the acme side-car (Traefik owns ACME now): remove the service + acme-state
  volume; renew-loop.sh kept + relabelled as the self-TLS-mode cert helper.
- :443 only (admin UI is https-only) so it can't collide with a boot-LAN nginx :80.
This commit is contained in:
Audiobuzz
2026-07-05 00:57:04 +00:00
parent 20acdb0254
commit 4c1db3f765
3 changed files with 77 additions and 30 deletions
+45 -29
View File
@@ -71,13 +71,17 @@ services:
influxdb:
condition: service_healthy
environment:
# admin plane (HTTPS + passkey). Bind loopback; the mgmt proxy (holding the
# LE cert) fronts it, or set the cert paths below + expose directly.
# admin plane — passkey auth behind the Traefik mgmt ingress: Traefik
# terminates the LE cert on :443 and forwards plaintext here on loopback,
# so the listener MUST stay private (127.0.0.1) — only Traefik reaches it.
FLEET_MGR_ADMIN_ADDR: "${FLEET_MGR_ADMIN_ADDR:-127.0.0.1:9500}"
FLEET_MGR_ADMIN_BEHIND_PROXY: "${FLEET_MGR_ADMIN_BEHIND_PROXY:-true}"
FLEET_MGR_ADMIN_INSECURE: "${FLEET_MGR_ADMIN_INSECURE:-false}"
# Only used for self-TLS mode (BEHIND_PROXY=false + a cert, no Traefik):
FLEET_MGR_ADMIN_CERT: "${FLEET_MGR_ADMIN_CERT:-/pki/admin/fullchain.pem}"
FLEET_MGR_ADMIN_KEY: "${FLEET_MGR_ADMIN_KEY:-/pki/admin/privkey.pem}"
FLEET_MGR_ADMIN_INSECURE: "${FLEET_MGR_ADMIN_INSECURE:-false}"
# WebAuthn RP — MUST be the UI's real origin (origin-bound passkeys).
# WebAuthn RP — MUST be the UI's real origin (origin-bound passkeys). Behind
# Traefik on :443 this is the PORT-LESS https origin.
FLEET_MGR_RP_ID: "${FLEET_MGR_RP_ID:?set FLEET_MGR_RP_ID in .env}"
FLEET_MGR_RP_ORIGIN: "${FLEET_MGR_RP_ORIGIN:?set FLEET_MGR_RP_ORIGIN in .env}"
FLEET_MGR_RP_NAME: "${FLEET_MGR_RP_NAME:-Fleet Command}"
@@ -114,43 +118,55 @@ services:
# The admin LE cert under ./pki/admin/ is produced by the `acme` side-car.
- ./pki:/pki:ro
# --- admin-UI cert renewal side-car (Let's Encrypt DNS-01 via Route 53) -----
# Long-running "compose cron": issues the UI cert on first start, then renews
# within 30 days of expiry. Writes into ./pki/admin/ — the SAME dir the manager
# reads (./pki:/pki:ro) — so the manager hot-reloads it with no restart. Needs
# outbound reach to Let's Encrypt + the AWS Route 53 API (bridge NAT).
# Bring this up BEFORE the manager (or the manager crash-loops until the cert
# exists, then self-heals). See DEPLOY.md §2c.
acme:
image: goacme/lego:v4.17.4 # mirror via harbor if no dockerhub egress
# --- management-plane ingress (Traefik) — admin UI on :443 ------------------
# Terminates the Let's Encrypt cert (Route 53 DNS-01) on :443 and forwards
# plaintext to the manager (behind-proxy mode, loopback :9500). Host networking
# so it binds :443 on the mgmt interface and reaches the host-networked manager
# on 127.0.0.1:9500. Needs outbound reach to Let's Encrypt + the AWS Route 53
# API. ONLY the admin plane rides Traefik — the agent gRPC/mTLS plane (:9600) is
# direct (Traefik can't terminate fleet-CA client-cert mTLS; hosts reach it via
# OPNsense L3 routing). This is the k8s ingress-controller + cert-manager role
# in miniature — the router/ACME model maps to Traefik Ingress + cert-manager.
#
# NOTE: only :443 (the admin UI is https-only; passkeys require it) so it can't
# collide with a boot-LAN nginx :80 on a single-host lab. Route 53 DNS-01 needs
# no :80. Set the Host() rule in traefik/dynamic/fleet-manager.yml to your UI host.
traefik:
image: traefik:v3.1 # mirror via harbor if no dockerhub egress
restart: unless-stopped
entrypoint: ["/bin/sh", "/renew-loop.sh"]
network_mode: host
command:
- "--global.checknewversion=false"
- "--global.sendanonymoususage=false"
- "--entrypoints.websecure.address=:443"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--certificatesresolvers.le.acme.email=${ACME_EMAIL:?set ACME_EMAIL in .env}"
- "--certificatesresolvers.le.acme.storage=/acme/acme.json"
- "--certificatesresolvers.le.acme.dnschallenge.provider=route53"
# Public resolvers for the propagation check (split-horizon DNS won't serve
# the public _acme-challenge). First dry-run vs LE staging: also add
# --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
- "--certificatesresolvers.le.acme.dnschallenge.resolvers=8.8.8.8:53,1.1.1.1:53"
- "--log.level=INFO"
environment:
ACME_DOMAIN: "${FLEET_MGR_RP_ID:?set FLEET_MGR_RP_ID in .env}" # = the UI hostname
ACME_EMAIL: "${ACME_EMAIL:?set ACME_EMAIL in .env}"
ACME_STAGING: "${ACME_STAGING:-}" # set 1 for a dry-run
# Public resolver(s) for the propagation check — the internal/split-horizon
# DNS doesn't serve the public _acme-challenge record. Comma-separated host:port.
ACME_RESOLVERS: "${ACME_RESOLVERS:-8.8.8.8:53}"
# AWS creds for the Route 53 DNS-01 challenge (or omit + use an instance role).
# Route 53 DNS-01 creds (or attach an instance role + omit). IAM policy in
# DEPLOY.md §2c; AWS_HOSTED_ZONE_ID optional (pins the zone).
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID:-}"
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY:-}"
AWS_REGION: "${AWS_REGION:-us-east-1}"
# Optional: pin the zone (skips the ListHostedZonesByName lookup — needed if
# a public + private zone share the domain; lets you tighten the IAM policy).
AWS_HOSTED_ZONE_ID: "${AWS_HOSTED_ZONE_ID:-}"
volumes:
- ./acme/renew-loop.sh:/renew-loop.sh:ro
- acme-state:/le # lego account + cert store — PERSIST
- ./pki/admin:/out # shared with the manager's ./pki/admin
networks:
- fleet-mgmt
- ./traefik/dynamic:/etc/traefik/dynamic:ro
- traefik-acme:/acme # acme.json (ACME account + issued certs) — PERSIST
depends_on:
- fleet-manager
volumes:
influx-data:
influx-config:
fleet-data:
acme-state:
traefik-acme:
networks:
fleet-mgmt: