Compare commits

...
Author SHA1 Message Date
claude-botandClaude Fable 5.1 ebb214f290 ci(kaniko): build-vmtest on the maintained fork like every other step
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#797 added build-vmtest with gcr.io/kaniko-project/executor:v1.23.2-debug,
the archived Google image the other nine steps left in August (60f231f8).
It passed on master only because that image was still cached on the node;
the branch push of the controller PR scheduled it elsewhere and the step
died before its first command. Same fork pin as the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:08:57 +02:00
claude-botandClaude Fable 5.1 c9ec97f6b6 feat(netbird): controller for reverse-proxy services from public Ingresses
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
netbird-proxy-services reconciles the NetBird reverse-proxy services that
nobody managed so far: one TLS-passthrough service on :443 per host of every
Ingress carrying dns.renner.uno/public, targeting Traefik ClusterIP through
a subnet resource over the service CIDR. SNI and Host reach Traefik
untouched, the proxy terminates nothing, no domain resource is involved --
so the dns-interceptor pinning bug that wedges the proxies on every
routing-peer loss has nothing to pin on.

Ownership is derived from NetBird, not from local state: managed iff mode is
tls and the target is the controller subnet resource. The ten hand-made
HTTP/domain services are reported as held and never touched; migration is
deleting one of them, the controller creates the passthrough on its next
pass. The PAT is read from OpenBao per pass through a read-only role
(policies/netbird-proxy-services.hcl, applied once by hand like the rotator
roles). Standard library only; unprivileged image built alongside heating-can.

Validated against the live management (ONCE=1 through the rotator role):
network, resource, router and policy created, 10 hosts held, one host that
had no service at all (llm.renner.uno) created and answering behind its
Authentik gate; second pass 0 created / 0 updated / 0 deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:02:23 +02:00
k.renner 57b2485487 Merge pull request 'fix(ceph): state the cluster CIDR — host-device leaves Rook's canary no NIC' (#807) from ceph-address-ranges into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#807
2026-09-07 05:22:30 +00:00
k.renner cbd47596bf Merge pull request 'fix(talos): answer ARP only on the link that owns the address' (#804) from fix/arp-flux-second-nic into master
Reviewed-on: k.renner/homelab#804
2026-09-07 05:17:09 +00:00
claude-botandClaude Fable 5.1 4d629bfd66 fix(talos): answer ARP only on the link that owns the address
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
Every node has a second, address-less NIC (enp87s0) up on the untagged
fallback network, which shares the mgmt VLAN's broadcast domain. With
the Linux default arp_ignore=0 the host answers ARP for the node IP from
that NIC too, so the UDM can learn 10.2.40.x against enp87s0's MAC.
Inbound IPv4 then arrives on a device Cilium is not attached to, never
reaches cil_from_netdev, and is routed straight back to the UDM by the
default route — a loop that made every IPv4 LoadBalancer VIP on the
affected node unreachable for 13.5 hours. IPv6 was untouched because
NDP only answers on the interface that owns the address.

The flux was measurable on all three nodes: an ARP request for a node
IP from a neighbour returned replies from two MACs. Which node's entry
had actually flipped at the UDM was a race, and it moved during the
diagnosis.

arp_ignore=1 answers only when the target address is configured on the
receiving interface; arp_announce=2 stops the host from sourcing ARP
from an interface that does not own the address. Applied live with
talosctl on all three nodes before this commit and verified with
enp87s0 up: each node now answers from its mgmt0 MAC only.

A node property, so it lives next to the existing enp87s0 sysctls in
each machine patch rather than in the site patch.

Refs #793, #576

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:46:51 +02:00
7 changed files with 349 additions and 1 deletions
+28 -1
View File
@@ -89,7 +89,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/vmtest
depends_on: []
when:
@@ -349,3 +349,30 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
# netbird-proxy-services: the controller that reconciles NetBird reverse-proxy
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-proxy-services
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/netbird-proxy-services
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/netbird-proxy-services/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
@@ -0,0 +1,12 @@
# netbird-proxy-services: reconciles NetBird reverse-proxy services (public
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.13-slim
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
ENV PYTHONUNBUFFERED=1
USER controller
ENTRYPOINT ["python3", "/opt/controller.py"]
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""netbird-proxy-services: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
For each host on such an Ingress the NetBird management gets one reverse-proxy
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
subnet resource that covers the cluster's service CIDR. SNI and Host reach
Traefik untouched, the proxy terminates nothing, and no domain resource is
involved -- the routing peers stay ordinary IP routers.
Ownership is derived from NetBird itself, not from local state: a service is
managed iff its mode is "tls" and its target is this controller's subnet
resource. Services that do not match (the hand-made HTTP/domain ones) are never
touched; a desired host held by such a service is reported and skipped until
that service is removed by hand.
Credentials: the NetBird PAT is read from OpenBao on every pass through the
Kubernetes auth role, exactly like the setup-key rotator; it never lands in a
Kubernetes Secret or in a log line.
"""
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger("netbird-proxy-services")
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-proxy-services")
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
PUBLIC_LABEL = os.environ.get("PUBLIC_LABEL", "dns.renner.uno/public")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
class ApiError(Exception):
pass
def http(method, url, body=None, headers=None, cafile=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
ctx = None
if cafile:
import ssl
ctx = ssl.create_default_context(cafile=cafile)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
raise ApiError(f"{method} {url} -> {e.code}: {detail}") from None
# --- Kubernetes -------------------------------------------------------------
def k8s(path):
with open(K8S_TOKEN_FILE) as f:
token = f.read().strip()
return http("GET", K8S_API + path, headers={"Authorization": f"Bearer {token}"}, cafile=K8S_CA_FILE)
def desired_hosts():
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
hosts = {}
for ing in ingresses.get("items", []):
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
for rule in ing.get("spec", {}).get("rules", []):
host = rule.get("host")
if not host or host.startswith("*"):
continue
hosts.setdefault(host, ref)
return hosts
def traefik_cluster_ip():
svc = k8s(f"/api/v1/namespaces/{TRAEFIK_NS}/services/{TRAEFIK_SVC}")
for ip in svc["spec"].get("clusterIPs", []):
if ":" not in ip:
return ip
raise ApiError(f"{TRAEFIK_NS}/{TRAEFIK_SVC} has no IPv4 ClusterIP")
# --- OpenBao ----------------------------------------------------------------
def netbird_pat():
with open(K8S_TOKEN_FILE) as f:
jwt = f.read().strip()
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
bao_token = login["auth"]["client_token"]
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_PAT_PATH}", headers={"X-Vault-Token": bao_token})
pat = kv["data"]["data"].get(BAO_PAT_KEY)
if not pat:
raise ApiError(f"OpenBao {BAO_PAT_PATH} has no key {BAO_PAT_KEY}")
return pat
# --- NetBird management -----------------------------------------------------
class NetBird:
def __init__(self, pat):
self.h = {"Authorization": f"Token {pat}"}
def get(self, path):
return http("GET", f"{NB}/api/{path}", headers=self.h)
def list(self, path):
# An empty collection comes back as an empty body / null, not [].
return self.get(path) or []
def post(self, path, body):
return http("POST", f"{NB}/api/{path}", body, headers=self.h)
def put(self, path, body):
return http("PUT", f"{NB}/api/{path}", body, headers=self.h)
def delete(self, path):
return http("DELETE", f"{NB}/api/{path}", headers=self.h)
def group_id(nb, name):
for g in nb.list("groups"):
if g["name"] == name:
return g["id"]
raise ApiError(f"NetBird group {name!r} not found")
def ensure_network(nb):
for n in nb.list("networks"):
if n["name"] == NETWORK_NAME:
return n["id"]
n = nb.post("networks", {"name": NETWORK_NAME,
"description": "managed by netbird-proxy-services; public ingress via TLS passthrough"})
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
return n["id"]
def ensure_resource(nb, net):
for r in nb.list(f"networks/{net}/resources"):
if r.get("address") == SERVICE_CIDR:
return r["id"]
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
"description": "managed by netbird-proxy-services",
"enabled": True, "groups": []})
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
return r["id"]
def ensure_router(nb, net, router_group):
for r in nb.list(f"networks/{net}/routers"):
if router_group in (r.get("peer_groups") or []):
return r["id"]
r = nb.post(f"networks/{net}/routers", {"peer_groups": [router_group], "metric": 9999,
"masquerade": True, "enabled": True})
LOG.info("created router on group %s (%s)", ROUTER_GROUP, r["id"])
return r["id"]
def ensure_policy(nb, resource, source_group):
name = f"{NETWORK_NAME} access"
for p in nb.list("policies"):
if p["name"] == name:
return p["id"]
p = nb.post("policies", {"name": name, "enabled": True, "rules": [{
"name": name, "enabled": True, "action": "accept", "protocol": "tcp",
"ports": [str(LISTEN_PORT)], "bidirectional": False, "sources": [source_group],
"destinationResource": {"id": resource, "type": "subnet"}}]})
LOG.info("created policy %s (%s)", name, p["id"])
return p["id"]
def service_body(host, resource, target_ip):
return {"name": host, "domain": host, "mode": "tls", "listen_port": LISTEN_PORT, "enabled": True,
"targets": [{"target_type": "subnet", "target_id": resource, "protocol": "tcp",
"host": target_ip, "port": 443, "enabled": True}]}
def is_managed(svc, resource):
return svc.get("mode") == "tls" and any(t.get("target_id") == resource for t in svc.get("targets") or [])
def target_matches(svc, target_ip):
if svc.get("listen_port") != LISTEN_PORT or not svc.get("enabled"):
return False
ts = svc.get("targets") or []
return len(ts) == 1 and ts[0].get("host") == target_ip and ts[0].get("port") == 443 \
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled")
def reconcile():
hosts = desired_hosts()
target_ip = traefik_cluster_ip()
nb = NetBird(netbird_pat())
router_group = group_id(nb, ROUTER_GROUP)
source_group = group_id(nb, SOURCE_GROUP)
net = ensure_network(nb)
resource = ensure_resource(nb, net)
ensure_router(nb, net, router_group)
ensure_policy(nb, resource, source_group)
by_domain = {s["domain"]: s for s in nb.list("reverse-proxies/services")}
created = updated = deleted = held = 0
for host, ref in sorted(hosts.items()):
svc = by_domain.get(host)
if svc is None:
nb.post("reverse-proxies/services", service_body(host, resource, target_ip))
LOG.info("created %s (from %s) -> %s:443", host, ref, target_ip)
created += 1
elif not is_managed(svc, resource):
LOG.warning("%s is held by unmanaged service %s (mode %s); remove it to migrate",
host, svc["id"], svc.get("mode"))
held += 1
elif not target_matches(svc, target_ip):
nb.put(f"reverse-proxies/services/{svc['id']}", service_body(host, resource, target_ip))
LOG.info("updated %s -> %s:443", host, target_ip)
updated += 1
for domain, svc in by_domain.items():
if is_managed(svc, resource) and domain not in hosts:
nb.delete(f"reverse-proxies/services/{svc['id']}")
LOG.info("deleted %s (no public Ingress carries it any more)", domain)
deleted += 1
LOG.info("reconciled: %d hosts desired, %d created, %d updated, %d deleted, %d held by unmanaged services",
len(hosts), created, updated, deleted, held)
def main():
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s")
if os.environ.get("ONCE") == "1":
reconcile()
return
while True:
try:
reconcile()
except Exception as e: # keep running; the next pass retries
LOG.error("reconcile failed: %s", e)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
# OpenBao policy: netbird-proxy-services
#
# The reverse-proxy-services controller (Deployment netbird-proxy-services in
# the openbao namespace) reads the NetBird PAT on every reconcile pass and
# calls the management API with it. Read-only on that one path; it shares the
# bootstrap credential with rotator-netbird but not that role's PATCH rights.
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write netbird-proxy-services kubernetes/infrastructure/openbao/policies/netbird-proxy-services.hcl
# bao write auth/kubernetes/role/netbird-proxy-services \
# bound_service_account_names=netbird-proxy-services \
# bound_service_account_namespaces=openbao \
# policies=netbird-proxy-services ttl=15m
#
# The PAT itself is the one rotator-netbird already uses
# (homelab/infrastructure/secret-rotators-bootstrap/netbird, key `pat`).
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
path "homelab/data/infrastructure/secret-rotators-bootstrap/netbird" {
capabilities = ["read"]
}
+7
View File
@@ -14,6 +14,13 @@ machine:
# Disabling v6 on a VLAN parent does not affect the VLAN child.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# That network also carries the mgmt VLAN's ARP. Answer ARP only on the
# link that owns the address, or the UDM learns the node IP against
# enp87s0's MAC and delivers IPv4 to a NIC Cilium is not attached to (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select disks by serial only. A broad selector such as
# `disk.transport == 'nvme'` matches every drive in the machine, and the
+5
View File
@@ -12,6 +12,11 @@ machine:
# cluster IPv6 egress.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.
+5
View File
@@ -8,6 +8,11 @@ machine:
# k8s-1.yaml.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.