Merge branch 'feat/parity-b2' into feat/parity-next

This commit is contained in:
2026-09-22 22:06:25 +02:00
5 changed files with 411 additions and 24 deletions
+3 -3
View File
@@ -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/<digits>`, `/proc/self/fd/<digits>`, 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.
+131 -1
View File
@@ -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);
+25 -5
View File
@@ -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
@@ -44,7 +53,12 @@
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 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/
@@ -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);
+63 -7
View File
@@ -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",
+181
View File
@@ -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();
}