diff --git a/README.md b/README.md
index 44ddf08..515df0f 100644
--- a/README.md
+++ b/README.md
@@ -57,8 +57,10 @@ applies the additions/updates with [Server-Side Apply], and deletes orphans.
| `middlewares.addPrefix` | HTTPRoute filter `URLRewrite{path.ReplacePrefixMatch}` |
| `middlewares.replacePath` | HTTPRoute filter `URLRewrite{path.ReplaceFullPath}` |
| `middlewares.replacePathRegex` | not in core Gateway API – logged + skipped |
-| `middlewares.plugin.badger` | pangolin's auth plugin – configure via Envoy Gateway `SecurityPolicy` instead |
-| `tcp.*` / `udp.*` | not yet handled (planned: `TCPRoute` / `UDPRoute`) |
+| `middlewares.plugin.badger` | when `CONFIG_BADGER_EXT_AUTH=true`: per-route Envoy Gateway `SecurityPolicy.extAuth`; otherwise logged + skipped |
+| Pangolin dashboard host | optional static `HTTPRoute`s for Next.js (`:3002`) and API/WebSocket (`:3000`) when `CONFIG_PANGOLIN_DASHBOARD_HOST` is set |
+| Gerbil UDP ports | optional `UDPRoute`s + UDP listeners when `CONFIG_GERBIL_UDP_ROUTE=true` |
+| `tcp.*` / other `udp.*` dynamic config | logged + skipped |
Unsupported rule constructs (`||` disjunction, `!` negation, `HostRegexp`,
`Method`, `Headers`, …) cause the affected router to be **logged and skipped**
@@ -81,6 +83,83 @@ direct `Service` `backendRef` — the controller does not synthesize a duplicate
Pick the default unless you specifically want FQDN backends or the `Backend`
CRD's other features (health checking via `BackendTrafficPolicy`, etc.).
+## Badger external auth
+
+Gateway API cannot express Traefik plugins directly. When
+`CONFIG_BADGER_EXT_AUTH=true`, routers that reference Pangolin's `badger`
+plugin get a managed Envoy Gateway `SecurityPolicy` targeting the generated
+`HTTPRoute`.
+
+The policy must point at an HTTP service that speaks Envoy's ext_authz protocol.
+Pangolin's `/api/v1/badger/verify-session` endpoint expects Badger's JSON body,
+so in today's Pangolin releases this normally means running a small shim service
+that adapts Envoy's auth check request to Pangolin's badger verify-session API.
+The controller defaults to a Service named `pangolin-badger-ext-authz` on port
+`9002`; override that with the `CONFIG_BADGER_EXT_AUTH_*` settings.
+
+## Scoped reconcile
+
+Set `CONFIG_RECONCILE_ONLY` to a comma-separated allow-list to test a subset of
+objects without touching the rest of the controller-owned state. The easiest
+form is a hostname; the controller expands it to the matching `HTTPRoute`, its
+badger `SecurityPolicy`, generated backend objects, and the shared
+`ListenerSet`:
+
+```sh
+CONFIG_RECONCILE_ONLY="9-chris.example.com"
+```
+
+Explicit object selectors still work as `Kind/name` or `Kind:name`, for example
+`HTTPRoute/hr-9-chris-connect-router`. Use `Hostname/name` if you need to force
+a hostname selector that does not contain a dot.
+
+Apply and GC both honor the scope. Objects outside the scope are left alone, and
+selected objects that are no longer desired can still be garbage-collected.
+
+## Migration config changes
+
+For a dual-stack rollout, keep Traefik active and add the Gateway API pieces in
+small steps:
+
+1. Deploy a badger ext_auth shim Service, then set:
+
+ ```sh
+ CONFIG_BADGER_EXT_AUTH=true
+ CONFIG_BADGER_EXT_AUTH_BACKEND_NAME=pangolin-badger-ext-authz
+ CONFIG_BADGER_EXT_AUTH_BACKEND_PORT=9002
+ ```
+
+2. Start with a narrow reconcile scope:
+
+ ```sh
+ CONFIG_RECONCILE_ONLY="9-chris.example.com"
+ ```
+
+3. To serve Pangolin itself through Envoy Gateway, expose the Pangolin Service
+ ports for API/WebSocket (`3000`) and Next.js (`3002`), then set:
+
+ ```sh
+ CONFIG_PANGOLIN_DASHBOARD_HOST=pangolin.example.com
+ ```
+
+4. To move Gerbil UDP traffic through Envoy Gateway, expose UDP ports on the
+ Gerbil Service and enable:
+
+ ```sh
+ CONFIG_GERBIL_UDP_ROUTE=true
+ CONFIG_GERBIL_UDP_PORTS=51820,21820
+ ```
+
+Remove `CONFIG_RECONCILE_ONLY` only after the selected route, policy, dashboard,
+and UDP paths have been validated against Envoy Gateway.
+
+For the Gerbil public-IP migration pattern, including direct old-IP traffic plus
+a separate Envoy Gateway IP, see
+[docs/gerbil-gateway-migration.md](docs/gerbil-gateway-migration.md).
+For the shared-IP path where only HTTP/HTTPS moves from Traefik to Envoy while
+Gerbil keeps the WireGuard UDP ports, see
+[docs/shared-ip-http-envoy-migration.md](docs/shared-ip-http-envoy-migration.md).
+
## Certificate handling with cert-manager
There are two ways the controller fills in `tls.certificateRefs` on its listeners:
@@ -181,6 +260,25 @@ upstream Go controller (`CONFIG_*`) where the concepts overlap.
| `CONFIG_BACKEND_KIND` | `service` | `service` (default) or `envoy-backend`. See [Backend strategy](#backend-strategy) |
| `CONFIG_HTTPROUTE_ANNOTATIONS` | _(unset)_ | `k=v,k=v` annotations stamped on every HTTPRoute. Typical: `cert-manager.io/cluster-issuer=letsencrypt-prod` |
| `CONFIG_LISTENERSET_ANNOTATIONS` | _(unset)_ | `k=v,k=v` annotations stamped on the ListenerSet |
+| `CONFIG_BADGER_EXT_AUTH` | `false` | Emit Envoy Gateway `SecurityPolicy.extAuth` for routes that use badger |
+| `CONFIG_BADGER_EXT_AUTH_BACKEND_NAME` | `pangolin-badger-ext-authz` | Service name of the Envoy ext_auth-compatible badger shim |
+| `CONFIG_BADGER_EXT_AUTH_BACKEND_NAMESPACE` | controller namespace | Namespace of the ext_auth backend Service |
+| `CONFIG_BADGER_EXT_AUTH_BACKEND_PORT` | `9002` | Service port for the ext_auth backend |
+| `CONFIG_BADGER_EXT_AUTH_PATH` | _(unset)_ | Optional fixed auth check path |
+| `CONFIG_BADGER_EXT_AUTH_HEADERS_TO_EXTAUTH` | `authorization,cookie,x-forwarded-for,x-forwarded-host,x-forwarded-proto,x-real-ip,p-access-token-id,p-access-token` | Headers forwarded to the auth service |
+| `CONFIG_BADGER_EXT_AUTH_HEADERS_TO_BACKEND` | `remote-user,remote-email,remote-name,remote-role` | Headers copied from auth response to upstream backends |
+| `CONFIG_BADGER_EXT_AUTH_FAIL_OPEN` | `false` | Allow traffic if the auth service is unavailable |
+| `CONFIG_RECONCILE_ONLY` | _(unset)_ | Optional comma-separated allow-list of hostnames or `Kind/name` objects to apply/GC |
+| `CONFIG_PANGOLIN_DASHBOARD_HOST` | _(unset)_ | When set, emit static dashboard HTTPRoutes for this hostname |
+| `CONFIG_PANGOLIN_SERVICE_NAME` | `pangolin` | Service that backs the dashboard/API routes |
+| `CONFIG_PANGOLIN_SERVICE_NAMESPACE`| controller namespace | Namespace of the Pangolin Service |
+| `CONFIG_PANGOLIN_API_PORT` | `3000` | Pangolin API/WebSocket Service port |
+| `CONFIG_PANGOLIN_NEXT_PORT` | `3002` | Pangolin Next.js Service port |
+| `CONFIG_PANGOLIN_REDIRECT_HTTP_TO_HTTPS` | `true` | Emit an HTTP-to-HTTPS redirect route when HTTPS listeners are configured |
+| `CONFIG_GERBIL_UDP_ROUTE` | `false` | Emit UDP listeners and UDPRoutes for Gerbil |
+| `CONFIG_GERBIL_SERVICE_NAME` | `gerbil` | Service that backs Gerbil UDP traffic |
+| `CONFIG_GERBIL_SERVICE_NAMESPACE` | controller namespace | Namespace of the Gerbil Service |
+| `CONFIG_GERBIL_UDP_PORTS` | `51820,21820` | UDP ports to expose through the ListenerSet |
| `CONFIG_FIELD_MANAGER` | `pangolin-gateway-controller` | Server-Side Apply field manager |
| `CONFIG_MANAGED_LABEL_KEY` | `app.kubernetes.io/managed-by` | Used for GC selector |
| `CONFIG_MANAGED_LABEL_VALUE` | `pangolin-gateway-controller` | Used for GC selector |
diff --git a/deploy/README.md b/deploy/README.md
index b3633c2..1dc1d0d 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -10,14 +10,15 @@ and as a component from sibling infrastructure repos.
| File | Resource | Notes |
|---|---|---|
| `namespace.yaml` | `Namespace/pangolin-system` | Drop this if the namespace is created elsewhere — see [Embedding from another kustomization](#embedding-from-another-kustomization). |
-| `rbac.yaml` | `ServiceAccount`, `ClusterRole`, `ClusterRoleBinding` | Cluster-wide read on Gateways; write on HTTPRoute / ListenerSet / Service / EndpointSlice / Envoy Gateway `Backend`. |
+| `rbac.yaml` | `ServiceAccount`, `ClusterRole`, `ClusterRoleBinding` | Cluster-wide read on Gateways; write on HTTPRoute / UDPRoute / ListenerSet / Service / EndpointSlice / Envoy Gateway `Backend` and `SecurityPolicy`. |
| `deployment.yaml` | `Deployment/pangolin-gateway-controller` | Single replica, read-only rootfs, all caps dropped. |
The CRDs are **not** installed here. You need:
- Gateway API v1.5+ **experimental** channel (for `ListenerSet`):
`kubectl apply -k https://github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.5.1`
-- Envoy Gateway, if you set `CONFIG_BACKEND_KIND=envoy-backend`:
+- Envoy Gateway, if you set `CONFIG_BACKEND_KIND=envoy-backend` or
+ `CONFIG_BADGER_EXT_AUTH=true`:
see .
A `Gateway` object the controller can attach its `ListenerSet` to must exist
@@ -72,6 +73,13 @@ that file for authoritative defaults.
| `CONFIG_TLS_SECRET_TEMPLATE` | _(unset)_ | Required when HTTPS listeners are on. Supports `{hostname}` and `{hostname-dashed}` placeholders. |
| `CONFIG_HTTPROUTE_ANNOTATIONS` | _(empty)_ | `k=v,k=v` annotations stamped onto every `HTTPRoute`. Typical use: cert-manager cluster-issuer. |
| `CONFIG_LISTENERSET_ANNOTATIONS` | _(empty)_ | Same for `ListenerSet`. |
+| `CONFIG_BADGER_EXT_AUTH` | `false` | Emit Envoy Gateway `SecurityPolicy.extAuth` for routes that use Pangolin's badger plugin. Requires an Envoy ext_auth-compatible shim service. |
+| `CONFIG_BADGER_EXT_AUTH_BACKEND_NAME` | `pangolin-badger-ext-authz` | Auth shim Service name. |
+| `CONFIG_BADGER_EXT_AUTH_BACKEND_PORT` | `9002` | Auth shim Service port. |
+| `CONFIG_RECONCILE_ONLY` | _(empty)_ | Optional comma-separated allow-list of hostnames or object selectors such as `9-chris.example.com` or `HTTPRoute/hr-9-chris-connect-router`. |
+| `CONFIG_PANGOLIN_DASHBOARD_HOST` | _(empty)_ | Emit static dashboard/API/WebSocket HTTPRoutes for this host. |
+| `CONFIG_GERBIL_UDP_ROUTE` | `false` | Emit Gerbil UDP listeners and UDPRoutes; requires UDPRoute support in your Gateway implementation. |
+| `CONFIG_GERBIL_UDP_PORTS` | `51820,21820` | UDP ports to route to the Gerbil Service. |
| `CONFIG_FIELD_MANAGER` | `pangolin-gateway-controller` | SSA field manager. Change only if you need to migrate ownership. |
| `RUST_LOG` | `info` | Standard `tracing_subscriber::EnvFilter`. |
@@ -98,6 +106,80 @@ annotation env vars to your cluster issuer:
cert-manager will see the annotated `ListenerSet`, mint a Certificate per
listener hostname, and write the Secret matching `CONFIG_TLS_SECRET_TEMPLATE`.
+## Badger ext_authz (optional)
+
+The controller can close the badger gap from the Traefik plugin side by emitting
+Envoy Gateway `SecurityPolicy` objects, but it does not make Pangolin's badger
+endpoint speak Envoy's auth protocol by itself. Run a shim Service that accepts
+Envoy HTTP ext_auth checks and calls
+`http://pangolin:3001/api/v1/badger/verify-session`, then enable:
+
+```yaml
+- name: CONFIG_BADGER_EXT_AUTH
+ value: "true"
+- name: CONFIG_BADGER_EXT_AUTH_BACKEND_NAME
+ value: "pangolin-badger-ext-authz"
+- name: CONFIG_BADGER_EXT_AUTH_BACKEND_PORT
+ value: "9002"
+```
+
+During migration, combine this with `CONFIG_RECONCILE_ONLY` to apply a single
+hostname first. Hostname scopes expand to the matching `HTTPRoute`, badger
+`SecurityPolicy`, generated backend objects, and the shared `ListenerSet`.
+
+## Configuration changes for migration
+
+When enabling the new Gateway API path in an existing dual-stack deployment,
+change configuration in this order:
+
+1. Keep Traefik serving production traffic.
+2. Add or deploy a Service that fronts your badger ext_auth shim. The Service
+ name and port must match `CONFIG_BADGER_EXT_AUTH_BACKEND_NAME` and
+ `CONFIG_BADGER_EXT_AUTH_BACKEND_PORT`.
+3. Make sure the Pangolin Service exposes the API/WebSocket port (`3000` by
+ default) and the Next.js port (`3002` by default) if you want
+ `CONFIG_PANGOLIN_DASHBOARD_HOST`.
+4. Make sure the Gerbil Service exposes UDP `51820` and `21820`, or override
+ `CONFIG_GERBIL_UDP_PORTS`.
+5. Patch the controller Deployment env vars.
+
+Example kustomize patch:
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: pangolin-gateway-controller
+ namespace: pangolin-system
+spec:
+ template:
+ spec:
+ containers:
+ - name: controller
+ env:
+ - name: CONFIG_BADGER_EXT_AUTH
+ value: "true"
+ - name: CONFIG_BADGER_EXT_AUTH_BACKEND_NAME
+ value: "pangolin-badger-ext-authz"
+ - name: CONFIG_BADGER_EXT_AUTH_BACKEND_PORT
+ value: "9002"
+ - name: CONFIG_RECONCILE_ONLY
+ value: "9-chris.example.com"
+ - name: CONFIG_PANGOLIN_DASHBOARD_HOST
+ value: "pangolin.example.com"
+ - name: CONFIG_GERBIL_UDP_ROUTE
+ value: "true"
+```
+
+For the first test, keep `CONFIG_RECONCILE_ONLY` narrow. Remove it only after the
+selected hostname's `HTTPRoute` and `SecurityPolicy` are accepted by Envoy
+Gateway and the auth shim returns the expected allow/redirect/deny responses.
+For Gerbil's old-IP plus Envoy-IP migration pattern, see
+[`docs/gerbil-gateway-migration.md`](../docs/gerbil-gateway-migration.md).
+For a shared public IP where only TCP `80`/`443` moves from Traefik to Envoy,
+see
+[`docs/shared-ip-http-envoy-migration.md`](../docs/shared-ip-http-envoy-migration.md).
+
## Embedding from another kustomization
The deploy folder is a working stand-alone overlay. To embed it inside an
diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml
index 8f99d08..f0555f8 100644
--- a/deploy/deployment.yaml
+++ b/deploy/deployment.yaml
@@ -54,6 +54,23 @@ spec:
# value: "cert-manager.io/cluster-issuer=letsencrypt-prod"
# - name: CONFIG_LISTENERSET_ANNOTATIONS
# value: "cert-manager.io/cluster-issuer=letsencrypt-prod"
+ # Badger ext_authz (optional). Point this at an Envoy ext_auth-compatible
+ # shim for pangolin's /api/v1/badger/verify-session protocol.
+ # - name: CONFIG_BADGER_EXT_AUTH
+ # value: "true"
+ # - name: CONFIG_BADGER_EXT_AUTH_BACKEND_NAME
+ # value: "pangolin-badger-ext-authz"
+ # - name: CONFIG_BADGER_EXT_AUTH_BACKEND_PORT
+ # value: "9002"
+ # Test-mode reconcile scope (optional), e.g. only one hostname.
+ # - name: CONFIG_RECONCILE_ONLY
+ # value: "9-chris.example.com"
+ # Pangolin dashboard through Gateway API (optional).
+ # - name: CONFIG_PANGOLIN_DASHBOARD_HOST
+ # value: "pangolin.example.com"
+ # Gerbil UDP through Gateway API (optional; requires UDPRoute support).
+ # - name: CONFIG_GERBIL_UDP_ROUTE
+ # value: "true"
- name: RUST_LOG
value: "info,pangolin_gateway_controller=info"
resources:
diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml
index 05876bf..1a9e806 100644
--- a/deploy/rbac.yaml
+++ b/deploy/rbac.yaml
@@ -11,10 +11,10 @@ metadata:
rules:
# Gateway API objects we own.
- apiGroups: ["gateway.networking.k8s.io"]
- resources: ["httproutes", "listenersets"]
+ resources: ["httproutes", "listenersets", "udproutes"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["gateway.networking.k8s.io"]
- resources: ["httproutes/status", "listenersets/status"]
+ resources: ["httproutes/status", "listenersets/status", "udproutes/status"]
verbs: ["get"]
# Core resources we synthesize for IP-backed pangolin targets.
- apiGroups: [""]
@@ -27,10 +27,11 @@ rules:
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways", "gatewayclasses"]
verbs: ["get", "list", "watch"]
- # Envoy Gateway's Backend CRD. Only used when CONFIG_BACKEND_KIND=envoy-backend.
- # Safe to leave granted even when unused — RBAC checks happen lazily.
+ # Envoy Gateway CRDs. Backend is used when CONFIG_BACKEND_KIND=envoy-backend;
+ # SecurityPolicy is used when CONFIG_BADGER_EXT_AUTH=true.
+ # Safe to leave granted even when unused — checks happen lazily.
- apiGroups: ["gateway.envoyproxy.io"]
- resources: ["backends"]
+ resources: ["backends", "securitypolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
diff --git a/docs/gerbil-gateway-migration.md b/docs/gerbil-gateway-migration.md
new file mode 100644
index 0000000..410e677
--- /dev/null
+++ b/docs/gerbil-gateway-migration.md
@@ -0,0 +1,237 @@
+# Gerbil Gateway API Migration
+
+This is the safe rollout shape for moving Gerbil L4 traffic toward Envoy
+Gateway without taking the old public address away from the existing Gerbil
+`LoadBalancer` Service.
+
+For the shared-IP variant where Gerbil keeps UDP and only HTTP/HTTPS moves from
+Traefik to Envoy, see
+[shared-ip-http-envoy-migration.md](shared-ip-http-envoy-migration.md).
+
+## Target Shape
+
+```mermaid
+flowchart LR
+ internet["Internet clients"]
+ oldip["old external IP\nGerbil LoadBalancer Service"]
+ envoyip["new external IP\nEnvoy Gateway Service"]
+ gateway["Gateway API\nUDPRoute / TCPRoute"]
+ gerbil["Gerbil pods"]
+
+ internet --> oldip --> gerbil
+ internet --> envoyip --> gateway --> gerbil
+```
+
+Yes, Gerbil can receive traffic from both paths at the same time. The important
+constraint is that the same external IP and port cannot normally be owned by the
+old Gerbil `LoadBalancer` Service and the Envoy Gateway `LoadBalancer` Service
+simultaneously. Start with two public IPs, then move the old IP to Envoy only
+when Envoy is the only intended public entrypoint for those ports.
+
+## Prerequisites
+
+- Gateway API experimental CRDs are installed, because `UDPRoute` and `TCPRoute`
+ are `gateway.networking.k8s.io/v1alpha2`.
+- Envoy Gateway supports the L4 protocols and ports you want to expose.
+- Gerbil has a stable in-cluster backend Service that Envoy can route to.
+- The old direct `LoadBalancer` Service keeps the old external IP during the
+ test phase.
+
+## Services
+
+Keep the old Service that owns the old IP. Use your provider's static-IP
+annotation if it does not honor `loadBalancerIP`.
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: gerbil-public-old
+ namespace: pangolin-system
+spec:
+ type: LoadBalancer
+ loadBalancerIP: OLD_PUBLIC_IP
+ externalTrafficPolicy: Local
+ selector:
+ app.kubernetes.io/name: gerbil
+ ports:
+ - name: wg-51820
+ protocol: UDP
+ port: 51820
+ targetPort: 51820
+ - name: wg-21820
+ protocol: UDP
+ port: 21820
+ targetPort: 21820
+ - name: tcp-443
+ protocol: TCP
+ port: 443
+ targetPort: 443
+```
+
+Add or reuse a normal in-cluster Service for Envoy's route backend:
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: gerbil
+ namespace: pangolin-system
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: gerbil
+ ports:
+ - name: wg-51820
+ protocol: UDP
+ port: 51820
+ targetPort: 51820
+ - name: wg-21820
+ protocol: UDP
+ port: 21820
+ targetPort: 21820
+ - name: tcp-443
+ protocol: TCP
+ port: 443
+ targetPort: 443
+```
+
+## Separate Envoy Gateway IP
+
+Create a Gateway whose Envoy data plane gets its own external IP first. The
+exact static-IP field depends on your Envoy Gateway and cloud provider setup;
+the example shows only the Gateway API part.
+
+```yaml
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: gerbil-eg
+ namespace: envoy-gateway-system
+spec:
+ gatewayClassName: eg
+ listeners:
+ - name: gerbil-udp-51820
+ protocol: UDP
+ port: 51820
+ allowedRoutes:
+ namespaces:
+ from: All
+ - name: gerbil-udp-21820
+ protocol: UDP
+ port: 21820
+ allowedRoutes:
+ namespaces:
+ from: All
+ - name: gerbil-tcp-443
+ protocol: TCP
+ port: 443
+ allowedRoutes:
+ namespaces:
+ from: All
+```
+
+## UDPRoute And TCPRoute
+
+Attach routes to the new Gateway and point them at the Gerbil backend Service.
+
+```yaml
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: UDPRoute
+metadata:
+ name: gerbil-udp-51820
+ namespace: pangolin-system
+spec:
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gerbil-eg
+ namespace: envoy-gateway-system
+ sectionName: gerbil-udp-51820
+ rules:
+ - backendRefs:
+ - group: ""
+ kind: Service
+ name: gerbil
+ port: 51820
+---
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: UDPRoute
+metadata:
+ name: gerbil-udp-21820
+ namespace: pangolin-system
+spec:
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gerbil-eg
+ namespace: envoy-gateway-system
+ sectionName: gerbil-udp-21820
+ rules:
+ - backendRefs:
+ - group: ""
+ kind: Service
+ name: gerbil
+ port: 21820
+---
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: TCPRoute
+metadata:
+ name: gerbil-tcp-443
+ namespace: pangolin-system
+spec:
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: gerbil-eg
+ namespace: envoy-gateway-system
+ sectionName: gerbil-tcp-443
+ rules:
+ - backendRefs:
+ - group: ""
+ kind: Service
+ name: gerbil
+ port: 443
+```
+
+The controller can emit Gerbil `UDPRoute`s for its managed `ListenerSet` with:
+
+```yaml
+- name: CONFIG_GERBIL_UDP_ROUTE
+ value: "true"
+- name: CONFIG_GERBIL_UDP_PORTS
+ value: "51820,21820"
+- name: CONFIG_GERBIL_SERVICE_NAME
+ value: "gerbil"
+```
+
+Use either the controller-managed UDP path or the manual Gateway above for the
+same port, not both, unless they attach to different Envoy Gateways/IPs.
+`TCPRoute` is currently a manual manifest.
+
+## Validation
+
+Check that both entrypoints exist:
+
+```sh
+kubectl -n pangolin-system get svc gerbil-public-old gerbil
+kubectl -n envoy-gateway-system get gateway gerbil-eg
+kubectl -n pangolin-system get udproute,tcproute
+```
+
+Then test clients against the new Envoy external IP while existing clients keep
+using the old IP. Watch Gerbil logs and Envoy Gateway route status conditions.
+
+## Cutover
+
+When the Envoy path is validated:
+
+1. Remove or pause the old Gerbil `LoadBalancer` Service so it releases the old
+ IP.
+2. Assign the old static IP to the Envoy Gateway data-plane Service using your
+ cloud provider's static-IP mechanism.
+3. Confirm `UDPRoute` and `TCPRoute` are accepted and the old IP now reaches
+ Gerbil through Envoy.
+4. Delete the temporary Envoy IP after traffic has drained.
+
+Do not move the old IP while the direct Gerbil `LoadBalancer` still owns it.
diff --git a/docs/shared-ip-http-envoy-migration.md b/docs/shared-ip-http-envoy-migration.md
new file mode 100644
index 0000000..d109f1a
--- /dev/null
+++ b/docs/shared-ip-http-envoy-migration.md
@@ -0,0 +1,284 @@
+# Shared IP HTTP/HTTPS Envoy Migration
+
+This path keeps one public IP, leaves Gerbil responsible for WireGuard traffic,
+and moves only web traffic from Traefik to Envoy Gateway.
+
+## Target Shape
+
+```mermaid
+flowchart LR
+ internet["Internet clients"]
+ ip["same public IP"]
+ udp["UDP 51820/21820"]
+ tcp["TCP 80/443"]
+ gerbil["Gerbil"]
+ envoy["Envoy Gateway"]
+ routes["Gateway API HTTPRoutes"]
+ overlay["Gerbil / overlay reachability"]
+ backends["Pangolin resources"]
+
+ internet --> ip
+ ip --> udp --> gerbil
+ ip --> tcp --> envoy --> routes --> overlay --> backends
+```
+
+This is "Envoy replaces Traefik" for HTTP/HTTPS only. Gerbil remains the
+WireGuard/tunnel component and the old public endpoint for UDP. For tunneled
+resources, Envoy must have the same backend reachability that Traefik had,
+which may mean reaching resource networks through Gerbil or another cluster
+route to the Gerbil-managed overlay.
+
+## What The Upstream Helm Chart Does
+
+The upstream chart supports controller mode, standalone mode, and single or
+multi workload topology. In the chart README, controller mode is the
+recommended production path and expects a Traefik controller; standalone mode
+runs chart-managed Traefik; single mode puts Pangolin plus optional Gerbil and
+either controller or standalone Traefik into one shared Pod.
+
+Relevant chart behavior:
+
+- `gerbil.service` is value-driven and selects Gerbil pods in multi mode, or
+ the shared single Pod in single mode.
+- Default Gerbil service ports are `wg1` UDP `51820`, `wg2` UDP `21820`, and
+ `internal-api` TCP `3004`.
+- Standalone Traefik listens on container ports `8000` and `8443`; its Service
+ exposes public TCP `80` and `443` to those container ports.
+- In single+standalone mode, one Service can target ports from both Gerbil and
+ Traefik because all containers live in the same Pod network namespace.
+
+Important consequence: the chart does not configure Gerbil as the HTTP/HTTPS
+frontend that forwards port 80/443 to Traefik. Gerbil is not a drop-in L7
+replacement for a LoadBalancer or Gateway. With standard Envoy Gateway, Envoy
+data-plane pods are separate from the Pangolin/Gerbil pods, so a single
+Kubernetes Service cannot select Gerbil for UDP and Envoy for TCP unless your
+load balancer supports sharing one IP across multiple Services, or you put an
+external L4 load balancer in front.
+
+Sources checked:
+
+- [Pangolin chart README](https://github.com/fosrl/helm-charts/blob/main/charts/pangolin/README.md)
+- [Pangolin chart values](https://github.com/fosrl/helm-charts/blob/main/charts/pangolin/values.yaml)
+- [Gerbil Service template](https://github.com/fosrl/helm-charts/blob/main/charts/pangolin/templates/service-gerbil.yaml)
+- [Standalone Traefik Service template](https://github.com/fosrl/helm-charts/blob/main/charts/pangolin/templates/service-traefik.yaml)
+- [Single Pod template](https://github.com/fosrl/helm-charts/blob/main/charts/pangolin/templates/deployment-single.yaml)
+
+## Recommended Shared-IP Pattern
+
+Use one public IP with two L4 owners:
+
+- Gerbil public UDP Service owns UDP `51820` and `21820`.
+- Envoy Gateway data-plane Service owns TCP `80` and `443`.
+
+This requires one of:
+
+- a load-balancer implementation that supports the same IP on multiple Services
+ when their ports do not overlap, such as some MetalLB or Cilium LB IPAM
+ setups;
+- a cloud/provider feature that can bind different port mappings for the same
+ IP to different Kubernetes Services;
+- an external L4 load balancer that sends UDP ports to Gerbil and TCP ports to
+ Envoy.
+
+If your provider cannot share one IP across Services, Kubernetes cannot express
+"UDP to Service A, TCP to Service B, same external IP" with ordinary Services.
+Use the separate-IP migration first, or do a cutover where Envoy becomes the
+only owner of the public IP.
+
+## Keep The Chart Gerbil Service Internal
+
+For a clean production setup, keep the chart-managed Gerbil Service as
+`ClusterIP` so Gerbil's internal API stays internal:
+
+```yaml
+gerbil:
+ service:
+ enabled: true
+ type: ClusterIP
+```
+
+Then create a separate public UDP Service outside the chart. Copy the selector
+from the chart-rendered Gerbil Service:
+
+```sh
+kubectl -n pangolin-system get svc -pangolin-gerbil -o yaml
+```
+
+Use the same selector in a UDP-only public Service:
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: gerbil-public-udp
+ namespace: pangolin-system
+ annotations:
+ # Provider-specific static/shared IP annotations go here.
+ example.com/shared-ip-group: pangolin-public
+spec:
+ type: LoadBalancer
+ loadBalancerIP: OLD_PUBLIC_IP
+ externalTrafficPolicy: Local
+ selector:
+ # Copy from the chart-rendered -pangolin-gerbil Service.
+ # These are examples; single mode uses the shared single-Pod selector.
+ app.kubernetes.io/instance: RELEASE_NAME
+ app.kubernetes.io/component: gerbil
+ ports:
+ - name: wg1
+ protocol: UDP
+ port: 51820
+ targetPort: wg1
+ - name: wg2
+ protocol: UDP
+ port: 21820
+ targetPort: wg2
+```
+
+Avoid exposing Gerbil `internal-api` TCP `3004` on the public LoadBalancer.
+
+## Put Envoy On TCP 80/443 Of The Same IP
+
+Configure the Envoy Gateway data-plane Service with the same static IP and the
+same provider-specific shared-IP group, but only for TCP `80` and `443`.
+
+The exact object is Envoy Gateway installation dependent. In many clusters,
+Envoy Gateway creates a Service in the Envoy namespace for the data-plane
+Deployment. Patch or configure that Service through your Envoy Gateway
+installation mechanism rather than hand-editing generated resources when your
+operator supports a first-class value.
+
+Schematic only:
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: envoy-gateway-data-plane
+ namespace: envoy-gateway-system
+ annotations:
+ # Must match the Gerbil public UDP Service for providers that require a
+ # shared-IP grouping key.
+ example.com/shared-ip-group: pangolin-public
+spec:
+ type: LoadBalancer
+ loadBalancerIP: OLD_PUBLIC_IP
+ ports:
+ - name: http
+ protocol: TCP
+ port: 80
+ - name: https
+ protocol: TCP
+ port: 443
+```
+
+Make sure the final public IP ownership is non-overlapping:
+
+```sh
+kubectl -n pangolin-system get svc gerbil-public-udp
+kubectl -n envoy-gateway-system get svc
+```
+
+You should see the same external IP on both Services, with UDP ports only on
+Gerbil and TCP web ports only on Envoy.
+
+## Gateway API Setup
+
+Create or keep a parent Gateway that allows the controller's `ListenerSet` to
+attach:
+
+```yaml
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: eg
+ namespace: envoy-gateway-system
+spec:
+ gatewayClassName: eg
+ allowedListeners:
+ namespaces:
+ from: All
+ listeners:
+ - name: placeholder
+ protocol: HTTP
+ port: 8080
+```
+
+Then configure the Pangolin Gateway API controller for Envoy:
+
+```yaml
+- name: CONFIG_PARENT_GATEWAY
+ value: "eg"
+- name: CONFIG_PARENT_GATEWAY_NAMESPACE
+ value: "envoy-gateway-system"
+- name: CONFIG_ENABLE_HTTPS_LISTENERS
+ value: "true"
+- name: CONFIG_TLS_SECRET_TEMPLATE
+ value: "{hostname-dashed}-tls"
+- name: CONFIG_BADGER_EXT_AUTH
+ value: "true"
+- name: CONFIG_BADGER_EXT_AUTH_BACKEND_NAME
+ value: "pangolin-badger-ext-authz"
+- name: CONFIG_RECONCILE_ONLY
+ value: "first-test-resource.example.com"
+```
+
+Once the selected hostname works through Envoy, remove
+`CONFIG_RECONCILE_ONLY`.
+
+## Pangolin Dashboard
+
+If the Pangolin dashboard should also move from Traefik to Envoy, disable or
+ignore the Traefik dashboard `IngressRoute` and enable the controller's Gateway
+API dashboard routes:
+
+```yaml
+- name: CONFIG_PANGOLIN_DASHBOARD_HOST
+ value: "pangolin.example.com"
+```
+
+The Pangolin Service must expose:
+
+- API/WebSocket port `3000` via `CONFIG_PANGOLIN_API_PORT`
+- Next.js UI port `3002` via `CONFIG_PANGOLIN_NEXT_PORT`
+
+## Critical Reachability Check
+
+Replacing Traefik with Envoy is only safe if Envoy can reach the same resource
+backends Traefik reached.
+
+The upstream chart's single+standalone mode can make Traefik and Gerbil share a
+Pod network namespace. Standard Envoy Gateway data-plane pods do not share that
+namespace. If your current Traefik reaches tunneled resources only because it is
+co-located with Gerbil, Envoy may not have equivalent reachability by default.
+
+Before moving production traffic:
+
+```sh
+kubectl -n get httproute
+kubectl -n get securitypolicy
+kubectl -n get svc,endpointslice
+kubectl -n envoy-gateway-system logs deploy/
+```
+
+Then test a single hostname through the shared IP. Confirm:
+
+- TLS terminates at Envoy.
+- Badger auth redirects or allows as expected.
+- The selected route can reach its backend.
+- Gerbil UDP tunnels still connect through the same public IP.
+
+## Cutover Checklist
+
+1. Keep Traefik serving production HTTP/HTTPS.
+2. Add Envoy on the shared IP for TCP `80` and `443`, if your LB supports that.
+3. Keep Gerbil UDP on the same IP and do not expose `internal-api` publicly.
+4. Scope the controller with `CONFIG_RECONCILE_ONLY=`.
+5. Validate one protected hostname and the Pangolin dashboard.
+6. Move DNS/traffic policy from Traefik to Envoy for HTTP/HTTPS.
+7. Remove Traefik's public Service or stop exposing ports `80` and `443` from
+ Traefik.
+8. Remove `CONFIG_RECONCILE_ONLY`.
+
+If the load balancer cannot share one IP across Services, stop here and use the
+separate-IP guide until you are ready for a full public-IP cutover.
diff --git a/src/config.rs b/src/config.rs
index 2c76f99..c3f8f50 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -50,10 +50,228 @@ pub struct Config {
/// implementations that watch the ListenerSet directly.
pub listenerset_annotations: BTreeMap,
+ /// Optional Envoy Gateway external auth wiring for pangolin's badger plugin.
+ pub badger_ext_auth: Option,
+
+ /// Optional static routes for serving the Pangolin dashboard through the same
+ /// ListenerSet as managed resources.
+ pub pangolin_dashboard: Option,
+
+ /// Optional UDP routing for Gerbil's WireGuard-facing ports.
+ pub gerbil_udp: Option,
+
+ /// Optional allow-list used for migration testing. Empty means reconcile all
+ /// desired objects.
+ pub reconcile_scope: ReconcileScope,
+
pub read_only: bool,
pub log_traefik_config: bool,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BadgerExtAuthConfig {
+ pub backend_name: String,
+ pub backend_namespace: Option,
+ pub backend_port: i32,
+ pub path: Option,
+ pub headers_to_ext_auth: Vec,
+ pub headers_to_backend: Vec,
+ pub fail_open: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PangolinDashboardConfig {
+ pub hostname: String,
+ pub service_name: String,
+ pub service_namespace: Option,
+ pub api_port: i32,
+ pub next_port: i32,
+ pub redirect_http_to_https: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GerbilUdpConfig {
+ pub service_name: String,
+ pub service_namespace: Option,
+ pub ports: Vec,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ReconcileScope {
+ selectors: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum ReconcileSelector {
+ Object {
+ kind: Option,
+ name: String,
+ },
+ Hostname(String),
+ ObjectNameOrHostname(String),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum ReconcileKind {
+ HttpRoute,
+ ListenerSet,
+ Service,
+ EndpointSlice,
+ Backend,
+ SecurityPolicy,
+ UDPRoute,
+}
+
+impl ReconcileKind {
+ pub fn parse(s: &str) -> Result {
+ match normalize_kind(s).as_str() {
+ "httproute" | "httproutes" | "hr" => Ok(Self::HttpRoute),
+ "listenerset" | "listenersets" | "ls" => Ok(Self::ListenerSet),
+ "service" | "services" | "svc" => Ok(Self::Service),
+ "endpointslice" | "endpointslices" | "eps" => Ok(Self::EndpointSlice),
+ "backend" | "backends" | "envoybackend" | "envoybackends" | "be" => Ok(Self::Backend),
+ "securitypolicy" | "securitypolicies" | "sp" => Ok(Self::SecurityPolicy),
+ "udproute" | "udproutes" | "udp" => Ok(Self::UDPRoute),
+ other => bail!(
+ "invalid reconcile object kind {other:?}; expected HTTPRoute, ListenerSet, \
+ Service, EndpointSlice, Backend, SecurityPolicy, or UDPRoute"
+ ),
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::HttpRoute => "HTTPRoute",
+ Self::ListenerSet => "ListenerSet",
+ Self::Service => "Service",
+ Self::EndpointSlice => "EndpointSlice",
+ Self::Backend => "Backend",
+ Self::SecurityPolicy => "SecurityPolicy",
+ Self::UDPRoute => "UDPRoute",
+ }
+ }
+}
+
+impl ReconcileScope {
+ pub fn parse(raw: &str) -> Result {
+ let mut selectors = Vec::new();
+ for entry in raw.split(',') {
+ let entry = entry.trim();
+ if entry.is_empty() {
+ continue;
+ }
+
+ let selector = if let Some((kind, name)) = entry.split_once('/') {
+ let kind = kind.trim();
+ let name = name.trim();
+ if name.is_empty() {
+ bail!("CONFIG_RECONCILE_ONLY entry {entry:?} has an empty selector value");
+ }
+ if is_hostname_selector_kind(kind) {
+ ReconcileSelector::Hostname(normalize_hostname_selector(name))
+ } else {
+ ReconcileSelector::Object {
+ kind: Some(ReconcileKind::parse(kind)?),
+ name: name.to_string(),
+ }
+ }
+ } else if let Some((kind, name)) = entry.split_once(':') {
+ let kind = kind.trim();
+ let name = name.trim();
+ if name.is_empty() {
+ bail!("CONFIG_RECONCILE_ONLY entry {entry:?} has an empty selector value");
+ }
+ if is_hostname_selector_kind(kind) {
+ ReconcileSelector::Hostname(normalize_hostname_selector(name))
+ } else {
+ ReconcileSelector::Object {
+ kind: Some(ReconcileKind::parse(kind)?),
+ name: name.to_string(),
+ }
+ }
+ } else {
+ ReconcileSelector::ObjectNameOrHostname(entry.to_string())
+ };
+
+ selectors.push(selector);
+ }
+ Ok(Self { selectors })
+ }
+
+ pub fn all() -> Self {
+ Self::default()
+ }
+
+ pub fn is_all(&self) -> bool {
+ self.selectors.is_empty()
+ }
+
+ pub fn includes(&self, kind: ReconcileKind, name: &str) -> bool {
+ self.is_all()
+ || self.selectors.iter().any(|selector| {
+ matches!(
+ selector,
+ ReconcileSelector::Object {
+ kind: selected,
+ name: selected_name,
+ } if selected.is_none_or(|selected| selected == kind) && selected_name == name
+ ) || matches!(
+ selector,
+ ReconcileSelector::ObjectNameOrHostname(selected_name)
+ if selected_name == name
+ )
+ })
+ }
+
+ pub fn affects_kind(&self, kind: ReconcileKind) -> bool {
+ self.is_all()
+ || self.selectors.iter().any(|selector| match selector {
+ ReconcileSelector::Object { kind: selected, .. } => {
+ selected.is_none_or(|selected| selected == kind)
+ }
+ ReconcileSelector::ObjectNameOrHostname(_) => true,
+ ReconcileSelector::Hostname(_) => false,
+ })
+ }
+
+ pub fn hostname_candidates(&self) -> Vec {
+ self.selectors
+ .iter()
+ .filter_map(|selector| match selector {
+ ReconcileSelector::Hostname(hostname) => {
+ Some(normalize_hostname_selector(hostname))
+ }
+ ReconcileSelector::ObjectNameOrHostname(hostname) => {
+ looks_like_hostname_selector(hostname)
+ .then(|| normalize_hostname_selector(hostname))
+ }
+ ReconcileSelector::Object { .. } => None,
+ })
+ .collect()
+ }
+
+ pub fn with_expanded_objects(&self, objects: I) -> Self
+ where
+ I: IntoIterator,
+ {
+ let mut selectors: Vec<_> = self
+ .selectors
+ .iter()
+ .filter(|selector| !matches!(selector, ReconcileSelector::Hostname(_)))
+ .cloned()
+ .collect();
+ selectors.extend(
+ objects
+ .into_iter()
+ .map(|(kind, name)| ReconcileSelector::Object {
+ kind: Some(kind),
+ name,
+ }),
+ );
+ Self { selectors }
+ }
+}
+
/// Backend object kind used for IP / FQDN pangolin targets.
///
/// Cluster-DNS pass-through (`..svc[.cluster.local]`) is independent of
@@ -100,6 +318,15 @@ impl Config {
);
}
+ let namespace = optional_env("CONFIG_NAMESPACE").unwrap_or_else(|| "default".into());
+ let badger_ext_auth = parse_badger_ext_auth(&namespace)?;
+ let pangolin_dashboard = parse_pangolin_dashboard(&namespace)?;
+ let gerbil_udp = parse_gerbil_udp(&namespace)?;
+ let reconcile_scope = optional_env("CONFIG_RECONCILE_ONLY")
+ .map(|raw| ReconcileScope::parse(&raw))
+ .transpose()?
+ .unwrap_or_else(ReconcileScope::all);
+
Ok(Self {
pangolin_endpoint,
auth_header: optional_env("CONFIG_AUTH_HEADER"),
@@ -110,7 +337,7 @@ impl Config {
tls_skip_verify,
ca_file: optional_env("CONFIG_CA_FILE"),
- namespace: optional_env("CONFIG_NAMESPACE").unwrap_or_else(|| "default".into()),
+ namespace,
parent_gateway: required_env("CONFIG_PARENT_GATEWAY")?,
parent_gateway_namespace: optional_env("CONFIG_PARENT_GATEWAY_NAMESPACE"),
listener_set_name: optional_env("CONFIG_LISTENERSET_NAME")
@@ -144,6 +371,11 @@ impl Config {
httproute_annotations: parse_kv_env("CONFIG_HTTPROUTE_ANNOTATIONS")?,
listenerset_annotations: parse_kv_env("CONFIG_LISTENERSET_ANNOTATIONS")?,
+ badger_ext_auth,
+ pangolin_dashboard,
+ gerbil_udp,
+ reconcile_scope,
+
read_only: bool_env("CONFIG_READ_ONLY", false)?,
log_traefik_config: bool_env("CONFIG_LOG_TRAEFIK_CONFIG", false)?,
})
@@ -209,6 +441,133 @@ fn i32_env(key: &str, default: i32) -> Result {
}
}
+fn parse_badger_ext_auth(namespace: &str) -> Result