A callback-driven SSH client and server library for Acton, built on libssh and integrated with Acton's libuv event loop. All I/O is non-blocking and exposed through the actor model: you create actors and receive results via action callbacks.
It implements both sides of the protocol — an Acton client can talk to an
Acton server, to OpenSSH sshd, or be driven by the OpenSSH ssh client.
- Client: password and public-key authentication, host-key verification
(known_hosts and/or an
on_hostkeycallback), connect/auth timeouts, keepalive. - Channels:
exec,shell(with optional PTY), andsubsystemrequests; stdout/stderr streaming; EOF, exit status, and close with a defined callback ordering.RunCommandis a convenience wrapper that buffers output. - Server: password and public-key auth callbacks,
exec/subsystemdispatch, per-channel data streaming, ephemeral-port binding, in-memory or file-based host keys, and admission limits (max sessions / channels). - Hardened: bounded teardown (a stalled peer can't wedge a close),
per-channel write-buffer limits, an accept loop that survives
per-connection failures, and no use-after-free under load (validated with
acton test stress, an OpenSSH interop suite, and a leak-checked soak).
This package depends on a fork of libssh that adds the hooks needed for an
external event loop (ssh_session_handle_poll, ssh_channel_read_buffered,
server build) and builds with Zig. The dependency is declared in
Build.act; point it at your checkout of the fork.
libssh and its crypto backend (mbedtls) run on the C library heap with their
own ownership; only libuv and Acton objects live on the GC heap. See the
header comment in src/ssh.ext.c for the full memory model.
acton buildimport net
import ssh
actor main(env):
var client: ?ssh.Client = None
def on_hostkey(c: ssh.Client, state: str, info: ssh.HostKeyInfo):
# Verify info.fingerprint against a known value in production.
c.accept_hostkey()
def on_connect(c: ssh.Client, err: ?str):
if err is not None:
print("connect failed:", err)
env.exit(1)
return
ssh.RunCommand(c, "uname -a", on_exit, timeout=30.0)
def on_close(c: ssh.Client, reason: str):
pass
def on_exit(ch: ssh.Channel, code: int, sig: ?str, out: bytes, err: bytes, error: ?str):
if error is None:
print(out.decode(), end="")
if client is not None:
client.close()
env.exit(code)
client = ssh.Client(
net.TCPConnectCap(net.TCPCap(net.NetCap(env.cap))),
"example.com", "alice",
on_connect, on_close, on_hostkey,
password="secret", port=u16(22))import net
import ssh
actor main(env):
def on_listen(s: ssh.Server, err: ?str):
if err is None:
print("listening")
def on_auth(sess: ssh.ServerSession, req: ssh.AuthRequest):
# req.method is "password" or "publickey". For publickey, libssh has
# already verified the signature; just decide if req.pubkey is allowed.
if req.method == "password" and req.password == "secret":
sess.accept_auth()
else:
sess.reject_auth("denied")
def on_channel_open(sess: ssh.ServerSession):
sess.accept_channel(ssh.ServerChannel(sess, on_data, on_stderr, on_chan_close))
def on_exec(sess: ssh.ServerSession, ch: ssh.ServerChannel, cmd: str):
ch.accept_request()
ch.write(("you ran: " + cmd + "\n").encode())
ch.send_exit_status(0)
ch.close()
def on_data(ch, data): pass
def on_stderr(ch, data): pass
def on_chan_close(ch, reason): pass
def on_server_close(s, reason): pass
def on_session(sess): pass
ssh.Server(
net.TCPListenCap(net.TCPCap(net.NetCap(env.cap))),
"0.0.0.0", u16(2222),
on_listen, on_server_close, on_session,
on_auth, on_channel_open, on_exec)Runnable versions of both are in src/example_client.act
and src/example_server.act:
./out/bin/example_server 2222 demo demo &
./out/bin/example_client 127.0.0.1 demo "hello" 2222 demoClient(cap, host, username, on_connect, on_close, on_hostkey?, ...)—accept_hostkey(),reject_hostkey(reason),close(). Auth viapasswordand/orprivate_key_file(+private_key_passphrase). Tunables:port,known_hosts,connect_timeout,auth_timeout,keepalive_interval,keepalive_enabled,close_timeout,max_write_buffer.Channel(client, on_open, on_stdout, on_stderr, on_exit, on_close)—request_exec(cmd),request_shell(...),request_subsystem(name),write(data),send_eof(),close().?bytescallbacks deliverNonefor EOF. Teardown order:on_exit, then stream EOFs, thenon_close.RunCommand(client, cmd, on_exit, timeout?)— buffers stdout/stderr and reports once:on_exit(channel, code, signal?, stdout, stderr, error?).Server(cap, host, port, on_listen, on_close, on_session, on_auth, on_channel_open, on_exec?, on_subsystem?, on_session_close?, ...)—close(),bound_port()(useport=0for an ephemeral port). Tunables includehost_key_path/host_key_type/host_key_bits, the timeouts above, andmax_sessions/max_channels_per_session/max_write_buffer.ServerSession—accept_auth(),reject_auth(reason),accept_channel(ServerChannel),reject_channel(reason),close().ServerChannel(session, on_data, on_stderr, on_close)—accept_request(),reject_request(reason),write(data),write_stderr(data),send_eof(),send_exit_status(status),close().AuthRequest—method("password"/"publickey"),user,password?,pubkey?(an authorized_keys"<type> <base64>"line).HostKeyInfo—key_type,fingerprint. Host-key states:HOSTKEY_OK,HOSTKEY_UNKNOWN,HOSTKEY_NOT_FOUND,HOSTKEY_CHANGED,HOSTKEY_OTHER,HOSTKEY_ERROR.
acton test # loopback client<->server suite
acton test stress # concurrent churn (race/lifetime fuzzing)
./interop/run_interop.sh # interop with the OpenSSH ssh client and sshdsrc/bench_echo.act measures echo throughput; src/soak.act runs many
connect→exec→close cycles for leak checking.
Set ACTON_SSH_DEBUG=1 for gated lifecycle tracing (optionally
ACTON_SSH_DEBUG_FILE=/path to write per-pid logs), and
ACTON_SSH_LIBSSH_LOG=<level> to surface libssh's own logging.