diff --git a/README.md b/README.md index 204a19d..d84146b 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,32 @@ defaults to the current directory. | | `-v`, `--verbose` | Enable debug logging. | | `--help` | Print server usage. | +### Daemon configuration + +`fastsync-server --daemon --config FILE` reads a line-based module config (an +implicit global section, then `[module]` sections). Besides `port`, `motd file`, +and `address`, the global section accepts: + +- `max connections = N` — cap on concurrent connections, default 100. The + listener enforces it; `0`, negative, and non-numeric values are parse errors. +- `auth failure delay = MS` — milliseconds to sleep after a failed + authentication, default 500. `0` disables it and the value is capped at 60000, + so online password guessing is rate-limited per connection. Successful auths + are never delayed. +- `hosts allow` / `hosts deny` — comma- and/or whitespace-separated host access + patterns. + +A `[module]` may also set `max connections` (parsed and validated but not +enforced per module — the global cap applies to the whole listener) and its own +`hosts allow`/`hosts deny`. + +Host patterns are `*` (match all), IPv4/IPv6 literals, IPv4/IPv6 CIDR +(`10.0.0.0/8`, `2001:db8::/32`), or hostname globs (`*.example.com`). A matching +`hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of +them is rejected; deny takes precedence over allow. The global list is checked +before the module list, before authentication, and the connecting peer address +(IPv4 or IPv6) appears in the connection and authentication audit log lines. + ## Architecture ### Client diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index ef5ead2..8cdeafb 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -627,7 +627,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved |------|-------------------|-----------------|-------| | `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding | | `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` | -| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` | +| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global keys the grammar defines (`port`, `motd file`, `address`, `max connections`, `auth failure delay`, `hosts allow`, `hosts deny`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` | | `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` | | `--password-file=FILE` | Read daemon password from file | ✅ Implemented | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat | | `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) | @@ -635,7 +635,9 @@ now transmits targets (the prior behavior was broken/partial); its status moved **Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding. -- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves. +- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address), `max connections` (positive integer cap on concurrent connections, default 100; 0/negative/garbage is a parse error), `auth failure delay` (milliseconds to sleep after a failed authentication, default 500; 0 disables, capped at 60000), `hosts allow` and `hosts deny` (comma- and/or whitespace-separated host access patterns — see the host access control note below). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list), `max connections` (optional per-module cap; parsed and stored but **not enforced** — the global cap applies to the whole listener), `hosts allow`/`hosts deny` (per-module host access lists). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves. +- **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple lines append). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`), and a simple glob (`*.example.com`; globs are matched case-insensitively against the peer string, so a numeric peer never matches a hostname glob). rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time. +- **Connection cap and auth throttle:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. The optional per-module `max connections` key is parsed and validated but **not enforced** (connections are counted in the parent before the client's module is known); the daemon logs a startup warning for any module that sets it. On a failed authentication the per-connection child sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 60000) via `nanosleep` before the connection closes, rate-limiting online guessing without delaying a success. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. diff --git a/src/server/server.c b/src/server/server.c index 842931b..92b65cf 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -23,8 +23,10 @@ #include #include #include +#include #include #include +#include #include static char* authorized_root; @@ -68,6 +70,11 @@ typedef struct ModuleGateContext { activity (operator --no-super, or a daemon module without the `client owner = yes` opt-in); -1 when the config's own mode stands. */ int super_mode_override; + /* Numeric peer address (INET6_ADDRSTRLEN is always enough), filled once by + * server_module_gate. has_peer_ip is false when getpeername/inet_ntop could + * not classify the peer; an ACL-configured module then fails closed. */ + bool has_peer_ip; + char peer_ip[INET6_ADDRSTRLEN]; } ModuleGateContext; /* Server half of the SCRAM challenge/response (A7 remediation, protocol @@ -320,6 +327,66 @@ static const char* module_gate_check_ownership(const Config* config, const Daemo return NULL; } +/* Online-guessing throttle: sleep the configured `auth failure delay` + * milliseconds after a failed authentication. Runs in the per-connection + * forked child, so it never blocks the accept loop or another connection. 0 + * disables it; the parser already caps it at DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS. + * Resumes after EINTR so a signal cannot cut the delay short. */ +static void daemon_auth_failure_delay(void) { + if (!g_daemon_conf || g_daemon_conf->global.auth_failure_delay_ms <= 0) + return; + int ms = g_daemon_conf->global.auth_failure_delay_ms; + struct timespec delay; + delay.tv_sec = ms / 1000; + delay.tv_nsec = (long)(ms % 1000) * 1000000L; + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) + ; +} + +/* Host access control (global then per-module). A configured list makes an + * unprovable peer fail closed. Deny always takes precedence over allow, and a + * non-empty allow list rejects a peer that matches none of its entries. The + * audit line names the peer, the module and the outcome. Returns an + * error string on refusal, NULL on acceptance. */ +static const char* module_gate_check_hosts(const Config* config, const DaemonModule* module, + ModuleGateContext* gate_ctx) { + bool global_restricted = daemon_hosts_restricted( + g_daemon_conf->global.hosts_allow, g_daemon_conf->global.hosts_allow_count, + g_daemon_conf->global.hosts_deny, g_daemon_conf->global.hosts_deny_count); + bool module_restricted = daemon_hosts_restricted(module->hosts_allow, module->hosts_allow_count, + module->hosts_deny, module->hosts_deny_count); + if (!global_restricted && !module_restricted) + return NULL; + if (!gate_ctx || !gate_ctx->has_peer_ip) { + log_message(LOG_LEVEL_WARNING, + "daemon module '%s': cannot determine peer address with host ACLs configured; " + "refusing (fail closed)", + config->module); + return "cannot verify the client host against host access controls"; + } + const char* peer = gate_ctx->peer_ip; + if (global_restricted && !daemon_hosts_allowed(peer, g_daemon_conf->global.hosts_allow, + g_daemon_conf->global.hosts_allow_count, + g_daemon_conf->global.hosts_deny, + g_daemon_conf->global.hosts_deny_count)) { + log_message(LOG_LEVEL_WARNING, + "daemon module '%s': peer %s denied by global 'hosts allow'/'hosts deny'; " + "refusing", + config->module, peer); + return "client host is not permitted by this daemon"; + } + if (module_restricted && + !daemon_hosts_allowed(peer, module->hosts_allow, module->hosts_allow_count, + module->hosts_deny, module->hosts_deny_count)) { + log_message(LOG_LEVEL_WARNING, + "daemon module '%s': peer %s denied by module 'hosts allow'/'hosts deny'; " + "refusing", + config->module, peer); + return "client host is not permitted by this daemon module"; + } + return NULL; +} + /* A7 auth gate: runs the SCRAM challenge/response for an auth-required module * BEFORE the module root is installed and before any data moves. Returns * MODULE_AUTH_ACCEPTED when the module needs no auth or the handshake succeeds, @@ -376,16 +443,21 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae * via MODULE_AUTH_TERMINATED; the username may be logged (never the password * or any derived proof). */ if (!server_auth_handshake(gate_ctx->fd, config, module)) { + const char* peer = gate_ctx->has_peer_ip ? gate_ctx->peer_ip : "unknown"; char* escaped_user = config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL; - log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'", - config->module, escaped_user ? escaped_user : "(none)"); + log_message(LOG_LEVEL_WARNING, + "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). */ + daemon_auth_failure_delay(); return MODULE_AUTH_TERMINATED; } char* escaped_user = output_escape(config->auth_user, config->eight_bit_output); - log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module, - escaped_user ? escaped_user : ""); + log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module, + escaped_user ? escaped_user : "", + gate_ctx->has_peer_ip ? gate_ctx->peer_ip : "unknown"); free(escaped_user); return MODULE_AUTH_ACCEPTED; } @@ -478,6 +550,19 @@ static const char* server_module_gate(const Config* config, void* context) { const DaemonModule* module = module_gate_lookup_module(config, &error); if (!module) return error; + /* Resolve the peer once, before any auth or ownership work, so the host ACL + * and the audit lines all use the same address. A module with ACLs fails + * closed when the peer cannot be classified; an ACL-free module continues + * (the accept loop still logged the address). */ + if (gate_ctx) { + gate_ctx->has_peer_ip = + 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); + } + error = module_gate_check_hosts(config, module, gate_ctx); + if (error) + return error; error = module_gate_check_ownership(config, module, gate_ctx); if (error) return error; @@ -503,6 +588,8 @@ void handler(int file_descriptor) { gate_ctx.ssl = ssl; gate_ctx.fd = file_descriptor; gate_ctx.super_mode_override = -1; + gate_ctx.has_peer_ip = false; + gate_ctx.peer_ip[0] = '\0'; /* 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; @@ -785,7 +872,8 @@ static void print_server_usage(void) { printf(" --config=FILE Daemon config file (default: ~/.config/fastsync/\n"); 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)\n"); + printf(" (port, motd file, address, max connections,\n"); + printf(" auth failure delay, 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"); @@ -1000,6 +1088,12 @@ int main(int argc, char* argv[]) { "and device nodes within that module root -- pair it with `auth users` " "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); } /* Daemon credential store (Wave B). --password-file and --early-input * feed the same store, loaded BEFORE the listener forks so every @@ -1064,6 +1158,8 @@ int main(int argc, char* argv[]) { exit_code = 1; goto out; } + if (g_daemon_conf) + server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections); 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"); diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 7a1c83d..10c0f63 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -1,9 +1,13 @@ #include "daemon_conf.h" #include "credentials.h" #include "utils.h" +#include #include #include +#include +#include #include +#include #include #include #include @@ -49,6 +53,144 @@ static bool parse_bool_value(const char* value, bool* out) { return false; } +/* Parse an IPv4/IPv6 CIDR "addr/prefix" into `bytes`/`*family`. Returns false + * for a malformed address, a missing/oversized prefix, or a prefix that does + * not fit the address family. */ +static bool parse_cidr(const char* cidr, int* prefix_out, uint8_t* bytes, int* family_out) { + const char* slash = strchr(cidr, '/'); + if (!slash) + return false; + size_t addr_len = (size_t)(slash - cidr); + if (addr_len == 0 || addr_len >= INET6_ADDRSTRLEN) + return false; + char addr[INET6_ADDRSTRLEN]; + memcpy(addr, cidr, addr_len); + addr[addr_len] = '\0'; + char* end = NULL; + long prefix = strtol(slash + 1, &end, 10); + if (end == slash + 1 || *end != '\0') + return false; + struct in_addr v4; + struct in6_addr v6; + if (inet_pton(AF_INET, addr, &v4) == 1) { + if (prefix < 0 || prefix > 32) + return false; + memcpy(bytes, &v4, sizeof(v4)); + *prefix_out = (int)prefix; + *family_out = AF_INET; + return true; + } + if (inet_pton(AF_INET6, addr, &v6) == 1) { + if (prefix < 0 || prefix > 128) + return false; + memcpy(bytes, &v6, sizeof(v6)); + *prefix_out = (int)prefix; + *family_out = AF_INET6; + return true; + } + return false; +} + +/* A host pattern is valid when it is non-empty and, when it contains a '/', its + * address/prefix halves parse as a CIDR. Literals, `*` and globs are accepted + * as-is (a glob only ever matches a peer of the same shape). */ +static bool host_pattern_valid(const char* pattern) { + if (!pattern || *pattern == '\0') + return false; + if (!strchr(pattern, '/')) + return true; + uint8_t bytes[16]; + int prefix; + int family; + return parse_cidr(pattern, &prefix, bytes, &family); +} + +/* Append every comma- and/or whitespace-separated host pattern in `value` to + * the heap-owned list. Returns false (err filled) on an invalid pattern or an + * allocation failure. */ +static bool store_host_list(char*** list, int* count, const char* value, const char* key, + const char* module_name, char* err, size_t err_size) { + char* copy = str_dup(value); + if (!copy) { + if (module_name) + set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name); + else + set_error(err, err_size, "out of memory parsing '%s'", key); + return false; + } + char* save = NULL; + for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) { + if (!host_pattern_valid(token)) { + if (module_name) + set_error(err, err_size, "module '%s': invalid host pattern '%s' in '%s'", module_name, + token, key); + else + set_error(err, err_size, "invalid host pattern '%s' in '%s'", token, key); + free(copy); + return false; + } + char** grown = realloc(*list, (size_t)(*count + 1) * sizeof(char*)); + if (!grown) { + if (module_name) + set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name); + else + set_error(err, err_size, "out of memory parsing '%s'", key); + free(copy); + return false; + } + *list = grown; + char* dup = str_dup(token); + if (!dup) { + if (module_name) + set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name); + else + set_error(err, err_size, "out of memory parsing '%s'", key); + free(copy); + return false; + } + (*list)[(*count)++] = dup; + } + free(copy); + return true; +} + +/* Parse a `max connections` value: a positive integer (0/negative/garbage are + * rejected because they would silently disable the cap or admit nothing). */ +static bool store_max_connections(int* slot, const char* value, 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 > INT_MAX) { + if (module_name) + set_error(err, err_size, + "module '%s': invalid 'max connections' '%s' (must be a positive " + "integer)", + module_name, value); + else + set_error(err, err_size, "invalid 'max connections' '%s' (must be a positive integer)", + 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; + errno = 0; + long n = strtol(value, &end, 10); + if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 || + n > DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS) { + set_error(err, err_size, "invalid 'auth failure delay' '%s' (must be 0-%d milliseconds)", value, + DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS); + return false; + } + *slot = (int)n; + return true; +} + bool daemon_module_name_valid(const char* name) { if (!name || *name == '\0') return false; @@ -69,14 +211,25 @@ DaemonConf* daemon_conf_create(void) { if (!conf) return NULL; 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; return conf; } +/* Free a heap-owned pattern list of `count` entries. */ +static void free_string_list(char** list, int count) { + for (int i = 0; i < count; i++) + free(list[i]); + free(list); +} + void daemon_conf_free(DaemonConf* conf) { if (!conf) return; free(conf->global.motd_file); free(conf->global.address); + free_string_list(conf->global.hosts_allow, conf->global.hosts_allow_count); + free_string_list(conf->global.hosts_deny, conf->global.hosts_deny_count); for (int i = 0; i < conf->module_count; i++) { DaemonModule* m = &conf->modules[i]; free(m->name); @@ -84,6 +237,8 @@ void daemon_conf_free(DaemonConf* conf) { for (int j = 0; j < m->auth_user_count; j++) free(m->auth_users[j]); free(m->auth_users); + free_string_list(m->hosts_allow, m->hosts_allow_count); + free_string_list(m->hosts_deny, m->hosts_deny_count); } free(conf->modules); free(conf); @@ -141,6 +296,16 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, cha } return true; } + if (key_equals(key, "max connections")) + return store_max_connections(&conf->global.max_connections, value, 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, "hosts allow")) + return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value, + "hosts allow", NULL, err, err_size); + if (key_equals(key, "hosts deny")) + return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value, + "hosts deny", NULL, err, err_size); set_error(err, err_size, "unknown global key '%s'", key); return false; } @@ -220,6 +385,14 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char* free(list); return true; } + if (key_equals(key, "max connections")) + return store_max_connections(&module->max_connections, value, 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", + module->name, err, err_size); + if (key_equals(key, "hosts deny")) + return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny", + module->name, err, err_size); set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name); return false; } @@ -459,3 +632,86 @@ int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err free(copy); return ok ? 0 : -1; } + +/* Compare the first `prefix` bits of two 16-byte address buffers. */ +static bool bit_prefix_match(const uint8_t* a, const uint8_t* b, int prefix) { + int whole = prefix / 8; + if (whole > 0 && memcmp(a, b, (size_t)whole) != 0) + return false; + int remainder = prefix % 8; + if (remainder == 0) + return true; + uint8_t mask = (uint8_t)(0xffu << (8 - remainder)); + return (a[whole] & mask) == (b[whole] & mask); +} + +/* Case-insensitive glob match used for hostname patterns. Falls back to the + * shared case-sensitive matcher when an operand is too long for the stack + * buffers. */ +static bool host_glob_match(const char* pattern, const char* str) { + char pbuf[256]; + char sbuf[256]; + size_t plen = strlen(pattern); + size_t slen = strlen(str); + if (plen >= sizeof(pbuf) || slen >= sizeof(sbuf)) + return glob_match(pattern, str); + for (size_t i = 0; i <= plen; i++) + pbuf[i] = (char)tolower((unsigned char)pattern[i]); + for (size_t i = 0; i <= slen; i++) + sbuf[i] = (char)tolower((unsigned char)str[i]); + return glob_match(pbuf, sbuf); +} + +bool daemon_host_pattern_match(const char* pattern, const char* peer_ip) { + if (!pattern || *pattern == '\0' || !peer_ip || *peer_ip == '\0') + return false; + if (strcmp(pattern, "*") == 0) + return true; + if (strchr(pattern, '/')) { + uint8_t pattern_bytes[16]; + uint8_t peer_bytes[16]; + int prefix = 0; + int family = AF_UNSPEC; + if (!parse_cidr(pattern, &prefix, pattern_bytes, &family)) + return false; + if (inet_pton(family, peer_ip, peer_bytes) != 1) + return false; + return bit_prefix_match(pattern_bytes, peer_bytes, prefix); + } + struct in_addr pattern_v4; + struct in_addr peer_v4; + if (inet_pton(AF_INET, pattern, &pattern_v4) == 1) + return inet_pton(AF_INET, peer_ip, &peer_v4) == 1 && pattern_v4.s_addr == peer_v4.s_addr; + struct in6_addr pattern_v6; + struct in6_addr peer_v6; + if (inet_pton(AF_INET6, pattern, &pattern_v6) == 1) + return inet_pton(AF_INET6, peer_ip, &peer_v6) == 1 && + memcmp(&pattern_v6, &peer_v6, sizeof(pattern_v6)) == 0; + /* Not a literal: a hostname/glob pattern. */ + return host_glob_match(pattern, peer_ip); +} + +bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count, + char* const* deny, int deny_count) { + if (!peer_ip) + return false; + for (int i = 0; i < deny_count; i++) { + if (daemon_host_pattern_match(deny[i], peer_ip)) + return false; + } + if (allow_count > 0) { + for (int i = 0; i < allow_count; i++) { + if (daemon_host_pattern_match(allow[i], peer_ip)) + return true; + } + return false; + } + return true; +} + +bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny, + int deny_count) { + (void)allow; + (void)deny; + return allow_count > 0 || deny_count > 0; +} diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index 0ecfe24..d82276b 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -52,14 +52,32 @@ 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). */ + int max_connections; + char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */ + int hosts_allow_count; + char** hosts_deny; /* `hosts deny = a,b`; host access deny patterns */ + int hosts_deny_count; } 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 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 hosts_allow_count; + char** hosts_deny; /* `hosts deny`; global host access deny patterns */ + int hosts_deny_count; } DaemonConfGlobals; typedef struct DaemonConf { @@ -69,6 +87,14 @@ typedef struct DaemonConf { } DaemonConf; #define DAEMON_CONF_DEFAULT_PORT 873 +/* Default global connection cap when `max connections` is absent. Matches the + * historical hardcoded listener value. */ +#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 +/* Largest accepted `auth failure delay`, so a typo cannot pin a connection + * child in nanosleep for an absurd time. */ +#define DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS 60000 /* Longest accepted config line (excluding the trailing newline). Longer lines * are rejected rather than buffered unboundedly. */ #define DAEMON_CONF_MAX_LINE 4096 @@ -99,9 +125,30 @@ const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* 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 scalars only. Keys are case-insensitive and limited - * to the global scalar keys defined by the grammar (port, motd file, address). - * Returns 0 on success, -1 on error (err filled). */ + * 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). */ 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` + * matches one configured pattern against a numeric peer IP string. Supported + * patterns: `*` (match anything), an IPv4/IPv6 literal, an IPv4/IPv6 CIDR + * (`10.0.0.0/8`, `2001:db8::/32`), or a glob (`*.example.com`) evaluated with + * the same matcher as file globs; a glob only matches a peer string of the + * same shape, so a numeric peer never matches a hostname glob. */ +bool daemon_host_pattern_match(const char* pattern, const char* peer_ip); + +/* rsync-like combined decision over a deny list and an allow list: a matching + * deny rejects (deny takes precedence); otherwise, when any allow entries + * exist, a peer that matches none is rejected; with no allow entries every + * peer not denied is accepted. An empty/unset pair returns true. */ +bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count, + char* const* deny, int deny_count); + +/* True when at least one allow or deny pattern is configured (i.e. an + * unprovable peer must fail closed rather than being treated as unrestricted). */ +bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny, + int deny_count); + #endif diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 0a4aeef..a3f9a07 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -115,6 +115,11 @@ Server* server_create(int port) { return server_create_ex(port, NULL); } +void server_set_max_connections(Server* server, unsigned int max_connections) { + if (server && max_connections > 0) + server->max_connections = max_connections; +} + void server_delete(Server** server) { if (server == NULL || *server == NULL) return; @@ -135,7 +140,7 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil } signal(SIGCHLD, sigchld_handler); while (1) { - struct sockaddr_in client_addr; + struct sockaddr_storage client_addr; socklen_t client_len = sizeof(client_addr); int fd = accept(server->file_descriptor, (struct sockaddr*)&client_addr, &client_len); if (fd < 0) { @@ -143,13 +148,16 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil continue; } tcp_apply_socket_timeout(fd); + char peer[128]; + if (!utils_sockaddr_to_string((const struct sockaddr*)&client_addr, peer, sizeof(peer))) + snprintf(peer, sizeof(peer), "unknown"); if ((unsigned int)g_active_connections >= server->max_connections) { - log_message(LOG_LEVEL_WARNING, "Max connections (%u) reached, rejecting", - server->max_connections); + log_message(LOG_LEVEL_WARNING, "Max connections (%u) reached, rejecting %s", + server->max_connections, peer); close(fd); continue; } - log_message(LOG_LEVEL_INFO, "%s", log_fmt); + log_message(LOG_LEVEL_INFO, "%s from %s", log_fmt, peer); pid_t pid = fork(); if (pid == 0) { /* Connection children must not run the parent's global cleanup(): it diff --git a/src/shared/transport_tcp.h b/src/shared/transport_tcp.h index b5937e6..4439003 100644 --- a/src/shared/transport_tcp.h +++ b/src/shared/transport_tcp.h @@ -45,6 +45,9 @@ typedef struct { Server* server_create_ex(int port, const ServerBindOptions* bind_opts); 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); 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); diff --git a/src/shared/utils.c b/src/shared/utils.c index 16ea671..e00ec25 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -587,6 +587,62 @@ bool utils_fd_peer_is_local(int fd) { return utils_sockaddr_is_loopback((const struct sockaddr*)&peer); } +/* Numeric peer address of a connected fd. Only AF_INET/AF_INET6 peers are + formatted; every other descriptor/family (pipe, AF_UNIX socketpair, ...) or a + getpeername failure returns false with buf emptied. The caller must treat + that as "cannot tell". */ +bool utils_fd_peer_ip(int fd, char* buf, size_t len) { + if (!buf || len == 0) + return false; + buf[0] = '\0'; + if (fd < 0) + return false; + struct sockaddr_storage peer; + socklen_t peer_len = sizeof(peer); + if (getpeername(fd, (struct sockaddr*)&peer, &peer_len) != 0) + return false; + const void* src = NULL; + int family = peer.ss_family; + if (family == AF_INET) + src = &((const struct sockaddr_in*)&peer)->sin_addr; + else if (family == AF_INET6) + src = &((const struct sockaddr_in6*)&peer)->sin6_addr; + else + return false; + return inet_ntop(family, src, buf, (socklen_t)len) != NULL; +} + +/* "ip:port" / "[ip]:port" for a connected peer, used to log the connecting + address in the accept loop. Returns false for a non-INET family. */ +bool utils_sockaddr_to_string(const struct sockaddr* addr, char* buf, size_t len) { + if (!addr || !buf || len == 0) + return false; + buf[0] = '\0'; + char ip[INET6_ADDRSTRLEN]; + unsigned short port; + int written; + if (addr->sa_family == AF_INET) { + const struct sockaddr_in* v4 = (const struct sockaddr_in*)addr; + if (!inet_ntop(AF_INET, &v4->sin_addr, ip, sizeof(ip))) + return false; + port = ntohs(v4->sin_port); + written = snprintf(buf, len, "%s:%u", ip, port); + } else if (addr->sa_family == AF_INET6) { + const struct sockaddr_in6* v6 = (const struct sockaddr_in6*)addr; + if (!inet_ntop(AF_INET6, &v6->sin6_addr, ip, sizeof(ip))) + return false; + port = ntohs(v6->sin6_port); + written = snprintf(buf, len, "[%s]:%u", ip, port); + } else { + return false; + } + if (written < 0 || (size_t)written >= len) { + buf[0] = '\0'; + return false; + } + return true; +} + /* True when a client-supplied host string names a loopback destination: "localhost", any 127.0.0.0/8 literal, "::1", or "[::1]". */ bool utils_host_is_loopback(const char* host) { diff --git a/src/shared/utils.h b/src/shared/utils.h index 4d09c67..4c9144a 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -77,5 +77,13 @@ bool append_tail_length(unsigned long long old_size, unsigned long long check_si bool utils_sockaddr_is_loopback(const struct sockaddr* addr); bool utils_fd_peer_is_local(int fd); bool utils_host_is_loopback(const char* host); +/* Numeric peer address of a connected fd (INET6_ADDRSTRLEN is always enough). + * Returns false and leaves buf empty when the fd is not a connected INET socket + * or getpeername/inet_ntop fails. Used by the daemon host-access gate; a false + * return is "cannot tell" and must be treated as fail-closed when ACLs apply. */ +bool utils_fd_peer_ip(int fd, char* buf, size_t len); +/* Format a sockaddr as "ip:port" (IPv4) or "[ip]:port" (IPv6) for logging. + * Returns false (buf emptied) for a non-INET family or a formatting failure. */ +bool utils_sockaddr_to_string(const struct sockaddr* addr, char* buf, size_t len); #endif diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 30410e8..98c963d 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -51,6 +51,7 @@ READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly") AUTH_MODULE = os.path.join(MODULE_ROOT, "auth") TEAM_MODULE = os.path.join(MODULE_ROOT, "team") OWNER_MODULE = os.path.join(MODULE_ROOT, "owner") +DENIED_MODULE = os.path.join(MODULE_ROOT, "denied") CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf") CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd") STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf") @@ -191,7 +192,7 @@ def _config_port(config_path): @pytest.fixture(scope="module", autouse=True) def daemon_env(): for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE, - DETACH_MODULE): + DENIED_MODULE, DETACH_MODULE): shutil.rmtree(d, ignore_errors=True) os.makedirs(d, exist_ok=True) generate_test_files(SOURCE_DIR, full=False) @@ -231,7 +232,12 @@ def daemon_env(): "[owner]\n" "path = %s\n" "client owner = yes\n" - % (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE)) + "\n" + "[denied]\n" + "path = %s\n" + "hosts deny = 127.0.0.1\n" + % (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE, + DENIED_MODULE)) # A dedicated config for the fail-closed startup check: an auth-required # module with no credential store must refuse to start. Its own free port @@ -368,6 +374,15 @@ class TestDaemonRejection: result = _push("127.0.0.1::/sub", daemon.port) assert result.returncode != 0 + @pytest.mark.ci + def test_hosts_deny_rejects_loopback(self, daemon): + """Host access control: a module with `hosts deny = 127.0.0.1` refuses a + loopback client at the config gate, before any data is exchanged.""" + before = self._tree_files() + result = _push("127.0.0.1::denied", daemon.port) + assert result.returncode != 0 + assert self._tree_files() == before, "host-denied connection wrote under the module root" + def test_dotdot_destination_rejected(self, daemon): """A '..' path expansion in the module-relative path is refused at parse time so a client cannot escape the module root while it is still on the diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index 1f54a2e..7e7577a 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -31,6 +31,10 @@ static void test_daemon_conf_create_defaults() { EXPECT_EQ_INT(conf->global.port, DAEMON_CONF_DEFAULT_PORT); EXPECT_NULL(conf->global.motd_file); 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.hosts_allow_count, 0); + EXPECT_EQ_INT(conf->global.hosts_deny_count, 0); EXPECT_EQ_INT(conf->module_count, 0); daemon_conf_free(conf); } @@ -310,6 +314,14 @@ static void test_daemon_conf_dparam_override() { EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port = 9000", err, sizeof(err)), 0); EXPECT_EQ_INT(conf->global.port, 9000); + 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, "AUTH FAILURE DELAY=1500", err, sizeof(err)), 0); + EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 1500); + EXPECT_EQ_INT( + daemon_conf_apply_dparam(conf, "hosts allow=127.0.0.1,10.0.0.0/8", err, sizeof(err)), 0); + EXPECT_EQ_INT(conf->global.hosts_allow_count, 2); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=notaport", err, sizeof(err)), -1); EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "bogus=1", err, sizeof(err)), -1); EXPECT_TRUE(strstr(err, "unknown global key") != NULL); @@ -366,6 +378,114 @@ static void test_daemon_conf_auth_users_validated() { daemon_conf_free(ok_conf); } +/* Wave 3 daemon hardening: configurable global/per-module connection caps, + * auth-failure throttle and host access lists parse strictly (valid values are + * stored, malformed values fail the whole load). */ +static void test_daemon_conf_limits_and_hosts_parse() { + char* path; + char err[256]; + EXPECT_EQ_INT(write_conf("max connections = 25\n" + "auth failure delay = 0\n" + "hosts allow = 10.0.0.0/8, *.example.com\n" + "hosts deny = 192.168.0.1 2001:db8::/32\n" + "\n" + "[m]\n" + "path = /x\n" + "max connections = 3\n" + "hosts allow = 127.0.0.1\n" + "hosts deny = *\n", + &path), + 0); + DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + 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.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], "*.example.com"); + EXPECT_EQ_INT(conf->global.hosts_deny_count, 2); + EXPECT_EQ_STR(conf->global.hosts_deny[0], "192.168.0.1"); + EXPECT_EQ_STR(conf->global.hosts_deny[1], "2001:db8::/32"); + EXPECT_EQ_INT(conf->modules[0].max_connections, 3); + EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 1); + EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1"); + EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 1); + EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "*"); + 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", + }; + for (size_t i = 0; i < sizeof(bad_values) / sizeof(bad_values[0]); i++) { + EXPECT_EQ_INT(write_conf(bad_values[i], &path), 0); + const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(rejected); + } + + /* The same strictness applies inside a module section. */ + const char* bad_module[] = { + "[m]\npath = /x\nmax connections = 0\n", + "[m]\npath = /x\nhosts allow = 10.0.0.0/40\n", + "[m]\npath = /x\nhosts deny = 999.1.1.1/8\n", + }; + for (size_t i = 0; i < sizeof(bad_module) / sizeof(bad_module[0]); i++) { + EXPECT_EQ_INT(write_conf(bad_module[i], &path), 0); + const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(rejected); + EXPECT_TRUE(strstr(err, "invalid") != NULL); + } + + /* 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)); + free(path); + EXPECT_NOT_NULL(conf); + EXPECT_EQ_INT(conf->global.hosts_allow_count, 0); + daemon_conf_free(conf); +} + +static void test_daemon_hosts_allowed() { + /* Pattern forms. */ + EXPECT_TRUE(daemon_host_pattern_match("*", "203.0.113.9")); + EXPECT_TRUE(daemon_host_pattern_match("10.0.0.1", "10.0.0.1")); + EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", "10.0.0.2")); + EXPECT_TRUE(daemon_host_pattern_match("10.0.0.0/8", "10.255.1.2")); + EXPECT_FALSE(daemon_host_pattern_match("10.0.0.0/8", "11.0.0.1")); + EXPECT_TRUE(daemon_host_pattern_match("2001:db8::/32", "2001:db8:1234::5")); + EXPECT_FALSE(daemon_host_pattern_match("2001:db8::/32", "2001:db9::1")); + EXPECT_TRUE(daemon_host_pattern_match("::1", "::1")); + EXPECT_FALSE(daemon_host_pattern_match("::1", "::2")); + EXPECT_TRUE(daemon_host_pattern_match("*.example.com", "host.example.com")); + EXPECT_FALSE(daemon_host_pattern_match("*.example.com", "example.org")); + EXPECT_FALSE(daemon_host_pattern_match(NULL, "10.0.0.1")); + EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", NULL)); + EXPECT_FALSE(daemon_host_pattern_match("", "10.0.0.1")); + + char* allow[] = {"10.0.0.0/8"}; + char* deny[] = {"10.0.0.1"}; + /* Deny takes precedence over a matching allow. */ + EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", allow, 1, deny, 1)); + EXPECT_TRUE(daemon_hosts_allowed("10.0.0.2", allow, 1, deny, 1)); + /* A non-empty allow list rejects a peer that matches none of its entries. */ + EXPECT_FALSE(daemon_hosts_allowed("192.168.1.1", allow, 1, NULL, 0)); + /* With only a deny list, everything not denied is accepted. */ + EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, deny, 1)); + EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", NULL, 0, deny, 1)); + /* No lists at all accepts everyone. */ + EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, NULL, 0)); + /* An unprovable peer (NULL) never matches an allow list. */ + EXPECT_FALSE(daemon_hosts_allowed(NULL, allow, 1, NULL, 0)); + + EXPECT_FALSE(daemon_hosts_restricted(NULL, 0, NULL, 0)); + EXPECT_TRUE(daemon_hosts_restricted(allow, 1, NULL, 0)); + EXPECT_TRUE(daemon_hosts_restricted(NULL, 0, deny, 1)); +} + static void test_daemon_module_name_valid() { EXPECT_TRUE(daemon_module_name_valid("backup")); EXPECT_TRUE(daemon_module_name_valid("Backup_2")); @@ -398,5 +518,7 @@ void test_daemon_conf() { test_daemon_conf_find_module(); test_daemon_conf_dparam_override(); test_daemon_conf_auth_users_validated(); + test_daemon_conf_limits_and_hosts_parse(); + test_daemon_hosts_allowed(); test_daemon_module_name_valid(); } \ No newline at end of file diff --git a/tests/test_shared_utils.c b/tests/test_shared_utils.c index c9da6cf..86ce24b 100644 --- a/tests/test_shared_utils.c +++ b/tests/test_shared_utils.c @@ -356,6 +356,69 @@ static void test_loopback_helpers() { close(listener); } +/* The daemon host ACL reads the numeric peer address through + * utils_fd_peer_ip. A real loopback TCP peer reports "127.0.0.1"; a pipe or an + * AF_UNIX socketpair has no INET peer and must return false with an empty + * buffer (the fail-closed "cannot tell" result). */ +static void test_fd_peer_ip() { + char ip[INET6_ADDRSTRLEN]; + EXPECT_FALSE(utils_fd_peer_ip(-1, ip, sizeof(ip))); + EXPECT_EQ_STR(ip, ""); + EXPECT_FALSE(utils_fd_peer_ip(-1, NULL, 0)); + + int pipe_fds[2]; + EXPECT_EQ_INT(pipe(pipe_fds), 0); + EXPECT_FALSE(utils_fd_peer_ip(pipe_fds[0], ip, sizeof(ip))); + EXPECT_EQ_STR(ip, ""); + close(pipe_fds[0]); + close(pipe_fds[1]); + + int pair_fds[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, pair_fds), 0); + EXPECT_FALSE(utils_fd_peer_ip(pair_fds[0], ip, sizeof(ip))); + EXPECT_EQ_STR(ip, ""); + close(pair_fds[0]); + close(pair_fds[1]); + + int listener = socket(AF_INET, SOCK_STREAM, 0); + EXPECT_TRUE(listener >= 0); + struct sockaddr_in bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin_family = AF_INET; + bind_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind_addr.sin_port = 0; + EXPECT_EQ_INT(bind(listener, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0); + EXPECT_EQ_INT(listen(listener, 1), 0); + socklen_t addr_len = sizeof(bind_addr); + EXPECT_EQ_INT(getsockname(listener, (struct sockaddr*)&bind_addr, &addr_len), 0); + int dialer = socket(AF_INET, SOCK_STREAM, 0); + EXPECT_TRUE(dialer >= 0); + EXPECT_EQ_INT(connect(dialer, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0); + int accepted = accept(listener, NULL, NULL); + EXPECT_TRUE(accepted >= 0); + EXPECT_TRUE(utils_fd_peer_ip(accepted, ip, sizeof(ip))); + EXPECT_EQ_STR(ip, "127.0.0.1"); + + /* utils_sockaddr_to_string includes the port for a real peer. */ + struct sockaddr_storage peer; + socklen_t peer_len = sizeof(peer); + EXPECT_EQ_INT(getpeername(accepted, (struct sockaddr*)&peer, &peer_len), 0); + char peer_string[128]; + EXPECT_TRUE( + utils_sockaddr_to_string((const struct sockaddr*)&peer, peer_string, sizeof(peer_string))); + EXPECT_TRUE(strncmp(peer_string, "127.0.0.1:", strlen("127.0.0.1:")) == 0); + close(accepted); + close(dialer); + close(listener); + + /* A non-INET family formats to "unknown" at the call site, not a bogus IP. */ + struct sockaddr sa_unix; + memset(&sa_unix, 0, sizeof(sa_unix)); + sa_unix.sa_family = AF_UNIX; + EXPECT_FALSE(utils_sockaddr_to_string(&sa_unix, peer_string, sizeof(peer_string))); + EXPECT_EQ_STR(peer_string, ""); +} + void test_shared_utils() { test_walker_removes_extras_keeps_manifest_and_protected(); test_walker_max_delete_exceeded_deletes_nothing(); @@ -363,6 +426,7 @@ void test_shared_utils() { test_walker_unlimited_deletes_all(); test_walker_hard_bound_all_or_nothing(); test_loopback_helpers(); + test_fd_peer_ip(); /* --append / --append-verify tail-resume math: a resume is eligible only for a shorter existing destination, and the tail length is then the difference. */