feat(daemon): accept rsync rsyncd.conf key subset and --dparam mapping
This commit is contained in:
+3
-3
@@ -730,8 +730,8 @@ targets verbatim, matching rsync.
|
|||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| 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 |
|
| `--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` |
|
| `--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). 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` |
|
| `--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` |
|
| `--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 |
|
| `--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) |
|
| `--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.
|
**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.
|
- **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.
|
- **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.
|
- **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
@@ -33,6 +33,108 @@ static bool key_equals(const char* key, const char* canonical) {
|
|||||||
return strcasecmp(key, canonical) == 0;
|
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) {
|
static bool parse_bool_value(const char* value, bool* out) {
|
||||||
if (strcasecmp(value, "yes") == 0 || strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0) {
|
if (strcasecmp(value, "yes") == 0 || strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0) {
|
||||||
*out = true;
|
*out = true;
|
||||||
@@ -250,6 +352,7 @@ DaemonConf* daemon_conf_create(void) {
|
|||||||
if (!conf)
|
if (!conf)
|
||||||
return NULL;
|
return NULL;
|
||||||
conf->global.port = DAEMON_CONF_DEFAULT_PORT;
|
conf->global.port = DAEMON_CONF_DEFAULT_PORT;
|
||||||
|
conf->global.read_only_default = false;
|
||||||
conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS;
|
conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS;
|
||||||
conf->global.auth_failure_delay_ms = DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS;
|
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.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) {
|
char* err, size_t err_size) {
|
||||||
if (key_equals(key, "port"))
|
if (key_equals(key, "port"))
|
||||||
return store_port(&conf->global.port, value, err, err_size);
|
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)) {
|
if (!store_string(&conf->global.motd_file, value)) {
|
||||||
set_error(err, err_size, "out of memory parsing 'motd file'");
|
set_error(err, err_size, "out of memory parsing 'motd file'");
|
||||||
return false;
|
return false;
|
||||||
@@ -338,6 +441,25 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo
|
|||||||
}
|
}
|
||||||
return true;
|
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"))
|
if (key_equals(key, "max connections"))
|
||||||
return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size);
|
return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size);
|
||||||
if (key_equals(key, "max connections per host"))
|
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"))
|
if (key_equals(key, "hosts deny"))
|
||||||
return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value,
|
return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value,
|
||||||
"hosts deny", NULL, replace_hosts, err, err_size);
|
"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);
|
set_error(err, err_size, "unknown global key '%s'", key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -388,6 +513,7 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
module->read_only = parsed;
|
module->read_only = parsed;
|
||||||
|
module->read_only_explicit = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key_equals(key, "client owner")) {
|
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"))
|
if (key_equals(key, "hosts deny"))
|
||||||
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
|
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
|
||||||
module->name, false, err, err_size);
|
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);
|
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -507,6 +636,7 @@ static int open_module(DaemonConf* conf, int* current_module, const char* name,
|
|||||||
}
|
}
|
||||||
conf->modules = grown;
|
conf->modules = grown;
|
||||||
memset(&conf->modules[conf->module_count], 0, sizeof(DaemonModule));
|
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);
|
conf->modules[conf->module_count].name = str_dup(name);
|
||||||
if (!conf->modules[conf->module_count].name) {
|
if (!conf->modules[conf->module_count].name) {
|
||||||
set_error(err, err_size, "out of memory adding module '%s'", name);
|
set_error(err, err_size, "out of memory adding module '%s'", name);
|
||||||
|
|||||||
@@ -17,7 +17,16 @@
|
|||||||
* DAEMON_CONF_MAX_LINE all fail the whole load with a clear, line-numbered
|
* 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
|
* error instead of being silently ignored. This keeps a typo from silently
|
||||||
* changing what a module serves.
|
* 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
|
/* A daemon module's configured root is used exactly like the standalone
|
||||||
* server's --destination-root: the daemon confines every connection that
|
* server's --destination-root: the daemon confines every connection that
|
||||||
@@ -44,7 +53,12 @@
|
|||||||
typedef struct DaemonModule {
|
typedef struct DaemonModule {
|
||||||
char* name; /* module name, as the client requests it */
|
char* name; /* module name, as the client requests it */
|
||||||
char* path; /* module root (daemon-side authorized root) */
|
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
|
bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in
|
||||||
that lets this module's clients choose ownership
|
that lets this module's clients choose ownership
|
||||||
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
||||||
@@ -69,6 +83,9 @@ typedef struct DaemonConfGlobals {
|
|||||||
int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */
|
int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */
|
||||||
char* motd_file; /* `motd file`, may be NULL */
|
char* motd_file; /* `motd file`, may be NULL */
|
||||||
char* address; /* `address` (optional bind address), 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
|
int max_connections; /* `max connections`, default
|
||||||
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */
|
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */
|
||||||
int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default
|
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);
|
bool daemon_module_name_valid(const char* name);
|
||||||
|
|
||||||
/* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and
|
/* 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
|
* apply it to the global keys only. Keys are case-insensitive and cover the
|
||||||
* the global keys defined by the grammar (port, motd file, address,
|
* global keys defined by the grammar (port, motd file, address, read only,
|
||||||
* max connections, max connections per host, auth failure delay,
|
* 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). */
|
* 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);
|
int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size);
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ class DaemonManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._proc = None
|
self._proc = None
|
||||||
self._port = None
|
self._port = None
|
||||||
|
self.log_path = None
|
||||||
|
|
||||||
def start(self, config_path, port_override=None, extra_args=None, log_path=None):
|
def start(self, config_path, port_override=None, extra_args=None, log_path=None):
|
||||||
self.stop()
|
self.stop()
|
||||||
@@ -154,7 +155,11 @@ class DaemonManager:
|
|||||||
if extra_args:
|
if extra_args:
|
||||||
cmd += extra_args
|
cmd += extra_args
|
||||||
if log_path is None:
|
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")
|
log = open(log_path, "w")
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||||
@@ -375,6 +380,58 @@ class TestDaemonModuleSelection:
|
|||||||
proc.kill()
|
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:
|
class TestDaemonRejection:
|
||||||
def _tree_files(self):
|
def _tree_files(self):
|
||||||
"""Snapshot every file path (module-relative) currently under the module
|
"""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
|
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
|
refusal (a non-root daemon refuses --copy-as earlier, at the privilege
|
||||||
check, so the caller accepts that phrase too)."""
|
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 = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||||
before_files = self._tree_files()
|
before_files = self._tree_files()
|
||||||
result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags)
|
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."""
|
the refusal into a silent accept."""
|
||||||
port = _find_free_port()
|
port = _find_free_port()
|
||||||
d = DaemonManager()
|
d = DaemonManager()
|
||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
|
||||||
try:
|
try:
|
||||||
d.start(CONF_FILE, port_override=port,
|
d.start(CONF_FILE, port_override=port,
|
||||||
extra_args=["--password-file", CRED_FILE, "--no-super"])
|
extra_args=["--password-file", CRED_FILE, "--no-super"])
|
||||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=d.port,
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=d.port,
|
||||||
flags=["--super", "--preserve"])
|
flags=["--super", "--preserve"])
|
||||||
assert result.returncode != 0, "the --no-super daemon must refuse --super"
|
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")
|
tail = f.read().decode("utf-8", "replace")
|
||||||
assert "client-chosen ownership" in tail, (
|
assert "client-chosen ownership" in tail, (
|
||||||
f"daemon did not log the --super refusal: {tail[-400:]!r}"
|
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):
|
def test_auth_log_does_not_leak_password(self, daemon):
|
||||||
"""The daemon log must never contain the password or the store verifier."""
|
"""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
|
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", WRONG_PASS)
|
||||||
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_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", ALICE_PASS)
|
||||||
_push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
|
_push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
|
||||||
time.sleep(0.3)
|
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:
|
with open(log_path, "rb") as f:
|
||||||
log = f.read().decode("utf-8", "replace")
|
log = f.read().decode("utf-8", "replace")
|
||||||
finally:
|
finally:
|
||||||
@@ -1256,12 +1312,12 @@ class TestDaemonTLSAuth:
|
|||||||
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||||
d = DaemonManager()
|
d = DaemonManager()
|
||||||
port = _find_free_port()
|
port = _find_free_port()
|
||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
|
||||||
try:
|
try:
|
||||||
d.start(CONF_FILE, port_override=port, extra_args=[
|
d.start(CONF_FILE, port_override=port, extra_args=[
|
||||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||||
"--ca", certs["ca"], "--client-cn", "fastsync-client",
|
"--ca", certs["ca"], "--client-cn", "fastsync-client",
|
||||||
"--password-file", CRED_FILE])
|
"--password-file", CRED_FILE])
|
||||||
|
log_path = d.log_path
|
||||||
before_files = _tree_file_count(AUTH_MODULE)
|
before_files = _tree_file_count(AUTH_MODULE)
|
||||||
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||||
tls_flags = ["--tls",
|
tls_flags = ["--tls",
|
||||||
|
|||||||
@@ -626,6 +626,182 @@ static void test_daemon_conf_module_count_capped() {
|
|||||||
EXPECT_TRUE(strstr(err, "too many modules") != NULL);
|
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() {
|
void test_daemon_conf() {
|
||||||
test_daemon_conf_create_defaults();
|
test_daemon_conf_create_defaults();
|
||||||
test_daemon_conf_full_parse();
|
test_daemon_conf_full_parse();
|
||||||
@@ -645,4 +821,9 @@ void test_daemon_conf() {
|
|||||||
test_daemon_conf_module_count_capped();
|
test_daemon_conf_module_count_capped();
|
||||||
test_daemon_hosts_allowed();
|
test_daemon_hosts_allowed();
|
||||||
test_daemon_module_name_valid();
|
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();
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user