diff --git a/CHANGELOG.md b/CHANGELOG.md index 66132a2..fe26d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to FastSync are documented here. Versions match `PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must run the same version because the handshake is strict. +## [Unreleased] + +### Security + +- Enforce the daemon's per-module `max connections` cap and add a global + `max connections per host` cap plus a cross-process `auth lockout` + (`auth lockout threshold` / `auth lockout duration`). Because the listener + forks one child per connection, the counters live in an anonymous shared + mapping created before the accept loop and reclaimed by the parent's + `SIGCHLD` handler, so the per-module, per-source and auth-failure state is + shared across every child (including after `SIGKILL`). The per-source table + now has a bounded lifetime (expired-lockout/idle entries are reclaimed, with a + rate-limited warning when it is genuinely full), and the occupancy counters are + re-derived from the shared slot table on every child exit so a child killed + mid-registration cannot leak a count. Trusted loopback peers are exempt from the + per-host cap and the auth lockout (they share one address); clients behind a + shared NAT/proxy still share a single per-host budget and lockout, which is + documented. + ## [2.20.0] - 2026-09-13 ### Security diff --git a/CMakeLists.txt b/CMakeLists.txt index e61b0fa..479cbca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,7 @@ set(SHARED_SRCS src/shared/config.c src/shared/credentials.c src/shared/daemon_conf.c + src/shared/daemon_limits.c src/shared/data.c src/shared/delay_updates.c src/shared/delta.c @@ -205,6 +206,7 @@ set(TEST_SRCS tests/test_config.c tests/test_credentials.c tests/test_daemon_conf.c + tests/test_daemon_limits.c tests/test_data.c tests/test_delay_updates.c tests/test_delta.c diff --git a/README.md b/README.md index ee5627c..d19ed99 100644 --- a/README.md +++ b/README.md @@ -508,18 +508,47 @@ defaults to the current directory. | 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 +- `max connections = N` — global cap on concurrent connections, default 100. The listener enforces it; `0`, negative, and non-numeric values are parse errors. +- `max connections per host = N` — cap on concurrent connections from a single + source IP, default 0 (unlimited). Enforced across all forked connection + children through a shared registry. - `auth failure delay = MS` — milliseconds to sleep after a failed - authentication, default 500. `0` disables it and the value is capped at 60000, + authentication, default 500. `0` disables it and the value is capped at 5000, so online password guessing is rate-limited per connection. Successful auths are never delayed. +- `auth lockout threshold = N` — number of failed authentications from one source + IP before that source is locked out, default 10; `0` disables the lockout. The + failure counter is shared across every connection child, so the lockout holds + even when the next attempt is handled by a different forked child. +- `auth lockout duration = SECONDS` — how long a locked-out source is refused + (default 300). A locked-out client is refused before any SCRAM challenge is + sent; a successful authentication clears the counter. - `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`. +A `[module]` may also set `max connections` (0 = unlimited; enforced per module +across all connection children) and its own `hosts allow`/`hosts deny`. + +The per-host cap and the shared auth lockout identify a source by its numeric +peer IP. **Loopback peers (127.0.0.0/8, IPv6 `::1`) are exempt**: every local +client shares that one address, so counting or locking them out would let one +local process deny service to all the others. The per-module and global +`max connections` caps still apply to loopback. Because the key is the peer IP, +`max connections per host` and `auth lockout` also cannot distinguish clients +behind the same NAT, proxy, or reverse-proxy address — they share one budget and +one lockout counter, so an over-aggressive lockout can affect unrelated users +behind that address. Prefer TLS client certificates (`--client-cn`) plus +`hosts allow`/`hosts deny` for per-client policy when clients share an address, +and size `auth lockout threshold` accordingly. + +The shared per-source table has a bounded lifetime: an entry with no live +connection is reclaimed once its lockout has expired, or after it has been idle +(300 s). If every entry is still live or locked, a new source is admitted without +per-host accounting (fail open) and a rate-limited warning is logged; the +per-module cap and host ACLs still apply. The occupancy counters are re-derived +from the shared slot table after every child exit, so a child killed mid-transfer +(or mid-registration) cannot leak a slot or an occupancy count. Host patterns are `*` (match all), IPv4/IPv6 literals, or IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostnames are not resolved, so hostname globs diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index ea171bc..df03870 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 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` | +| `--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`, `max connections per host`, `auth failure delay`, `auth lockout threshold`, `auth lockout duration`, `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,9 +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), `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 5000), `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. +- **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), and at most 256 `[module]` sections are accepted. 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), `max connections per host` (concurrent-connection cap per source IP, default 0 = unlimited), `auth failure delay` (milliseconds to sleep after a failed authentication, default 500; 0 disables, capped at 5000), `auth lockout threshold` (failed authentications from one source before lockout, default 10; 0 disables), `auth lockout duration` (seconds a locked-out source is refused, default 300), `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, 0 = unlimited; enforced across all connection children), `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 config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. 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 5000) via `nanosleep` before the connection closes, rate-limiting online guessing without delaying a success. +- **Connection caps, shared registry and auth lockout:** 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. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **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/client/client_cli.c b/src/client/client_cli.c index dd644c4..3fa2004 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -608,7 +608,15 @@ typedef struct { size_t offset; /* offsetof of the boolean target field in Config */ } NegatableOption; -/* Options that map directly onto a Config field with no side effects. */ +/* Options that map directly onto a Config field with no side effects. + * + * NOTE: these CLI tables are intentionally NOT generated from the wire-field + * X-macro table in config.h. The two sets only overlap partially: the CLI + * surface also carries client-only fields that never cross the wire (rsh, + * outbuf, remote-option, batch paths, trust-sender, ...) and needs flag/alias/ + * negation semantics that the wire table does not model. Keeping them + * hand-maintained is deliberate; the shared contract is enforced at the wire + * boundary by config.[ch] and the golden test. */ static const OptionEntry OPTION_TABLE[] = { {"--dry-run", "-n", OPT_FLAG, offsetof(Config, dry_run)}, {"--remove-source-files", NULL, OPT_FLAG, offsetof(Config, remove_source_files)}, diff --git a/src/client/client_send.c b/src/client/client_send.c index db7a560..e220fd4 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -1157,6 +1157,7 @@ static int send_append(const Client* client, File* file, Config* config, tail_view.data = (char*)file->data->data + off; tail_view.size = tail_len; tail_view.protocol_charge = 0; + tail_view.owner = NULL; ok = send_data(fd, &tail_view); } return ok ? 0 : -1; diff --git a/src/server/server.c b/src/server/server.c index 494a840..35e0bd2 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -2,6 +2,7 @@ #include "charset.h" #include "credentials.h" #include "daemon_conf.h" +#include "daemon_limits.h" #include "delay_updates.h" #include "file.h" #include "identity.h" @@ -29,8 +30,6 @@ #include #include -static char* authorized_root; -static int authorized_root_fd = -1; static bool allow_delete; static bool trust_sender; static bool allow_unauthenticated; @@ -56,6 +55,12 @@ static DaemonConf* g_daemon_conf = NULL; * such a module exists. */ static CredentialStore* g_credentials = NULL; +/* Cross-process connection registry (per-module and per-source caps plus the + * shared auth lockout), created once in main BEFORE the accept loop forks and + * shared read-only-by-pointer with every connection child. NULL outside daemon + * mode or when the mapping could not be allocated (global cap + ACLs remain). */ +static DaemonLimitRegistry* g_daemon_limits = NULL; + /* Opaque context threaded through to the config-frame gate: the connection's * SSL object (NULL over plaintext) so the gate can warn when a credential * exchange is not encrypted, plus the super-mode override the gate decides on. @@ -75,6 +80,13 @@ typedef struct ModuleGateContext { * not classify the peer; an ACL-configured module then fails closed. */ bool has_peer_ip; char peer_ip[INET6_ADDRSTRLEN]; + /* True when the peer is provably loopback (utils_fd_peer_is_local, fail + * closed). A trusted local/SSH peer is exempt from the per-host cap and the + * cross-process auth lockout: every loopback client shares the 127.0.0.1 + * identity, so counting/locking them out would let one local client deny + * service to (or leak lockout state about) all the others. The per-module and + * global caps still apply. */ + bool is_local; } ModuleGateContext; /* Server half of the SCRAM challenge/response (A7 remediation, protocol @@ -187,13 +199,10 @@ static bool tls_client_identity_allowed(SSL* ssl) { } static void release_authorization(void) { - file_set_authorized_root(-1, NULL); - utils_set_authorized_root_fd(-1); - if (authorized_root_fd >= 0) - close(authorized_root_fd); - authorized_root_fd = -1; - free(authorized_root); - authorized_root = NULL; + int root_fd = utils_get_authorized_root_fd(); + utils_set_authorized_root(-1, NULL); + if (root_fd >= 0) + close(root_fd); } static bool path_is_within(const char* root, const char* path) { @@ -219,13 +228,11 @@ static bool ensure_receive_root(const Config* config) { static bool configure_authorization(const char* root) { char resolved[PATH_MAX]; if (!root) { - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } int root_fd = open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (root_fd < 0) { - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } @@ -234,26 +241,12 @@ static bool configure_authorization(const char* root) { if (fd_path_length < 0 || (size_t)fd_path_length >= sizeof(fd_path) || !realpath(fd_path, resolved)) { close(root_fd); - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } - authorized_root = str_dup(resolved); - if (!authorized_root) { + if (!utils_set_authorized_root(root_fd, resolved)) { + /* The setter already cleared the fd/path state on allocation failure. */ close(root_fd); - file_set_authorized_root(-1, NULL); - utils_set_authorized_root(-1, NULL); - return false; - } - authorized_root_fd = root_fd; - if (!file_set_authorized_root(authorized_root_fd, authorized_root) || - !utils_set_authorized_root(authorized_root_fd, authorized_root)) { - file_set_authorized_root(-1, NULL); - utils_set_authorized_root(-1, NULL); - close(authorized_root_fd); - authorized_root_fd = -1; - free(authorized_root); - authorized_root = NULL; return false; } return true; @@ -292,6 +285,60 @@ static const DaemonModule* module_gate_lookup_module(const Config* config, const return module; } +/* Index of `module` within the loaded config's module array (the registry's + * per-module counter key). Returns -1 when it cannot be resolved. */ +static int daemon_module_index(const DaemonModule* module) { + if (!g_daemon_conf || !module || module < g_daemon_conf->modules || + module >= g_daemon_conf->modules + g_daemon_conf->module_count) + return -1; + return (int)(module - g_daemon_conf->modules); +} + +/* Shared-registry admission: reserve this connection's slot for the selected + * module and the peer source IP. Enforces the per-module `max connections` and + * the global `max connections per host` across every forked child. Runs before + * auth/ownership so a client that is over a cap is refused before any work. + * The per-source cap is skipped when the peer cannot be classified (host ACLs + * fail closed separately); the module cap still applies. A missing registry + * (allocation failure / non-fork path) fails open -- the global cap and ACLs + * still bound the listener. */ +static const char* module_gate_check_limits(const Config* config, const DaemonModule* module, + ModuleGateContext* gate_ctx) { + if (!g_daemon_limits) + return NULL; + int slot = transport_tcp_current_slot(); + if (slot < 0) + return NULL; /* not on the forked accept-loop path (e.g. --stdio) */ + int module_index = daemon_module_index(module); + if (module_index < 0) + return NULL; + /* A trusted loopback peer is exempt from the per-source cap: pass an + * unparseable peer so the registry skips per-source tracking, while the + * per-module cap below is still enforced. Remote peers are tracked normally. */ + const char* peer = + (!gate_ctx || gate_ctx->is_local || !gate_ctx->has_peer_ip) ? "" : gate_ctx->peer_ip; + DaemonLimitResult result = + daemon_limits_register(g_daemon_limits, slot, module_index, peer, module->max_connections); + switch (result) { + case DAEMON_LIMIT_OK: + return NULL; + case DAEMON_LIMIT_MODULE_FULL: + log_message(LOG_LEVEL_ERROR, + "daemon module '%s': 'max connections' cap (%d) reached; refusing %s", + config->module, module->max_connections, peer[0] ? peer : "peer"); + return "requested daemon module is at its connection limit"; + case DAEMON_LIMIT_HOST_FULL: + log_message(LOG_LEVEL_ERROR, + "daemon: 'max connections per host' cap (%d) reached for %s; refusing module '%s'", + g_daemon_conf->global.max_connections_per_host, peer[0] ? peer : "peer", + config->module); + return "too many concurrent connections from this host"; + case DAEMON_LIMIT_UNAVAILABLE: + default: + return NULL; + } +} + /* Per-module client-chosen ownership / super-user policy (P7 Wave E hardening): * a daemon module refuses EVERY ownership-affecting request (--numeric-ids, * --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super) @@ -396,6 +443,22 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae ModuleGateContext* gate_ctx, const char** error) { if (module->auth_user_count == 0) return MODULE_AUTH_ACCEPTED; + /* Cross-process lockout: a source that failed too many authentications is + * refused before the challenge is sent (the counter lives in the shared + * registry, so it spans every forked child and survives a child exit). A + * trusted loopback peer is exempt: all local clients share the 127.0.0.1 + * identity, so a lockout would let one deny the others. */ + if (g_daemon_limits && gate_ctx && gate_ctx->has_peer_ip && !gate_ctx->is_local) { + int remaining = 0; + if (daemon_limits_auth_locked(g_daemon_limits, gate_ctx->peer_ip, &remaining)) { + log_message(LOG_LEVEL_ERROR, + "daemon module '%s': source %s is locked out after repeated authentication " + "failures (%d s remaining); refusing", + config->module, gate_ctx->peer_ip, remaining); + *error = "too many failed authentication attempts from this host; try again later"; + return MODULE_AUTH_REFUSED; + } + } /* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR). */ if (g_credentials == NULL) { log_message(LOG_LEVEL_ERROR, @@ -450,10 +513,17 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae "daemon module '%s': authentication failed for user '%s' from %s; refusing", config->module, escaped_user ? escaped_user : "(none)", peer); free(escaped_user); - /* Rate-limit online guessing per connection (no delay on success). */ + /* Count the failure in the shared registry (locks the source out once the + * configured threshold is reached) and rate-limit online guessing per + * connection (no delay on success). A loopback peer is exempt from the + * shared counter. */ + if (g_daemon_limits && gate_ctx->has_peer_ip && !gate_ctx->is_local) + daemon_limits_auth_record_failure(g_daemon_limits, gate_ctx->peer_ip); daemon_auth_failure_delay(); return MODULE_AUTH_TERMINATED; } + if (g_daemon_limits && gate_ctx->has_peer_ip && !gate_ctx->is_local) + daemon_limits_auth_record_success(g_daemon_limits, gate_ctx->peer_ip); char* escaped_user = output_escape(config->auth_user, config->eight_bit_output); log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module, escaped_user ? escaped_user : "", @@ -559,8 +629,14 @@ static const char* server_module_gate(const Config* config, void* context) { utils_fd_peer_ip(gate_ctx->fd, gate_ctx->peer_ip, sizeof(gate_ctx->peer_ip)); if (!gate_ctx->has_peer_ip) log_message(LOG_LEVEL_DEBUG, "daemon module '%s': peer address unavailable", config->module); + /* utils_fd_peer_is_local is fail-closed (getpeername must succeed and report + * a loopback peer), so "cannot tell" is never treated as trusted. */ + gate_ctx->is_local = utils_fd_peer_is_local(gate_ctx->fd); } error = module_gate_check_hosts(config, module, gate_ctx); + if (error) + return error; + error = module_gate_check_limits(config, module, gate_ctx); if (error) return error; error = module_gate_check_ownership(config, module, gate_ctx); @@ -590,6 +666,7 @@ void handler(int file_descriptor) { gate_ctx.super_mode_override = -1; gate_ctx.has_peer_ip = false; gate_ctx.peer_ip[0] = '\0'; + gate_ctx.is_local = false; /* All teardown state starts empty so the single `done` epilogue is safe to * reach from any error path (including before the config frame arrives). */ Config* config = NULL; @@ -615,6 +692,7 @@ void handler(int file_descriptor) { * in effect. A client's --timeout tightens only that client's own protocol * I/O and the server's socket read/write timeout is the transport default. */ protocol_session_set_io_timeout(&session, config->timeout); + const char* authorized_root = utils_get_authorized_root_path(); if (!authorized_root) { log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); goto done; @@ -880,7 +958,9 @@ static void print_server_usage(void) { printf(" fastsyncd.conf, else /etc/fastsyncd.conf)\n"); printf(" --dparam=KEY=VALUE Override one global config key on the command line\n"); printf(" (port, motd file, address, max connections,\n"); - printf(" auth failure delay, hosts allow, hosts deny)\n"); + printf(" max connections per host, auth failure delay,\n"); + printf(" auth lockout threshold, auth lockout duration,\n"); + printf(" hosts allow, hosts deny)\n"); printf(" --no-detach Stay in the foreground (default detaches to\n"); printf(" background when running --daemon)\n"); printf(" --password-file=FILE Credential store for modules that declare\n"); @@ -1096,11 +1176,10 @@ int main(int argc, char* argv[]) { "unless the module is intentionally open to the network", g_daemon_conf->modules[i].name); if (g_daemon_conf->modules[i].max_connections > 0) - log_message(LOG_LEVEL_WARNING, - "daemon module '%s': per-module 'max connections' is stored but not enforced " - "per module; the global 'max connections' cap (%d) applies to the whole " - "listener", - g_daemon_conf->modules[i].name, g_daemon_conf->global.max_connections); + log_message(LOG_LEVEL_INFO, + "daemon module '%s': per-module 'max connections' cap = %d (enforced " + "across all connection children)", + g_daemon_conf->modules[i].name, g_daemon_conf->modules[i].max_connections); } /* Daemon credential store (Wave B). --password-file and --early-input * feed the same store, loaded BEFORE the listener forks so every @@ -1144,6 +1223,21 @@ int main(int argc, char* argv[]) { module->name, module->auth_users[j]); } } + /* Shared cross-process registry for the per-module / per-source caps and + * the auth lockout. Created HERE in the parent before any accept-loop + * fork; every connection child inherits the mapping. A failure degrades to + * "registry disabled" (the global cap and host ACLs still apply) rather + * than refusing to start. */ + g_daemon_limits = daemon_limits_create((int)g_daemon_conf->global.max_connections, + g_daemon_conf->module_count, + g_daemon_conf->global.max_connections_per_host, + g_daemon_conf->global.auth_lockout_threshold, + g_daemon_conf->global.auth_lockout_duration_sec); + if (!g_daemon_limits) + log_message(LOG_LEVEL_WARNING, + "daemon: could not allocate the shared connection registry; per-module / " + "per-host caps and the cross-process auth lockout are disabled (the global " + "'max connections' cap and host ACLs still apply)"); } else { if (!configure_authorization(opts.destination_root)) { char* escaped = output_escape(opts.destination_root, false); @@ -1167,6 +1261,8 @@ int main(int argc, char* argv[]) { } if (g_daemon_conf) server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections); + if (g_daemon_limits) + server_set_limit_registry(g_server, g_daemon_limits); if (opts.use_tls) { if (!opts.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) { fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); @@ -1206,6 +1302,8 @@ int main(int argc, char* argv[]) { release_authorization(); out: + daemon_limits_destroy(g_daemon_limits); + g_daemon_limits = NULL; daemon_conf_free(g_daemon_conf); g_daemon_conf = NULL; credentials_free(g_credentials); diff --git a/src/shared/config.c b/src/shared/config.c index 64adf10..8ac4938 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -18,33 +18,16 @@ #include static void config_set_defaults(Config* config) { - config->version = str_dup(PROTOCOL_VERSION); - config->send_directory = NULL; - config->receive_root_directory = NULL; - config->save_to_disk = false; - config->use_multithreading = false; config->scanner_threads = 0; - config->use_chunk_serialization = false; - config->use_compression = false; - config->use_metadata = false; - config->use_executability = false; config->metadata_explicitly_disabled = false; config->show_progress = false; config->dry_run = false; - config->remove_source_files = false; - config->use_delete = false; - config->compression_level = 5; config->compression_threads = 0; - config->use_sendfile = false; - config->chunk_size = DEFAULT_CHUNK_SIZE; config->ssh_port = 22; config->transport = TRANSPORT_TCP; config->ssh_destination = NULL; - config->module = NULL; - config->auth_user = NULL; config->auth_password = NULL; config->password_file = NULL; - config->iconv_spec = NULL; config->fastsync_server_path = NULL; config->exclude_patterns = NULL; config->exclude_count = 0; @@ -52,16 +35,7 @@ static void config_set_defaults(Config* config) { config->include_count = 0; config->max_size = 0; config->min_size = 0; - config->max_alloc = DEFAULT_MAX_ALLOC; - config->use_incremental = false; - config->ignore_times = false; - config->size_only = false; - config->use_delta = false; config->whole_file = false; - config->fuzzy = false; - config->modify_window = 0; - config->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT; - config->delta_max_file_size = DELTA_MAX_FILE_SIZE; config->use_tls = false; config->tls_cert = NULL; config->tls_key = NULL; @@ -75,27 +49,10 @@ static void config_set_defaults(Config* config) { config->timeout = 0; config->contimeout = 10; config->quiet = false; - config->backup = false; - config->backup_dir = NULL; config->stats = false; config->max_depth = 0; config->log_file = NULL; - config->follow_symlinks = false; - config->partial = false; - config->copy_links = false; - config->safe_links = false; - config->copy_unsafe_links = false; config->copy_dirlinks = false; - config->munge_links = false; - config->keep_dirlinks = false; - config->preserve_hard_links = false; - config->preserve_acls = false; - config->preserve_xattrs = false; - config->preserve_devices = false; - config->preserve_sparse = false; - config->preserve_specials = false; - config->copy_devices = false; - config->write_devices = false; config->itemize_changes = false; config->out_format = NULL; config->log_file_format = NULL; @@ -103,49 +60,23 @@ static void config_set_defaults(Config* config) { config->debug_level = 0; config->list_only = false; config->human_readable = false; - config->eight_bit_output = false; - config->existing = false; - config->ignore_existing = false; - config->update = false; - config->inplace = false; - config->delay_updates = false; - config->use_fsync = false; - config->append = false; - config->append_verify = false; - config->preallocate = false; - config->delete_excluded = false; - config->delete_after = false; - config->max_delete = -1; config->ignore_errors = false; - config->force_delete = false; config->ignore_missing_args = false; - config->delete_missing_args = false; config->filters = NULL; config->files_from = NULL; config->files_from_set = NULL; config->from0 = false; config->cvs_exclude = false; config->per_dir_filter = false; - config->prune_empty_dirs = false; config->one_file_system = false; - config->relative = false; config->no_implied_dirs = false; config->dirs = false; - config->mkpath = false; config->rsh_command = NULL; config->blocking_io = false; config->outbuf = OUTBUF_BLOCK; config->old_args = false; - config->temp_dir = NULL; config->remote_options = NULL; config->remote_option_count = 0; - config->basis_dirs = NULL; - config->basis_count = 0; - config->partial_dir = NULL; - config->suffix = NULL; - config->delete_before = false; - config->delete_during = false; - config->delete_delay = false; config->address = NULL; config->ipv6 = false; config->ipv4 = false; @@ -153,35 +84,9 @@ static void config_set_defaults(Config* config) { config->sockopt_count = 0; config->daemon = false; config->no_motd = false; - config->checksum = false; - config->checksum_algo = CHECKSUM_ALGO_XXH64; - config->checksum_seed = 0; - config->compress_choice = NULL; - config->chmod_spec = NULL; - config->skip_compress_suffixes = NULL; - config->skip_compress_count = 0; - config->skip_compress_set = false; - config->numeric_ids = false; - config->chown_uid_set = false; - config->chown_uid = 0; - config->chown_gid_set = false; - config->chown_gid = 0; - config->usermap = NULL; - config->usermap_count = 0; - config->groupmap = NULL; - config->groupmap_count = 0; - config->super_mode = SUPER_MODE_AUTO; config->delay_context = NULL; - config->preserve_atimes = false; - config->preserve_crtimes = false; - config->omit_dir_times = false; - config->omit_link_times = false; config->open_noatime = false; config->use_xattrs = false; - config->fake_super = false; - config->copy_as_set = false; - config->copy_as_uid = 0; - config->copy_as_gid = 0; config->trust_sender = false; config->stop_after_mins = 0; config->stop_at = 0; @@ -189,6 +94,12 @@ static void config_set_defaults(Config* config) { config->write_batch = NULL; config->only_write_batch = NULL; config->read_batch = NULL; + + /* Serialized fields: defaults come from the CONFIG_WIRE_FIELDS table so the + * member declaration, default and wire codec can never drift apart. */ +#define CONFIG_DEFAULT_FIELD(name, ctype, def, kind) config->name = def; + CONFIG_WIRE_FIELDS(CONFIG_DEFAULT_FIELD) +#undef CONFIG_DEFAULT_FIELD } static bool valid_wire_bool(int value) { @@ -776,11 +687,13 @@ void config_delete(Config* config) { } config->remote_options = NULL; config->remote_option_count = 0; - for (int i = 0; i < config->basis_count; i++) { - free(config->basis_dirs[i].path); - config->basis_dirs[i].path = NULL; + if (config->basis_dirs) { + for (int i = 0; i < config->basis_count; i++) { + free(config->basis_dirs[i].path); + config->basis_dirs[i].path = NULL; + } + free(config->basis_dirs); } - free(config->basis_dirs); config->basis_dirs = NULL; config->basis_count = 0; free(config->partial_dir); @@ -812,408 +725,52 @@ void config_delete(Config* config) { free(config); } -/* Each helper is deliberately ordered to match the wire format. Keep the - * helper call order in config_send and config_receive unchanged when adding - * fields. */ -static bool send_core_fields(int fd, const Config* c) { - if (!send_str(fd, c->version) || !send_int(fd, c->eight_bit_output)) +/* --------------------------------------------------------------------------- + * Wire codec helpers. + * + * The CONFIG_WIRE_*_FIELDS tables in config.h drive the send/receive + * sequences below. Each field's KIND names a CONFIG_SEND_ / + * CONFIG_RECV_ macro (defined after the helpers) that expands to the + * exact primitive call the previous hand-written code used, so the byte + * stream is unchanged. Fields whose per-field logic is not a plain scalar + * (bounded enums, redacted auth, repeated count+array blocks) delegate to a + * dedicated helper here. + * ------------------------------------------------------------------------- */ + +/* --max-alloc: raw 64-bit value, clamped server-side and installed as the + * session allocation ceiling. A zero value is rejected. */ +static bool config_receive_max_alloc(int fd, unsigned long long* value) { + if (!receive_n_data(fd, value, sizeof(*value)) || *value == 0) return false; - protocol_set_8_bit_output(c->eight_bit_output); - if (!send_n_data(fd, &c->max_alloc, sizeof(c->max_alloc))) - return false; - return send_str(fd, c->send_directory) && send_str(fd, c->receive_root_directory) && - send_int(fd, c->save_to_disk) && send_int(fd, c->use_multithreading) && - send_int(fd, c->use_chunk_serialization) && send_int(fd, c->use_compression) && - send_int(fd, c->use_metadata) && send_int(fd, c->use_executability) && - send_int(fd, c->compression_level) && - send_n_data(fd, &c->chunk_size, sizeof(c->chunk_size)) && send_int(fd, c->use_sendfile); -} - -static bool send_delta_fields(int fd, const Config* c) { - return send_int(fd, c->use_delete) && send_int(fd, c->use_incremental) && - send_int(fd, c->size_only) && send_int(fd, c->ignore_times) && - send_int(fd, c->use_delta && !c->whole_file) && - send_n_data(fd, &c->delta_block_size, sizeof(c->delta_block_size)) && - send_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long)); -} - -static bool send_file_options(int fd, const Config* c) { - /* Device/special preservation flags cross the wire so the receiver knows a - * special/device entry must be recreated. Trailing fields; protocol 2.13.0. */ - return send_int(fd, c->backup) && send_str(fd, c->backup_dir ? c->backup_dir : "") && - send_int(fd, c->remove_source_files) && send_int(fd, c->follow_symlinks) && - send_int(fd, c->copy_links) && send_int(fd, c->safe_links) && - send_int(fd, c->copy_unsafe_links) && send_int(fd, c->preserve_hard_links) && - send_int(fd, c->preserve_acls) && send_int(fd, c->preserve_xattrs) && - send_int(fd, c->preserve_devices) && send_int(fd, c->preserve_sparse) && - send_int(fd, c->preserve_specials) && send_int(fd, c->copy_devices) && - send_int(fd, c->write_devices); -} - -static bool send_selection_options(int fd, const Config* c) { - return send_int(fd, c->ignore_existing) && send_int(fd, c->existing) && send_int(fd, c->update) && - send_int(fd, c->inplace) && send_int(fd, c->delay_updates) && send_int(fd, c->append) && - send_int(fd, c->use_fsync) && send_int(fd, c->append_verify) && - send_int(fd, c->delete_excluded) && send_int(fd, c->force_delete) && - send_int(fd, c->delete_missing_args) && send_int(fd, c->delete_after) && - send_int(fd, c->preallocate) && send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && - send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs) && - send_int(fd, c->mkpath) && send_int(fd, c->delete_during) && send_int(fd, c->delete_delay); -} - -static bool send_skip_compress_options(int fd, const Config* c) { - if (!send_int(fd, c->skip_compress_set) || !send_int(fd, c->skip_compress_count)) - return false; - for (int i = 0; i < c->skip_compress_count; i++) { - if (!send_str(fd, c->skip_compress_suffixes[i])) - return false; - } + if (*value > MAX_SERVER_ALLOC) + *value = MAX_SERVER_ALLOC; + protocol_session_set_max_alloc(NULL, *value); return true; } -static bool send_resume_options(int fd, const Config* c) { - return send_str(fd, c->temp_dir ? c->temp_dir : "") && send_int(fd, c->partial) && - send_str(fd, c->partial_dir ? c->partial_dir : "") && - send_str(fd, c->suffix ? c->suffix : "") && send_int(fd, c->delete_before) && - send_int(fd, c->checksum) && send_int(fd, c->modify_window) && - send_str(fd, c->compress_choice ? c->compress_choice : "") && - send_str(fd, c->chmod_spec ? c->chmod_spec : "") && send_skip_compress_options(fd, c); -} - -static bool send_basis_options(int fd, const Config* c) { - if (!send_int(fd, c->basis_count)) +/* Optional string: the sender serializes an unset (NULL) string as "", so the + * receiver canonicalizes the empty wire value back to NULL to preserve + * NULL-vs-empty semantics. */ +static bool config_receive_optional_str(int fd, ConfigStringBudget* budget, char** out) { + char* value = config_receive_str(fd, budget); + if (!value) return false; - for (int i = 0; i < c->basis_count; i++) { - if (!send_int(fd, (int)c->basis_dirs[i].type) || - !send_str(fd, c->basis_dirs[i].path ? c->basis_dirs[i].path : "")) - return false; + if (*value == '\0') { + free(value); + *out = NULL; + return true; } + *out = value; return true; } -/* -y/--fuzzy (receiver-side similar-file basis selection). Trailing field on - * the config frame; protocol 2.9.0. */ -static bool send_fuzzy_option(int fd, const Config* c) { - return send_int(fd, c->fuzzy); -} - -/* --checksum-choice/--cc + --checksum-seed. The algorithm id and seed travel - * with the config so the receiver hashes the on-disk old file with the same - * parameters the sender used for its digest (see checksum.h). Trailing fields - * on the config frame; protocol 2.10.0. */ -static bool send_checksum_options(int fd, const Config* c) { - return send_int(fd, c->checksum_algo) && - send_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed)); -} - -static bool receive_core_fields(int fd, Config* c, ConfigStringBudget* budget) { - int value; - if (!receive_wire_bool(fd, &c->eight_bit_output)) - return false; - protocol_set_8_bit_output(c->eight_bit_output); - if (!receive_n_data(fd, &c->max_alloc, sizeof(c->max_alloc)) || c->max_alloc == 0) - return false; - if (c->max_alloc > MAX_SERVER_ALLOC) - c->max_alloc = MAX_SERVER_ALLOC; - protocol_session_set_max_alloc(NULL, c->max_alloc); - c->send_directory = config_receive_str(fd, budget); - c->receive_root_directory = config_receive_str(fd, budget); - if (!c->send_directory || !c->receive_root_directory) - return false; - if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) || - !receive_wire_bool(fd, &c->use_chunk_serialization) || - !receive_wire_bool(fd, &c->use_compression) || !receive_wire_bool(fd, &c->use_metadata) || - !receive_wire_bool(fd, &c->use_executability)) - return false; - if (!receive_int(fd, &value)) - return false; - c->compression_level = value; - if (!receive_n_data(fd, &c->chunk_size, sizeof(c->chunk_size))) - return false; - if (!receive_wire_bool(fd, &c->use_sendfile)) - return false; - return true; -} - -static bool receive_delta_fields(int fd, Config* c) { - if (!receive_wire_bool(fd, &c->use_delete)) - return false; - if (!receive_wire_bool(fd, &c->use_incremental)) - return false; - if (!receive_wire_bool(fd, &c->size_only)) - return false; - if (!receive_wire_bool(fd, &c->ignore_times)) - return false; - if (!receive_wire_bool(fd, &c->use_delta)) - return false; - return receive_n_data(fd, &c->delta_block_size, sizeof(c->delta_block_size)) && - receive_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long)); -} - -static bool receive_file_options(int fd, Config* c, ConfigStringBudget* budget) { - if (!receive_wire_bool(fd, &c->backup)) - return false; - char* backup_dir = config_receive_str(fd, budget); - if (!backup_dir) - return false; - if (*backup_dir != '\0') { - c->backup_dir = backup_dir; - } else { - /* The sender serializes an unset (NULL) string as "", so canonicalize the - empty wire value back to NULL to preserve NULL-vs-empty semantics. */ - free(backup_dir); - } - if (!receive_wire_bool(fd, &c->remove_source_files)) - return false; - bool* flags[] = {&c->follow_symlinks, &c->copy_links, &c->safe_links, - &c->copy_unsafe_links, &c->preserve_hard_links, &c->preserve_acls, - &c->preserve_xattrs, &c->preserve_devices, &c->preserve_sparse, - &c->preserve_specials, &c->copy_devices, &c->write_devices}; - for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { - if (!receive_wire_bool(fd, flags[i])) - return false; - } - return true; -} - -static bool receive_selection_options(int fd, Config* c) { - bool* flags[] = {&c->ignore_existing, - &c->existing, - &c->update, - &c->inplace, - &c->delay_updates, - &c->append, - &c->use_fsync, - &c->append_verify, - &c->delete_excluded, - &c->force_delete, - &c->delete_missing_args, - &c->delete_after, - &c->preallocate}; - for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { - if (!receive_wire_bool(fd, flags[i])) - return false; - } - if (!receive_n_data(fd, &c->max_delete, sizeof(c->max_delete))) - return false; - if (!receive_wire_bool(fd, &c->relative)) - return false; - if (!receive_wire_bool(fd, &c->prune_empty_dirs)) - return false; - if (!receive_wire_bool(fd, &c->mkpath)) - return false; - if (!receive_wire_bool(fd, &c->delete_during)) - return false; - return receive_wire_bool(fd, &c->delete_delay); -} - -static bool receive_resume_options(int fd, Config* c, ConfigStringBudget* budget) { - char* temp_dir = config_receive_str(fd, budget); - if (!temp_dir) - return false; - if (*temp_dir != '\0') { - c->temp_dir = temp_dir; - } else { - free(temp_dir); - } - if (!receive_wire_bool(fd, &c->partial)) - return false; - /* These options have NULL client defaults, so the sender transmits an empty - string for "unset". Canonicalize the empty wire value back to NULL so - receivers observe exactly what the client configured (plain --backup, for - example, must not look like --backup-dir ""). */ - char* partial_dir = config_receive_str(fd, budget); - if (!partial_dir) - return false; - if (*partial_dir != '\0') { - c->partial_dir = partial_dir; - } else { - free(partial_dir); - } - char* suffix = config_receive_str(fd, budget); - if (!suffix) - return false; - if (*suffix != '\0') { - c->suffix = suffix; - } else { - free(suffix); - } - if (!receive_wire_bool(fd, &c->delete_before)) - return false; - if (!receive_wire_bool(fd, &c->checksum)) - return false; - if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window))) - return false; - c->compress_choice = config_receive_str(fd, budget); - if (!c->compress_choice) - return false; - c->chmod_spec = config_receive_str(fd, budget); - if (!c->chmod_spec || !receive_wire_bool(fd, &c->skip_compress_set) || - !receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 || - c->skip_compress_count > MAX_SKIP_COMPRESS_SUFFIXES) - return false; - if (c->skip_compress_count > 0) { - c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); - if (!c->skip_compress_suffixes) - return false; - for (int i = 0; i < c->skip_compress_count; i++) { - c->skip_compress_suffixes[i] = config_receive_str(fd, budget); - if (!c->skip_compress_suffixes[i]) - return false; - } - } - return true; -} - -static bool receive_basis_options(int fd, Config* c, ConfigStringBudget* budget) { - int count; - if (!receive_int(fd, &count)) - return false; - if (count < 0 || count > MAX_BASIS_DIRS) - return false; - for (int i = 0; i < count; i++) { - int type; - if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) - return false; - char* path = config_receive_str(fd, budget); - if (!path) - return false; - /* config_basis_append validates and canonicalizes the path; a rejected - path (absolute / traversal / empty) drops the whole connection. */ - bool ok = config_basis_append(c, (BasisDestType)type, path) == 0; - free(path); - if (!ok) - return false; - } - return true; -} - -static bool receive_fuzzy_option(int fd, Config* c) { - return receive_wire_bool(fd, &c->fuzzy); -} - -static bool receive_checksum_options(int fd, Config* c) { - int algo; - if (!receive_int(fd, &algo) || !checksum_algo_valid(algo)) - return false; - c->checksum_algo = algo; - return receive_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed)); -} - -/* --numeric-ids / --usermap / --groupmap / --chown (identity mapping). The - * receiver needs these to apply the ownership the client requested, so they - * cross the config frame. Trailing fields; protocol 2.11.0. */ -static bool send_identity_map(int fd, const IdentityMap* map, int count) { - if (!send_int(fd, count)) - return false; - for (int i = 0; i < count; i++) { - if (!send_int(fd, map[i].from) || !send_int(fd, map[i].to)) - return false; - } - return true; -} - -static bool send_identity_options(int fd, const Config* c) { - return send_int(fd, c->numeric_ids) && send_int(fd, c->chown_uid_set) && - send_int(fd, c->chown_uid) && send_int(fd, c->chown_gid_set) && - send_int(fd, c->chown_gid) && send_identity_map(fd, c->usermap, c->usermap_count) && - send_identity_map(fd, c->groupmap, c->groupmap_count); -} - -static bool receive_identity_map(int fd, int* pcount, IdentityMap** pmap) { - int count; - if (!receive_int(fd, &count) || count < 0 || count > MAX_IDENTITY_MAP) - return false; - if (count > 0) { - IdentityMap* map = calloc((size_t)count, sizeof(IdentityMap)); - if (!map) - return false; - for (int i = 0; i < count; i++) { - if (!receive_int(fd, &map[i].from) || !receive_int(fd, &map[i].to)) { - free(map); - return false; - } - } - *pmap = map; - } - *pcount = count; - return true; -} - -static bool receive_identity_options(int fd, Config* c) { - int numeric_ids; - if (!receive_int(fd, &numeric_ids) || !valid_wire_bool(numeric_ids)) - return false; - c->numeric_ids = numeric_ids != 0; - if (!receive_wire_bool(fd, &c->chown_uid_set) || !receive_int(fd, &c->chown_uid) || - !receive_wire_bool(fd, &c->chown_gid_set) || !receive_int(fd, &c->chown_gid)) - return false; - if (c->chown_uid < IDENTITY_MATCH_ANY || c->chown_gid < IDENTITY_MATCH_ANY) - return false; - return receive_identity_map(fd, &c->usermap_count, &c->usermap) && - receive_identity_map(fd, &c->groupmap_count, &c->groupmap); -} - -/* -U/--atimes, -N/--crtimes (affect both sender capture and receiver apply) - * and -O/--omit-dir-times, -J/--omit-link-times (receiver-side prefs) all cross - * the wire so the receiver knows what to apply / suppress. --open-noatime is - * client-only (it only governs the sender's source reads) and is never - * serialized. Trailing fields; protocol 2.12.0. */ -static bool send_metadata_times_options(int fd, const Config* c) { - return send_int(fd, c->preserve_atimes) && send_int(fd, c->preserve_crtimes) && - send_int(fd, c->omit_dir_times) && send_int(fd, c->omit_link_times); -} - -static bool receive_metadata_times_options(int fd, Config* c) { - return receive_wire_bool(fd, &c->preserve_atimes) && - receive_wire_bool(fd, &c->preserve_crtimes) && receive_wire_bool(fd, &c->omit_dir_times) && - receive_wire_bool(fd, &c->omit_link_times); -} - -/* Phase 4 symlink-trust: --munge-links and -K/--keep-dirlinks. Both CROSS the - * wire (the receiver unmunges symlink targets and, with -K, follows an in-root - * destination symlink-to-directory). -k/--copy-dirlinks is sender-only and is - * never serialized. Trailing fields; protocol 2.13.0. */ -static bool send_symlink_trust_options(int fd, const Config* c) { - return send_int(fd, c->munge_links) && send_int(fd, c->keep_dirlinks); -} - -static bool receive_symlink_trust_options(int fd, Config* c) { - return receive_wire_bool(fd, &c->munge_links) && receive_wire_bool(fd, &c->keep_dirlinks); -} - -/* -X/--xattrs, -A/--acls, --fake-super (Phase-4). The receiver learns - * preserve_xattrs/preserve_acls from the earlier file-options block and - * recomputes the derived use_xattrs there; only --fake-super (receiver-side - * behavior) needs an extra wire bit. Trailing field; protocol 2.13.0. */ -static bool send_phase4_xattr_options(int fd, const Config* c) { - return send_int(fd, c->fake_super); -} - -static bool receive_phase4_xattr_options(int fd, Config* c) { - if (!receive_wire_bool(fd, &c->fake_super)) - return false; - c->use_xattrs = c->preserve_acls || c->preserve_xattrs; - return true; -} - -/* Daemon module selection (Wave A, protocol 2.15.0). Trailing string on the - * config frame, sent after the Phase-4 xattr block and before the ack. The - * client composes it from a host::module/path destination; an unset module is - * serialized as "" and canonicalized back to NULL on receive so the two never - * look different to a peer. */ -static bool send_daemon_module(int fd, const Config* c) { - return send_str(fd, c->module ? c->module : ""); -} - -static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) { +/* Daemon module name (Wave A, protocol 2.15.0): an unset module is "" (-> NULL + * on receive). A hostile over-long/invalid name is rejected with an explicit + * STATUS_ERROR rather than logged and accepted. */ +static bool config_receive_module(int fd, Config* c, ConfigStringBudget* budget) { char* module = config_receive_str(fd, budget); if (!module) return false; - /* Guard against a hostile client flooding the log with an over-long module - * name: only an empty string (module-less) or a valid module name - * (bounded by DAEMON_MAX_MODULE_NAME) is accepted. This is an input - * guard, not a wire-format change. */ if (*module != '\0' && !daemon_module_name_valid(module)) { log_message(LOG_LEVEL_WARNING, "Daemon client sent an invalid or over-long module name"); free(module); @@ -1228,27 +785,15 @@ static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) return true; } -/* Daemon password credentials (A7 remediation, protocol 2.19.0). A single - * presence int is followed, when set, by ONLY the username; the password is - * never serialized. The daemon answers an auth-required module with the SCRAM - * challenge (see the auth exchange below). */ -static bool send_daemon_auth(int fd, const Config* c) { - bool present = c->auth_user != NULL && c->auth_user[0] != '\0'; - if (!send_int(fd, present ? 1 : 0)) - return false; - if (!present) - return true; - /* Redacted send: the username must never reach a --verbose debug log. */ - return send_str_redacted(fd, c->auth_user); -} - -static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) { +/* Daemon auth username (A7 remediation, protocol 2.19.0): a presence int is + * followed, when set, by ONLY the redacted username; the password is never + * serialized. */ +static bool config_receive_auth_user(int fd, Config* c, ConfigStringBudget* budget) { int present; if (!receive_int(fd, &present) || !valid_wire_bool(present)) return false; if (!present) return true; - /* Redacted receive: never log the incoming username body. */ char* user = config_receive_str_redacted(fd, budget); if (!user) return false; @@ -1261,6 +806,308 @@ static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) { return true; } +static bool config_send_auth_user(int fd, const Config* c) { + bool present = c->auth_user != NULL && c->auth_user[0] != '\0'; + if (!send_int(fd, present ? 1 : 0)) + return false; + if (!present) + return true; + /* Redacted send: the username must never reach a --verbose debug log. */ + return send_str_redacted(fd, c->auth_user); +} + +static bool config_receive_checksum_algo(int fd, int* value) { + int algo; + if (!receive_int(fd, &algo) || !checksum_algo_valid(algo)) + return false; + *value = algo; + return true; +} + +static bool config_receive_super_mode(int fd, SuperMode* value) { + int mode; + if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) + return false; + *value = (SuperMode)mode; + return true; +} + +/* chown override ids: IDENTITY_MATCH_ANY (-1) is the lowest legal value. */ +static bool config_receive_identity_id(int fd, int32_t* value) { + int v; + if (!receive_int(fd, &v) || v < IDENTITY_MATCH_ANY) + return false; + *value = v; + return true; +} + +/* Read a peer-controlled count into a LOCAL, validate the range, and only then + * publish it through `*value`. Writing through `*value` before validating + * leaves the Config holding an over-cap count (e.g. 999999999) whose backing + * array is still NULL; the receive error path then runs config_delete(), which + * walks the array and dereferences NULL. Leaving `*value` untouched on failure + * also keeps the failed Config in a coherent, safely-deletable state. */ +static bool config_receive_skip_count(int fd, int* value) { + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_SKIP_COMPRESS_SUFFIXES) + return false; + *value = v; + return true; +} + +static bool config_receive_basis_count(int fd, int* value) { + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_BASIS_DIRS) + return false; + *value = v; + return true; +} + +static bool config_receive_idmap_count(int fd, int* value) { + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_IDENTITY_MAP) + return false; + *value = v; + return true; +} + +static bool config_receive_copy_as_presence(int fd, bool* value) { + int present; + if (!receive_int(fd, &present) || !valid_wire_bool(present)) + return false; + *value = present != 0; + return true; +} + +/* --copy-as ids are forced onto the ownership path, so a hostile peer must not + * smuggle a negative sentinel. */ +static bool config_receive_copy_as_id(int fd, int32_t* value) { + int v; + if (!receive_int(fd, &v) || v < 0) + return false; + *value = v; + return true; +} + +static bool send_skip_compress_suffixes(int fd, const Config* c) { + for (int i = 0; i < c->skip_compress_count; i++) { + if (!send_str(fd, c->skip_compress_suffixes[i])) + return false; + } + return true; +} + +static bool receive_skip_compress_suffixes(int fd, Config* c, ConfigStringBudget* budget) { + if (c->skip_compress_count <= 0) + return true; + c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); + if (!c->skip_compress_suffixes) + return false; + for (int i = 0; i < c->skip_compress_count; i++) { + c->skip_compress_suffixes[i] = config_receive_str(fd, budget); + if (!c->skip_compress_suffixes[i]) + return false; + } + return true; +} + +static bool send_basis_entries(int fd, const Config* c) { + for (int i = 0; i < c->basis_count; i++) { + if (!send_int(fd, (int)c->basis_dirs[i].type) || + !send_str(fd, c->basis_dirs[i].path ? c->basis_dirs[i].path : "")) + return false; + } + return true; +} + +static bool receive_basis_entries(int fd, Config* c, ConfigStringBudget* budget) { + /* The count was read by the preceding INT_BASISCOUNT entry; config_basis_append + * rebuilds basis_count as it validates and canonicalizes each path. */ + int count = c->basis_count; + c->basis_count = 0; + for (int i = 0; i < count; i++) { + int type; + if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) + return false; + char* path = config_receive_str(fd, budget); + if (!path) + return false; + bool ok = config_basis_append(c, (BasisDestType)type, path) == 0; + free(path); + if (!ok) + return false; + } + return true; +} + +static bool send_identity_entries(int fd, const IdentityMap* map, int count) { + for (int i = 0; i < count; i++) { + if (!send_int(fd, map[i].from) || !send_int(fd, map[i].to)) + return false; + } + return true; +} + +static bool receive_identity_entries(int fd, ConfigStringBudget* budget, int count, + IdentityMap** out) { + (void)budget; + if (count <= 0) + return true; + IdentityMap* map = calloc((size_t)count, sizeof(IdentityMap)); + if (!map) + return false; + for (int i = 0; i < count; i++) { + if (!receive_int(fd, &map[i].from) || !receive_int(fd, &map[i].to)) { + free(map); + return false; + } + } + *out = map; + return true; +} + +/* --------------------------------------------------------------------------- + * KIND dispatch. A table entry X(member, ctype, def, KIND) expands to + * CONFIG_SEND_(member) in a sender and CONFIG_RECV_(member) in a + * receiver. Send macros are bool expressions; receive macros are bool + * expressions too (strings allocate through `budget`). + * ------------------------------------------------------------------------- */ +#define CONFIG_SEND_BOOL(name) send_int(fd, c->name) +#define CONFIG_RECV_BOOL(name) receive_wire_bool(fd, &c->name) + +#define CONFIG_SEND_INT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT(name) receive_int(fd, &c->name) + +#define CONFIG_SEND_RAW(name) send_n_data(fd, &c->name, sizeof(c->name)) +#define CONFIG_RECV_RAW(name) receive_n_data(fd, &c->name, sizeof(c->name)) + +#define CONFIG_SEND_BOOL_8BIT(name) \ + (send_int(fd, c->name) && (protocol_set_8_bit_output(c->name), true)) +#define CONFIG_RECV_BOOL_8BIT(name) \ + (receive_wire_bool(fd, &c->name) && (protocol_set_8_bit_output(c->name), true)) + +#define CONFIG_SEND_RAW_MAXALLOC(name) send_n_data(fd, &c->name, sizeof(c->name)) +#define CONFIG_RECV_RAW_MAXALLOC(name) config_receive_max_alloc(fd, &c->name) + +/* --delta is sent as (use_delta && !whole_file); whole_file never crosses the + * wire, so the receiver observes the effective bit. */ +#define CONFIG_SEND_DERIVED_DELTA(name) send_int(fd, c->name && !c->whole_file) +#define CONFIG_RECV_DERIVED_DELTA(name) receive_wire_bool(fd, &c->name) + +#define CONFIG_SEND_STR(name) send_str(fd, c->name) +#define CONFIG_RECV_STR(name) ((c->name = config_receive_str(fd, budget)) != NULL) + +#define CONFIG_SEND_STR_OPT(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_OPT(name) config_receive_optional_str(fd, budget, &c->name) + +#define CONFIG_SEND_STR_KEEP(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_KEEP(name) ((c->name = config_receive_str(fd, budget)) != NULL) + +#define CONFIG_SEND_STR_MODULE(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_MODULE(name) config_receive_module(fd, c, budget) + +#define CONFIG_SEND_STR_REDACTED_AUTH(name) config_send_auth_user(fd, c) +#define CONFIG_RECV_STR_REDACTED_AUTH(name) config_receive_auth_user(fd, c, budget) + +#define CONFIG_SEND_INT_CHECKSUM_ALGO(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_CHECKSUM_ALGO(name) config_receive_checksum_algo(fd, &c->name) + +#define CONFIG_SEND_SUPERMODE(name) send_int(fd, (int)c->name) +#define CONFIG_RECV_SUPERMODE(name) config_receive_super_mode(fd, &c->name) + +#define CONFIG_SEND_INT_IDENTITY(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_IDENTITY(name) config_receive_identity_id(fd, &c->name) + +#define CONFIG_SEND_INT_SKIPCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_SKIPCOUNT(name) config_receive_skip_count(fd, &c->name) + +#define CONFIG_SEND_INT_BASISCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_BASISCOUNT(name) config_receive_basis_count(fd, &c->name) + +#define CONFIG_SEND_INT_IDMAPCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_IDMAPCOUNT(name) config_receive_idmap_count(fd, &c->name) + +/* use_xattrs is derived receiver-side from the xattr/acl preservation flags + * that crossed the wire in the file-options block. */ +#define CONFIG_SEND_BOOL_XATTR_DERIVE(name) send_int(fd, c->name) +#define CONFIG_RECV_BOOL_XATTR_DERIVE(name) \ + (receive_wire_bool(fd, &c->name) && \ + (c->use_xattrs = (c->preserve_acls || c->preserve_xattrs), true)) + +#define CONFIG_SEND_COPY_AS_PRESENCE(name) send_int(fd, c->name ? 1 : 0) +#define CONFIG_RECV_COPY_AS_PRESENCE(name) config_receive_copy_as_presence(fd, &c->name) + +/* The uid/gid follow the presence int only when --copy-as is set. */ +#define CONFIG_SEND_COPY_AS_ID(name) (!c->copy_as_set || send_int(fd, c->name)) +#define CONFIG_RECV_COPY_AS_ID(name) (!c->copy_as_set || config_receive_copy_as_id(fd, &c->name)) + +#define CONFIG_SEND_BLOCK_SKIP_SUFFIXES(name) send_skip_compress_suffixes(fd, c) +#define CONFIG_RECV_BLOCK_SKIP_SUFFIXES(name) receive_skip_compress_suffixes(fd, c, budget) + +#define CONFIG_SEND_BLOCK_BASIS(name) send_basis_entries(fd, c) +#define CONFIG_RECV_BLOCK_BASIS(name) receive_basis_entries(fd, c, budget) + +#define CONFIG_SEND_BLOCK_IDMAP(name) send_identity_entries(fd, c->name, c->name##_count) +#define CONFIG_RECV_BLOCK_IDMAP(name) \ + receive_identity_entries(fd, budget, c->name##_count, &c->name) + +/* One table entry, applied in sequence. XSEND/XRECV are statement macros so + * consecutive entries read as a plain sequence of assignments. */ +#define XSEND(name, ctype, def, kind) ok = ok && (CONFIG_SEND_##kind(name)); +#define XRECV(name, ctype, def, kind) ok = ok && (CONFIG_RECV_##kind(name)); + +#define CONFIG_DEFINE_SEND(fn, fields) \ + static bool fn(int fd, const Config* c) { \ + bool ok = true; \ + fields(XSEND) return ok; \ + } + +#define CONFIG_DEFINE_RECV(fn, fields) \ + static bool fn(int fd, Config* c, ConfigStringBudget* budget) { \ + (void)budget; \ + bool ok = true; \ + fields(XRECV) return ok; \ + } + +CONFIG_DEFINE_SEND(send_core_fields, CONFIG_WIRE_CORE_FIELDS) +CONFIG_DEFINE_SEND(send_delta_fields, CONFIG_WIRE_DELTA_FIELDS) +CONFIG_DEFINE_SEND(send_file_options, CONFIG_WIRE_FILE_OPTIONS_FIELDS) +CONFIG_DEFINE_SEND(send_selection_options, CONFIG_WIRE_SELECTION_FIELDS) +CONFIG_DEFINE_SEND(send_resume_options, CONFIG_WIRE_RESUME_FIELDS) +CONFIG_DEFINE_SEND(send_basis_options, CONFIG_WIRE_BASIS_FIELDS) +CONFIG_DEFINE_SEND(send_fuzzy_option, CONFIG_WIRE_FUZZY_FIELDS) +CONFIG_DEFINE_SEND(send_checksum_options, CONFIG_WIRE_CHECKSUM_FIELDS) +CONFIG_DEFINE_SEND(send_identity_options, CONFIG_WIRE_IDENTITY_FIELDS) +CONFIG_DEFINE_SEND(send_metadata_times_options, CONFIG_WIRE_METADATA_TIMES_FIELDS) +CONFIG_DEFINE_SEND(send_symlink_trust_options, CONFIG_WIRE_SYMLINK_TRUST_FIELDS) +CONFIG_DEFINE_SEND(send_phase4_xattr_options, CONFIG_WIRE_XATTR_FIELDS) +CONFIG_DEFINE_SEND(send_daemon_module, CONFIG_WIRE_MODULE_FIELDS) +CONFIG_DEFINE_SEND(send_daemon_auth, CONFIG_WIRE_DAEMON_AUTH_FIELDS) +CONFIG_DEFINE_SEND(send_iconv_spec, CONFIG_WIRE_ICONV_FIELDS) +CONFIG_DEFINE_SEND(send_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS) +CONFIG_DEFINE_SEND(send_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS) + +CONFIG_DEFINE_RECV(receive_core_fields, CONFIG_WIRE_CORE_FIELDS) +CONFIG_DEFINE_RECV(receive_delta_fields, CONFIG_WIRE_DELTA_FIELDS) +CONFIG_DEFINE_RECV(receive_file_options, CONFIG_WIRE_FILE_OPTIONS_FIELDS) +CONFIG_DEFINE_RECV(receive_selection_options, CONFIG_WIRE_SELECTION_FIELDS) +CONFIG_DEFINE_RECV(receive_resume_options, CONFIG_WIRE_RESUME_FIELDS) +CONFIG_DEFINE_RECV(receive_basis_options, CONFIG_WIRE_BASIS_FIELDS) +CONFIG_DEFINE_RECV(receive_fuzzy_option, CONFIG_WIRE_FUZZY_FIELDS) +CONFIG_DEFINE_RECV(receive_checksum_options, CONFIG_WIRE_CHECKSUM_FIELDS) +CONFIG_DEFINE_RECV(receive_identity_options, CONFIG_WIRE_IDENTITY_FIELDS) +CONFIG_DEFINE_RECV(receive_metadata_times_options, CONFIG_WIRE_METADATA_TIMES_FIELDS) +CONFIG_DEFINE_RECV(receive_symlink_trust_options, CONFIG_WIRE_SYMLINK_TRUST_FIELDS) +CONFIG_DEFINE_RECV(receive_phase4_xattr_options, CONFIG_WIRE_XATTR_FIELDS) +CONFIG_DEFINE_RECV(receive_daemon_module, CONFIG_WIRE_MODULE_FIELDS) +CONFIG_DEFINE_RECV(receive_daemon_auth, CONFIG_WIRE_DAEMON_AUTH_FIELDS) +CONFIG_DEFINE_RECV(receive_iconv_spec, CONFIG_WIRE_ICONV_FIELDS) +CONFIG_DEFINE_RECV(receive_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS) +CONFIG_DEFINE_RECV(receive_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS) + +#undef XSEND +#undef XRECV + /* Client half of the SCRAM challenge/response (A7 remediation). Called by * config_send after the config frame is written and the server answered * STATUS_AUTH_CHALLENGE. The plaintext password lives only in @@ -1349,96 +1196,35 @@ static bool client_auth_exchange(int fd, const Config* c) { return ok; } -/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame, - * sent after the Wave A/B daemon-auth block and before the ack, so the - * receiver knows the wire charset before the first file name arrives. The full - * spec travels (LOCAL,REMOTE) and each end derives its own LOCAL and the wire - * (REMOTE) charset symmetrically; an unset spec is serialized as "" and - * canonicalized back to NULL on receive. */ -static bool send_iconv_spec(int fd, const Config* c) { - return send_str(fd, c->iconv_spec ? c->iconv_spec : ""); -} +/* The --iconv, --super/--no-super and --copy-as segment functions are + * generated above from CONFIG_WIRE_ICONV_FIELDS, CONFIG_WIRE_PRIVILEGE_FIELDS + * and CONFIG_WIRE_COPY_AS_FIELDS. */ -static bool receive_iconv_spec(int fd, Config* c, ConfigStringBudget* budget) { - char* spec = config_receive_str(fd, budget); - if (!spec) - return false; - if (*spec == '\0') { - free(spec); - c->iconv_spec = NULL; - return true; - } - c->iconv_spec = spec; - return true; -} - -/* --super / --no-super privilege policy (P7 Wave E, protocol 2.18.0). One - * trailing int on the config frame, sent after the --iconv spec and before the - * STATUS_OK ack, so the receiver knows whether it may attempt super-user - * activities (ownership application, char/block device-node creation) that are - * already confined below the authorized receive root. The received value is - * validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by - * validate_received_config). */ -static bool send_privilege_options(int fd, const Config* c) { - return send_int(fd, (int)c->super_mode); -} - -static bool receive_privilege_options(int fd, Config* c) { - int mode; - if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) - return false; - c->super_mode = (SuperMode)mode; - return true; -} - -/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Trailing block on the - * config frame, sent after the --super int and before the ack: a presence int, - * then (when set) the target uid and gid as int32. The receiver forces the - * ownership of every entry it writes to these ids through the confined - * fd-relative identity path and requires privilege; both ids are validated - * `>= 0` on receive so a hostile peer cannot smuggle a negative (sentinel) - * value into the ownership path. */ -static bool send_copy_as_options(int fd, const Config* c) { - if (!send_int(fd, c->copy_as_set ? 1 : 0)) - return false; - if (!c->copy_as_set) - return true; - return send_int(fd, c->copy_as_uid) && send_int(fd, c->copy_as_gid); -} - -static bool receive_copy_as_options(int fd, Config* c) { - int present; - if (!receive_int(fd, &present) || !valid_wire_bool(present)) - return false; - if (!present) { - c->copy_as_set = false; - return true; - } - int uid, gid; - if (!receive_int(fd, &uid) || !receive_int(fd, &gid) || uid < 0 || gid < 0) - return false; - c->copy_as_set = true; - c->copy_as_uid = uid; - c->copy_as_gid = gid; - return true; +bool config_send_wire_block(int file_descriptor, const Config* config) { + protocol_session_set_max_alloc(NULL, config->max_alloc); + /* The version is the frame header: the receiver validates it before parsing + * any other field (see config_receive_with_validate), so it is not part of + * the generated segment sequence. It is still declared once, in + * CONFIG_WIRE_HEADER_FIELDS. */ + return send_str(file_descriptor, config->version) && send_core_fields(file_descriptor, config) && + send_delta_fields(file_descriptor, config) && send_file_options(file_descriptor, config) && + send_selection_options(file_descriptor, config) && + send_resume_options(file_descriptor, config) && + send_basis_options(file_descriptor, config) && + send_fuzzy_option(file_descriptor, config) && + send_checksum_options(file_descriptor, config) && + send_identity_options(file_descriptor, config) && + send_metadata_times_options(file_descriptor, config) && + send_symlink_trust_options(file_descriptor, config) && + send_phase4_xattr_options(file_descriptor, config) && + send_daemon_module(file_descriptor, config) && send_daemon_auth(file_descriptor, config) && + send_iconv_spec(file_descriptor, config) && + send_privilege_options(file_descriptor, config) && + send_copy_as_options(file_descriptor, config); } bool config_send(int file_descriptor, const Config* config) { - protocol_session_set_max_alloc(NULL, config->max_alloc); - if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || - !send_file_options(file_descriptor, config) || - !send_selection_options(file_descriptor, config) || - !send_resume_options(file_descriptor, config) || - !send_basis_options(file_descriptor, config) || !send_fuzzy_option(file_descriptor, config) || - !send_checksum_options(file_descriptor, config) || - !send_identity_options(file_descriptor, config) || - !send_metadata_times_options(file_descriptor, config) || - !send_symlink_trust_options(file_descriptor, config) || - !send_phase4_xattr_options(file_descriptor, config) || - !send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) || - !send_iconv_spec(file_descriptor, config) || - !send_privilege_options(file_descriptor, config) || - !send_copy_as_options(file_descriptor, config)) + if (!config_send_wire_block(file_descriptor, config)) return false; Status status; if (!receive_status(file_descriptor, &status)) @@ -1477,22 +1263,22 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val goto error; } if (!receive_core_fields(file_descriptor, config, &budget) || - !receive_delta_fields(file_descriptor, config) || + !receive_delta_fields(file_descriptor, config, &budget) || !receive_file_options(file_descriptor, config, &budget) || - !receive_selection_options(file_descriptor, config) || + !receive_selection_options(file_descriptor, config, &budget) || !receive_resume_options(file_descriptor, config, &budget) || !receive_basis_options(file_descriptor, config, &budget) || - !receive_fuzzy_option(file_descriptor, config) || - !receive_checksum_options(file_descriptor, config) || - !receive_identity_options(file_descriptor, config) || - !receive_metadata_times_options(file_descriptor, config) || - !receive_symlink_trust_options(file_descriptor, config) || - !receive_phase4_xattr_options(file_descriptor, config) || + !receive_fuzzy_option(file_descriptor, config, &budget) || + !receive_checksum_options(file_descriptor, config, &budget) || + !receive_identity_options(file_descriptor, config, &budget) || + !receive_metadata_times_options(file_descriptor, config, &budget) || + !receive_symlink_trust_options(file_descriptor, config, &budget) || + !receive_phase4_xattr_options(file_descriptor, config, &budget) || !receive_daemon_module(file_descriptor, config, &budget) || !receive_daemon_auth(file_descriptor, config, &budget) || !receive_iconv_spec(file_descriptor, config, &budget) || - !receive_privilege_options(file_descriptor, config) || - !receive_copy_as_options(file_descriptor, config)) + !receive_privilege_options(file_descriptor, config, &budget) || + !receive_copy_as_options(file_descriptor, config, &budget)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { diff --git a/src/shared/config.h b/src/shared/config.h index 0aba9d6..6e2e425 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -75,87 +75,209 @@ typedef struct { * privilege_super_mode_permitted() in identity.h. */ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode; +/* =========================================================================== + * Config wire-field table (single source of truth for protocol 2.20.0). + * + * Every field below crosses the wire. The table is the ONLY place a + * serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare + * the struct member, config_set_defaults() expands it to assign the default, + * and config_send_wire_block()/config_receive_with_validate() expand the + * per-segment lists to emit/consume the frame in exactly this order. Do NOT + * reorder entries and do NOT change a field's segment/KIND without a + * PROTOCOL_VERSION bump: the resulting byte stream is pinned by + * test_config_wire_golden(). + * + * Entry layout: X(MEMBER, CTYPE, DEFAULT, KIND) + * MEMBER struct member name (public; never rename) + * CTYPE C type of the member + * DEFAULT default-value expression used by config_set_defaults() + * KIND wire codec, dispatched to CONFIG_SEND_/CONFIG_RECV_ + * in config.c (strings receive through a ConfigStringBudget). + * + * Fields with genuinely custom logic keep dedicated helpers but are still + * declared here exactly once: the protocol-version handshake (HEADER), the + * daemon SCRAM auth username (STR_REDACTED_AUTH), the daemon module name + * (STR_MODULE), repeated count+array blocks (BLOCK_*), --copy-as presence + * (COPY_AS_*), and the derived --delta / use_xattrs bits (DERIVED_DELTA, + * BOOL_XATTR_DERIVE). + * + * SCOPE: this table covers ONLY the serialized wire frame. The client CLI + * option tables in client_cli.c (OPTION_TABLE / NEGATABLE_OPTIONS) are still + * hand-maintained and are deliberately NOT generated from this table: the CLI + * surface carries client-only fields and flag/alias/negation semantics that + * have no wire representation. Do not assume the two are folded together. + * =========================================================================== */ +#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR) + +#define CONFIG_WIRE_CORE_FIELDS(X) \ + X(eight_bit_output, bool, false, BOOL_8BIT) \ + X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \ + X(send_directory, char*, NULL, STR) \ + X(receive_root_directory, char*, NULL, STR) \ + X(save_to_disk, bool, false, BOOL) \ + X(use_multithreading, bool, false, BOOL) \ + X(use_chunk_serialization, bool, false, BOOL) \ + X(use_compression, bool, false, BOOL) \ + X(use_metadata, bool, false, BOOL) \ + X(use_executability, bool, false, BOOL) \ + X(compression_level, int, 5, INT) \ + X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \ + X(use_sendfile, bool, false, BOOL) + +#define CONFIG_WIRE_DELTA_FIELDS(X) \ + X(use_delete, bool, false, BOOL) \ + X(use_incremental, bool, false, BOOL) \ + X(size_only, bool, false, BOOL) \ + X(ignore_times, bool, false, BOOL) \ + X(use_delta, bool, false, DERIVED_DELTA) \ + X(delta_block_size, uint32_t, DELTA_BLOCK_SIZE_DEFAULT, RAW) \ + X(delta_max_file_size, unsigned long long, DELTA_MAX_FILE_SIZE, RAW) + +#define CONFIG_WIRE_FILE_OPTIONS_FIELDS(X) \ + X(backup, bool, false, BOOL) \ + X(backup_dir, char*, NULL, STR_OPT) \ + X(remove_source_files, bool, false, BOOL) \ + X(follow_symlinks, bool, false, BOOL) \ + X(copy_links, bool, false, BOOL) \ + X(safe_links, bool, false, BOOL) \ + X(copy_unsafe_links, bool, false, BOOL) \ + X(preserve_hard_links, bool, false, BOOL) \ + X(preserve_acls, bool, false, BOOL) \ + X(preserve_xattrs, bool, false, BOOL) \ + X(preserve_devices, bool, false, BOOL) \ + X(preserve_sparse, bool, false, BOOL) \ + X(preserve_specials, bool, false, BOOL) \ + X(copy_devices, bool, false, BOOL) \ + X(write_devices, bool, false, BOOL) + +#define CONFIG_WIRE_SELECTION_FIELDS(X) \ + X(ignore_existing, bool, false, BOOL) \ + X(existing, bool, false, BOOL) \ + X(update, bool, false, BOOL) \ + X(inplace, bool, false, BOOL) \ + X(delay_updates, bool, false, BOOL) \ + X(append, bool, false, BOOL) \ + X(use_fsync, bool, false, BOOL) \ + X(append_verify, bool, false, BOOL) \ + X(delete_excluded, bool, false, BOOL) \ + X(force_delete, bool, false, BOOL) \ + X(delete_missing_args, bool, false, BOOL) \ + X(delete_after, bool, false, BOOL) \ + X(preallocate, bool, false, BOOL) \ + X(max_delete, int, -1, RAW) \ + X(relative, bool, false, BOOL) \ + X(prune_empty_dirs, bool, false, BOOL) \ + X(mkpath, bool, false, BOOL) \ + X(delete_during, bool, false, BOOL) \ + X(delete_delay, bool, false, BOOL) + +#define CONFIG_WIRE_RESUME_FIELDS(X) \ + X(temp_dir, char*, NULL, STR_OPT) \ + X(partial, bool, false, BOOL) \ + X(partial_dir, char*, NULL, STR_OPT) \ + X(suffix, char*, NULL, STR_OPT) \ + X(delete_before, bool, false, BOOL) \ + X(checksum, bool, false, BOOL) \ + X(modify_window, int, 0, RAW) \ + X(compress_choice, char*, NULL, STR_KEEP) \ + X(chmod_spec, char*, NULL, STR_KEEP) \ + X(skip_compress_set, bool, false, BOOL) \ + X(skip_compress_count, int, 0, INT_SKIPCOUNT) \ + X(skip_compress_suffixes, char**, NULL, BLOCK_SKIP_SUFFIXES) + +#define CONFIG_WIRE_BASIS_FIELDS(X) \ + X(basis_count, int, 0, INT_BASISCOUNT) \ + X(basis_dirs, BasisDest*, NULL, BLOCK_BASIS) + +#define CONFIG_WIRE_FUZZY_FIELDS(X) X(fuzzy, bool, false, BOOL) + +#define CONFIG_WIRE_CHECKSUM_FIELDS(X) \ + X(checksum_algo, int, CHECKSUM_ALGO_XXH64, INT_CHECKSUM_ALGO) \ + X(checksum_seed, uint64_t, 0, RAW) + +#define CONFIG_WIRE_IDENTITY_FIELDS(X) \ + X(numeric_ids, bool, false, BOOL) \ + X(chown_uid_set, bool, false, BOOL) \ + X(chown_uid, int32_t, 0, INT_IDENTITY) \ + X(chown_gid_set, bool, false, BOOL) \ + X(chown_gid, int32_t, 0, INT_IDENTITY) \ + X(usermap_count, int, 0, INT_IDMAPCOUNT) \ + X(usermap, IdentityMap*, NULL, BLOCK_IDMAP) \ + X(groupmap_count, int, 0, INT_IDMAPCOUNT) \ + X(groupmap, IdentityMap*, NULL, BLOCK_IDMAP) + +#define CONFIG_WIRE_METADATA_TIMES_FIELDS(X) \ + X(preserve_atimes, bool, false, BOOL) \ + X(preserve_crtimes, bool, false, BOOL) \ + X(omit_dir_times, bool, false, BOOL) \ + X(omit_link_times, bool, false, BOOL) + +#define CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \ + X(munge_links, bool, false, BOOL) \ + X(keep_dirlinks, bool, false, BOOL) + +#define CONFIG_WIRE_XATTR_FIELDS(X) X(fake_super, bool, false, BOOL_XATTR_DERIVE) + +#define CONFIG_WIRE_MODULE_FIELDS(X) X(module, char*, NULL, STR_MODULE) + +#define CONFIG_WIRE_DAEMON_AUTH_FIELDS(X) X(auth_user, char*, NULL, STR_REDACTED_AUTH) + +#define CONFIG_WIRE_ICONV_FIELDS(X) X(iconv_spec, char*, NULL, STR_OPT) + +#define CONFIG_WIRE_PRIVILEGE_FIELDS(X) X(super_mode, SuperMode, SUPER_MODE_AUTO, SUPERMODE) + +#define CONFIG_WIRE_COPY_AS_FIELDS(X) \ + X(copy_as_set, bool, false, COPY_AS_PRESENCE) \ + X(copy_as_uid, int32_t, 0, COPY_AS_ID) \ + X(copy_as_gid, int32_t, 0, COPY_AS_ID) + +/* All serialized fields, in exact wire order. Concatenating the per-segment + * lists here is what keeps the declaration order = the wire order. */ +#define CONFIG_WIRE_FIELDS(X) \ + CONFIG_WIRE_HEADER_FIELDS(X) \ + CONFIG_WIRE_CORE_FIELDS(X) \ + CONFIG_WIRE_DELTA_FIELDS(X) \ + CONFIG_WIRE_FILE_OPTIONS_FIELDS(X) \ + CONFIG_WIRE_SELECTION_FIELDS(X) \ + CONFIG_WIRE_RESUME_FIELDS(X) \ + CONFIG_WIRE_BASIS_FIELDS(X) \ + CONFIG_WIRE_FUZZY_FIELDS(X) \ + CONFIG_WIRE_CHECKSUM_FIELDS(X) \ + CONFIG_WIRE_IDENTITY_FIELDS(X) \ + CONFIG_WIRE_METADATA_TIMES_FIELDS(X) \ + CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \ + CONFIG_WIRE_XATTR_FIELDS(X) \ + CONFIG_WIRE_MODULE_FIELDS(X) \ + CONFIG_WIRE_DAEMON_AUTH_FIELDS(X) \ + CONFIG_WIRE_ICONV_FIELDS(X) \ + CONFIG_WIRE_PRIVILEGE_FIELDS(X) \ + CONFIG_WIRE_COPY_AS_FIELDS(X) + typedef struct Config { - char* version; - char* send_directory; - char* receive_root_directory; - bool save_to_disk; - bool use_multithreading; /* -j/--threads=N: number of parallel scanner worker threads for the -m * pipeline. 0 (the default, also set by bare -j/--threads) means "use the * scanner's built-in default" (4). CLIENT-ONLY: it is a local scheduling * concern and is NEVER serialized into the wire config frame. */ int scanner_threads; - bool use_chunk_serialization; - bool use_compression; - bool use_sendfile; - bool use_metadata; - bool use_executability; bool metadata_explicitly_disabled; bool show_progress; bool dry_run; - bool remove_source_files; - bool use_delete; - int compression_level; int compression_threads; - unsigned long long chunk_size; int ssh_port; TransportType transport; char* ssh_destination; - /* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a - * host::module/path destination; NULL or "" means "no module" (the ordinary - * standalone-server path). Crosses the wire as a trailing config-frame - * string so the daemon can look the module up in its own config and confine - * the connection to the module's root (never a client-chosen root). */ - char* module; - /* Daemon password authentication (A7 remediation, protocol 2.19.0). - * Client-composed from a --password-file whose first meaningful line is - * `user:password`: the client sends ONLY the username in the config frame - * (auth_user); the literal password is kept in auth_password CLIENT-SIDE for - * the duration of the SCRAM challenge/response and is NEVER serialized. Both - * are NULL when the client has no credentials to present; a module WITHOUT - * `auth users` stays open and the server ignores any credentials that do - * arrive (the client sends them opportunistically and the server decides). */ - char* auth_user; char* auth_password; /* Client-only path of --password-file (never crosses the wire; it is read to * populate auth_user/auth_password before connecting). */ char* password_file; char* fastsync_server_path; - /* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the - * charset of FILE NAMES at the wire boundary. CONVERT_SPEC is - * "LOCAL[,REMOTE]": LOCAL is the charset of our own file names, REMOTE is - * the remote side's charset and defaults to LOCAL. The sender converts - * every path LOCAL->REMOTE before transmitting it; the receiver converts - * every received path back REMOTE->LOCAL before creating/writing it. The - * FULL SPEC crosses the wire as a trailing config-frame string so each end - * derives its own LOCAL and the wire (REMOTE) charset symmetrically. NULL - * (or "") means no conversion: identity with zero overhead. See charset.c - * and the PROTOCOL_VERSION note below. */ - char* iconv_spec; char** exclude_patterns; int exclude_count; char** include_patterns; int include_count; unsigned long long max_size; unsigned long long min_size; - unsigned long long max_alloc; - bool use_incremental; - bool ignore_times; - bool size_only; - bool use_delta; bool whole_file; - /* -y/--fuzzy: when a file must be transferred and the destination holds no - * usable file at the exact path, the receiver may reuse a SIMILAR-named - * existing regular file in the same destination directory as the delta - * basis so the sender transmits only the differences. Crosses the wire - * (the receiver performs the candidate search); the CLI implies - * --incremental + --delta because the similar-basis only matters on the - * receiver-driven delta path. Off by default. */ - bool fuzzy; - int modify_window; - uint32_t delta_block_size; - unsigned long long delta_max_file_size; bool use_tls; char* server_host; int server_port; @@ -170,18 +292,10 @@ typedef struct Config { /* --contimeout: connect()/accept timeout, transport layer only. */ int contimeout; bool quiet; - bool backup; - char* backup_dir; bool stats; int max_depth; FILE* log_file; - bool follow_symlinks; - bool partial; - // Issue #120: Symlink handling - bool copy_links; - bool safe_links; - bool copy_unsafe_links; /* Phase 4 symlink-trust. -k/--copy-dirlinks and --munge-links are * CLIENT/sender-side only (they decide how the SENDER scans and rewrites * symlinks; the receiver never reads them), so they never cross the wire. @@ -189,31 +303,6 @@ typedef struct Config { * symlink-to-directory as a directory) and CROSSES the wire along with * --munge-links (so the receiver knows to unmunge). */ bool copy_dirlinks; /* client-only, sender-side (-k) */ - bool munge_links; /* crosses the wire */ - bool keep_dirlinks; /* crosses the wire (-K) */ - - // Issue #121: Extended metadata preservation - bool preserve_hard_links; - bool preserve_acls; - bool preserve_xattrs; - bool preserve_devices; - bool preserve_sparse; - /* Phase 4 special/devices: preserve special files (FIFOs, sockets) and device - * nodes on the destination by recreating them (mknod/mkfifo) instead of - * transferring content. preserve_specials mirrors rsync --specials (the - * special-file half of -D); preserve_devices mirrors --devices (the device - * half of -D); both CROSS the wire so the receiver knows a special/device - * entry must be recreated rather than written as a regular file. */ - bool preserve_specials; - /* --copy-devices: copy the CONTENT of a source device as an ordinary regular - * file on the destination (rsync's non-privileged safe mode), instead of - * recreating the device node. CROSSES the wire (receiver treats the entry as - * a regular file, which is the default, so this is belt-and-braces). */ - bool copy_devices; - /* --write-devices: write the received data directly INTO an existing device - * node on the destination instead of creating a regular file. Dangeroud; - * see RSYNC_COMPAT.md for the tight gating. CROSSES the wire. */ - bool write_devices; // Issue #122: Output/logging options bool itemize_changes; @@ -223,57 +312,17 @@ typedef struct Config { int debug_level; bool list_only; bool human_readable; - bool eight_bit_output; - // Issue #127: Transfer modes - bool existing; - bool ignore_existing; - bool update; - bool inplace; - bool delay_updates; - bool use_fsync; - bool append; - bool append_verify; - /* --preallocate: allocates the destination file's full expected space up - * front (before any data is written) so a transfer that would overflow disk - * fails fast at allocation time and the file is laid out contiguously, - * avoiding fragmentation. Receiver-side, crosses the wire. */ - bool preallocate; - - // Issue #128: Extended delete options - /* --delete-excluded: also delete destination entries that were excluded on - * the source. Default (off) matches rsync: excluded paths are protected from - * deletion. Crosses the wire (the sender encodes the choice by whether it - * transmits a protected-prefix list with the keep-set manifest). */ - bool delete_excluded; - bool delete_after; - /* --max-delete=NUM: the receiver refuses to delete more than NUM entries per - * run (all-or-nothing: when the extras would exceed NUM nothing is removed and - * the transfer fails with a distinct error). -1 == no client limit (the - * server hard bound MAX_SERVER_DELETE_COUNT still applies). */ - int max_delete; /* --ignore-errors (client-only, never serialized): a sender-side source I/O * error (an unreadable directory during the scan) normally aborts the run so * no deletion happens; with --ignore-errors the scan continues and the * (partial) keep-set is still transmitted so the deletion runs. */ bool ignore_errors; - /* --force (receiver-side): a regular file may replace a destination - * directory by removing that (possibly non-empty, symlink-safe) directory - * tree first, instead of failing the write. Crosses the wire. */ - bool force_delete; /* --ignore-missing-args (client-only, never serialized): a --files-from * entry that does not exist under the source is silently skipped instead of * failing the run. Sender-side only: nothing is sent for it and it never * enters the keep-set. Implied by --delete-missing-args. */ bool ignore_missing_args; - /* --delete-missing-args: implies --ignore-missing-args; additionally each - * missing entry's destination mirror (computed like a present entry's wire - * path) is deleted receiver-side. Crosses the wire and is gated by the - * server's --allow-delete policy like --delete. rsync-parity: independent - * of ordinary --delete processing (it does not imply --delete); a non-empty - * directory mirror is only removed with --force or --delete in effect, and - * the missing-args deletions are not counted toward --max-delete. */ - bool delete_missing_args; // Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are // never serialized to the wire (the receiver must not learn them). @@ -283,23 +332,14 @@ typedef struct Config { bool from0; /* -0/--from0: NUL-delimited *-from files */ bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */ bool per_dir_filter; /* -F: apply per-directory .rsync-filter files */ - bool prune_empty_dirs; bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */ - /* -R/--relative: crosses the wire; with --files-from listed entries keep - * their bare relative destination path (no source-root mirror prefix). */ - bool relative; /* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a * listed file whose ancestor directory is not itself explicitly listed. */ bool no_implied_dirs; /* -d/--dirs: client-only. Transfer the directory entries named by the * source argument / --files-from list without recursing into contents. */ bool dirs; - /* --mkpath: crosses the wire. Tells the server to create the destination - * root directory (and missing leading components below its authorized root) - * at connection start instead of requiring it to already exist. */ - bool mkpath; - // Issue #130: Remote shell/connection options /* -e/--rsh: the remote-shell program used to establish the SSH transport. * NULL means the default "ssh". Client-only launch concern: NEVER crosses * the wire (it is not meaningful to the daemon/server handshake). */ @@ -312,7 +352,6 @@ typedef struct Config { * concern: NEVER crosses the wire. */ int outbuf; bool old_args; - char* temp_dir; /* --remote-option=OPT (Phase 5, long form only): one or more extra command-line * options to append to the REMOTE server invocation over SSH. CLIENT-ONLY: * they are composed into the remote command line by ssh_build_remote_command() @@ -321,34 +360,6 @@ typedef struct Config { * do NOT cross the wire and are never parsed on the receiver process. */ char** remote_options; int remote_option_count; - /* Alternate basis directories, ordered by command-line appearance. Each - * entry's type selects compare/copy/link behavior on an exact match. These - * cross the wire so the receiver can consult them; they are interpreted - * relative to the destination root and confined there. */ - BasisDest* basis_dirs; - int basis_count; - - // PR #174: Partial transfer resumption - char* partial_dir; - - // PR #178: Backup versioning - char* suffix; - - // PR #179: Delete policies - bool delete_before; - - /* rsync deletion-timing family (real from Phase 3). At most one of - delete_before / delete_during / delete_delay / delete_after may be set, and - only together with use_delete (the CLI implies --delete for each of them). - delete_before and delete_during select the EARLY engine mode: the keep-set - manifest is transmitted before any file data and extras are removed then, - acknowledged, before the first data byte. delete_delay and delete_after - select the LATE commit mode: extras are removed only after the whole - transfer has succeeded (plain --delete keeps this mode). The exact - semantics and the divergences from rsync are documented in RSYNC_COMPAT.md - and in config_delete_timing_early() below. */ - bool delete_during; - bool delete_delay; // PR #181: IPv6 and bind address char* address; @@ -370,119 +381,19 @@ typedef struct Config { * MOTD is shown when a daemon offers one). */ bool no_motd; - // PR #183: Checksum comparison - bool checksum; - - // PR #184: Compression algorithm negotiation - char* compress_choice; - char* chmod_spec; - - /* --checksum-choice / --cc and --checksum-seed. checksum_algo is the id of - * the whole-file content-digest algorithm used by the per-file --incremental - * handshake (sender computes it, receiver compares it to skip unchanged - * files) and by the basis-dir content verification. checksum_seed is passed - * to xxHash64 (and to the delta block strong hash, low 32 bits); md5 has no - * seed so it is ignored there. Both cross the wire: the receiver MUST hash - * the on-disk old file with the same algorithm and seed to reach a matching - * digest. Defaults (XXH64 / seed 0) reproduce the pre-existing behavior - * byte-for-byte. */ - int checksum_algo; /* ChecksumAlgo, default CHECKSUM_ALGO_XXH64 */ - uint64_t checksum_seed; /* default 0 */ - - char** skip_compress_suffixes; - int skip_compress_count; - bool skip_compress_set; - - // Issue #131: Identity mapping. These configure whether and how the receiver - // applies ownership when it is actually preserved/applied. ALL of them cross - // the wire (protocol 2.11.0) so the receiver resolves and applies ownership - // with the exact policy the client requested. Plain -M/--preserve still does - // NOT apply ownership (FastSync's deliberate conservative default); it is - // only attempted when at least one of these is set (see identity.h). - /* --numeric-ids: no name lookup, use the transmitted numeric ids raw. */ - bool numeric_ids; - /* --chown USER (owner) override; IDENTITY_CURRENT = the receiver's euid. */ - bool chown_uid_set; - int32_t chown_uid; - /* --chown :GROUP (group) override; IDENTITY_CURRENT = the receiver's egid. */ - bool chown_gid_set; - int32_t chown_gid; - /* --usermap / --groupmap entries, in order (first match wins). */ - IdentityMap* usermap; - int usermap_count; - IdentityMap* groupmap; - int groupmap_count; - - /* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege - * policy for super-user activities confined below the authorized receive - * root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort - * behavior: the confined super-user operation is ALWAYS attempted and an - * unprivileged attempt is refused by the kernel and skipped per entry. - * SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block - * device-node creation, --write-devices); it does NOT imply --numeric-ids and - * never enables ownership application on its own. SUPER_MODE_OFF - * (--no-super) FORBIDS them even when running as root. FastSync NEVER - * elevates privileges (no setuid/seteuid/setgid) and never bypasses the - * fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks); - * --super only permits an attempt that is already confined. Crosses the wire - * as a trailing int so the receiver can enforce the policy. See - * privilege_super_permitted() and identity_ownership_requested() in - * identity.h. */ - SuperMode super_mode; - // Receiver-side runtime staging registry for --delay-updates. Never sent // over the wire and never set on the sender side. DelayUpdatesContext* delay_context; - // Phase 4: metadata time preservation. -U/--atimes and -N/--crtimes capture - // and transmit the source access / birth time (both sender and receiver - // effect, so they CROSS the wire). --omit-dir-times/-O and - // --omit-link-times/-J are receiver-side prefs (CROSS the wire). Their - // exact capture/transmit/apply semantics are documented in RSYNC_COMPAT.md. - /* -U/--atimes: preserve source access times on the destination. */ - bool preserve_atimes; - /* -N/--crtimes: capture+transmit source birth time; see RSYNC_COMPAT for the - * receiver not-applied divergence. */ - bool preserve_crtimes; - /* -O/--omit-dir-times: do not apply mtimes to directories. */ - bool omit_dir_times; - /* -J/--omit-link-times: do not apply times to symlinks. */ - bool omit_link_times; /* --open-noatime: CLIENT-ONLY (never crosses the wire). The sender opens * source files with O_NOATIME so reading for transfer does not bump the * source access time. */ bool open_noatime; - // Phase 4: xattr / ACL / fake-super preservation. - /* -X/--xattrs and -A/--acls toggle the sender's capture and the receiver's - * application of per-file extended attributes (xattrs). Both cross the wire: - * the sender only transmits the bounded, whitelisted attribute set it - * captures and the receiver re-validates namespaces/sizes before applying - * fd-relative. With neither set (the default) no xattr block is sent, so the - * wire is byte-identical to prior protocol versions for unaffected runs. */ /* true when preserve_xattrs || preserve_acls; the sender/receiver gate the * xattr wire block on this single flag. */ bool use_xattrs; - /* --fake-super: receiver-only. When set, each written file additionally gets - * a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime - * so a later privileged restore could re-apply them. Crosses the wire. */ - bool fake_super; - /* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset - * implementation, a documented divergence from rsync's real identity switch: - * the receiver does NOT change its process credentials (FastSync's receiver - * is multithreaded, so a setuid/seteuid drop would be unsafe). Instead the - * receiver FORCES the ownership of every entry it writes to copy_as_uid / - * copy_as_gid through the existing confined, fd-relative identity path - * (fchown/fchownat), which REQUIRES receiver privilege (root); an - * unprivileged receiver REFUSES the whole transfer up front at the config - * handshake (never a silent wrong-ownership result). All three fields CROSS - * the wire as a trailing config-frame block so the receiver learns the - * requested ids; see the PROTOCOL_VERSION note below. */ - bool copy_as_set; - int32_t copy_as_uid; - int32_t copy_as_gid; - // Phase 5: --trust-sender /* Long-form-only, receiver-local policy. rsync's --trust-sender tells the * receiving side to trust that the sender already produced a sane file list, * relaxing the receiver's own up-front re-validation of every incoming path. @@ -501,7 +412,6 @@ typedef struct Config { * default; only relaxes validation when explicitly requested. */ bool trust_sender; - // Phase 6: --stop-after / --stop-at /* Client-only sender-side transfer stop deadlines. --stop-after=MINS stops * the transfer after a number of elapsed minutes (checked against * CLOCK_MONOTONIC so clock changes do not skew it); --stop-at=TIME stops at @@ -513,7 +423,6 @@ typedef struct Config { time_t stop_at; /* --stop-at=... absolute wall-clock deadline */ bool stop_at_set; /* true when --stop-at was given */ - // Phase 6: --write-batch / --only-write-batch / --read-batch /* Client-only residual-batch paths. A residual batch is a self-contained * single-file record of the whole source tree (full file images using the * chunk codec), independent of any live server. --write-batch=FILE runs the @@ -525,6 +434,196 @@ typedef struct Config { char* write_batch; /* --write-batch=FILE path, or NULL */ char* only_write_batch; /* --only-write-batch=FILE path, or NULL */ char* read_batch; /* --read-batch=FILE path, or NULL */ + + /* =================================================================== + * Serialized wire fields. Their members, defaults and send/receive + * sequence are generated from the CONFIG_WIRE_*_FIELDS table above (the + * single source of truth); they are declared here in exact wire order. + * The per-field notes were moved here from their original positions and + * are listed in wire order. + * =================================================================== */ + /* copy_links */ + // Issue #120: Symlink handling + /* preserve_hard_links */ + // Issue #121: Extended metadata preservation + /* preserve_specials */ + /* Phase 4 special/devices: preserve special files (FIFOs, sockets) and device + * nodes on the destination by recreating them (mknod/mkfifo) instead of + * transferring content. preserve_specials mirrors rsync --specials (the + * special-file half of -D); preserve_devices mirrors --devices (the device + * half of -D); both CROSS the wire so the receiver knows a special/device + * entry must be recreated rather than written as a regular file. */ + /* copy_devices */ + /* --copy-devices: copy the CONTENT of a source device as an ordinary regular + * file on the destination (rsync's non-privileged safe mode), instead of + * recreating the device node. CROSSES the wire (receiver treats the entry as + * a regular file, which is the default, so this is belt-and-braces). */ + /* write_devices */ + /* --write-devices: write the received data directly INTO an existing device + * node on the destination instead of creating a regular file. Dangeroud; + * see RSYNC_COMPAT.md for the tight gating. CROSSES the wire. */ + /* existing */ + // Issue #127: Transfer modes + /* delete_excluded */ + /* --delete-excluded: also delete destination entries that were excluded on + * the source. Default (off) matches rsync: excluded paths are protected from + * deletion. Crosses the wire (the sender encodes the choice by whether it + * transmits a protected-prefix list with the keep-set manifest). */ + /* force_delete */ + /* --force (receiver-side): a regular file may replace a destination + * directory by removing that (possibly non-empty, symlink-safe) directory + * tree first, instead of failing the write. Crosses the wire. */ + /* delete_missing_args */ + /* --delete-missing-args: implies --ignore-missing-args; additionally each + * missing entry's destination mirror (computed like a present entry's wire + * path) is deleted receiver-side. Crosses the wire and is gated by the + * server's --allow-delete policy like --delete. rsync-parity: independent + * of ordinary --delete processing (it does not imply --delete); a non-empty + * directory mirror is only removed with --force or --delete in effect, and + * the missing-args deletions are not counted toward --max-delete. */ + /* preallocate */ + /* --preallocate: allocates the destination file's full expected space up + * front (before any data is written) so a transfer that would overflow disk + * fails fast at allocation time and the file is laid out contiguously, + * avoiding fragmentation. Receiver-side, crosses the wire. */ + /* max_delete */ + /* --max-delete=NUM: the receiver refuses to delete more than NUM entries per + * run (all-or-nothing: when the extras would exceed NUM nothing is removed and + * the transfer fails with a distinct error). -1 == no client limit (the + * server hard bound MAX_SERVER_DELETE_COUNT still applies). */ + /* relative */ + /* -R/--relative: crosses the wire; with --files-from listed entries keep + * their bare relative destination path (no source-root mirror prefix). */ + /* mkpath */ + /* --mkpath: crosses the wire. Tells the server to create the destination + * root directory (and missing leading components below its authorized root) + * at connection start instead of requiring it to already exist. */ + /* delete_during */ + /* rsync deletion-timing family (real from Phase 3). At most one of + delete_before / delete_during / delete_delay / delete_after may be set, and + only together with use_delete (the CLI implies --delete for each of them). + delete_before and delete_during select the EARLY engine mode: the keep-set + manifest is transmitted before any file data and extras are removed then, + acknowledged, before the first data byte. delete_delay and delete_after + select the LATE commit mode: extras are removed only after the whole + transfer has succeeded (plain --delete keeps this mode). The exact + semantics and the divergences from rsync are documented in RSYNC_COMPAT.md + and in config_delete_timing_early() below. */ + /* partial_dir */ + // PR #174: Partial transfer resumption + /* suffix */ + // PR #178: Backup versioning + /* delete_before */ + // PR #179: Delete policies + /* checksum */ + // PR #183: Checksum comparison + /* compress_choice */ + // PR #184: Compression algorithm negotiation + /* basis_dirs */ + /* Alternate basis directories, ordered by command-line appearance. Each + * entry's type selects compare/copy/link behavior on an exact match. These + * cross the wire so the receiver can consult them; they are interpreted + * relative to the destination root and confined there. */ + /* fuzzy */ + /* -y/--fuzzy: when a file must be transferred and the destination holds no + * usable file at the exact path, the receiver may reuse a SIMILAR-named + * existing regular file in the same destination directory as the delta + * basis so the sender transmits only the differences. Crosses the wire + * (the receiver performs the candidate search); the CLI implies + * --incremental + --delta because the similar-basis only matters on the + * receiver-driven delta path. Off by default. */ + /* checksum_algo / checksum_seed */ + /* --checksum-choice / --cc and --checksum-seed. checksum_algo is the id of + * the whole-file content-digest algorithm used by the per-file --incremental + * handshake (sender computes it, receiver compares it to skip unchanged + * files) and by the basis-dir content verification. checksum_seed is passed + * to xxHash64 (and to the delta block strong hash, low 32 bits); md5 has no + * seed so it is ignored there. Both cross the wire: the receiver MUST hash + * the on-disk old file with the same algorithm and seed to reach a matching + * digest. */ + /* munge_links / keep_dirlinks */ + /* Phase 4 symlink-trust: both cross the wire (the receiver unmunges symlink + * targets and, with -K, follows an in-root destination symlink-to-directory); + * -k/--copy-dirlinks is sender-only and is never serialized. */ + /* numeric_ids */ + /* --numeric-ids: no name lookup, use the transmitted numeric ids raw. */ + /* chown_uid_set */ + /* --chown USER (owner) override; IDENTITY_CURRENT = the receiver's euid. */ + /* chown_gid_set */ + /* --chown :GROUP (group) override; IDENTITY_CURRENT = the receiver's egid. */ + /* usermap */ + /* --usermap / --groupmap entries, in order (first match wins). */ + /* preserve_atimes */ + /* -U/--atimes: preserve source access times on the destination. */ + /* preserve_crtimes */ + /* -N/--crtimes: capture+transmit source birth time; see RSYNC_COMPAT for the + * receiver not-applied divergence. */ + /* omit_dir_times */ + /* -O/--omit-dir-times: do not apply mtimes to directories. */ + /* omit_link_times */ + /* -J/--omit-link-times: do not apply times to symlinks. */ + /* fake_super */ + /* --fake-super: receiver-only. When set, each written file additionally gets + * a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime + * so a later privileged restore could re-apply them. Crosses the wire. */ + /* module */ + /* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a + * host::module/path destination; NULL or "" means "no module" (the ordinary + * standalone-server path). Crosses the wire as a trailing config-frame + * string so the daemon can look the module up in its own config and confine + * the connection to the module's root (never a client-chosen root). */ + /* auth_user */ + /* Daemon password authentication (A7 remediation, protocol 2.19.0). + * Client-composed from a --password-file whose first meaningful line is + * `user:password`: the client sends ONLY the username in the config frame + * (auth_user); the literal password is kept in auth_password CLIENT-SIDE for + * the duration of the SCRAM challenge/response and is NEVER serialized. Both + * are NULL when the client has no credentials to present; a module WITHOUT + * `auth users` stays open and the server ignores any credentials that do + * arrive (the client sends them opportunistically and the server decides). */ + /* iconv_spec */ + /* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the + * charset of FILE NAMES at the wire boundary. CONVERT_SPEC is + * "LOCAL[,REMOTE]": LOCAL is the charset of our own file names, REMOTE is + * the remote side's charset and defaults to LOCAL. The sender converts + * every path LOCAL->REMOTE before transmitting it; the receiver converts + * every received path back REMOTE->LOCAL before creating/writing it. The + * FULL SPEC crosses the wire as a trailing config-frame string so each end + * derives its own LOCAL and the wire (REMOTE) charset symmetrically. NULL + * (or "") means no conversion: identity with zero overhead. See charset.c + * and the PROTOCOL_VERSION note below. */ + /* super_mode */ + /* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege + * policy for super-user activities confined below the authorized receive + * root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort + * behavior: the confined super-user operation is ALWAYS attempted and an + * unprivileged attempt is refused by the kernel and skipped per entry. + * SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block + * device-node creation, --write-devices); it does NOT imply --numeric-ids and + * never enables ownership application on its own. SUPER_MODE_OFF + * (--no-super) FORBIDS them even when running as root. FastSync NEVER + * elevates privileges (no setuid/seteuid/setgid) and never bypasses the + * fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks); + * --super only permits an attempt that is already confined. Crosses the wire + * as a trailing int so the receiver can enforce the policy. See + * privilege_super_permitted() and identity_ownership_requested() in + * identity.h. */ + /* copy_as_set */ + /* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset + * implementation, a documented divergence from rsync's real identity switch: + * the receiver does NOT change its process credentials (FastSync's receiver + * is multithreaded, so a setuid/seteuid drop would be unsafe). Instead the + * receiver FORCES the ownership of every entry it writes to copy_as_uid / + * copy_as_gid through the existing confined, fd-relative identity path + * (fchown/fchownat), which REQUIRES receiver privilege (root); an + * unprivileged receiver REFUSES the whole transfer up front at the config + * handshake (never a silent wrong-ownership result). All three fields CROSS + * the wire as a trailing config-frame block so the receiver learns the + * requested ids; see the PROTOCOL_VERSION note below. */ + +#define CONFIG_STRUCT_MEMBER(name, ctype, def, kind) ctype name; + CONFIG_WIRE_FIELDS(CONFIG_STRUCT_MEMBER) +#undef CONFIG_STRUCT_MEMBER } Config; /* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0. @@ -699,6 +798,10 @@ void config_delete(Config* config); void config_burn_auth(Config* config); bool config_send(int file_descriptor, const Config* config); +/* Emit the config frame BODY (every serialized field, in wire order) without + * the trailing STATUS_OK handshake. config_send() is this plus the handshake; + * the wire-compatibility golden test uses it to hash the exact byte stream. */ +bool config_send_wire_block(int file_descriptor, const Config* config); Config* config_receive(int file_descriptor); bool config_is_remote_dest(const char* s); void config_parse_ssh_dest(Config* config); diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 01a759a..300e196 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -190,6 +190,26 @@ static bool store_max_connections(int* slot, const char* value, const char* modu return true; } +/* Parse a non-negative concurrency cap where 0 means unlimited/disabled + * (per-module `max connections`, `max connections per host`, + * `auth lockout threshold`). Negative/garbage/oversized values are rejected. */ +static bool store_optional_cap(int* slot, const char* value, int max_value, const char* key, + const char* module_name, char* err, size_t err_size) { + char* end = NULL; + errno = 0; + long n = strtol(value, &end, 10); + if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 || n > max_value) { + if (module_name) + set_error(err, err_size, "module '%s': invalid '%s' '%s' (must be 0-%d)", module_name, key, + value, max_value); + else + set_error(err, err_size, "invalid '%s' '%s' (must be 0-%d)", key, value, max_value); + return false; + } + *slot = (int)n; + return true; +} + /* Parse an `auth failure delay` value: 0 (disabled) through the configured cap. */ static bool store_auth_failure_delay(int* slot, const char* value, char* err, size_t err_size) { char* end = NULL; @@ -227,6 +247,9 @@ DaemonConf* daemon_conf_create(void) { conf->global.port = DAEMON_CONF_DEFAULT_PORT; conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS; conf->global.auth_failure_delay_ms = DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS; + conf->global.max_connections_per_host = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST; + conf->global.auth_lockout_threshold = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD; + conf->global.auth_lockout_duration_sec = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC; return conf; } @@ -312,8 +335,20 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo } if (key_equals(key, "max connections")) return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size); + if (key_equals(key, "max connections per host")) + return store_optional_cap(&conf->global.max_connections_per_host, value, + DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "max connections per host", NULL, + err, err_size); if (key_equals(key, "auth failure delay")) return store_auth_failure_delay(&conf->global.auth_failure_delay_ms, value, err, err_size); + if (key_equals(key, "auth lockout threshold")) + return store_optional_cap(&conf->global.auth_lockout_threshold, value, + DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "auth lockout threshold", NULL, + err, err_size); + if (key_equals(key, "auth lockout duration")) + return store_optional_cap(&conf->global.auth_lockout_duration_sec, value, + DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC, "auth lockout duration", + NULL, err, err_size); if (key_equals(key, "hosts allow")) return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value, "hosts allow", NULL, replace_hosts, err, err_size); @@ -400,7 +435,8 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char* return true; } if (key_equals(key, "max connections")) - return store_max_connections(&module->max_connections, value, module->name, err, err_size); + return store_optional_cap(&module->max_connections, value, DAEMON_CONF_MAX_CONCURRENCY_LIMIT, + "max connections", module->name, err, err_size); if (key_equals(key, "hosts allow")) return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow", false, module->name, err, err_size); @@ -444,6 +480,11 @@ static int open_module(DaemonConf* conf, int* current_module, const char* name, set_error(err, err_size, "duplicate module '%s'", name); return -1; } + if (conf->module_count >= DAEMON_CONF_MAX_MODULES) { + set_error(err, err_size, "too many modules (limit %d); module '%s' rejected", + DAEMON_CONF_MAX_MODULES, name); + return -1; + } DaemonModule* grown = realloc(conf->modules, (size_t)(conf->module_count + 1) * sizeof(DaemonModule)); if (!grown) { diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index 3b790a7..d04399e 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -52,11 +52,10 @@ typedef struct DaemonModule { activities. Without it the daemon refuses all of them. */ char** auth_users; /* `auth users = a,b`; Wave B credential list */ int auth_user_count; - /* `max connections = N` (optional per-module cap). 0 means "not set" - * (inherit the global cap). Parsed, stored, and validated, but NOT enforced - * per-module: connections are counted in the accept-loop parent before the - * client's module is known, so only the global cap is enforced (see - * transport_tcp.c and the Daemon Mode notes in RSYNC_COMPAT.md). */ + /* `max connections = N` (optional per-module cap). 0 means unlimited. The + * per-connection child records the selected module in the shared registry + * (daemon_limits.c) once the config frame names it, so the cap is enforced + * across all forked children; the parent reclaims the slot on SIGCHLD. */ int max_connections; char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */ int hosts_allow_count; @@ -67,14 +66,25 @@ typedef struct DaemonModule { /* Global (pre-module) scalar keys. `motd file` is parsed and stored but has * no wire effect yet (MOTD display is Wave C). */ typedef struct DaemonConfGlobals { - int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */ - char* motd_file; /* `motd file`, may be NULL */ - char* address; /* `address` (optional bind address), may be NULL */ - int max_connections; /* `max connections`, default - DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */ - int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default - DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */ - char** hosts_allow; /* `hosts allow`; global host access allow patterns */ + int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */ + char* motd_file; /* `motd file`, may be NULL */ + char* address; /* `address` (optional bind address), may be NULL */ + int max_connections; /* `max connections`, default + DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */ + int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default + DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */ + int max_connections_per_host; /* `max connections per host`, concurrent cap per + source IP; default + DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST (0 = + unlimited) */ + int auth_lockout_threshold; /* `auth lockout threshold`, failed attempts from + one source before lockout; default + DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD (0 + disables) */ + int auth_lockout_duration_sec; /* `auth lockout duration`, seconds; default + DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC + (0 disables) */ + char** hosts_allow; /* `hosts allow`; global host access allow patterns */ int hosts_allow_count; char** hosts_deny; /* `hosts deny`; global host access deny patterns */ int hosts_deny_count; @@ -92,11 +102,26 @@ typedef struct DaemonConf { #define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS 100 /* Default `auth failure delay` in milliseconds (0 disables the throttle). */ #define DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS 500 +/* Default `max connections per host` (0 = unlimited). */ +#define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST 0 +/* Default cross-process auth lockout: 10 failed attempts from one source lock + * it out for 300 s (0 disables either knob). */ +#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD 10 +#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC 300 +/* Upper bound on a `max connections per host` or `auth lockout threshold` + * value, so a typo cannot size the shared registry absurdly. */ +#define DAEMON_CONF_MAX_CONCURRENCY_LIMIT 1000000 +/* Upper bound on `auth lockout duration` (7 days). */ +#define DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC 604800 /* Largest accepted `auth failure delay`, so a typo cannot pin a connection * child in nanosleep for an absurd time. */ /* Bounded well below the socket I/O timeout so a failed-auth child cannot hold * a connection slot for long enough to amplify connection-cap exhaustion. */ #define DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS 5000 +/* Upper bound on the number of [module] sections, so the shared registry's + * per-module counter array stays fixed-size. The parser rejects the next + * section past this bound. */ +#define DAEMON_CONF_MAX_MODULES 256 /* Longest accepted config line (excluding the trailing newline). Longer lines * are rejected rather than buffered unboundedly. */ #define DAEMON_CONF_MAX_LINE 4096 @@ -129,8 +154,9 @@ bool daemon_module_name_valid(const char* name); /* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and * apply it to the global keys only. Keys are case-insensitive and limited to * the global keys defined by the grammar (port, motd file, address, - * max connections, auth failure delay, hosts allow, hosts deny). Returns 0 on - * success, -1 on error (err filled). */ + * max connections, max connections per host, auth failure delay, + * auth lockout threshold, auth lockout duration, hosts allow, hosts deny). + * Returns 0 on success, -1 on error (err filled). */ int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size); /* Host access-control matching (pure; no I/O). `daemon_host_pattern_match` diff --git a/src/shared/daemon_limits.c b/src/shared/daemon_limits.c new file mode 100644 index 0000000..067173e --- /dev/null +++ b/src/shared/daemon_limits.c @@ -0,0 +1,494 @@ +#include "daemon_limits.h" +#include "daemon_conf.h" +#include "log.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* The two module-count bounds must agree: the daemon config parser never + * produces more than DAEMON_CONF_MAX_MODULES modules, so the shared registry's + * per-module counter array is sized from the same bound. */ +_Static_assert(DAEMON_LIMITS_MAX_MODULES == DAEMON_CONF_MAX_MODULES, + "daemon_limits module bound must match daemon_conf"); + +/* Slot lifecycle states (stored in slot_state). */ +enum { + SLOT_FREE = 0, + SLOT_CLAIMED = 1, + SLOT_REGISTERED = 2, +}; + +/* The registry header lives at the base of the shared mapping; the pointer + * fields point at the arrays carved out of the same mapping. Absolute pointers + * remain valid in a forked child because fork() clones the address space and + * mapping, so parent and child observe the same virtual addresses. */ +struct DaemonLimitRegistry { + int max_slots; + int module_count; + int host_slots; /* power of two; 1 when no per-source tracking is needed */ + int per_host_cap; + int lockout_threshold; + int lockout_duration_sec; + size_t map_size; + _Atomic long long host_full_warn; /* last "table full" warning epoch */ + _Atomic int* slot_state; + _Atomic int* slot_pid; + _Atomic int* slot_module; + _Atomic int* slot_host; /* per-source table bucket, or -1 */ + _Atomic int* module_active; + _Atomic uint64_t* host_key; /* 0 == empty bucket */ + _Atomic int* host_active; + _Atomic int* host_fail; + _Atomic long long* host_until; /* epoch seconds the lockout expires */ + _Atomic long long* host_last_use; /* epoch seconds the bucket was last touched */ +}; + +static size_t round_up(size_t n, size_t align) { + return (n + align - 1) & ~(align - 1); +} + +static size_t next_pow2(size_t n) { + size_t p = 1; + while (p < n) + p <<= 1; + return p; +} + +/* Parse a numeric IPv4/IPv6 peer string into family + raw bytes. */ +static bool parse_peer_ip(const char* peer_ip, int* family, unsigned char* bytes) { + if (!peer_ip || *peer_ip == '\0') + return false; + struct in_addr v4; + if (inet_pton(AF_INET, peer_ip, &v4) == 1) { + memcpy(bytes, &v4, sizeof(v4)); + *family = AF_INET; + return true; + } + struct in6_addr v6; + if (inet_pton(AF_INET6, peer_ip, &v6) == 1) { + memcpy(bytes, &v6, sizeof(v6)); + *family = AF_INET6; + return true; + } + return false; +} + +uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok) { + if (ok) + *ok = false; + unsigned char bytes[16]; + int family = AF_UNSPEC; + if (!parse_peer_ip(peer_ip, &family, bytes)) + return 0; + uint64_t hash = 14695981039346656037ULL ^ (uint64_t)(uint32_t)family; + size_t length = family == AF_INET ? 4 : 16; + for (size_t i = 0; i < length; i++) { + hash ^= bytes[i]; + hash *= 1099511628211ULL; + } + if (hash == 0) + hash = 0x9e3779b97f4a7c15ULL; + if (ok) + *ok = true; + return hash; +} + +/* True when the registry must maintain per-source buckets: either the per-host + * cap is configured, or the auth lockout is (threshold AND duration > 0). A + * lockout threshold without a duration is a no-op, so it must not size or intern + * the table. create(), register() and the lockout paths all agree on this. */ +static bool registry_tracks_hosts(const DaemonLimitRegistry* registry) { + return registry->per_host_cap > 0 || + (registry->lockout_threshold > 0 && registry->lockout_duration_sec > 0); +} + +/* Find the bucket holding `peer_ip`, or -1 when it has no entry. Finding a + * bucket refreshes its last-use time so the eviction policy sees it as live. */ +static int host_lookup(DaemonLimitRegistry* registry, const char* peer_ip) { + bool ok = false; + uint64_t key = daemon_limits_host_hash(peer_ip, &ok); + if (!ok) + return -1; + size_t mask = (size_t)registry->host_slots - 1; + size_t start = (size_t)(key & mask); + for (size_t i = 0; i < (size_t)registry->host_slots; i++) { + size_t idx = (start + i) & mask; + uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire); + if (current == key) { + atomic_store_explicit(®istry->host_last_use[idx], (long long)time(NULL), + memory_order_relaxed); + return (int)idx; + } + if (current == 0) + return -1; /* no tombstones: an empty bucket ends the probe chain */ + } + return -1; +} + +/* A bucket with no live connection may be repurposed: immediately when its + * lockout deadline has already passed (the review's "expired" case), or after an + * idle window when it holds no pending lockout. A bucket with a future lockout + * deadline is retained so the lockout actually lasts its configured duration. */ +static bool host_bucket_reclaimable(DaemonLimitRegistry* registry, size_t idx, long long now) { + if (atomic_load_explicit(®istry->host_active[idx], memory_order_relaxed) != 0) + return false; + long long until = atomic_load_explicit(®istry->host_until[idx], memory_order_relaxed); + if (until != 0) + return until <= now; + long long last_use = atomic_load_explicit(®istry->host_last_use[idx], memory_order_relaxed); + /* A bucket whose key is published but whose last_use has not yet been stamped + * (last_use == 0) must be treated as live: reclaiming it here would steal a + * bucket a racing child just claimed. The claim path also stamps last_use + * before publishing the key, so this window cannot persist. */ + return last_use != 0 && now - last_use >= DAEMON_LIMITS_HOST_EVICT_IDLE_SEC; +} + +/* Emit at most one "per-source table full" warning per + * DAEMON_LIMITS_HOST_FULL_WARN_SEC across all forked children. Called from a + * normal (non-signal) child path, so logging is safe here. */ +static void host_warn_table_full(DaemonLimitRegistry* registry, long long now) { + long long last = atomic_load_explicit(®istry->host_full_warn, memory_order_relaxed); + if (last != 0 && now - last < DAEMON_LIMITS_HOST_FULL_WARN_SEC) + return; + if (atomic_compare_exchange_strong_explicit(®istry->host_full_warn, &last, now, + memory_order_relaxed, memory_order_relaxed)) { + log_message(LOG_LEVEL_WARNING, + "daemon: per-source registry is full (%d slots) and no bucket can be reclaimed; " + "'max connections per host' and the auth lockout are temporarily not enforced for " + "new sources (the per-module cap and host ACLs still apply)", + registry->host_slots); + } +} + +/* Find or insert the bucket for `peer_ip`. Insertion is a lock-free CAS so two + * forked children racing on the same source converge on one bucket. + * + * When the probe finds no empty bucket it reclaims, via a key CAS, the first + * bucket that is reclaimable (expired lockout or idle, and no active + * connection) and resets its counters. This bounds the table's lifetime so it + * cannot fill permanently and stay fail-open. Returns -1 only when the address + * is unparseable or the table is genuinely full of live/locked buckets + * (callers fail open: the global/module caps and ACLs still apply). */ +static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) { + bool ok = false; + uint64_t key = daemon_limits_host_hash(peer_ip, &ok); + if (!ok) + return -1; + long long now = (long long)time(NULL); + size_t mask = (size_t)registry->host_slots - 1; + size_t start = (size_t)(key & mask); + /* A couple of passes bound the work: the first normally claims/seeds a bucket; + * a lost eviction CAS retries once against the freshly observed table. */ + for (int pass = 0; pass < 2; pass++) { + int evict = -1; + uint64_t evict_key = 0; + for (size_t i = 0; i < (size_t)registry->host_slots; i++) { + size_t idx = (start + i) & mask; + uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire); + if (current == key) { + atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); + return (int)idx; + } + if (current == 0) { + /* Stamp last_use *before* publishing the key so a reclaimer racing the + * claim can never observe a claimed bucket with last_use == 0 and + * evict it. A pre-stamp is harmless if the CAS loses: the bucket is + * either still empty (never inspected for reclaim) or has just been + * taken by another source that wants a fresh timestamp anyway. */ + atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); + uint64_t expected = 0; + if (atomic_compare_exchange_strong_explicit(®istry->host_key[idx], &expected, key, + memory_order_acq_rel, memory_order_acquire)) { + return (int)idx; + } + if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) { + return (int)idx; + } + continue; /* another child won this empty bucket; keep probing */ + } + if (evict < 0 && host_bucket_reclaimable(registry, idx, now)) { + evict = (int)idx; + evict_key = current; + } + } + if (evict >= 0) { + /* Refresh the timestamp before the key changes hands so the reused bucket + * is not seen as immediately idle by a racing reclaimer. */ + atomic_store_explicit(®istry->host_last_use[evict], now, memory_order_relaxed); + uint64_t expected = evict_key; + if (atomic_compare_exchange_strong_explicit(®istry->host_key[evict], &expected, key, + memory_order_acq_rel, memory_order_acquire)) { + /* The bucket now belongs to the new source; clear the evicted source's + * stale lockout/failure state. */ + atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed); + atomic_store_explicit(®istry->host_fail[evict], 0, memory_order_relaxed); + atomic_store_explicit(®istry->host_until[evict], 0, memory_order_relaxed); + /* Two children can race to intern the same brand-new key into different + * eviction targets, leaving the table with duplicate buckets for `key`. + * Re-scan for the first (canonical) bucket holding `key`; when it + * precedes `evict`, drop our duplicate's occupancy and hand back the + * canonical bucket so per-source counts are not orphaned on the + * duplicate. The duplicate keeps its key, so no tombstone hole is + * created and probe chains stay intact; it ages out normally. */ + for (size_t i = 0; i < (size_t)registry->host_slots; i++) { + size_t candidate = (start + i) & mask; + uint64_t found = + atomic_load_explicit(®istry->host_key[candidate], memory_order_acquire); + if (found == key) { + if (candidate != (size_t)evict) { + atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed); + return (int)candidate; + } + break; + } + if (found == 0) + break; /* the key is present at `evict`, so this cannot happen first */ + } + return evict; + } + continue; /* lost the race; re-probe with fresh observations */ + } + break; /* no free and no reclaimable bucket: genuinely full */ + } + host_warn_table_full(registry, now); + return -1; +} + +DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap, + int lockout_threshold, int lockout_duration_sec) { + if (max_slots < DAEMON_LIMITS_MIN_SLOTS) + max_slots = DAEMON_LIMITS_MIN_SLOTS; + if (max_slots > DAEMON_LIMITS_MAX_SLOTS) + max_slots = DAEMON_LIMITS_MAX_SLOTS; + if (module_count < 1) + module_count = 1; + if (module_count > DAEMON_LIMITS_MAX_MODULES) + module_count = DAEMON_LIMITS_MAX_MODULES; + if (per_host_cap < 0) + per_host_cap = 0; + if (lockout_threshold < 0) + lockout_threshold = 0; + if (lockout_duration_sec < 0) + lockout_duration_sec = 0; + + bool need_hosts = per_host_cap > 0 || (lockout_threshold > 0 && lockout_duration_sec > 0); + int host_slots = 1; + if (need_hosts) { + size_t want = (size_t)max_slots * 4; + if (want < 64) + want = 64; + if (want > DAEMON_LIMITS_MAX_HOST_SLOTS) + want = DAEMON_LIMITS_MAX_HOST_SLOTS; + host_slots = (int)next_pow2(want); + } + + size_t header = round_up(sizeof(DaemonLimitRegistry), 16); + size_t slot_bytes = + round_up((size_t)max_slots * sizeof(_Atomic int), 16) * 4; /* state,pid,module,host */ + size_t module_bytes = round_up((size_t)module_count * sizeof(_Atomic int), 16); + size_t host_key_bytes = round_up((size_t)host_slots * sizeof(_Atomic uint64_t), 16); + size_t host_int_bytes = round_up((size_t)host_slots * sizeof(_Atomic int), 16) * 2; + size_t host_until_bytes = round_up((size_t)host_slots * sizeof(_Atomic long long), 16) * 2; + size_t total = + header + slot_bytes + module_bytes + host_key_bytes + host_int_bytes + host_until_bytes + 16; + + void* map = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (map == MAP_FAILED) + return NULL; + memset(map, 0, total); + + DaemonLimitRegistry* registry = (DaemonLimitRegistry*)map; + registry->max_slots = max_slots; + registry->module_count = module_count; + registry->host_slots = host_slots; + registry->per_host_cap = per_host_cap; + registry->lockout_threshold = lockout_threshold; + registry->lockout_duration_sec = lockout_duration_sec; + registry->map_size = total; + + unsigned char* cursor = (unsigned char*)map + header; + registry->slot_state = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_pid = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_module = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->slot_host = (atomic_int*)cursor; + cursor += (size_t)max_slots * sizeof(_Atomic int); + registry->module_active = (atomic_int*)cursor; + cursor += (size_t)module_count * sizeof(_Atomic int); + cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16); + registry->host_key = (_Atomic uint64_t*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic uint64_t); + registry->host_active = (atomic_int*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic int); + registry->host_fail = (atomic_int*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic int); + cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16); + registry->host_until = (atomic_llong*)cursor; + cursor += (size_t)host_slots * sizeof(_Atomic long long); + registry->host_last_use = (atomic_llong*)cursor; + + for (int i = 0; i < max_slots; i++) { + atomic_store(®istry->slot_module[i], -1); + atomic_store(®istry->slot_host[i], -1); + } + return registry; +} + +void daemon_limits_destroy(DaemonLimitRegistry* registry) { + if (!registry) + return; + munmap(registry, registry->map_size); +} + +int daemon_limits_claim_slot(DaemonLimitRegistry* registry) { + if (!registry) + return DAEMON_LIMITS_NO_SLOT; + for (int i = 0; i < registry->max_slots; i++) { + int expected = SLOT_FREE; + if (atomic_compare_exchange_strong(®istry->slot_state[i], &expected, SLOT_CLAIMED)) { + atomic_store(®istry->slot_pid[i], 0); + atomic_store(®istry->slot_module[i], -1); + atomic_store(®istry->slot_host[i], -1); + return i; + } + } + return DAEMON_LIMITS_NO_SLOT; +} + +void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return; + atomic_store(®istry->slot_pid[slot], (int)pid); +} + +void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return; + atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel); + atomic_store_explicit(®istry->slot_pid[slot], 0, memory_order_relaxed); + /* The module/host occupancy arrays are derived from the slot table; do not + * decrement here or a SIGKILL between a child's increment and its REGISTERED + * publish would leak a count. Callers that need the derived counts call + * daemon_limits_recompute. */ +} + +void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid) { + if (!registry || pid <= 0) + return; + for (int i = 0; i < registry->max_slots; i++) { + if (atomic_load(®istry->slot_state[i]) == SLOT_FREE) + continue; + if (atomic_load(®istry->slot_pid[i]) == (int)pid) { + daemon_limits_reclaim_slot(registry, i); + return; + } + } +} + +void daemon_limits_recompute(DaemonLimitRegistry* registry) { + if (!registry) + return; + /* Zero the derived arrays, then re-derive solely from the REGISTERED slots. + * A child that was SIGKILLed after incrementing a counter but before + * publishing REGISTERED is not counted, and its leaked increment is erased by + * the zeroing, so the leak cannot persist. */ + for (int m = 0; m < registry->module_count; m++) + atomic_store_explicit(®istry->module_active[m], 0, memory_order_relaxed); + for (int h = 0; h < registry->host_slots; h++) + atomic_store_explicit(®istry->host_active[h], 0, memory_order_relaxed); + for (int i = 0; i < registry->max_slots; i++) { + if (atomic_load_explicit(®istry->slot_state[i], memory_order_acquire) != SLOT_REGISTERED) + continue; + int module = atomic_load_explicit(®istry->slot_module[i], memory_order_relaxed); + if (module >= 0 && module < registry->module_count) + atomic_fetch_add_explicit(®istry->module_active[module], 1, memory_order_relaxed); + int host = atomic_load_explicit(®istry->slot_host[i], memory_order_relaxed); + if (host >= 0 && host < registry->host_slots) + atomic_fetch_add_explicit(®istry->host_active[host], 1, memory_order_relaxed); + } +} + +DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index, + const char* peer_ip, int module_cap) { + if (!registry || slot < 0 || slot >= registry->max_slots) + return DAEMON_LIMIT_UNAVAILABLE; + if (module_index < 0 || module_index >= registry->module_count) + return DAEMON_LIMIT_UNAVAILABLE; + if (atomic_load_explicit(®istry->slot_state[slot], memory_order_acquire) != SLOT_CLAIMED) + return DAEMON_LIMIT_UNAVAILABLE; + + int host = -1; + if (registry_tracks_hosts(registry)) + host = host_intern(registry, peer_ip); + + int module_count = atomic_fetch_add(®istry->module_active[module_index], 1) + 1; + if (module_cap > 0 && module_count > module_cap) { + atomic_fetch_sub(®istry->module_active[module_index], 1); + return DAEMON_LIMIT_MODULE_FULL; + } + if (host >= 0) { + int host_count = atomic_fetch_add(®istry->host_active[host], 1) + 1; + if (registry->per_host_cap > 0 && host_count > registry->per_host_cap) { + atomic_fetch_sub(®istry->host_active[host], 1); + atomic_fetch_sub(®istry->module_active[module_index], 1); + return DAEMON_LIMIT_HOST_FULL; + } + } + atomic_store(®istry->slot_module[slot], module_index); + atomic_store(®istry->slot_host[slot], host); + atomic_store_explicit(®istry->slot_state[slot], SLOT_REGISTERED, memory_order_release); + return DAEMON_LIMIT_OK; +} + +bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip, + int* seconds_remaining) { + if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0) + return false; + int bucket = host_lookup(registry, peer_ip); + if (bucket < 0) + return false; + long long until = atomic_load(®istry->host_until[bucket]); + long long now = (long long)time(NULL); + if (until > now) { + if (seconds_remaining) + *seconds_remaining = (int)(until - now); + return true; + } + if (until != 0) { + /* The previous lockout has expired: clear the stale counter so the source + * gets a fresh allowance. */ + atomic_store(®istry->host_fail[bucket], 0); + atomic_store(®istry->host_until[bucket], 0); + } + return false; +} + +void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip) { + if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0) + return; + int bucket = host_intern(registry, peer_ip); + if (bucket < 0) + return; + int failures = atomic_fetch_add(®istry->host_fail[bucket], 1) + 1; + if (failures >= registry->lockout_threshold) { + long long now = (long long)time(NULL); + atomic_store(®istry->host_until[bucket], now + (long long)registry->lockout_duration_sec); + } +} + +void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip) { + if (!registry) + return; + int bucket = host_lookup(registry, peer_ip); + if (bucket < 0) + return; + atomic_store(®istry->host_fail[bucket], 0); + atomic_store(®istry->host_until[bucket], 0); +} diff --git a/src/shared/daemon_limits.h b/src/shared/daemon_limits.h new file mode 100644 index 0000000..b6d89d2 --- /dev/null +++ b/src/shared/daemon_limits.h @@ -0,0 +1,147 @@ +#ifndef DAEMON_LIMITS_H +#define DAEMON_LIMITS_H + +#include +#include +#include + +/* Cross-process daemon connection registry. + * + * The daemon listener forks ONE child per accepted connection, so any + * per-module / per-source accounting must live in state shared across the + * forked children. This module owns a fixed-size registry carved out of an + * anonymous shared mapping (mmap(MAP_SHARED | MAP_ANONYMOUS)) created by the + * accept-loop PARENT before it forks; every child inherits the mapping (and the + * pointer to it) across fork(). + * + * Rules: + * - ONLY C11 atomics (atomic_*); never mtx_t/pthread locks, which can deadlock + * in a forked child if another thread held them at fork time. + * - No heap allocation after fork: the mapping is fixed-size and all access is + * atomic load/store/CAS over preallocated arrays. + * + * Slot lifecycle (the parent reclaims even when a child is SIGKILLed): + * FREE --(parent claim_slot)--> CLAIMED + * CLAIMED --(child register)--> REGISTERED + * any --(parent reclaim)--> FREE + * The child records its module index and per-source bucket into the slot before + * publishing REGISTERED; the parent's SIGCHLD handler matches the reaped pid to + * the slot and, when REGISTERED, decrements the module/per-source counters. + * A child killed before registering holds no counts, so reclaiming a CLAIMED + * slot only frees the slot. + * + * Per-source identity is the normalized numeric peer IP (IPv4-mapped IPv6 is + * already collapsed to IPv4 by utils_fd_peer_ip); it is interned into an + * open-addressed, linear-probing table keyed by a 64-bit hash. The same table + * also carries the cross-process auth-failure counter and lockout deadline. + * + * Per-source table lifetime: a bucket's key is never cleared back to empty (that + * would break every later probe chain that passed through it). Instead the + * table has a bounded-lifetime eviction policy: when no empty bucket exists, the + * first bucket that is reclaimable -- no active connection AND (its lockout + * deadline has passed OR it has been idle for + * DAEMON_LIMITS_HOST_EVICT_IDLE_SEC) -- is atomically repurposed for the new + * source via a CAS of its key, and its counters are reset. The table therefore + * cannot fill permanently, and a full table degrades to fail-open for the + * per-source cap/lockout of new sources (the per-module cap and host ACLs still + * apply) instead of staying fail-open forever. A rate-limited warning is logged + * on the fail-open path. The eviction race with a concurrent + * registration/reclaim on the same bucket is benign: it can at worst lose one + * source's counter (fail-open), never corrupt memory or the module caps. + */ + +typedef struct DaemonLimitRegistry DaemonLimitRegistry; + +/* Result of a per-connection admission check. */ +typedef enum { + DAEMON_LIMIT_OK = 0, /* admitted; slot is now REGISTERED */ + DAEMON_LIMIT_MODULE_FULL, /* module's `max connections` cap reached */ + DAEMON_LIMIT_HOST_FULL, /* global `max connections per host` cap reached */ + DAEMON_LIMIT_UNAVAILABLE, /* registry/slot unusable (caller fails open) */ +} DaemonLimitResult; + +/* Bounds for registry sizing. A slot is one concurrently live child. */ +#define DAEMON_LIMITS_MIN_SLOTS 16 +#define DAEMON_LIMITS_MAX_SLOTS 65536 +#define DAEMON_LIMITS_MAX_HOST_SLOTS 65536 +#define DAEMON_LIMITS_NO_SLOT (-1) +/* Upper bound on `module_count`, matching daemon_conf.h's DAEMON_CONF_MAX_MODULES + * (asserted in daemon_limits.c) so a caller can never size the per-module counter + * array larger than the config parser can produce. */ +#define DAEMON_LIMITS_MAX_MODULES 256 + +/* Per-source table lifetime: a bucket with no active connection and no pending + * lockout is reclaimable once it has been idle this long, so a flood of distinct + * sources cannot pin the table full forever. A bucket whose lockout deadline + * has passed is reclaimable immediately (independent of this idle window). */ +#define DAEMON_LIMITS_HOST_EVICT_IDLE_SEC 300 +/* Minimum spacing between "per-source table is full" warnings, so a table-full + * attack cannot flood the log. */ +#define DAEMON_LIMITS_HOST_FULL_WARN_SEC 60 + +/* Create the shared registry in the calling (parent) process. `max_slots` is + * the number of concurrently live children to track (clamped to + * [DAEMON_LIMITS_MIN_SLOTS, DAEMON_LIMITS_MAX_SLOTS]); `module_count` is the + * number of daemon modules (clamped to + * [1, DAEMON_LIMITS_MAX_MODULES]); `per_host_cap` and the lockout pair come + * from the daemon config (0 disables). Returns NULL on failure (e.g. mmap + * allocation); callers must degrade gracefully (global cap + ACLs still + * apply). */ +DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap, + int lockout_threshold, int lockout_duration_sec); + +/* Unmap the registry. Only the creating process may call this. */ +void daemon_limits_destroy(DaemonLimitRegistry* registry); + +/* Parent side: reserve a slot for the next fork. Returns the slot index or + * DAEMON_LIMITS_NO_SLOT when every slot is in use. */ +int daemon_limits_claim_slot(DaemonLimitRegistry* registry); +/* Parent side: record the forked child's pid in a claimed slot. */ +void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid); +/* Parent side: release a slot. The slot becomes FREE; the module/per-source + * occupancy arrays are DERIVED state and are only refreshed by + * daemon_limits_recompute, which callers must invoke afterwards when they rely + * on the derived counts (the SIGCHLD handler batches one recompute for the whole + * reap). Idempotent. */ +void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot); +/* Parent SIGCHLD side: release the slot owned by `pid` (no-op when not found). + * Like reclaim_slot this does not touch the derived occupancy arrays; call + * daemon_limits_recompute after a batch of releases. */ +void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid); + +/* Parent side (async-signal-safe; atomics only, no malloc/log): rebuild + * module_active[] / host_active[] from scratch by scanning the REGISTERED slots. + * The slot table is the single source of truth, so this self-heals any + * count leaked by a child that was SIGKILLed mid-registration (it zeroes the + * arrays and re-derives them). Bounded by max_slots + host_slots. A + * registration racing this call can be transiently undercounted until the next + * recompute, which can only relax a cap briefly -- never corrupt memory. */ +void daemon_limits_recompute(DaemonLimitRegistry* registry); + +/* Child side: admit the connection for `module_index` from `peer_ip`. Always + * tracks the module/per-source occupancy (so the parent's reclaim is + * symmetric); when `module_cap` > 0 it additionally enforces the per-module + * cap. A NULL/empty or non-numeric `peer_ip` skips the per-source track (the + * callers use that to exempt a trusted loopback peer from the per-host cap; the + * per-module cap still applies). Returns DAEMON_LIMIT_OK and publishes the + * slot, or a refusal reason. */ +DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index, + const char* peer_ip, int module_cap); + +/* Child side: true when `peer_ip` is currently locked out after too many failed + * authentications. `seconds_remaining` may be NULL. */ +bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip, + int* seconds_remaining); +/* Child side: count one failed authentication for `peer_ip`; once the threshold + * is reached the source is locked out for the configured duration. */ +void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip); +/* Child side: clear the failure counter/lockout for a source that authenticated + * successfully (no-op when the source has no table entry). */ +void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip); + +/* Pure helper: 64-bit FNV-1a hash of a numeric peer IP plus its family, used to + * index the per-source table. *ok is set false (and 0 returned) for a NULL or + * non-numeric address. Exposed for unit testing. */ +uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok); + +#endif diff --git a/src/shared/data.c b/src/shared/data.c index 55af503..2ec189c 100644 --- a/src/shared/data.c +++ b/src/shared/data.c @@ -23,6 +23,7 @@ Data* data_create_reserve(size_t size) { d->data = NULL; d->size = size; d->protocol_charge = 0; + d->owner = NULL; return d; } @@ -36,14 +37,19 @@ Data* data_create(void* data, size_t data_size) { new_data->data = data; new_data->size = data_size; new_data->protocol_charge = 0; + new_data->owner = NULL; return new_data; } void data_destroy(Data* data) { if (data == NULL) return; - if (data->protocol_charge != 0) - protocol_release_memory(data->protocol_charge); + if (data->protocol_charge != 0) { + if (data->owner != NULL) + protocol_release_memory_for_session(data->owner, data->protocol_charge); + else + protocol_release_memory(data->protocol_charge); + } free(data->data); free(data); } diff --git a/src/shared/data.h b/src/shared/data.h index b65ae29..9e811fc 100644 --- a/src/shared/data.h +++ b/src/shared/data.h @@ -3,11 +3,25 @@ #include +/* Forward declaration for the connection budget a received Data is charged + * against; defined in protocol.h (which includes this header). */ +typedef struct ProtocolSession ProtocolSession; + typedef struct { void* data; size_t size; /* Non-zero only for a buffer charged to the protocol connection budget. */ size_t protocol_charge; + /* Session whose budget `protocol_charge` was reserved from. When non-NULL, + * the charge is returned to this session directly, regardless of which + * session (if any) is bound to the destroying thread. owner is not + * guaranteed to be set whenever protocol_charge is non-zero: it is NULL for + * uncharged Data and for Data that has no recorded owner, in which case any + * charge falls back to the session bound at destroy time. + * + * Lifetime contract: a Data with a non-NULL owner must not outlive that + * ProtocolSession -- data_destroy dereferences owner to return the charge. */ + ProtocolSession* owner; } Data; Data* data_create_empty(size_t data_size); @@ -15,5 +29,9 @@ Data* data_create_reserve(size_t size); Data* data_create(void* data, size_t data_size); void data_destroy(Data* data); void protocol_release_memory(size_t charge); +/* Release `charge` against `session` directly instead of the thread-local bound + * session. Used by data_destroy to honor Data.owner; `session` must outlive + * the Data whose charge is being returned. A NULL session is a no-op. */ +void protocol_release_memory_for_session(ProtocolSession* session, size_t charge); #endif diff --git a/src/shared/file.c b/src/shared/file.c index b85e988..9d5aa23 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -284,23 +284,6 @@ size_t file_content_to_buffer(File* file) { /* ---- Secure filesystem primitives ---- */ -static int authorized_root_fd = -1; -static char* authorized_root_path; - -bool file_set_authorized_root(int fd, const char* canonical_path) { - char* path_copy = canonical_path ? str_dup(canonical_path) : NULL; - if (canonical_path && !path_copy) { - authorized_root_fd = -1; - free(authorized_root_path); - authorized_root_path = NULL; - return false; - } - authorized_root_fd = fd; - free(authorized_root_path); - authorized_root_path = path_copy; - return true; -} - bool file_path_exists_secure(const char* path) { if (!path) return false; @@ -483,7 +466,10 @@ static int open_dir_beneath_root(const char* resolved, const char* root) { rel++; if (*rel == '\0') return -1; - int fd = dup(authorized_root_fd); + int root_fd = utils_get_authorized_root_fd(); + if (root_fd < 0) + return -1; + int fd = dup(root_fd); if (fd < 0) return -1; char* copy = str_dup(rel); @@ -525,20 +511,21 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) return -1; } int fd; - if (authorized_root_fd >= 0) { - if (!authorized_root_path || path[0] != '/' || - !path_is_within_root(authorized_root_path, path)) { + int root_fd = utils_get_authorized_root_fd(); + const char* root_path = utils_get_authorized_root_path(); + if (root_fd >= 0) { + if (!root_path || path[0] != '/' || !path_is_within_root(root_path, path)) { free(copy); free(leaf); return -1; } - fd = dup(authorized_root_fd); + fd = dup(root_fd); if (fd < 0) { free(copy); free(leaf); return -1; } - size_t root_len = strlen(authorized_root_path); + size_t root_len = strlen(root_path); char* relative = str_dup(path + root_len); if (!relative) { free(copy); @@ -602,15 +589,14 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) O_NOFOLLOW walk. Only honoured when the symlink resolves to a directory that stays beneath the authorized root, so a malicious link can never redirect the write outside it. */ - if (next < 0 && file_keep_dirlinks && authorized_root_path != NULL && + if (next < 0 && file_keep_dirlinks && root_path != NULL && (errno == ELOOP || errno == ENOTDIR || errno == EACCES)) { struct stat lst; if (fstatat(fd, component, &lst, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(lst.st_mode)) { char candidate[PATH_MAX]; char root[PATH_MAX]; - if (realpath(authorized_root_path, root) && - snprintf(candidate, sizeof(candidate), "%s%s/%s", root, rel_buf, component) < - (int)sizeof(candidate)) { + if (realpath(root_path, root) && snprintf(candidate, sizeof(candidate), "%s%s/%s", root, + rel_buf, component) < (int)sizeof(candidate)) { char resolved[PATH_MAX]; if (realpath(candidate, resolved) && strcmp(resolved, root) != 0 && strncmp(root, resolved, strlen(root)) == 0 && @@ -685,8 +671,9 @@ bool file_ensure_directory_secure(const char* path) { return false; /* The authorized root is already an open directory, and the filesystem root is always present: there is no final component left to create for them. */ + const char* root_path = utils_get_authorized_root_path(); bool root_is_open = - authorized_root_fd >= 0 && authorized_root_path && strcmp(norm, authorized_root_path) == 0; + utils_get_authorized_root_fd() >= 0 && root_path && strcmp(norm, root_path) == 0; if (root_is_open || strcmp(norm, "/") == 0) { free(norm); return true; @@ -733,8 +720,9 @@ bool file_directory_exists_secure(const char* path) { char* norm = normalize_directory_path(path); if (!norm) return false; + const char* root_path = utils_get_authorized_root_path(); bool root_is_open = - authorized_root_fd >= 0 && authorized_root_path && strcmp(norm, authorized_root_path) == 0; + utils_get_authorized_root_fd() >= 0 && root_path && strcmp(norm, root_path) == 0; if (root_is_open || strcmp(norm, "/") == 0) { free(norm); return true; diff --git a/src/shared/file.h b/src/shared/file.h index 570d703..3ccc2aa 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -62,9 +62,6 @@ void file_set_keep_dirlinks(bool enable); void file_set_trust_sender(bool enable); bool file_get_trust_sender(void); -/* A configured fd without a canonical identity deliberately rejects paths. */ -bool file_set_authorized_root(int fd, const char* canonical_path); - /* Secure path/filesystem primitives (symlink-safe, O_NOFOLLOW, root-confined). */ bool file_path_exists_secure(const char* path); bool file_stat_secure(const char* path, struct stat* st); diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 57fd2e2..c65c4f8 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -40,7 +40,9 @@ static bool protocol_reserve_memory(ProtocolSession* session, size_t charge) { } } -static void protocol_release_memory_for_session(ProtocolSession* session, size_t charge) { +void protocol_release_memory_for_session(ProtocolSession* session, size_t charge) { + if (!session) + return; unsigned long long allocated = atomic_load(&session->total_allocated_bytes); while (true) { unsigned long long remaining = (unsigned long long)charge >= allocated ? 0 : allocated - charge; @@ -573,6 +575,7 @@ Data* protocol_receive_data_limited(ProtocolSession* session, unsigned long long return NULL; } result->protocol_charge = allocation_size; + result->owner = session; return result; } diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 2758cbe..9fedfb7 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -1,4 +1,5 @@ #include "transport_tcp.h" +#include "daemon_limits.h" #include "log.h" #include "protocol.h" #include "utils.h" @@ -8,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -18,19 +20,45 @@ static volatile sig_atomic_t g_active_connections = 0; +/* Shared registry installed on the active server; the SIGCHLD handler needs a + * file-scope pointer so it can reclaim the dead child's slot. Set once by + * accept_loop before the fork loop (single-threaded parent). */ +static DaemonLimitRegistry* g_limit_registry = NULL; +/* Slot reserved by the parent for the connection child currently being forked. + * Written before fork(), read by the child (which inherits the value). */ +static int g_current_slot = DAEMON_LIMITS_NO_SLOT; + static void tcp_apply_socket_timeout(int fd); static void tcp_enable_nodelay_default(int fd, int family); static void sigchld_handler(int sig) { (void)sig; int saved_errno = errno; - while (waitpid(-1, NULL, WNOHANG) > 0) { + pid_t pid; + while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) { if (g_active_connections > 0) g_active_connections--; + daemon_limits_reclaim_pid(g_limit_registry, (long)pid); } + /* Re-derive the occupancy counters once for the whole reap batch. The slot + * table is the source of truth, so this self-heals any count leaked by a child + * SIGKILLed mid-registration. Atomics only: async-signal-safe. */ + if (g_limit_registry) + daemon_limits_recompute(g_limit_registry); errno = saved_errno; } +/* Reset a signal to its default action with sigaction (preferred over + * signal(3), whose semantics are implementation-defined). Used in the forked + * child before it can spawn any thread. */ +static void reset_signal_default(int sig) { + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = SIG_DFL; + sigemptyset(&action.sa_mask); + sigaction(sig, &action, NULL); +} + /* Map a listen socket's address to its numeric port for logging, independent * of whether it is an IPv4 or IPv6 sockaddr. */ static unsigned short server_address_port(const struct sockaddr_storage* addr) { @@ -108,6 +136,7 @@ Server* server_create_ex(int port, const ServerBindOptions* bind_opts) { server->ssl_ctx = NULL; server->max_connections = 100; server->active_connections = 0; + server->limit_registry = NULL; return server; } @@ -121,6 +150,15 @@ void server_set_max_connections(Server* server, unsigned int max_connections) { server->max_connections = max_connections; } +void server_set_limit_registry(Server* server, struct DaemonLimitRegistry* registry) { + if (server) + server->limit_registry = registry; +} + +int transport_tcp_current_slot(void) { + return g_current_slot; +} + void server_delete(Server** server) { if (server == NULL || *server == NULL) return; @@ -139,7 +177,17 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil log_perror("Could not listen on port!"); return; } - signal(SIGCHLD, sigchld_handler); + /* SIGCHLD via sigaction (not signal(3)); SA_RESTART keeps accept(2) from + * failing with EINTR, and SA_NOCLDSTOP only notifies on child exit. The + * accept loop is single-threaded at this point, so installing here cannot race + * a worker thread. */ + struct sigaction chld_action; + memset(&chld_action, 0, sizeof(chld_action)); + chld_action.sa_handler = sigchld_handler; + sigemptyset(&chld_action.sa_mask); + chld_action.sa_flags = SA_RESTART | SA_NOCLDSTOP; + sigaction(SIGCHLD, &chld_action, NULL); + g_limit_registry = server->limit_registry; while (1) { struct sockaddr_storage client_addr; socklen_t client_len = sizeof(client_addr); @@ -159,9 +207,37 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil close(fd); continue; } + int slot = DAEMON_LIMITS_NO_SLOT; + if (server->limit_registry) { + slot = daemon_limits_claim_slot(server->limit_registry); + if (slot == DAEMON_LIMITS_NO_SLOT) { + /* The global cap bounds live children, so this only happens when the + * fixed registry is smaller than the configured cap; fail closed. */ + log_message(LOG_LEVEL_WARNING, "Connection registry slots exhausted (max %u), rejecting %s", + server->max_connections, peer); + close(fd); + continue; + } + } log_message(LOG_LEVEL_INFO, "%s from %s", log_fmt, peer); + g_current_slot = slot; + /* Block SIGCHLD across fork() and the parent's pid publication: a child + * that exits immediately must not be reaped before its slot records its + * pid, which would leak the slot and its module/source counts. Use + * pthread_sigmask rather than sigprocmask so the behavior is well defined + * even if this process ever gains threads: the mask is per-thread, the fork + * copies only the calling thread, and the child inherits this thread's + * blocked mask until it restores `previous` below. No thread exists yet at + * this point, and none is created before the mask is restored, so the + * critical window is race-free. */ + sigset_t blocked; + sigset_t previous; + sigemptyset(&blocked); + sigaddset(&blocked, SIGCHLD); + pthread_sigmask(SIG_BLOCK, &blocked, &previous); pid_t pid = fork(); if (pid == 0) { + pthread_sigmask(SIG_SETMASK, &previous, NULL); /* Connection children must not run the parent's global cleanup(): it * frees state (credentials / daemon conf) that the child's worker * threads may still be reading and closes fd numbers the child could @@ -169,15 +245,21 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil * terminates the child directly; SIGCHLD is reset too since a child * must never reap the parent's children. This runs before the child * spawns any thread, so it cannot race one. */ - signal(SIGINT, SIG_DFL); - signal(SIGTERM, SIG_DFL); - signal(SIGCHLD, SIG_DFL); + reset_signal_default(SIGINT); + reset_signal_default(SIGTERM); + reset_signal_default(SIGCHLD); close(server->file_descriptor); child_fn(fd, child_ctx); _exit(0); } else if (pid > 0) { g_active_connections++; + if (server->limit_registry) + daemon_limits_set_slot_pid(server->limit_registry, slot, (long)pid); + } else if (server->limit_registry) { + /* fork() failed: release the reservation so the slot is not leaked. */ + daemon_limits_reclaim_slot(server->limit_registry, slot); } + pthread_sigmask(SIG_SETMASK, &previous, NULL); close(fd); } } diff --git a/src/shared/transport_tcp.h b/src/shared/transport_tcp.h index e37b879..c3862a6 100644 --- a/src/shared/transport_tcp.h +++ b/src/shared/transport_tcp.h @@ -7,6 +7,10 @@ #include #include +/* Cross-process daemon registry (daemon_limits.c). Only an opaque pointer is + * stored here so the transport layer does not depend on daemon config. */ +struct DaemonLimitRegistry; + typedef struct Server { struct sockaddr_storage address; unsigned int address_length; @@ -14,6 +18,7 @@ typedef struct Server { void* ssl_ctx; unsigned int max_connections; volatile unsigned int active_connections; + struct DaemonLimitRegistry* limit_registry; } Server; typedef struct Client { @@ -48,6 +53,14 @@ Server* server_create(int port); /* Override the listener's connection cap (the global daemon `max connections` * value). A non-positive value is ignored so the default cap stands. */ void server_set_max_connections(Server* server, unsigned int max_connections); +/* Install the shared per-module / per-source registry used by the accept loop + * to reserve a slot for each forked child. NULL disables the accounting (the + * global cap and ACLs still apply). */ +void server_set_limit_registry(Server* server, struct DaemonLimitRegistry* registry); +/* Slot reserved for the connection child currently running (set by the parent + * before fork, inherited by the child). Returns DAEMON_LIMITS_NO_SLOT (-1) + * outside the accept-loop child path. */ +int transport_tcp_current_slot(void); bool server_listen(Server* server, void (*handler)(int file_descriptor)); void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx, const char* log_fmt); diff --git a/src/shared/utils.c b/src/shared/utils.c index d790172..64a5581 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -36,6 +36,17 @@ void utils_set_authorized_root_fd(int fd) { (void)utils_set_authorized_root(fd, NULL); } +/* Accessors for the process-global authorized root. The path pointer is + * borrowed and valid until the next setter call; the root is a single-threaded, + * set-before-worker-threads value (see server.c), so these carry no locking. */ +int utils_get_authorized_root_fd(void) { + return authorized_root_fd; +} + +const char* utils_get_authorized_root_path(void) { + return authorized_root_path; +} + bool path_is_within_root(const char* root, const char* path) { size_t root_len = strlen(root); return strncmp(root, path, root_len) == 0 && (path[root_len] == '\0' || path[root_len] == '/'); @@ -50,15 +61,16 @@ bool path_is_within_root(const char* root, const char* path) { * in the extra receiver policies they apply, so they are intentionally kept * separate. Both rely on the shared lexical path_is_within_root check. */ static int open_authorized_destination(const char* dest_root) { - if (authorized_root_fd < 0 || !authorized_root_path || !dest_root || - !path_is_within_root(authorized_root_path, dest_root)) + int root_fd = utils_get_authorized_root_fd(); + const char* root_path = utils_get_authorized_root_path(); + if (root_fd < 0 || !root_path || !dest_root || !path_is_within_root(root_path, dest_root)) return -1; - int dirfd = dup(authorized_root_fd); + int dirfd = dup(root_fd); if (dirfd < 0) return -1; - const char* relative_path = dest_root + strlen(authorized_root_path); + const char* relative_path = dest_root + strlen(root_path); while (*relative_path == '/') relative_path++; char* relative = str_dup(*relative_path ? relative_path : "."); @@ -689,11 +701,12 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m if (!build_keep_index(manifest, &keep)) return DELETE_WALK_ERROR; int rootfd; - if (authorized_root_fd >= 0) { - if (authorized_root_path) + int root_fd = utils_get_authorized_root_fd(); + if (root_fd >= 0) { + if (utils_get_authorized_root_path()) rootfd = open_authorized_destination(dest_root); else if (dest_root == NULL) - rootfd = dup(authorized_root_fd); + rootfd = dup(root_fd); else rootfd = -1; } else { diff --git a/src/shared/utils.h b/src/shared/utils.h index f4a5bd6..ff83ac0 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -127,6 +127,21 @@ bool utils_set_authorized_root(int fd, const char* canonical_path); /* The fd-only compatibility form is fail-closed for path-based operations; * callers should use utils_set_authorized_root with the canonical identity. */ void utils_set_authorized_root_fd(int fd); +/* Read accessors for the process-wide authorized root, so every secure-walk + * site consumes the single shared state instead of keeping its own copy. The + * fd is caller-owned (see the setters): it is returned verbatim, never dup'd, + * and the caller that opened it is responsible for closing it. With no root + * configured the fd accessor returns -1 and the path accessor returns NULL. + * + * The pointer returned by utils_get_authorized_root_path() is borrowed into + * process-global state and is invalidated by the next + * utils_set_authorized_root() / utils_set_authorized_root_fd() call. The fd + * and path are stored separately and read independently, so the pair is NOT + * observed atomically together; the accessors are non-reentrant and callers + * must serialize configuration (the server installs the root before any worker + * threads spawn; see utils.c). */ +int utils_get_authorized_root_fd(void); +const char* utils_get_authorized_root_path(void); /* True when `path` is `root` itself or lies directly beneath it: a lexical * prefix test requiring the byte after `root` to be '\0' or '/'. Both `root` * and `path` must be absolute canonical paths free of "."/".." components (the diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 98c963d..a7870e9 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -135,7 +135,7 @@ class DaemonManager: self._proc = None self._port = None - def start(self, config_path, port_override=None, extra_args=None): + def start(self, config_path, port_override=None, extra_args=None, log_path=None): self.stop() # When no override is given the daemon binds the config file's `port` # (the plain config-port path); with an override the --dparam path. @@ -146,7 +146,8 @@ class DaemonManager: cmd += ["--dparam", f"port={port_override}"] if extra_args: cmd += extra_args - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + if log_path is None: + log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") log = open(log_path, "w") self._proc = subprocess.Popen( cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True) @@ -1208,3 +1209,80 @@ class TestDaemonTLSAuth: d.stop() os.unlink(client_creds) shutil.rmtree(cert_dir, ignore_errors=True) + + +class TestDaemonConnectionLimits: + """Wave 8: cross-process per-module / per-source connection caps and the + shared auth lockout. Each test boots its own daemon with a unique port so + the shared (per-daemon) registry state is isolated from the module-scoped + `daemon` fixture.""" + + LOCKOUT_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_lockout.conf") + CAPS_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_caps.conf") + + @pytest.mark.ci + def test_auth_lockout_exempts_trusted_loopback(self): + """`auth lockout threshold = 1`: a trusted loopback peer is EXEMPT from + the shared lockout because every local client shares the 127.0.0.1 + identity, so a single wrong password must not lock out correct-password + attempts (that would be a local denial of service). The shared + per-source lockout machinery itself is covered by the daemon_limits unit + tests; this locks in the loopback policy and the absence of a stale + "locked out" log line.""" + port = _find_free_port() + with open(self.LOCKOUT_CONF, "w") as f: + f.write("port = %d\n" + "auth lockout threshold = 1\n" + "auth lockout duration = 300\n" + "\n" + "[locked]\n" + "path = %s\n" + "auth users = alice\n" + % (port, AUTH_MODULE)) + d = DaemonManager() + log_path = os.path.join(TEST_DATA_DIR, f"fastsyncd_lockout_{os.getpid()}.log") + try: + d.start(self.LOCKOUT_CONF, port_override=port, extra_args=["--password-file", CRED_FILE], + log_path=log_path) + log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 + # First attempt: wrong password -> a failure is logged, but a loopback + # peer is not counted toward the lockout. + wrong = _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS) + assert wrong.returncode != 0 + # Second attempt: the correct password from the same local source must + # still be accepted (no lockout), which also runs the SCRAM handshake + # to completion in a fresh forked child. + right = _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS) + assert right.returncode == 0, (right.stderr or right.stdout) + time.sleep(0.3) + with open(log_path, "rb") as f: + f.seek(log_before) + tail = f.read().decode("utf-8", "replace") + assert "locked out" not in tail, tail[-400:] + finally: + d.stop() + + def test_caps_keys_accepted_and_transfer_still_works(self): + """A daemon configured with the new keys (per-host cap, lockout threshold + and duration, per-module cap) starts and serves a normal transfer.""" + port = _find_free_port() + with open(self.CAPS_CONF, "w") as f: + f.write("port = %d\n" + "max connections per host = 5\n" + "auth lockout threshold = 3\n" + "auth lockout duration = 60\n" + "\n" + "[files]\n" + "path = %s\n" + "max connections = 2\n" + % (port, FILES_MODULE)) + d = DaemonManager() + try: + d.start(self.CAPS_CONF, port_override=port) + result = _push("127.0.0.1::files", port) + assert result.returncode == 0, result.stderr or result.stdout + received = get_dest_received_dir(FILES_MODULE, SOURCE_DIR) + _, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"missing: {missing[:5]}" + finally: + d.stop() diff --git a/tests/runner.c b/tests/runner.c index 9e12323..9549220 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -9,6 +9,7 @@ #include "test_credentials.h" #include "test_data.h" #include "test_daemon_conf.h" +#include "test_daemon_limits.h" #include "test_delay_updates.h" #include "test_delta.h" #include "test_file.h" @@ -85,6 +86,7 @@ int main() { RUN_TEST(test_client_cli); RUN_TEST(test_server); RUN_TEST(test_daemon_conf); + RUN_TEST(test_daemon_limits); RUN_TEST(test_motd); RUN_TEST(test_server_cli); RUN_TEST(test_fuzz_smoke); diff --git a/tests/test_config.c b/tests/test_config.c index 7689cf5..db35101 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,5 +1,6 @@ #include "test_config.h" #include "config.h" +#include "delta.h" #include "identity.h" #include "multiprocessing.h" #include "protocol.h" @@ -2194,6 +2195,592 @@ static void test_config_receive_rejects_unified_invariants() { } } +/* --------------------------------------------------------------------------- + * Wire round-trip equivalence. + * + * config_wire_equal() is generated from the SAME CONFIG_WIRE_FIELDS table as + * the serializer, so it can never miss a serialized field: adding a table + * entry automatically extends this comparison. Each KIND maps to a comparison + * macro; STR_OPT/STR_KEEP normalize the NULL-vs-"" canonicalization the + * receiver performs, RAW_MAXALLOC models the server-side clamp, and + * DERIVED_DELTA compares the effective (whole_file-suppressed) bit. + * ------------------------------------------------------------------------- */ +static void golden_config_populate(Config* c); + +static bool str_opt_equal(const char* a, const char* b) { + if (a == NULL || a[0] == '\0') + return b == NULL || b[0] == '\0'; + return b != NULL && strcmp(a, b) == 0; +} + +static bool idmap_equal(const IdentityMap* a, int ac, const IdentityMap* b, int bc) { + if (ac != bc) + return false; + for (int i = 0; i < ac; i++) { + if (a[i].from != b[i].from || a[i].to != b[i].to) + return false; + } + return true; +} + +static bool skip_suffixes_equal(const Config* a, const Config* b) { + if (a->skip_compress_count != b->skip_compress_count) + return false; + for (int i = 0; i < a->skip_compress_count; i++) { + if (!str_opt_equal(a->skip_compress_suffixes[i], b->skip_compress_suffixes[i])) + return false; + } + return true; +} + +static bool basis_equal(const Config* a, const Config* b) { + if (a->basis_count != b->basis_count) + return false; + for (int i = 0; i < a->basis_count; i++) { + if (a->basis_dirs[i].type != b->basis_dirs[i].type || + !str_opt_equal(a->basis_dirs[i].path, b->basis_dirs[i].path)) + return false; + } + return true; +} + +#define CONFIG_CMP_BOOL(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_RAW(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_BOOL_8BIT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_RAW_MAXALLOC(a, b, name) \ + ((b)->name == ((a)->name > MAX_SERVER_ALLOC ? MAX_SERVER_ALLOC : (a)->name)) +#define CONFIG_CMP_DERIVED_DELTA(a, b, name) ((b)->name == ((a)->name && !(a)->whole_file)) +#define CONFIG_CMP_STR(a, b, name) \ + ((a)->name != NULL && (b)->name != NULL && strcmp((a)->name, (b)->name) == 0) +#define CONFIG_CMP_STR_OPT(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_KEEP(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_MODULE(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_STR_REDACTED_AUTH(a, b, name) str_opt_equal((a)->name, (b)->name) +#define CONFIG_CMP_INT_CHECKSUM_ALGO(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_SUPERMODE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_IDENTITY(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_SKIPCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_BASISCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_INT_IDMAPCOUNT(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_BOOL_XATTR_DERIVE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_COPY_AS_PRESENCE(a, b, name) ((a)->name == (b)->name) +#define CONFIG_CMP_COPY_AS_ID(a, b, name) (!(a)->copy_as_set || (a)->name == (b)->name) +#define CONFIG_CMP_BLOCK_SKIP_SUFFIXES(a, b, name) skip_suffixes_equal((a), (b)) +#define CONFIG_CMP_BLOCK_BASIS(a, b, name) basis_equal((a), (b)) +#define CONFIG_CMP_BLOCK_IDMAP(a, b, name) \ + idmap_equal((a)->name, (a)->name##_count, (b)->name, (b)->name##_count) + +#define WIRE_CMP(name, ctype, def, kind) \ + &&(CONFIG_CMP_##kind(a, b, name) \ + ? true \ + : (fprintf(stderr, " mismatched field: %s\n", #name), false)) + +static bool config_wire_equal(const Config* a, const Config* b) { + return true CONFIG_WIRE_FIELDS(WIRE_CMP); +} + +static bool roundtrip_and_compare(const Config* send_cfg) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return false; + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + Config* recv = config_receive(p[0]); + bool equal = recv != NULL && config_wire_equal(send_cfg, recv); + config_delete(recv); + close(p[0]); + _exit(equal ? 0 : 1); + } + close(p[0]); + io_set_fds(p[1], p[1]); + io_set_bwlimit(0); + bool sent = config_send(p[1], send_cfg); + int status; + waitpid(pid, &status, 0); + close(p[1]); + return sent && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + +/* Every serialized field must survive a frame round-trip, for a defaults config + * and for a fully-populated config. */ +static void test_config_wire_roundtrip_all_fields() { + if (is_running_under_valgrind()) + return; + + Config* defaults = config_create(); + EXPECT_NOT_NULL(defaults); + defaults->send_directory = str_dup("/src"); + defaults->receive_root_directory = str_dup("/dst"); + EXPECT_TRUE(roundtrip_and_compare(defaults)); + config_delete(defaults); + + Config* populated = config_create(); + EXPECT_NOT_NULL(populated); + /* The golden fixture is already receiver-valid, so the same fully-populated + * config that backs the byte-exact golden also round-trips unchanged. */ + golden_config_populate(populated); + EXPECT_TRUE(roundtrip_and_compare(populated)); + config_delete(populated); +} + +/* Populate every serialized field with a non-default value so the wire frame + * exercises each table entry. Boolean runs deliberately alternate true/false: + * a run of identical booleans would make an adjacent swap (same KIND) produce + * the same byte stream, hiding a table reorder from the golden hash. The whole + * frame stays receiver-valid so the receive-side golden can feed it straight + * through config_receive() (hence the valid chmod grammar and delta bound). */ +static void golden_config_populate(Config* c) { + c->eight_bit_output = true; + c->max_alloc = 123456789ULL; + c->send_directory = str_dup("/golden/src"); + c->receive_root_directory = str_dup("/golden/dst"); + c->save_to_disk = true; + c->use_multithreading = false; + c->use_chunk_serialization = false; + c->use_compression = true; + c->use_metadata = true; + c->use_executability = false; + c->compression_level = 7; + c->chunk_size = 65536; + c->use_sendfile = false; + c->use_delete = true; + c->use_incremental = true; + c->size_only = false; + c->ignore_times = true; + c->use_delta = true; + c->whole_file = false; + c->delta_block_size = 4096; + c->delta_max_file_size = 200000000ULL; + c->backup = true; + c->backup_dir = str_dup("/golden/backup"); + c->remove_source_files = false; + c->follow_symlinks = true; + c->copy_links = false; + c->safe_links = true; + c->copy_unsafe_links = false; + c->preserve_hard_links = true; + c->preserve_acls = false; + c->preserve_xattrs = true; + c->preserve_devices = false; + c->preserve_sparse = true; + c->preserve_specials = false; + c->copy_devices = true; + c->write_devices = false; + c->ignore_existing = true; + c->existing = false; + c->update = true; + c->inplace = false; + c->delay_updates = true; + c->append = false; + c->use_fsync = true; + c->append_verify = false; + c->delete_excluded = true; + c->force_delete = false; + c->delete_missing_args = true; + c->delete_after = false; + c->preallocate = true; + c->max_delete = 42; + c->relative = false; + c->prune_empty_dirs = true; + c->mkpath = false; + c->delete_during = true; + c->delete_delay = false; + c->temp_dir = str_dup("/golden/tmp"); + c->partial = true; + c->partial_dir = str_dup("/golden/partial"); + c->suffix = str_dup(".golden"); + c->delete_before = false; + c->checksum = true; + c->modify_window = 3; + c->compress_choice = str_dup("zstd"); + /* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the + * frame stays 633 bytes) but X is not in FastSync's chmod grammar, and the + * receive-side golden validates the frame. */ + c->chmod_spec = str_dup("u=rwx,go=rx"); + c->skip_compress_set = true; + c->skip_compress_count = 2; + c->skip_compress_suffixes = calloc(2, sizeof(char*)); + c->skip_compress_suffixes[0] = str_dup(".gz"); + c->skip_compress_suffixes[1] = str_dup(".xz"); + EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_COMPARE, "compare"), 0); + EXPECT_EQ_INT(config_basis_append(c, BASIS_DEST_LINK, "link"), 0); + c->fuzzy = true; + c->checksum_algo = CHECKSUM_ALGO_MD5; + c->checksum_seed = 0x1122334455667788ULL; + c->numeric_ids = true; + c->chown_uid_set = false; + c->chown_uid = 1234; + c->chown_gid_set = true; + c->chown_gid = 5678; + c->usermap_count = 2; + c->usermap = calloc(2, sizeof(IdentityMap)); + c->usermap[0].from = IDENTITY_MATCH_ANY; + c->usermap[0].to = 1000; + c->usermap[1].from = 5; + c->usermap[1].to = 6; + c->groupmap_count = 1; + c->groupmap = calloc(1, sizeof(IdentityMap)); + c->groupmap[0].from = 7; + c->groupmap[0].to = 8; + c->preserve_atimes = true; + c->preserve_crtimes = false; + c->omit_dir_times = true; + c->omit_link_times = false; + c->munge_links = true; + c->keep_dirlinks = false; + c->fake_super = true; + c->module = str_dup("goldenmod"); + c->auth_user = str_dup("goldenuser"); + c->auth_password = str_dup("golden-pw"); + c->iconv_spec = str_dup("UTF-8,UTF-8"); + c->super_mode = SUPER_MODE_ON; + c->copy_as_set = true; + c->copy_as_uid = 111; + c->copy_as_gid = 222; +} + +/* The pinned golden frame (protocol 2.20.0). The values below are the only + * thing that ties the generated table to the historical wire format; update + * them ONLY with a PROTOCOL_VERSION bump and a documented reason. */ +#define GOLDEN_WIRE_LEN 633 +#define GOLDEN_WIRE_HASH 9160991280011164139ULL + +static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) { + unsigned long long h = 1469598103934665603ULL; + for (size_t i = 0; i < len; i++) { + h ^= (unsigned long long)buf[i]; + h *= 1099511628211ULL; + } + return h; +} + +/* Capture the exact config-frame body emitted by config_send_wire_block() into + * a heap buffer. Returns NULL on any failure. */ +static unsigned char* capture_wire_bytes(const Config* cfg, size_t* out_len) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return NULL; + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + bool ok = config_send_wire_block(p[0], cfg); + close(p[0]); + _exit(ok ? 0 : 1); + } + close(p[0]); + size_t capacity = 1024; + size_t total = 0; + unsigned char* bytes = malloc(capacity); + if (!bytes) { + close(p[1]); + waitpid(pid, NULL, 0); + return NULL; + } + for (;;) { + if (total == capacity) { + size_t grown_capacity = capacity * 2; + unsigned char* grown = realloc(bytes, grown_capacity); + if (!grown) { + free(bytes); + close(p[1]); + waitpid(pid, NULL, 0); + return NULL; + } + bytes = grown; + capacity = grown_capacity; + } + ssize_t n = read(p[1], bytes + total, capacity - total); + if (n < 0) { + free(bytes); + close(p[1]); + waitpid(pid, NULL, 0); + return NULL; + } + if (n == 0) + break; + total += (size_t)n; + } + close(p[1]); + int status = 0; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + free(bytes); + return NULL; + } + *out_len = total; + return bytes; +} + +/* FNV-1a 64 over the exact config-frame bytes emitted by + * config_send_wire_block(). This pins field order and width: any reorder or + * resize changes the hash. */ +static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len) { + unsigned char* bytes = capture_wire_bytes(cfg, out_len); + if (!bytes) + return 0; + unsigned long long h = fnv1a_64(bytes, *out_len); + free(bytes); + return h; +} + +/* Byte-for-byte wire compatibility guard (protocol 2.20.0). The expected hash + * pins the pre-X-macro byte stream; the refactor MUST NOT change it. */ +static void test_config_wire_golden() { + if (is_running_under_valgrind()) + return; + Config* c = config_create(); + EXPECT_NOT_NULL(c); + golden_config_populate(c); + size_t len = 0; + unsigned long long h = capture_wire_hash(c, &len); + printf(" wire golden: len=%zu hash=%llu\n", len, h); + EXPECT_TRUE(len == GOLDEN_WIRE_LEN); + EXPECT_TRUE(h == GOLDEN_WIRE_HASH); + config_delete(c); +} + +/* Receive-side oracle. Hashing the sender alone cannot catch a RECV KIND that + * reads a different width/order yet still round-trips symmetrically, so feed + * the SAME hash-pinned golden bytes through config_receive() and assert both + * the decoded struct fields and the derived bits. Because the bytes are + * anchored to the send golden, a divergence on either side fails here. */ +static void test_config_wire_golden_receive() { + if (is_running_under_valgrind()) + return; + Config* c = config_create(); + EXPECT_NOT_NULL(c); + golden_config_populate(c); + + size_t len = 0; + unsigned char* bytes = capture_wire_bytes(c, &len); + EXPECT_NOT_NULL(bytes); + EXPECT_TRUE(len == GOLDEN_WIRE_LEN); + EXPECT_TRUE(fnv1a_64(bytes, len) == GOLDEN_WIRE_HASH); + + int p[2]; + EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); + io_set_fds(p[0], p[1]); + io_set_bwlimit(0); + pid_t pid = fork(); + if (pid == 0) { + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + Config* recv = config_receive(p[0]); + bool ok = recv != NULL; + if (ok) { + /* Full field-by-field comparison (generated from CONFIG_WIRE_FIELDS). */ + ok = config_wire_equal(c, recv); + /* Explicit spot checks of the decoded struct, including derived bits. */ + ok = ok && recv->eight_bit_output && recv->use_compression && recv->use_metadata && + !recv->use_multithreading; + ok = ok && recv->compression_level == 7 && recv->chunk_size == 65536; + ok = ok && recv->use_delta && !recv->whole_file && recv->use_xattrs; + /* Bounded/validated KINDs decoded from the pinned bytes. */ + ok = ok && recv->checksum_algo == CHECKSUM_ALGO_MD5; + ok = ok && recv->super_mode == SUPER_MODE_ON; + ok = ok && recv->chown_uid == 1234 && recv->chown_gid == 5678; + ok = ok && recv->usermap_count == 2 && recv->usermap[0].from == IDENTITY_MATCH_ANY && + recv->usermap[0].to == 1000 && recv->usermap[1].from == 5 && recv->usermap[1].to == 6; + ok = ok && recv->basis_count == 2 && recv->basis_dirs[0].type == BASIS_DEST_COMPARE && + recv->basis_dirs[1].type == BASIS_DEST_LINK; + ok = ok && recv->module != NULL && strcmp(recv->module, "goldenmod") == 0; + ok = ok && recv->copy_as_set && recv->copy_as_uid == 111 && recv->copy_as_gid == 222; + } + config_delete(recv); + close(p[0]); + _exit(ok ? 0 : 1); + } + close(p[0]); + io_set_fds(p[1], p[1]); + io_set_bwlimit(0); + size_t written = 0; + bool wrote = true; + while (written < len) { + ssize_t n = write(p[1], bytes + written, len - written); + if (n <= 0) { + wrote = false; + break; + } + written += (size_t)n; + } + Status status = STATUS_ERROR; + bool got_status = wrote && receive_status(p[1], &status); + close(p[1]); + free(bytes); + int child_status = 0; + waitpid(pid, &child_status, 0); + EXPECT_TRUE(got_status && status == STATUS_OK); + EXPECT_TRUE(WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0); + config_delete(c); +} + +/* Hand-build a frame that is valid up to the first core BOOL, then write an + * out-of-range boolean (2): a BOOL receiver must reject anything but 0/1. */ +static void write_frame_with_invalid_bool(int fd) { + send_str(fd, PROTOCOL_VERSION); + send_int(fd, 1); /* eight_bit_output */ + unsigned long long max_alloc = DEFAULT_MAX_ALLOC; + send_n_data(fd, &max_alloc, sizeof(max_alloc)); + send_str(fd, "/src"); + send_str(fd, "/dst"); + send_int(fd, 2); /* save_to_disk: not 0/1 */ +} + +/* Feed a caller-built frame into config_receive() and report whether the + * receiver rejected it. The writer runs in a child (SIGPIPE ignored) so a + * mid-frame rejection cannot kill the test process. */ +static bool receive_hand_built_frame_rejected(void (*write_frame)(int fd)) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return false; + pid_t pid = fork(); + if (pid == 0) { + (void)signal(SIGPIPE, SIG_IGN); + close(p[0]); + io_set_fds(p[1], p[1]); + io_set_bwlimit(0); + write_frame(p[1]); + close(p[1]); + _exit(0); + } + close(p[1]); + io_set_fds(p[0], p[0]); + io_set_bwlimit(0); + Config* recv = config_receive(p[0]); + bool rejected = recv == NULL; + config_delete(recv); + close(p[0]); + int status = 0; + waitpid(pid, &status, 0); + return rejected; +} + +/* Receive-side bounds for the bounded/validated KINDs that the round-trip + * helper cannot exercise (an illegal value has no symmetric sender). */ +static void test_config_wire_receive_bounds() { + if (is_running_under_valgrind()) + return; + + /* BOOL: only 0/1 is a legal wire value. */ + EXPECT_TRUE(receive_hand_built_frame_rejected(write_frame_with_invalid_bool)); + + /* RAW_MAXALLOC: zero is rejected before it can become the session ceiling. */ + Config* c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->max_alloc = 0; + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); + + /* STR_MODULE: a name outside [A-Za-z0-9._-] is refused. */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->module = str_dup("bad module"); + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); + + /* INT_IDMAPCOUNT: one past the identity-map cap is refused at the count. */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->usermap_count = MAX_IDENTITY_MAP + 1; + c->usermap = calloc((size_t)c->usermap_count, sizeof(IdentityMap)); + if (c->usermap) { + for (int i = 0; i < c->usermap_count; i++) { + c->usermap[i].from = 0; + c->usermap[i].to = 0; + } + } + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); + + /* INT_IDENTITY: an out-of-range chown_uid (below IDENTITY_MATCH_ANY) is + * refused by the identity validator. */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->chown_uid_set = true; + c->chown_uid = IDENTITY_MATCH_ANY - 1; + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); +} + +/* Regression (pre-auth NULL-deref): the *_count receive helpers used to write + * the peer-controlled int through the Config member BEFORE validating it. An + * over-cap basis_count therefore left config->basis_count huge while + * config->basis_dirs stayed NULL; the config_receive() error path then called + * config_delete(), whose `for (i < basis_count) free(basis_dirs[i].path)` loop + * dereferenced NULL. A malicious client could crash the daemon before auth. + * + * The helpers now validate a LOCAL and publish only on success, so a rejected + * count leaves the member at its safe default (0). The idmap/skip helpers have + * the same "write then validate" shape and are covered here too, as is the + * config_delete() NULL-array guard that backstops the whole class. */ +static void test_config_receive_rejects_overcap_counts() { + if (is_running_under_valgrind()) + return; + + /* Over-cap basis count. The values are injected directly (config_basis_append + * enforces the cap) with a matching array so the sender can emit the block; + * the receiver must reject at the count and remain crash-free while deleting + * the partially populated Config. */ + Config* c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->basis_count = MAX_BASIS_DIRS + 1; + c->basis_dirs = calloc((size_t)c->basis_count, sizeof(BasisDest)); + EXPECT_NOT_NULL(c->basis_dirs); + for (int i = 0; i < c->basis_count; i++) { + c->basis_dirs[i].type = BASIS_DEST_LINK; + c->basis_dirs[i].path = str_dup("basis"); + } + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); + + /* Over-cap identity-map count (usermap and groupmap share the helper). */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->send_directory = str_dup("/src"); + c->receive_root_directory = str_dup("/dst"); + c->usermap_count = MAX_IDENTITY_MAP + 1; + c->usermap = calloc((size_t)c->usermap_count, sizeof(IdentityMap)); + EXPECT_NOT_NULL(c->usermap); + for (int i = 0; i < c->usermap_count; i++) { + c->usermap[i].from = 0; + c->usermap[i].to = 0; + } + EXPECT_TRUE(roundtrip_config_rejected(c)); + config_delete(c); + + /* Over-cap skip-compress count. */ + Config* over_skip = make_skip_compress_config(MAX_SKIP_COMPRESS_SUFFIXES + 1, 1); + EXPECT_NOT_NULL(over_skip); + EXPECT_TRUE(roundtrip_config_rejected(over_skip)); + config_delete(over_skip); + + /* Defense-in-depth: config_delete() on a Config left with a non-zero count + * but a NULL array (the exact partial state an over-cap count used to leave + * behind) must be safe. */ + c = config_create(); + EXPECT_NOT_NULL(c); + c->basis_count = MAX_BASIS_DIRS + 1; + c->basis_dirs = NULL; + config_delete(c); +} + void test_config() { test_config_lifecycle(); test_config_ssh_dest(); @@ -2249,6 +2836,11 @@ void test_config() { test_config_receive_with_validate_rejects(); test_config_invariants_error_all_combinations(); test_config_receive_rejects_unified_invariants(); + test_config_wire_golden(); + test_config_wire_golden_receive(); + test_config_wire_receive_bounds(); + test_config_receive_rejects_overcap_counts(); + test_config_wire_roundtrip_all_fields(); } test_identity_copy_as_refused(); test_identity_ownership_requested(); diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index e0020c9..3cddf0f 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -33,6 +33,11 @@ static void test_daemon_conf_create_defaults() { EXPECT_NULL(conf->global.address); EXPECT_EQ_INT(conf->global.max_connections, DAEMON_CONF_DEFAULT_MAX_CONNECTIONS); EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS); + EXPECT_EQ_INT(conf->global.max_connections_per_host, + DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST); + EXPECT_EQ_INT(conf->global.auth_lockout_threshold, DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD); + EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec, + DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC); EXPECT_EQ_INT(conf->global.hosts_allow_count, 0); EXPECT_EQ_INT(conf->global.hosts_deny_count, 0); EXPECT_EQ_INT(conf->module_count, 0); @@ -316,6 +321,12 @@ static void test_daemon_conf_dparam_override() { EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "max connections=7", err, sizeof(err)), 0); EXPECT_EQ_INT(conf->global.max_connections, 7); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "max connections per host=3", err, sizeof(err)), 0); + EXPECT_EQ_INT(conf->global.max_connections_per_host, 3); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "auth lockout threshold=5", err, sizeof(err)), 0); + EXPECT_EQ_INT(conf->global.auth_lockout_threshold, 5); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "auth lockout duration=120", err, sizeof(err)), 0); + EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec, 120); EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "AUTH FAILURE DELAY=1500", err, sizeof(err)), 0); EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 1500); EXPECT_EQ_INT( @@ -390,6 +401,9 @@ static void test_daemon_conf_limits_and_hosts_parse() { char err[256]; EXPECT_EQ_INT(write_conf("max connections = 25\n" "auth failure delay = 0\n" + "max connections per host = 4\n" + "auth lockout threshold = 3\n" + "auth lockout duration = 60\n" "hosts allow = 10.0.0.0/8, 192.168.1.0/24\n" "hosts deny = 192.168.0.1 2001:db8::/32\n" "\n" @@ -405,6 +419,9 @@ static void test_daemon_conf_limits_and_hosts_parse() { EXPECT_NOT_NULL(conf); EXPECT_EQ_INT(conf->global.max_connections, 25); EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 0); + EXPECT_EQ_INT(conf->global.max_connections_per_host, 4); + EXPECT_EQ_INT(conf->global.auth_lockout_threshold, 3); + EXPECT_EQ_INT(conf->global.auth_lockout_duration_sec, 60); EXPECT_EQ_INT(conf->global.hosts_allow_count, 2); EXPECT_EQ_STR(conf->global.hosts_allow[0], "10.0.0.0/8"); EXPECT_EQ_STR(conf->global.hosts_allow[1], "192.168.1.0/24"); @@ -419,11 +436,14 @@ static void test_daemon_conf_limits_and_hosts_parse() { daemon_conf_free(conf); const char* bad_values[] = { - "max connections = 0\n", "max connections = -1\n", - "max connections = abc\n", "auth failure delay = -1\n", - "auth failure delay = 70000\n", "auth failure delay = soon\n", - "hosts allow = 10.0.0.0/99\n", "hosts deny = 2001:db8::/129\n", - "hosts allow = *.example.com\n", "hosts deny = not-an-ip\n", + "max connections = 0\n", "max connections = -1\n", + "max connections = abc\n", "auth failure delay = -1\n", + "auth failure delay = 70000\n", "auth failure delay = soon\n", + "max connections per host = -1\n", "max connections per host = lots\n", + "auth lockout threshold = -2\n", "auth lockout threshold = many\n", + "auth lockout duration = -1\n", "auth lockout duration = forever\n", + "hosts allow = 10.0.0.0/99\n", "hosts deny = 2001:db8::/129\n", + "hosts allow = *.example.com\n", "hosts deny = not-an-ip\n", }; for (size_t i = 0; i < sizeof(bad_values) / sizeof(bad_values[0]); i++) { EXPECT_EQ_INT(write_conf(bad_values[i], &path), 0); @@ -434,7 +454,8 @@ static void test_daemon_conf_limits_and_hosts_parse() { /* The same strictness applies inside a module section. */ const char* bad_module[] = { - "[m]\npath = /x\nmax connections = 0\n", + "[m]\npath = /x\nmax connections = -1\n", + "[m]\npath = /x\nmax connections = abc\n", "[m]\npath = /x\nhosts allow = 10.0.0.0/40\n", "[m]\npath = /x\nhosts deny = 999.1.1.1/8\n", }; @@ -446,6 +467,14 @@ static void test_daemon_conf_limits_and_hosts_parse() { EXPECT_TRUE(strstr(err, "invalid") != NULL); } + /* Module `max connections = 0` is now valid and means unlimited. */ + EXPECT_EQ_INT(write_conf("[m]\npath = /x\nmax connections = 0\n", &path), 0); + conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NOT_NULL(conf); + EXPECT_EQ_INT(conf->modules[0].max_connections, 0); + daemon_conf_free(conf); + /* An empty hosts list is not an error (no patterns are added). */ EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0); conf = daemon_conf_load(path, err, sizeof(err)); @@ -509,6 +538,35 @@ static void test_daemon_module_name_valid() { } } +static void test_daemon_conf_module_count_capped() { + size_t cap = DAEMON_CONF_MAX_MODULES; + size_t len = (cap + 8) * 32; + char* body = malloc(len); + EXPECT_NOT_NULL(body); + size_t used = 0; + body[0] = '\0'; + for (size_t i = 0; i < cap + 1; i++) { + char line[48]; + int n = snprintf(line, sizeof(line), "[m%zu]\npath = /x\n", i); + if (n < 0 || (size_t)n >= sizeof(line) || used + (size_t)n >= len) { + free(body); + EXPECT_FAIL("module-count test buffer overflow"); + return; + } + memcpy(body + used, line, (size_t)n); + used += (size_t)n; + body[used] = '\0'; + } + char* path; + EXPECT_EQ_INT(write_conf(body, &path), 0); + free(body); + char err[256]; + const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(conf); + EXPECT_TRUE(strstr(err, "too many modules") != NULL); +} + void test_daemon_conf() { test_daemon_conf_create_defaults(); test_daemon_conf_full_parse(); @@ -525,6 +583,7 @@ void test_daemon_conf() { test_daemon_conf_dparam_override(); test_daemon_conf_auth_users_validated(); test_daemon_conf_limits_and_hosts_parse(); + test_daemon_conf_module_count_capped(); test_daemon_hosts_allowed(); test_daemon_module_name_valid(); } \ No newline at end of file diff --git a/tests/test_daemon_limits.c b/tests/test_daemon_limits.c new file mode 100644 index 0000000..2b04b7b --- /dev/null +++ b/tests/test_daemon_limits.c @@ -0,0 +1,311 @@ +#include "test_daemon_limits.h" +#include "daemon_limits.h" +#include "test_utils.h" +#include +#include +#include +#include +#include + +/* The per-source hash is a pure helper: numeric addresses hash to a nonzero, + * stable value and unparseable input reports failure. */ +static void test_daemon_limits_host_hash() { + bool ok = false; + uint64_t v4 = daemon_limits_host_hash("127.0.0.1", &ok); + EXPECT_TRUE(ok); + EXPECT_TRUE(v4 != 0); + EXPECT_EQ_INT((int)(daemon_limits_host_hash("127.0.0.1", NULL) == v4), 1); + + bool ok6 = false; + uint64_t v6 = daemon_limits_host_hash("2001:db8::1", &ok6); + EXPECT_TRUE(ok6); + EXPECT_TRUE(v6 != 0); + /* Distinct textual forms of different addresses must differ. */ + EXPECT_TRUE(v4 != v6); + + bool bad = true; + EXPECT_TRUE(daemon_limits_host_hash("not-an-ip", &bad) == 0); + EXPECT_FALSE(bad); + bad = true; + EXPECT_TRUE(daemon_limits_host_hash(NULL, &bad) == 0); + EXPECT_FALSE(bad); + bad = true; + EXPECT_TRUE(daemon_limits_host_hash("", &bad) == 0); + EXPECT_FALSE(bad); +} + +/* Slot reservation is a plain parent-side resource: claim until exhausted, + * reclaim, then claim again. */ +static void test_daemon_limits_slots() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 0, 0, 0); + EXPECT_NOT_NULL(registry); + int slots[DAEMON_LIMITS_MIN_SLOTS]; + for (int i = 0; i < DAEMON_LIMITS_MIN_SLOTS; i++) { + slots[i] = daemon_limits_claim_slot(registry); + EXPECT_EQ_INT(slots[i], i); + } + EXPECT_EQ_INT(daemon_limits_claim_slot(registry), DAEMON_LIMITS_NO_SLOT); + daemon_limits_reclaim_slot(registry, slots[3]); + int reclaimed = daemon_limits_claim_slot(registry); + EXPECT_EQ_INT(reclaimed, slots[3]); + daemon_limits_destroy(registry); +} + +/* Per-module accounting: the cap is enforced across slots and a reclaimed slot + * frees a module count. */ +static void test_daemon_limits_module_cap() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 0, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + int slot2 = daemon_limits_claim_slot(registry); + int slot3 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0 && slot2 >= 0 && slot3 >= 0); + + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 2), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 2), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2), + DAEMON_LIMIT_MODULE_FULL); + /* A different module has its own counter. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 1, "10.0.0.3", 2), DAEMON_LIMIT_OK); + /* A module cap of 0 is unlimited. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot3, 0, "10.0.0.3", 0), DAEMON_LIMIT_OK); + + daemon_limits_reclaim_slot(registry, slot0); + daemon_limits_reclaim_slot(registry, slot1); + daemon_limits_recompute(registry); + int slot4 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot4 >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot4, 0, "10.0.0.4", 2), DAEMON_LIMIT_OK); + + daemon_limits_destroy(registry); +} + +/* Per-source accounting: the same peer hits the cap, a different peer does not. */ +static void test_daemon_limits_host_cap() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + int slot2 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0 && slot2 >= 0); + + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 0), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 0), DAEMON_LIMIT_HOST_FULL); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.2", 0), DAEMON_LIMIT_OK); + /* Reclaiming the first source frees its per-host allowance. */ + daemon_limits_reclaim_slot(registry, slot0); + daemon_limits_recompute(registry); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 0), DAEMON_LIMIT_OK); + + daemon_limits_destroy(registry); +} + +/* The pid-indexed reclaim is what the parent's SIGCHLD handler uses: a dead + * child's module/source counts must be released. */ +static void test_daemon_limits_reclaim_pid() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0); + daemon_limits_set_slot_pid(registry, slot0, 4242); + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 1), DAEMON_LIMIT_OK); + /* Cap (module 1) and per-host (1) are both saturated. */ + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 1), + DAEMON_LIMIT_MODULE_FULL); + + daemon_limits_reclaim_pid(registry, 4242); + daemon_limits_recompute(registry); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.1", 1), DAEMON_LIMIT_OK); + /* Reclaiming an unknown pid is a no-op. */ + daemon_limits_reclaim_pid(registry, 999999); + + daemon_limits_destroy(registry); +} + +/* Cross-process lockout: failures counted in the shared mapping lock the source + * out after the threshold; a success clears it; threshold 0 disables it. */ +static void test_daemon_limits_auth_lockout() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 2, 300); + EXPECT_NOT_NULL(registry); + + int remaining = 0; + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_TRUE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + EXPECT_TRUE(remaining > 0 && remaining <= 300); + /* Another source is unaffected. */ + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.2", &remaining)); + /* A successful authentication clears the lockout. */ + daemon_limits_auth_record_success(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_destroy(registry); + + /* threshold 0 disables the lockout entirely. */ + registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 0, 300); + EXPECT_NOT_NULL(registry); + for (int i = 0; i < 50; i++) + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_destroy(registry); +} + +/* The registry must be visible across fork(): a child's registration is seen by + * the parent, and the parent's pid reclaim releases it. */ +static void test_daemon_limits_fork_shared() { + if (is_running_under_valgrind()) + return; /* fork + shared mapping is slow/noisy under valgrind */ + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 0, 0); + EXPECT_NOT_NULL(registry); + + int slot0 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0); + pid_t pid = fork(); + if (pid == 0) { + if (daemon_limits_register(registry, slot0, 0, "10.0.0.1", 1) != DAEMON_LIMIT_OK) + _exit(1); + _exit(0); + } + EXPECT_TRUE(pid > 0); + daemon_limits_set_slot_pid(registry, slot0, (long)pid); + int status = 0; + EXPECT_TRUE(waitpid(pid, &status, 0) == pid); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + /* The child's module count is still held in the shared mapping. */ + int slot1 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot1 >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), + DAEMON_LIMIT_MODULE_FULL); + /* The parent reclaims the dead child's slot by pid. */ + daemon_limits_reclaim_pid(registry, (long)pid); + daemon_limits_recompute(registry); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), DAEMON_LIMIT_OK); + daemon_limits_destroy(registry); +} + +/* Cross-process auth lockout: failures recorded by forked children against the + * shared mmap must lock the source out for the parent. This is the + * cross-process path the integration test can no longer cover because trusted + * loopback peers are exempt from the per-host limits. */ +static void test_daemon_limits_fork_auth_lockout() { + if (is_running_under_valgrind()) + return; /* fork + shared mapping is slow/noisy under valgrind */ + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 0, 2, 300); + EXPECT_NOT_NULL(registry); + + int remaining = 0; + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + + /* One failure from each of two children reaches the threshold of 2 in the + * shared mapping; atomics only, no mtx/malloc, so fork-safe. */ + for (int i = 0; i < 2; i++) { + pid_t pid = fork(); + if (pid == 0) { + daemon_limits_auth_record_failure(registry, "10.0.0.1"); + _exit(0); + } + EXPECT_TRUE(pid > 0); + int status = 0; + EXPECT_TRUE(waitpid(pid, &status, 0) == pid); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + } + + /* The parent observes the lockout the children established. */ + EXPECT_TRUE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + EXPECT_TRUE(remaining > 0 && remaining <= 300); + /* A different source is unaffected across processes. */ + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.2", &remaining)); + /* The parent clears the shared lockout on a successful authentication. */ + daemon_limits_auth_record_success(registry, "10.0.0.1"); + EXPECT_FALSE(daemon_limits_auth_locked(registry, "10.0.0.1", &remaining)); + daemon_limits_destroy(registry); +} + +/* The occupancy arrays are derived from the slot table: recompute rebuilds them + * and is the self-heal path the SIGCHLD handler uses after a child dies. */ +static void test_daemon_limits_recompute() { + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 2, 1, 0, 0); + EXPECT_NOT_NULL(registry); + int slot0 = daemon_limits_claim_slot(registry); + int slot1 = daemon_limits_claim_slot(registry); + int slot2 = daemon_limits_claim_slot(registry); + EXPECT_TRUE(slot0 >= 0 && slot1 >= 0 && slot2 >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, slot0, 0, "10.0.0.1", 0), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 0), DAEMON_LIMIT_OK); + + /* Recompute is idempotent and re-derives the same counts from REGISTERED + * slots (a CLAIMED slot is never counted). */ + daemon_limits_recompute(registry); + daemon_limits_recompute(registry); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2), + DAEMON_LIMIT_MODULE_FULL); + + /* Freeing a slot and recomputing releases its module/per-source count. */ + daemon_limits_reclaim_slot(registry, slot0); + daemon_limits_recompute(registry); + EXPECT_EQ_INT(daemon_limits_register(registry, slot2, 0, "10.0.0.3", 2), DAEMON_LIMIT_OK); + daemon_limits_destroy(registry); +} + +/* The per-source table has a bounded lifetime. When every bucket is occupied + * but not yet reclaimable, a new source is fail-open: the per-host cap is not + * enforced and the probe must terminate. Once the occupied buckets' lockouts + * expire (or they go idle), a new source reclaims a bucket and enforcement comes + * back. This covers the "table never evicts -> cap silently fails open forever" + * review finding. */ +static void test_daemon_limits_host_table_eviction() { + char ip[32]; + + /* Part A: all buckets locked out with a long deadline and no active + * connection are not reclaimable yet. A new source cannot be interned, so the + * per-host cap is documented fail-open (both connections admitted) -- and the + * bounded probe returns instead of looping forever. */ + DaemonLimitRegistry* registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 1, 300); + EXPECT_NOT_NULL(registry); + for (int i = 0; i < 64; i++) { + snprintf(ip, sizeof(ip), "10.0.0.%d", i + 1); + daemon_limits_auth_record_failure(registry, ip); + } + int a = daemon_limits_claim_slot(registry); + int b = daemon_limits_claim_slot(registry); + EXPECT_TRUE(a >= 0 && b >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, a, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, b, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK); + daemon_limits_destroy(registry); + + /* Part B: with an already-expired lockout every bucket is reclaimable, so a + * new source reclaims one and the per-host cap is enforced again. */ + registry = daemon_limits_create(DAEMON_LIMITS_MIN_SLOTS, 1, 1, 1, 1); + EXPECT_NOT_NULL(registry); + for (int i = 0; i < 64; i++) { + snprintf(ip, sizeof(ip), "10.0.0.%d", i + 1); + daemon_limits_auth_record_failure(registry, ip); + } + struct timespec pause = {2, 0}; + nanosleep(&pause, NULL); + int c = daemon_limits_claim_slot(registry); + int d = daemon_limits_claim_slot(registry); + EXPECT_TRUE(c >= 0 && d >= 0); + EXPECT_EQ_INT(daemon_limits_register(registry, c, 0, "10.9.9.9", 0), DAEMON_LIMIT_OK); + EXPECT_EQ_INT(daemon_limits_register(registry, d, 0, "10.9.9.9", 0), DAEMON_LIMIT_HOST_FULL); + daemon_limits_destroy(registry); +} + +void test_daemon_limits() { + test_daemon_limits_host_hash(); + test_daemon_limits_slots(); + test_daemon_limits_module_cap(); + test_daemon_limits_host_cap(); + test_daemon_limits_reclaim_pid(); + test_daemon_limits_recompute(); + test_daemon_limits_auth_lockout(); + test_daemon_limits_host_table_eviction(); + test_daemon_limits_fork_shared(); + test_daemon_limits_fork_auth_lockout(); +} diff --git a/tests/test_daemon_limits.h b/tests/test_daemon_limits.h new file mode 100644 index 0000000..67e905a --- /dev/null +++ b/tests/test_daemon_limits.h @@ -0,0 +1,6 @@ +#ifndef TEST_DAEMON_LIMITS_H +#define TEST_DAEMON_LIMITS_H + +void test_daemon_limits(); + +#endif diff --git a/tests/test_file.c b/tests/test_file.c index 4561f1a..73c3431 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -1180,7 +1180,7 @@ static void test_trust_sender_authorized_root_confinement() { rmdir(sibling); return; } - EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs)); + EXPECT_TRUE(utils_set_authorized_root(root_fd, root_abs)); file_set_trust_sender(true); struct stat st; @@ -1204,7 +1204,7 @@ static void test_trust_sender_authorized_root_confinement() { free(outside_link); free(inside_link); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); close(root_fd); unlink("test_trust_sender_outside_link"); rmdir(sibling); @@ -1220,7 +1220,7 @@ void test_trust_sender() { test_trust_sender_confines_hostile_paths(); test_trust_sender_authorized_root_confinement(); file_set_trust_sender(false); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); } /* --sparse/-S hole preservation: a buffer with a long zero run written via @@ -1341,7 +1341,7 @@ static void test_file_write_to_disk_partial_retention() { static void test_dir_time_list() { const char* root = "test_dir_time_root"; const char* sub = "test_dir_time_root/sub"; - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); rmdir(sub); rmdir(root); EXPECT_EQ_INT(mkdir(root, 0755), 0); @@ -1485,7 +1485,7 @@ static void test_keep_dirlinks_secure_open_impl() { rmdir(outside); return; } - EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs)); + EXPECT_TRUE(utils_set_authorized_root(root_fd, root_abs)); file_set_keep_dirlinks(true); struct stat real_st; @@ -1538,7 +1538,7 @@ static void test_keep_dirlinks_secure_open_impl() { free(leaf); file_set_keep_dirlinks(false); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); close(root_fd); unlink(link); unlink(abslink); @@ -1552,10 +1552,10 @@ static void test_keep_dirlinks_secure_open_impl() { * cleared even when an EXPECT inside the body returns early (a failing EXPECT * returns from its own function, so the body's trailing resets may be skipped). */ static void test_keep_dirlinks_secure_open() { - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); file_set_keep_dirlinks(false); test_keep_dirlinks_secure_open_impl(); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); file_set_keep_dirlinks(false); } diff --git a/tests/test_protocol.c b/tests/test_protocol.c index 3a63125..32e4a64 100644 --- a/tests/test_protocol.c +++ b/tests/test_protocol.c @@ -412,6 +412,87 @@ static void test_protocol_accounting_release_does_not_underflow() { protocol_session_unbind(); } +/* A Data acquired on session A must return its connection-memory charge to A + regardless of what (if anything) is bound at destroy time. The original bug + had two halves: destroying A's Data while a different session is bound leaks + A and drains the bound session, and destroying it with nothing bound leaks A + and drains the legacy fallback session. */ +static void test_receive_data_charge_follows_owning_session() { + int pipe_a[2]; + int pipe_b[2]; + EXPECT_EQ_INT(pipe(pipe_a), 0); + EXPECT_EQ_INT(pipe(pipe_b), 0); + + ProtocolSession session_a; + ProtocolSession session_b; + protocol_session_init(&session_a, pipe_a[0], pipe_a[1]); + protocol_session_init(&session_b, pipe_b[0], pipe_b[1]); + protocol_session_set_max_alloc(&session_a, 64); + protocol_session_set_max_alloc(&session_b, 64); + + unsigned long long size = 8; + EXPECT_EQ_INT((int)write(pipe_a[1], &size, sizeof(size)), (int)sizeof(size)); + EXPECT_EQ_INT((int)write(pipe_a[1], "12345678", 8), 8); + EXPECT_EQ_INT((int)write(pipe_a[1], &size, sizeof(size)), (int)sizeof(size)); + EXPECT_EQ_INT((int)write(pipe_a[1], "ABCDEFGH", 8), 8); + EXPECT_EQ_INT((int)write(pipe_b[1], &size, sizeof(size)), (int)sizeof(size)); + EXPECT_EQ_INT((int)write(pipe_b[1], "abcdefgh", 8), 8); + + Data* data_a1 = protocol_receive_data_limited(&session_a, 8); + Data* data_a2 = protocol_receive_data_limited(&session_a, 8); + Data* data_b = protocol_receive_data_limited(&session_b, 8); + EXPECT_NOT_NULL(data_a1); + EXPECT_NOT_NULL(data_a2); + EXPECT_NOT_NULL(data_b); + EXPECT_TRUE(data_a1->owner == &session_a); + EXPECT_TRUE(data_a2->owner == &session_a); + EXPECT_TRUE(data_b->owner == &session_b); + EXPECT_EQ_INT((int)atomic_load(&session_a.total_allocated_bytes), 16); + EXPECT_EQ_INT((int)atomic_load(&session_b.total_allocated_bytes), 8); + + /* Half 1: destroy A's Data while the unrelated session B is bound. The + charge must go to A, not to the bound B. */ + protocol_session_bind(&session_b); + data_destroy(data_a1); + protocol_session_unbind(); + + EXPECT_EQ_INT((int)atomic_load(&session_a.total_allocated_bytes), 8); + EXPECT_EQ_INT((int)atomic_load(&session_b.total_allocated_bytes), 8); + + /* Half 2: destroy A's remaining Data with NO session bound. The charge must + still go to A, not to the legacy fallback session. */ + protocol_session_unbind(); + data_destroy(data_a2); + EXPECT_EQ_INT((int)atomic_load(&session_a.total_allocated_bytes), 0); + EXPECT_EQ_INT((int)atomic_load(&session_b.total_allocated_bytes), 8); + + data_destroy(data_b); + EXPECT_EQ_INT((int)atomic_load(&session_b.total_allocated_bytes), 0); + + close(pipe_a[0]); + close(pipe_a[1]); + close(pipe_b[0]); + close(pipe_b[1]); +} + +/* Freshest Data holds no connection charge; only a bounded receive binds an + owner and a charge, so creation helpers must start uncharged and unowned. */ +static void test_data_create_starts_uncharged_and_unowned() { + void* buf = malloc(8); + EXPECT_NOT_NULL(buf); + Data* created = data_create(buf, 8); + EXPECT_NOT_NULL(created); + EXPECT_TRUE(created->owner == NULL); + EXPECT_EQ_INT((int)created->protocol_charge, 0); + data_destroy(created); + + Data* reserved = data_create_reserve(64); + EXPECT_NOT_NULL(reserved); + EXPECT_TRUE(reserved->owner == NULL); + EXPECT_EQ_INT((int)reserved->protocol_charge, 0); + data_destroy(reserved); +} + static void test_protocol_session_io_timeout() { /* Default is the built-in 60 s window; the setter stores exactly what it is * given (<= 0 means "fall back to the default") so callers can propagate @@ -574,4 +655,6 @@ void test_protocol() { test_protocol_accounting_reservation_is_atomic(); test_protocol_string_accounting_is_transient(); test_protocol_accounting_release_does_not_underflow(); + test_receive_data_charge_follows_owning_session(); + test_data_create_starts_uncharged_and_unowned(); } diff --git a/tests/test_utils.h b/tests/test_utils.h index 67b6bca..4d5f3d3 100644 --- a/tests/test_utils.h +++ b/tests/test_utils.h @@ -112,4 +112,12 @@ extern bool current_test_failed; } \ } while (0) +/* Unconditional test failure carrying an explanatory message. */ +#define EXPECT_FAIL(message) \ + do { \ + printf(" \033[1;31m[FAIL]\033[0m %s:%d: %s\n", __FILE__, __LINE__, (message)); \ + current_test_failed = true; \ + return; \ + } while (0) + #endif