feat(daemon): host ACL, configurable max connections, peer audit, auth-failure delay

This commit is contained in:
2026-09-13 02:36:15 +02:00
parent 1acb66628d
commit dff6609976
12 changed files with 722 additions and 19 deletions
+26
View File
@@ -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
+4 -2
View File
@@ -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.
+101 -5
View File
@@ -23,8 +23,10 @@
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <time.h>
#include <openssl/x509.h>
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 : "<allocation failed>");
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>",
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");
+256
View File
@@ -1,9 +1,13 @@
#include "daemon_conf.h"
#include "credentials.h"
#include "utils.h"
#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <netinet/in.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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;
}
+50 -3
View File
@@ -52,6 +52,16 @@ 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
@@ -60,6 +70,14 @@ 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 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
+12 -4
View File
@@ -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
+3
View File
@@ -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);
+56
View File
@@ -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) {
+8
View File
@@ -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
+17 -2
View File
@@ -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
+122
View File
@@ -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();
}
+64
View File
@@ -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. */