Skip to content

freepik-company/customrouter

Repository files navigation

CustomRouter

Warning

This project is under active development and evolving quickly. Some features may be missing, incomplete, or subject to change. Use in production at your own discretion.

Dynamic HTTP routing for Kubernetes with Envoy and Istio. Define your routing rules as Kubernetes resources and let CustomRouter handle the rest.

CustomRouter consists of two components that work together:

  • Operator: Watches CustomHTTPRoute resources and compiles them into optimized routing tables
  • External Processor: An Envoy ext_proc that receives requests from your gateway and routes them based on the compiled rules

How it works

flowchart LR
    subgraph Kubernetes
        CHR[CustomHTTPRoute]
        EPA[ExternalProcessorAttachment]
        OP[Operator]
        CM[ConfigMap]
        EF[EnvoyFilter]
    end

    subgraph "Istio Gateway"
        GW[Gateway Pod]
        EP[External Processor]
    end

    subgraph Backend
        SVC1[Service A]
        SVC2[Service B]
        SVC3[Service C]
    end

    CHR -->|watches| OP
    EPA -->|watches| OP
    OP -->|generates| CM
    OP -->|generates| EF
    CM -->|loads routes| EP
    EF -->|configures| GW
    GW -->|ext_proc call| EP
    EP -->|routing decision| GW
    GW --> SVC1
    GW --> SVC2
    GW --> SVC3
Loading
  1. You create CustomHTTPRoute resources defining your routing rules
  2. The operator compiles these rules into optimized ConfigMaps
  3. You create an ExternalProcessorAttachment to connect the external processor to your gateway
  4. The operator generates EnvoyFilters that configure Istio to use the external processor
  5. Incoming requests hit the gateway, which calls the external processor
  6. The external processor matches the request against the routing rules and tells Envoy where to send it

Requirements

  • Kubernetes v1.26+
  • Istio v1.18+ (for gateway integration)
  • Go v1.25+ (for development)

Installation

Using Helm (recommended)

helm install customrouter oci://ghcr.io/freepik-company/customrouter/helm-chart/customrouter \
  --namespace customrouter \
  --create-namespace

Quick Start

1. Create routing rules

apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
  name: my-routes
spec:
  # Target identifies which external processor handles these routes
  targetRef:
    name: default

  # Hostnames this route applies to
  hostnames:
    - www.example.com
    - example.com

  # Optional path prefixes (e.g., for i18n)
  pathPrefixes:
    values: [es, fr, de]
    policy: Optional  # Optional | Required | Disabled
    # Control which match types get prefix expansion (default: all)
    expandMatchTypes: [PathPrefix, Exact]  # Only expand these types

  # Rules (max 100 per CustomHTTPRoute)
  rules:
    # Simple prefix match (default)
    - matches:  # max 50 matches per rule
        - path: /api
      backendRefs:
        - name: api-service       # RFC 1123 label (no dots, max 63 chars)
          namespace: backend
          port: 8080

    # Exact match with high priority
    - matches:
        - path: /health
          type: Exact
          priority: 2000          # range: 1-10000
      backendRefs:
        - name: health-service
          namespace: infra
          port: 8080

    # Regex match
    - matches:
        - path: ^/users/[0-9]+/profile$
          type: Regex
      backendRefs:
        - name: users-service
          namespace: backend
          port: 8080

    # Default fallback (low priority)
    - matches:
        - path: /
          priority: 100
      backendRefs:
        - name: default-service
          namespace: web
          port: 80

2. Attach to your gateway

apiVersion: customrouter.freepik.com/v1alpha1
kind: ExternalProcessorAttachment
metadata:
  name: production-gateway
  namespace: istio-system
spec:
  # Select which gateway pods to attach to
  gatewayRef:
    selector:
      istio: gateway-production

  # Reference to the external processor service
  externalProcessorRef:
    service:
      name: customrouter-extproc
      namespace: customrouter
      port: 9001

  # Optional: Generate catch-all routes for hostnames without HTTPRoute
  # This allows CustomHTTPRoute to handle requests without requiring
  # a base HTTPRoute to be configured separately
  catchAllRoute:
    hostnames:
      - example.com
      - www.example.com
    backendRef:
      name: default-backend
      namespace: web
      port: 80

Helm Chart Configuration

The chart supports deploying the operator and multiple external processors:

# values.yaml
operator:
  enabled: true
  replicaCount: 1
  args:
    - --leader-elect
    - --health-probe-bind-address=:8081

  # Optional: enable validating webhooks for hostname conflict detection
  webhook:
    enabled: true
    timeoutSeconds: 10
    # certManager:
    #   enabled: true
    #   issuerName: my-cluster-issuer

externalProcessors:
  # Default processor
  default:
    enabled: true
    replicaCount: 2
    args:
      - --addr=:9001
      - --target-name=default
      - --routes-configmap-namespace=default
      - --access-log=true

  # Additional processor for different routes
  internal:
    enabled: true
    replicaCount: 1
    args:
      - --addr=:9001
      - --target-name=internal
      - --routes-configmap-namespace=default
      - --access-log=false

# Deploy additional resources
extraObjects:
  - apiVersion: customrouter.freepik.com/v1alpha1
    kind: CustomHTTPRoute
    metadata:
      name: my-routes
    spec:
      targetRef:
        name: default
      # ...

See chart/values.yaml for all available options.

Development

Prerequisites

  • Go v1.25+
  • Docker
  • kubectl
  • A Kubernetes cluster (kind, minikube, etc.)

Building

# Build all binaries
make build-all

# Build only the operator
make build

# Build only the external processor
make build-extproc

Running locally

# Install CRDs
make install

# Run the operator locally
make run

# Run the external processor locally
make run-extproc

Testing

# Run unit tests
make test

# Run e2e tests (requires a cluster)
make test-e2e

Docker images

# Build all images
make docker-build-all

# Build and push all images
make docker-build-all docker-push-all

Code generation

# Generate CRDs, RBAC, and DeepCopy methods
make generate manifests

Available Make targets

Run make help to see all available targets:

Usage:
  make <target>

General:
  help             Display this help

Development:
  manifests        Generate CRDs and RBAC
  generate         Generate DeepCopy methods
  fmt              Run go fmt
  vet              Run go vet
  test             Run tests
  test-e2e         Run e2e tests

Build:
  build            Build operator binary
  build-extproc    Build external processor binary
  build-all        Build all binaries
  run              Run operator locally
  run-extproc      Run external processor locally

Docker:
  docker-build     Build operator image
  docker-build-extproc  Build external processor image
  docker-build-all Build all images
  docker-push      Push operator image
  docker-push-extproc   Push external processor image
  docker-push-all  Push all images

Deployment:
  install          Install CRDs
  uninstall        Uninstall CRDs
  deploy           Deploy to cluster
  undeploy         Undeploy from cluster
  sync-chart-crds  Copy generated CRDs into the Helm chart directory

Architecture

Operator

The operator watches CustomHTTPRoute resources, compiles routing rules, and writes them to ConfigMaps in the namespace configured by --routes-configmap-namespace (default: default).

Flag Default Description
--routes-configmap-namespace default Namespace where route ConfigMaps are written
--leader-elect false Enable leader election for HA
--health-probe-bind-address :8081 Address for health probes
--enable-webhooks false Enable validating admission webhooks
--webhook-port 9443 Port for the webhook server
--webhook-config-name "" ValidatingWebhookConfiguration name (auto-cert mode)
--webhook-service-name "" Webhook Service name for TLS SAN (auto-cert mode)
--webhook-cert-path "" Directory with TLS certs (cert-manager mode)

Security

Both the operator and external processor containers run with a hardened security context:

  • runAsNonRoot: true — containers never run as root
  • readOnlyRootFilesystem: true — no writes to the container filesystem
  • capabilities: drop: ["ALL"] — all Linux capabilities are dropped
  • seccompProfile: type: RuntimeDefault — default seccomp filtering

These defaults are applied by the Helm chart and can be customized via operator.securityContext and externalProcessors.<name>.securityContext in values.yaml.

External Processor

The external processor reads route ConfigMaps and makes routing decisions for Envoy.

Flag Default Description
--addr :9001 gRPC listen address
--target-name "" Target name to filter ConfigMaps (matches spec.targetRef.name)
--routes-configmap-namespace "" Namespace to read ConfigMaps from (empty = all namespaces)
--access-log true Enable access logging
--debug false Enable debug logging and gRPC reflection
--kubeconfig "" Path to kubeconfig (uses in-cluster config if not set)

Important: Set --routes-configmap-namespace on the external processor to match the operator's --routes-configmap-namespace. This prevents stale ConfigMaps in other namespaces from causing route conflicts.

CustomHTTPRoute

Defines routing rules for a set of hostnames. Rules are compiled into an optimized routing table stored in ConfigMaps.

Field Description
targetRef.name Which external processor handles these routes
hostnames List of hostnames this route applies to (max 50)
pathPrefixes Optional prefixes to prepend to all paths (max 100 values)
pathPrefixes.expandMatchTypes Which match types are expanded with prefixes (default: all)
rules[].matches Path matching conditions (max 50 per rule)
rules[].actions Optional transformations (redirect, rewrite, headers)
rules[].actions[].rewrite.preservePrefix Prepend language prefix to rewrite path in expanded routes
rules[].actions[].redirect.preservePrefix Prepend language prefix to redirect path in expanded routes
rules[].backendRefs Target services — name must be a valid RFC 1123 label (no dots)
rules[].allowOverlap Permit overlap with other CustomHTTPRoutes (warn instead of reject)

ExternalName Services

When a backendRef points to a Kubernetes Service of type ExternalName, the controller automatically resolves spec.externalName and uses it as the backend hostname. This is necessary because Istio/Envoy does not create clusters for the .svc.cluster.local FQDN of ExternalName services.

# Given this Service:
apiVersion: v1
kind: Service
metadata:
  name: profile-external
  namespace: web
spec:
  type: ExternalName
  externalName: stable.profile.apps.internal

# This backendRef:
backendRefs:
  - name: profile-external
    namespace: web
    port: 80

# Resolves to: stable.profile.apps.internal:80
# Instead of: profile-external.web.svc.cluster.local:80

The controller watches Services and re-reconciles affected CustomHTTPRoutes when an ExternalName service changes.

ExternalProcessorAttachment

Connects an external processor to Istio gateway pods by generating EnvoyFilters.

Field Description
gatewayRef.selector Labels to match gateway pods
externalProcessorRef.service External processor service reference
externalProcessorRef.timeout gRPC connection timeout — valid duration string, e.g. 5s, 500ms (default: "5s")
externalProcessorRef.messageTimeout Message exchange timeout — valid duration string, e.g. 5s, 500ms (default: "5s")
catchAllRoute.hostnames Hostnames to generate catch-all routes for
catchAllRoute.backendRef Default backend for unmatched requests

Status Conditions

Both CRDs report status via standard Kubernetes conditions. Each condition includes ObservedGeneration so clients can distinguish stale status from the current spec revision.

CRD Condition Description
CustomHTTPRoute Reconciled Whether the manifest was processed successfully
CustomHTTPRoute ConfigMapSynced Whether the ConfigMap was generated and synced
ExternalProcessorAttachment Reconciled Whether the attachment was processed successfully
ExternalProcessorAttachment EnvoyFilterSynced Whether the EnvoyFilters were generated and synced

Catch-All Routes

By default, CustomHTTPRoute requires a base HTTPRoute to be configured at the Istio Gateway level. Without it, requests are rejected with 404 before reaching the external processor.

The catchAllRoute field solves this by generating an EnvoyFilter that creates virtual hosts for the specified hostnames:

spec:
  catchAllRoute:
    hostnames:
      - example.com
      - api.example.com
    backendRef:
      name: default-backend
      namespace: default
      port: 80

When configured, the operator generates three EnvoyFilters:

  1. <name>-extproc: Inserts the ext_proc filter
  2. <name>-routes: Adds dynamic routing based on ext_proc headers
  3. <name>-catchall: Creates catch-all virtual hosts for the specified hostnames

Match Types

Type Description Example
PathPrefix Matches path prefix (default) /api matches /api/users
Exact Matches exact path /health only matches /health
Regex Go regexp syntax ^/users/[0-9]+$

Expand Match Types

By default, all match types (PathPrefix, Exact, Regex) are expanded with path prefixes. You can control which types are expanded using expandMatchTypes:

pathPrefixes:
  values: [es, fr]
  policy: Optional
  expandMatchTypes: [PathPrefix]  # Only expand PathPrefix, leave Exact and Regex as-is

This can also be overridden at the rule level:

rules:
  - matches:
      - path: /user/me
        type: Exact
    backendRefs:
      - name: user-service
        namespace: backend
        port: 8080
    pathPrefixes:
      policy: Optional
      expandMatchTypes: [PathPrefix]  # This rule won't expand Exact matches

Priority

Routes are evaluated by priority (higher first). Default priority is 1000. Valid range: 1–10000.

  • Use high priority (e.g., 2000) for specific routes like /health
  • Use low priority (e.g., 100) for catch-all routes like /

Actions

Actions allow you to transform requests before forwarding or return immediate responses.

Action Type Description
redirect Return HTTP redirect (301, 302, 307, 308). No backend needed.
rewrite Rewrite path and/or hostname before forwarding
header-set Set a header (overwrite if exists)
header-add Add a header (append if exists)
header-remove Remove a header

Redirect Example

rules:
  - matches:
      - path: /old-page
        type: Exact
    actions:
      - type: redirect
        redirect:
          path: /new-page
          statusCode: 301
    # No backendRefs needed for redirects

Rewrite Example

For PathPrefix matches, the rewrite replaces only the matched prefix and preserves the remaining path suffix and query parameters. For Exact and Regex matches, the rewrite replaces the entire path.

rules:
  # Prefix rewrite: /mockup-editor-api/unity?page=1 -> /api/v1/unity?page=1
  - matches:
      - path: /mockup-editor-api
    actions:
      - type: rewrite
        rewrite:
          path: /api/v1
    backendRefs:
      - name: backend-api
        namespace: backend
        port: 80

  # Rewrite with hostname change
  - matches:
      - path: /blog
    actions:
      - type: rewrite
        rewrite:
          path: /cms/blog
          hostname: cms-internal.svc.cluster.local
    backendRefs:
      - name: cms-service
        namespace: backend
        port: 8080

  # Full rewrite with variables: /users/42 -> /api/v2/profile/42
  - matches:
      - path: /users
    actions:
      - type: rewrite
        rewrite:
          path: /api/v2/profile/${path.segment.1}
    backendRefs:
      - name: user-service
        namespace: backend
        port: 8080

  # Explicit replacePrefixMatch override:
  # Force full rewrite even on a PathPrefix match
  - matches:
      - path: /old-api
    actions:
      - type: rewrite
        rewrite:
          path: /v2
          replacePrefixMatch: false
    backendRefs:
      - name: api-service
        namespace: backend
        port: 8080

Preserve Prefix in Rewrites and Redirects

When using pathPrefixes, expanded routes normally share the same rewrite/redirect path. This means the language prefix is lost during rewrite:

Request: /es/blog/post1 → match /es/blog → rewrite /cms/blog/post1  (prefix /es lost)

The preservePrefix option prepends the language prefix to the rewrite/redirect path at expansion time, so each expanded route gets its own prefixed path:

spec:
  pathPrefixes:
    values: [es, fr]
    policy: Optional
  rules:
    # Rewrite with preservePrefix
    - matches:
        - path: /blog
      actions:
        - type: rewrite
          rewrite:
            path: /cms/blog
            preservePrefix: true
      backendRefs:
        - name: cms-service
          namespace: backend
          port: 8080

    # Redirect with preservePrefix
    - matches:
        - path: /old-blog
      actions:
        - type: redirect
          redirect:
            path: /new-blog
            statusCode: 301
            preservePrefix: true

This generates the following expanded routes:

Route path Rewrite/Redirect path
/blog /cms/blog
/es/blog /es/cms/blog
/fr/blog /fr/cms/blog
/old-blog /new-blog
/es/old-blog /es/new-blog
/fr/old-blog /fr/new-blog

More examples:

Localized app with a CMS backend — the CMS needs the language prefix to serve the right content:

spec:
  pathPrefixes:
    values: [es, fr, de, it, pt]
    policy: Required   # All routes must have a language prefix
  rules:
    - matches:
        - path: /
      actions:
        - type: rewrite
          rewrite:
            path: /app
            preservePrefix: true
      backendRefs:
        - name: frontend
          namespace: web
          port: 3000
Request Route match Rewrite
/es /es /es/app
/fr/products/123 /fr /fr/app/products/123

Selective preservePrefix — only the CMS rewrite needs it, the API rewrite doesn't:

spec:
  pathPrefixes:
    values: [es, fr]
    policy: Optional
  rules:
    # CMS needs the language to serve localized content
    - matches:
        - path: /blog
      actions:
        - type: rewrite
          rewrite:
            path: /cms/blog
            preservePrefix: true
      backendRefs:
        - name: cms
          namespace: backend
          port: 8080

    # API is language-agnostic, no preservePrefix needed
    - matches:
        - path: /api
      actions:
        - type: rewrite
          rewrite:
            path: /v2/api
      backendRefs:
        - name: api
          namespace: backend
          port: 8080
Request Rewrite
/es/blog/post1 /es/cms/blog/post1
/blog/post1 /cms/blog/post1
/es/api/users /v2/api/users
/api/users /v2/api/users

Notes:

  • Works with PathPrefix and Exact match types. Not supported for Regex (rejected at validation).
  • When preservePrefix is false or not set, all expanded routes share the same rewrite/redirect path (existing behavior).
  • When no pathPrefixes are defined, preservePrefix is a no-op.
  • Zero runtime overhead: prefix is resolved at expansion time, not per-request.

Header Manipulation Example

rules:
  - matches:
      - path: /api
    actions:
      - type: header-set
        header:
          name: X-Real-IP
          value: ${client_ip}
      - type: header-add
        header:
          name: X-Request-ID
          value: ${request_id}
      - type: header-remove
        headerName: X-Internal-Debug
    backendRefs:
      - name: api-service
        namespace: backend
        port: 8080

Supported Variables

Variables can be used in redirect.path, rewrite.path, and header.value:

Variable Description
${path} Original request path
${host} Original request host
${method} HTTP method (GET, POST, etc.)
${scheme} Request scheme (http or https)
${client_ip} Client IP from X-Forwarded-For
${request_id} Request ID from X-Request-ID header
${path.segment.N} Nth path segment (0-indexed)

Validation Limits

The CRD enforces the following limits to prevent resource exhaustion:

Field Limit
spec.hostnames[] Max 50 items
spec.rules[] Max 100 items
rules[].matches[] Max 50 items per rule
pathPrefixes.values[] Max 100 items
matches[].priority Range 1–10000
backendRefs[].name RFC 1123 label (max 63 chars, no dots)
backendRefs[].namespace RFC 1123 label (max 63 chars, no dots)
matches[].path MaxLength 4096
rewrite.path MaxLength 4096
rewrite.hostname MaxLength 253
redirect.path MaxLength 4096
redirect.hostname MaxLength 253
header.name MaxLength 256
header.value MaxLength 4096
action.headerName MaxLength 256
externalProcessorRef.timeout Valid duration pattern: ^[0-9]+(s|ms|m|h)$
externalProcessorRef.messageTimeout Valid duration pattern: ^[0-9]+(s|ms|m|h)$

Additionally, route expansion is capped at 500,000 routes per CRD at runtime. CRDs exceeding this limit are skipped with an error log.

Multi-Tenancy

In multi-tenant clusters, hostnames are scoped by namespace. When multiple CustomHTTPRoute resources across different namespaces target the same hostname, the namespace that appears first alphabetically owns that hostname. Routes from non-owning namespaces for the same hostname are silently dropped.

Validating Webhooks

The operator includes optional validating admission webhooks that prevent route conflicts at admission time, before resources reach etcd.

Two webhooks are provided:

  • CustomHTTPRoute webhook (failurePolicy: Fail): Blocks creation/update if another CustomHTTPRoute with the same target has overlapping hostname + route match (path + method + headers + query parameters). Different paths, methods, headers, or query parameters on the same hostname are allowed.
  • HTTPRoute webhook (failurePolicy: Ignore): Blocks creation/update if a Gateway API HTTPRoute uses a hostname + route match already claimed by a CustomHTTPRoute.

Conflict detection uses the full HTTPRouteMatch surface:

  • Path: type + value (with trailing slash normalization — /api/ equals /api)
  • Method: empty means "matches all methods"; different methods (e.g. GET vs POST) don't conflict
  • Headers: empty means "matches all"; different values for the same header name don't conflict
  • Query parameters: same logic as headers

Enable in Helm:

operator:
  webhook:
    enabled: true

By default, TLS certificates are auto-generated at startup and shared across replicas via a Secret. A CABundleReconciler periodically ensures the CA bundle survives Helm upgrades. For environments with cert-manager:

operator:
  webhook:
    enabled: true
    certManager:
      enabled: true
      issuerName: my-cluster-issuer
      issuerKind: ClusterIssuer

See chart/values.yaml for all webhook options including timeoutSeconds, namespaceSelector, failurePolicy, and caBundle.

Allowing Overlapping Routes (allowOverlap)

The allowOverlap field on a rule lets it overlap with rules in other CustomHTTPRoutes. When true, the webhook emits a warning instead of rejecting the resource. This enables zero-downtime migrations between CustomHTTPRoutes.

Note: Conflicts with Gateway API HTTPRoute resources are always rejected regardless of this setting.

Migrating a route between CustomHTTPRoutes

Given an existing route in legacy-routes:

apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
  name: legacy-routes
spec:
  targetRef:
    name: default
  hostnames:
    - www.example.com
  rules:
    - matches:
        - path: /api/users
      backendRefs:
        - name: users-service
          namespace: backend
          port: 8080

Create the new route with allowOverlap: true to avoid rejection:

apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
  name: new-routes
spec:
  targetRef:
    name: default
  hostnames:
    - www.example.com
  rules:
    - matches:
        - path: /api/users
      backendRefs:
        - name: users-service-v2
          namespace: backend
          port: 8080
      allowOverlap: true

The webhook accepts the resource with a warning:

Warning: route conflict on hostnames [www.example.com]: PathPrefix /api/users
already defined in CustomHTTPRoute legacy-routes (allowed via allowOverlap)

Once verified, remove the rule from legacy-routes and drop allowOverlap from new-routes.

Selective overlap per rule

allowOverlap is set per rule, so only the rules that need it are affected:

rules:
  - matches:
      - path: /api/users
    backendRefs:
      - name: users-service-v2
        namespace: backend
        port: 8080
    allowOverlap: true   # Warning on conflict

  - matches:
      - path: /api/orders
    backendRefs:
      - name: orders-service
        namespace: backend
        port: 8080
    # allowOverlap defaults to false — conflicts are rejected

License

Copyright 2024-2026 Freepik Company S.L.

Licensed under the Apache License, Version 2.0. See LICENSE for details.

About

Dynamic HTTP routing for Kubernetes using Envoy ext_proc and Istio. Define routes as CRDs, automatically configure your Gateway API gateways

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages