Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions io.c
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ static time_t last_io_out;
* transfer timeout and may be supplied by the module or client. */
static time_t daemon_handshake_deadline;

/* Wall-clock bound the client puts on establishing a daemon connection made
* through a remote shell (daemon_connection == 1): spawning the helper, its
* connect()/TLS handshake, and the exchange of the daemon greeting all happen
* before any buffered I/O begins, so --contimeout can time the whole phase the
* same way the socket path times its connect(). */
static time_t client_connect_deadline;

static int write_batch_monitor_in = -1;
static int write_batch_monitor_out = -1;

Expand Down Expand Up @@ -146,17 +153,28 @@ static int handshake_poll_timeout_ms(void)
time_t now, left;
int timeout = poll_timeout_ms();

if (!daemon_handshake_deadline)
if (!daemon_handshake_deadline && !client_connect_deadline)
return timeout;

now = time(NULL);
left = daemon_handshake_deadline - now;
if (left <= 0) {
rprintf(FERROR, "[%s] daemon handshake timeout -- exiting\n", who_am_i());
exit_cleanup(RERR_TIMEOUT);
if (daemon_handshake_deadline) {
left = daemon_handshake_deadline - now;
if (left <= 0) {
rprintf(FERROR, "[%s] daemon handshake timeout -- exiting\n", who_am_i());
exit_cleanup(RERR_TIMEOUT);
}
if (left <= INT_MAX / 1000 && left * 1000 < timeout)
timeout = (int)left * 1000;
}
if (client_connect_deadline) {
left = client_connect_deadline - now;
if (left <= 0) {
rprintf(FERROR, "[%s] connection timed out -- exiting\n", who_am_i());
exit_cleanup(RERR_CONTIMEOUT);
}
if (left <= INT_MAX / 1000 && left * 1000 < timeout)
timeout = (int)left * 1000;
}
if (left <= INT_MAX / 1000 && left * 1000 < timeout)
timeout = (int)left * 1000;
return timeout;
}

Expand Down Expand Up @@ -1303,6 +1321,14 @@ void set_daemon_handshake_timeout(int secs)
daemon_handshake_deadline = 0;
}

void set_client_connect_timeout(int secs)
{
if (secs > 0)
client_connect_deadline = time(NULL) + secs;
else
client_connect_deadline = 0;
}

static void check_for_d_option_error(const char *msg)
{
static const char rsync263_opts[] = "BCDHIKLPRSTWabceghlnopqrtuvxz";
Expand Down
11 changes: 10 additions & 1 deletion main.c
Original file line number Diff line number Diff line change
Expand Up @@ -1612,7 +1612,7 @@ static int start_client(int argc, char *argv[])
exit_cleanup(RERR_SYNTAX);
}

if (connect_timeout) {
if (connect_timeout && !daemon_connection) {
rprintf(FERROR, "The --contimeout option may only be "
"used when connecting to an rsync daemon.\n");
exit_cleanup(RERR_SYNTAX);
Expand Down Expand Up @@ -1644,13 +1644,22 @@ static int start_client(int argc, char *argv[])
(void)env_port;
#endif

/* For a daemon reached through a remote shell, the "connection" rsync
* waits on is: the helper is spawned, it establishes its own link (e.g.
* rsync-ssl's openssl connect + TLS handshake), and the daemon greeting is
* exchanged. Bound that whole phase with --contimeout so the option
* behaves for daemon-via-rsh the way it does for a socket connection. */
if (daemon_connection && connect_timeout > 0)
set_client_connect_timeout(connect_timeout);

pid = do_cmd(shell_cmd, shell_machine, shell_user, remote_argv, remote_argc, &f_in, &f_out);

/* if we're running an rsync server on the remote host over a
* remote shell command, we need to do the RSYNCD protocol first */
if (daemon_connection) {
int tmpret;
tmpret = start_inband_exchange(f_in, f_out, shell_user, remote_argc, remote_argv);
set_client_connect_timeout(0);
if (tmpret < 0)
return tmpret;
}
Expand Down
69 changes: 69 additions & 0 deletions testsuite/contimeout-rsh_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# --contimeout is documented as a "daemon connection timeout". The guard that
# rejects it only looked at whether connect_timeout was set, so a daemon
# connection made through a remote shell (e.g. rsync-ssl, which runs rsync with
# --rsh pointing at its helper) was rejected with the same syntax error as a
# plain non-daemon remote-shell transfer. Only reject the option when there is
# no daemon connection at all: a daemon reached via --rsh (daemon_connection ==
# 1) is still a daemon connection, and rsync now also times that connection's
# establishment phase with --contimeout the same way it times a socket connect.

import subprocess
import time

from rsyncfns import SCRATCHDIR, SRCDIR, rsync_argv, rmtree, test_fail

RERR_CONTIMEOUT = 35

base = SCRATCHDIR / 'contimeout-rsh'
rmtree(base)
base.mkdir(parents=True)


def run(*args):
return subprocess.run(rsync_argv(*args), capture_output=True, text=True)


rejected_marker = "may only be used when connecting to an rsync daemon"

# A remote-shell command that fails immediately: the option guard runs before
# rsync ever tries to exec it, so it only has to exist as a plausible --rsh
# target to put rsync into its daemon-via-rsh connection mode.
rsh_prog = str(SRCDIR / 'support' / 'lsh.sh')

# --- Daemon via --rsh must accept --contimeout (rsync-ssl's shape of call).
proc = run('--contimeout=5', '--rsh=' + rsh_prog,
'-av', 'rsync://127.0.0.1:9/mod/', str(base / 'dest'))
if rejected_marker in (proc.stderr or ''):
test_fail(f"--contimeout was rejected for a daemon-via-rsh connection:\n{proc.stderr}")

# --- A plain remote-shell (non-daemon) destination must still be rejected.
proc = run('--contimeout=5', '-av', str(base / 'src'), 'localhost:' + str(base / 'dst'))
if rejected_marker not in (proc.stderr or ''):
test_fail("--contimeout was not rejected for a non-daemon remote shell:\n" +
(proc.stderr or '') + (proc.stdout or ''))

# --- A daemon-via-rsh connection that never establishes must time out with the
# daemon-connection timeout exit code. The fake helper sleeps instead of
# connecting, so the only thing that can end the run is --contimeout firing.
fake_rsh = base / 'hang-rsh'
# The helper inherits rsync's stderr; redirect it so an orphaned "sleep" does
# not keep the harness's captured-pipe open after rsync has already exited.
fake_rsh.write_text("#!/bin/sh\nexec 2>/dev/null\nsleep 60\n")
fake_rsh.chmod(0o755)

start = time.monotonic()
proc = run('--contimeout=1', '--rsh=' + str(fake_rsh),
'-av', 'rsync://127.0.0.1:9/mod/', str(base / 'dest2'))
elapsed = time.monotonic() - start

if proc.returncode != RERR_CONTIMEOUT:
test_fail(f"--contimeout did not abort the hung connection with exit "
f"{RERR_CONTIMEOUT}; got {proc.returncode}:\n{proc.stderr}")
if elapsed >= 15:
test_fail(f"--contimeout=1 took {elapsed:.1f}s; the timeout did not bound "
"the connection establishment phase")

print("contimeout-rsh: --contimeout is accepted for a daemon-via-rsh "
"connection, rejected for a non-daemon remote shell, and times out a "
"connection that never establishes")
Loading