diff --git a/CMakeLists.txt b/CMakeLists.txt index f9b2dcc..54dafde 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,7 +82,7 @@ set(TEST_INCLUDES tests src/shared src/server src/client) # Monolithic test binary (backward compatible) 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_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD) target_link_libraries(tests PRIVATE ${TEST_LIBS}) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index bd89bdf..faa144c 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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) | | `--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 | | `--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** | @@ -622,13 +622,22 @@ now transmits targets (the prior behavior was broken/partial); its status moved | Flag | Rsync Description | FastSync Status | Notes | |------|-------------------|-----------------|-------| -| `--daemon` | Run as rsync daemon | ❌ Not Implemented | Removed because it had no effect | -| `--config=FILE` | Alternate rsyncd.conf file | ❌ Not Implemented | Removed because it had no effect | -| `--dparam=OVERRIDE` | Override global daemon config | ❌ Not Implemented | | -| `--no-detach` | Don't detach from parent | ❌ Not Implemented | | +| `--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 | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` | +| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` | +| `--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 | | | `--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 | Flag | Rsync Description | FastSync Status | Notes | diff --git a/pytest.ini b/pytest.ini index 9047220..9cd9e47 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,3 +5,6 @@ markers = setpriv: privilege-dependent tests (drop to an unprivileged user); excluded from CI because their result depends on the runner/container uid and the 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 diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 7597cb4..6841193 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1485,7 +1485,6 @@ int main(int argc, char* argv[]) { goto cleanup; } config->save_to_disk = true; - config_parse_ssh_dest(config); } else if (positional_count == 1) { log_message(LOG_LEVEL_ERROR, "missing destination argument"); 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)) { exit_code = 1; goto cleanup; diff --git a/src/client/usage.c b/src/client/usage.c index c7eaa4f..e0de670 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -11,6 +11,9 @@ void print_usage(void) { printf("Destination formats:\n"); printf(" user@host:/path SSH transport (rsync-style)\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("\n"); printf("Options:\n"); diff --git a/src/server/server.c b/src/server/server.c index f105c3b..73eb02e 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -1,4 +1,5 @@ #include "config.h" +#include "daemon_conf.h" #include "delay_updates.h" #include "file.h" #include "identity.h" @@ -7,6 +8,7 @@ #include "protocol.h" #include "queue.h" #include "receiver.h" +#include "server_cli.h" #include "transport_tcp.h" #include "transport_tls.h" #include "utils.h" @@ -17,6 +19,8 @@ #include #include #include +#include +#include #include static char* authorized_root; @@ -26,6 +30,11 @@ static bool trust_sender; static bool allow_unauthenticated; 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 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 @@ -80,7 +89,7 @@ static bool ensure_receive_root(const Config* config) { 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]; if (!root) { file_set_authorized_root(-1, NULL); @@ -123,13 +132,77 @@ static bool __attribute__((unused)) configure_authorization(const char* root) { 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 : ""); + 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) { SSL* ssl = io_get_ssl(); ProtocolSession session; protocol_session_init(&session, file_descriptor, file_descriptor); protocol_session_set_ssl(&session, ssl); 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) { log_message(LOG_LEVEL_ERROR, "Failed to receive config"); close(file_descriptor); @@ -157,6 +230,19 @@ void handler(int file_descriptor) { close(file_descriptor); 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* joined_destination = NULL; if (destination && destination[0] != '/') @@ -336,6 +422,8 @@ static void cleanup(int sig) { (void)sig; if (g_server) server_delete(&g_server); + daemon_conf_free(g_daemon_conf); + g_daemon_conf = NULL; _exit(0); } @@ -344,6 +432,14 @@ static void print_server_usage(void) { printf("Usage: fastsync-server [options]\n\n"); printf("Options:\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 TCP port (default: 8080, range: 1-65535)\n"); printf(" --tls Enable TLS encryption\n"); printf(" --cert TLS certificate file (PEM)\n"); @@ -361,95 +457,146 @@ static void print_server_usage(void) { printf(" --help Show this help\n"); } -int main(int argc, char* argv[]) { - bool use_tls = false; - char *tls_cert = NULL, *tls_key = NULL, *tls_ca = NULL; - int port = 8080; - const char* destination_root = "."; - bool stdio_mode = false; - const char* bind_address = NULL; - int bind_family = AF_UNSPEC; - - signal(SIGPIPE, SIG_IGN); - 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 : ""); - 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 : ""); - free(escaped); - print_server_usage(); - return 1; - } +/* Resolve the daemon config default: ~/.config/fastsync/fastsyncd.conf when it + * exists (or when HOME is set), otherwise /etc/fastsyncd.conf. Returns a + * pointer to a static buffer (never NULL). */ +static const char* default_daemon_config_path(void) { + const char* home = getenv("HOME"); + if (home && *home) { + static char user_path[PATH_MAX]; + 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) + return user_path; } - if (tls_ca && !use_tls) - log_message(LOG_LEVEL_WARNING, "--ca has no effect without --tls"); - signal(SIGINT, cleanup); - signal(SIGTERM, cleanup); - if (!configure_authorization(destination_root)) { - char* escaped = output_escape(destination_root, false); - fprintf(stderr, "Error: invalid destination root '%s'\n", - escaped ? escaped : ""); - free(escaped); + /* Fall back to the traditional system path. */ + return "/etc/fastsyncd.conf"; +} + +/* Detach from the controlling terminal: fork, exit the parent, and make the + * surviving child a session leader (setsid) with stdio redirected to + * /dev/null. The listening socket is already open (bound in main before this + * runs), so it is inherited by the background daemon. Returns true on + * 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; } - 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. */ 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 : ""); + free(escaped); + server_cli_options_free(&opts); + return 1; + } io_set_fds(STDIN_FILENO, STDOUT_FILENO); handler(STDIN_FILENO); release_authorization(); + server_cli_options_free(&opts); 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 : ""); + free(escaped); + server_cli_options_free(&opts); + return 1; + } + } + ServerBindOptions bind_opts; bind_opts.bind_address = bind_address; bind_opts.family = bind_family; @@ -457,28 +604,51 @@ int main(int argc, char* argv[]) { if (!g_server) { log_message(LOG_LEVEL_ERROR, "Failed to create server"); release_authorization(); - return 1; + exit_code = 1; + goto out; } - if (use_tls) { - if (!tls_cert || !tls_key || !tls_ca || !required_client_cn) { + if (opts.use_tls) { + if (!opts.tls_cert || !opts.tls_key || !opts.tls_ca || !opts.client_cn) { fprintf(stderr, "Error: --tls requires --cert, --key, --ca, and --client-cn\n"); server_delete(&g_server); release_authorization(); - return 1; + exit_code = 1; + goto out; } 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"); server_delete(&g_server); 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); release_authorization(); - return 0; + +out: + daemon_conf_free(g_daemon_conf); + g_daemon_conf = NULL; + server_cli_options_free(&opts); + return exit_code; } #endif diff --git a/src/server/server_cli.c b/src/server/server_cli.c new file mode 100644 index 0000000..460389a --- /dev/null +++ b/src/server/server_cli.c @@ -0,0 +1,206 @@ +#include "server_cli.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include + +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 : ""); + 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 : ""); + 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; +} \ No newline at end of file diff --git a/src/server/server_cli.h b/src/server/server_cli.h new file mode 100644 index 0000000..5a7ddfa --- /dev/null +++ b/src/server/server_cli.h @@ -0,0 +1,48 @@ +#ifndef SERVER_CLI_H +#define SERVER_CLI_H + +#include +#include + +/* 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 diff --git a/src/shared/config.c b/src/shared/config.c index a252819..c7423da 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -1,5 +1,6 @@ #include "config.h" #include "chmod.h" +#include "daemon_conf.h" #include "delay_updates.h" #include "delta.h" #include "file_list.h" @@ -36,6 +37,7 @@ static void config_set_defaults(Config* config) { config->ssh_port = 22; config->transport = TRANSPORT_TCP; config->ssh_destination = NULL; + config->module = NULL; config->fastsync_server_path = NULL; config->exclude_patterns = NULL; config->exclude_count = 0; @@ -470,6 +472,120 @@ bool config_is_remote_dest(const char* s) { 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 : ""); + 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) { if (!config_is_remote_dest(config->receive_root_directory)) return; @@ -492,6 +608,7 @@ void config_delete(Config* config) { free(config->send_directory); free(config->receive_root_directory); free(config->ssh_destination); + free(config->module); free(config->fastsync_server_path); for (int i = 0; i < config->exclude_count; i++) free(config->exclude_patterns[i]); @@ -940,6 +1057,37 @@ static bool receive_phase4_xattr_options(int fd, Config* c) { 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) { protocol_session_set_max_alloc(NULL, config->max_alloc); 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_metadata_times_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; Status status; if (!receive_status(file_descriptor, &status)) @@ -963,7 +1112,8 @@ bool config_send(int file_descriptor, const Config* config) { return true; } -Config* config_receive(int file_descriptor) { +Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate, + void* context) { Config* config = config_create(); if (!config) return NULL; @@ -990,7 +1140,8 @@ Config* config_receive(int file_descriptor) { !receive_identity_options(file_descriptor, config) || !receive_metadata_times_options(file_descriptor, config) || !receive_symlink_trust_options(file_descriptor, config) || - !receive_phase4_xattr_options(file_descriptor, config)) + !receive_phase4_xattr_options(file_descriptor, config) || + !receive_daemon_module(file_descriptor, config)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { @@ -1006,6 +1157,17 @@ Config* config_receive(int file_descriptor) { send_status(file_descriptor, STATUS_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)) goto error; return config; @@ -1014,3 +1176,7 @@ error: config_delete(config); return NULL; } + +Config* config_receive(int file_descriptor) { + return config_receive_with_validate(file_descriptor, NULL, NULL); +} diff --git a/src/shared/config.h b/src/shared/config.h index 1a2b0cf..ee5859e 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -89,6 +89,12 @@ typedef struct Config { int ssh_port; TransportType transport; char* ssh_destination; + /* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a + * host::module/path destination; NULL or "" means "no module" (the ordinary + * standalone-server path). Crosses the wire as a trailing config-frame + * string so the daemon can look the module up in its own config and confine + * the connection to the module's root (never a client-chosen root). */ + char* module; char* fastsync_server_path; char** exclude_patterns; int exclude_count; @@ -438,7 +444,22 @@ typedef struct Config { * fails the version check cleanly up front (rather than the remote server * rejecting an unfamiliar forwarded argv at a confusing later point), which is * 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) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 @@ -458,6 +479,38 @@ Config* config_receive(int file_descriptor); bool config_is_remote_dest(const char* s); 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 * 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 diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c new file mode 100644 index 0000000..60e3d27 --- /dev/null +++ b/src/shared/daemon_conf.c @@ -0,0 +1,443 @@ +#include "daemon_conf.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 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, ¤t_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; +} diff --git a/src/shared/daemon_conf.h b/src/shared/daemon_conf.h new file mode 100644 index 0000000..0bd5034 --- /dev/null +++ b/src/shared/daemon_conf.h @@ -0,0 +1,92 @@ +#ifndef DAEMON_CONF_H +#define DAEMON_CONF_H + +#include +#include + +/* 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 diff --git a/tests/integration/test_daemon.py b/tests/integration/test_daemon.py new file mode 100644 index 0000000..262802f --- /dev/null +++ b/tests/integration/test_daemon.py @@ -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() \ No newline at end of file diff --git a/tests/runner.c b/tests/runner.c index 572d012..b45b50c 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -6,6 +6,7 @@ #include "test_compression.h" #include "test_config.h" #include "test_data.h" +#include "test_daemon_conf.h" #include "test_delay_updates.h" #include "test_delta.h" #include "test_file.h" @@ -21,6 +22,7 @@ #include "test_robustness.h" #include "test_scanner.h" #include "test_server.h" +#include "test_server_cli.h" #include "test_shared_utils.h" #include "test_stress.h" #include "test_transport_tcp.h" @@ -68,6 +70,8 @@ int main() { RUN_TEST(test_transport_tls); RUN_TEST(test_client_cli); RUN_TEST(test_server); + RUN_TEST(test_daemon_conf); + RUN_TEST(test_server_cli); RUN_TEST(test_fuzz_smoke); RUN_TEST(test_xattr); diff --git a/tests/test_config.c b/tests/test_config.c index f00aa2b..b7a681e 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -83,6 +83,210 @@ static void test_config_ssh_dest_no_user() { 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() { Config* cfg = make_config("2.0", "/src2", "/dst2", false, false, true, true, false, 1, false, 0); Queue* q1 = queue_create(5, NULL); @@ -1284,6 +1488,12 @@ void test_config() { test_config_ssh_dest(); test_config_ssh_dest_local_path(); 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_pipeline_sender_lifecycle(); test_pipeline_receiver_lifecycle(); @@ -1312,6 +1522,9 @@ void test_config() { test_config_devices_wire_roundtrip(); test_config_preallocate_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_is_remote_dest(); diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c new file mode 100644 index 0000000..c6a7211 --- /dev/null +++ b/tests/test_daemon_conf.c @@ -0,0 +1,343 @@ +#include "test_daemon_conf.h" +#include "daemon_conf.h" +#include "test_utils.h" +#include +#include +#include +#include + +/* 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(); +} \ No newline at end of file diff --git a/tests/test_daemon_conf.h b/tests/test_daemon_conf.h new file mode 100644 index 0000000..d2dab46 --- /dev/null +++ b/tests/test_daemon_conf.h @@ -0,0 +1,6 @@ +#ifndef TEST_DAEMON_CONF_H +#define TEST_DAEMON_CONF_H + +void test_daemon_conf(); + +#endif \ No newline at end of file diff --git a/tests/test_server_cli.c b/tests/test_server_cli.c new file mode 100644 index 0000000..5cfb197 --- /dev/null +++ b/tests/test_server_cli.c @@ -0,0 +1,161 @@ +#include "test_server_cli.h" +#include "server_cli.h" +#include "test_utils.h" +#include +#include + +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(); +} \ No newline at end of file diff --git a/tests/test_server_cli.h b/tests/test_server_cli.h new file mode 100644 index 0000000..5023a65 --- /dev/null +++ b/tests/test_server_cli.h @@ -0,0 +1,6 @@ +#ifndef TEST_SERVER_CLI_H +#define TEST_SERVER_CLI_H + +void test_server_cli(); + +#endif \ No newline at end of file