Compare commits

..
Author SHA1 Message Date
claude-bot c90406cca7 docs(apps): trim otbr, trmnl, zigbee2mqtt — and correct a copy-pasted falsehood
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comments only across otbr, trmnl/renderer, zigbee2mqtt-deployment and the
smartctl ServiceMonitor. All four still parse; monitoring-rules still renders.

Each keeps the instruction and loses the investigation. otbr stays disabled with
the condition for re-enabling it — verified partition merge, one OMR prefix, the
Nest routers visible — instead of the paragraph reconstructing how the split
partition was diagnosed. Its socat bridge keeps both dead ends, because they are
what stops someone simplifying it: forkpty needs a socat the image does not ship,
and a sidecar cannot supply the PTY because devpts is per-container. zigbee2mqtt
keeps "never run alongside ZHA, and never start without the restored coordinator
backup or you form a new network and drop every device", and loses the cutover
diary around it.

The ServiceMonitor correction is the useful part. Four monitors carry the claim
that kube-prometheus-stack's Prometheus "selects ServiceMonitors by this label;
without it the resource exists but is never discovered". The live resource has
serviceMonitorSelector {} and podMonitorSelector {} — empty, so every monitor is
picked up regardless of labels. Only ruleSelector matches on
release=kube-prometheus-stack, which is why the same sentence on the disk-wear
PrometheusRule is correct and this one is not.

Corrected here for the monitor I added yesterday, which is where I introduced it
by copying a neighbour. The other three carry the same wrong sentence and are
left for a pass that can check each rather than sed across them.
2026-08-28 06:59:04 +02:00
claude-bot 14d62f3aa1 docs(home-assistant): trim the mdns-relay and mosquitto comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
mdns-relay 122 -> 64 comment lines, mosquitto 131 -> 118. Comments only; the
diff filtered for non-comment lines is empty and apps/home-assistant still
renders 83 objects.

The mdns-relay header was mine, written yesterday, and was the clearest case
of the problem: a seventy-line essay with sections. What survives is what stops
someone undoing it — that the relay must stay unicast because Cilium runs in
tunnel mode without enable-multicast and drops re-emitted multicast, so an
avahi-style reflector cannot work; that python-zeroconf accepts unicast at all,
which is the non-obvious fact the design rests on; that it restores
announcements but not HA-initiated browsing; and that so-bindtodevice=net1 is
what keeps any sibling pod from injecting records into HA's zeroconf cache.

Cut: the problem statement, the Errno 113 symptom, the exposure analysis
weighing what the IoT VLAN could already forge, and a paragraph explaining that
the relay is not what makes HA's listener reachable in-cluster.

mosquitto keeps every warning and loses the reasoning around them. The one
worth naming is the retained-state note: a roll preserves connectivity but not
the retained store, and Zigbee2MQTT does not recover on its own. That is an
operational consequence someone needs before touching the rollout strategy, so
it stays — the paragraph explaining why maxSurge:1 is safe does not.
2026-08-28 06:55:34 +02:00
k.renner 85b8f0eba9 Merge pull request 'docs(infra): trim the cilium and traefik helm release comments' (#685) from trim-comments-helm into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#685
2026-08-28 04:53:37 +00:00
claude-bot 6ffbc468d7 docs(infra): trim the remaining oversized comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
crowdsec, netbird-ingress, woodpecker-ns, san-network-attachment, cilium-bgp
and the openbao rotation kustomization. Comments only; the diff filtered for
non-comment lines is empty and infrastructure/ still builds.

Every instruction survives, because that is the half worth having: do not
switch crowdsec back to password auth, or a wiped LAPI crashloops the agents
and wedges the Kustomization; do not move netbird-ingress off the rootless
image, or socket-LB stops seeing its traffic and the VIPs blackhole; do not
pin the rotators by digest, or a quarterly job fails to pull a manifest that
was garbage-collected months earlier and looks like it simply did not run.

What went is the incident each was learned from, and one duplicate: the
san-network-attachment header re-derived the provider: host argument that
cephcluster.yaml already makes, so it now points there instead.

Two more stale claims found on the way through:

  - woodpecker-ns.yaml described the namespace as holding wp-pg alone with
    'server and agent arriving in stage 2'. Both have been running there for
    some time.
  - cilium-bgp.yaml said each node sources its BGP session from eth0. The
    management interface is mgmt0; eth0 was the VM-era name.

infrastructure/ is now 2553 comment lines.
2026-08-28 06:50:54 +02:00
claude-bot 8c7fd25e03 docs(infra): trim the cilium and traefik helm release comments
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
cilium 86 -> 63 comment lines, traefik 71 -> 59. Comments only; the diff
filtered for non-comment lines is empty and both files still parse.

Cilium keeps everything that is a trap rather than a story: that bpf-policy-map-max
and vlan-bpf-bypass each exist in three files with nothing enforcing agreement,
that both take an agent restart, that a full policy map surfaces as a pod stuck
in sandbox creation, and why only VLAN 30 belongs in the bypass list. What went
is how each was diagnosed — the Hubble traces, the manual agent deletions, the
device-by-device walk through `cilium-dbg status`.

Traefik keeps the version ceiling and the reason it exists, and keeps the
CrowdSec bouncer note as an instruction: do not re-attach until client-IP
visibility is fixed and a LAN whitelist exists. It loses the retelling of the
outage that produced that instruction.

The traefik header was outright wrong and is the reason this file was worth
opening. It described Traefik as running "alongside ingress-nginx during the
migration", called ingress-nginx "the default IngressClass" and Traefik
"opt-in via ingressClassName: traefik". There is one IngressClass in the
cluster and it is traefik; ingress-nginx has no deployment and no class. A
reader following that comment would have added an ingressClassName they do not
need and looked for a controller that is not there.
2026-08-28 06:47:39 +02:00
k.renner 7f11867407 Merge pull request 'docs(netpol): deduplicate the home-assistant policy comments' (#684) from trim-comments-netpol into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#684
2026-08-28 04:45:23 +00:00
claude-bot c3bad3bece docs(netpol): deduplicate the home-assistant policy comments
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
152 comment lines to 125 — a much lighter cut than the previous batches, on
purpose. Most of this file is per-rule security rationale, and that is exactly
the category worth keeping: someone deleting the :8124 rule needs to know it is
the trust anchor for X-Forwarded-For, and someone deleting a gate rule needs to
know it is what stands between an --auth=none editor and the LAN.

What was cut is repetition. Four near-identical paragraphs explained the
Authentik gate pattern once per app, three of them re-deriving that
endpointSelector {} makes a :8080 grant namespace-wide. That warning is now
stated once, at the first :8080 rule, and it is stronger for being in one
place: three apps share that port, so changing any of those rules affects all
three.

The world-ingress and ICMP blocks keep their rules and lose their incident
reports — do not add a blanket fromEntities: world to an endpointSelector {}
policy; ICMP needs an explicit allow once any L4 rule exists and Cilium takes
one type per entry.

Two stale references went with it. Egress-to-world was documented as reaching
"garage S3" and "legacy InfluxDB on VM 108": Garage was retired to nas-1 and
there is no Proxmox left to host VM 108. Both replaced by what the rule
actually grants rather than a list of destinations that has already changed.

Comments only; the diff filtered for non-comment lines is empty, and the file
still passes a server-side dry run.
2026-08-28 06:43:03 +02:00
k.renner 68672d3fb9 Merge pull request 'docs(infra): cut the narration out of cephcluster and the velero schedules' (#683) from trim-comments-infra into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#683
2026-08-28 04:41:06 +00:00
15 changed files with 172 additions and 393 deletions
@@ -1,80 +1,22 @@
---
# mDNS relay: gives Home Assistant sight of the IoT VLAN's mDNS announcements
# without putting a macvlan on the HA pod itself.
# mDNS relay: holds the multicast membership on the IoT VLAN through its own
# macvlan and forwards each datagram to Home Assistant as ordinary UNICAST UDP.
#
# THE PROBLEM
# It must stay unicast. An avahi-style reflector cannot work here — Cilium runs
# in tunnel mode without enable-multicast, so re-emitted multicast is dropped by
# the CNI. Unicast is carried, and python-zeroconf accepts it: its datagram
# handler checks size and duplicates but never the destination or source
# address. RFC 6762's QU bit makes this protocol-legal rather than a trick.
#
# HA's zeroconf listener joins both mDNS groups on eth0 — 224.0.0.251 and
# ff02::fb — and never hears anything on either.
# Multicast is link-local, so IoT-VLAN announcements do not reach the pod
# CIDR; and Cilium runs in tunnel mode (VXLAN) without `enable-multicast`, so
# it does not replicate multicast between pods either. Both halves of the path
# are dead, which is why an avahi-style reflector — receive on one interface,
# re-emit as multicast on the other — cannot work here: the re-emitted packets
# would be dropped by the CNI.
# This restores passive reception only — announcements, including the
# re-announce a device sends when its address changes. HA-initiated browsing
# still dies on eth0; that would need a querier on the VLAN side, which this
# deliberately is not.
#
# Consequence for HA: the ESPHome integration dials devices at the address
# cached in its config entry. When a lease rotates, every attempt fails with
# `[Errno 113] Connect call failed` and the device stays unreachable, because
# the mDNS announcement that would carry the new address never arrives. New
# devices are never discovered at all.
#
# THE MECHANISM
#
# This relay holds the multicast membership on the IoT VLAN through its own
# macvlan (mdns-relay-network-attachment.yaml) and forwards each datagram it
# receives to HA as ordinary UNICAST UDP. That is the one direction of travel
# the CNI will carry.
#
# It works because python-zeroconf does not filter on how a packet arrived:
# its datagram handler validates size, deduplicates, and parses, but performs
# no check on destination address, source address, or source port. A unicast
# datagram delivered to :5353 is processed exactly like a multicast one. (mDNS
# unicast responses are part of RFC 6762 — the QU bit — so this is within the
# protocol, not a trick.)
#
# WHAT THIS DOES AND DOES NOT RESTORE
#
# Restored: passive reception. RFC 6762 requires a responder to re-announce
# when its address changes, so a device that moves to a new lease announces
# the move, HA sees it, and the ESPHome integration updates the config entry.
# That is the failure this exists to fix. New devices announcing themselves
# are likewise seen.
#
# NOT restored: HA-initiated browsing. Queries HA sends leave on eth0 as
# multicast and die there, so HA cannot actively solicit a response. Anything
# depending on a query rather than an announcement stays broken. Making that
# work needs a querier on the VLAN side, which this relay deliberately is not.
#
# EXPOSURE
#
# The receive sockets carry so-bindtodevice=net1, so only datagrams that
# actually arrived on the IoT VLAN interface are delivered. Binding to net1's
# address instead is not possible — it is DHCP-leased and unknown at exec time
# — but binding to the device achieves the same confinement without needing to
# know the address, and needs no capability: SO_BINDTODEVICE succeeds as an
# unprivileged uid here.
#
# Without it socat would bind the wildcard, and since the namespace CNP grants
# every pod in home-assistant unrestricted access to every other pod on every
# port, any sibling pod could reach :5353 over the cluster network and have
# records forwarded into HA's zeroconf cache.
#
# What remains, and cannot be closed here: any host on the IoT VLAN can send
# this port a datagram. That is the same trust already extended to genuine
# mDNS from that segment, which any device there can equally forge — the relay
# widens who can reach HA's listener, not what can be claimed. macvlan traffic
# bypasses Cilium policy entirely, so the namespace CNP does not constrain
# this interface either way.
#
# Note that the relay is not what makes HA's zeroconf reachable from inside
# the cluster: HA binds :5353 on the pod address (v4) and the wildcard (v6),
# and the namespace CNP already permits any sibling pod to reach it directly.
# Confining the relay removes a second route to that listener; it does not
# remove the first.
#
# Nothing else is exposed: the containers run socat and only socat, with no
# second listener to reach.
# so-bindtodevice=net1 on both receive sockets is load-bearing: without it
# socat binds the wildcard, and the namespace CNP lets any sibling pod reach
# :5353 and inject records into HA's zeroconf cache. Binding net1's address
# instead is impossible — it is DHCP-leased and unknown at exec time.
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -6,32 +6,23 @@ metadata:
labels:
app.kubernetes.io/name: mosquitto
annotations:
# REQUIRED for credential rotation to take effect at all. The startup shim
# rebuilds /mosquitto/auth/passwd from these Secrets on every pod start, so
# a rotated Secret is inert until the pod restarts — without this the new
# password would sit unused until some unrelated restart, while the old one
# stayed valid indefinitely.
# REQUIRED for credential rotation to take effect. The startup shim rebuilds
# the password file only on pod start, so without this a rotated Secret sits
# inert while the old password stays valid indefinitely.
#
# Listed per-Secret rather than using the blanket `reloader.stakater.com/
# auto: "true"`, so that enrolling each remaining broker user in rotation is
# a visible, reviewable edit to this line rather than something that starts
# happening implicitly.
# Listed per-Secret rather than with the blanket auto annotation, so that
# enrolling another broker user is a visible edit to this line.
secret.reloader.stakater.com/reload: mosquitto-health-credentials
spec:
replicas: 1
strategy:
# No PVC anymore, so nothing to fence: the new broker can come up and pass
# its readiness probe before the old one is torn down. maxUnavailable:0 keeps
# a broker serving throughout an image bump / reschedule (clients on the old
# pod just reconnect to the new one); maxSurge:1 permits the brief two-pod
# overlap — two independent stateless brokers behind the LB, fine since there
# is no shared state.
# No PVC, so nothing to fence — the new broker can serve before the old one
# is torn down. Two stateless brokers overlapping behind the LB is fine.
#
# NOTE: connectivity survives the roll, but RETAINED STATE DOES NOT — the
# new pod starts with an empty retained store, so a roll is equivalent to a
# broker restart for every client that depends on retention. Zigbee2MQTT
# does not recover from that on its own; see mosquitto-config.yaml and
# gitea issue #381.
# Connectivity survives a roll but RETAINED STATE DOES NOT: the new pod
# starts with an empty retained store, so every roll is a broker restart
# for clients that depend on retention. Zigbee2MQTT does not recover from
# that on its own (#381).
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
@@ -44,14 +35,10 @@ spec:
labels:
app.kubernetes.io/name: mosquitto
spec:
# Runs on any node (no nodeSelector); the mosquitto-iot LB forwards
# IoT-VLAN traffic to the broker wherever it lands.
# Availability: home-critical priority (never preempted, reschedules
# first) + short fault tolerations so a node failure reschedules the
# broker in ~30s instead of the default ~5min. With persistence off there
# is no PVC to fence/detach/reattach, so the broker can (re)start anywhere
# instantly, no longer depends on Ceph being healthy, and rolls without a
# tear-down-first gap (see the RollingUpdate strategy above).
# Runs anywhere; the mosquitto-iot LB finds it. Short fault tolerations
# reschedule the broker in ~30s rather than the default ~5min, which is
# only possible because there is no PVC to fence — so it also does not
# depend on Ceph being healthy.
priorityClassName: home-critical
terminationGracePeriodSeconds: 10
tolerations:
@@ -6,12 +6,9 @@ metadata:
labels:
app.kubernetes.io/name: zigbee2mqtt
spec:
# CUTOVER DONE (2026-07-23): ZHA disabled, the coordinator-backup Secret is
# populated in OpenBao, and Z2M is now the live Zigbee stack — so replicas is
# 1. It shipped at 0 as a safety interlock (Z2M contends with ZHA for the
# coordinator's single TCP session, and starting without the restored backup
# would form a NEW network and drop every device); that interlock is spent
# once the migration is committed. To roll back, set 0 and re-enable ZHA.
# Z2M is the live Zigbee stack. Never run it alongside ZHA: they contend for
# the coordinator's single TCP session, and a Z2M start without the restored
# coordinator backup forms a NEW network and drops every paired device.
replicas: 1
# RWO PVC → single writer; never run two pods against the same /app/data.
strategy:
@@ -39,14 +36,10 @@ spec:
# network_key, the latter extracted from the OpenBao coordinator backup
# as a natural-byte-order int array.
- name: seed-data
# alpine + jq, built by .woodpecker/build-ci-images.yml from
# ci/images/alpine-jq/. Replaces `apk add --no-cache jq` on every pod
# start: tools belong in the image, not installed at runtime.
# Referenced by tag rather than digest — these are our own images.
# DATED tag, not :latest, matching the ci-ansible:2026-04-29 precedent
# in kubernetes/infrastructure/netbird-backup/cronjob.yaml. With
# :latest, rebuilding this base for some unrelated consumer would
# silently change what this init container runs on its next pod
# Our own alpine + jq image, built by build-ci-images.yml. DATED tag,
# never :latest — rebuilding this base for an unrelated consumer
# would otherwise silently change what this init container runs on
# its next pod
# restart, with no diff here to review — and this is the container that
# renders the Zigbee network key. Bumping the tag is a deliberate,
# reviewable act; the cost is remembering to bump it.
@@ -5,9 +5,8 @@ metadata:
namespace: monitoring
labels:
app.kubernetes.io/name: smartctl-exporter
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered.
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
+17 -35
View File
@@ -5,25 +5,15 @@ metadata:
labels:
app.kubernetes.io/name: otbr
spec:
# Disabled 2026-07-11 (replicas: 0) after otbr broke Matter.
# DISABLED (replicas: 0). Running otbr broke Matter: its radio is RF-isolated
# enough from the Nest mesh that it forms a rival partition of the same
# network, and the two bridge only over Ethernet — fast enough for a ping,
# too slow for the fragmented CASE handshake, so devices stay discoverable
# while every commissioning attempt times out.
#
# otbr is seeded with the Nest network's dataset so it should join the
# existing partition as an additive co-BR. In practice it does not merge
# cleanly: its SLZB-06 radio is RF-isolated enough from the Nest mesh that
# otbr elects itself leader of a rival partition (or the network re-addresses
# under a new OMR prefix, observed fd5f -> fdd5). The two same-network
# partitions then bridge only over the Ethernet backbone, which carries a
# ping but is too slow for the large fragmented CASE handshake -- every
# Matter device stays mDNS-discoverable while CASE times out. Removing otbr
# re-merges the mesh onto the Nest hubs and Matter recovers.
#
# This is NOT a diagnosis of a specific config bug: the DHCP/sbr routing
# changes (19a4482, df61407) were investigated and matter-server's IPv6 path
# to the mesh works (reaches devices, ping ok); sbr's v6 table 100 is an
# inert orphan and otbr's eth0 default is outranked by net1's pref-high RA
# default. The instability is at the Thread partition layer. Re-enable only
# after the co-BR integration is verified to MERGE (one partition, one OMR,
# otbr's router table shows the Nest routers) instead of split.
# The instability is at the Thread partition layer, not in the config below.
# Re-enable only once co-BR integration is verified to MERGE: one partition,
# one OMR prefix, and the Nest routers visible in otbr's router table.
replicas: 0
strategy:
type: Recreate
@@ -46,25 +36,17 @@ spec:
# (slzb-thread.renner.uno). Dropping the workload-class=pinned
# toleration lets the edge taint keep it off the tiny edge box.
containers:
# The Thread RCP lives on a SLZB-06 over Ethernet at
# slzb-thread.renner.uno:6638 (Serial-over-IP). otbr-agent's
# spinel transport supports `spinel+hdlc+forkpty://<exec>?<args>`
# which forks an exec whose stdio becomes the spinel-HDLC
# transport, but the upstream openthread/otbr image does NOT
# ship socat (or any TCP-stream tool), so that URL form ends in
# silent ENOENT and otbr-agent dies right after logging the
# radio URL.
# The Thread RCP is a network-attached radio (Serial-over-IP), so the
# spinel transport needs a TCP-to-PTY bridge. Two obvious approaches
# do not work and should not be retried:
#
# A sidecar socat won't work either: each container has its own
# devpts namespace, so a PTY (e.g. /dev/pts/0) created in a
# sibling container is invisible from this container — a shared
# emptyDir symlink to /dev/pts/0 is dangling.
# - `spinel+hdlc+forkpty://` needs socat, which the upstream image
# does not ship; the URL fails with a silent ENOENT and otbr-agent
# dies right after logging it.
# - A sidecar cannot provide it either: devpts is per-container, so
# a PTY created next door is invisible here and the symlink dangles.
#
# The bridge has to live in the otbr container's own namespace.
# Install socat at startup via apt (Ubuntu 18.04 base, no egress
# restrictions on this namespace), run it in the background as
# the spinel-HDLC TCP↔PTY bridge, then exec the upstream
# entrypoint with RADIO_URL pointing at the PTY symlink.
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest
command:
+8 -13
View File
@@ -1,20 +1,15 @@
# Headless renderer: screenshots the Home Assistant e-ink dashboard,
# dithers it to 16 greys and hands it to Terminus.
#
# Upstream ships this as a Home Assistant add-on. Our HA is a plain
# container with no Supervisor, so it runs standalone here instead —
# viable because the app prefers HOME_ASSISTANT_URL/ACCESS_TOKEN env
# vars over /data/options.json and authenticates with an ordinary
# long-lived token. SUPERVISOR_TOKEN is deliberately NOT set: it only
# flips an internal isAddOn flag, and setting it would switch the app
# to add-on behaviour (fixed port, /data assumptions).
# Upstream ships this as a Home Assistant add-on; it runs standalone here
# because the app prefers the env vars over /data/options.json. Do NOT set
# SUPERVISOR_TOKEN — it flips an internal isAddOn flag and switches the app to
# add-on behaviour (fixed port, /data assumptions).
#
# DIRECTION OF TRAVEL, which is the non-obvious part: with the
# byos-hanami webhook in `uri` delivery mode the renderer does NOT
# push image bytes. It POSTs a URL pointing at its own /output/<file>
# endpoint, and TERMINUS FETCHES THE IMAGE BACK from it. That is why
# both live in this namespace — the pull is intra-namespace and is
# already permitted by the default-deny-ingress rule.
# DIRECTION OF TRAVEL, the non-obvious part: in `uri` delivery mode the
# renderer does not push image bytes. It POSTs a URL to its own /output/<file>
# and Terminus fetches the image back. Both therefore live in this namespace,
# where that pull is already permitted.
apiVersion: v1
kind: ConfigMap
metadata:
@@ -33,22 +33,14 @@ spec:
remediation:
retries: 3
values:
# Roll the agent DaemonSet when cilium-config changes. Cilium reads
# datapath options once, at agent start, and the chart puts no config
# checksum on the pod template — so a changed ConfigMap leaves every agent
# running the previous datapath until something restarts it. Nothing did.
# Rolls the agent DaemonSet when cilium-config changes. Cilium reads
# datapath options once at agent start and the chart puts no config
# checksum on the pod template, so without this a changed ConfigMap leaves
# every agent on the previous datapath indefinitely (#592).
#
# This is not hypothetical. vlan-bpf-bypass reached cilium-config and every
# v30iot macvlan workload stayed broken until the agents were deleted by
# hand, one node at a time (issue #592). policyMapMax has the same
# exposure, and its comment below says "takes effect on agent restart" —
# documentation standing in for automation.
#
# The trade: any cilium-config change now rolls the CNI cluster-wide,
# node by node. That is disruptive-ish, but it is what a datapath change
# already required — the difference is that it now happens on its own
# instead of being forgotten. Reloader is already deployed for exactly
# this pattern; named per-ConfigMap rather than blanket, as elsewhere.
# The trade is that any cilium-config change now rolls the CNI node by
# node — which is what a datapath change required anyway, only now it is
# not forgotten.
annotations:
configmap.reloader.stakater.com/reload: cilium-config
# Which pod labels feed security-identity computation. Patterns are
@@ -69,57 +61,42 @@ spec:
socketLB:
enabled: true
prometheus:
# The agent exported no metrics at all, so BPF map utilisation was
# invisible: when per-endpoint policy maps (cilium_policy_v3_*) run out
# of space the agent only says "no space left on device" in its log and
# pod sandbox creation fails (issue #452). cilium_bpf_map_pressure makes
# that observable — and alertable — before it stalls a rollout.
# Without agent metrics, BPF map utilisation is invisible: a full policy
# map only shows up as "no space left on device" in the log while pod
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
# alertable beforehand.
enabled: true
serviceMonitor:
enabled: true
bpf:
# Size of each endpoint's own cilium_policy_v3_<id> map, holding one
# entry per (identity, direction, protocol, port) the endpoint is
# allowed. When it fills, endpoint regeneration fails with "no space
# left on device", the CNI ADD call never returns, and the pod is stuck
# in sandbox creation — so this must stay comfortably above the busiest
# endpoint's entry count, tracked by cilium_bpf_map_pressure.
# Takes effect on agent restart; the map is sized at creation.
# Bootstrap installs Cilium from the inline manifest in
# kubernetes/talos/patches/cilium-cni.yaml, so bpf-policy-map-max there
# must move with this value or a cold rebuild starts with the old size.
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
# Per-endpoint policy map size: one entry per (identity, direction,
# protocol, port) allowed. When it fills, endpoint regeneration fails
# with "no space left on device" and the pod hangs in sandbox creation.
# Keep it comfortably above the busiest endpoint, watched by
# cilium_bpf_map_pressure. Sized at map creation, so it takes an agent
# restart.
#
# THREE COPIES of this key exist — here, talos/patches/cilium-cni.yaml
# and talos/cilium.yaml. The latter two are what a cold rebuild installs
# from. Nothing enforces agreement; grep bpf-policy-map-max before
# changing it.
policyMapMax: 65536
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
# filter" in cilium_drop_count_total.
#
# 30 is v30iot, the macvlan parent for the shelly-manager, matter-server,
# otbr and music-assistant attachments. On bare metal it is a VLAN
# sub-interface of enp2s0f0np0; inside the former Proxmox guests it was
# an untagged virtio NIC, which is why this was not needed before.
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
# carry an address, so it sees mgmt0's frames already untagged by the
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
# address-less, so its frames only meet the filter on the shared parent,
# still tagged. Adding 40 would change nothing and would touch the
# datapath carrying etcd and the API.
#
# VLAN 40 (mgmt0) is deliberately NOT listed, and the asymmetry is the
# interesting part. Cilium attaches to devices that carry an address —
# `cilium-dbg status --verbose` lists enp87s0, enp2s0f0np0, enp2s0f1np1
# and mgmt0, but NOT ens19. mgmt0 holds the node IP, so Cilium owns it as
# a device and sees frames the 8021q layer has already untagged; nothing
# for a VLAN filter to match. ens19 is deliberately address-less (see
# talos/patches/k8s-1.yaml — host addresses on that L2 make node replies
# bypass the UDM), so Cilium never attaches to it and VLAN 30 frames only
# meet the filter on the shared parent, still tagged.
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
# "no buffer space available" (#592).
#
# So the fix belongs on 30 alone. Listing 40 would change nothing, and
# would touch the datapath carrying etcd and the API for no reason.
#
# A tc-layer drop surfaces to an AF_PACKET sender as ENOBUFS, so the
# symptom is the CNI dhcp plugin reporting "no buffer space available"
# rather than a DHCP timeout. See issue #592.
#
# Same three-copy problem as policyMapMax above: vlan-bpf-bypass in
# kubernetes/talos/cilium.yaml and kubernetes/talos/patches/cilium-cni.yaml
# must move with this list. Takes effect on agent restart.
# Same three-copy problem as policyMapMax above. Takes an agent restart.
vlanBypass: [30]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
@@ -36,25 +36,16 @@ spec:
values:
container_runtime: containerd
# --- mTLS between agents and the LAPI: the durable fix for the recurring
# KubeDaemonSetRolloutStuck auth loop (#340) ---
# mTLS between agents and the LAPI. Do not switch back to password auth:
# the LAPI runs without persistence, so its machine-registration DB is
# wiped on every restart, and password-registered agents then get 401
# "machine not found" and crashloop until re-registered by hand — which
# wedges the whole `infrastructure` Kustomization behind a HelmRelease
# that never goes Ready (#340).
#
# The LAPI runs WITHOUT persistence (see persistentVolume note below), so
# its sqlite machine-registration DB is ephemeral and every LAPI pod restart
# wiped it. Under the old password flow the agents registered exactly ONCE
# (init container running `cscli lapi register --token`) and cached a
# password the wiped LAPI no longer recognised -> 401 "machine not found"
# -> CrashLoopBackOff on all three agents -> DaemonSet never Ready -> the
# crowdsec HelmRelease upgrade `--wait` times out forever -> the whole
# `infrastructure` Kustomization (and every app depending on it) stays
# unhealthy. That is the #340 outage.
#
# With client-cert auth the agent init container no longer registers at all
# (verified via `helm template`: the `cscli lapi register` step is gone).
# The agent presents a cert whose OU (agent-ou) the LAPI trusts, and a
# freshly restarted LAPI with an EMPTY DB auto-recreates each machine record
# from the presented cert on connect. So the system self-heals across LAPI
# restarts with no PVC, no Ceph, and no manual re-registration.
# With client certs the agent never registers: a restarted LAPI with an
# empty DB recreates each machine record from the presented cert, so this
# self-heals with no PVC and no intervention.
#
# cert-manager (already reconciled earlier in this same `infrastructure`
# stage) provisions a fully self-contained self-signed CA (selfSigned
@@ -87,13 +78,10 @@ spec:
lapi:
replicas: 1
# The chart defaults request 500m. Measured steady-state draw is 2m, and
# the request is what the scheduler reserves, so the default parks half a
# core of unusable capacity per LAPI. That is affordable on three nodes
# and is not on two: with one node out for maintenance the remaining pair
# sit at 100% of requested CPU while actually using ~22%, and nothing new
# can be placed. The limit stays at the chart default so a log flood can
# still burst; only the reservation shrinks.
# Far below the chart default, which reserves half a core per LAPI for a
# workload that draws single-digit millicores — enough to leave the
# cluster unschedulable with one node out. Only the reservation shrinks;
# the limit stays at the chart default so a log flood can still burst.
resources:
requests:
cpu: 50m
@@ -6,19 +6,12 @@
# fails the route over across all per-node peers (no single point of
# failure if a node/pod drops).
#
# Uses the ROOTLESS image (userspace wireguard-go). The netbird process
# terminates the WireGuard tunnel in userspace and reaches cluster
# services by opening its OWN sockets to them — NOT by L3-forwarding
# packets. That distinction is the whole point:
# - Cilium socket-LB DNATs the netbird process's own connections, so
# forwarding to ClusterIP / LB VIPs actually works. A kernel L3
# routing peer fails here: socket-LB never sees forwarded packets,
# so they hit the raw VIP and are blackholed (confirmed via conntrack).
# - No NET_ADMIN / no kernel WireGuard → runs fully unprivileged
# (drop ALL caps, runAsNonRoot), so it satisfies Pod Security too.
#
# Flow: mesh peer (100.96.x.x) --wg--> a per-node pod (userspace netbird)
# --own socket (socket-LB DNAT)--> service ClusterIP/LB VIP
# Must stay on the ROOTLESS image (userspace wireguard-go). The process
# terminates the tunnel in userspace and reaches services by opening its own
# sockets, which Cilium's socket-LB can DNAT. A kernel WireGuard peer
# L3-forwards instead, socket-LB never sees those packets, and they are
# blackholed at the raw VIP. Rootless also means no NET_ADMIN, so the pod
# runs fully unprivileged.
#
# Egress is locked down by the netbird-ingress-egress CiliumNetworkPolicy
# (network-policies/ingress-isolation.yaml).
@@ -41,18 +41,14 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# shelly-manager (:8080) is likewise reached only through its own
# Authentik gate outpost. Note this rule and the vscode one are
# port-identical: because this CNP's endpointSelector is {}, a
# `toPorts: 8080` grant here applies to EVERY pod in the namespace that
# listens on 8080, not just the intended one — which is exactly how
# shelly-manager silently rode on the old traefik→:8080 allow written
# for code-server, and why removing that allow in 760e187 took
# shelly-manager's ingress down with it. The `fromEndpoints` selector is
# what keeps these two apart: each gate outpost can only reach its own
# target, enforced on the egress side by the matching CNPs in the
# authentik namespace (vscode-gate-egress.yaml,
# shelly-manager-gate-egress.yaml).
# shelly-manager, behind its own Authentik gate outpost.
#
# CAUTION for every :8080 rule here: this policy's endpointSelector is {},
# so a toPorts grant on 8080 applies to EVERY pod in the namespace
# listening on that port, not just the intended one. Three apps share
# :8080. What keeps them apart is the fromEndpoints selector plus the
# matching per-gate egress CNPs in the authentik namespace. Changing a
# :8080 rule here affects all of them.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -61,11 +57,8 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# esphome (ESPHome Device Builder, :6052) is likewise reached only through
# its own Authentik gate outpost. Unlike the :8080 vscode/shelly rules
# above, :6052 is unique to esphome, so this namespace-wide (endpointSelector
# {}) toPorts grant doesn't silently apply to any other app — the
# fromEndpoints selector still scopes it to the esphome-gate outpost.
# esphome (:6052), behind its own gate outpost. Port is unique to it, so
# the namespace-wide grant does not reach anything else.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -74,13 +67,8 @@ spec:
- ports:
- port: "6052"
protocol: TCP
# zigbee2mqtt (Z2M frontend, :8080) is likewise reached only through its own
# Authentik gate outpost. Like the vscode/shelly-manager rules above this is
# the SHARED :8080 (zigbee2mqtt is the third app on that port), so this
# namespace-wide (endpointSelector {}) grant applies to every :8080 pod — the
# fromEndpoints selector scopes it to the zigbee2mqtt-gate outpost, and the
# matching authentik-side zigbee2mqtt-gate-egress.yaml scopes egress to the
# zigbee2mqtt pod specifically.
# zigbee2mqtt frontend, behind its own gate outpost. Shared :8080 — see the
# caution above.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -89,11 +77,8 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# zone-configurator (Everything Presence mmWave zone configurator, :42069)
# is likewise reached only through its own Authentik gate outpost. Like
# esphome's :6052, :42069 is unique to it, so this namespace-wide (endpoint
# Selector {}) toPorts grant doesn't silently apply to any other app; the
# fromEndpoints selector scopes it to the zone-configurator-gate outpost.
# zone-configurator (:42069), behind its own gate outpost. Port is unique
# to it.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -131,25 +116,16 @@ spec:
- fromEntities:
- host
- remote-node
# World ingress (IoT-VLAN devices via the BGP LoadBalancers) is NOT
# granted namespace-wide here. A blanket `fromEntities: world` on this
# endpointSelector:{} policy exposed EVERY pod in the namespace to the
# untrusted IoT VLAN / any BGP-routed source — vscode (:8080),
# zone-configurator (:42069), otbr, whisper, etc. — not just the two
# LoadBalancers that need it. World is now scoped per-target in the
# dedicated policies at the bottom of this file: world -> mosquitto
# :1883/:8883 and world -> home-assistant :8123 only. (matter-server on
# 10.2.x.226 rides a multus net1 interface that bypasses Cilium policy
# entirely, so it is unaffected either way.)
# ICMP — all standard ICMPv4 and ICMPv6 types from any cluster pod.
# Per Cilium docs, "If any layer 4 policy is specified, then ICMP
# will be blocked unless it's related to a connection that is
# otherwise allowed by the policy." The CT-RELATED path is not
# passing these in practice (Hubble shows ICMPv6 DestinationUnreachable
# being POLICY_DENIED on the egress side of established DNS flows),
# so allow ICMP explicitly. Same shape as commit 3c517a0 — widened
# from the error class to the full standard type set. Cilium's
# ICMPField requires an explicit type per entry; no wildcard.
# Do NOT add a blanket `fromEntities: world` to this policy. With
# endpointSelector {} it exposes every pod in the namespace to the IoT
# VLAN and any BGP-routed source, not just the LoadBalancers that need it.
# World is scoped per target in the dedicated policies at the bottom of
# this file. (matter-server rides a multus interface that bypasses Cilium
# policy entirely and is unaffected either way.)
# ICMP must be allowed explicitly: once any L4 policy exists Cilium blocks
# it unless CT-RELATED passes it, which in practice it does not. Cilium's
# ICMPField takes one type per entry — there is no wildcard, so the list
# has to be spelled out.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -186,10 +162,8 @@ spec:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
# In-cluster InfluxDB: HA's HACS InfluxDB integration writes state
# changes here (replaces the legacy http://influxdb.srv.renner.uno
# target — see kubernetes/apps/influxdb/ha-token-job.yaml for the
# write-bucket token wiring).
# In-cluster InfluxDB: HA's HACS integration writes state changes here.
# Token wiring is in apps/influxdb/ha-token-job.yaml.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": influxdb
@@ -213,9 +187,8 @@ spec:
- toEntities:
- host
- remote-node
# World: IoT-VLAN devices, ESPHome, HA Cloud, garage S3 (initial
# tarball seed), legacy InfluxDB on VM 108, third-party integration
# endpoints (Hue, Roomba, Philips, Brother IPP, …).
# World: IoT-VLAN devices, HA Cloud, object storage, and third-party
# integration endpoints on the LAN and internet.
- toEntities:
- world
# ICMP — egress mirror of the ingress ICMP allow above. Lets HA
@@ -1,12 +1,5 @@
---
# Baseline ingress policy for the `woodpecker` namespace (wp-pg now; server and
# agent arrive in stage 2).
#
# Unlike the `apps` baseline this replaces, every allow here lands on a
# namespace holding one service, so a namespace-scoped grant is not a blanket
# grant across unrelated workloads. That is the point of the move: in `apps`,
# letting the CNPG operator reach wp-pg would also have opened gitea, gatus,
# external-dns and ical-proxy to the whole database namespace (issue #433).
# Baseline ingress policy for the `woodpecker` namespace.
#
# CAUTION when adding rules here: this policy has `endpointSelector: {}`, so
# every rule below applies to EVERY pod in the namespace. A `toPorts` block
@@ -15,11 +8,7 @@
# belongs in its own policy with its own selector, as
# `wp-pg-allow-cnpg-operator` below does.
#
# No egress section, so egress is unrestricted. Deliberate for now and revisited
# in stage 2: wp-pg needs DNS plus `world` for WAL archiving to Hetzner S3, and
# the server/agent add gitea, the Kubernetes API and outbound plugin pulls.
# Writing that ruleset once, with the full workload set present, beats guessing
# it here and silently breaking the WAL archiving this stage exists to verify.
# There is no egress section, so egress is unrestricted. Still open (#433).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -5,25 +5,15 @@ kind: Kustomization
# secret. The matching OpenBao policy/role for each lives in ../policies/ and is
# bootstrapped manually (not GitOps-managed).
#
# IMAGE PINNING: every rotator here uses a DATED TAG of secret-rotator
# (`:YYYY-MM-DD`), never a digest and never `:latest`. This is a registry-local
# image, so it does not need digest pinning, and a dated tag makes "what actually
# runs" a reviewable diff.
# IMAGE PINNING: dated tags (`:YYYY-MM-DD`), never a digest and never
# `:latest`. Do NOT switch to digests — untagged manifests in this registry are
# garbage-collected once the tags move past them, so a pinned digest silently
# stops resolving after a few unrelated rebuilds of build-ci-images.yml. These
# jobs are quarterly, so the resulting ImagePullBackOff looks like nothing
# happening rather than like a failure (#458).
#
# Digests were tried and actively broke: untagged manifests are garbage-collected
# once :latest and the dated tags move past them, so a digest pinned today stops
# resolving after a few rebuilds. That decay is driven by edits to
# .woodpecker/build-ci-images.yml — a shared file — so adding an UNRELATED image
# republishes secret-rotator and orphans the manifest every rotator here was
# pinned to. 11 of these were pinned to digests that no longer existed and would
# have ImagePullBackOff'd on their first scheduled run (issue #458); because they
# are quarterly, that would have looked like nothing happening rather than like a
# failure.
#
# Keep them all on one tag: it is a single binary containing every mode
# (random | ha-token | cloudflare-roll | netbird-key | proxmox-token |
# gitea-token | woodpecker-secret), so a split serves no purpose and just makes
# the next bump partial.
# Keep every rotator on the same tag: it is one binary containing all modes, so
# splitting only makes the next bump partial.
#
# ADDING A ROTATOR: copy an existing file, and check you did not inherit a
# digest pin from an older example — nothing enforces this automatically, so it
@@ -24,12 +24,10 @@ spec:
- start: "fd99::"
stop: "fd99::ff"
---
# Cilium BGPv2 control-plane config peering each k8s node with the UDM
# over its MGMT LAN interface — NOT over v30iot. Control-plane traffic
# belongs on the management network; the IoT VLAN is the noisy untrusted
# L2 segment we're explicitly trying to keep cluster infrastructure off
# of. Each node sources from its eth0 (10.2.40.x / fd40::<host>) via
# the kernel routing decision; no explicit localAddress override needed.
# Cilium BGPv2 control-plane config peering each node with the UDM over the
# management LAN — never over v30iot, which is the untrusted IoT segment.
# The source address comes from the kernel routing decision, so no explicit
# localAddress override is needed.
#
# Two parallel sessions, one per address family:
# - v4 session: peer 10.2.40.1, family IPv4 unicast only
@@ -1,22 +1,7 @@
---
# The Ceph cluster network as a Multus attachment, so OSDs reach VLAN 49
# without every Ceph daemon being put on host networking to get there.
#
# WHAT THIS REPLACES, AND WHY
#
# `addressRanges` only takes effect under `provider: host` — Rook ignores it
# for the default CNI provider. So carrying replication over the dedicated
# 10G link meant setting provider: host, which is not per-daemon: it puts
# mons on host networking too. A mon's address is then the node's DHCP lease,
# and when that lease moves the mon cannot bind and fails. Worse, three mons
# on three nodes each hold the mon host ports, so Rook cannot schedule a
# replacement anywhere and the failover it starts cannot finish.
#
# None of that has anything to do with the goal. Per the Ceph documentation
# the cluster network carries OSD heartbeat, replication and recovery only —
# "Ceph Monitors always operate on the public network". Attaching it here
# gives the OSDs the link and leaves everything else on the pod network,
# where Rook manages identity itself.
# The Ceph cluster network as a Multus attachment, so the OSDs reach VLAN 49
# without every Ceph daemon landing on host networking to get there. The
# alternative and why it was rejected is in cephcluster.yaml.
#
# ISOLATION
#
@@ -7,11 +7,8 @@ spec:
interval: 24h
url: https://traefik.github.io/charts
---
# Traefik runs alongside ingress-nginx during the migration off the
# archived kubernetes/ingress-nginx project. Shares the `ingress` namespace
# so the post-migration topology is just "ingress namespace, one controller".
# Pilots: shelly-manager.home.renner.uno. ingress-nginx remains the default
# IngressClass; this controller is opt-in via `ingressClassName: traefik`.
# The cluster's only ingress controller, and the only IngressClass. It
# replaced ingress-nginx, which was retired after that project was archived.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
@@ -44,27 +41,18 @@ spec:
values:
deployment:
replicas: 2
# Entrypoint-default Middlewares for websecure: baseline security
# response headers (HSTS/CSP/X-Frame-Options/etc.) and a generous
# abuse/brute-force rate limit, both applied to every router on this
# entrypoint without per-Ingress annotations. See
# security-headers-middleware.yaml / rate-limit-middleware.yaml for the
# actual settings and rationale. This is the same entrypoint-default
# wiring mechanism the (currently detached) crowdsec-bouncer Middleware
# documents re-enabling via — independent of that CrowdSec enforcement
# decision, which stays off pending the client-IP-visibility fix
# referenced below.
# Entrypoint-default Middlewares for websecure: security response headers
# and a rate limit, applied to every router on this entrypoint without
# per-Ingress annotations. Settings live in
# security-headers-middleware.yaml and rate-limit-middleware.yaml.
additionalArguments:
- --entryPoints.websecure.http.middlewares=ingress-security-headers@kubernetescrd,ingress-rate-limit@kubernetescrd
# CrowdSec bouncer plugin (Yaegi), loaded but NOT globally attached.
# Enforcement was disabled after it caused an IPv6 ingress outage on
# first deploy: Traefik sees a single shared/SNAT'd source IP for v6
# ingress (no real per-client IP), so one http-admin-interface-probing
# hit (the e2e scan) banned that shared IP and 403'd all v6 clients.
# Re-attach the bouncer (additionalArguments entrypoint middleware ->
# ingress-crowdsec-bouncer@kubernetescrd) only after fixing client-IP
# visibility (forwardedHeaders/trustedIPs or the v6 masquerade) and
# adding an internal/LAN whitelist. The plugin + crowdsec-middleware.yaml
# CrowdSec bouncer plugin (Yaegi), loaded but deliberately NOT attached.
# Traefik sees one shared SNAT'd source for IPv6 ingress, so a single
# probe banned that address and 403'd every v6 client. Do not re-attach
# it until client-IP visibility is fixed (forwardedHeaders/trustedIPs or
# the v6 masquerade) and a LAN whitelist exists.
# The plugin + crowdsec-middleware.yaml
# stay in place so re-enabling is a one-line change. Detection (the
# crowdsec agents parsing Traefik logs) remains on and is unaffected.
experimental: