╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │10 const evt = @import("./event.zig"); │ │11 const serve_common = @import("./serve_common.zig"); │ │12 │ │13 +// listener resource limits. idle enforcement is coarse: the watchdog wakes │ │14 +// once per interval and shuts the connection down if no SSH packet arrived │ │15 +// during the entire previous interval. each connection holds two OS threads│ │16 +// under std.Io.Threaded (session + watchdog), so the cap is sized to keep │ │17 +// the worst-case thread count in comfortable territory, not by RAM. │ │18 +const max_connections: u32 = 4096; │ │19 +const idle_interval = std.Io.Duration.fromSeconds(120); │ │20 + │ │21 +var active_connections: std.atomic.Value(u32) = .init(0); │ │22 + │ │23 pub const SessionHandler = struct { │ │24 admin_repo_path: []const u8, │ │25 repo_root_path: []const u8, │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │38 try sess.exit(1); │ │39 return; │ │40 }; │ │41 + sess.exemptFromIdleTimeout(); │ │42 try runTuiSession(self, sess, pty);│ │43 }, │ │44 .exec => |command| { │ ╰───────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │68 const handle = struct { │ │69 fn h(ctx: Context, stream: std.Io.net.Stream) void { │ │70 defer stream.close(ctx.io); │ │71 + │ │72 + const prev_count = active_connections.fetchAdd(1, .monotonic); │ │73 + defer _ = active_connections.fetchSub(1, .monotonic); │ │74 + if (prev_count >= max_connections) { │ │75 + serve_common.logError(ctx.err, "ssh: connection limit reached, dropping\n", .{}); │ │76 + return; │ │77 + } │ │78 + │ │79 + var idle_state = ssh.IdleState{}; │ │80 + var watchdog_future = std.Io.concurrent(ctx.io, watchdog, .{ ctx.io, &stream, &idle_state }) catch |spawn_err| {│ │81 + serve_common.logError(ctx.err, "ssh: watchdog spawn failed: {s}\n", .{@errorName(spawn_err)}); │ │82 + return; │ │83 + }; │ │84 + defer watchdog_future.cancel(ctx.io); │ │85 + │ │86 var recv_buf: [4096]u8 = undefined; │ │87 var send_buf: [4096]u8 = undefined; │ │88 var stream_reader = stream.reader(ctx.io, &recv_buf); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │93 &stream_reader.interface, │ │94 &stream_writer.interface, │ │95 ctx.host_key, │ │96 + &idle_state, │ │97 ctx.session_handler, │ │98 ) catch |session_err| { │ │99 serve_common.logError(ctx.err, "ssh session failed: {s}\n", .{@errorName(session_err)});│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │110 }, handle); │ │111 } │ │112 │ │113 +// shut down connections that made no SSH packet progress for a whole │ │114 +// interval. exempt connections (interactive TUIs) end enforcement instead. │ │115 +fn watchdog(io: std.Io, stream: *const std.Io.net.Stream, idle_state: *ssh.IdleState) void { │ │116 + var last_seen: u64 = 0; │ │117 + while (true) { │ │118 + io.sleep(idle_interval, .awake) catch return; // canceled — connection is done │ │119 + if (idle_state.exempt.load(.monotonic)) return; │ │120 + const seen = idle_state.activity.load(.monotonic); │ │121 + if (seen == last_seen) { │ │122 + stream.shutdown(io, .both) catch {}; │ │123 + return; │ │124 + } │ │125 + last_seen = seen; │ │126 + } │ │127 +} │ │128 + │ │129 fn runTuiSession(handler: *const SessionHandler, sess: *ssh.SessionCtx, pty: ssh.PtySize) !void {│ │130 // runTui owns the terminal, whose deinit restores the client's screen and │ │131 // runs as the function unwinds — so any error it returns leaves the TUI torn │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │2 //! │ │3 //! handles, per connection: version exchange, KEXINIT, curve25519 key │ │4 //! exchange, NEWKEYS, key derivation, chacha20-poly1305 packet codec, │ │5 +//! strict kex (Terrapin mitigation), client-initiated rekeying, │ │6 //! ssh-userauth service request, publickey authentication (ed25519 only;│ │7 //! captures the SHA256 fingerprint of the verified key), channel-layer │ │8 //! requests (pty-req, env, window-change, shell, exec), CHANNEL_DATA │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │27 //! MAC: none (implicit in AEAD) │ │28 //! compress: none │ │29 //! user auth: publickey + ssh-ed25519 only (RSA/ECDSA deferred) │ │29 -//! │ │30 -//! TODO: add SSH session idle deadlines. A reachable SSH listener can currently│ │31 -//! hold one task per idle connection while blocked in banner exchange, KEX, │ │32 -//! auth, channel setup, waiting for send WINDOW_ADJUST, reading git pack data, │ │33 -//! or waiting for peer CLOSE during teardown. The eventual fix should enforce a│ │34 -//! per-connection idle timeout that is refreshed by successful SSH packet I/O │ │35 -//! and closes/shuts down the underlying stream when no progress occurs. Prefer │ │36 -//! an I/O-layer deadline if std.Io exposes one; otherwise use a carefully-owned│ │37 -//! watchdog that cannot outlive the connection state it observes. │ │30 │ │31 const std = @import("std"); │ │32 const builtin = @import("builtin"); │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────╮ │43 const max_name_list_len: u32 = 4096; │ │44 const max_auth_attempts: u32 = 20; │ │45 const max_exit_drain_packets: u32 = 32; │ │46 +const max_banner_lines: u32 = 32; │ │47 │ │48 // SSH message type bytes (RFC 4250 §4.1)│ │49 pub const SSH_MSG_DISCONNECT: u8 = 1; │ ╰─────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 91 const max_incoming_buffered: usize = initial_recv_window; │ │ 92 const incoming_refill_threshold: usize = max_incoming_buffered / 2; │ │ 93 │ │ 94 +/// shared with the host's idle watchdog. `activity` is bumped on every │ │ 95 +/// successfully decrypted packet so the watchdog can tell an idle connection │ │ 96 +/// from a slow one; `exempt` is set once an interactive session starts, │ │ 97 +/// since users idle in a TUI legitimately. │ │ 98 +pub const IdleState = struct { │ │ 99 + activity: std.atomic.Value(u64) = .init(0), │ │100 + exempt: std.atomic.Value(bool) = .init(false), │ │101 +}; │ │102 + │ │103 // ---------------------------------------------------------------------------│ │104 // host key │ │105 // ---------------------------------------------------------------------------│ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │127 } │ │128 │ │129 const keypair = Ed25519.KeyPair.generate(io); │ │128 - const file = try cwd.createFile(io, path, .{}); │ │130 + // owner-only from the start — never a window where the key is readable │ │131 + const permissions: std.Io.File.Permissions = if (builtin.os.tag == .windows) .default_file else @enumFromInt(0o600);│ │132 + const file = try cwd.createFile(io, path, .{ .permissions = permissions }); │ │133 defer file.close(io); │ │134 try file.writeStreamingAll(io, &keypair.secret_key.bytes); │ │131 - if (builtin.os.tag != .windows) { │ │132 - try file.setPermissions(io, @enumFromInt(0o600)); │ │133 - } │ │135 return .{ .keypair = keypair }; │ │136 } │ │137 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯