Merge feat/d5-daemon-motd: daemon MOTD display + --no-motd
This commit is contained in:
+4
-3
@@ -27,7 +27,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
| `--info=FLAGS` | Fine-grained info verbosity | ✅ Implemented | Supports `copy`, `misc`, `skip`, `stats`, `all`, and `none`; explicit flags override `--verbose`, and `none` suppresses info output; unsupported names are rejected |
|
||||
| `--debug=FLAGS` | Fine-grained debug verbosity | ✅ Implemented | `io`, `proto`, `pack`, and `util` are supported; `--debug=help` lists flags; other rsync categories are rejected |
|
||||
| `--stderr=MODE` | Change stderr output mode | ⚠️ Partial | `errors` (default) and `all` are supported; `client` is rejected because FastSync has no rsync message channel |
|
||||
| `--no-motd` | Suppress daemon MOTD | ❌ Not Implemented | |
|
||||
| `--no-motd` | Suppress daemon MOTD | ✅ Implemented | Client-only display switch (Wave C): the daemon still sends the configured `motd file` on a `host::module/path` connection; the client reads and discards the frame without showing it. Without the flag the MOTD is printed to stdout after the config/auth handshake and escaped so control bytes cannot inject terminal sequences |
|
||||
| `--exclude=PATTERN` | Exclude files matching pattern | ✅ Implemented | Glob matching in scanner |
|
||||
| `--include=PATTERN` | Include files matching pattern | ✅ Implemented | Glob matching in scanner |
|
||||
| `-C`, `--cvs-exclude` | Auto-ignore CVS files | ✅ Implemented | Applies the well-known rsync default exclude set as exclude rules during scanning (RCS SCCS CVS CVS.adm RCSLOG cvslog.* tags TAGS .make.state .nse_depinfo *~ #* .#* ,* _$* *$ *.old *.bak *.BAK *.orig *.rej .del-* *.a *.olb *.o *.obj *.so *.exe *.Z *.elc *.ln core .svn/ .git/ .hg/ .bzr/); `.git/`-style repo dirs are pruned without descending |
|
||||
@@ -629,9 +629,9 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
| `--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. 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, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||
**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.
|
||||
|
||||
- **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). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
||||
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). 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). **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` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
|
||||
@@ -639,6 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
- **Plaintext caveat:** over a plaintext (non-TLS) daemon, a sniffer can capture the transmitted digest and replay it (the exchange is challenge-less, like rsync), and it sees the same value that is already stored in the server's own credential file — so use `--tls` to protect the exchange. The daemon logs a warning when an auth-required module is reached over plaintext. TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
|
||||
- **Wire/protocol:** the auth payload is two trailing config-frame strings (username + digest) behind a presence int, sent after the Wave A module string and before the STATUS_OK/STATUS_ERROR ack. Because both peers of a 2.15.0 build always parse the same full frame (the strict same-version handshake rejects any other version before any byte is parsed), this is NOT a new frame layout and does **not** require a `PROTOCOL_VERSION` bump — the 2.15.0 release ships Wave A + Wave B together (see the NOTE in `src/shared/config.h`).
|
||||
- **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. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`.
|
||||
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
|
||||
- **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
|
||||
|
||||
@@ -819,6 +819,13 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
||||
config->no_implied_dirs = true;
|
||||
continue;
|
||||
}
|
||||
/* "--no-motd" is a real rsync option name (client-side daemon MOTD display
|
||||
* suppression), not a negation of a "--motd" flag, so it is handled before
|
||||
* the generic --no-* negation branch. */
|
||||
if (strcmp(argv[i], "--no-motd") == 0) {
|
||||
config->no_motd = true;
|
||||
continue;
|
||||
}
|
||||
if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) {
|
||||
if (strcmp(argv[i], "--no-delta") == 0)
|
||||
no_delta = true;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "filter.h"
|
||||
#include "hardlink.h"
|
||||
#include "metadata.h"
|
||||
#include "motd.h"
|
||||
#include "log.h"
|
||||
#include "multiprocessing.h"
|
||||
#include "protocol.h"
|
||||
@@ -359,6 +360,36 @@ static bool basis_oversize_preflight(const Config* config) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Read the daemon's MOTD frame and, unless --no-motd, display it on stdout.
|
||||
*
|
||||
* The daemon sends the MOTD as the first thing after the config-frame STATUS_OK
|
||||
* on a host::module/path connection (rsync semantics), so this runs immediately
|
||||
* after config_send succeeds. The frame is ALWAYS consumed for a daemon
|
||||
* connection -- even with --no-motd -- so the byte stream stays in sync; the
|
||||
* flag only suppresses the display. A non-daemon (local TCP / SSH) connection
|
||||
* has no MOTD frame. The text is rendered through motd_render so a hostile
|
||||
* server cannot inject terminal escape sequences. A read failure is not fatal
|
||||
* here: the transfer that follows surfaces the real connection error. */
|
||||
static void receive_daemon_motd(Client* client, const Config* config) {
|
||||
if (!config->module || config->module[0] == '\0')
|
||||
return;
|
||||
char* motd = motd_receive(client->file_descriptor);
|
||||
if (!motd)
|
||||
return;
|
||||
if (!config->no_motd && motd[0] != '\0') {
|
||||
char* rendered = motd_render(motd, config->eight_bit_output);
|
||||
if (rendered) {
|
||||
fputs(rendered, stdout);
|
||||
size_t length = strlen(rendered);
|
||||
if (length == 0 || rendered[length - 1] != '\n')
|
||||
fputc('\n', stdout);
|
||||
fflush(stdout);
|
||||
free(rendered);
|
||||
}
|
||||
}
|
||||
free(motd);
|
||||
}
|
||||
|
||||
/* Select the configured transport for both transfer execution paths. */
|
||||
static Client* connect_transfer_client(const Config* config) {
|
||||
if (config->transport == TRANSPORT_SSH) {
|
||||
@@ -1350,6 +1381,7 @@ static int send_chunks_multithreaded(void* pipeline_context) {
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
receive_daemon_motd(client, context->config);
|
||||
if (context->early_delete) {
|
||||
/* The keep-set manifest was prebuilt by a path-only pre-scan. Transmit it
|
||||
and wait for the receiver to delete extras before streaming any data. */
|
||||
@@ -1702,6 +1734,7 @@ int send_files(Config* config) {
|
||||
memset(&prepared, 0, sizeof(prepared));
|
||||
if (!config_send(client->file_descriptor, config))
|
||||
goto send_fail;
|
||||
receive_daemon_motd(client, config);
|
||||
if (!prepare_scanner(config, 0, &prepared))
|
||||
goto send_fail;
|
||||
if (config->remove_source_files)
|
||||
|
||||
@@ -174,6 +174,8 @@ void print_usage(void) {
|
||||
printf(" The file's first user:password line supplies the\n");
|
||||
printf(" username and password (only a SHA-256 digest of the\n");
|
||||
printf(" password is sent; keep the file mode 0600)\n");
|
||||
printf(" --no-motd Suppress display of the daemon's MOTD (the server\n");
|
||||
printf(" still sends it; the client just does not show it)\n");
|
||||
printf(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
|
||||
printf(" --tls Enable TLS encryption\n");
|
||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "file.h"
|
||||
#include "identity.h"
|
||||
#include "log.h"
|
||||
#include "motd.h"
|
||||
#include "multiprocessing.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
@@ -365,6 +366,29 @@ void handler(int file_descriptor) {
|
||||
during the whole transfer, and never bleeds across the per-connection
|
||||
forked processes. Off by default. */
|
||||
file_set_trust_sender(trust_sender);
|
||||
/* Wave C MOTD: on the daemon listener path only, once the module gate + auth
|
||||
have accepted and every destination check has passed, send the configured
|
||||
`motd file` as the first server->client frame before any transfer data
|
||||
(rsync sends its MOTD as the first thing from the server on a daemon
|
||||
connection). Every daemon connection gets the frame -- an unset or
|
||||
unreadable motd file sends an empty string -- so the client's read is
|
||||
deterministic and an absent file is never an error. The --stdio SSH path
|
||||
has no MOTD (g_daemon_conf is NULL there). No PROTOCOL_VERSION bump: the
|
||||
frame is symmetric server->client in every 2.15.0 daemon build (see the
|
||||
Wave C note in config.h). */
|
||||
if (g_daemon_conf) {
|
||||
char* motd = motd_read_file(g_daemon_conf->global.motd_file);
|
||||
if (!motd_send(file_descriptor, motd ? motd : "")) {
|
||||
free(motd);
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to send daemon MOTD");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
}
|
||||
free(motd);
|
||||
}
|
||||
if (config->use_multithreading) {
|
||||
Queue* q = queue_create(100, file_destroy);
|
||||
if (q == NULL) {
|
||||
|
||||
@@ -149,6 +149,7 @@ static void config_set_defaults(Config* config) {
|
||||
config->daemon = false;
|
||||
config->daemon_config = NULL;
|
||||
config->server_mode = false;
|
||||
config->no_motd = false;
|
||||
config->checksum = false;
|
||||
config->checksum_algo = CHECKSUM_ALGO_XXH64;
|
||||
config->checksum_seed = 0;
|
||||
|
||||
+15
-1
@@ -340,6 +340,12 @@ typedef struct Config {
|
||||
bool daemon;
|
||||
char* daemon_config;
|
||||
bool server_mode;
|
||||
/* --no-motd (Wave C): CLIENT-ONLY, never crosses the wire. Suppresses
|
||||
* DISPLAY of the daemon's MOTD; the daemon still sends the MOTD frame, so
|
||||
* the client reads and discards it to keep the stream in sync. rsync's
|
||||
* --no-motd is likewise a client-side display switch. Default false (the
|
||||
* MOTD is shown when a daemon offers one). */
|
||||
bool no_motd;
|
||||
|
||||
// PR #183: Checksum comparison
|
||||
bool checksum;
|
||||
@@ -479,7 +485,15 @@ typedef struct Config {
|
||||
* build always read and write the same full layout (the strict same-version
|
||||
* handshake rejects any other version before a byte of the frame is parsed),
|
||||
* so a peer can never desynchronize on the added tail. The 2.15.0 release
|
||||
* ships Wave A + Wave B together; the bump stays owned by Wave A. */
|
||||
* ships Wave A + Wave B together; the bump stays owned by Wave A.
|
||||
*
|
||||
* Wave C (MOTD) adds NO config-frame field and no version bump either. On the
|
||||
* daemon listener path only, the server sends one MOTD string frame AFTER the
|
||||
* config-frame STATUS_OK (server.c handler), and every 2.15.0 daemon client
|
||||
* reads that frame right after the ack (client_send.c) -- symmetric
|
||||
* server->client in every build, so the strict same-version handshake keeps the
|
||||
* two peers in lockstep and nothing can desynchronize. The --stdio SSH path
|
||||
* sends/reads no MOTD at all. */
|
||||
#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). */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "motd.h"
|
||||
#include "protocol.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
char* motd_read_file(const char* path) {
|
||||
if (!path || path[0] == '\0')
|
||||
return NULL;
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
char* buffer = malloc(MOTD_MAX_BYTES + 1);
|
||||
if (!buffer) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
/* fread stops at the bound; a larger file is truncated rather than read
|
||||
* unbounded. ferror distinguishes a truncated read from an I/O failure. */
|
||||
size_t total = fread(buffer, 1, MOTD_MAX_BYTES, fp);
|
||||
if (ferror(fp)) {
|
||||
free(buffer);
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
fclose(fp);
|
||||
buffer[total] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
char* motd_render(const char* motd, bool eight_bit_output) {
|
||||
if (!motd)
|
||||
return NULL;
|
||||
size_t length = strlen(motd);
|
||||
if (length > (SIZE_MAX - 1) / 5)
|
||||
return NULL;
|
||||
char* rendered = malloc(length * 5 + 1);
|
||||
if (!rendered)
|
||||
return NULL;
|
||||
size_t out = 0;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
unsigned char byte = (unsigned char)motd[i];
|
||||
if (byte == '\n' || byte == '\t') {
|
||||
rendered[out++] = (char)byte;
|
||||
} else if ((byte >= 32 && byte <= 126) || (eight_bit_output && byte >= 128)) {
|
||||
rendered[out++] = (char)byte;
|
||||
} else {
|
||||
rendered[out++] = '\\';
|
||||
rendered[out++] = '#';
|
||||
rendered[out++] = (char)('0' + ((byte >> 6) & 7));
|
||||
rendered[out++] = (char)('0' + ((byte >> 3) & 7));
|
||||
rendered[out++] = (char)('0' + (byte & 7));
|
||||
}
|
||||
}
|
||||
rendered[out] = '\0';
|
||||
return rendered;
|
||||
}
|
||||
|
||||
bool motd_send(int file_descriptor, const char* motd) {
|
||||
return send_str(file_descriptor, motd ? motd : "");
|
||||
}
|
||||
|
||||
char* motd_receive(int file_descriptor) {
|
||||
char* motd = receive_str(file_descriptor);
|
||||
if (!motd)
|
||||
return NULL;
|
||||
/* Guard against a hostile/oversized peer: receive_str already bounded the
|
||||
* frame at MAX_STRING_SIZE and consumed it, so discarding an over-bound
|
||||
* body here keeps the stream framed while refusing to display it. */
|
||||
if (strlen(motd) > MOTD_MAX_BYTES) {
|
||||
free(motd);
|
||||
return NULL;
|
||||
}
|
||||
return motd;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#ifndef MOTD_H
|
||||
#define MOTD_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Daemon Message-Of-The-Day (Wave C).
|
||||
*
|
||||
* The daemon listener (fastsync-server --daemon) may advertise a `motd file`
|
||||
* configured in its globals. When a client connects with a host::module/path
|
||||
* destination and the module gate accepts the connection, the server sends the
|
||||
* MOTD as a single string frame BEFORE any transfer data (rsync sends its MOTD
|
||||
* as the first thing from the server at the start of a daemon connection).
|
||||
* The client reads that frame right after the config/status handshake and
|
||||
* displays it on stdout unless --no-motd was given.
|
||||
*
|
||||
* The MOTD is ordinary display text, never a secret, so it uses the normal
|
||||
* (non-redacted) string primitive. The exchange is strictly server->client
|
||||
* and happens on the daemon listener path only; the --stdio SSH path has no
|
||||
* MOTD.
|
||||
*
|
||||
* No PROTOCOL_VERSION bump is involved: the frame is sent and read
|
||||
* symmetrically by every 2.15.0 daemon build (the strict same-version
|
||||
* handshake rejects any other version before the frame), so it cannot
|
||||
* desynchronize a peer. */
|
||||
|
||||
/* Upper bound on the MOTD bytes the server will read from disk and put on the
|
||||
* wire. Kept far below MAX_STRING_SIZE (64 KB) so a huge/hostile motd file
|
||||
* can never produce an unbounded frame or allocation. */
|
||||
#define MOTD_MAX_BYTES 4096
|
||||
|
||||
/* Read a daemon MOTD file, bounded to MOTD_MAX_BYTES. Returns a malloc'd
|
||||
* NUL-terminated copy of the file content (bytes beyond the bound are
|
||||
* truncated) or NULL when path is NULL/empty, the file cannot be opened or
|
||||
* read, or allocation fails. An absent or unreadable motd file is NOT an
|
||||
* error: the caller simply sends an empty MOTD frame and continues. */
|
||||
char* motd_read_file(const char* path);
|
||||
|
||||
/* Render MOTD text for terminal display. Newlines and tabs are preserved so
|
||||
* a multi-line motd still reads naturally, while every other non-printable /
|
||||
* control byte (ESC included) is escaped with FastSync's `\NNN` octal
|
||||
* convention, so a hostile server cannot inject terminal escape sequences
|
||||
* through the MOTD. eight_bit_output keeps bytes >= 0x80 verbatim (matching
|
||||
* --8-bit-output). Returns a malloc'd string or NULL on allocation failure. */
|
||||
char* motd_render(const char* motd, bool eight_bit_output);
|
||||
|
||||
/* Send/receive the MOTD string frame. These wrap the normal string
|
||||
* primitive: the MOTD is not a credential, so no redaction is used. The
|
||||
* receiver additionally rejects an over-bound frame (> MOTD_MAX_BYTES) as a
|
||||
* hostile input guard; the frame itself is always fully consumed first, so the
|
||||
* stream stays framed. */
|
||||
bool motd_send(int file_descriptor, const char* motd);
|
||||
char* motd_receive(int file_descriptor);
|
||||
|
||||
#endif
|
||||
@@ -539,6 +539,101 @@ class TestDaemonAuthentication:
|
||||
assert _pw_hash(WRONG_PASS) not in log
|
||||
|
||||
|
||||
class TestDaemonMotd:
|
||||
"""Wave C MOTD: a daemon configured with a global `motd file` sends it to a
|
||||
host::module/path client right after the config/auth handshake; the client
|
||||
shows it on stdout unless --no-motd suppresses the display. The MOTD is
|
||||
escaped at display time so a hostile motd cannot inject terminal escapes.
|
||||
|
||||
Each test boots its own motd-configured daemon (the shared `daemon` fixture
|
||||
config has no `motd file`). The MOTD is only sent on the daemon listener
|
||||
path; these all exercise `host::module` connections.
|
||||
"""
|
||||
|
||||
MOTD_MODULE = os.path.join(MODULE_ROOT, "motd_module")
|
||||
MOTD_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_motd.conf")
|
||||
|
||||
def _start(self, motd_path):
|
||||
port = _find_free_port()
|
||||
motd_line = "motd file = %s\n" % motd_path if motd_path else ""
|
||||
os.makedirs(self.MOTD_MODULE, exist_ok=True)
|
||||
with open(self.MOTD_CONF, "w") as f:
|
||||
f.write("port = %d\n%s\n[files]\npath = %s\n" % (port, motd_line, self.MOTD_MODULE))
|
||||
d = DaemonManager()
|
||||
d.start(self.MOTD_CONF, port_override=port)
|
||||
return d, port
|
||||
|
||||
def _push(self, port, extra_args=None):
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=port,
|
||||
extra_args=extra_args)
|
||||
return result
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_motd_displayed(self):
|
||||
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_banner.txt")
|
||||
banner = "Welcome to the FastSync test daemon\nSecond line here.\n"
|
||||
with open(motd_path, "w") as f:
|
||||
f.write(banner)
|
||||
d, port = self._start(motd_path)
|
||||
try:
|
||||
result = self._push(port)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert "Welcome to the FastSync test daemon" in (result.stdout or "")
|
||||
assert "Second line here." in (result.stdout or "")
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_motd_no_motd_suppresses_display(self):
|
||||
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_banner2.txt")
|
||||
banner = "This banner must never be shown.\n"
|
||||
with open(motd_path, "w") as f:
|
||||
f.write(banner)
|
||||
d, port = self._start(motd_path)
|
||||
try:
|
||||
result = self._push(port, extra_args=["--no-motd"])
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert banner.strip() not in (result.stdout or "")
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_motd_absent_motd_file_no_error(self):
|
||||
d, port = self._start(os.path.join(TEST_DATA_DIR, "no-such-motd-file.txt"))
|
||||
try:
|
||||
result = self._push(port)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert "no-such-motd" not in (result.stdout or "")
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_motd_no_config_key_sends_no_banner(self):
|
||||
d, port = self._start(None)
|
||||
try:
|
||||
result = self._push(port)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert "FastSync test daemon" not in (result.stdout or "")
|
||||
assert "banner" not in (result.stdout or "")
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_motd_control_bytes_are_escaped(self):
|
||||
"""A hostile motd (ANSI escape sequences) is displayed with every
|
||||
control byte escaped octal-style, so no terminal escape reaches the
|
||||
controlling terminal. The transfer still succeeds (the motd is only
|
||||
display text, never a wire/transfer hazard)."""
|
||||
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_hostile.txt")
|
||||
with open(motd_path, "w") as f:
|
||||
f.write("hello\033[31mred\033[0m\n")
|
||||
d, port = self._start(motd_path)
|
||||
try:
|
||||
result = self._push(port)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
assert "\x1b" not in (result.stdout or ""), "raw ESC byte leaked to stdout"
|
||||
assert "\\#033[31m" in (result.stdout or ""), result.stdout
|
||||
assert "\\#033[0m" in (result.stdout or ""), result.stdout
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
|
||||
def _generate_tls_certs(cert_dir):
|
||||
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client
|
||||
cert signed by that CA, for the TLS+auth composition test."""
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "test_glob.h"
|
||||
#include "test_log.h"
|
||||
#include "test_metadata.h"
|
||||
#include "test_motd.h"
|
||||
#include "test_multiprocessing.h"
|
||||
#include "test_property.h"
|
||||
#include "test_protocol.h"
|
||||
@@ -73,6 +74,7 @@ int main() {
|
||||
RUN_TEST(test_client_cli);
|
||||
RUN_TEST(test_server);
|
||||
RUN_TEST(test_daemon_conf);
|
||||
RUN_TEST(test_motd);
|
||||
RUN_TEST(test_server_cli);
|
||||
RUN_TEST(test_fuzz_smoke);
|
||||
RUN_TEST(test_xattr);
|
||||
|
||||
@@ -2772,6 +2772,29 @@ static void test_parse_args_remote_option_no_short_M() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --no-motd is a real rsync option (client-side daemon MOTD display
|
||||
* suppression), not a negation of a --motd flag: it sets config->no_motd. */
|
||||
static void test_parse_args_no_motd() {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
EXPECT_FALSE(cfg->no_motd);
|
||||
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--no-motd"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->no_motd);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
EXPECT_FALSE(cfg->no_motd);
|
||||
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->no_motd);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --password-file stores its path on the config (the file is read later, once
|
||||
* the destination form is known). */
|
||||
static void test_parse_args_password_file() {
|
||||
@@ -2934,5 +2957,6 @@ void test_client_cli() {
|
||||
test_parse_args_remote_option_missing_value();
|
||||
test_parse_args_remote_option_rejects_bad_values();
|
||||
test_parse_args_remote_option_no_short_M();
|
||||
test_parse_args_no_motd();
|
||||
test_parse_args_password_file();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#include "test_motd.h"
|
||||
#include "motd.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Write `body` (len bytes) to a fresh temp file; returns its heap path. */
|
||||
static int write_file(const char* body, size_t len, char** out_path) {
|
||||
char tmpl[] = "/tmp/fastsync_motd_XXXXXX";
|
||||
int fd = mkstemp(tmpl);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
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_motd_read_present() {
|
||||
char* path;
|
||||
char body[] = "Welcome to FastSync\nBe excellent to each other.\n";
|
||||
EXPECT_EQ_INT(write_file(body, strlen(body), &path), 0);
|
||||
char* motd = motd_read_file(path);
|
||||
unlink(path);
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(motd);
|
||||
EXPECT_EQ_STR(motd, body);
|
||||
free(motd);
|
||||
}
|
||||
|
||||
static void test_motd_read_absent() {
|
||||
EXPECT_NULL(motd_read_file("/nonexistent/fastsync_motd_zzz"));
|
||||
EXPECT_NULL(motd_read_file(""));
|
||||
EXPECT_NULL(motd_read_file(NULL));
|
||||
}
|
||||
|
||||
static void test_motd_read_unreadable() {
|
||||
/* Reading a directory through fopen succeeds for the open but fread fails
|
||||
* with EISDIR, which is a reliable "unreadable" probe even for root. */
|
||||
const char* dir = "/tmp";
|
||||
EXPECT_NULL(motd_read_file(dir));
|
||||
}
|
||||
|
||||
static void test_motd_read_large_truncated() {
|
||||
size_t total = MOTD_MAX_BYTES + 100;
|
||||
char* body = malloc(total);
|
||||
EXPECT_NOT_NULL(body);
|
||||
memset(body, 'x', total);
|
||||
body[0] = 'h';
|
||||
char* path;
|
||||
EXPECT_EQ_INT(write_file(body, total, &path), 0);
|
||||
char* motd = motd_read_file(path);
|
||||
unlink(path);
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(motd);
|
||||
EXPECT_EQ_INT((int)strlen(motd), MOTD_MAX_BYTES);
|
||||
EXPECT_EQ_INT(motd[0], 'h');
|
||||
EXPECT_EQ_INT(motd[MOTD_MAX_BYTES - 1], 'x');
|
||||
EXPECT_EQ_STR(motd + MOTD_MAX_BYTES, "");
|
||||
free(motd);
|
||||
free(body);
|
||||
}
|
||||
|
||||
static void test_motd_render_escaping() {
|
||||
/* Newlines/tabs survive; control bytes (ESC included) become \NNN octal. */
|
||||
char* rendered = motd_render("line1\n\tansi\033[31m", false);
|
||||
EXPECT_NOT_NULL(rendered);
|
||||
EXPECT_EQ_STR(rendered, "line1\n\tansi\\#033[31m");
|
||||
free(rendered);
|
||||
|
||||
/* High-bit bytes are escaped without --8-bit-output. */
|
||||
rendered = motd_render("\xC3\xA9", false);
|
||||
EXPECT_NOT_NULL(rendered);
|
||||
EXPECT_EQ_STR(rendered, "\\#303\\#251");
|
||||
free(rendered);
|
||||
|
||||
/* --8-bit-output keeps bytes >= 0x80 verbatim. */
|
||||
rendered = motd_render("\xC3\xA9", true);
|
||||
EXPECT_NOT_NULL(rendered);
|
||||
EXPECT_EQ_STR(rendered, "\xC3\xA9");
|
||||
free(rendered);
|
||||
|
||||
EXPECT_NULL(motd_render(NULL, false));
|
||||
}
|
||||
|
||||
static void test_motd_frame_roundtrip() {
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(pipe(p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
const char* motd = "Greetings from the module server.\nEnjoy your stay.\n";
|
||||
EXPECT_TRUE(motd_send(0, motd));
|
||||
char* received = motd_receive(0);
|
||||
EXPECT_NOT_NULL(received);
|
||||
EXPECT_EQ_STR(received, motd);
|
||||
free(received);
|
||||
|
||||
/* An unset MOTD is an empty (but present) frame, not an error. */
|
||||
EXPECT_TRUE(motd_send(0, NULL));
|
||||
received = motd_receive(0);
|
||||
EXPECT_NOT_NULL(received);
|
||||
EXPECT_EQ_STR(received, "");
|
||||
free(received);
|
||||
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
}
|
||||
|
||||
static void test_motd_receive_over_bound_rejected() {
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(pipe(p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
size_t size = MOTD_MAX_BYTES + 100;
|
||||
char* big = malloc(size);
|
||||
EXPECT_NOT_NULL(big);
|
||||
memset(big, 'a', size);
|
||||
big[size - 1] = '\0';
|
||||
/* A (hostile/oversized) peer frame within MAX_STRING_SIZE but above the MOTD
|
||||
* bound is consumed and discarded: motd_receive returns NULL and the stream
|
||||
* stays framed for the next message. */
|
||||
EXPECT_TRUE(send_str(0, big));
|
||||
EXPECT_NULL(motd_receive(0));
|
||||
EXPECT_TRUE(send_str(0, "after"));
|
||||
char* next = receive_str(0);
|
||||
EXPECT_NOT_NULL(next);
|
||||
EXPECT_EQ_STR(next, "after");
|
||||
free(next);
|
||||
free(big);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
}
|
||||
|
||||
void test_motd() {
|
||||
test_motd_read_present();
|
||||
test_motd_read_absent();
|
||||
test_motd_read_unreadable();
|
||||
test_motd_read_large_truncated();
|
||||
test_motd_render_escaping();
|
||||
test_motd_frame_roundtrip();
|
||||
test_motd_receive_over_bound_rejected();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_MOTD_H
|
||||
#define TEST_MOTD_H
|
||||
|
||||
void test_motd(void);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user