diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0e99523..c957fe0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -210,6 +210,44 @@ jobs: echo "=== Events ===" kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true + e2e-gateway: + name: "E2E — NetBird: Gateway API (Envoy Gateway)" + runs-on: ubuntu-latest + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (gateway) + run: ci/scripts/netbird/e2e-gateway.sh + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n netbird-gateway-e2e get pods -o wide || true + echo "=== Gateway status ===" + kubectl -n netbird-gateway-e2e get gateway netbird-gateway -o yaml || true + echo "=== Route statuses ===" + kubectl -n netbird-gateway-e2e get httproute,grpcroute -o yaml || true + echo "=== Envoy Gateway logs ===" + kubectl -n envoy-gateway-system logs deployment/envoy-gateway --tail=100 || true + echo "=== Server logs ===" + kubectl -n netbird-gateway-e2e logs deployment/netbird-gateway-e2e-server --all-containers --tail=100 || true + echo "=== Events ===" + kubectl -n netbird-gateway-e2e get events --sort-by='.lastTimestamp' || true + e2e-oidc-embedded: name: "E2E — NetBird: OIDC (Embedded IdP)" runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index b024423..2766105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `https://netbird.example.com`). NetBird clients require the port — without it the daemon fails with `missing port in address`. Use `https://netbird.example.com:443` instead. Fixes #75. +- **netbird**: Gateway API support as a mutually-exclusive alternative to + Kubernetes Ingress for every traffic class. New values: + `server.httpRoute` (`HTTPRoute`), `server.grpcRoute` (`GRPCRoute`), + `server.relayHttpRoute` (`HTTPRoute`), `server.relayTcpRoute` + (`TCPRoute`, v1alpha2), and `dashboard.httpRoute` (`HTTPRoute`). The + chart renders routes only; users provide `parentRefs` to a Gateway they + already manage. Omitted `backendRefs` auto-fill to the netbird + server / dashboard Service on port 80. Fixes #74 — controllers that + support plaintext h2c (Envoy Gateway, Traefik Gateway, …) can now expose + gRPC without TLS via `GRPCRoute`, sidestepping the nginx-ingress h2c + limitation that made `server.ingressGrpc` fail silently without a cert. +- **netbird**: Fail-fast validation that rejects enabling both an Ingress + and its Gateway-API counterpart for the same traffic class (and between + `server.relayHttpRoute` / `server.relayTcpRoute`), or enabling a route + with an empty `parentRefs` list. +- **netbird**: Fail-fast validation that rejects + `server.ingressGrpc.enabled=true` with an empty `server.ingressGrpc.tls` + list. gRPC over Kubernetes Ingress requires TLS (nginx-ingress cannot + negotiate plaintext h2c, and the default `ssl-redirect: "true"` + annotation redirects plaintext gRPC to HTTPS) — previously this + misconfiguration failed silently. Fixes #74. Users who want plaintext + gRPC should use `server.grpcRoute` with a Gateway API controller that + supports h2c. ### Changed - **netbird**: README and `values.yaml` examples now show `exposedAddress` with an explicit `:443` port and document that the port is required even when it matches the scheme default. +- **netbird**: README gains a "Gateway API as an alternative to Ingress" + section with copy-pasteable examples, parameter tables for the new + route blocks, and an updated architecture diagram. ## [0.4.1] — 2026-04-14 diff --git a/Makefile b/Makefile index b4e229c..5b78830 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint unittest e2e e2e-netbird e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-keycloak e2e-keycloak-dev e2e-keycloak-postgres e2e-keycloak-replicas e2e-setup e2e-teardown test compat-matrix +.PHONY: lint unittest e2e e2e-netbird e2e-sqlite e2e-postgres e2e-mysql e2e-gateway e2e-oidc-keycloak e2e-oidc-zitadel e2e-keycloak e2e-keycloak-dev e2e-keycloak-postgres e2e-keycloak-replicas e2e-setup e2e-teardown test compat-matrix CHARTS := $(wildcard charts/*) @@ -36,6 +36,9 @@ e2e-postgres: e2e-setup e2e-mysql: e2e-setup ci/scripts/netbird/e2e.sh mysql +e2e-gateway: e2e-setup + ci/scripts/netbird/e2e-gateway.sh + e2e-oidc-keycloak: e2e-setup ci/scripts/netbird/e2e-oidc.sh keycloak @@ -46,6 +49,7 @@ e2e-netbird: e2e-setup ci/scripts/netbird/e2e.sh sqlite ci/scripts/netbird/e2e.sh postgres ci/scripts/netbird/e2e.sh mysql + ci/scripts/netbird/e2e-gateway.sh ci/scripts/netbird/e2e-oidc.sh keycloak ci/scripts/netbird/e2e-oidc.sh zitadel diff --git a/charts/netbird/README.md b/charts/netbird/README.md index 018011c..4343ba9 100644 --- a/charts/netbird/README.md +++ b/charts/netbird/README.md @@ -162,6 +162,13 @@ server: - secretName: netbird-tls hosts: - netbird.example.com + # ⚠ ingressGrpc requires TLS. Standard nginx-ingress cannot negotiate + # HTTP/2 cleartext (h2c), and the chart sets + # nginx.ingress.kubernetes.io/ssl-redirect: "true" by default, so + # plaintext gRPC is redirected to HTTPS and fails without a cert. + # Enabling this block with an empty `tls:` is rejected at template time. + # For plaintext h2c, use server.grpcRoute (Gateway API) instead — see the + # "Gateway API as an alternative to Ingress" section below. ingressGrpc: enabled: true hosts: @@ -209,6 +216,90 @@ dashboard: - netbird.example.com ``` +## Gateway API as an alternative to Ingress + +Each of the Ingress blocks above has a mutually-exclusive Gateway API +counterpart. Use Gateway API when: + +- You already terminate TLS at a cluster-wide Gateway and don't want per-app + `Secret` references. +- You need **plaintext h2c for gRPC**. Standard nginx-ingress cannot + negotiate HTTP/2 cleartext, so `server.ingressGrpc` requires TLS; + `server.grpcRoute` does not. +- You prefer Gateway API's richer matching (header/method matches, filters, + traffic splitting). + +The chart renders **routes only** — `HTTPRoute`, `GRPCRoute`, `TCPRoute` — +and attaches them via `parentRefs` to a `Gateway` you already manage. TLS +is configured on that Gateway's listeners, not in these values. Enabling +an Ingress block and its Gateway API counterpart for the same traffic +class fails template rendering with a clear error. + +```yaml +server: + httpRoute: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-system + sectionName: https + hostnames: + - netbird.example.com + rules: + - matches: + - path: { type: PathPrefix, value: /api } + - path: { type: PathPrefix, value: /oauth2 } + grpcRoute: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-system + sectionName: https + hostnames: + - netbird.example.com + rules: + - matches: + - method: { service: signalexchange.SignalExchange } + - matches: + - method: { service: management.ManagementService } + relayHttpRoute: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-system + sectionName: https + hostnames: + - netbird.example.com + rules: + - matches: + - path: { type: PathPrefix, value: /relay } + - path: { type: PathPrefix, value: /ws-proxy } + +dashboard: + httpRoute: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-system + sectionName: https + hostnames: + - netbird.example.com + rules: + - matches: + - path: { type: PathPrefix, value: / } +``` + +Rules that omit `backendRefs` get the netbird server / dashboard `Service` +auto-filled on port 80. Specify `backendRefs` explicitly for traffic +splitting or non-default ports. + +For deployments that expose relay as a raw TCP listener (no HTTP path +matching), use `server.relayTcpRoute` — apiVersion +`gateway.networking.k8s.io/v1alpha2`. TCPRoute ships in the Gateway API +**experimental channel**; make sure its CRDs are installed +(`experimental-install.yaml` from the Gateway API release) before +enabling it. + ## STUN Networking NetBird's embedded STUN server uses **UDP port 3478**, which standard HTTP @@ -650,23 +741,55 @@ ADFS) can be tested manually: #### Server Ingress -| Key | Type | Default | Description | -| --------------------------------- | ------ | --------------- | ----------------------------------------- | -| `server.ingress.enabled` | bool | `false` | Create HTTP ingress (API + OAuth2) | -| `server.ingress.className` | string | `"nginx"` | Ingress class | -| `server.ingress.annotations` | object | `{}` | Ingress annotations | -| `server.ingress.hosts` | list | `[]` | Ingress host rules | -| `server.ingress.tls` | list | `[]` | TLS configuration | -| `server.ingressGrpc.enabled` | bool | `false` | Create gRPC ingress (Signal + Management) | -| `server.ingressGrpc.className` | string | `"nginx"` | Ingress class | -| `server.ingressGrpc.annotations` | object | see values.yaml | GRPC backend annotations | -| `server.ingressGrpc.hosts` | list | `[]` | Ingress host rules | -| `server.ingressGrpc.tls` | list | `[]` | TLS configuration | -| `server.ingressRelay.enabled` | bool | `false` | Create relay/WebSocket ingress | -| `server.ingressRelay.className` | string | `"nginx"` | Ingress class | -| `server.ingressRelay.annotations` | object | `{}` | Ingress annotations | -| `server.ingressRelay.hosts` | list | `[]` | Ingress host rules | -| `server.ingressRelay.tls` | list | `[]` | TLS configuration | +| Key | Type | Default | Description | +| --------------------------------- | ------ | --------------- | ----------------------------------------------------------------------------------------------------------- | +| `server.ingress.enabled` | bool | `false` | Create HTTP ingress (API + OAuth2). Mutually exclusive with `server.httpRoute`. | +| `server.ingress.className` | string | `"nginx"` | Ingress class | +| `server.ingress.annotations` | object | `{}` | Ingress annotations | +| `server.ingress.hosts` | list | `[]` | Ingress host rules | +| `server.ingress.tls` | list | `[]` | TLS configuration | +| `server.ingressGrpc.enabled` | bool | `false` | Create gRPC ingress (Signal + Management). Mutually exclusive with `server.grpcRoute`. | +| `server.ingressGrpc.className` | string | `"nginx"` | Ingress class | +| `server.ingressGrpc.annotations` | object | see values.yaml | GRPC backend annotations | +| `server.ingressGrpc.hosts` | list | `[]` | Ingress host rules | +| `server.ingressGrpc.tls` | list | `[]` | TLS configuration | +| `server.ingressRelay.enabled` | bool | `false` | Create relay/WebSocket ingress. Mutually exclusive with `server.relayHttpRoute` and `server.relayTcpRoute`. | +| `server.ingressRelay.className` | string | `"nginx"` | Ingress class | +| `server.ingressRelay.annotations` | object | `{}` | Ingress annotations | +| `server.ingressRelay.hosts` | list | `[]` | Ingress host rules | +| `server.ingressRelay.tls` | list | `[]` | TLS configuration | + +#### Server Gateway API routes + +Gateway API alternatives to the Ingress blocks above. Enabling both an +Ingress and its matching route block is a template-time error. TLS is +terminated at the referenced Gateway's listeners, not in these values. + +| Key | Type | Default | Description | +| ----------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------- | +| `server.httpRoute.enabled` | bool | `false` | Create `HTTPRoute` for HTTP (API + OAuth2). Requires `parentRefs`. | +| `server.httpRoute.parentRefs` | list | `[]` | Gateways to attach to (`name`, `namespace`, optional `sectionName`). | +| `server.httpRoute.hostnames` | list | `[]` | HTTPRoute hostnames | +| `server.httpRoute.rules` | list | `[]` | `HTTPRoute.spec.rules`. Omitted `backendRefs` default to server Service on port 80. | +| `server.httpRoute.annotations` | object | `{}` | Route annotations | +| `server.httpRoute.labels` | object | `{}` | Extra labels | +| `server.grpcRoute.enabled` | bool | `false` | Create `GRPCRoute` for Signal + Management. Works with plaintext h2c. | +| `server.grpcRoute.parentRefs` | list | `[]` | Gateway parent refs | +| `server.grpcRoute.hostnames` | list | `[]` | GRPCRoute hostnames | +| `server.grpcRoute.rules` | list | `[]` | `GRPCRoute.spec.rules` (method or header matches) | +| `server.grpcRoute.annotations` | object | `{}` | Route annotations | +| `server.grpcRoute.labels` | object | `{}` | Extra labels | +| `server.relayHttpRoute.enabled` | bool | `false` | Create `HTTPRoute` for relay + WebSocket (default Gateway API path). | +| `server.relayHttpRoute.parentRefs` | list | `[]` | Gateway parent refs | +| `server.relayHttpRoute.hostnames` | list | `[]` | HTTPRoute hostnames | +| `server.relayHttpRoute.rules` | list | `[]` | `HTTPRoute.spec.rules` | +| `server.relayHttpRoute.annotations` | object | `{}` | Route annotations | +| `server.relayHttpRoute.labels` | object | `{}` | Extra labels | +| `server.relayTcpRoute.enabled` | bool | `false` | Create `TCPRoute` (`v1alpha2`) for raw-TCP relay listeners. | +| `server.relayTcpRoute.parentRefs` | list | `[]` | Gateway parent refs | +| `server.relayTcpRoute.rules` | list | `[]` | `TCPRoute.spec.rules`. Defaults to a single rule targeting server Service on port 80. | +| `server.relayTcpRoute.annotations` | object | `{}` | Route annotations | +| `server.relayTcpRoute.labels` | object | `{}` | Extra labels | #### Server Pod @@ -725,15 +848,21 @@ ADFS) can be tested manually: #### Dashboard Networking -| Key | Type | Default | Description | -| ------------------------------- | ------ | ------------- | ------------------------ | -| `dashboard.service.type` | string | `"ClusterIP"` | Dashboard service type | -| `dashboard.service.port` | int | `80` | Dashboard service port | -| `dashboard.ingress.enabled` | bool | `false` | Create dashboard ingress | -| `dashboard.ingress.className` | string | `"nginx"` | Ingress class | -| `dashboard.ingress.annotations` | object | `{}` | Ingress annotations | -| `dashboard.ingress.hosts` | list | `[]` | Ingress host rules | -| `dashboard.ingress.tls` | list | `[]` | TLS configuration | +| Key | Type | Default | Description | +| --------------------------------- | ------ | ------------- | -------------------------------------------------------------------------------------- | +| `dashboard.service.type` | string | `"ClusterIP"` | Dashboard service type | +| `dashboard.service.port` | int | `80` | Dashboard service port | +| `dashboard.ingress.enabled` | bool | `false` | Create dashboard ingress. Mutually exclusive with `dashboard.httpRoute`. | +| `dashboard.ingress.className` | string | `"nginx"` | Ingress class | +| `dashboard.ingress.annotations` | object | `{}` | Ingress annotations | +| `dashboard.ingress.hosts` | list | `[]` | Ingress host rules | +| `dashboard.ingress.tls` | list | `[]` | TLS configuration | +| `dashboard.httpRoute.enabled` | bool | `false` | Create Gateway API `HTTPRoute` for the dashboard. Requires `parentRefs`. | +| `dashboard.httpRoute.parentRefs` | list | `[]` | Gateways to attach to | +| `dashboard.httpRoute.hostnames` | list | `[]` | HTTPRoute hostnames | +| `dashboard.httpRoute.rules` | list | `[]` | `HTTPRoute.spec.rules`. Omitted `backendRefs` default to dashboard Service on port 80. | +| `dashboard.httpRoute.annotations` | object | `{}` | Route annotations | +| `dashboard.httpRoute.labels` | object | `{}` | Extra labels | #### Dashboard Pod @@ -753,21 +882,28 @@ ADFS) can be tested manually: ## Architecture ``` -┌──────────────────────────────────────────────────────────┐ -│ Ingress Controller │ -│ │ -│ /api, /oauth2 ──────────┐ │ -│ /signalexchange/*, /management/* ──► Server Pod :80 │ -│ /relay, /ws-proxy ──────┘ (Management + Signal │ -│ + Relay combined) │ -│ / ──────────────────────────────► Dashboard Pod :80 │ -└──────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ Ingress Controller ─or─ Gateway API Gateway │ +│ │ +│ /api, /oauth2 ─────┐ HTTPRoute ──► Server Pod :80 │ +│ /signalexchange/*, /management/* GRPCRoute │ +│ ├─────────────► Server Pod :80 │ +│ /relay, /ws-proxy ─┘ HTTPRoute / TCPRoute │ +│ │ +│ / ──────────────────── HTTPRoute ──► Dashboard Pod :80 │ +└─────────────────────────────────────────────────────────────┘ │ STUN Service :3478/UDP (LoadBalancer — separate IP, - cannot use HTTP Ingress) + cannot use HTTP Ingress/Gateway) ``` +Each traffic class picks **either** an Ingress **or** a Gateway API route, +independently, via `server.ingress{,Grpc,Relay}` / `server.httpRoute` / +`server.grpcRoute` / `server.relayHttpRoute` / `server.relayTcpRoute` and +`dashboard.ingress` / `dashboard.httpRoute`. Enabling both for the same +class is rejected at template time. + ## Upstream Source This chart is based on the [NetBird](https://github.com/netbirdio/netbird) project. See the `sources` field in `Chart.yaml` for details. diff --git a/charts/netbird/ci/e2e-values-gateway.yaml b/charts/netbird/ci/e2e-values-gateway.yaml new file mode 100644 index 0000000..ab425a8 --- /dev/null +++ b/charts/netbird/ci/e2e-values-gateway.yaml @@ -0,0 +1,92 @@ +# E2E values for exercising Gateway API support on a kind cluster with +# Envoy Gateway. Mirrors the sqlite-backend minimal config but swaps the +# four Ingress blocks for their Gateway API counterparts. + +database: + type: sqlite + +server: + persistentVolume: + enabled: false + + stunService: + type: ClusterIP + + config: + exposedAddress: "http://netbird-gateway-e2e-server.netbird-gateway-e2e.svc.cluster.local:80" + auth: + issuer: "https://auth.localhost" + dashboardRedirectURIs: + - "https://netbird.localhost/nb-auth" + - "https://netbird.localhost/nb-silent-auth" + + livenessProbe: + failureThreshold: 10 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + readinessProbe: + failureThreshold: 10 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + + httpRoute: + enabled: true + parentRefs: + - name: netbird-gateway + sectionName: http + hostnames: + - netbird.localhost + rules: + - matches: + - path: { type: PathPrefix, value: /api } + - path: { type: PathPrefix, value: /oauth2 } + + grpcRoute: + enabled: true + parentRefs: + - name: netbird-gateway + sectionName: http + hostnames: + - netbird.localhost + rules: + - matches: + - method: { service: signalexchange.SignalExchange } + - matches: + - method: { service: management.ManagementService } + + relayHttpRoute: + enabled: true + parentRefs: + - name: netbird-gateway + sectionName: http + hostnames: + - netbird.localhost + rules: + - matches: + - path: { type: PathPrefix, value: /relay } + - path: { type: PathPrefix, value: /ws-proxy } + +dashboard: + config: + mgmtApiEndpoint: "https://netbird.localhost" + mgmtGrpcApiEndpoint: "https://netbird.localhost" + authAuthority: "https://auth.localhost" + authClientId: "test-client" + authAudience: "test-audience" + + httpRoute: + enabled: true + parentRefs: + - name: netbird-gateway + sectionName: http + hostnames: + - netbird.localhost + rules: + - matches: + - path: { type: PathPrefix, value: / } diff --git a/charts/netbird/templates/_helpers.tpl b/charts/netbird/templates/_helpers.tpl index f0e21c8..804405f 100644 --- a/charts/netbird/templates/_helpers.tpl +++ b/charts/netbird/templates/_helpers.tpl @@ -127,6 +127,40 @@ here to keep `helm template` usable for partial inspection). {{- end -}} {{- end }} +{{/* +netbird.validate.routeExclusion — enforce mutual exclusion between the +Kubernetes Ingress and the Gateway API route for each traffic class. Both +resources would otherwise claim the same paths/hostnames and silently +create duplicate or racing rules. +*/}} +{{- define "netbird.validate.routeExclusion" -}} +{{- if and .Values.server.ingress.enabled .Values.server.httpRoute.enabled -}} + {{- fail "server.ingress.enabled and server.httpRoute.enabled are mutually exclusive — pick Kubernetes Ingress or Gateway API HTTPRoute for server HTTP traffic." -}} +{{- end -}} +{{- if and .Values.server.ingressGrpc.enabled .Values.server.grpcRoute.enabled -}} + {{- fail "server.ingressGrpc.enabled and server.grpcRoute.enabled are mutually exclusive — pick Kubernetes Ingress or Gateway API GRPCRoute for server gRPC traffic." -}} +{{- end -}} +{{- if and .Values.server.ingressRelay.enabled (or .Values.server.relayHttpRoute.enabled .Values.server.relayTcpRoute.enabled) -}} + {{- fail "server.ingressRelay.enabled conflicts with server.relayHttpRoute/relayTcpRoute — pick exactly one route type for relay/WebSocket traffic." -}} +{{- end -}} +{{- if and .Values.server.relayHttpRoute.enabled .Values.server.relayTcpRoute.enabled -}} + {{- fail "server.relayHttpRoute.enabled and server.relayTcpRoute.enabled are mutually exclusive — pick HTTPRoute or TCPRoute, not both." -}} +{{- end -}} +{{- if and .Values.dashboard.ingress.enabled .Values.dashboard.httpRoute.enabled -}} + {{- fail "dashboard.ingress.enabled and dashboard.httpRoute.enabled are mutually exclusive — pick Kubernetes Ingress or Gateway API HTTPRoute for the dashboard." -}} +{{- end -}} +{{- range $path := list "server.httpRoute" "server.grpcRoute" "server.relayHttpRoute" "server.relayTcpRoute" "dashboard.httpRoute" -}} + {{- $parts := splitList "." $path -}} + {{- $block := index $.Values (index $parts 0) (index $parts 1) -}} + {{- if and $block.enabled (not $block.parentRefs) -}} + {{- fail (printf "%s.enabled is true but %s.parentRefs is empty — Gateway API routes must reference at least one Gateway." $path $path) -}} + {{- end -}} +{{- end -}} +{{- if and .Values.server.ingressGrpc.enabled (not .Values.server.ingressGrpc.tls) -}} + {{- fail "server.ingressGrpc.enabled is true but server.ingressGrpc.tls is empty. gRPC over Kubernetes Ingress requires TLS: standard nginx-ingress cannot negotiate HTTP/2 cleartext (h2c) and the default `nginx.ingress.kubernetes.io/ssl-redirect: \"true\"` annotation redirects plaintext gRPC to HTTPS — without a cert, requests fail silently. Either configure server.ingressGrpc.tls, or disable server.ingressGrpc and expose gRPC via server.grpcRoute (Gateway API) with a controller that supports plaintext h2c." -}} +{{- end -}} +{{- end }} + {{/* netbird.server.generatedSecretName — name of the auto-generated Secret. */}} diff --git a/charts/netbird/templates/dashboard-httproute.yaml b/charts/netbird/templates/dashboard-httproute.yaml new file mode 100644 index 0000000..4b198f9 --- /dev/null +++ b/charts/netbird/templates/dashboard-httproute.yaml @@ -0,0 +1,36 @@ +{{- if .Values.dashboard.httpRoute.enabled }} +{{- $serviceName := include "netbird.dashboard.fullname" . }} +{{- $rules := list }} +{{- range .Values.dashboard.httpRoute.rules }} + {{- $rule := deepCopy . }} + {{- if not (hasKey $rule "backendRefs") }} + {{- $_ := set $rule "backendRefs" (list (dict "name" $serviceName "port" 80)) }} + {{- end }} + {{- $rules = append $rules $rule }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $serviceName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "netbird.dashboard.labels" . | nindent 4 }} + {{- with .Values.dashboard.httpRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.dashboard.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.dashboard.httpRoute.parentRefs | nindent 4 }} + {{- with .Values.dashboard.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $rules }} + rules: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/netbird/templates/server-configmap.yaml b/charts/netbird/templates/server-configmap.yaml index 907dfc1..5eb6704 100644 --- a/charts/netbird/templates/server-configmap.yaml +++ b/charts/netbird/templates/server-configmap.yaml @@ -1,4 +1,5 @@ {{- include "netbird.validate.exposedAddress" . -}} +{{- include "netbird.validate.routeExclusion" . -}} apiVersion: v1 kind: ConfigMap metadata: diff --git a/charts/netbird/templates/server-grpcroute.yaml b/charts/netbird/templates/server-grpcroute.yaml new file mode 100644 index 0000000..d85b795 --- /dev/null +++ b/charts/netbird/templates/server-grpcroute.yaml @@ -0,0 +1,36 @@ +{{- if .Values.server.grpcRoute.enabled }} +{{- $serviceName := include "netbird.server.fullname" . }} +{{- $rules := list }} +{{- range .Values.server.grpcRoute.rules }} + {{- $rule := deepCopy . }} + {{- if not (hasKey $rule "backendRefs") }} + {{- $_ := set $rule "backendRefs" (list (dict "name" $serviceName "port" 80)) }} + {{- end }} + {{- $rules = append $rules $rule }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: {{ $serviceName }}-grpc + namespace: {{ .Release.Namespace }} + labels: + {{- include "netbird.server.labels" . | nindent 4 }} + {{- with .Values.server.grpcRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.server.grpcRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.server.grpcRoute.parentRefs | nindent 4 }} + {{- with .Values.server.grpcRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $rules }} + rules: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/netbird/templates/server-httproute.yaml b/charts/netbird/templates/server-httproute.yaml new file mode 100644 index 0000000..945968d --- /dev/null +++ b/charts/netbird/templates/server-httproute.yaml @@ -0,0 +1,36 @@ +{{- if .Values.server.httpRoute.enabled }} +{{- $serviceName := include "netbird.server.fullname" . }} +{{- $rules := list }} +{{- range .Values.server.httpRoute.rules }} + {{- $rule := deepCopy . }} + {{- if not (hasKey $rule "backendRefs") }} + {{- $_ := set $rule "backendRefs" (list (dict "name" $serviceName "port" 80)) }} + {{- end }} + {{- $rules = append $rules $rule }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $serviceName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "netbird.server.labels" . | nindent 4 }} + {{- with .Values.server.httpRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.server.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.server.httpRoute.parentRefs | nindent 4 }} + {{- with .Values.server.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $rules }} + rules: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/netbird/templates/server-relay-httproute.yaml b/charts/netbird/templates/server-relay-httproute.yaml new file mode 100644 index 0000000..415df79 --- /dev/null +++ b/charts/netbird/templates/server-relay-httproute.yaml @@ -0,0 +1,36 @@ +{{- if .Values.server.relayHttpRoute.enabled }} +{{- $serviceName := include "netbird.server.fullname" . }} +{{- $rules := list }} +{{- range .Values.server.relayHttpRoute.rules }} + {{- $rule := deepCopy . }} + {{- if not (hasKey $rule "backendRefs") }} + {{- $_ := set $rule "backendRefs" (list (dict "name" $serviceName "port" 80)) }} + {{- end }} + {{- $rules = append $rules $rule }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $serviceName }}-relay + namespace: {{ .Release.Namespace }} + labels: + {{- include "netbird.server.labels" . | nindent 4 }} + {{- with .Values.server.relayHttpRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.server.relayHttpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.server.relayHttpRoute.parentRefs | nindent 4 }} + {{- with .Values.server.relayHttpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $rules }} + rules: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/netbird/templates/server-relay-tcproute.yaml b/charts/netbird/templates/server-relay-tcproute.yaml new file mode 100644 index 0000000..8bff12f --- /dev/null +++ b/charts/netbird/templates/server-relay-tcproute.yaml @@ -0,0 +1,33 @@ +{{- if .Values.server.relayTcpRoute.enabled }} +{{- $serviceName := include "netbird.server.fullname" . }} +{{- $rules := list }} +{{- range .Values.server.relayTcpRoute.rules }} + {{- $rule := deepCopy . }} + {{- if not (hasKey $rule "backendRefs") }} + {{- $_ := set $rule "backendRefs" (list (dict "name" $serviceName "port" 80)) }} + {{- end }} + {{- $rules = append $rules $rule }} +{{- end }} +{{- if not $rules }} + {{- $rules = list (dict "backendRefs" (list (dict "name" $serviceName "port" 80))) }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: + name: {{ $serviceName }}-relay-tcp + namespace: {{ .Release.Namespace }} + labels: + {{- include "netbird.server.labels" . | nindent 4 }} + {{- with .Values.server.relayTcpRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.server.relayTcpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.server.relayTcpRoute.parentRefs | nindent 4 }} + rules: + {{- toYaml $rules | nindent 4 }} +{{- end }} diff --git a/charts/netbird/tests/dashboard-httproute_test.yaml b/charts/netbird/tests/dashboard-httproute_test.yaml new file mode 100644 index 0000000..ccd28ad --- /dev/null +++ b/charts/netbird/tests/dashboard-httproute_test.yaml @@ -0,0 +1,50 @@ +suite: dashboard HTTPRoute tests +templates: + - templates/dashboard-httproute.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render HTTPRoute when enabled + set: + dashboard.httpRoute.enabled: true + dashboard.httpRoute.parentRefs: + - name: gw + dashboard.httpRoute.rules: + - matches: + - path: { type: PathPrefix, value: / } + asserts: + - hasDocuments: + count: 1 + - isKind: + of: HTTPRoute + - isAPIVersion: + of: gateway.networking.k8s.io/v1 + + - it: should carry dashboard component label + set: + dashboard.httpRoute.enabled: true + dashboard.httpRoute.parentRefs: + - name: gw + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: dashboard + + - it: should auto-fill backendRefs to dashboard service when omitted + set: + dashboard.httpRoute.enabled: true + dashboard.httpRoute.parentRefs: + - name: gw + dashboard.httpRoute.rules: + - matches: + - path: { type: PathPrefix, value: / } + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-netbird-dashboard + - equal: + path: spec.rules[0].backendRefs[0].port + value: 80 diff --git a/charts/netbird/tests/server-configmap_test.yaml b/charts/netbird/tests/server-configmap_test.yaml index c972234..aaf1d1b 100644 --- a/charts/netbird/tests/server-configmap_test.yaml +++ b/charts/netbird/tests/server-configmap_test.yaml @@ -368,3 +368,90 @@ tests: - matchRegex: path: data["config.yaml.tpl"] pattern: 'authAudience: "my\$\{DOLLAR\}api"' + + # ── Route mutual-exclusion validation ───────────────────────────────── + + - it: should fail when server ingress and httpRoute are both enabled + set: + server.ingress.enabled: true + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: gw + asserts: + - failedTemplate: + errorPattern: "server.ingress.enabled and server.httpRoute.enabled are mutually exclusive" + + - it: should fail when server ingressGrpc and grpcRoute are both enabled + set: + server.ingressGrpc.enabled: true + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: gw + asserts: + - failedTemplate: + errorPattern: "server.ingressGrpc.enabled and server.grpcRoute.enabled are mutually exclusive" + + - it: should fail when server ingressRelay and relayHttpRoute are both enabled + set: + server.ingressRelay.enabled: true + server.relayHttpRoute.enabled: true + server.relayHttpRoute.parentRefs: + - name: gw + asserts: + - failedTemplate: + errorPattern: "conflicts with server.relayHttpRoute/relayTcpRoute" + + - it: should fail when server relayHttpRoute and relayTcpRoute are both enabled + set: + server.relayHttpRoute.enabled: true + server.relayHttpRoute.parentRefs: + - name: gw + server.relayTcpRoute.enabled: true + server.relayTcpRoute.parentRefs: + - name: gw + asserts: + - failedTemplate: + errorPattern: "server.relayHttpRoute.enabled and server.relayTcpRoute.enabled are mutually exclusive" + + - it: should fail when dashboard ingress and httpRoute are both enabled + set: + dashboard.ingress.enabled: true + dashboard.httpRoute.enabled: true + dashboard.httpRoute.parentRefs: + - name: gw + asserts: + - failedTemplate: + errorPattern: "dashboard.ingress.enabled and dashboard.httpRoute.enabled are mutually exclusive" + + - it: should fail when httpRoute enabled without parentRefs + set: + server.httpRoute.enabled: true + asserts: + - failedTemplate: + errorPattern: "server.httpRoute.parentRefs is empty" + + - it: should fail when ingressGrpc is enabled without TLS + set: + server.ingressGrpc.enabled: true + server.ingressGrpc.hosts: + - host: netbird.example.com + paths: + - path: /signalexchange.SignalExchange + asserts: + - failedTemplate: + errorPattern: "server.ingressGrpc.tls is empty" + + - it: should accept ingressGrpc when TLS is configured + set: + server.ingressGrpc.enabled: true + server.ingressGrpc.hosts: + - host: netbird.example.com + paths: + - path: /signalexchange.SignalExchange + server.ingressGrpc.tls: + - secretName: netbird-tls + hosts: + - netbird.example.com + asserts: + - hasDocuments: + count: 1 diff --git a/charts/netbird/tests/server-grpcroute_test.yaml b/charts/netbird/tests/server-grpcroute_test.yaml new file mode 100644 index 0000000..6fcae81 --- /dev/null +++ b/charts/netbird/tests/server-grpcroute_test.yaml @@ -0,0 +1,88 @@ +suite: server GRPCRoute tests +templates: + - templates/server-grpcroute.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render GRPCRoute when enabled + set: + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: my-gateway + server.grpcRoute.rules: + - matches: + - method: { service: signalexchange.SignalExchange } + asserts: + - hasDocuments: + count: 1 + - isKind: + of: GRPCRoute + - isAPIVersion: + of: gateway.networking.k8s.io/v1 + + - it: should have -grpc suffix in name + set: + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: gw + asserts: + - matchRegex: + path: metadata.name + pattern: "-grpc$" + + - it: should pass method matches through + set: + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: gw + server.grpcRoute.rules: + - matches: + - method: { service: signalexchange.SignalExchange } + - matches: + - method: { service: management.ManagementService } + asserts: + - equal: + path: spec.rules[0].matches[0].method.service + value: signalexchange.SignalExchange + - equal: + path: spec.rules[1].matches[0].method.service + value: management.ManagementService + + - it: should auto-fill backendRefs when omitted + set: + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: gw + server.grpcRoute.rules: + - matches: + - method: { service: signalexchange.SignalExchange } + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-netbird-server + - equal: + path: spec.rules[0].backendRefs[0].port + value: 80 + + - it: should support header-based matches (traefik-style) + set: + server.grpcRoute.enabled: true + server.grpcRoute.parentRefs: + - name: traefik-gateway + namespace: kube-system + sectionName: web + server.grpcRoute.hostnames: + - vpn.example.com + server.grpcRoute.rules: + - matches: + - headers: + - type: RegularExpression + name: Content-Type + value: "application/grpc.*" + asserts: + - equal: + path: spec.rules[0].matches[0].headers[0].name + value: Content-Type diff --git a/charts/netbird/tests/server-httproute_test.yaml b/charts/netbird/tests/server-httproute_test.yaml new file mode 100644 index 0000000..a0e66c0 --- /dev/null +++ b/charts/netbird/tests/server-httproute_test.yaml @@ -0,0 +1,117 @@ +suite: server HTTPRoute tests +templates: + - templates/server-httproute.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render HTTPRoute when enabled + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: my-gateway + namespace: gateway-system + server.httpRoute.hostnames: + - netbird.example.com + server.httpRoute.rules: + - matches: + - path: { type: PathPrefix, value: /api } + asserts: + - hasDocuments: + count: 1 + - isKind: + of: HTTPRoute + - isAPIVersion: + of: gateway.networking.k8s.io/v1 + + - it: should pass parentRefs through + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: my-gateway + namespace: gateway-system + sectionName: https + asserts: + - equal: + path: spec.parentRefs[0].name + value: my-gateway + - equal: + path: spec.parentRefs[0].namespace + value: gateway-system + - equal: + path: spec.parentRefs[0].sectionName + value: https + + - it: should pass hostnames through + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: gw + server.httpRoute.hostnames: + - netbird.example.com + - netbird.example.org + asserts: + - contains: + path: spec.hostnames + content: netbird.example.com + - contains: + path: spec.hostnames + content: netbird.example.org + + - it: should auto-fill backendRefs when omitted + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: gw + server.httpRoute.rules: + - matches: + - path: { type: PathPrefix, value: /api } + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-netbird-server + - equal: + path: spec.rules[0].backendRefs[0].port + value: 80 + + - it: should pass user backendRefs through unchanged + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: gw + server.httpRoute.rules: + - matches: + - path: { type: PathPrefix, value: /api } + backendRefs: + - name: custom-svc + port: 8080 + weight: 100 + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: custom-svc + - equal: + path: spec.rules[0].backendRefs[0].port + value: 8080 + - equal: + path: spec.rules[0].backendRefs[0].weight + value: 100 + + - it: should render annotations and labels + set: + server.httpRoute.enabled: true + server.httpRoute.parentRefs: + - name: gw + server.httpRoute.annotations: + foo: bar + server.httpRoute.labels: + team: netbird + asserts: + - equal: + path: metadata.annotations.foo + value: bar + - equal: + path: metadata.labels.team + value: netbird diff --git a/charts/netbird/tests/server-relay-httproute_test.yaml b/charts/netbird/tests/server-relay-httproute_test.yaml new file mode 100644 index 0000000..a241cdd --- /dev/null +++ b/charts/netbird/tests/server-relay-httproute_test.yaml @@ -0,0 +1,49 @@ +suite: server relay HTTPRoute tests +templates: + - templates/server-relay-httproute.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render HTTPRoute when enabled + set: + server.relayHttpRoute.enabled: true + server.relayHttpRoute.parentRefs: + - name: gw + server.relayHttpRoute.rules: + - matches: + - path: { type: PathPrefix, value: /relay } + asserts: + - hasDocuments: + count: 1 + - isKind: + of: HTTPRoute + + - it: should have -relay suffix in name + set: + server.relayHttpRoute.enabled: true + server.relayHttpRoute.parentRefs: + - name: gw + asserts: + - matchRegex: + path: metadata.name + pattern: "-relay$" + + - it: should pass path matches through + set: + server.relayHttpRoute.enabled: true + server.relayHttpRoute.parentRefs: + - name: gw + server.relayHttpRoute.rules: + - matches: + - path: { type: PathPrefix, value: /relay } + - path: { type: PathPrefix, value: /ws-proxy } + asserts: + - equal: + path: spec.rules[0].matches[0].path.value + value: /relay + - equal: + path: spec.rules[0].matches[1].path.value + value: /ws-proxy diff --git a/charts/netbird/tests/server-relay-tcproute_test.yaml b/charts/netbird/tests/server-relay-tcproute_test.yaml new file mode 100644 index 0000000..cb7e49e --- /dev/null +++ b/charts/netbird/tests/server-relay-tcproute_test.yaml @@ -0,0 +1,61 @@ +suite: server relay TCPRoute tests +templates: + - templates/server-relay-tcproute.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render TCPRoute when enabled + set: + server.relayTcpRoute.enabled: true + server.relayTcpRoute.parentRefs: + - name: gw + asserts: + - hasDocuments: + count: 1 + - isKind: + of: TCPRoute + - isAPIVersion: + of: gateway.networking.k8s.io/v1alpha2 + + - it: should have -relay-tcp suffix in name + set: + server.relayTcpRoute.enabled: true + server.relayTcpRoute.parentRefs: + - name: gw + asserts: + - matchRegex: + path: metadata.name + pattern: "-relay-tcp$" + + - it: should default to a single backend rule when rules omitted + set: + server.relayTcpRoute.enabled: true + server.relayTcpRoute.parentRefs: + - name: gw + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-netbird-server + - equal: + path: spec.rules[0].backendRefs[0].port + value: 80 + + - it: should pass user backendRefs through + set: + server.relayTcpRoute.enabled: true + server.relayTcpRoute.parentRefs: + - name: gw + server.relayTcpRoute.rules: + - backendRefs: + - name: custom + port: 9000 + asserts: + - equal: + path: spec.rules[0].backendRefs[0].name + value: custom + - equal: + path: spec.rules[0].backendRefs[0].port + value: 9000 diff --git a/charts/netbird/values.yaml b/charts/netbird/values.yaml index 24cdd14..e22da3b 100644 --- a/charts/netbird/values.yaml +++ b/charts/netbird/values.yaml @@ -424,6 +424,7 @@ server: annotations: {} # -- Ingress for HTTP routes (API + OAuth2). + # Mutually exclusive with server.httpRoute. ingress: enabled: false className: "nginx" @@ -431,7 +432,32 @@ server: hosts: [] tls: [] + # -- Gateway API HTTPRoute for HTTP routes (API + OAuth2). + # Mutually exclusive with server.ingress. TLS is terminated at the Gateway + # listener, so no tls block here. parentRefs and hostnames are required + # when enabled. rules pass through verbatim to HTTPRoute.spec.rules; + # backendRefs are auto-filled from the netbird server Service when a rule + # omits them. + httpRoute: + enabled: false + parentRefs: [] + # - name: my-gateway + # namespace: gateway-system + # sectionName: https + hostnames: [] + rules: [] + # - matches: + # - path: { type: PathPrefix, value: /api } + # - path: { type: PathPrefix, value: /oauth2 } + annotations: {} + labels: {} + # -- Ingress for gRPC routes (signal + management gRPC). + # Mutually exclusive with server.grpcRoute. Requires TLS: standard + # nginx-ingress cannot negotiate HTTP/2 cleartext (h2c) and + # `ssl-redirect: "true"` (set by default in this block) will redirect + # plaintext gRPC to HTTPS. For plaintext h2c, use server.grpcRoute with a + # Gateway API controller that supports it (Envoy Gateway, Traefik, …). ingressGrpc: enabled: false className: "nginx" @@ -443,7 +469,24 @@ server: hosts: [] tls: [] + # -- Gateway API GRPCRoute for gRPC routes (signal + management). + # Mutually exclusive with server.ingressGrpc. Gateway controllers forward + # gRPC over HTTP/2 — including plaintext h2c — so this works without TLS + # when the Gateway listener is HTTP. rules pass through to + # GRPCRoute.spec.rules; backendRefs are auto-filled when omitted. + grpcRoute: + enabled: false + parentRefs: [] + hostnames: [] + rules: [] + # - matches: + # - method: { service: signalexchange.SignalExchange } + # - method: { service: management.ManagementService } + annotations: {} + labels: {} + # -- Ingress for relay and WebSocket routes. + # Mutually exclusive with server.relayHttpRoute and server.relayTcpRoute. ingressRelay: enabled: false className: "nginx" @@ -451,6 +494,35 @@ server: hosts: [] tls: [] + # -- Gateway API HTTPRoute for relay + WebSocket. + # Default choice for Gateway API — WebSocket uses HTTP/1.1 Upgrade which + # HTTPRoute handles natively. Mutually exclusive with server.ingressRelay + # and server.relayTcpRoute. + relayHttpRoute: + enabled: false + parentRefs: [] + hostnames: [] + rules: [] + # - matches: + # - path: { type: PathPrefix, value: /relay } + # - path: { type: PathPrefix, value: /ws-proxy } + annotations: {} + labels: {} + + # -- Gateway API TCPRoute for relay exposed as raw TCP. + # Alternative to relayHttpRoute for deployments that front relay with a + # dedicated TCP listener (no HTTP path matching). TCPRoute is v1alpha2. + # Mutually exclusive with server.ingressRelay and server.relayHttpRoute. + relayTcpRoute: + enabled: false + parentRefs: [] + rules: [] + # - backendRefs: + # - name: + # port: 80 + annotations: {} + labels: {} + # -- CPU/memory resource requests and limits. resources: {} nodeSelector: {} @@ -513,6 +585,8 @@ dashboard: type: ClusterIP port: 80 + # -- Ingress for the dashboard web UI. + # Mutually exclusive with dashboard.httpRoute. ingress: enabled: false className: "nginx" @@ -520,6 +594,20 @@ dashboard: hosts: [] tls: [] + # -- Gateway API HTTPRoute for the dashboard web UI. + # Mutually exclusive with dashboard.ingress. TLS is terminated at the + # Gateway listener. rules pass through; backendRefs auto-filled when + # omitted. + httpRoute: + enabled: false + parentRefs: [] + hostnames: [] + rules: [] + # - matches: + # - path: { type: PathPrefix, value: / } + annotations: {} + labels: {} + resources: {} nodeSelector: {} tolerations: [] diff --git a/ci/scripts/netbird/e2e-gateway.sh b/ci/scripts/netbird/e2e-gateway.sh new file mode 100755 index 0000000..52151d4 --- /dev/null +++ b/ci/scripts/netbird/e2e-gateway.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# E2E test for the netbird chart's Gateway API support. +# +# Installs Gateway API CRDs + Envoy Gateway on the kind cluster, provisions +# a Gateway, and verifies the chart's HTTPRoute/GRPCRoute resources attach +# successfully (Accepted=True) with the correct backend references. +# +# Scope: route integration only. The full peer-registration/PAT flow is +# already covered by ci/scripts/netbird/e2e.sh — this test focuses on the +# Gateway API surface added in #74. +# +set -euo pipefail + +RELEASE="netbird-gateway-e2e" +NAMESPACE="netbird-gateway-e2e" +CHART="charts/netbird" +VALUES_FILE="$CHART/ci/e2e-values-gateway.yaml" +TIMEOUT="10m" + +# Pinned versions so CI is reproducible. Envoy Gateway bundles the Gateway +# API CRDs (standard + experimental channels, including TCPRoute), so we +# don't install them separately — doing both causes field-manager conflicts +# during EG's server-side apply. +ENVOY_GATEWAY_VERSION="v1.5.3" +ENVOY_NS="envoy-gateway-system" + +log() { echo "==> $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +cleanup() { + local rc=$? + if [ $rc -ne 0 ]; then + log "Exit $rc — skipping cleanup so CI can collect debug info." + return + fi + log "Cleaning up..." + helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found 2>/dev/null || true + kubectl delete namespace "$NAMESPACE" --ignore-not-found 2>/dev/null || true + # Leave Envoy Gateway + CRDs installed to speed up re-runs on the same + # kind cluster; the kind cluster itself is torn down by CI. +} +trap cleanup EXIT + +# ── Install Envoy Gateway (also installs Gateway API CRDs) ──────────── +log "Installing Envoy Gateway ($ENVOY_GATEWAY_VERSION)..." +helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm \ + --version "$ENVOY_GATEWAY_VERSION" \ + -n "$ENVOY_NS" \ + --create-namespace \ + --wait --timeout 5m + +# ── Create netbird namespace ────────────────────────────────────────── +log "Creating namespace $NAMESPACE..." +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + +# ── Provision a Gateway for the routes to attach to ─────────────────── +log "Applying GatewayClass + Gateway..." +kubectl apply -f - <0: r.append(BASE62[n%62]); n//=62 + return ''.join(reversed(r)).rjust(l,'0') +s='TestSecretValue1234567890ABCDE' +print('nbp_'+s+b62(binascii.crc32(s.encode())&0xFFFFFFFF)) +") + kubectl -n "$NAMESPACE" create secret generic netbird-pat \ + --from-literal=token="$PAT_TOKEN" +} +generate_pat_secret + +# ── Install netbird chart ───────────────────────────────────────────── +log "Installing netbird chart with Gateway API routes enabled..." +if ! helm install "$RELEASE" "$CHART" \ + -n "$NAMESPACE" \ + -f "$VALUES_FILE" \ + --set pat.enabled=true \ + --set pat.secret.secretName=netbird-pat \ + --set server.persistentVolume.enabled=true \ + --timeout "$TIMEOUT"; then + log "Helm install failed — dumping logs..." + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-server -c pat-seed 2>/dev/null || true + fail "Helm install failed" +fi + +log "Verifying deployments..." +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-server --timeout=300s +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-dashboard --timeout=120s + +# ── Verify the routes attached to the Gateway ───────────────────────── +wait_route_accepted() { + local kind="$1" name="$2" + log "Waiting for $kind/$name to report Accepted=True..." + for _ in $(seq 1 30); do + # Route status uses parents[].conditions[type=Accepted]. + local accepted + accepted=$(kubectl -n "$NAMESPACE" get "$kind" "$name" \ + -o jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}' 2>/dev/null || true) + if [ "$accepted" = "True" ]; then + log " $kind/$name Accepted=True" + return 0 + fi + sleep 3 + done + log "$kind/$name did NOT reach Accepted=True. Full status:" + kubectl -n "$NAMESPACE" get "$kind" "$name" -o yaml | sed -n '/^status:/,$p' || true + fail "$kind/$name was not accepted by Gateway" +} + +wait_route_accepted httproute "$RELEASE-server" +wait_route_accepted httproute "$RELEASE-server-relay" +wait_route_accepted httproute "$RELEASE-dashboard" +wait_route_accepted grpcroute "$RELEASE-server-grpc" + +# ── Confirm backendRefs auto-filled correctly ──────────────────────── +assert_backend_ref() { + local kind="$1" name="$2" expected_svc="$3" + local svc + svc=$(kubectl -n "$NAMESPACE" get "$kind" "$name" \ + -o jsonpath='{.spec.rules[0].backendRefs[0].name}') + if [ "$svc" != "$expected_svc" ]; then + fail "$kind/$name backendRefs[0].name = '$svc' (expected '$expected_svc')" + fi + log " $kind/$name backendRefs[0].name = $svc ✓" +} + +assert_backend_ref httproute "$RELEASE-server" "$RELEASE-server" +assert_backend_ref httproute "$RELEASE-server-relay" "$RELEASE-server" +assert_backend_ref httproute "$RELEASE-dashboard" "$RELEASE-dashboard" +assert_backend_ref grpcroute "$RELEASE-server-grpc" "$RELEASE-server" + +# ── Confirm mutual-exclusion validation trips ──────────────────────── +log "Verifying template validation: enabling Ingress + HTTPRoute should fail..." +if helm template "$RELEASE" "$CHART" \ + -f "$VALUES_FILE" \ + --set server.ingress.enabled=true > /dev/null 2>&1; then + fail "Template rendered with both Ingress and HTTPRoute enabled — mutual-exclusion check missing" +fi +log " mutual-exclusion validation correctly rejects the combination ✓" + +log "E2E Gateway API test PASSED — routes accepted, backendRefs auto-filled, exclusion validated."