Merge Wave 3a: daemon host ACL, configurable max connections, peer audit, auth-failure delay
CI / lint (push) Successful in 1m31s
CI / sanitizers (undefined) (push) Successful in 57s
CI / sanitizers (address) (push) Successful in 1m5s
CI / fuzz-build (push) Successful in 29s
CI / coverage (push) Successful in 49s
CI / build-and-test (push) Successful in 4m35s
CI / valgrind (push) Successful in 3m10s
CI / lint (push) Successful in 1m31s
CI / sanitizers (undefined) (push) Successful in 57s
CI / sanitizers (address) (push) Successful in 1m5s
CI / fuzz-build (push) Successful in 29s
CI / coverage (push) Successful in 49s
CI / build-and-test (push) Successful in 4m35s
CI / valgrind (push) Successful in 3m10s
This commit is contained in:
@@ -487,6 +487,33 @@ defaults to the current directory. |
|
||||
| `-v`, `--verbose` | Enable debug logging. |
|
||||
| `--help` | Print server usage. |
|
||||
|
||||
### Daemon configuration
|
||||
|
||||
`fastsync-server --daemon --config FILE` reads a line-based module config (an
|
||||
implicit global section, then `[module]` sections). Besides `port`, `motd file`,
|
||||
and `address`, the global section accepts:
|
||||
|
||||
- `max connections = N` — cap on concurrent connections, default 100. The
|
||||
listener enforces it; `0`, negative, and non-numeric values are parse errors.
|
||||
- `auth failure delay = MS` — milliseconds to sleep after a failed
|
||||
authentication, default 500. `0` disables it and the value is capped at 60000,
|
||||
so online password guessing is rate-limited per connection. Successful auths
|
||||
are never delayed.
|
||||
- `hosts allow` / `hosts deny` — comma- and/or whitespace-separated host access
|
||||
patterns.
|
||||
|
||||
A `[module]` may also set `max connections` (parsed and validated but not
|
||||
enforced per module — the global cap applies to the whole listener) and its own
|
||||
`hosts allow`/`hosts deny`.
|
||||
|
||||
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
|
||||
are rejected at parse time rather than silently never matching. 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 global list is checked
|
||||
before the module list, before authentication, and the connecting peer address
|
||||
(IPv4 or IPv6) appears in the connection and authentication audit log lines.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client
|
||||
|
||||
+4
-2
@@ -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 |
|
||||
| `--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 scalar keys the grammar defines (`port`, `motd file`, `address`); 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`, `auth failure delay`, `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` |
|
||||
| `--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) |
|
||||
@@ -635,7 +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.
|
||||
|
||||
- **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). 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). **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). 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **`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.
|
||||
|
||||
+101
-5
@@ -23,8 +23,10 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <openssl/x509.h>
|
||||
|
||||
static char* authorized_root;
|
||||
@@ -68,6 +70,11 @@ typedef struct ModuleGateContext {
|
||||
activity (operator --no-super, or a daemon module without the
|
||||
`client owner = yes` opt-in); -1 when the config's own mode stands. */
|
||||
int super_mode_override;
|
||||
/* Numeric peer address (INET6_ADDRSTRLEN is always enough), filled once by
|
||||
* server_module_gate. has_peer_ip is false when getpeername/inet_ntop could
|
||||
* not classify the peer; an ACL-configured module then fails closed. */
|
||||
bool has_peer_ip;
|
||||
char peer_ip[INET6_ADDRSTRLEN];
|
||||
} ModuleGateContext;
|
||||
|
||||
/* Server half of the SCRAM challenge/response (A7 remediation, protocol
|
||||
@@ -320,6 +327,66 @@ static const char* module_gate_check_ownership(const Config* config, const Daemo
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Online-guessing throttle: sleep the configured `auth failure delay`
|
||||
* milliseconds after a failed authentication. Runs in the per-connection
|
||||
* forked child, so it never blocks the accept loop or another connection. 0
|
||||
* disables it; the parser already caps it at DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS.
|
||||
* Resumes after EINTR so a signal cannot cut the delay short. */
|
||||
static void daemon_auth_failure_delay(void) {
|
||||
if (!g_daemon_conf || g_daemon_conf->global.auth_failure_delay_ms <= 0)
|
||||
return;
|
||||
int ms = g_daemon_conf->global.auth_failure_delay_ms;
|
||||
struct timespec delay;
|
||||
delay.tv_sec = ms / 1000;
|
||||
delay.tv_nsec = (long)(ms % 1000) * 1000000L;
|
||||
while (nanosleep(&delay, &delay) != 0 && errno == EINTR)
|
||||
;
|
||||
}
|
||||
|
||||
/* Host access control (global then per-module). A configured list makes an
|
||||
* unprovable peer fail closed. Deny always takes precedence over allow, and a
|
||||
* non-empty allow list rejects a peer that matches none of its entries. The
|
||||
* audit line names the peer, the module and the outcome. Returns an
|
||||
* error string on refusal, NULL on acceptance. */
|
||||
static const char* module_gate_check_hosts(const Config* config, const DaemonModule* module,
|
||||
ModuleGateContext* gate_ctx) {
|
||||
bool global_restricted = daemon_hosts_restricted(
|
||||
g_daemon_conf->global.hosts_allow, g_daemon_conf->global.hosts_allow_count,
|
||||
g_daemon_conf->global.hosts_deny, g_daemon_conf->global.hosts_deny_count);
|
||||
bool module_restricted = daemon_hosts_restricted(module->hosts_allow, module->hosts_allow_count,
|
||||
module->hosts_deny, module->hosts_deny_count);
|
||||
if (!global_restricted && !module_restricted)
|
||||
return NULL;
|
||||
if (!gate_ctx || !gate_ctx->has_peer_ip) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': cannot determine peer address with host ACLs configured; "
|
||||
"refusing (fail closed)",
|
||||
config->module);
|
||||
return "cannot verify the client host against host access controls";
|
||||
}
|
||||
const char* peer = gate_ctx->peer_ip;
|
||||
if (global_restricted && !daemon_hosts_allowed(peer, g_daemon_conf->global.hosts_allow,
|
||||
g_daemon_conf->global.hosts_allow_count,
|
||||
g_daemon_conf->global.hosts_deny,
|
||||
g_daemon_conf->global.hosts_deny_count)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': peer %s denied by global 'hosts allow'/'hosts deny'; "
|
||||
"refusing",
|
||||
config->module, peer);
|
||||
return "client host is not permitted by this daemon";
|
||||
}
|
||||
if (module_restricted &&
|
||||
!daemon_hosts_allowed(peer, module->hosts_allow, module->hosts_allow_count,
|
||||
module->hosts_deny, module->hosts_deny_count)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': peer %s denied by module 'hosts allow'/'hosts deny'; "
|
||||
"refusing",
|
||||
config->module, peer);
|
||||
return "client host is not permitted by this daemon module";
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A7 auth gate: runs the SCRAM challenge/response for an auth-required module
|
||||
* BEFORE the module root is installed and before any data moves. Returns
|
||||
* MODULE_AUTH_ACCEPTED when the module needs no auth or the handshake succeeds,
|
||||
@@ -376,16 +443,21 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae
|
||||
* via MODULE_AUTH_TERMINATED; the username may be logged (never the password
|
||||
* or any derived proof). */
|
||||
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
|
||||
const char* peer = gate_ctx->has_peer_ip ? gate_ctx->peer_ip : "unknown";
|
||||
char* escaped_user =
|
||||
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
|
||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
|
||||
config->module, escaped_user ? escaped_user : "(none)");
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': authentication failed for user '%s' from %s; refusing",
|
||||
config->module, escaped_user ? escaped_user : "(none)", peer);
|
||||
free(escaped_user);
|
||||
/* Rate-limit online guessing per connection (no delay on success). */
|
||||
daemon_auth_failure_delay();
|
||||
return MODULE_AUTH_TERMINATED;
|
||||
}
|
||||
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
|
||||
escaped_user ? escaped_user : "<allocation failed>");
|
||||
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module,
|
||||
escaped_user ? escaped_user : "<allocation failed>",
|
||||
gate_ctx->has_peer_ip ? gate_ctx->peer_ip : "unknown");
|
||||
free(escaped_user);
|
||||
return MODULE_AUTH_ACCEPTED;
|
||||
}
|
||||
@@ -478,6 +550,19 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
const DaemonModule* module = module_gate_lookup_module(config, &error);
|
||||
if (!module)
|
||||
return error;
|
||||
/* Resolve the peer once, before any auth or ownership work, so the host ACL
|
||||
* and the audit lines all use the same address. A module with ACLs fails
|
||||
* closed when the peer cannot be classified; an ACL-free module continues
|
||||
* (the accept loop still logged the address). */
|
||||
if (gate_ctx) {
|
||||
gate_ctx->has_peer_ip =
|
||||
utils_fd_peer_ip(gate_ctx->fd, gate_ctx->peer_ip, sizeof(gate_ctx->peer_ip));
|
||||
if (!gate_ctx->has_peer_ip)
|
||||
log_message(LOG_LEVEL_DEBUG, "daemon module '%s': peer address unavailable", config->module);
|
||||
}
|
||||
error = module_gate_check_hosts(config, module, gate_ctx);
|
||||
if (error)
|
||||
return error;
|
||||
error = module_gate_check_ownership(config, module, gate_ctx);
|
||||
if (error)
|
||||
return error;
|
||||
@@ -503,6 +588,8 @@ void handler(int file_descriptor) {
|
||||
gate_ctx.ssl = ssl;
|
||||
gate_ctx.fd = file_descriptor;
|
||||
gate_ctx.super_mode_override = -1;
|
||||
gate_ctx.has_peer_ip = false;
|
||||
gate_ctx.peer_ip[0] = '\0';
|
||||
/* All teardown state starts empty so the single `done` epilogue is safe to
|
||||
* reach from any error path (including before the config frame arrives). */
|
||||
Config* config = NULL;
|
||||
@@ -785,7 +872,8 @@ static void print_server_usage(void) {
|
||||
printf(" --config=FILE Daemon config file (default: ~/.config/fastsync/\n");
|
||||
printf(" fastsyncd.conf, else /etc/fastsyncd.conf)\n");
|
||||
printf(" --dparam=KEY=VALUE Override one global config key on the command line\n");
|
||||
printf(" (port, motd file, address)\n");
|
||||
printf(" (port, motd file, address, max connections,\n");
|
||||
printf(" auth failure delay, hosts allow, hosts deny)\n");
|
||||
printf(" --no-detach Stay in the foreground (default detaches to\n");
|
||||
printf(" background when running --daemon)\n");
|
||||
printf(" --password-file=FILE Credential store for modules that declare\n");
|
||||
@@ -1000,6 +1088,12 @@ int main(int argc, char* argv[]) {
|
||||
"and device nodes within that module root -- pair it with `auth users` "
|
||||
"unless the module is intentionally open to the network",
|
||||
g_daemon_conf->modules[i].name);
|
||||
if (g_daemon_conf->modules[i].max_connections > 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s': per-module 'max connections' is stored but not enforced "
|
||||
"per module; the global 'max connections' cap (%d) applies to the whole "
|
||||
"listener",
|
||||
g_daemon_conf->modules[i].name, g_daemon_conf->global.max_connections);
|
||||
}
|
||||
/* Daemon credential store (Wave B). --password-file and --early-input
|
||||
* feed the same store, loaded BEFORE the listener forks so every
|
||||
@@ -1064,6 +1158,8 @@ int main(int argc, char* argv[]) {
|
||||
exit_code = 1;
|
||||
goto out;
|
||||
}
|
||||
if (g_daemon_conf)
|
||||
server_set_max_connections(g_server, (unsigned int)g_daemon_conf->global.max_connections);
|
||||
if (opts.use_tls) {
|
||||
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");
|
||||
|
||||
+274
-4
@@ -1,9 +1,13 @@
|
||||
#include "daemon_conf.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -49,6 +53,158 @@ static bool parse_bool_value(const char* value, bool* out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Parse an IPv4/IPv6 CIDR "addr/prefix" into `bytes`/`*family`. Returns false
|
||||
* for a malformed address, a missing/oversized prefix, or a prefix that does
|
||||
* not fit the address family. */
|
||||
static bool parse_cidr(const char* cidr, int* prefix_out, uint8_t* bytes, int* family_out) {
|
||||
const char* slash = strchr(cidr, '/');
|
||||
if (!slash)
|
||||
return false;
|
||||
size_t addr_len = (size_t)(slash - cidr);
|
||||
if (addr_len == 0 || addr_len >= INET6_ADDRSTRLEN)
|
||||
return false;
|
||||
char addr[INET6_ADDRSTRLEN];
|
||||
memcpy(addr, cidr, addr_len);
|
||||
addr[addr_len] = '\0';
|
||||
char* end = NULL;
|
||||
long prefix = strtol(slash + 1, &end, 10);
|
||||
if (end == slash + 1 || *end != '\0')
|
||||
return false;
|
||||
struct in_addr v4;
|
||||
struct in6_addr v6;
|
||||
if (inet_pton(AF_INET, addr, &v4) == 1) {
|
||||
if (prefix < 0 || prefix > 32)
|
||||
return false;
|
||||
memcpy(bytes, &v4, sizeof(v4));
|
||||
*prefix_out = (int)prefix;
|
||||
*family_out = AF_INET;
|
||||
return true;
|
||||
}
|
||||
if (inet_pton(AF_INET6, addr, &v6) == 1) {
|
||||
if (prefix < 0 || prefix > 128)
|
||||
return false;
|
||||
memcpy(bytes, &v6, sizeof(v6));
|
||||
*prefix_out = (int)prefix;
|
||||
*family_out = AF_INET6;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A host pattern is valid when it is `*`, a valid IPv4/IPv6 literal, or a valid
|
||||
* CIDR. Peer addresses reaching the matcher are always numeric, so hostname
|
||||
* globs are rejected at parse time: accepting one would create a deny rule that
|
||||
* silently never matches (fail-open). */
|
||||
static bool host_pattern_valid(const char* pattern) {
|
||||
if (!pattern || *pattern == '\0')
|
||||
return false;
|
||||
if (strcmp(pattern, "*") == 0)
|
||||
return true;
|
||||
if (strchr(pattern, '/')) {
|
||||
uint8_t bytes[16];
|
||||
int prefix;
|
||||
int family;
|
||||
return parse_cidr(pattern, &prefix, bytes, &family);
|
||||
}
|
||||
struct in_addr v4;
|
||||
struct in6_addr v6;
|
||||
return inet_pton(AF_INET, pattern, &v4) == 1 || inet_pton(AF_INET6, pattern, &v6) == 1;
|
||||
}
|
||||
|
||||
/* Append every comma- and/or whitespace-separated host pattern in `value` to
|
||||
* the heap-owned list (or replace the list when `replace` is set, which --dparam
|
||||
* uses so an override can narrow access rather than only widen it). Returns
|
||||
* false (err filled) on an invalid pattern or an allocation failure. */
|
||||
static bool store_host_list(char*** list, int* count, const char* value, const char* key,
|
||||
const char* module_name, bool replace, char* err, size_t err_size) {
|
||||
if (replace) {
|
||||
for (int i = 0; i < *count; i++)
|
||||
free((*list)[i]);
|
||||
free(*list);
|
||||
*list = NULL;
|
||||
*count = 0;
|
||||
}
|
||||
char* copy = str_dup(value);
|
||||
if (!copy) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
return false;
|
||||
}
|
||||
char* save = NULL;
|
||||
for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) {
|
||||
if (!host_pattern_valid(token)) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "module '%s': invalid host pattern '%s' in '%s'", module_name,
|
||||
token, key);
|
||||
else
|
||||
set_error(err, err_size, "invalid host pattern '%s' in '%s'", token, key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
char** grown = realloc(*list, (size_t)(*count + 1) * sizeof(char*));
|
||||
if (!grown) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
*list = grown;
|
||||
char* dup = str_dup(token);
|
||||
if (!dup) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
(*list)[(*count)++] = dup;
|
||||
}
|
||||
free(copy);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse a `max connections` value: a positive integer (0/negative/garbage are
|
||||
* rejected because they would silently disable the cap or admit nothing). */
|
||||
static bool store_max_connections(int* slot, const char* value, 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 > INT_MAX) {
|
||||
if (module_name)
|
||||
set_error(err, err_size,
|
||||
"module '%s': invalid 'max connections' '%s' (must be a positive "
|
||||
"integer)",
|
||||
module_name, value);
|
||||
else
|
||||
set_error(err, err_size, "invalid 'max connections' '%s' (must be a positive integer)",
|
||||
value);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
char* end = NULL;
|
||||
errno = 0;
|
||||
long n = strtol(value, &end, 10);
|
||||
if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 ||
|
||||
n > DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS) {
|
||||
set_error(err, err_size, "invalid 'auth failure delay' '%s' (must be 0-%d milliseconds)", value,
|
||||
DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool daemon_module_name_valid(const char* name) {
|
||||
if (!name || *name == '\0')
|
||||
return false;
|
||||
@@ -69,14 +225,25 @@ DaemonConf* daemon_conf_create(void) {
|
||||
if (!conf)
|
||||
return NULL;
|
||||
conf->global.port = DAEMON_CONF_DEFAULT_PORT;
|
||||
conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS;
|
||||
conf->global.auth_failure_delay_ms = DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS;
|
||||
return conf;
|
||||
}
|
||||
|
||||
/* Free a heap-owned pattern list of `count` entries. */
|
||||
static void free_string_list(char** list, int count) {
|
||||
for (int i = 0; i < count; i++)
|
||||
free(list[i]);
|
||||
free(list);
|
||||
}
|
||||
|
||||
void daemon_conf_free(DaemonConf* conf) {
|
||||
if (!conf)
|
||||
return;
|
||||
free(conf->global.motd_file);
|
||||
free(conf->global.address);
|
||||
free_string_list(conf->global.hosts_allow, conf->global.hosts_allow_count);
|
||||
free_string_list(conf->global.hosts_deny, conf->global.hosts_deny_count);
|
||||
for (int i = 0; i < conf->module_count; i++) {
|
||||
DaemonModule* m = &conf->modules[i];
|
||||
free(m->name);
|
||||
@@ -84,6 +251,8 @@ void daemon_conf_free(DaemonConf* conf) {
|
||||
for (int j = 0; j < m->auth_user_count; j++)
|
||||
free(m->auth_users[j]);
|
||||
free(m->auth_users);
|
||||
free_string_list(m->hosts_allow, m->hosts_allow_count);
|
||||
free_string_list(m->hosts_deny, m->hosts_deny_count);
|
||||
}
|
||||
free(conf->modules);
|
||||
free(conf);
|
||||
@@ -123,8 +292,8 @@ static bool store_port(int* slot, const char* value, char* err, size_t err_size)
|
||||
|
||||
/* Apply a global scalar key/value. Keys are case-insensitive. Returns false
|
||||
* (err filled) on an unknown key or an invalid value. */
|
||||
static bool apply_global_key(DaemonConf* conf, char* key, const char* value, char* err,
|
||||
size_t err_size) {
|
||||
static bool apply_global_key(DaemonConf* conf, char* key, const char* value, bool replace_hosts,
|
||||
char* err, size_t err_size) {
|
||||
if (key_equals(key, "port"))
|
||||
return store_port(&conf->global.port, value, err, err_size);
|
||||
if (key_equals(key, "motd file")) {
|
||||
@@ -141,6 +310,16 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, cha
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "max connections"))
|
||||
return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size);
|
||||
if (key_equals(key, "auth failure delay"))
|
||||
return store_auth_failure_delay(&conf->global.auth_failure_delay_ms, value, err, err_size);
|
||||
if (key_equals(key, "hosts allow"))
|
||||
return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value,
|
||||
"hosts allow", NULL, replace_hosts, err, err_size);
|
||||
if (key_equals(key, "hosts deny"))
|
||||
return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value,
|
||||
"hosts deny", NULL, replace_hosts, err, err_size);
|
||||
set_error(err, err_size, "unknown global key '%s'", key);
|
||||
return false;
|
||||
}
|
||||
@@ -220,6 +399,14 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
||||
free(list);
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "max connections"))
|
||||
return store_max_connections(&module->max_connections, value, module->name, err, err_size);
|
||||
if (key_equals(key, "hosts allow"))
|
||||
return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow",
|
||||
false, module->name, err, err_size);
|
||||
if (key_equals(key, "hosts deny"))
|
||||
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
|
||||
false, module->name, err, err_size);
|
||||
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
|
||||
return false;
|
||||
}
|
||||
@@ -400,7 +587,7 @@ DaemonConf* daemon_conf_load(const char* path, char* err, size_t err_size) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!apply_global_key(conf, key, value, err, err_size)) {
|
||||
if (!apply_global_key(conf, key, value, false, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -455,7 +642,90 @@ int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err
|
||||
set_error(err, err_size, "--dparam '%s' has an empty value", assignment);
|
||||
return -1;
|
||||
}
|
||||
bool ok = apply_global_key(conf, key, value, err, err_size);
|
||||
bool ok = apply_global_key(conf, key, value, true, err, err_size);
|
||||
free(copy);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Compare the first `prefix` bits of two 16-byte address buffers. */
|
||||
static bool bit_prefix_match(const uint8_t* a, const uint8_t* b, int prefix) {
|
||||
int whole = prefix / 8;
|
||||
if (whole > 0 && memcmp(a, b, (size_t)whole) != 0)
|
||||
return false;
|
||||
int remainder = prefix % 8;
|
||||
if (remainder == 0)
|
||||
return true;
|
||||
uint8_t mask = (uint8_t)(0xffu << (8 - remainder));
|
||||
return (a[whole] & mask) == (b[whole] & mask);
|
||||
}
|
||||
|
||||
/* Case-insensitive glob match used for hostname patterns. Falls back to the
|
||||
* shared case-sensitive matcher when an operand is too long for the stack
|
||||
* buffers. */
|
||||
static bool host_glob_match(const char* pattern, const char* str) {
|
||||
char pbuf[256];
|
||||
char sbuf[256];
|
||||
size_t plen = strlen(pattern);
|
||||
size_t slen = strlen(str);
|
||||
if (plen >= sizeof(pbuf) || slen >= sizeof(sbuf))
|
||||
return glob_match(pattern, str);
|
||||
for (size_t i = 0; i <= plen; i++)
|
||||
pbuf[i] = (char)tolower((unsigned char)pattern[i]);
|
||||
for (size_t i = 0; i <= slen; i++)
|
||||
sbuf[i] = (char)tolower((unsigned char)str[i]);
|
||||
return glob_match(pbuf, sbuf);
|
||||
}
|
||||
|
||||
bool daemon_host_pattern_match(const char* pattern, const char* peer_ip) {
|
||||
if (!pattern || *pattern == '\0' || !peer_ip || *peer_ip == '\0')
|
||||
return false;
|
||||
if (strcmp(pattern, "*") == 0)
|
||||
return true;
|
||||
if (strchr(pattern, '/')) {
|
||||
uint8_t pattern_bytes[16];
|
||||
uint8_t peer_bytes[16];
|
||||
int prefix = 0;
|
||||
int family = AF_UNSPEC;
|
||||
if (!parse_cidr(pattern, &prefix, pattern_bytes, &family))
|
||||
return false;
|
||||
if (inet_pton(family, peer_ip, peer_bytes) != 1)
|
||||
return false;
|
||||
return bit_prefix_match(pattern_bytes, peer_bytes, prefix);
|
||||
}
|
||||
struct in_addr pattern_v4;
|
||||
struct in_addr peer_v4;
|
||||
if (inet_pton(AF_INET, pattern, &pattern_v4) == 1)
|
||||
return inet_pton(AF_INET, peer_ip, &peer_v4) == 1 && pattern_v4.s_addr == peer_v4.s_addr;
|
||||
struct in6_addr pattern_v6;
|
||||
struct in6_addr peer_v6;
|
||||
if (inet_pton(AF_INET6, pattern, &pattern_v6) == 1)
|
||||
return inet_pton(AF_INET6, peer_ip, &peer_v6) == 1 &&
|
||||
memcmp(&pattern_v6, &peer_v6, sizeof(pattern_v6)) == 0;
|
||||
/* Not a literal: a hostname/glob pattern. */
|
||||
return host_glob_match(pattern, peer_ip);
|
||||
}
|
||||
|
||||
bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count,
|
||||
char* const* deny, int deny_count) {
|
||||
if (!peer_ip)
|
||||
return false;
|
||||
for (int i = 0; i < deny_count; i++) {
|
||||
if (daemon_host_pattern_match(deny[i], peer_ip))
|
||||
return false;
|
||||
}
|
||||
if (allow_count > 0) {
|
||||
for (int i = 0; i < allow_count; i++) {
|
||||
if (daemon_host_pattern_match(allow[i], peer_ip))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny,
|
||||
int deny_count) {
|
||||
(void)allow;
|
||||
(void)deny;
|
||||
return allow_count > 0 || deny_count > 0;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,16 @@ typedef struct DaemonModule {
|
||||
activities. Without it the daemon refuses all of them. */
|
||||
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
||||
int auth_user_count;
|
||||
/* `max connections = N` (optional per-module cap). 0 means "not set"
|
||||
* (inherit the global cap). Parsed, stored, and validated, but NOT enforced
|
||||
* per-module: connections are counted in the accept-loop parent before the
|
||||
* client's module is known, so only the global cap is enforced (see
|
||||
* transport_tcp.c and the Daemon Mode notes in RSYNC_COMPAT.md). */
|
||||
int max_connections;
|
||||
char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */
|
||||
int hosts_allow_count;
|
||||
char** hosts_deny; /* `hosts deny = a,b`; host access deny patterns */
|
||||
int hosts_deny_count;
|
||||
} DaemonModule;
|
||||
|
||||
/* Global (pre-module) scalar keys. `motd file` is parsed and stored but has
|
||||
@@ -60,6 +70,14 @@ typedef struct DaemonConfGlobals {
|
||||
int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */
|
||||
char* motd_file; /* `motd file`, may be NULL */
|
||||
char* address; /* `address` (optional bind address), may be NULL */
|
||||
int max_connections; /* `max connections`, default
|
||||
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */
|
||||
int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default
|
||||
DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */
|
||||
char** hosts_allow; /* `hosts allow`; global host access allow patterns */
|
||||
int hosts_allow_count;
|
||||
char** hosts_deny; /* `hosts deny`; global host access deny patterns */
|
||||
int hosts_deny_count;
|
||||
} DaemonConfGlobals;
|
||||
|
||||
typedef struct DaemonConf {
|
||||
@@ -69,6 +87,16 @@ typedef struct DaemonConf {
|
||||
} DaemonConf;
|
||||
|
||||
#define DAEMON_CONF_DEFAULT_PORT 873
|
||||
/* Default global connection cap when `max connections` is absent. Matches the
|
||||
* historical hardcoded listener value. */
|
||||
#define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS 100
|
||||
/* Default `auth failure delay` in milliseconds (0 disables the throttle). */
|
||||
#define DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS 500
|
||||
/* Largest accepted `auth failure delay`, so a typo cannot pin a connection
|
||||
* child in nanosleep for an absurd time. */
|
||||
/* 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. */
|
||||
#define DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS 5000
|
||||
/* Longest accepted config line (excluding the trailing newline). Longer lines
|
||||
* are rejected rather than buffered unboundedly. */
|
||||
#define DAEMON_CONF_MAX_LINE 4096
|
||||
@@ -99,9 +127,30 @@ const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char*
|
||||
bool daemon_module_name_valid(const char* name);
|
||||
|
||||
/* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and
|
||||
* apply it to the global scalars only. Keys are case-insensitive and limited
|
||||
* to the global scalar keys defined by the grammar (port, motd file, address).
|
||||
* Returns 0 on success, -1 on error (err filled). */
|
||||
* 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,
|
||||
* max connections, auth failure delay, 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);
|
||||
|
||||
/* Host access-control matching (pure; no I/O). `daemon_host_pattern_match`
|
||||
* matches one configured pattern against a numeric peer IP string. Supported
|
||||
* patterns: `*` (match anything), an IPv4/IPv6 literal, an IPv4/IPv6 CIDR
|
||||
* (`10.0.0.0/8`, `2001:db8::/32`), or a glob (`*.example.com`) evaluated with
|
||||
* the same matcher as file globs; a glob only matches a peer string of the
|
||||
* same shape, so a numeric peer never matches a hostname glob. */
|
||||
bool daemon_host_pattern_match(const char* pattern, const char* peer_ip);
|
||||
|
||||
/* rsync-like combined decision over a deny list and an allow list: a matching
|
||||
* deny rejects (deny takes precedence); otherwise, when any allow entries
|
||||
* exist, a peer that matches none is rejected; with no allow entries every
|
||||
* peer not denied is accepted. An empty/unset pair returns true. */
|
||||
bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count,
|
||||
char* const* deny, int deny_count);
|
||||
|
||||
/* True when at least one allow or deny pattern is configured (i.e. an
|
||||
* unprovable peer must fail closed rather than being treated as unrestricted). */
|
||||
bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny,
|
||||
int deny_count);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -115,6 +115,11 @@ Server* server_create(int port) {
|
||||
return server_create_ex(port, NULL);
|
||||
}
|
||||
|
||||
void server_set_max_connections(Server* server, unsigned int max_connections) {
|
||||
if (server && max_connections > 0)
|
||||
server->max_connections = max_connections;
|
||||
}
|
||||
|
||||
void server_delete(Server** server) {
|
||||
if (server == NULL || *server == NULL)
|
||||
return;
|
||||
@@ -135,7 +140,7 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
}
|
||||
signal(SIGCHLD, sigchld_handler);
|
||||
while (1) {
|
||||
struct sockaddr_in client_addr;
|
||||
struct sockaddr_storage client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
int fd = accept(server->file_descriptor, (struct sockaddr*)&client_addr, &client_len);
|
||||
if (fd < 0) {
|
||||
@@ -143,13 +148,16 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
continue;
|
||||
}
|
||||
tcp_apply_socket_timeout(fd);
|
||||
char peer[128];
|
||||
if (!utils_sockaddr_to_string((const struct sockaddr*)&client_addr, peer, sizeof(peer)))
|
||||
snprintf(peer, sizeof(peer), "unknown");
|
||||
if ((unsigned int)g_active_connections >= server->max_connections) {
|
||||
log_message(LOG_LEVEL_WARNING, "Max connections (%u) reached, rejecting",
|
||||
server->max_connections);
|
||||
log_message(LOG_LEVEL_WARNING, "Max connections (%u) reached, rejecting %s",
|
||||
server->max_connections, peer);
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
log_message(LOG_LEVEL_INFO, "%s", log_fmt);
|
||||
log_message(LOG_LEVEL_INFO, "%s from %s", log_fmt, peer);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* Connection children must not run the parent's global cleanup(): it
|
||||
|
||||
@@ -45,6 +45,9 @@ typedef struct {
|
||||
|
||||
Server* server_create_ex(int port, const ServerBindOptions* bind_opts);
|
||||
Server* server_create(int port);
|
||||
/* Override the listener's connection cap (the global daemon `max connections`
|
||||
* value). A non-positive value is ignored so the default cap stands. */
|
||||
void server_set_max_connections(Server* server, unsigned int max_connections);
|
||||
bool server_listen(Server* server, void (*handler)(int file_descriptor));
|
||||
void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx,
|
||||
const char* log_fmt);
|
||||
|
||||
@@ -587,6 +587,71 @@ bool utils_fd_peer_is_local(int fd) {
|
||||
return utils_sockaddr_is_loopback((const struct sockaddr*)&peer);
|
||||
}
|
||||
|
||||
/* Numeric peer address of a connected fd. Only AF_INET/AF_INET6 peers are
|
||||
formatted; every other descriptor/family (pipe, AF_UNIX socketpair, ...) or a
|
||||
getpeername failure returns false with buf emptied. The caller must treat
|
||||
that as "cannot tell". */
|
||||
bool utils_fd_peer_ip(int fd, char* buf, size_t len) {
|
||||
if (!buf || len == 0)
|
||||
return false;
|
||||
buf[0] = '\0';
|
||||
if (fd < 0)
|
||||
return false;
|
||||
struct sockaddr_storage peer;
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
if (getpeername(fd, (struct sockaddr*)&peer, &peer_len) != 0)
|
||||
return false;
|
||||
const void* src = NULL;
|
||||
int family = peer.ss_family;
|
||||
if (family == AF_INET) {
|
||||
src = &((const struct sockaddr_in*)&peer)->sin_addr;
|
||||
} else if (family == AF_INET6) {
|
||||
const struct sockaddr_in6* peer6 = (const struct sockaddr_in6*)&peer;
|
||||
/* A dual-stack IPv6 listener reports IPv4 peers as ::ffff:a.b.c.d. Emit
|
||||
* the IPv4 form so IPv4 ACL patterns (and logs) see the real address. */
|
||||
if (IN6_IS_ADDR_V4MAPPED(&peer6->sin6_addr)) {
|
||||
struct in_addr v4;
|
||||
memcpy(&v4, &peer6->sin6_addr.s6_addr[12], sizeof(v4));
|
||||
return inet_ntop(AF_INET, &v4, buf, (socklen_t)len) != NULL;
|
||||
}
|
||||
src = &peer6->sin6_addr;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return inet_ntop(family, src, buf, (socklen_t)len) != NULL;
|
||||
}
|
||||
|
||||
/* "ip:port" / "[ip]:port" for a connected peer, used to log the connecting
|
||||
address in the accept loop. Returns false for a non-INET family. */
|
||||
bool utils_sockaddr_to_string(const struct sockaddr* addr, char* buf, size_t len) {
|
||||
if (!addr || !buf || len == 0)
|
||||
return false;
|
||||
buf[0] = '\0';
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
unsigned short port;
|
||||
int written;
|
||||
if (addr->sa_family == AF_INET) {
|
||||
const struct sockaddr_in* v4 = (const struct sockaddr_in*)addr;
|
||||
if (!inet_ntop(AF_INET, &v4->sin_addr, ip, sizeof(ip)))
|
||||
return false;
|
||||
port = ntohs(v4->sin_port);
|
||||
written = snprintf(buf, len, "%s:%u", ip, port);
|
||||
} else if (addr->sa_family == AF_INET6) {
|
||||
const struct sockaddr_in6* v6 = (const struct sockaddr_in6*)addr;
|
||||
if (!inet_ntop(AF_INET6, &v6->sin6_addr, ip, sizeof(ip)))
|
||||
return false;
|
||||
port = ntohs(v6->sin6_port);
|
||||
written = snprintf(buf, len, "[%s]:%u", ip, port);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (written < 0 || (size_t)written >= len) {
|
||||
buf[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* True when a client-supplied host string names a loopback destination:
|
||||
"localhost", any 127.0.0.0/8 literal, "::1", or "[::1]". */
|
||||
bool utils_host_is_loopback(const char* host) {
|
||||
|
||||
@@ -77,5 +77,13 @@ bool append_tail_length(unsigned long long old_size, unsigned long long check_si
|
||||
bool utils_sockaddr_is_loopback(const struct sockaddr* addr);
|
||||
bool utils_fd_peer_is_local(int fd);
|
||||
bool utils_host_is_loopback(const char* host);
|
||||
/* Numeric peer address of a connected fd (INET6_ADDRSTRLEN is always enough).
|
||||
* Returns false and leaves buf empty when the fd is not a connected INET socket
|
||||
* or getpeername/inet_ntop fails. Used by the daemon host-access gate; a false
|
||||
* return is "cannot tell" and must be treated as fail-closed when ACLs apply. */
|
||||
bool utils_fd_peer_ip(int fd, char* buf, size_t len);
|
||||
/* Format a sockaddr as "ip:port" (IPv4) or "[ip]:port" (IPv6) for logging.
|
||||
* Returns false (buf emptied) for a non-INET family or a formatting failure. */
|
||||
bool utils_sockaddr_to_string(const struct sockaddr* addr, char* buf, size_t len);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -51,6 +51,7 @@ READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||
OWNER_MODULE = os.path.join(MODULE_ROOT, "owner")
|
||||
DENIED_MODULE = os.path.join(MODULE_ROOT, "denied")
|
||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||
@@ -191,7 +192,7 @@ def _config_port(config_path):
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def daemon_env():
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||
DETACH_MODULE):
|
||||
DENIED_MODULE, DETACH_MODULE):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
@@ -231,7 +232,12 @@ def daemon_env():
|
||||
"[owner]\n"
|
||||
"path = %s\n"
|
||||
"client owner = yes\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE))
|
||||
"\n"
|
||||
"[denied]\n"
|
||||
"path = %s\n"
|
||||
"hosts deny = 127.0.0.1\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||
DENIED_MODULE))
|
||||
|
||||
# A dedicated config for the fail-closed startup check: an auth-required
|
||||
# module with no credential store must refuse to start. Its own free port
|
||||
@@ -368,6 +374,15 @@ class TestDaemonRejection:
|
||||
result = _push("127.0.0.1::/sub", daemon.port)
|
||||
assert result.returncode != 0
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_hosts_deny_rejects_loopback(self, daemon):
|
||||
"""Host access control: a module with `hosts deny = 127.0.0.1` refuses a
|
||||
loopback client at the config gate, before any data is exchanged."""
|
||||
before = self._tree_files()
|
||||
result = _push("127.0.0.1::denied", daemon.port)
|
||||
assert result.returncode != 0
|
||||
assert self._tree_files() == before, "host-denied connection wrote under the module root"
|
||||
|
||||
def test_dotdot_destination_rejected(self, daemon):
|
||||
"""A '..' path expansion in the module-relative path is refused at parse
|
||||
time so a client cannot escape the module root while it is still on the
|
||||
|
||||
@@ -31,6 +31,10 @@ static void test_daemon_conf_create_defaults() {
|
||||
EXPECT_EQ_INT(conf->global.port, DAEMON_CONF_DEFAULT_PORT);
|
||||
EXPECT_NULL(conf->global.motd_file);
|
||||
EXPECT_NULL(conf->global.address);
|
||||
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.hosts_allow_count, 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_deny_count, 0);
|
||||
EXPECT_EQ_INT(conf->module_count, 0);
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
@@ -310,6 +314,18 @@ static void test_daemon_conf_dparam_override() {
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port = 9000", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.port, 9000);
|
||||
|
||||
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(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(
|
||||
daemon_conf_apply_dparam(conf, "hosts allow=127.0.0.1,10.0.0.0/8", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 2);
|
||||
/* A later --dparam replaces the list (an override must be able to narrow). */
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "hosts allow=127.0.0.1", err, sizeof(err)), 0);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 1);
|
||||
EXPECT_EQ_STR(conf->global.hosts_allow[0], "127.0.0.1");
|
||||
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=notaport", err, sizeof(err)), -1);
|
||||
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "bogus=1", err, sizeof(err)), -1);
|
||||
EXPECT_TRUE(strstr(err, "unknown global key") != NULL);
|
||||
@@ -366,6 +382,116 @@ static void test_daemon_conf_auth_users_validated() {
|
||||
daemon_conf_free(ok_conf);
|
||||
}
|
||||
|
||||
/* Wave 3 daemon hardening: configurable global/per-module connection caps,
|
||||
* auth-failure throttle and host access lists parse strictly (valid values are
|
||||
* stored, malformed values fail the whole load). */
|
||||
static void test_daemon_conf_limits_and_hosts_parse() {
|
||||
char* path;
|
||||
char err[256];
|
||||
EXPECT_EQ_INT(write_conf("max connections = 25\n"
|
||||
"auth failure delay = 0\n"
|
||||
"hosts allow = 10.0.0.0/8, 192.168.1.0/24\n"
|
||||
"hosts deny = 192.168.0.1 2001:db8::/32\n"
|
||||
"\n"
|
||||
"[m]\n"
|
||||
"path = /x\n"
|
||||
"max connections = 3\n"
|
||||
"hosts allow = 127.0.0.1\n"
|
||||
"hosts deny = *\n",
|
||||
&path),
|
||||
0);
|
||||
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->global.max_connections, 25);
|
||||
EXPECT_EQ_INT(conf->global.auth_failure_delay_ms, 0);
|
||||
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[1], "192.168.1.0/24");
|
||||
EXPECT_EQ_INT(conf->global.hosts_deny_count, 2);
|
||||
EXPECT_EQ_STR(conf->global.hosts_deny[0], "192.168.0.1");
|
||||
EXPECT_EQ_STR(conf->global.hosts_deny[1], "2001:db8::/32");
|
||||
EXPECT_EQ_INT(conf->modules[0].max_connections, 3);
|
||||
EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 1);
|
||||
EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1");
|
||||
EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 1);
|
||||
EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "*");
|
||||
daemon_conf_free(conf);
|
||||
|
||||
const char* bad_values[] = {
|
||||
"max connections = 0\n", "max connections = -1\n",
|
||||
"max connections = abc\n", "auth failure delay = -1\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",
|
||||
"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++) {
|
||||
EXPECT_EQ_INT(write_conf(bad_values[i], &path), 0);
|
||||
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(rejected);
|
||||
}
|
||||
|
||||
/* The same strictness applies inside a module section. */
|
||||
const char* bad_module[] = {
|
||||
"[m]\npath = /x\nmax connections = 0\n",
|
||||
"[m]\npath = /x\nhosts allow = 10.0.0.0/40\n",
|
||||
"[m]\npath = /x\nhosts deny = 999.1.1.1/8\n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(bad_module) / sizeof(bad_module[0]); i++) {
|
||||
EXPECT_EQ_INT(write_conf(bad_module[i], &path), 0);
|
||||
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(rejected);
|
||||
EXPECT_TRUE(strstr(err, "invalid") != NULL);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(conf);
|
||||
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0);
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
|
||||
static void test_daemon_hosts_allowed() {
|
||||
/* Pattern forms. */
|
||||
EXPECT_TRUE(daemon_host_pattern_match("*", "203.0.113.9"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("10.0.0.1", "10.0.0.1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", "10.0.0.2"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("10.0.0.0/8", "10.255.1.2"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.0/8", "11.0.0.1"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("2001:db8::/32", "2001:db8:1234::5"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("2001:db8::/32", "2001:db9::1"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("::1", "::1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("::1", "::2"));
|
||||
EXPECT_TRUE(daemon_host_pattern_match("*.example.com", "host.example.com"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("*.example.com", "example.org"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match(NULL, "10.0.0.1"));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("10.0.0.1", NULL));
|
||||
EXPECT_FALSE(daemon_host_pattern_match("", "10.0.0.1"));
|
||||
|
||||
char* allow[] = {"10.0.0.0/8"};
|
||||
char* deny[] = {"10.0.0.1"};
|
||||
/* Deny takes precedence over a matching allow. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", allow, 1, deny, 1));
|
||||
EXPECT_TRUE(daemon_hosts_allowed("10.0.0.2", allow, 1, deny, 1));
|
||||
/* A non-empty allow list rejects a peer that matches none of its entries. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed("192.168.1.1", allow, 1, NULL, 0));
|
||||
/* With only a deny list, everything not denied is accepted. */
|
||||
EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, deny, 1));
|
||||
EXPECT_FALSE(daemon_hosts_allowed("10.0.0.1", NULL, 0, deny, 1));
|
||||
/* No lists at all accepts everyone. */
|
||||
EXPECT_TRUE(daemon_hosts_allowed("192.168.1.1", NULL, 0, NULL, 0));
|
||||
/* An unprovable peer (NULL) never matches an allow list. */
|
||||
EXPECT_FALSE(daemon_hosts_allowed(NULL, allow, 1, NULL, 0));
|
||||
|
||||
EXPECT_FALSE(daemon_hosts_restricted(NULL, 0, NULL, 0));
|
||||
EXPECT_TRUE(daemon_hosts_restricted(allow, 1, NULL, 0));
|
||||
EXPECT_TRUE(daemon_hosts_restricted(NULL, 0, deny, 1));
|
||||
}
|
||||
|
||||
static void test_daemon_module_name_valid() {
|
||||
EXPECT_TRUE(daemon_module_name_valid("backup"));
|
||||
EXPECT_TRUE(daemon_module_name_valid("Backup_2"));
|
||||
@@ -398,5 +524,7 @@ void test_daemon_conf() {
|
||||
test_daemon_conf_find_module();
|
||||
test_daemon_conf_dparam_override();
|
||||
test_daemon_conf_auth_users_validated();
|
||||
test_daemon_conf_limits_and_hosts_parse();
|
||||
test_daemon_hosts_allowed();
|
||||
test_daemon_module_name_valid();
|
||||
}
|
||||
@@ -356,6 +356,69 @@ static void test_loopback_helpers() {
|
||||
close(listener);
|
||||
}
|
||||
|
||||
/* The daemon host ACL reads the numeric peer address through
|
||||
* utils_fd_peer_ip. A real loopback TCP peer reports "127.0.0.1"; a pipe or an
|
||||
* AF_UNIX socketpair has no INET peer and must return false with an empty
|
||||
* buffer (the fail-closed "cannot tell" result). */
|
||||
static void test_fd_peer_ip() {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
EXPECT_FALSE(utils_fd_peer_ip(-1, ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
EXPECT_FALSE(utils_fd_peer_ip(-1, NULL, 0));
|
||||
|
||||
int pipe_fds[2];
|
||||
EXPECT_EQ_INT(pipe(pipe_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_ip(pipe_fds[0], ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
close(pipe_fds[0]);
|
||||
close(pipe_fds[1]);
|
||||
|
||||
int pair_fds[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, pair_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_ip(pair_fds[0], ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "");
|
||||
close(pair_fds[0]);
|
||||
close(pair_fds[1]);
|
||||
|
||||
int listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(listener >= 0);
|
||||
struct sockaddr_in bind_addr;
|
||||
memset(&bind_addr, 0, sizeof(bind_addr));
|
||||
bind_addr.sin_family = AF_INET;
|
||||
bind_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind_addr.sin_port = 0;
|
||||
EXPECT_EQ_INT(bind(listener, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
EXPECT_EQ_INT(listen(listener, 1), 0);
|
||||
socklen_t addr_len = sizeof(bind_addr);
|
||||
EXPECT_EQ_INT(getsockname(listener, (struct sockaddr*)&bind_addr, &addr_len), 0);
|
||||
int dialer = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(dialer >= 0);
|
||||
EXPECT_EQ_INT(connect(dialer, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
int accepted = accept(listener, NULL, NULL);
|
||||
EXPECT_TRUE(accepted >= 0);
|
||||
EXPECT_TRUE(utils_fd_peer_ip(accepted, ip, sizeof(ip)));
|
||||
EXPECT_EQ_STR(ip, "127.0.0.1");
|
||||
|
||||
/* utils_sockaddr_to_string includes the port for a real peer. */
|
||||
struct sockaddr_storage peer;
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
EXPECT_EQ_INT(getpeername(accepted, (struct sockaddr*)&peer, &peer_len), 0);
|
||||
char peer_string[128];
|
||||
EXPECT_TRUE(
|
||||
utils_sockaddr_to_string((const struct sockaddr*)&peer, peer_string, sizeof(peer_string)));
|
||||
EXPECT_TRUE(strncmp(peer_string, "127.0.0.1:", strlen("127.0.0.1:")) == 0);
|
||||
close(accepted);
|
||||
close(dialer);
|
||||
close(listener);
|
||||
|
||||
/* A non-INET family formats to "unknown" at the call site, not a bogus IP. */
|
||||
struct sockaddr sa_unix;
|
||||
memset(&sa_unix, 0, sizeof(sa_unix));
|
||||
sa_unix.sa_family = AF_UNIX;
|
||||
EXPECT_FALSE(utils_sockaddr_to_string(&sa_unix, peer_string, sizeof(peer_string)));
|
||||
EXPECT_EQ_STR(peer_string, "");
|
||||
}
|
||||
|
||||
void test_shared_utils() {
|
||||
test_walker_removes_extras_keeps_manifest_and_protected();
|
||||
test_walker_max_delete_exceeded_deletes_nothing();
|
||||
@@ -363,6 +426,7 @@ void test_shared_utils() {
|
||||
test_walker_unlimited_deletes_all();
|
||||
test_walker_hard_bound_all_or_nothing();
|
||||
test_loopback_helpers();
|
||||
test_fd_peer_ip();
|
||||
|
||||
/* --append / --append-verify tail-resume math: a resume is eligible only for
|
||||
a shorter existing destination, and the tail length is then the difference. */
|
||||
|
||||
Reference in New Issue
Block a user