╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
344 const ParsedGitCommand = struct { 345 service: GitService, 346 dir: []u8, 347 348 fn deinit(self: ParsedGitCommand, allocator: std.mem.Allocator) void { 349 allocator.free(self.dir); 350 } 351 }; 352 353 fn runGitSession(handler: *const SessionHandler, sess: *ssh.SessionCtx, exec: ssh.Request.Exec) !void { 354 const allocator = sess.conn.allocator; 355 const io = sess.conn.io; 356 const protocol_version = xit.net_server_common.parseProtocolVersion(exec.git_protocol); 357 358 // the repo's pack functions consume the channel like any other stream. 359 // whatever they leave buffered is flushed before the exit status goes out. 360 var reader_buf: [4096]u8 = undefined; 361 var writer_buf: [4096]u8 = undefined; 362 var reader = ssh.SessionReader.init(sess, &reader_buf); 363 var writer = ssh.SessionWriter.init(sess, &writer_buf); 364 defer writer.interface.flush() catch {}; 365 366 const parsed = parseGitCommand(allocator, exec.command) catch return writeError(sess, "unsupported command (expected git-upload-pack or git-receive-pack)"); 367 defer parsed.deinit(allocator); 368 const path = std.mem.trimStart(u8, parsed.dir, "/"); 369 const fork_prefix = "fork/"; 370 371 if (std.mem.startsWith(u8, path, fork_prefix)) { 372 return runForkSession(handler, sess, path[fork_prefix.len..], parsed.service, protocol_version, &reader.interface, &writer.interface); 373 } 374 375 const repo_prefix = "repo/"; 376 const repo_identity = if (std.mem.startsWith(u8, path, repo_prefix)) path[repo_prefix.len..] else path; 377 378 const create_if_missing = parsed.service == .receive_pack; 379 380 const owner_repo = evt.parseOwnerRepoPath(repo_identity) orelse return writeError(sess, "repo path must be <owner>/<repo>"); 381 var author_arena = std.heap.ArenaAllocator.init(allocator); 382 defer author_arena.deinit(); 383 const author = switch (try authorizeRepoKey(io, &author_arena, handler.admin_repo_path, owner_repo.owner, owner_repo.name, parsed.service, &sess.fingerprint)) { 384 .allowed => |user| user, 385 .denied => return switch (parsed.service) { 386 .upload_pack => writeError(sess, "unauthorized: this SSH key cannot read this repo"), 387 .receive_pack => writeError(sess, "unauthorized: this SSH key cannot push to this repo"), 388 }, 389 .not_found => return writeError(sess, "repo not found"), 390 }; 391 392 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.repo_root_path, handler.admin_repo_path, repo_identity, create_if_missing)) { 393 .ok => |p| p, 394 .invalid => unreachable, // parsed above 395 .not_found => return writeError(sess, "repo not found"), 396 }; 397 defer allocator.free(repo_path); 398 399 if (try serveIfExists(repo_path, handler.repo_root_path, &reader.interface, &writer.interface, io, allocator, parsed.service, protocol_version, author, handler.err, sess)) return; 400 401 if (!create_if_missing) return writeError(sess, "repo not found"); 402 403 // create the on-disk repo for the just-minted event and serve the push 404 var repo = try rp.Repo(.xit, any_repo_opts.toRepoOpts()).init(io, allocator, .{ .path = repo_path, .bare = true }); 405 defer repo.deinit(io, allocator); 406 try servePack(repo.self_repo_opts, &repo, handler.repo_root_path, &reader.interface, &writer.interface, io, allocator, parsed.service, protocol_version, author, handler.err, sess); 407 } 408 409 // serve a patch draft: anyone may fetch it, only its author may push to it 410 fn runForkSession( 411 handler: *const SessionHandler, 412 sess: *ssh.SessionCtx, 413 fork_path: []const u8, 414 service: GitService, 415 protocol_version: xit.net_server_common.ProtocolVersion, 416 reader: *std.Io.Reader, 417 writer: *std.Io.Writer, 418 ) !void { 419 const allocator = sess.conn.allocator; 420 const io = sess.conn.io; 421 422 const route = fork.parseRoute(fork_path) orelse return writeError(sess, "invalid fork path"); 423 const owner_repo = evt.parseOwnerRepoPath(route.identity) orelse return writeError(sess, "repo path must be <owner>/<repo>"); 424 425 var admin = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = handler.admin_repo_path }); 426 defer admin.deinit(io, allocator); 427 var admin_arena = std.heap.ArenaAllocator.init(allocator); 428 defer admin_arena.deinit(); 429 const admin_moment = try evt.currentMoment(evt.admin_repo_opts, &admin); 430 const fork_id = try evt.parseEventId(&route.id); 431 const fork_record = (try evt.Fork.readById(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, &admin_arena, &fork_id)) orelse 432 return writeError(sess, "patch draft not found"); 433 if (fork_record.removed) return writeError(sess, "patch draft not found"); 434 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, &admin_arena, fork_record.event.user_id)) orelse 435 return writeError(sess, "invalid patch draft"); 436 if (user.removed) return writeError(sess, "invalid patch draft"); 437 if (service == .receive_pack and !isKeyInAuthorizedKeys(user.event.ssh_keys, &sess.fingerprint)) 438 return writeError(sess, "unauthorized: this SSH key is not registered to the patch author"); 439 440 const draft_path = try fork.forkPath(allocator, handler.repo_root_path, &route.id); 441 defer allocator.free(draft_path); 442 var draft = rp.AnyRepo(.xit, any_repo_opts).open(io, allocator, .{ .path = draft_path }) catch 443 return writeError(sess, "patch draft not found"); 444 defer draft.deinit(io, allocator); 445 if (service == .upload_pack) { 446 var sideband = progress.Sideband{ .writer = writer, .sess = sess }; 447 switch (draft) { 448 inline else => |*repo| try repo.uploadPack(io, allocator, reader, writer, .{ .protocol_version = protocol_version }, &sideband), 449 } 450 } else { 451 const target = (try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, &admin_arena, owner_repo.owner, owner_repo.name)) orelse 452 return writeError(sess, "repo not found"); 453 if (!std.mem.eql(u8, fork_record.event.repo_id, &target.event_id)) return writeError(sess, "patch draft belongs to another repo"); 454 const target_id = std.fmt.bytesToHex(target.event_id, .lower); 455 const target_path = try std.fs.path.join(allocator, &.{ handler.repo_root_path, &target_id }); 456 defer allocator.free(target_path); 457 const author: evt.CommitAuthor = .{ .name = user.event.name, .email = user.event.email }; 458 const timestamp: u64 = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()); 459 switch (draft) { 460 inline else => |*repo| { 461 var target_repo = rp.Repo(.xit, repo.self_repo_opts).open(io, allocator, .{ .path = target_path }) catch 462 return writeError(sess, "repo not found or has the wrong hash"); 463 defer target_repo.deinit(io, allocator); 464 try push.receiveFork(repo.self_repo_opts, io, allocator, repo, &target_repo, &route.id, author, timestamp, reader, writer, handler.err, sess); 465 }, 466 } 467 } 468 } 469 470 // serve an existing repo at `repo_path`, or return false if none is there 471 fn serveIfExists( 472 repo_path: []const u8, 473 repo_root_path: []const u8, 474 reader: *std.Io.Reader, 475 writer: *std.Io.Writer, 476 io: std.Io, 477 allocator: std.mem.Allocator, 478 service: GitService, 479 protocol_version: xit.net_server_common.ProtocolVersion, 480 author: ?evt.CommitAuthor, 481 error_writer: *std.Io.Writer, 482 sess: ?*ssh.SessionCtx, 483 ) !bool { 484 // a bare open() creates the directory while probing for the repo, so only 485 // attempt it when the path already exists 486 std.Io.Dir.accessAbsolute(io, repo_path, .{}) catch return false; 487 488 var any_repo = rp.AnyRepo(.xit, any_repo_opts).open(io, allocator, .{ .path = repo_path }) catch |err| switch (err) { 489 error.RepoNotFound => return false, 490 else => |e| return e, 491 }; 492 defer any_repo.deinit(io, allocator); 493 494 switch (any_repo) { 495 inline else => |*repo| try servePack(repo.self_repo_opts, repo, repo_root_path, reader, writer, io, allocator, service, protocol_version, author, error_writer, sess), 496 } 497 return true; 498 } 499 500 fn servePack( 501 comptime repo_opts: rp.RepoOpts(.xit), 502 repo: *rp.Repo(.xit, repo_opts), 503 repo_root_path: []const u8, 504 reader: *std.Io.Reader, 505 writer: *std.Io.Writer, 506 io: std.Io, 507 allocator: std.mem.Allocator, 508 service: GitService, 509 protocol_version: xit.net_server_common.ProtocolVersion, 510 author: ?evt.CommitAuthor, 511 error_writer: *std.Io.Writer, 512 sess: ?*ssh.SessionCtx, 513 ) !void { 514 switch (service) { 515 .upload_pack => { 516 var sideband = progress.Sideband{ .writer = writer, .sess = sess }; 517 try repo.uploadPack(io, allocator, reader, writer, .{ .protocol_version = protocol_version }, &sideband); 518 }, 519 .receive_pack => try push.receivePackAndConsume(repo_opts, io, allocator, repo, reader, writer, .{ .protocol_version = protocol_version }, author, repo_root_path, error_writer, sess), 520 } 521 } 522 523 fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { 524 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command); 525 defer tokens.deinit(); 526 527 const service_token = tokens.next() orelse return error.InvalidCommand; 528 const dir_token = tokens.next() orelse return error.InvalidCommand; 529 530 const service: GitService = 531 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 532 .upload_pack 533 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 534 .receive_pack 535 else 536 return error.UnsupportedService; 537 538 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 539 } 540 541 const RepoAuthorization = union(enum) { allowed: ?evt.CommitAuthor, denied, not_found }; 542 543 fn authorizeRepoKey( 544 io: std.Io, 545 arena: *std.heap.ArenaAllocator, 546 admin_repo_path: []const u8, 547 owner_name: []const u8, 548 repo_name: []const u8, 549 service: GitService, 550 fingerprint: *const [ssh.fingerprint_len]u8, 551 ) !RepoAuthorization { 552 const allocator = arena.child_allocator; 553 var admin = rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = admin_repo_path }) catch |err| switch (err) { 554 error.RepoNotFound => return .not_found, 555 else => |e| return e, 556 }; 557 defer admin.deinit(io, allocator); 558 559 const moment = try evt.currentMoment(evt.admin_repo_opts, &admin); 560 const repo = (try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, owner_name, repo_name)) orelse { 561 if (service == .upload_pack) return .not_found; 562 const owner = (try evt.User.readByName(io, allocator, admin_repo_path, arena, owner_name)) orelse return .not_found; 563 return if (isKeyInAuthorizedKeys(owner.event.ssh_keys, fingerprint)) 564 .{ .allowed = .{ .name = owner.event.name, .email = owner.event.email } } 565 else 566 .denied; 567 }; 568 569 const min_role: evt.Repo.Role = switch (service) { 570 .upload_pack => .read, 571 .receive_pack => .write, 572 }; 573 // skip the key lookups when the base role is enough 574 if (evt.Repo.roleOf(repo.repo, null).atLeast(min_role)) return .{ .allowed = null }; 575 576 const user = (try repoUserWithKey(moment, arena, repo.repo.event, fingerprint)) orelse return .denied; 577 if (!evt.Repo.roleOf(repo.repo, user.id).atLeast(min_role)) return .denied; 578 return .{ .allowed = user.author }; 579 } 580 581 const UserWithKey = struct { 582 id: [evt.event_id_size]u8, 583 author: evt.CommitAuthor, 584 }; 585 586 // the repo's owner or collaborator holding the key, or null for anyone else 587 fn repoUserWithKey( 588 moment: evt.AdminDB.HashMap(.read_only), 589 arena: *std.heap.ArenaAllocator, 590 repo: evt.Repo, 591 fingerprint: *const [ssh.fingerprint_len]u8, 592 ) !?UserWithKey { 593 if (try userWithKey(moment, arena, repo.user_id, fingerprint)) |user| return user; 594 595 for ([_][]const u8{ repo.owner_user_ids, repo.write_user_ids, repo.read_user_ids }) |user_ids| { 596 var lines = std.mem.tokenizeScalar(u8, user_ids, '\n'); 597 while (lines.next()) |id| { 598 const user_id = try evt.parseEventId(id); 599 if (try userWithKey(moment, arena, &user_id, fingerprint)) |user| return user; 600 } 601 } 602 return null; 603 } 604 605 fn userWithKey( 606 moment: evt.AdminDB.HashMap(.read_only), 607 arena: *std.heap.ArenaAllocator, 608 user_id: []const u8, 609 fingerprint: *const [ssh.fingerprint_len]u8, 610 ) !?UserWithKey { 611 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, user_id)) orelse return null; 612 if (user.removed or !isKeyInAuthorizedKeys(user.event.ssh_keys, fingerprint)) return null; 613 return .{ 614 .id = user_id[0..evt.event_id_size].*, 615 .author = .{ .name = user.event.name, .email = user.event.email }, 616 }; 617 } 618 619 fn isKeyInAuthorizedKeys(ssh_keys: []const u8, fingerprint: *const [ssh.fingerprint_len]u8) bool { 620 var it = std.mem.splitScalar(u8, ssh_keys, '\n'); 621 while (it.next()) |line| { 622 const fp = fingerprintOfAuthorizedKey(line) orelse continue; 623 if (std.mem.eql(u8, &fp, fingerprint)) return true; 624 } 625 return false; 626 } 627 628 // the SHA256 fingerprint of one authorized_keys line, or null if the line is 629 // blank or not a parseable "<type> <base64> [comment]" entry 630 fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { 631 var it = std.mem.tokenizeAny(u8, line, " \t\r"); 632 _ = it.next() orelse return null; // key type 633 const blob_b64 = it.next() orelse return null; 634 635 const decoder = std.base64.standard.Decoder; 636 const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; 637 var blob_buf: [4096]u8 = undefined; 638 if (blob_len > blob_buf.len) return null; 639 decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; 640 641 return ssh.formatFingerprint(blob_buf[0..blob_len]); 642 } 643 644 fn writeError(sess: *ssh.SessionCtx, comptime msg: []const u8) !void { 645 const body = "ERR haxy ssh: " ++ msg; 646 var packet: [body.len + 4]u8 = undefined; 647 const line = try std.fmt.bufPrint(&packet, "{x:0>4}{s}", .{ packet.len, body }); 648 try sess.writeBytes(line); 649 try sess.exit(1); 650 } 651 652 test "parseGitCommand rejects malformed input" { 653 const allocator = std.testing.allocator; 654 try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); 655 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); 656 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'")); 657 } 658 659 test "parseGitCommand happy paths" { 660 const allocator = std.testing.allocator; 661 662 { 663 const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); 664 defer parsed.deinit(allocator); 665 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 666 try std.testing.expectEqualStrings("some-repo", parsed.dir); 667 } 668 { 669 const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); 670 defer parsed.deinit(allocator); 671 try std.testing.expectEqual(GitService.receive_pack, parsed.service); 672 try std.testing.expectEqualStrings("user/proj", parsed.dir); 673 } 674 { 675 const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); 676 defer parsed.deinit(allocator); 677 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 678 try std.testing.expectEqualStrings("repo", parsed.dir); 679 } 680 } 681