caddyconsul

package module
v0.0.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 31, 2026 License: MIT Imports: 31 Imported by: 0

README

Caddy Consul

Dynamic Caddy routing from Consul service registrations. Replaces Fabio as a single ingress layer by driving both HTTP and TCP/TLS routing directly from Consul service catalog and health data supporting traditional consul services and consul connect with intentions.

NOTE: This Caddy plugin is NOT a storage engine for Caddy.

Features

  • Dynamic Discovery: Watches Consul catalog and health APIs via blocking queries (not polling)
  • HTTP Routing: Host-based, path-based, wildcard hosts, weighted upstreams, strip-prefix
  • TCP/TLS Routing: Port-based, SNI-based, TLS passthrough via caddy-l4
  • Health-Aware: Only routes to healthy upstreams (configurable policy)
  • Consul Connect: Sidecar proxy integration via Agent API (sidecar mode only)
  • Fabio Compatible: Supports urlprefix- tags for gradual migration
  • Zero-Restart: All routing changes apply dynamically via Caddy Admin API
  • Conflict Detection: Static config wins over Consul routes; first-seen wins among duplicates

Installation

Building with xcaddy
xcaddy build \
    --with github.com/honest-hosting/caddy-consul \
    --with github.com/mholt/caddy-l4@...

Configuration

Configuration Options
Option Description Default
address Consul HTTP API address 127.0.0.1:8500
token Consul ACL token (empty)
scheme Consul API scheme (http or https) http
datacenter Consul datacenter (empty, uses agent default)
tls_ca Path to CA certificate for Consul TLS (empty)
tls_cert Path to client certificate for Consul TLS (empty)
tls_key Path to client key for Consul TLS (empty)
insecure_skip_verify Skip TLS verification for Consul connection false
service_proxy_enable Enable service discovery and direct routing true
health_policy Health check policy: passing, warning, any passing
conflict_policy Route conflict policy: reject, first-wins reject
connect_proxy_enable Enable Connect sidecar proxy routing false
connect_service_name Caddy's service identity in the mesh <hostname>-caddy-consul
connect_auto_register Auto-register Caddy in Consul on startup true
poll_interval Delay between sequential health queries during initial load/sync 50ms
full_sync_interval How often to re-fetch all services (catches metadata changes) 5m
debounce Debounce window for rapid Consul changes 500ms
service_tag Sentinel tag for service proxy discovery caddy-consul
connect_tag Sentinel tag for connect proxy discovery caddy-consul-connect
connect_port_range_start Start of port range for dynamic sidecar upstreams 19000
connect_port_range_end End of port range for dynamic sidecar upstreams 29000
caddy_admin_api Caddy admin API address for TCP route reconciliation localhost:2019
data_dir Directory for runtime state (persisted across reloads) $XDG_DATA_HOME/caddy/caddy-consul
metrics Admin API path for Prometheus metrics (empty, disabled)
l4_mode Layer 4 routing mode: global or node global
l4_node_hostname Explicit Consul node name override for l4_mode=node (auto-detected from agent)

Environment variables CONSUL_HTTP_ADDR, CONSUL_HTTP_TOKEN, CONSUL_HTTP_SSL, CONSUL_CACERT, CONSUL_CLIENT_CERT, and CONSUL_CLIENT_KEY are supported as fallbacks when the corresponding option is not set.

Quick Start

Minimal configuration:

{
    admin localhost:2019

    consul {
        address 127.0.0.1:8500
    }
}

:443 {
    consul_proxy
}

Note: The consul_proxy handler must be added to your server block. It dynamically routes HTTP requests based on Consul service discovery. Static routes defined before consul_proxy take precedence. The Caddy admin API is only needed for TCP/L4 route management.

Complete Caddyfile Example
{
    # Admin API required for caddy-consul
    admin localhost:2019

    consul {
        # Consul connection
        address 127.0.0.1:8500
        token {env.CONSUL_HTTP_TOKEN}
        scheme https
        datacenter dc1

        # TLS to Consul
        tls_ca /etc/consul/ca.pem
        tls_cert /etc/consul/cert.pem
        tls_key /etc/consul/key.pem

        # Skip TLS verification for Consul connection (default: false)
        insecure_skip_verify false

        # Enable service proxy (default: true). Set to false to disable service discovery.
        service_proxy_enable true

        # Health policy: "passing" (default), "warning", "any"
        health_policy passing

        # Conflict policy: "reject" (default), "first-wins"
        conflict_policy reject

        # Enable connect proxy via sidecar (default: false)
        connect_proxy_enable false

        # Caddy's service identity in the mesh (default: <hostname>-caddy-consul)
        connect_service_name my-ingress

        # Auto-register Caddy as a service in Consul on startup (default: true)
        connect_auto_register true

        # Max concurrent Consul health check queries (default: 5)
        max_concurrent_checks 5

        # Debounce window for rapid Consul changes (default: 500ms)
        debounce 500ms

        # Layer 4 mode: "global" (default) or "node"
        # In "node" mode, TCP/L4 routes are only created for services with
        # at least one healthy instance on the local Consul node
        l4_mode global

        # Explicit node name override for l4_mode=node (default: auto-detected from Consul agent)
        # l4_node_hostname worker-03.dc1

        # Enable metrics on admin API (optional)
        metrics /metrics/consul
    }
}

:443 {
    # Static routes defined here always win over Consul-discovered routes
    @admin host admin.internal
    handle @admin {
        reverse_proxy localhost:9090
    }

    # Consul dynamic routing (catch-all for all other hosts)
    consul_proxy
}
Complete Caddy JSON Example

The JSON field names match the Caddyfile directive names. The consul config lives under apps.consul:

{
  "admin": {
    "listen": "localhost:2019"
  },
  "apps": {
    "consul": {
      "address": "127.0.0.1:8500",
      "token": "",
      "scheme": "https",
      "datacenter": "dc1",
      "tls_ca": "/etc/consul/ca.pem",
      "tls_cert": "/etc/consul/cert.pem",
      "tls_key": "/etc/consul/key.pem",
      "insecure_skip_verify": false,
      "service_proxy_enable": true,
      "health_policy": "passing",
      "conflict_policy": "reject",
      "connect_proxy_enable": false,
      "connect_service_name": "my-ingress",
      "connect_auto_register": true,
      "poll_interval": "50ms",
      "full_sync_interval": "5m",
      "debounce_duration": "500ms",
      "service_tag": "caddy-consul",
      "connect_tag": "caddy-consul-connect",
      "caddy_admin_api": "localhost:2019",
      "data_dir": "/var/lib/caddy-consul",
      "metrics": "/metrics/consul",
      "l4_mode": "global",
      "l4_node_hostname": ""
    },
    "http": {
      "servers": {
        "srv0": {
          "listen": [":443"],
          "routes": [
            {
              "handle": [{"handler": "consul_proxy"}]
            }
          ]
        }
      }
    }
  }
}

Consul Service Configuration

Services declare routing instructions via Consul service metadata or tags.

Service Discovery Tags

Services using metadata-based routing (not Fabio urlprefix- tags) must include a sentinel tag in their service registration. This tag tells caddy-consul to inspect the service's metadata for routing configuration. Without it, the service will be skipped during catalog discovery.

Tag Purpose Default
caddy-consul Standard service proxy routing Configurable via service_tag
caddy-consul-connect Connect mesh service routing Configurable via connect_tag

Standard service (direct routing):

{
    "service": {
        "name": "my-app",
        "port": 8080,
        "tags": ["caddy-consul"],
        "meta": {
            "caddy-host": "app.example.com"
        }
    }
}

Connect service (sidecar routing):

{
    "service": {
        "name": "my-mesh-app",
        "port": 8080,
        "tags": ["caddy-consul-connect"],
        "meta": {
            "caddy-host": "mesh-app.example.com"
        },
        "connect": {
            "sidecar_service": {}
        }
    }
}

Services using Fabio-compatible urlprefix- tags do NOT need sentinel tags — they are detected automatically.

Metadata Format (Preferred)
Key Description Default
caddy-protocol http, https, tcp, tls-passthrough http
caddy-host Hostname for HTTP routing or SNI for TLS (required for HTTP)
caddy-path HTTP path prefix /
caddy-port TCP listener port (required for TCP)
caddy-priority Route priority (higher wins) 0
caddy-weight Upstream weight 1
caddy-strip-prefix Strip path prefix before forwarding (true/false) false
caddy-redirect-code HTTP redirect status code (301, 302, etc.) (empty = proxy)
caddy-redirect-url Redirect target URL (may use {http.request.uri}) (empty = proxy)
caddy-enabled Enable/disable route (true/false) true
Example: HTTP Redirect
{
    "service": {
        "name": "old-domain",
        "port": 8080,
        "meta": {
            "caddy-host": "old.example.com",
            "caddy-redirect-code": "301",
            "caddy-redirect-url": "https://new.example.com{http.request.uri}"
        }
    }
}

Redirect routes return an HTTP redirect response instead of proxying. They work in both service and connect proxy modes. The {http.request.uri} placeholder preserves the original request path.

Example: Consul Service Tags

Routing can also be configured via Consul service tags (useful for Nomad job definitions or consul services register CLI):

{
    "service": {
        "name": "web-app",
        "port": 8080,
        "tags": [
            "urlprefix-app.example.com/"
        ]
    }
}
{
    "service": {
        "name": "old-domain",
        "port": 8080,
        "tags": [
            "urlprefix-old.example.com/ redirect=301,https://new.example.com$path"
        ]
    }
}

Service metadata (meta) and Fabio-compatible tags (urlprefix-) can be used interchangeably. Metadata takes precedence if both are present.

Example: Simple HTTP Service
{
    "service": {
        "name": "web-app",
        "port": 8080,
        "meta": {
            "caddy-host": "app.example.com",
            "caddy-path": "/",
            "caddy-protocol": "http"
        }
    }
}
Multi-Route Metadata

For services that need multiple routes, use indexed keys:

{
    "meta": {
        "caddy-route-0-protocol": "http",
        "caddy-route-0-host": "app.example.com",
        "caddy-route-0-path": "/api",
        "caddy-route-1-protocol": "tcp",
        "caddy-route-1-port": "5432"
    }
}

If both indexed (caddy-route-N-*) and non-indexed (caddy-*) keys exist, indexed keys take precedence.

Fabio-Compatible Tags

For migration from Fabio, urlprefix- tags are supported:

urlprefix-app.example.com/
urlprefix-app.example.com/api strip=/api
urlprefix-:5432 proto=tcp
urlprefix-secure.example.com/ proto=https
urlprefix-old.example.com/ redirect=301,https://new.example.com$path

Supported modifiers:

  • proto=http|https|tcp (default: http)
  • strip=<path> (strip path prefix)
  • redirect=<code>,<url> (HTTP redirect; $path expands to request URI)

Port handling: Standard ports (:80, :443) in the host are stripped automatically (e.g., urlprefix-host:80/ matches on host). Caddy's automatic HTTPS handles HTTP→HTTPS redirects.

If both metadata and Fabio tags exist, metadata takes precedence.

Architecture

How consul_proxy works

HTTP routing uses an in-memory route table — no Caddy config reloads for HTTP route changes:

  1. The consul app watches Consul for service changes using 2 blocking queries (catalog + health state)
  2. On change, routes are compiled and the in-memory route table is updated
  3. The consul_proxy HTTP handler matches each request against the route table at request time
  4. Static routes defined in your Caddyfile run first (before consul_proxy) and always win

TCP routing uses the Caddy admin API to create L4 servers (since new TCP listener ports require config changes). TCP state is persisted across reloads to prevent cascading reload cycles.

Scaling

The watcher uses only 2 Consul connections regardless of service count:

  • Catalog watcher: blocking query on /v1/catalog/services — detects service add/remove
  • Health state watcher: blocking query on /v1/health/state/passing — detects health changes across ALL services

Health changes (node failure, instance recovery) update cached state locally — zero extra Consul queries. New services get a single targeted health fetch. A periodic full sync (full_sync_interval) catches metadata changes.

Scale Connections Cold start time Health change detection
50 services 2 ~2.5s Instant
1,000 services 2 ~50s Instant
10,000 services 2 ~8min (routes from disk instantly) Instant
Admin API endpoints
  • GET /consul/metrics — Prometheus metrics (if enabled)
  • GET /consul/state — JSON dump of current state
  • GET /consul/routes — JSON dump of in-memory HTTP route table

Routing

HTTP Routing

The consul_proxy handler dynamically routes HTTP requests based on Consul service discovery:

  • Host-based and path-based matching
  • Wildcard hosts (*.example.com)
  • Longest path prefix wins
  • Automatic upstream load balancing
  • WebSocket support
  • Strip-prefix rewrite
  • HTTP redirects
TCP Routing

TCP routes automatically create L4 (caddy-l4) servers:

  • urlprefix-:5432 proto=tcp creates a listener on port 5432
  • Multiple services on the same port are disambiguated by SNI matching
  • TLS passthrough forwards encrypted traffic without termination
L4 Mode

The l4_mode option controls how TCP/TLS-passthrough routes are materialized:

Mode Behavior
global (default) All TCP/L4 routes are created regardless of which node Caddy runs on
node TCP/L4 routes are only created when at least one healthy upstream runs on the same Consul node as Caddy

In node mode, the plugin resolves the local Consul node name automatically from the agent. Use l4_node_hostname to override this with an explicit node name (useful when Caddy's hostname doesn't match the Consul node name).

HTTP/HTTPS routes are unaffected by l4_mode and always pass through regardless of the setting.

Service Proxy

The service_proxy_enable option controls whether Consul service discovery and routing is active (default: true). Set to false to disable service discovery entirely — no watcher, no routes.

Connect Proxy (Service Mesh)

By default, Connect proxy is disabled (connect_proxy_enable false). Set connect_proxy_enable true to enable Consul Connect service mesh integration. caddy-consul will automatically manage Envoy sidecar upstream configuration — no manual upstream setup needed.

Mixed mode is fully supported: services using direct routing (caddy-consul tag) and services using mesh routing (caddy-consul-connect tag) coexist in the same Caddy instance.

How it works
Client → Caddy (TLS termination) → localhost:<port> → Envoy sidecar → mTLS → upstream service
  1. caddy-consul discovers services tagged caddy-consul-connect
  2. For each Connect service, caddy-consul automatically allocates a local port and registers it as an upstream in Consul
  3. Envoy detects the change via xDS and opens a local listener on that port
  4. caddy-consul's SidecarResolver discovers the bind port and routes traffic through it
  5. Envoy handles mTLS, certificate rotation, and intention enforcement
Running the Envoy sidecar

The Envoy sidecar must be running alongside Caddy. Start it with:

consul connect envoy \
  -sidecar-for <connect_service_name> \
  -http-addr <consul_addr> \
  -token <consul_token>

Example:

consul connect envoy \
  -sidecar-for my-ingress \
  -http-addr http://127.0.0.1:8500 \
  -token "$CONSUL_HTTP_TOKEN"

Prerequisites:

  • consul binary in PATH
  • envoy binary in PATH (install via func-e use <version>)
  • ACL token with service:write for connect_service_name
  • Local Consul agent running

The sidecar is stateless — Consul is the source of truth. On restart, it re-bootstraps automatically. caddy-consul manages the upstream configuration dynamically via the Consul API; Envoy picks up changes via xDS without restart.

Dynamic upstream management

caddy-consul automatically manages Envoy's upstream list:

  • New Connect service discovered → port allocated, upstream added to Consul registration, Envoy opens listener
  • Connect service removed → upstream removed, Envoy closes listener, port freed
  • Port range: configurable via connect_port_range_start / connect_port_range_end (default: 19000-29000, supports 10,000 services)
  • Port allocation: deterministic hash-based, stable across restarts (persisted to state file)
Connect Identity

Caddy's identity in the mesh is set by connect_service_name (default: <hostname>-caddy-consul). This is used for:

  • Sidecar proxy registration
  • Upstream management
  • Intention rules (source identity)

Intentions are written as: ALLOW <connect_service_name> → <upstream-service>

Health-Aware Routing

Policy Behavior
passing (default) Only route to instances where all checks pass
warning Include instances with warning-level checks
any Include all registered instances regardless of health

When no healthy upstreams remain for a service, the route is removed until health recovers.

Conflict Resolution

  1. Static Caddy config always wins — routes defined in your Caddyfile are never overwritten by Consul-discovered routes. A WARN log is emitted for the discarded Consul route.
  2. Among Consul routes, first-seen wins — determined by alphabetical service name order for consistency. A WARN log is emitted for the duplicate.
  3. Priority tiebreaker — if services set caddy-priority, higher values win before falling back to first-seen.

Monitoring

Prometheus Metrics

When metrics are enabled (metrics /metrics/consul):

Metric Type Description
caddy_consul_services_total Gauge Number of watched services
caddy_consul_routes_total Gauge Active routes by protocol
caddy_consul_upstreams_healthy Gauge Healthy upstreams per service
caddy_consul_upstreams_total Gauge Total upstreams per service
caddy_consul_reconcile_duration_seconds Histogram Reconciliation timing
caddy_consul_reconcile_errors_total Counter Reconciliation failures
caddy_consul_watcher_errors_total Counter Consul watch errors
caddy_consul_conflicts_total Counter Route conflicts by type
caddy_consul_debounce_events_total Counter Debounce flush events
Admin API Endpoints

Available when Caddy admin API is enabled:

  • GET /consul/metrics — Prometheus metrics
  • GET /consul/state — JSON dump of current routing state
Prometheus Scrape Configuration
scrape_configs:
  - job_name: 'caddy_consul'
    static_configs:
      - targets: ['localhost:2019']
    metrics_path: /consul/metrics

Migration from Fabio

Service Discovery Requirements

caddy-consul discovers services based on Consul service tags. A service must have at least one of the following tags to be discovered:

Tag When to use
urlprefix-* Fabio-compatible services (auto-detected)
caddy-consul Services using caddy-* metadata for routing

Services with neither tag are skipped during catalog discovery. This keeps the health check load proportional to routable services, not total services in the cluster.

Tag Mapping
Fabio Tag caddy-consul Equivalent (tag + metadata)
urlprefix-host.com/ tag: caddy-consul, meta: caddy-host=host.com
urlprefix-host.com/path tag: caddy-consul, meta: caddy-host=host.com, caddy-path=/path
urlprefix-host.com/api strip=/api tag: caddy-consul, meta: caddy-host=host.com, caddy-path=/api, caddy-strip-prefix=true
urlprefix-:5432 proto=tcp tag: caddy-consul, meta: caddy-protocol=tcp, caddy-port=5432
urlprefix-old.com/ redirect=301,https://new.com$path tag: caddy-consul, meta: caddy-host=old.com, caddy-redirect-code=301, caddy-redirect-url=https://new.com{http.request.uri}
Migration Steps
  1. Deploy Caddy with caddy-consul alongside Fabio
  2. Services already using urlprefix- tags will be discovered automatically — no changes needed
  3. Gradually migrate services to caddy-* metadata:
    • Add the caddy-consul tag to the service registration
    • Add caddy-* metadata keys for routing configuration
    • Keep urlprefix- tags during transition (caddy-consul prefers metadata when both exist)
  4. Verify traffic flows through Caddy correctly
  5. Remove urlprefix- tags from migrated services
  6. Once all traffic flows through Caddy, decommission Fabio

Consul ACL Permissions

caddy-consul requires a Consul ACL token with the following permissions. The exact set depends on which features you use.

Required (all deployments)
Resource Permission API Endpoint Purpose
service_prefix "" read /v1/catalog/services Watch the service catalog for changes
node_prefix "" read /v1/health/service/<name> Watch service health and read node addresses
service_prefix "" read /v1/health/service/<name> Read service instances, tags, metadata
Required for Connect (sidecar mode)
Resource Permission API Endpoint Purpose
service "<connect_service_name>" read /v1/agent/service/<name>-sidecar-proxy Read Caddy's sidecar proxy config and upstream bind ports
Required for auto-registration (connect_auto_register true)
Resource Permission API Endpoint Purpose
service "<connect_service_name>" write /v1/agent/service/register Register Caddy as a service with Connect sidecar
service "<connect_service_name>-sidecar-proxy" write /v1/agent/service/register Register the sidecar proxy service
Minimal policy (no Connect)
service_prefix "" {
  policy = "read"
}

node_prefix "" {
  policy = "read"
}
Full policy (with Connect + auto-registration)
service_prefix "" {
  policy = "read"
}

node_prefix "" {
  policy = "read"
}

# Replace "my-ingress" with your connect_service_name
service "my-ingress" {
  policy = "write"
}

service "my-ingress-sidecar-proxy" {
  policy = "write"
}
Creating the token
# Create the policy
consul acl policy create \
  -name caddy-consul \
  -rules @caddy-consul-policy.hcl

# Create the token
consul acl token create \
  -description "caddy-consul plugin" \
  -policy-name caddy-consul

Troubleshooting

Routes not appearing?
  1. Check Caddy logs for parse errors or conflicts
  2. Verify the service has caddy-* metadata or urlprefix- tags
  3. Ensure the service has at least one healthy instance
  4. Check the admin API: curl http://localhost:2019/consul/state
Caddy won't start?
  1. Ensure admin is not set to off — caddy-consul requires the admin API
  2. Check Consul connectivity: curl http://127.0.0.1:8500/v1/status/leader
Stale routes after Consul changes?
  1. Check the debounce duration — changes are batched within the debounce window
  2. Look for WARN logs about debounce enter/exit timing
  3. Reduce debounce if convergence is too slow
Duplicate route warnings?

This is expected when two services claim the same host/path or port/SNI. The first-seen service wins. Check WARN logs for details on which service was discarded.

Connect sidecar routes not working?
  1. Verify Caddy's service is registered in Consul: consul catalog services | grep <connect_service_name>
  2. Check the sidecar has upstream entries for the target service: consul connect proxy-config <connect_service_name>-sidecar-proxy
  3. Verify intentions allow the connection: consul intention check <connect_service_name> <target-service>
  4. Check Caddy logs for no upstream entry for service warnings

License

MIT License

Documentation

Index

Constants

View Source
const (
	DefaultConsulAddr          = "127.0.0.1:8500"
	DefaultConsulScheme        = "http"
	DefaultHealthPolicy        = "passing"
	DefaultConflictPolicy      = "reject"
	DefaultServiceProxyEnable  = true
	DefaultConnectProxyEnable  = false
	DefaultDebounce            = "500ms"
	DefaultConnectAutoRegister = true
	DefaultPollInterval        = "50ms"
	DefaultFullSyncInterval    = "5m"
	DefaultCaddyAdminAPI       = "localhost:2019"
	DefaultServiceTag          = "caddy-consul"
	DefaultConnectTag          = "caddy-consul-connect"
	DefaultConnectPortStart    = 19000
	DefaultConnectPortEnd      = 29000
	DefaultL4Mode              = "global"

	// MaxServiceNameLen is the max length for a Consul service name (DNS label).
	MaxServiceNameLen = 63
)

Variables

This section is empty.

Functions

func BuildHTTPRouteJSON

func BuildHTTPRouteJSON(route CompiledHTTPRoute) (json.RawMessage, error)

BuildHTTPRouteJSON builds the Caddy JSON config fragment for an HTTP route.

func BuildHTTPRoutesJSON

func BuildHTTPRoutesJSON(routes []CompiledHTTPRoute) ([]json.RawMessage, error)

BuildHTTPRoutesJSON builds an array of Caddy JSON route configs for all HTTP routes.

func BuildTCPRouteJSON

func BuildTCPRouteJSON(route CompiledTCPRoute) (json.RawMessage, error)

BuildTCPRouteJSON builds the Caddy L4 JSON config fragment for a TCP route.

func BuildTCPServerJSON

func BuildTCPServerJSON(port int, routes []CompiledTCPRoute) (json.RawMessage, error)

BuildTCPServerJSON builds a complete L4 server config for a given port with its routes.

func GroupTCPRoutesByPort

func GroupTCPRoutesByPort(routes []CompiledTCPRoute) map[int][]CompiledTCPRoute

GroupTCPRoutesByPort groups compiled TCP routes by their port number.

Types

type AdminConsul

type AdminConsul struct {
	// contains filtered or unexported fields
}

AdminConsul is a Caddy admin module that exposes consul metrics and debug endpoints.

func (AdminConsul) CaddyModule

func (AdminConsul) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*AdminConsul) Cleanup

func (ac *AdminConsul) Cleanup() error

Cleanup cleans up the admin handler.

func (*AdminConsul) Provision

func (ac *AdminConsul) Provision(ctx caddy.Context) error

Provision sets up the admin consul handler.

func (*AdminConsul) Routes

func (ac *AdminConsul) Routes() []caddy.AdminRoute

Routes returns the routes for the admin API.

type ChangeType

type ChangeType string

ChangeType represents the type of service change.

const (
	ChangeAdded   ChangeType = "added"
	ChangeUpdated ChangeType = "updated"
	ChangeRemoved ChangeType = "removed"
)

type CompiledConfig

type CompiledConfig struct {
	HTTPRoutes []CompiledHTTPRoute
	TCPRoutes  []CompiledTCPRoute
	Conflicts  []Conflict
	Warnings   []string
}

CompiledConfig holds the result of route compilation.

type CompiledHTTPRoute

type CompiledHTTPRoute struct {
	Host         string
	Path         string
	Upstreams    []Upstream
	StripPrefix  bool
	ServiceName  string
	Via          string // routing tag for X-Caddy-Consul-Via response header
	RedirectCode int
	RedirectURL  string
}

CompiledHTTPRoute is a Consul-managed HTTP route ready for injection into Caddy config.

type CompiledTCPRoute

type CompiledTCPRoute struct {
	Port        int
	SNI         string
	Upstreams   []Upstream
	Passthrough bool
	ServiceName string
}

CompiledTCPRoute is a Consul-managed TCP route ready for injection into Caddy config.

type Conflict

type Conflict struct {
	Type   ConflictType
	Winner *RouteDefinition
	Loser  *RouteDefinition
	Reason string
}

Conflict represents a detected route conflict.

type ConflictType

type ConflictType string

ConflictType represents the type of route conflict.

const (
	ConflictDuplicateHostPath ConflictType = "duplicate_host_path"
	ConflictDuplicatePortSNI  ConflictType = "duplicate_port_sni"
	ConflictConflictingTLS    ConflictType = "conflicting_tls"
	ConflictStaticWins        ConflictType = "static_wins"
)

type ConsulProxyHandler added in v0.0.5

type ConsulProxyHandler struct {
	// contains filtered or unexported fields
}

ConsulProxyHandler is a Caddy HTTP handler that dynamically routes requests based on Consul service discovery. It reads from an in-memory route table shared with the consul app module — no admin API calls, no config reloads.

func (ConsulProxyHandler) CaddyModule added in v0.0.5

func (ConsulProxyHandler) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*ConsulProxyHandler) Provision added in v0.0.5

func (h *ConsulProxyHandler) Provision(ctx caddy.Context) error

Provision sets up the handler by getting a reference to the consul app's route table.

func (*ConsulProxyHandler) ServeHTTP added in v0.0.5

ServeHTTP handles an HTTP request by matching it against the dynamic route table.

func (*ConsulProxyHandler) UnmarshalCaddyfile added in v0.0.5

func (h *ConsulProxyHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile parses the consul_proxy directive. It takes no arguments.

consul_proxy

type ConsulRouter

type ConsulRouter struct {

	// Consul connection
	ConsulAddr   string `json:"address,omitempty"`
	ConsulToken  string `json:"token,omitempty"`
	ConsulScheme string `json:"scheme,omitempty"`
	ConsulDC     string `json:"datacenter,omitempty"`

	// TLS to Consul
	ConsulTLSCA         string `json:"tls_ca,omitempty"`
	ConsulTLSCert       string `json:"tls_cert,omitempty"`
	ConsulTLSKey        string `json:"tls_key,omitempty"`
	ConsulTLSSkipVerify bool   `json:"insecure_skip_verify,omitempty"`

	// Behavior
	ServiceProxyEnable *bool  `json:"service_proxy_enable,omitempty"`
	HealthPolicy       string `json:"health_policy,omitempty"`
	ConflictPolicy     string `json:"conflict_policy,omitempty"`
	ConnectProxyEnable *bool  `json:"connect_proxy_enable,omitempty"`
	DebounceDuration   string `json:"debounce_duration,omitempty"`
	PollInterval       string `json:"poll_interval,omitempty"`
	FullSyncInterval   string `json:"full_sync_interval,omitempty"`

	// Connect
	ConnectServiceName  string `json:"connect_service_name,omitempty"`
	ConnectAutoRegister *bool  `json:"connect_auto_register,omitempty"`

	// Caddy admin API
	CaddyAdminAPI string `json:"caddy_admin_api,omitempty"`

	// Service discovery tags
	ServiceTag string `json:"service_tag,omitempty"`
	ConnectTag string `json:"connect_tag,omitempty"`

	// Connect port range for dynamic sidecar upstreams
	ConnectPortStart int `json:"connect_port_range_start,omitempty"`
	ConnectPortEnd   int `json:"connect_port_range_end,omitempty"`

	// Data directory for runtime state (persisted across reloads)
	DataDir string `json:"data_dir,omitempty"`

	// Metrics
	Metrics string `json:"metrics,omitempty"`

	// Layer 4 mode: "global" (default) or "node"
	// In "node" mode, TCP/L4 routes are only materialized for services that
	// have at least one healthy instance on the local Consul node.
	L4Mode         string `json:"l4_mode,omitempty"`
	L4NodeHostname string `json:"l4_node_hostname,omitempty"` // explicit override for node identity
	// contains filtered or unexported fields
}

ConsulRouter is a Caddy app that dynamically configures HTTP and TCP/TLS routing from Consul service registrations.

func (ConsulRouter) CaddyModule

func (ConsulRouter) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*ConsulRouter) Cleanup

func (cr *ConsulRouter) Cleanup() error

Cleanup releases resources.

func (*ConsulRouter) Provision

func (cr *ConsulRouter) Provision(ctx caddy.Context) error

Provision sets up the ConsulRouter.

func (*ConsulRouter) RouteTable added in v0.0.5

func (cr *ConsulRouter) RouteTable() *RouteTable

RouteTable returns the shared route table for the consul_proxy handler.

func (*ConsulRouter) Start

func (cr *ConsulRouter) Start() error

Start begins the Consul watcher. This is non-blocking. The admin API connectivity is verified lazily on the first reconciliation attempt, with retries, to avoid a chicken-and-egg problem during Caddy startup.

func (*ConsulRouter) Stop

func (cr *ConsulRouter) Stop() error

Stop gracefully shuts down the Consul watcher and cert manager. Note: we intentionally do NOT deregister from Consul here. Stop() is called on every config reload (via admin API PATCH), not just on final shutdown. Deregistering would cause a registration gap between the old and new app instances. The registration persists and the TTL will expire naturally.

func (*ConsulRouter) UnmarshalCaddyfile

func (cr *ConsulRouter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile sets up the ConsulRouter from Caddyfile tokens.

consul {
    address 127.0.0.1:8500
    token {env.CONSUL_HTTP_TOKEN}
    scheme https
    datacenter dc1
    tls_ca /path/to/ca.pem
    tls_cert /path/to/cert.pem
    tls_key /path/to/key.pem
    insecure_skip_verify false
    service_proxy_enable true
    health_policy passing
    conflict_policy reject
    connect_proxy_enable false
    connect_service_name my-ingress
    connect_auto_register true
    poll_interval 50ms
    full_sync_interval 5m
    debounce 500ms
    caddy_admin_api localhost:2019
    service_tag caddy-consul
    connect_tag caddy-consul-connect
    connect_port_range_start 19000
    connect_port_range_end 29000
    data_dir /var/lib/caddy-consul
    metrics /metrics/consul
    l4_mode node
    l4_node_hostname worker-03.dc1
}

type ConsulWatcher

type ConsulWatcher struct {
	// contains filtered or unexported fields
}

ConsulWatcher watches Consul for service changes using two blocking queries:

  • /v1/catalog/services — detects service add/remove
  • /v1/health/state/passing — detects health changes across ALL services

This architecture uses 2 connections regardless of service count, scaling to 10,000+ services without overwhelming the Consul agent.

func NewConsulWatcher

func NewConsulWatcher(
	client *consul.Client,
	logger *zap.Logger,
	healthPolicy HealthPolicy,
	debounce time.Duration,
	pollInterval time.Duration,
	fullSyncInterval time.Duration,
	serviceTag string,
	connectTag string,
	onChange func([]ServiceChange, map[string]*ServiceState),
) *ConsulWatcher

NewConsulWatcher creates a new ConsulWatcher.

func (*ConsulWatcher) CatalogIndex added in v0.0.5

func (w *ConsulWatcher) CatalogIndex() uint64

CatalogIndex returns the current catalog blocking query index (exported for state persistence).

func (*ConsulWatcher) HealthStateIndex added in v0.0.5

func (w *ConsulWatcher) HealthStateIndex() uint64

HealthStateIndex returns the current health state blocking query index.

func (*ConsulWatcher) PassingChecks added in v0.0.5

func (w *ConsulWatcher) PassingChecks() map[string][]string

PassingChecks returns the current passing checks map for state persistence.

func (*ConsulWatcher) RestoreState added in v0.0.5

func (w *ConsulWatcher) RestoreState(catalogIndex uint64, healthStateIndex uint64, services map[string]*persistedServiceState, passingChecks map[string][]string)

RestoreState restores watcher state from a previous run.

func (*ConsulWatcher) Start

func (w *ConsulWatcher) Start()

Start begins watching Consul for service changes. Non-blocking. Launches 3 goroutines: catalog watcher, health state watcher, and periodic full sync.

func (*ConsulWatcher) Stop

func (w *ConsulWatcher) Stop()

Stop gracefully stops all watchers. Safe to call multiple times.

type HealthPolicy

type HealthPolicy int

HealthPolicy controls which instances are considered routable.

const (
	HealthPolicyPassing HealthPolicy = iota
	HealthPolicyWarning
	HealthPolicyAny
)

type MetricsCollector

type MetricsCollector struct {
	ServicesTotal    prometheus.Gauge
	RoutesTotal      *prometheus.GaugeVec
	UpstreamsHealthy *prometheus.GaugeVec
	UpstreamsTotal   *prometheus.GaugeVec

	ReconcileDuration prometheus.Histogram
	ReconcileErrors   prometheus.Counter
	WatcherErrors     prometheus.Counter
	ConflictsTotal    *prometheus.CounterVec
	DebounceEvents    prometheus.Counter
	// contains filtered or unexported fields
}

MetricsCollector handles all metrics collection for the caddy-consul plugin.

func GetMetrics

func GetMetrics() *MetricsCollector

GetMetrics returns the global MetricsCollector, or nil if not initialized.

func GetOrCreateGlobalMetrics

func GetOrCreateGlobalMetrics(logger *zap.Logger) *MetricsCollector

GetOrCreateGlobalMetrics returns the global MetricsCollector, creating it if needed.

func (*MetricsCollector) ServeHTTP

func (m *MetricsCollector) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP handles Prometheus metrics scraping.

type Protocol

type Protocol string

Protocol represents the routing protocol type.

const (
	ProtocolHTTP           Protocol = "http"
	ProtocolHTTPS          Protocol = "https"
	ProtocolTCP            Protocol = "tcp"
	ProtocolTLSPassthrough Protocol = "tls-passthrough"
)

type Reconciler

type Reconciler struct {
	// contains filtered or unexported fields
}

Reconciler applies compiled TCP routes to Caddy's running config via the Admin API. HTTP routes are handled in-memory by the consul_proxy handler and do NOT use this.

TCP routes require the admin API because new listeners (ports) cannot be created without a config change. TCP route changes are infrequent (only when TCP services are added/removed). TCP state is persisted across reloads to prevent cascading reload cycles.

func NewReconciler

func NewReconciler(logger *zap.Logger, adminAddr string) *Reconciler

NewReconciler creates a new Reconciler targeting the given Caddy admin address.

func (*Reconciler) ApplyTCP added in v0.0.5

func (r *Reconciler) ApplyTCP(compiled *CompiledConfig) error

ApplyTCP reconciles TCP routes with Caddy's live L4 config via the admin API. HTTP routes are handled in-memory by the consul_proxy handler and do not use this.

func (*Reconciler) RestoreTCPState added in v0.0.5

func (r *Reconciler) RestoreTCPState(hashes map[string]string, names []string)

RestoreTCPState restores TCP reconciler state from a previous reload cycle. This prevents the reconciler from re-applying identical TCP servers and triggering another unnecessary config reload.

After restoring, it verifies that the servers actually exist in the live Caddy config. If any are missing (e.g., after a reload wiped them), the hashes are cleared so the reconciler will re-create them.

func (*Reconciler) TCPState added in v0.0.5

func (r *Reconciler) TCPState() (hashes map[string]string, names []string)

TCPState returns the current TCP state for persistence across reloads.

func (*Reconciler) VerifyTCPServersExist added in v0.0.5

func (r *Reconciler) VerifyTCPServersExist()

VerifyTCPServersExist checks that persisted TCP servers actually exist in the live Caddy config. If any are missing, clears the hash state so they get re-created on the next reconciliation.

type RouteCompiler

type RouteCompiler struct {
	// contains filtered or unexported fields
}

RouteCompiler converts RouteDefinitions into compiled Caddy config structures.

func NewRouteCompiler

func NewRouteCompiler(logger *zap.Logger) *RouteCompiler

NewRouteCompiler creates a new RouteCompiler.

func (*RouteCompiler) Compile

func (rc *RouteCompiler) Compile(routes []RouteDefinition) *CompiledConfig

Compile takes a set of RouteDefinitions and produces a CompiledConfig. It groups routes by protocol, detects conflicts, and applies resolution rules.

type RouteDefinition

type RouteDefinition struct {
	ServiceName  string
	Protocol     Protocol
	Host         string
	Path         string
	Port         int
	UpstreamMode UpstreamMode
	Priority     int
	Weight       int
	StripPrefix  bool
	Enabled      bool
	Upstreams    []Upstream
	Via          string // routing tag value for X-Caddy-Consul-Via header
	RedirectCode int    // HTTP redirect status code (301, 302, etc.); 0 = not a redirect
	RedirectURL  string // redirect target URL template (may contain {http.request.uri})
}

RouteDefinition holds parsed routing instructions from Consul metadata/tags.

func FilterTCPRoutesByNode added in v0.0.7

func FilterTCPRoutesByNode(routes []RouteDefinition, nodeName string) []RouteDefinition

FilterTCPRoutesByNode returns only routes that should be materialized on the given node. TCP/TLS-passthrough routes are kept only if at least one upstream runs on nodeName. When kept, ALL upstreams are preserved (not just local ones) so Caddy can load-balance across the full set. HTTP/HTTPS routes pass through unchanged. If nodeName is empty, all routes pass through (safety fallback).

func ParseServiceRoutes

func ParseServiceRoutes(svc *ServiceState, serviceTag, connectTag string, logger *zap.Logger) []RouteDefinition

ParseServiceRoutes extracts RouteDefinitions from a service's metadata and tags.

Each instance is processed independently — its effective metadata is built by merging service-level meta (svc.Meta) with instance-level meta (inst.Meta wins). Routes are parsed from that per-instance metadata, and the instance becomes the sole upstream for its own routes. Instances that produce identical route definitions (same host, path, protocol, port, etc.) are grouped into one route with multiple upstreams for load balancing.

Precedence per instance: indexed (caddy-route-N-*) > non-indexed (caddy-*) > Fabio (urlprefix-).

An instance is only considered if it has the serviceTag/connectTag in its Tags, or has caddy-* keys in its own Meta, or has urlprefix-* in its own Tags.

func (*RouteDefinition) IsRedirect added in v0.0.2

func (rd *RouteDefinition) IsRedirect() bool

IsRedirect returns true if this route is an HTTP redirect (not a proxy).

type RouteTable added in v0.0.5

type RouteTable struct {
	// contains filtered or unexported fields
}

RouteTable is a thread-safe in-memory HTTP route table shared between the consul app (writer) and the consul_proxy handler (reader). It is updated whenever the watcher detects service changes and the compiler produces new compiled routes. The handler reads from it on every HTTP request.

func NewRouteTable added in v0.0.5

func NewRouteTable() *RouteTable

NewRouteTable creates an empty RouteTable.

func (*RouteTable) Len added in v0.0.5

func (rt *RouteTable) Len() int

Len returns the number of routes in the table.

func (*RouteTable) Match added in v0.0.5

func (rt *RouteTable) Match(host, path string) *CompiledHTTPRoute

Match finds the best matching route for the given host and path. Returns nil if no route matches.

Matching rules:

  1. Exact host match takes priority over wildcard
  2. Longest path prefix wins among same-host matches
  3. Routes are pre-sorted by priority from the compiler
  4. Port-aware: if a route pattern includes a port, the request must match on host:port; if the pattern has no port, only the hostname is compared

func (*RouteTable) Routes added in v0.0.5

func (rt *RouteTable) Routes() []CompiledHTTPRoute

Routes returns a snapshot of the current routes (for debugging/metrics).

func (*RouteTable) Update added in v0.0.5

func (rt *RouteTable) Update(routes []CompiledHTTPRoute)

Update replaces the entire route set atomically. Routes should already be sorted by priority (handled by the compiler).

type ServiceChange

type ServiceChange struct {
	Type    ChangeType
	Service *ServiceState
}

ServiceChange represents a change to a Consul service.

type ServiceInstance

type ServiceInstance struct {
	ID       string
	Address  string
	Port     int
	Tags     []string
	Meta     map[string]string
	Healthy  bool
	Weight   int
	NodeName string // Consul node name where this instance runs
}

ServiceInstance represents a single instance of a Consul service.

type ServiceRegistrar

type ServiceRegistrar struct {
	// contains filtered or unexported fields
}

ServiceRegistrar handles auto-registration of Caddy as a service in Consul with a Connect sidecar proxy definition. This is required for both sidecar and direct Connect modes — without registration, Caddy has no mesh identity.

The registrar keeps the TTL health check alive via a background goroutine. Registration is idempotent — safe to call on every Caddy config reload.

func NewServiceRegistrar

func NewServiceRegistrar(client *consul.Client, logger *zap.Logger, serviceName string) *ServiceRegistrar

NewServiceRegistrar creates a new ServiceRegistrar.

func (*ServiceRegistrar) Deregister added in v0.0.5

func (sr *ServiceRegistrar) Deregister()

Deregister removes the service and its sidecar proxy from Consul. This should only be called on actual process exit, not on config reloads.

func (*ServiceRegistrar) Register

func (sr *ServiceRegistrar) Register() error

Register registers Caddy as a service in Consul with Connect enabled and starts a background goroutine to keep the TTL health check alive. If the service is already registered, it skips re-registration to avoid overwriting existing sidecar proxy configurations (e.g., upstream entries).

func (*ServiceRegistrar) Stop

func (sr *ServiceRegistrar) Stop()

Stop stops the TTL updater. Does NOT deregister the service — registration persists across config reloads. The TTL will eventually expire if Caddy truly shuts down. Use Deregister() for clean removal on process exit.

type ServiceState

type ServiceState struct {
	Name      string
	Tags      []string
	Meta      map[string]string
	Instances []ServiceInstance
	LastIndex uint64
}

ServiceState holds the current known state of a Consul service.

type SidecarResolver

type SidecarResolver struct {
	// contains filtered or unexported fields
}

SidecarResolver resolves upstream addresses by querying Caddy's own sidecar proxy for local bind ports.

func NewSidecarResolver

func NewSidecarResolver(client *consul.Client, logger *zap.Logger, serviceName string) *SidecarResolver

NewSidecarResolver creates a new SidecarResolver.

func (*SidecarResolver) ResolveUpstreams

func (sr *SidecarResolver) ResolveUpstreams(route *RouteDefinition) error

ResolveUpstreams replaces the route's upstreams with the sidecar proxy's local bind address for the target service. It queries Caddy's own sidecar at /v1/agent/service/<serviceName>-sidecar-proxy and matches the route's service name against Proxy.Upstreams[].DestinationName.

type Upstream

type Upstream struct {
	Address  string
	Weight   int
	Healthy  bool
	NodeName string // Consul node name (used for l4_mode filtering; not serialized to Caddy)
}

Upstream represents a single backend target.

type UpstreamManager added in v0.0.5

type UpstreamManager struct {
	// contains filtered or unexported fields
}

UpstreamManager dynamically manages sidecar proxy upstream registrations in Consul. When caddy-consul discovers a Connect service, it allocates a local port and adds the upstream to the sidecar's Proxy.Upstreams. Envoy detects the change via xDS and opens a local listener on that port.

func NewUpstreamManager added in v0.0.5

func NewUpstreamManager(
	client *consul.Client,
	logger *zap.Logger,
	serviceName string,
	portStart, portEnd int,
) *UpstreamManager

NewUpstreamManager creates a new UpstreamManager.

func (*UpstreamManager) Allocations added in v0.0.5

func (um *UpstreamManager) Allocations() map[string]int

Allocations returns the current port allocations for persistence.

func (*UpstreamManager) RestoreAllocations added in v0.0.5

func (um *UpstreamManager) RestoreAllocations(allocations map[string]int)

RestoreAllocations restores port allocations from persisted state.

func (*UpstreamManager) SyncUpstreams added in v0.0.5

func (um *UpstreamManager) SyncUpstreams(desired map[string]bool) (bool, error)

SyncUpstreams ensures the sidecar proxy registration's Proxy.Upstreams matches the desired set of Connect services. Adds missing upstreams with allocated ports, removes stale ones. Makes a single Consul API call.

Returns true if the registration was updated (upstreams changed).

type UpstreamMode

type UpstreamMode string

UpstreamMode determines how traffic reaches the backend.

const (
	UpstreamDirect         UpstreamMode = "direct"          // no mesh, connect directly
	UpstreamConnectSidecar UpstreamMode = "connect-sidecar" // via local sidecar proxy
)

func (UpstreamMode) IsConnect

func (m UpstreamMode) IsConnect() bool

IsConnect returns true if the mode involves Consul Connect.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL