|
|
|
@@ -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()
|