╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
176 try ui_session.applyAndWritePending(io, allocator, &repo); 177 178 // pick up data written by other handles so the next navigation 179 // builds its page from a current moment 180 try ui_session.reloadMoment(&repo); 181 182 // reconcile navigation: forward to a new page, or back on escape 183 try nav.sync(allocator, &ui_session); 184 185 // the quit button (on the quit tab) asks the host to tear down 186 if (ui_session.quit_requested) terminal.requestQuit(); 187 188 try nav.root.build(allocator, .{ 189 .min_size = .{ .width = null, .height = null }, 190 .max_size = .{ .width = last_size.width, .height = last_size.height }, 191 }, nav.root.getFocus()); 192 _ = try terminal.render(&nav.root, &last_grid, &last_size); 193 } 194 } 195 196 // --------------------------------------------------------------------------- 197 // git path (exec) 198 // --------------------------------------------------------------------------- 199 200 pub const GitService = enum { upload_pack, receive_pack }; 201 202 pub const ParsedGitCommand = struct { 203 service: GitService, 204 dir: []u8, 205 206 pub fn deinit(self: ParsedGitCommand, allocator: std.mem.Allocator) void { 207 allocator.free(self.dir); 208 } 209 }; 210 211 fn runGitSession(handler: *const SessionHandler, sess: *ssh.SessionCtx, command: []const u8) !void { 212 const allocator = sess.allocator; 213 const io = sess.io; 214 215 const parsed = parseGitCommand(allocator, command) catch return writeError(sess, "unsupported command (expected git-upload-pack or git-receive-pack)"); 216 defer parsed.deinit(allocator); 217 218 const create_if_missing = parsed.service == .receive_pack; 219 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; 220 221 // authenticate pushes: the authenticated key must be registered to the 222 // repo's owner. 223 if (create_if_missing) { 224 const owner_repo = evt.parseOwnerRepoPath(parsed.dir) orelse return writeError(sess, "repo path must be <owner>/<repo>"); 225 if (!try isKeyAuthorized(io, allocator, handler.admin_repo_path, owner_repo.owner, &sess.fingerprint)) 226 return writeError(sess, "unauthorized: this SSH key is not registered to the repo owner"); 227 } 228 229 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.repo_root_path, handler.admin_repo_path, parsed.dir, create_if_missing)) { 230 .ok => |p| p, 231 .invalid => return writeError(sess, "repo path must be <owner>/<repo>"), 232 .not_found => return writeError(sess, "repo not found"), 233 }; 234 defer allocator.free(repo_path); 235 236 if (try serveIfExists(repo_path, sess, io, allocator, parsed.service)) { 237 try sess.exit(0); 238 return; 239 } 240 241 if (!create_if_missing) return writeError(sess, "repo not found"); 242 243 // create the on-disk repo for the just-minted event and serve the push 244 var repo = try createRepo(any_repo_opts.toRepoOpts(), io, allocator, repo_path); 245 defer repo.deinit(io, allocator); 246 try servePack(repo.self_repo_opts, &repo, sess, io, allocator, parsed.service); 247 try sess.exit(0); 248 } 249 250 // serve an existing repo at `repo_path`, or return false if none is there 251 fn serveIfExists( 252 repo_path: []const u8, 253 sess: *ssh.SessionCtx, 254 io: std.Io, 255 allocator: std.mem.Allocator, 256 service: GitService, 257 ) !bool { 258 // a bare open() creates the directory while probing for the repo, so only 259 // attempt it when the path already exists 260 std.Io.Dir.accessAbsolute(io, repo_path, .{}) catch return false; 261 262 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; 263 var any_repo = rp.AnyRepo(.xit, any_repo_opts).open(io, allocator, .{ .path = repo_path }) catch |err| switch (err) { 264 error.RepoNotFound => return false, 265 else => |e| return e, 266 }; 267 defer any_repo.deinit(io, allocator); 268 269 switch (any_repo) { 270 inline else => |*repo| try servePack(repo.self_repo_opts, repo, sess, io, allocator, service), 271 } 272 return true; 273 } 274 275 fn createRepo( 276 comptime repo_opts: rp.RepoOpts(.xit), 277 io: std.Io, 278 allocator: std.mem.Allocator, 279 repo_path: []const u8, 280 ) !rp.Repo(.xit, repo_opts) { 281 var repo = try rp.Repo(.xit, repo_opts).init(io, allocator, .{ .path = repo_path }); 282 errdefer repo.deinit(io, allocator); 283 try repo.addConfig(io, allocator, .{ .name = "http.receivepack", .value = "true" }); 284 try repo.addConfig(io, allocator, .{ .name = "receive.denycurrentbranch", .value = "updateinstead" }); 285 return repo; 286 } 287 288 // wrap the channel I/O in std.Io.Reader/Writer adapters so the repo's pack 289 // functions can consume them like any other stream, then run the requested 290 // service 291 fn servePack( 292 comptime repo_opts: rp.RepoOpts(.xit), 293 repo: *rp.Repo(.xit, repo_opts), 294 sess: *ssh.SessionCtx, 295 io: std.Io, 296 allocator: std.mem.Allocator, 297 service: GitService, 298 ) !void { 299 var session_reader_buf: [4096]u8 = undefined; 300 var session_writer_buf: [4096]u8 = undefined; 301 var session_reader = ssh.SessionReader.init(sess, &session_reader_buf); 302 var session_writer = ssh.SessionWriter.init(sess, &session_writer_buf); 303 304 switch (service) { 305 .upload_pack => try repo.uploadPack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 306 .receive_pack => try repo.receivePack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 307 } 308 309 // flush whatever the pack op buffered into our writer adapter 310 session_writer.interface.flush() catch {}; 311 } 312 313 fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { 314 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command); 315 defer tokens.deinit(); 316 317 const service_token = tokens.next() orelse return error.InvalidCommand; 318 const dir_token = tokens.next() orelse return error.InvalidCommand; 319 320 const service: GitService = 321 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 322 .upload_pack 323 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 324 .receive_pack 325 else 326 return error.UnsupportedService; 327 328 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 329 } 330 331 // true if `fingerprint` matches one of the named user's registered SSH keys 332 fn isKeyAuthorized( 333 io: std.Io, 334 allocator: std.mem.Allocator, 335 admin_repo_path: []const u8, 336 owner_name: []const u8, 337 fingerprint: *const [ssh.fingerprint_len]u8, 338 ) !bool { 339 var arena = std.heap.ArenaAllocator.init(allocator); 340 defer arena.deinit(); 341 342 const user = (try evt.User.readByName(io, allocator, admin_repo_path, &arena, owner_name)) orelse return false; 343 344 var it = std.mem.splitScalar(u8, user.ssh_keys, '\n'); 345 while (it.next()) |line| { 346 const fp = fingerprintOfAuthorizedKey(line) orelse continue; 347 if (std.mem.eql(u8, &fp, fingerprint)) return true; 348 } 349 return false; 350 } 351 352 // the SHA256 fingerprint of one authorized_keys line, or null if the line is 353 // blank or not a parseable "<type> <base64> [comment]" entry 354 fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { 355 var it = std.mem.tokenizeAny(u8, line, " \t\r"); 356 _ = it.next() orelse return null; // key type 357 const blob_b64 = it.next() orelse return null; 358 359 const decoder = std.base64.standard.Decoder; 360 const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; 361 var blob_buf: [4096]u8 = undefined; 362 if (blob_len > blob_buf.len) return null; 363 decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; 364 365 return ssh.formatFingerprint(blob_buf[0..blob_len]); 366 } 367 368 // write an error to the client's stderr and exit non-zero. stderr (not stdout) 369 // because the git exec sessions that call this carry the pack protocol on 370 // stdout, where stray text breaks the client's pkt-line parser. 371 fn writeError(sess: *ssh.SessionCtx, comptime msg: []const u8) !void { 372 try sess.writeStderr("haxy ssh: " ++ msg ++ "\n"); 373 try sess.exit(1); 374 } 375 376 test "parseGitCommand rejects malformed input" { 377 const allocator = std.testing.allocator; 378 try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); 379 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); 380 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'")); 381 } 382 383 test "parseGitCommand happy paths" { 384 const allocator = std.testing.allocator; 385 386 { 387 const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); 388 defer parsed.deinit(allocator); 389 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 390 try std.testing.expectEqualStrings("some-repo", parsed.dir); 391 } 392 { 393 const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); 394 defer parsed.deinit(allocator); 395 try std.testing.expectEqual(GitService.receive_pack, parsed.service); 396 try std.testing.expectEqualStrings("user/proj", parsed.dir); 397 } 398 { 399 const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); 400 defer parsed.deinit(allocator); 401 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 402 try std.testing.expectEqualStrings("repo", parsed.dir); 403 } 404 } 405