Release v2.26.0 #284
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)},
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+372
-269
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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(®istry->host_key[idx], memory_order_acquire);
|
||||||
|
if (current == key) {
|
||||||
|
atomic_store_explicit(®istry->host_last_use[idx], (long long)time(NULL),
|
||||||
|
memory_order_relaxed);
|
||||||
|
return (int)idx;
|
||||||
|
}
|
||||||
|
if (current == 0)
|
||||||
|
return -1; /* no tombstones: an empty bucket ends the probe chain */
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A bucket with no live connection may be repurposed: immediately when its
|
||||||
|
* lockout deadline has already passed (the review's "expired" case), or after an
|
||||||
|
* idle window when it holds no pending lockout. A bucket with a future lockout
|
||||||
|
* deadline is retained so the lockout actually lasts its configured duration. */
|
||||||
|
static bool host_bucket_reclaimable(DaemonLimitRegistry* registry, size_t idx, long long now) {
|
||||||
|
if (atomic_load_explicit(®istry->host_active[idx], memory_order_relaxed) != 0)
|
||||||
|
return false;
|
||||||
|
long long until = atomic_load_explicit(®istry->host_until[idx], memory_order_relaxed);
|
||||||
|
if (until != 0)
|
||||||
|
return until <= now;
|
||||||
|
long long last_use = atomic_load_explicit(®istry->host_last_use[idx], memory_order_relaxed);
|
||||||
|
/* A bucket whose key is published but whose last_use has not yet been stamped
|
||||||
|
* (last_use == 0) must be treated as live: reclaiming it here would steal a
|
||||||
|
* bucket a racing child just claimed. The claim path also stamps last_use
|
||||||
|
* before publishing the key, so this window cannot persist. */
|
||||||
|
return last_use != 0 && now - last_use >= DAEMON_LIMITS_HOST_EVICT_IDLE_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emit at most one "per-source table full" warning per
|
||||||
|
* DAEMON_LIMITS_HOST_FULL_WARN_SEC across all forked children. Called from a
|
||||||
|
* normal (non-signal) child path, so logging is safe here. */
|
||||||
|
static void host_warn_table_full(DaemonLimitRegistry* registry, long long now) {
|
||||||
|
long long last = atomic_load_explicit(®istry->host_full_warn, memory_order_relaxed);
|
||||||
|
if (last != 0 && now - last < DAEMON_LIMITS_HOST_FULL_WARN_SEC)
|
||||||
|
return;
|
||||||
|
if (atomic_compare_exchange_strong_explicit(®istry->host_full_warn, &last, now,
|
||||||
|
memory_order_relaxed, memory_order_relaxed)) {
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"daemon: per-source registry is full (%d slots) and no bucket can be reclaimed; "
|
||||||
|
"'max connections per host' and the auth lockout are temporarily not enforced for "
|
||||||
|
"new sources (the per-module cap and host ACLs still apply)",
|
||||||
|
registry->host_slots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Find or insert the bucket for `peer_ip`. Insertion is a lock-free CAS so two
|
||||||
|
* forked children racing on the same source converge on one bucket.
|
||||||
|
*
|
||||||
|
* When the probe finds no empty bucket it reclaims, via a key CAS, the first
|
||||||
|
* bucket that is reclaimable (expired lockout or idle, and no active
|
||||||
|
* connection) and resets its counters. This bounds the table's lifetime so it
|
||||||
|
* cannot fill permanently and stay fail-open. Returns -1 only when the address
|
||||||
|
* is unparseable or the table is genuinely full of live/locked buckets
|
||||||
|
* (callers fail open: the global/module caps and ACLs still apply). */
|
||||||
|
static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||||
|
bool ok = false;
|
||||||
|
uint64_t key = daemon_limits_host_hash(peer_ip, &ok);
|
||||||
|
if (!ok)
|
||||||
|
return -1;
|
||||||
|
long long now = (long long)time(NULL);
|
||||||
|
size_t mask = (size_t)registry->host_slots - 1;
|
||||||
|
size_t start = (size_t)(key & mask);
|
||||||
|
/* A couple of passes bound the work: the first normally claims/seeds a bucket;
|
||||||
|
* a lost eviction CAS retries once against the freshly observed table. */
|
||||||
|
for (int pass = 0; pass < 2; pass++) {
|
||||||
|
int evict = -1;
|
||||||
|
uint64_t evict_key = 0;
|
||||||
|
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||||
|
size_t idx = (start + i) & mask;
|
||||||
|
uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire);
|
||||||
|
if (current == key) {
|
||||||
|
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||||
|
return (int)idx;
|
||||||
|
}
|
||||||
|
if (current == 0) {
|
||||||
|
/* Stamp last_use *before* publishing the key so a reclaimer racing the
|
||||||
|
* claim can never observe a claimed bucket with last_use == 0 and
|
||||||
|
* evict it. A pre-stamp is harmless if the CAS loses: the bucket is
|
||||||
|
* either still empty (never inspected for reclaim) or has just been
|
||||||
|
* taken by another source that wants a fresh timestamp anyway. */
|
||||||
|
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||||
|
uint64_t expected = 0;
|
||||||
|
if (atomic_compare_exchange_strong_explicit(®istry->host_key[idx], &expected, key,
|
||||||
|
memory_order_acq_rel, memory_order_acquire)) {
|
||||||
|
return (int)idx;
|
||||||
|
}
|
||||||
|
if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) {
|
||||||
|
return (int)idx;
|
||||||
|
}
|
||||||
|
continue; /* another child won this empty bucket; keep probing */
|
||||||
|
}
|
||||||
|
if (evict < 0 && host_bucket_reclaimable(registry, idx, now)) {
|
||||||
|
evict = (int)idx;
|
||||||
|
evict_key = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (evict >= 0) {
|
||||||
|
/* Refresh the timestamp before the key changes hands so the reused bucket
|
||||||
|
* is not seen as immediately idle by a racing reclaimer. */
|
||||||
|
atomic_store_explicit(®istry->host_last_use[evict], now, memory_order_relaxed);
|
||||||
|
uint64_t expected = evict_key;
|
||||||
|
if (atomic_compare_exchange_strong_explicit(®istry->host_key[evict], &expected, key,
|
||||||
|
memory_order_acq_rel, memory_order_acquire)) {
|
||||||
|
/* The bucket now belongs to the new source; clear the evicted source's
|
||||||
|
* stale lockout/failure state. */
|
||||||
|
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||||
|
atomic_store_explicit(®istry->host_fail[evict], 0, memory_order_relaxed);
|
||||||
|
atomic_store_explicit(®istry->host_until[evict], 0, memory_order_relaxed);
|
||||||
|
/* Two children can race to intern the same brand-new key into different
|
||||||
|
* eviction targets, leaving the table with duplicate buckets for `key`.
|
||||||
|
* Re-scan for the first (canonical) bucket holding `key`; when it
|
||||||
|
* precedes `evict`, drop our duplicate's occupancy and hand back the
|
||||||
|
* canonical bucket so per-source counts are not orphaned on the
|
||||||
|
* duplicate. The duplicate keeps its key, so no tombstone hole is
|
||||||
|
* created and probe chains stay intact; it ages out normally. */
|
||||||
|
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||||
|
size_t candidate = (start + i) & mask;
|
||||||
|
uint64_t found =
|
||||||
|
atomic_load_explicit(®istry->host_key[candidate], memory_order_acquire);
|
||||||
|
if (found == key) {
|
||||||
|
if (candidate != (size_t)evict) {
|
||||||
|
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||||
|
return (int)candidate;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (found == 0)
|
||||||
|
break; /* the key is present at `evict`, so this cannot happen first */
|
||||||
|
}
|
||||||
|
return evict;
|
||||||
|
}
|
||||||
|
continue; /* lost the race; re-probe with fresh observations */
|
||||||
|
}
|
||||||
|
break; /* no free and no reclaimable bucket: genuinely full */
|
||||||
|
}
|
||||||
|
host_warn_table_full(registry, now);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap,
|
||||||
|
int lockout_threshold, int lockout_duration_sec) {
|
||||||
|
if (max_slots < DAEMON_LIMITS_MIN_SLOTS)
|
||||||
|
max_slots = DAEMON_LIMITS_MIN_SLOTS;
|
||||||
|
if (max_slots > DAEMON_LIMITS_MAX_SLOTS)
|
||||||
|
max_slots = DAEMON_LIMITS_MAX_SLOTS;
|
||||||
|
if (module_count < 1)
|
||||||
|
module_count = 1;
|
||||||
|
if (module_count > DAEMON_LIMITS_MAX_MODULES)
|
||||||
|
module_count = DAEMON_LIMITS_MAX_MODULES;
|
||||||
|
if (per_host_cap < 0)
|
||||||
|
per_host_cap = 0;
|
||||||
|
if (lockout_threshold < 0)
|
||||||
|
lockout_threshold = 0;
|
||||||
|
if (lockout_duration_sec < 0)
|
||||||
|
lockout_duration_sec = 0;
|
||||||
|
|
||||||
|
bool need_hosts = per_host_cap > 0 || (lockout_threshold > 0 && lockout_duration_sec > 0);
|
||||||
|
int host_slots = 1;
|
||||||
|
if (need_hosts) {
|
||||||
|
size_t want = (size_t)max_slots * 4;
|
||||||
|
if (want < 64)
|
||||||
|
want = 64;
|
||||||
|
if (want > DAEMON_LIMITS_MAX_HOST_SLOTS)
|
||||||
|
want = DAEMON_LIMITS_MAX_HOST_SLOTS;
|
||||||
|
host_slots = (int)next_pow2(want);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t header = round_up(sizeof(DaemonLimitRegistry), 16);
|
||||||
|
size_t slot_bytes =
|
||||||
|
round_up((size_t)max_slots * sizeof(_Atomic int), 16) * 4; /* state,pid,module,host */
|
||||||
|
size_t module_bytes = round_up((size_t)module_count * sizeof(_Atomic int), 16);
|
||||||
|
size_t host_key_bytes = round_up((size_t)host_slots * sizeof(_Atomic uint64_t), 16);
|
||||||
|
size_t host_int_bytes = round_up((size_t)host_slots * sizeof(_Atomic int), 16) * 2;
|
||||||
|
size_t host_until_bytes = round_up((size_t)host_slots * sizeof(_Atomic long long), 16) * 2;
|
||||||
|
size_t total =
|
||||||
|
header + slot_bytes + module_bytes + host_key_bytes + host_int_bytes + host_until_bytes + 16;
|
||||||
|
|
||||||
|
void* map = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
|
||||||
|
if (map == MAP_FAILED)
|
||||||
|
return NULL;
|
||||||
|
memset(map, 0, total);
|
||||||
|
|
||||||
|
DaemonLimitRegistry* registry = (DaemonLimitRegistry*)map;
|
||||||
|
registry->max_slots = max_slots;
|
||||||
|
registry->module_count = module_count;
|
||||||
|
registry->host_slots = host_slots;
|
||||||
|
registry->per_host_cap = per_host_cap;
|
||||||
|
registry->lockout_threshold = lockout_threshold;
|
||||||
|
registry->lockout_duration_sec = lockout_duration_sec;
|
||||||
|
registry->map_size = total;
|
||||||
|
|
||||||
|
unsigned char* cursor = (unsigned char*)map + header;
|
||||||
|
registry->slot_state = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||||
|
registry->slot_pid = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||||
|
registry->slot_module = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||||
|
registry->slot_host = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||||
|
registry->module_active = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)module_count * sizeof(_Atomic int);
|
||||||
|
cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16);
|
||||||
|
registry->host_key = (_Atomic uint64_t*)cursor;
|
||||||
|
cursor += (size_t)host_slots * sizeof(_Atomic uint64_t);
|
||||||
|
registry->host_active = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)host_slots * sizeof(_Atomic int);
|
||||||
|
registry->host_fail = (atomic_int*)cursor;
|
||||||
|
cursor += (size_t)host_slots * sizeof(_Atomic int);
|
||||||
|
cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16);
|
||||||
|
registry->host_until = (atomic_llong*)cursor;
|
||||||
|
cursor += (size_t)host_slots * sizeof(_Atomic long long);
|
||||||
|
registry->host_last_use = (atomic_llong*)cursor;
|
||||||
|
|
||||||
|
for (int i = 0; i < max_slots; i++) {
|
||||||
|
atomic_store(®istry->slot_module[i], -1);
|
||||||
|
atomic_store(®istry->slot_host[i], -1);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_destroy(DaemonLimitRegistry* registry) {
|
||||||
|
if (!registry)
|
||||||
|
return;
|
||||||
|
munmap(registry, registry->map_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
int daemon_limits_claim_slot(DaemonLimitRegistry* registry) {
|
||||||
|
if (!registry)
|
||||||
|
return DAEMON_LIMITS_NO_SLOT;
|
||||||
|
for (int i = 0; i < registry->max_slots; i++) {
|
||||||
|
int expected = SLOT_FREE;
|
||||||
|
if (atomic_compare_exchange_strong(®istry->slot_state[i], &expected, SLOT_CLAIMED)) {
|
||||||
|
atomic_store(®istry->slot_pid[i], 0);
|
||||||
|
atomic_store(®istry->slot_module[i], -1);
|
||||||
|
atomic_store(®istry->slot_host[i], -1);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DAEMON_LIMITS_NO_SLOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid) {
|
||||||
|
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||||
|
return;
|
||||||
|
atomic_store(®istry->slot_pid[slot], (int)pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot) {
|
||||||
|
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||||
|
return;
|
||||||
|
atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel);
|
||||||
|
atomic_store_explicit(®istry->slot_pid[slot], 0, memory_order_relaxed);
|
||||||
|
/* The module/host occupancy arrays are derived from the slot table; do not
|
||||||
|
* decrement here or a SIGKILL between a child's increment and its REGISTERED
|
||||||
|
* publish would leak a count. Callers that need the derived counts call
|
||||||
|
* daemon_limits_recompute. */
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid) {
|
||||||
|
if (!registry || pid <= 0)
|
||||||
|
return;
|
||||||
|
for (int i = 0; i < registry->max_slots; i++) {
|
||||||
|
if (atomic_load(®istry->slot_state[i]) == SLOT_FREE)
|
||||||
|
continue;
|
||||||
|
if (atomic_load(®istry->slot_pid[i]) == (int)pid) {
|
||||||
|
daemon_limits_reclaim_slot(registry, i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_recompute(DaemonLimitRegistry* registry) {
|
||||||
|
if (!registry)
|
||||||
|
return;
|
||||||
|
/* Zero the derived arrays, then re-derive solely from the REGISTERED slots.
|
||||||
|
* A child that was SIGKILLed after incrementing a counter but before
|
||||||
|
* publishing REGISTERED is not counted, and its leaked increment is erased by
|
||||||
|
* the zeroing, so the leak cannot persist. */
|
||||||
|
for (int m = 0; m < registry->module_count; m++)
|
||||||
|
atomic_store_explicit(®istry->module_active[m], 0, memory_order_relaxed);
|
||||||
|
for (int h = 0; h < registry->host_slots; h++)
|
||||||
|
atomic_store_explicit(®istry->host_active[h], 0, memory_order_relaxed);
|
||||||
|
for (int i = 0; i < registry->max_slots; i++) {
|
||||||
|
if (atomic_load_explicit(®istry->slot_state[i], memory_order_acquire) != SLOT_REGISTERED)
|
||||||
|
continue;
|
||||||
|
int module = atomic_load_explicit(®istry->slot_module[i], memory_order_relaxed);
|
||||||
|
if (module >= 0 && module < registry->module_count)
|
||||||
|
atomic_fetch_add_explicit(®istry->module_active[module], 1, memory_order_relaxed);
|
||||||
|
int host = atomic_load_explicit(®istry->slot_host[i], memory_order_relaxed);
|
||||||
|
if (host >= 0 && host < registry->host_slots)
|
||||||
|
atomic_fetch_add_explicit(®istry->host_active[host], 1, memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index,
|
||||||
|
const char* peer_ip, int module_cap) {
|
||||||
|
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||||
|
return DAEMON_LIMIT_UNAVAILABLE;
|
||||||
|
if (module_index < 0 || module_index >= registry->module_count)
|
||||||
|
return DAEMON_LIMIT_UNAVAILABLE;
|
||||||
|
if (atomic_load_explicit(®istry->slot_state[slot], memory_order_acquire) != SLOT_CLAIMED)
|
||||||
|
return DAEMON_LIMIT_UNAVAILABLE;
|
||||||
|
|
||||||
|
int host = -1;
|
||||||
|
if (registry_tracks_hosts(registry))
|
||||||
|
host = host_intern(registry, peer_ip);
|
||||||
|
|
||||||
|
int module_count = atomic_fetch_add(®istry->module_active[module_index], 1) + 1;
|
||||||
|
if (module_cap > 0 && module_count > module_cap) {
|
||||||
|
atomic_fetch_sub(®istry->module_active[module_index], 1);
|
||||||
|
return DAEMON_LIMIT_MODULE_FULL;
|
||||||
|
}
|
||||||
|
if (host >= 0) {
|
||||||
|
int host_count = atomic_fetch_add(®istry->host_active[host], 1) + 1;
|
||||||
|
if (registry->per_host_cap > 0 && host_count > registry->per_host_cap) {
|
||||||
|
atomic_fetch_sub(®istry->host_active[host], 1);
|
||||||
|
atomic_fetch_sub(®istry->module_active[module_index], 1);
|
||||||
|
return DAEMON_LIMIT_HOST_FULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic_store(®istry->slot_module[slot], module_index);
|
||||||
|
atomic_store(®istry->slot_host[slot], host);
|
||||||
|
atomic_store_explicit(®istry->slot_state[slot], SLOT_REGISTERED, memory_order_release);
|
||||||
|
return DAEMON_LIMIT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip,
|
||||||
|
int* seconds_remaining) {
|
||||||
|
if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0)
|
||||||
|
return false;
|
||||||
|
int bucket = host_lookup(registry, peer_ip);
|
||||||
|
if (bucket < 0)
|
||||||
|
return false;
|
||||||
|
long long until = atomic_load(®istry->host_until[bucket]);
|
||||||
|
long long now = (long long)time(NULL);
|
||||||
|
if (until > now) {
|
||||||
|
if (seconds_remaining)
|
||||||
|
*seconds_remaining = (int)(until - now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (until != 0) {
|
||||||
|
/* The previous lockout has expired: clear the stale counter so the source
|
||||||
|
* gets a fresh allowance. */
|
||||||
|
atomic_store(®istry->host_fail[bucket], 0);
|
||||||
|
atomic_store(®istry->host_until[bucket], 0);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||||
|
if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0)
|
||||||
|
return;
|
||||||
|
int bucket = host_intern(registry, peer_ip);
|
||||||
|
if (bucket < 0)
|
||||||
|
return;
|
||||||
|
int failures = atomic_fetch_add(®istry->host_fail[bucket], 1) + 1;
|
||||||
|
if (failures >= registry->lockout_threshold) {
|
||||||
|
long long now = (long long)time(NULL);
|
||||||
|
atomic_store(®istry->host_until[bucket], now + (long long)registry->lockout_duration_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||||
|
if (!registry)
|
||||||
|
return;
|
||||||
|
int bucket = host_lookup(registry, peer_ip);
|
||||||
|
if (bucket < 0)
|
||||||
|
return;
|
||||||
|
atomic_store(®istry->host_fail[bucket], 0);
|
||||||
|
atomic_store(®istry->host_until[bucket], 0);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef TEST_DAEMON_LIMITS_H
|
||||||
|
#define TEST_DAEMON_LIMITS_H
|
||||||
|
|
||||||
|
void test_daemon_limits();
|
||||||
|
|
||||||
|
#endif
|
||||||
+8
-8
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user