From 86741725fd659069d347aaf5816bbc68a2463cc2 Mon Sep 17 00:00:00 2001 From: TapTap Date: Tue, 22 Sep 2026 22:02:09 +0200 Subject: [PATCH] feat(daemon): accept rsync rsyncd.conf key subset and --dparam mapping --- RSYNC_COMPAT.md | 6 +- src/shared/daemon_conf.c | 132 +++++++++++++++++++++- src/shared/daemon_conf.h | 46 +++++--- tests/integration/test_daemon.py | 70 ++++++++++-- tests/test_daemon_conf.c | 181 +++++++++++++++++++++++++++++++ 5 files changed, 411 insertions(+), 24 deletions(-) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 5a6b5b8..2dca0d8 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -730,8 +730,8 @@ targets verbatim, matching rsync. | Flag | Rsync Description | FastSync Status | Notes | |------|-------------------|-----------------|-------| | `--daemon` | Run as rsync daemon | ❌ Divergent | 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 | ❌ Divergent | 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 | ❌ Divergent | 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` | +| `--config=FILE` | Alternate rsyncd.conf file | ❌ Divergent | 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 still strictly rejects a genuinely unknown key so a typo can never silently change what a module serves; requires `--daemon`. **rsync 3.4.1 key subset accepted:** the common rsyncd.conf GLOBAL keys (`port`, `address`, `motd file`, `max connections`, `hosts allow`/`hosts deny`, plus the inert `pid file`, `log file`, `socket options`/`sockopts`, `listen backlog`, `syslog facility`, `syslog tag`, `log format`, `use chroot`, `uid`, `gid`, `timeout`, `max verbosity`/`min verbosity`, `lock file`, `transfer logging`, `strict modes`, `reverse lookup`/`forward lookup`, `ignore errors`, `ignore nonreadable`, `dont compress`) and MODULE keys (`path`, `read only`, `max connections`, `auth users`, `hosts allow`/`hosts deny`, plus the inert `comment`, `use chroot`, `uid`/`gid`/`daemon uid`/`daemon gid`, `exclude`, `include`, `exclude from`/`include from`, `filter`, `secrets file`, `auth digest`, `max verbosity`/`min verbosity`, `lock file`, `transfer logging`, `log file`/`log format`/`syslog facility`/`syslog tag`, `timeout`, `strict modes`, `numeric ids`, `fake super`, `munge symlinks`, `write only`, `list`, `dont compress`, `charset`, `refuse options`, `incoming chmod`/`outgoing chmod`, `open noatime`, `max size`/`min size`, `temp dir`, `pre-xfer exec`/`post-xfer exec`, `name converter`, `proxy protocol`/`proxy protocol hosts`, `reverse lookup`/`forward lookup`, `ignore errors`, `ignore nonreadable`) are recognized. Keys with a FastSync equivalent map onto it (a global `read only` is honored as the default for later modules); keys with no FastSync equivalent load **inert** (no effect) rather than failing the whole config. Residual: the native grammar still differs from rsync's (no `\` line continuation, `%VAR%` expansion, `[global]` re-entry, or inline `#` comments), and the inert keys are genuinely not enforced — in particular a daemon-side `exclude`/`filter` is NOT applied and `secrets file` is NOT read (use `path`, `--password-file`, and client-side filters instead) | +| `--dparam=OVERRIDE` | Override global daemon config | ❌ Divergent | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Reuses the exact same global-key dispatch as `--config`, so it accepts the native global keys (`port`, `motd file`, `address`, `read only`, `max connections`, `max connections per host`, `auth failure delay`, `auth lockout threshold`, `auth lockout duration`, `hosts allow`, `hosts deny`), the recognized inert rsync global keys, and rsync's compact spellings (`motdfile`, `pidfile`, `logfile`); keys are case-insensitive. `read only` sets the global default and re-applies it to every module that did not set its own value. Genuinely unknown keys and invalid values are rejected. Requires `--daemon` | | `--no-detach` | Don't detach from parent | ✅ Parity | 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 | ❌ Divergent | 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. **Hardening follow-up:** the file is opened with `O_NOFOLLOW`, so a symlinked credential path fails closed (`ELOOP`) instead of being followed before the owner/mode gate; literal fd-backed paths (`/dev/fd/`, `/proc/self/fd/`, which is what a bash process substitution passes) are exempt, so process substitution still works. A FIFO/process-substitution read now waits under a bounded ~3 s deadline for its writer, so a slow producer works while a connected-but-silent FIFO fails instead of hanging. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat | | `--early-input=FILE` | Use FILE for daemon early exec | ❌ Divergent | 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. Opened with the same `O_NOFOLLOW` hardening as `--password-file` (a symlinked path fails closed with `ELOOP`; fd-backed `/dev/fd/N`/`/proc/self/fd/N` process-substitution paths are exempt) and a FIFO read is bound-waited (~3 s) so a slow producer works while a writer-less FIFO cannot hang. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) | @@ -739,7 +739,7 @@ targets verbatim, matching rsync. **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), 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. +- **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. To reduce the rsync divergence, the parser additionally accepts the common rsync 3.4.1 GLOBAL and MODULE keys: the keys with a FastSync equivalent (`path`, `read only`, `max connections`, `auth users`, `hosts allow`/`hosts deny`, and the global `port`/`address`/`motd file`) map onto it, a global `read only` becomes the default for modules defined after it, and the keys with no FastSync equivalent (e.g. `pid file`, `log file`, `use chroot`, `uid`/`gid`, `comment`, `exclude`/`include`, `max verbosity`, `lock file`, `transfer logging`, `timeout`, `secrets file`) are recognized and loaded **inert** (accepted-but-ignored) instead of failing the whole file. `--dparam` reuses the same dispatch, so it also accepts the inert rsync global keys and the compact spellings `motdfile`/`pidfile`/`logfile`. A key outside both sets is still rejected. The inert keys are genuinely not enforced: a daemon-side `exclude`/`include`/`filter` is not applied and a `secrets file` is not read (use `--password-file`/`--early-input`), so an rsync config that relies on those must be edited rather than trusted. - **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 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. diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 2297a48..3b0a351 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -33,6 +33,108 @@ static bool key_equals(const char* key, const char* canonical) { return strcasecmp(key, canonical) == 0; } +/* True when `key` matches one of the NUL-terminated names in `list`. */ +static bool key_in_list(const char* key, const char* const* list, size_t count) { + for (size_t i = 0; i < count; i++) { + if (strcasecmp(key, list[i]) == 0) + return true; + } + return false; +} + +/* rsync 3.4.1 rsyncd.conf GLOBAL keys accepted in the pre-module section that + * have no FastSync equivalent. They are recognized and documented as inert: + * accepting a real rsync config must not fail on a logging/process key, but a + * silently-reinterpreted key is never invented. `pidfile`/`logfile` are the + * compact --dparam spellings rsync documents. The same list is used by the + * `--dparam` dispatch (apply_global_key), so there is a single impl. */ +static const char* const kRsyncInertGlobalKeys[] = { + "pid file", + "pidfile", + "log file", + "logfile", + "socket options", + "sockopts", + "listen backlog", + "syslog facility", + "syslog tag", + "log format", + "use chroot", + "uid", + "gid", + "timeout", + "max verbosity", + "min verbosity", + "lock file", + "transfer logging", + "strict modes", + "reverse lookup", + "forward lookup", + "ignore errors", + "ignore nonreadable", + "dont compress", +}; + +/* rsync 3.4.1 rsyncd.conf MODULE keys accepted in a [module] section that have + * no FastSync equivalent (accepted-and-documented inert). Keys with a FastSync + * meaning (`path`, `read only`, `auth users`, `max connections`, + * `hosts allow`/`hosts deny`, `client owner`) are handled by apply_module_key + * before this list is consulted. Security-relevant keys (`exclude`, `filter`, + * `secrets file`, `refuse options`, ...) are inert, so a daemon-side filter or + * rsync secrets file is NOT enforced: see RSYNC_COMPAT.md for the residual. */ +static const char* const kRsyncInertModuleKeys[] = { + "comment", + "use chroot", + "daemon chroot", + "uid", + "gid", + "daemon uid", + "daemon gid", + "exclude", + "include", + "exclude from", + "include from", + "filter", + "max verbosity", + "min verbosity", + "lock file", + "transfer logging", + "log file", + "log format", + "syslog facility", + "syslog tag", + "timeout", + "secrets file", + "auth digest", + "strict modes", + "numeric ids", + "fake super", + "munge symlinks", + "write only", + "list", + "dont compress", + "charset", + "refuse options", + "incoming chmod", + "outgoing chmod", + "open noatime", + "max size", + "min size", + "temp dir", + "pre-xfer exec", + "post-xfer exec", + "name converter", + "proxy protocol", + "proxy protocol hosts", + "reverse lookup", + "forward lookup", + "ignore errors", + "ignore nonreadable", +}; + +#define kRsyncInertGlobalCount (sizeof(kRsyncInertGlobalKeys) / sizeof(kRsyncInertGlobalKeys[0])) +#define kRsyncInertModuleCount (sizeof(kRsyncInertModuleKeys) / sizeof(kRsyncInertModuleKeys[0])) + static bool parse_bool_value(const char* value, bool* out) { if (strcasecmp(value, "yes") == 0 || strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0) { *out = true; @@ -250,6 +352,7 @@ DaemonConf* daemon_conf_create(void) { if (!conf) return NULL; conf->global.port = DAEMON_CONF_DEFAULT_PORT; + conf->global.read_only_default = false; 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; @@ -324,7 +427,7 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo char* err, size_t err_size) { if (key_equals(key, "port")) return store_port(&conf->global.port, value, err, err_size); - if (key_equals(key, "motd file")) { + if (key_equals(key, "motd file") || key_equals(key, "motdfile")) { if (!store_string(&conf->global.motd_file, value)) { set_error(err, err_size, "out of memory parsing 'motd file'"); return false; @@ -338,6 +441,25 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo } return true; } + /* rsync allows the `read only` module key in the global section as the + * default for modules defined after it. Map it to that default (a later + * --dparam re-applies it to modules that did not set their own value) so a + * global `read only = yes` cannot be silently dropped into a writable + * default. */ + if (key_equals(key, "read only")) { + bool parsed; + if (!parse_bool_value(value, &parsed)) { + set_error(err, err_size, "global 'read only' must be yes/no (or true/false/1/0), got '%s'", + value); + return false; + } + conf->global.read_only_default = parsed; + for (int i = 0; i < conf->module_count; i++) { + if (!conf->modules[i].read_only_explicit) + conf->modules[i].read_only = parsed; + } + return true; + } if (key_equals(key, "max connections")) return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size); if (key_equals(key, "max connections per host")) @@ -360,6 +482,9 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo if (key_equals(key, "hosts deny")) return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value, "hosts deny", NULL, replace_hosts, err, err_size); + /* A recognized rsync global key with no FastSync equivalent loads inert. */ + if (key_in_list(key, kRsyncInertGlobalKeys, kRsyncInertGlobalCount)) + return true; set_error(err, err_size, "unknown global key '%s'", key); return false; } @@ -388,6 +513,7 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char* return false; } module->read_only = parsed; + module->read_only_explicit = true; return true; } if (key_equals(key, "client owner")) { @@ -457,6 +583,9 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char* if (key_equals(key, "hosts deny")) return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny", module->name, false, err, err_size); + /* A recognized rsync module key with no FastSync equivalent loads inert. */ + if (key_in_list(key, kRsyncInertModuleKeys, kRsyncInertModuleCount)) + return true; set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name); return false; } @@ -507,6 +636,7 @@ static int open_module(DaemonConf* conf, int* current_module, const char* name, } conf->modules = grown; memset(&conf->modules[conf->module_count], 0, sizeof(DaemonModule)); + conf->modules[conf->module_count].read_only = conf->global.read_only_default; conf->modules[conf->module_count].name = str_dup(name); if (!conf->modules[conf->module_count].name) { set_error(err, err_size, "out of memory adding module '%s'", name); diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index d04399e..81af6db 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -17,7 +17,16 @@ * DAEMON_CONF_MAX_LINE all fail the whole load with a clear, line-numbered * error instead of being silently ignored. This keeps a typo from silently * changing what a module serves. - */ + * + * rsync compatibility: to reduce the divergence from rsync 3.4.1's rsyncd.conf + * grammar, the parser also ACCEPTS the common rsync GLOBAL and MODULE keys. + * Keys with a FastSync equivalent are mapped onto it (the native spellings are + * unchanged). Keys with no FastSync equivalent are accepted and documented as + * inert (they load successfully but have no effect) rather than failing the + * whole config; the accepted inert set is listed in kRsyncInertGlobalKeys / + * kRsyncInertModuleKeys in daemon_conf.c and in RSYNC_COMPAT.md. A key + * outside both the FastSync-native grammar and the recognized rsync subset is + * still rejected as unknown. */ /* A daemon module's configured root is used exactly like the standalone * server's --destination-root: the daemon confines every connection that @@ -42,15 +51,20 @@ * store refuses (fail closed) rather than falling open; see server.c. Auth is * never bypassed by ignoring the list. */ typedef struct DaemonModule { - char* name; /* module name, as the client requests it */ - char* path; /* module root (daemon-side authorized root) */ - bool read_only; /* `read only = yes/no`; default no */ - bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in - that lets this module's clients choose ownership - (--numeric-ids/--chown/--usermap/--groupmap/--fake-super/ - --copy-as) and request explicit --super super-user - activities. Without it the daemon refuses all of them. */ - char** auth_users; /* `auth users = a,b`; Wave B credential list */ + char* name; /* module name, as the client requests it */ + char* path; /* module root (daemon-side authorized root) */ + bool read_only; /* `read only = yes/no`; defaults to the global `read only` + default (rsync allows it in the global section), which is + itself default no */ + bool read_only_explicit; /* set when this module set its own `read only`, so a + later global default (from a `--dparam read only=`) + does not override it */ + bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in + that lets this module's clients choose ownership + (--numeric-ids/--chown/--usermap/--groupmap/--fake-super/ + --copy-as) and request explicit --super super-user + 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 unlimited. The * per-connection child records the selected module in the shared registry @@ -69,6 +83,9 @@ 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 */ + bool read_only_default; /* global `read only` default for modules defined + after it (rsync allows the module key in the + global section); default no */ int max_connections; /* `max connections`, default DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */ int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default @@ -152,10 +169,13 @@ const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* bool daemon_module_name_valid(const char* name); /* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and - * apply it to the global keys only. Keys are case-insensitive and limited to - * the global keys defined by the grammar (port, motd file, address, + * apply it to the global keys only. Keys are case-insensitive and cover the + * global keys defined by the grammar (port, motd file, address, read only, * max connections, max connections per host, auth failure delay, - * auth lockout threshold, auth lockout duration, hosts allow, hosts deny). + * auth lockout threshold, auth lockout duration, hosts allow, hosts deny) plus + * the recognized inert rsync global keys and the compact rsync spellings + * (`motdfile`, `pidfile`, `logfile`). Applying `read only` sets the global + * default and re-applies it to every module that did not set its own value. * 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); diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index ae1d765..2b9917a 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -141,6 +141,7 @@ class DaemonManager: def __init__(self): self._proc = None self._port = None + self.log_path = None def start(self, config_path, port_override=None, extra_args=None, log_path=None): self.stop() @@ -154,7 +155,11 @@ class DaemonManager: if extra_args: cmd += extra_args if log_path is None: - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + # A unique log per manager: several managers run in one xdist + # worker, and a shared log lets one daemon's truncate/write offset + # corrupt the other's appended lines (a flaky log assertion). + log_path = os.path.join(TEST_DATA_DIR, f"fastsyncd_{id(self):x}.log") + self.log_path = log_path log = open(log_path, "w") self._proc = subprocess.Popen( cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True) @@ -375,6 +380,58 @@ class TestDaemonModuleSelection: proc.kill() +class TestRsyncConfigCompat: + """A real rsyncd.conf can be pointed at FastSync: the common rsync GLOBAL + and MODULE keys are accepted, the ones with a FastSync equivalent (port, + path, read only, max connections) take effect, and the inert ones (pid + file, log file, comment, use chroot, uid, gid, exclude, timeout, ...) are + documented no-ops. --dparam accepts the same expanded key set.""" + + @pytest.mark.ci + def test_rsync_style_config_round_trip(self): + module = os.path.join(MODULE_ROOT, "rsync_style") + shutil.rmtree(module, ignore_errors=True) + os.makedirs(module, exist_ok=True) + port = _find_free_port() + conf = os.path.join(TEST_DATA_DIR, "fastsyncd_rsync_style.conf") + with open(conf, "w") as f: + f.write( + "# an rsync 3.4.1-style rsyncd.conf\n" + "pid file = /tmp/fastsyncd_rsync_style.pid\n" + "log file = /tmp/fastsyncd_rsync_style.log\n" + "socket options = TCP_NODELAY\n" + "use chroot = no\n" + "uid = nobody\n" + "gid = nogroup\n" + "timeout = 600\n" + "max verbosity = 2\n" + "transfer logging = yes\n" + "port = %d\n" + "\n" + "[rsync_style]\n" + "path = %s\n" + "comment = rsync-style module\n" + "use chroot = no\n" + "exclude = *.tmp\n" + "read only = no\n" + "max connections = 4\n" + % (port, module)) + d = DaemonManager() + # --dparam borrows rsync's compact spelling; `pidfile` is inert but must + # not be rejected, proving dparam reuses the expanded global key set. + d.start(conf, extra_args=["--dparam", "pidfile=/tmp/rsync_style.pid"], + log_path=os.path.join(TEST_DATA_DIR, "fastsyncd_rsync_style.log")) + try: + result = _push("127.0.0.1::rsync_style", d.port) + assert result.returncode == 0, result.stderr or result.stdout + received = get_dest_received_dir(module, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"missing: {missing[:5]}" + assert not mismatches, f"mismatch: {mismatches[:5]}" + finally: + d.stop() + + class TestDaemonRejection: def _tree_files(self): """Snapshot every file path (module-relative) currently under the module @@ -477,7 +534,7 @@ class TestDaemonRejection: before any data lands. `accept` lists the log phrases that count as the refusal (a non-root daemon refuses --copy-as earlier, at the privilege check, so the caller accepts that phrase too).""" - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + log_path = daemon.log_path before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 before_files = self._tree_files() result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags) @@ -514,14 +571,13 @@ class TestDaemonRejection: the refusal into a silent accept.""" port = _find_free_port() d = DaemonManager() - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") try: d.start(CONF_FILE, port_override=port, extra_args=["--password-file", CRED_FILE, "--no-super"]) result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=d.port, flags=["--super", "--preserve"]) assert result.returncode != 0, "the --no-super daemon must refuse --super" - with open(log_path, "rb") as f: + with open(d.log_path, "rb") as f: tail = f.read().decode("utf-8", "replace") assert "client-chosen ownership" in tail, ( f"daemon did not log the --super refusal: {tail[-400:]!r}" @@ -1010,7 +1066,7 @@ class TestDaemonAuthentication: def test_auth_log_does_not_leak_password(self, daemon): """The daemon log must never contain the password or the store verifier.""" - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + log_path = daemon.log_path before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 _push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS) _push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS) @@ -1037,7 +1093,7 @@ class TestDaemonAuthentication: _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS) _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS) time.sleep(0.3) - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") + log_path = d.log_path with open(log_path, "rb") as f: log = f.read().decode("utf-8", "replace") finally: @@ -1256,12 +1312,12 @@ class TestDaemonTLSAuth: _write_client_password_file(client_creds, "alice", ALICE_PASS) d = DaemonManager() port = _find_free_port() - log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log") try: d.start(CONF_FILE, port_override=port, extra_args=[ "--tls", "--cert", certs["server_cert"], "--key", certs["server_key"], "--ca", certs["ca"], "--client-cn", "fastsync-client", "--password-file", CRED_FILE]) + log_path = d.log_path before_files = _tree_file_count(AUTH_MODULE) log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 tls_flags = ["--tls", diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index 5d0a46d..fde819c 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -626,6 +626,182 @@ static void test_daemon_conf_module_count_capped() { EXPECT_TRUE(strstr(err, "too many modules") != NULL); } +/* rsync rsyncd.conf compatibility: the common GLOBAL keys FastSync does not + * implement (pid file, log file, use chroot, uid/gid, timeout, ...) are + * accepted as documented inert keys, while the keys with a FastSync equivalent + * keep working and the compact rsync --dparam spellings (`pidfile`, `logfile`, + * `motdfile`) are recognized. A global `read only` is rsync's module default + * and must not be silently dropped. */ +static void test_daemon_conf_rsync_global_keys() { + char* path; + char err[256]; + EXPECT_EQ_INT(write_conf("pid file = /run/fastsyncd.pid\n" + "log file = /var/log/fastsyncd.log\n" + "socket options = TCP_NODELAY\n" + "listen backlog = 10\n" + "syslog facility = daemon\n" + "syslog tag = fastsyncd\n" + "use chroot = no\n" + "uid = nobody\n" + "gid = nogroup\n" + "timeout = 600\n" + "max verbosity = 3\n" + "lock file = /var/run/fastsyncd.lock\n" + "transfer logging = yes\n" + "strict modes = yes\n" + "reverse lookup = no\n" + "dont compress = *.gz\n" + "read only = yes\n" + "port = 8734\n" + "address = 127.0.0.1\n" + "pidfile = /run/other.pid\n" + "logfile = /var/log/other.log\n" + "motdfile = /etc/fastsync/motd.alt\n" + "\n" + "[pub]\n" + "path = /srv/pub\n" + "\n" + "[explicit]\n" + "path = /srv/explicit\n" + "read only = no\n", + &path), + 0); + DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NOT_NULL(conf); + /* Mapped globals took effect; the compact aliases too. */ + EXPECT_EQ_INT(conf->global.port, 8734); + EXPECT_EQ_STR(conf->global.address, "127.0.0.1"); + EXPECT_EQ_STR(conf->global.motd_file, "/etc/fastsync/motd.alt"); + /* The global `read only = yes` is the default for modules defined after it. */ + EXPECT_TRUE(conf->global.read_only_default); + EXPECT_EQ_INT(conf->module_count, 2); + EXPECT_TRUE(conf->modules[0].read_only); + /* An explicit per-module value wins over the global default. */ + EXPECT_FALSE(conf->modules[1].read_only); + daemon_conf_free(conf); +} + +/* rsync module keys with no FastSync equivalent load inert; the keys with a + * FastSync meaning still map onto their native fields. */ +static void test_daemon_conf_rsync_module_keys() { + char* path; + char err[256]; + EXPECT_EQ_INT(write_conf("[data]\n" + "path = /srv/data\n" + "comment = Public data\n" + "use chroot = yes\n" + "uid = nobody\n" + "gid = nogroup\n" + "exclude = *.tmp\n" + "include = keep.tmp\n" + "exclude from = /etc/rsync.exclude\n" + "max verbosity = 2\n" + "lock file = /var/run/rsyncd.lock\n" + "transfer logging = yes\n" + "timeout = 300\n" + "secrets file = /etc/rsyncd.secrets\n" + "auth digest = sha256\n" + "numeric ids = yes\n" + "write only = no\n" + "list = yes\n" + "dont compress = *.gz\n" + "refuse options = delete\n" + "read only = yes\n" + "max connections = 5\n" + "hosts allow = 10.0.0.0/8\n" + "auth users = alice\n", + &path), + 0); + DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NOT_NULL(conf); + EXPECT_EQ_STR(conf->modules[0].path, "/srv/data"); + EXPECT_TRUE(conf->modules[0].read_only); + EXPECT_EQ_INT(conf->modules[0].max_connections, 5); + EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 1); + EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "10.0.0.0/8"); + EXPECT_EQ_INT(conf->modules[0].auth_user_count, 1); + EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice"); + daemon_conf_free(conf); +} + +/* A genuinely unknown key is still rejected in both contexts, so accepting the + * rsync subset did not turn typos into silent no-ops. */ +static void test_daemon_conf_rsync_unknown_keys_rejected() { + char* path; + char err[256]; + EXPECT_EQ_INT(write_conf("bogus rsync key = 1\n", &path), 0); + const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(conf); + EXPECT_TRUE(strstr(err, "unknown global key") != NULL); + + EXPECT_EQ_INT(write_conf("[m]\npath = /x\nnot a real key = 1\n", &path), 0); + conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(conf); + EXPECT_TRUE(strstr(err, "unknown key 'not a real key'") != NULL); +} + +/* A recognized rsync key with an invalid value is still a clear parse error. */ +static void test_daemon_conf_rsync_read_only_invalid() { + char* path; + char err[256]; + EXPECT_EQ_INT(write_conf("read only = maybe\n[m]\npath = /x\n", &path), 0); + const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(conf); + EXPECT_TRUE(strstr(err, "read only") != NULL); + + EXPECT_EQ_INT(write_conf("[m]\npath = /x\nread only = maybe\n", &path), 0); + conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(conf); + EXPECT_TRUE(strstr(err, "read only") != NULL); +} + +/* --dparam reuses the same global dispatch: it accepts the compact rsync + * spellings and the inert rsync global keys, and `read only` sets the default + * for modules that did not set their own value. */ +static void test_daemon_conf_dparam_rsync_keys() { + DaemonConf* conf = daemon_conf_create(); + EXPECT_NOT_NULL(conf); + char err[256]; + + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "pidfile=/run/x.pid", err, sizeof(err)), 0); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "pid file=/run/y.pid", err, sizeof(err)), 0); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "logfile=/tmp/x.log", err, sizeof(err)), 0); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "log file=/tmp/y.log", err, sizeof(err)), 0); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "motdfile=/tmp/alt.motd", err, sizeof(err)), 0); + EXPECT_EQ_STR(conf->global.motd_file, "/tmp/alt.motd"); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "timeout=600", err, sizeof(err)), 0); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "use chroot=no", err, sizeof(err)), 0); + + /* A module already parsed without an explicit `read only` takes the + * --dparam default; an explicit module value is preserved. */ + { + char* path; + EXPECT_EQ_INT(write_conf("[plain]\npath = /p\n[explicit]\npath = /e\nread only = no\n", &path), + 0); + DaemonConf* loaded = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NOT_NULL(loaded); + EXPECT_EQ_INT(daemon_conf_apply_dparam(loaded, "read only=yes", err, sizeof(err)), 0); + EXPECT_TRUE(loaded->global.read_only_default); + EXPECT_TRUE(loaded->modules[0].read_only); + EXPECT_FALSE(loaded->modules[1].read_only); + daemon_conf_free(loaded); + } + + /* Invalid values and genuinely unknown keys are still rejected. */ + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "read only=maybe", err, sizeof(err)), -1); + EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "definitely not rsync=1", err, sizeof(err)), -1); + EXPECT_TRUE(strstr(err, "unknown global key") != NULL); + + daemon_conf_free(conf); +} + void test_daemon_conf() { test_daemon_conf_create_defaults(); test_daemon_conf_full_parse(); @@ -645,4 +821,9 @@ void test_daemon_conf() { test_daemon_conf_module_count_capped(); test_daemon_hosts_allowed(); test_daemon_module_name_valid(); + test_daemon_conf_rsync_global_keys(); + test_daemon_conf_rsync_module_keys(); + test_daemon_conf_rsync_unknown_keys_rejected(); + test_daemon_conf_rsync_read_only_invalid(); + test_daemon_conf_dparam_rsync_keys(); } \ No newline at end of file