A small Kubernetes controller, written in Rust with kube-rs, that reconciles
Pangolin's Traefik dynamic-config output into Gateway API objects for
Envoy Gateway. It is the same idea as the upstream pangolin-kube-controller
(which emits Traefik IngressRoute CRDs), retargeted at Gateway API so a single
Envoy Gateway can serve every tunneled resource pangolin manages.
┌─────────────────────────────┐
│ pangolin │
│ GET /api/v1/traefik-config│
└────────────┬────────────────┘
│ poll + ETag
▼
┌──────────────────────────────────────────────────┐
│ pangolin-gateway-controller │
│ – decode Traefik dynamic config │
│ – translate routers/services/middlewares │
│ – Server-Side Apply with field manager │
│ – sweep orphans via managed-by label selector │
└────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Kubernetes / Gateway API │
│ • HTTPRoute (one per pangolin router) │
│ • ListenerSet (attached to your Envoy Gateway) │
│ • Service (IP-backed pangolin targets only) │
│ • EndpointSlice (IP-backed pangolin targets only) │
└─────────────────────────────────────────────────────────┘
The controller is intentionally stateless: every poll it diffs the desired set
against whatever it owns in the cluster (matched by the managed-by label),
applies the additions/updates with Server-Side Apply, and deletes orphans.
| Pangolin / Traefik | Gateway API |
|---|---|
http.routers[*].rule = Host(…) |
HTTPRoute.spec.hostnames + a Listener per unique host on the ListenerSet |
http.routers[*].rule = PathPrefix(/x) |
HTTPRouteRulesMatchesPath{type=PathPrefix,value="/x"} |
http.routers[*].rule = Path(/x) |
HTTPRouteRulesMatchesPath{type=Exact,value="/x"} |
http.services[*].loadBalancer (IP) |
service mode (default): headless Service + EndpointSlice with the IPs.envoy-backend mode: one gateway.envoyproxy.io/v1alpha1 Backend with endpoints[].ip. |
http.services[*].loadBalancer (cluster DNS <svc>.<ns>.svc[.cluster.local]) |
direct backendRef to the existing Service (both modes) |
http.services[*].loadBalancer (other FQDN, e.g. api.example.com) |
service mode: logged and dropped (EndpointSlice can't carry hostnames). envoy-backend mode: Backend.spec.endpoints[].fqdn. |
middlewares.redirectScheme |
HTTPRoute filter RequestRedirect{scheme} |
middlewares.headers.customRequestHeaders |
HTTPRoute filter RequestHeaderModifier{set} — except Host, which becomes URLRewrite{hostname} (Gateway API forbids Host via header modifiers) |
middlewares.headers.customResponseHeaders |
HTTPRoute filter ResponseHeaderModifier{set} |
http.services[*].loadBalancer (https URL) |
TLS origination: envoy-backend mode: Backend.spec.tls (incl. insecureSkipVerify from the service's serversTransport).service mode: BackendTLSPolicy (system CAs; same-namespace targets only). |
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 – SecurityPolicy (ext-authz) when CONFIG_EXT_AUTHZ_SERVICE is set; otherwise the router is skipped (see Authentication) |
tcp.routers[*] (entrypoint tcp-<port>, rule HostSNI(*)) |
TCPRoute + a TCP listener on port <port> (requires CONFIG_ENABLE_TCP_ROUTES=true) |
tcp.routers[*] with a concrete SNI or tls options |
needs TLSRoute passthrough – logged + skipped |
udp.routers[*] (entrypoint udp-<port>) |
UDPRoute + a UDP listener on port <port> (requires CONFIG_ENABLE_UDP_ROUTES=true) |
tcp.services / udp.services loadBalancer.servers[].address |
same IP / cluster-DNS / FQDN classification as HTTP backends (synthesized objects get a -tcp-/-udp- name infix and the right port protocol) |
Unsupported rule constructs (|| disjunction, ! negation, HostRegexp,
Method, Headers, …) cause the affected router to be logged and skipped
rather than silently misrouted.
CONFIG_BACKEND_KIND selects how pangolin's IP/FQDN backends are represented
in the cluster:
| Value | Emits | Portable? | Supports FQDN? |
|---|---|---|---|
envoy-backend (default) |
gateway.envoyproxy.io/v1alpha1 Backend per pangolin service |
Envoy Gateway only | yes |
service |
headless Service + EndpointSlice per pangolin service |
yes | no |
In either mode, pangolin URLs that point at a Kubernetes cluster Service
(<name>.<namespace>.svc[.cluster.local][:port]) are passed through as a
direct Service backendRef — the controller does not synthesize a duplicate.
Pick the default (envoy-backend) unless you need portability to a non-Envoy
Gateway API implementation — Backend supports FQDN targets and unlocks the
CRD's other features (health checking via BackendTrafficPolicy, etc.), at
the cost of being Envoy Gateway-specific.
Pangolin targets with an https:// URL get TLS origination:
- envoy-backend mode expresses everything on the
Backenditself (spec.tls):insecureSkipVerifywhen the pangolin service'sserversTransportrequests it (pangolin's "skip TLS verification" option), otherwise system-CA validation with SNI. Cluster-DNS https targets are wrapped in aBackend(FQDN endpoint) instead of the usual direct Service backendRef, because that's the only place skip-verify can be expressed. Requires an Envoy Gateway whose Backend CRD carriesspec.tlswithinsecureSkipVerify/sni— EG v1.7+ (older CRDs reject the field under Server-Side Apply). - service mode emits a
BackendTLSPolicy(system CAs + hostname). Limits: skip-verify cannot be expressed, IP targets need a matching IP SAN in the certificate, and a policy can only be attached to Services in the controller's own namespace — cross-namespace https targets are warned about and need a manualBackendTLSPolicyin the target namespace.
Pangolin's "raw" resources arrive as tcp/udp blocks whose entrypoint names
encode the public port (tcp-234, udp-345). With
CONFIG_ENABLE_TCP_ROUTES / CONFIG_ENABLE_UDP_ROUTES set, each router
becomes a TCPRoute/UDPRoute (experimental channel, v1alpha2) attached via
sectionName to a TCP/UDP listener the controller adds to its
ListenerSet. Envoy Gateway merges those listeners into the Gateway's
LoadBalancer Service, so the ports surface on the existing Envoy LB.
Both flags default to off because they have cluster-level prerequisites:
- the Gateway API experimental channel CRDs (TCPRoute/UDPRoute),
- an Envoy Gateway release that reconciles the graduated
ListenerSetkind, - for UDP: a cloud LoadBalancer that accepts mixed TCP/UDP protocol Services (otherwise leave UDP to gerbil's own Service and enable TCP only),
- every raw-resource port added in pangolin mutates the cloud LB's port list — expect slower propagation than in-cluster changes.
Only HostSNI(*) TCP rules are translated; a concrete SNI (or tls
options on the router) would need TLSRoute passthrough semantics and is logged
and skipped instead.
Pangolin enforces resource auth (SSO, password, PIN, access rules) through its
badger Traefik plugin, which it attaches to every resource router. Envoy
can't run Traefik plugins, so the controller treats badger-protected routers
as follows:
CONFIG_EXT_AUTHZ_SERVICEset (recommended): every protectedHTTPRoutegets an Envoy GatewaySecurityPolicywhoseextAuth.httppoints at that service. This repo ships exactly that service: the badger ext-authz shim (badger-ext-authz-shim, second binary in the controller image — see The badger ext-authz shim). Policies are emitted withfailOpen: false: if the auth service is down, protected resources stay closed. The session cookie andAuthorizationheader are forwarded by default (CONFIG_EXT_AUTHZ_HEADERS_TO_EXT_AUTH).- Nothing configured (the default): protected routers are skipped
with a warning. This is deliberate — emitting them would silently expose
SSO/password/PIN-protected resources to the internet. Redirect-only routers
(pangolin's
redirect-to-https) carry no auth and are unaffected. CONFIG_ALLOW_UNAUTHENTICATED_ROUTES=true: escape hatch that emits protected routers without any auth filter. Only sane when every pangolin resource is intentionally public.
Raw TCP/UDP resources are not affected — pangolin does not apply badger at L4.
badger-ext-authz-shim bridges Envoy's HTTP external-authorization protocol
to pangolin's badger session API (the same endpoints the upstream Traefik
plugin uses). Per Envoy's contract the check request preserves the original
request's method, Host and path (prefixed with CONFIG_EXT_AUTHZ_PATH), and
on a non-2xx answer Envoy forwards the shim's status and headers — including
Location and Set-Cookie — to the client. That gives the full badger flow:
| Situation | Shim answer | Client sees |
|---|---|---|
valid session (verify-session) |
200 + Remote-User/Remote-Email/… |
request reaches the backend |
| no/invalid session, portal known | 302 + Location |
redirect to the pangolin auth portal |
post-login handoff (?p_session_request=…) |
exchange via exchange-session, then 302 back to the cleaned URL + Set-Cookie |
logged-in session on the resource domain |
| header-auth challenge | 401 + WWW-Authenticate: Basic |
basic-auth prompt |
| pangolin unreachable | 503 |
denied — auth outages fail closed |
Configuration (env, all prefixed SHIM_): SHIM_PANGOLIN_API_BASE_URL
(required — the apiBaseUrl pangolin hands to badger),
SHIM_LISTEN (0.0.0.0:9001), SHIM_PATH_PREFIX (/verify, must equal
CONFIG_EXT_AUTHZ_PATH), SHIM_USER_SESSION_COOKIE_NAME (p_session_token),
SHIM_RESOURCE_SESSION_REQUEST_PARAM (p_session_request),
SHIM_PANGOLIN_TIMEOUT (10s), SHIM_CA_FILE (PEM bundle for an https
pangolin API), SHIM_TLS_SKIP_VERIFY (requires
I_UNDERSTAND_SHIM_TLS_SKIP_VERIFY_IS_INSECURE=true). A ready-to-adapt
Deployment + Service lives in
deploy/badger-shim.yaml.
To pass the verified identity on to backends, list the shim's response
headers in CONFIG_EXT_AUTHZ_HEADERS_TO_BACKEND
(e.g. remote-user,remote-email).
There are two ways the controller fills in tls.certificateRefs on its listeners:
-
Predetermined Secret naming. Set
CONFIG_TLS_SECRET_TEMPLATEto a template referencing{hostname}or{hostname-dashed}and create the Secrets yourself (or with a tool of your choice). The controller resolves the template per host and wires up thecertificateRefsfor every HTTPS listener. -
Annotation-driven (e.g. cert-manager). Set
CONFIG_HTTPROUTE_ANNOTATIONSand/orCONFIG_LISTENERSET_ANNOTATIONSto a comma-separatedkey=valuelist and the controller stamps those annotations onto every HTTPRoute / ListenerSet it creates. Combined with cert-manager's gateway-shim, cert-manager mints a Secret per hostname automatically and the template above just refers to that Secret. For example:CONFIG_HTTPROUTE_ANNOTATIONS="cert-manager.io/cluster-issuer=letsencrypt-prod" CONFIG_LISTENERSET_ANNOTATIONS="cert-manager.io/cluster-issuer=letsencrypt-prod" CONFIG_TLS_SECRET_TEMPLATE="{hostname-dashed}-tls"
Annotations apply only to the kind they're configured for; the synthesized
ServiceandEndpointSliceobjects are never tagged, so cert-manager (or any other consumer that filters by annotation) won't accidentally target them.
Note on the kind name. This controller emits
ListenerSet(gateway.networking.k8s.io/v1, experimental channel) — not the olderXListenerSetfromgateway.networking.x-k8s.io/v1alpha1. Make sure your installed Gateway API CRDs match (v1.5.1+).
-
Install Gateway API CRDs (experimental channel — needed for
ListenerSet):kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml
-
Install Envoy Gateway and create a
Gatewaynamedeg(or whatever you configure viaCONFIG_PARENT_GATEWAY). TheGatewayshould permit attachment fromListenerSets in your controller namespace: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
-
Apply the controller manifests:
kubectl apply -k deploy/
-
Point
CONFIG_ENDPOINTat your pangolin's internal Traefik provider URL (http://pangolin:8081/api/v1/traefik-configby default) and watch new HTTPRoutes appear as you add resources in pangolin.
Everything is configured via environment variables. The naming follows the
upstream Go controller (CONFIG_*) where the concepts overlap.
| Env var | Default | Notes |
|---|---|---|
CONFIG_ENDPOINT |
(required) | Pangolin Traefik provider URL |
CONFIG_PARENT_GATEWAY |
(required) | Name of the Envoy Gateway the ListenerSet attaches to |
CONFIG_PARENT_GATEWAY_NAMESPACE |
controller namespace | Namespace of the parent Gateway |
CONFIG_NAMESPACE |
default |
Namespace where HTTPRoute/ListenerSet/etc. are written |
CONFIG_LISTENERSET_NAME |
pangolin |
Name of the single ListenerSet object |
CONFIG_POLL_INTERVAL |
30s |
How often to poll pangolin |
CONFIG_FETCH_TIMEOUT |
30s |
Per-request HTTP timeout |
CONFIG_MAX_BACKOFF |
5m |
Upper bound on exponential backoff for failed polls |
CONFIG_MAX_RESPONSE_BODY_BYTES |
16777216 |
Cap on pangolin response size |
CONFIG_AUTH_HEADER |
(unset) | Sent as Authorization: if pangolin is protected |
CONFIG_CA_FILE |
(unset) | PEM root CA bundle for pangolin TLS |
CONFIG_TLS_SKIP_VERIFY |
false |
Requires I_UNDERSTAND_CONFIG_TLS_SKIP_VERIFY_IS_INSECURE=true to enable |
CONFIG_ALLOW_INSECURE_HTTP |
false |
Allow http:// pangolin endpoints |
CONFIG_HTTP_PORT |
80 |
Port for HTTP listeners on the ListenerSet |
CONFIG_HTTPS_PORT |
443 |
Port for HTTPS listeners on the ListenerSet |
CONFIG_ENABLE_HTTPS_LISTENERS |
true |
Add an HTTPS listener per host when TLS is configured |
CONFIG_ENABLE_TCP_ROUTES |
false |
Translate pangolin's tcp block into TCPRoutes + TCP listeners. See Raw TCP/UDP resources |
CONFIG_ENABLE_UDP_ROUTES |
false |
Translate pangolin's udp block into UDPRoutes + UDP listeners. See Raw TCP/UDP resources |
CONFIG_TLS_SECRET_TEMPLATE |
(unset) | Template for cert secret name. {hostname} and {hostname-dashed} placeholders. When unset, no HTTPS listener is created. |
CONFIG_TLS_SECRET_NAMESPACE |
controller namespace | Namespace where TLS Secrets live |
CONFIG_BACKEND_KIND |
envoy-backend |
envoy-backend (default) or service. See Backend strategy |
CONFIG_EXT_AUTHZ_SERVICE |
(unset) | Service name of the ext-authz endpoint for badger-protected routes. See Authentication |
CONFIG_EXT_AUTHZ_NAMESPACE |
controller namespace | Namespace of the ext-authz Service (cross-ns needs a ReferenceGrant) |
CONFIG_EXT_AUTHZ_PORT |
80 |
Port of the ext-authz Service |
CONFIG_EXT_AUTHZ_PATH |
(unset) | Optional path prefix for check requests |
CONFIG_EXT_AUTHZ_HEADERS_TO_EXT_AUTH |
cookie,authorization |
Client headers forwarded to the ext-authz service |
CONFIG_EXT_AUTHZ_HEADERS_TO_BACKEND |
(empty) | Auth-response headers copied onto the upstream request |
CONFIG_ALLOW_UNAUTHENTICATED_ROUTES |
false |
Dangerous. Emit badger-protected routes without auth |
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_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 |
CONFIG_INSTANCE_LABEL_KEY |
pangolin.envisia.de/instance |
Lets multiple controller instances coexist without trampling each other |
CONFIG_INSTANCE_LABEL_VALUE |
default |
|
CONFIG_HEALTH_LISTEN |
0.0.0.0:8081 |
/healthz (liveness) + /readyz (ready after the first successful poll). Set off to disable. |
CONFIG_READ_ONLY |
false |
Dry-run: log what would happen, do not call apply/delete |
CONFIG_LOG_TRAEFIK_CONFIG |
false |
Debug: log the raw pangolin response at debug level |
cargo test # unit + fixture tests
cargo run # needs CONFIG_ENDPOINT, CONFIG_PARENT_GATEWAY, kubeconfig
docker build -t pangolin-gateway-controller:dev .Fixtures under tests/fixtures/ are real pangolin Traefik provider responses
from the upstream pangolin-kube-controller test corpus.
This project was AI-generated using the Go reference implementation pangolin-kube-controller as the architectural model. Because that upstream is licensed under the GNU Affero General Public License v3.0, and the design and behaviour of this controller (polling model, managed-label GC, SSA apply strategy, fixture corpus) was derived from it, the combined work must be distributed under the AGPL-3.0:
- GNU Affero General Public License v3.0 (LICENSE-AGPL or https://www.gnu.org/licenses/agpl-3.0.html)
Separately, the new code contributed in this repository — i.e. the Rust implementation itself, exclusive of any patterns inherited from pangolin-kube-controller — is additionally offered by its authors under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option, so that those original portions may be reused outside the context of this combined work. The dual MIT/Apache-2.0 grant does not extend to any portion derived from pangolin-kube-controller or any other AGPL-3.0 code — if you cannot satisfy the AGPL-3.0, you cannot redistribute this repository as a whole.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be licensed under AGPL-3.0 for the combined work and additionally available under MIT or Apache-2.0 for the original portions, as above, without any additional terms or conditions.
- pangolin for the upstream tunneled reverse-proxy server.
- pangolin-kube-controller for the Go-based Traefik-CRD prior art that this project mirrors at a high level.
- kube-rs and gateway-api-rs for making Rust controllers pleasant.