blob: eb0e3eba9486592a66dd31501a8c91d64372e85d [file] [view]
# A Practical Guide to `mod_proxy_beacon`
*Self-registering, self-healing reverse-proxy balancer membership over UDP.*
---
## 1. What it does
Normally, adding a backend to an httpd reverse-proxy load balancer means editing
the proxy config (or clicking around in `balancer-manager`) every time the fleet
changes. `mod_proxy_beacon` flips that around: **each backend announces itself**
to the proxy, and the proxy automatically:
- **adds and enables** the backend as a live `BalancerMember` when it first
announces, and
- **disables it** (takes it out of rotation) when it stops announcing — then
**re-enables it** when it comes back.
The result is a balancer whose membership tracks the running fleet with no
operator action. A backend added this way is a normal balancer member: it shows
up in `balancer-manager` and behaves exactly like a statically configured
`BalancerMember`.
```
backend-1 ──┐ (periodic UDP "BEACON" datagrams,
backend-2 ──┤ each advertising its own URL)
backend-3 ──┘ │
▼
┌──────────────┐
│ reverse proxy│ ProxyBeaconListen :5555
│ balancer:// │ → add / enable / evict members
│ cluster │
└──────────────┘
│
clients
```
---
## 2. How it works (the short version)
- **Transport:** plain **unicast UDP** datagrams (not multicast — multicast is
filtered on most networks and never crosses the public Internet). Backends
`sendto` the proxy; the proxy binds one socket and receives. No external
library — APR sockets for transport, APR-util SipHash for auth.
- **Fire-and-forget:** a lost datagram is simply recovered by the next periodic
announcement; reordered/replayed datagrams are rejected by a per-backend
timestamp check. No connection, reconnect, or framing layer.
- **Where it runs:** all the work happens in a single `mod_watchdog` **singleton**
child process, which owns the socket and applies membership changes. The change
propagates fleet-wide through the balancer's shared-memory “updated” timestamp,
the same way `balancer-manager` edits do.
---
## 3. Requirements
| Need | Why |
|------|-----|
| `mod_proxy` + `mod_proxy_balancer` | the balancer the members are added to |
| `mod_watchdog` | runs the background send/receive/evict loop |
| A **non-prefork** MPM (`event`, `worker`, …) | the watchdog singleton can't run under `prefork`; the module is silently inactive there |
| `mod_proxy_http` (or your backend protocol module) | to actually proxy the traffic |
Build it with `--enable-proxy-beacon` (or `--enable-mods-shared=all`). Confirm
it's present:
```sh
httpd -M 2>&1 | grep proxy_beacon # shared build
# or
httpd -l | grep mod_proxy_beacon # static build
```
---
## 4. Quick start
### On the reverse proxy
```apache
# Receive backend announcements on the cluster-facing interface (UDP/5555).
ProxyBeaconListen 0.0.0.0:5555
ProxyBeaconSecret "hex:00112233445566778899aabbccddeeff"
ProxyBeaconBalancer cluster
# Drop a backend from rotation if it goes silent for 30s; re-add on next beacon.
ProxyBeaconTimeout 30
# An initially EMPTY balancer with spare slots for the dynamic members.
<Proxy balancer://cluster>
ProxySet growth=16
</Proxy>
ProxyPass "/" "balancer://cluster/"
ProxyPassReverse "/" "balancer://cluster/"
```
### On each backend
```apache
# Announce this backend's routable origin to the proxy every 10s (UDP).
ProxyBeaconAddress proxy.example.com:5555
ProxyBeaconAdvertise http://10.0.0.5:8080
ProxyBeaconSecret "hex:00112233445566778899aabbccddeeff"
ProxyBeaconInterval 10
```
That's it. Start the backends; within one announcement interval each one appears
as an enabled member of `balancer://cluster` on the proxy. Stop one, and after
`ProxyBeaconTimeout` it drops out of rotation; start it again and it returns.
> **`ProxyBeaconAdvertise` must be the URL the *proxy* can reach** — the
> backend's routable origin, not `127.0.0.1`. It's validated at config-parse
> time and must be a full `scheme://host[:port]` **with no path** (see
> [Member identity](#member-identity)).
---
## 5. Directive reference
### Proxy side (the receiver)
| Directive | Default | Meaning |
|-----------|---------|---------|
| `ProxyBeaconListen [addr][:port]` | — | **Marks this server as the receiver.** Binds a UDP socket. `addr`/`port` are optional and inherited from the server's own `Listen`/`ServerName` when omitted (see note below). |
| `ProxyBeaconBalancer name` | — | Balancer that announced backends are added to. Bare name (`cluster`); a leading `balancer://` is stripped. Must already exist with spare `growth`. |
| `ProxyBeaconTimeout interval` | `0` (no eviction) | Seconds of silence before a member is disabled. `0` = add-only, never auto-remove. Set to a small multiple of the backends' interval to get self-healing. |
| `ProxyBeaconSecret secret` | — (**required**) | Shared cluster secret; **same value on proxy and all backends**, either `hex:` + 32 hex digits (a random key: `openssl rand -hex 16`) or a passphrase of at least 16 characters (weaker: guessable offline from one captured packet). The server fails to start if a participating server omits it or it is invalid. |
| `ProxyBeaconMaxSkew interval` | `30` | Anti-replay freshness window: reject announcements whose signed timestamp is more than this far from now (either direction). Sub-second values (`500ms`) are honoured. |
**Inheriting the listen address:** because UDP and TCP are separate port spaces,
`ProxyBeaconListen` can share the server's own service port without colliding
with the TCP listener. With no argument it binds the server's own address:port;
given just an address it inherits the port; etc. (The socket binds in an
unprivileged watchdog child, so you **can't** share a privileged port like 80/443
this way — use an explicit high port there.)
### Backend side (the sender)
| Directive | Default | Meaning |
|-----------|---------|---------|
| `ProxyBeaconAddress addr:port` | — | **Marks this server as a sender.** UDP target = the proxy's `ProxyBeaconListen` address. |
| `ProxyBeaconAdvertise url` | — | The routable `scheme://host[:port]` origin the proxy adds as a member — no path, query, fragment or userinfo (rejected at config time). Omit it and the backend beacons but advertises nothing (logged, never added). |
| `ProxyBeaconInterval interval` | `5` | How often this backend announces. Must be **meaningfully smaller** than the proxy's `ProxyBeaconTimeout`. |
| `ProxyBeaconSecret secret` | — (**required**) | Same shared secret as the proxy. |
> `ProxyBeaconListen` and `ProxyBeaconAddress` are **mutually exclusive** on the
> same server — a server is either a receiver or a sender, not both.
All directives accept `server config` and `virtual host` context. Interval-style
directives use the standard httpd
[time-interval syntax](../../docs/manual/mod/directive-dict.html#Syntax) and
default to seconds (e.g. `ProxyBeaconInterval 10` or `... 500ms`).
### Member identity
An announced URL names a backend **origin** — `scheme://host[:port]`, nothing
more. The proxy canonicalizes it and uses that one string as the member's
identity everywhere: the key for its lease and replay state, the worker name it
creates, and the name used for every later enable or evict.
- **Canonical form:** scheme and host lower-cased, the scheme's default port
dropped, a single trailing `/` ignored. `HTTP://App.Example.com:80`,
`http://app.example.com/` and `http://app.example.com` are **one** member.
- **Rejected:** a path (other than `/`), query, fragment, userinfo, an invalid
port, or the `unix:/path|http://…` socket form. These are dropped and logged as
`unacceptable url`; the backend is not added. `ProxyBeaconAdvertise` enforces
the same rule when the config is parsed, so a backend can't be misconfigured
into announcing one.
**Why no paths?** The balancer finds a member by *longest-prefix* match.
`http://10.0.0.5:8080/one` and `http://10.0.0.5:8080/one/two` would be tracked as
two members with separate leases but resolve to one worker — so when one lease
lapsed, the proxy would disable a worker whose other lease was still current. If
you need different paths to go to different places, do that on the proxy with
`ProxyPass`; advertise only where the backend lives.
### Ownership and hand-off
Beacon marks every worker it creates with the **`Bcn`** status (flag `B`, visible
in the balancer-manager). It only ever enables or evicts workers carrying that
mark: a `BalancerMember` from your configuration, or one an administrator added,
is never touched, even if a backend announces exactly its URL.
The announcements and leases are handled by **one** watchdog child. If that child
dies, the watchdog moves to another child, which starts with no memory of the
members. On taking over it **adopts every `Bcn` member** in the balancer and
starts their leases afresh:
- a member that has since gone silent is evicted `ProxyBeaconTimeout` after the
hand-off, instead of staying enabled forever;
- a member that was already disabled is treated as evicted and is re-enabled by
its next announcement.
Two things to know: a dead backend can stay in rotation for up to one
`ProxyBeaconTimeout` after a hand-off, and the per-member replay high-water mark
is not carried over — during a hand-off a captured announcement still inside
`ProxyBeaconMaxSkew` can be replayed once per member, refreshing that lease for one
timeout (it cannot add a member without the secret).
**Holding a member out of rotation.** Beacon owns a member's **Disabled** flag
(lease expiry sets it, the next announcement clears it), so a manual "Disable" in
the balancer-manager is overridden. To keep a member out regardless of what it
announces, set it **Stopped** or **Drain** — beacon never touches those, nor the
health-check failure flag.
**Capacity.** At most 256 announced URLs are tracked. Records of evicted members
and of failed adds are released after being silent for more than twice
`ProxyBeaconMaxSkew` (a replay of their last message would be stale by then), so
they don't lock out new backends; a member that comes back is re-adopted. A
balancer itself accepts at most 100 runtime members (`growth`). A holder of the
secret can still fill the table with distinct URLs until they fall silent.
**Socket problems.** If the UDP socket can't be opened (unresolvable address, port
in use) it is retried with back-off, 30 s doubling to 10 min. Name lookups are
synchronous on the single watchdog thread shared by every beacon configuration of
the server, so an unresolvable name can stall them all for as long as the resolver
takes to give up — **use IP addresses** in `ProxyBeaconAddress` / `ProxyBeaconListen`.
> A new owner can only start once the old one's `watchdog-callback` mutex is
> released. If the default mechanism isn't released when its holder is killed on
> your platform, pick one that is: `Mutex sysvsem watchdog-callback`.
---
## 6. Security — read this before production
The control channel decides where the proxy sends client traffic, so an
unauthenticated channel is a route-hijack / SSRF risk: anyone who can reach the
receive port could announce an arbitrary backend URL, and **UDP source addresses
are trivially spoofable.**
`ProxyBeaconSecret` is therefore **required** (identical on proxy and every
backend) — `httpd -t` and startup both fail if a participating server omits it, so
there is no unauthenticated mode to fall into. With it:
- Each announcement is signed with a **SipHash-2-4 MAC** plus a timestamp; the
proxy recomputes the MAC (constant-time compare) and drops anything forged or
tampered **before parsing the URL**.
- **Replay protection is two-layered:** (1) the `ProxyBeaconMaxSkew` freshness
window rejects stale timestamps, and (2) a per-backend high-water mark rejects
any timestamp that doesn't strictly advance — so capturing and re-sending a
dead backend's last datagram (to keep it from being evicted) is rejected even
inside the freshness window.
**What the secret is.** A valid MAC proves only that the sender holds the
secret — not that it owns the URL it announces, may speak for another backend, or
is ready to serve. Every holder can register any origin the proxy can reach,
keep other members' leases alive, and use up growth slots and tracking records.
Treat the secret as an **administrator credential for the balancer** and share it
only among hosts you'd trust with that; it is not a boundary between partially
trusted workloads. Because the key is derived from the passphrase with a fast
hash (and the tag is 64 bits), a captured announcement allows offline guessing:
**use a long random secret** (at least 16 characters is enforced).
**What is checked.** Every datagram is dropped unless it is ≤ 2048 bytes with no
NUL, carries a valid MAC (even when no `ProxyBeaconBalancer` is set, so log-only
mode logs only authenticated announcements), has the exact `BEACON key=value … mac=…`
shape with no repeated field and a plain decimal `ts=`, and names a printable-ASCII
origin.
Operational notes:
- **Clocks must be roughly in sync** (NTP). The timestamp check compares the
announcement's time against the proxy's clock; widen `ProxyBeaconMaxSkew` if
your hosts drift.
- The secret lives in your config file — **restrict its permissions like a
private key.**
- Announcements are **authenticated, not encrypted.** The payload is operational
metadata (URLs), not secrets. There's no transport confidentiality (DTLS would
be a separate future layer).
---
## 7. Tuning
- **Interval vs. timeout.** Keep `ProxyBeaconInterval` well under
`ProxyBeaconTimeout` so an occasional dropped datagram doesn't evict a healthy
backend. A common ratio is timeout ≈ 3–6× interval (e.g. interval 10s, timeout
30–60s).
- **Size `growth` for your peak fleet.** A runtime-added member occupies one
growth slot **for the life of the proxy process** — it's *disabled* on
eviction, not removed (httpd can't remove workers at runtime, mirroring
`balancer-manager`). So `growth` must cover the maximum number of distinct
backend URLs you ever expect, not just the count live at one moment.
- **One proxy, many backends.** A single receiver serves the whole fleet. For
multiple proxies, run `ProxyBeaconListen` on each and point backends at all of
them (one `ProxyBeaconAddress` server per proxy, or accept that only the
configured proxy learns about the backend).
---
## 8. Verifying & troubleshooting
**See members appear:** enable `balancer-manager` on the proxy and watch the
worker list:
```apache
<Location "/balancer-manager">
SetHandler balancer-manager
Require host localhost
</Location>
```
**Watch the log.** Bump verbosity for the module and tail the error log:
```apache
LogLevel proxy_beacon:info
```
Useful log signals (grep the error log):
| Log fragment | Means |
|--------------|-------|
| `received: BEACON ... url=...` | proxy is receiving announcements |
| `added backend ... balancer://cluster` | member created + enabled |
| `evicted backend ...` | a backend went silent past the timeout |
| `re-enabled backend ...` | a previously-evicted backend came back |
| `dropped ... mac mismatch` | wrong/missing secret on a sender (or a forged datagram) |
| `replayed/reordered ts` | a stale/duplicate datagram was rejected |
| `dropped ... oversize datagram` / `embedded NUL` / `bad frame` / `duplicate field` / `bad ts` | a datagram that isn't a well-formed announcement (see "What is checked") |
| `dropped ... member table full` | 256 urls are already tracked; records free up as silent ones age out (see Capacity) |
| `dropped ... unacceptable url` | an announcement's `url=` is not a plain origin (it has a path, query, userinfo, a bad port, or is a `unix:` URL) |
| `took over N existing member(s)` | a new watchdog owner adopted the beacon-created members already in the balancer (see [Ownership and hand-off](#ownership-and-hand-off)) |
| `dropped ... member not managed by beacon` | a backend announced the URL of a worker beacon did not create (configured or admin-added); it is left alone |
| `dropped ... balancer add not confirmed` | the balancer reported success but no such member exists; retried after the back-off |
| `ProxyBeaconSecret is required` (startup) | a participating server has no `ProxyBeaconSecret` — startup aborts |
**Common gotchas:**
- *Backend never joins.* Usually a **secret mismatch** (look for `mac mismatch`)
or a firewall blocking **UDP** on the beacon port (it's UDP, not TCP — separate
rule). Confirm the proxy is logging `received: BEACON` at all.
- *Nothing happens, no errors.* You're probably on the **prefork MPM** — the
watchdog singleton doesn't run there, so the module is inactive. Switch to
`event`/`worker`.
- *Traffic 503s to a backend that's “up”.* Check that `ProxyBeaconAdvertise` is
the URL the **proxy** can reach (not the backend's loopback) and that the
balancer has free `growth` slots.
- *Healthy backend keeps getting evicted.* Interval too close to timeout, or
clock skew larger than `ProxyBeaconMaxSkew`. Widen the timeout/skew or sync
clocks.
---
## 9. The wire format (for the curious)
One ASCII, space-separated, key=value datagram per announcement:
```
BEACON url=http://host:port host=<h> pid=<n> seq=<n> ts=<usec> mac=<hex>
```
- `url=` — the routable origin added as a `BalancerMember`; `scheme://host[:port]`
only (see [Member identity](#member-identity)).
- `ts=` (microseconds since epoch) and `mac=` — present only when a secret is set;
`ts` drives replay protection, `mac` is the SipHash signature.
- `host=` / `pid=` / `seq=` — informational. (`seq` resets on backend restart, so
it's *not* used for replay detection — the wall-clock `ts` is.)
---
## 10. See also
- `mod_proxy`, `mod_proxy_balancer` — the balancer these members live in
- `mod_proxy_hcheck` — active health checking, complementary to beacon liveness
- `mod_watchdog` — the background runner this module rides on
- `modules/proxy/README.beacon` — the architecture/internals companion to this guide
- `docs/manual/mod/mod_proxy_beacon.xml` — the formal directive reference