Merge branch 'fix/w8-daemonlim' into fix/w8-integration
This commit is contained in:
+125
-7
@@ -2,6 +2,7 @@
|
||||
#include "charset.h"
|
||||
#include "credentials.h"
|
||||
#include "daemon_conf.h"
|
||||
#include "daemon_limits.h"
|
||||
#include "delay_updates.h"
|
||||
#include "file.h"
|
||||
#include "identity.h"
|
||||
@@ -56,6 +57,12 @@ static DaemonConf* g_daemon_conf = NULL;
|
||||
* such a module exists. */
|
||||
static CredentialStore* g_credentials = NULL;
|
||||
|
||||
/* Cross-process connection registry (per-module and per-source caps plus the
|
||||
* shared auth lockout), created once in main BEFORE the accept loop forks and
|
||||
* shared read-only-by-pointer with every connection child. NULL outside daemon
|
||||
* mode or when the mapping could not be allocated (global cap + ACLs remain). */
|
||||
static DaemonLimitRegistry* g_daemon_limits = NULL;
|
||||
|
||||
/* Opaque context threaded through to the config-frame gate: the connection's
|
||||
* SSL object (NULL over plaintext) so the gate can warn when a credential
|
||||
* exchange is not encrypted, plus the super-mode override the gate decides on.
|
||||
@@ -75,6 +82,13 @@ typedef struct ModuleGateContext {
|
||||
* not classify the peer; an ACL-configured module then fails closed. */
|
||||
bool has_peer_ip;
|
||||
char peer_ip[INET6_ADDRSTRLEN];
|
||||
/* True when the peer is provably loopback (utils_fd_peer_is_local, fail
|
||||
* closed). A trusted local/SSH peer is exempt from the per-host cap and the
|
||||
* cross-process auth lockout: every loopback client shares the 127.0.0.1
|
||||
* identity, so counting/locking them out would let one local client deny
|
||||
* service to (or leak lockout state about) all the others. The per-module and
|
||||
* global caps still apply. */
|
||||
bool is_local;
|
||||
} ModuleGateContext;
|
||||
|
||||
/* Server half of the SCRAM challenge/response (A7 remediation, protocol
|
||||
@@ -292,6 +306,60 @@ static const DaemonModule* module_gate_lookup_module(const Config* config, const
|
||||
return module;
|
||||
}
|
||||
|
||||
/* Index of `module` within the loaded config's module array (the registry's
|
||||
* per-module counter key). Returns -1 when it cannot be resolved. */
|
||||
static int daemon_module_index(const DaemonModule* module) {
|
||||
if (!g_daemon_conf || !module || module < g_daemon_conf->modules ||
|
||||
module >= g_daemon_conf->modules + g_daemon_conf->module_count)
|
||||
return -1;
|
||||
return (int)(module - g_daemon_conf->modules);
|
||||
}
|
||||
|
||||
/* Shared-registry admission: reserve this connection's slot for the selected
|
||||
* module and the peer source IP. Enforces the per-module `max connections` and
|
||||
* the global `max connections per host` across every forked child. Runs before
|
||||
* auth/ownership so a client that is over a cap is refused before any work.
|
||||
* The per-source cap is skipped when the peer cannot be classified (host ACLs
|
||||
* fail closed separately); the module cap still applies. A missing registry
|
||||
* (allocation failure / non-fork path) fails open -- the global cap and ACLs
|
||||
* still bound the listener. */
|
||||
static const char* module_gate_check_limits(const Config* config, const DaemonModule* module,
|
||||
ModuleGateContext* gate_ctx) {
|
||||
if (!g_daemon_limits)
|
||||
return NULL;
|
||||
int slot = transport_tcp_current_slot();
|
||||
if (slot < 0)
|
||||
return NULL; /* not on the forked accept-loop path (e.g. --stdio) */
|
||||
int module_index = daemon_module_index(module);
|
||||
if (module_index < 0)
|
||||
return NULL;
|
||||
/* A trusted loopback peer is exempt from the per-source cap: pass an
|
||||
* unparseable peer so the registry skips per-source tracking, while the
|
||||
* per-module cap below is still enforced. Remote peers are tracked normally. */
|
||||
const char* peer =
|
||||
(!gate_ctx || gate_ctx->is_local || !gate_ctx->has_peer_ip) ? "" : gate_ctx->peer_ip;
|
||||
DaemonLimitResult result =
|
||||
daemon_limits_register(g_daemon_limits, slot, module_index, peer, module->max_connections);
|
||||
switch (result) {
|
||||
case DAEMON_LIMIT_OK:
|
||||
return NULL;
|
||||
case DAEMON_LIMIT_MODULE_FULL:
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon module '%s': 'max connections' cap (%d) reached; refusing %s",
|
||||
config->module, module->max_connections, peer[0] ? peer : "peer");
|
||||
return "requested daemon module is at its connection limit";
|
||||
case DAEMON_LIMIT_HOST_FULL:
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon: 'max connections per host' cap (%d) reached for %s; refusing module '%s'",
|
||||
g_daemon_conf->global.max_connections_per_host, peer[0] ? peer : "peer",
|
||||
config->module);
|
||||
return "too many concurrent connections from this host";
|
||||
case DAEMON_LIMIT_UNAVAILABLE:
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-module client-chosen ownership / super-user policy (P7 Wave E hardening):
|
||||
* a daemon module refuses EVERY ownership-affecting request (--numeric-ids,
|
||||
* --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super)
|
||||
@@ -396,6 +464,22 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae
|
||||
ModuleGateContext* gate_ctx, const char** error) {
|
||||
if (module->auth_user_count == 0)
|
||||
return MODULE_AUTH_ACCEPTED;
|
||||
/* Cross-process lockout: a source that failed too many authentications is
|
||||
* refused before the challenge is sent (the counter lives in the shared
|
||||
* registry, so it spans every forked child and survives a child exit). A
|
||||
* trusted loopback peer is exempt: all local clients share the 127.0.0.1
|
||||
* identity, so a lockout would let one deny the others. */
|
||||
if (g_daemon_limits && gate_ctx && gate_ctx->has_peer_ip && !gate_ctx->is_local) {
|
||||
int remaining = 0;
|
||||
if (daemon_limits_auth_locked(g_daemon_limits, gate_ctx->peer_ip, &remaining)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon module '%s': source %s is locked out after repeated authentication "
|
||||
"failures (%d s remaining); refusing",
|
||||
config->module, gate_ctx->peer_ip, remaining);
|
||||
*error = "too many failed authentication attempts from this host; try again later";
|
||||
return MODULE_AUTH_REFUSED;
|
||||
}
|
||||
}
|
||||
/* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR). */
|
||||
if (g_credentials == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
@@ -450,10 +534,17 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae
|
||||
"daemon module '%s': authentication failed for user '%s' from %s; refusing",
|
||||
config->module, escaped_user ? escaped_user : "(none)", peer);
|
||||
free(escaped_user);
|
||||
/* Rate-limit online guessing per connection (no delay on success). */
|
||||
/* Count the failure in the shared registry (locks the source out once the
|
||||
* configured threshold is reached) and rate-limit online guessing per
|
||||
* connection (no delay on success). A loopback peer is exempt from the
|
||||
* shared counter. */
|
||||
if (g_daemon_limits && gate_ctx->has_peer_ip && !gate_ctx->is_local)
|
||||
daemon_limits_auth_record_failure(g_daemon_limits, gate_ctx->peer_ip);
|
||||
daemon_auth_failure_delay();
|
||||
return MODULE_AUTH_TERMINATED;
|
||||
}
|
||||
if (g_daemon_limits && gate_ctx->has_peer_ip && !gate_ctx->is_local)
|
||||
daemon_limits_auth_record_success(g_daemon_limits, gate_ctx->peer_ip);
|
||||
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module,
|
||||
escaped_user ? escaped_user : "<allocation failed>",
|
||||
@@ -559,8 +650,14 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
utils_fd_peer_ip(gate_ctx->fd, gate_ctx->peer_ip, sizeof(gate_ctx->peer_ip));
|
||||
if (!gate_ctx->has_peer_ip)
|
||||
log_message(LOG_LEVEL_DEBUG, "daemon module '%s': peer address unavailable", config->module);
|
||||
/* utils_fd_peer_is_local is fail-closed (getpeername must succeed and report
|
||||
* a loopback peer), so "cannot tell" is never treated as trusted. */
|
||||
gate_ctx->is_local = utils_fd_peer_is_local(gate_ctx->fd);
|
||||
}
|
||||
error = module_gate_check_hosts(config, module, gate_ctx);
|
||||
if (error)
|
||||
return error;
|
||||
error = module_gate_check_limits(config, module, gate_ctx);
|
||||
if (error)
|
||||
return error;
|
||||
error = module_gate_check_ownership(config, module, gate_ctx);
|
||||
@@ -590,6 +687,7 @@ void handler(int file_descriptor) {
|
||||
gate_ctx.super_mode_override = -1;
|
||||
gate_ctx.has_peer_ip = false;
|
||||
gate_ctx.peer_ip[0] = '\0';
|
||||
gate_ctx.is_local = false;
|
||||
/* All teardown state starts empty so the single `done` epilogue is safe to
|
||||
* reach from any error path (including before the config frame arrives). */
|
||||
Config* config = NULL;
|
||||
@@ -880,7 +978,9 @@ static void print_server_usage(void) {
|
||||
printf(" fastsyncd.conf, else /etc/fastsyncd.conf)\n");
|
||||
printf(" --dparam=KEY=VALUE Override one global config key on the command line\n");
|
||||
printf(" (port, motd file, address, max connections,\n");
|
||||
printf(" auth failure delay, hosts allow, hosts deny)\n");
|
||||
printf(" max connections per host, auth failure delay,\n");
|
||||
printf(" auth lockout threshold, auth lockout duration,\n");
|
||||
printf(" hosts allow, hosts deny)\n");
|
||||
printf(" --no-detach Stay in the foreground (default detaches to\n");
|
||||
printf(" background when running --daemon)\n");
|
||||
printf(" --password-file=FILE Credential store for modules that declare\n");
|
||||
@@ -1096,11 +1196,10 @@ int main(int argc, char* argv[]) {
|
||||
"unless the module is intentionally open to the network",
|
||||
g_daemon_conf->modules[i].name);
|
||||
if (g_daemon_conf->modules[i].max_connections > 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': per-module 'max connections' is stored but not enforced "
|
||||
"per module; the global 'max connections' cap (%d) applies to the whole "
|
||||
"listener",
|
||||
g_daemon_conf->modules[i].name, g_daemon_conf->global.max_connections);
|
||||
log_message(LOG_LEVEL_INFO,
|
||||
"daemon module '%s': per-module 'max connections' cap = %d (enforced "
|
||||
"across all connection children)",
|
||||
g_daemon_conf->modules[i].name, g_daemon_conf->modules[i].max_connections);
|
||||
}
|
||||
/* Daemon credential store (Wave B). --password-file and --early-input
|
||||
* feed the same store, loaded BEFORE the listener forks so every
|
||||
@@ -1144,6 +1243,21 @@ int main(int argc, char* argv[]) {
|
||||
module->name, module->auth_users[j]);
|
||||
}
|
||||
}
|
||||
/* Shared cross-process registry for the per-module / per-source caps and
|
||||
* the auth lockout. Created HERE in the parent before any accept-loop
|
||||
* fork; every connection child inherits the mapping. A failure degrades to
|
||||
* "registry disabled" (the global cap and host ACLs still apply) rather
|
||||
* than refusing to start. */
|
||||
g_daemon_limits = daemon_limits_create((int)g_daemon_conf->global.max_connections,
|
||||
g_daemon_conf->module_count,
|
||||
g_daemon_conf->global.max_connections_per_host,
|
||||
g_daemon_conf->global.auth_lockout_threshold,
|
||||
g_daemon_conf->global.auth_lockout_duration_sec);
|
||||
if (!g_daemon_limits)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon: could not allocate the shared connection registry; per-module / "
|
||||
"per-host caps and the cross-process auth lockout are disabled (the global "
|
||||
"'max connections' cap and host ACLs still apply)");
|
||||
} else {
|
||||
if (!configure_authorization(opts.destination_root)) {
|
||||
char* escaped = output_escape(opts.destination_root, false);
|
||||
@@ -1167,6 +1281,8 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
if (g_daemon_conf)
|
||||
server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections);
|
||||
if (g_daemon_limits)
|
||||
server_set_limit_registry(g_server, g_daemon_limits);
|
||||
if (opts.use_tls) {
|
||||
if (!opts.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) {
|
||||
fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n");
|
||||
@@ -1206,6 +1322,8 @@ int main(int argc, char* argv[]) {
|
||||
release_authorization();
|
||||
|
||||
out:
|
||||
daemon_limits_destroy(g_daemon_limits);
|
||||
g_daemon_limits = NULL;
|
||||
daemon_conf_free(g_daemon_conf);
|
||||
g_daemon_conf = NULL;
|
||||
credentials_free(g_credentials);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+41
-15
@@ -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`
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
#include "daemon_limits.h"
|
||||
#include "daemon_conf.h"
|
||||
#include "log.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <time.h>
|
||||
|
||||
/* The two module-count bounds must agree: the daemon config parser never
|
||||
* produces more than DAEMON_CONF_MAX_MODULES modules, so the shared registry's
|
||||
* per-module counter array is sized from the same bound. */
|
||||
_Static_assert(DAEMON_LIMITS_MAX_MODULES == DAEMON_CONF_MAX_MODULES,
|
||||
"daemon_limits module bound must match daemon_conf");
|
||||
|
||||
/* 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 long long host_full_warn; /* last "table full" warning epoch */
|
||||
_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 */
|
||||
_Atomic long long* host_last_use; /* epoch seconds the bucket was last touched */
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* True when the registry must maintain per-source buckets: either the per-host
|
||||
* cap is configured, or the auth lockout is (threshold AND duration > 0). A
|
||||
* lockout threshold without a duration is a no-op, so it must not size or intern
|
||||
* the table. create(), register() and the lockout paths all agree on this. */
|
||||
static bool registry_tracks_hosts(const DaemonLimitRegistry* registry) {
|
||||
return registry->per_host_cap > 0 ||
|
||||
(registry->lockout_threshold > 0 && registry->lockout_duration_sec > 0);
|
||||
}
|
||||
|
||||
/* Find the bucket holding `peer_ip`, or -1 when it has no entry. Finding a
|
||||
* bucket refreshes its last-use time so the eviction policy sees it as live. */
|
||||
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) {
|
||||
atomic_store_explicit(®istry->host_last_use[idx], (long long)time(NULL),
|
||||
memory_order_relaxed);
|
||||
return (int)idx;
|
||||
}
|
||||
if (current == 0)
|
||||
return -1; /* no tombstones: an empty bucket ends the probe chain */
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* A bucket with no live connection may be repurposed: immediately when its
|
||||
* lockout deadline has already passed (the review's "expired" case), or after an
|
||||
* idle window when it holds no pending lockout. A bucket with a future lockout
|
||||
* deadline is retained so the lockout actually lasts its configured duration. */
|
||||
static bool host_bucket_reclaimable(DaemonLimitRegistry* registry, size_t idx, long long now) {
|
||||
if (atomic_load_explicit(®istry->host_active[idx], memory_order_relaxed) != 0)
|
||||
return false;
|
||||
long long until = atomic_load_explicit(®istry->host_until[idx], memory_order_relaxed);
|
||||
if (until != 0)
|
||||
return until <= now;
|
||||
long long last_use = atomic_load_explicit(®istry->host_last_use[idx], memory_order_relaxed);
|
||||
/* A bucket whose key is published but whose last_use has not yet been stamped
|
||||
* (last_use == 0) must be treated as live: reclaiming it here would steal a
|
||||
* bucket a racing child just claimed. The claim path also stamps last_use
|
||||
* before publishing the key, so this window cannot persist. */
|
||||
return last_use != 0 && now - last_use >= DAEMON_LIMITS_HOST_EVICT_IDLE_SEC;
|
||||
}
|
||||
|
||||
/* Emit at most one "per-source table full" warning per
|
||||
* DAEMON_LIMITS_HOST_FULL_WARN_SEC across all forked children. Called from a
|
||||
* normal (non-signal) child path, so logging is safe here. */
|
||||
static void host_warn_table_full(DaemonLimitRegistry* registry, long long now) {
|
||||
long long last = atomic_load_explicit(®istry->host_full_warn, memory_order_relaxed);
|
||||
if (last != 0 && now - last < DAEMON_LIMITS_HOST_FULL_WARN_SEC)
|
||||
return;
|
||||
if (atomic_compare_exchange_strong_explicit(®istry->host_full_warn, &last, now,
|
||||
memory_order_relaxed, memory_order_relaxed)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon: per-source registry is full (%d slots) and no bucket can be reclaimed; "
|
||||
"'max connections per host' and the auth lockout are temporarily not enforced for "
|
||||
"new sources (the per-module cap and host ACLs still apply)",
|
||||
registry->host_slots);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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.
|
||||
*
|
||||
* When the probe finds no empty bucket it reclaims, via a key CAS, the first
|
||||
* bucket that is reclaimable (expired lockout or idle, and no active
|
||||
* connection) and resets its counters. This bounds the table's lifetime so it
|
||||
* cannot fill permanently and stay fail-open. Returns -1 only when the address
|
||||
* is unparseable or the table is genuinely full of live/locked buckets
|
||||
* (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;
|
||||
long long now = (long long)time(NULL);
|
||||
size_t mask = (size_t)registry->host_slots - 1;
|
||||
size_t start = (size_t)(key & mask);
|
||||
/* A couple of passes bound the work: the first normally claims/seeds a bucket;
|
||||
* a lost eviction CAS retries once against the freshly observed table. */
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
int evict = -1;
|
||||
uint64_t evict_key = 0;
|
||||
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) {
|
||||
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||
return (int)idx;
|
||||
}
|
||||
if (current == 0) {
|
||||
/* Stamp last_use *before* publishing the key so a reclaimer racing the
|
||||
* claim can never observe a claimed bucket with last_use == 0 and
|
||||
* evict it. A pre-stamp is harmless if the CAS loses: the bucket is
|
||||
* either still empty (never inspected for reclaim) or has just been
|
||||
* taken by another source that wants a fresh timestamp anyway. */
|
||||
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||
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;
|
||||
}
|
||||
continue; /* another child won this empty bucket; keep probing */
|
||||
}
|
||||
if (evict < 0 && host_bucket_reclaimable(registry, idx, now)) {
|
||||
evict = (int)idx;
|
||||
evict_key = current;
|
||||
}
|
||||
}
|
||||
if (evict >= 0) {
|
||||
/* Refresh the timestamp before the key changes hands so the reused bucket
|
||||
* is not seen as immediately idle by a racing reclaimer. */
|
||||
atomic_store_explicit(®istry->host_last_use[evict], now, memory_order_relaxed);
|
||||
uint64_t expected = evict_key;
|
||||
if (atomic_compare_exchange_strong_explicit(®istry->host_key[evict], &expected, key,
|
||||
memory_order_acq_rel, memory_order_acquire)) {
|
||||
/* The bucket now belongs to the new source; clear the evicted source's
|
||||
* stale lockout/failure state. */
|
||||
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||
atomic_store_explicit(®istry->host_fail[evict], 0, memory_order_relaxed);
|
||||
atomic_store_explicit(®istry->host_until[evict], 0, memory_order_relaxed);
|
||||
/* Two children can race to intern the same brand-new key into different
|
||||
* eviction targets, leaving the table with duplicate buckets for `key`.
|
||||
* Re-scan for the first (canonical) bucket holding `key`; when it
|
||||
* precedes `evict`, drop our duplicate's occupancy and hand back the
|
||||
* canonical bucket so per-source counts are not orphaned on the
|
||||
* duplicate. The duplicate keeps its key, so no tombstone hole is
|
||||
* created and probe chains stay intact; it ages out normally. */
|
||||
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||
size_t candidate = (start + i) & mask;
|
||||
uint64_t found =
|
||||
atomic_load_explicit(®istry->host_key[candidate], memory_order_acquire);
|
||||
if (found == key) {
|
||||
if (candidate != (size_t)evict) {
|
||||
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||
return (int)candidate;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (found == 0)
|
||||
break; /* the key is present at `evict`, so this cannot happen first */
|
||||
}
|
||||
return evict;
|
||||
}
|
||||
continue; /* lost the race; re-probe with fresh observations */
|
||||
}
|
||||
break; /* no free and no reclaimable bucket: genuinely full */
|
||||
}
|
||||
host_warn_table_full(registry, now);
|
||||
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 (module_count > DAEMON_LIMITS_MAX_MODULES)
|
||||
module_count = DAEMON_LIMITS_MAX_MODULES;
|
||||
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) * 2;
|
||||
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;
|
||||
cursor += (size_t)host_slots * sizeof(_Atomic long long);
|
||||
registry->host_last_use = (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;
|
||||
atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel);
|
||||
atomic_store_explicit(®istry->slot_pid[slot], 0, memory_order_relaxed);
|
||||
/* The module/host occupancy arrays are derived from the slot table; do not
|
||||
* decrement here or a SIGKILL between a child's increment and its REGISTERED
|
||||
* publish would leak a count. Callers that need the derived counts call
|
||||
* daemon_limits_recompute. */
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void daemon_limits_recompute(DaemonLimitRegistry* registry) {
|
||||
if (!registry)
|
||||
return;
|
||||
/* Zero the derived arrays, then re-derive solely from the REGISTERED slots.
|
||||
* A child that was SIGKILLed after incrementing a counter but before
|
||||
* publishing REGISTERED is not counted, and its leaked increment is erased by
|
||||
* the zeroing, so the leak cannot persist. */
|
||||
for (int m = 0; m < registry->module_count; m++)
|
||||
atomic_store_explicit(®istry->module_active[m], 0, memory_order_relaxed);
|
||||
for (int h = 0; h < registry->host_slots; h++)
|
||||
atomic_store_explicit(®istry->host_active[h], 0, memory_order_relaxed);
|
||||
for (int i = 0; i < registry->max_slots; i++) {
|
||||
if (atomic_load_explicit(®istry->slot_state[i], memory_order_acquire) != SLOT_REGISTERED)
|
||||
continue;
|
||||
int module = atomic_load_explicit(®istry->slot_module[i], memory_order_relaxed);
|
||||
if (module >= 0 && module < registry->module_count)
|
||||
atomic_fetch_add_explicit(®istry->module_active[module], 1, memory_order_relaxed);
|
||||
int host = atomic_load_explicit(®istry->slot_host[i], memory_order_relaxed);
|
||||
if (host >= 0 && host < registry->host_slots)
|
||||
atomic_fetch_add_explicit(®istry->host_active[host], 1, memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
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_tracks_hosts(registry))
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
#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.
|
||||
*
|
||||
* Per-source table lifetime: a bucket's key is never cleared back to empty (that
|
||||
* would break every later probe chain that passed through it). Instead the
|
||||
* table has a bounded-lifetime eviction policy: when no empty bucket exists, the
|
||||
* first bucket that is reclaimable -- no active connection AND (its lockout
|
||||
* deadline has passed OR it has been idle for
|
||||
* DAEMON_LIMITS_HOST_EVICT_IDLE_SEC) -- is atomically repurposed for the new
|
||||
* source via a CAS of its key, and its counters are reset. The table therefore
|
||||
* cannot fill permanently, and a full table degrades to fail-open for the
|
||||
* per-source cap/lockout of new sources (the per-module cap and host ACLs still
|
||||
* apply) instead of staying fail-open forever. A rate-limited warning is logged
|
||||
* on the fail-open path. The eviction race with a concurrent
|
||||
* registration/reclaim on the same bucket is benign: it can at worst lose one
|
||||
* source's counter (fail-open), never corrupt memory or the module caps.
|
||||
*/
|
||||
|
||||
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)
|
||||
/* Upper bound on `module_count`, matching daemon_conf.h's DAEMON_CONF_MAX_MODULES
|
||||
* (asserted in daemon_limits.c) so a caller can never size the per-module counter
|
||||
* array larger than the config parser can produce. */
|
||||
#define DAEMON_LIMITS_MAX_MODULES 256
|
||||
|
||||
/* Per-source table lifetime: a bucket with no active connection and no pending
|
||||
* lockout is reclaimable once it has been idle this long, so a flood of distinct
|
||||
* sources cannot pin the table full forever. A bucket whose lockout deadline
|
||||
* has passed is reclaimable immediately (independent of this idle window). */
|
||||
#define DAEMON_LIMITS_HOST_EVICT_IDLE_SEC 300
|
||||
/* Minimum spacing between "per-source table is full" warnings, so a table-full
|
||||
* attack cannot flood the log. */
|
||||
#define DAEMON_LIMITS_HOST_FULL_WARN_SEC 60
|
||||
|
||||
/* 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, DAEMON_LIMITS_MAX_MODULES]); `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. The slot becomes FREE; the module/per-source
|
||||
* occupancy arrays are DERIVED state and are only refreshed by
|
||||
* daemon_limits_recompute, which callers must invoke afterwards when they rely
|
||||
* on the derived counts (the SIGCHLD handler batches one recompute for the whole
|
||||
* reap). Idempotent. */
|
||||
void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot);
|
||||
/* Parent SIGCHLD side: release the slot owned by `pid` (no-op when not found).
|
||||
* Like reclaim_slot this does not touch the derived occupancy arrays; call
|
||||
* daemon_limits_recompute after a batch of releases. */
|
||||
void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid);
|
||||
|
||||
/* Parent side (async-signal-safe; atomics only, no malloc/log): rebuild
|
||||
* module_active[] / host_active[] from scratch by scanning the REGISTERED slots.
|
||||
* The slot table is the single source of truth, so this self-heals any
|
||||
* count leaked by a child that was SIGKILLed mid-registration (it zeroes the
|
||||
* arrays and re-derives them). Bounded by max_slots + host_slots. A
|
||||
* registration racing this call can be transiently undercounted until the next
|
||||
* recompute, which can only relax a cap briefly -- never corrupt memory. */
|
||||
void daemon_limits_recompute(DaemonLimitRegistry* registry);
|
||||
|
||||
/* 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. A NULL/empty or non-numeric `peer_ip` skips the per-source track (the
|
||||
* callers use that to exempt a trusted loopback peer from the per-host cap; the
|
||||
* per-module cap still applies). 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
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "transport_tcp.h"
|
||||
#include "daemon_limits.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
@@ -8,6 +9,7 @@
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -18,19 +20,45 @@
|
||||
|
||||
static volatile sig_atomic_t g_active_connections = 0;
|
||||
|
||||
/* Shared registry installed on the active server; the SIGCHLD handler needs a
|
||||
* file-scope pointer so it can reclaim the dead child's slot. Set once by
|
||||
* accept_loop before the fork loop (single-threaded parent). */
|
||||
static DaemonLimitRegistry* g_limit_registry = NULL;
|
||||
/* Slot reserved by the parent for the connection child currently being forked.
|
||||
* Written before fork(), read by the child (which inherits the value). */
|
||||
static int g_current_slot = DAEMON_LIMITS_NO_SLOT;
|
||||
|
||||
static void tcp_apply_socket_timeout(int fd);
|
||||
static void tcp_enable_nodelay_default(int fd, int family);
|
||||
|
||||
static void sigchld_handler(int sig) {
|
||||
(void)sig;
|
||||
int saved_errno = errno;
|
||||
while (waitpid(-1, NULL, WNOHANG) > 0) {
|
||||
pid_t pid;
|
||||
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
|
||||
if (g_active_connections > 0)
|
||||
g_active_connections--;
|
||||
daemon_limits_reclaim_pid(g_limit_registry, (long)pid);
|
||||
}
|
||||
/* Re-derive the occupancy counters once for the whole reap batch. The slot
|
||||
* table is the source of truth, so this self-heals any count leaked by a child
|
||||
* SIGKILLed mid-registration. Atomics only: async-signal-safe. */
|
||||
if (g_limit_registry)
|
||||
daemon_limits_recompute(g_limit_registry);
|
||||
errno = saved_errno;
|
||||
}
|
||||
|
||||
/* Reset a signal to its default action with sigaction (preferred over
|
||||
* signal(3), whose semantics are implementation-defined). Used in the forked
|
||||
* child before it can spawn any thread. */
|
||||
static void reset_signal_default(int sig) {
|
||||
struct sigaction action;
|
||||
memset(&action, 0, sizeof(action));
|
||||
action.sa_handler = SIG_DFL;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(sig, &action, NULL);
|
||||
}
|
||||
|
||||
/* Map a listen socket's address to its numeric port for logging, independent
|
||||
* of whether it is an IPv4 or IPv6 sockaddr. */
|
||||
static unsigned short server_address_port(const struct sockaddr_storage* addr) {
|
||||
@@ -108,6 +136,7 @@ Server* server_create_ex(int port, const ServerBindOptions* bind_opts) {
|
||||
server->ssl_ctx = NULL;
|
||||
server->max_connections = 100;
|
||||
server->active_connections = 0;
|
||||
server->limit_registry = NULL;
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -121,6 +150,15 @@ void server_set_max_connections(Server* server, unsigned int max_connections) {
|
||||
server->max_connections = max_connections;
|
||||
}
|
||||
|
||||
void server_set_limit_registry(Server* server, struct DaemonLimitRegistry* registry) {
|
||||
if (server)
|
||||
server->limit_registry = registry;
|
||||
}
|
||||
|
||||
int transport_tcp_current_slot(void) {
|
||||
return g_current_slot;
|
||||
}
|
||||
|
||||
void server_delete(Server** server) {
|
||||
if (server == NULL || *server == NULL)
|
||||
return;
|
||||
@@ -139,7 +177,17 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
log_perror("Could not listen on port!");
|
||||
return;
|
||||
}
|
||||
signal(SIGCHLD, sigchld_handler);
|
||||
/* SIGCHLD via sigaction (not signal(3)); SA_RESTART keeps accept(2) from
|
||||
* failing with EINTR, and SA_NOCLDSTOP only notifies on child exit. The
|
||||
* accept loop is single-threaded at this point, so installing here cannot race
|
||||
* a worker thread. */
|
||||
struct sigaction chld_action;
|
||||
memset(&chld_action, 0, sizeof(chld_action));
|
||||
chld_action.sa_handler = sigchld_handler;
|
||||
sigemptyset(&chld_action.sa_mask);
|
||||
chld_action.sa_flags = SA_RESTART | SA_NOCLDSTOP;
|
||||
sigaction(SIGCHLD, &chld_action, NULL);
|
||||
g_limit_registry = server->limit_registry;
|
||||
while (1) {
|
||||
struct sockaddr_storage client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
@@ -159,9 +207,37 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
int slot = DAEMON_LIMITS_NO_SLOT;
|
||||
if (server->limit_registry) {
|
||||
slot = daemon_limits_claim_slot(server->limit_registry);
|
||||
if (slot == DAEMON_LIMITS_NO_SLOT) {
|
||||
/* The global cap bounds live children, so this only happens when the
|
||||
* fixed registry is smaller than the configured cap; fail closed. */
|
||||
log_message(LOG_LEVEL_WARNING, "Connection registry slots exhausted (max %u), rejecting %s",
|
||||
server->max_connections, peer);
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log_message(LOG_LEVEL_INFO, "%s from %s", log_fmt, peer);
|
||||
g_current_slot = slot;
|
||||
/* Block SIGCHLD across fork() and the parent's pid publication: a child
|
||||
* that exits immediately must not be reaped before its slot records its
|
||||
* pid, which would leak the slot and its module/source counts. Use
|
||||
* pthread_sigmask rather than sigprocmask so the behavior is well defined
|
||||
* even if this process ever gains threads: the mask is per-thread, the fork
|
||||
* copies only the calling thread, and the child inherits this thread's
|
||||
* blocked mask until it restores `previous` below. No thread exists yet at
|
||||
* this point, and none is created before the mask is restored, so the
|
||||
* critical window is race-free. */
|
||||
sigset_t blocked;
|
||||
sigset_t previous;
|
||||
sigemptyset(&blocked);
|
||||
sigaddset(&blocked, SIGCHLD);
|
||||
pthread_sigmask(SIG_BLOCK, &blocked, &previous);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
pthread_sigmask(SIG_SETMASK, &previous, NULL);
|
||||
/* Connection children must not run the parent's global cleanup(): it
|
||||
* frees state (credentials / daemon conf) that the child's worker
|
||||
* threads may still be reading and closes fd numbers the child could
|
||||
@@ -169,15 +245,21 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
* terminates the child directly; SIGCHLD is reset too since a child
|
||||
* must never reap the parent's children. This runs before the child
|
||||
* spawns any thread, so it cannot race one. */
|
||||
signal(SIGINT, SIG_DFL);
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
signal(SIGCHLD, SIG_DFL);
|
||||
reset_signal_default(SIGINT);
|
||||
reset_signal_default(SIGTERM);
|
||||
reset_signal_default(SIGCHLD);
|
||||
close(server->file_descriptor);
|
||||
child_fn(fd, child_ctx);
|
||||
_exit(0);
|
||||
} else if (pid > 0) {
|
||||
g_active_connections++;
|
||||
if (server->limit_registry)
|
||||
daemon_limits_set_slot_pid(server->limit_registry, slot, (long)pid);
|
||||
} else if (server->limit_registry) {
|
||||
/* fork() failed: release the reservation so the slot is not leaked. */
|
||||
daemon_limits_reclaim_slot(server->limit_registry, slot);
|
||||
}
|
||||
pthread_sigmask(SIG_SETMASK, &previous, NULL);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
#include <stdbool.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* Cross-process daemon registry (daemon_limits.c). Only an opaque pointer is
|
||||
* stored here so the transport layer does not depend on daemon config. */
|
||||
struct DaemonLimitRegistry;
|
||||
|
||||
typedef struct Server {
|
||||
struct sockaddr_storage address;
|
||||
unsigned int address_length;
|
||||
@@ -14,6 +18,7 @@ typedef struct Server {
|
||||
void* ssl_ctx;
|
||||
unsigned int max_connections;
|
||||
volatile unsigned int active_connections;
|
||||
struct DaemonLimitRegistry* limit_registry;
|
||||
} Server;
|
||||
|
||||
typedef struct Client {
|
||||
@@ -48,6 +53,14 @@ Server* server_create(int port);
|
||||
/* Override the listener's connection cap (the global daemon `max connections`
|
||||
* value). A non-positive value is ignored so the default cap stands. */
|
||||
void server_set_max_connections(Server* server, unsigned int max_connections);
|
||||
/* Install the shared per-module / per-source registry used by the accept loop
|
||||
* to reserve a slot for each forked child. NULL disables the accounting (the
|
||||
* global cap and ACLs still apply). */
|
||||
void server_set_limit_registry(Server* server, struct DaemonLimitRegistry* registry);
|
||||
/* Slot reserved for the connection child currently running (set by the parent
|
||||
* before fork, inherited by the child). Returns DAEMON_LIMITS_NO_SLOT (-1)
|
||||
* outside the accept-loop child path. */
|
||||
int transport_tcp_current_slot(void);
|
||||
bool server_listen(Server* server, void (*handler)(int file_descriptor));
|
||||
void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx,
|
||||
const char* log_fmt);
|
||||
|
||||
Reference in New Issue
Block a user