20 Commits
Author SHA1 Message Date
TapTap 72cccaa256 Merge Wave 8: config X-macro, authorized_root dedup, daemon limits, protocol_charge ownership
CI / lint (push) Successful in 1m31s
CI / sanitizers (undefined) (push) Successful in 1m0s
CI / sanitizers (address) (push) Successful in 1m6s
CI / fuzz-build (push) Successful in 34s
CI / coverage (push) Successful in 53s
CI / valgrind (push) Successful in 3m14s
CI / build-and-test (push) Successful in 5m33s
2026-09-13 11:19:55 +02:00
TapTap eb71b29d1c Merge branch 'fix/w8-authroot' into fix/w8-integration 2026-09-13 11:13:40 +02:00
TapTap 4d5befedfe Merge branch 'fix/w8-charge' into fix/w8-integration 2026-09-13 11:13:40 +02:00
TapTap f00844cf9a Merge branch 'fix/w8-daemonlim' into fix/w8-integration 2026-09-13 11:13:40 +02:00
TapTap 2ec17e821c fix(daemon): harden bounded per-source registry races
Stamp host_last_use before publishing a bucket key and treat an unstamped
(last_use == 0) bucket as live, so a just-claimed bucket can no longer be
stolen by a concurrent reclaimer.

After a successful eviction CAS, re-scan for the interned key and, when an
earlier bucket already holds it, zero the duplicate's active count and
return the canonical bucket, preventing orphaned per-host counts and cap
overshoot under full-table concurrency.

Add a message-carrying EXPECT_FAIL primitive and use it for the daemon-conf
buffer-overflow guard, and add a fork-based test that records auth failures
from forked children and asserts the parent observes the shared lockout.
2026-09-13 11:11:23 +02:00
TapTap 6d47d93fd7 fix(config): validate received counts before publishing them
The config_receive_{basis,skip,idmap}_count helpers wrote the
peer-controlled int through the Config member before range-checking it.
An over-cap basis_count therefore left config->basis_count huge while
config->basis_dirs was still NULL; config_receive()'s error path then
called config_delete(), whose basis loop dereferenced NULL and crashed
the daemon before authentication.

Read each count into a local, validate, and only then assign, leaving the
member untouched on failure.  config_delete() also guards the basis loop
with the array pointer as defense in depth.

Add a regression test that feeds over-cap basis/idmap/skip counts and
asserts rejection without crashing, plus a direct config_delete() check
on the partial (count set, array NULL) state.
2026-09-13 11:05:57 +02:00
TapTap 6ea966781f test(config): add receive-side golden oracle and sharpen fixture
Address low-severity review findings on the X-macro config refactor:

1. The golden test only hashed config_send_wire_block(), so a
   receive-side KIND that reads a different width/order could still
   round-trip symmetrically.  Add test_config_wire_golden_receive():
   capture the same hash-pinned 633-byte frame and feed it through
   config_receive(), asserting every field (config_wire_equal) plus the
   derived use_delta/use_xattrs bits and representative bounded kinds.
   Add test_config_wire_receive_bounds() for bounds the symmetric
   round-trip cannot reach: an out-of-range BOOL (hand-built frame),
   RAW_MAXALLOC zero, a malformed STR_MODULE, an over-cap
   INT_IDMAPCOUNT, and an out-of-range INT_IDENTITY chown_uid.

2. golden_config_populate() set long runs of booleans to all-1, so an
   adjacent swap within a run produced identical bytes.  Alternate the
   boolean values and make the fixture receiver-valid (chmod grammar
   "u=rwx,go=rx" is the same 11 bytes; delta_max_file_size inside the
   bound).  Re-pin the golden: len stays 633, hash is now
   9160991280011164139 (computed, not guessed).

3. Document in config.h and client_cli.c that the CLI option tables
   remain hand-maintained and are deliberately not generated from the
   wire-field X-macro (client-only fields, flag/alias/negation
   semantics).  No CLI-table rewrite.

PROTOCOL_VERSION stays "2.20.0"; src/shared/config.c is untouched and
the wire bytes are unchanged apart from the fixture's own new values.
2026-09-13 10:56:34 +02:00
TapTap 0a7f5faea6 test: replace strcat with a bounds-checked append in daemon-conf test 2026-09-13 10:51:01 +02:00
TapTap 25909110ac fix(daemon): exempt trusted loopback peers from per-host limits
Every client on loopback shares the 127.0.0.1 identity, so counting them
against 'max connections per host' or the default-on auth lockout lets one
local client deny service to all the others (and makes a shared-NAT/proxy
address a natural DoS vector for remote clients).  Use
utils_fd_peer_is_local (fail-closed) in the daemon gate to exempt a
provably local peer from the per-source cap and the auth lockout while
keeping the per-module and global caps.  Remote peers are unchanged.

Document the shared-NAT/proxy identity limitation and the loopback
exemption in README/RSYNC_COMPAT/CHANGELOG, update the integration test to
assert the exemption, and fix the README 'auth failure delay' cap (5000,
not 60000).
2026-09-13 10:50:58 +02:00
TapTap bd43448af2 fix(daemon): bound per-source table lifetime and recompute occupancy
The per-source host table only grew: once its fixed open-addressed table
filled, host_intern returned -1 and the per-host cap plus the shared auth
lockout silently failed open forever.  Add a bounded-lifetime eviction
policy: track a per-bucket last-use time and, when no empty bucket exists,
atomically repurpose the first bucket that has no active connection and
either has an expired lockout or has been idle, resetting its counters.
Warn (rate-limited) on the genuine fail-open path.

A child SIGKILLed mid-registration could also leak a module/host count
because the parent only decremented on a REGISTERED slot.  Make the slot
table the source of truth: after the SIGCHLD reap the parent recomputes
module_active[]/host_active[] from the surviving REGISTERED slots (atomics
only, async-signal-safe) so any leaked increment is erased.

Also clamp module_count to DAEMON_LIMITS_MAX_MODULES and use one helper
for the sizing/register host-tracking condition (a lockout threshold with
duration 0 is a no-op and must not intern hosts).
2026-09-13 10:50:53 +02:00
TapTap 18d1b84246 refactor(protocol): guard session release, clarify Data.owner contract
Add a NULL guard to protocol_release_memory_for_session so it no-ops like
the sibling session setters.  Correct the Data.owner doc comment, which
implied a non-zero protocol_charge always has an owner; document that
owner may be NULL for uncharged/ownerless Data, that any such charge
falls back to the bound session, and that a charged Data must not outlive
its owning session.  Note the lifetime contract on the release API too.

Extend tests/test_protocol.c to cover destroying a charged Data with no
session bound (the other half of the original bug) and to assert that
data_create/data_create_reserve start with owner == NULL and
protocol_charge == 0.
2026-09-13 10:42:45 +02:00
TapTap c78a21de57 docs(shared): clarify authorized_root accessor contracts
Document on utils_get_authorized_root_path() that the returned pointer is
borrowed and invalidated by the next authorized-root setter, that the fd
and path are not read atomically (non-reentrant), and that the fd remains
caller-owned.  Add a matching single-threaded/set-before-threads note at
the accessor definitions in utils.c.

In server.c, drop the redundant utils_set_authorized_root(-1, NULL) after
a failed utils_set_authorized_root(): the setter already fail-closes the
state on allocation failure.  The following close(root_fd) is unchanged.
2026-09-13 10:38:21 +02:00
TapTap 4e918a1b69 test(config): pin wire bytes and round-trip every field
test_config_wire_golden() serializes a fully-populated Config through
config_send_wire_block() and pins the exact frame to len=633 and FNV-1a
hash 6163263374908258816, captured from the pre-X-macro implementation.
Any field reorder, resize or codec change fails the test.

test_config_wire_roundtrip_all_fields() serializes/deserializes a defaults
Config and a fully-populated Config over a socketpair and compares every
serialized field.  The comparison is itself generated from
CONFIG_WIRE_FIELDS (one CONFIG_CMP_<KIND> per table entry), so a new table
entry automatically extends coverage; it cannot fall out of sync.  It
normalizes the receiver's NULL/"" canonicalization, the max_alloc server
clamp and the derived use_delta/use_xattrs bits.
2026-09-13 10:28:31 +02:00
TapTap 87f6cb0243 refactor(config): single X-macro table for serialized fields
Every Config field that crosses the wire was declared in up to six places
(struct member, config_set_defaults, send_*, receive_*, and the two CLI
option tables) and could drift silently.  Add CONFIG_WIRE_FIELDS in
config.h: one ordered per-segment table where each serialized field is
declared once with its C type, default and wire codec (KIND).

config.h now expands the table to declare the struct members;
config_set_defaults() expands it to assign the defaults; and
config_send_wire_block()/config_receive() expand the per-segment lists to
emit/consume the frame.  The per-segment function names, call order and
segment boundaries are preserved exactly.

Fields with genuinely custom logic keep dedicated helpers but are still
declared once in the table: the protocol-version handshake (HEADER), daemon
SCRAM auth (STR_REDACTED_AUTH), the daemon module name (STR_MODULE), the
repeated count+array blocks (BLOCK_SKIP_SUFFIXES/BLOCK_BASIS/BLOCK_IDMAP),
--copy-as presence/ids (COPY_AS_*), and the derived --delta / use_xattrs
bits (DERIVED_DELTA, BOOL_XATTR_DERIVE).  The version field remains a
special header (validated before any other field is parsed) and is sent by
config_send_wire_block() explicitly.

No public field is renamed and PROTOCOL_VERSION stays "2.20.0".  Because
the struct declaration order is no longer the wire order, the wire order is
now enforced solely by the table and by a byte-exact golden test
(follow-up commit).  Add config_send_wire_block() so that test can hash the
frame body without the STATUS_OK handshake.
2026-09-13 10:28:26 +02:00
TapTap e1f8f75e7c docs: document daemon per-module/per-host caps and shared auth lockout 2026-09-13 10:24:09 +02:00
TapTap 4c17122b00 feat(daemon): enforce per-module/per-host caps and shared auth lockout
Wire the shared registry into the accept loop (parent claims a slot before
fork, blocks SIGCHLD across fork+pid publication, and reclaims the dead
child's slot from the SIGCHLD handler so per-module/per-source counts are
released even on SIGKILL). The connection child records the selected module
and normalized peer IP once the config frame names them: an over-cap module
or source is refused at the config gate with an audit log, and a source
that exceeded the auth-failure threshold is refused before a SCRAM
challenge (the counter is shared across children and cleared on success).
The existing global cap and host ACLs are untouched.
2026-09-13 10:24:05 +02:00
TapTap 0abaa62193 feat(daemon): parse per-host cap and auth lockout config keys
Add global keys `max connections per host` (default 0 = unlimited),
`auth lockout threshold` (default 10, 0 disables) and
`auth lockout duration` (default 300 s, 0 disables). Module
`max connections` now accepts 0 as unlimited. Bound the number of
[module] sections (DAEMON_CONF_MAX_MODULES) so the shared registry's
per-module counter array stays fixed-size; absent keys keep their
defaults so old configs still load.
2026-09-13 10:24:01 +02:00
TapTap 5334397b81 feat(daemon): add shared cross-process connection registry
The daemon forks one child per accepted connection, so per-module and
per-source accounting must live in state shared across the children. Add a
fixed-size registry carved from an anonymous shared mapping
(mmap(MAP_SHARED|MAP_ANONYMOUS)) created before the accept loop: a slot
lifecycle (FREE/CLAIMED/REGISTERED) with parent claim/reclaim and a
lock-free, open-addressed per-source table for the per-host occupancy and
the shared auth-failure counter. C11 atomics only; no pthread locks across
fork.

Unit tests cover slot exhaustion, the module/host caps, pid reclaim and
fork-shared visibility.
2026-09-13 10:23:58 +02:00
TapTap 3260a39ab4 refactor(shared): single owner for authorized_root state 2026-09-13 10:06:04 +02:00
TapTap 5d3c43305e fix(protocol): release Data charge to its owning session
Data charged against a ProtocolSession kept only the charge amount, so
data_destroy released it from whatever session was thread-locally bound
at destroy time. Destroying a received Data on another thread, after the
session was unbound, or while a different session was bound leaked the
originating session's budget and underflowed the other's.

Add Data.owner, set it whenever protocol_receive_data_limited charges a
session, and have data_destroy release against that owner directly via
the newly-exported protocol_release_memory_for_session. Uncharged Data
(owner NULL) keeps the previous bound-session fallback.

Add a unit test proving a Data acquired on session A is released to A
even when unrelated session B is bound at destroy time.
2026-09-13 10:05:38 +02:00
31 changed files with 3020 additions and 992 deletions

No files matched your search

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