pathosd
Health-aware BGP VIP announcer. Runs local health checks (HTTP, HTTPS, DNS, ICMP ping, TCP, UDP, gRPC) with HAProxy-style rise/fall hysteresis, and announces or withdraws VIP routes over BGP based on service health.
Why pathosd?
High-availability services need a single, stable IP that fails over with the service. Classic approaches fall short:
- Keepalived (VRRP): ties failover to a shared virtual MAC and requires a master/backup election. It couples the health decision to link-layer state and doesn't interoperate with routing fabrics.
- Static VIPs: a load balancer or router advertises a VIP unconditionally — traffic is black-holed whenever the service behind it is down.
- Plain BGP speakers: GoBGP/BIRD can advertise a prefix, but you still have to build the health-checking, hysteresis, and withdraw logic and wire it into the route originator.
pathosd combines the health check and the BGP originator in one process, so the health decision and the route state are always consistent. VIPs are only announced once the backing service proves healthy, and withdrawn the instant it does not — with no external dependencies and no IPC to drift out of sync.
- Fail-closed by design: if
pathosd dies, BGP sessions drop and every route is withdrawn. A dead health checker never leaves stale routes in the network.
- Works with any BGP fabric: it is a route originator, so it peers with FRR, BIRD, or any standard BGP router — no VRRP, no shared MAC, no proprietary protocol.
- IPv4 and IPv6: announce IPv4
/32 and IPv6 /128 VIPs (or any prefix) over standard BGP/MP-BGP.
- One moving part: pure static binary, no sidecars, no agent to coordinate with.
pathosd is a service route originator, not a router. It embeds GoBGP to advertise /32 (or other) prefixes for Virtual IPs when the backing service is healthy, and withdraws them when it is not.
Fail-Closed Invariant
If the pathosd process dies, all BGP sessions drop and all routes are withdrawn. This is by design — a dead health checker must not leave stale routes in the network.
Single Process
The health checker and BGP speaker live in the same process. There is no separate checker binary or IPC — check results feed directly into route decisions with no external dependencies.
Features
- Health checks: HTTP/HTTPS (TLS, custom headers, response codes/text/regex/JQ), DNS (A/AAAA/CNAME/etc.), ICMP ping (loss ratio), TCP, UDP, gRPC (standard Health Checking Protocol + arbitrary unary methods)
- Rise/Fall hysteresis: Configurable consecutive success/failure thresholds before state transitions (HAProxy-style)
- Policy actions:
withdraw (remove route entirely) or lower_priority (AS-path prepend + communities)
- VIPs start withdrawn: No route is announced until the service proves healthy
- IPv4 + IPv6 VIPs: announce IPv4
/32 and IPv6 /128 prefixes (or any subnet) over BGP/MP-BGP; IPv6 routes are carried over IPv4-transport MP-BGP with a dedicated IPv6 next-hop
- Prometheus metrics: VIP state, check results, durations, peer status — plus GoBGP's built-in peer/route metrics
- HTTP API: landing page (
/), /healthz, /readyz, /status, /metrics, ad-hoc check trigger
- Optional GoBGP gRPC API: enable for
gobgp CLI inspection/debugging
- YAML and TOML config with JSON Schema validation
- Graceful restart support for BGP sessions
Configuration
Configuration uses schema: v1 versioning. Supported formats: YAML (.yaml/.yml) and TOML (.toml).
See examples/pathosd.yaml and examples/pathosd.toml for complete examples.
Validation
Validate a config file without starting the daemon:
pathosd validate --config /etc/pathosd/pathosd.yaml
Environment Placeholders
Config values can reference environment variables using VictoriaMetrics-style placeholders:
router:
router_id: "%{POD_IP}"
local_address: "%{PATHOSD_LOCAL_IP}"
local_address_ipv6: "%{PATHOSD_LOCAL_IPV6}"
bgp:
listen_address: "%{PATHOSD_LISTEN_IP}"
listen_port: 1179
gobgp_api:
enabled: true
listen: "%{PATHOSD_GOBGP_API_LISTEN}"
neighbors:
- name: frr
address: "%{FRR_PEER_IP}"
local_address: "%{PATHOSD_LOCAL_IP}"
%{VAR_NAME}: replaces with the value of VAR_NAME from the process environment.
%%{VAR_NAME}: escapes the pattern and keeps it as literal %{VAR_NAME}.
- If any referenced variable is missing, startup fails with a clear error listing missing names.
Key Config Rules
check.timeout must be strictly less than check.interval
rise and fall must be ≥ 1
- Each VIP name and prefix must be unique
- At least one neighbor and one VIP are required
lower_priority block is only valid when fail_action is lower_priority
- Any IPv6 VIP prefix requires
router.local_address_ipv6 (used as the IPv6 next-hop); the router_id stays IPv4
OpenWrt/BIRD Localhost Peering
On OpenWrt the mesh routers run BIRD, and pathosd lives on the same host. pathosd acts as a distinct ASN and announces anycast VIPs over a local eBGP session. The session is passive on pathosd's side: BIRD dials out to pathosd on a second IP that BIRD assigns to its own LAN interface, at port 1179.
Corresponding pathosd config:
router:
local_address: "<pathosd-local-ip>"
bgp:
listen_address: <pathosd-listen-ip>
listen_port: 1179
neighbors:
- name: bird-local
address: <bird-local-ip>
peer_asn: <bird_as>
passive: true
required: true
BIRD-side config for the local pathosd peer:
protocol bgp peer_pathosd_local {
local <bird-local-ip> as <bird_as>;
neighbor <pathosd-listen-ip> port 1179 as <pathosd_as>;
multihop;
ipv4 {
import filter {
if net ~ [ <vip>/32, ... ] then {
bgp_next_hop = net.ip;
preference = 250;
accept;
}
reject;
};
export none;
};
}
The import filter above is the critical part. pathosd announces VIP routes with router.local_address (the router's own address) as next-hop. BIRD refuses to install a route whose next-hop is a local address ("Next hop address X is a local address of iface"), so a naive import all would leave the route unreachable and never selected. The fix is done entirely in the filter:
- Restrict imports to the VIP prefixes only (
if net ~ [ <vip>/32, ... ]).
- Rewrite each VIP's next-hop to the VIP itself (
bgp_next_hop = net.ip) so the route is resolvable via the connected dummy_vip.
- Raise
preference = 250, above the connected/device route's default of 240, so BIRD selects pathosd's route over the connected VIP route.
- Reject everything else.
BIRD still marks the route unreachable locally (the next-hop caveat), but it selects and exports it to eBGP mesh peers — so the VIP reaches the mesh.
Pessimization: pathosd's fail_action: lower_priority with as_path_prepend propagates through BIRD to the mesh — remote routers see a longer AS path and steer away. This works directly over eBGP, so no iBGP or communities are required.
GoBGP CLI Debugging
Enable the embedded GoBGP gRPC API when you want to inspect live state with the gobgp CLI:
bgp:
gobgp_api:
enabled: true
# Optional, defaults to 127.0.0.1:50051 when enabled.
listen: 127.0.0.1:50051
Then query it with:
gobgp -u 127.0.0.1:50051 neighbor
gobgp -u 127.0.0.1:50051 global rib
JSON Schema
A JSON Schema is provided at schema/pathosd-config-v1.schema.json for editor autocompletion and validation.
Regenerate after Config struct changes:
go generate ./internal/config/...
Health Check Semantics
Rise/Fall
- Fall: number of consecutive failures before a healthy VIP transitions to unhealthy
- Rise: number of consecutive successes before an unhealthy VIP transitions to healthy
- VIPs always start in the withdrawn state — they must pass
rise consecutive checks before being announced
Ad-Hoc Check Trigger
For VIPs with long check intervals, you can trigger an immediate check:
curl -X POST http://127.0.0.1:59179/api/v1/vips/web-frontend/check
The result feeds into the normal rise/fall state machine — it does not bypass hysteresis.
HTTP Endpoints
| Endpoint |
Method |
Description |
/ |
GET |
HTML landing page with quick links and current daemon summary |
/healthz |
GET |
Liveness — 200 if process is running |
/readyz |
GET |
Readiness — 200 if all required BGP peers are established |
/status |
GET |
Full daemon state: peers, VIPs, last check results |
/metrics |
GET |
Prometheus metrics exposition |
/api/v1/vips/{name}/check |
POST |
Trigger ad-hoc health check |
Readiness vs Liveness
/healthz returns 200 as long as the process is up and config is loaded. It does NOT depend on BGP peer state.
/readyz returns 200 only when all required: true BGP peers have established sessions. Returns 503 with a JSON body listing unready peers otherwise.
Building
From Source
make build
With GoReleaser
goreleaser build --snapshot --clean
Docker
docker build -t pathosd .
Development Checks
Run these checks before opening a PR:
go build ./...
go test ./...
go vet ./...
golangci-lint run
go generate ./internal/config/...
git diff --exit-code schema/
E2E Testing
End-to-end tests run pathosd with FRR, nginx, CoreDNS, etcd, and syslog inside k3d/k3s and validate announce, pessimization, and withdraw behavior across all check types.
- Test code:
tests/e2e/e2e_test.go (//go:build e2e)
- Manifests:
tests/e2e/manifests/
- Design notes:
docs/e2e-test-design.md
Run locally:
make e2e
Or step-by-step:
make e2e-cluster
make e2e-build
make e2e-deploy
make e2e-test
Running
Binary
pathosd run --config /etc/pathosd/pathosd.yaml
Force debug logging regardless of config:
pathosd run --debug --config /etc/pathosd/pathosd.yaml
Container
docker run -d \
--name pathosd \
--cap-add NET_RAW \
--network host \
-v /etc/pathosd:/etc/pathosd:ro \
pathosd run --config /etc/pathosd/pathosd.yaml
NET_RAW capability is required for ICMP ping checks when check.ping.privileged=true (raw ICMP mode). The default privileged=false uses unprivileged UDP ping.
Version
pathosd --version
Build version, commit, and date are injected via ldflags at build time.
License
See LICENSE.