Vectrum
Sign In
116 lines · 4.3 KB · TypeScript
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { Server, utils, type Connection } from "ssh2";
import { normalizeRepoName } from "./git";
import { findKeyByBlob, touchSshKey } from "./keys";
import { DATA_ROOT } from "./paths";
import { canWrite, findRepo, markPushed, repoAccess } from "./repos";
import { SSH_LISTEN_HOST, SSH_PORT } from "./ssh-config";
import { getUserById, type User } from "./users";

function hostKey() {
  const file = path.join(DATA_ROOT, "ssh_host_ed25519_key");
  if (!existsSync(file)) {
    mkdirSync(DATA_ROOT, { recursive: true });
    const { private: priv } = utils.generateKeyPairSync("ed25519");
    writeFileSync(file, priv, { mode: 0o600 });
  }
  return readFileSync(file);
}

// git sends e.g.: git-upload-pack '/alice/demo.git'
const COMMAND_RE = /^git[ -](upload-pack|receive-pack) '?\/?([^'/\s]+)\/([^'/\s]+?)'?$/;

function handleConnection(client: Connection) {
  let user: User | null = null;
  let keyId: number | null = null;

  client.on("authentication", (ctx) => {
    if (ctx.method !== "publickey") return ctx.reject(["publickey"]);
    const key = findKeyByBlob(ctx.key.data);
    if (!key) return ctx.reject(["publickey"]);
    // First the client asks whether a key is acceptable, then proves ownership with a signature.
    if (ctx.signature && ctx.blob) {
      const parsed = utils.parseKey(key.publicKey);
      if (parsed instanceof Error || parsed.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) {
        return ctx.reject(["publickey"]);
      }
      user = getUserById(key.userId);
      keyId = key.id;
      if (!user) return ctx.reject(["publickey"]);
    }
    ctx.accept();
  });

  client.on("ready", () => {
    if (keyId) touchSshKey(keyId);
    client.on("session", (acceptSession) => {
      const session = acceptSession();
      const env: Record<string, string> = {};

      session.on("env", (accept, _reject, info) => {
        if (info.key === "GIT_PROTOCOL") env.GIT_PROTOCOL = info.val;
        accept?.();
      });
      session.on("pty", (accept) => accept?.());

      session.on("shell", (accept) => {
        const channel = accept();
        channel.write(
          `Hi ${user?.username}! You've successfully authenticated, but this server does not provide shell access.\r\n`,
        );
        channel.exit(1);
        channel.end();
      });

      session.on("exec", (accept, _reject, info) => {
        const channel = accept();
        const fail = (message: string) => {
          channel.stderr.write(`ERROR: ${message}\n`);
          channel.exit(1);
          channel.end();
        };

        const match = COMMAND_RE.exec(info.command.trim());
        if (!match || !user) return fail("Only git clone, fetch and push are supported.");
        const [, service, owner, repoName] = match;
        const repo = findRepo(owner, normalizeRepoName(repoName));
        const access = repo ? repoAccess(repo, user.id) : null;
        if (!repo || !access) return fail("Repository not found.");
        const pushing = service === "receive-pack";
        if (pushing && !canWrite(access)) {
          return fail(`Permission to ${repo.owner}/${repo.name} denied to ${user.username}.`);
        }

        const child = spawn("git", [service, repo.dir], { env: { ...process.env, ...env } });
        channel.pipe(child.stdin);
        child.stdout.pipe(channel, { end: false });
        child.stderr.pipe(channel.stderr, { end: false });
        child.on("close", (code) => {
          if (pushing && code === 0) markPushed(repo.id);
          channel.exit(code ?? 1);
          channel.end();
        });
        channel.on("close", () => child.kill());
      });
    });
  });

  client.on("error", () => {
    // Clients disconnecting mid-handshake are routine; ignore.
  });
}

export function startSshServer() {
  const g = globalThis as unknown as { __sshServer?: Server };
  if (g.__sshServer) return;
  const server = new Server({ hostKeys: [hostKey()] }, handleConnection);
  server.on("error", (err: NodeJS.ErrnoException) => {
    console.warn(`[ssh] could not start on port ${SSH_PORT}: ${err.code ?? err.message}`);
  });
  server.listen(SSH_PORT, SSH_LISTEN_HOST, () => {
    console.log(`[ssh] git over SSH listening on ${SSH_LISTEN_HOST}:${SSH_PORT}`);
  });
  g.__sshServer = server;
}