╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │11 const serve_common = @import("./serve_common.zig"); │ │12 const fork = @import("./fork.zig"); │ │13 │ │14 -// listener resource limits. the watchdog gives unauthenticated peers a hard │ │15 -// deadline, detects idle non-interactive sessions from protocol progress, and │ │16 -// bounds the post-CLOSE drain. each connection holds two OS threads under │ │17 -// std.Io.Threaded (session + watchdog), so the cap is sized by thread count. │ │14 +// listener resource limits. the watchdog gives peers a hard deadline to start │ │15 +// a session, times out non-interactive sessions that stall while we're blocked │ │16 +// on the peer, and bounds the post-CLOSE drain. │ │17 const max_connections: u32 = 4096; │ │18 const watchdog_interval = std.Io.Duration.fromSeconds(5); │ │20 -const preauth_timeout_ticks: u32 = 6; // 30 seconds │ │19 +const presession_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 const escape_timeout: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(25), .clock = .awake } };│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────╮ │59 allocator: std.mem.Allocator, │ │60 host_key: *const ssh.HostKey, │ │61 session_handler: *const SessionHandler,│ │62 + watchdog: *Watchdog, │ │63 net_server: *std.Io.net.Server, │ │64 tasks: *std.Io.Group, │ │65 err: *std.Io.Writer, │ ╰───────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────╮ │69 allocator: std.mem.Allocator, │ │70 host_key: *const ssh.HostKey, │ │71 session_handler: *const SessionHandler,│ │72 + watchdog: *Watchdog, │ │73 err: *std.Io.Writer, │ │74 }; │ │75 │ ╰───────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │84 return; │ │85 } │ │86 │ │86 - var idle_state = ssh.IdleState{}; │ │87 - var watchdog_future = std.Io.concurrent(ctx.io, watchdog, .{ ctx.io, &stream, &idle_state }) catch |spawn_err| {│ │88 - serve_common.logError(ctx.io, ctx.err, "ssh: watchdog spawn failed: {s}\n", .{@errorName(spawn_err)}); │ │89 - return; │ │90 - }; │ │91 - defer watchdog_future.cancel(ctx.io); │ │87 + var connection = Watchdog.Connection{ .stream = &stream }; │ │88 + ctx.watchdog.add(ctx.io, &connection); │ │89 + defer ctx.watchdog.remove(ctx.io, &connection); │ │90 │ │91 // timed peeks need room for a complete encrypted packet │ │92 var recv_buf: [ssh.packet_buffer_size]u8 = undefined; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 99 &stream_reader.interface, │ │100 &stream_writer.interface, │ │101 ctx.host_key, │ │104 - &idle_state, │ │102 + &connection.idle_state, │ │103 ctx.session_handler, │ │104 ) catch |session_err| { │ │105 serve_common.logError(ctx.io, ctx.err, "ssh session failed: {s}\n", .{@errorName(session_err)});│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │107 } │ │108 }.h; │ │109 │ │110 + // without the watchdog nothing bounds a connection, so don't listen │ │111 + tasks.concurrent(io, Watchdog.run, .{ watchdog, io }) catch |spawn_err| { │ │112 + serve_common.logError(io, err, "ssh: watchdog spawn failed: {s}\n", .{@errorName(spawn_err)});│ │113 + return; │ │114 + }; │ │115 + │ │116 serve_common.runListener(io, net_server, tasks, err, "ssh", Context{ │ │117 .io = io, │ │118 .allocator = allocator, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────╮ │119 .host_key = host_key, │ │120 .session_handler = session_handler,│ │121 + .watchdog = watchdog, │ │122 .err = err, │ │123 }, handle); │ │124 } │ ╰────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │125 │ │121 -// enforce a hard pre-auth deadline, an idle timeout for non-interactive │ │122 -// sessions, and a bounded close drain. interactive sessions are exempt only │ │123 -// from the idle timeout; the watchdog remains available to break a stalled │ │124 -// teardown. │ │125 -fn watchdog(io: std.Io, stream: *const std.Io.net.Stream, idle_state: *ssh.IdleState) void { │ │126 - var last_seen: u64 = 0; │ │127 - var preauth_ticks: u32 = 0; │ │128 - var idle_ticks: u32 = 0; │ │129 - var closing_ticks: u32 = 0; │ │130 - while (true) { │ │131 - io.sleep(watchdog_interval, .awake) catch return; // canceled — connection is done │ │126 +/// one task watches every connection. a connection links its entry only │ │127 +/// while it runs, so the watchdog never sees a closed stream. │ │128 +pub const Watchdog = struct { │ │129 + mutex: std.Io.Mutex = .init, │ │130 + connections: std.DoublyLinkedList = .{}, │ │131 + │ │132 + const Connection = struct { │ │133 + node: std.DoublyLinkedList.Node = .{}, │ │134 + stream: *const std.Io.net.Stream, │ │135 + idle_state: ssh.IdleState = .{}, │ │136 + // tick state, touched only by the watchdog │ │137 + last_seen: u64 = 0, │ │138 + presession_ticks: u32 = 0, │ │139 + idle_ticks: u32 = 0, │ │140 + closing_ticks: u32 = 0, │ │141 │ │133 - if (idle_state.closing.load(.acquire)) { │ │134 - closing_ticks += 1; │ │135 - if (closing_ticks >= close_drain_timeout_ticks) { │ │136 - stream.shutdown(io, .both) catch {}; │ │137 - return; │ │142 + // interactive sessions are exempt only from the idle timeout, so a │ │143 + // stalled teardown can still be broken │ │144 + fn timedOut(self: *Connection) bool { │ │145 + if (self.idle_state.closing.load(.acquire)) { │ │146 + self.closing_ticks += 1; │ │147 + return self.closing_ticks >= close_drain_timeout_ticks; │ │148 + } │ │149 + if (!self.idle_state.session_started.load(.acquire)) { │ │150 + self.presession_ticks += 1; │ │151 + return self.presession_ticks >= presession_timeout_ticks; │ │152 } │ │139 - continue; │ │140 - } │ │153 + if (self.idle_state.exempt.load(.acquire)) return false; │ │154 │ │142 - if (!idle_state.authenticated.load(.acquire)) { │ │143 - preauth_ticks += 1; │ │144 - if (preauth_ticks >= preauth_timeout_ticks) { │ │145 - stream.shutdown(io, .both) catch {}; │ │146 - return; │ │155 + // our own work between packets is not the peer idling │ │156 + const seen = self.idle_state.activity.load(.monotonic); │ │157 + defer self.last_seen = seen; │ │158 + if (self.idle_state.waiting.load(.acquire) and seen == self.last_seen) { │ │159 + self.idle_ticks += 1; │ │160 + } else { │ │161 + self.idle_ticks = 0; │ │162 } │ │148 - continue; │ │163 + return self.idle_ticks >= idle_timeout_ticks; │ │164 } │ │165 + }; │ │166 │ │151 - if (idle_state.exempt.load(.monotonic)) continue; │ │152 - const seen = idle_state.activity.load(.monotonic); │ │153 - if (seen == last_seen) { │ │154 - idle_ticks += 1; │ │155 - if (idle_ticks >= idle_timeout_ticks) { │ │156 - stream.shutdown(io, .both) catch {}; │ │157 - return; │ │167 + fn add(self: *Watchdog, io: std.Io, connection: *Connection) void { │ │168 + self.mutex.lockUncancelable(io); │ │169 + defer self.mutex.unlock(io); │ │170 + self.connections.append(&connection.node); │ │171 + } │ │172 + │ │173 + fn remove(self: *Watchdog, io: std.Io, connection: *Connection) void { │ │174 + self.mutex.lockUncancelable(io); │ │175 + defer self.mutex.unlock(io); │ │176 + self.connections.remove(&connection.node); │ │177 + } │ │178 + │ │179 + fn run(self: *Watchdog, io: std.Io) void { │ │180 + while (true) { │ │181 + io.sleep(watchdog_interval, .awake) catch return; │ │182 + self.mutex.lock(io) catch return; │ │183 + defer self.mutex.unlock(io); │ │184 + var node = self.connections.first; │ │185 + while (node) |n| : (node = n.next) { │ │186 + const connection: *Connection = @fieldParentPtr("node", n); │ │187 + if (connection.timedOut()) connection.stream.shutdown(io, .both) catch {}; │ │188 } │ │159 - } else { │ │160 - idle_ticks = 0; │ │189 } │ │162 - last_seen = seen; │ │190 } │ │164 -} │ │191 +}; │ │192 │ │193 fn runTuiSession(handler: *const SessionHandler, sess: *ssh.SessionCtx, pty: ssh.PtySize) !void {│ │194 // runTui owns the terminal, whose deinit restores the client's screen and │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯