Merge branch 'fix/w8-daemonlim' into fix/w8-integration
This commit is contained in:
@@ -135,7 +135,7 @@ class DaemonManager:
|
||||
self._proc = None
|
||||
self._port = None
|
||||
|
||||
def start(self, config_path, port_override=None, extra_args=None):
|
||||
def start(self, config_path, port_override=None, extra_args=None, log_path=None):
|
||||
self.stop()
|
||||
# When no override is given the daemon binds the config file's `port`
|
||||
# (the plain config-port path); with an override the --dparam path.
|
||||
@@ -146,7 +146,8 @@ class DaemonManager:
|
||||
cmd += ["--dparam", f"port={port_override}"]
|
||||
if extra_args:
|
||||
cmd += extra_args
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
if log_path is None:
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
log = open(log_path, "w")
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
@@ -1208,3 +1209,80 @@ class TestDaemonTLSAuth:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
|
||||
class TestDaemonConnectionLimits:
|
||||
"""Wave 8: cross-process per-module / per-source connection caps and the
|
||||
shared auth lockout. Each test boots its own daemon with a unique port so
|
||||
the shared (per-daemon) registry state is isolated from the module-scoped
|
||||
`daemon` fixture."""
|
||||
|
||||
LOCKOUT_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_lockout.conf")
|
||||
CAPS_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_caps.conf")
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_auth_lockout_exempts_trusted_loopback(self):
|
||||
"""`auth lockout threshold = 1`: a trusted loopback peer is EXEMPT from
|
||||
the shared lockout because every local client shares the 127.0.0.1
|
||||
identity, so a single wrong password must not lock out correct-password
|
||||
attempts (that would be a local denial of service). The shared
|
||||
per-source lockout machinery itself is covered by the daemon_limits unit
|
||||
tests; this locks in the loopback policy and the absence of a stale
|
||||
"locked out" log line."""
|
||||
port = _find_free_port()
|
||||
with open(self.LOCKOUT_CONF, "w") as f:
|
||||
f.write("port = %d\n"
|
||||
"auth lockout threshold = 1\n"
|
||||
"auth lockout duration = 300\n"
|
||||
"\n"
|
||||
"[locked]\n"
|
||||
"path = %s\n"
|
||||
"auth users = alice\n"
|
||||
% (port, AUTH_MODULE))
|
||||
d = DaemonManager()
|
||||
log_path = os.path.join(TEST_DATA_DIR, f"fastsyncd_lockout_{os.getpid()}.log")
|
||||
try:
|
||||
d.start(self.LOCKOUT_CONF, port_override=port, extra_args=["--password-file", CRED_FILE],
|
||||
log_path=log_path)
|
||||
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
# First attempt: wrong password -> a failure is logged, but a loopback
|
||||
# peer is not counted toward the lockout.
|
||||
wrong = _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
|
||||
assert wrong.returncode != 0
|
||||
# Second attempt: the correct password from the same local source must
|
||||
# still be accepted (no lockout), which also runs the SCRAM handshake
|
||||
# to completion in a fresh forked child.
|
||||
right = _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS)
|
||||
assert right.returncode == 0, (right.stderr or right.stdout)
|
||||
time.sleep(0.3)
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(log_before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "locked out" not in tail, tail[-400:]
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_caps_keys_accepted_and_transfer_still_works(self):
|
||||
"""A daemon configured with the new keys (per-host cap, lockout threshold
|
||||
and duration, per-module cap) starts and serves a normal transfer."""
|
||||
port = _find_free_port()
|
||||
with open(self.CAPS_CONF, "w") as f:
|
||||
f.write("port = %d\n"
|
||||
"max connections per host = 5\n"
|
||||
"auth lockout threshold = 3\n"
|
||||
"auth lockout duration = 60\n"
|
||||
"\n"
|
||||
"[files]\n"
|
||||
"path = %s\n"
|
||||
"max connections = 2\n"
|
||||
% (port, FILES_MODULE))
|
||||
d = DaemonManager()
|
||||
try:
|
||||
d.start(self.CAPS_CONF, port_override=port)
|
||||
result = _push("127.0.0.1::files", port)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(FILES_MODULE, SOURCE_DIR)
|
||||
_, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"missing: {missing[:5]}"
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,6 +33,11 @@ static void test_daemon_conf_create_defaults() {
|
||||
EXPECT_NULL(conf->global.address);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, DAEMON_CONF_DEFAULT_MAX_CONNECTIONS);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS);
|
||||
EXPECT_EQ_INT(conf->global.max_connections_per_host,
|
||||
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_threshold, DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec,
|
||||
DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_deny_count, 0);
|
||||
EXPECT_EQ_INT(conf->module_count, 0);
|
||||
@@ -316,6 +321,12 @@ static void test_daemon_conf_dparam_override() {
|
||||
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "max connections=7", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, 7);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "max connections per host=3", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.max_connections_per_host, 3);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "auth lockout threshold=5", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_threshold, 5);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "auth lockout duration=120", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec, 120);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "AUTH FAILURE DELAY=1500", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 1500);
|
||||
EXPECT_EQ_INT(
|
||||
@@ -390,6 +401,9 @@ static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
char err[256];
|
||||
EXPECT_EQ_INT(write_conf("max connections = 25\n"
|
||||
"auth failure delay = 0\n"
|
||||
"max connections per host = 4\n"
|
||||
"auth lockout threshold = 3\n"
|
||||
"auth lockout duration = 60\n"
|
||||
"hosts allow = 10.0.0.0/8, 192.168.1.0/24\n"
|
||||
"hosts deny = 192.168.0.1 2001:db8::/32\n"
|
||||
"\n"
|
||||
@@ -405,6 +419,9 @@ static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, 25);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 0);
|
||||
EXPECT_EQ_INT(conf->global.max_connections_per_host, 4);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_threshold, 3);
|
||||
EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec, 60);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 2);
|
||||
EXPECT_EQ_STR(conf->global.hosts_allow[0], "10.0.0.0/8");
|
||||
EXPECT_EQ_STR(conf->global.hosts_allow[1], "192.168.1.0/24");
|
||||
@@ -419,11 +436,14 @@ static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
daemon_conf_free(conf);
|
||||
|
||||
const char* bad_values[] = {
|
||||
"max connections = 0\n", "max connections = -1\n",
|
||||
"max connections = abc\n", "auth failure delay = -1\n",
|
||||
"auth failure delay = 70000\n", "auth failure delay = soon\n",
|
||||
"hosts allow = 10.0.0.0/99\n", "hosts deny = 2001:db8::/129\n",
|
||||
"hosts allow = *.example.com\n", "hosts deny = not-an-ip\n",
|
||||
"max connections = 0\n", "max connections = -1\n",
|
||||
"max connections = abc\n", "auth failure delay = -1\n",
|
||||
"auth failure delay = 70000\n", "auth failure delay = soon\n",
|
||||
"max connections per host = -1\n", "max connections per host = lots\n",
|
||||
"auth lockout threshold = -2\n", "auth lockout threshold = many\n",
|
||||
"auth lockout duration = -1\n", "auth lockout duration = forever\n",
|
||||
"hosts allow = 10.0.0.0/99\n", "hosts deny = 2001:db8::/129\n",
|
||||
"hosts allow = *.example.com\n", "hosts deny = not-an-ip\n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(bad_values) / sizeof(bad_values[0]); i++) {
|
||||
EXPECT_EQ_INT(write_conf(bad_values[i], &path), 0);
|
||||
@@ -434,7 +454,8 @@ static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
|
||||
/* The same strictness applies inside a module section. */
|
||||
const char* bad_module[] = {
|
||||
"[m]\npath = /x\nmax connections = 0\n",
|
||||
"[m]\npath = /x\nmax connections = -1\n",
|
||||
"[m]\npath = /x\nmax connections = abc\n",
|
||||
"[m]\npath = /x\nhosts allow = 10.0.0.0/40\n",
|
||||
"[m]\npath = /x\nhosts deny = 999.1.1.1/8\n",
|
||||
};
|
||||
@@ -446,6 +467,14 @@ static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
EXPECT_TRUE(strstr(err, "invalid") != NULL);
|
||||
}
|
||||
|
||||
/* Module `max connections = 0` is now valid and means unlimited. */
|
||||
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nmax connections = 0\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->modules[0].max_connections, 0);
|
||||
daemon_conf_free(conf);
|
||||
|
||||
/* An empty hosts list is not an error (no patterns are added). */
|
||||
EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
@@ -509,6 +538,35 @@ static void test_daemon_module_name_valid() {
|
||||
}
|
||||
}
|
||||
|
||||
static void test_daemon_conf_module_count_capped() {
|
||||
size_t cap = DAEMON_CONF_MAX_MODULES;
|
||||
size_t len = (cap + 8) * 32;
|
||||
char* body = malloc(len);
|
||||
EXPECT_NOT_NULL(body);
|
||||
size_t used = 0;
|
||||
body[0] = '\0';
|
||||
for (size_t i = 0; i < cap + 1; i++) {
|
||||
char line[48];
|
||||
int n = snprintf(line, sizeof(line), "[m%zu]\npath = /x\n", i);
|
||||
if (n < 0 || (size_t)n >= sizeof(line) || used + (size_t)n >= len) {
|
||||
free(body);
|
||||
EXPECT_FAIL("module-count test buffer overflow");
|
||||
return;
|
||||
}
|
||||
memcpy(body + used, line, (size_t)n);
|
||||
used += (size_t)n;
|
||||
body[used] = '\0';
|
||||
}
|
||||
char* path;
|
||||
EXPECT_EQ_INT(write_conf(body, &path), 0);
|
||||
free(body);
|
||||
char err[256];
|
||||
const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "too many modules") != NULL);
|
||||
}
|
||||
|
||||
void test_daemon_conf() {
|
||||
test_daemon_conf_create_defaults();
|
||||
test_daemon_conf_full_parse();
|
||||
@@ -525,6 +583,7 @@ void test_daemon_conf() {
|
||||
test_daemon_conf_dparam_override();
|
||||
test_daemon_conf_auth_users_validated();
|
||||
test_daemon_conf_limits_and_hosts_parse();
|
||||
test_daemon_conf_module_count_capped();
|
||||
test_daemon_hosts_allowed();
|
||||
test_daemon_module_name_valid();
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
#include "test_daemon_limits.h"
|
||||
#include "daemon_limits.h"
|
||||
#include "test_utils.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* 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);
|
||||
daemon_limits_recompute(registry);
|
||||
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);
|
||||
daemon_limits_recompute(registry);
|
||||
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);
|
||||
daemon_limits_recompute(registry);
|
||||
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);
|
||||
daemon_limits_recompute(registry);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), DAEMON_LIMIT_OK);
|
||||
daemon_limits_destroy(registry);
|
||||
}
|
||||
|
||||
/* Cross-process auth lockout: failures recorded by forked children against the
|
||||
* shared mmap must lock the source out for the parent. This is the
|
||||
* cross-process path the integration test can no longer cover because trusted
|
||||
* loopback peers are exempt from the per-host limits. */
|
||||
static void test_daemon_limits_fork_auth_lockout() {
|
||||
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, 2, 300);
|
||||
EXPECT_NOT_NULL(registry);
|
||||
|
||||
int remaining = 0;
|
||||
EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining));
|
||||
|
||||
/* One failure from each of two children reaches the threshold of 2 in the
|
||||
* shared mapping; atomics only, no mtx/malloc, so fork-safe. */
|
||||
for (int i = 0; i < 2; i++) {
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
daemon_limits_auth_record_failure(registry, "10.0.0.1");
|
||||
_exit(0);
|
||||
}
|
||||
EXPECT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
EXPECT_TRUE(waitpid(pid, &status, 0) == pid);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
|
||||
/* The parent observes the lockout the children established. */
|
||||
EXPECT_TRUE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining));
|
||||
EXPECT_TRUE(remaining > 0 && remaining <= 300);
|
||||
/* A different source is unaffected across processes. */
|
||||
EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.2", &remaining));
|
||||
/* The parent clears the shared lockout on a successful authentication. */
|
||||
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);
|
||||
}
|
||||
|
||||
/* The occupancy arrays are derived from the slot table: recompute rebuilds them
|
||||
* and is the self-heal path the SIGCHLD handler uses after a child dies. */
|
||||
static void test_daemon_limits_recompute() {
|
||||
DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 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.2", 0), DAEMON_LIMIT_OK);
|
||||
|
||||
/* Recompute is idempotent and re-derives the same counts from REGISTERED
|
||||
* slots (a CLAIMED slot is never counted). */
|
||||
daemon_limits_recompute(registry);
|
||||
daemon_limits_recompute(registry);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2),
|
||||
DAEMON_LIMIT_MODULE_FULL);
|
||||
|
||||
/* Freeing a slot and recomputing releases its module/per-source count. */
|
||||
daemon_limits_reclaim_slot(registry, slot0);
|
||||
daemon_limits_recompute(registry);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2), DAEMON_LIMIT_OK);
|
||||
daemon_limits_destroy(registry);
|
||||
}
|
||||
|
||||
/* The per-source table has a bounded lifetime. When every bucket is occupied
|
||||
* but not yet reclaimable, a new source is fail-open: the per-host cap is not
|
||||
* enforced and the probe must terminate. Once the occupied buckets' lockouts
|
||||
* expire (or they go idle), a new source reclaims a bucket and enforcement comes
|
||||
* back. This covers the "table never evicts -> cap silently fails open forever"
|
||||
* review finding. */
|
||||
static void test_daemon_limits_host_table_eviction() {
|
||||
char ip[32];
|
||||
|
||||
/* Part A: all buckets locked out with a long deadline and no active
|
||||
* connection are not reclaimable yet. A new source cannot be interned, so the
|
||||
* per-host cap is documented fail-open (both connections admitted) -- and the
|
||||
* bounded probe returns instead of looping forever. */
|
||||
DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 1, 300);
|
||||
EXPECT_NOT_NULL(registry);
|
||||
for (int i = 0; i < 64; i++) {
|
||||
snprintf(ip, sizeof(ip), "10.0.0.%d", i + 1);
|
||||
daemon_limits_auth_record_failure(registry, ip);
|
||||
}
|
||||
int a = daemon_limits_claim_slot(registry);
|
||||
int b = daemon_limits_claim_slot(registry);
|
||||
EXPECT_TRUE(a >= 0 && b >= 0);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, a, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, b, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK);
|
||||
daemon_limits_destroy(registry);
|
||||
|
||||
/* Part B: with an already-expired lockout every bucket is reclaimable, so a
|
||||
* new source reclaims one and the per-host cap is enforced again. */
|
||||
registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 1, 1);
|
||||
EXPECT_NOT_NULL(registry);
|
||||
for (int i = 0; i < 64; i++) {
|
||||
snprintf(ip, sizeof(ip), "10.0.0.%d", i + 1);
|
||||
daemon_limits_auth_record_failure(registry, ip);
|
||||
}
|
||||
struct timespec pause = {2, 0};
|
||||
nanosleep(&pause, NULL);
|
||||
int c = daemon_limits_claim_slot(registry);
|
||||
int d = daemon_limits_claim_slot(registry);
|
||||
EXPECT_TRUE(c >= 0 && d >= 0);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, c, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK);
|
||||
EXPECT_EQ_INT(daemon_limits_register(registry, d, 0, "10.9.9.9", 0), DAEMON_LIMIT_HOST_FULL);
|
||||
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_recompute();
|
||||
test_daemon_limits_auth_lockout();
|
||||
test_daemon_limits_host_table_eviction();
|
||||
test_daemon_limits_fork_shared();
|
||||
test_daemon_limits_fork_auth_lockout();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_DAEMON_LIMITS_H
|
||||
#define TEST_DAEMON_LIMITS_H
|
||||
|
||||
void test_daemon_limits();
|
||||
|
||||
#endif
|
||||
@@ -112,4 +112,12 @@ extern bool current_test_failed;
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* Unconditional test failure carrying an explanatory message. */
|
||||
#define EXPECT_FAIL(message) \
|
||||
do { \
|
||||
printf(" \033[1;31m[FAIL]\033[0m %s:%d: %s\n", __FILE__, __LINE__, (message)); \
|
||||
current_test_failed = true; \
|
||||
return; \
|
||||
} while (0)
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user