╭ ssh_tui.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 //! the `ssh-tui` helper subcommand, plus the wire protocol it uses to talk 2 //! to the tui listener inside `haxy serve`. the protocol types and codecs 3 //! are shared between this file (which originates the messages) and 4 //! `serve_ssh_tui.zig` (which consumes them). 5 //! 6 //! the session begins with a text prelude: 7 //! 8 //! haxy-tui-v1 9 //! user-key=<ssh key fingerprint> 10 //! term=<value of $TERM> 11 //! width=<u16> 12 //! height=<u16> 13 //! <empty line> 14 //! 15 //! after the empty terminator line, the helper sends a stream of binary 16 //! frames. each frame is: 17 //! 18 //! 1 byte type 19 //! 4 bytes payload length (big-endian) 20 //! N bytes payload 21 //! 22 //! types are: 23 //! 0x01 data — raw stdin bytes from the user's terminal 24 //! 0x02 resize — payload is 4 bytes: u16 width, u16 height (big-endian) 25 //! 0x03 close — empty payload; clean session termination 26 //! 27 //! the reverse direction (serve → helper) is unframed: the server writes 28 //! ANSI bytes and the helper copies them straight to stdout. nothing on 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 37 pub const max_user_key_len: usize = 256; 38 pub const max_term_len: usize = 64; 39 pub const max_data_frame_len: usize = 64 * 1024; 40 41 pub const Prelude = struct { 42 user_key: []const u8, 43 term: []const u8, 44 width: u16, 45 height: u16, 46 47 pub fn deinit(self: Prelude, allocator: std.mem.Allocator) void { 48 allocator.free(self.user_key); 49 allocator.free(self.term); 50 } 51 }; 52 53 pub fn writePrelude(writer: *std.Io.Writer, prelude: Prelude) !void { 54 try writer.print( 55 "{s}\nuser-key={s}\nterm={s}\nwidth={d}\nheight={d}\n\n", 56 .{ magic, prelude.user_key, prelude.term, prelude.width, prelude.height }, 57 ); 58 } 59 60 pub fn readPrelude(allocator: std.mem.Allocator, reader: *std.Io.Reader) !Prelude { 61 const magic_line = try takeLine(reader); 62 if (!std.mem.eql(u8, magic_line, magic)) return error.InvalidMagic; 63 64 const user_key_val = stripPrefix(try takeLine(reader), "user-key=") orelse return error.InvalidPrelude; 65 if (user_key_val.len > max_user_key_len) return error.InvalidPrelude; 66 const user_key = try allocator.dupe(u8, user_key_val); 67 errdefer allocator.free(user_key); 68 69 const term_val = stripPrefix(try takeLine(reader), "term=") orelse return error.InvalidPrelude; 70 if (term_val.len > max_term_len) return error.InvalidPrelude; 71 const term = try allocator.dupe(u8, term_val); 72 errdefer allocator.free(term); 73 74 const width_val = stripPrefix(try takeLine(reader), "width=") orelse return error.InvalidPrelude; 75 const width = std.fmt.parseInt(u16, width_val, 10) catch return error.InvalidPrelude; 76 77 const height_val = stripPrefix(try takeLine(reader), "height=") orelse return error.InvalidPrelude; 78 const height = std.fmt.parseInt(u16, height_val, 10) catch return error.InvalidPrelude; 79 80 const terminator = try takeLine(reader); 81 if (terminator.len != 0) return error.InvalidPrelude; 82 83 return .{ .user_key = user_key, .term = term, .width = width, .height = height }; 84 } 85 86 fn takeLine(reader: *std.Io.Reader) ![]const u8 { 87 return (try reader.takeDelimiter('\n')) orelse error.InvalidPrelude; 88 } 89 90 fn stripPrefix(line: []const u8, prefix: []const u8) ?[]const u8 { 91 if (!std.mem.startsWith(u8, line, prefix)) return null; 92 return line[prefix.len..]; 93 } 94 95 pub const FrameKind = enum(u8) { 96 data = 0x01, 97 resize = 0x02, 98 close = 0x03, 99 }; 100 101 pub const Frame = union(FrameKind) { 102 data: []u8, 103 resize: struct { width: u16, height: u16 }, 104 close, 105 106 pub fn deinit(self: Frame, allocator: std.mem.Allocator) void { 107 switch (self) { 108 .data => |bytes| allocator.free(bytes), 109 else => {}, 110 } 111 } 112 }; 113 114 pub fn writeDataFrame(writer: *std.Io.Writer, payload: []const u8) !void { 115 if (payload.len > max_data_frame_len) return error.FrameTooLarge; 116 try writeFrameHeader(writer, .data, @intCast(payload.len)); 117 try writer.writeAll(payload); 118 } 119 120 pub fn writeResizeFrame(writer: *std.Io.Writer, width: u16, height: u16) !void { 121 try writeFrameHeader(writer, .resize, 4); 122 try writer.writeInt(u16, width, .big); 123 try writer.writeInt(u16, height, .big); 124 } 125 126 pub fn writeCloseFrame(writer: *std.Io.Writer) !void { 127 try writeFrameHeader(writer, .close, 0); 128 } 129 130 fn writeFrameHeader(writer: *std.Io.Writer, kind: FrameKind, length: u32) !void { 131 try writer.writeByte(@intFromEnum(kind)); 132 try writer.writeInt(u32, length, .big); 133 } 134 135 pub fn readFrame(allocator: std.mem.Allocator, reader: *std.Io.Reader) !Frame { 136 const type_byte = try reader.takeByte(); 137 const length = try reader.takeInt(u32, .big); 138 const kind: FrameKind = switch (type_byte) { 139 @intFromEnum(FrameKind.data) => .data, 140 @intFromEnum(FrameKind.resize) => .resize, 141 @intFromEnum(FrameKind.close) => .close, 142 else => return error.InvalidFrameType, 143 }; 144 145 return switch (kind) { 146 .data => blk: { 147 if (length > max_data_frame_len) return error.FrameTooLarge; 148 const payload = try reader.readAlloc(allocator, length); 149 break :blk .{ .data = payload }; 150 }, 151 .resize => blk: { 152 if (length != 4) return error.InvalidResizeFrame; 153 const w = try reader.takeInt(u16, .big); 154 const h = try reader.takeInt(u16, .big); 155 break :blk .{ .resize = .{ .width = w, .height = h } }; 156 }, 157 .close => blk: { 158 if (length != 0) return error.InvalidCloseFrame; 159 break :blk .close; 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 // fall back to a conventional 80x24 if the PTY hasn't been told a size yet — 274 // this happens when the SSH client has no local controlling terminal (e.g. 275 // non-interactive piped input) and never sends a window-change request. 276 const default_width: u16 = 80; 277 const default_height: u16 = 24; 278 279 fn getWinSize() !WinSize { 280 var ws: std.posix.winsize = undefined; 281 const rc = std.os.linux.ioctl(std.posix.STDOUT_FILENO, std.posix.T.IOCGWINSZ, @intFromPtr(&ws)); 282 if (std.posix.errno(rc) != .SUCCESS) return error.IoctlFailed; 283 return .{ 284 .width = if (ws.col == 0) default_width else ws.col, 285 .height = if (ws.row == 0) default_height else ws.row, 286 }; 287 } 288 289 const StdinThreadCtx = struct { 290 io: std.Io, 291 stream: std.Io.net.Stream, 292 pipe_read: std.posix.fd_t, 293 }; 294 295 fn stdinProxy(ctx: StdinThreadCtx) void { 296 var send_buf: [4096]u8 = undefined; 297 var stream_writer = ctx.stream.writer(ctx.io, &send_buf); 298 const w = &stream_writer.interface; 299 300 while (true) { 301 var fds = [_]std.posix.pollfd{ 302 .{ .fd = std.posix.STDIN_FILENO, .events = std.posix.POLL.IN, .revents = 0 }, 303 .{ .fd = ctx.pipe_read, .events = std.posix.POLL.IN, .revents = 0 }, 304 }; 305 _ = std.posix.poll(&fds, -1) catch return; 306 307 if (fds[1].revents & std.posix.POLL.IN != 0) { 308 // drain whatever's in the pipe — multiple coalesced SIGWINCHes 309 // all collapse into one resize-frame emission. 310 var drain: [16]u8 = undefined; 311 _ = std.posix.read(ctx.pipe_read, &drain) catch {}; 312 if (getWinSize()) |size| { 313 writeResizeFrame(w, size.width, size.height) catch return; 314 w.flush() catch return; 315 } else |_| {} 316 } 317 if (fds[0].revents & std.posix.POLL.IN != 0) { 318 var buf: [4096]u8 = undefined; 319 const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch return; 320 if (n == 0) { 321 writeCloseFrame(w) catch {}; 322 w.flush() catch {}; 323 ctx.stream.shutdown(ctx.io, .send) catch {}; 324 return; 325 } 326 writeDataFrame(w, buf[0..n]) catch return; 327 w.flush() catch return; 328 } 329 } 330 } 331