diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 44f4baa..e2ab969 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -629,7 +629,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved | `--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 | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server refuse to load a `--password-file`/`--early-input` that grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat | +| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat | | `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) | **Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding. diff --git a/src/client/usage.c b/src/client/usage.c index 0da87ba..f63a5c3 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -246,8 +246,8 @@ void print_usage(void) { printf(" -T, --temp-dir Scratch dir for temp files before atomic install\n"); printf(" --fastsync-server-path \n"); printf(" Path to fastsync-server on remote (default: fastsync-server)\n"); - printf( - " --old-args Disable safe SSH command argument quoting (legacy compatibility)\n"); + printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n"); + printf(" remote server path is always safely quoted now)\n"); printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n"); printf(" (repeatable; each value is single-quote-escaped on the remote\n"); printf(" command line; empty values and values with control characters\n"); diff --git a/src/server/server.c b/src/server/server.c index 9c0058b..80d367b 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -257,10 +257,11 @@ static const char* server_module_gate(const Config* config, void* context) { standalone/SSH server has a single operator-authorized root and keeps honoring these. */ if (!module->client_owner) { - /* Ownership: refuse the whole transfer up front (a clear failure). Evaluated - against the effective copy (so an operator --no-super has already - neutralized an explicit --super), exactly as before. */ - if (identity_ownership_requested(&effective)) { + /* Ownership: refuse the whole transfer up front (a clear failure). + Evaluated against the ORIGINAL config so an explicit --super is refused + even when an operator --no-super veto already forced the effective copy + to OFF (the veto must not silently convert a refusal into an accept). */ + if (identity_ownership_requested(config)) { log_message(LOG_LEVEL_ERROR, "daemon module '%s' refuses client-chosen ownership/super-user activities " "(no `client owner = yes` opt-in); refusing", diff --git a/src/shared/credentials.c b/src/shared/credentials.c index 79d600b..fa1088f 100644 --- a/src/shared/credentials.c +++ b/src/shared/credentials.c @@ -2,6 +2,7 @@ #include "utils.h" #include #include +#include #include #include #include @@ -9,6 +10,7 @@ #include #include #include +#include /* One store entry: a username and its password's SHA-256 hex digest. The * plaintext password never appears here (and never on the daemon host). */ @@ -36,21 +38,44 @@ static bool is_comment_char(char c) { return c == '#' || c == ';'; } -/* A --password-file / --early-input carries plaintext or credential material - * and must not be accessible to group or other, mirroring the TLS private-key - * check in transport_tls.c. Reject any group/other permission bit (including - * execute) with a clear error. A stat failure is left for the caller's fopen - * to report, so a missing file keeps its existing "cannot open" message. */ -static bool secret_file_is_private(const char* path, char* err, size_t err_size) { - struct stat st; - if (stat(path, &st) != 0) - return true; - if (!S_ISREG(st.st_mode) || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) { - set_error(err, err_size, - "refusing to read secret file '%s': permissions must be owner-only (0600)", path); - return false; +/* Open a --password-file / --early-input after verifying the EXACT inode we + * will read: it must be owned by the effective user and grant no group/other + * permission bit (mode 0600), mirroring the TLS private-key check. We open by + * path and then fstat the resulting fd (rather than stat()ing the path first + * and reopening it), so the permission decision is made on the same inode that + * is read and cannot be raced by swapping the path between check and open. + * The path may be a process-substitution pipe (`<(...)` -> /dev/fd/N), so + * regular files and FIFOs are accepted when the ownership/mode checks pass. + * + * Returns a FILE* the caller must fclose, or NULL with `err` filled. */ +static FILE* secret_file_open(const char* path, char* err, size_t err_size) { + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + set_error(err, err_size, "cannot open secret file '%s': %s", path, strerror(errno)); + return NULL; } - return true; + struct stat st; + if (fstat(fd, &st) != 0) { + set_error(err, err_size, "cannot stat secret file '%s': %s", path, strerror(errno)); + close(fd); + return NULL; + } + bool is_readable_kind = S_ISREG(st.st_mode) || S_ISFIFO(st.st_mode); + if (!is_readable_kind || st.st_uid != geteuid() || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) { + set_error(err, err_size, + "refusing to read secret file '%s': it must be owned by the current user and " + "owner-only (0600), not accessible to group/other", + path); + close(fd); + return NULL; + } + FILE* fp = fdopen(fd, "r"); + if (!fp) { + set_error(err, err_size, "cannot read secret file '%s': %s", path, strerror(errno)); + close(fd); + return NULL; + } + return fp; } /* Trim leading/trailing ASCII space and tab in place; returns the new start. */ @@ -142,14 +167,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_ if (!path) return store; - if (!secret_file_is_private(path, err, err_size)) { - credentials_free(store); - return NULL; - } - - FILE* fp = fopen(path, "r"); + FILE* fp = secret_file_open(path, err, err_size); if (!fp) { - set_error(err, err_size, "cannot open credential file '%s': %s", path, strerror(errno)); credentials_free(store); return NULL; } @@ -328,13 +347,9 @@ int credentials_read_secret_file(const char* path, char** user_out, char** passw set_error(err, err_size, "no --password-file path"); return -1; } - if (!secret_file_is_private(path, err, err_size)) + FILE* fp = secret_file_open(path, err, err_size); + if (!fp) return -1; - FILE* fp = fopen(path, "r"); - if (!fp) { - set_error(err, err_size, "cannot open password file '%s': %s", path, strerror(errno)); - return -1; - } int line_no = 0; char line[CREDENTIAL_MAX_LINE + 2]; diff --git a/src/shared/file.c b/src/shared/file.c index 70efd9d..6220967 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -930,9 +930,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, int prealloc_rc = 0; if (preallocate && !sparse && data_size > 0) { prealloc_rc = preallocate_fd(fd, data_size); - if (prealloc_rc != 0) - log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, - strerror(prealloc_rc)); + if (prealloc_rc != 0) { + char* escaped_path = output_escape(path, log_get_8_bit_output()); + log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", + escaped_path ? escaped_path : "", strerror(prealloc_rc)); + free(escaped_path); + } } if (prealloc_rc == 0) { /* posix_fallocate does not guarantee the fd's file offset is left @@ -1037,9 +1040,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, int prealloc_rc = 0; if (preallocate && !sparse && data_size > 0) { prealloc_rc = preallocate_fd(fd, data_size); - if (prealloc_rc != 0) - log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, - strerror(prealloc_rc)); + if (prealloc_rc != 0) { + char* escaped_path = output_escape(path, log_get_8_bit_output()); + log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", + escaped_path ? escaped_path : "", strerror(prealloc_rc)); + free(escaped_path); + } } if (prealloc_rc == 0) { lseek(fd, 0, SEEK_SET); diff --git a/src/shared/protocol.c b/src/shared/protocol.c index a840ca3..ee70d87 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -430,10 +430,14 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b return false; if (!protocol_send_n_data(session, data, size)) return false; - if (redact) + if (redact) { log_debug_message(LOG_DEBUG_PROTO, "Send String: "); - else - log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", data); + } else { + char* escaped_data = output_escape(data, log_get_8_bit_output()); + log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", + escaped_data ? escaped_data : ""); + free(escaped_data); + } return true; }