Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c00ecf43c |
@@ -89,7 +89,7 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-vmtest
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
directory: ci/images/vmtest
|
||||
depends_on: []
|
||||
when:
|
||||
@@ -349,30 +349,3 @@ 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,13 +32,8 @@ 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. 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
|
||||
# watchdog and the flood watchdog below.
|
||||
netbird_watchdog_cooldown_seconds: 600
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/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()
|
||||
@@ -27,11 +27,25 @@ spec:
|
||||
# not-Ready. The converse does not hold — a crash or a stuck
|
||||
# scheduling would also fire this — but both want a human too, so the
|
||||
# imprecision costs nothing.
|
||||
#
|
||||
# WHY THE TWO THRESHOLD ALERTS AGGREGATE AWAY THE SCRAPE TARGET
|
||||
#
|
||||
# kube-state-metrics stamps instance/pod/endpoint onto every series it
|
||||
# exports, so a bare selector yields a *different* series each time the
|
||||
# kube-state-metrics pod is replaced — and Prometheus tracks the `for:`
|
||||
# timer per output series, so a replacement resets a pending alert to
|
||||
# inactive. That is not hypothetical: during the 2026-09-07 quorum
|
||||
# outage kube-state-metrics itself moved twice (the openbao replicas
|
||||
# were on the same three nodes that were being rebooted), and the two
|
||||
# alerts below fired ~14 min and ~6.5 min later than their `for:`
|
||||
# windows say they should have. Aggregating to the identity that
|
||||
# actually matters — the StatefulSet — makes the series survive a
|
||||
# kube-state-metrics restart and the timer run continuously.
|
||||
- alert: OpenBaoReplicaNotReady
|
||||
# A replica that is sealed never recovers on its own, so the
|
||||
# duration is only there to ignore ordinary rescheduling: a healthy
|
||||
# pod moving between nodes is Ready again in well under a minute.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
|
||||
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 3
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
@@ -56,7 +70,7 @@ spec:
|
||||
# Shorter window than the warning above because this is an outage,
|
||||
# not a degradation — but not zero, so a rolling restart that
|
||||
# briefly dips does not page.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
|
||||
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
|
||||
@@ -18,7 +18,6 @@ resources:
|
||||
- cloudnative-pg/
|
||||
- mariadb-operator/
|
||||
- netbird-ingress/
|
||||
- netbird-proxy-services/
|
||||
- coredns-config/
|
||||
- k8s-gateway/
|
||||
- external-dns-unifi/
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Reconciles NetBird reverse-proxy services from Ingresses that carry
|
||||
# dns.renner.uno/public: one TLS-passthrough service per host on :443,
|
||||
# targeting Traefik's ClusterIP through a subnet resource over the service
|
||||
# CIDR (ci/images/netbird-proxy-services/controller.py).
|
||||
#
|
||||
# openbao namespace, like the rotators: its default-deny already allows egress
|
||||
# to OpenBao, DNS, the apiserver and `world` (netbird.renner.uno), and the
|
||||
# NetBird PAT is read from OpenBao on every pass through the
|
||||
# netbird-proxy-services role — policies/netbird-proxy-services.hcl, applied
|
||||
# once by hand like the rotator roles. Until that role exists the pod logs an
|
||||
# auth error each minute and does nothing.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
---
|
||||
# Read-only, cluster-wide: public Ingresses live in many namespaces, the
|
||||
# Traefik Service in ingress/.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: netbird-proxy-services-read
|
||||
rules:
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: netbird-proxy-services-read
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: netbird-proxy-services-read
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer against the management API at a time.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
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:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/netbird-proxy-services:2026-09-07
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: NB_MANAGEMENT_URL
|
||||
value: https://netbird.renner.uno
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: netbird-proxy-services
|
||||
# Must match cluster.network.serviceSubnets in talos/patches/cluster.yaml.
|
||||
- name: SERVICE_CIDR
|
||||
value: "10.96.0.0/12"
|
||||
- name: TRAEFIK_SERVICE
|
||||
value: ingress/traefik
|
||||
- name: INTERVAL_SECONDS
|
||||
value: "60"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
@@ -1,4 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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"]
|
||||
}
|
||||
@@ -93,17 +93,13 @@ 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 — 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"
|
||||
# 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.
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.244.0.0/16"
|
||||
|
||||
Reference in New Issue
Block a user