feat(daemon): accept rsync rsyncd.conf key subset and --dparam mapping

This commit is contained in:
2026-09-22 22:02:09 +02:00
parent d780a4625e
commit 86741725fd
5 changed files with 411 additions and 24 deletions
+63 -7
View File
@@ -141,6 +141,7 @@ class DaemonManager:
def __init__(self):
self._proc = None
self._port = None
self.log_path = None
def start(self, config_path, port_override=None, extra_args=None, log_path=None):
self.stop()
@@ -154,7 +155,11 @@ class DaemonManager:
if extra_args:
cmd += extra_args
if log_path is None:
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
# A unique log per manager: several managers run in one xdist
# worker, and a shared log lets one daemon's truncate/write offset
# corrupt the other's appended lines (a flaky log assertion).
log_path = os.path.join(TEST_DATA_DIR, f"fastsyncd_{id(self):x}.log")
self.log_path = log_path
log = open(log_path, "w")
self._proc = subprocess.Popen(
cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
@@ -375,6 +380,58 @@ class TestDaemonModuleSelection:
proc.kill()
class TestRsyncConfigCompat:
"""A real rsyncd.conf can be pointed at FastSync: the common rsync GLOBAL
and MODULE keys are accepted, the ones with a FastSync equivalent (port,
path, read only, max connections) take effect, and the inert ones (pid
file, log file, comment, use chroot, uid, gid, exclude, timeout, ...) are
documented no-ops. --dparam accepts the same expanded key set."""
@pytest.mark.ci
def test_rsync_style_config_round_trip(self):
module = os.path.join(MODULE_ROOT, "rsync_style")
shutil.rmtree(module, ignore_errors=True)
os.makedirs(module, exist_ok=True)
port = _find_free_port()
conf = os.path.join(TEST_DATA_DIR, "fastsyncd_rsync_style.conf")
with open(conf, "w") as f:
f.write(
"# an rsync 3.4.1-style rsyncd.conf\n"
"pid file = /tmp/fastsyncd_rsync_style.pid\n"
"log file = /tmp/fastsyncd_rsync_style.log\n"
"socket options = TCP_NODELAY\n"
"use chroot = no\n"
"uid = nobody\n"
"gid = nogroup\n"
"timeout = 600\n"
"max verbosity = 2\n"
"transfer logging = yes\n"
"port = %d\n"
"\n"
"[rsync_style]\n"
"path = %s\n"
"comment = rsync-style module\n"
"use chroot = no\n"
"exclude = *.tmp\n"
"read only = no\n"
"max connections = 4\n"
% (port, module))
d = DaemonManager()
# --dparam borrows rsync's compact spelling; `pidfile` is inert but must
# not be rejected, proving dparam reuses the expanded global key set.
d.start(conf, extra_args=["--dparam", "pidfile=/tmp/rsync_style.pid"],
log_path=os.path.join(TEST_DATA_DIR, "fastsyncd_rsync_style.log"))
try:
result = _push("127.0.0.1::rsync_style", d.port)
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(module, SOURCE_DIR)
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}"
finally:
d.stop()
class TestDaemonRejection:
def _tree_files(self):
"""Snapshot every file path (module-relative) currently under the module
@@ -477,7 +534,7 @@ class TestDaemonRejection:
before any data lands. `accept` lists the log phrases that count as the
refusal (a non-root daemon refuses --copy-as earlier, at the privilege
check, so the caller accepts that phrase too)."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
log_path = daemon.log_path
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
before_files = self._tree_files()
result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags)
@@ -514,14 +571,13 @@ class TestDaemonRejection:
the refusal into a silent accept."""
port = _find_free_port()
d = DaemonManager()
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
try:
d.start(CONF_FILE, port_override=port,
extra_args=["--password-file", CRED_FILE, "--no-super"])
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=d.port,
flags=["--super", "--preserve"])
assert result.returncode != 0, "the --no-super daemon must refuse --super"
with open(log_path, "rb") as f:
with open(d.log_path, "rb") as f:
tail = f.read().decode("utf-8", "replace")
assert "client-chosen ownership" in tail, (
f"daemon did not log the --super refusal: {tail[-400:]!r}"
@@ -1010,7 +1066,7 @@ class TestDaemonAuthentication:
def test_auth_log_does_not_leak_password(self, daemon):
"""The daemon log must never contain the password or the store verifier."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
log_path = daemon.log_path
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS)
@@ -1037,7 +1093,7 @@ class TestDaemonAuthentication:
_push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS)
_push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
time.sleep(0.3)
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
log_path = d.log_path
with open(log_path, "rb") as f:
log = f.read().decode("utf-8", "replace")
finally:
@@ -1256,12 +1312,12 @@ class TestDaemonTLSAuth:
_write_client_password_file(client_creds, "alice", ALICE_PASS)
d = DaemonManager()
port = _find_free_port()
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
try:
d.start(CONF_FILE, port_override=port, extra_args=[
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
"--ca", certs["ca"], "--client-cn", "fastsync-client",
"--password-file", CRED_FILE])
log_path = d.log_path
before_files = _tree_file_count(AUTH_MODULE)
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
tls_flags = ["--tls",
+181
View File
@@ -626,6 +626,182 @@ static void test_daemon_conf_module_count_capped() {
EXPECT_TRUE(strstr(err, "too many modules") != NULL);
}
/* rsync rsyncd.conf compatibility: the common GLOBAL keys FastSync does not
* implement (pid file, log file, use chroot, uid/gid, timeout, ...) are
* accepted as documented inert keys, while the keys with a FastSync equivalent
* keep working and the compact rsync --dparam spellings (`pidfile`, `logfile`,
* `motdfile`) are recognized. A global `read only` is rsync's module default
* and must not be silently dropped. */
static void test_daemon_conf_rsync_global_keys() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("pid file = /run/fastsyncd.pid\n"
"log file = /var/log/fastsyncd.log\n"
"socket options = TCP_NODELAY\n"
"listen backlog = 10\n"
"syslog facility = daemon\n"
"syslog tag = fastsyncd\n"
"use chroot = no\n"
"uid = nobody\n"
"gid = nogroup\n"
"timeout = 600\n"
"max verbosity = 3\n"
"lock file = /var/run/fastsyncd.lock\n"
"transfer logging = yes\n"
"strict modes = yes\n"
"reverse lookup = no\n"
"dont compress = *.gz\n"
"read only = yes\n"
"port = 8734\n"
"address = 127.0.0.1\n"
"pidfile = /run/other.pid\n"
"logfile = /var/log/other.log\n"
"motdfile = /etc/fastsync/motd.alt\n"
"\n"
"[pub]\n"
"path = /srv/pub\n"
"\n"
"[explicit]\n"
"path = /srv/explicit\n"
"read only = no\n",
&path),
0);
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
/* Mapped globals took effect; the compact aliases too. */
EXPECT_EQ_INT(conf->global.port, 8734);
EXPECT_EQ_STR(conf->global.address, "127.0.0.1");
EXPECT_EQ_STR(conf->global.motd_file, "/etc/fastsync/motd.alt");
/* The global `read only = yes` is the default for modules defined after it. */
EXPECT_TRUE(conf->global.read_only_default);
EXPECT_EQ_INT(conf->module_count, 2);
EXPECT_TRUE(conf->modules[0].read_only);
/* An explicit per-module value wins over the global default. */
EXPECT_FALSE(conf->modules[1].read_only);
daemon_conf_free(conf);
}
/* rsync module keys with no FastSync equivalent load inert; the keys with a
* FastSync meaning still map onto their native fields. */
static void test_daemon_conf_rsync_module_keys() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("[data]\n"
"path = /srv/data\n"
"comment = Public data\n"
"use chroot = yes\n"
"uid = nobody\n"
"gid = nogroup\n"
"exclude = *.tmp\n"
"include = keep.tmp\n"
"exclude from = /etc/rsync.exclude\n"
"max verbosity = 2\n"
"lock file = /var/run/rsyncd.lock\n"
"transfer logging = yes\n"
"timeout = 300\n"
"secrets file = /etc/rsyncd.secrets\n"
"auth digest = sha256\n"
"numeric ids = yes\n"
"write only = no\n"
"list = yes\n"
"dont compress = *.gz\n"
"refuse options = delete\n"
"read only = yes\n"
"max connections = 5\n"
"hosts allow = 10.0.0.0/8\n"
"auth users = alice\n",
&path),
0);
DaemonConf* conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(conf);
EXPECT_EQ_STR(conf->modules[0].path, "/srv/data");
EXPECT_TRUE(conf->modules[0].read_only);
EXPECT_EQ_INT(conf->modules[0].max_connections, 5);
EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 1);
EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "10.0.0.0/8");
EXPECT_EQ_INT(conf->modules[0].auth_user_count, 1);
EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice");
daemon_conf_free(conf);
}
/* A genuinely unknown key is still rejected in both contexts, so accepting the
* rsync subset did not turn typos into silent no-ops. */
static void test_daemon_conf_rsync_unknown_keys_rejected() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("bogus rsync 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\nnot a real key = 1\n", &path), 0);
conf = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(conf);
EXPECT_TRUE(strstr(err, "unknown key 'not a real key'") != NULL);
}
/* A recognized rsync key with an invalid value is still a clear parse error. */
static void test_daemon_conf_rsync_read_only_invalid() {
char* path;
char err[256];
EXPECT_EQ_INT(write_conf("read only = maybe\n[m]\npath = /x\n", &path), 0);
const DaemonConf* 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("[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);
}
/* --dparam reuses the same global dispatch: it accepts the compact rsync
* spellings and the inert rsync global keys, and `read only` sets the default
* for modules that did not set their own value. */
static void test_daemon_conf_dparam_rsync_keys() {
DaemonConf* conf = daemon_conf_create();
EXPECT_NOT_NULL(conf);
char err[256];
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "pidfile=/run/x.pid", err, sizeof(err)), 0);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "pid file=/run/y.pid", err, sizeof(err)), 0);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "logfile=/tmp/x.log", err, sizeof(err)), 0);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "log file=/tmp/y.log", err, sizeof(err)), 0);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "motdfile=/tmp/alt.motd", err, sizeof(err)), 0);
EXPECT_EQ_STR(conf->global.motd_file, "/tmp/alt.motd");
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "timeout=600", err, sizeof(err)), 0);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "use chroot=no", err, sizeof(err)), 0);
/* A module already parsed without an explicit `read only` takes the
* --dparam default; an explicit module value is preserved. */
{
char* path;
EXPECT_EQ_INT(write_conf("[plain]\npath = /p\n[explicit]\npath = /e\nread only = no\n", &path),
0);
DaemonConf* loaded = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NOT_NULL(loaded);
EXPECT_EQ_INT(daemon_conf_apply_dparam(loaded, "read only=yes", err, sizeof(err)), 0);
EXPECT_TRUE(loaded->global.read_only_default);
EXPECT_TRUE(loaded->modules[0].read_only);
EXPECT_FALSE(loaded->modules[1].read_only);
daemon_conf_free(loaded);
}
/* Invalid values and genuinely unknown keys are still rejected. */
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "read only=maybe", err, sizeof(err)), -1);
EXPECT_EQ_INT(daemon_conf_apply_dparam(conf, "definitely not rsync=1", err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "unknown global key") != NULL);
daemon_conf_free(conf);
}
void test_daemon_conf() {
test_daemon_conf_create_defaults();
test_daemon_conf_full_parse();
@@ -645,4 +821,9 @@ void test_daemon_conf() {
test_daemon_conf_module_count_capped();
test_daemon_hosts_allowed();
test_daemon_module_name_valid();
test_daemon_conf_rsync_global_keys();
test_daemon_conf_rsync_module_keys();
test_daemon_conf_rsync_unknown_keys_rejected();
test_daemon_conf_rsync_read_only_invalid();
test_daemon_conf_dparam_rsync_keys();
}