--- /dev/null
+<?xml version="1.0"?>
+<!DOCTYPE modulesynopsis SYSTEM "../style/modulesynopsis.dtd">
+<?xml-stylesheet type="text/xsl" href="../style/manual.en.xsl"?>
+<!-- $LastChangedRevision$ -->
+
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<modulesynopsis metafile="mod_proxy_beacon.xml.meta">
+
+<name>mod_proxy_beacon</name>
+<description>Dynamic Balancer membership where backends announce themselves
+to the reverse proxy over unicast UDP datagrams</description>
+<status>Extension</status>
+<sourcefile>mod_proxy_beacon.c</sourcefile>
+<identifier>proxy_beacon_module</identifier>
+<compatibility>Available in Apache 2.5 and later</compatibility>
+
+<summary>
+ <p>This module lets backend servers <em>announce themselves</em> to a
+ front-end reverse proxy, which then adds each announcing backend as a live
+ member (worker) of a <module>mod_proxy_balancer</module> balancer. When a
+ backend stops announcing, the proxy takes it out of rotation. This provides
+ self-registering, self-healing balancer membership without editing the proxy
+ configuration or driving the <code>balancer-manager</code> by hand.</p>
+
+ <p>Communication uses plain <strong>unicast UDP</strong> datagrams (not
+ multicast, which is filtered on most networks and does not traverse the
+ public Internet). The data flows from backend to proxy:</p>
+
+ <ul>
+ <li>The reverse proxy binds a UDP socket and <em>receives</em> on a
+ stable address (<directive>ProxyBeaconListen</directive>).</li>
+ <li>Each backend periodically <em>sends</em> a short announcement datagram
+ to the proxy (<directive>ProxyBeaconAddress</directive>), advertising
+ its own routable URL
+ (<directive>ProxyBeaconAdvertise</directive>).</li>
+ </ul>
+
+ <p>Datagrams are fire-and-forget: a lost announcement is recovered by the
+ next periodic one, and reordering is rejected by a per-backend timestamp
+ check, so no connection, reconnect, or framing layer is needed.</p>
+
+ <p>On the proxy, <directive>ProxyBeaconBalancer</directive> names the balancer
+ that announced backends are added to. Membership changes are applied using
+ the same internal mechanism as the <code>balancer-manager</code> web
+ interface, so a backend added this way behaves exactly like a statically
+ configured or manually added
+ <directive module="mod_proxy">BalancerMember</directive>, and is visible and
+ editable in the <code>balancer-manager</code>.</p>
+
+ <p>This module <em>requires</em> the service of
+ <module>mod_watchdog</module> and <module>mod_proxy_balancer</module>. The
+ background work (listening, publishing, adding and evicting members) runs in
+ a single <module>mod_watchdog</module> child process, so it is not available
+ under the <code>prefork</code> MPM behaviour where that singleton cannot
+ run.</p>
+
+<note type="warning"><title>Authentication</title>
+ <p>Any host that can reach the proxy's receive port could otherwise announce
+ an arbitrary backend URL and cause the proxy to send client traffic to it
+ (and a UDP source address is trivially spoofable). Set
+ <directive>ProxyBeaconSecret</directive> to the same value on the proxy and on
+ every backend so that announcements are authenticated with a keyed
+ message-authentication code (MAC) and a timestamp. When a secret is
+ configured the proxy drops any announcement that is not validly signed and
+ recent. If no secret is configured the channel is <strong>unauthenticated</strong>
+ and the proxy logs a warning at startup.</p>
+</note>
+
+<note><title>Confidentiality</title>
+ <p>Announcements are authenticated but not encrypted; the payload is
+ operational metadata (backend URLs), not secret data. Transport
+ confidentiality (e.g. DTLS) is not currently provided and would be a separate
+ future layer.</p>
+</note>
+
+</summary>
+<seealso><module>mod_proxy</module></seealso>
+<seealso><module>mod_proxy_balancer</module></seealso>
+<seealso><module>mod_proxy_hcheck</module></seealso>
+<seealso><module>mod_watchdog</module></seealso>
+
+<section id="examples">
+ <title>Usage example</title>
+
+ <p>The following pair of configurations sets up a self-registering balancer.
+ The backends require no knowledge of each other and the proxy needs no
+ pre-declared <directive module="mod_proxy">BalancerMember</directive>
+ entries — only an empty balancer with room to grow.</p>
+
+ <p>On the <strong>reverse proxy</strong>:</p>
+ <highlight language="config">
+# Receive backend announcements on the cluster network interface (UDP).
+ProxyBeaconListen 0.0.0.0:5555
+ProxyBeaconSecret "a-long-random-shared-cluster-secret"
+ProxyBeaconBalancer cluster
+
+# A backend is dropped from rotation if it does not announce for 30 seconds.
+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/"
+ </highlight>
+
+ <p>On each <strong>backend</strong> server:</p>
+ <highlight language="config">
+# Announce this backend's routable origin to the proxy every 10 seconds (UDP).
+ProxyBeaconAddress proxy.example.com:5555
+ProxyBeaconAdvertise http://10.0.0.5:8080
+ProxyBeaconSecret "a-long-random-shared-cluster-secret"
+ProxyBeaconInterval 10
+ </highlight>
+
+ <p>When a backend starts it begins sending announcements. The proxy
+ verifies each announcement against the shared secret, adds
+ <code>http://10.0.0.5:8080</code> as a member of
+ <code>balancer://cluster</code>, and enables it. If that backend later stops
+ announcing for longer than <directive>ProxyBeaconTimeout</directive>, the proxy
+ disables the member (taking it out of rotation); a subsequent announcement
+ re-enables it.</p>
+
+ <note>
+ <p>A backend added at runtime occupies one of the balancer's growth slots
+ for the lifetime of the server process; it is disabled rather than removed
+ when it stops announcing, matching the behaviour of the
+ <code>balancer-manager</code> (which can add, but not remove, workers at
+ runtime). Size <code>growth</code> for the maximum number of backends you
+ expect to register.</p>
+ </note>
+
+</section>
+
+<directivesynopsis>
+<name>ProxyBeaconListen</name>
+<description>Address on which the reverse proxy receives backend
+beacons</description>
+<syntax>ProxyBeaconListen [<em>address</em>][:<em>port</em>]</syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconListen</directive> directive marks a server as
+ the beacon <em>receiver</em> (the reverse proxy). It binds a UDP socket to
+ the given address, e.g. <code>0.0.0.0:5555</code> to receive on all
+ interfaces. A leading scheme (such as <code>tcp://</code>) is accepted and
+ ignored.</p>
+
+ <p>The address and port are both optional and, when omitted, are inherited
+ from this server's own address and port (its <directive
+ module="core">Listen</directive>/<directive
+ module="core">ServerName</directive>). With no argument at all, the beacon
+ listener binds the server's own address and port; given just an address it
+ inherits the port, and so on. Because UDP and TCP are independent port
+ spaces, binding the beacon socket to the server's port does <em>not</em>
+ collide with the server's TCP listener — letting the beacon channel
+ share the service endpoint, which also identifies the proxy to backends by
+ its real address. (The listener binds in an unprivileged child, so a
+ privileged port such as 80 or 443 cannot be shared this way; use the
+ server's port only when it is non-privileged.)</p>
+
+ <p>Backends send to this address via
+ <directive>ProxyBeaconAddress</directive>. The directive should be used
+ together with <directive>ProxyBeaconBalancer</directive>; without it,
+ announcements are received and logged but no members are added.
+ <directive>ProxyBeaconListen</directive> and
+ <directive>ProxyBeaconAddress</directive> are mutually exclusive on the same
+ server.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconAddress</name>
+<description>Address of the reverse proxy to which a backend sends its
+announcements</description>
+<syntax>ProxyBeaconAddress <em>address:port</em></syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconAddress</directive> directive marks a server as an
+ announcement <em>sender</em> (a backend). It sends UDP datagrams to the
+ proxy's <directive>ProxyBeaconListen</directive> address given by
+ <em>address:port</em>, e.g. <code>proxy.example.com:5555</code> (a leading
+ scheme such as <code>tcp://</code> is accepted and ignored). Because UDP is
+ connectionless, a backend may be started before the proxy is available:
+ early datagrams are simply dropped and the next interval retries.</p>
+
+ <p>Use <directive>ProxyBeaconAdvertise</directive> to specify the routable URL
+ the backend announces. <directive>ProxyBeaconAddress</directive> and
+ <directive>ProxyBeaconListen</directive> are mutually exclusive on the same
+ server.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconAdvertise</name>
+<description>The routable URL a backend announces to the reverse proxy</description>
+<syntax>ProxyBeaconAdvertise <em>url</em></syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconAdvertise</directive> directive sets the backend's
+ own reachable origin (for example <code>http://10.0.0.5:8080</code>) that the
+ proxy will add as a <directive module="mod_proxy">BalancerMember</directive>.
+ It must be a full <code>scheme://host[:port]</code> URL that the proxy can
+ reach — not the local listen address — and is validated when the
+ configuration is parsed.</p>
+
+ <p>This directive is used on a backend, alongside
+ <directive>ProxyBeaconAddress</directive>. If it is omitted, the backend still
+ sends a heartbeat but advertises no URL, so the proxy logs the
+ announcement without adding a member.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconBalancer</name>
+<description>Name of the balancer that announced backends are added to</description>
+<syntax>ProxyBeaconBalancer <em>name</em></syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconBalancer</directive> directive names the balancer,
+ on the reverse proxy, into which announced backends are inserted as members.
+ Give the bare balancer name (for example <code>cluster</code> for
+ <code>balancer://cluster</code>); a leading <code>balancer://</code> is
+ accepted and stripped.</p>
+
+ <p>The named balancer must exist and have spare capacity. Declare it with a
+ <directive module="mod_proxy"><Proxy></directive> block and a
+ <code>growth</code> setting (or rely on
+ <directive module="mod_proxy">BalancerGrowth</directive>) so there are free
+ slots for the dynamically added members. This directive is used together
+ with <directive>ProxyBeaconListen</directive>.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconInterval</name>
+<description>How often a backend publishes its announcement</description>
+<syntax>ProxyBeaconInterval <em>interval</em></syntax>
+<default>ProxyBeaconInterval 5</default>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconInterval</directive> directive sets how frequently
+ a backend (a <directive>ProxyBeaconAddress</directive> server) publishes its
+ announcement. It uses the
+ <a href="directive-dict.html#Syntax">time-interval</a> directive syntax and
+ defaults to seconds; the default is 5 seconds.</p>
+
+ <p>The interval must be meaningfully smaller than the proxy's
+ <directive>ProxyBeaconTimeout</directive>, so that the occasional lost or
+ delayed announcement does not cause a healthy backend to be evicted.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconTimeout</name>
+<description>How long the proxy waits, without an announcement, before a backend
+is taken out of rotation</description>
+<syntax>ProxyBeaconTimeout <em>interval</em></syntax>
+<default>ProxyBeaconTimeout 0</default>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconTimeout</directive> directive sets how long the
+ reverse proxy will wait for an announcement from a backend before disabling
+ that backend's balancer member (taking it out of rotation). A later
+ announcement from the same backend re-enables it. It uses the
+ <a href="directive-dict.html#Syntax">time-interval</a> directive syntax and
+ defaults to seconds.</p>
+
+ <p>The default, <code>0</code>, disables eviction entirely: backends are
+ added when they announce but are never automatically removed. Set this to a
+ small multiple of the backends' <directive>ProxyBeaconInterval</directive> to
+ enable self-healing membership. This directive is used on the proxy.</p>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconSecret</name>
+<description>Pre-shared secret used to authenticate announcements</description>
+<syntax>ProxyBeaconSecret <em>secret</em></syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconSecret</directive> directive sets a pre-shared
+ cluster secret. It must be configured with the <em>same</em> value on the
+ reverse proxy and on every backend. The backend (sender) signs each
+ announcement with a keyed message-authentication code (a SipHash MAC) derived
+ from the secret, together with a timestamp; the proxy (receiver) recomputes the MAC and
+ checks the timestamp, dropping any announcement that is forged, tampered
+ with, or replayed. Replayed messages are caught two ways: a freshness window
+ (<directive>ProxyBeaconMaxSkew</directive>) rejects old timestamps, and a
+ per-backend check rejects any announcement whose timestamp does not strictly
+ advance, so a captured-and-resent message (for example, one replayed to keep
+ a dead backend from being evicted) is dropped.</p>
+
+ <p>If <directive>ProxyBeaconSecret</directive> is set on the proxy, every
+ announcement must carry a valid, recent MAC or it is rejected. If the
+ secrets on the proxy and a backend differ, that backend's announcements are
+ silently rejected (and logged), which appears as the backend never joining
+ the balancer.</p>
+
+ <p>If no secret is configured the channel is unauthenticated and the proxy
+ emits a warning when it starts listening. Because the secret is stored in
+ the configuration file, restrict that file's permissions as you would for a
+ private key.</p>
+
+ <note><title>Clock synchronisation</title>
+ <p>The timestamp-based replay protection compares the announcement's time
+ against the proxy's clock, so the proxy and backends must have reasonably
+ synchronised clocks (for example via NTP). See
+ <directive>ProxyBeaconMaxSkew</directive>.</p>
+ </note>
+</usage>
+</directivesynopsis>
+
+<directivesynopsis>
+<name>ProxyBeaconMaxSkew</name>
+<description>Maximum allowed age of a signed announcement</description>
+<syntax>ProxyBeaconMaxSkew <em>interval</em></syntax>
+<contextlist><context>server config</context><context>virtual host</context>
+</contextlist>
+
+<usage>
+ <p>The <directive>ProxyBeaconMaxSkew</directive> directive sets the anti-replay
+ window used when <directive>ProxyBeaconSecret</directive> is configured: the
+ proxy rejects any announcement whose signed timestamp differs from the
+ current time by more than this amount, in either direction. It uses the
+ <a href="directive-dict.html#Syntax">time-interval</a> directive syntax and
+ defaults to seconds.</p>
+
+ <p>If unset, the default is 30 seconds. A larger window tolerates greater
+ clock skew between hosts; a smaller window bounds the freshness check. Note
+ that the per-backend strictly-increasing-timestamp check (see
+ <directive>ProxyBeaconSecret</directive>) blocks replays regardless of this
+ window. This directive is used on the proxy.</p>
+</usage>
+</directivesynopsis>
+
+</modulesynopsis>
--- /dev/null
+# 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 "a-long-random-shared-cluster-secret"
+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 "a-long-random-shared-cluster-secret"
+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]`.
+
+---
+
+## 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` | — (unauthenticated) | Shared cluster secret; **same value on proxy and all backends.** |
+| `ProxyBeaconMaxSkew interval` | `30` | Anti-replay freshness window: reject announcements whose signed timestamp is more than this far from now (either direction). |
+
+**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]` the proxy adds as a member. 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` | — | 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`).
+
+---
+
+## 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.**
+
+**Always set `ProxyBeaconSecret`** in production (identical on proxy and every
+backend). 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.
+
+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.
+- With **no secret**, the channel is unauthenticated and the proxy logs a
+ one-time `UNAUTHENTICATED` warning at startup.
+- 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 |
+| `UNAUTHENTICATED` (startup) | no `ProxyBeaconSecret` — channel is open |
+
+**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`.
+- `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
--- /dev/null
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * mod_proxy_beacon -- a UDP datagram announcement channel between backend
+ * servers and a front-end reverse proxy, by which backends self-register as
+ * balancer members.
+ *
+ * This module uses plain APR UDP sockets, adapting the socket-handling pattern
+ * of mod_heartmonitor. UDP datagrams are a natural fit for small, periodic,
+ * fire-and-forget announcements, and avoid a third-party dependency.
+ *
+ * UNICAST, not multicast: unlike mod_heartbeat (which multicasts to a group),
+ * each backend sends a point-to-point datagram to the proxy's configured address
+ * (apr_socket_sendto to a single resolved unicast sockaddr). Multicast is
+ * filtered on most networks and does not traverse the public Internet, so a
+ * cross-host control plane must be unicast. The proxy is a fixed unicast
+ * rendezvous; each backend is configured with its address.
+ *
+ * Direction: backends announce themselves to the proxy. The proxy binds a
+ * stable UDP port (the rendezvous) and receives; each backend sends to it:
+ *
+ * - Reverse proxy: ProxyBeaconListen 0.0.0.0:5555 (receiver, binds)
+ * - Backend server: ProxyBeaconAddress proxy-host:5555 (sender, sendto)
+ *
+ * A leading scheme (e.g. tcp://) in the address is accepted and ignored, so
+ * addresses written with a transport scheme keep working. Each backend sends
+ * a "BEACON" datagram every interval. Datagrams are fire-and-forget: a lost
+ * packet just delays an update by one interval, and reordering is rejected by
+ * the per-url monotonic timestamp (Phase 4), so the design tolerates UDP's lack
+ * of delivery/ordering guarantees without a reconnect or framing layer.
+ *
+ * Phase 1 (the channel): the proxy simply logged each announcement, proving the
+ * channel works between separate, remote servers.
+ *
+ * Phase 2: when the proxy receives an announcement that carries a backend
+ * URL, it ADDS that backend as a member (worker) of a configured balancer and
+ * ENABLES it -- the dynamic equivalent of "Add Worker" in the balancer-manager
+ * web UI, but driven by the backend announcing itself. We reuse
+ * mod_proxy_balancer's exported balancer_manage() optional function (the same
+ * core balancer_handler calls), so the add path is identical to the proven
+ * manager flow. The backend advertises its routable URL via ProxyBeaconAdvertise;
+ * the proxy names the target balancer via ProxyBeaconBalancer.
+ *
+ * Phase 3: the inverse -- when a backend STOPS announcing for longer than
+ * ProxyBeaconTimeout, the proxy takes it out of rotation by setting the worker's
+ * DISABLED flag (again via balancer_manage), and re-enables it if the backend
+ * starts announcing again. This gives the cluster automatic liveness without
+ * operator action. The timeout is a config directive (0 = disabled = pure
+ * Phase-2 behavior). All of this runs in the watchdog singleton that already
+ * owns the socket, so the per-URL last-seen table lives in this process's
+ * ctx -- no shared memory.
+ *
+ * Phase 4: authenticate the channel. Without this, anyone who can reach the
+ * proxy's bound port can announce an arbitrary url and the proxy will route real
+ * client traffic to it (SSRF / hijack) -- and a UDP source address is trivially
+ * spoofable, so authentication matters as much here as on any control plane. With
+ * a pre-shared cluster secret (ProxyBeaconSecret) on both ends, the sender appends
+ * a keyed MAC (SipHash-2-4 via APR-util -- the same primitive mod_session_crypto
+ * uses) plus a timestamp; the receiver recomputes the MAC and checks timestamp
+ * freshness (anti-replay), dropping any forged, tampered, or stale message
+ * before it is parsed/acted on. Authentication is opt-in: with no secret the
+ * channel behaves as before but logs a one-time "UNAUTHENTICATED" warning. We
+ * authenticate, not encrypt -- the payload (backend URLs) is not secret; for
+ * transport confidentiality DTLS would be a separate, orthogonal future layer.
+ *
+ * The background work runs on a mod_watchdog SINGLETON instance (exactly one
+ * child process owns the socket), mirroring mod_proxy_hcheck.c. That child has
+ * no request_rec, so the receiver synthesizes a minimal one for balancer_manage()
+ * (see beacon_make_fake_request -- adapted from mod_proxy_hcheck's
+ * create_request_rec, plus a fake conn_rec that ap_log_rerror requires).
+ */
+
+#include "httpd.h"
+#include "http_config.h"
+#include "http_core.h"
+#include "http_log.h"
+#include "http_protocol.h"
+#include "ap_provider.h"
+#include "ap_listen.h"
+#include "mod_watchdog.h"
+#include "mod_proxy.h"
+
+#include "apr_strings.h"
+#include "apr_time.h"
+#include "apr_hash.h"
+#include "apr_uri.h"
+#include "apr_network_io.h"
+#include "apr_siphash.h"
+#include "apr_md5.h"
+
+#if APR_HAVE_UNISTD_H
+#include <unistd.h> /* for getpid() */
+#endif
+
+module AP_MODULE_DECLARE_DATA proxy_beacon_module;
+
+#define BEACON_WATCHDOG_NAME "_proxy_beacon_"
+#define BEACON_DEFAULT_INTERVAL apr_time_from_sec(5)
+
+typedef enum {
+ BEACON_ROLE_NONE = 0,
+ BEACON_ROLE_SEND, /* backend: dials the proxy and announces itself */
+ BEACON_ROLE_LISTEN /* reverse proxy: listens and receives announcements */
+} beacon_role_e;
+
+/* listener-side per-backend state, keyed by url in ctx->seen. */
+typedef struct {
+ apr_time_t last_seen; /* updated on every beacon for this url */
+ apr_time_t last_attempt; /* last add attempt (throttles retry when full) */
+ apr_int64_t last_ts; /* highest signed ts= accepted (replay guard) */
+ int added; /* worker exists in the balancer (add dedup) */
+ int evicted; /* WE set DISABLED on timeout (guards flip churn) */
+} beacon_member_t;
+
+typedef struct {
+ apr_pool_t *p; /* subpool for this context */
+ server_rec *s; /* canonical server identity */
+ char *pub_url; /* ProxyBeaconAddress (backend dial url) */
+ char *sub_url; /* ProxyBeaconListen (proxy listen url) */
+ char *advertise_url;/* ProxyBeaconAdvertise (sender: routable url) */
+ char *balancer_name;/* ProxyBeaconBalancer (listener: bare bal name) */
+ apr_interval_time_t interval; /* ProxyBeaconInterval (announce period) */
+ apr_interval_time_t evict_timeout;/* ProxyBeaconTimeout (0 = no eviction) */
+ beacon_role_e role;
+ apr_socket_t *sock; /* live UDP socket; valid only when opened */
+ apr_sockaddr_t *addr; /* listener: bind addr; sender: destination addr */
+ int opened; /* guard double-open / stop-without-start */
+ apr_time_t last_publish; /* sender throttle */
+ apr_time_t last_sweep; /* listener eviction-scan throttle */
+ apr_uint64_t seq; /* announcement counter */
+ apr_hash_t *seen; /* listener: url -> beacon_member_t* */
+ APR_OPTIONAL_FN_TYPE(balancer_manage) *manage_fn; /* listener: cached, lazy */
+ /* Phase 4: channel authentication. */
+ int has_secret; /* ProxyBeaconSecret was set */
+ unsigned char mac_key[APR_MD5_DIGESTSIZE]; /* siphash key (16 bytes) */
+ apr_int64_t max_skew; /* ProxyBeaconMaxSkew, seconds (replay window) */
+ apr_uint64_t reject_count; /* listener: dropped-since-last-log counter */
+ apr_time_t last_reject_log; /* listener: reject-log rate limiter */
+ int warned_insecure; /* listener: emitted the one-time warning */
+ int open_failed; /* STARTING: socket open/listen/dial failed permanently */
+} beacon_ctx_t;
+
+/* SipHash-2-4 output is 8 bytes -> 16 hex chars (+ NUL). */
+#define BEACON_MAC_HEXLEN (APR_SIPHASH_DSIZE * 2)
+#define BEACON_MAXSKEW_DEFAULT 30 /* seconds */
+
+/* Max announcement datagram we accept. Our messages are a few hundred bytes;
+ * anything larger is not one of ours and is truncated on receive. */
+#define BEACON_MAX_MSG_LEN 2048
+
+/* When an add fails (e.g. the balancer is full), don't retry on every
+ * announcement -- back off this long between attempts for a given url. */
+#define BEACON_RETRY_BACKOFF apr_time_from_sec(60)
+
+/* Cap on tracked backend urls, to bound memory against an unauthenticated
+ * channel announcing unboundedly many distinct urls. Once reached, unknown
+ * urls are dropped (rate-limited log) rather than tracked/added. */
+#define BEACON_MAX_MEMBERS 256
+
+/* Process-wide watchdog handle, like mod_proxy_hcheck's static watchdog. */
+static ap_watchdog_t *beacon_watchdog;
+
+/*
+ * Per-server config. No socket calls here -- the role may not even be known yet,
+ * and this runs in the parent at config-parse time (possibly more than once).
+ */
+static void *beacon_create_server_config(apr_pool_t *p, server_rec *s)
+{
+ beacon_ctx_t *ctx = apr_pcalloc(p, sizeof(beacon_ctx_t));
+
+ ctx->s = s;
+ apr_pool_create(&ctx->p, p);
+ apr_pool_tag(ctx->p, "proxy_beacon");
+ ctx->interval = BEACON_DEFAULT_INTERVAL;
+ ctx->role = BEACON_ROLE_NONE;
+ ctx->seen = apr_hash_make(ctx->p);
+
+ return ctx;
+}
+
+static const char *beacon_set_sub_url(cmd_parms *cmd, void *dummy,
+ int argc, char *const argv[])
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ if (argc > 1) {
+ return "ProxyBeaconListen takes at most one argument: [host][:port]";
+ }
+ if (ctx->pub_url) {
+ return "ProxyBeaconListen and ProxyBeaconAddress are mutually exclusive "
+ "on the same server";
+ }
+ /* No argument -> inherit both host and port from this server; one argument
+ * -> "[host][:port]", an omitted host or port being inherited from the
+ * server too. Store "" for the no-argument case (resolved against the
+ * server's own address at startup). */
+ ctx->sub_url = (argc == 1) ? apr_pstrdup(cmd->pool, argv[0]) : "";
+ ctx->role = BEACON_ROLE_LISTEN;
+ return NULL;
+}
+
+static const char *beacon_set_pub_url(cmd_parms *cmd, void *dummy, const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ if (ctx->sub_url) {
+ return "ProxyBeaconListen and ProxyBeaconAddress are mutually exclusive "
+ "on the same server";
+ }
+ ctx->pub_url = apr_pstrdup(cmd->pool, arg);
+ ctx->role = BEACON_ROLE_SEND;
+ return NULL;
+}
+
+static const char *beacon_set_advertise(cmd_parms *cmd, void *dummy,
+ const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ apr_uri_t uri;
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ /* Must be a routable backend URL (scheme://host[:port]); it becomes a
+ * BalancerMember on the proxy. */
+ if (apr_uri_parse(cmd->pool, arg, &uri) != APR_SUCCESS
+ || !uri.scheme || !uri.hostname) {
+ return apr_psprintf(cmd->pool,
+ "ProxyBeaconAdvertise: '%s' is not a valid "
+ "scheme://host[:port] URL", arg);
+ }
+ ctx->advertise_url = apr_pstrdup(cmd->pool, arg);
+ return NULL;
+}
+
+static const char *beacon_set_balancer(cmd_parms *cmd, void *dummy,
+ const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ /* Store the bare name; balancer_manage() prepends BALANCER_PREFIX itself.
+ * Be forgiving if the admin wrote the full "balancer://name". */
+ if (!strncasecmp(arg, BALANCER_PREFIX, sizeof(BALANCER_PREFIX) - 1)) {
+ arg += sizeof(BALANCER_PREFIX) - 1;
+ }
+ if (!*arg) {
+ return "ProxyBeaconBalancer: empty balancer name";
+ }
+ ctx->balancer_name = apr_pstrdup(cmd->pool, arg);
+ return NULL;
+}
+
+static const char *beacon_set_interval(cmd_parms *cmd, void *dummy,
+ const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ apr_interval_time_t iv;
+ apr_status_t rv;
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ rv = ap_timeout_parameter_parse(arg, &iv, "s");
+ if (rv != APR_SUCCESS) {
+ return "Unparse-able ProxyBeaconInterval setting";
+ }
+ if (iv < AP_WD_TM_SLICE) {
+ return apr_psprintf(cmd->pool,
+ "ProxyBeaconInterval must be greater than %"
+ APR_TIME_T_FMT "ms",
+ apr_time_as_msec(AP_WD_TM_SLICE));
+ }
+ ctx->interval = iv;
+ return NULL;
+}
+
+static const char *beacon_set_timeout(cmd_parms *cmd, void *dummy,
+ const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ apr_interval_time_t iv;
+ apr_status_t rv;
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ rv = ap_timeout_parameter_parse(arg, &iv, "s");
+ if (rv != APR_SUCCESS || iv < 0) {
+ return "ProxyBeaconTimeout must be a non-negative time (0 disables "
+ "eviction)";
+ }
+ /* 0 = eviction disabled (pure announce-and-add behavior). */
+ ctx->evict_timeout = iv;
+ return NULL;
+}
+
+static const char *beacon_set_secret(cmd_parms *cmd, void *dummy, const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ if (!*arg) {
+ return "ProxyBeaconSecret: empty secret";
+ }
+ /* Derive a 16-byte SipHash key from the passphrase. This is key
+ * derivation (spreading the shared secret to the key size both ends use),
+ * not message hashing, so MD5 is fine here -- same approach as
+ * mod_session_crypto.c's compute_auth(). */
+ apr_md5(ctx->mac_key, arg, strlen(arg));
+ ctx->has_secret = 1;
+ return NULL;
+}
+
+static const char *beacon_set_maxskew(cmd_parms *cmd, void *dummy, const char *arg)
+{
+ beacon_ctx_t *ctx = ap_get_module_config(cmd->server->module_config,
+ &proxy_beacon_module);
+ apr_interval_time_t iv;
+ apr_status_t rv;
+ const char *err = ap_check_cmd_context(cmd, NOT_IN_HTACCESS);
+ if (err) {
+ return err;
+ }
+ rv = ap_timeout_parameter_parse(arg, &iv, "s");
+ if (rv != APR_SUCCESS || iv <= 0) {
+ return "ProxyBeaconMaxSkew must be a positive time (replay window)";
+ }
+ ctx->max_skew = apr_time_sec(iv);
+ return NULL;
+}
+
+static const command_rec beacon_cmds[] = {
+ AP_INIT_TAKE_ARGV("ProxyBeaconListen", beacon_set_sub_url, NULL, RSRC_CONF,
+ "reverse proxy: UDP address [host][:port] to receive beacons "
+ "on. An omitted host or port (or no argument at all) is "
+ "inherited from this server's own address and port, so the "
+ "beacon channel can share the service endpoint. "
+ "e.g. 0.0.0.0:5555"),
+ AP_INIT_TAKE1("ProxyBeaconAddress", beacon_set_pub_url, NULL, RSRC_CONF,
+ "backend: UDP address host:port of the proxy to send beacons "
+ "to, e.g. proxy-host:5555"),
+ AP_INIT_TAKE1("ProxyBeaconInterval", beacon_set_interval, NULL, RSRC_CONF,
+ "backend announcement interval in seconds (default 5)"),
+ AP_INIT_TAKE1("ProxyBeaconAdvertise", beacon_set_advertise, NULL, RSRC_CONF,
+ "backend: routable URL to advertise to the proxy, "
+ "e.g. http://10.0.0.5:8080 (added as a BalancerMember)"),
+ AP_INIT_TAKE1("ProxyBeaconBalancer", beacon_set_balancer, NULL, RSRC_CONF,
+ "proxy: name of the balancer that announced backends are "
+ "added to, e.g. mycluster (for balancer://mycluster)"),
+ AP_INIT_TAKE1("ProxyBeaconTimeout", beacon_set_timeout, NULL, RSRC_CONF,
+ "proxy: seconds without an announcement after which a backend "
+ "is disabled (taken out of rotation); 0 disables eviction"),
+ AP_INIT_TAKE1("ProxyBeaconSecret", beacon_set_secret, NULL, RSRC_CONF,
+ "pre-shared cluster secret; set on both proxy and backends to "
+ "authenticate announcements (SipHash MAC). Keep the conf file "
+ "readable only by the server user."),
+ AP_INIT_TAKE1("ProxyBeaconMaxSkew", beacon_set_maxskew, NULL, RSRC_CONF,
+ "proxy: max allowed seconds between an announcement's timestamp "
+ "and now (anti-replay window; requires NTP-synced clocks). "
+ "Default 30 seconds."),
+ { NULL }
+};
+
+/*
+ * Resolve a "[scheme://][host][:port]" address string to a UDP sockaddr. Any
+ * leading scheme (e.g. tcp://) is accepted and ignored, so addresses written
+ * with a transport scheme keep working. A NULL/empty url, or an omitted host
+ * or port, is filled from the caller-supplied defaults:
+ * - default_host NULL -> bind/resolve the wildcard address (all interfaces);
+ * - default_port 0 -> the port is then required (APR_EINVAL if missing).
+ * Letting the host/port be inherited lets the beacon channel share the server's
+ * own address:port; UDP and TCP are independent port spaces, so binding UDP:N
+ * does not collide with the server's TCP listener. *addr_out is from p.
+ */
+static apr_status_t beacon_resolve(apr_pool_t *p, const char *url,
+ const char *default_host,
+ apr_port_t default_port,
+ apr_sockaddr_t **addr_out)
+{
+ char *host = NULL, *scope;
+ apr_port_t port = 0;
+ apr_status_t rv;
+
+ if (url && *url) {
+ const char *hostport = url, *sep;
+ if ((sep = strstr(url, "://")) != NULL) {
+ hostport = sep + 3;
+ }
+ rv = apr_parse_addr_port(&host, &scope, &port, hostport, p);
+ if (rv != APR_SUCCESS) {
+ return rv;
+ }
+ }
+ if (!host || !*host) {
+ host = (char *)default_host; /* NULL -> wildcard (any address) */
+ }
+ if (!port) {
+ port = default_port;
+ }
+ if (!port) {
+ return APR_EINVAL; /* no port given and none to inherit */
+ }
+ return apr_sockaddr_info_get(addr_out, host, APR_UNSPEC, port, 0, p);
+}
+
+/*
+ * The listener may inherit its bind host/port from this server (so the beacon
+ * channel can share the server's own endpoint). Derive a concrete address/port
+ * from the server's actual bound listeners (ap_listeners) -- this reflects the
+ * real Listen socket (e.g. 0.0.0.0), which is what the beacon should match, and
+ * avoids leaving the host NULL (which resolves to the IPv6 wildcard and could
+ * miss IPv4 senders on a v6-only-strict host). The port is the server's own
+ * (ServerName/Listen) port; the host is taken from the listener on that port,
+ * else the first listener. Either output may remain unset (host NULL / port 0)
+ * if no listeners are configured.
+ */
+static void beacon_server_default_addr(server_rec *s, const char **host_out,
+ apr_port_t *port_out)
+{
+ const char *host = NULL;
+ apr_port_t port = s->port;
+ ap_listen_rec *lr, *match = NULL;
+
+ for (lr = ap_listeners; lr; lr = lr->next) {
+ if (!lr->bind_addr) {
+ continue;
+ }
+ if (!match) {
+ match = lr; /* first, as a fallback */
+ }
+ if (port && lr->bind_addr->port == port) {
+ match = lr; /* prefer the server's own port */
+ break;
+ }
+ }
+ if (match && match->bind_addr) {
+ char *ip = NULL;
+ if (!port) {
+ port = match->bind_addr->port;
+ }
+ if (apr_sockaddr_ip_get(&ip, match->bind_addr) == APR_SUCCESS) {
+ host = ip;
+ }
+ }
+
+ *host_out = host;
+ *port_out = port;
+}
+
+/* STARTING: open the UDP socket; listener binds to receive, sender resolves
+ * its destination. */
+static void beacon_cb_starting(beacon_ctx_t *ctx)
+{
+ server_rec *s = ctx->s;
+ apr_status_t rv;
+
+ /* A prior open/bind failure is treated as permanent: the admin must fix the
+ * configuration (bad address, port conflict) and restart. Without this
+ * guard the watchdog would retry every ~100ms and fill the error log. */
+ if (ctx->opened || ctx->open_failed) {
+ return;
+ }
+
+ if (ctx->role == BEACON_ROLE_LISTEN) {
+ /* An omitted host and/or port in ProxyBeaconListen is inherited from
+ * this server's own address/port (ServerName/Listen), so the beacon
+ * channel can share the server's service endpoint. UDP and TCP are
+ * independent, so binding UDP:<port> does not collide with the server's
+ * TCP listener. (A privileged port still cannot be bound here -- this
+ * callback runs in an unprivileged watchdog child -- so endpoint sharing
+ * is for non-privileged service ports.) */
+ const char *defhost;
+ apr_port_t defport;
+ beacon_server_default_addr(s, &defhost, &defport);
+ if ((rv = beacon_resolve(ctx->p, ctx->sub_url, defhost, defport,
+ &ctx->addr)) != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, rv, s,
+ APLOGNO(10567) "mod_proxy_beacon: cannot resolve "
+ "ProxyBeaconListen address '%s' (an omitted port "
+ "requires a known server port)",
+ *ctx->sub_url ? ctx->sub_url : "(inherit)");
+ ctx->open_failed = 1;
+ return;
+ }
+ if ((rv = apr_socket_create(&ctx->sock, ctx->addr->family, SOCK_DGRAM,
+ APR_PROTO_UDP, ctx->p)) != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, rv, s,
+ APLOGNO(10568) "mod_proxy_beacon: apr_socket_create failed");
+ ctx->open_failed = 1;
+ return;
+ }
+ apr_socket_opt_set(ctx->sock, APR_SO_REUSEADDR, 1);
+ apr_socket_opt_set(ctx->sock, APR_SO_NONBLOCK, 1);
+ if ((rv = apr_socket_bind(ctx->sock, ctx->addr)) != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, rv, s,
+ APLOGNO(10569) "mod_proxy_beacon: bind to %pI failed",
+ ctx->addr);
+ apr_socket_close(ctx->sock);
+ ctx->open_failed = 1;
+ return;
+ }
+ ctx->opened = 1;
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, s,
+ APLOGNO(10570) "mod_proxy_beacon: listening for beacons on %pI",
+ ctx->addr);
+ /* Warn if no balancer is configured: beacons will be logged but no
+ * backend workers will be added (Phase-1 log-only behavior). */
+ if (!ctx->balancer_name) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s,
+ APLOGNO(10571) "mod_proxy_beacon: listener on %pI has no "
+ "ProxyBeaconBalancer; beacons will be logged only. "
+ "Set ProxyBeaconBalancer to add announced backends.",
+ ctx->addr);
+ }
+ /* Phase 4: warn once if we'll add members from an unauthenticated
+ * channel (anyone who can reach this port could announce a backend). */
+ if (ctx->balancer_name && !ctx->has_secret && !ctx->warned_insecure) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s,
+ APLOGNO(10572) "mod_proxy_beacon: beacon channel on %pI is "
+ "UNAUTHENTICATED; set ProxyBeaconSecret on the proxy and "
+ "all backends to require signed beacons", ctx->addr);
+ ctx->warned_insecure = 1;
+ }
+ }
+ else if (ctx->role == BEACON_ROLE_SEND) {
+ /* Sender: the destination host:port targets the proxy and cannot be
+ * inferred from this backend's own address, so both must be given
+ * explicitly (no defaults). */
+ if ((rv = beacon_resolve(ctx->p, ctx->pub_url, NULL, 0, &ctx->addr))
+ != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, rv, s,
+ APLOGNO(10573) "mod_proxy_beacon: cannot resolve "
+ "ProxyBeaconAddress '%s' (must be host:port)",
+ ctx->pub_url);
+ ctx->open_failed = 1;
+ return;
+ }
+ if ((rv = apr_socket_create(&ctx->sock, ctx->addr->family, SOCK_DGRAM,
+ APR_PROTO_UDP, ctx->p)) != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, rv, s,
+ APLOGNO(10574) "mod_proxy_beacon: apr_socket_create failed");
+ ctx->open_failed = 1;
+ return;
+ }
+ /* UDP is connectionless: no dial, no reconnect. Datagrams are sent to
+ * ctx->addr each interval; if the proxy isn't up yet they are simply
+ * dropped and the next interval tries again. */
+ apr_socket_opt_set(ctx->sock, APR_SO_NONBLOCK, 1);
+ ctx->opened = 1;
+ ctx->last_publish = 0; /* beacon on the first running tick */
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, s,
+ APLOGNO(10575) "mod_proxy_beacon: beaconing to %pI", ctx->addr);
+ }
+}
+
+/*
+ * Synthesize a minimal request_rec for balancer_manage(), which runs here in
+ * the watchdog thread with no real client request. Adapted from
+ * mod_proxy_hcheck.c's create_request_rec (same watchdog context), but that one
+ * attaches a real backend conn_rec before logging; we have none, so we fabricate
+ * a minimal conn_rec too.
+ *
+ * balancer_manage()/balancer_process_balancer_worker() touch only r->pool,
+ * r->server, and ap_log_rerror(). ap_log_rerror is the trap: log_error_core()
+ * asserts r->connection != NULL and do_errorlog_default() unconditionally reads
+ * r->connection->outgoing, while add_log_id()->core_generate_log_id() reads
+ * c->current_thread. So we (a) give r a non-NULL conn_rec with ->outgoing set,
+ * and (b) pre-set r->log_id / c->log_id so the log-id generation path is skipped
+ * entirely.
+ */
+static request_rec *beacon_make_fake_request(apr_pool_t *p, server_rec *s)
+{
+ conn_rec *c = apr_pcalloc(p, sizeof(*c));
+ request_rec *r = apr_pcalloc(p, sizeof(*r));
+
+ c->pool = p;
+ c->base_server = s;
+ c->log = &s->log;
+ c->log_id = "beacon"; /* non-NULL -> skip add_log_id/log-id gen */
+ c->outgoing = 1; /* read by do_errorlog_default() */
+ c->client_ip = "-";
+ c->notes = apr_table_make(p, 1);
+
+ r->pool = p;
+ r->server = s;
+ r->connection = c;
+ r->log = &s->log;
+ r->log_id = "beacon";
+ r->per_dir_config = s->lookup_defaults;
+ r->request_config = ap_create_request_config(p);
+ r->notes = apr_table_make(p, 4);
+ r->subprocess_env = apr_table_make(p, 4);
+ r->headers_in = apr_table_make(p, 1);
+ r->headers_out = apr_table_make(p, 1);
+ r->err_headers_out = apr_table_make(p, 1);
+ r->useragent_ip = "-";
+ r->useragent_addr = NULL;
+ r->proxyreq = PROXYREQ_PROXY;
+ r->status = HTTP_OK;
+
+ return r;
+}
+
+/*
+ * Resolve mod_proxy_balancer's balancer_manage() optional fn (lazily, cached).
+ * Registered at hook-registration time, so available by the time we run.
+ */
+static int beacon_have_manage_fn(beacon_ctx_t *ctx)
+{
+ if (!ctx->manage_fn) {
+ ctx->manage_fn = APR_RETRIEVE_OPTIONAL_FN(balancer_manage);
+ if (!ctx->manage_fn) {
+ ap_log_error(APLOG_MARK, APLOG_ERR, 0, ctx->s,
+ APLOGNO(10576) "mod_proxy_beacon: balancer_manage unavailable "
+ "(is mod_proxy_balancer loaded?)");
+ return 0;
+ }
+ }
+ return 1;
+}
+
+/*
+ * Set (or clear) a worker's DISABLED flag via balancer_manage -- the exact path
+ * the balancer-manager UI uses (params { b, w, w_status_D }). This is how both
+ * "enable" (disabled=0) and "evict" (disabled=1) are performed. Returns
+ * APR_SUCCESS on success.
+ */
+static apr_status_t beacon_set_worker_disabled(beacon_ctx_t *ctx, apr_pool_t *pool,
+ const char *url, int disabled)
+{
+ apr_pool_t *subp;
+ request_rec *r;
+ apr_table_t *params;
+ apr_status_t rv;
+
+ apr_pool_create(&subp, pool);
+ r = beacon_make_fake_request(subp, ctx->s);
+
+ params = apr_table_make(subp, 4);
+ apr_table_setn(params, "b", ctx->balancer_name);
+ apr_table_setn(params, "w", url);
+ apr_table_setn(params, "w_status_D", disabled ? "1" : "0");
+ rv = ctx->manage_fn(r, params);
+
+ apr_pool_destroy(subp);
+ return rv;
+}
+
+/*
+ * Emit a rate-limited (~1/s) message about dropped/failed announcements, so a
+ * full balancer or a flood of bad urls can't flood the error log. Shares the
+ * counter/timer with the Phase-4 rejection logging.
+ */
+static void beacon_log_throttled(beacon_ctx_t *ctx, apr_time_t now, const char *what)
+{
+ ctx->reject_count++;
+ if (ctx->last_reject_log == 0
+ || now - ctx->last_reject_log >= apr_time_from_sec(1)) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, ctx->s,
+ APLOGNO(10577) "mod_proxy_beacon: dropped %" APR_UINT64_T_FMT
+ " announcement(s) (last: %s)", ctx->reject_count, what);
+ ctx->reject_count = 0;
+ ctx->last_reject_log = now;
+ }
+}
+
+/*
+ * Attempt to add (and enable) url as a member of the configured balancer,
+ * reusing balancer_manage(). Two calls: add (the worker is created DISABLED by
+ * default), then clear the DISABLED flag so it serves traffic. Updates the
+ * caller-owned member entry m: m->added is set on success. Returns APR_SUCCESS
+ * on success. A failure here (e.g. the balancer is full) is expected and
+ * handled by the caller via backoff, not retried every announcement.
+ */
+static apr_status_t beacon_try_add(beacon_ctx_t *ctx, apr_pool_t *pool,
+ const char *url, beacon_member_t *m,
+ apr_time_t now)
+{
+ server_rec *s = ctx->s;
+ apr_pool_t *subp;
+ request_rec *r;
+ apr_table_t *params;
+ apr_status_t rv;
+
+ m->last_attempt = now;
+
+ if (!beacon_have_manage_fn(ctx)) {
+ return APR_EGENERAL;
+ }
+
+ apr_pool_create(&subp, pool);
+ r = beacon_make_fake_request(subp, s);
+
+ /* Step 1: add the worker (created DISABLED). */
+ params = apr_table_make(subp, 4);
+ apr_table_setn(params, "b", ctx->balancer_name);
+ apr_table_setn(params, "b_nwrkr", url);
+ apr_table_setn(params, "b_wyes", "1");
+ rv = ctx->manage_fn(r, params);
+ apr_pool_destroy(subp);
+ if (rv != APR_SUCCESS) {
+ /* Most likely the balancer has no free slots (grow it via ProxySet
+ * growth / BalancerGrowth). Throttled log; retried only after the
+ * backoff window, not on every announcement. */
+ beacon_log_throttled(ctx, now, "balancer add failed (full?)");
+ return rv;
+ }
+
+ /* Step 2: enable it (clear the DISABLED flag) so it serves traffic.
+ * beacon_set_worker_disabled is self-contained -- it creates its own subpool
+ * from `pool` -- so it does not rely on `subp`, which we destroyed above. */
+ rv = beacon_set_worker_disabled(ctx, pool, url, 0);
+ if (rv != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s,
+ APLOGNO(10578) "mod_proxy_beacon: added but failed to enable worker %s "
+ "in balancer://%s", url, ctx->balancer_name);
+ /* fall through: still a member, just disabled */
+ }
+
+ m->added = 1;
+ m->evicted = 0;
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, s,
+ APLOGNO(10579) "mod_proxy_beacon: added backend %s to balancer://%s",
+ url, ctx->balancer_name);
+ return APR_SUCCESS;
+}
+
+/*
+ * Handle one announced url. Tracks every url in ctx->seen (capped) so that:
+ * - a successful member is deduplicated and its last_seen refreshed;
+ * - a previously-evicted member is re-enabled when it announces again;
+ * - an add that failed (e.g. balancer full) is retried only after a backoff,
+ * not on every announcement.
+ * msg_ts is the signed timestamp (microseconds) from the verified message, or 0
+ * when the channel is unauthenticated.
+ */
+static void beacon_handle_announce(beacon_ctx_t *ctx, apr_pool_t *pool,
+ const char *url, apr_time_t now,
+ apr_int64_t msg_ts)
+{
+ beacon_member_t *m = apr_hash_get(ctx->seen, url, APR_HASH_KEY_STRING);
+
+ if (!m) {
+ /* Bound memory: don't track unboundedly many distinct urls (a concern
+ * on an unauthenticated channel). */
+ if (apr_hash_count(ctx->seen) >= BEACON_MAX_MEMBERS) {
+ beacon_log_throttled(ctx, now, "member table full");
+ return;
+ }
+ m = apr_pcalloc(ctx->p, sizeof(*m));
+ m->last_seen = now;
+ m->last_ts = msg_ts;
+ apr_hash_set(ctx->seen, apr_pstrdup(ctx->p, url), APR_HASH_KEY_STRING,
+ m);
+ beacon_try_add(ctx, pool, url, m, now);
+ return;
+ }
+
+ /* Anti-replay (2): on an authenticated channel, each url's signed ts must
+ * strictly increase. A replayed (byte-identical) announcement carries a ts
+ * we've already accepted, so reject it -- this closes the in-window replay
+ * the freshness check alone allows (e.g. replaying a dead backend's last
+ * announcement to keep it from being evicted). */
+ if (ctx->has_secret && msg_ts <= m->last_ts) {
+ beacon_log_throttled(ctx, now, "replayed/reordered ts");
+ return;
+ }
+ m->last_ts = msg_ts;
+ m->last_seen = now;
+
+ if (!m->added) {
+ /* A prior add failed; retry, but only after the backoff window. */
+ if (now - m->last_attempt >= BEACON_RETRY_BACKOFF) {
+ beacon_try_add(ctx, pool, url, m, now);
+ }
+ return;
+ }
+
+ if (m->evicted) {
+ if (beacon_set_worker_disabled(ctx, pool, url, 0) == APR_SUCCESS) {
+ m->evicted = 0;
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ctx->s,
+ APLOGNO(10580) "mod_proxy_beacon: re-enabled backend %s in "
+ "balancer://%s (announcing again)",
+ url, ctx->balancer_name);
+ }
+ }
+}
+
+/*
+ * Eviction sweep: disable any member that has not announced within
+ * evict_timeout. No-op when eviction is disabled (timeout 0). Runs in the
+ * watchdog singleton, throttled by the caller.
+ */
+static void beacon_sweep(beacon_ctx_t *ctx, apr_pool_t *pool, apr_time_t now)
+{
+ apr_hash_index_t *hi;
+
+ if (ctx->evict_timeout <= 0 || !beacon_have_manage_fn(ctx)) {
+ return;
+ }
+
+ for (hi = apr_hash_first(pool, ctx->seen); hi; hi = apr_hash_next(hi)) {
+ const void *key;
+ void *val;
+ beacon_member_t *m;
+
+ apr_hash_this(hi, &key, NULL, &val);
+ m = val;
+ if (!m->added || m->evicted) {
+ continue;
+ }
+ if (now - m->last_seen > ctx->evict_timeout) {
+ const char *url = key;
+ if (beacon_set_worker_disabled(ctx, pool, url, 1) == APR_SUCCESS) {
+ m->evicted = 1;
+ ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ctx->s,
+ APLOGNO(10581) "mod_proxy_beacon: evicted backend %s from "
+ "balancer://%s (no announcement for %"
+ APR_TIME_T_FMT "s)",
+ url, ctx->balancer_name,
+ apr_time_sec(now - m->last_seen));
+ }
+ }
+ }
+}
+
+/*
+ * Extract the value of the "url=" token from a beacon message into buf.
+ * Returns 1 on success. The payload is untrusted network input, so parse
+ * defensively: the token is "url=" followed by non-space characters.
+ */
+static int beacon_parse_url(const char *msg, char *buf, apr_size_t buflen)
+{
+ const char *p = msg;
+
+ while (p && *p) {
+ if (!strncmp(p, "url=", 4)) {
+ apr_size_t i = 0;
+ p += 4;
+ while (*p && *p != ' ' && i + 1 < buflen) {
+ buf[i++] = *p++;
+ }
+ buf[i] = '\0';
+ return (i > 0);
+ }
+ /* advance to the next space-separated token */
+ p = strchr(p, ' ');
+ if (p) {
+ p++;
+ }
+ }
+ return 0;
+}
+
+/* ----- Phase 4: channel authentication (SipHash MAC + timestamp) ----- */
+
+/* Constant-time buffer compare (time depends only on size, not contents) --
+ * avoids a timing oracle on the MAC. apr_crypto_equals() would do this but is
+ * gated behind APU_HAVE_CRYPTO, which isn't always built; this is the same
+ * branchless idiom. */
+static int beacon_const_time_eq(const void *a, const void *b, apr_size_t n)
+{
+ const unsigned char *p1 = a, *p2 = b;
+ volatile unsigned char diff = 0;
+ apr_size_t i;
+ for (i = 0; i < n; i++) {
+ diff |= p1[i] ^ p2[i];
+ }
+ return diff == 0;
+}
+
+/* Hex-encode the SipHash-2-4 MAC of [base, base+len) into out (BEACON_MAC_HEXLEN+1
+ * bytes). */
+static void beacon_mac_hex(const beacon_ctx_t *ctx, const char *base,
+ apr_size_t len, char *out)
+{
+ unsigned char mac[APR_SIPHASH_DSIZE];
+ apr_siphash24_auth(mac, base, len, ctx->mac_key);
+ ap_bin2hex(mac, sizeof(mac), out); /* writes BEACON_MAC_HEXLEN + NUL */
+}
+
+/* sender: return "<base> mac=<hex>" (signed) when a secret is set, else base. */
+static const char *beacon_sign(beacon_ctx_t *ctx, apr_pool_t *pool, const char *base)
+{
+ char hex[BEACON_MAC_HEXLEN + 1];
+
+ if (!ctx->has_secret) {
+ return base;
+ }
+ beacon_mac_hex(ctx, base, strlen(base), hex);
+ return apr_psprintf(pool, "%s mac=%s", base, hex);
+}
+
+/*
+ * listener: verify a received, NUL-terminated message. Returns 1 if the MAC matches
+ * the pre-shared key AND the ts= timestamp is within ctx->max_skew of now.
+ * Only called when ctx->has_secret. Constant-time MAC compare. On success the
+ * parsed ts= (microseconds since the epoch) is written to *ts_out for the
+ * caller's per-url monotonic replay check. reason (out) is set on failure.
+ */
+static int beacon_verify(beacon_ctx_t *ctx, const char *msg, apr_time_t now,
+ apr_int64_t *ts_out, const char **reason)
+{
+ const char *macp = NULL, *p, *tsp;
+ char expected[BEACON_MAC_HEXLEN + 1];
+ apr_size_t prefix_len;
+ apr_int64_t ts, skew, delta;
+
+ /* Locate the last " mac=" -- the MAC covers everything before it. */
+ for (p = msg; (p = strstr(p, " mac=")) != NULL; p += 5) {
+ macp = p;
+ }
+ if (!macp) {
+ *reason = "no mac";
+ return 0;
+ }
+ /* mac= must be the final field: exactly BEACON_MAC_HEXLEN chars followed by
+ * end-of-string. strlen is NUL-safe here -- the caller hands us a
+ * NUL-terminated apr_pstrmemdup() copy -- so a short or garbled payload
+ * cannot make the constant-time compare below over-read past the buffer.
+ * The compare itself validates the hex content; we deliberately do not
+ * pre-scan for hex digits, which would leak MAC bytes through an early
+ * exit (timing oracle). */
+ if (strlen(macp + 5) != BEACON_MAC_HEXLEN) {
+ *reason = "bad mac length";
+ return 0;
+ }
+
+ prefix_len = (apr_size_t)(macp - msg);
+ beacon_mac_hex(ctx, msg, prefix_len, expected);
+ if (!beacon_const_time_eq(expected, macp + 5, BEACON_MAC_HEXLEN)) {
+ *reason = "mac mismatch";
+ return 0;
+ }
+
+ /* Anti-replay (1): ts= must be present and within the freshness window.
+ * ts is in microseconds (raw apr_time_now()) so that announcements made
+ * less than a second apart still have strictly-increasing timestamps for
+ * the caller's per-url check. */
+ tsp = NULL;
+ for (p = msg; (p = strstr(p, "ts=")) != NULL && p < macp; p += 3) {
+ /* token-start: beginning of message or preceded by a space */
+ if (p == msg || *(p - 1) == ' ') {
+ tsp = p;
+ }
+ }
+ if (!tsp) {
+ *reason = "no ts";
+ return 0;
+ }
+ ts = apr_atoi64(tsp + 3);
+ skew = ctx->max_skew > 0 ? ctx->max_skew : BEACON_MAXSKEW_DEFAULT;
+ delta = (apr_int64_t)now - ts;
+ if (delta < 0) {
+ delta = -delta;
+ }
+ if (delta > apr_time_from_sec(skew)) {
+ *reason = "stale timestamp";
+ return 0;
+ }
+
+ *ts_out = ts;
+ return 1;
+}
+
+/* RUNNING: sender sends a throttled beacon; listener drains its receive socket. */
+static void beacon_cb_running(beacon_ctx_t *ctx, apr_pool_t *pool)
+{
+ server_rec *s = ctx->s;
+ apr_status_t rv;
+
+ if (!ctx->opened) {
+ return;
+ }
+
+ if (ctx->role == BEACON_ROLE_SEND) {
+ apr_time_t now = apr_time_now();
+ if (now >= ctx->last_publish + ctx->interval) {
+ const char *host = ctx->s->server_hostname
+ ? ctx->s->server_hostname : "(unknown)";
+ const char *msg;
+ char *base;
+ apr_size_t len;
+ /* Carry the routable URL so the proxy can add us as a member.
+ * Without ProxyBeaconAdvertise, fall back to the Phase-1 payload
+ * (channel proof only; the listener just logs it). ts= is the signing
+ * timestamp in microseconds (Phase 4 anti-replay): the proxy checks
+ * both a freshness window and that it strictly increases per url, so
+ * a byte-identical replay (same ts) is rejected. Harmless when
+ * unsigned. */
+ if (ctx->advertise_url) {
+ base = apr_psprintf(pool,
+ "BEACON url=%s host=%s pid=%" APR_PID_T_FMT
+ " seq=%" APR_UINT64_T_FMT " ts=%" APR_INT64_T_FMT,
+ ctx->advertise_url, host, getpid(), ctx->seq++,
+ (apr_int64_t)now);
+ }
+ else {
+ base = apr_psprintf(pool,
+ "BEACON host=%s pid=%" APR_PID_T_FMT
+ " seq=%" APR_UINT64_T_FMT " ts=%" APR_INT64_T_FMT,
+ host, getpid(), ctx->seq++,
+ (apr_int64_t)now);
+ }
+ /* Phase 4: append a keyed MAC when a shared secret is configured. */
+ msg = beacon_sign(ctx, pool, base);
+ /* Send strlen(msg) bytes (no trailing NUL on the wire) as a single
+ * UDP datagram to the proxy. Fire-and-forget; a failure here just
+ * means this announcement is lost and the next interval retries. */
+ len = strlen(msg);
+ if ((rv = apr_socket_sendto(ctx->sock, ctx->addr, 0, msg, &len))
+ != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, rv, s,
+ APLOGNO(10582) "mod_proxy_beacon: sendto %s failed",
+ ctx->pub_url);
+ }
+ ctx->last_publish = now;
+ }
+ }
+ else if (ctx->role == BEACON_ROLE_LISTEN) {
+ apr_time_t now;
+
+ /* Drain the socket every tick (re-enable latency matters). */
+ for (;;) {
+ char buf[BEACON_MAX_MSG_LEN + 1];
+ apr_sockaddr_t from;
+ apr_size_t sz = BEACON_MAX_MSG_LEN;
+ apr_time_t msg_now;
+ const char *msg_str, *safe;
+ char url[512];
+ apr_int64_t msg_ts = 0;
+
+ from.pool = pool;
+ rv = apr_socket_recvfrom(&from, ctx->sock, 0, buf, &sz);
+ if (APR_STATUS_IS_EAGAIN(rv)) {
+ break; /* socket drained */
+ }
+ if (rv != APR_SUCCESS) {
+ ap_log_error(APLOG_MARK, APLOG_WARNING, rv, s,
+ APLOGNO(10583) "mod_proxy_beacon: recvfrom failed");
+ break;
+ }
+ if (sz == 0) {
+ continue; /* empty datagram */
+ }
+
+ /* Capture a single wall-clock snapshot for this message so that
+ * MAC verification, throttle logging, and last-seen recording all
+ * share the same time base (no TOCTOU drift across calls). */
+ msg_now = apr_time_now();
+
+ /* recvfrom wrote into our own stack buffer; NUL-terminate it (a UDP
+ * datagram is delivered whole, so sz is the full message length). */
+ buf[sz] = '\0';
+ msg_str = buf;
+
+ /* Escape the untrusted payload before it can reach the log: a
+ * publisher -- or, on an unauthenticated channel, anyone who can
+ * reach the listen port -- could embed newline/control/ANSI
+ * sequences to forge or corrupt error-log lines. */
+ safe = ap_escape_logitem(pool, msg_str);
+
+ /* Phase 4: when a secret is set, verify the MAC and freshness
+ * BEFORE logging or acting on the payload, so forged/tampered
+ * content is dropped (throttled) and never reaches the INFO log.
+ * msg_ts (microseconds) is the signed timestamp, used below for the
+ * per-url monotonic replay check. Verification only matters in the
+ * active (balancer) path; with no balancer we take no action and
+ * the escaped payload is safe to log for diagnostics. */
+ if (ctx->balancer_name && ctx->has_secret) {
+ const char *reason = "?";
+ if (!beacon_verify(ctx, msg_str, msg_now, &msg_ts, &reason)) {
+ beacon_log_throttled(ctx, msg_now, reason);
+ continue;
+ }
+ }
+
+ ap_log_error(APLOG_MARK, APLOG_INFO, 0, s,
+ APLOGNO(10584) "mod_proxy_beacon: received: %s", safe);
+
+ if (!ctx->balancer_name) {
+ continue;
+ }
+
+ /* Phase 2/3: carries a routable url= -> add the backend (or
+ * refresh last-seen / re-enable if previously evicted). */
+ if (beacon_parse_url(msg_str, url, sizeof(url))) {
+ beacon_handle_announce(ctx, pool, url, msg_now, msg_ts);
+ }
+ }
+
+ /* Phase 3: evict backends that stopped announcing. Throttle the scan
+ * to ~1s (the recv drain above already runs every tick). */
+ now = apr_time_now();
+ if (ctx->balancer_name && ctx->evict_timeout > 0
+ && now - ctx->last_sweep >= apr_time_from_sec(1)) {
+ beacon_sweep(ctx, pool, now);
+ ctx->last_sweep = now;
+ }
+ }
+}
+
+/* STOPPING: close the socket if we opened it. */
+static void beacon_cb_stopping(beacon_ctx_t *ctx)
+{
+ if (ctx->opened) {
+ apr_socket_close(ctx->sock);
+ ctx->opened = 0;
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, ctx->s,
+ APLOGNO(10585) "mod_proxy_beacon: socket closed");
+ }
+}
+
+/*
+ * Watchdog callback. Always returns APR_SUCCESS: a non-success return would
+ * unregister the callback and permanently stop the channel, which we never
+ * want for a transient socket error.
+ */
+static apr_status_t beacon_watchdog_callback(int state, void *data,
+ apr_pool_t *pool)
+{
+ beacon_ctx_t *ctx = (beacon_ctx_t *)data;
+
+ switch (state) {
+ case AP_WATCHDOG_STATE_STARTING:
+ beacon_cb_starting(ctx);
+ break;
+ case AP_WATCHDOG_STATE_RUNNING:
+ beacon_cb_running(ctx, pool);
+ break;
+ case AP_WATCHDOG_STATE_STOPPING:
+ beacon_cb_stopping(ctx);
+ break;
+ }
+
+ return APR_SUCCESS;
+}
+
+static int beacon_post_config(apr_pool_t *pconf, apr_pool_t *plog,
+ apr_pool_t *ptemp, server_rec *main_s)
+{
+ apr_status_t rv;
+ server_rec *s;
+ APR_OPTIONAL_FN_TYPE(ap_watchdog_get_instance) *beacon_get_instance;
+ APR_OPTIONAL_FN_TYPE(ap_watchdog_register_callback) *beacon_register_callback;
+
+ /* post_config runs twice; skip the initial config-test pass. */
+ if (ap_state_query(AP_SQ_MAIN_STATE) == AP_SQ_MS_CREATE_PRE_CONFIG) {
+ return OK;
+ }
+
+ beacon_get_instance = APR_RETRIEVE_OPTIONAL_FN(ap_watchdog_get_instance);
+ beacon_register_callback =
+ APR_RETRIEVE_OPTIONAL_FN(ap_watchdog_register_callback);
+ if (!beacon_get_instance || !beacon_register_callback) {
+ ap_log_error(APLOG_MARK, APLOG_CRIT, 0, main_s,
+ APLOGNO(10586) "mod_proxy_beacon: mod_watchdog is required");
+ return !OK;
+ }
+
+ rv = beacon_get_instance(&beacon_watchdog, BEACON_WATCHDOG_NAME, 0, 1, pconf);
+ if (rv) {
+ ap_log_error(APLOG_MARK, APLOG_CRIT, rv, main_s,
+ APLOGNO(10587) "mod_proxy_beacon: failed to create watchdog instance (%s)",
+ BEACON_WATCHDOG_NAME);
+ return !OK;
+ }
+
+ for (s = main_s; s; s = s->next) {
+ beacon_ctx_t *ctx = ap_get_module_config(s->module_config,
+ &proxy_beacon_module);
+ /* Skip non-canonical server contexts (shared across vhosts). */
+ if (s != ctx->s) {
+ continue;
+ }
+ /* Only servers with a directive participate. */
+ if (ctx->role == BEACON_ROLE_NONE) {
+ continue;
+ }
+ rv = beacon_register_callback(beacon_watchdog, AP_WD_TM_SLICE, ctx,
+ beacon_watchdog_callback);
+ if (rv) {
+ ap_log_error(APLOG_MARK, APLOG_CRIT, rv, s,
+ APLOGNO(10588) "mod_proxy_beacon: failed to register watchdog callback");
+ return !OK;
+ }
+ }
+
+ return OK;
+}
+
+static void beacon_register_hooks(apr_pool_t *p)
+{
+ static const char *const aszSucc[] = { "mod_watchdog.c",
+ "mod_proxy_balancer.c", NULL };
+ ap_hook_post_config(beacon_post_config, NULL, aszSucc, APR_HOOK_LAST);
+}
+
+AP_DECLARE_MODULE(proxy_beacon) =
+{
+ STANDARD20_MODULE_STUFF,
+ NULL, /* create per-dir config */
+ NULL, /* merge per-dir config */
+ beacon_create_server_config, /* create per-server config */
+ NULL, /* merge per-server config */
+ beacon_cmds, /* command table */
+ beacon_register_hooks /* register hooks */
+};