From 5334397b817babe93c78c0a2a3bb011f4d4b60fb Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:23:58 +0200 Subject: [PATCH] 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. --- CMakeLists.txt | 2 + src/shared/daemon_limits.c | 356 +++++++++++++++++++++++++++++++++++++ src/shared/daemon_limits.h | 102 +++++++++++ tests/runner.c | 2 + tests/test_daemon_limits.c | 194 ++++++++++++++++++++ tests/test_daemon_limits.h | 6 + 6 files changed, 662 insertions(+) create mode 100644 src/shared/daemon_limits.c create mode 100644 src/shared/daemon_limits.h create mode 100644 tests/test_daemon_limits.c create mode 100644 tests/test_daemon_limits.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e61b0fa..479cbca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,7 @@ set(SHARED_SRCS src/shared/config.c src/shared/credentials.c src/shared/daemon_conf.c + src/shared/daemon_limits.c src/shared/data.c src/shared/delay_updates.c src/shared/delta.c @@ -205,6 +206,7 @@ set(TEST_SRCS tests/test_config.c tests/test_credentials.c tests/test_daemon_conf.c + tests/test_daemon_limits.c tests/test_data.c tests/test_delay_updates.c tests/test_delta.c diff --git a/src/shared/daemon_limits.c b/src/shared/daemon_limits.c new file mode 100644 index 0000000..2ba7e4e --- /dev/null +++ b/src/shared/daemon_limits.c @@ -0,0 +1,356 @@ +#include "daemon_limits.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* Slot lifecycle states (stored in slot_state). */ +enum { + SLOT_FREE = 0, + SLOT_CLAIMED = 1, + SLOT_REGISTERED = 2, +}; + +/* The registry header lives at the base of the shared mapping; the pointer + * fields point at the arrays carved out of the same mapping. Absolute pointers + * remain valid in a forked child because fork() clones the address space and + * mapping, so parent and child observe the same virtual addresses. */ +struct DaemonLimitRegistry { + int max_slots; + int module_count; + int host_slots; /* power of two; 1 when no per-source tracking is needed */ + int per_host_cap; + int lockout_threshold; + int lockout_duration_sec; + size_t map_size; + _Atomic int* slot_state; + _Atomic int* slot_pid; + _Atomic int* slot_module; + _Atomic int* slot_host; /* per-source table bucket, or -1 */ + _Atomic int* module_active; + _Atomic uint64_t* host_key; /* 0 == empty bucket */ + _Atomic int* host_active; + _Atomic int* host_fail; + _Atomic long long* host_until; /* epoch seconds the lockout expires */ +}; + +static size_t round_up(size_t n, size_t align) { + return (n + align - 1) & ~(align - 1); +} + +static size_t next_pow2(size_t n) { + size_t p = 1; + while (p < n) + p <<= 1; + return p; +} + +/* Parse a numeric IPv4/IPv6 peer string into family + raw bytes. */ +static bool parse_peer_ip(const char* peer_ip, int* family, unsigned char* bytes) { + if (!peer_ip || *peer_ip == '\0') + return false; + struct in_addr v4; + if (inet_pton(AF_INET, peer_ip, &v4) == 1) { + memcpy(bytes, &v4, sizeof(v4)); + *family = AF_INET; + return true; + } + struct in6_addr v6; + if (inet_pton(AF_INET6, peer_ip, &v6) == 1) { + memcpy(bytes, &v6, sizeof(v6)); + *family = AF_INET6; + return true; + } + return false; +} + +uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok) { + if (ok) + *ok = false; + unsigned char bytes[16]; + int family = AF_UNSPEC; + if (!parse_peer_ip(peer_ip, &family, bytes)) + return 0; + uint64_t hash = 14695981039346656037ULL ^ (uint64_t)(uint32_t)family; + size_t length = family == AF_INET ? 4 : 16; + for (size_t i = 0; i < length; i++) { + hash ^= bytes[i]; + hash *= 1099511628211ULL; + } + if (hash == 0) + hash = 0x9e3779b97f4a7c15ULL; + if (ok) + *ok = true; + return hash; +} + +/* Find the bucket holding `peer_ip`, or -1 when it has no entry. */ +static int host_lookup(DaemonLimitRegistry* registry, const char* peer_ip) { + bool ok = false; + uint64_t key = daemon_limits_host_hash(peer_ip, &ok); + if (!ok) + return -1; + size_t mask = (size_t)registry->host_slots - 1; + size_t start = (size_t)(key & mask); + for (size_t i = 0; i < (size_t)registry->host_slots; i++) { + size_t idx = (start + i) & mask; + uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire); + if (current == key) + return (int)idx; + if (current == 0) + return -1; /* no tombstones: an empty bucket ends the probe chain */ + } + return -1; +} + +/* Find or insert the bucket for `peer_ip`. Insertion is a lock-free CAS so two + * forked children racing on the same source converge on one bucket. Returns -1 + * when the table is full or the address is unparseable (callers fail open: the + * global/module caps and ACLs still apply). */ +static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) { + bool ok = false; + uint64_t key = daemon_limits_host_hash(peer_ip, &ok); + if (!ok) + return -1; + size_t mask = (size_t)registry->host_slots - 1; + size_t start = (size_t)(key & mask); + for (size_t i = 0; i < (size_t)registry->host_slots; i++) { + size_t idx = (start + i) & mask; + uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire); + if (current == key) + return (int)idx; + if (current == 0) { + uint64_t expected = 0; + if (atomic_compare_exchange_strong_explicit(®istry->host_key[idx], &expected, key, + memory_order_acq_rel, memory_order_acquire)) + return (int)idx; + if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) + return (int)idx; + } + } + return -1; +} + +DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap, + int lockout_threshold, int lockout_duration_sec) { + if (max_slots < DAEMON_LIMITS_MIN_SLOTS) + max_slots = DAEMON_LIMITS_MIN_SLOTS; + if (max_slots > DAEMON_LIMITS_MAX_SLOTS) + max_slots = DAEMON_LIMITS_MAX_SLOTS; + if (module_count < 1) + module_count = 1; + if (per_host_cap < 0) + per_host_cap = 0; + if (lockout_threshold < 0) + lockout_threshold = 0; + if (lockout_duration_sec < 0) + lockout_duration_sec = 0; + + bool need_hosts = per_host_cap > 0 || (lockout_threshold > 0 && lockout_duration_sec > 0); + int host_slots = 1; + if (need_hosts) { + size_t want = (size_t)max_slots * 4; + if (want < 64) + want = 64; + if (want > DAEMON_LIMITS_MAX_HOST_SLOTS) + want = DAEMON_LIMITS_MAX_HOST_SLOTS; + host_slots = (int)next_pow2(want); + } + + size_t header = round_up(sizeof(DaemonLimitRegistry), 16); + size_t slot_bytes = + round_up((size_t)max_slots * sizeof(_Atomic int), 16) * 4; /* state,pid,module,host */ + size_t module_bytes = round_up((size_t)module_count * sizeof(_Atomic int), 16); + size_t host_key_bytes = round_up((size_t)host_slots * sizeof(_Atomic uint64_t), 16); + size_t host_int_bytes = round_up((size_t)host_slots * sizeof(_Atomic int), 16) * 2; + size_t host_until_bytes = round_up((size_t)host_slots * sizeof(_Atomic long long), 16); + size_t total = + header + slot_bytes + module_bytes + host_key_bytes + host_int_bytes + host_until_bytes + 16; + + void* map = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (map == MAP_FAILED) + return NULL; + memset(map, 0, total); + + DaemonLimitRegistry* registry = (DaemonLimitRegistry*)map; + registry->max_slots = max_slots; + registry->module_count = module_count; + registry->host_slots = host_slots; + registry->per_host_cap = per_host_cap; + registry->lockout_threshold = lockout_threshold; + registry->lockout_duration_sec = lockout_duration_sec; + registry->map_size = total; + + unsigned char* cursor = (unsigned char*)map + header; + registry->slot_state = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_pid = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_module = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_host = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->module_active = (atomic_int*)cursor; + cursor += (size_t)module_count * sizeof(_Atomic int); + cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16); + registry->host_key = (_Atomic uint64_t*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic uint64_t); + registry->host_active = (atomic_int*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic int); + registry->host_fail = (atomic_int*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic int); + cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16); + registry->host_until = (atomic_llong*)cursor; + + for (int i = 0; i < max_slots; i++) { + atomic_store(®istry->slot_module[i], -1); + atomic_store(®istry->slot_host[i], -1); + } + return registry; +} + +void daemon_limits_destroy(DaemonLimitRegistry* registry) { + if (!registry) + return; + munmap(registry, registry->map_size); +} + +int daemon_limits_claim_slot(DaemonLimitRegistry* registry) { + if (!registry) + return DAEMON_LIMITS_NO_SLOT; + for (int i = 0; i < registry->max_slots; i++) { + int expected = SLOT_FREE; + if (atomic_compare_exchange_strong(®istry->slot_state[i], &expected, SLOT_CLAIMED)) { + atomic_store(®istry->slot_pid[i], 0); + atomic_store(®istry->slot_module[i], -1); + atomic_store(®istry->slot_host[i], -1); + return i; + } + } + return DAEMON_LIMITS_NO_SLOT; +} + +void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return; + atomic_store(®istry->slot_pid[slot], (int)pid); +} + +void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return; + int previous = + atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel); + if (previous == SLOT_REGISTERED) { + int module = atomic_load(®istry->slot_module[slot]); + int host = atomic_load(®istry->slot_host[slot]); + if (module >= 0 && module < registry->module_count) { + int current = atomic_load(®istry->module_active[module]); + while (current > 0 && + !atomic_compare_exchange_weak(®istry->module_active[module], ¤t, current - 1)) + ; + } + if (host >= 0 && host < registry->host_slots) { + int current = atomic_load(®istry->host_active[host]); + while (current > 0 && + !atomic_compare_exchange_weak(®istry->host_active[host], ¤t, current - 1)) + ; + } + } + atomic_store(®istry->slot_pid[slot], 0); +} + +void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid) { + if (!registry || pid <= 0) + return; + for (int i = 0; i < registry->max_slots; i++) { + if (atomic_load(®istry->slot_state[i]) == SLOT_FREE) + continue; + if (atomic_load(®istry->slot_pid[i]) == (int)pid) { + daemon_limits_reclaim_slot(registry, i); + return; + } + } +} + +DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index, + const char* peer_ip, int module_cap) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return DAEMON_LIMIT_UNAVAILABLE; + if (module_index < 0 || module_index >= registry->module_count) + return DAEMON_LIMIT_UNAVAILABLE; + if (atomic_load_explicit(®istry->slot_state[slot], memory_order_acquire) != SLOT_CLAIMED) + return DAEMON_LIMIT_UNAVAILABLE; + + int host = -1; + if (registry->per_host_cap > 0 || registry->lockout_threshold > 0) + host = host_intern(registry, peer_ip); + + int module_count = atomic_fetch_add(®istry->module_active[module_index], 1) + 1; + if (module_cap > 0 && module_count > module_cap) { + atomic_fetch_sub(®istry->module_active[module_index], 1); + return DAEMON_LIMIT_MODULE_FULL; + } + if (host >= 0) { + int host_count = atomic_fetch_add(®istry->host_active[host], 1) + 1; + if (registry->per_host_cap > 0 && host_count > registry->per_host_cap) { + atomic_fetch_sub(®istry->host_active[host], 1); + atomic_fetch_sub(®istry->module_active[module_index], 1); + return DAEMON_LIMIT_HOST_FULL; + } + } + atomic_store(®istry->slot_module[slot], module_index); + atomic_store(®istry->slot_host[slot], host); + atomic_store_explicit(®istry->slot_state[slot], SLOT_REGISTERED, memory_order_release); + return DAEMON_LIMIT_OK; +} + +bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip, + int* seconds_remaining) { + if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0) + return false; + int bucket = host_lookup(registry, peer_ip); + if (bucket < 0) + return false; + long long until = atomic_load(®istry->host_until[bucket]); + long long now = (long long)time(NULL); + if (until > now) { + if (seconds_remaining) + *seconds_remaining = (int)(until - now); + return true; + } + if (until != 0) { + /* The previous lockout has expired: clear the stale counter so the source + * gets a fresh allowance. */ + atomic_store(®istry->host_fail[bucket], 0); + atomic_store(®istry->host_until[bucket], 0); + } + return false; +} + +void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip) { + if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0) + return; + int bucket = host_intern(registry, peer_ip); + if (bucket < 0) + return; + int failures = atomic_fetch_add(®istry->host_fail[bucket], 1) + 1; + if (failures >= registry->lockout_threshold) { + long long now = (long long)time(NULL); + atomic_store(®istry->host_until[bucket], now + (long long)registry->lockout_duration_sec); + } +} + +void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip) { + if (!registry) + return; + int bucket = host_lookup(registry, peer_ip); + if (bucket < 0) + return; + atomic_store(®istry->host_fail[bucket], 0); + atomic_store(®istry->host_until[bucket], 0); +} diff --git a/src/shared/daemon_limits.h b/src/shared/daemon_limits.h new file mode 100644 index 0000000..47bfb0b --- /dev/null +++ b/src/shared/daemon_limits.h @@ -0,0 +1,102 @@ +#ifndef DAEMON_LIMITS_H +#define DAEMON_LIMITS_H + +#include +#include +#include + +/* 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 diff --git a/tests/runner.c b/tests/runner.c index 9e12323..9549220 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -9,6 +9,7 @@ #include "test_credentials.h" #include "test_data.h" #include "test_daemon_conf.h" +#include "test_daemon_limits.h" #include "test_delay_updates.h" #include "test_delta.h" #include "test_file.h" @@ -85,6 +86,7 @@ int main() { RUN_TEST(test_client_cli); RUN_TEST(test_server); RUN_TEST(test_daemon_conf); + RUN_TEST(test_daemon_limits); RUN_TEST(test_motd); RUN_TEST(test_server_cli); RUN_TEST(test_fuzz_smoke); diff --git a/tests/test_daemon_limits.c b/tests/test_daemon_limits.c new file mode 100644 index 0000000..2815e0a --- /dev/null +++ b/tests/test_daemon_limits.c @@ -0,0 +1,194 @@ +#include "test_daemon_limits.h" +#include "daemon_limits.h" +#include "test_utils.h" +#include +#include +#include + +/* The per-source hash is a pure helper: numeric addresses hash to a nonzero, + * stable value and unparseable input reports failure. */ +static void test_daemon_limits_host_hash() { + bool ok = false; + uint64_t v4 = daemon_limits_host_hash("127.0.0.1", &ok); + EXPECT_TRUE(ok); + EXPECT_TRUE(v4 != 0); + EXPECT_EQ_INT((int)(daemon_limits_host_hash("127.0.0.1", NULL) == v4), 1); + + bool ok6 = false; + uint64_t v6 = daemon_limits_host_hash("2001:db8::1", &ok6); + EXPECT_TRUE(ok6); + EXPECT_TRUE(v6 != 0); + /* Distinct textual forms of different addresses must differ. */ + EXPECT_TRUE(v4 != v6); + + bool bad = true; + EXPECT_TRUE(daemon_limits_host_hash("not-an-ip", &bad) == 0); + EXPECT_FALSE(bad); + bad = true; + EXPECT_TRUE(daemon_limits_host_hash(NULL, &bad) == 0); + EXPECT_FALSE(bad); + bad = true; + EXPECT_TRUE(daemon_limits_host_hash("", &bad) == 0); + EXPECT_FALSE(bad); +} + +/* Slot reservation is a plain parent-side resource: claim until exhausted, + * reclaim, then claim again. */ +static void test_daemon_limits_slots() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 0, 0, 0); + EXPECT_NOT_NULL(registry); + int slots[DAEMON_LIMITS_MIN_SLOTS]; + for (int i = 0; i < DAEMON_LIMITS_MIN_SLOTS; i++) { + slots[i] = daemon_limits_claim_slot(registry); + EXPECT_EQ_INT(slots[i], i); + } + EXPECT_EQ_INT(daemon_limits_claim_slot(registry), DAEMON_LIMITS_NO_SLOT); + daemon_limits_reclaim_slot(registry, slots[3]); + int reclaimed = daemon_limits_claim_slot(registry); + EXPECT_EQ_INT(reclaimed, slots[3]); + daemon_limits_destroy(registry); +} + +/* Per-module accounting: the cap is enforced across slots and a reclaimed slot + * frees a module count. */ +static void test_daemon_limits_module_cap() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 0, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + int slot2 = daemon_limits_claim_slot(registry); + int slot3 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0 && slot2 >= 0 && slot3 >= 0); + + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 2), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 2), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2), + DAEMON_LIMIT_MODULE_FULL); + /* A different module has its own counter. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 1, "10.0.0.3", 2), DAEMON_LIMIT_OK); + /* A module cap of 0 is unlimited. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot3, 0, "10.0.0.3", 0), DAEMON_LIMIT_OK); + + daemon_limits_reclaim_slot(registry, slot0); + daemon_limits_reclaim_slot(registry, slot1); + int slot4 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot4 >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot4, 0, "10.0.0.4", 2), DAEMON_LIMIT_OK); + + daemon_limits_destroy(registry); +} + +/* Per-source accounting: the same peer hits the cap, a different peer does not. */ +static void test_daemon_limits_host_cap() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + int slot2 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0 && slot2 >= 0); + + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 0), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 0), DAEMON_LIMIT_HOST_FULL); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.2", 0), DAEMON_LIMIT_OK); + /* Reclaiming the first source frees its per-host allowance. */ + daemon_limits_reclaim_slot(registry, slot0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 0), DAEMON_LIMIT_OK); + + daemon_limits_destroy(registry); +} + +/* The pid-indexed reclaim is what the parent's SIGCHLD handler uses: a dead + * child's module/source counts must be released. */ +static void test_daemon_limits_reclaim_pid() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0); + daemon_limits_set_slot_pid(registry, slot0, 4242); + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 1), DAEMON_LIMIT_OK); + /* Cap (module 1) and per-host (1) are both saturated. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 1), + DAEMON_LIMIT_MODULE_FULL); + + daemon_limits_reclaim_pid(registry, 4242); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 1), DAEMON_LIMIT_OK); + /* Reclaiming an unknown pid is a no-op. */ + daemon_limits_reclaim_pid(registry, 999999); + + daemon_limits_destroy(registry); +} + +/* Cross-process lockout: failures counted in the shared mapping lock the source + * out after the threshold; a success clears it; threshold 0 disables it. */ +static void test_daemon_limits_auth_lockout() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 2, 300); + EXPECT_NOT_NULL(registry); + + int remaining = 0; + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_TRUE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + EXPECT_TRUE(remaining > 0 && remaining <= 300); + /* Another source is unaffected. */ + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.2", &remaining)); + /* A successful authentication clears the lockout. */ + daemon_limits_auth_record_success(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_destroy(registry); + + /* threshold 0 disables the lockout entirely. */ + registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 0, 300); + EXPECT_NOT_NULL(registry); + for (int i = 0; i < 50; i++) + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_destroy(registry); +} + +/* The registry must be visible across fork(): a child's registration is seen by + * the parent, and the parent's pid reclaim releases it. */ +static void test_daemon_limits_fork_shared() { + if (is_running_under_valgrind()) + return; /* fork + shared mapping is slow/noisy under valgrind */ + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0); + pid_t pid = fork(); + if (pid == 0) { + if (daemon_limits_register(registry, slot0, 0, "10.0.0.1", 1) != DAEMON_LIMIT_OK) + _exit(1); + _exit(0); + } + EXPECT_TRUE(pid > 0); + daemon_limits_set_slot_pid(registry, slot0, (long)pid); + int status = 0; + EXPECT_TRUE(waitpid(pid, &status, 0) == pid); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + /* The child's module count is still held in the shared mapping. */ + int slot1 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot1 >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), + DAEMON_LIMIT_MODULE_FULL); + /* The parent reclaims the dead child's slot by pid. */ + daemon_limits_reclaim_pid(registry, (long)pid); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), DAEMON_LIMIT_OK); + daemon_limits_destroy(registry); +} + +void test_daemon_limits() { + test_daemon_limits_host_hash(); + test_daemon_limits_slots(); + test_daemon_limits_module_cap(); + test_daemon_limits_host_cap(); + test_daemon_limits_reclaim_pid(); + test_daemon_limits_auth_lockout(); + test_daemon_limits_fork_shared(); +} diff --git a/tests/test_daemon_limits.h b/tests/test_daemon_limits.h new file mode 100644 index 0000000..67e905a --- /dev/null +++ b/tests/test_daemon_limits.h @@ -0,0 +1,6 @@ +#ifndef TEST_DAEMON_LIMITS_H +#define TEST_DAEMON_LIMITS_H + +void test_daemon_limits(); + +#endif