╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────╮ │src/ssh_tui.zig│ ╰───────────────╯ ╭──────────────────────────────────────────────╮ │29 //! that side needs out-of-band signaling.│ │30 │ │31 const std = @import("std"); │ │32 +const builtin = @import("builtin"); │ │33 +const ssh = @import("./ssh.zig"); │ │34 │ │35 pub const magic = "haxy-tui-v1"; │ │36 │ ╰──────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │160 }, │ │161 }; │ │162 } │ │163 + │ │164 +// helper subcommand: connects to the tui listener and proxies the user's │ │165 +// PTY in both directions. invoked by sshd via a forced-command entry in │ │166 +// authorized_keys. │ │167 │ │168 +pub const Options = struct { │ │169 + tui_connect: []const u8 = blk: { │ │170 + const srv = @import("./serve.zig"); │ │171 + const opts: srv.Options = .{}; │ │172 + break :blk opts.tui_listen; │ │173 + }, │ │174 + user_key: ?[]const u8 = null, │ │175 +}; │ │176 + │ │177 +// SIGWINCH delivers no context; the handler reads this file-scope fd to │ │178 +// know where to write its wake-up byte. set by run() before sigaction is │ │179 +// installed; reset on the way out. │ │180 +var sigwinch_pipe_write: std.posix.fd_t = -1; │ │181 + │ │182 +pub fn run( │ │183 + io: std.Io, │ │184 + allocator: std.mem.Allocator, │ │185 + options: Options, │ │186 + environ_map: *std.process.Environ.Map, │ │187 +) !void { │ │188 + _ = allocator; │ │189 + │ │190 + if (builtin.os.tag == .windows) return error.WindowsNotSupported; │ │191 + │ │192 + const user_key = options.user_key orelse return error.MissingUserKey; │ │193 + const term_name = environ_map.get("TERM") orelse "xterm"; │ │194 + │ │195 + const initial_size = try getWinSize(); │ │196 + │ │197 + const conn = try ssh.parseConnectAddress(options.tui_connect); │ │198 + const address = try std.Io.net.IpAddress.parseIp4(conn.host, conn.port); │ │199 + const stream = try address.connect(io, .{ .mode = .stream }); │ │200 + defer stream.close(io); │ │201 + │ │202 + // put stdin into raw mode so the server sees user keystrokes as-is. │ │203 + // restore the original termios on the way out so we don't leave the │ │204 + // user's shell in a broken state if anything throws. │ │205 + const cooked = try std.posix.tcgetattr(std.posix.STDIN_FILENO); │ │206 + defer std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, cooked) catch {}; │ │207 + try setRawMode(std.posix.STDIN_FILENO, cooked); │ │208 + │ │209 + // self-pipe: SIGWINCH handler writes a byte, stdinProxy polls on the │ │210 + // read end and converts it into a resize frame. │ │211 + var pipe_fds: [2]i32 = undefined; │ │212 + const o_flags: std.os.linux.O = .{ .NONBLOCK = true, .CLOEXEC = true }; │ │213 + if (std.posix.errno(std.os.linux.pipe2(&pipe_fds, o_flags)) != .SUCCESS) { │ │214 + return error.PipeFailed; │ │215 + } │ │216 + defer { │ │217 + sigwinch_pipe_write = -1; │ │218 + _ = std.posix.system.close(pipe_fds[1]); │ │219 + _ = std.posix.system.close(pipe_fds[0]); │ │220 + } │ │221 + sigwinch_pipe_write = pipe_fds[1]; │ │222 + │ │223 + std.posix.sigaction(std.posix.SIG.WINCH, &.{ │ │224 + .handler = .{ .handler = sigwinchHandler }, │ │225 + .mask = std.posix.sigemptyset(), │ │226 + .flags = 0, │ │227 + }, null); │ │228 + │ │229 + // prelude │ │230 + var prelude_buf: [1024]u8 = undefined; │ │231 + var prelude_writer = stream.writer(io, &prelude_buf); │ │232 + try writePrelude(&prelude_writer.interface, .{ │ │233 + .user_key = user_key, │ │234 + .term = term_name, │ │235 + .width = initial_size.width, │ │236 + .height = initial_size.height, │ │237 + }); │ │238 + try prelude_writer.interface.flush(); │ │239 + │ │240 + // stdin proxy thread: frames stdin bytes + emits resize frames on │ │241 + // SIGWINCH. detached so we don't have to coordinate shutdown — when │ │242 + // run() returns, the process exits and the thread goes with it. │ │243 + const ctx = StdinThreadCtx{ .io = io, .stream = stream, .pipe_read = pipe_fds[0] }; │ │244 + const stdin_thread = try std.Thread.spawn(.{}, stdinProxy, .{ctx}); │ │245 + stdin_thread.detach(); │ │246 + │ │247 + // main: socket → stdout, raw. server's already-formed ANSI bytes pass │ │248 + // straight through to the user's terminal. │ │249 + try ssh.copyFd(stream.socket.handle, std.posix.STDOUT_FILENO); │ │250 +} │ │251 + │ │252 +fn sigwinchHandler(_: std.posix.SIG) callconv(.c) void { │ │253 + const fd = sigwinch_pipe_write; │ │254 + if (fd >= 0) { │ │255 + const byte = [_]u8{1}; │ │256 + _ = std.posix.system.write(fd, byte[0..].ptr, 1); │ │257 + } │ │258 +} │ │259 + │ │260 +fn setRawMode(fd: std.posix.fd_t, cooked: std.posix.termios) !void { │ │261 + var raw = cooked; │ │262 + raw.iflag = .{}; │ │263 + raw.oflag = .{}; │ │264 + raw.lflag = .{}; │ │265 + raw.cflag.CSIZE = .CS8; │ │266 + raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; │ │267 + raw.cc[@intFromEnum(std.posix.V.TIME)] = 0; │ │268 + try std.posix.tcsetattr(fd, .FLUSH, raw); │ │269 +} │ │270 + │ │271 +const WinSize = struct { width: u16, height: u16 }; │ │272 + │ │273 +fn getWinSize() !WinSize { │ │274 + var ws: std.posix.winsize = undefined; │ │275 + const rc = std.os.linux.ioctl(std.posix.STDOUT_FILENO, std.posix.T.IOCGWINSZ, @intFromPtr(&ws));│ │276 + if (std.posix.errno(rc) != .SUCCESS) return error.IoctlFailed; │ │277 + return .{ .width = ws.col, .height = ws.row }; │ │278 +} │ │279 + │ │280 +const StdinThreadCtx = struct { │ │281 + io: std.Io, │ │282 + stream: std.Io.net.Stream, │ │283 + pipe_read: std.posix.fd_t, │ │284 +}; │ │285 + │ │286 +fn stdinProxy(ctx: StdinThreadCtx) void { │ │287 + var send_buf: [4096]u8 = undefined; │ │288 + var stream_writer = ctx.stream.writer(ctx.io, &send_buf); │ │289 + const w = &stream_writer.interface; │ │290 + │ │291 + while (true) { │ │292 + var fds = [_]std.posix.pollfd{ │ │293 + .{ .fd = std.posix.STDIN_FILENO, .events = std.posix.POLL.IN, .revents = 0 }, │ │294 + .{ .fd = ctx.pipe_read, .events = std.posix.POLL.IN, .revents = 0 }, │ │295 + }; │ │296 + _ = std.posix.poll(&fds, -1) catch return; │ │297 + │ │298 + if (fds[1].revents & std.posix.POLL.IN != 0) { │ │299 + // drain whatever's in the pipe — multiple coalesced SIGWINCHes │ │300 + // all collapse into one resize-frame emission. │ │301 + var drain: [16]u8 = undefined; │ │302 + _ = std.posix.read(ctx.pipe_read, &drain) catch {}; │ │303 + if (getWinSize()) |size| { │ │304 + writeResizeFrame(w, size.width, size.height) catch return; │ │305 + w.flush() catch return; │ │306 + } else |_| {} │ │307 + } │ │308 + if (fds[0].revents & std.posix.POLL.IN != 0) { │ │309 + var buf: [4096]u8 = undefined; │ │310 + const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch return; │ │311 + if (n == 0) { │ │312 + writeCloseFrame(w) catch {}; │ │313 + w.flush() catch {}; │ │314 + ctx.stream.shutdown(ctx.io, .send) catch {}; │ │315 + return; │ │316 + } │ │317 + writeDataFrame(w, buf[0..n]) catch return; │ │318 + w.flush() catch return; │ │319 + } │ │320 + } │ │321 +} │ │322 + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯