╭ changed ╮ │README.md│ ╰─────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │50 │ │51 This will launch a server with fake data that it stores in the `temp-try` directory. It will then launch the TUI directly in your terminal (you can exit by pressing escape).│ │52 │ │53 -Additionally, you can view the web UI at http://localhost:8000 and push a git repo to http://localhost:8080 like this: │ │53 +Additionally, you can view the web UI at http://localhost:8000 and push a git repo over SSH like this: │ │54 │ │55 ``` │ │56 mkdir -p temp-try/client/test │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │59 echo "hello" > hello.txt │ │60 git add hello.txt │ │61 git commit -m "let there be light" │ │62 -git remote add origin http://localhost:8080/admin/test │ │63 -git push origin HEAD:master │ │62 +GIT_SSH_COMMAND='ssh -p 8022 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -i ../../key' git push localhost:admin/test HEAD:master│ │63 ``` │ │64 │ │66 -After that, the repo will be in `temp-try/server/repos` at a directory named by its repo id. │ │65 +After that, the repo will be in `temp-try/server/repos` in a directory named by its repo id. │ │66 │ │67 *"C'mon Alex! You always dreamt about going on a big adventure! Let this be our first!" -- Lunar: Silver Star Story* │ │68 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/User.zig│ ╰──────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │143 const user_cursor = try event_id_to_user.getCursor(hash.hashInt(hash_kind, user_id)) orelse return null; │ │144 const user_map = try DB.HashMap(.read_only).init(user_cursor); │ │145 return try evt.read(@This(), DB, hash_kind, arena, user_map); │ │146 +} │ │147 + │ │148 +// read a user by name from the admin event store, or null if the admin repo or │ │149 +// the user doesn't exist. field byte slices are allocated in `arena`. │ │150 +pub fn readByName( │ │151 + io: std.Io, │ │152 + allocator: std.mem.Allocator, │ │153 + admin_repo_path: []const u8, │ │154 + arena: *std.heap.ArenaAllocator, │ │155 + name: []const u8, │ │156 +) !?Self { │ │157 + var repo = rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = admin_repo_path }) catch |err| switch (err) { │ │158 + error.RepoNotFound => return null, │ │159 + else => |e| return e, │ │160 + }; │ │161 + defer repo.deinit(io, allocator); │ │162 + │ │163 + const moment = try evt.currentMoment(evt.admin_repo_opts, &repo); │ │164 + │ │165 + const name_to_user_id_cursor = try moment.getCursor(hash.hashInt(evt.admin_repo_opts.hash, "name->user-id")) orelse return null;│ │166 + const name_to_user_id = try evt.AdminDB.HashMap(.read_only).init(name_to_user_id_cursor); │ │167 + const user_id_cursor = try name_to_user_id.getCursor(hash.hashInt(evt.admin_repo_opts.hash, name)) orelse return null; │ │168 + var user_id: [evt.event_id_size]u8 = undefined; │ │169 + _ = try user_id_cursor.readBytes(&user_id); │ │170 + │ │171 + return try readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id); │ │172 } │ │173 │ │174 // flip a user's ANSI-art preference by re-emitting their User event with │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────╮ │src/serve.zig│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 99 .admin_repo_path = admin_repo_path, │ │100 .repo_root_path = repo_root_path, │ │101 .is_test = options.is_test, │ │102 + .err = err, │ │103 }; │ │104 serve_ssh.runListener(io, allocator, &host_key, &ssh_session_handler, &ssh_server, &tasks, err);│ │105 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/serve_http.zig│ ╰──────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │115 defer allocator.free(content_type); │ │116 const has_remote_user = findHeader(request, "authorization") != null; │ │117 const protocol_version = protocolVersionFromHeader(findHeader(request, "git-protocol")); │ │118 + │ │119 + const create_if_missing = isReceivePack(handler, suffix, uri.query); │ │120 │ │121 + // pushing over HTTP is disabled outside test mode; pushes go over SSH │ │122 + if (create_if_missing and !is_test) { │ │123 + if (http_server.reader.state == .received_head) { │ │124 + http_server.reader.state = .ready; │ │125 + } │ │126 + try writeSimpleResponse(http_server, 403, "Forbidden", "text/plain", "push over HTTP is disabled");│ │127 + return; │ │128 + } │ │129 + │ │130 const body = if (request.head.method == .POST) blk: { │ │131 const reader = try request.readerExpectContinue(&.{}); │ │132 break :blk try reader.allocRemaining(allocator, .unlimited); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │137 http_server.reader.state = .ready; │ │138 } │ │139 │ │129 - const create_if_missing = isReceivePack(handler, suffix, uri.query); │ │130 - │ │140 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, is_test, repo_root_path, admin_repo_path, repo_rel, create_if_missing)) {│ │141 .ok => |p| p, │ │142 .invalid => return writeSimpleResponse(http_server, 400, "Bad Request", "text/plain", "repo path must be <owner>/<repo>"), │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │16 // test mode serves repos directly by path instead of resolving them as │ │17 // <owner>/<repo> through the event store │ │18 is_test: bool, │ │19 + err: *std.Io.Writer, │ │20 │ │21 pub fn handleSession(self: *const SessionHandler, sess: *ssh.SessionCtx, request: ssh.Request) !void { │ │21 - std.debug.print("ssh session: kind={s} key={s}\n", .{ @tagName(request), sess.fingerprint }); │ │22 + serve_common.logError(self.err, "ssh session: kind={s} key={s}\n", .{ @tagName(request), sess.fingerprint });│ │23 switch (request) { │ │24 .shell => |pty_maybe| { │ │25 const pty = pty_maybe orelse { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │90 // runs as the function unwinds — so any error it returns leaves the TUI torn │ │91 // down and we can surface the failure on the restored screen before exiting. │ │92 runTui(handler, sess, pty) catch |err| { │ │92 - std.debug.print("ssh tui session failed: {s}\n", .{@errorName(err)}); │ │93 + serve_common.logError(handler.err, "ssh tui session failed: {s}\n", .{@errorName(err)}); │ │94 var buf: [256]u8 = undefined; │ │95 const msg = std.fmt.bufPrint(&buf, "haxy ssh: {s}\r\n", .{@errorName(err)}) catch "haxy ssh: internal error\r\n";│ │96 sess.writeBytes(msg) catch {}; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │215 const create_if_missing = parsed.service == .receive_pack; │ │216 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; │ │217 │ │218 + // authenticate pushes: the authenticated key must be registered to the │ │219 + // repo's owner. skipped in test mode, which serves repos directly by path. │ │220 + if (create_if_missing and !handler.is_test) { │ │221 + const owner_repo = evt.parseOwnerRepoPath(parsed.dir) orelse return writeError(sess, "repo path must be <owner>/<repo>"); │ │222 + if (!try isKeyAuthorized(io, allocator, handler.admin_repo_path, owner_repo.owner, &sess.fingerprint)) │ │223 + return writeError(sess, "unauthorized: this SSH key is not registered to the repo owner"); │ │224 + } │ │225 + │ │226 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.is_test, handler.repo_root_path, handler.admin_repo_path, parsed.dir, create_if_missing)) {│ │227 .ok => |p| p, │ │228 .invalid => return writeError(sess, "repo path must be <owner>/<repo>"), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │323 return error.UnsupportedService; │ │324 │ │325 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; │ │326 +} │ │327 + │ │328 +// true if `fingerprint` matches one of the named user's registered SSH keys │ │329 +fn isKeyAuthorized( │ │330 + io: std.Io, │ │331 + allocator: std.mem.Allocator, │ │332 + admin_repo_path: []const u8, │ │333 + owner_name: []const u8, │ │334 + fingerprint: *const [ssh.fingerprint_len]u8, │ │335 +) !bool { │ │336 + var arena = std.heap.ArenaAllocator.init(allocator); │ │337 + defer arena.deinit(); │ │338 + │ │339 + const user = (try evt.User.readByName(io, allocator, admin_repo_path, &arena, owner_name)) orelse return false;│ │340 + │ │341 + var it = std.mem.splitScalar(u8, user.ssh_keys, '\n'); │ │342 + while (it.next()) |line| { │ │343 + const fp = fingerprintOfAuthorizedKey(line) orelse continue; │ │344 + if (std.mem.eql(u8, &fp, fingerprint)) return true; │ │345 + } │ │346 + return false; │ │347 } │ │348 │ │349 +// the SHA256 fingerprint of one authorized_keys line, or null if the line is │ │350 +// blank or not a parseable "<type> <base64> [comment]" entry │ │351 +fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { │ │352 + var it = std.mem.tokenizeAny(u8, line, " \t\r"); │ │353 + _ = it.next() orelse return null; // key type │ │354 + const blob_b64 = it.next() orelse return null; │ │355 + │ │356 + const decoder = std.base64.standard.Decoder; │ │357 + const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; │ │358 + var blob_buf: [4096]u8 = undefined; │ │359 + if (blob_len > blob_buf.len) return null; │ │360 + decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; │ │361 + │ │362 + return ssh.formatFingerprint(blob_buf[0..blob_len]); │ │363 +} │ │364 + │ │365 // write an error to the client and exit non-zero │ │366 fn writeError(sess: *ssh.SessionCtx, msg: []const u8) !void { │ │367 var buf: [256]u8 = undefined; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯