Files
pikasTech-unidesk/scripts/src/deploy-ssh-identity.ts
T
2026-05-24 14:06:58 +00:00

399 lines
15 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
export interface GithubSshIdentityDistribution {
ok: boolean;
detail: string;
fingerprint: string | null;
seededFromLocal: boolean;
raw?: unknown;
}
interface GithubSshIdentity {
privateKey: string;
publicKey: string;
knownHosts: string;
updatedAt: string | null;
}
const identityId = "github.com";
const defaultPrivateKeyPath = "/root/.ssh/id_ed25519";
const defaultKnownHostsPath = "/root/.ssh/known_hosts";
const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789";
function providerHomeDir(providerId: string): string {
return providerId === "G14" ? "/root" : "/home/ubuntu";
}
function pgLiteral(value: string): string {
return `'${value.replace(/'/gu, "''")}'`;
}
function commandOutput(command: string[], input?: string, timeoutMs = 30_000): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } {
const result = spawnSync(command[0], command.slice(1), {
cwd: repoRoot,
input,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 8,
timeout: timeoutMs,
});
return {
ok: result.status === 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? result.error?.message ?? "",
exitCode: result.status,
};
}
function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } {
return commandOutput([
"docker",
"exec",
"-i",
"unidesk-database",
"psql",
"-v",
"ON_ERROR_STOP=1",
"-U",
config.database.user,
"-d",
config.database.name,
"-X",
"-q",
"-t",
"-A",
], sql);
}
function ensureIdentityTableSql(): string {
return `
CREATE TABLE IF NOT EXISTS unidesk_deploy_ssh_identities (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL DEFAULT '',
known_hosts TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'operator-local',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`;
}
function publicKeyFingerprint(publicKey: string): string {
const parts = publicKey.trim().split(/\s+/u);
const encoded = parts[1] ?? "";
if (encoded.length === 0) return "";
const digest = createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/u, "");
return `SHA256:${digest}`;
}
function githubKnownHostLinesFromText(text: string): string[] {
const rows: string[] = [];
const seen = new Set<string>();
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const parts = line.split(/\s+/u);
if (parts.length < 3) continue;
const hostList = parts[0]?.split(",") ?? [];
if (!hostList.some((host) => host === "github.com" || host === "[github.com]:22")) continue;
if (seen.has(line)) continue;
seen.add(line);
rows.push(line);
}
return rows;
}
function readGithubKnownHosts(): string {
const knownHostsPath = process.env.UNIDESK_GITHUB_KNOWN_HOSTS_PATH || defaultKnownHostsPath;
const localLines = existsSync(knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(knownHostsPath, "utf8")) : [];
if (localLines.length > 0) return `${localLines.join("\n")}\n`;
const scan = commandOutput(["ssh-keyscan", "-t", "rsa,ecdsa,ed25519", "github.com"], undefined, 20_000);
const scannedLines = githubKnownHostLinesFromText(scan.stdout);
if (scannedLines.length === 0) throw new Error(scan.stderr || "failed to collect github.com SSH host keys");
return `${scannedLines.join("\n")}\n`;
}
function readLocalGithubIdentity(): GithubSshIdentity | null {
const privateKeyPath = process.env.UNIDESK_GITHUB_SSH_KEY_PATH || defaultPrivateKeyPath;
if (!existsSync(privateKeyPath)) return null;
const privateKey = readFileSync(privateKeyPath, "utf8");
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error(`invalid private key format: ${privateKeyPath}`);
const publicKeyPath = `${privateKeyPath}.pub`;
const publicKey = existsSync(publicKeyPath)
? readFileSync(publicKeyPath, "utf8").trim()
: commandOutput(["ssh-keygen", "-y", "-f", privateKeyPath]).stdout.trim();
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256)\s+\S+/u.test(publicKey)) throw new Error(`invalid public key for ${privateKeyPath}`);
return {
privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
publicKey,
knownHosts: readGithubKnownHosts(),
updatedAt: null,
};
}
function upsertGithubIdentity(config: UniDeskConfig, identity: GithubSshIdentity): void {
const fingerprint = publicKeyFingerprint(identity.publicKey);
const sql = `
${ensureIdentityTableSql()}
INSERT INTO unidesk_deploy_ssh_identities (id, host, private_key, public_key, public_key_fingerprint, known_hosts, source, updated_at)
VALUES (
${pgLiteral(identityId)},
${pgLiteral(identityId)},
${pgLiteral(identity.privateKey)},
${pgLiteral(identity.publicKey)},
${pgLiteral(fingerprint)},
${pgLiteral(identity.knownHosts)},
'operator-local',
now()
)
ON CONFLICT (id) DO UPDATE SET
host = EXCLUDED.host,
private_key = EXCLUDED.private_key,
public_key = EXCLUDED.public_key,
public_key_fingerprint = EXCLUDED.public_key_fingerprint,
known_hosts = EXCLUDED.known_hosts,
source = EXCLUDED.source,
updated_at = now();
`;
const result = runPsql(config, sql);
if (!result.ok) throw new Error(`failed to upsert GitHub SSH identity in PostgreSQL: ${result.stderr || `exit=${result.exitCode}`}`);
}
function readGithubIdentityFromDatabase(config: UniDeskConfig): GithubSshIdentity {
const sql = `
${ensureIdentityTableSql()}
SELECT json_build_object(
'privateKey', private_key,
'publicKey', public_key,
'knownHosts', known_hosts,
'updatedAt', updated_at
)::text
FROM unidesk_deploy_ssh_identities
WHERE id = ${pgLiteral(identityId)}
LIMIT 1;
`;
const result = runPsql(config, sql);
if (!result.ok) throw new Error(`failed to read GitHub SSH identity from PostgreSQL: ${result.stderr || `exit=${result.exitCode}`}`);
const line = result.stdout.trim().split(/\r?\n/u).find((item) => item.trim().startsWith("{"));
if (line === undefined) throw new Error("GitHub SSH identity is missing in PostgreSQL");
const parsed = JSON.parse(line) as Partial<GithubSshIdentity>;
const privateKey = typeof parsed.privateKey === "string" ? parsed.privateKey : "";
const publicKey = typeof parsed.publicKey === "string" ? parsed.publicKey : "";
const knownHosts = typeof parsed.knownHosts === "string" ? parsed.knownHosts : "";
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error("PostgreSQL GitHub SSH identity has invalid private key material");
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256)\s+\S+/u.test(publicKey)) throw new Error("PostgreSQL GitHub SSH identity has invalid public key material");
if (githubKnownHostLinesFromText(knownHosts).length === 0) throw new Error("PostgreSQL GitHub SSH identity has no github.com known_hosts rows");
return {
privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
publicKey,
knownHosts: knownHosts.endsWith("\n") ? knownHosts : `${knownHosts}\n`,
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null,
};
}
function remoteInstallPythonSource(): string {
return String.raw`
import json
import os
import pathlib
import stat
import subprocess
import sys
data = json.load(sys.stdin)
home_dir = pathlib.Path(str(data.get("homeDir") or os.environ.get("HOME") or "/home/ubuntu"))
ssh_dir = home_dir / ".ssh"
private_key = str(data.get("privateKey") or "")
public_key = str(data.get("publicKey") or "").strip()
known_hosts = str(data.get("knownHosts") or "")
if "PRIVATE KEY-----" not in private_key:
raise SystemExit("invalid private key payload")
if not public_key.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-nistp256 ")):
raise SystemExit("invalid public key payload")
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(ssh_dir, 0o700)
private_path = ssh_dir / "id_ed25519"
public_path = ssh_dir / "id_ed25519.pub"
known_hosts_path = ssh_dir / "known_hosts"
private_path.write_text(private_key if private_key.endswith("\n") else private_key + "\n")
public_path.write_text(public_key + "\n")
os.chmod(private_path, stat.S_IRUSR | stat.S_IWUSR)
os.chmod(public_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
derived = subprocess.run(["ssh-keygen", "-y", "-f", str(private_path)], text=True, capture_output=True)
if derived.returncode != 0:
raise SystemExit("installed private key failed ssh-keygen verification")
if derived.stdout.strip() != public_key:
raise SystemExit("installed private key does not match public key")
known_hosts_path.touch(mode=0o600, exist_ok=True)
subprocess.run(["ssh-keygen", "-R", "github.com", "-f", str(known_hosts_path)], text=True, capture_output=True)
subprocess.run(["ssh-keygen", "-R", "[github.com]:22", "-f", str(known_hosts_path)], text=True, capture_output=True)
existing = known_hosts_path.read_text().splitlines()
rows = []
seen = set()
for line in existing + known_hosts.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
if line in seen:
continue
seen.add(line)
rows.append(line)
known_hosts_path.write_text("\n".join(rows) + "\n")
os.chmod(known_hosts_path, stat.S_IRUSR | stat.S_IWUSR)
print(f"github_ssh_identity_written path={private_path} known_hosts_rows={len(rows)}")
`;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'\\''`)}'`;
}
export function gitSshHttpConnectProxySource(): string {
return String.raw`#!/usr/bin/env python3
import os
import select
import socket
import sys
from urllib.parse import urlparse
if len(sys.argv) != 3:
raise SystemExit("usage: unidesk-git-ssh-http-connect.py host port")
target_host = sys.argv[1]
target_port = int(sys.argv[2])
proxy = urlparse(os.environ.get("UNIDESK_GIT_SSH_HTTP_PROXY", "http://127.0.0.1:18789"))
proxy_host = proxy.hostname or "127.0.0.1"
proxy_port = proxy.port or 80
sock = socket.create_connection((proxy_host, proxy_port), timeout=20)
sock.sendall(f"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n".encode("ascii"))
header = b""
while b"\r\n\r\n" not in header:
chunk = sock.recv(4096)
if not chunk:
raise SystemExit("proxy closed before CONNECT response")
header += chunk
head, rest = header.split(b"\r\n\r\n", 1)
if not (head.startswith(b"HTTP/1.1 200") or head.startswith(b"HTTP/1.0 200")):
sys.stderr.write(head.decode("latin1", "replace") + "\n")
raise SystemExit(1)
if rest:
os.write(1, rest)
stdin_open = True
sock.setblocking(False)
while True:
readers = [sock]
if stdin_open:
readers.append(sys.stdin.buffer)
ready, _, _ = select.select(readers, [], [])
if sock in ready:
try:
data = sock.recv(65536)
except BlockingIOError:
data = b""
if not data:
break
os.write(1, data)
if stdin_open and sys.stdin.buffer in ready:
data = os.read(0, 65536)
if data:
sock.sendall(data)
else:
stdin_open = False
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
`;
}
function distributeGithubIdentity(providerId: string, identity: GithubSshIdentity): { ok: boolean; detail: string; raw: unknown } {
const payload = JSON.stringify({
privateKey: identity.privateKey,
publicKey: identity.publicKey,
knownHosts: identity.knownHosts,
homeDir: providerHomeDir(providerId),
});
const remotePython = remoteInstallPythonSource();
const proxyPython = gitSshHttpConnectProxySource();
const remoteCommand = [
"set -euo pipefail",
`python3 -c ${shellQuote(remotePython)}`,
`proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
"curl -fsSI --max-time 20 -x \"$proxy_url\" https://github.com >/dev/null",
"git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py",
"cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'",
proxyPython,
"UNIDESK_GIT_SSH_PROXY",
"chmod 700 \"$git_ssh_proxy\"",
"export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"",
"export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"",
"auth_output=$(ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o \"ProxyCommand=$git_ssh_proxy %h %p\" -T git@github.com 2>&1 || true)",
"printf '%s\\n' \"$auth_output\"",
"printf '%s\\n' \"$auth_output\" | grep -q 'successfully authenticated'",
].join("\n");
const result = spawnSync(process.execPath, [
rootPath("scripts", "cli.ts"),
"ssh",
providerId,
"--",
remoteCommand,
], {
cwd: repoRoot,
input: payload,
encoding: "utf8",
timeout: 90_000,
maxBuffer: 1024 * 1024 * 4,
env: { ...process.env, UNIDESK_SSH_OPEN_TIMEOUT_MS: process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || "60000" },
});
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? result.error?.message ?? "";
return {
ok: result.status === 0,
detail: [stdout, stderr].filter(Boolean).join("\n").slice(-2000),
raw: { exitCode: result.status, stdoutTail: stdout.slice(-1200), stderrTail: stderr.slice(-1200) },
};
}
export async function ensureGithubSshIdentityForProvider(config: UniDeskConfig, providerId: string): Promise<GithubSshIdentityDistribution> {
let seededFromLocal = false;
const localIdentity = readLocalGithubIdentity();
if (localIdentity !== null) {
upsertGithubIdentity(config, localIdentity);
seededFromLocal = true;
}
const identity = readGithubIdentityFromDatabase(config);
const fingerprint = publicKeyFingerprint(identity.publicKey);
const distribution = distributeGithubIdentity(providerId, identity);
if (!distribution.ok) {
return {
ok: false,
detail: `failed to distribute GitHub SSH identity from PostgreSQL to ${providerId}: ${distribution.detail || "remote ssh failed"}`,
fingerprint,
seededFromLocal,
raw: distribution.raw,
};
}
return {
ok: true,
detail: `GitHub SSH identity distributed from PostgreSQL to ${providerId}; fingerprint=${fingerprint}; seededFromLocal=${seededFromLocal}`,
fingerprint,
seededFromLocal,
raw: distribution.raw,
};
}