╭ 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. │ │13 +// listener resource limits. the watchdog gives unauthenticated peers a hard │ │14 +// deadline, detects idle non-interactive sessions from protocol progress, and│ │15 +// bounds the post-CLOSE drain. each connection holds two OS threads under │ │16 +// std.Io.Threaded (session + watchdog), so the cap is sized by thread count. │ │17 const max_connections: u32 = 4096; │ │19 -const idle_interval = std.Io.Duration.fromSeconds(120); │ │18 +const watchdog_interval = std.Io.Duration.fromSeconds(5); │ │19 +const preauth_timeout_ticks: u32 = 6; // 30 seconds │ │20 +const idle_timeout_ticks: u32 = 24; // 120 seconds │ │21 +const close_drain_timeout_ticks: u32 = 2; // 5-10 seconds │ │22 │ │23 var active_connections: std.atomic.Value(u32) = .init(0); │ │24 │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │112 }, handle); │ │113 } │ │114 │ │113 -// shut down connections that made no SSH packet progress for a whole │ │114 -// interval. exempt connections (interactive TUIs) end enforcement instead. │ │115 +// enforce a hard pre-auth deadline, an idle timeout for non-interactive │ │116 +// sessions, and a bounded close drain. interactive sessions are exempt only │ │117 +// from the idle timeout; the watchdog remains available to break a stalled │ │118 +// teardown. │ │119 fn watchdog(io: std.Io, stream: *const std.Io.net.Stream, idle_state: *ssh.IdleState) void {│ │120 var last_seen: u64 = 0; │ │121 + var preauth_ticks: u32 = 0; │ │122 + var idle_ticks: u32 = 0; │ │123 + var closing_ticks: u32 = 0; │ │124 while (true) { │ │118 - io.sleep(idle_interval, .awake) catch return; // canceled — connection is done │ │119 - if (idle_state.exempt.load(.monotonic)) return; │ │125 + io.sleep(watchdog_interval, .awake) catch return; // canceled — connection is done │ │126 + │ │127 + if (idle_state.closing.load(.acquire)) { │ │128 + closing_ticks += 1; │ │129 + if (closing_ticks >= close_drain_timeout_ticks) { │ │130 + stream.shutdown(io, .both) catch {}; │ │131 + return; │ │132 + } │ │133 + continue; │ │134 + } │ │135 + │ │136 + if (!idle_state.authenticated.load(.acquire)) { │ │137 + preauth_ticks += 1; │ │138 + if (preauth_ticks >= preauth_timeout_ticks) { │ │139 + stream.shutdown(io, .both) catch {}; │ │140 + return; │ │141 + } │ │142 + continue; │ │143 + } │ │144 + │ │145 + if (idle_state.exempt.load(.monotonic)) continue; │ │146 const seen = idle_state.activity.load(.monotonic); │ │147 if (seen == last_seen) { │ │122 - stream.shutdown(io, .both) catch {}; │ │123 - return; │ │148 + idle_ticks += 1; │ │149 + if (idle_ticks >= idle_timeout_ticks) { │ │150 + stream.shutdown(io, .both) catch {}; │ │151 + return; │ │152 + } │ │153 + } else { │ │154 + idle_ticks = 0; │ │155 } │ │156 last_seen = seen; │ │157 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 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. │ │ 94 +/// shared with the host's watchdog. `activity` is bumped only for non-ignored│ │ 95 +/// session packets, `authenticated` releases the hard pre-auth deadline, and │ │ 96 +/// `closing` starts the bounded close drain. `exempt` is set once an │ │ 97 +/// interactive session starts, since users idle in a TUI legitimately. │ │ 98 pub const IdleState = struct { │ │ 99 activity: std.atomic.Value(u64) = .init(0), │ │100 + authenticated: std.atomic.Value(bool) = .init(false), │ │101 exempt: std.atomic.Value(bool) = .init(false), │ │102 + closing: std.atomic.Value(bool) = .init(false), │ │103 }; │ │104 │ │105 // ---------------------------------------------------------------------------│ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │379 if (self.closed) return; │ │380 self.closed = true; │ │381 const conn = self.conn; │ │382 + if (conn.cs_cipher.idle) |idle| idle.closing.store(true, .release);│ │383 │ │384 // exit-status request (informational; client uses it as the │ │385 // command's exit code) │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │596 conn.cs_cipher.idle = idle; │ │597 │ │598 const fingerprint = try runAuth(&conn); │ │599 + if (idle) |state| state.authenticated.store(true, .release);│ │600 │ │601 try runChannelLayer(&conn, &fingerprint, handler); │ │602 } │ ╰─────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │668 │ │669 // transport for KEX packets: the initial exchange runs in plaintext, a rekey│ │670 // runs under the current session ciphers. │ │667 -const KexTransport = union(enum) { │ │671 +pub const KexTransport = union(enum) { │ │672 plain: *u64, // counts packets read so the caller can seed cipher seqnos │ │673 encrypted: struct { cs: *Cipher, sc: *Cipher }, │ │674 │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │675 // read one packet, skipping ignorable messages — unless strict kex is in │ │676 // force, which bans them mid-handshake │ │673 - fn readPacket(self: KexTransport, allocator: std.mem.Allocator, reader: *std.Io.Reader, strict: bool) ![]u8 { │ │677 + pub fn readPacket(self: KexTransport, allocator: std.mem.Allocator, reader: *std.Io.Reader, strict: bool) ![]u8 {│ │678 while (true) { │ │679 const packet = switch (self) { │ │680 .plain => |count| packet: { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │703 │ │704 // read one encrypted packet for the auth and channel layers, transparently │ │705 // skipping ignorable messages and servicing client-initiated rekeys. │ │702 -fn readSessionPacket(conn: *Conn) ![]u8 { │ │706 +pub fn readSessionPacket(conn: *Conn) ![]u8 { │ │707 while (true) { │ │708 const packet = try conn.cs_cipher.readPacket(conn.allocator, conn.reader);│ │709 if (packet.len >= 1 and isIgnorableMsg(packet[0])) { │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────╮ │712 } │ │713 if (packet.len >= 1 and packet[0] == SSH_MSG_KEXINIT) {│ │714 defer conn.allocator.free(packet); │ │715 + recordActivity(conn); │ │716 try runRekey(conn, packet); │ │717 continue; │ │718 } │ ╰────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────╮ │719 + recordActivity(conn);│ │720 return packet; │ │721 } │ │722 } │ ╰──────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯