From 5d3c43305e5d680784fe194af23a66c3fcee147d Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:05:38 +0200 Subject: [PATCH 01/16] fix(protocol): release Data charge to its owning session Data charged against a ProtocolSession kept only the charge amount, so data_destroy released it from whatever session was thread-locally bound at destroy time. Destroying a received Data on another thread, after the session was unbound, or while a different session was bound leaked the originating session's budget and underflowed the other's. Add Data.owner, set it whenever protocol_receive_data_limited charges a session, and have data_destroy release against that owner directly via the newly-exported protocol_release_memory_for_session. Uncharged Data (owner NULL) keeps the previous bound-session fallback. Add a unit test proving a Data acquired on session A is released to A even when unrelated session B is bound at destroy time. --- src/client/client_send.c | 1 + src/shared/data.c | 10 ++++++-- src/shared/data.h | 11 +++++++++ src/shared/protocol.c | 3 ++- tests/test_protocol.c | 49 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/client/client_send.c b/src/client/client_send.c index db7a560..e220fd4 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -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.size = tail_len; tail_view.protocol_charge = 0; + tail_view.owner = NULL; ok = send_data(fd, &tail_view); } return ok ? 0 : -1; diff --git a/src/shared/data.c b/src/shared/data.c index 55af503..2ec189c 100644 --- a/src/shared/data.c +++ b/src/shared/data.c @@ -23,6 +23,7 @@ Data* data_create_reserve(size_t size) { d->data = NULL; d->size = size; d->protocol_charge = 0; + d->owner = NULL; return d; } @@ -36,14 +37,19 @@ Data* data_create(void* data, size_t data_size) { new_data->data = data; new_data->size = data_size; new_data->protocol_charge = 0; + new_data->owner = NULL; return new_data; } void data_destroy(Data* data) { if (data == NULL) return; - if (data->protocol_charge != 0) - protocol_release_memory(data->protocol_charge); + if (data->protocol_charge != 0) { + 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); } diff --git a/src/shared/data.h b/src/shared/data.h index b65ae29..8112976 100644 --- a/src/shared/data.h +++ b/src/shared/data.h @@ -3,11 +3,19 @@ #include +/* 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 { void* data; size_t size; /* Non-zero only for a buffer charged to the protocol connection budget. */ size_t protocol_charge; + /* Session whose budget `protocol_charge` was reserved from. The charge must + * always be returned to this session, regardless of which session (if any) is + * bound to the destroying thread. NULL for uncharged Data. */ + ProtocolSession* owner; } Data; Data* data_create_empty(size_t data_size); @@ -15,5 +23,8 @@ Data* data_create_reserve(size_t size); Data* data_create(void* data, size_t data_size); void data_destroy(Data* data); 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. */ +void protocol_release_memory_for_session(ProtocolSession* session, size_t charge); #endif diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 57fd2e2..4527296 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -40,7 +40,7 @@ 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) { unsigned long long allocated = atomic_load(&session->total_allocated_bytes); while (true) { unsigned long long remaining = (unsigned long long)charge >= allocated ? 0 : allocated - charge; @@ -573,6 +573,7 @@ Data* protocol_receive_data_limited(ProtocolSession* session, unsigned long long return NULL; } result->protocol_charge = allocation_size; + result->owner = session; return result; } diff --git a/tests/test_protocol.c b/tests/test_protocol.c index 3a63125..d84f293 100644 --- a/tests/test_protocol.c +++ b/tests/test_protocol.c @@ -412,6 +412,54 @@ static void test_protocol_accounting_release_does_not_underflow() { protocol_session_unbind(); } +/* A Data acquired on session A must return its connection-memory charge to A + even when a different session B is bound at destroy time: releasing against + the thread-local bound session would leak A's budget and drain B's. */ +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_b[1], &size, sizeof(size)), (int)sizeof(size)); + EXPECT_EQ_INT((int)write(pipe_b[1], "abcdefgh", 8), 8); + + Data* data_a = protocol_receive_data_limited(&session_a, 8); + Data* data_b = protocol_receive_data_limited(&session_b, 8); + EXPECT_NOT_NULL(data_a); + EXPECT_NOT_NULL(data_b); + EXPECT_TRUE(data_a->owner == &session_a); + EXPECT_TRUE(data_b->owner == &session_b); + EXPECT_EQ_INT((int)atomic_load(&session_a.total_allocated_bytes), 8); + EXPECT_EQ_INT((int)atomic_load(&session_b.total_allocated_bytes), 8); + + /* Destroy A's Data while the unrelated session B is the bound session. */ + protocol_session_bind(&session_b); + data_destroy(data_a); + protocol_session_unbind(); + + 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]); +} + static void test_protocol_session_io_timeout() { /* 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 @@ -574,4 +622,5 @@ void test_protocol() { test_protocol_accounting_reservation_is_atomic(); test_protocol_string_accounting_is_transient(); test_protocol_accounting_release_does_not_underflow(); + test_receive_data_charge_follows_owning_session(); } From 3260a39ab454dabebe0ecee4318f44ff5f40c9da Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:06:04 +0200 Subject: [PATCH 02/16] refactor(shared): single owner for authorized_root state --- src/server/server.c | 34 +++++++-------------------------- src/shared/file.c | 46 +++++++++++++++++---------------------------- src/shared/file.h | 3 --- src/shared/utils.c | 24 ++++++++++++++++------- src/shared/utils.h | 7 +++++++ tests/test_file.c | 16 ++++++++-------- 6 files changed, 56 insertions(+), 74 deletions(-) diff --git a/src/server/server.c b/src/server/server.c index 494a840..1eb1e8d 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -29,8 +29,6 @@ #include #include -static char* authorized_root; -static int authorized_root_fd = -1; static bool allow_delete; static bool trust_sender; static bool allow_unauthenticated; @@ -187,13 +185,10 @@ static bool tls_client_identity_allowed(SSL* ssl) { } static void release_authorization(void) { - file_set_authorized_root(-1, NULL); - utils_set_authorized_root_fd(-1); - if (authorized_root_fd >= 0) - close(authorized_root_fd); - authorized_root_fd = -1; - free(authorized_root); - authorized_root = NULL; + int root_fd = utils_get_authorized_root_fd(); + utils_set_authorized_root(-1, NULL); + if (root_fd >= 0) + close(root_fd); } static bool path_is_within(const char* root, const char* path) { @@ -219,13 +214,11 @@ static bool ensure_receive_root(const Config* config) { static bool configure_authorization(const char* root) { char resolved[PATH_MAX]; if (!root) { - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } int root_fd = open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (root_fd < 0) { - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } @@ -234,26 +227,12 @@ static bool configure_authorization(const char* root) { if (fd_path_length < 0 || (size_t)fd_path_length >= sizeof(fd_path) || !realpath(fd_path, resolved)) { close(root_fd); - file_set_authorized_root(-1, NULL); utils_set_authorized_root(-1, NULL); return false; } - authorized_root = str_dup(resolved); - if (!authorized_root) { + if (!utils_set_authorized_root(root_fd, resolved)) { + utils_set_authorized_root(-1, NULL); 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 true; @@ -615,6 +594,7 @@ void handler(int file_descriptor) { * 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. */ protocol_session_set_io_timeout(&session, config->timeout); + const char* authorized_root = utils_get_authorized_root_path(); if (!authorized_root) { log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); goto done; diff --git a/src/shared/file.c b/src/shared/file.c index b85e988..9d5aa23 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -284,23 +284,6 @@ size_t file_content_to_buffer(File* file) { /* ---- 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) { if (!path) return false; @@ -483,7 +466,10 @@ static int open_dir_beneath_root(const char* resolved, const char* root) { rel++; if (*rel == '\0') 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) return -1; 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; } int fd; - if (authorized_root_fd >= 0) { - if (!authorized_root_path || path[0] != '/' || - !path_is_within_root(authorized_root_path, path)) { + int root_fd = utils_get_authorized_root_fd(); + const char* root_path = utils_get_authorized_root_path(); + if (root_fd >= 0) { + if (!root_path || path[0] != '/' || !path_is_within_root(root_path, path)) { free(copy); free(leaf); return -1; } - fd = dup(authorized_root_fd); + fd = dup(root_fd); if (fd < 0) { free(copy); free(leaf); return -1; } - size_t root_len = strlen(authorized_root_path); + size_t root_len = strlen(root_path); char* relative = str_dup(path + root_len); if (!relative) { 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 directory that stays beneath the authorized root, so a malicious link 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)) { struct stat lst; if (fstatat(fd, component, &lst, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(lst.st_mode)) { char candidate[PATH_MAX]; char root[PATH_MAX]; - if (realpath(authorized_root_path, root) && - snprintf(candidate, sizeof(candidate), "%s%s/%s", root, rel_buf, component) < - (int)sizeof(candidate)) { + if (realpath(root_path, root) && snprintf(candidate, sizeof(candidate), "%s%s/%s", root, + rel_buf, component) < (int)sizeof(candidate)) { char resolved[PATH_MAX]; if (realpath(candidate, resolved) && strcmp(resolved, root) != 0 && strncmp(root, resolved, strlen(root)) == 0 && @@ -685,8 +671,9 @@ bool file_ensure_directory_secure(const char* path) { return false; /* 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. */ + const char* root_path = utils_get_authorized_root_path(); 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) { free(norm); return true; @@ -733,8 +720,9 @@ bool file_directory_exists_secure(const char* path) { char* norm = normalize_directory_path(path); if (!norm) return false; + const char* root_path = utils_get_authorized_root_path(); 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) { free(norm); return true; diff --git a/src/shared/file.h b/src/shared/file.h index 570d703..3ccc2aa 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -62,9 +62,6 @@ void file_set_keep_dirlinks(bool enable); void file_set_trust_sender(bool enable); 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). */ bool file_path_exists_secure(const char* path); bool file_stat_secure(const char* path, struct stat* st); diff --git a/src/shared/utils.c b/src/shared/utils.c index d790172..6ab725a 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -36,6 +36,14 @@ void utils_set_authorized_root_fd(int fd) { (void)utils_set_authorized_root(fd, NULL); } +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) { size_t root_len = strlen(root); return strncmp(root, path, root_len) == 0 && (path[root_len] == '\0' || path[root_len] == '/'); @@ -50,15 +58,16 @@ bool path_is_within_root(const char* root, const char* path) { * in the extra receiver policies they apply, so they are intentionally kept * separate. Both rely on the shared lexical path_is_within_root check. */ static int open_authorized_destination(const char* dest_root) { - if (authorized_root_fd < 0 || !authorized_root_path || !dest_root || - !path_is_within_root(authorized_root_path, dest_root)) + int root_fd = utils_get_authorized_root_fd(); + 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; - int dirfd = dup(authorized_root_fd); + int dirfd = dup(root_fd); if (dirfd < 0) return -1; - const char* relative_path = dest_root + strlen(authorized_root_path); + const char* relative_path = dest_root + strlen(root_path); while (*relative_path == '/') relative_path++; char* relative = str_dup(*relative_path ? relative_path : "."); @@ -689,11 +698,12 @@ DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* m if (!build_keep_index(manifest, &keep)) return DELETE_WALK_ERROR; int rootfd; - if (authorized_root_fd >= 0) { - if (authorized_root_path) + int root_fd = utils_get_authorized_root_fd(); + if (root_fd >= 0) { + if (utils_get_authorized_root_path()) rootfd = open_authorized_destination(dest_root); else if (dest_root == NULL) - rootfd = dup(authorized_root_fd); + rootfd = dup(root_fd); else rootfd = -1; } else { diff --git a/src/shared/utils.h b/src/shared/utils.h index f4a5bd6..7ac9059 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -127,6 +127,13 @@ bool utils_set_authorized_root(int fd, const char* canonical_path); /* The fd-only compatibility form is fail-closed for path-based operations; * callers should use utils_set_authorized_root with the canonical identity. */ 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. */ +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 * prefix test requiring the byte after `root` to be '\0' or '/'. Both `root` * and `path` must be absolute canonical paths free of "."/".." components (the diff --git a/tests/test_file.c b/tests/test_file.c index 4561f1a..73c3431 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -1180,7 +1180,7 @@ static void test_trust_sender_authorized_root_confinement() { rmdir(sibling); 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); struct stat st; @@ -1204,7 +1204,7 @@ static void test_trust_sender_authorized_root_confinement() { free(outside_link); free(inside_link); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); close(root_fd); unlink("test_trust_sender_outside_link"); rmdir(sibling); @@ -1220,7 +1220,7 @@ void test_trust_sender() { test_trust_sender_confines_hostile_paths(); test_trust_sender_authorized_root_confinement(); 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 @@ -1341,7 +1341,7 @@ static void test_file_write_to_disk_partial_retention() { static void test_dir_time_list() { const char* root = "test_dir_time_root"; const char* sub = "test_dir_time_root/sub"; - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); rmdir(sub); rmdir(root); EXPECT_EQ_INT(mkdir(root, 0755), 0); @@ -1485,7 +1485,7 @@ static void test_keep_dirlinks_secure_open_impl() { rmdir(outside); 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); struct stat real_st; @@ -1538,7 +1538,7 @@ static void test_keep_dirlinks_secure_open_impl() { free(leaf); file_set_keep_dirlinks(false); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); close(root_fd); unlink(link); 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 * returns from its own function, so the body's trailing resets may be skipped). */ static void test_keep_dirlinks_secure_open() { - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); file_set_keep_dirlinks(false); test_keep_dirlinks_secure_open_impl(); - file_set_authorized_root(-1, NULL); + utils_set_authorized_root(-1, NULL); file_set_keep_dirlinks(false); } From 5334397b817babe93c78c0a2a3bb011f4d4b60fb Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:23:58 +0200 Subject: [PATCH 03/16] feat(daemon): add shared cross-process connection registry The daemon forks one child per accepted connection, so per-module and per-source accounting must live in state shared across the children. Add a fixed-size registry carved from an anonymous shared mapping (mmap(MAP_SHARED|MAP_ANONYMOUS)) created before the accept loop: a slot lifecycle (FREE/CLAIMED/REGISTERED) with parent claim/reclaim and a lock-free, open-addressed per-source table for the per-host occupancy and the shared auth-failure counter. C11 atomics only; no pthread locks across fork. Unit tests cover slot exhaustion, the module/host caps, pid reclaim and fork-shared visibility. --- CMakeLists.txt | 2 + src/shared/daemon_limits.c | 356 +++++++++++++++++++++++++++++++++++++ src/shared/daemon_limits.h | 102 +++++++++++ tests/runner.c | 2 + tests/test_daemon_limits.c | 194 ++++++++++++++++++++ tests/test_daemon_limits.h | 6 + 6 files changed, 662 insertions(+) create mode 100644 src/shared/daemon_limits.c create mode 100644 src/shared/daemon_limits.h create mode 100644 tests/test_daemon_limits.c create mode 100644 tests/test_daemon_limits.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e61b0fa..479cbca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,7 @@ set(SHARED_SRCS src/shared/config.c src/shared/credentials.c src/shared/daemon_conf.c + src/shared/daemon_limits.c src/shared/data.c src/shared/delay_updates.c src/shared/delta.c @@ -205,6 +206,7 @@ set(TEST_SRCS tests/test_config.c tests/test_credentials.c tests/test_daemon_conf.c + tests/test_daemon_limits.c tests/test_data.c tests/test_delay_updates.c tests/test_delta.c diff --git a/src/shared/daemon_limits.c b/src/shared/daemon_limits.c new file mode 100644 index 0000000..2ba7e4e --- /dev/null +++ b/src/shared/daemon_limits.c @@ -0,0 +1,356 @@ +#include "daemon_limits.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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 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 */ +}; + +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; +} + +/* Find the bucket holding `peer_ip`, or -1 when it has no entry. */ +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) + return (int)idx; + if (current == 0) + return -1; /* no tombstones: an empty bucket ends the probe chain */ + } + return -1; +} + +/* 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. Returns -1 + * when the table is full or the address is unparseable (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; + 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) + return (int)idx; + if (current == 0) { + 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; + } + } + 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 (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); + 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; + + 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; + int previous = + atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel); + if (previous == SLOT_REGISTERED) { + int module = atomic_load(®istry->slot_module[slot]); + int host = atomic_load(®istry->slot_host[slot]); + if (module >= 0 && module < registry->module_count) { + int current = atomic_load(®istry->module_active[module]); + while (current > 0 && + !atomic_compare_exchange_weak(®istry->module_active[module], ¤t, current - 1)) + ; + } + if (host >= 0 && host < registry->host_slots) { + int current = atomic_load(®istry->host_active[host]); + while (current > 0 && + !atomic_compare_exchange_weak(®istry->host_active[host], ¤t, current - 1)) + ; + } + } + atomic_store(®istry->slot_pid[slot], 0); +} + +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; + } + } +} + +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->per_host_cap > 0 || registry->lockout_threshold > 0) + 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); +} diff --git a/src/shared/daemon_limits.h b/src/shared/daemon_limits.h new file mode 100644 index 0000000..47bfb0b --- /dev/null +++ b/src/shared/daemon_limits.h @@ -0,0 +1,102 @@ +#ifndef DAEMON_LIMITS_H +#define DAEMON_LIMITS_H + +#include +#include +#include + +/* 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. + */ + +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) + +/* 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); `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, decrementing the module/per-source counters when + * the slot was actually REGISTERED. Idempotent. */ +void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot); +/* Parent SIGCHLD side: reclaim the slot owned by `pid` (no-op when not found). */ +void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid); + +/* 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. 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 diff --git a/tests/runner.c b/tests/runner.c index 9e12323..9549220 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -9,6 +9,7 @@ #include "test_credentials.h" #include "test_data.h" #include "test_daemon_conf.h" +#include "test_daemon_limits.h" #include "test_delay_updates.h" #include "test_delta.h" #include "test_file.h" @@ -85,6 +86,7 @@ int main() { RUN_TEST(test_client_cli); RUN_TEST(test_server); RUN_TEST(test_daemon_conf); + RUN_TEST(test_daemon_limits); RUN_TEST(test_motd); RUN_TEST(test_server_cli); RUN_TEST(test_fuzz_smoke); diff --git a/tests/test_daemon_limits.c b/tests/test_daemon_limits.c new file mode 100644 index 0000000..2815e0a --- /dev/null +++ b/tests/test_daemon_limits.c @@ -0,0 +1,194 @@ +#include "test_daemon_limits.h" +#include "daemon_limits.h" +#include "test_utils.h" +#include +#include +#include + +/* 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); + 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); + 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); + 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); + EXPECT_EQ_INT(daemon_limits_register(registry, slot1, 0, "10.0.0.2", 1), DAEMON_LIMIT_OK); + 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_auth_lockout(); + test_daemon_limits_fork_shared(); +} diff --git a/tests/test_daemon_limits.h b/tests/test_daemon_limits.h new file mode 100644 index 0000000..67e905a --- /dev/null +++ b/tests/test_daemon_limits.h @@ -0,0 +1,6 @@ +#ifndef TEST_DAEMON_LIMITS_H +#define TEST_DAEMON_LIMITS_H + +void test_daemon_limits(); + +#endif From 0abaa62193d5f918d8b7c499c76b87bd5a6755dd Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:24:01 +0200 Subject: [PATCH 04/16] feat(daemon): parse per-host cap and auth lockout config keys Add global keys `max connections per host` (default 0 = unlimited), `auth lockout threshold` (default 10, 0 disables) and `auth lockout duration` (default 300 s, 0 disables). Module `max connections` now accepts 0 as unlimited. Bound the number of [module] sections (DAEMON_CONF_MAX_MODULES) so the shared registry's per-module counter array stays fixed-size; absent keys keep their defaults so old configs still load. --- src/shared/daemon_conf.c | 43 ++++++++++++++++++++++++++- src/shared/daemon_conf.h | 56 +++++++++++++++++++++++++---------- tests/test_daemon_conf.c | 63 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 22 deletions(-) diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 01a759a..300e196 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -190,6 +190,26 @@ static bool store_max_connections(int* slot, const char* value, const char* modu 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. */ static bool store_auth_failure_delay(int* slot, const char* value, char* err, size_t err_size) { char* end = NULL; @@ -227,6 +247,9 @@ DaemonConf* daemon_conf_create(void) { 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; + 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; } @@ -312,8 +335,20 @@ static bool apply_global_key(DaemonConf* conf, char* key, const char* value, boo } if (key_equals(key, "max connections")) return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size); + if (key_equals(key, "max connections per host")) + 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")) 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")) return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value, "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; } 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")) return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow", 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); 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 = realloc(conf->modules, (size_t)(conf->module_count + 1) * sizeof(DaemonModule)); if (!grown) { diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h index 3b790a7..d04399e 100644 --- a/src/shared/daemon_conf.h +++ b/src/shared/daemon_conf.h @@ -52,11 +52,10 @@ 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). */ + /* `max connections = N` (optional per-module cap). 0 means unlimited. The + * per-connection child records the selected module in the shared registry + * (daemon_limits.c) once the config frame names it, so the cap is enforced + * across all forked children; the parent reclaims the slot on SIGCHLD. */ int max_connections; char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */ int hosts_allow_count; @@ -67,14 +66,25 @@ typedef struct DaemonModule { /* Global (pre-module) scalar keys. `motd file` is parsed and stored but has * no wire effect yet (MOTD display is Wave C). */ 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 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 */ + 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; char** hosts_deny; /* `hosts deny`; global host access deny patterns */ int hosts_deny_count; @@ -92,11 +102,26 @@ typedef struct DaemonConf { #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 +/* 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 * 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 +/* 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 * are rejected rather than buffered unboundedly. */ #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 * 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). */ + * max connections, max connections per host, auth failure delay, + * 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); /* Host access-control matching (pure; no I/O). `daemon_host_pattern_match` diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index e0020c9..a9113a5 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -33,6 +33,11 @@ static void test_daemon_conf_create_defaults() { 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.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_deny_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(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(conf->global.auth_failure_delay_ms, 1500); EXPECT_EQ_INT( @@ -390,6 +401,9 @@ static void test_daemon_conf_limits_and_hosts_parse() { char err[256]; EXPECT_EQ_INT(write_conf("max connections = 25\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 deny = 192.168.0.1 2001:db8::/32\n" "\n" @@ -405,6 +419,9 @@ static void test_daemon_conf_limits_and_hosts_parse() { 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.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_STR(conf->global.hosts_allow[0], "10.0.0.0/8"); 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); 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", + "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", + "max connections per host = -1\n", "max connections per host = lots\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++) { 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. */ 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 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); } + /* 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). */ EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0); conf = daemon_conf_load(path, err, sizeof(err)); @@ -509,6 +538,27 @@ 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); + body[0] = '\0'; + for (size_t i = 0; i < cap + 1; i++) { + char line[48]; + snprintf(line, sizeof(line), "[m%zu]\npath = /x\n", i); + strcat(body, line); + } + 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() { test_daemon_conf_create_defaults(); test_daemon_conf_full_parse(); @@ -525,6 +575,7 @@ void test_daemon_conf() { test_daemon_conf_dparam_override(); test_daemon_conf_auth_users_validated(); test_daemon_conf_limits_and_hosts_parse(); + test_daemon_conf_module_count_capped(); test_daemon_hosts_allowed(); test_daemon_module_name_valid(); } \ No newline at end of file From 4c17122b008aa7dae550c4235d6787cf12427820 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:24:05 +0200 Subject: [PATCH 05/16] feat(daemon): enforce per-module/per-host caps and shared auth lockout Wire the shared registry into the accept loop (parent claims a slot before fork, blocks SIGCHLD across fork+pid publication, and reclaims the dead child's slot from the SIGCHLD handler so per-module/per-source counts are released even on SIGKILL). The connection child records the selected module and normalized peer IP once the config frame names them: an over-cap module or source is refused at the config gate with an audit log, and a source that exceeded the auth-failure threshold is refused before a SCRAM challenge (the counter is shared across children and cleared on success). The existing global cap and host ACLs are untouched. --- src/server/server.c | 114 +++++++++++++++++++++++++++++-- src/shared/transport_tcp.c | 52 +++++++++++++- src/shared/transport_tcp.h | 13 ++++ tests/integration/test_daemon.py | 79 ++++++++++++++++++++- 4 files changed, 248 insertions(+), 10 deletions(-) diff --git a/src/server/server.c b/src/server/server.c index 494a840..6f3862c 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -2,6 +2,7 @@ #include "charset.h" #include "credentials.h" #include "daemon_conf.h" +#include "daemon_limits.h" #include "delay_updates.h" #include "file.h" #include "identity.h" @@ -56,6 +57,12 @@ static DaemonConf* g_daemon_conf = NULL; * such a module exists. */ 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 * 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. @@ -292,6 +299,56 @@ static const DaemonModule* module_gate_lookup_module(const Config* config, const 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; + const char* peer = (gate_ctx && 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): * a daemon module refuses EVERY ownership-affecting request (--numeric-ids, * --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super) @@ -396,6 +453,20 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae ModuleGateContext* gate_ctx, const char** error) { if (module->auth_user_count == 0) 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). */ + if (g_daemon_limits && gate_ctx && gate_ctx->has_peer_ip) { + 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). */ if (g_credentials == NULL) { log_message(LOG_LEVEL_ERROR, @@ -450,10 +521,16 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae "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). */ + /* 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). */ + if (g_daemon_limits && gate_ctx->has_peer_ip) + daemon_limits_auth_record_failure(g_daemon_limits, gate_ctx->peer_ip); daemon_auth_failure_delay(); return MODULE_AUTH_TERMINATED; } + if (g_daemon_limits && gate_ctx->has_peer_ip) + daemon_limits_auth_record_success(g_daemon_limits, gate_ctx->peer_ip); 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, escaped_user ? escaped_user : "", @@ -561,6 +638,9 @@ static const char* server_module_gate(const Config* config, void* context) { 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_limits(config, module, gate_ctx); if (error) return error; error = module_gate_check_ownership(config, module, gate_ctx); @@ -880,7 +960,9 @@ static void print_server_usage(void) { 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, 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(" background when running --daemon)\n"); printf(" --password-file=FILE Credential store for modules that declare\n"); @@ -1096,11 +1178,10 @@ int main(int argc, char* argv[]) { "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); + log_message(LOG_LEVEL_INFO, + "daemon module '%s': per-module 'max connections' cap = %d (enforced " + "across all connection children)", + g_daemon_conf->modules[i].name, g_daemon_conf->modules[i].max_connections); } /* Daemon credential store (Wave B). --password-file and --early-input * feed the same store, loaded BEFORE the listener forks so every @@ -1144,6 +1225,21 @@ int main(int argc, char* argv[]) { 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 { if (!configure_authorization(opts.destination_root)) { char* escaped = output_escape(opts.destination_root, false); @@ -1167,6 +1263,8 @@ int main(int argc, char* argv[]) { } if (g_daemon_conf) 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.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) { fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); @@ -1206,6 +1304,8 @@ int main(int argc, char* argv[]) { release_authorization(); out: + daemon_limits_destroy(g_daemon_limits); + g_daemon_limits = NULL; daemon_conf_free(g_daemon_conf); g_daemon_conf = NULL; credentials_free(g_credentials); diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index 2758cbe..e73dbce 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -1,4 +1,5 @@ #include "transport_tcp.h" +#include "daemon_limits.h" #include "log.h" #include "protocol.h" #include "utils.h" @@ -18,15 +19,25 @@ 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_enable_nodelay_default(int fd, int family); static void sigchld_handler(int sig) { (void)sig; 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) g_active_connections--; + daemon_limits_reclaim_pid(g_limit_registry, (long)pid); } errno = saved_errno; } @@ -108,6 +119,7 @@ Server* server_create_ex(int port, const ServerBindOptions* bind_opts) { server->ssl_ctx = NULL; server->max_connections = 100; server->active_connections = 0; + server->limit_registry = NULL; return server; } @@ -121,6 +133,15 @@ void server_set_max_connections(Server* server, unsigned int 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) { if (server == NULL || *server == NULL) return; @@ -140,6 +161,7 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil return; } signal(SIGCHLD, sigchld_handler); + g_limit_registry = server->limit_registry; while (1) { struct sockaddr_storage client_addr; socklen_t client_len = sizeof(client_addr); @@ -159,9 +181,31 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil close(fd); 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); + 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. */ + sigset_t blocked; + sigset_t previous; + sigemptyset(&blocked); + sigaddset(&blocked, SIGCHLD); + sigprocmask(SIG_BLOCK, &blocked, &previous); pid_t pid = fork(); if (pid == 0) { + sigprocmask(SIG_SETMASK, &previous, NULL); /* Connection children must not run the parent's global cleanup(): it * frees state (credentials / daemon conf) that the child's worker * threads may still be reading and closes fd numbers the child could @@ -177,7 +221,13 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil _exit(0); } else if (pid > 0) { 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); } + sigprocmask(SIG_SETMASK, &previous, NULL); close(fd); } } diff --git a/src/shared/transport_tcp.h b/src/shared/transport_tcp.h index e37b879..c3862a6 100644 --- a/src/shared/transport_tcp.h +++ b/src/shared/transport_tcp.h @@ -7,6 +7,10 @@ #include #include +/* 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 { struct sockaddr_storage address; unsigned int address_length; @@ -14,6 +18,7 @@ typedef struct Server { void* ssl_ctx; unsigned int max_connections; volatile unsigned int active_connections; + struct DaemonLimitRegistry* limit_registry; } Server; typedef struct Client { @@ -48,6 +53,14 @@ 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); +/* 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)); void server_accept_loop(Server* server, void (*child_fn)(int, void*), void* child_ctx, const char* log_fmt); diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 98c963d..1ff1d9d 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -135,7 +135,7 @@ class DaemonManager: self._proc = 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() # When no override is given the daemon binds the config file's `port` # (the plain config-port path); with an override the --dparam path. @@ -146,7 +146,8 @@ class DaemonManager: cmd += ["--dparam", f"port={port_override}"] if 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") self._proc = subprocess.Popen( cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True) @@ -1208,3 +1209,77 @@ class TestDaemonTLSAuth: d.stop() os.unlink(client_creds) 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_is_shared_across_children(self): + """`auth lockout threshold = 1`: the first failed authentication locks the + source out for the cooldown in the SHARED registry, so a subsequent + correct-password attempt (a different forked child) is refused before a + SCRAM challenge is even sent.""" + 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) + before = _tree_file_count(AUTH_MODULE) + log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 + # First attempt: wrong password -> records failure #1 -> locks. + wrong = _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS) + assert wrong.returncode != 0 + # Second attempt: CORRECT password from the same source must still be + # refused by the shared lockout. + right = _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS) + assert right.returncode != 0, "the shared auth lockout must refuse after threshold" + assert _tree_file_count(AUTH_MODULE) == before, "a locked-out source wrote data" + 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" 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() From e1f8f75e7c794dc5ea5249755fcc519f4b7993e5 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:24:09 +0200 Subject: [PATCH 06/16] docs: document daemon per-module/per-host caps and shared auth lockout --- CHANGELOG.md | 12 ++++++++++++ README.md | 17 +++++++++++++---- RSYNC_COMPAT.md | 6 +++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66132a2..a641f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to FastSync are documented here. Versions match `PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must 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`). + ## [2.20.0] - 2026-09-13 ### Security diff --git a/README.md b/README.md index 9820885..58d0d10 100644 --- a/README.md +++ b/README.md @@ -506,18 +506,27 @@ defaults to the current directory. | 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 +- `max connections = N` — global cap on concurrent connections, default 100. The 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 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. +- `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 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`. +A `[module]` may also set `max connections` (0 = unlimited; enforced per module +across all connection children) 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 diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index ea171bc..f0d4ba2 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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 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` | | `--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,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. -- **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. -- **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`), decrementing the per-module and per-source counts. 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). 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. - **`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. From 87f6cb02431d6682bc9592a90d458eee58625594 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:28:26 +0200 Subject: [PATCH 07/16] refactor(config): single X-macro table for serialized fields Every Config field that crosses the wire was declared in up to six places (struct member, config_set_defaults, send_*, receive_*, and the two CLI option tables) and could drift silently. Add CONFIG_WIRE_FIELDS in config.h: one ordered per-segment table where each serialized field is declared once with its C type, default and wire codec (KIND). config.h now expands the table to declare the struct members; config_set_defaults() expands it to assign the defaults; and config_send_wire_block()/config_receive() expand the per-segment lists to emit/consume the frame. The per-segment function names, call order and segment boundaries are preserved exactly. Fields with genuinely custom logic keep dedicated helpers but are still declared once in the table: the protocol-version handshake (HEADER), daemon SCRAM auth (STR_REDACTED_AUTH), the daemon module name (STR_MODULE), the repeated count+array blocks (BLOCK_SKIP_SUFFIXES/BLOCK_BASIS/BLOCK_IDMAP), --copy-as presence/ids (COPY_AS_*), and the derived --delta / use_xattrs bits (DERIVED_DELTA, BOOL_XATTR_DERIVE). The version field remains a special header (validated before any other field is parsed) and is sent by config_send_wire_block() explicitly. No public field is renamed and PROTOCOL_VERSION stays "2.20.0". Because the struct declaration order is no longer the wire order, the wire order is now enforced solely by the table and by a byte-exact golden test (follow-up commit). Add config_send_wire_block() so that test can hash the frame body without the STATUS_OK handshake. --- src/shared/config.c | 966 +++++++++++++++++--------------------------- src/shared/config.h | 635 +++++++++++++++++------------ 2 files changed, 735 insertions(+), 866 deletions(-) diff --git a/src/shared/config.c b/src/shared/config.c index 64adf10..24ccc1f 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -18,33 +18,16 @@ #include static void config_set_defaults(Config* config) { - config->version = str_dup(PROTOCOL_VERSION); - config->send_directory = NULL; - config->receive_root_directory = NULL; - config->save_to_disk = false; - config->use_multithreading = false; config->scanner_threads = 0; - config->use_chunk_serialization = false; - config->use_compression = false; - config->use_metadata = false; - config->use_executability = false; config->metadata_explicitly_disabled = false; config->show_progress = false; config->dry_run = false; - config->remove_source_files = false; - config->use_delete = false; - config->compression_level = 5; config->compression_threads = 0; - config->use_sendfile = false; - config->chunk_size = DEFAULT_CHUNK_SIZE; config->ssh_port = 22; config->transport = TRANSPORT_TCP; config->ssh_destination = NULL; - config->module = NULL; - config->auth_user = NULL; config->auth_password = NULL; config->password_file = NULL; - config->iconv_spec = NULL; config->fastsync_server_path = NULL; config->exclude_patterns = NULL; config->exclude_count = 0; @@ -52,16 +35,7 @@ static void config_set_defaults(Config* config) { config->include_count = 0; config->max_size = 0; config->min_size = 0; - config->max_alloc = DEFAULT_MAX_ALLOC; - config->use_incremental = false; - config->ignore_times = false; - config->size_only = false; - config->use_delta = false; config->whole_file = false; - config->fuzzy = false; - config->modify_window = 0; - config->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT; - config->delta_max_file_size = DELTA_MAX_FILE_SIZE; config->use_tls = false; config->tls_cert = NULL; config->tls_key = NULL; @@ -75,27 +49,10 @@ static void config_set_defaults(Config* config) { config->timeout = 0; config->contimeout = 10; config->quiet = false; - config->backup = false; - config->backup_dir = NULL; config->stats = false; config->max_depth = 0; config->log_file = NULL; - config->follow_symlinks = false; - config->partial = false; - config->copy_links = false; - config->safe_links = false; - config->copy_unsafe_links = false; config->copy_dirlinks = false; - config->munge_links = false; - config->keep_dirlinks = false; - config->preserve_hard_links = false; - config->preserve_acls = false; - config->preserve_xattrs = false; - config->preserve_devices = false; - config->preserve_sparse = false; - config->preserve_specials = false; - config->copy_devices = false; - config->write_devices = false; config->itemize_changes = false; config->out_format = NULL; config->log_file_format = NULL; @@ -103,49 +60,23 @@ static void config_set_defaults(Config* config) { config->debug_level = 0; config->list_only = false; config->human_readable = false; - config->eight_bit_output = false; - config->existing = false; - config->ignore_existing = false; - config->update = false; - config->inplace = false; - config->delay_updates = false; - config->use_fsync = false; - config->append = false; - config->append_verify = false; - config->preallocate = false; - config->delete_excluded = false; - config->delete_after = false; - config->max_delete = -1; config->ignore_errors = false; - config->force_delete = false; config->ignore_missing_args = false; - config->delete_missing_args = false; config->filters = NULL; config->files_from = NULL; config->files_from_set = NULL; config->from0 = false; config->cvs_exclude = false; config->per_dir_filter = false; - config->prune_empty_dirs = false; config->one_file_system = false; - config->relative = false; config->no_implied_dirs = false; config->dirs = false; - config->mkpath = false; config->rsh_command = NULL; config->blocking_io = false; config->outbuf = OUTBUF_BLOCK; config->old_args = false; - config->temp_dir = NULL; config->remote_options = NULL; config->remote_option_count = 0; - config->basis_dirs = NULL; - config->basis_count = 0; - config->partial_dir = NULL; - config->suffix = NULL; - config->delete_before = false; - config->delete_during = false; - config->delete_delay = false; config->address = NULL; config->ipv6 = false; config->ipv4 = false; @@ -153,35 +84,9 @@ static void config_set_defaults(Config* config) { config->sockopt_count = 0; config->daemon = false; config->no_motd = false; - config->checksum = false; - config->checksum_algo = CHECKSUM_ALGO_XXH64; - config->checksum_seed = 0; - config->compress_choice = NULL; - config->chmod_spec = NULL; - config->skip_compress_suffixes = NULL; - config->skip_compress_count = 0; - config->skip_compress_set = false; - config->numeric_ids = false; - config->chown_uid_set = false; - config->chown_uid = 0; - config->chown_gid_set = false; - config->chown_gid = 0; - config->usermap = NULL; - config->usermap_count = 0; - config->groupmap = NULL; - config->groupmap_count = 0; - config->super_mode = SUPER_MODE_AUTO; config->delay_context = NULL; - config->preserve_atimes = false; - config->preserve_crtimes = false; - config->omit_dir_times = false; - config->omit_link_times = false; config->open_noatime = false; config->use_xattrs = false; - config->fake_super = false; - config->copy_as_set = false; - config->copy_as_uid = 0; - config->copy_as_gid = 0; config->trust_sender = false; config->stop_after_mins = 0; config->stop_at = 0; @@ -189,6 +94,12 @@ static void config_set_defaults(Config* config) { config->write_batch = NULL; config->only_write_batch = NULL; config->read_batch = NULL; + + /* Serialized fields: defaults come from the CONFIG_WIRE_FIELDS table so the + * member declaration, default and wire codec can never drift apart. */ +#define CONFIG_DEFAULT_FIELD(name, ctype, def, kind) config->name = def; + CONFIG_WIRE_FIELDS(CONFIG_DEFAULT_FIELD) +#undef CONFIG_DEFAULT_FIELD } static bool valid_wire_bool(int value) { @@ -812,408 +723,52 @@ void config_delete(Config* config) { free(config); } -/* Each helper is deliberately ordered to match the wire format. Keep the - * helper call order in config_send and config_receive unchanged when adding - * fields. */ -static bool send_core_fields(int fd, const Config* c) { - if (!send_str(fd, c->version) || !send_int(fd, c->eight_bit_output)) +/* --------------------------------------------------------------------------- + * Wire codec helpers. + * + * The CONFIG_WIRE_*_FIELDS tables in config.h drive the send/receive + * sequences below. Each field's KIND names a CONFIG_SEND_ / + * CONFIG_RECV_ macro (defined after the helpers) that expands to the + * exact primitive call the previous hand-written code used, so the byte + * stream is unchanged. Fields whose per-field logic is not a plain scalar + * (bounded enums, redacted auth, repeated count+array blocks) delegate to a + * dedicated helper here. + * ------------------------------------------------------------------------- */ + +/* --max-alloc: raw 64-bit value, clamped server-side and installed as the + * session allocation ceiling. A zero value is rejected. */ +static bool config_receive_max_alloc(int fd, unsigned long long* value) { + if (!receive_n_data(fd, value, sizeof(*value)) || *value == 0) return false; - protocol_set_8_bit_output(c->eight_bit_output); - if (!send_n_data(fd, &c->max_alloc, sizeof(c->max_alloc))) - return false; - return send_str(fd, c->send_directory) && send_str(fd, c->receive_root_directory) && - send_int(fd, c->save_to_disk) && send_int(fd, c->use_multithreading) && - send_int(fd, c->use_chunk_serialization) && send_int(fd, c->use_compression) && - send_int(fd, c->use_metadata) && send_int(fd, c->use_executability) && - send_int(fd, c->compression_level) && - send_n_data(fd, &c->chunk_size, sizeof(c->chunk_size)) && send_int(fd, c->use_sendfile); -} - -static bool send_delta_fields(int fd, const Config* c) { - return send_int(fd, c->use_delete) && send_int(fd, c->use_incremental) && - send_int(fd, c->size_only) && send_int(fd, c->ignore_times) && - send_int(fd, c->use_delta && !c->whole_file) && - send_n_data(fd, &c->delta_block_size, sizeof(c->delta_block_size)) && - send_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long)); -} - -static bool send_file_options(int fd, const Config* c) { - /* Device/special preservation flags cross the wire so the receiver knows a - * special/device entry must be recreated. Trailing fields; protocol 2.13.0. */ - return send_int(fd, c->backup) && send_str(fd, c->backup_dir ? c->backup_dir : "") && - send_int(fd, c->remove_source_files) && send_int(fd, c->follow_symlinks) && - send_int(fd, c->copy_links) && send_int(fd, c->safe_links) && - send_int(fd, c->copy_unsafe_links) && send_int(fd, c->preserve_hard_links) && - send_int(fd, c->preserve_acls) && send_int(fd, c->preserve_xattrs) && - send_int(fd, c->preserve_devices) && send_int(fd, c->preserve_sparse) && - send_int(fd, c->preserve_specials) && send_int(fd, c->copy_devices) && - send_int(fd, c->write_devices); -} - -static bool send_selection_options(int fd, const Config* c) { - return send_int(fd, c->ignore_existing) && send_int(fd, c->existing) && send_int(fd, c->update) && - send_int(fd, c->inplace) && send_int(fd, c->delay_updates) && send_int(fd, c->append) && - send_int(fd, c->use_fsync) && send_int(fd, c->append_verify) && - send_int(fd, c->delete_excluded) && send_int(fd, c->force_delete) && - send_int(fd, c->delete_missing_args) && send_int(fd, c->delete_after) && - send_int(fd, c->preallocate) && send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && - send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs) && - send_int(fd, c->mkpath) && send_int(fd, c->delete_during) && send_int(fd, c->delete_delay); -} - -static bool send_skip_compress_options(int fd, const Config* c) { - if (!send_int(fd, c->skip_compress_set) || !send_int(fd, c->skip_compress_count)) - return false; - for (int i = 0; i < c->skip_compress_count; i++) { - if (!send_str(fd, c->skip_compress_suffixes[i])) - return false; - } + if (*value > MAX_SERVER_ALLOC) + *value = MAX_SERVER_ALLOC; + protocol_session_set_max_alloc(NULL, *value); return true; } -static bool send_resume_options(int fd, const Config* c) { - return send_str(fd, c->temp_dir ? c->temp_dir : "") && send_int(fd, c->partial) && - send_str(fd, c->partial_dir ? c->partial_dir : "") && - send_str(fd, c->suffix ? c->suffix : "") && send_int(fd, c->delete_before) && - send_int(fd, c->checksum) && send_int(fd, c->modify_window) && - send_str(fd, c->compress_choice ? c->compress_choice : "") && - send_str(fd, c->chmod_spec ? c->chmod_spec : "") && send_skip_compress_options(fd, c); -} - -static bool send_basis_options(int fd, const Config* c) { - if (!send_int(fd, c->basis_count)) +/* Optional string: the sender serializes an unset (NULL) string as "", so the + * receiver canonicalizes the empty wire value back to NULL to preserve + * NULL-vs-empty semantics. */ +static bool config_receive_optional_str(int fd, ConfigStringBudget* budget, char** out) { + char* value = config_receive_str(fd, budget); + if (!value) return false; - for (int i = 0; i < c->basis_count; i++) { - if (!send_int(fd, (int)c->basis_dirs[i].type) || - !send_str(fd, c->basis_dirs[i].path ? c->basis_dirs[i].path : "")) - return false; + if (*value == '\0') { + free(value); + *out = NULL; + return true; } + *out = value; return true; } -/* -y/--fuzzy (receiver-side similar-file basis selection). Trailing field on - * the config frame; protocol 2.9.0. */ -static bool send_fuzzy_option(int fd, const Config* c) { - return send_int(fd, c->fuzzy); -} - -/* --checksum-choice/--cc + --checksum-seed. The algorithm id and seed travel - * with the config so the receiver hashes the on-disk old file with the same - * parameters the sender used for its digest (see checksum.h). Trailing fields - * on the config frame; protocol 2.10.0. */ -static bool send_checksum_options(int fd, const Config* c) { - return send_int(fd, c->checksum_algo) && - send_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed)); -} - -static bool receive_core_fields(int fd, Config* c, ConfigStringBudget* budget) { - int value; - if (!receive_wire_bool(fd, &c->eight_bit_output)) - return false; - protocol_set_8_bit_output(c->eight_bit_output); - if (!receive_n_data(fd, &c->max_alloc, sizeof(c->max_alloc)) || c->max_alloc == 0) - return false; - if (c->max_alloc > MAX_SERVER_ALLOC) - c->max_alloc = MAX_SERVER_ALLOC; - protocol_session_set_max_alloc(NULL, c->max_alloc); - c->send_directory = config_receive_str(fd, budget); - c->receive_root_directory = config_receive_str(fd, budget); - if (!c->send_directory || !c->receive_root_directory) - return false; - if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) || - !receive_wire_bool(fd, &c->use_chunk_serialization) || - !receive_wire_bool(fd, &c->use_compression) || !receive_wire_bool(fd, &c->use_metadata) || - !receive_wire_bool(fd, &c->use_executability)) - return false; - if (!receive_int(fd, &value)) - return false; - c->compression_level = value; - if (!receive_n_data(fd, &c->chunk_size, sizeof(c->chunk_size))) - return false; - if (!receive_wire_bool(fd, &c->use_sendfile)) - return false; - return true; -} - -static bool receive_delta_fields(int fd, Config* c) { - if (!receive_wire_bool(fd, &c->use_delete)) - return false; - if (!receive_wire_bool(fd, &c->use_incremental)) - return false; - if (!receive_wire_bool(fd, &c->size_only)) - return false; - if (!receive_wire_bool(fd, &c->ignore_times)) - return false; - if (!receive_wire_bool(fd, &c->use_delta)) - return false; - return receive_n_data(fd, &c->delta_block_size, sizeof(c->delta_block_size)) && - receive_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long)); -} - -static bool receive_file_options(int fd, Config* c, ConfigStringBudget* budget) { - if (!receive_wire_bool(fd, &c->backup)) - return false; - char* backup_dir = config_receive_str(fd, budget); - if (!backup_dir) - return false; - if (*backup_dir != '\0') { - c->backup_dir = backup_dir; - } else { - /* The sender serializes an unset (NULL) string as "", so canonicalize the - empty wire value back to NULL to preserve NULL-vs-empty semantics. */ - free(backup_dir); - } - if (!receive_wire_bool(fd, &c->remove_source_files)) - return false; - bool* flags[] = {&c->follow_symlinks, &c->copy_links, &c->safe_links, - &c->copy_unsafe_links, &c->preserve_hard_links, &c->preserve_acls, - &c->preserve_xattrs, &c->preserve_devices, &c->preserve_sparse, - &c->preserve_specials, &c->copy_devices, &c->write_devices}; - for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { - if (!receive_wire_bool(fd, flags[i])) - return false; - } - return true; -} - -static bool receive_selection_options(int fd, Config* c) { - bool* flags[] = {&c->ignore_existing, - &c->existing, - &c->update, - &c->inplace, - &c->delay_updates, - &c->append, - &c->use_fsync, - &c->append_verify, - &c->delete_excluded, - &c->force_delete, - &c->delete_missing_args, - &c->delete_after, - &c->preallocate}; - for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { - if (!receive_wire_bool(fd, flags[i])) - return false; - } - if (!receive_n_data(fd, &c->max_delete, sizeof(c->max_delete))) - return false; - if (!receive_wire_bool(fd, &c->relative)) - return false; - if (!receive_wire_bool(fd, &c->prune_empty_dirs)) - return false; - if (!receive_wire_bool(fd, &c->mkpath)) - return false; - if (!receive_wire_bool(fd, &c->delete_during)) - return false; - return receive_wire_bool(fd, &c->delete_delay); -} - -static bool receive_resume_options(int fd, Config* c, ConfigStringBudget* budget) { - char* temp_dir = config_receive_str(fd, budget); - if (!temp_dir) - return false; - if (*temp_dir != '\0') { - c->temp_dir = temp_dir; - } else { - free(temp_dir); - } - if (!receive_wire_bool(fd, &c->partial)) - return false; - /* These options have NULL client defaults, so the sender transmits an empty - string for "unset". Canonicalize the empty wire value back to NULL so - receivers observe exactly what the client configured (plain --backup, for - example, must not look like --backup-dir ""). */ - char* partial_dir = config_receive_str(fd, budget); - if (!partial_dir) - return false; - if (*partial_dir != '\0') { - c->partial_dir = partial_dir; - } else { - free(partial_dir); - } - char* suffix = config_receive_str(fd, budget); - if (!suffix) - return false; - if (*suffix != '\0') { - c->suffix = suffix; - } else { - free(suffix); - } - if (!receive_wire_bool(fd, &c->delete_before)) - return false; - if (!receive_wire_bool(fd, &c->checksum)) - return false; - if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window))) - return false; - c->compress_choice = config_receive_str(fd, budget); - if (!c->compress_choice) - return false; - c->chmod_spec = config_receive_str(fd, budget); - if (!c->chmod_spec || !receive_wire_bool(fd, &c->skip_compress_set) || - !receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 || - c->skip_compress_count > MAX_SKIP_COMPRESS_SUFFIXES) - return false; - if (c->skip_compress_count > 0) { - c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); - if (!c->skip_compress_suffixes) - return false; - for (int i = 0; i < c->skip_compress_count; i++) { - c->skip_compress_suffixes[i] = config_receive_str(fd, budget); - if (!c->skip_compress_suffixes[i]) - return false; - } - } - return true; -} - -static bool receive_basis_options(int fd, Config* c, ConfigStringBudget* budget) { - int count; - if (!receive_int(fd, &count)) - return false; - if (count < 0 || count > MAX_BASIS_DIRS) - return false; - for (int i = 0; i < count; i++) { - int type; - if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) - return false; - char* path = config_receive_str(fd, budget); - if (!path) - return false; - /* config_basis_append validates and canonicalizes the path; a rejected - path (absolute / traversal / empty) drops the whole connection. */ - bool ok = config_basis_append(c, (BasisDestType)type, path) == 0; - free(path); - if (!ok) - return false; - } - return true; -} - -static bool receive_fuzzy_option(int fd, Config* c) { - return receive_wire_bool(fd, &c->fuzzy); -} - -static bool receive_checksum_options(int fd, Config* c) { - int algo; - if (!receive_int(fd, &algo) || !checksum_algo_valid(algo)) - return false; - c->checksum_algo = algo; - return receive_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed)); -} - -/* --numeric-ids / --usermap / --groupmap / --chown (identity mapping). The - * receiver needs these to apply the ownership the client requested, so they - * cross the config frame. Trailing fields; protocol 2.11.0. */ -static bool send_identity_map(int fd, const IdentityMap* map, int count) { - if (!send_int(fd, count)) - return false; - for (int i = 0; i < count; i++) { - if (!send_int(fd, map[i].from) || !send_int(fd, map[i].to)) - return false; - } - return true; -} - -static bool send_identity_options(int fd, const Config* c) { - return send_int(fd, c->numeric_ids) && send_int(fd, c->chown_uid_set) && - send_int(fd, c->chown_uid) && send_int(fd, c->chown_gid_set) && - send_int(fd, c->chown_gid) && send_identity_map(fd, c->usermap, c->usermap_count) && - send_identity_map(fd, c->groupmap, c->groupmap_count); -} - -static bool receive_identity_map(int fd, int* pcount, IdentityMap** pmap) { - int count; - if (!receive_int(fd, &count) || count < 0 || count > MAX_IDENTITY_MAP) - return false; - if (count > 0) { - IdentityMap* map = calloc((size_t)count, sizeof(IdentityMap)); - if (!map) - return false; - for (int i = 0; i < count; i++) { - if (!receive_int(fd, &map[i].from) || !receive_int(fd, &map[i].to)) { - free(map); - return false; - } - } - *pmap = map; - } - *pcount = count; - return true; -} - -static bool receive_identity_options(int fd, Config* c) { - int numeric_ids; - if (!receive_int(fd, &numeric_ids) || !valid_wire_bool(numeric_ids)) - return false; - c->numeric_ids = numeric_ids != 0; - if (!receive_wire_bool(fd, &c->chown_uid_set) || !receive_int(fd, &c->chown_uid) || - !receive_wire_bool(fd, &c->chown_gid_set) || !receive_int(fd, &c->chown_gid)) - return false; - if (c->chown_uid < IDENTITY_MATCH_ANY || c->chown_gid < IDENTITY_MATCH_ANY) - return false; - return receive_identity_map(fd, &c->usermap_count, &c->usermap) && - receive_identity_map(fd, &c->groupmap_count, &c->groupmap); -} - -/* -U/--atimes, -N/--crtimes (affect both sender capture and receiver apply) - * and -O/--omit-dir-times, -J/--omit-link-times (receiver-side prefs) all cross - * the wire so the receiver knows what to apply / suppress. --open-noatime is - * client-only (it only governs the sender's source reads) and is never - * serialized. Trailing fields; protocol 2.12.0. */ -static bool send_metadata_times_options(int fd, const Config* c) { - return send_int(fd, c->preserve_atimes) && send_int(fd, c->preserve_crtimes) && - send_int(fd, c->omit_dir_times) && send_int(fd, c->omit_link_times); -} - -static bool receive_metadata_times_options(int fd, Config* c) { - return receive_wire_bool(fd, &c->preserve_atimes) && - receive_wire_bool(fd, &c->preserve_crtimes) && receive_wire_bool(fd, &c->omit_dir_times) && - receive_wire_bool(fd, &c->omit_link_times); -} - -/* Phase 4 symlink-trust: --munge-links and -K/--keep-dirlinks. 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. Trailing fields; protocol 2.13.0. */ -static bool send_symlink_trust_options(int fd, const Config* c) { - return send_int(fd, c->munge_links) && send_int(fd, c->keep_dirlinks); -} - -static bool receive_symlink_trust_options(int fd, Config* c) { - return receive_wire_bool(fd, &c->munge_links) && receive_wire_bool(fd, &c->keep_dirlinks); -} - -/* -X/--xattrs, -A/--acls, --fake-super (Phase-4). The receiver learns - * preserve_xattrs/preserve_acls from the earlier file-options block and - * recomputes the derived use_xattrs there; only --fake-super (receiver-side - * behavior) needs an extra wire bit. Trailing field; protocol 2.13.0. */ -static bool send_phase4_xattr_options(int fd, const Config* c) { - return send_int(fd, c->fake_super); -} - -static bool receive_phase4_xattr_options(int fd, Config* c) { - if (!receive_wire_bool(fd, &c->fake_super)) - return false; - c->use_xattrs = c->preserve_acls || c->preserve_xattrs; - return true; -} - -/* Daemon module selection (Wave A, protocol 2.15.0). Trailing string on the - * config frame, sent after the Phase-4 xattr block and before the ack. The - * client composes it from a host::module/path destination; an unset module is - * serialized as "" and canonicalized back to NULL on receive so the two never - * look different to a peer. */ -static bool send_daemon_module(int fd, const Config* c) { - return send_str(fd, c->module ? c->module : ""); -} - -static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) { +/* Daemon module name (Wave A, protocol 2.15.0): an unset module is "" (-> NULL + * on receive). A hostile over-long/invalid name is rejected with an explicit + * STATUS_ERROR rather than logged and accepted. */ +static bool config_receive_module(int fd, Config* c, ConfigStringBudget* budget) { char* module = config_receive_str(fd, budget); if (!module) return false; - /* Guard against a hostile client flooding the log with an over-long module - * name: only an empty string (module-less) or a valid module name - * (bounded by DAEMON_MAX_MODULE_NAME) is accepted. This is an input - * guard, not a wire-format change. */ if (*module != '\0' && !daemon_module_name_valid(module)) { log_message(LOG_LEVEL_WARNING, "Daemon client sent an invalid or over-long module name"); free(module); @@ -1228,27 +783,15 @@ static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) return true; } -/* Daemon password credentials (A7 remediation, protocol 2.19.0). A single - * presence int is followed, when set, by ONLY the username; the password is - * never serialized. The daemon answers an auth-required module with the SCRAM - * challenge (see the auth exchange below). */ -static bool send_daemon_auth(int fd, const Config* c) { - bool present = c->auth_user != NULL && c->auth_user[0] != '\0'; - if (!send_int(fd, present ? 1 : 0)) - return false; - if (!present) - return true; - /* Redacted send: the username must never reach a --verbose debug log. */ - return send_str_redacted(fd, c->auth_user); -} - -static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) { +/* Daemon auth username (A7 remediation, protocol 2.19.0): a presence int is + * followed, when set, by ONLY the redacted username; the password is never + * serialized. */ +static bool config_receive_auth_user(int fd, Config* c, ConfigStringBudget* budget) { int present; if (!receive_int(fd, &present) || !valid_wire_bool(present)) return false; if (!present) return true; - /* Redacted receive: never log the incoming username body. */ char* user = config_receive_str_redacted(fd, budget); if (!user) return false; @@ -1261,6 +804,296 @@ static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) { return true; } +static bool config_send_auth_user(int fd, const Config* c) { + bool present = c->auth_user != NULL && c->auth_user[0] != '\0'; + if (!send_int(fd, present ? 1 : 0)) + return false; + if (!present) + return true; + /* Redacted send: the username must never reach a --verbose debug log. */ + return send_str_redacted(fd, c->auth_user); +} + +static bool config_receive_checksum_algo(int fd, int* value) { + int algo; + if (!receive_int(fd, &algo) || !checksum_algo_valid(algo)) + return false; + *value = algo; + return true; +} + +static bool config_receive_super_mode(int fd, SuperMode* value) { + int mode; + if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) + return false; + *value = (SuperMode)mode; + return true; +} + +/* chown override ids: IDENTITY_MATCH_ANY (-1) is the lowest legal value. */ +static bool config_receive_identity_id(int fd, int32_t* value) { + int v; + if (!receive_int(fd, &v) || v < IDENTITY_MATCH_ANY) + return false; + *value = v; + return true; +} + +static bool config_receive_skip_count(int fd, int* value) { + if (!receive_int(fd, value) || *value < 0 || *value > MAX_SKIP_COMPRESS_SUFFIXES) + return false; + return true; +} + +static bool config_receive_basis_count(int fd, int* value) { + if (!receive_int(fd, value) || *value < 0 || *value > MAX_BASIS_DIRS) + return false; + return true; +} + +static bool config_receive_idmap_count(int fd, int* value) { + if (!receive_int(fd, value) || *value < 0 || *value > MAX_IDENTITY_MAP) + return false; + return true; +} + +static bool config_receive_copy_as_presence(int fd, bool* value) { + int present; + if (!receive_int(fd, &present) || !valid_wire_bool(present)) + return false; + *value = present != 0; + return true; +} + +/* --copy-as ids are forced onto the ownership path, so a hostile peer must not + * smuggle a negative sentinel. */ +static bool config_receive_copy_as_id(int fd, int32_t* value) { + int v; + if (!receive_int(fd, &v) || v < 0) + return false; + *value = v; + return true; +} + +static bool send_skip_compress_suffixes(int fd, const Config* c) { + for (int i = 0; i < c->skip_compress_count; i++) { + if (!send_str(fd, c->skip_compress_suffixes[i])) + return false; + } + return true; +} + +static bool receive_skip_compress_suffixes(int fd, Config* c, ConfigStringBudget* budget) { + if (c->skip_compress_count <= 0) + return true; + c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*)); + if (!c->skip_compress_suffixes) + return false; + for (int i = 0; i < c->skip_compress_count; i++) { + c->skip_compress_suffixes[i] = config_receive_str(fd, budget); + if (!c->skip_compress_suffixes[i]) + return false; + } + return true; +} + +static bool send_basis_entries(int fd, const Config* c) { + for (int i = 0; i < c->basis_count; i++) { + if (!send_int(fd, (int)c->basis_dirs[i].type) || + !send_str(fd, c->basis_dirs[i].path ? c->basis_dirs[i].path : "")) + return false; + } + return true; +} + +static bool receive_basis_entries(int fd, Config* c, ConfigStringBudget* budget) { + /* The count was read by the preceding INT_BASISCOUNT entry; config_basis_append + * rebuilds basis_count as it validates and canonicalizes each path. */ + int count = c->basis_count; + c->basis_count = 0; + for (int i = 0; i < count; i++) { + int type; + if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK) + return false; + char* path = config_receive_str(fd, budget); + if (!path) + return false; + bool ok = config_basis_append(c, (BasisDestType)type, path) == 0; + free(path); + if (!ok) + return false; + } + return true; +} + +static bool send_identity_entries(int fd, const IdentityMap* map, int count) { + for (int i = 0; i < count; i++) { + if (!send_int(fd, map[i].from) || !send_int(fd, map[i].to)) + return false; + } + return true; +} + +static bool receive_identity_entries(int fd, ConfigStringBudget* budget, int count, + IdentityMap** out) { + (void)budget; + if (count <= 0) + return true; + IdentityMap* map = calloc((size_t)count, sizeof(IdentityMap)); + if (!map) + return false; + for (int i = 0; i < count; i++) { + if (!receive_int(fd, &map[i].from) || !receive_int(fd, &map[i].to)) { + free(map); + return false; + } + } + *out = map; + return true; +} + +/* --------------------------------------------------------------------------- + * KIND dispatch. A table entry X(member, ctype, def, KIND) expands to + * CONFIG_SEND_(member) in a sender and CONFIG_RECV_(member) in a + * receiver. Send macros are bool expressions; receive macros are bool + * expressions too (strings allocate through `budget`). + * ------------------------------------------------------------------------- */ +#define CONFIG_SEND_BOOL(name) send_int(fd, c->name) +#define CONFIG_RECV_BOOL(name) receive_wire_bool(fd, &c->name) + +#define CONFIG_SEND_INT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT(name) receive_int(fd, &c->name) + +#define CONFIG_SEND_RAW(name) send_n_data(fd, &c->name, sizeof(c->name)) +#define CONFIG_RECV_RAW(name) receive_n_data(fd, &c->name, sizeof(c->name)) + +#define CONFIG_SEND_BOOL_8BIT(name) \ + (send_int(fd, c->name) && (protocol_set_8_bit_output(c->name), true)) +#define CONFIG_RECV_BOOL_8BIT(name) \ + (receive_wire_bool(fd, &c->name) && (protocol_set_8_bit_output(c->name), true)) + +#define CONFIG_SEND_RAW_MAXALLOC(name) send_n_data(fd, &c->name, sizeof(c->name)) +#define CONFIG_RECV_RAW_MAXALLOC(name) config_receive_max_alloc(fd, &c->name) + +/* --delta is sent as (use_delta && !whole_file); whole_file never crosses the + * wire, so the receiver observes the effective bit. */ +#define CONFIG_SEND_DERIVED_DELTA(name) send_int(fd, c->name && !c->whole_file) +#define CONFIG_RECV_DERIVED_DELTA(name) receive_wire_bool(fd, &c->name) + +#define CONFIG_SEND_STR(name) send_str(fd, c->name) +#define CONFIG_RECV_STR(name) ((c->name = config_receive_str(fd, budget)) != NULL) + +#define CONFIG_SEND_STR_OPT(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_OPT(name) config_receive_optional_str(fd, budget, &c->name) + +#define CONFIG_SEND_STR_KEEP(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_KEEP(name) ((c->name = config_receive_str(fd, budget)) != NULL) + +#define CONFIG_SEND_STR_MODULE(name) send_str(fd, c->name ? c->name : "") +#define CONFIG_RECV_STR_MODULE(name) config_receive_module(fd, c, budget) + +#define CONFIG_SEND_STR_REDACTED_AUTH(name) config_send_auth_user(fd, c) +#define CONFIG_RECV_STR_REDACTED_AUTH(name) config_receive_auth_user(fd, c, budget) + +#define CONFIG_SEND_INT_CHECKSUM_ALGO(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_CHECKSUM_ALGO(name) config_receive_checksum_algo(fd, &c->name) + +#define CONFIG_SEND_SUPERMODE(name) send_int(fd, (int)c->name) +#define CONFIG_RECV_SUPERMODE(name) config_receive_super_mode(fd, &c->name) + +#define CONFIG_SEND_INT_IDENTITY(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_IDENTITY(name) config_receive_identity_id(fd, &c->name) + +#define CONFIG_SEND_INT_SKIPCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_SKIPCOUNT(name) config_receive_skip_count(fd, &c->name) + +#define CONFIG_SEND_INT_BASISCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_BASISCOUNT(name) config_receive_basis_count(fd, &c->name) + +#define CONFIG_SEND_INT_IDMAPCOUNT(name) send_int(fd, c->name) +#define CONFIG_RECV_INT_IDMAPCOUNT(name) config_receive_idmap_count(fd, &c->name) + +/* use_xattrs is derived receiver-side from the xattr/acl preservation flags + * that crossed the wire in the file-options block. */ +#define CONFIG_SEND_BOOL_XATTR_DERIVE(name) send_int(fd, c->name) +#define CONFIG_RECV_BOOL_XATTR_DERIVE(name) \ + (receive_wire_bool(fd, &c->name) && \ + (c->use_xattrs = (c->preserve_acls || c->preserve_xattrs), true)) + +#define CONFIG_SEND_COPY_AS_PRESENCE(name) send_int(fd, c->name ? 1 : 0) +#define CONFIG_RECV_COPY_AS_PRESENCE(name) config_receive_copy_as_presence(fd, &c->name) + +/* The uid/gid follow the presence int only when --copy-as is set. */ +#define CONFIG_SEND_COPY_AS_ID(name) (!c->copy_as_set || send_int(fd, c->name)) +#define CONFIG_RECV_COPY_AS_ID(name) (!c->copy_as_set || config_receive_copy_as_id(fd, &c->name)) + +#define CONFIG_SEND_BLOCK_SKIP_SUFFIXES(name) send_skip_compress_suffixes(fd, c) +#define CONFIG_RECV_BLOCK_SKIP_SUFFIXES(name) receive_skip_compress_suffixes(fd, c, budget) + +#define CONFIG_SEND_BLOCK_BASIS(name) send_basis_entries(fd, c) +#define CONFIG_RECV_BLOCK_BASIS(name) receive_basis_entries(fd, c, budget) + +#define CONFIG_SEND_BLOCK_IDMAP(name) send_identity_entries(fd, c->name, c->name##_count) +#define CONFIG_RECV_BLOCK_IDMAP(name) \ + receive_identity_entries(fd, budget, c->name##_count, &c->name) + +/* One table entry, applied in sequence. XSEND/XRECV are statement macros so + * consecutive entries read as a plain sequence of assignments. */ +#define XSEND(name, ctype, def, kind) ok = ok && (CONFIG_SEND_##kind(name)); +#define XRECV(name, ctype, def, kind) ok = ok && (CONFIG_RECV_##kind(name)); + +#define CONFIG_DEFINE_SEND(fn, fields) \ + static bool fn(int fd, const Config* c) { \ + bool ok = true; \ + fields(XSEND) return ok; \ + } + +#define CONFIG_DEFINE_RECV(fn, fields) \ + static bool fn(int fd, Config* c, ConfigStringBudget* budget) { \ + (void)budget; \ + bool ok = true; \ + fields(XRECV) return ok; \ + } + +CONFIG_DEFINE_SEND(send_core_fields, CONFIG_WIRE_CORE_FIELDS) +CONFIG_DEFINE_SEND(send_delta_fields, CONFIG_WIRE_DELTA_FIELDS) +CONFIG_DEFINE_SEND(send_file_options, CONFIG_WIRE_FILE_OPTIONS_FIELDS) +CONFIG_DEFINE_SEND(send_selection_options, CONFIG_WIRE_SELECTION_FIELDS) +CONFIG_DEFINE_SEND(send_resume_options, CONFIG_WIRE_RESUME_FIELDS) +CONFIG_DEFINE_SEND(send_basis_options, CONFIG_WIRE_BASIS_FIELDS) +CONFIG_DEFINE_SEND(send_fuzzy_option, CONFIG_WIRE_FUZZY_FIELDS) +CONFIG_DEFINE_SEND(send_checksum_options, CONFIG_WIRE_CHECKSUM_FIELDS) +CONFIG_DEFINE_SEND(send_identity_options, CONFIG_WIRE_IDENTITY_FIELDS) +CONFIG_DEFINE_SEND(send_metadata_times_options, CONFIG_WIRE_METADATA_TIMES_FIELDS) +CONFIG_DEFINE_SEND(send_symlink_trust_options, CONFIG_WIRE_SYMLINK_TRUST_FIELDS) +CONFIG_DEFINE_SEND(send_phase4_xattr_options, CONFIG_WIRE_XATTR_FIELDS) +CONFIG_DEFINE_SEND(send_daemon_module, CONFIG_WIRE_MODULE_FIELDS) +CONFIG_DEFINE_SEND(send_daemon_auth, CONFIG_WIRE_DAEMON_AUTH_FIELDS) +CONFIG_DEFINE_SEND(send_iconv_spec, CONFIG_WIRE_ICONV_FIELDS) +CONFIG_DEFINE_SEND(send_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS) +CONFIG_DEFINE_SEND(send_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS) + +CONFIG_DEFINE_RECV(receive_core_fields, CONFIG_WIRE_CORE_FIELDS) +CONFIG_DEFINE_RECV(receive_delta_fields, CONFIG_WIRE_DELTA_FIELDS) +CONFIG_DEFINE_RECV(receive_file_options, CONFIG_WIRE_FILE_OPTIONS_FIELDS) +CONFIG_DEFINE_RECV(receive_selection_options, CONFIG_WIRE_SELECTION_FIELDS) +CONFIG_DEFINE_RECV(receive_resume_options, CONFIG_WIRE_RESUME_FIELDS) +CONFIG_DEFINE_RECV(receive_basis_options, CONFIG_WIRE_BASIS_FIELDS) +CONFIG_DEFINE_RECV(receive_fuzzy_option, CONFIG_WIRE_FUZZY_FIELDS) +CONFIG_DEFINE_RECV(receive_checksum_options, CONFIG_WIRE_CHECKSUM_FIELDS) +CONFIG_DEFINE_RECV(receive_identity_options, CONFIG_WIRE_IDENTITY_FIELDS) +CONFIG_DEFINE_RECV(receive_metadata_times_options, CONFIG_WIRE_METADATA_TIMES_FIELDS) +CONFIG_DEFINE_RECV(receive_symlink_trust_options, CONFIG_WIRE_SYMLINK_TRUST_FIELDS) +CONFIG_DEFINE_RECV(receive_phase4_xattr_options, CONFIG_WIRE_XATTR_FIELDS) +CONFIG_DEFINE_RECV(receive_daemon_module, CONFIG_WIRE_MODULE_FIELDS) +CONFIG_DEFINE_RECV(receive_daemon_auth, CONFIG_WIRE_DAEMON_AUTH_FIELDS) +CONFIG_DEFINE_RECV(receive_iconv_spec, CONFIG_WIRE_ICONV_FIELDS) +CONFIG_DEFINE_RECV(receive_privilege_options, CONFIG_WIRE_PRIVILEGE_FIELDS) +CONFIG_DEFINE_RECV(receive_copy_as_options, CONFIG_WIRE_COPY_AS_FIELDS) + +#undef XSEND +#undef XRECV + /* Client half of the SCRAM challenge/response (A7 remediation). Called by * config_send after the config frame is written and the server answered * STATUS_AUTH_CHALLENGE. The plaintext password lives only in @@ -1349,96 +1182,35 @@ static bool client_auth_exchange(int fd, const Config* c) { return ok; } -/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame, - * sent after the Wave A/B daemon-auth block and before the ack, so the - * receiver knows the wire charset before the first file name arrives. The full - * spec travels (LOCAL,REMOTE) and each end derives its own LOCAL and the wire - * (REMOTE) charset symmetrically; an unset spec is serialized as "" and - * canonicalized back to NULL on receive. */ -static bool send_iconv_spec(int fd, const Config* c) { - return send_str(fd, c->iconv_spec ? c->iconv_spec : ""); -} +/* The --iconv, --super/--no-super and --copy-as segment functions are + * generated above from CONFIG_WIRE_ICONV_FIELDS, CONFIG_WIRE_PRIVILEGE_FIELDS + * and CONFIG_WIRE_COPY_AS_FIELDS. */ -static bool receive_iconv_spec(int fd, Config* c, ConfigStringBudget* budget) { - char* spec = config_receive_str(fd, budget); - if (!spec) - return false; - if (*spec == '\0') { - free(spec); - c->iconv_spec = NULL; - return true; - } - c->iconv_spec = spec; - return true; -} - -/* --super / --no-super privilege policy (P7 Wave E, protocol 2.18.0). One - * trailing int on the config frame, sent after the --iconv spec and before the - * STATUS_OK ack, so the receiver knows whether it may attempt super-user - * activities (ownership application, char/block device-node creation) that are - * already confined below the authorized receive root. The received value is - * validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by - * validate_received_config). */ -static bool send_privilege_options(int fd, const Config* c) { - return send_int(fd, (int)c->super_mode); -} - -static bool receive_privilege_options(int fd, Config* c) { - int mode; - if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF) - return false; - c->super_mode = (SuperMode)mode; - return true; -} - -/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Trailing block on the - * config frame, sent after the --super int and before the ack: a presence int, - * then (when set) the target uid and gid as int32. The receiver forces the - * ownership of every entry it writes to these ids through the confined - * fd-relative identity path and requires privilege; both ids are validated - * `>= 0` on receive so a hostile peer cannot smuggle a negative (sentinel) - * value into the ownership path. */ -static bool send_copy_as_options(int fd, const Config* c) { - if (!send_int(fd, c->copy_as_set ? 1 : 0)) - return false; - if (!c->copy_as_set) - return true; - return send_int(fd, c->copy_as_uid) && send_int(fd, c->copy_as_gid); -} - -static bool receive_copy_as_options(int fd, Config* c) { - int present; - if (!receive_int(fd, &present) || !valid_wire_bool(present)) - return false; - if (!present) { - c->copy_as_set = false; - return true; - } - int uid, gid; - if (!receive_int(fd, &uid) || !receive_int(fd, &gid) || uid < 0 || gid < 0) - return false; - c->copy_as_set = true; - c->copy_as_uid = uid; - c->copy_as_gid = gid; - return true; +bool config_send_wire_block(int file_descriptor, const Config* config) { + protocol_session_set_max_alloc(NULL, config->max_alloc); + /* The version is the frame header: the receiver validates it before parsing + * any other field (see config_receive_with_validate), so it is not part of + * the generated segment sequence. It is still declared once, in + * CONFIG_WIRE_HEADER_FIELDS. */ + return send_str(file_descriptor, config->version) && send_core_fields(file_descriptor, config) && + send_delta_fields(file_descriptor, config) && send_file_options(file_descriptor, config) && + send_selection_options(file_descriptor, config) && + send_resume_options(file_descriptor, config) && + send_basis_options(file_descriptor, config) && + send_fuzzy_option(file_descriptor, config) && + send_checksum_options(file_descriptor, config) && + send_identity_options(file_descriptor, config) && + send_metadata_times_options(file_descriptor, config) && + send_symlink_trust_options(file_descriptor, config) && + send_phase4_xattr_options(file_descriptor, config) && + send_daemon_module(file_descriptor, config) && send_daemon_auth(file_descriptor, config) && + send_iconv_spec(file_descriptor, config) && + send_privilege_options(file_descriptor, config) && + send_copy_as_options(file_descriptor, config); } bool config_send(int file_descriptor, const Config* config) { - protocol_session_set_max_alloc(NULL, config->max_alloc); - if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || - !send_file_options(file_descriptor, config) || - !send_selection_options(file_descriptor, config) || - !send_resume_options(file_descriptor, config) || - !send_basis_options(file_descriptor, config) || !send_fuzzy_option(file_descriptor, config) || - !send_checksum_options(file_descriptor, config) || - !send_identity_options(file_descriptor, config) || - !send_metadata_times_options(file_descriptor, config) || - !send_symlink_trust_options(file_descriptor, config) || - !send_phase4_xattr_options(file_descriptor, config) || - !send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) || - !send_iconv_spec(file_descriptor, config) || - !send_privilege_options(file_descriptor, config) || - !send_copy_as_options(file_descriptor, config)) + if (!config_send_wire_block(file_descriptor, config)) return false; Status status; if (!receive_status(file_descriptor, &status)) @@ -1477,22 +1249,22 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val goto error; } if (!receive_core_fields(file_descriptor, config, &budget) || - !receive_delta_fields(file_descriptor, config) || + !receive_delta_fields(file_descriptor, config, &budget) || !receive_file_options(file_descriptor, config, &budget) || - !receive_selection_options(file_descriptor, config) || + !receive_selection_options(file_descriptor, config, &budget) || !receive_resume_options(file_descriptor, config, &budget) || !receive_basis_options(file_descriptor, config, &budget) || - !receive_fuzzy_option(file_descriptor, config) || - !receive_checksum_options(file_descriptor, config) || - !receive_identity_options(file_descriptor, config) || - !receive_metadata_times_options(file_descriptor, config) || - !receive_symlink_trust_options(file_descriptor, config) || - !receive_phase4_xattr_options(file_descriptor, config) || + !receive_fuzzy_option(file_descriptor, config, &budget) || + !receive_checksum_options(file_descriptor, config, &budget) || + !receive_identity_options(file_descriptor, config, &budget) || + !receive_metadata_times_options(file_descriptor, config, &budget) || + !receive_symlink_trust_options(file_descriptor, config, &budget) || + !receive_phase4_xattr_options(file_descriptor, config, &budget) || !receive_daemon_module(file_descriptor, config, &budget) || !receive_daemon_auth(file_descriptor, config, &budget) || !receive_iconv_spec(file_descriptor, config, &budget) || - !receive_privilege_options(file_descriptor, config) || - !receive_copy_as_options(file_descriptor, config)) + !receive_privilege_options(file_descriptor, config, &budget) || + !receive_copy_as_options(file_descriptor, config, &budget)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { diff --git a/src/shared/config.h b/src/shared/config.h index 0aba9d6..f7c5998 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -75,87 +75,203 @@ typedef struct { * privilege_super_mode_permitted() in identity.h. */ 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_/CONFIG_RECV_ + * 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). + * =========================================================================== */ +#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 { - 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 * 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 * concern and is NEVER serialized into the wire config frame. */ int scanner_threads; - bool use_chunk_serialization; - bool use_compression; - bool use_sendfile; - bool use_metadata; - bool use_executability; bool metadata_explicitly_disabled; bool show_progress; bool dry_run; - bool remove_source_files; - bool use_delete; - int compression_level; int compression_threads; - unsigned long long chunk_size; int ssh_port; TransportType transport; 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; /* Client-only path of --password-file (never crosses the wire; it is read to * populate auth_user/auth_password before connecting). */ char* password_file; 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; int exclude_count; char** include_patterns; int include_count; unsigned long long max_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; - /* -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; char* server_host; int server_port; @@ -170,18 +286,10 @@ typedef struct Config { /* --contimeout: connect()/accept timeout, transport layer only. */ int contimeout; bool quiet; - bool backup; - char* backup_dir; bool stats; int max_depth; 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 * CLIENT/sender-side only (they decide how the SENDER scans and rewrites * symlinks; the receiver never reads them), so they never cross the wire. @@ -189,31 +297,6 @@ typedef struct Config { * symlink-to-directory as a directory) and CROSSES the wire along with * --munge-links (so the receiver knows to unmunge). */ 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 bool itemize_changes; @@ -223,57 +306,17 @@ typedef struct Config { int debug_level; bool list_only; 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 * error (an unreadable directory during the scan) normally aborts the run so * no deletion happens; with --ignore-errors the scan continues and the * (partial) keep-set is still transmitted so the deletion runs. */ 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 * 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 * enters the keep-set. Implied by --delete-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 // never serialized to the wire (the receiver must not learn them). @@ -283,23 +326,14 @@ typedef struct Config { bool from0; /* -0/--from0: NUL-delimited *-from files */ bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */ 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 */ - /* -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 * listed file whose ancestor directory is not itself explicitly listed. */ bool no_implied_dirs; /* -d/--dirs: client-only. Transfer the directory entries named by the * source argument / --files-from list without recursing into contents. */ 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. * NULL means the default "ssh". Client-only launch concern: NEVER crosses * the wire (it is not meaningful to the daemon/server handshake). */ @@ -312,7 +346,6 @@ typedef struct Config { * concern: NEVER crosses the wire. */ int outbuf; bool old_args; - char* temp_dir; /* --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: * they are composed into the remote command line by ssh_build_remote_command() @@ -321,34 +354,6 @@ typedef struct Config { * do NOT cross the wire and are never parsed on the receiver process. */ char** remote_options; 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 char* address; @@ -370,119 +375,19 @@ typedef struct Config { * MOTD is shown when a daemon offers one). */ 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 // over the wire and never set on the sender side. 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 * source files with O_NOATIME so reading for transfer does not bump the * source access time. */ 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 * xattr wire block on this single flag. */ 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 * 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. @@ -501,7 +406,6 @@ typedef struct Config { * default; only relaxes validation when explicitly requested. */ bool trust_sender; - // Phase 6: --stop-after / --stop-at /* Client-only sender-side transfer stop deadlines. --stop-after=MINS stops * the transfer after a number of elapsed minutes (checked against * CLOCK_MONOTONIC so clock changes do not skew it); --stop-at=TIME stops at @@ -513,7 +417,6 @@ typedef struct Config { time_t stop_at; /* --stop-at=... absolute wall-clock deadline */ 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 * 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 @@ -525,6 +428,196 @@ typedef struct Config { char* write_batch; /* --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 */ + + /* =================================================================== + * 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; /* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0. @@ -699,6 +792,10 @@ void config_delete(Config* config); void config_burn_auth(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); bool config_is_remote_dest(const char* s); void config_parse_ssh_dest(Config* config); From 4e918a1b69caa4fb5ee9643bef423c1bc7d03b55 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:28:31 +0200 Subject: [PATCH 08/16] test(config): pin wire bytes and round-trip every field test_config_wire_golden() serializes a fully-populated Config through config_send_wire_block() and pins the exact frame to len=633 and FNV-1a hash 6163263374908258816, captured from the pre-X-macro implementation. Any field reorder, resize or codec change fails the test. test_config_wire_roundtrip_all_fields() serializes/deserializes a defaults Config and a fully-populated Config over a socketpair and compares every serialized field. The comparison is itself generated from CONFIG_WIRE_FIELDS (one CONFIG_CMP_ per table entry), so a new table entry automatically extends coverage; it cannot fall out of sync. It normalizes the receiver's NULL/"" canonicalization, the max_alloc server clamp and the derived use_delta/use_xattrs bits. --- tests/test_config.c | 304 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) diff --git a/tests/test_config.c b/tests/test_config.c index 7689cf5..41d6a30 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,5 +1,6 @@ #include "test_config.h" #include "config.h" +#include "delta.h" #include "identity.h" #include "multiprocessing.h" #include "protocol.h" @@ -2194,6 +2195,307 @@ 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); + golden_config_populate(populated); + /* Keep the populated config within the server-side validation bounds. */ + populated->delta_max_file_size = DELTA_MAX_FILE_SIZE; + populated->whole_file = false; + /* "X" is not part of FastSync's chmod grammar (see parse_clause), so use a + * spec the receiver-side validator accepts. */ + free(populated->chmod_spec); + populated->chmod_spec = str_dup("u=rw,go=r"); + 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. The values are deterministic. */ +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 = true; + c->use_chunk_serialization = false; + c->use_compression = false; + c->use_metadata = true; + c->use_executability = true; + c->compression_level = 7; + c->chunk_size = 65536; + c->use_sendfile = false; + c->use_delete = true; + c->use_incremental = true; + c->size_only = true; + c->ignore_times = true; + c->use_delta = true; + c->whole_file = false; + c->delta_block_size = 4096; + c->delta_max_file_size = 987654321ULL; + c->backup = true; + c->backup_dir = str_dup("/golden/backup"); + c->remove_source_files = true; + c->follow_symlinks = true; + c->copy_links = true; + c->safe_links = true; + c->copy_unsafe_links = true; + c->preserve_hard_links = true; + c->preserve_acls = true; + c->preserve_xattrs = true; + c->preserve_devices = true; + c->preserve_sparse = true; + c->preserve_specials = true; + c->copy_devices = true; + c->write_devices = true; + c->ignore_existing = true; + c->existing = true; + c->update = true; + c->inplace = false; + c->delay_updates = false; + c->append = false; + c->use_fsync = true; + c->append_verify = false; + c->delete_excluded = true; + c->force_delete = true; + c->delete_missing_args = true; + c->delete_after = true; + c->preallocate = true; + c->max_delete = 42; + c->relative = true; + c->prune_empty_dirs = true; + c->mkpath = true; + c->delete_during = false; + 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"); + 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 = true; + 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 = true; + c->omit_dir_times = true; + c->omit_link_times = true; + c->munge_links = true; + c->keep_dirlinks = true; + 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; +} + +/* 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) { + int p[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0) + return 0; + 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]); + unsigned long long h = 1469598103934665603ULL; + unsigned char buf[4096]; + ssize_t n; + size_t total = 0; + while ((n = read(p[1], buf, sizeof(buf))) > 0) { + for (ssize_t i = 0; i < n; i++) { + h ^= (unsigned long long)buf[i]; + h *= 1099511628211ULL; + } + total += (size_t)n; + } + close(p[1]); + int status = 0; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + return 0; + *out_len = total; + return h; +} + +/* Byte-for-byte wire compatibility guard (protocol 2.20.0). The expected hash + * was captured from the pre-X-macro implementation; 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); + /* Captured from the pre-X-macro (protocol 2.20.0) implementation. */ + EXPECT_TRUE(len == 633); + EXPECT_TRUE(h == 6163263374908258816ULL); + config_delete(c); +} + void test_config() { test_config_lifecycle(); test_config_ssh_dest(); @@ -2249,6 +2551,8 @@ void test_config() { test_config_receive_with_validate_rejects(); test_config_invariants_error_all_combinations(); test_config_receive_rejects_unified_invariants(); + test_config_wire_golden(); + test_config_wire_roundtrip_all_fields(); } test_identity_copy_as_refused(); test_identity_ownership_requested(); From c78a21de576f4b2d11d5cd2a518df412eb435330 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:38:21 +0200 Subject: [PATCH 09/16] docs(shared): clarify authorized_root accessor contracts Document on utils_get_authorized_root_path() that the returned pointer is borrowed and invalidated by the next authorized-root setter, that the fd and path are not read atomically (non-reentrant), and that the fd remains caller-owned. Add a matching single-threaded/set-before-threads note at the accessor definitions in utils.c. In server.c, drop the redundant utils_set_authorized_root(-1, NULL) after a failed utils_set_authorized_root(): the setter already fail-closes the state on allocation failure. The following close(root_fd) is unchanged. --- src/server/server.c | 2 +- src/shared/utils.c | 3 +++ src/shared/utils.h | 10 +++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/server/server.c b/src/server/server.c index 1eb1e8d..9728500 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -231,7 +231,7 @@ static bool configure_authorization(const char* root) { return false; } if (!utils_set_authorized_root(root_fd, resolved)) { - utils_set_authorized_root(-1, NULL); + /* The setter already cleared the fd/path state on allocation failure. */ close(root_fd); return false; } diff --git a/src/shared/utils.c b/src/shared/utils.c index 6ab725a..64a5581 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -36,6 +36,9 @@ void utils_set_authorized_root_fd(int fd) { (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; } diff --git a/src/shared/utils.h b/src/shared/utils.h index 7ac9059..ff83ac0 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -131,7 +131,15 @@ void utils_set_authorized_root_fd(int fd); * 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. */ + * 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 From 18d1b8424604b0b5ed981ee871df1c379a8db180 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:42:45 +0200 Subject: [PATCH 10/16] refactor(protocol): guard session release, clarify Data.owner contract Add a NULL guard to protocol_release_memory_for_session so it no-ops like the sibling session setters. Correct the Data.owner doc comment, which implied a non-zero protocol_charge always has an owner; document that owner may be NULL for uncharged/ownerless Data, that any such charge falls back to the bound session, and that a charged Data must not outlive its owning session. Note the lifetime contract on the release API too. Extend tests/test_protocol.c to cover destroying a charged Data with no session bound (the other half of the original bug) and to assert that data_create/data_create_reserve start with owner == NULL and protocol_charge == 0. --- src/shared/data.h | 15 +++++++++---- src/shared/protocol.c | 2 ++ tests/test_protocol.c | 52 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/shared/data.h b/src/shared/data.h index 8112976..9e811fc 100644 --- a/src/shared/data.h +++ b/src/shared/data.h @@ -12,9 +12,15 @@ typedef struct { size_t size; /* Non-zero only for a buffer charged to the protocol connection budget. */ size_t protocol_charge; - /* Session whose budget `protocol_charge` was reserved from. The charge must - * always be returned to this session, regardless of which session (if any) is - * bound to the destroying thread. NULL for uncharged Data. */ + /* 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; @@ -24,7 +30,8 @@ Data* data_create(void* data, size_t data_size); void data_destroy(Data* data); 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. 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 diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 4527296..c65c4f8 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -41,6 +41,8 @@ static bool protocol_reserve_memory(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); while (true) { unsigned long long remaining = (unsigned long long)charge >= allocated ? 0 : allocated - charge; diff --git a/tests/test_protocol.c b/tests/test_protocol.c index d84f293..32e4a64 100644 --- a/tests/test_protocol.c +++ b/tests/test_protocol.c @@ -413,8 +413,10 @@ static void test_protocol_accounting_release_does_not_underflow() { } /* A Data acquired on session A must return its connection-memory charge to A - even when a different session B is bound at destroy time: releasing against - the thread-local bound session would leak A's budget and drain B's. */ + 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]; @@ -431,23 +433,36 @@ static void test_receive_data_charge_follows_owning_session() { 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_a = protocol_receive_data_limited(&session_a, 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_a); + EXPECT_NOT_NULL(data_a1); + EXPECT_NOT_NULL(data_a2); EXPECT_NOT_NULL(data_b); - EXPECT_TRUE(data_a->owner == &session_a); + 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); - /* Destroy A's Data while the unrelated session B is the bound session. */ - protocol_session_bind(&session_b); - data_destroy(data_a); + /* 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); @@ -460,6 +475,24 @@ static void test_receive_data_charge_follows_owning_session() { 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() { /* 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 @@ -623,4 +656,5 @@ void test_protocol() { test_protocol_string_accounting_is_transient(); test_protocol_accounting_release_does_not_underflow(); test_receive_data_charge_follows_owning_session(); + test_data_create_starts_uncharged_and_unowned(); } From bd43448af2ef80afd38b2396351815b04ed6b727 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:50:53 +0200 Subject: [PATCH 11/16] fix(daemon): bound per-source table lifetime and recompute occupancy The per-source host table only grew: once its fixed open-addressed table filled, host_intern returned -1 and the per-host cap plus the shared auth lockout silently failed open forever. Add a bounded-lifetime eviction policy: track a per-bucket last-use time and, when no empty bucket exists, atomically repurpose the first bucket that has no active connection and either has an expired lockout or has been idle, resetting its counters. Warn (rate-limited) on the genuine fail-open path. A child SIGKILLed mid-registration could also leak a module/host count because the parent only decremented on a REGISTERED slot. Make the slot table the source of truth: after the SIGCHLD reap the parent recomputes module_active[]/host_active[] from the surviving REGISTERED slots (atomics only, async-signal-safe) so any leaked increment is erased. Also clamp module_count to DAEMON_LIMITS_MAX_MODULES and use one helper for the sizing/register host-tracking condition (a lockout threshold with duration 0 is a no-op and must not intern hosts). --- src/shared/daemon_limits.c | 183 +++++++++++++++++++++++++++++-------- src/shared/daemon_limits.h | 59 ++++++++++-- src/shared/transport_tcp.c | 48 ++++++++-- tests/test_daemon_limits.c | 78 ++++++++++++++++ 4 files changed, 315 insertions(+), 53 deletions(-) diff --git a/src/shared/daemon_limits.c b/src/shared/daemon_limits.c index 2ba7e4e..edb5819 100644 --- a/src/shared/daemon_limits.c +++ b/src/shared/daemon_limits.c @@ -1,4 +1,6 @@ #include "daemon_limits.h" +#include "daemon_conf.h" +#include "log.h" #include #include #include @@ -8,6 +10,12 @@ #include #include +/* 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, @@ -27,6 +35,7 @@ struct DaemonLimitRegistry { 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; @@ -35,7 +44,8 @@ struct DaemonLimitRegistry { _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_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) { @@ -88,7 +98,17 @@ uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok) { return hash; } -/* Find the bucket holding `peer_ip`, or -1 when it has no entry. */ +/* 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); @@ -99,39 +119,112 @@ static int host_lookup(DaemonLimitRegistry* registry, const char* peer_ip) { 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) + 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); + 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. Returns -1 - * when the table is full or the address is unparseable (callers fail open: the - * global/module caps and ACLs still apply). */ + * 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); - 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) - return (int)idx; - if (current == 0) { - 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) + /* 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) { + uint64_t expected = 0; + if (atomic_compare_exchange_strong_explicit(®istry->host_key[idx], &expected, key, + memory_order_acq_rel, memory_order_acquire)) { + atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); + return (int)idx; + } + if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) { + atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); + 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) { + 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); + atomic_store_explicit(®istry->host_last_use[evict], now, memory_order_relaxed); + 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; } @@ -143,6 +236,8 @@ DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int p 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) @@ -167,7 +262,7 @@ DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int p 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); + 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; @@ -205,6 +300,8 @@ DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int p 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); @@ -243,25 +340,12 @@ void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pi void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot) { if (!registry || slot < 0 || slot >= registry->max_slots) return; - int previous = - atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel); - if (previous == SLOT_REGISTERED) { - int module = atomic_load(®istry->slot_module[slot]); - int host = atomic_load(®istry->slot_host[slot]); - if (module >= 0 && module < registry->module_count) { - int current = atomic_load(®istry->module_active[module]); - while (current > 0 && - !atomic_compare_exchange_weak(®istry->module_active[module], ¤t, current - 1)) - ; - } - if (host >= 0 && host < registry->host_slots) { - int current = atomic_load(®istry->host_active[host]); - while (current > 0 && - !atomic_compare_exchange_weak(®istry->host_active[host], ¤t, current - 1)) - ; - } - } - atomic_store(®istry->slot_pid[slot], 0); + 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) { @@ -277,6 +361,29 @@ void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid) { } } +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) @@ -287,7 +394,7 @@ DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot return DAEMON_LIMIT_UNAVAILABLE; int host = -1; - if (registry->per_host_cap > 0 || registry->lockout_threshold > 0) + if (registry_tracks_hosts(registry)) host = host_intern(registry, peer_ip); int module_count = atomic_fetch_add(®istry->module_active[module_index], 1) + 1; diff --git a/src/shared/daemon_limits.h b/src/shared/daemon_limits.h index 47bfb0b..b6d89d2 100644 --- a/src/shared/daemon_limits.h +++ b/src/shared/daemon_limits.h @@ -34,6 +34,20 @@ * 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; @@ -51,13 +65,27 @@ typedef enum { #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); `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 + * 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); @@ -70,16 +98,33 @@ void daemon_limits_destroy(DaemonLimitRegistry* registry); 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, decrementing the module/per-source counters when - * the slot was actually REGISTERED. Idempotent. */ +/* 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: reclaim the slot owned by `pid` (no-op when not found). */ +/* 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. Returns DAEMON_LIMIT_OK and publishes the slot, or a refusal reason. */ + * 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); diff --git a/src/shared/transport_tcp.c b/src/shared/transport_tcp.c index e73dbce..9fedfb7 100644 --- a/src/shared/transport_tcp.c +++ b/src/shared/transport_tcp.c @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -39,9 +40,25 @@ static void sigchld_handler(int sig) { 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; } +/* 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 * of whether it is an IPv4 or IPv6 sockaddr. */ static unsigned short server_address_port(const struct sockaddr_storage* addr) { @@ -160,7 +177,16 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil log_perror("Could not listen on port!"); 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) { struct sockaddr_storage client_addr; @@ -197,15 +223,21 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil 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. */ + * 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); - sigprocmask(SIG_BLOCK, &blocked, &previous); + pthread_sigmask(SIG_BLOCK, &blocked, &previous); pid_t pid = fork(); if (pid == 0) { - sigprocmask(SIG_SETMASK, &previous, NULL); + pthread_sigmask(SIG_SETMASK, &previous, NULL); /* Connection children must not run the parent's global cleanup(): it * frees state (credentials / daemon conf) that the child's worker * threads may still be reading and closes fd numbers the child could @@ -213,9 +245,9 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil * terminates the child directly; SIGCHLD is reset too since a child * must never reap the parent's children. This runs before the child * spawns any thread, so it cannot race one. */ - signal(SIGINT, SIG_DFL); - signal(SIGTERM, SIG_DFL); - signal(SIGCHLD, SIG_DFL); + reset_signal_default(SIGINT); + reset_signal_default(SIGTERM); + reset_signal_default(SIGCHLD); close(server->file_descriptor); child_fn(fd, child_ctx); _exit(0); @@ -227,7 +259,7 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil /* fork() failed: release the reservation so the slot is not leaked. */ daemon_limits_reclaim_slot(server->limit_registry, slot); } - sigprocmask(SIG_SETMASK, &previous, NULL); + pthread_sigmask(SIG_SETMASK, &previous, NULL); close(fd); } } diff --git a/tests/test_daemon_limits.c b/tests/test_daemon_limits.c index 2815e0a..8f36bb0 100644 --- a/tests/test_daemon_limits.c +++ b/tests/test_daemon_limits.c @@ -2,7 +2,9 @@ #include "daemon_limits.h" #include "test_utils.h" #include +#include #include +#include #include /* The per-source hash is a pure helper: numeric addresses hash to a nonzero, @@ -72,6 +74,7 @@ static void test_daemon_limits_module_cap() { 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); @@ -94,6 +97,7 @@ static void test_daemon_limits_host_cap() { 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); @@ -115,6 +119,7 @@ static void test_daemon_limits_reclaim_pid() { 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); @@ -179,16 +184,89 @@ static void test_daemon_limits_fork_shared() { 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); } +/* 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(); } From 25909110ac5ded4083a9380f0c1739956913ea59 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:50:58 +0200 Subject: [PATCH 12/16] fix(daemon): exempt trusted loopback peers from per-host limits Every client on loopback shares the 127.0.0.1 identity, so counting them against 'max connections per host' or the default-on auth lockout lets one local client deny service to all the others (and makes a shared-NAT/proxy address a natural DoS vector for remote clients). Use utils_fd_peer_is_local (fail-closed) in the daemon gate to exempt a provably local peer from the per-source cap and the auth lockout while keeping the per-module and global caps. Remote peers are unchanged. Document the shared-NAT/proxy identity limitation and the loopback exemption in README/RSYNC_COMPAT/CHANGELOG, update the integration test to assert the exemption, and fix the README 'auth failure delay' cap (5000, not 60000). --- CHANGELOG.md | 9 ++++++++- README.md | 22 +++++++++++++++++++++- RSYNC_COMPAT.md | 2 +- src/server/server.c | 30 ++++++++++++++++++++++++------ tests/integration/test_daemon.py | 27 +++++++++++++++------------ 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a641f4b..fe26d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,14 @@ run the same version because the handshake is strict. 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`). + 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 diff --git a/README.md b/README.md index 58d0d10..4e7733c 100644 --- a/README.md +++ b/README.md @@ -512,7 +512,7 @@ and `address`, the global section accepts: 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 - 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 are never delayed. - `auth lockout threshold = N` — number of failed authentications from one source @@ -528,6 +528,26 @@ and `address`, the global section accepts: A `[module]` may also set `max connections` (0 = unlimited; enforced per module across all connection children) and its own `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 (`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 diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index f0d4ba2..df03870 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -637,7 +637,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved - **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. -- **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`), decrementing the per-module and per-source counts. 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). A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. +- **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. - **`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. diff --git a/src/server/server.c b/src/server/server.c index 6f3862c..6785b12 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -82,6 +82,13 @@ typedef struct ModuleGateContext { * not classify the peer; an ACL-configured module then fails closed. */ bool has_peer_ip; 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; /* Server half of the SCRAM challenge/response (A7 remediation, protocol @@ -326,7 +333,11 @@ static const char* module_gate_check_limits(const Config* config, const DaemonMo int module_index = daemon_module_index(module); if (module_index < 0) return NULL; - const char* peer = (gate_ctx && gate_ctx->has_peer_ip) ? gate_ctx->peer_ip : ""; + /* 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) { @@ -455,8 +466,10 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae 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). */ - if (g_daemon_limits && gate_ctx && gate_ctx->has_peer_ip) { + * 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, @@ -523,13 +536,14 @@ static ModuleAuthResult module_gate_authenticate(const Config* config, const Dae free(escaped_user); /* 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). */ - if (g_daemon_limits && gate_ctx->has_peer_ip) + * 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(); return MODULE_AUTH_TERMINATED; } - if (g_daemon_limits && gate_ctx->has_peer_ip) + 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); log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' from %s authenticated", config->module, @@ -636,6 +650,9 @@ 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)); if (!gate_ctx->has_peer_ip) 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); if (error) @@ -670,6 +687,7 @@ void handler(int file_descriptor) { gate_ctx.super_mode_override = -1; gate_ctx.has_peer_ip = false; gate_ctx.peer_ip[0] = '\0'; + gate_ctx.is_local = false; /* 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; diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py index 1ff1d9d..a7870e9 100644 --- a/tests/integration/test_daemon.py +++ b/tests/integration/test_daemon.py @@ -1221,11 +1221,14 @@ class TestDaemonConnectionLimits: CAPS_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_caps.conf") @pytest.mark.ci - def test_auth_lockout_is_shared_across_children(self): - """`auth lockout threshold = 1`: the first failed authentication locks the - source out for the cooldown in the SHARED registry, so a subsequent - correct-password attempt (a different forked child) is refused before a - SCRAM challenge is even sent.""" + 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" @@ -1241,21 +1244,21 @@ class TestDaemonConnectionLimits: try: d.start(self.LOCKOUT_CONF, port_override=port, extra_args=["--password-file", CRED_FILE], log_path=log_path) - before = _tree_file_count(AUTH_MODULE) log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0 - # First attempt: wrong password -> records failure #1 -> locks. + # 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: CORRECT password from the same source must still be - # refused by the shared lockout. + # 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, "the shared auth lockout must refuse after threshold" - assert _tree_file_count(AUTH_MODULE) == before, "a locked-out source wrote data" + 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" in tail, tail[-400:] + assert "locked out" not in tail, tail[-400:] finally: d.stop() From 0a7f5faea6e4739ce88ac7fb119e199c9fdca0aa Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:51:01 +0200 Subject: [PATCH 13/16] test: replace strcat with a bounds-checked append in daemon-conf test --- tests/test_daemon_conf.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index a9113a5..b6f54bc 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -543,11 +543,19 @@ static void test_daemon_conf_module_count_capped() { 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]; - snprintf(line, sizeof(line), "[m%zu]\npath = /x\n", i); - strcat(body, line); + 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_TRUE(0 && "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); From 6ea966781f89a12e888f3b1d97f728cc5b6e0af2 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 10:56:34 +0200 Subject: [PATCH 14/16] test(config): add receive-side golden oracle and sharpen fixture Address low-severity review findings on the X-macro config refactor: 1. The golden test only hashed config_send_wire_block(), so a receive-side KIND that reads a different width/order could still round-trip symmetrically. Add test_config_wire_golden_receive(): capture the same hash-pinned 633-byte frame and feed it through config_receive(), asserting every field (config_wire_equal) plus the derived use_delta/use_xattrs bits and representative bounded kinds. Add test_config_wire_receive_bounds() for bounds the symmetric round-trip cannot reach: an out-of-range BOOL (hand-built frame), RAW_MAXALLOC zero, a malformed STR_MODULE, an over-cap INT_IDMAPCOUNT, and an out-of-range INT_IDENTITY chown_uid. 2. golden_config_populate() set long runs of booleans to all-1, so an adjacent swap within a run produced identical bytes. Alternate the boolean values and make the fixture receiver-valid (chmod grammar "u=rwx,go=rx" is the same 11 bytes; delta_max_file_size inside the bound). Re-pin the golden: len stays 633, hash is now 9160991280011164139 (computed, not guessed). 3. Document in config.h and client_cli.c that the CLI option tables remain hand-maintained and are deliberately not generated from the wire-field X-macro (client-only fields, flag/alias/negation semantics). No CLI-table rewrite. PROTOCOL_VERSION stays "2.20.0"; src/shared/config.c is untouched and the wire bytes are unchanged apart from the fixture's own new values. --- src/client/client_cli.c | 10 +- src/shared/config.h | 6 + tests/test_config.c | 325 +++++++++++++++++++++++++++++++++------- 3 files changed, 289 insertions(+), 52 deletions(-) diff --git a/src/client/client_cli.c b/src/client/client_cli.c index dd644c4..3fa2004 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -608,7 +608,15 @@ typedef struct { size_t offset; /* offsetof of the boolean target field in Config */ } 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[] = { {"--dry-run", "-n", OPT_FLAG, offsetof(Config, dry_run)}, {"--remove-source-files", NULL, OPT_FLAG, offsetof(Config, remove_source_files)}, diff --git a/src/shared/config.h b/src/shared/config.h index f7c5998..6e2e425 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -100,6 +100,12 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF * (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) diff --git a/tests/test_config.c b/tests/test_config.c index 41d6a30..595abcd 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -2320,75 +2320,74 @@ static void test_config_wire_roundtrip_all_fields() { 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); - /* Keep the populated config within the server-side validation bounds. */ - populated->delta_max_file_size = DELTA_MAX_FILE_SIZE; - populated->whole_file = false; - /* "X" is not part of FastSync's chmod grammar (see parse_clause), so use a - * spec the receiver-side validator accepts. */ - free(populated->chmod_spec); - populated->chmod_spec = str_dup("u=rw,go=r"); 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. The values are deterministic. */ + * 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 = true; + c->use_multithreading = false; c->use_chunk_serialization = false; - c->use_compression = false; + c->use_compression = true; c->use_metadata = true; - c->use_executability = 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 = 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 = 987654321ULL; + c->delta_max_file_size = 200000000ULL; c->backup = true; c->backup_dir = str_dup("/golden/backup"); - c->remove_source_files = true; + c->remove_source_files = false; c->follow_symlinks = true; - c->copy_links = true; + c->copy_links = false; c->safe_links = true; - c->copy_unsafe_links = true; + c->copy_unsafe_links = false; c->preserve_hard_links = true; - c->preserve_acls = true; + c->preserve_acls = false; c->preserve_xattrs = true; - c->preserve_devices = true; + c->preserve_devices = false; c->preserve_sparse = true; - c->preserve_specials = true; + c->preserve_specials = false; c->copy_devices = true; - c->write_devices = true; + c->write_devices = false; c->ignore_existing = true; - c->existing = true; + c->existing = false; c->update = true; c->inplace = false; - c->delay_updates = false; + c->delay_updates = true; c->append = false; c->use_fsync = true; c->append_verify = false; c->delete_excluded = true; - c->force_delete = true; + c->force_delete = false; c->delete_missing_args = true; - c->delete_after = true; + c->delete_after = false; c->preallocate = true; c->max_delete = 42; - c->relative = true; + c->relative = false; c->prune_empty_dirs = true; - c->mkpath = true; - c->delete_during = false; + c->mkpath = false; + c->delete_during = true; c->delete_delay = false; c->temp_dir = str_dup("/golden/tmp"); c->partial = true; @@ -2398,7 +2397,10 @@ static void golden_config_populate(Config* c) { c->checksum = true; c->modify_window = 3; c->compress_choice = str_dup("zstd"); - c->chmod_spec = str_dup("u=rwX,go=rX"); + /* "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*)); @@ -2410,7 +2412,7 @@ static void golden_config_populate(Config* c) { c->checksum_algo = CHECKSUM_ALGO_MD5; c->checksum_seed = 0x1122334455667788ULL; c->numeric_ids = true; - c->chown_uid_set = true; + c->chown_uid_set = false; c->chown_uid = 1234; c->chown_gid_set = true; c->chown_gid = 5678; @@ -2425,11 +2427,11 @@ static void golden_config_populate(Config* c) { c->groupmap[0].from = 7; c->groupmap[0].to = 8; c->preserve_atimes = true; - c->preserve_crtimes = true; + c->preserve_crtimes = false; c->omit_dir_times = true; - c->omit_link_times = true; + c->omit_link_times = false; c->munge_links = true; - c->keep_dirlinks = true; + c->keep_dirlinks = false; c->fake_super = true; c->module = str_dup("goldenmod"); c->auth_user = str_dup("goldenuser"); @@ -2441,13 +2443,27 @@ static void golden_config_populate(Config* c) { c->copy_as_gid = 222; } -/* 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) { +/* 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 0; + return NULL; pid_t pid = fork(); if (pid == 0) { close(p[1]); @@ -2458,29 +2474,63 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len) _exit(ok ? 0 : 1); } close(p[0]); - unsigned long long h = 1469598103934665603ULL; - unsigned char buf[4096]; - ssize_t n; + size_t capacity = 1024; size_t total = 0; - while ((n = read(p[1], buf, sizeof(buf))) > 0) { - for (ssize_t i = 0; i < n; i++) { - h ^= (unsigned long long)buf[i]; - h *= 1099511628211ULL; + 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) - return 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 - * was captured from the pre-X-macro implementation; the refactor MUST NOT - * change it. */ + * 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; @@ -2490,9 +2540,180 @@ static void test_config_wire_golden() { size_t len = 0; unsigned long long h = capture_wire_hash(c, &len); printf(" wire golden: len=%zu hash=%llu\n", len, h); - /* Captured from the pre-X-macro (protocol 2.20.0) implementation. */ - EXPECT_TRUE(len == 633); - EXPECT_TRUE(h == 6163263374908258816ULL); + 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); } @@ -2552,6 +2773,8 @@ void test_config() { test_config_invariants_error_all_combinations(); test_config_receive_rejects_unified_invariants(); test_config_wire_golden(); + test_config_wire_golden_receive(); + test_config_wire_receive_bounds(); test_config_wire_roundtrip_all_fields(); } test_identity_copy_as_refused(); From 6d47d93fd714386cda28abaefa2448f0e1e977da Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 11:05:57 +0200 Subject: [PATCH 15/16] fix(config): validate received counts before publishing them The config_receive_{basis,skip,idmap}_count helpers wrote the peer-controlled int through the Config member before range-checking it. An over-cap basis_count therefore left config->basis_count huge while config->basis_dirs was still NULL; config_receive()'s error path then called config_delete(), whose basis loop dereferenced NULL and crashed the daemon before authentication. Read each count into a local, validate, and only then assign, leaving the member untouched on failure. config_delete() also guards the basis loop with the array pointer as defense in depth. Add a regression test that feeds over-cap basis/idmap/skip counts and asserts rejection without crashing, plus a direct config_delete() check on the partial (count set, array NULL) state. --- src/shared/config.c | 28 ++++++++++++++----- tests/test_config.c | 65 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/shared/config.c b/src/shared/config.c index 24ccc1f..8ac4938 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -687,11 +687,13 @@ void config_delete(Config* config) { } config->remote_options = NULL; config->remote_option_count = 0; - for (int i = 0; i < config->basis_count; i++) { - free(config->basis_dirs[i].path); - config->basis_dirs[i].path = NULL; + if (config->basis_dirs) { + for (int i = 0; i < config->basis_count; i++) { + free(config->basis_dirs[i].path); + config->basis_dirs[i].path = NULL; + } + free(config->basis_dirs); } - free(config->basis_dirs); config->basis_dirs = NULL; config->basis_count = 0; free(config->partial_dir); @@ -839,21 +841,33 @@ static bool config_receive_identity_id(int fd, int32_t* value) { return true; } +/* Read a peer-controlled count into a LOCAL, validate the range, and only then + * publish it through `*value`. Writing through `*value` before validating + * leaves the Config holding an over-cap count (e.g. 999999999) whose backing + * array is still NULL; the receive error path then runs config_delete(), which + * walks the array and dereferences NULL. Leaving `*value` untouched on failure + * also keeps the failed Config in a coherent, safely-deletable state. */ static bool config_receive_skip_count(int fd, int* value) { - if (!receive_int(fd, value) || *value < 0 || *value > MAX_SKIP_COMPRESS_SUFFIXES) + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_SKIP_COMPRESS_SUFFIXES) return false; + *value = v; return true; } static bool config_receive_basis_count(int fd, int* value) { - if (!receive_int(fd, value) || *value < 0 || *value > MAX_BASIS_DIRS) + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_BASIS_DIRS) return false; + *value = v; return true; } static bool config_receive_idmap_count(int fd, int* value) { - if (!receive_int(fd, value) || *value < 0 || *value > MAX_IDENTITY_MAP) + int v; + if (!receive_int(fd, &v) || v < 0 || v > MAX_IDENTITY_MAP) return false; + *value = v; return true; } diff --git a/tests/test_config.c b/tests/test_config.c index 595abcd..db35101 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -2717,6 +2717,70 @@ static void test_config_wire_receive_bounds() { 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() { test_config_lifecycle(); test_config_ssh_dest(); @@ -2775,6 +2839,7 @@ void test_config() { 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(); From 2ec17e821c62b5da48cd0e8c7adc442c4f77c0a9 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 13 Sep 2026 11:11:23 +0200 Subject: [PATCH 16/16] fix(daemon): harden bounded per-source registry races Stamp host_last_use before publishing a bucket key and treat an unstamped (last_use == 0) bucket as live, so a just-claimed bucket can no longer be stolen by a concurrent reclaimer. After a successful eviction CAS, re-scan for the interned key and, when an earlier bucket already holds it, zero the duplicate's active count and return the canonical bucket, preventing orphaned per-host counts and cap overshoot under full-table concurrency. Add a message-carrying EXPECT_FAIL primitive and use it for the daemon-conf buffer-overflow guard, and add a fork-based test that records auth failures from forked children and asserts the parent observes the shared lockout. --- src/shared/daemon_limits.c | 39 ++++++++++++++++++++++++++++++++++---- tests/test_daemon_conf.c | 2 +- tests/test_daemon_limits.c | 39 ++++++++++++++++++++++++++++++++++++++ tests/test_utils.h | 8 ++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/shared/daemon_limits.c b/src/shared/daemon_limits.c index edb5819..067173e 100644 --- a/src/shared/daemon_limits.c +++ b/src/shared/daemon_limits.c @@ -141,7 +141,11 @@ static bool host_bucket_reclaimable(DaemonLimitRegistry* registry, size_t idx, l if (until != 0) return until <= now; long long last_use = atomic_load_explicit(®istry->host_last_use[idx], memory_order_relaxed); - return last_use == 0 || now - last_use >= DAEMON_LIMITS_HOST_EVICT_IDLE_SEC; + /* 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 @@ -191,14 +195,18 @@ static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) { 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)) { - atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); return (int)idx; } if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) { - atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed); return (int)idx; } continue; /* another child won this empty bucket; keep probing */ @@ -209,6 +217,9 @@ static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) { } } 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)) { @@ -217,7 +228,27 @@ static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) { 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); - atomic_store_explicit(®istry->host_last_use[evict], now, 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 */ diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index b6f54bc..3cddf0f 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -550,7 +550,7 @@ static void test_daemon_conf_module_count_capped() { 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_TRUE(0 && "module-count test buffer overflow"); + EXPECT_FAIL("module-count test buffer overflow"); return; } memcpy(body + used, line, (size_t)n); diff --git a/tests/test_daemon_limits.c b/tests/test_daemon_limits.c index 8f36bb0..2b04b7b 100644 --- a/tests/test_daemon_limits.c +++ b/tests/test_daemon_limits.c @@ -189,6 +189,44 @@ static void test_daemon_limits_fork_shared() { 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() { @@ -269,4 +307,5 @@ void test_daemon_limits() { test_daemon_limits_auth_lockout(); test_daemon_limits_host_table_eviction(); test_daemon_limits_fork_shared(); + test_daemon_limits_fork_auth_lockout(); } diff --git a/tests/test_utils.h b/tests/test_utils.h index 67b6bca..4d5f3d3 100644 --- a/tests/test_utils.h +++ b/tests/test_utils.h @@ -112,4 +112,12 @@ extern bool current_test_failed; } \ } 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