From 2d8eb91b25cffd644b43ff6c4f8d1b4bf46ebfc6 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 29 Jul 2026 19:33:45 +0200 Subject: [PATCH] feat(cert): dial the TLS terminator directly so cert-expiry works from the container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkCertExpiry now accepts a `dial` address and sets ServerName to the hostname — it connects to the terminator's IP while SNI/cert-read use the hostname. The scheduler container has no mesh interface and the host resolver doesn't know the split-horizon zone, so *.hubris.network can't be dialed by name from there; dialing Caddy's lab IP (reachable on the LAN) makes the probe work. The builder passes through a cert entity's `dial` attribute. Re-seed the 20 *.hubris.network certificate entities with dial=192.168.8.175 (Caddy) and uses-certificate edges; cert-expiry monitoring now has real data. --- internal/checkdefaults/defaults.go | 19 ++++++++---- internal/scheduler/scheduler.go | 14 +++++++-- seeds/inventory.yaml | 47 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/internal/checkdefaults/defaults.go b/internal/checkdefaults/defaults.go index e6f5c0e..745a245 100644 --- a/internal/checkdefaults/defaults.go +++ b/internal/checkdefaults/defaults.go @@ -249,17 +249,26 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p }}, "" case KindCertExpiry: - // The host to TLS-dial for the cert. Prefer an explicit `hostname` - // attribute, then `cn`, then a dotted name (a certificate's name is - // its CN/SAN). Hourly: expiry changes once a day, but a renewal or a - // mis-issued cert is worth noticing within the hour. + // The host whose cert to read (SNI / cert CN). Prefer an explicit + // `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry + // changes once a day, but a renewal or mis-issued cert is worth + // noticing within the hour. host := certHost(t, attrs) if host == "" { return nil, "no hostname / cn / dotted name to dial for the cert" } + // `dial` is the TLS terminator's address to connect to (Caddy's lab + // IP), used when the hostname doesn't resolve/reach from the scheduler. + // Without it the probe can't reach *.hubris.network from a container + // with no mesh / split-horizon DNS. + dial, _ := attrs["dial"].(string) + config := map[string]any{"host": host, "warn_days": 30, "crit_days": 7} + if dial != "" { + config["dial"] = dial + } return []checkDef{{ kind: "cert-expiry", - config: map[string]any{"host": host, "warn_days": 30, "crit_days": 7}, + config: config, interval: 3600, }}, "" } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 6cd848e..b36f3d9 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -536,6 +536,12 @@ func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkRes func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult { cfg := struct { Host string `json:"host"` + // Dial is an optional explicit dial address (the TLS terminator's IP) + // for when the hostname doesn't resolve/reach from the scheduler — the + // container has no mesh interface and the host resolver doesn't know + // the split-horizon zone, so *.hubris.network dials Caddy's lab IP + // directly while SNI/cert-read still uses Host. + Dial string `json:"dial"` Port int `json:"port"` WarnDays int `json:"warn_days"` CritDays int `json:"crit_days"` @@ -556,9 +562,13 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) ch timeout = 30 * time.Second } - addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)) + dialHost := cfg.Host + if cfg.Dial != "" { + dialHost = cfg.Dial + } + addr := net.JoinHostPort(dialHost, fmt.Sprintf("%d", cfg.Port)) - d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}} + d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true, ServerName: cfg.Host}} conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return checkResult{ diff --git a/seeds/inventory.yaml b/seeds/inventory.yaml index aa1a475..a9b72a7 100644 --- a/seeds/inventory.yaml +++ b/seeds/inventory.yaml @@ -339,6 +339,32 @@ entities: - {slug: "ingress:sab.hubris.network", type: ingress-route, name: sab.hubris.network, attributes: {forward_auth: true}} + # ─── TLS certificates (Caddy-managed, *.hubris.network) ─────────── + # Each cert's expiry is probed by dialing Caddy's lab IP (`dial`) with SNI + # set to the hostname — the scheduler container has no mesh/split-horizon + # DNS, so it can't resolve *.hubris.network, but it CAN reach Caddy on the + # lab LAN. + - {slug: "cert:proxmox.hubris.network", type: certificate, name: proxmox.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:git.hubris.network", type: certificate, name: git.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:auth.hubris.network", type: certificate, name: auth.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:media.hubris.network", type: certificate, name: media.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:cloud.hubris.network", type: certificate, name: cloud.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:paperless.hubris.network", type: certificate, name: paperless.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:matrix.hubris.network", type: certificate, name: matrix.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:photos.hubris.network", type: certificate, name: photos.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:artifacto.hubris.network", type: certificate, name: artifacto.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:trmnl.hubris.network", type: certificate, name: trmnl.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:zimaos.hubris.network", type: certificate, name: zimaos.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:teddy.hubris.network", type: certificate, name: teddy.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:mcp.hubris.network", type: certificate, name: mcp.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:house.hubris.network", type: certificate, name: house.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:books.hubris.network", type: certificate, name: books.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:seanime.hubris.network", type: certificate, name: seanime.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:roms.hubris.network", type: certificate, name: roms.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:jellyseerr.hubris.network", type: certificate, name: jellyseerr.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:qbit.hubris.network", type: certificate, name: qbit.hubris.network, attributes: {dial: "192.168.8.175"}} + - {slug: "cert:sab.hubris.network", type: certificate, name: sab.hubris.network, attributes: {dial: "192.168.8.175"}} + # ─── Governance ──────────────────────────────────────────────────── - {slug: "person:dtoro", type: person, name: dtoro, attributes: {matrix_id: "@dtoro:avispero"}} @@ -486,6 +512,27 @@ relationships: - {source: "ingress:jellyseerr.hubris.network", target: "service:caddy", type: served-by} - {source: "ingress:qbit.hubris.network", target: "service:caddy", type: served-by} - {source: "ingress:sab.hubris.network", target: "service:caddy", type: served-by} + # Each public route is served with its TLS certificate. + - {source: "ingress:proxmox.hubris.network", target: "cert:proxmox.hubris.network", type: uses-certificate} + - {source: "ingress:git.hubris.network", target: "cert:git.hubris.network", type: uses-certificate} + - {source: "ingress:auth.hubris.network", target: "cert:auth.hubris.network", type: uses-certificate} + - {source: "ingress:media.hubris.network", target: "cert:media.hubris.network", type: uses-certificate} + - {source: "ingress:cloud.hubris.network", target: "cert:cloud.hubris.network", type: uses-certificate} + - {source: "ingress:paperless.hubris.network", target: "cert:paperless.hubris.network", type: uses-certificate} + - {source: "ingress:matrix.hubris.network", target: "cert:matrix.hubris.network", type: uses-certificate} + - {source: "ingress:photos.hubris.network", target: "cert:photos.hubris.network", type: uses-certificate} + - {source: "ingress:artifacto.hubris.network", target: "cert:artifacto.hubris.network", type: uses-certificate} + - {source: "ingress:trmnl.hubris.network", target: "cert:trmnl.hubris.network", type: uses-certificate} + - {source: "ingress:zimaos.hubris.network", target: "cert:zimaos.hubris.network", type: uses-certificate} + - {source: "ingress:teddy.hubris.network", target: "cert:teddy.hubris.network", type: uses-certificate} + - {source: "ingress:mcp.hubris.network", target: "cert:mcp.hubris.network", type: uses-certificate} + - {source: "ingress:house.hubris.network", target: "cert:house.hubris.network", type: uses-certificate} + - {source: "ingress:books.hubris.network", target: "cert:books.hubris.network", type: uses-certificate} + - {source: "ingress:seanime.hubris.network", target: "cert:seanime.hubris.network", type: uses-certificate} + - {source: "ingress:roms.hubris.network", target: "cert:roms.hubris.network", type: uses-certificate} + - {source: "ingress:jellyseerr.hubris.network", target: "cert:jellyseerr.hubris.network", type: uses-certificate} + - {source: "ingress:qbit.hubris.network", target: "cert:qbit.hubris.network", type: uses-certificate} + - {source: "ingress:sab.hubris.network", target: "cert:sab.hubris.network", type: uses-certificate} - {source: "ingress:secrets.hubris.network", target: "service:secrets-issuance", type: routes-to} - {source: "ingress:house.hubris.network", target: "service:house", type: routes-to}