]> git.ipfire.org Git - thirdparty/apache/httpd.git/commitdiff
mod_proxy_beacon: self-registering balancer membership over UDP
authorJim Jagielski <jim@apache.org>
Fri, 5 Jun 2026 08:02:50 +0000 (08:02 +0000)
committerJim Jagielski <jim@apache.org>
Fri, 5 Jun 2026 08:02:50 +0000 (08:02 +0000)
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1934995 13f79535-47bb-0310-9956-ffa450edef68

CHANGES
docs/log-message-tags/next-number
docs/manual/mod/allmodules.xml
docs/manual/mod/mod_proxy_beacon.xml [new file with mode: 0644]
docs/manual/mod/mod_proxy_beacon.xml.meta [new file with mode: 0644]
modules/proxy/README.beacon [new file with mode: 0644]
modules/proxy/config.m4
modules/proxy/mod_proxy_beacon-guide.md [new file with mode: 0644]
modules/proxy/mod_proxy_beacon.c [new file with mode: 0644]
test/pytest_suite/t/conf/proxy_beacon.conf.in [new file with mode: 0644]
test/pytest_suite/tests/t/modules/test_proxy_beacon.py [new file with mode: 0644]

diff --git a/CHANGES b/CHANGES
index 9f9e2b9d06cce8706389a59e770e36be9207e8a4..1b7ada499938ebe4ebb44158a8455cbef1f1b6e2 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,10 @@
                                                          -*- coding: utf-8 -*-
 Changes with Apache 2.5.1
 
+  *) mod_proxy_beacon: Back-end reverse proxy servers can announce
+     themselves and be auto-added to their front-end proxy balancer.
+     [Jim Jagielski]
+
   *) support: Add in Python versions of various Perl-based support
      scripts - apxs-ng, dbmmanage-ng, log_server_status-ng,
      logresolve.py, phf_abuse_log-ng.cgi and split-logfile-ng.
index f6a15fb2f302f8efeb864c3a439e0e7518c12079..4e6347722a5b8b9939784f10903bf5a1c5643ba9 100644 (file)
@@ -1 +1 @@
-10567
+10589
index 9f114e808eae77eba4459cf8840c17cba5a74609..68f40302aac0f8f6698ed47cd739d3d0a91d0087 100644 (file)
@@ -97,6 +97,7 @@
   <modulefile>mod_proxy_html.xml</modulefile>
   <modulefile>mod_proxy_http.xml</modulefile>
   <modulefile>mod_proxy_http2.xml</modulefile>
+  <modulefile>mod_proxy_beacon.xml</modulefile>
   <modulefile>mod_proxy_scgi.xml</modulefile>
   <modulefile>mod_proxy_uwsgi.xml</modulefile>
   <modulefile>mod_proxy_wstunnel.xml</modulefile>
diff --git a/docs/manual/mod/mod_proxy_beacon.xml b/docs/manual/mod/mod_proxy_beacon.xml
new file mode 100644 (file)
index 0000000..c4ac7c7
--- /dev/null
@@ -0,0 +1,367 @@
+<?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 &mdash; 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.
+&lt;Proxy balancer://cluster&gt;
+  ProxySet growth=16
+&lt;/Proxy&gt;
+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 &mdash; 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 &mdash; not the local listen address &mdash; 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">&lt;Proxy&gt;</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>
diff --git a/docs/manual/mod/mod_proxy_beacon.xml.meta b/docs/manual/mod/mod_proxy_beacon.xml.meta
new file mode 100644 (file)
index 0000000..3f37efd
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!-- GENERATED FROM XML: DO NOT EDIT -->
+
+<metafile reference="mod_proxy_beacon.xml">
+  <basename>mod_proxy_beacon</basename>
+  <path>/mod/</path>
+  <relpath>..</relpath>
+
+  <variants>
+    <variant>en</variant>
+  </variants>
+</metafile>
diff --git a/modules/proxy/README.beacon b/modules/proxy/README.beacon
new file mode 100644 (file)
index 0000000..143befd
--- /dev/null
@@ -0,0 +1,127 @@
+mod_proxy_beacon
+
+Self-registering reverse-proxy balancer membership: backend servers announce
+themselves to a front-end proxy over UDP datagrams, and the proxy adds, enables,
+and evicts them as live balancer members. See
+docs/manual/mod/mod_proxy_beacon.xml for the user-facing directive reference;
+this file describes the architecture.
+
+The transport is plain APR UDP sockets -- small, periodic, fire-and-forget
+announcements with no third-party dependency.
+
+
+Dependencies:
+  Requires mod_watchdog and mod_proxy_balancer. No external library: the UDP
+  transport uses APR sockets and authentication uses SipHash from APR-util (no
+  OpenSSL dependency).
+
+
+Transport (unicast UDP, not multicast):
+  Each backend sends a point-to-point UDP datagram (apr_socket_sendto) to the
+  proxy's configured address. This is deliberately UNICAST -- unlike
+  mod_heartbeat, which multicasts to a group. Multicast is filtered on most
+  networks and does not traverse the public Internet, so a cross-host control
+  plane must be unicast. The socket-handling pattern is adapted from
+  mod_heartmonitor's unicast receive path.
+
+  Datagrams are fire-and-forget. The design tolerates UDP loss and reordering
+  without a reconnect or framing layer: a lost announcement just delays an update
+  by one interval (announcements are periodic and idempotent), and an out-of-order
+  datagram is rejected by the per-url monotonic timestamp (see Authentication).
+
+
+Roles:
+  Backend  = sender   (ProxyBeaconAddress): sends to the proxy, advertising its
+             own routable URL (ProxyBeaconAdvertise) periodically.
+  Proxy    = receiver (ProxyBeaconListen): binds a stable address, receives
+             announcements, and manages balancer://<name> (ProxyBeaconBalancer).
+
+  The proxy is the fixed unicast rendezvous; each backend is configured with its
+  address. One proxy receiver serves many backend senders. A leading scheme in
+  an address (e.g. tcp://) is accepted and ignored.
+
+  ProxyBeaconListen's host and port are both optional: an omitted host/port (or
+  no argument at all) is inherited from the server's own address/port. Since UDP
+  and TCP are independent port spaces, the beacon socket can share the server's
+  service port without colliding with its TCP listener -- so a backend can beacon
+  to the proxy at its real service endpoint. (The listener binds in an
+  unprivileged watchdog child, so privileged ports like 80/443 cannot be shared
+  this way.) The sender's ProxyBeaconAddress targets the proxy and must be given
+  in full.
+
+
+Where it runs:
+  All background work runs in a single mod_watchdog SINGLETON callback -- exactly
+  one child process owns the socket (so only one process binds the receive port
+  and performs membership changes). The watchdog fires every ~100ms:
+
+    STARTING  open the UDP socket; receiver binds, sender resolves its dest.
+    RUNNING   sender:   send a throttled announcement (ProxyBeaconInterval).
+              receiver: drain the socket, then run a throttled (~1s) eviction
+                        sweep.
+    STOPPING  close the socket.
+
+  Because this relies on a singleton watchdog child, it is inactive under the
+  prefork MPM.
+
+
+Adding/removing members (reuses the balancer-manager path):
+  Runtime worker *removal* does not exist in httpd, so membership is managed
+  entirely as status-flag changes via mod_proxy_balancer's exported
+  balancer_manage() optional function -- the same code the balancer-manager web
+  UI calls. The receiver synthesizes a minimal request_rec (no real client
+  request exists in the watchdog thread; see beacon_make_fake_request) and calls:
+
+    add     b=<name> b_nwrkr=<url> b_wyes=1     (worker created, disabled)
+    enable  b=<name> w=<url> w_status_D=0       (cleared -> serves traffic)
+    evict   b=<name> w=<url> w_status_D=1       (disabled -> out of rotation)
+
+  Adds bump the balancer's shm "updated" timestamp, so every other child picks
+  up the new worker through the normal ap_proxy_sync_balancer() path -- the
+  change made in the singleton propagates fleet-wide. The target balancer must
+  have spare slots (ProxySet growth / BalancerGrowth).
+
+  Per-backend state (last-seen time, added/evicted flags) is kept in a hash in
+  the singleton's own pool -- no shared memory is needed, since the singleton is
+  the only process that adds or evicts. A re-announcement after eviction
+  re-enables the worker; the flags make these transitions idempotent.
+
+
+Message format:
+  Plain ASCII, space-separated key=value tokens, one datagram per announcement:
+
+    BEACON url=http://host:port host=<h> pid=<n> seq=<n> ts=<usec> mac=<hex>
+
+  url= is the routable backend origin the proxy adds as a BalancerMember. ts=
+  (microseconds since the epoch) and mac= are present only when a shared secret
+  is configured (see below). host=/pid=/seq= are informational.
+
+  A UDP datagram is delivered whole, so the receiver reads each message into a
+  fixed stack buffer and NUL-terminates it (buf[len] = '\0') before parsing.
+  The sender transmits strlen(msg) bytes (no trailing NUL on the wire).
+
+
+Authentication (ProxyBeaconSecret, optional but recommended):
+  Without a secret the channel is unauthenticated: anyone who can reach the
+  receiver port could announce a URL and hijack client traffic (the proxy logs a
+  warning at startup in this case). A UDP source address is trivially spoofable,
+  so authentication matters at least as much here as it would over a connection.
+
+  With ProxyBeaconSecret set identically on the proxy and all backends, each
+  announcement is signed with a SipHash-2-4 MAC over the message prefix (the key
+  is derived from the passphrase via apr_md5, as in mod_session_crypto). The
+  receiver recomputes the MAC (constant-time compare) and rejects anything that
+  does not match -- before the URL is parsed or acted on. Replay protection has
+  two parts, both keyed on the signed ts= (microseconds): (1) a freshness window
+  (ProxyBeaconMaxSkew) rejects messages whose timestamp is too far from now,
+  which assumes proxy and backend clocks are roughly synchronized (NTP); (2) a
+  per-url high-water mark rejects any ts= that does not strictly exceed the last
+  one accepted for that url, so a byte-identical replay (same ts, same MAC) --
+  e.g. replaying a dead backend's last announcement to keep it from being evicted
+  -- is dropped even within the freshness window. Microsecond granularity ensures
+  genuine sub-second announcements always advance. seq= is NOT used for replay
+  (it resets when a backend restarts); the wall-clock ts= does not.
+
+  Announcements are authenticated, not encrypted -- the payload is operational
+  metadata, not secret. For transport confidentiality, DTLS would be a separate,
+  orthogonal future layer.
index 9dc42e83f013dd59c1c0342c80709983f707fceb..110c546556880101df0ffa4fa099a3acd3621b1b 100644 (file)
@@ -64,6 +64,9 @@ APACHE_MODULE(serf, [Reverse proxy module using Serf], , , no, [
 APACHE_MODULE(proxy_express, mass reverse-proxy module. Requires --enable-proxy., , , most, , proxy)
 APACHE_MODULE(proxy_hcheck, [reverse-proxy health-check module. Requires --enable-proxy and --enable-watchdog.], , , most, , [proxy,watchdog])
 
+proxy_beacon_objs="mod_proxy_beacon.lo"
+APACHE_MODULE(proxy_beacon, [reverse-proxy UDP member announcement; backends self-register as balancer members. Requires --enable-proxy, --enable-proxy-balancer and --enable-watchdog.], $proxy_beacon_objs, , no, , [proxy,watchdog,proxy_balancer])
+
 APR_ADDTO(INCLUDES, [-I\$(top_srcdir)/$modpath_current -I\$(top_srcdir)/modules/http2])
 
 APACHE_MODPATH_FINISH
diff --git a/modules/proxy/mod_proxy_beacon-guide.md b/modules/proxy/mod_proxy_beacon-guide.md
new file mode 100644 (file)
index 0000000..930fb00
--- /dev/null
@@ -0,0 +1,277 @@
+# 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
diff --git a/modules/proxy/mod_proxy_beacon.c b/modules/proxy/mod_proxy_beacon.c
new file mode 100644 (file)
index 0000000..a0c9567
--- /dev/null
@@ -0,0 +1,1241 @@
+/* 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 */
+};
diff --git a/test/pytest_suite/t/conf/proxy_beacon.conf.in b/test/pytest_suite/t/conf/proxy_beacon.conf.in
new file mode 100644 (file)
index 0000000..dc4bea0
--- /dev/null
@@ -0,0 +1,115 @@
+#t/TEST -proxy_beacon
+#
+# Config for mod_proxy_beacon (UDP backend announcement).
+#
+# Backends announce themselves to the reverse proxy over UDP datagrams:
+#
+#   - reverse proxy (main server): ProxyBeaconListen  -- binds, receives
+#   - backend (vhost proxy_beacon): ProxyBeaconAddress -- sends to the proxy
+#
+# Phase 1 proved the channel (the receiver logs each BEACON).  Phase 2 adds the
+# announcing backend as a member of balancer://beacon and enables it, so a
+# request to /beacon on the proxy routes to the dynamically-added backend.
+# Phase 3 evicts a backend that stops announcing.  Phase 4 authenticates the
+# channel with a pre-shared secret (ProxyBeaconSecret): the proxy drops any
+# announcement that isn't validly signed and fresh.
+#
+# Everything runs in this single test instance over loopback UDP, driven by the
+# mod_watchdog singleton thread.  The backend advertises the MAIN server's
+# origin (@SERVERNAME@:@PORT@), which serves the framework's index.html
+# ("welcome to <servername>:<port>"); routing /beacon/index.html through the
+# proxy must return that body, proving the added member serves traffic.
+#
+# Two backends send to the SAME proxy port: one with the matching secret (must be
+# added + serve), and a decoy with a WRONG secret advertising an unroutable URL
+# (must be rejected and never added) -- proving authentication works.
+
+<IfModule mod_proxy_beacon.c>
+<IfModule mod_proxy_balancer.c>
+
+    # Received-beacon + balancer add/route logging in the error_log.
+    LogLevel proxy_beacon:info proxy_balancer:debug
+
+    # Reverse proxy == receiver.  ProxyBeaconListen with NO argument inherits the
+    # server's own address and port (here the main server's @PORT@), so the UDP
+    # beacon socket shares the server's TCP service port -- UDP and TCP are
+    # independent port spaces, so this does not collide with the TCP listener.
+    # Backends therefore beacon to the proxy at its real service endpoint.
+    ProxyBeaconListen
+    ProxyBeaconBalancer beacon
+
+    # Phase 4: require announcements to be signed with this shared secret.
+    # Unsigned / wrong-secret / stale messages are dropped before being acted on.
+    ProxyBeaconSecret  beacon-test-shared-secret
+
+    # Phase 3: disable a backend that hasn't announced in 1s.  Combined with the
+    # backend's 4s announce interval below, this guarantees an eviction gap
+    # (announce -> stale after ~1s -> evicted -> next announce re-enables).
+    ProxyBeaconTimeout 1
+
+    # An initially-empty balancer with spare slots for runtime members.
+    <Proxy balancer://beacon>
+        ProxySet growth=10
+    </Proxy>
+    ProxyPass /beacon balancer://beacon
+
+    # Legit backend == sender: send to the proxy, advertise its routable origin
+    # (the main server here), sign with the MATCHING secret, announce every 4s.
+    <VirtualHost proxy_beacon>
+        ProxyBeaconAddress   127.0.0.1:@PORT@
+        ProxyBeaconAdvertise http://@SERVERNAME@:@PORT@
+        ProxyBeaconSecret    beacon-test-shared-secret
+        # Announce every 4s.  With the proxy's 1s ProxyBeaconTimeout this leaves
+        # a ~3s window each cycle where the member is stale and gets evicted, then
+        # the next announcement re-enables it -- exercising the full Phase-3
+        # add -> evict -> re-enable lifecycle in a single-instance test.
+        ProxyBeaconInterval  4
+    </VirtualHost>
+
+    # Phase 4 negative case: a rogue backend sending to the SAME proxy port with a
+    # WRONG secret, advertising an unroutable decoy URL.  Its announcements must
+    # be rejected (bad MAC) and the decoy must NEVER become a balancer member.
+    <VirtualHost proxy_beacon_bad>
+        ProxyBeaconAddress   127.0.0.1:@PORT@
+        ProxyBeaconAdvertise http://127.0.0.1:1
+        ProxyBeaconSecret    wrong-secret
+        ProxyBeaconInterval  1
+    </VirtualHost>
+
+    #
+    # Capacity / slot-exhaustion scenario (separate, second receiver + balancer).
+    # balancer://cap has room for exactly ONE runtime member (growth=1).  Two
+    # backends announce distinct URLs to it: one wins the slot, the other can
+    # never be added.  The failed add must be retried on a long backoff, NOT on
+    # every announcement -- so there must be no per-interval add-failure storm.
+    #
+    Define BEACON_CAP_PORT @NextAvailablePort@
+
+    # Second receiver lives on its own vhost so it has a distinct bind port.
+    <VirtualHost proxy_beacon_cap>
+        ProxyBeaconListen  127.0.0.1:${BEACON_CAP_PORT}
+        ProxyBeaconBalancer cap
+        ProxyBeaconSecret  beacon-test-shared-secret
+        ProxyBeaconTimeout 0
+        <Proxy balancer://cap>
+            ProxySet growth=1
+        </Proxy>
+        ProxyPass /cap balancer://cap
+    </VirtualHost>
+
+    # Two backends competing for the single cap slot (announce every 1s).
+    <VirtualHost proxy_beacon_cap1>
+        ProxyBeaconAddress   127.0.0.1:${BEACON_CAP_PORT}
+        ProxyBeaconAdvertise http://127.0.0.2:18081
+        ProxyBeaconSecret    beacon-test-shared-secret
+        ProxyBeaconInterval  1
+    </VirtualHost>
+    <VirtualHost proxy_beacon_cap2>
+        ProxyBeaconAddress   127.0.0.1:${BEACON_CAP_PORT}
+        ProxyBeaconAdvertise http://127.0.0.2:18082
+        ProxyBeaconSecret    beacon-test-shared-secret
+        ProxyBeaconInterval  1
+    </VirtualHost>
+
+</IfModule>
+</IfModule>
diff --git a/test/pytest_suite/tests/t/modules/test_proxy_beacon.py b/test/pytest_suite/tests/t/modules/test_proxy_beacon.py
new file mode 100644 (file)
index 0000000..2fa951c
--- /dev/null
@@ -0,0 +1,126 @@
+r"""Test for mod_proxy_beacon -- UDP backend announce -> balancer membership.
+
+The reverse proxy (main server) binds a UDP socket on a loopback port to receive
+announcements; a backend vhost sends BEACON datagrams to it carrying a routable
+URL (see t/conf/proxy_beacon.conf.in). Both halves run in the same instance,
+driven by the mod_watchdog singleton thread.
+
+  Phase 1: the receiver logs each "BEACON" it receives (channel proof).
+  Phase 2: on an announcement carrying url=, the proxy adds that backend as a
+           member of balancer://beacon (reusing mod_proxy_balancer's
+           balancer_manage) and enables it, so /announce routes to the backend.
+  Phase 3: when a backend stops announcing for longer than ProxyBeaconTimeout, the
+           proxy disables it (out of rotation); a later announcement re-enables
+           it.
+  Phase 4: announcements are authenticated with a pre-shared secret
+           (ProxyBeaconSecret); the proxy drops any that aren't validly signed and
+           fresh. A second backend sends to the same port with the WRONG secret
+           and a decoy URL -- it must be rejected and never added.
+
+The config sets ProxyBeaconTimeout=1s on the proxy and ProxyBeaconInterval=4s on the
+legit backend, so each cycle is: announce (add/enable) -> stale after ~1s
+(evict) -> next announce (re-enable). We assert that whole lifecycle from the
+error_log (the reliable signal; cf. t/modules/heartbeat.t's log-scan approach),
+prove the member serves traffic while enabled, and assert the wrong-secret
+backend is rejected.
+
+mod_watchdog runs its singleton in a child process, so (like heartbeat.t) this
+is skipped under the prefork MPM.
+"""
+
+import time
+from pathlib import Path
+
+import pytest
+
+from apache_pytest import need_module, t_cmp
+
+# Backend announces every 4s; cover at least one full add/evict/re-enable cycle.
+NB_SECONDS = 11
+
+
+@need_module("proxy_beacon", "watchdog", "proxy_balancer", "proxy_http")
+def test_proxy_beacon(http):
+    if http.have_module("mpm_prefork"):
+        pytest.skip("proxy_beacon not run under the prefork MPM")
+
+    error_log = Path(http.vars("t_logs")) / "error_log"
+    start = error_log.stat().st_size if error_log.exists() else 0
+
+    # Prove the member serves while enabled: poll /announce right after startup
+    # (the first announce enables it within ~1 announce interval).
+    vars_ = http.vars()
+    expected_body = f"welcome to {vars_['servername']}:{vars_['port']}"
+    served = False
+    deadline = time.time() + 6
+    while time.time() < deadline:
+        r = http.GET("/beacon/index.html")
+        if r.status_code == 200 and expected_body in r.text:
+            served = True
+            break
+        time.sleep(0.5)
+    assert served, "request through balancer://beacon never reached the backend"
+
+    # Let the add -> evict -> re-enable lifecycle play out.
+    time.sleep(NB_SECONDS)
+
+    with error_log.open("r", errors="replace") as fh:
+        fh.seek(start)
+        loglines = fh.read().splitlines()
+
+    # Announcements are received and carry a routable url=.
+    received = [ln for ln in loglines if "received: BEACON" in ln]
+    assert received, "no announcements received by the SUB"
+    assert any("url=" in ln for ln in received), (
+        "announcements should carry a url= token")
+
+    # Phase 2: the backend was added exactly once (dedup), no add-failure spam.
+    # Qualify by balancer://beacon so the capacity-test balancer (below) doesn't
+    # perturb these counts.
+    added = [ln for ln in loglines
+             if "added backend" in ln and "balancer://beacon" in ln]
+    assert len(added) == 1, (
+        f"backend should be added exactly once; saw {len(added)}: {added}")
+    assert not [ln for ln in loglines if "failed to add worker" in ln
+                and "balancer://beacon:" in ln], (
+        "unexpected add-failure log lines for balancer://beacon")
+
+    # Phase 3: the member was evicted (stopped announcing within the timeout)
+    # and later re-enabled (started announcing again).
+    evicted = [ln for ln in loglines if "evicted backend" in ln]
+    reenabled = [ln for ln in loglines if "re-enabled backend" in ln]
+    assert evicted, "backend should have been evicted after the announce gap"
+    assert reenabled, "backend should have been re-enabled on the next announce"
+
+    # Phase 4: the rogue backend (wrong secret, decoy URL) must be rejected --
+    # its announcements dropped and its url NEVER added as a balancer member.
+    assert not [ln for ln in loglines if "http://127.0.0.1:1" in ln
+                and "added backend" in ln], (
+        "decoy backend with wrong secret must never be added")
+    assert [ln for ln in loglines
+            if "dropped" in ln and "mac mismatch" in ln], (
+        "proxy should log dropping the wrong-secret announcements")
+
+    # Slot exhaustion: balancer://cap has room for one member but two backends
+    # announce to it. Exactly one must be added; the other can never fit.
+    cap_added = [ln for ln in loglines
+                 if "added backend" in ln and "balancer://cap" in ln]
+    assert len(cap_added) == 1, (
+        f"exactly one backend should fit balancer://cap; saw: {cap_added}")
+
+    # The key regression guard: the un-addable backend must NOT trigger an
+    # add attempt (and its log) on every announcement. Both backends announce
+    # once per second over ~16s, so a per-interval retry storm would be >10
+    # failure logs; the backoff must keep it tiny.
+    cap_fail = [ln for ln in loglines if "balancer add failed" in ln]
+    assert len(cap_fail) <= 3, (
+        f"failed add must back off, not retry every announcement; "
+        f"saw {len(cap_fail)} failure logs: {cap_fail}")
+
+    # Anti-replay: the per-url monotonic ts check must NOT false-reject the
+    # legitimate backends, which announce ~once/second. Because ts is in
+    # microseconds it strictly increases between announcements, so no genuine
+    # announcement should be logged as a replay. (A real replay can't be
+    # injected from this harness; this guards against over-rejection.)
+    assert not [ln for ln in loglines if "replayed/reordered ts" in ln], (
+        "legitimate sub-second announcements must not be seen as replays")