caddypangolin

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jul 23, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

README

caddy-pangolin

Caddy plugin that lets a local Caddy instance mirror the resources of a remote Pangolin instance, so LAN clients can reach your services directly without hairpinning traffic through your VPS (see fosrl discussion #685).

It provides four modules:

  • http.reverse_proxy.upstreams.pangolin — a dynamic upstreams source that resolves the backend for each request by matching the request Host against Pangolin's resource fullDomains and their targets (ip:port).
  • http.matchers.pangolin_https_backend — a request matcher that matches hosts whose Pangolin target uses https, so you can route them through a TLS transport.
  • http.matchers.pangolin_remote — a request matcher that matches hosts that exist in Pangolin but have no locally reachable targets, so you can route them back through the public Pangolin instance (see Remote sites).
  • http.matchers.pangolin_local — a request matcher that limits the dynamic proxy to known resources with locally reachable targets, allowing unknown or disabled resources to reach your normal fallback.

A background poller fetches GET /v1/org/{org}/resources (plus per-resource targets for the http/https method) from Pangolin's Integration API and caches the map in memory. If Pangolin becomes unreachable, the last-known map keeps serving. Pollers are shared between modules with identical config.

[!IMPORTANT] Local requests bypass Pangolin's SSO, PIN, password, and policy enforcement. Restrict this listener to trusted LAN/VPN clients or add equivalent Caddy access controls. Caddy obtains and terminates the local certificates.

Pangolin 1.21 added same-network peer detection for updated Pangolin clients and site connectors. This plugin remains useful for clientless browsers, local DNS, and cached operation when the Pangolin control plane or WAN is unavailable.

Build

xcaddy build v2.11.4 \
  --with github.com/abs3ntdev/caddy-pangolin=. \
  --with github.com/caddy-dns/cloudflare

A prebuilt Docker image (hotio/caddy base with this plugin included) is published from abs3ntdev/caddy-image.

Pangolin setup

  1. Enable the Integration API (default port 3003) and expose it to your LAN host (e.g. proxy it or tunnel it — it is a separate listener from the main API).
  2. Create an API key with permission to list resources and targets, scoped to your org. The key is used as Authorization: Bearer <id>.<secret>.

Caddyfile example

{
	http_port 8080
	https_port 8443
}

(pangolin_cfg) {
	endpoint https://pangolin.example.com:3003
	api_key {env.PANGOLIN_API_KEY}
	org_id myorg
	refresh 60s
}

*.example.com, example.org {
	tls {
		dns cloudflare {env.CF_API_TOKEN}
	}

	# hosts whose Pangolin target method is https (e.g. unraid, nextcloud)
	@https_backend pangolin_https_backend {
		import pangolin_cfg
	}
	handle @https_backend {
		reverse_proxy {
			dynamic pangolin {
				import pangolin_cfg
			}
			transport http {
				tls
				tls_server_name {http.request.host}
			}
		}
	}

	@local pangolin_local {
		import pangolin_cfg
	}
	handle @local {
		reverse_proxy {
			dynamic pangolin {
				import pangolin_cfg
			}
		}
	}

	handle {
		respond 404
	}
}

Point your local DNS wildcard (*.example.com) at this Caddy host and LAN traffic stays local; external traffic still flows through Pangolin on the VPS.

Options

Option Required Description
endpoint yes Base URL of the Pangolin Integration API (https://host:3003)
api_key yes API key as <id>.<secret>; placeholders like {env.X} supported
org_id yes Pangolin organization ID
refresh no Poll interval (default 60s)
method_refresh no Maximum age of cached target HTTP/HTTPS methods (default 5m)
max_stale no Stop serving a cached snapshot after this age (default: unlimited)
initial_timeout no Fail Caddy provisioning unless a cache is loaded or the first API sync succeeds within this duration (default: do not wait)
sites no Site names or niceIds whose targets are locally reachable; targets on other sites are treated as remote (default: all sites local)
resolvers no DNS server addresses (port 53 assumed) used to resolve the Pangolin endpoint instead of the system resolver — set this when split-horizon DNS would resolve the endpoint back to this Caddy instance
insecure_skip_verify no Skip TLS verification when talking to the Pangolin API
allow_http no Explicitly permit an insecure http:// API endpoint; the API key is sent in cleartext

For HTTPS targets with a private CA, configure a Caddy tls_trust_pool instead of disabling certificate verification.

Remote sites

If your Pangolin org has resources on sites that are not on this LAN, their targets are not reachable by the local Caddy. Set sites to the site(s) local to this Caddy instance, and use the pangolin_remote matcher to send everything else back through the public Pangolin instance:

(pangolin_cfg) {
	endpoint https://pangolin-api.example.com
	api_key {env.PANGOLIN_API_KEY}
	org_id default
	sites Home
}

*.example.com {
	# resources on remote sites: send back out through the public path
	@remote pangolin_remote {
		import pangolin_cfg
	}
	handle @remote {
		reverse_proxy {http.request.host}:443 {
			transport http {
				tls
				resolvers 1.1.1.1 8.8.8.8
			}
		}
	}

	@https_backend pangolin_https_backend {
		import pangolin_cfg
	}
	handle @https_backend {
		reverse_proxy {
			dynamic pangolin {
				import pangolin_cfg
			}
			transport http {
				tls
				tls_server_name {http.request.host}
			}
		}
	}

	@local pangolin_local {
		import pangolin_cfg
	}
	handle @local {
		reverse_proxy {
			dynamic pangolin {
				import pangolin_cfg
			}
		}
	}

	handle {
		respond 404
	}
}

pangolin_remote matches hosts that exist in Pangolin but have no locally reachable targets. Matched requests are proxied back to the original hostname, but resolved with public DNS servers (resolvers) instead of your local split-horizon DNS — which would otherwise point the name right back at this Caddy instance and loop. The request therefore takes the normal public route (Cloudflare, your VPS, Pangolin's traefik), with correct SNI and Host for free, and Pangolin's auth rules still apply since it goes through the real path.

Alternatively, if you'd rather not depend on outbound DNS, you can pin the upstream to your VPS directly and override the SNI:

	reverse_proxy @remote https://<vps-ip> {
		transport http {
			tls
			tls_server_name {http.request.host}
		}
	}

Offline resilience

Every successful sync is persisted to disk under Caddy's data directory ($XDG_DATA_HOME/caddy/pangolin/<hash>.json, typically /config/caddy/pangolin/ when XDG_DATA_HOME=/config). On startup the last snapshot is loaded from disk before any network call, so once the plugin has synced at least once:

  • Caddy restarts serve immediately from cache (no 503 window)
  • if the WAN or the Pangolin instance is down, local routing keeps working indefinitely with the last-known resource map
  • the poller keeps retrying in the background and replaces the cache as soon as Pangolin is reachable again

The cache is isolated by endpoint, API key, organization, sites, and resolvers, so configurations with different visibility or DNS views cannot overwrite one another. Set max_stale when revocation freshness is more important than indefinite offline availability.

Metrics

When Caddy metrics are enabled, the plugin exposes:

Metric Type Labels Description
caddy_pangolin_refresh_total counter org, config, outcome Refresh attempts by outcome (success/error)
caddy_pangolin_last_refresh_success_timestamp_seconds gauge org, config When the API last refreshed successfully
caddy_pangolin_mapped_hosts gauge org, config, kind Hosts in the resource map (exact/wildcard)
caddy_pangolin_snapshot_timestamp_seconds gauge org, config, source Timestamp and source (api/cache) of the active snapshot
caddy_pangolin_targets gauge org, config, kind Local targets and remote-only hosts

Alerting on time() - caddy_pangolin_last_refresh_success_timestamp_seconds tells you when the map has gone stale (e.g. API unreachable).

Behavior notes

  • Only enabled resources with enabled targets are mapped; disabled ones 404 through your normal fallback.
  • Pangolin health checks are respected: targets marked unhealthy are removed from rotation while at least one healthy target remains. If every target of a resource is unhealthy, they are all kept (failing loudly beats hiding the resource).
  • Polling is cheap in steady state: the per-resource target details (http vs https method) are refetched when topology changes or method_refresh elapses.
  • If a resource mixes HTTP and HTTPS targets, HTTPS targets are preferred so a single request never sends plaintext targets through a TLS transport.
  • Refreshes only log at INFO level when the resource map actually changed; unchanged polls log at DEBUG.
  • Wildcard resources (*.example.com) match any single-level subdomain.
  • Multiple enabled targets become multiple upstreams and use the reverse_proxy load balancing policy you configure.
  • Pangolin auth rules are not enforced locally. Gate the site block yourself (e.g. @block not remote_ip private_ranges + abort @block).

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Endpoint is the base URL of the Pangolin integration API.
	Endpoint string
	// APIKey is the bearer token in the form "<id>.<secret>".
	APIKey string
	// OrgID is the Pangolin organization to list resources from.
	OrgID string
	// Refresh is the poll interval.
	Refresh        time.Duration
	MethodRefresh  time.Duration
	MaxStale       time.Duration
	InitialTimeout time.Duration
	// InsecureSkipVerify disables TLS verification for API requests.
	InsecureSkipVerify bool
	AllowHTTP          bool
	// Sites restricts which Pangolin sites' targets are considered locally
	// reachable. Matches site name or niceId, case-insensitive. Empty means
	// all sites are considered local.
	Sites []string
	// Resolvers are DNS server addresses used to resolve the Pangolin
	// endpoint, bypassing the system resolver (useful with split-horizon
	// DNS where the endpoint hostname would resolve back to this Caddy).
	// Port 53 is assumed if not specified. Empty means system resolver.
	Resolvers []string
}

Config is the resolved configuration for a shared Pangolin poller. Modules with identical configs share a single poller instance.

type HTTPSBackendMatcher

type HTTPSBackendMatcher struct {
	ModuleConfig
}

HTTPSBackendMatcher matches requests whose Pangolin resource has at least one locally reachable target with method "https". Use it to route those hosts through a reverse_proxy with an HTTPS transport.

func (HTTPSBackendMatcher) CaddyModule

func (HTTPSBackendMatcher) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*HTTPSBackendMatcher) Cleanup

func (m *HTTPSBackendMatcher) Cleanup() error

Cleanup implements caddy.CleanerUpper.

func (*HTTPSBackendMatcher) Match

func (m *HTTPSBackendMatcher) Match(r *http.Request) bool

Match implements caddyhttp.RequestMatcher.

func (*HTTPSBackendMatcher) MatchWithError

func (m *HTTPSBackendMatcher) MatchWithError(r *http.Request) (bool, error)

MatchWithError reports whether the request's host maps to a Pangolin resource with at least one locally reachable HTTPS target.

func (*HTTPSBackendMatcher) Provision

func (m *HTTPSBackendMatcher) Provision(ctx caddy.Context) error

Provision implements caddy.Provisioner.

func (*HTTPSBackendMatcher) UnmarshalCaddyfile

func (m *HTTPSBackendMatcher) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile implements caddyfile.Unmarshaler.

type LocalMatcher added in v0.3.0

type LocalMatcher struct {
	ModuleConfig
}

func (LocalMatcher) CaddyModule added in v0.3.0

func (LocalMatcher) CaddyModule() caddy.ModuleInfo

func (*LocalMatcher) Cleanup added in v0.3.0

func (m *LocalMatcher) Cleanup() error

func (*LocalMatcher) Match added in v0.3.0

func (m *LocalMatcher) Match(r *http.Request) bool

func (*LocalMatcher) MatchWithError added in v0.3.0

func (m *LocalMatcher) MatchWithError(r *http.Request) (bool, error)

func (*LocalMatcher) Provision added in v0.3.0

func (m *LocalMatcher) Provision(ctx caddy.Context) error

func (*LocalMatcher) UnmarshalCaddyfile added in v0.3.0

func (m *LocalMatcher) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

type ModuleConfig

type ModuleConfig struct {
	// Base URL of the Pangolin integration API, e.g. https://pangolin-api.example.com
	Endpoint string `json:"endpoint,omitempty"`

	// API key in the form "<id>.<secret>" (sent as Authorization: Bearer).
	APIKey string `json:"api_key,omitempty"`

	// The Pangolin organization ID to list resources from.
	OrgID string `json:"org_id,omitempty"`

	// How often to refresh the resource map. Default: 60s.
	Refresh        caddy.Duration `json:"refresh,omitempty"`
	MethodRefresh  caddy.Duration `json:"method_refresh,omitempty"`
	MaxStale       caddy.Duration `json:"max_stale,omitempty"`
	InitialTimeout caddy.Duration `json:"initial_timeout,omitempty"`

	// Skip TLS verification when talking to the Pangolin API.
	InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
	AllowHTTP          bool `json:"allow_http,omitempty"`

	// Sites whose targets are locally reachable (name or niceId,
	// case-insensitive). Targets on other sites are treated as remote.
	// Empty means all sites are local.
	Sites []string `json:"sites,omitempty"`

	// Resolvers are DNS server addresses (port 53 assumed) used to resolve
	// the Pangolin endpoint instead of the system resolver. Set this when
	// split-horizon DNS would resolve the endpoint back to this Caddy.
	Resolvers []string `json:"resolvers,omitempty"`
	// contains filtered or unexported fields
}

ModuleConfig holds the user-facing configuration shared by all caddy-pangolin modules.

type RemoteMatcher

type RemoteMatcher struct {
	ModuleConfig
}

RemoteMatcher matches requests whose Pangolin resource exists but has no locally reachable targets (all its targets live on sites excluded by the `sites` filter). Use it to route those hosts back through the public Pangolin instance.

func (RemoteMatcher) CaddyModule

func (RemoteMatcher) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*RemoteMatcher) Cleanup

func (m *RemoteMatcher) Cleanup() error

Cleanup implements caddy.CleanerUpper.

func (*RemoteMatcher) Match

func (m *RemoteMatcher) Match(r *http.Request) bool

Match implements caddyhttp.RequestMatcher.

func (*RemoteMatcher) MatchWithError

func (m *RemoteMatcher) MatchWithError(r *http.Request) (bool, error)

MatchWithError reports whether the request's host maps to a Pangolin resource that has no locally reachable targets.

func (*RemoteMatcher) Provision

func (m *RemoteMatcher) Provision(ctx caddy.Context) error

Provision implements caddy.Provisioner.

func (*RemoteMatcher) UnmarshalCaddyfile

func (m *RemoteMatcher) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile implements caddyfile.Unmarshaler.

type Upstreams

type Upstreams struct {
	ModuleConfig
}

Upstreams is a dynamic upstream source that resolves the backend for a request by matching its Host against Pangolin resources.

func (Upstreams) CaddyModule

func (Upstreams) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*Upstreams) Cleanup

func (u *Upstreams) Cleanup() error

Cleanup implements caddy.CleanerUpper.

func (*Upstreams) GetUpstreams

func (u *Upstreams) GetUpstreams(r *http.Request) ([]*reverseproxy.Upstream, error)

GetUpstreams implements reverseproxy.UpstreamSource by resolving the request's host against the current Pangolin resource map.

func (*Upstreams) Provision

func (u *Upstreams) Provision(ctx caddy.Context) error

Provision implements caddy.Provisioner.

func (*Upstreams) UnmarshalCaddyfile

func (u *Upstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile implements caddyfile.Unmarshaler.

Jump to

Keyboard shortcuts

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