Merge feat/d5-daemon-core: daemon lifecycle, module config, ::dest (PROTOCOL 2.15.0)

This commit is contained in:
2026-09-09 18:39:37 +02:00
19 changed files with 2345 additions and 102 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ set(TEST_INCLUDES tests src/shared src/server src/client)
# Monolithic test binary (backward compatible) # Monolithic test binary (backward compatible)
file(GLOB TEST_SRCS "tests/test_*.c" "tests/runner.c") file(GLOB TEST_SRCS "tests/test_*.c" "tests/runner.c")
add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_RECEIVER_SRCS} src/client/scanner.c src/client/change_list.c src/client/client_cli.c src/client/client_validation.c src/client/usage.c) add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_RECEIVER_SRCS} src/client/scanner.c src/client/change_list.c src/client/client_cli.c src/client/client_validation.c src/client/usage.c src/server/server_cli.c)
target_include_directories(tests PRIVATE ${TEST_INCLUDES}) target_include_directories(tests PRIVATE ${TEST_INCLUDES})
target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD) target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD)
target_link_libraries(tests PRIVATE ${TEST_LIBS}) target_link_libraries(tests PRIVATE ${TEST_LIBS})
+14 -5
View File
@@ -609,7 +609,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-e`, `--rsh=COMMAND` | Remote shell to use | ✅ Implemented | `-e`/`--rsh` (and `--rsh=COMMAND`) select the remote-shell program used to build the SSH child argv, overriding the default `ssh`. The command is whitespace-split into the leading argv words so rsync's `-e "ssh -p 2222"` works; the standard `-o` family, an optional `-p` port, `user@host` and the quoted remote command (`fastsync-server --stdio`) follow. Stored in the `rsh_command` config field. **Client-only, never crosses the wire** (it is a launch concern, not a handshake property) | | `-e`, `--rsh=COMMAND` | Remote shell to use | ✅ Implemented | `-e`/`--rsh` (and `--rsh=COMMAND`) select the remote-shell program used to build the SSH child argv, overriding the default `ssh`. The command is whitespace-split into the leading argv words so rsync's `-e "ssh -p 2222"` works; the standard `-o` family, an optional `-p` port, `user@host` and the quoted remote command (`fastsync-server --stdio`) follow. Stored in the `rsh_command` config field. **Client-only, never crosses the wire** (it is a launch concern, not a handshake property) |
| `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (quoted as one remote-shell word unless `--old-args`), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program | | `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (quoted as one remote-shell word unless `--old-args`), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program |
| `--port=PORT` | Alternate daemon port | ✅ Implemented | `server_port` config field | | `--port=PORT` | Alternate daemon port | ✅ Implemented | rsync's daemon-port flag maps to the client-side `server_port` config field: a client connects to a TCP/TLS server (incl. `host::module/path` daemon destinations) with `--server-port`, and the `fastsync-server --daemon` listener's port is taken from its config's `port` key (default 873) or overridden by `--dparam port=` / `-p` |
| `--sockopts=OPTIONS` | Custom TCP options | ✅ Implemented | Comma-separated allowlist of `OPT=VAL` applied via `setsockopt` after `socket()` before `connect()`/`bind()`. Only `TCP_NODELAY`, `SO_KEEPALIVE`, `SO_REUSEADDR` (0/1) and `SO_RCVBUF`/`SO_SNDBUF` (byte count) are accepted; an unknown option name or a bad value is rejected up front, never silently ignored. A value is required for every option (`OPT=VAL`; a bare name is an error). Applied to the outgoing TCP and TLS client socket; absent by default. `SockOptEntry`/`sockopts` config fields. Local socket concern: never crosses the wire | | `--sockopts=OPTIONS` | Custom TCP options | ✅ Implemented | Comma-separated allowlist of `OPT=VAL` applied via `setsockopt` after `socket()` before `connect()`/`bind()`. Only `TCP_NODELAY`, `SO_KEEPALIVE`, `SO_REUSEADDR` (0/1) and `SO_RCVBUF`/`SO_SNDBUF` (byte count) are accepted; an unknown option name or a bad value is rejected up front, never silently ignored. A value is required for every option (`OPT=VAL`; a bare name is an error). Applied to the outgoing TCP and TLS client socket; absent by default. `SockOptEntry`/`sockopts` config fields. Local socket concern: never crosses the wire |
| `--blocking-io` | Use blocking I/O for remote shell | ✅ Implemented | With `--blocking-io` the SSH-transport socketpair socket is left without `SO_RCVTIMEO`/`SO_SNDTIMEO`, so the transfer blocks naturally; by default it gets the same read/write timeout as the TCP transport (see `--timeout`). `blocking_io` config bool. **Client-only, never crosses the wire** | | `--blocking-io` | Use blocking I/O for remote shell | ✅ Implemented | With `--blocking-io` the SSH-transport socketpair socket is left without `SO_RCVTIMEO`/`SO_SNDTIMEO`, so the transfer blocks naturally; by default it gets the same read/write timeout as the TCP transport (see `--timeout`). `blocking_io` config bool. **Client-only, never crosses the wire** |
| `--outbuf=N\|L\|B` | Set output buffering | ✅ Implemented | `N` (none/unbuffered) → `_IONBF`, `L` (line) → `_IOLBF`, `B` (block, the default) → `_IOFBF` via `setvbuf` on stdout and stderr. Garbage values are rejected. `outbuf` config field (`OutbufMode`). **Client-only, never crosses the wire** | | `--outbuf=N\|L\|B` | Set output buffering | ✅ Implemented | `N` (none/unbuffered) → `_IONBF`, `L` (line) → `_IOLBF`, `B` (block, the default) → `_IOFBF` via `setvbuf` on stdout and stderr. Garbage values are rejected. `outbuf` config field (`OutbufMode`). **Client-only, never crosses the wire** |
@@ -622,13 +622,22 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `--daemon` | Run as rsync daemon | ❌ Not Implemented | Removed because it had no effect | | `--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; no `--super`/`--copy-as`). 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 | ❌ Not Implemented | Removed because it had no effect | | `--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 | ❌ Not Implemented | | | `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
| `--no-detach` | Don't detach from parent | ❌ Not Implemented | | | `--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 | ❌ Not Implemented | | | `--password-file=FILE` | Read daemon password from file | ❌ Not Implemented | |
| `--early-input=FILE` | Use FILE for daemon early exec | ❌ Not Implemented | | | `--early-input=FILE` | Use FILE for daemon early exec | ❌ Not Implemented | |
**Daemon Mode notes (Wave A, protocol 2.15.0):** 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` (parsed/stored now; MOTD display is Wave C), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `auth users` (comma list, stored for Wave B). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
- **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 and there is no `--super`/`--copy-as`. 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.
- **`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.
- **`auth users` safe default:** because FastSync daemon authentication is Wave B, a module that declares `auth users` refuses every connection this wave (the daemon cannot verify a claimed user yet). The list is parsed and stored for Wave B to honor; refusing is deliberate so an admin who expected a credential list is never silently left wide open (no auth-bypass path is shipped).
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. A `user@host::module` form is rejected until auth exists.
- **Merge note:** later daemon waves (auth, MOTD) must not bump `PROTOCOL_VERSION` again — the module-selection bump is owned by Wave A (see the NOTE in `src/shared/config.h`).
## 15. Safety & Security ## 15. Safety & Security
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
+3
View File
@@ -5,3 +5,6 @@ markers =
setpriv: privilege-dependent tests (drop to an unprivileged user); excluded setpriv: privilege-dependent tests (drop to an unprivileged user); excluded
from CI because their result depends on the runner/container uid and the from CI because their result depends on the runner/container uid and the
host mount permissions, but run locally as root host mount permissions, but run locally as root
daemon_detach: real double-fork backgrounding path (--daemon without
--no-detach); slower/fragile, so it runs in the full suite but not the
fast PR gate
+10 -1
View File
@@ -1485,7 +1485,6 @@ int main(int argc, char* argv[]) {
goto cleanup; goto cleanup;
} }
config->save_to_disk = true; config->save_to_disk = true;
config_parse_ssh_dest(config);
} else if (positional_count == 1) { } else if (positional_count == 1) {
log_message(LOG_LEVEL_ERROR, "missing destination argument"); log_message(LOG_LEVEL_ERROR, "missing destination argument");
print_usage(); print_usage();
@@ -1510,6 +1509,16 @@ int main(int argc, char* argv[]) {
} }
} }
/* Resolve the destination's transport form after the source/destination are
* final (positional, --dest-dir, or the FASTSYNC_DEST_DIR env fallback):
* host::module[/path] selects the daemon TCP transport, host:path the SSH
* transport, anything else stays local TCP. An invalid daemon destination
* already logged its reason and is a hard error here. */
if (config_parse_transport_dest(config) < 0) {
exit_code = 1;
goto cleanup;
}
if (!validate_config(config)) { if (!validate_config(config)) {
exit_code = 1; exit_code = 1;
goto cleanup; goto cleanup;
+3
View File
@@ -11,6 +11,9 @@ void print_usage(void) {
printf("Destination formats:\n"); printf("Destination formats:\n");
printf(" user@host:/path SSH transport (rsync-style)\n"); printf(" user@host:/path SSH transport (rsync-style)\n");
printf(" host:/path SSH transport (current user)\n"); printf(" host:/path SSH transport (current user)\n");
printf(" host::module/path Daemon TCP transport (fastsync-server --daemon);\n");
printf(" module names a server-side module, path is relative\n");
printf(" within it (connect with --server-port)\n");
printf(" /local/path TCP transport (requires server on localhost:8080)\n"); printf(" /local/path TCP transport (requires server on localhost:8080)\n");
printf("\n"); printf("\n");
printf("Options:\n"); printf("Options:\n");
+261 -91
View File
@@ -1,4 +1,5 @@
#include "config.h" #include "config.h"
#include "daemon_conf.h"
#include "delay_updates.h" #include "delay_updates.h"
#include "file.h" #include "file.h"
#include "identity.h" #include "identity.h"
@@ -7,6 +8,7 @@
#include "protocol.h" #include "protocol.h"
#include "queue.h" #include "queue.h"
#include "receiver.h" #include "receiver.h"
#include "server_cli.h"
#include "transport_tcp.h" #include "transport_tcp.h"
#include "transport_tls.h" #include "transport_tls.h"
#include "utils.h" #include "utils.h"
@@ -17,6 +19,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include <errno.h>
#include <sys/stat.h>
#include <openssl/x509.h> #include <openssl/x509.h>
static char* authorized_root; static char* authorized_root;
@@ -26,6 +30,11 @@ static bool trust_sender;
static bool allow_unauthenticated; static bool allow_unauthenticated;
static const char* required_client_cn; static const char* required_client_cn;
/* Non-NULL exactly when the listener runs in --daemon mode. Loaded once in
* main before any accept-loop fork, then shared read-only by every forked
* connection child (and their threads). */
static DaemonConf* g_daemon_conf = NULL;
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the /* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE
of transient wire/decompression buffers on top of the queued payloads, so of transient wire/decompression buffers on top of the queued payloads, so
@@ -80,7 +89,7 @@ static bool ensure_receive_root(const Config* config) {
return file_directory_exists_secure(config->receive_root_directory); return file_directory_exists_secure(config->receive_root_directory);
} }
static bool __attribute__((unused)) configure_authorization(const char* root) { static bool configure_authorization(const char* root) {
char resolved[PATH_MAX]; char resolved[PATH_MAX];
if (!root) { if (!root) {
file_set_authorized_root(-1, NULL); file_set_authorized_root(-1, NULL);
@@ -123,13 +132,77 @@ static bool __attribute__((unused)) configure_authorization(const char* root) {
return true; return true;
} }
/* Config-frame gate (runs inside config_receive_with_validate, BEFORE the
* STATUS_OK ack, so a rejected connection is refused at the config handshake
* and no file data is ever exchanged).
*
* Plain mode: a connection that carries a daemon module name is refused (the
* standalone server simply does not offer modules; honouring one would silently
* change what the destination means). Empty module -> accept.
*
* Daemon mode: the client MUST select a module (host::module/path). The
* requested module is looked up in the daemon config and its configured `path`
* becomes the authorized root via configure_authorization -- exactly the same
* root confinement the standalone server applies to its single
* --destination-root, but per-module and NEVER client-chosen. The module is
* refused (with a clear log) when it is unknown, when it is `read only` (every
* FastSync network transfer writes; there is no read-only wire operation yet),
* or when it declares `auth users` (FastSync cannot authenticate a claimed user
* this wave, so a module whose admin expected a credential list is refused
* rather than silently opened up -- auth is Wave B and will honor the list). */
static const char* server_module_gate(const Config* config, void* context) {
(void)context;
if (!config)
return "missing config frame";
bool is_daemon = g_daemon_conf != NULL;
bool has_module = config->module != NULL && config->module[0] != '\0';
if (!is_daemon) {
if (has_module)
return "client requested a daemon module but this server is not running "
"with --daemon";
return NULL;
}
if (!has_module)
return "daemon connection did not select a module (expected a "
"host::module/path destination)";
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
if (module == NULL) {
char* escaped_module = output_escape(config->module, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested",
escaped_module ? escaped_module : "<allocation failed>");
free(escaped_module);
return "requested daemon module does not exist";
}
if (module->read_only) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
config->module);
return "requested daemon module is read only";
}
if (module->auth_user_count > 0) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication (auth users), which this "
"daemon version does not implement; refusing",
config->module);
return "requested daemon module requires authentication that is not yet "
"supported";
}
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
module->path ? module->path : "(null)");
return "requested daemon module root is not usable";
}
return NULL; /* accepted; authorized root is now the module's path */
}
void handler(int file_descriptor) { void handler(int file_descriptor) {
SSL* ssl = io_get_ssl(); SSL* ssl = io_get_ssl();
ProtocolSession session; ProtocolSession session;
protocol_session_init(&session, file_descriptor, file_descriptor); protocol_session_init(&session, file_descriptor, file_descriptor);
protocol_session_set_ssl(&session, ssl); protocol_session_set_ssl(&session, ssl);
protocol_session_bind(&session); protocol_session_bind(&session);
Config* config = config_receive(file_descriptor); Config* config = config_receive_with_validate(file_descriptor, server_module_gate, NULL);
if (config == NULL) { if (config == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to receive config"); log_message(LOG_LEVEL_ERROR, "Failed to receive config");
close(file_descriptor); close(file_descriptor);
@@ -157,6 +230,19 @@ void handler(int file_descriptor) {
close(file_descriptor); close(file_descriptor);
return; return;
} }
/* Daemon mode: the module's root is the authorized root (installed by
server_module_gate), and the client's destination is a MODULE-RELATIVE
path. Reject an absolute destination up front so the module-relative
confinement contract is never eroded by a client that tries to address the
module root by absolute path. */
if (g_daemon_conf && config->receive_root_directory && config->receive_root_directory[0] == '/') {
log_message(LOG_LEVEL_ERROR, "Rejected absolute daemon destination (must be relative to the "
"selected module root)");
config_delete(config);
close(file_descriptor);
protocol_session_unbind();
return;
}
char* destination = config->receive_root_directory; char* destination = config->receive_root_directory;
char* joined_destination = NULL; char* joined_destination = NULL;
if (destination && destination[0] != '/') if (destination && destination[0] != '/')
@@ -336,6 +422,8 @@ static void cleanup(int sig) {
(void)sig; (void)sig;
if (g_server) if (g_server)
server_delete(&g_server); server_delete(&g_server);
daemon_conf_free(g_daemon_conf);
g_daemon_conf = NULL;
_exit(0); _exit(0);
} }
@@ -344,6 +432,14 @@ static void print_server_usage(void) {
printf("Usage: fastsync-server [options]\n\n"); printf("Usage: fastsync-server [options]\n\n");
printf("Options:\n"); printf("Options:\n");
printf(" --stdio Run in stdio mode (SSH transport)\n"); printf(" --stdio Run in stdio mode (SSH transport)\n");
printf(" --daemon Run as a persistent daemon listener using a module\n");
printf(" config file (-p/config port; default 873)\n");
printf(" --config=FILE Daemon config file (default: ~/.config/fastsync/\n");
printf(" fastsyncd.conf, else /etc/fastsyncd.conf)\n");
printf(" --dparam=KEY=VALUE Override one global config key on the command line\n");
printf(" (port, motd file, address)\n");
printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n");
printf(" -p <port> TCP port (default: 8080, range: 1-65535)\n"); printf(" -p <port> TCP port (default: 8080, range: 1-65535)\n");
printf(" --tls Enable TLS encryption\n"); printf(" --tls Enable TLS encryption\n");
printf(" --cert <path> TLS certificate file (PEM)\n"); printf(" --cert <path> TLS certificate file (PEM)\n");
@@ -361,95 +457,146 @@ static void print_server_usage(void) {
printf(" --help Show this help\n"); printf(" --help Show this help\n");
} }
int main(int argc, char* argv[]) { /* Resolve the daemon config default: ~/.config/fastsync/fastsyncd.conf when it
bool use_tls = false; * exists (or when HOME is set), otherwise /etc/fastsyncd.conf. Returns a
char *tls_cert = NULL, *tls_key = NULL, *tls_ca = NULL; * pointer to a static buffer (never NULL). */
int port = 8080; static const char* default_daemon_config_path(void) {
const char* destination_root = "."; const char* home = getenv("HOME");
bool stdio_mode = false; if (home && *home) {
const char* bind_address = NULL; static char user_path[PATH_MAX];
int bind_family = AF_UNSPEC; int n = snprintf(user_path, sizeof(user_path), "%s/.config/fastsync/fastsyncd.conf", home);
if (n > 0 && (size_t)n < sizeof(user_path) && access(user_path, R_OK) == 0)
signal(SIGPIPE, SIG_IGN); return user_path;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--help") == 0) {
print_server_usage();
return 0;
} else if (strcmp(argv[i], "--stdio") == 0) {
stdio_mode = true;
} else if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) {
set_log_level(LOG_LEVEL_DEBUG);
set_log_debug_flags(LOG_DEBUG_ALL);
} else if (strcmp(argv[i], "--tls") == 0) {
use_tls = true;
} else if (strcmp(argv[i], "--cert") == 0 && i + 1 < argc) {
tls_cert = argv[++i];
} else if (strcmp(argv[i], "--key") == 0 && i + 1 < argc) {
tls_key = argv[++i];
} else if (strcmp(argv[i], "--ca") == 0 && i + 1 < argc) {
tls_ca = argv[++i];
} else if (strcmp(argv[i], "--client-cn") == 0 && i + 1 < argc) {
required_client_cn = argv[++i];
} else if (strcmp(argv[i], "--destination-root") == 0 && i + 1 < argc) {
destination_root = argv[++i];
} else if (strcmp(argv[i], "--address") == 0 && i + 1 < argc) {
bind_address = argv[++i];
} else if (strcmp(argv[i], "-4") == 0 || strcmp(argv[i], "--ipv4") == 0) {
if (bind_family == AF_INET6) {
fprintf(stderr, "Error: --ipv4 and --ipv6 are mutually exclusive\n");
return 1;
}
bind_family = AF_INET;
} else if (strcmp(argv[i], "-6") == 0 || strcmp(argv[i], "--ipv6") == 0) {
if (bind_family == AF_INET) {
fprintf(stderr, "Error: --ipv4 and --ipv6 are mutually exclusive\n");
return 1;
}
bind_family = AF_INET6;
} else if (strcmp(argv[i], "--allow-delete") == 0) {
allow_delete = true;
} else if (strcmp(argv[i], "--trust-sender") == 0) {
trust_sender = true;
} else if (strcmp(argv[i], "--allow-unauthenticated") == 0) {
allow_unauthenticated = true;
} else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
char* end;
long p = strtol(argv[++i], &end, 10);
if (*end || p <= 0 || p > 65535) {
char* escaped = output_escape(argv[i], false);
fprintf(stderr, "Error: invalid port '%s' (must be 1-65535)\n",
escaped ? escaped : "<allocation failed>");
free(escaped);
return 1;
}
port = (int)p;
} else if (argv[i][0] == '-') {
char* escaped = output_escape(argv[i], false);
fprintf(stderr, "Unknown option: %s\n", escaped ? escaped : "<allocation failed>");
free(escaped);
print_server_usage();
return 1;
}
} }
if (tls_ca && !use_tls) /* Fall back to the traditional system path. */
log_message(LOG_LEVEL_WARNING, "--ca has no effect without --tls"); return "/etc/fastsyncd.conf";
signal(SIGINT, cleanup); }
signal(SIGTERM, cleanup);
if (!configure_authorization(destination_root)) { /* Detach from the controlling terminal: fork, exit the parent, and make the
char* escaped = output_escape(destination_root, false); * surviving child a session leader (setsid) with stdio redirected to
fprintf(stderr, "Error: invalid destination root '%s'\n", * /dev/null. The listening socket is already open (bound in main before this
escaped ? escaped : "<allocation failed>"); * runs), so it is inherited by the background daemon. Returns true on
free(escaped); * success (in the daemon's own process). */
static bool daemonize(void) {
pid_t pid = fork();
if (pid < 0)
return false;
if (pid > 0)
_exit(0);
if (setsid() < 0)
return false;
pid = fork();
if (pid < 0)
return false;
if (pid > 0)
_exit(0);
int devnull = open("/dev/null", O_RDWR);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
dup2(devnull, STDOUT_FILENO);
dup2(devnull, STDERR_FILENO);
if (devnull > STDERR_FILENO)
close(devnull);
}
/* Do not pin the launch CWD (module-relative 'path' entries would resolve
* against an unstable working directory) and drop the restrictive host umask
* so modules can create files/dirs with the modes the config requests. */
if (chdir("/") != 0)
log_message(LOG_LEVEL_WARNING, "daemon: chdir to / failed: %s", strerror(errno));
umask(0);
return true;
}
int main(int argc, char* argv[]) {
ServerCliOptions opts;
char cli_err[512];
int parse_result = server_cli_parse(argc, argv, &opts, cli_err, sizeof(cli_err));
if (parse_result == 1) {
print_server_usage();
return 0;
}
if (parse_result < 0) {
server_cli_options_free(&opts);
fprintf(stderr, "Error: %s\n", cli_err);
print_server_usage();
return 1; return 1;
} }
if (stdio_mode) {
int exit_code = 0;
signal(SIGPIPE, SIG_IGN);
if (opts.verbose) {
set_log_level(LOG_LEVEL_DEBUG);
set_log_debug_flags(LOG_DEBUG_ALL);
}
if (opts.tls_ca && !opts.use_tls)
log_message(LOG_LEVEL_WARNING, "--ca has no effect without --tls");
/* Persist the parsed server policies into the process-global policy state
* BEFORE the stdio branch: an SSH-launched `--stdio` server (whose argv came
* from the client via --remote-option and friends) must honor --allow-delete,
* --trust-sender and --client-cn exactly like the standalone listener. */
required_client_cn = opts.client_cn;
allow_delete = opts.allow_delete;
trust_sender = opts.trust_sender;
allow_unauthenticated = opts.allow_unauthenticated;
signal(SIGINT, cleanup);
signal(SIGTERM, cleanup);
if (opts.stdio_mode) {
/* SSH authenticates the stdio transport outside of FastSync. */ /* SSH authenticates the stdio transport outside of FastSync. */
allow_unauthenticated = true; allow_unauthenticated = true;
if (!configure_authorization(opts.destination_root)) {
char* escaped = output_escape(opts.destination_root, false);
fprintf(stderr, "Error: invalid destination root '%s'\n",
escaped ? escaped : "<allocation failed>");
free(escaped);
server_cli_options_free(&opts);
return 1;
}
io_set_fds(STDIN_FILENO, STDOUT_FILENO); io_set_fds(STDIN_FILENO, STDOUT_FILENO);
handler(STDIN_FILENO); handler(STDIN_FILENO);
release_authorization(); release_authorization();
server_cli_options_free(&opts);
return 0; return 0;
} }
int port = opts.port;
int bind_family = opts.bind_family;
const char* bind_address = opts.bind_address;
if (opts.daemon_mode) {
const char* config_path = opts.config_path ? opts.config_path : default_daemon_config_path();
g_daemon_conf = daemon_conf_load(config_path, cli_err, sizeof(cli_err));
if (!g_daemon_conf) {
server_cli_options_free(&opts);
fprintf(stderr, "Error: %s\n", cli_err);
return 1;
}
for (int i = 0; i < opts.dparam_count; i++) {
if (daemon_conf_apply_dparam(g_daemon_conf, opts.dparams[i], cli_err, sizeof(cli_err)) != 0) {
fprintf(stderr, "Error: --dparam: %s\n", cli_err);
exit_code = 1;
goto out;
}
}
/* Effective port: -p (highest) > --dparam port > config port (default 873). */
if (!opts.port_set)
port = g_daemon_conf->global.port;
if (!bind_address)
bind_address = g_daemon_conf->global.address;
if (g_daemon_conf->module_count == 0)
log_message(LOG_LEVEL_WARNING,
"daemon config has no modules; every connection will be refused");
} else {
if (!configure_authorization(opts.destination_root)) {
char* escaped = output_escape(opts.destination_root, false);
fprintf(stderr, "Error: invalid destination root '%s'\n",
escaped ? escaped : "<allocation failed>");
free(escaped);
server_cli_options_free(&opts);
return 1;
}
}
ServerBindOptions bind_opts; ServerBindOptions bind_opts;
bind_opts.bind_address = bind_address; bind_opts.bind_address = bind_address;
bind_opts.family = bind_family; bind_opts.family = bind_family;
@@ -457,28 +604,51 @@ int main(int argc, char* argv[]) {
if (!g_server) { if (!g_server) {
log_message(LOG_LEVEL_ERROR, "Failed to create server"); log_message(LOG_LEVEL_ERROR, "Failed to create server");
release_authorization(); release_authorization();
return 1; exit_code = 1;
goto out;
} }
if (use_tls) { if (opts.use_tls) {
if (!tls_cert || !tls_key || !tls_ca || !required_client_cn) { if (!opts.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) {
fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n");
server_delete(&g_server); server_delete(&g_server);
release_authorization(); release_authorization();
return 1; exit_code = 1;
goto out;
} }
tls_global_init(); tls_global_init();
if (!server_create_tls(g_server, tls_cert, tls_key, tls_ca)) { if (!server_create_tls(g_server, opts.tls_cert, opts.tls_key, opts.tls_ca)) {
log_message(LOG_LEVEL_ERROR, "Failed to set up TLS"); log_message(LOG_LEVEL_ERROR, "Failed to set up TLS");
server_delete(&g_server); server_delete(&g_server);
release_authorization(); release_authorization();
return 1; exit_code = 1;
goto out;
} }
server_listen_tls(g_server, handler);
} else {
server_listen(g_server, handler);
} }
/* Detach after the listening socket (and TLS context) exist so the
* background daemon inherits a fully-bound listener. --no-detach runs in
* the foreground, which is how tests drive the daemon. */
if (opts.daemon_mode && !opts.no_detach) {
if (!daemonize()) {
log_message(LOG_LEVEL_ERROR, "Failed to daemonize");
server_delete(&g_server);
release_authorization();
exit_code = 1;
goto out;
}
}
if (opts.use_tls)
server_listen_tls(g_server, handler);
else
server_listen(g_server, handler);
server_delete(&g_server); server_delete(&g_server);
release_authorization(); release_authorization();
return 0;
out:
daemon_conf_free(g_daemon_conf);
g_daemon_conf = NULL;
server_cli_options_free(&opts);
return exit_code;
} }
#endif #endif
+206
View File
@@ -0,0 +1,206 @@
#include "server_cli.h"
#include "utils.h"
#include <limits.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
if (!err || err_size == 0)
return;
va_list args;
va_start(args, fmt);
vsnprintf(err, err_size, fmt, args);
va_end(args);
}
void server_cli_options_default(ServerCliOptions* opts) {
if (!opts)
return;
memset(opts, 0, sizeof(*opts));
opts->destination_root = ".";
opts->port = 8080;
opts->bind_family = AF_UNSPEC;
}
static bool arg_is(const char* arg, const char* name) {
return strcmp(arg, name) == 0;
}
/* Match "--opt" against "--opt=value" / separate-value forms; on the "=" form
* *value receives the inline value. Returns true when the argument is the
* named option in either form. */
static bool arg_has_value(const char* arg, const char* name, const char** value) {
if (strcmp(arg, name) == 0)
return true; /* separate form; caller takes the next argv slot */
size_t name_len = strlen(name);
if (strncmp(arg, name, name_len) == 0 && arg[name_len] == '=') {
*value = arg + name_len + 1;
return true;
}
return false;
}
static int parse_port_arg(const char* value, int* port, char* err, size_t err_size) {
char* end;
long p = strtol(value, &end, 10);
if (*end != '\0' || p <= 0 || p > 65535) {
char* escaped = output_escape(value, false);
set_error(err, err_size, "invalid port '%s' (must be 1-65535)",
escaped ? escaped : "<allocation failed>");
free(escaped);
return -1;
}
*port = (int)p;
return 0;
}
int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
server_cli_options_default(opts);
for (int i = 1; i < argc; i++) {
if (arg_is(argv[i], "--help")) {
opts->show_help = true;
return 1;
} else if (arg_is(argv[i], "--stdio")) {
opts->stdio_mode = true;
} else if (arg_is(argv[i], "--daemon")) {
opts->daemon_mode = true;
} else if (arg_is(argv[i], "--no-detach")) {
opts->no_detach = true;
} else if (arg_is(argv[i], "-v") || arg_is(argv[i], "--verbose")) {
opts->verbose = true;
} else if (arg_is(argv[i], "--tls")) {
opts->use_tls = true;
} else if (arg_is(argv[i], "--cert")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --cert");
return -1;
}
opts->tls_cert = argv[++i];
} else if (arg_is(argv[i], "--key")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --key");
return -1;
}
opts->tls_key = argv[++i];
} else if (arg_is(argv[i], "--ca")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --ca");
return -1;
}
opts->tls_ca = argv[++i];
} else if (arg_is(argv[i], "--client-cn")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --client-cn");
return -1;
}
opts->client_cn = argv[++i];
} else if (arg_is(argv[i], "--destination-root")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --destination-root");
return -1;
}
opts->destination_root = argv[++i];
opts->destination_root_set = true;
} else if (arg_is(argv[i], "--address")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --address");
return -1;
}
opts->bind_address = argv[++i];
} else if (arg_is(argv[i], "-4") || arg_is(argv[i], "--ipv4")) {
if (opts->bind_family == AF_INET6) {
set_error(err, err_size, "--ipv4 and --ipv6 are mutually exclusive");
return -1;
}
opts->bind_family = AF_INET;
} else if (arg_is(argv[i], "-6") || arg_is(argv[i], "--ipv6")) {
if (opts->bind_family == AF_INET) {
set_error(err, err_size, "--ipv4 and --ipv6 are mutually exclusive");
return -1;
}
opts->bind_family = AF_INET6;
} else if (arg_is(argv[i], "--allow-delete")) {
opts->allow_delete = true;
} else if (arg_is(argv[i], "--trust-sender")) {
opts->trust_sender = true;
} else if (arg_is(argv[i], "--allow-unauthenticated")) {
opts->allow_unauthenticated = true;
} else if (arg_is(argv[i], "-p")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for -p");
return -1;
}
opts->port_set = true;
if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0)
return -1;
} else {
const char* inline_value = NULL;
if (arg_has_value(argv[i], "--config", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --config");
return -1;
}
inline_value = argv[++i];
}
opts->config_path = inline_value;
} else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --dparam");
return -1;
}
inline_value = argv[++i];
}
const char** grown =
realloc((char**)opts->dparams, (size_t)(opts->dparam_count + 1) * sizeof(const char*));
if (!grown) {
set_error(err, err_size, "out of memory parsing --dparam");
return -1;
}
opts->dparams = grown;
opts->dparams[opts->dparam_count++] = inline_value;
} else if (argv[i][0] == '-') {
char* escaped = output_escape(argv[i], false);
set_error(err, err_size, "unknown option: %s", escaped ? escaped : "<allocation failed>");
free(escaped);
return -1;
} else {
set_error(err, err_size, "unexpected argument '%s'", argv[i]);
return -1;
}
}
}
/* Cross-mode validation. */
if (opts->stdio_mode && opts->daemon_mode) {
set_error(err, err_size, "--stdio and --daemon are mutually exclusive");
return -1;
}
if (opts->daemon_mode && opts->destination_root_set) {
set_error(err, err_size,
"--destination-root cannot be combined with --daemon (module paths "
"replace it)");
return -1;
}
if (!opts->daemon_mode &&
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach)) {
set_error(err, err_size, "--config, --dparam, and --no-detach require --daemon");
return -1;
}
return 0;
}
void server_cli_options_free(ServerCliOptions* opts) {
if (!opts)
return;
free((char**)opts->dparams);
opts->dparams = NULL;
opts->dparam_count = 0;
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef SERVER_CLI_H
#define SERVER_CLI_H
#include <stdbool.h>
#include <stddef.h>
/* Parsed fastsync-server command line. All string members are borrowed
* pointers into the original argv (valid for the life of the argv array the
* caller passed to server_cli_parse); dparams points at the raw --dparam
* argument strings. No member owns heap memory. */
typedef struct ServerCliOptions {
bool stdio_mode; /* --stdio */
bool daemon_mode; /* --daemon */
bool no_detach; /* --no-detach */
bool verbose; /* -v / --verbose */
bool show_help; /* --help */
bool use_tls; /* --tls */
const char* tls_cert; /* --cert */
const char* tls_key; /* --key */
const char* tls_ca; /* --ca */
const char* client_cn; /* --client-cn */
bool destination_root_set; /* an explicit --destination-root was given */
const char* destination_root; /* --destination-root value ("." if unset) */
bool port_set; /* an explicit -p was given */
int port; /* -p value (default 8080 when unset) */
const char* config_path; /* --config value, or NULL */
const char** dparams; /* raw --dparam override strings */
int dparam_count;
const char* bind_address; /* --address */
int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */
bool allow_delete; /* --allow-delete */
bool trust_sender; /* --trust-sender */
bool allow_unauthenticated; /* --allow-unauthenticated */
} ServerCliOptions;
/* Parse argc/argv into *opts. Zero-initialize *opts before calling (or use
* server_cli_options_default). Returns:
* 1 -- --help was requested (opts->show_help set; caller prints usage).
* 0 -- parsed successfully.
* -1 -- invalid arguments (err is filled with the reason).
*/
void server_cli_options_default(ServerCliOptions* opts);
int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, size_t err_size);
/* Release the only heap the parsed options own (the dparams pointer array; the
* strings it points at are borrowed from argv and are not freed). Safe to
* call on a zero-initialized/defaulted struct. */
void server_cli_options_free(ServerCliOptions* opts);
#endif
+169 -3
View File
@@ -1,5 +1,6 @@
#include "config.h" #include "config.h"
#include "chmod.h" #include "chmod.h"
#include "daemon_conf.h"
#include "delay_updates.h" #include "delay_updates.h"
#include "delta.h" #include "delta.h"
#include "file_list.h" #include "file_list.h"
@@ -36,6 +37,7 @@ static void config_set_defaults(Config* config) {
config->ssh_port = 22; config->ssh_port = 22;
config->transport = TRANSPORT_TCP; config->transport = TRANSPORT_TCP;
config->ssh_destination = NULL; config->ssh_destination = NULL;
config->module = NULL;
config->fastsync_server_path = NULL; config->fastsync_server_path = NULL;
config->exclude_patterns = NULL; config->exclude_patterns = NULL;
config->exclude_count = 0; config->exclude_count = 0;
@@ -470,6 +472,120 @@ bool config_is_remote_dest(const char* s) {
return true; return true;
} }
/* Daemon destination detection: rsync's host::module[/path] marker is a "::"
* immediately after the host part (the first ':' is immediately followed by a
* second ':'), with no '/' before it. A single ':' (host:path) stays the SSH
* form even when the path itself later contains colons, and a "[::1]"-style
* bracketed IPv6 literal is not recognized as a daemon destination this wave
* (its first "::" is inside the brackets). */
bool config_is_daemon_dest(const char* s) {
if (s == NULL)
return false;
const char* colon = strchr(s, ':');
if (colon == NULL || colon == s || colon[1] != ':')
return false;
for (const char* p = s; p < colon; p++) {
if (*p == '/')
return false;
}
return true;
}
/* Log an escaped message with an 8-bit-safe output policy and return -1 (the
* caller-visible parse failure code). */
static int daemon_dest_parse_error(const char* message, const char* detail) {
char* escaped = output_escape(detail ? detail : "", false);
log_message(LOG_LEVEL_ERROR, "%s: %s", message, escaped ? escaped : "<allocation failed>");
free(escaped);
return -1;
}
int config_parse_daemon_dest(Config* config) {
if (!config || !config->receive_root_directory)
return 0;
const char* dest = config->receive_root_directory;
if (!config_is_daemon_dest(dest))
return 0;
const char* colon = strchr(dest, ':');
/* user@host::module names a daemon auth user, which this daemon version
* cannot verify: reject it rather than silently ignoring the user (auth is
* Wave B). */
if (memchr(dest, '@', (size_t)(colon - dest)) != NULL)
return daemon_dest_parse_error("daemon destination user@host::module is not supported: user "
"authentication is not implemented by this daemon version",
dest);
const char* host_start = dest;
const char* module_and_path = colon + 2;
if (*module_and_path == '\0')
return daemon_dest_parse_error("daemon destination is missing its module name", dest);
const char* slash = strchr(module_and_path, '/');
size_t module_len = slash ? (size_t)(slash - module_and_path) : strlen(module_and_path);
char* module = malloc(module_len + 1);
if (!module)
return daemon_dest_parse_error("out of memory parsing daemon destination", dest);
memcpy(module, module_and_path, module_len);
module[module_len] = '\0';
if (!daemon_module_name_valid(module)) {
free(module);
return daemon_dest_parse_error(
"invalid daemon module name (must be 1-200 chars of [A-Za-z0-9._-])", dest);
}
const char* path = slash ? slash + 1 : "";
while (*path == '/')
path++; /* normalize "mod//a" to "mod/a"; keeps path module-relative */
if (has_path_traversal(path)) {
free(module);
return daemon_dest_parse_error("daemon destination path must not contain '..'", dest);
}
size_t host_len = (size_t)(colon - host_start);
char* host = malloc(host_len + 1);
if (!host) {
free(module);
return daemon_dest_parse_error("out of memory parsing daemon destination", dest);
}
memcpy(host, host_start, host_len);
host[host_len] = '\0';
if (*host == '\0') {
free(host);
free(module);
return daemon_dest_parse_error("daemon destination has no host", dest);
}
char* path_dup = str_dup(path);
if (!path_dup) {
free(host);
free(module);
return daemon_dest_parse_error("out of memory parsing daemon destination", dest);
}
free(config->server_host);
config->server_host = host;
free(config->module);
config->module = module;
free(config->receive_root_directory);
config->receive_root_directory = path_dup;
config->transport = TRANSPORT_TCP;
return 1;
}
int config_parse_transport_dest(Config* config) {
if (!config || !config->receive_root_directory)
return 0;
/* Daemon (host::module[/path]) first: the single-colon SSH parser would
* otherwise mis-split the double colon. Returns 1 (parsed as daemon), 0
* (not daemon syntax -> try SSH below), or -1 (invalid daemon destination,
* already logged). */
int daemon_ret = config_parse_daemon_dest(config);
if (daemon_ret != 0)
return daemon_ret;
config_parse_ssh_dest(config);
return 0;
}
void config_parse_ssh_dest(Config* config) { void config_parse_ssh_dest(Config* config) {
if (!config_is_remote_dest(config->receive_root_directory)) if (!config_is_remote_dest(config->receive_root_directory))
return; return;
@@ -492,6 +608,7 @@ void config_delete(Config* config) {
free(config->send_directory); free(config->send_directory);
free(config->receive_root_directory); free(config->receive_root_directory);
free(config->ssh_destination); free(config->ssh_destination);
free(config->module);
free(config->fastsync_server_path); free(config->fastsync_server_path);
for (int i = 0; i < config->exclude_count; i++) for (int i = 0; i < config->exclude_count; i++)
free(config->exclude_patterns[i]); free(config->exclude_patterns[i]);
@@ -940,6 +1057,37 @@ static bool receive_phase4_xattr_options(int fd, Config* c) {
return true; 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) {
char* module = receive_str(fd);
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);
send_status(fd, STATUS_ERROR);
return false;
}
if (*module != '\0') {
c->module = module;
} else {
free(module);
}
return true;
}
bool config_send(int file_descriptor, const Config* config) { bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc); protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
@@ -951,7 +1099,8 @@ bool config_send(int file_descriptor, const Config* config) {
!send_identity_options(file_descriptor, config) || !send_identity_options(file_descriptor, config) ||
!send_metadata_times_options(file_descriptor, config) || !send_metadata_times_options(file_descriptor, config) ||
!send_symlink_trust_options(file_descriptor, config) || !send_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config)) !send_phase4_xattr_options(file_descriptor, config) ||
!send_daemon_module(file_descriptor, config))
return false; return false;
Status status; Status status;
if (!receive_status(file_descriptor, &status)) if (!receive_status(file_descriptor, &status))
@@ -963,7 +1112,8 @@ bool config_send(int file_descriptor, const Config* config) {
return true; return true;
} }
Config* config_receive(int file_descriptor) { Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
void* context) {
Config* config = config_create(); Config* config = config_create();
if (!config) if (!config)
return NULL; return NULL;
@@ -990,7 +1140,8 @@ Config* config_receive(int file_descriptor) {
!receive_identity_options(file_descriptor, config) || !receive_identity_options(file_descriptor, config) ||
!receive_metadata_times_options(file_descriptor, config) || !receive_metadata_times_options(file_descriptor, config) ||
!receive_symlink_trust_options(file_descriptor, config) || !receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config)) !receive_phase4_xattr_options(file_descriptor, config) ||
!receive_daemon_module(file_descriptor, config))
goto error; goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) { strcmp(config->compress_choice, "none") != 0) {
@@ -1006,6 +1157,17 @@ Config* config_receive(int file_descriptor) {
send_status(file_descriptor, STATUS_ERROR); send_status(file_descriptor, STATUS_ERROR);
goto error; goto error;
} }
if (validate) {
const char* rejection = validate(config, context);
if (rejection != NULL) {
/* Daemon module gate (unknown module / read-only module / auth-required
* module): refuse BEFORE the STATUS_OK so the client aborts at the
* config handshake and no file data is ever exchanged. */
fprintf(stderr, "%s\n", rejection);
send_status(file_descriptor, STATUS_ERROR);
goto error;
}
}
if (!send_status(file_descriptor, STATUS_OK)) if (!send_status(file_descriptor, STATUS_OK))
goto error; goto error;
return config; return config;
@@ -1014,3 +1176,7 @@ error:
config_delete(config); config_delete(config);
return NULL; return NULL;
} }
Config* config_receive(int file_descriptor) {
return config_receive_with_validate(file_descriptor, NULL, NULL);
}
+54 -1
View File
@@ -89,6 +89,12 @@ typedef struct Config {
int ssh_port; int ssh_port;
TransportType transport; TransportType transport;
char* ssh_destination; char* ssh_destination;
/* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a
* host::module/path destination; NULL or "" means "no module" (the ordinary
* standalone-server path). Crosses the wire as a trailing config-frame
* string so the daemon can look the module up in its own config and confine
* the connection to the module's root (never a client-chosen root). */
char* module;
char* fastsync_server_path; char* fastsync_server_path;
char** exclude_patterns; char** exclude_patterns;
int exclude_count; int exclude_count;
@@ -438,7 +444,22 @@ typedef struct Config {
* fails the version check cleanly up front (rather than the remote server * fails the version check cleanly up front (rather than the remote server
* rejecting an unfamiliar forwarded argv at a confusing later point), which is * rejecting an unfamiliar forwarded argv at a confusing later point), which is
* exactly what the lockstep convention of this project requires. */ * exactly what the lockstep convention of this project requires. */
#define PROTOCOL_VERSION "2.14.0" /* Daemon Wave A: 2.14.0 -> 2.15.0.
*
* WHY the bump, grounded in the wire: this wave really does add a serialized
* field to the binary config frame. The client sends its requested daemon
* module name (Config->module) as a new trailing string on the frame (sent
* after the Phase-4 xattr block and before the STATUS_OK/STATUS_ERROR ack, in
* config_send/config_receive), and the daemon reads it to select which module
* root confines the connection. Any config-frame layout change must bump the
* protocol version because a peer that does not parse the new trailing bytes
* would desynchronize on the frame boundary; the strict same-version handshake
* (config_receive rejects a mismatched version before parsing anything else)
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
*
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
* waves (auth, motd) must not bump PROTOCOL_VERSION. */
#define PROTOCOL_VERSION "2.15.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
@@ -458,6 +479,38 @@ Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s); bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config); void config_parse_ssh_dest(Config* config);
/* Server-side config-frame gate (daemon module selection, Wave A). A server
* that needs to make an accept/reject decision about a received Config BEFORE
* it sends the STATUS_OK ack (so a rejected connection is refused cleanly with
* no data transferred) passes a callback here; it runs after the frame parses
* and validates but before the STATUS_OK/STATUS_ERROR ack. Return NULL to
* accept the connection; return a non-NULL message to reject it (the message
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK). The
* callback runs in the connection's own process, so it may set up per-module
* process state (e.g. the authorized root). context is an opaque caller
* pointer. */
typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
void* context);
/* Daemon-destination (host::module[/path]) helpers, Wave A. config_is_remote_dest
* recognizes the ordinary rsync-style single-colon host:path form used by the
* SSH transport; config_is_daemon_dest recognizes the double-colon form that
* selects a daemon module over TCP. config_parse_transport_dest is the single
* entry point main() uses: it parses a :: destination as a daemon TCP
* destination (host -> server_host, module -> config->module, path ->
* receive_root_directory) and otherwise falls back to the existing SSH
* host:path handling. */
bool config_is_daemon_dest(const char* s);
/* Returns 1 when the destination was daemon syntax and was parsed, 0 when it
* is not daemon syntax (nothing changed), -1 on an invalid daemon destination
* (a message is logged and config is left untouched). */
int config_parse_daemon_dest(Config* config);
/* Returns 1/0/-1 mirroring config_parse_daemon_dest when the destination is
* daemon syntax; otherwise runs the existing SSH host:path parse and returns
* 0. */
int config_parse_transport_dest(Config* config);
/* True when the negotiated delete timing performs the extra-file deletion /* True when the negotiated delete timing performs the extra-file deletion
* BEFORE the transfer data (--delete-before / --delete-during). The flag is * BEFORE the transfer data (--delete-before / --delete-during). The flag is
* a pure function of the config and is used identically on the sender (to pick * a pure function of the config and is used identically on the sender (to pick
+443
View File
@@ -0,0 +1,443 @@
#include "daemon_conf.h"
#include "utils.h"
#include <ctype.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
/* ------------------------------------------------------------------ */
/* helpers */
/* ------------------------------------------------------------------ */
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
if (!err || err_size == 0)
return;
va_list args;
va_start(args, fmt);
vsnprintf(err, err_size, fmt, args);
va_end(args);
}
/* Trim leading and trailing ASCII space/tab in place; returns the new start. */
static char* trim_ws(char* s) {
while (*s == ' ' || *s == '\t')
s++;
size_t len = strlen(s);
while (len > 0 && (s[len - 1] == ' ' || s[len - 1] == '\t'))
s[--len] = '\0';
return s;
}
/* Case-insensitive equality of a parsed key against a canonical key name. */
static bool key_equals(const char* key, const char* canonical) {
return strcasecmp(key, canonical) == 0;
}
static bool parse_bool_value(const char* value, bool* out) {
if (strcasecmp(value, "yes") == 0 || strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0) {
*out = true;
return true;
}
if (strcasecmp(value, "no") == 0 || strcasecmp(value, "false") == 0 || strcmp(value, "0") == 0) {
*out = false;
return true;
}
return false;
}
bool daemon_module_name_valid(const char* name) {
if (!name || *name == '\0')
return false;
size_t len = strlen(name);
if (len > DAEMON_MAX_MODULE_NAME)
return false;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)name[i];
bool alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
if (!alnum && c != '.' && c != '_' && c != '-')
return false;
}
return true;
}
DaemonConf* daemon_conf_create(void) {
DaemonConf* conf = calloc(1, sizeof(DaemonConf));
if (!conf)
return NULL;
conf->global.port = DAEMON_CONF_DEFAULT_PORT;
return conf;
}
void daemon_conf_free(DaemonConf* conf) {
if (!conf)
return;
free(conf->global.motd_file);
free(conf->global.address);
for (int i = 0; i < conf->module_count; i++) {
DaemonModule* m = &conf->modules[i];
free(m->name);
free(m->path);
for (int j = 0; j < m->auth_user_count; j++)
free(m->auth_users[j]);
free(m->auth_users);
}
free(conf->modules);
free(conf);
}
const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* name) {
if (!conf || !name)
return NULL;
for (int i = 0; i < conf->module_count; i++) {
if (strcmp(conf->modules[i].name, name) == 0)
return &conf->modules[i];
}
return NULL;
}
/* Replace *slot with a str_dup of value; returns false on allocation failure. */
static bool store_string(char** slot, const char* value) {
char* dup = str_dup(value);
if (!dup)
return false;
free(*slot);
*slot = dup;
return true;
}
static bool store_port(int* slot, const char* value, char* err, size_t err_size) {
char* end;
errno = 0;
long p = strtol(value, &end, 10);
if (errno != 0 || *end != '\0' || *value == '\0' || p <= 0 || p > 65535) {
set_error(err, err_size, "invalid port '%s' (must be 1-65535)", value);
return false;
}
*slot = (int)p;
return true;
}
/* Apply a global scalar key/value. Keys are case-insensitive. Returns false
* (err filled) on an unknown key or an invalid value. */
static bool apply_global_key(DaemonConf* conf, char* key, const char* value, char* err,
size_t err_size) {
if (key_equals(key, "port"))
return store_port(&conf->global.port, value, err, err_size);
if (key_equals(key, "motd file")) {
if (!store_string(&conf->global.motd_file, value)) {
set_error(err, err_size, "out of memory parsing 'motd file'");
return false;
}
return true;
}
if (key_equals(key, "address")) {
if (!store_string(&conf->global.address, value)) {
set_error(err, err_size, "out of memory parsing 'address'");
return false;
}
return true;
}
set_error(err, err_size, "unknown global key '%s'", key);
return false;
}
/* Apply a module key/value to the currently-open module. Returns false (err
* filled) on an unknown module key or an invalid value. */
static bool apply_module_key(DaemonModule* module, char* key, char* value, char* err,
size_t err_size) {
if (key_equals(key, "path")) {
if (*value == '\0') {
set_error(err, err_size, "module '%s': 'path' must not be empty", module->name);
return false;
}
if (!store_string(&module->path, value)) {
set_error(err, err_size, "out of memory parsing 'path' for module '%s'", module->name);
return false;
}
return true;
}
if (key_equals(key, "read only")) {
bool parsed;
if (!parse_bool_value(value, &parsed)) {
set_error(err, err_size,
"module '%s': 'read only' must be yes/no (or true/false/1/0), got '%s'",
module->name, value);
return false;
}
module->read_only = parsed;
return true;
}
if (key_equals(key, "auth users")) {
char* list = str_dup(value);
if (!list) {
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'", module->name);
return false;
}
char* save = NULL;
for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) {
const char* user = trim_ws(token);
if (*user == '\0')
continue;
char** grown =
realloc(module->auth_users, (size_t)(module->auth_user_count + 1) * sizeof(char*));
if (!grown) {
free(list);
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'",
module->name);
return false;
}
module->auth_users = grown;
char* dup = str_dup(user);
if (!dup) {
free(list);
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'",
module->name);
return false;
}
module->auth_users[module->auth_user_count++] = dup;
}
free(list);
return true;
}
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
return false;
}
static bool module_open_valid(const DaemonModule* module, char* err, size_t err_size) {
if (module->path == NULL) {
set_error(err, err_size, "module '%s' has no 'path'", module->name);
return false;
}
return true;
}
/* Validate a [section] header line body (text between the brackets) and set
* *name to the module name. Returns false on a malformed header. */
static bool parse_section_name(char* body, const char** name_out, char* err, size_t err_size) {
char* name = trim_ws(body);
if (!daemon_module_name_valid(name)) {
set_error(err, err_size, "invalid module name '%s' (must be 1-%d chars of [A-Za-z0-9._-])",
name, DAEMON_MAX_MODULE_NAME);
return false;
}
*name_out = name;
return true;
}
/* Open (or switch to) a module section. Closes any previously open module
* (validating it has a path) and appends the new one. */
static int open_module(DaemonConf* conf, int* current_module, const char* name, char* err,
size_t err_size) {
if (*current_module >= 0) {
if (!module_open_valid(&conf->modules[*current_module], err, err_size))
return -1;
}
if (daemon_conf_find_module(conf, name)) {
set_error(err, err_size, "duplicate module '%s'", name);
return -1;
}
DaemonModule* grown =
realloc(conf->modules, (size_t)(conf->module_count + 1) * sizeof(DaemonModule));
if (!grown) {
set_error(err, err_size, "out of memory adding module '%s'", name);
return -1;
}
conf->modules = grown;
memset(&conf->modules[conf->module_count], 0, sizeof(DaemonModule));
conf->modules[conf->module_count].name = str_dup(name);
if (!conf->modules[conf->module_count].name) {
set_error(err, err_size, "out of memory adding module '%s'", name);
return -1;
}
conf->module_count++;
*current_module = conf->module_count - 1;
return 0;
}
/* Split a "key = value" line (value pointer returned in *value, pointing into
* line). Returns false when there is no '='. */
static bool split_key_value(char* line, char** key, char** value) {
char* eq = strchr(line, '=');
if (!eq)
return false;
*eq = '\0';
*key = trim_ws(line);
*value = trim_ws(eq + 1);
return true;
}
/* Strip one layer of surrounding double quotes from a trimmed value. A value
* that starts with '"' but does not end with '"' is an error. */
static bool unquote_value(char* value, char* err, size_t err_size) {
size_t len = strlen(value);
if (len == 0 || value[0] != '"')
return true;
if (len < 2 || value[len - 1] != '"') {
set_error(err, err_size, "unterminated quoted value");
return false;
}
memmove(value, value + 1, len - 2);
value[len - 2] = '\0';
return true;
}
DaemonConf* daemon_conf_load(const char* path, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
if (!path) {
set_error(err, err_size, "no daemon config path");
return NULL;
}
FILE* fp = fopen(path, "r");
if (!fp) {
set_error(err, err_size, "cannot open daemon config '%s': %s", path, strerror(errno));
return NULL;
}
DaemonConf* conf = daemon_conf_create();
if (!conf) {
fclose(fp);
set_error(err, err_size, "out of memory allocating daemon config");
return NULL;
}
int current_module = -1;
int line_no = 0;
char line[DAEMON_CONF_MAX_LINE + 2];
bool ok = true;
while (ok && fgets(line, sizeof(line), fp)) {
line_no++;
size_t len = strlen(line);
if (len == DAEMON_CONF_MAX_LINE + 1 && line[len - 1] != '\n') {
/* The read stopped at the buffer edge without a newline and there is
* more file to come: the line exceeds the bound. */
if (!feof(fp)) {
set_error(err, err_size, "line %d exceeds the %d-byte limit", line_no,
DAEMON_CONF_MAX_LINE);
ok = false;
break;
}
}
if (len > 0 && line[len - 1] == '\n')
line[--len] = '\0';
if (len > 0 && line[len - 1] == '\r')
line[--len] = '\0';
char* cursor = line;
while (*cursor == ' ' || *cursor == '\t')
cursor++;
if (*cursor == '\0' || *cursor == '#' || *cursor == ';')
continue; /* blank or comment line */
if (*cursor == '[') {
char* close = strchr(cursor, ']');
if (!close) {
set_error(err, err_size, "line %d: unterminated module header", line_no);
ok = false;
break;
}
*close = '\0';
char* trailing = close + 1;
const char* rest = trim_ws(trailing);
if (*rest != '\0') {
set_error(err, err_size, "line %d: unexpected text after module header", line_no);
ok = false;
break;
}
const char* name = NULL;
if (!parse_section_name(cursor + 1, &name, err, err_size)) {
ok = false;
break;
}
if (open_module(conf, &current_module, name, err, err_size) != 0) {
ok = false;
break;
}
continue;
}
char* key;
char* value;
if (!split_key_value(cursor, &key, &value)) {
set_error(err, err_size, "line %d: expected 'key = value'", line_no);
ok = false;
break;
}
if (*key == '\0') {
set_error(err, err_size, "line %d: empty key", line_no);
ok = false;
break;
}
if (!unquote_value(value, err, err_size)) {
ok = false;
break;
}
if (current_module >= 0) {
if (!apply_module_key(&conf->modules[current_module], key, value, err, err_size)) {
ok = false;
break;
}
} else {
if (!apply_global_key(conf, key, value, err, err_size)) {
ok = false;
break;
}
}
}
if (ok && ferror(fp)) {
set_error(err, err_size, "error reading daemon config '%s': %s", path, strerror(errno));
ok = false;
}
fclose(fp);
if (ok && current_module >= 0 &&
!module_open_valid(&conf->modules[current_module], err, err_size)) {
ok = false;
}
if (!ok) {
daemon_conf_free(conf);
return NULL;
}
return conf;
}
int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
if (!conf || !assignment || *assignment == '\0') {
set_error(err, err_size, "--dparam requires a KEY=VALUE override");
return -1;
}
char* copy = str_dup(assignment);
if (!copy) {
set_error(err, err_size, "out of memory parsing --dparam");
return -1;
}
char* eq = strchr(copy, '=');
if (!eq) {
free(copy);
set_error(err, err_size, "--dparam '%s' has no '=' (expected KEY=VALUE)", assignment);
return -1;
}
*eq = '\0';
char* key = trim_ws(copy);
const char* value = trim_ws(eq + 1);
if (*key == '\0') {
free(copy);
set_error(err, err_size, "--dparam '%s' has an empty key", assignment);
return -1;
}
if (*value == '\0') {
free(copy);
set_error(err, err_size, "--dparam '%s' has an empty value", assignment);
return -1;
}
bool ok = apply_global_key(conf, key, value, err, err_size);
free(copy);
return ok ? 0 : -1;
}
+92
View File
@@ -0,0 +1,92 @@
#ifndef DAEMON_CONF_H
#define DAEMON_CONF_H
#include <stdbool.h>
#include <stddef.h>
/* FastSync-native daemon configuration (a FastSync analog of rsyncd.conf).
*
* This is the config the fastsync-server --daemon listener consumes. It is
* line-based with an implicit global section followed by zero or more
* [module] sections. The full grammar is documented in RSYNC_COMPAT.md
* ("Daemon Mode") and summarized below; the parser lives entirely in
* daemon_conf.c so it can be unit tested without any socket code.
*
* The parser is STRICT: an unknown key, a malformed line, a value that does
* not parse, a module without a `path`, or a line longer than
* DAEMON_CONF_MAX_LINE all fail the whole load with a clear, line-numbered
* error instead of being silently ignored. This keeps a typo from silently
* changing what a module serves.
*/
/* A daemon module's configured root is used exactly like the standalone
* server's --destination-root: the daemon confines every connection that
* selects this module to this path (file_open_secure_parent /
* has_path_traversal / path_is_within all keep the existing confinement, just
* per-module). There is never any client-chosen root and no --super /
* --copy-as: a module path always stays confined.
*
* `auth_users` is parsed and stored now (Wave A) for Wave B to honor, but the
* presence of auth users is already enforced with a SAFE default this wave:
* because FastSync cannot yet authenticate a claimed user, a module that
* declares auth users refuses every connection (see server.c). Auth is never
* bypassed by ignoring the list. */
typedef struct DaemonModule {
char* name; /* module name, as the client requests it */
char* path; /* module root (daemon-side authorized root) */
bool read_only; /* `read only = yes/no`; default no */
char** auth_users; /* `auth users = a,b`; Wave B credential list */
int auth_user_count;
} 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 */
} DaemonConfGlobals;
typedef struct DaemonConf {
DaemonConfGlobals global;
DaemonModule* modules;
int module_count;
} DaemonConf;
#define DAEMON_CONF_DEFAULT_PORT 873
/* Longest accepted config line (excluding the trailing newline). Longer lines
* are rejected rather than buffered unboundedly. */
#define DAEMON_CONF_MAX_LINE 4096
/* Upper bound on a module name. Kept far below MAX_STRING_SIZE so a wire
* module name can never exhaust anything by being long. */
#define DAEMON_MAX_MODULE_NAME 200
/* Allocate an empty daemon config with defaulted globals (port 873, no
* modules, no motd/address). Never fails for an allocation failure; callers
* must still NULL-check. */
DaemonConf* daemon_conf_create(void);
/* Parse `path` into a freshly allocated DaemonConf. Returns NULL on any error
* and fills `err` (err_size bytes) with a clear, line-numbered message. The
* returned object is heap-owned; free it with daemon_conf_free. */
DaemonConf* daemon_conf_load(const char* path, char* err, size_t err_size);
void daemon_conf_free(DaemonConf* conf);
/* Case-sensitive exact module lookup by name. Returns the module or NULL.
* Module names are matched exactly (rsync semantics). */
const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* name);
/* Module-name syntax check: non-empty, at most DAEMON_MAX_MODULE_NAME chars,
* and only [A-Za-z0-9._-]. Used by the config parser, the client's
* host::module/path destination parser, and (implicitly) by the daemon lookup
* (a name that fails this can never match a parsed module). */
bool daemon_module_name_valid(const char* name);
/* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and
* apply it to the global scalars only. Keys are case-insensitive and limited
* to the global scalar keys defined by the grammar (port, motd file, address).
* Returns 0 on success, -1 on error (err filled). */
int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size);
#endif
+308
View File
@@ -0,0 +1,308 @@
"""Daemon mode (--daemon + module config + host::module/path destinations) tests.
These exercise the Wave A daemon foundation end to end: a fastsync-server
started with --daemon reads a FastSync-native module config file, the client
asks for a module with a host::module/path destination, and the transfer lands
in the configured module root only. Read-only modules, unknown modules, and
auth-required modules are all refused cleanly before any data moves.
"""
import glob
import os
import shutil
import signal
import subprocess
import sys
import time
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from common import (
TEST_DATA_DIR,
CLIENT_CMD,
SERVER_CMD,
generate_test_files,
run_client,
get_dest_received_dir,
verify_transfer,
_find_free_port,
_wait_for_port,
)
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "daemon_source")
MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
FILES_MODULE = os.path.join(MODULE_ROOT, "files")
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
DETACH_MODULE = os.path.join(MODULE_ROOT, "detach")
DETACH_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.conf")
DETACH_PORT = None
def _kill_by_cmdline_marker(marker):
"""Send SIGTERM to every running process whose cmdline contains `marker`
(used to clean up the double-forked --daemon, which is orphaned to init and
no longer a child of the test's own process). Portable over /proc so the
tests do not depend on pgrep being present."""
for proc_path in glob.glob("/proc/[0-9]*/cmdline"):
try:
with open(proc_path, "rb") as f:
data = f.read()
except OSError:
continue
if marker.encode() in data:
try:
os.kill(int(proc_path.split("/")[2]), signal.SIGTERM)
except (ProcessLookupError, ValueError):
pass
time.sleep(0.5)
class DaemonManager:
"""Boots one fastsync-server --daemon from a config file and tears it down
(including its accept-loop children) on exit."""
def __init__(self):
self._proc = None
self._port = None
def start(self, config_path, port_override=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.
self._port = port_override if port_override is not None else _config_port(config_path)
cmd = (SERVER_CMD + ["--daemon", "--config", config_path, "--allow-unauthenticated",
"--no-detach"])
if port_override is not None:
cmd += ["--dparam", f"port={port_override}"]
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)
_wait_for_port(self._port, timeout=10)
def stop(self):
if self._proc:
try:
os.killpg(self._proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(self._proc.pid, signal.SIGKILL)
self._proc.wait()
self._proc = None
@property
def port(self):
return self._port
def __enter__(self):
return self
def __exit__(self, *args):
self.stop()
def __del__(self):
self.stop()
def _config_port(config_path):
"""Read the explicit `port = N` line out of the daemon config file."""
with open(config_path) as f:
for line in f:
stripped = line.strip()
if stripped.startswith("port") and "=" in stripped:
return int(stripped.split("=", 1)[1].strip())
raise RuntimeError(f"no port= in {config_path}")
@pytest.fixture(scope="module", autouse=True)
def daemon_env():
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, DETACH_MODULE):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d, exist_ok=True)
generate_test_files(SOURCE_DIR, full=False)
# The config's port is a free port chosen per worker; the `daemon` fixture
# boots on it (the config-port path) and the --dparam override test boots a
# second daemon on a different port.
config_port = _find_free_port()
with open(CONF_FILE, "w") as f:
f.write(
"# FastSync-native daemon config (Wave A grammar)\n"
"port = %d\n"
"\n"
"[files]\n"
"path = %s\n"
"\n"
"[readonly]\n"
"path = %s\n"
"read only = yes\n"
"\n"
"[locked]\n"
"path = %s\n"
"auth users = alice\n"
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE))
# A dedicated config for the real (double-fork) detach test: an unique path
# lets cleanup identify and kill the orphaned background daemon by cmdline.
global DETACH_PORT
DETACH_PORT = _find_free_port()
with open(DETACH_CONF, "w") as f:
f.write("port = %d\n\n[detach]\npath = %s\n" % (DETACH_PORT, DETACH_MODULE))
yield
_kill_by_cmdline_marker(DETACH_CONF)
shutil.rmtree(MODULE_ROOT, ignore_errors=True)
shutil.rmtree(SOURCE_DIR, ignore_errors=True)
@pytest.fixture(scope="module")
def daemon():
d = DaemonManager()
d.start(CONF_FILE)
yield d
d.stop()
def _push(dest, port):
result, _ = run_client(SOURCE_DIR, dest, port=port)
return result
class TestDaemonModuleSelection:
@pytest.mark.ci
def test_module_transfer(self, daemon):
"""A host::module/path destination lands inside the module root only."""
result = _push("127.0.0.1::files", daemon.port)
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(FILES_MODULE, SOURCE_DIR)
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}"
def test_module_subtree(self, daemon):
"""The /path part of host::module/path is relative inside the module."""
sub = os.path.join(FILES_MODULE, "subtree")
os.makedirs(sub, exist_ok=True)
result = _push("127.0.0.1::files/subtree", daemon.port)
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(sub, SOURCE_DIR)
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}"
class TestDaemonRejection:
def _tree_files(self):
"""Snapshot every file path (module-relative) currently under the module
root tree, so confinement can be asserted by diff rather than by an
absolute 'empty' check (other tests legitimately populate modules)."""
files = set()
for root, _, names in os.walk(MODULE_ROOT):
for name in names:
full = os.path.join(root, name)
files.add(os.path.relpath(full, MODULE_ROOT))
return files
def test_read_only_module_blocked(self, daemon):
result = _push("127.0.0.1::readonly", daemon.port)
assert result.returncode != 0
file_count = sum(len(files) for _, _, files in os.walk(READONLY_MODULE))
assert file_count == 0, "read-only module must not receive any file"
def test_read_only_no_write_anywhere(self, daemon):
"""A refused read-only transfer must not add a single file anywhere under
the module root tree (negative confinement, not just the target)."""
before = self._tree_files()
result = _push("127.0.0.1::readonly", daemon.port)
assert result.returncode != 0
assert self._tree_files() == before, "read-only rejection wrote under the module root"
def test_unknown_module_rejected(self, daemon):
result = _push("127.0.0.1::no-such-module", daemon.port)
assert result.returncode != 0
def test_unknown_module_no_write_anywhere(self, daemon):
"""An unknown module must be refused cleanly before any file lands
anywhere beneath the module root tree."""
before = self._tree_files()
result = _push("127.0.0.1::no-such-module", daemon.port)
assert result.returncode != 0
assert self._tree_files() == before, "unknown-module rejection wrote under the module root"
def test_module_less_destination_rejected(self, daemon):
"""A daemon destination with no module name (host::/path) is refused at
parse time, before any connection payload is sent."""
result = _push("127.0.0.1::", daemon.port)
assert result.returncode != 0
result = _push("127.0.0.1::/sub", daemon.port)
assert result.returncode != 0
def test_dotdot_destination_rejected(self, daemon):
"""A '..' path expansion in the module-relative path is refused at parse
time so a client cannot escape the module root while it is still on the
client side of the wire."""
result = _push("127.0.0.1::files/../..", daemon.port)
assert result.returncode != 0
def test_auth_required_module_rejected(self, daemon):
result = _push("127.0.0.1::locked", daemon.port)
assert result.returncode != 0
file_count = sum(len(files) for _, _, files in os.walk(AUTH_MODULE))
assert file_count == 0
@pytest.mark.daemon_detach
def test_real_detach_path(self):
"""--daemon WITHOUT --no-detach double-forks a real background daemon;
a client can still transfer into the module root, and the orphaned
process is terminated cleanly (via SIGTERM after polling the port)."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.log")
log = open(log_path, "w")
cmd = SERVER_CMD + ["--daemon", "--config", DETACH_CONF, "--allow-unauthenticated"]
proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL)
try:
_wait_for_port(DETACH_PORT, timeout=15)
result = _push("127.0.0.1::detach", DETACH_PORT)
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(DETACH_MODULE, SOURCE_DIR)
_, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"missing: {missing[:5]}"
finally:
_kill_by_cmdline_marker(DETACH_CONF)
def test_plaintext_requires_allow_unauthenticated(self):
"""Secure default: a daemon started WITHOUT --allow-unauthenticated must
refuse a plaintext client (same posture as the standalone server)."""
d = DaemonManager()
port = _find_free_port()
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth.log")
log = open(log_path, "w")
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
"--dparam", f"port={port}"]
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
start_new_session=True)
d._port = port
_wait_for_port(port, timeout=10)
try:
result = _push("127.0.0.1::files", port)
assert result.returncode != 0
finally:
d.stop()
def test_dparam_port_override(self):
"""--dparam port=N overrides the config's port and the daemon serves on N."""
override = _find_free_port()
d = DaemonManager()
d.start(CONF_FILE, port_override=override)
try:
result = _push("127.0.0.1::files", override)
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()
+4
View File
@@ -6,6 +6,7 @@
#include "test_compression.h" #include "test_compression.h"
#include "test_config.h" #include "test_config.h"
#include "test_data.h" #include "test_data.h"
#include "test_daemon_conf.h"
#include "test_delay_updates.h" #include "test_delay_updates.h"
#include "test_delta.h" #include "test_delta.h"
#include "test_file.h" #include "test_file.h"
@@ -21,6 +22,7 @@
#include "test_robustness.h" #include "test_robustness.h"
#include "test_scanner.h" #include "test_scanner.h"
#include "test_server.h" #include "test_server.h"
#include "test_server_cli.h"
#include "test_shared_utils.h" #include "test_shared_utils.h"
#include "test_stress.h" #include "test_stress.h"
#include "test_transport_tcp.h" #include "test_transport_tcp.h"
@@ -68,6 +70,8 @@ int main() {
RUN_TEST(test_transport_tls); RUN_TEST(test_transport_tls);
RUN_TEST(test_client_cli); RUN_TEST(test_client_cli);
RUN_TEST(test_server); RUN_TEST(test_server);
RUN_TEST(test_daemon_conf);
RUN_TEST(test_server_cli);
RUN_TEST(test_fuzz_smoke); RUN_TEST(test_fuzz_smoke);
RUN_TEST(test_xattr); RUN_TEST(test_xattr);
+213
View File
@@ -83,6 +83,210 @@ static void test_config_ssh_dest_no_user() {
config_delete(cfg); config_delete(cfg);
} }
static void test_config_daemon_dest_parse() {
Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false,
false, 1, false, 0);
int ret = config_parse_daemon_dest(cfg);
EXPECT_EQ_INT(ret, 1);
EXPECT_EQ_INT(cfg->transport, TRANSPORT_TCP);
EXPECT_EQ_STR(cfg->server_host, "dahost");
EXPECT_EQ_STR(cfg->module, "files");
EXPECT_EQ_STR(cfg->receive_root_directory, "sub/dir");
config_delete(cfg);
}
static void test_config_daemon_dest_no_path() {
Config* cfg =
make_config("1.0", "/src", "dahost::files", true, false, false, false, false, 1, false, 0);
int ret = config_parse_daemon_dest(cfg);
EXPECT_EQ_INT(ret, 1);
EXPECT_EQ_STR(cfg->server_host, "dahost");
EXPECT_EQ_STR(cfg->module, "files");
EXPECT_EQ_STR(cfg->receive_root_directory, "");
config_delete(cfg);
}
static void test_config_daemon_dest_double_slash_normalized() {
Config* cfg = make_config("1.0", "/src", "dahost::files//sub", true, false, false, false, false,
1, false, 0);
int ret = config_parse_daemon_dest(cfg);
EXPECT_EQ_INT(ret, 1);
EXPECT_EQ_STR(cfg->module, "files");
EXPECT_EQ_STR(cfg->receive_root_directory, "sub");
config_delete(cfg);
}
static void test_config_daemon_dest_bad() {
/* Missing module name after "::". */
Config* cfg =
make_config("1.0", "/src", "dahost::", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_daemon_dest(cfg), -1);
config_delete(cfg);
/* Invalid module name. */
cfg =
make_config("1.0", "/src", "dahost::bad name", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_daemon_dest(cfg), -1);
config_delete(cfg);
/* Traversal path rejected. */
cfg = make_config("1.0", "/src", "dahost::mod/../../etc", true, false, false, false, false, 1,
false, 0);
EXPECT_EQ_INT(config_parse_daemon_dest(cfg), -1);
config_delete(cfg);
/* user@host::module is not yet supported. */
cfg =
make_config("1.0", "/src", "user@dahost::mod", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_daemon_dest(cfg), -1);
config_delete(cfg);
/* A non-daemon destination is untouched (returns 0). */
cfg = make_config("1.0", "/src", "plain:path", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_daemon_dest(cfg), 0);
EXPECT_EQ_STR(cfg->receive_root_directory, "plain:path");
config_delete(cfg);
}
static void test_config_transport_dest_daemon_beats_ssh() {
/* host::module selects daemon TCP; host:path still selects SSH. */
Config* cfg = make_config("1.0", "/src", "h::m/x", true, false, false, false, false, 1, false, 0);
int ret = config_parse_transport_dest(cfg);
EXPECT_EQ_INT(ret, 1);
EXPECT_EQ_INT(cfg->transport, TRANSPORT_TCP);
EXPECT_EQ_STR(cfg->module, "m");
config_delete(cfg);
cfg = make_config("1.0", "/src", "h:dst", true, false, false, false, false, 1, false, 0);
ret = config_parse_transport_dest(cfg);
EXPECT_EQ_INT(ret, 0);
EXPECT_EQ_INT(cfg->transport, TRANSPORT_SSH);
EXPECT_EQ_STR(cfg->receive_root_directory, "dst");
config_delete(cfg);
}
static void test_config_is_daemon_dest() {
EXPECT_TRUE(config_is_daemon_dest("host::mod"));
EXPECT_TRUE(config_is_daemon_dest("host::mod/path"));
EXPECT_FALSE(config_is_daemon_dest("host:path"));
EXPECT_FALSE(config_is_daemon_dest("/local/path"));
/* A colon inside the module-relative path does not change the detection. */
EXPECT_TRUE(config_is_daemon_dest("host::mod/single:colon"));
EXPECT_FALSE(config_is_daemon_dest(NULL));
}
static void test_config_module_wire_roundtrip() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("rel/path");
send_cfg->module = str_dup("backup");
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]);
Config* recv_cfg = config_receive(p[0]);
bool ok = recv_cfg != NULL && recv_cfg->module != NULL &&
strcmp(recv_cfg->module, "backup") == 0 &&
strcmp(recv_cfg->receive_root_directory, "rel/path") == 0;
config_delete(recv_cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
static void test_config_module_wire_empty_canonicalizes_to_null() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
/* module left NULL -> serialized as "" -> received back as NULL. */
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]);
Config* recv_cfg = config_receive(p[0]);
bool ok = recv_cfg != NULL && recv_cfg->module == NULL;
config_delete(recv_cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* A module gate that rejects any connection that names a module. */
static const char* reject_named_module_gate(const Config* config, void* context) {
(void)context;
if (config && config->module && config->module[0] != '\0')
return "test rejection";
return NULL;
}
static void test_config_receive_with_validate_rejects() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->module = str_dup("any-module");
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]);
Config* recv = config_receive_with_validate(p[0], reject_named_module_gate, NULL);
bool ok = recv == NULL;
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_FALSE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
static void test_pipeline_sender_lifecycle() { static void test_pipeline_sender_lifecycle() {
Config* cfg = make_config("2.0", "/src2", "/dst2", false, false, true, true, false, 1, false, 0); Config* cfg = make_config("2.0", "/src2", "/dst2", false, false, true, true, false, 1, false, 0);
Queue* q1 = queue_create(5, NULL); Queue* q1 = queue_create(5, NULL);
@@ -1284,6 +1488,12 @@ void test_config() {
test_config_ssh_dest(); test_config_ssh_dest();
test_config_ssh_dest_local_path(); test_config_ssh_dest_local_path();
test_config_ssh_dest_no_user(); test_config_ssh_dest_no_user();
test_config_daemon_dest_parse();
test_config_daemon_dest_no_path();
test_config_daemon_dest_double_slash_normalized();
test_config_daemon_dest_bad();
test_config_transport_dest_daemon_beats_ssh();
test_config_is_daemon_dest();
test_config_trust_sender_default_false(); test_config_trust_sender_default_false();
test_pipeline_sender_lifecycle(); test_pipeline_sender_lifecycle();
test_pipeline_receiver_lifecycle(); test_pipeline_receiver_lifecycle();
@@ -1312,6 +1522,9 @@ void test_config() {
test_config_devices_wire_roundtrip(); test_config_devices_wire_roundtrip();
test_config_preallocate_wire_roundtrip(); test_config_preallocate_wire_roundtrip();
test_config_phase4_xattr_wire_roundtrip(); test_config_phase4_xattr_wire_roundtrip();
test_config_module_wire_roundtrip();
test_config_module_wire_empty_canonicalizes_to_null();
test_config_receive_with_validate_rejects();
} }
test_config_delete_timing_early_helper(); test_config_delete_timing_early_helper();
test_config_is_remote_dest(); test_config_is_remote_dest();
+343
View File
@@ -0,0 +1,343 @@
#include "test_daemon_conf.h"
#include "daemon_conf.h"
#include "test_utils.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* Write a config body into a fresh temp file and return its path in out_path
* (heap-allocated; caller frees). Returns 0 on success. */
static int write_conf(const char* body, char** out_path) {
char tmpl[] = "/tmp/fastsync_daemon_conf_XXXXXX";
int fd = mkstemp(tmpl);
if (fd < 0)
return -1;
size_t len = strlen(body);
if (write(fd, body, len) != (ssize_t)len) {
close(fd);
unlink(tmpl);
return -1;
}
close(fd);
*out_path = strdup(tmpl);
return *out_path ? 0 : -1;
}
static void test_daemon_conf_create_defaults() {
DaemonConf* conf = daemon_conf_create();
EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.port, DAEMON_CONF_DEFAULT_PORT);
EXPECT_NULL(conf->global.motd_file);
EXPECT_NULL(conf->global.address);
EXPECT_EQ_INT(conf->module_count, 0);
daemon_conf_free(conf);
}
static void test_daemon_conf_full_parse() {
char* path;
EXPECT_EQ_INT(write_conf("port = 8734\n"
"motd file = /etc/fastsync/motd\n"
"address = 127.0.0.1\n"
"\n"
"[backup]\n"
"path = /srv/backup\n"
"read only = yes\n"
"auth users = alice, bob\n",
&path),
0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.port, 8734);
EXPECT_EQ_STR(conf->global.motd_file, "/etc/fastsync/motd");
EXPECT_EQ_STR(conf->global.address, "127.0.0.1");
EXPECT_EQ_INT(conf->module_count, 1);
EXPECT_EQ_STR(conf->modules[0].name, "backup");
EXPECT_EQ_STR(conf->modules[0].path, "/srv/backup");
EXPECT_TRUE(conf->modules[0].read_only);
EXPECT_EQ_INT(conf->modules[0].auth_user_count, 2);
EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice");
EXPECT_EQ_STR(conf->modules[0].auth_users[1], "bob");
daemon_conf_free(conf);
}
static void test_daemon_conf_comments_and_blank_lines() {
char* path;
EXPECT_EQ_INT(write_conf("# a full-line comment\n"
"; a semicolon comment\n"
" # indented comment\n"
" \n"
"\t; another\n"
"[alpha]\n"
"path = /a\n"
"\n"
"[beta]\n"
"path = /b\n",
&path),
0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->module_count, 2);
EXPECT_EQ_STR(conf->modules[0].name, "alpha");
EXPECT_EQ_STR(conf->modules[1].name, "beta");
daemon_conf_free(conf);
}
static void test_daemon_conf_case_insensitive_and_bool_variants() {
char* path;
EXPECT_EQ_INT(write_conf("PORT = 9001\n"
"[CaseMod]\n"
"PATH = /cm\n"
"READ ONLY = True\n",
&path),
0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.port, 9001);
EXPECT_EQ_INT(conf->module_count, 1);
EXPECT_EQ_STR(conf->modules[0].name, "CaseMod");
EXPECT_TRUE(conf->modules[0].read_only);
daemon_conf_free(conf);
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nread only = 0\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_FALSE(conf->modules[0].read_only);
daemon_conf_free(conf);
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nread only = false\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_FALSE(conf->modules[0].read_only);
daemon_conf_free(conf);
}
static void test_daemon_conf_quoted_value() {
char* path;
EXPECT_EQ_INT(write_conf("[m]\npath = \"/srv/my dir/mod\"\n", &path), 0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_STR(conf->modules[0].path, "/srv/my dir/mod");
daemon_conf_free(conf);
}
static void test_daemon_conf_global_defaults_when_absent() {
char* path;
EXPECT_EQ_INT(write_conf("[m]\npath = /x\n", &path), 0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.port, DAEMON_CONF_DEFAULT_PORT); /* 873 */
EXPECT_NULL(conf->global.motd_file);
EXPECT_NULL(conf->global.address);
daemon_conf_free(conf);
}
static void test_daemon_conf_unknown_key_rejected() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("bogus_key = 1\n", &path), 0);
const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "unknown global key") != NULL);
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nflavor = van\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "unknown key 'flavor'") != NULL);
}
static void test_daemon_conf_malformed_rejected() {
char* path;
char err[256];
const DaemonConf* conf;
EXPECT_EQ_INT(write_conf("port 8734\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "expected 'key = value'") != NULL);
EXPECT_EQ_INT(write_conf("[m]\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "no 'path'") != NULL);
EXPECT_EQ_INT(write_conf("[m\npath = /x\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "unterminated module header") != NULL);
EXPECT_EQ_INT(write_conf("[m] trailing\npath = /x\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "after module header") != NULL);
EXPECT_EQ_INT(write_conf("port = notanumber\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid port") != NULL);
EXPECT_EQ_INT(write_conf("port = 70000\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid port") != NULL);
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nread only = maybe\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "read only") != NULL);
EXPECT_EQ_INT(write_conf("= value\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "empty key") != NULL);
EXPECT_EQ_INT(write_conf("[]\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "invalid module name") != NULL);
EXPECT_EQ_INT(write_conf("[bad/name]\npath = /x\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_EQ_INT(write_conf("[m]\npath = \"/unterminated\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "unterminated quoted value") != NULL);
}
static void test_daemon_conf_duplicate_module_rejected() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("[m]\npath = /a\n[m]\npath = /b\n", &path), 0);
const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "duplicate module") != NULL);
}
static void test_daemon_conf_long_line_rejected() {
char* path;
char err[256];
char body[4600];
memset(body, 'a', sizeof(body) - 1);
memcpy(body, "[m]\npath = /x\nport = ", 21);
body[sizeof(body) - 1] = '\0';
EXPECT_EQ_INT(write_conf(body, &path), 0);
const DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "exceeds the") != NULL);
}
static void test_daemon_conf_missing_file_rejected() {
char err[256];
const DaemonConf* conf =
daemon_conf_load("/nonexistent/fastsync_daemon_conf_zzz", err, sizeof(err));
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "cannot open") != NULL);
}
static void test_daemon_conf_find_module() {
char* path;
EXPECT_EQ_INT(write_conf("[known]\npath = /rooted\n[m]\npath = /other\n", &path), 0);
char err[256];
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
const DaemonModule* found = daemon_conf_find_module(conf, "known");
EXPECT_NOT_NULL(found);
EXPECT_EQ_STR(found->path, "/rooted");
EXPECT_NULL(daemon_conf_find_module(conf, "nope"));
/* Case-sensitive like rsync module names. */
EXPECT_NULL(daemon_conf_find_module(conf, "Known"));
daemon_conf_free(conf);
}
static void test_daemon_conf_dparam_override() {
DaemonConf* conf = daemon_conf_create();
EXPECT_NOT_NULL(conf);
char err[256];
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=8734", err, sizeof(err)), 0);
EXPECT_EQ_INT(conf->global.port, 8734);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "motd file=/tmp/motd", err, sizeof(err)), 0);
EXPECT_EQ_STR(conf->global.motd_file, "/tmp/motd");
/* Keys are case-insensitive. */
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "ADDRESS=127.0.0.1", err, sizeof(err)), 0);
EXPECT_EQ_STR(conf->global.address, "127.0.0.1");
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port = 9000", err, sizeof(err)), 0);
EXPECT_EQ_INT(conf->global.port, 9000);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=notaport", err, sizeof(err)), -1);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "bogus=1", err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "unknown global key") != NULL);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port", err, sizeof(err)), -1);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "=1", err, sizeof(err)), -1);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "port=", err, sizeof(err)), -1);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "", err, sizeof(err)), -1);
daemon_conf_free(conf);
}
static void test_daemon_module_name_valid() {
EXPECT_TRUE(daemon_module_name_valid("backup"));
EXPECT_TRUE(daemon_module_name_valid("Backup_2"));
EXPECT_TRUE(daemon_module_name_valid("a.b-c"));
EXPECT_FALSE(daemon_module_name_valid(""));
EXPECT_FALSE(daemon_module_name_valid("with space"));
EXPECT_FALSE(daemon_module_name_valid("with/slash"));
EXPECT_FALSE(daemon_module_name_valid("with\t\ttab"));
EXPECT_FALSE(daemon_module_name_valid("bracket]"));
{
char long_name[DAEMON_MAX_MODULE_NAME + 2];
memset(long_name, 'a', sizeof(long_name) - 1);
long_name[sizeof(long_name) - 1] = '\0';
EXPECT_FALSE(daemon_module_name_valid(long_name));
}
}
void test_daemon_conf() {
test_daemon_conf_create_defaults();
test_daemon_conf_full_parse();
test_daemon_conf_comments_and_blank_lines();
test_daemon_conf_case_insensitive_and_bool_variants();
test_daemon_conf_quoted_value();
test_daemon_conf_global_defaults_when_absent();
test_daemon_conf_unknown_key_rejected();
test_daemon_conf_malformed_rejected();
test_daemon_conf_duplicate_module_rejected();
test_daemon_conf_long_line_rejected();
test_daemon_conf_missing_file_rejected();
test_daemon_conf_find_module();
test_daemon_conf_dparam_override();
test_daemon_module_name_valid();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_DAEMON_CONF_H
#define TEST_DAEMON_CONF_H
void test_daemon_conf();
#endif
+161
View File
@@ -0,0 +1,161 @@
#include "test_server_cli.h"
#include "server_cli.h"
#include "test_utils.h"
#include <string.h>
#include <sys/socket.h>
static int parse_ok(const char* const* args, int count, ServerCliOptions* opts) {
char err[512];
int r = server_cli_parse(count, (char**)args, opts, err, sizeof(err));
if (r == 0)
return 0;
if (r < 0)
return -1;
return 1;
}
static void test_server_cli_defaults() {
const char* args[] = {"fastsync-server"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 1, &opts), 0);
EXPECT_FALSE(opts.stdio_mode);
EXPECT_FALSE(opts.daemon_mode);
EXPECT_FALSE(opts.no_detach);
EXPECT_FALSE(opts.verbose);
EXPECT_FALSE(opts.use_tls);
EXPECT_EQ_INT(opts.port, 8080);
EXPECT_FALSE(opts.port_set);
EXPECT_EQ_STR(opts.destination_root, ".");
EXPECT_NULL(opts.config_path);
EXPECT_EQ_INT(opts.dparam_count, 0);
EXPECT_EQ_INT(opts.bind_family, AF_UNSPEC);
EXPECT_FALSE(opts.allow_delete);
EXPECT_FALSE(opts.allow_unauthenticated);
server_cli_options_free(&opts);
}
static void test_server_cli_daemon_flags() {
const char* args[] = {"fastsync-server", "--daemon", "--no-detach", "--allow-unauthenticated"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 4, &opts), 0);
EXPECT_TRUE(opts.daemon_mode);
EXPECT_TRUE(opts.no_detach);
EXPECT_TRUE(opts.allow_unauthenticated);
server_cli_options_free(&opts);
}
static void test_server_cli_config_and_dparam_forms() {
const char* args[] = {"fastsync-server", "--daemon", "--config=/tmp/x.conf",
"--dparam=port=8734", "--dparam", "address=127.0.0.1"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 6, &opts), 0);
EXPECT_EQ_STR(opts.config_path, "/tmp/x.conf");
EXPECT_EQ_INT(opts.dparam_count, 2);
EXPECT_EQ_STR(opts.dparams[0], "port=8734");
EXPECT_EQ_STR(opts.dparams[1], "address=127.0.0.1");
const char* args2[] = {"fastsync-server", "--daemon", "--config", "/tmp/y.conf"};
ServerCliOptions opts2;
EXPECT_EQ_INT(parse_ok(args2, 4, &opts2), 0);
EXPECT_EQ_STR(opts2.config_path, "/tmp/y.conf");
server_cli_options_free(&opts);
server_cli_options_free(&opts2);
}
static void test_server_cli_preserves_existing_flags() {
const char* args[] = {"fastsync-server",
"-p",
"9000",
"--tls",
"--cert",
"/c",
"--key",
"/k",
"--ca",
"/ca",
"--client-cn",
"cn",
"--allow-delete",
"--trust-sender",
"--address",
"127.0.0.1",
"-6",
"--destination-root",
"/srv"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 19, &opts), 0);
EXPECT_EQ_INT(opts.port, 9000);
EXPECT_TRUE(opts.port_set);
EXPECT_TRUE(opts.use_tls);
EXPECT_EQ_STR(opts.tls_cert, "/c");
EXPECT_EQ_STR(opts.tls_key, "/k");
EXPECT_EQ_STR(opts.tls_ca, "/ca");
EXPECT_EQ_STR(opts.client_cn, "cn");
EXPECT_TRUE(opts.allow_delete);
EXPECT_TRUE(opts.trust_sender);
EXPECT_EQ_STR(opts.bind_address, "127.0.0.1");
EXPECT_EQ_INT(opts.bind_family, AF_INET6);
EXPECT_TRUE(opts.destination_root_set);
EXPECT_EQ_STR(opts.destination_root, "/srv");
server_cli_options_free(&opts);
}
static void test_server_cli_conflicts() {
char err[256];
ServerCliOptions opts;
const char* a1[] = {"s", "--daemon", "--stdio"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "mutually exclusive") != NULL);
const char* a2[] = {"s", "--daemon", "--destination-root", "/x"};
EXPECT_EQ_INT(server_cli_parse(4, (char**)a2, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "module paths") != NULL);
const char* a3[] = {"s", "--config", "/x.conf"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a3, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
const char* a4[] = {"s", "--no-detach"};
EXPECT_EQ_INT(server_cli_parse(2, (char**)a4, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
server_cli_options_free(&opts);
}
static void test_server_cli_invalid() {
char err[256];
ServerCliOptions opts;
const char* a1[] = {"s", "-p", "notaport"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "invalid port") != NULL);
const char* a2[] = {"s", "-4", "-6"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a2, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "mutually exclusive") != NULL);
const char* a3[] = {"s", "--nope"};
EXPECT_EQ_INT(server_cli_parse(2, (char**)a3, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "unknown option") != NULL);
const char* a4[] = {"s", "--cert"};
EXPECT_EQ_INT(server_cli_parse(2, (char**)a4, &opts, err, sizeof(err)), -1);
server_cli_options_free(&opts);
}
static void test_server_cli_help() {
char err[256];
const char* a1[] = {"s", "--help"};
ServerCliOptions opts;
EXPECT_EQ_INT(server_cli_parse(2, (char**)a1, &opts, err, sizeof(err)), 1);
EXPECT_TRUE(opts.show_help);
server_cli_options_free(&opts);
}
void test_server_cli() {
test_server_cli_defaults();
test_server_cli_daemon_flags();
test_server_cli_config_and_dparam_forms();
test_server_cli_preserves_existing_flags();
test_server_cli_conflicts();
test_server_cli_invalid();
test_server_cli_help();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_SERVER_CLI_H
#define TEST_SERVER_CLI_H
void test_server_cli();
#endif