feat(daemon): enforce per-module/per-host caps and shared auth lockout
Wire the shared registry into the accept loop (parent claims a slot before fork, blocks SIGCHLD across fork+pid publication, and reclaims the dead child's slot from the SIGCHLD handler so per-module/per-source counts are released even on SIGKILL). The connection child records the selected module and normalized peer IP once the config frame names them: an over-cap module or source is refused at the config gate with an audit log, and a source that exceeded the auth-failure threshold is refused before a SCRAM challenge (the counter is shared across children and cleared on success). The existing global cap and host ACLs are untouched.
This commit is contained in:
+107
-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.
|
||||
@@ -292,6 +299,56 @@ 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;
|
||||
const char* peer = (gate_ctx && 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 +453,20 @@ 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). */
|
||||
if (g_daemon_limits && gate_ctx && gate_ctx->has_peer_ip) {
|
||||
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 +521,16 @@ 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). */
|
||||
if (g_daemon_limits && gate_ctx->has_peer_ip)
|
||||
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)
|
||||
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>",
|
||||
@@ -561,6 +638,9 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
log_message(LOG_LEVEL_DEBUG, "daemon module '%s': peer address unavailable", config->module);
|
||||
}
|
||||
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);
|
||||
@@ -880,7 +960,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 +1178,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 +1225,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 +1263,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 +1304,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);
|
||||
|
||||
Reference in New Issue
Block a user