╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
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 createRepo( 501 comptime repo_opts: rp.RepoOpts(.xit), 502 io: std.Io, 503 allocator: std.mem.Allocator, 504 repo_path: []const u8, 505 ) !rp.Repo(.xit, repo_opts) { 506 var repo = try rp.Repo(.xit, repo_opts).init(io, allocator, .{ .path = repo_path, .bare = true }); 507 errdefer repo.deinit(io, allocator); 508 try repo.setMergeAlgorithm(io, allocator, .patch); 509 try repo.addConfig(io, allocator, .{ .name = "http.receivepack", .value = "true" }); 510 return repo; 511 } 512 513 fn servePack( 514 comptime repo_opts: rp.RepoOpts(.xit), 515 repo: *rp.Repo(.xit, repo_opts), 516 repo_root_path: []const u8, 517 reader: *std.Io.Reader, 518 writer: *std.Io.Writer, 519 io: std.Io, 520 allocator: std.mem.Allocator, 521 service: GitService, 522 protocol_version: xit.net_server_common.ProtocolVersion, 523 author: ?evt.CommitAuthor, 524 error_writer: *std.Io.Writer, 525 sess: ?*ssh.SessionCtx, 526 ) !void { 527 switch (service) { 528 .upload_pack => { 529 var sideband = progress.Sideband{ .writer = writer, .sess = sess }; 530 try repo.uploadPack(io, allocator, reader, writer, .{ .protocol_version = protocol_version }, &sideband); 531 }, 532 .receive_pack => try push.receivePackAndConsume(repo_opts, io, allocator, repo, reader, writer, .{ .protocol_version = protocol_version }, author, repo_root_path, error_writer, sess), 533 } 534 } 535 536 fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { 537 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command); 538 defer tokens.deinit(); 539 540 const service_token = tokens.next() orelse return error.InvalidCommand; 541 const dir_token = tokens.next() orelse return error.InvalidCommand; 542 543 const service: GitService = 544 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 545 .upload_pack 546 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 547 .receive_pack 548 else 549 return error.UnsupportedService; 550 551 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 552 } 553 554 const RepoAuthorization = union(enum) { allowed: ?evt.CommitAuthor, denied, not_found }; 555 556 fn authorizeRepoKey( 557 io: std.Io, 558 arena: *std.heap.ArenaAllocator, 559 admin_repo_path: []const u8, 560 owner_name: []const u8, 561 repo_name: []const u8, 562 service: GitService, 563 fingerprint: *const [ssh.fingerprint_len]u8, 564 ) !RepoAuthorization { 565 const allocator = arena.child_allocator; 566 var admin = rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = admin_repo_path }) catch |err| switch (err) { 567 error.RepoNotFound => return .not_found, 568 else => |e| return e, 569 }; 570 defer admin.deinit(io, allocator); 571 572 const moment = try evt.currentMoment(evt.admin_repo_opts, &admin); 573 const repo = (try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, owner_name, repo_name)) orelse { 574 if (service == .upload_pack) return .not_found; 575 const owner = (try evt.User.readByName(io, allocator, admin_repo_path, arena, owner_name)) orelse return .not_found; 576 return if (isKeyInAuthorizedKeys(owner.event.ssh_keys, fingerprint)) 577 .{ .allowed = .{ .name = owner.event.name, .email = owner.event.email } } 578 else 579 .denied; 580 }; 581 582 const min_role: evt.Repo.Role = switch (service) { 583 .upload_pack => .read, 584 .receive_pack => .write, 585 }; 586 // skip the key lookups when the base role is enough 587 if (evt.Repo.roleOf(repo.repo, null).atLeast(min_role)) return .{ .allowed = null }; 588 589 const user = (try repoUserWithKey(moment, arena, repo.repo.event, fingerprint)) orelse return .denied; 590 if (!evt.Repo.roleOf(repo.repo, user.id).atLeast(min_role)) return .denied; 591 return .{ .allowed = user.author }; 592 } 593 594 const UserWithKey = struct { 595 id: [evt.event_id_size]u8, 596 author: evt.CommitAuthor, 597 }; 598 599 // the repo's owner or collaborator holding the key, or null for anyone else 600 fn repoUserWithKey( 601 moment: evt.AdminDB.HashMap(.read_only), 602 arena: *std.heap.ArenaAllocator, 603 repo: evt.Repo, 604 fingerprint: *const [ssh.fingerprint_len]u8, 605 ) !?UserWithKey { 606 if (try userWithKey(moment, arena, repo.user_id, fingerprint)) |user| return user; 607 608 for ([_][]const u8{ repo.owner_user_ids, repo.write_user_ids, repo.read_user_ids }) |user_ids| { 609 var lines = std.mem.tokenizeScalar(u8, user_ids, '\n'); 610 while (lines.next()) |id| { 611 const user_id = try evt.parseEventId(id); 612 if (try userWithKey(moment, arena, &user_id, fingerprint)) |user| return user; 613 } 614 } 615 return null; 616 } 617 618 fn userWithKey( 619 moment: evt.AdminDB.HashMap(.read_only), 620 arena: *std.heap.ArenaAllocator, 621 user_id: []const u8, 622 fingerprint: *const [ssh.fingerprint_len]u8, 623 ) !?UserWithKey { 624 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, user_id)) orelse return null; 625 if (user.removed or !isKeyInAuthorizedKeys(user.event.ssh_keys, fingerprint)) return null; 626 return .{ 627 .id = user_id[0..evt.event_id_size].*, 628 .author = .{ .name = user.event.name, .email = user.event.email }, 629 }; 630 } 631 632 fn isKeyInAuthorizedKeys(ssh_keys: []const u8, fingerprint: *const [ssh.fingerprint_len]u8) bool { 633 var it = std.mem.splitScalar(u8, ssh_keys, '\n'); 634 while (it.next()) |line| { 635 const fp = fingerprintOfAuthorizedKey(line) orelse continue; 636 if (std.mem.eql(u8, &fp, fingerprint)) return true; 637 } 638 return false; 639 } 640 641 // the SHA256 fingerprint of one authorized_keys line, or null if the line is 642 // blank or not a parseable "<type> <base64> [comment]" entry 643 fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { 644 var it = std.mem.tokenizeAny(u8, line, " \t\r"); 645 _ = it.next() orelse return null; // key type 646 const blob_b64 = it.next() orelse return null; 647 648 const decoder = std.base64.standard.Decoder; 649 const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; 650 var blob_buf: [4096]u8 = undefined; 651 if (blob_len > blob_buf.len) return null; 652 decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; 653 654 return ssh.formatFingerprint(blob_buf[0..blob_len]); 655 } 656 657 fn writeError(sess: *ssh.SessionCtx, comptime msg: []const u8) !void { 658 const body = "ERR haxy ssh: " ++ msg; 659 var packet: [body.len + 4]u8 = undefined; 660 const line = try std.fmt.bufPrint(&packet, "{x:0>4}{s}", .{ packet.len, body }); 661 try sess.writeBytes(line); 662 try sess.exit(1); 663 } 664 665 test "parseGitCommand rejects malformed input" { 666 const allocator = std.testing.allocator; 667 try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); 668 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); 669 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'")); 670 } 671 672 test "parseGitCommand happy paths" { 673 const allocator = std.testing.allocator; 674 675 { 676 const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); 677 defer parsed.deinit(allocator); 678 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 679 try std.testing.expectEqualStrings("some-repo", parsed.dir); 680 } 681 { 682 const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); 683 defer parsed.deinit(allocator); 684 try std.testing.expectEqual(GitService.receive_pack, parsed.service); 685 try std.testing.expectEqualStrings("user/proj", parsed.dir); 686 } 687 { 688 const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); 689 defer parsed.deinit(allocator); 690 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 691 try std.testing.expectEqualStrings("repo", parsed.dir); 692 } 693 } 694