╭ changed ──────╮ │src/ssh_tui.zig│ ╰───────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │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; │ │277 - return .{ .width = ws.col, .height = ws.row }; │ │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 { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭──────────────────────────────────────╮ │3 //! out safely. │ │4 │ │5 const std = @import("std"); │ │6 +const builtin = @import("builtin");│ │7 const hx = @import("haxy"); │ │8 const srv = hx.serve; │ │9 const evt = hx.event; │ ╰──────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │11 const rp = xit.repo; │ │12 const ui = hx.ui; │ │13 │ │13 -pub fn main() !void { │ │14 +pub fn main(init: std.process.Init) !void { │ │15 var gpa: std.heap.DebugAllocator(.{}) = .init;│ │16 defer _ = gpa.deinit(); │ │17 const allocator = gpa.allocator(); │ ╰──────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │18 │ │18 - var threaded = std.Io.Threaded.init(allocator, .{});│ │19 + var threaded = std.Io.Threaded.init(allocator, .{ │ │20 + .environ = init.minimal.environ, │ │21 + }); │ │22 defer threaded.deinit(); │ │23 const io = threaded.io(); │ │24 │ ╰────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │192 │ │193 // start the server │ │194 │ │192 - var null_writer = std.Io.Writer.Discarding.init(&.{}); │ │193 - var environ_map = std.process.Environ.Map.init(allocator); │ │194 - defer environ_map.deinit(); │ │195 - const run_opts = hx.main.RunOpts{ .out = &null_writer.writer, .err = &null_writer.writer, .environ_map = &environ_map };│ │195 + var launch_sshd = false; │ │196 + │ │197 + var arg_it = try init.minimal.args.iterateAllocator(allocator); │ │198 + defer arg_it.deinit(); │ │199 + _ = arg_it.skip(); │ │200 + while (arg_it.next()) |arg| { │ │201 + if (std.mem.eql(u8, "--sshd", arg)) { │ │202 + launch_sshd = true; │ │203 + } │ │204 + } │ │205 │ │206 const server_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "server" }); │ │207 defer allocator.free(server_path); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 + │ │209 + if (launch_sshd) { │ │210 + var stdout_writer = std.Io.File.stdout().writer(io, &.{}); │ │211 + var stderr_writer = std.Io.File.stderr().writer(io, &.{}); │ │212 + const run_opts = hx.main.RunOpts{ .out = &stdout_writer.interface, .err = &stderr_writer.interface, .environ_map = init.environ_map }; │ │213 + │ │214 + const port = 2222; │ │215 + │ │216 + // create priv host key │ │217 + const host_key_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/host_key", .{}); │ │218 + defer host_key_file.close(io); │ │219 + try host_key_file.writeStreamingAll(io, │ │220 + \\-----BEGIN OPENSSH PRIVATE KEY----- │ │221 + \\b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS │ │222 + \\1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQS1ppUfk8n7yvVKEgz3tXjt4q76VGuj │ │223 + \\LcQlRwmogzovV40LLcX0aTObZlQaLWfzJMNpCa/ztMpQlr86nsarE4lEAAAAqLe43zK3uN │ │224 + \\8yAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLWmlR+TyfvK9UoS │ │225 + \\DPe1eO3irvpUa6MtxCVHCaiDOi9XjQstxfRpM5tmVBotZ/Mkw2kJr/O0ylCWvzqexqsTiU │ │226 + \\QAAAAgQ+LCk30ZNJxb2Da5JL+QOFWCMf7bgXCWcEzhEGGvFWYAAAALcmFkYXJAcm9hcmsB │ │227 + \\AgMEBQ== │ │228 + \\-----END OPENSSH PRIVATE KEY----- │ │229 + \\ │ │230 + ); │ │231 + if (.windows != builtin.os.tag) { │ │232 + try host_key_file.setPermissions(io, @enumFromInt(0o600)); │ │233 + } │ │234 + │ │235 + // create priv client key │ │236 + const priv_key_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/key", .{}); │ │237 + defer priv_key_file.close(io); │ │238 + try priv_key_file.writeStreamingAll(io, │ │239 + \\-----BEGIN OPENSSH PRIVATE KEY----- │ │240 + \\b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW │ │241 + \\QyNTUxOQAAACCniLPJiaooAWecvOCeAjoJwCSeWxzysvpTNkpYjF22JgAAAJA+7hikPu4Y │ │242 + \\pAAAAAtzc2gtZWQyNTUxOQAAACCniLPJiaooAWecvOCeAjoJwCSeWxzysvpTNkpYjF22Jg │ │243 + \\AAAEDVlopOMnKt/7by/IA8VZvQXUS/O6VLkixOqnnahUdPCKeIs8mJqigBZ5y84J4COgnA │ │244 + \\JJ5bHPKy+lM2SliMXbYmAAAAC3JhZGFyQHJvYXJrAQI= │ │245 + \\-----END OPENSSH PRIVATE KEY----- │ │246 + \\ │ │247 + ); │ │248 + if (.windows != builtin.os.tag) { │ │249 + try priv_key_file.setPermissions(io, @enumFromInt(0o600)); │ │250 + } │ │251 + │ │252 + // create pub key │ │253 + const pub_key_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/key.pub", .{}); │ │254 + defer pub_key_file.close(io); │ │255 + try pub_key_file.writeStreamingAll(io, │ │256 + \\ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeIs8mJqigBZ5y84J4COgnAJJ5bHPKy+lM2SliMXbYm radar@roark │ │257 + \\ │ │258 + ); │ │259 + if (.windows != builtin.os.tag) { │ │260 + try pub_key_file.setPermissions(io, @enumFromInt(0o600)); │ │261 + } │ │262 + │ │263 + const haxy_bin_path = try std.fs.path.join(allocator, &.{ cwd_path, "zig-out", "bin", "haxy" }); │ │264 + defer allocator.free(haxy_bin_path); │ │265 + │ │266 + // dispatch.sh — sshd's forced command. picks between ssh-tui (for │ │267 + // interactive sessions) and ssh-git (for `git clone`/`git push`) by │ │268 + // checking whether sshd populated $SSH_ORIGINAL_COMMAND │ │269 + const dispatch_contents = try std.fmt.allocPrint( │ │270 + allocator, │ │271 + "#!/bin/sh\n" ++ │ │272 + "if [ -z \"$SSH_ORIGINAL_COMMAND\" ]; then\n" ++ │ │273 + " exec {s} ssh-tui --tui-connect 127.0.0.1:8082 --user-key test-user\n" ++ │ │274 + "else\n" ++ │ │275 + " exec {s} ssh-git\n" ++ │ │276 + "fi\n", │ │277 + .{ haxy_bin_path, haxy_bin_path }, │ │278 + ); │ │279 + defer allocator.free(dispatch_contents); │ │280 + │ │281 + // scope the file handle so the close happens before sshd ever spawns │ │282 + // a shell that tries to exec it — otherwise Linux raises ETXTBSY. │ │283 + { │ │284 + const dispatch_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/dispatch.sh", .{}); │ │285 + defer dispatch_file.close(io); │ │286 + try dispatch_file.writeStreamingAll(io, dispatch_contents); │ │287 + if (.windows != builtin.os.tag) { │ │288 + try dispatch_file.setPermissions(io, @enumFromInt(0o755)); │ │289 + } │ │290 + } │ │291 + │ │292 + // create authorized_keys file. forced command runs dispatch.sh so a │ │293 + // single key can drive both the TUI and git operations. │ │294 + const dispatch_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "dispatch.sh" }); │ │295 + defer allocator.free(dispatch_path); │ │296 + │ │297 + const auth_keys_contents = try std.fmt.allocPrint( │ │298 + allocator, │ │299 + "command=\"{s}\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeIs8mJqigBZ5y84J4COgnAJJ5bHPKy+lM2SliMXbYm radar@roark\n", │ │300 + .{dispatch_path}, │ │301 + ); │ │302 + defer allocator.free(auth_keys_contents); │ │303 + │ │304 + const auth_keys_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/authorized_keys", .{}); │ │305 + defer auth_keys_file.close(io); │ │306 + try auth_keys_file.writeStreamingAll(io, auth_keys_contents); │ │307 + if (.windows != builtin.os.tag) { │ │308 + try auth_keys_file.setPermissions(io, @enumFromInt(0o600)); │ │309 + } │ │310 + │ │311 + // create known_hosts file │ │312 + const known_hosts_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/known_hosts", .{}); │ │313 + defer known_hosts_file.close(io); │ │314 + const port_str = std.fmt.comptimePrint("{}", .{port}); │ │315 + try known_hosts_file.writeStreamingAll(io, "[localhost]:" ++ port_str ++ " ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLWmlR+TyfvK9UoSDPe1eO3irvpUa6MtxCVHCaiDOi9XjQstxfRpM5tmVBotZ/Mkw2kJr/O0ylCWvzqexqsTiUQ=");│ │316 + if (.windows != builtin.os.tag) { │ │317 + try known_hosts_file.setPermissions(io, @enumFromInt(0o600)); │ │318 + } │ │319 │ │200 - const Runnable = struct { │ │201 - io: std.Io, │ │202 - allocator: std.mem.Allocator, │ │203 - page: *const ui.Page, │ │320 + // create sshd_config file │ │321 + const sshd_config_str = blk: { │ │322 + // SetEnv PATH=... allows us to propagate the test process's PATH │ │323 + // to the spawned login shell (in sshd). │ │324 + // │ │325 + // without it, shells that don't auto-resource PATH on startup (e.g. nushell │ │326 + // on NixOS) through /etc/set-environment will fail to find │ │327 + // git-upload-pack / git-receive-pack. │ │328 + const base_config = │ │329 + \\AuthenticationMethods publickey │ │330 + \\PubkeyAuthentication yes │ │331 + \\PasswordAuthentication no │ │332 + \\StrictModes no │ │333 + //SetEnv PATH={s} -- if we find $PATH defined. │ │334 + ; │ │335 + var env_map = try std.process.Environ.createMap(init.minimal.environ, allocator); │ │336 + defer env_map.deinit(); │ │337 │ │205 - pub fn run(self: @This()) !void { │ │206 - // launch the TUI │ │207 - try hx.ui.run(self.io, self.allocator, self.page); │ │338 + const config_str = if (env_map.get("PATH")) |path_str| │ │339 + try std.fmt.allocPrint(allocator, "{s}\n" ++ "SetEnv PATH={s}\n", .{ base_config, path_str }) │ │340 + else │ │341 + try std.fmt.allocPrint(allocator, "{s}", .{base_config}); │ │342 + │ │343 + break :blk config_str; │ │344 + }; │ │345 + defer allocator.free(sshd_config_str); │ │346 + │ │347 + const sshd_config_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/sshd_config", .{}); │ │348 + defer sshd_config_file.close(io); │ │349 + try sshd_config_file.writeStreamingAll(io, sshd_config_str); │ │350 + if (.windows != builtin.os.tag) { │ │351 + try sshd_config_file.setPermissions(io, @enumFromInt(0o600)); │ │352 + } │ │353 + │ │354 + // create sshd.sh contents │ │355 + const host_key_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "host_key" }); │ │356 + defer allocator.free(host_key_path); │ │357 + const auth_keys_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "authorized_keys" }); │ │358 + defer allocator.free(auth_keys_path); │ │359 + const sshd_contents = try std.fmt.allocPrint( │ │360 + allocator, │ │361 + "#!/bin/sh\nexec $(which sshd) -p {} -f sshd_config -h \"{s}\" -D -e -o AuthorizedKeysFile=\"{s}\" -o PidFile=none", │ │362 + .{ port, host_key_path, auth_keys_path }, │ │363 + ); │ │364 + defer allocator.free(sshd_contents); │ │365 + │ │366 + // if path has a space char, it fucks up sshd │ │367 + try std.testing.expect(null == std.mem.indexOfScalar(u8, auth_keys_path, ' ')); │ │368 + │ │369 + // create sshd.sh │ │370 + { │ │371 + const sshd_file = try std.Io.Dir.cwd().createFile(io, temp_dir_name ++ "/sshd.sh", .{}); │ │372 + defer sshd_file.close(io); │ │373 + try sshd_file.writeStreamingAll(io, sshd_contents); │ │374 + if (.windows != builtin.os.tag) { │ │375 + try sshd_file.setPermissions(io, .executable_file); │ │376 + } │ │377 } │ │209 - }; │ │378 + │ │379 + // build a copy-pasteable ssh command line for the user │ │380 + const ssh_key_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "key" }); │ │381 + defer allocator.free(ssh_key_path); │ │382 + const known_hosts_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "known_hosts" }); │ │383 + defer allocator.free(known_hosts_path); │ │384 + │ │385 + const ssh_command = try std.fmt.allocPrint( │ │386 + allocator, │ │387 + "ssh -p {} -i {s} -o UserKnownHostsFile={s} -o IdentitiesOnly=yes localhost", │ │388 + .{ port, ssh_key_path, known_hosts_path }, │ │389 + ); │ │390 + defer allocator.free(ssh_command); │ │391 + │ │392 + // git uses GIT_SSH_COMMAND to override the ssh invocation it'd │ │393 + // normally do. uses scp-style `host:path` rather than ssh://host/path │ │394 + // so the path stays relative — ssh://host/test would send /test │ │395 + // (absolute), which haxy serve rejects as outside the repo root. │ │396 + const git_command = try std.fmt.allocPrint( │ │397 + allocator, │ │398 + "GIT_SSH_COMMAND='ssh -p {} -i {s} -o UserKnownHostsFile={s} -o IdentitiesOnly=yes' git push localhost:test master", │ │399 + .{ port, ssh_key_path, known_hosts_path }, │ │400 + ); │ │401 + defer allocator.free(git_command); │ │402 + │ │403 + const Runnable = struct { │ │404 + io: std.Io, │ │405 + ssh_command: []const u8, │ │406 + git_command: []const u8, │ │407 + │ │408 + pub fn run(self: @This()) !void { │ │409 + std.debug.print( │ │410 + \\ │ │411 + \\open the TUI from another terminal with: │ │412 + \\ │ │413 + \\ {s} │ │414 + \\ │ │415 + \\create a git repo and push it over ssh with: │ │416 + \\ │ │417 + \\ mkdir -p temp-try/client/test │ │418 + \\ cd temp-try/client/test │ │419 + \\ git init │ │420 + \\ echo "hello" > hello.txt │ │421 + \\ git add hello.txt │ │422 + \\ git commit -m "let there be light" │ │423 + \\ {s} │ │424 + \\ │ │425 + \\press enter to stop. │ │426 + \\ │ │427 + \\ │ │428 + , .{ self.ssh_command, self.git_command }); │ │429 + │ │430 + // launch sshd │ │431 + var process = try std.process.spawn(self.io, .{ │ │432 + .argv = &.{"./sshd.sh"}, │ │433 + .cwd = .{ .path = temp_dir_name }, │ │434 + .stdin = .pipe, │ │435 + .stdout = .inherit, │ │436 + .stderr = .inherit, │ │437 + }); │ │438 + defer process.kill(self.io); │ │439 + │ │440 + var buf: [1]u8 = undefined; │ │441 + _ = std.posix.read(std.posix.STDIN_FILENO, &buf) catch {}; │ │442 + } │ │443 + }; │ │444 + │ │445 + try srv.run(.xit, .{}, io, allocator, cwd_path, .{ │ │446 + .data_dir = server_path, │ │447 + }, run_opts.err, Runnable{ .io = io, .ssh_command = ssh_command, .git_command = git_command }); │ │448 + } else { │ │449 + var null_writer = std.Io.Writer.Discarding.init(&.{}); │ │450 + const run_opts = hx.main.RunOpts{ .out = &null_writer.writer, .err = &null_writer.writer, .environ_map = init.environ_map }; │ │451 + │ │452 + const Runnable = struct { │ │453 + io: std.Io, │ │454 + allocator: std.mem.Allocator, │ │455 + page: *const ui.Page, │ │456 + │ │457 + pub fn run(self: @This()) !void { │ │458 + // launch the TUI │ │459 + try hx.ui.run(self.io, self.allocator, self.page); │ │460 + } │ │461 + }; │ │462 │ │211 - try srv.run(.xit, .{}, io, allocator, cwd_path, .{ │ │212 - .data_dir = server_path, │ │213 - }, run_opts.err, Runnable{ .io = io, .allocator = allocator, .page = &page }); │ │463 + try srv.run(.xit, .{}, io, allocator, cwd_path, .{ │ │464 + .data_dir = server_path, │ │465 + }, run_opts.err, Runnable{ .io = io, .allocator = allocator, .page = &page }); │ │466 + } │ │467 } │ │468 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯