diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 01a759a..300e196 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -190,6 +190,26 @@ static bool store_max_connections(int* slot, const char* value, const char* modu return true; } +/* Parse a non-negative concurrency cap where 0 means unlimited/disabled + * (per-module `max connections`, `max connections per host`, + * `auth lockout threshold`). Negative/garbage/oversized values are rejected. */ +static bool store_optional_cap(int* slot, const char* value, int max_value, const char* key, + const char* module_name, char* err, size_t err_size) { + char* end = NULL; + errno = 0; + long n = strtol(value, &end, 10); + if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 || n > max_value) { + if (module_name) + set_error(err, err_size, "module '%s': invalid '%s' '%s' (must be 0-%d)", module_name, key, + value, max_value); + else + set_error(err, err_size, "invalid '%s' '%s' (must be 0-%d)", key, value, max_value); + return false; + } + *slot = (int)n; + return true; +} + /* Parse an `auth failure delay` value: 0 (disabled) through the configured cap. */ static bool store_auth_failure_delay(int* slot, const char* value, char* err, size_t err_size) { char* end = NULL; @@ -227,6 +247,9 @@ DaemonConf* daemon_conf_create(void) { conf->global.port = DAEMON_CONF_DEFAULT_PORT; conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS; conf->global.auth_failure_delay_ms = DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS; + conf->global.max_connections_per_host = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST; + conf->global.auth_lockout_threshold = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD; + conf->global.auth_lockout_duration_sec = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC; return conf; } @@ -312,8 +335,20 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo } if (key_equals(key, "max connections")) return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size); + if (key_equals(key, "max connections per host")) + return store_optional_cap(&conf->global.max_connections_per_host, value, + DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "max connections per host", NULL, + err, err_size); if (key_equals(key, "auth failure delay")) return store_auth_failure_delay(&conf->global.auth_failure_delay_ms, value, err, err_size); + if (key_equals(key, "auth lockout threshold")) + return store_optional_cap(&conf->global.auth_lockout_threshold, value, + DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "auth lockout threshold", NULL, + err, err_size); + if (key_equals(key, "auth lockout duration")) + return store_optional_cap(&conf->global.auth_lockout_duration_sec, value, + DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC, "auth lockout duration", + NULL, err, err_size); if (key_equals(key, "hosts allow")) return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value, "hosts allow", NULL, replace_hosts, err, err_size); @@ -400,7 +435,8 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char* return true; } if (key_equals(key, "max connections")) - return store_max_connections(&module->max_connections, value, module->name, err, err_size); + return store_optional_cap(&module->max_connections, value, DAEMON_CONF_MAX_CONCURRENCY_LIMIT, + "max connections", module->name, err, err_size); if (key_equals(key, "hosts allow")) return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow", false, module->name, err, err_size); @@ -444,6 +480,11 @@ static int open_module(DaemonConf* conf, int* current_module, const char* name, set_error(err, err_size, "duplicate module '%s'", name); return -1; } + if (conf->module_count >= DAEMON_CONF_MAX_MODULES) { + set_error(err, err_size, "too many modules (limit %d); module '%s' rejected", + DAEMON_CONF_MAX_MODULES, name); + return -1; + } DaemonModule* grown = realloc(conf->modules, (size_t)(conf->module_count + 1) * sizeof(DaemonModule)); if (!grown) { diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index 3b790a7..d04399e 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -52,11 +52,10 @@ typedef struct DaemonModule { activities. Without it the daemon refuses all of them. */ char** auth_users; /* `auth users = a,b`; Wave B credential list */ int auth_user_count; - /* `max connections = N` (optional per-module cap). 0 means "not set" - * (inherit the global cap). Parsed, stored, and validated, but NOT enforced - * per-module: connections are counted in the accept-loop parent before the - * client's module is known, so only the global cap is enforced (see - * transport_tcp.c and the Daemon Mode notes in RSYNC_COMPAT.md). */ + /* `max connections = N` (optional per-module cap). 0 means unlimited. The + * per-connection child records the selected module in the shared registry + * (daemon_limits.c) once the config frame names it, so the cap is enforced + * across all forked children; the parent reclaims the slot on SIGCHLD. */ int max_connections; char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */ int hosts_allow_count; @@ -67,14 +66,25 @@ typedef struct DaemonModule { /* Global (pre-module) scalar keys. `motd file` is parsed and stored but has * no wire effect yet (MOTD display is Wave C). */ typedef struct DaemonConfGlobals { - int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */ - char* motd_file; /* `motd file`, may be NULL */ - char* address; /* `address` (optional bind address), may be NULL */ - int max_connections; /* `max connections`, default - DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */ - int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default - DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */ - char** hosts_allow; /* `hosts allow`; global host access allow patterns */ + int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */ + char* motd_file; /* `motd file`, may be NULL */ + char* address; /* `address` (optional bind address), may be NULL */ + int max_connections; /* `max connections`, default + DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */ + int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default + DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */ + int max_connections_per_host; /* `max connections per host`, concurrent cap per + source IP; default + DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST (0 = + unlimited) */ + int auth_lockout_threshold; /* `auth lockout threshold`, failed attempts from + one source before lockout; default + DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD (0 + disables) */ + int auth_lockout_duration_sec; /* `auth lockout duration`, seconds; default + DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC + (0 disables) */ + char** hosts_allow; /* `hosts allow`; global host access allow patterns */ int hosts_allow_count; char** hosts_deny; /* `hosts deny`; global host access deny patterns */ int hosts_deny_count; @@ -92,11 +102,26 @@ typedef struct DaemonConf { #define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS 100 /* Default `auth failure delay` in milliseconds (0 disables the throttle). */ #define DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS 500 +/* Default `max connections per host` (0 = unlimited). */ +#define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST 0 +/* Default cross-process auth lockout: 10 failed attempts from one source lock + * it out for 300 s (0 disables either knob). */ +#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD 10 +#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC 300 +/* Upper bound on a `max connections per host` or `auth lockout threshold` + * value, so a typo cannot size the shared registry absurdly. */ +#define DAEMON_CONF_MAX_CONCURRENCY_LIMIT 1000000 +/* Upper bound on `auth lockout duration` (7 days). */ +#define DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC 604800 /* Largest accepted `auth failure delay`, so a typo cannot pin a connection * child in nanosleep for an absurd time. */ /* Bounded well below the socket I/O timeout so a failed-auth child cannot hold * a connection slot for long enough to amplify connection-cap exhaustion. */ #define DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS 5000 +/* Upper bound on the number of [module] sections, so the shared registry's + * per-module counter array stays fixed-size. The parser rejects the next + * section past this bound. */ +#define DAEMON_CONF_MAX_MODULES 256 /* Longest accepted config line (excluding the trailing newline). Longer lines * are rejected rather than buffered unboundedly. */ #define DAEMON_CONF_MAX_LINE 4096 @@ -129,8 +154,9 @@ bool daemon_module_name_valid(const char* name); /* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and * apply it to the global keys only. Keys are case-insensitive and limited to * the global keys defined by the grammar (port, motd file, address, - * max connections, auth failure delay, hosts allow, hosts deny). Returns 0 on - * success, -1 on error (err filled). */ + * max connections, max connections per host, auth failure delay, + * auth lockout threshold, auth lockout duration, hosts allow, hosts deny). + * Returns 0 on success, -1 on error (err filled). */ int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size); /* Host access-control matching (pure; no I/O). `daemon_host_pattern_match` diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index e0020c9..a9113a5 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -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,27 @@ 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); + body[0] = '\0'; + for (size_t i = 0; i < cap + 1; i++) { + char line[48]; + snprintf(line, sizeof(line), "[m%zu]\npath = /x\n", i); + strcat(body, line); + } + 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 +575,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(); } \ No newline at end of file