╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
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