feat(daemon): add shared cross-process connection registry

The daemon forks one child per accepted connection, so per-module and
per-source accounting must live in state shared across the children. Add a
fixed-size registry carved from an anonymous shared mapping
(mmap(MAP_SHARED|MAP_ANONYMOUS)) created before the accept loop: a slot
lifecycle (FREE/CLAIMED/REGISTERED) with parent claim/reclaim and a
lock-free, open-addressed per-source table for the per-host occupancy and
the shared auth-failure counter. C11 atomics only; no pthread locks across
fork.

Unit tests cover slot exhaustion, the module/host caps, pid reclaim and
fork-shared visibility.
This commit is contained in:
2026-09-13 10:23:58 +02:00
parent 9242e86772
commit 5334397b81
6 changed files with 662 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
#ifndef DAEMON_LIMITS_H
#define DAEMON_LIMITS_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Cross-process daemon connection registry.
*
* The daemon listener forks ONE child per accepted connection, so any
* per-module / per-source accounting must live in state shared across the
* forked children. This module owns a fixed-size registry carved out of an
* anonymous shared mapping (mmap(MAP_SHARED | MAP_ANONYMOUS)) created by the
* accept-loop PARENT before it forks; every child inherits the mapping (and the
* pointer to it) across fork().
*
* Rules:
* - ONLY C11 atomics (atomic_*); never mtx_t/pthread locks, which can deadlock
* in a forked child if another thread held them at fork time.
* - No heap allocation after fork: the mapping is fixed-size and all access is
* atomic load/store/CAS over preallocated arrays.
*
* Slot lifecycle (the parent reclaims even when a child is SIGKILLed):
* FREE --(parent claim_slot)--> CLAIMED
* CLAIMED --(child register)--> REGISTERED
* any --(parent reclaim)--> FREE
* The child records its module index and per-source bucket into the slot before
* publishing REGISTERED; the parent's SIGCHLD handler matches the reaped pid to
* the slot and, when REGISTERED, decrements the module/per-source counters.
* A child killed before registering holds no counts, so reclaiming a CLAIMED
* slot only frees the slot.
*
* Per-source identity is the normalized numeric peer IP (IPv4-mapped IPv6 is
* already collapsed to IPv4 by utils_fd_peer_ip); it is interned into an
* open-addressed, linear-probing table keyed by a 64-bit hash. The same table
* also carries the cross-process auth-failure counter and lockout deadline.
*/
typedef struct DaemonLimitRegistry DaemonLimitRegistry;
/* Result of a per-connection admission check. */
typedef enum {
DAEMON_LIMIT_OK = 0, /* admitted; slot is now REGISTERED */
DAEMON_LIMIT_MODULE_FULL, /* module's `max connections` cap reached */
DAEMON_LIMIT_HOST_FULL, /* global `max connections per host` cap reached */
DAEMON_LIMIT_UNAVAILABLE, /* registry/slot unusable (caller fails open) */
} DaemonLimitResult;
/* Bounds for registry sizing. A slot is one concurrently live child. */
#define DAEMON_LIMITS_MIN_SLOTS 16
#define DAEMON_LIMITS_MAX_SLOTS 65536
#define DAEMON_LIMITS_MAX_HOST_SLOTS 65536
#define DAEMON_LIMITS_NO_SLOT (-1)
/* Create the shared registry in the calling (parent) process. `max_slots` is
* the number of concurrently live children to track (clamped to
* [DAEMON_LIMITS_MIN_SLOTS, DAEMON_LIMITS_MAX_SLOTS]); `module_count` is the
* number of daemon modules (clamped to >= 1); `per_host_cap` and the lockout
* pair come from the daemon config (0 disables). Returns NULL on failure (e.g.
* mmap allocation); callers must degrade gracefully (global cap + ACLs still
* apply). */
DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap,
int lockout_threshold, int lockout_duration_sec);
/* Unmap the registry. Only the creating process may call this. */
void daemon_limits_destroy(DaemonLimitRegistry* registry);
/* Parent side: reserve a slot for the next fork. Returns the slot index or
* DAEMON_LIMITS_NO_SLOT when every slot is in use. */
int daemon_limits_claim_slot(DaemonLimitRegistry* registry);
/* Parent side: record the forked child's pid in a claimed slot. */
void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid);
/* Parent side: release a slot, decrementing the module/per-source counters when
* the slot was actually REGISTERED. Idempotent. */
void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot);
/* Parent SIGCHLD side: reclaim the slot owned by `pid` (no-op when not found). */
void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid);
/* Child side: admit the connection for `module_index` from `peer_ip`. Always
* tracks the module/per-source occupancy (so the parent's reclaim is
* symmetric); when `module_cap` > 0 it additionally enforces the per-module
* cap. Returns DAEMON_LIMIT_OK and publishes the slot, or a refusal reason. */
DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index,
const char* peer_ip, int module_cap);
/* Child side: true when `peer_ip` is currently locked out after too many failed
* authentications. `seconds_remaining` may be NULL. */
bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip,
int* seconds_remaining);
/* Child side: count one failed authentication for `peer_ip`; once the threshold
* is reached the source is locked out for the configured duration. */
void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip);
/* Child side: clear the failure counter/lockout for a source that authenticated
* successfully (no-op when the source has no table entry). */
void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip);
/* Pure helper: 64-bit FNV-1a hash of a numeric peer IP plus its family, used to
* index the per-source table. *ok is set false (and 0 returned) for a NULL or
* non-numeric address. Exposed for unit testing. */
uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok);
#endif