Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/lib.ext.c
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,7 @@ static void client_start_connect_timer(ssh_client_ctx *c) {
}

static void client_start_auth_timer(ssh_client_ctx *c) {
stop_timer(&c->connect_timer, client_timer_close_cb);
if (c->auth_timeout <= 0.0 || c->auth_timer != NULL)
return;
c->auth_timer = acton_calloc(1, sizeof(uv_timer_t));
Expand Down Expand Up @@ -1806,7 +1807,6 @@ static void client_drive(ssh_client_ctx *c) {
if (c->state == CLIENT_STATE_CONNECTING) {
int rc = ssh_connect(c->session);
if (rc == SSH_OK) {
stop_timer(&c->connect_timer, client_timer_close_cb);
if (fd_set_nonblocking(c->fd) != 0) {
client_fail(c, "Failed to restore SSH session fd nonblocking");
return;
Expand Down
103 changes: 103 additions & 0 deletions src/test_ssh.act
Original file line number Diff line number Diff line change
Expand Up @@ -1375,3 +1375,106 @@ actor WriteBufferLimitTester(t: testing.EnvT):
def _test_write_buffer_limit(t: testing.EnvT):
"""A write exceeding max_write_buffer fails the channel."""
WriteBufferLimitTester(t)


actor HostkeyStallTester(t: testing.EnvT):
"""a client stalled in CLIENT_STATE_HOSTKEY_WAIT must still be bounded by connect_timeout.

on_hostkey deliberately never calls accept_hostkey()/reject_hostkey(), so
the client parks in CLIENT_STATE_HOSTKEY_WAIT. connect_timeout is 1s and the
watchdog is 2s. If the connect timer still covered CLIENT_STATE_HOSTKEY_WAIT (as
connect_timeout_cb claims to), on_connect fires with "SSH connect timeout"
at ~2s and the test passes. With the bug, the connect timer was stopped at
the CONNECTING->HOSTKEY transition, nothing fires, and the watchdog trips
-> test fails, reproducing the hang."""
log = logging.Logger(t.log_handler)

var done = False
var server: ?ssh.Server = None
var client: ?ssh.Client = None
var reached_hostkey = False

def finish_error(msg: str):
if done:
return
done = True
if client is not None:
client.close()
if server is not None:
server.close()
t.error(Exception(msg))

def on_timeout():
# Reached only if on_connect never fired -> the bug.
if reached_hostkey:
finish_error("client hung in CLIENT_STATE_HOSTKEY_WAIT past connect_timeout (issue #1)")
else:
finish_error("timeout before host key was ever presented")

def on_listen(s: ssh.Server, err: ?str):
if err is not None:
finish_error("server listen error: " + err)
return
port = await async s.bound_port()
start_client(port)

def on_server_close(s: ssh.Server, reason: str):
pass

def on_session(sess: ssh.ServerSession):
pass

def on_auth(sess: ssh.ServerSession, req: ssh.AuthRequest):
sess.accept_auth()

def on_channel_open(sess: ssh.ServerSession):
sess.reject_channel("no channels")

def on_hostkey(c: ssh.Client, state: str, info: ssh.HostKeyInfo):
# The stall: intentionally neither accept nor reject.
reached_hostkey = True

def on_connect(c: ssh.Client, err: ?str):
if done:
return
if err is None:
finish_error("connect unexpectedly succeeded")
return
# Expected once the timer bug is fixed: connect_timeout fires here.
done = True
if server is not None:
server.close()
t.success()

def on_client_close(c: ssh.Client, reason: str):
pass

def start_client(port: u16):
client = ssh.Client(
net.TCPConnectCap(net.TCPCap(net.NetCap(t.env.cap))),
"127.0.0.1",
TEST_USER,
on_connect,
on_client_close,
on_hostkey,
password=TEST_PASS,
port=port,
connect_timeout=1.0,
)

server = ssh.Server(
net.TCPListenCap(net.TCPCap(net.NetCap(t.env.cap))),
"127.0.0.1",
u16(0),
on_listen,
on_server_close,
on_session,
on_auth,
on_channel_open,
)
after 2.0: on_timeout()


def _test_hostkey_stall(t: testing.EnvT):
"""A stalled host-key handler must be cut off by connect_timeout, not hang."""
HostkeyStallTester(t)