╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
360 const target_path = try std.fs.path.join(allocator, &.{ handler.repo_root_path, &target_id }); 361 defer allocator.free(target_path); 362 const author: evt.CommitAuthor = .{ .name = user.event.name, .email = user.event.email }; 363 const timestamp: u64 = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()); 364 switch (draft) { 365 inline else => |*repo| { 366 var target_repo = rp.Repo(.xit, repo.self_repo_opts).open(io, allocator, .{ .path = target_path }) catch 367 return writeError(sess, "repo not found or has the wrong hash"); 368 defer target_repo.deinit(io, allocator); 369 try fork.receivePack(repo.self_repo_opts, io, allocator, repo, &target_repo, &route.id, route.target, author, timestamp, &reader.interface, &writer.interface, handler.err); 370 }, 371 } 372 } 373 writer.interface.flush() catch {}; 374 try sess.exit(0); 375 return; 376 } 377 378 const create_if_missing = parsed.service == .receive_pack; 379 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; 380 381 const owner_repo = evt.parseOwnerRepoPath(repo_identity) orelse return writeError(sess, "repo path must be <owner>/<repo>"); 382 switch (try authorizeRepoKey(io, allocator, handler.admin_repo_path, owner_repo.owner, owner_repo.name, parsed.service, &sess.fingerprint)) { 383 .allowed => {}, 384 .denied => return switch (parsed.service) { 385 .upload_pack => writeError(sess, "unauthorized: this SSH key cannot read this repo"), 386 .receive_pack => writeError(sess, "unauthorized: this SSH key cannot push to this repo"), 387 }, 388 .not_found => return writeError(sess, "repo not found"), 389 } 390 391 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.repo_root_path, handler.admin_repo_path, repo_identity, create_if_missing)) { 392 .ok => |p| p, 393 .invalid => return writeError(sess, "repo path must be <owner>/<repo>"), 394 .not_found => return writeError(sess, "repo not found"), 395 }; 396 defer allocator.free(repo_path); 397 398 if (try serveIfExists(repo_path, handler.repo_root_path, sess, io, allocator, parsed.service, handler.err)) { 399 try sess.exit(0); 400 return; 401 } 402 403 if (!create_if_missing) return writeError(sess, "repo not found"); 404 405 // create the on-disk repo for the just-minted event and serve the push 406 var repo = try createRepo(any_repo_opts.toRepoOpts(), io, allocator, repo_path); 407 defer repo.deinit(io, allocator); 408 try servePack(repo.self_repo_opts, &repo, handler.repo_root_path, sess, io, allocator, parsed.service, handler.err); 409 try sess.exit(0); 410 } 411 412 // serve an existing repo at `repo_path`, or return false if none is there 413 fn serveIfExists( 414 repo_path: []const u8, 415 repo_root_path: []const u8, 416 sess: *ssh.SessionCtx, 417 io: std.Io, 418 allocator: std.mem.Allocator, 419 service: GitService, 420 error_writer: *std.Io.Writer, 421 ) !bool { 422 // a bare open() creates the directory while probing for the repo, so only 423 // attempt it when the path already exists 424 std.Io.Dir.accessAbsolute(io, repo_path, .{}) catch return false; 425 426 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; 427 var any_repo = rp.AnyRepo(.xit, any_repo_opts).open(io, allocator, .{ .path = repo_path }) catch |err| switch (err) { 428 error.RepoNotFound => return false, 429 else => |e| return e, 430 }; 431 defer any_repo.deinit(io, allocator); 432 433 switch (any_repo) { 434 inline else => |*repo| try servePack(repo.self_repo_opts, repo, repo_root_path, sess, io, allocator, service, error_writer), 435 } 436 return true; 437 } 438 439 fn createRepo( 440 comptime repo_opts: rp.RepoOpts(.xit), 441 io: std.Io, 442 allocator: std.mem.Allocator, 443 repo_path: []const u8, 444 ) !rp.Repo(.xit, repo_opts) { 445 var repo = try rp.Repo(.xit, repo_opts).init(io, allocator, .{ .path = repo_path }); 446 errdefer repo.deinit(io, allocator); 447 try repo.setMergeAlgorithm(io, allocator, .diff3); 448 try repo.addConfig(io, allocator, .{ .name = "http.receivepack", .value = "true" }); 449 try repo.addConfig(io, allocator, .{ .name = "receive.denycurrentbranch", .value = "updateinstead" }); 450 return repo; 451 } 452 453 // wrap the channel I/O in std.Io.Reader/Writer adapters so the repo's pack 454 // functions can consume them like any other stream, then run the requested 455 // service 456 fn servePack( 457 comptime repo_opts: rp.RepoOpts(.xit), 458 repo: *rp.Repo(.xit, repo_opts), 459 repo_root_path: []const u8, 460 sess: *ssh.SessionCtx, 461 io: std.Io, 462 allocator: std.mem.Allocator, 463 service: GitService, 464 error_writer: *std.Io.Writer, 465 ) !void { 466 var session_reader_buf: [4096]u8 = undefined; 467 var session_writer_buf: [4096]u8 = undefined; 468 var session_reader = ssh.SessionReader.init(sess, &session_reader_buf); 469 var session_writer = ssh.SessionWriter.init(sess, &session_writer_buf); 470 471 switch (service) { 472 .upload_pack => try repo.uploadPack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 473 .receive_pack => try evt.receivePackAndConsume(repo_opts, io, allocator, repo, &session_reader.interface, &session_writer.interface, .{}, repo_root_path, error_writer), 474 } 475 476 // flush whatever the pack op buffered into our writer adapter 477 session_writer.interface.flush() catch {}; 478 } 479 480 fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { 481 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command); 482 defer tokens.deinit(); 483 484 const service_token = tokens.next() orelse return error.InvalidCommand; 485 const dir_token = tokens.next() orelse return error.InvalidCommand; 486 487 const service: GitService = 488 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 489 .upload_pack 490 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 491 .receive_pack 492 else 493 return error.UnsupportedService; 494 495 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 496 } 497 498 const RepoAuthorization = enum { allowed, denied, not_found }; 499 500 fn authorizeRepoKey( 501 io: std.Io, 502 allocator: std.mem.Allocator, 503 admin_repo_path: []const u8, 504 owner_name: []const u8, 505 repo_name: []const u8, 506 service: GitService, 507 fingerprint: *const [ssh.fingerprint_len]u8, 508 ) !RepoAuthorization { 509 var arena = std.heap.ArenaAllocator.init(allocator); 510 defer arena.deinit(); 511 512 var admin = rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = admin_repo_path }) catch |err| switch (err) { 513 error.RepoNotFound => return .not_found, 514 else => |e| return e, 515 }; 516 defer admin.deinit(io, allocator); 517 518 const moment = try evt.currentMoment(evt.admin_repo_opts, &admin); 519 const repo = (try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, &arena, owner_name, repo_name)) orelse { 520 if (service == .upload_pack) return .not_found; 521 const owner = (try evt.User.readByName(io, allocator, admin_repo_path, &arena, owner_name)) orelse return .not_found; 522 return if (isKeyInAuthorizedKeys(owner.event.ssh_keys, fingerprint)) .allowed else .denied; 523 }; 524 525 if ((service == .upload_pack and repo.repo.event.read_access == .public) or 526 (service == .receive_pack and repo.repo.event.write_access == .public)) return .allowed; 527 if (try keyBelongsToUser(moment, &arena, repo.repo.event.user_id, fingerprint)) return .allowed; 528 529 const user_id_lists: []const []const u8 = switch (service) { 530 .upload_pack => &.{ repo.repo.event.write_user_ids, repo.repo.event.read_user_ids }, 531 .receive_pack => &.{repo.repo.event.write_user_ids}, 532 }; 533 for (user_id_lists) |user_ids| { 534 var lines = std.mem.tokenizeScalar(u8, user_ids, '\n'); 535 while (lines.next()) |id| { 536 const user_id = try evt.parseEventId(id); 537 if (try keyBelongsToUser(moment, &arena, &user_id, fingerprint)) return .allowed; 538 } 539 } 540 return .denied; 541 } 542 543 fn keyBelongsToUser( 544 moment: evt.AdminDB.HashMap(.read_only), 545 arena: *std.heap.ArenaAllocator, 546 user_id: []const u8, 547 fingerprint: *const [ssh.fingerprint_len]u8, 548 ) !bool { 549 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, user_id)) orelse return false; 550 return !user.removed and isKeyInAuthorizedKeys(user.event.ssh_keys, fingerprint); 551 } 552 553 fn isKeyInAuthorizedKeys(ssh_keys: []const u8, fingerprint: *const [ssh.fingerprint_len]u8) bool { 554 var it = std.mem.splitScalar(u8, ssh_keys, '\n'); 555 while (it.next()) |line| { 556 const fp = fingerprintOfAuthorizedKey(line) orelse continue; 557 if (std.mem.eql(u8, &fp, fingerprint)) return true; 558 } 559 return false; 560 } 561 562 // the SHA256 fingerprint of one authorized_keys line, or null if the line is 563 // blank or not a parseable "<type> <base64> [comment]" entry 564 fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { 565 var it = std.mem.tokenizeAny(u8, line, " \t\r"); 566 _ = it.next() orelse return null; // key type 567 const blob_b64 = it.next() orelse return null; 568 569 const decoder = std.base64.standard.Decoder; 570 const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; 571 var blob_buf: [4096]u8 = undefined; 572 if (blob_len > blob_buf.len) return null; 573 decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; 574 575 return ssh.formatFingerprint(blob_buf[0..blob_len]); 576 } 577 578 fn writeError(sess: *ssh.SessionCtx, comptime msg: []const u8) !void { 579 const body = "ERR haxy ssh: " ++ msg; 580 var packet: [body.len + 4]u8 = undefined; 581 const line = try std.fmt.bufPrint(&packet, "{x:0>4}{s}", .{ packet.len, body }); 582 try sess.writeBytes(line); 583 try sess.exit(1); 584 } 585 586 test "parseGitCommand rejects malformed input" { 587 const allocator = std.testing.allocator; 588 try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); 589 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); 590 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'")); 591 } 592 593 test "parseGitCommand happy paths" { 594 const allocator = std.testing.allocator; 595 596 { 597 const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); 598 defer parsed.deinit(allocator); 599 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 600 try std.testing.expectEqualStrings("some-repo", parsed.dir); 601 } 602 { 603 const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); 604 defer parsed.deinit(allocator); 605 try std.testing.expectEqual(GitService.receive_pack, parsed.service); 606 try std.testing.expectEqualStrings("user/proj", parsed.dir); 607 } 608 { 609 const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); 610 defer parsed.deinit(allocator); 611 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 612 try std.testing.expectEqualStrings("repo", parsed.dir); 613 } 614 } 615