From db878ff35d07620017653858f8d685a88afc3e0d Mon Sep 17 00:00:00 2001 From: twothatit Date: Sun, 8 Feb 2026 19:51:32 +0100 Subject: [PATCH] Fix NPM integration: correct forward host, SSL, and add UDP stream - Forward proxy to host IP + dashboard_port instead of container name - Remove redundant advanced_config (Caddy handles internal routing) - Add provider: letsencrypt to SSL certificate request - Add NPM UDP stream creation/deletion for STUN/TURN relay ports - Add npm_stream_id to Deployment model with migration - Fix API docs URL in README (/api/docs) Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +- app/database.py | 1 + app/models.py | 2 + app/services/netbird_service.py | 51 +++++++-- app/services/npm_service.py | 184 ++++++++++++++++++++++++-------- 5 files changed, 190 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index cc82ff1..75c3a25 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Per customer instance (5 containers): **~100 MB RAM** | Port | Protocol | Purpose | |------|----------|---------| | 8000 | TCP | NetBird MSP Appliance Web UI | -| 9000+ | TCP | NetBird Web Management per customer (one per customer, increments by 1) | +| 9000+ | TCP | NetBird Web Management per customer (only internal, one per customer, increments by 1) | | 3478+ | UDP | STUN/TURN relay per customer (one per customer, increments by 1) | Example for 3 customers: @@ -330,7 +330,7 @@ curl -X POST http://localhost:8000/api/auth/token \ Full interactive documentation available at: ``` -http://your-server:8000/docs +http://your-server:8000/api/docs ``` **Common Endpoints:** diff --git a/app/database.py b/app/database.py index f38f007..62b62c9 100644 --- a/app/database.py +++ b/app/database.py @@ -79,6 +79,7 @@ def _run_migrations() -> None: ("system_config", "branding_subtitle", "TEXT DEFAULT 'Multi-Tenant Management Platform'"), ("system_config", "default_language", "TEXT DEFAULT 'en'"), ("users", "default_language", "TEXT"), + ("deployments", "npm_stream_id", "INTEGER"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index 4e35eee..e6e6789 100644 --- a/app/models.py +++ b/app/models.py @@ -83,6 +83,7 @@ class Deployment(Base): relay_udp_port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) dashboard_port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) npm_proxy_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + npm_stream_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) relay_secret: Mapped[str] = mapped_column(Text, nullable=False) setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) netbird_admin_email: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -111,6 +112,7 @@ class Deployment(Base): "relay_udp_port": self.relay_udp_port, "dashboard_port": self.dashboard_port, "npm_proxy_id": self.npm_proxy_id, + "npm_stream_id": self.npm_stream_id, "relay_secret": "***", # Never expose secrets "setup_url": self.setup_url, "has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password), diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index 3b2bd17..6c2fb08 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -220,20 +220,19 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: _log_action(db, customer_id, "deploy", "info", "Auto-setup failed — admin must complete setup manually.") - # Step 9: Create NPM proxy host (production only) + # Step 9: Create NPM proxy host + stream (production only) npm_proxy_id = None + npm_stream_id = None if not local_mode: - caddy_container = f"netbird-kunde{customer_id}-caddy" + forward_host = npm_service._get_forward_host(config.npm_api_url) npm_result = await npm_service.create_proxy_host( api_url=config.npm_api_url, npm_email=config.npm_api_email, npm_password=config.npm_api_password, domain=netbird_domain, - forward_host=caddy_container, - forward_port=80, + forward_host=forward_host, + forward_port=dashboard_port, admin_email=config.admin_email, - subdomain=customer.subdomain, - customer_id=customer_id, ) npm_proxy_id = npm_result.get("proxy_id") if npm_result.get("error"): @@ -241,8 +240,34 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: db, customer_id, "deploy", "error", f"NPM proxy creation failed: {npm_result['error']}", ) + else: + _log_action( + db, customer_id, "deploy", "info", + f"NPM proxy host created: {netbird_domain} -> {forward_host}:{dashboard_port}", + ) - # Step 9: Create deployment record + # Create NPM UDP stream for relay STUN port + stream_result = await npm_service.create_stream( + api_url=config.npm_api_url, + npm_email=config.npm_api_email, + npm_password=config.npm_api_password, + incoming_port=allocated_port, + forwarding_host=forward_host, + forwarding_port=allocated_port, + ) + npm_stream_id = stream_result.get("stream_id") + if stream_result.get("error"): + _log_action( + db, customer_id, "deploy", "error", + f"NPM stream creation failed: {stream_result['error']}", + ) + else: + _log_action( + db, customer_id, "deploy", "info", + f"NPM UDP stream created: port {allocated_port} -> {forward_host}:{allocated_port}", + ) + + # Step 10: Create deployment record setup_url = external_url deployment = Deployment( @@ -251,6 +276,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: relay_udp_port=allocated_port, dashboard_port=dashboard_port, npm_proxy_id=npm_proxy_id, + npm_stream_id=npm_stream_id, relay_secret=encrypt_value(relay_secret), setup_url=setup_url, netbird_admin_email=encrypt_value(admin_email) if setup_ok else None, @@ -330,6 +356,17 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: except Exception as exc: _log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}") + # Remove NPM stream + if deployment.npm_stream_id and config.npm_api_email: + try: + await npm_service.delete_stream( + config.npm_api_url, config.npm_api_email, config.npm_api_password, + deployment.npm_stream_id, + ) + _log_action(db, customer_id, "undeploy", "info", "NPM stream removed.") + except Exception as exc: + _log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}") + # Remove instance directory if os.path.isdir(instance_dir): try: diff --git a/app/services/npm_service.py b/app/services/npm_service.py index b29c173..dc40e86 100644 --- a/app/services/npm_service.py +++ b/app/services/npm_service.py @@ -8,10 +8,13 @@ subsequent requests. Creates, updates, and deletes proxy host entries so each customer's NetBird dashboard is accessible at ``{subdomain}.{base_domain}`` with automatic Let's Encrypt SSL certificates. + +Also manages NPM streams for STUN/TURN relay UDP ports. """ import logging from typing import Any +from urllib.parse import urlparse import httpx @@ -21,6 +24,35 @@ logger = logging.getLogger(__name__) NPM_TIMEOUT = 30 +def _get_forward_host(npm_api_url: str) -> str: + """Determine the IP/hostname to forward traffic to. + + The NPM proxy host must forward to the MSP appliance's host IP, + NOT to a Docker container name, because the customer's Caddy + container exposes its port on the host via Docker port mapping. + + We extract the host from the NPM API URL — if the admin configured + ``http://10.0.0.5:81/api``, we forward to ``10.0.0.5``. + If the admin configured ``http://npm:81/api`` (container name), + we fall back to the Docker gateway IP ``172.17.0.1``. + + Args: + npm_api_url: The NPM API base URL from system config. + + Returns: + IP address or hostname to forward to. + """ + parsed = urlparse(npm_api_url) + host = parsed.hostname or "172.17.0.1" + + # If the host looks like a container name (no dots, not an IP), use Docker gateway + if not any(c == "." for c in host) and not host.startswith("172.") and host != "localhost": + logger.info("NPM URL host '%s' looks like a container name, using Docker gateway 172.17.0.1", host) + return "172.17.0.1" + + return host + + async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, password: str) -> str: """Authenticate with NPM and return a JWT token. @@ -96,60 +128,25 @@ async def create_proxy_host( forward_host: str, forward_port: int = 80, admin_email: str = "", - subdomain: str = "", - customer_id: int = 0, ) -> dict[str, Any]: """Create a proxy host entry in NPM with SSL for a customer. - Logs in first to get a JWT, then creates the proxy host with advanced - routing config for management, signal, and relay containers. + Forwards traffic to the host IP + dashboard_port where the customer's + Caddy reverse proxy is listening. Caddy handles internal routing to + management, signal, relay, and dashboard containers. Args: api_url: NPM API base URL. npm_email: NPM login email. npm_password: NPM login password. domain: Full domain (e.g. ``kunde1.example.com``). - forward_host: Container name for the dashboard. - forward_port: Port to forward to (default 80). + forward_host: IP/hostname to forward to (host IP, not container name). + forward_port: Port to forward to (dashboard_port, e.g. 9001). admin_email: Email for Let's Encrypt. - subdomain: Customer subdomain for building container names. - customer_id: Customer ID for building container names. Returns: Dict with ``proxy_id`` on success or ``error`` on failure. """ - # Build advanced Nginx config to route sub-paths to different containers - mgmt_container = f"netbird-kunde{customer_id}-management" - signal_container = f"netbird-kunde{customer_id}-signal" - relay_container = f"netbird-kunde{customer_id}-relay" - - advanced_config = f""" -# NetBird Management API -location /api {{ - proxy_pass http://{mgmt_container}:80; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; -}} - -# NetBird Signal (gRPC-Web) -location /signalexchange. {{ - grpc_pass grpc://{signal_container}:80; - grpc_set_header Host $host; -}} - -# NetBird Relay (WebSocket) -location /relay {{ - proxy_pass http://{relay_container}:80; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; -}} -""" - payload = { "domain_names": [domain], "forward_scheme": "http", @@ -163,7 +160,7 @@ location /relay {{ "block_exploits": True, "allow_websocket_upgrade": True, "access_list_id": 0, - "advanced_config": advanced_config.strip(), + "advanced_config": "", "meta": { "letsencrypt_agree": True, "letsencrypt_email": admin_email, @@ -187,7 +184,8 @@ location /relay {{ if resp.status_code in (200, 201): data = resp.json() proxy_id = data.get("id") - logger.info("Created NPM proxy host %s (id=%s)", domain, proxy_id) + logger.info("Created NPM proxy host %s -> %s:%d (id=%s)", + domain, forward_host, forward_port, proxy_id) # Step 3: Request SSL certificate await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email) @@ -225,6 +223,8 @@ async def _request_ssl( """ ssl_payload = { "domain_names": [domain], + "provider": "letsencrypt", + "nice_name": domain, "meta": { "letsencrypt_agree": True, "letsencrypt_email": admin_email, @@ -243,13 +243,111 @@ async def _request_ssl( json={"certificate_id": cert_id}, headers=headers, ) - logger.info("SSL certificate assigned to proxy host %s", proxy_id) + logger.info("SSL certificate %s assigned to proxy host %s", cert_id, proxy_id) else: logger.warning("SSL request returned %s: %s", resp.status_code, resp.text[:200]) except Exception as exc: logger.warning("SSL certificate request failed: %s", exc) +async def create_stream( + api_url: str, + npm_email: str, + npm_password: str, + incoming_port: int, + forwarding_host: str, + forwarding_port: int, +) -> dict[str, Any]: + """Create a UDP stream in NPM for STUN/TURN relay forwarding. + + NPM streams forward raw TCP/UDP traffic (Layer 4) without HTTP processing. + Used for the relay STUN port (UDP 3478+). + + Args: + api_url: NPM API base URL. + npm_email: NPM login email. + npm_password: NPM login password. + incoming_port: The public-facing port NPM listens on. + forwarding_host: IP/hostname to forward to. + forwarding_port: The port on the target host. + + Returns: + Dict with ``stream_id`` on success or ``error`` on failure. + """ + payload = { + "incoming_port": incoming_port, + "forwarding_host": forwarding_host, + "forwarding_port": forwarding_port, + "tcp_forwarding": False, + "udp_forwarding": True, + "meta": {}, + } + + try: + async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: + token = await _npm_login(client, api_url, npm_email, npm_password) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + resp = await client.post( + f"{api_url}/nginx/streams", json=payload, headers=headers + ) + if resp.status_code in (200, 201): + data = resp.json() + stream_id = data.get("id") + logger.info( + "Created NPM stream: UDP :%d -> %s:%d (id=%s)", + incoming_port, forwarding_host, forwarding_port, stream_id, + ) + return {"stream_id": stream_id} + else: + error_msg = f"NPM stream creation returned {resp.status_code}: {resp.text[:300]}" + logger.error("Failed to create NPM stream: %s", error_msg) + return {"error": error_msg} + except RuntimeError as exc: + logger.error("NPM login failed for stream creation: %s", exc) + return {"error": f"NPM login failed: {exc}"} + except Exception as exc: + logger.error("NPM stream API error: %s", exc) + return {"error": str(exc)} + + +async def delete_stream( + api_url: str, npm_email: str, npm_password: str, stream_id: int +) -> bool: + """Delete a stream from NPM. + + Args: + api_url: NPM API base URL. + npm_email: NPM login email. + npm_password: NPM login password. + stream_id: The stream ID to delete. + + Returns: + True on success. + """ + try: + async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client: + token = await _npm_login(client, api_url, npm_email, npm_password) + headers = {"Authorization": f"Bearer {token}"} + resp = await client.delete( + f"{api_url}/nginx/streams/{stream_id}", headers=headers + ) + if resp.status_code in (200, 204): + logger.info("Deleted NPM stream %d", stream_id) + return True + logger.warning( + "Failed to delete stream %d: %s %s", + stream_id, resp.status_code, resp.text[:200], + ) + return False + except Exception as exc: + logger.error("NPM stream delete error: %s", exc) + return False + + async def delete_proxy_host( api_url: str, npm_email: str, npm_password: str, proxy_id: int ) -> bool: