OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / bin / pgv3-git-shell.js

107 lines · javascript ref:
#!/usr/bin/env node
/**
 * pgv3-git-shell — forced-command SSH wrapper for the classic-git-auth SSH
 * push path (Phase 2, Option B). See spec/flows/classic-git-auth.md.
 *
 * Installed as the `command="…"` for every enrolled key in the dedicated `git`
 * account's authorized_keys. sshd runs it (never a shell) with the client's
 * request in $SSH_ORIGINAL_COMMAND. It:
 *
 *   1. parses + sanitizes the git command (git-shell-parse — git only)
 *   2. asks the daemon over UDS whether THIS fingerprint may run it
 *      (git-acl-check — reads open, push gated, revocation live)
 *   3. execs the real git pack process on the bare repo, wiring its stdio to
 *      the SSH channel so the pack protocol flows through.
 *
 * The daemon bakes --fp/--sock/--repos into the forced command at enrollment,
 * so this wrapper is self-configuring regardless of the git account's HOME.
 * It is intentionally tiny and fail-closed: any error denies.
 */

import process from '#node:process'
import { spawn } from '#node:child_process'
import { join } from '#node:path'
import { homedir } from '#node:os'
import fs from '#node:fs'
import { Client } from 'hyper-svc'

import { parseGitCommand, safeRepoPath } from '../lib/auth/git-shell-parse.js'

// Synchronous write to fd 2 — an async process.stderr.write() is truncated by
// the immediate process.exit() after a UDS round-trip, swallowing the reason
// (the deny-message-lost bug caught by the live smoke test).
function deny (msg) {
  try { fs.writeSync(2, 'pgv3: ' + msg + '\n') } catch {}
  process.exit(1)
}

function argval (args, name) {
  const pfx = '--' + name + '='
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith(pfx)) return args[i].slice(pfx.length)
    if (args[i] === '--' + name && i + 1 < args.length) return args[i + 1]
  }
  return null
}

async function main () {
  const args = process.argv.slice(2)
  const fingerprint = argval(args, 'fp')
  // Daemon bakes these in; env + sensible defaults are a fallback.
  const sock = argval(args, 'sock') || process.env.PGV3_DAEMON_SOCK ||
    join(homedir(), 'pgv3-storage', 'svc.sock')
  const reposDir = argval(args, 'repos') || process.env.PEAR_GIT_REPOS ||
    join(homedir(), 'git-repos')

  const parsed = parseGitCommand(process.env.SSH_ORIGINAL_COMMAND || '')
  if (!parsed.ok) return deny(parsed.error)

  // Ask the daemon whether this fingerprint may run this op on this repo.
  let verdict = null
  let client = null
  try {
    client = await Client.connectUds(sock)
    verdict = await client.call('git-acl-check', {
      fingerprint,
      repo: parsed.repo,
      op: parsed.op
    })
  } catch (err) {
    return deny('authorization unavailable (daemon unreachable): ' + (err && err.message ? err.message : err))
  } finally {
    if (client) { try { await client.close() } catch {} }
  }

  if (!verdict || !verdict.allowed) {
    return deny(verdict && verdict.reason ? verdict.reason : 'access denied')
  }

  // Resolve the repo to an absolute bare-repo path and PROVE containment in
  // reposDir before we exec anything (#74 Phase 4 — defense in depth, even
  // though parseGitCommand already rules out traversal).
  const repoPath = safeRepoPath(reposDir, parsed.repo)
  if (!repoPath) return deny('repository outside the allowed directory')

  // Run the real git pack process; its stdio IS the SSH channel. spawn with an
  // ARG ARRAY (never a shell) — gitCmd is a fixed string from the closed op
  // allowlist and repoPath is an absolute, contained path, so nothing from
  // $SSH_ORIGINAL_COMMAND is ever interpreted by a shell or seen as an option.
  const gitCmd = 'git-' + parsed.op // git-receive-pack | git-upload-pack
  const child = spawn(gitCmd, [repoPath], { stdio: 'inherit' })
  child.on('error', (err) => deny('failed to start ' + gitCmd + ': ' + (err && err.message ? err.message : err)))
  child.on('exit', async (code, signal) => {
    // Attribute a successful SSH push for the public profile "repos pushed"
    // list. Best-effort over a fresh UDS connection; never blocks or fails
    // the push (the pack has already been transferred at this point).
    if (!signal && code === 0 && parsed.op === 'receive-pack') {
      try {
        const c = await Client.connectUds(sock)
        try { await c.call('record-push', { repo: parsed.repo, fingerprint }) } finally { await c.close() }
      } catch {}
    }
    process.exit(signal ? 1 : (code || 0))
  })
}

main().catch((err) => deny(err && err.message ? err.message : String(err)))