feat(daemon): accept rsync rsyncd.conf key subset and --dparam mapping
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user