Commit Graph
4354 Commits
Author SHA1 Message Date
k.rennerandClaude Opus 4.7 5b4e3b0ee8 shelly-manager: valid-base64 placeholders so flux stays healthy pre-credentials
The previous placeholder string contained underscores — not valid base64 — which the API
server rejects on Secret create, making the infrastructure / apps Kustomizations report
unhealthy until the real token is filled in. Using base64 of `{"auths":{}}` instead lets
the Secret apply cleanly; only the pod-level pull (or image-reflector tag listing) fails
until you encrypt real credentials in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:58:22 +02:00
k.rennerandClaude Opus 4.7 e9268ad9e2 shelly-manager: deploy in-house admin tool, scoped MQTT users, image automation
New apps/shelly-manager Deployment + LAN-only Ingress under `shelly-manager.k.renner.uno`,
pulling from the in-house gitea registry. Adds a second broker user `shellies` scoped to
`shellies/#` + `homeassistant/#` so devices can be provisioned without sharing the HA
credential, and a third `admin` user for workstation debugging — each on its own bootstrap
Job + Secret so passwords rotate independently. Image automation tracks `build.<unix-ts>`
tags published by the shelly-manager CI; gitea registry creds live in both `apps` and
`flux-system` namespaces (SOPS placeholders pending tokens).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:56:38 +02:00
k.rennerandClaude Opus 4.7 6bad2c6ea4 crowdsec: whitelist Proxmox UI api2/json polling
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Proxmox web UI polls /api2/json/cluster/* and per-guest status/current
endpoints every ~2s; CrowdSec's http-probing scenario flagged the
pattern as scanning and banned residential IPv6 clients via the
ingress-nginx Lua bouncer (bare 403 across every host).

Adds a parser-stage whitelist matching only those polling paths on
pve.renner.uno.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:45:41 +02:00
k.rennerandClaude Opus 4.7 5f04eb7aa0 monitoring(pv-system-roi): cast pv_install_date to float in Flux
The template var \${pv_install_date} substitutes as an int literal
(1684706400). Flux is strict about float vs int arithmetic, so
`float / int_literal` in the elapsed_years calc was failing and
breaking the Payback panel. Wrap with float(v: \${pv_install_date}).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:40:23 +02:00
k.rennerandClaude Opus 4.7 ddd92ca5ed monitoring(dashboards): rewrite HA-historical dashboards to InfluxDB/Flux
Per the user directive that historical HA data lives in InfluxDB only,
the three time-spanning HA dashboards switch from PromQL against K8s
Prom (90d retention) to Flux against the in-cluster InfluxDB
(suedstr28 bucket, repointed in commit eb6399f).

- home-energy: 9 data panels (smart_meter L1/L2/L3/sum, energy total,
  top consumers, lights). Measurement = unit_of_measurement
  ("W"/"kWh"/"V"/"A"/"%"); filter by entity_id tag.
- home-climate: 10 data panels (room temperatures via Shelly/Meter Pro,
  humidity, CO2, heating system, hot water).
- pv-system-roi: 23 data panels including the multi-entity arithmetic
  panels (Self-Use Savings, Total Value, Payback, ROI Progress) which
  use Flux scalar findRecord + array.from for stats and pivot+map for
  the cumulative-value timeseries.

Expect some queries to need iteration as the InfluxDB schema is
discovered live — particularly the friendly_name regex translations
on home-climate and the climate-domain heating panel which assumes
all _measurement="°C" with domain="climate" entries are wanted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:30:39 +02:00
k.rennerandClaude Opus 4.7 eb6399fae8 monitoring(grafana): repoint InfluxDB datasource at in-cluster server
VM 108 (the legacy InfluxDB at influxdb.srv.renner.uno:10.2.40.16) is
unreachable — no ICMP, no SSH, no port 8086. K8s Grafana's InfluxDB
datasource has been silently broken since the in-cluster InfluxDB
came online (commit 1daaa6f, 2026-05-05). The heatpump-roi dashboard
and any historical HA panel that queries InfluxDB are dead until this
repoint lands.

Datasource URL: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086.

The legacy SOPS-stored token (`grafana-influxdb-token.sops.yaml`) is
useless against the in-cluster bolt store. Replaced with a seed Job
that mirrors the existing `influxdb-ha-token` pattern: ServiceAccount
in influxdb ns + cross-namespace Role on the `grafana-influxdb-token`
Secret in monitoring ns + Job that creates a read-only auth on the
suedstr28 bucket and writes the token to that Secret. The key name
inside the Secret stays `INFLUXDB_TOKEN` so the existing
`envFromSecrets` reference in the helmrelease keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:55:28 +02:00
k.rennerandClaude Opus 4.7 8e429175fa monitoring(dashboards): delete unfixable dashboards
Phase 2c. Three dashboards whose data path is structurally blocked in
the K8s-only setup:

- paperless-overview: paperless-ngx has no native /metrics; the
  dashboard's `paperless_*` metric names match an upstream community
  exporter that doesn't appear to ship a maintained public image.
- ntp-overview: dashboard queries `chrony_tracking_*` from a per-host
  chrony_exporter. Talos K8s nodes don't expose chronyd via a socket a
  DaemonSet can read without hostNetwork, which we forbid.
- synology-nas: requires a custom snmp.yml with the `synology` module
  (not in upstream snmp_exporter's bundled config — confirmed
  63110-line stock snmp.yml has zero `synology:` sections). Generating
  a synology snmp.yml from Synology MIB files is out of scope for a
  GitOps commit; the dashboard would also need updates for the v0.27+
  multi-auth schema. Revisit if Synology metrics become a priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:09:57 +02:00
k.rennerandClaude Opus 4.7 16c26eea3f monitoring: deploy blackbox-exporter + HTTP/TCP Probe CRs
Phase 2b of the dashboard triage. Deploys prometheus-community's
blackbox-exporter chart in the monitoring namespace and creates Probe
CRs covering the legacy Ansible blackbox_http_targets list plus the
TCP probe for the legacy postgres-ha host. The chart's ServiceMonitor
exposes the exporter's own metrics; the Probe CRs drive periodic
endpoint probing.

Feeds blackbox-overview dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:02:50 +02:00
k.rennerandClaude Opus 4.7 f5c6d7a13b monitoring: ServiceMonitors for alloy + immich, static scrape for gitea
Phase 2a of the dashboard triage. Wires three metric sources whose
dashboards previously had no data path:

- alloy-servicemonitor.yaml: scrapes the alloy DaemonSet's
  http-metrics:12345 endpoint. Feeds alloy-overview.
- immich-servicemonitor.yaml: scrapes immich-server's metrics:8081
  endpoint (already exposed by the existing Service). Feeds
  immich-overview.
- helmrelease-prometheus.yaml: adds a static scrape for
  gitea.srv.renner.uno:3000/metrics (anonymous, no auth token
  required). Feeds gitea-overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:58:11 +02:00
k.rennerandClaude Opus 4.7 366bbb0bed monitoring(dashboards): delete dead dashboards + repoint legacy datasource
Phase 1 of the dashboard triage.

Deleted (no current data source, no plan to wire one):
- unifi-overview (unpoller removed after UDM crash)
- caddy-overview (Caddy retired 2026-05-09)
- docker-overview (Docker host era done; workloads on K8s)
- log-explorer (legacy host log shipping not in scope)
- dns-overview (dns-servers job lives but a dedicated dashboard isn't worth keeping; Explore tab covers ad-hoc)

Repointed datasource from `prometheus-legacy` to `prometheus` (the
legacy LXC 227 Prom is retired):
- node-overview, node-metrics — show K8s nodes only (legacy host
  node-exporters were never re-scraped post-migration)
- infrastructure-overview — generic up{} + node_* metrics work on K8s
- proxmox-cluster — pve-exporter is now in-cluster (commit 33b452a)

Also deletes DASHBOARD-MIGRATION.md (the migration cut-over completed
with VM 109 retirement on 2026-05-09).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:56:21 +02:00
k.rennerandClaude Opus 4.7 b9a879077c monitoring: expose Prometheus + Alertmanager via LAN ingress
ingress-nginx + letsencrypt-cluster cert at prometheus.renner.uno
and alerts.renner.uno. No external-dns/cloudflare label, so only
external-dns-unifi publishes the hostnames — they resolve on LAN
and NXDOMAIN from the public internet.

externalUrl set on both so the click-through links embedded in
Alertmanager emails resolve to the external hostname instead of the
in-cluster service name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:24:04 +02:00
k.rennerandClaude Opus 4.7 a50dafc5d8 monitoring: expose Prometheus + Alertmanager via LAN ingress
ingress-nginx + letsencrypt-cluster cert at prometheus.k.renner.uno
and alerts.k.renner.uno. The .k.renner.uno zone is published by
external-dns-unifi only, so neither hostname is reachable from the
public internet.

externalUrl set on both so the click-through links embedded in
Alertmanager emails resolve to the external hostname instead of the
in-cluster service name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:23:08 +02:00
k.rennerandClaude Opus 4.7 bbe5184d18 alertmanager: drop KubeCPUOvercommit to null receiver
The kube-prom-stack default alert fires whenever the cluster can't
tolerate the failure of one node. On a small homelab with no spare
node headroom this is structurally unsatisfiable, not actionable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:34:42 +02:00
k.rennerandClaude Opus 4.7 33b452ae7d monitoring(pve-exporter): mount emptyDir at /tmp for Python tempfile
readOnlyRootFilesystem=true leaves no writable directory under /tmp,
and pve_exporter's Python startup fails with FileNotFoundError from
tempfile._get_default_tempdir. Mount a small emptyDir at /tmp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:25:40 +02:00
k.rennerandClaude Opus 4.7 2156d70d9b Revert "monitoring: rehost unpoller in-cluster"
Pointing unpoller v2.39.0 at the UDM caused the controller to crash.
Removing the deployment, secret, image-automation policy, and the
helmrelease pointer entirely. UniFi metrics stay offline until a
known-good poller/UDM combination is identified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:19:58 +02:00
k.rennerandClaude Opus 4.7 60dd3cb6e1 monitoring(exporters): fix pve-exporter args + unpoller output panic
pve-exporter image ENTRYPOINT is already `pve_exporter`; the args list
was duplicating the binary name as a literal argument, producing
"unrecognized arguments: pve_exporter".

unpoller v2.39.0 nil-panics in `datadogunifi.Run` when output sections
are missing from up.conf (regression from the 2.11.x era the Ansible
role targeted). Explicitly disable [influxdb]/[datadog]/[loki].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:55:01 +02:00
security-bot b073b37220 chore(image-automation): bump ghcr.io/unpoller/unpoller:v2.39.0
Updates managed by image-automation-controller.
2026-05-12 05:48:07 +00:00
k.rennerandClaude Opus 4.7 9fd89efa0c monitoring: rehost pve-exporter + unpoller in-cluster
The previous deploys (Ansible roles install-prometheus.yml against
hosts: prometheus.srv.renner.uno on LXC 227) died with that host's
retirement. Re-deploy both as K8s Deployments in the monitoring
namespace, reusing the existing Ansible-vault credentials for the
Proxmox root@pam!ansible token and the UniFi `Poller` read-only user
(now stored in SOPS-encrypted Secrets per the K8s convention).

pve-exporter (prompve/prometheus-pve-exporter:3.8.3) listens on 9221
and is queried as a blackbox-style exporter: the existing pve scrape
config keeps the three 10.2.40.20x targets and uses relabel_configs
to direct the actual HTTP call to pve-exporter.monitoring.svc.

unpoller (ghcr.io/unpoller/unpoller:v2.1.5) polls https://udm.renner.uno
and exposes :9130. Scraped via a ServiceMonitor; the dead static
unifi scrape config is removed from the helmrelease.

Both images are enrolled in image-automation: pve-exporter on semver
3.x with plain 3-component tags; unpoller on semver 2.x with v-prefix
extract that rejects -rc and arch-suffixed variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:47:16 +02:00
k.rennerandClaude Opus 4.7 8cfea986d0 monitoring(home-assistant): wire ServiceMonitor + drop dead external scrape
The in-cluster ServiceMonitor was missing the `release: kube-prometheus-stack`
selector label, so the Prometheus resource never discovered it (same trap
the garage PrometheusRule documents). Adding the label brings the scrape
online via the existing in-cluster Service + bearer-token Secret.

Also removes the obsolete `homeassistant` additionalScrapeConfig that
pointed at the external IoT-VLAN IP with an empty bearer_token TODO —
HA lives in the cluster now, the external path is dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:29:31 +02:00
k.rennerandClaude Opus 4.7 68515b5d30 alertmanager: drop kube-prometheus-stack Watchdog heartbeat to null receiver
Watchdog is an always-firing alert designed to feed an external
dead-man's-switch (PagerDuty DMS, Healthchecks.io, etc.); we don't
run one, and a 12h-repeat email of a continually-firing alert is
pure noise. Route alertname="Watchdog" to a null receiver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:22:15 +02:00
k.rennerandClaude Opus 4.7 b466cdaafb monitoring(garage): fix false-positive GarageScrapeFailing expression
scrape_samples_scraped is a gauge, not a counter, so rate() of a
stable value is 0 — the alert fired whenever Garage was healthy and
producing a steady sample count. Switch to a direct gauge==0
comparison so the alert reflects the annotated intent (endpoint up,
zero samples returned).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:19:41 +02:00
k.rennerandClaude Opus 4.7 315693bb27 monitoring: wire Proton SMTP into Alertmanager + Flux notifications
Alertmanager (kube-prometheus-stack) now sends to admin@renner.uno via
Proton SMTP submission (smtp.protonmail.ch:587 STARTTLS) using a per-
account token in the alertmanager-smtp SOPS-encrypted Secret.

A new infrastructure subtree, flux-notifications/, adds a Provider of
type alertmanager pointing at the in-cluster Alertmanager and two
Alert CRs: one info-severity for ImageUpdateAutomation bumps (with the
30m "no change" heartbeat excluded), and one error-severity catch-all
across every Flux source/sync kind. Flux events and Prometheus alerts
now share a single email funnel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:10:57 +02:00
k.rennerandClaude Opus 4.7 eff1c5ecb7 image-automation: enroll 9 more images
New ImageRepository/ImagePolicy pairs for gotenberg, redis (immich +
paperless), ceph, apache/tika, protonmail-bridge, kasm/firefox,
mmwave-configurator, and cnpg-barman-plugin. README's not-enrolled
list trimmed to the three genuinely-unfit (vsftpd, otbr, ical-proxy)
and gains a re-import-hazard section for cnpg-barman.

Pin replacements on moving tags (`:8`, `:7.4-alpine`, `:v19`, `:latest`)
resolve to current upstream digests, so no behavior change; firefox
bumps 1.17.0 -> 1.18.0 (next-newer rolling-daily).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:05:12 +02:00
k.renner f10b8d104d home-assistant: reconciler calls homeassistant.restart for trusted_proxies drift
reload_core_config only re-processes the `homeassistant:` section of
configuration.yaml — it does not re-initialize the http integration.
The http component reads http.trusted_proxies once in async_setup() at
boot and has no live-reload hook. Result: every time the ingress-nginx
controller's pod IPs change (rollout, node drain, IPAM reshuffle), the
reconciler silently wrote the new IPs to disk but HA kept rejecting
every external request as "untrusted proxy" until the next manual HA
restart.

Switch the API call to homeassistant.restart so the sidecar's "I
detected drift" path actually applies the change. HA validates its
config pre-restart, exits cleanly, kubelet respawns the container, and
the fresh process reads the patched trusted_proxies on boot. The cost
is a ~30s HA outage per ingress-nginx pod-IP change — acceptable for
an event that happens rarely and was previously silently breaking HA
indefinitely.

Comments in deployment.yaml and rbac.yaml were stale (deployment.yaml
still described the long-removed pod-delete path; rbac.yaml claimed
"no Recreate needed" under reload_core_config). Updated both to
describe the actual mechanism.
2026-05-11 23:00:32 +02:00
k.renner 56dc01d443 ingress-nginx(crowdsec): fix bouncer init container so the plugin actually loads
The init-clone-crowdsec-bouncer setup mounted the emptyDir at /crowdsec,
which shadowed the lua-bouncer-plugin image's baked-in plugin files and
template config. With nothing in /crowdsec, the entrypoint's sed against
/crowdsec/crowdsec-bouncer.conf failed silently and no files ever landed
in /etc/nginx/lua/plugins/crowdsec, so nginx logged

  load_plugin(): error loading plugin "plugins.crowdsec.main": module
  'plugins.crowdsec.main' not found

The bouncer registered against LAPI (BOUNCER_KEY pre-registration) but
its last_pull stayed null since the rebuild — agents detected attackers
and LAPI accumulated CAPI decisions, but the controller never enforced
anything.

Aligns with the canonical recipe in
https://docs.crowdsec.net/docs/bouncers/ingress-nginx:
  - mount the emptyDir at /lua_plugins so the image's /crowdsec stays
    intact
  - override command to run docker_start.sh and then cp the templated
    files into the volume
  - pin to v1.1.2 instead of the implicit :latest moving target
2026-05-11 22:44:27 +02:00
k.rennerandClaude Opus 4.7 9406cb535f music-assistant: drop Authentik gate
The gate fundamentally conflicted with MA's PWA model: Chrome fetches
the manifest, service worker, and icons with credentials omitted
(no crossorigin=use-credentials on MA's <link rel="manifest">), and
once installed the cached service worker bypasses fresh navigations
on cold-start so the Authentik cookie never lands in the WebAPK's
cookie jar. MA delegates auth to Home Assistant in-app; the gate's
marginal defense-in-depth wasn't worth the recurring breakage.

Ingress reverts to pointing at music-assistant directly. Cloudflared
keeps music.renner.uno wired to the tunnel. Blueprint marks the
provider/application/outpost state: absent so the worker tears down
ak-outpost-music-assistant-gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:31:48 +02:00
k.rennerandClaude Opus 4.7 8603f0c982 music-assistant: skip PWA-install assets in gate
Chrome fetches manifest.webmanifest, sw.js, favicon, and the icons it
references with credentials: 'omit' (no crossorigin=use-credentials
on MA's <link rel="manifest">), so the user's Authentik cookie never
reaches the gate on those requests. Without this skip, the gate 302s
the anonymous fetch and Chrome's installability check fails silently
- the install prompt stops being offered.

Skipped paths are static branding/manifest only; HTML, JS bundle,
/api, and /ws remain gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:21:27 +02:00
k.rennerandClaude Opus 4.7 bd3580b678 music-assistant: gate music.renner.uno on WAN via authentik
Dedicated paperless-gate-shaped outpost (music-assistant-gate) fronts
MA at the WAN edge. MA's own HA-delegated login inside the UI remains
the primary access decision; the gate adds defense in depth.

No egress CNP needed: MA is hostNetwork and the authentik baseline
already allows toEntities: [host, remote-node].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:45:23 +02:00
k.renner 753633e25b paperless(ftp-scanner): own consume tree as ftp so STOR succeeds
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
The prepare-dirs initContainer chowned /consume/scanner to 1000:1000
mode 2775 and only granted u:1000:rwx via ACL. The earlier commit
assumed fauria/vsftpd's entrypoint would chown the subPath mount back
to ftp:ftp on every start (ACL surviving chown), restoring write
access for the ftp user. In practice the entrypoint's chown didn't
reach the mounted PVC subPath, so the tree stayed 1000:1000 with the
ftp user (uid 14, the vsftpd virtual-user → guest mapping target)
falling through to other::r-x. Every STOR returned 553 since the
ACL deploy.

Own the tree as ftp:ftp explicitly in the initContainer instead of
relying on the image's chown side effect. The setfacl line continues
to grant paperless u:1000:rwx (access + default) so the consumer can
read and delete files.
2026-05-11 12:18:46 +02:00
k.renner fea3062728 paperless: add syncthing as the consume-folder ingest path
Replaces the legacy paperless-syncthing-1 docker container on VM 111
that the 2026-05-09 cutover stopped. Phones (Pixel 10 Pro) and
laptop (XPS 13-9310) sync into a syncthing/ subdirectory on the
existing paperless-consume PVC; paperless's recursive consumer picks
files up.

- StatefulSet runAsUser 1000 — matches paperless's uid, no ACL or
  chown gymnastics required (the FTP scanner path already proved the
  consume PVC works with mixed writers when uid lines up).
- Sync ports exposed via LoadBalancer at 10.2.40.85 with UniFi-only
  DNS (syncthing.paperless.renner.uno); cloudflare label deliberately
  omitted (sync is TCP+UDP, not HTTPS).
- UI (8384) is ClusterIP-only on first deploy. Set the admin password
  via kubectl port-forward before publishing it; no Ingress yet.
- allow-syncthing-sync CNP opens 22000 TCP+UDP and 21027 UDP from
  world/host/remote-node; the namespace default-deny otherwise blocks
  external ingress.

When adding the paperless-consume folder in the Syncthing UI, set
folder type to 'Receive Only' on the cluster side — paperless's
consume-then-delete would otherwise propagate deletions back to the
phones.
2026-05-10 21:28:22 +02:00
k.renner ef08d34284 paperless: switch probes to TCP to silence auth-log spam
ENABLE_HTTP_REMOTE_USER + ENABLE_HTTP_REMOTE_USER_API makes the
paperless.auth middleware log 'No authentication provided from
private IP …' on every API request without the X-Authentik-Username
header. The readinessProbe hitting /api/ every 10 s and the liveness
probe every 30 s flooded the log; the actual gate-outpost-fronted
traffic was lost in the noise.

paperless-ngx has no unauthenticated health endpoint, so a TCP probe
on the gunicorn port is the closest non-invasive equivalent. Trades
HTTP-level health for log readability — acceptable since gunicorn
binding the port is the same signal we previously got from /api/.
2026-05-10 21:03:20 +02:00
k.renner 8f9771bdf6 paperless: pin bridge cert + intra-pod socat for IMAP
Bridge generates a self-signed IMAP cert with SAN=IP:127.0.0.1 only;
paperless-ngx's mail.py uses ssl.create_default_context() with no
hostname-check opt-out, so connecting via 'protonmail-bridge'
(K8s DNS) fails CN/SAN verification.

Loop closed inside the paperless pod:
- initContainer fetches the live bridge cert via openssl s_client
  (STARTTLS, port 143) into an emptyDir; re-runs on every rollout so
  cert regeneration only requires a paperless restart.
- bridge-tls-proxy sidecar (alpine/socat:1.8.0.3) listens on
  127.0.0.1:11143 and forwards to protonmail-bridge:143. Hostname
  verification matches because paperless connects via the literal
  127.0.0.1 in the cert's SAN.
- paperless gets PAPERLESS_EMAIL_CERTIFICATE_LOCATION pointing at
  the captured cert.

Runtime DB change still required: MailAccount.imap_server=127.0.0.1
and imap_port=11143.
2026-05-10 20:51:28 +02:00
k.renner 43cd7893c6 restore-test: file-count parity (±10%) instead of mere file presence
The previous check ('at least one file exists') only proved the
restored fs was mountable, not that any meaningful fraction of the
data made the round trip. Now: per restored PVC, take a fresh CSI
snapshot of the live source PVC, mount it RO via the same noload SC
the data-mover uses, count regular files, and compare to the restored
count. Pass if the two are within 10% of each other — generous enough
to absorb a day of source drift, tight enough to catch silent
half-data losses.

Source-side artifacts (snapshot + tmp PVC + probe pod) live in the
source namespace and are torn down on every return path via a trap.
RBAC widened to grant cluster-wide volumesnapshots and PVC create.
2026-05-10 20:04:58 +02:00
k.renner 3fa96f260a paperless(ftp-scanner): publish ftp.paperless.renner.uno via UniFi DNS
LAN-only A record so the brother scanner can target a hostname instead
of the raw 10.2.40.82 LB IP. Picked up by the UniFi external-dns
instance via the standard `external-dns.alpha.kubernetes.io/hostname`
annotation; the cloudflare-scoped instance only follows the
external-dns/cloudflare label, which we deliberately don't set
(clear-text FTP must not traverse the cargo tunnel).
2026-05-10 20:04:42 +02:00
k.renner 6f3145e73a paperless(ftp-scanner): grant paperless ACL access to consume tree
fauria/vsftpd's entrypoint runs `chown -R ftp:ftp /home/vsftpd` on
every container start, stomping the initContainer's chown to 1000:1000.
Files created by vsftpd end up uid 14:gid 50 mode 0600, so paperless
(uid 1000) gets EACCES when the consumer scans /usr/src/paperless/
consume/scanner/.

POSIX ACLs survive chown. The initContainer now also installs `acl`
and applies u:1000:rwx as both access ACL (existing entries) and
default ACL (inherited by all future uploads + mkdir). cephfs supports
ACLs by default.
2026-05-10 19:55:55 +02:00
k.renner daf6e19c3e paperless(ftp-scanner): grant CAP_SYS_CHROOT to vsftpd
vsftpd uses virtual users (guest_enable=YES, pam_service_name=vsftpd_virtual)
and chroots each into /home/vsftpd/$USER. Without CAP_SYS_CHROOT the
chroot() syscall returns EPERM and login fails immediately with
'500 OOPS: chroot' — confirmed by 0x4c3 CapBnd missing bit 18.
2026-05-10 19:43:57 +02:00
k.renner 253ebb04fe paperless(ftp-scanner): use externalTrafficPolicy=Cluster
The Cilium L2 announcer leader-elects the lease holder independently of
pod locality. With ETP=Local, whenever the lease landed on a node
without a local backend (here: lease on k8s-2, pod on k8s-1) the LB IP
ARP-resolved successfully but every TCP SYN got RST'd. Same failure
mode the wiki documents from the alloy-syslog incident on 2026-05-02.

Cluster matches the convention used by every other cross-VLAN LB
service in the cluster; the only documented ETP=Local exception is
home-assistant on the iot VLAN where v30iot is L2-shared with the nodes.

The allow-ftp-scanner CNP permits world+host+remote-node, so the
masquerade-induced source-identity shift doesn't break the policy.
2026-05-10 19:37:55 +02:00
k.renner 623a1c376f restore-test: grant datauploads RBAC, fail loudly on list errors
The ClusterRole was missing velero.io/datauploads, so kubectl get
dataupload returned no items (Forbidden silenced) and the script
skipped every service as 'no-PVC schedule'. Add the missing verb and
make the script differentiate 'truly zero DataUploads' from 'list
errored' so a future RBAC regression fails loudly instead.
2026-05-10 18:58:59 +02:00
k.renner ea5e4c0a5c restore-test: count DataUploads (not csiVolumeSnapshotsAttempted), accept PartiallyFailed source
Velero v1.18 does not populate .status.csiVolumeSnapshotsAttempted, so
the prior no-PVC skip path matched every backup and the script never
ran a real restore. Count DataUpload children labelled with the
backup's name instead.

Also accept PartiallyFailed source backups: Velero restores only the
DataUploads that completed, so a partial source still produces a
valid restore for the PVCs that did upload. Excluding them would
silently let the test miss the case where one PVC's DataUpload kept
failing (e.g. the ext4-noload window before that fix).
2026-05-10 18:57:09 +02:00
k.renner a00f1b2af4 external-dns: switch cloudflare instance from annotationFilter to labelFilter
The annotation filter caused records on annotated ingresses to flap
once the UniFi external-dns instance also managed them: external-dns
turns annotations in its prefix namespace into ProviderSpecific on the
desired endpoint, the kashalls UniFi webhook does not implement
AdjustEndpoints to strip Cloudflare-only properties, and Records()
returns observed endpoints with empty ProviderSpecific — so plan
computed an Update every reconcile and the records cycled in UDM.

Labels are not translated into ProviderSpecific, so swapping the
selector from `external-dns.alpha.kubernetes.io/cloudflare-proxied`
to a plain `external-dns/cloudflare=true` label removes the round
trip. The Cloudflare instance still proxies all matched records
because of the existing `--cloudflare-proxied` extraArg default.

Touches the seven resources that use the selector: the cloudflare
external-dns HelmRelease and the six ingresses currently published
through the cargo tunnel (audiobookshelf, gatus, home-assistant,
immich, paperless, authentik-server).
2026-05-10 18:53:33 +02:00
k.renner d250f8acdb restore-test: pin generated script ConfigMap to velero ns 2026-05-10 18:50:53 +02:00
k.renner 2733458a4c Revert "external-dns(unifi): bump logLevel to debug"
Diagnosis complete; see follow-up commit for the actual flap fix.
2026-05-10 18:45:17 +02:00
k.renner d4304fb871 restore-test: convert to in-cluster CronJob
Drops the Woodpecker pipeline. Test runs from a Job inside the cluster
on a weekly schedule (Sun 02:00 UTC), keeping the verification path
contained: no external CI dependency, no kubeconfig leaving the
cluster. Script lives next to the rest of the velero infra and is
mounted into the CronJob via configMapGenerator.
2026-05-10 18:44:17 +02:00
k.renner ce0eb03716 restore-test: weekly verification that PVC backups are restorable
Discovers every velero.io Schedule labeled
app.kubernetes.io/part-of=homelab-backup, restores yesterday's actual
Completed Backup for each into <ns>-restore-test, mounts each restored
PVC, and confirms files exist. Sequential — peak storage cost is the
size of the largest single service. Backup phase=Completed alone never
proved the bytes were restorable; this closes that gap.
2026-05-10 18:40:47 +02:00
k.renner ea8f83ed51 external-dns(unifi): bump logLevel to debug
Temporary — investigating per-cycle update_old/update_new churn for a
subset of *.renner.uno records. external-dns at debug logs the plan
diff with the offending field, which is what we need.
2026-05-10 18:31:55 +02:00
k.renner 42fa826a7f external-dns(unifi): bump webhook read timeout to 30s
Default 5s causes the external-dns container to time out on
GET /records (webhook fetches 129 records from UDM and the
JSON write doesn't complete in time), driving CrashLoopBackOff.
2026-05-10 18:13:27 +02:00
k.rennerandClaude Opus 4.7 0a93d750f3 audiobookshelf: publish through Cloudflare via the cluster's cargo tunnel
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Same shape as the immich rollout, with the same auth-side reasoning:
ABS already has native OIDC against auth.renner.uno (the
NODE_EXTRA_CA_CERTS plumbing in deployment.yaml exists for exactly
that token-exchange path), so SSO is handled at the app layer. A
forward-auth gate doesn't fit because the upstream ABS PR for trusted-
header auth was closed (advplyr/audiobookshelf#1109) and the official
mobile app is documented as not working behind a redirect-style proxy
(advplyr/audiobookshelf-app#1155, "not planned"). What's added here
is just public reachability.

Two pieces:

1. external-dns annotation on the ABS Ingress so the cloudflare-scoped
   external-dns instance creates CNAMEs to the cargo tunnel for both
   hostnames in the rule list.

2. cloudflared configmap entry for audiobooks.renner.uno only. The
   list is an explicit allow with a 404 catch-all, so the
   .k.renner.uno LAN alias stays tunnel-404 — no public-internet use
   case for it, and audiobooks.k.renner.uno is the local-DNS path
   that bypasses CF's 100 MiB body cap for large uploads.

cloudflared mounts the configmap via subPath, so a manual
`kubectl -n ingress rollout restart deploy/cloudflared` is required
after Flux reconciles for live pods to pick up the new ingress list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:27:00 +02:00
k.rennerandClaude Opus 4.7 a0e3d2a23d music-assistant: rewrite hostNetwork rationale comment
The previous comment framed hostNetwork as "required for mDNS / uPnP /
DLNA / Chromecast discovery and for SlimProto." That's the wrong
abstraction layer. Discovery happens at the application layer; what
hostNetwork actually buys us is L2 adjacency with the audio clients
on the IoT VLAN (v30iot, attached via the edge node's second NIC).
The hard requirement is that Snapcast / SlimProto / Chromecast group
members must share an L2 broadcast domain — routing the audio path
through the CNI overlay breaks the sync timing budget. mDNS/uPnP/DLNA
visibility is a side-effect of L2 attachment, not the reason for it.

Comment-only change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:24:03 +02:00
k.renner beaf2ecb14 velero: add ceph-block-snapshot-readonly SC with mountOptions [noload]
Backups of continuously-written ext4 RBD volumes (influxdb-influxdb2,
paperless/protonmail-bridge) wedge for 4h then time out: the data-mover
exposer pod cannot mount the snapshot RO because ext4 has a dirty
journal it cannot replay under read-only. The new storage class clones
ceph-block and adds noload so journal recovery is skipped on the
exposer's snapshot mount; source PVCs are unaffected.
2026-05-10 11:22:16 +02:00
k.rennerandClaude Opus 4.7 19bed349e3 immich: publish through Cloudflare via the cluster's cargo tunnel
Same shape as the paperless rollout, minus the Authentik gate trust
boundary — Immich has no trusted-header auth path (only OAuth2/OIDC
and password), so the gate-in-front-of-app pattern doesn't fit, and
the mobile/CLI x-api-key clients would break under a forward-auth
proxy. The OIDC integration against auth.renner.uno already covers
SSO; what's added here is just public reachability.

Two pieces:

1. external-dns annotation on the immich Ingress so the cloudflare-
   scoped external-dns instance creates CNAMEs to the cargo tunnel
   for all three hostnames in the rule list.

2. cloudflared configmap entry for immich.renner.uno only. The list
   is an explicit allow with a 404 catch-all, so the .k.renner.uno
   LAN alias and the legacy photos.renner.uno stay tunnel-404 — no
   public-internet use case for either, and immich.k.renner.uno is
   the local-DNS path mobile clients already use to bypass CF
   entirely (relevant since CF's free/pro plans cap request bodies
   at 100 MiB, which would otherwise break large video uploads).

cloudflared mounts the configmap via subPath, so a manual
`kubectl -n ingress rollout restart deploy/cloudflared` is required
after Flux reconciles for live pods to pick up the new ingress list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:48:45 +02:00