Compare commits

...
Author SHA1 Message Date
claude-botandClaude Fable 5.1 2c47c68a8b feat(netbird): carry the WAN client address through the passthrough edge
ci/woodpecker/push/build-ci-images Pipeline was successful
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
TLS-passthrough services hand Traefik a raw TCP stream, so every WAN
request now logs the routing peer's pod IP (10.244.4.64/.112 in the
access log) instead of the client. CrowdSec's detection parses exactly
that log; with the HTTP-mode services gone it would attribute every
external request to two pod IPs, and a re-armed bouncer would repeat the
2026-06-27 mass ban.

The controller now sets options.proxy_protocol on every target (PROXY
protocol v2, TCP/TLS modes only per NetBird) and treats a service
without it as out of date, so the eleven existing services are updated
on the next pass. Traefik's websecure entrypoint accepts the header from
the pod CIDRs, mirroring forwardedHeaders.trustedIPs. Traefik takes
connections with or without a header from a trusted IP, so mesh and LAN
traffic that carries none is unaffected and the Traefik change can land
before the controller image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:29:48 +02:00
k.renner ceff817d88 Merge pull request 'fix(netbird-proxy-services): numeric runAsUser so runAsNonRoot can be verified' (#815) from netbird-proxy-services-uid into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#815
2026-09-07 10:48:04 +00:00
claude-botandClaude Fable 5.1 ea1032e184 fix(netbird-proxy-services): numeric runAsUser so runAsNonRoot can be verified
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
First rollout of #814 sat in CreateContainerConfigError: "container has
runAsNonRoot and image has non-numeric user (controller), cannot verify
user is non-root". The kubelet cannot resolve a named user; nobody/nogroup
in the pod spec is enough, the script needs no home and no writable path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:45:40 +02:00
k.renner 2856deb4f4 Merge pull request 'feat(netbird): deploy the reverse-proxy-services controller (2/2)' (#814) from netbird-proxy-services-deploy into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#814
2026-09-07 10:43:56 +00:00
k.renner 4d13acc9dd Merge pull request 'feat(netbird): controller for reverse-proxy services from public Ingresses (1/2: image)' (#813) from netbird-proxy-services into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#813
2026-09-07 10:43:47 +00:00
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 7e8077714e Merge pull request 'fix(netbird-proxy): watchdog restart cooldown 600 s → 60 s' (#812) from proxy-watchdog-cooldown into master
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#812
2026-09-07 09:43:20 +00:00
claude-botandClaude Fable 5.1 8e9eedfd16 fix(netbird-proxy): watchdog restart cooldown 600 s -> 60 s
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Every loss of the routing peer a proxy has pinned wedges its route
manager; the flood watchdog restarts the container within 2 s and the
public edge is back after one failed probe round. Inside the 600 s
cooldown the restart is suppressed and the URL watchdog three one-minute
rounds are the only recovery: 3.5 min of 000 on every public domain,
measured 2026-09-07 10:28 on proxy-2 ("restart suppressed, last restart
567s ago"). The morning node roll hit the same suppression on both
proxies at 06:58 and survived only because the routes recovered by luck.

Rolling reboots lose one peer every few minutes by design, so with 600 s
the second roll is always the unlucky one. A minute keeps the loop
protection (one restart per probe interval at most).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 10:38:28 +02:00
k.renner c7fd3e208a Merge pull request 'fix(infrastructure): LAPI ServersTransport behind the Traefik CRDs (phase 2/2, remove)' (#809) from lapi-transport-phase-2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#809
2026-09-07 06:09:31 +00:00
claude-botandClaude Fable 5.1 5d52e42887 fix(infrastructure): LAPI ServersTransport behind the Traefik CRDs (phase 2: remove)
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
Phase 1 (#802) put the ServersTransport under post-crds and
infrastructure-crds has applied it. The copy in crowdsec/lapi-ingress.yaml
goes; the Ingress stays (core kind). With it, the infrastructure apply set
holds no traefik.io consumer any more, which is what a cold rebuild needs.

Prune: infrastructure reconciles first and skips the object if the last
applier label names infrastructure-crds; otherwise it deletes and
infrastructure-crds recreates it seconds later. Only the public LAPI
ingress used by the external bouncers is behind that transport.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 07:48:22 +02:00
9 changed files with 364 additions and 25 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
@@ -32,8 +32,13 @@ netbird_watchdog_fail_threshold: 3
# Minimum seconds between watchdog-issued restarts, so a genuinely broken
# backend can't put the proxy into a restart loop. Shared by both the URL-probe
# watchdog and the flood watchdog below.
netbird_watchdog_cooldown_seconds: 600
# watchdog and the flood watchdog below. One minute: the route-manager wedge
# recurs on every routing-peer loss, and rolling node reboots lose one peer
# every few minutes; a suppressed restart there leaves the wedge standing
# until the URL probe's three rounds fire (3.5 min of 000 on every domain,
# measured 2026-09-07). A minute still caps a loop at one restart per
# probe interval.
netbird_watchdog_cooldown_seconds: 60
# Log fingerprint of the reverse-proxy route-manager wedge, matched as a plain
# substring in the proxy's docker logs by the flood watchdog. On this build the
@@ -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,275 @@
#!/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. The target sends a
PROXY protocol v2 header so Traefik (and CrowdSec behind its access log) sees
the WAN client address instead of the routing peer's pod IP.
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):
# proxy_protocol: PROXY v2 header carries the WAN client address to
# Traefik (proxyProtocol.trustedIPs on websecure); TCP/TLS modes only.
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,
"options": {"proxy_protocol": 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") \
and (ts[0].get("options") or {}).get("proxy_protocol") is True
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()
@@ -31,22 +31,12 @@
# serves read-only decision streams to a valid bouncer key and nothing else.
# The admin surface is cscli, in-cluster. If the source address ever becomes
# deterministic, an ipAllowList can be added on top.
---
#
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
# fallback and no second port; 8080 is the TLS port. Its certificate comes
# from the chart's own self-signed CA, so Traefik needs that CA to verify the
# backend. SANs include crowdsec-service.crowdsec.svc.cluster.local, which is
# what the Service resolves to.
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: crowdsec-lapi
namespace: crowdsec
spec:
serverName: crowdsec-service.crowdsec.svc.cluster.local
rootCAs:
- secret: crowdsec-root-secret
---
# backend; the ServersTransport carrying it is a traefik.io CRD consumer and
# lives in post-crds/crowdsec-lapi-serverstransport.yaml.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -67,6 +67,11 @@ spec:
serviceAccountName: netbird-proxy-services
securityContext:
runAsNonRoot: true
# Numeric, or the kubelet refuses the image's named user with
# "cannot verify user is non-root". The script needs no home and no
# writable path, so nobody/nogroup is enough.
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
@@ -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"]
}
@@ -31,7 +31,5 @@ resources:
- traefik-crowdsec-middleware.yaml
- traefik-rate-limit-middleware.yaml
- traefik-security-headers-middleware.yaml
# Phase 1 of a two-Kustomization move: also still in crowdsec/lapi-ingress.yaml
# until this layer has applied it once, so prune never runs ahead of apply.
- crowdsec-lapi-serverstransport.yaml
- ../network-policies/
@@ -93,13 +93,17 @@ spec:
# Real client-IP visibility. The netbird-ingress routing peer
# re-originates every mesh + WAN-via-proxy connection from its own
# pod IP (rootless userspace WireGuard), so Traefik's TCP peer for
# that traffic is always a pod IP (e.g. fd10:244::2de1) — collapsing
# all external clients to one source. The NetBird reverse-proxy
# (HTTP mode, on the VPS) sets X-Forwarded-For with the real WAN
# client IP; trust the pod CIDRs so Traefik reads it instead. Without
# this, a CrowdSec bouncer bans the shared pod IP and 403s everyone
# (the 2026-06-27 IPv6 outage). Harmless no-op when no XFF is present
# (direct LAN/mesh) — those keep their RemoteAddr.
# that traffic is always a pod IP — collapsing all external clients
# to one source. The NetBird reverse-proxy services are TLS
# passthrough and carry the WAN client address in a PROXY protocol
# v2 header (options.proxy_protocol, set by netbird-proxy-services);
# X-Forwarded-For stays trusted for HTTP-mode services. Both are
# accepted from the pod CIDRs only; connections without a header
# (direct LAN/mesh) keep their RemoteAddr.
proxyProtocol:
trustedIPs:
- "10.244.0.0/16"
- "fd10:244::/56"
forwardedHeaders:
trustedIPs:
- "10.244.0.0/16"