From b1447ad9987ae486702137f98cdab7499d316a0a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 03:56:44 +0000 Subject: [PATCH] fix: make deploy ssh identity yaml first --- config/deploy-ssh-identities.yaml | 60 ++++ scripts/src/ci/entry.ts | 30 +- scripts/src/ci/help.ts | 4 +- scripts/src/deploy-ssh-identity.ts | 484 +++++++++++++++++++++++++---- 4 files changed, 509 insertions(+), 69 deletions(-) create mode 100644 config/deploy-ssh-identities.yaml diff --git a/config/deploy-ssh-identities.yaml b/config/deploy-ssh-identities.yaml new file mode 100644 index 00000000..41744297 --- /dev/null +++ b/config/deploy-ssh-identities.yaml @@ -0,0 +1,60 @@ +version: 1 +kind: UnideskDeploySshIdentities +metadata: + name: unidesk-deploy-ssh-identities + owner: unidesk + relatedIssues: + - 1190 + +defaults: + targetId: D601 + identityId: github.com + +sources: + root: /root/unidesk/.state/secrets + +identities: + github.com: + host: github.com + privateKey: + sourceRef: deploy-ssh/github.com/id_ed25519 + sourceKey: file + publicKey: + sourceRef: deploy-ssh/github.com/id_ed25519.pub + sourceKey: file + knownHosts: + sourceRef: deploy-ssh/github.com/known_hosts + sourceKey: file + createFromLocal: + enabled: true + privateKeyPath: /root/.ssh/id_ed25519 + publicKeyPath: /root/.ssh/id_ed25519.pub + knownHostsPath: /root/.ssh/known_hosts + +targets: + D601: + providerId: D601 + route: D601 + homeDir: /home/ubuntu + egress: + mode: http-connect-proxy + proxyUrl: http://127.0.0.1:18789 + identities: + - github.com + D518: + providerId: D518 + route: D518 + homeDir: /home/ubuntu + egress: + mode: direct + identities: + - github.com + G14: + providerId: G14 + route: G14 + homeDir: /root + egress: + mode: http-connect-proxy + proxyUrl: http://127.0.0.1:18789 + identities: + - github.com diff --git a/scripts/src/ci/entry.ts b/scripts/src/ci/entry.ts index 6ce8b4f2..3c36000f 100644 --- a/scripts/src/ci/entry.ts +++ b/scripts/src/ci/entry.ts @@ -10,7 +10,7 @@ import { join, posix as posixPath } from "node:path"; import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog"; import { runCommand } from "../command"; import { type UniDeskConfig, repoRoot, rootPath } from "../config"; -import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity"; +import { deploySshIdentityPlan, ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity"; import { jobWithTail, listJobs, readJob, startJob } from "../jobs"; import { coreInternalFetch } from "../microservices"; import { @@ -61,6 +61,34 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi }; } if (action === "status") return status(ciTarget(providerIdOption(args))); + if (action === "github-ssh-identity") { + const subAction = nameArg ?? "plan"; + const target = providerIdOption(args); + const identityId = stringOption(args, "--identity") ?? stringOption(args, "--identity-id"); + if (subAction === "plan") return deploySshIdentityPlan(target, identityId); + if (subAction === "ensure") { + if (!boolFlag(args, "--confirm")) { + const confirmArgs = [ + "bun scripts/cli.ts ci github-ssh-identity ensure", + target === null ? "" : `--target ${target}`, + identityId === null ? "" : `--identity ${identityId}`, + "--confirm", + ].filter((item) => item.length > 0).join(" "); + return { + ok: false, + action: "deploy-ssh-identity-ensure", + mutation: false, + mode: "confirm-required", + plan: deploySshIdentityPlan(target, identityId), + next: { + confirm: confirmArgs, + }, + }; + } + return await ensureGithubSshIdentityForProvider(config, target ?? "", identityId); + } + throw new Error("ci github-ssh-identity must be one of: plan, ensure"); + } if (action === "run") { const target = ciTarget(providerIdOption(args)); const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk"; diff --git a/scripts/src/ci/help.ts b/scripts/src/ci/help.ts index 60aa5a43..701ff089 100644 --- a/scripts/src/ci/help.ts +++ b/scripts/src/ci/help.ts @@ -51,7 +51,7 @@ export function catalogArtifactDescriptor(artifact: CiCatalogArtifact): Record { const catalog = loadCiCatalog(); return { - command: "ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs|cleanup-runs|cleanup-failed-pods", + command: "ci install|status|github-ssh-identity|run|publish-backend-core|publish-user-service|run-dev-e2e|logs|cleanup-runs|cleanup-failed-pods", description: "Manage native k3s Tekton CI on D601 or G14. CI may publish commit-pinned image artifacts, but it intentionally does not deploy CD.", examples: [ "bun scripts/cli.ts ci install", @@ -60,6 +60,8 @@ export function ciHelp(): Record { "bun scripts/cli.ts ci install-status latest", "bun scripts/cli.ts ci install --wait --skip-prewarm --skip-tekton-install", "bun scripts/cli.ts ci plan --target D601", + "bun scripts/cli.ts ci github-ssh-identity plan --target D518", + "bun scripts/cli.ts ci github-ssh-identity ensure --target D518 --confirm", "bun scripts/cli.ts ci install --target G14", "bun scripts/cli.ts ci run --revision ", "bun scripts/cli.ts ci run --target G14 --revision ", diff --git a/scripts/src/deploy-ssh-identity.ts b/scripts/src/deploy-ssh-identity.ts index 8c878a4b..358728c9 100644 --- a/scripts/src/deploy-ssh-identity.ts +++ b/scripts/src/deploy-ssh-identity.ts @@ -1,13 +1,18 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; +import { runSshCommandCapture } from "./ssh"; export interface GithubSshIdentityDistribution { ok: boolean; detail: string; fingerprint: string | null; seededFromLocal: boolean; + identityId?: string; + targetId?: string; + configTruth?: Record; raw?: unknown; } @@ -18,13 +23,230 @@ interface GithubSshIdentity { 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"; +interface DeploySshSourceRef { + sourceRef: string; + sourceKey: "file"; +} -function providerHomeDir(providerId: string): string { - return providerId === "G14" ? "/root" : "/home/ubuntu"; +interface DeploySshCreateFromLocal { + enabled: boolean; + privateKeyPath: string; + publicKeyPath: string; + knownHostsPath: string; +} + +interface DeploySshIdentitySpec { + id: string; + host: string; + privateKey: DeploySshSourceRef; + publicKey: DeploySshSourceRef; + knownHosts: DeploySshSourceRef; + createFromLocal: DeploySshCreateFromLocal | null; +} + +interface DeploySshTargetSpec { + targetId: string; + providerId: string; + route: string; + homeDir: string; + egress: DeploySshEgressSpec; + identities: string[]; +} + +type DeploySshEgressSpec = + | { mode: "direct" } + | { mode: "http-connect-proxy"; proxyUrl: string }; + +interface DeploySshIdentityConfig { + configPath: string; + sourceRoot: string; + defaults: { + targetId: string; + identityId: string; + }; + identities: Record; + targets: Record; +} + +interface ResolvedDeploySshIdentitySourcePaths { + privateKeyPath: string; + publicKeyPath: string; + knownHostsPath: string; +} + +interface SourceGithubIdentityResult { + identity: GithubSshIdentity; + seededFromLocal: boolean; +} + +const deploySshIdentitiesConfigPath = "config/deploy-ssh-identities.yaml"; + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function optionalRecord(value: unknown, path: string): Record | null { + if (value === undefined || value === null) return null; + return asRecord(value, path); +} + +function stringField(record: Record, key: string, path: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + return value.trim(); +} + +function absolutePathField(record: Record, key: string, path: string): string { + const value = stringField(record, key, path); + if (!isAbsolute(value)) throw new Error(`${path}.${key} must be an absolute path`); + return value; +} + +function booleanField(record: Record, key: string, path: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); + return value; +} + +function stringArrayField(record: Record, key: string, path: string): string[] { + const value = record[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) { + throw new Error(`${path}.${key} must be a non-empty string array`); + } + return value.map((item) => item.trim()); +} + +function sourceRefField(record: Record, key: string, path: string): DeploySshSourceRef { + const value = asRecord(record[key], `${path}.${key}`); + const sourceRef = stringField(value, "sourceRef", `${path}.${key}`); + const sourceKey = stringField(value, "sourceKey", `${path}.${key}`); + if (sourceKey !== "file") throw new Error(`${path}.${key}.sourceKey must be file`); + if (isAbsolute(sourceRef) || sourceRef.split(/[\\/]+/u).includes("..")) { + throw new Error(`${path}.${key}.sourceRef must be a relative path under sources.root`); + } + return { sourceRef, sourceKey }; +} + +function parseDeploySshCreateFromLocal(value: unknown, path: string): DeploySshCreateFromLocal | null { + const record = optionalRecord(value, path); + if (record === null) return null; + return { + enabled: booleanField(record, "enabled", path), + privateKeyPath: absolutePathField(record, "privateKeyPath", path), + publicKeyPath: absolutePathField(record, "publicKeyPath", path), + knownHostsPath: absolutePathField(record, "knownHostsPath", path), + }; +} + +function parseDeploySshIdentity(id: string, value: unknown): DeploySshIdentitySpec { + const path = `${deploySshIdentitiesConfigPath}#identities.${id}`; + const record = asRecord(value, path); + return { + id, + host: stringField(record, "host", path), + privateKey: sourceRefField(record, "privateKey", path), + publicKey: sourceRefField(record, "publicKey", path), + knownHosts: sourceRefField(record, "knownHosts", path), + createFromLocal: parseDeploySshCreateFromLocal(record.createFromLocal, `${path}.createFromLocal`), + }; +} + +function parseDeploySshTarget(targetId: string, value: unknown): DeploySshTargetSpec { + const path = `${deploySshIdentitiesConfigPath}#targets.${targetId}`; + const record = asRecord(value, path); + const egress = parseDeploySshEgress(record.egress, `${path}.egress`); + return { + targetId, + providerId: stringField(record, "providerId", path), + route: stringField(record, "route", path), + homeDir: absolutePathField(record, "homeDir", path), + egress, + identities: stringArrayField(record, "identities", path), + }; +} + +function parseDeploySshEgress(value: unknown, path: string): DeploySshEgressSpec { + const record = asRecord(value, path); + const mode = stringField(record, "mode", path); + if (mode === "direct") return { mode }; + if (mode === "http-connect-proxy") return { mode, proxyUrl: stringField(record, "proxyUrl", path) }; + throw new Error(`${path}.mode must be direct or http-connect-proxy`); +} + +function loadDeploySshIdentityConfig(): DeploySshIdentityConfig { + const parsed = Bun.YAML.parse(readFileSync(rootPath(deploySshIdentitiesConfigPath), "utf8")) as unknown; + const root = asRecord(parsed, deploySshIdentitiesConfigPath); + if (root.version !== 1) throw new Error(`${deploySshIdentitiesConfigPath}.version must be 1`); + if (root.kind !== "UnideskDeploySshIdentities") throw new Error(`${deploySshIdentitiesConfigPath}.kind must be UnideskDeploySshIdentities`); + const defaults = asRecord(root.defaults, `${deploySshIdentitiesConfigPath}.defaults`); + const sources = asRecord(root.sources, `${deploySshIdentitiesConfigPath}.sources`); + const identities = asRecord(root.identities, `${deploySshIdentitiesConfigPath}.identities`); + const targets = asRecord(root.targets, `${deploySshIdentitiesConfigPath}.targets`); + const config: DeploySshIdentityConfig = { + configPath: deploySshIdentitiesConfigPath, + sourceRoot: absolutePathField(sources, "root", `${deploySshIdentitiesConfigPath}.sources`), + defaults: { + targetId: stringField(defaults, "targetId", `${deploySshIdentitiesConfigPath}.defaults`), + identityId: stringField(defaults, "identityId", `${deploySshIdentitiesConfigPath}.defaults`), + }, + identities: Object.fromEntries(Object.entries(identities).map(([id, value]) => [id, parseDeploySshIdentity(id, value)])), + targets: Object.fromEntries(Object.entries(targets).map(([id, value]) => [id, parseDeploySshTarget(id, value)])), + }; + if (config.targets[config.defaults.targetId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}.defaults.targetId references missing target ${config.defaults.targetId}`); + if (config.identities[config.defaults.identityId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}.defaults.identityId references missing identity ${config.defaults.identityId}`); + for (const target of Object.values(config.targets)) { + for (const identityId of target.identities) { + if (config.identities[identityId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}#targets.${target.targetId}.identities references missing identity ${identityId}`); + } + } + return config; +} + +function selectDeploySshTarget(config: DeploySshIdentityConfig, selection: string | null | undefined): { target: DeploySshTargetSpec; defaulted: boolean } { + const raw = selection === undefined || selection === null || selection.length === 0 ? config.defaults.targetId : selection; + const target = Object.values(config.targets).find((candidate) => candidate.targetId.toLowerCase() === raw.toLowerCase() || candidate.providerId.toLowerCase() === raw.toLowerCase()); + if (target === undefined) throw new Error(`${config.configPath} has no target ${raw}; known targets: ${Object.keys(config.targets).join(", ")}`); + return { target, defaulted: raw === config.defaults.targetId && (selection === undefined || selection === null || selection.length === 0) }; +} + +function selectDeploySshIdentity(config: DeploySshIdentityConfig, target: DeploySshTargetSpec, selection: string | null | undefined): { identity: DeploySshIdentitySpec; defaulted: boolean } { + const raw = selection === undefined || selection === null || selection.length === 0 ? config.defaults.identityId : selection; + const identity = config.identities[raw]; + if (identity === undefined) throw new Error(`${config.configPath} has no identity ${raw}; known identities: ${Object.keys(config.identities).join(", ")}`); + if (!target.identities.includes(identity.id)) throw new Error(`${config.configPath}#targets.${target.targetId}.identities does not include ${identity.id}`); + return { identity, defaulted: raw === config.defaults.identityId && (selection === undefined || selection === null || selection.length === 0) }; +} + +function sourceFilePath(config: DeploySshIdentityConfig, sourceRef: string): string { + const sourceRoot = resolve(config.sourceRoot); + const resolved = resolve(sourceRoot, sourceRef); + const rel = relative(sourceRoot, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`${deploySshIdentitiesConfigPath} sourceRef escapes sources.root: ${sourceRef}`); + return resolved; +} + +function sourcePaths(config: DeploySshIdentityConfig, identity: DeploySshIdentitySpec): ResolvedDeploySshIdentitySourcePaths { + return { + privateKeyPath: sourceFilePath(config, identity.privateKey.sourceRef), + publicKeyPath: sourceFilePath(config, identity.publicKey.sourceRef), + knownHostsPath: sourceFilePath(config, identity.knownHosts.sourceRef), + }; +} + +function configTruth(config: DeploySshIdentityConfig, target: DeploySshTargetSpec, identity: DeploySshIdentitySpec, targetDefaulted: boolean, identityDefaulted: boolean): Record { + return { + configPath: config.configPath, + sourceRoot: config.sourceRoot, + targetPath: `${config.configPath}#targets.${target.targetId}`, + identityPath: `${config.configPath}#identities.${identity.id}`, + defaults: { + targetPath: `${config.configPath}#defaults.targetId`, + identityPath: `${config.configPath}#defaults.identityId`, + targetDefaulted, + identityDefaulted, + }, + }; } function pgLiteral(value: string): string { @@ -91,7 +313,7 @@ function publicKeyFingerprint(publicKey: string): string { return `SHA256:${digest}`; } -function githubKnownHostLinesFromText(text: string): string[] { +function githubKnownHostLinesFromText(text: string, hostName = "github.com"): string[] { const rows: string[] = []; const seen = new Set(); for (const rawLine of text.split(/\r?\n/u)) { @@ -100,7 +322,7 @@ function githubKnownHostLinesFromText(text: string): string[] { 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 (!hostList.some((host) => host === hostName || host === `[${hostName}]:22`)) continue; if (seen.has(line)) continue; seen.add(line); rows.push(line); @@ -108,22 +330,23 @@ function githubKnownHostLinesFromText(text: string): string[] { return rows; } -function readGithubKnownHosts(): string { - const knownHostsPath = process.env.UNIDESK_GITHUB_KNOWN_HOSTS_PATH || defaultKnownHostsPath; - const localLines = existsSync(knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(knownHostsPath, "utf8")) : []; +function readGithubKnownHosts(knownHostsPath: string, host: string): string { + const localLines = existsSync(knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(knownHostsPath, "utf8"), host) : []; 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"); + const scan = commandOutput(["ssh-keyscan", "-t", "rsa,ecdsa,ed25519", host], undefined, 20_000); + const scannedLines = githubKnownHostLinesFromText(scan.stdout, host); + if (scannedLines.length === 0) throw new Error(scan.stderr || `failed to collect ${host} SSH host keys`); return `${scannedLines.join("\n")}\n`; } -function readLocalGithubIdentity(): GithubSshIdentity | null { - const privateKeyPath = process.env.UNIDESK_GITHUB_SSH_KEY_PATH || defaultPrivateKeyPath; +function readLocalGithubIdentity(identity: DeploySshIdentitySpec): GithubSshIdentity | null { + const local = identity.createFromLocal; + if (local === null || !local.enabled) return null; + const privateKeyPath = local.privateKeyPath; 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 publicKeyPath = local.publicKeyPath; const publicKey = existsSync(publicKeyPath) ? readFileSync(publicKeyPath, "utf8").trim() : commandOutput(["ssh-keygen", "-y", "-f", privateKeyPath]).stdout.trim(); @@ -131,24 +354,69 @@ function readLocalGithubIdentity(): GithubSshIdentity | null { return { privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, publicKey, - knownHosts: readGithubKnownHosts(), + knownHosts: readGithubKnownHosts(local.knownHostsPath, identity.host), updatedAt: null, }; } -function upsertGithubIdentity(config: UniDeskConfig, identity: GithubSshIdentity): void { +function writeSourceFileIfMissing(path: string, value: string, mode: number): boolean { + if (existsSync(path)) return false; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, value, { mode }); + chmodSync(path, mode); + return true; +} + +function ensureSourceGithubIdentity(config: DeploySshIdentityConfig, identitySpec: DeploySshIdentitySpec): SourceGithubIdentityResult { + const paths = sourcePaths(config, identitySpec); + let seededFromLocal = false; + if (!existsSync(paths.privateKeyPath) || !existsSync(paths.publicKeyPath) || !existsSync(paths.knownHostsPath)) { + const localIdentity = readLocalGithubIdentity(identitySpec); + if (localIdentity !== null) { + const seeded = [ + writeSourceFileIfMissing(paths.privateKeyPath, localIdentity.privateKey, 0o600), + writeSourceFileIfMissing(paths.publicKeyPath, `${localIdentity.publicKey.trim()}\n`, 0o644), + writeSourceFileIfMissing(paths.knownHostsPath, localIdentity.knownHosts, 0o600), + ]; + seededFromLocal = seeded.some(Boolean); + } + } + if (!existsSync(paths.privateKeyPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey sourceRef is missing`); + if (!existsSync(paths.publicKeyPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.publicKey sourceRef is missing`); + if (!existsSync(paths.knownHostsPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.knownHosts sourceRef is missing`); + const privateKey = readFileSync(paths.privateKeyPath, "utf8"); + const publicKey = readFileSync(paths.publicKeyPath, "utf8").trim(); + const knownHosts = readFileSync(paths.knownHostsPath, "utf8"); + if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey sourceRef has invalid private key material`); + if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256)\s+\S+/u.test(publicKey)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.publicKey sourceRef has invalid public key material`); + if (githubKnownHostLinesFromText(knownHosts, identitySpec.host).length === 0) throw new Error(`${config.configPath}#identities.${identitySpec.id}.knownHosts sourceRef has no ${identitySpec.host} rows`); + const derived = commandOutput(["ssh-keygen", "-y", "-f", paths.privateKeyPath]); + if (!derived.ok) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey failed ssh-keygen verification`); + if (derived.stdout.trim() !== publicKey) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey does not match publicKey`); + return { + identity: { + privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, + publicKey, + knownHosts: knownHosts.endsWith("\n") ? knownHosts : `${knownHosts}\n`, + updatedAt: null, + }, + seededFromLocal, + }; +} + +function upsertGithubIdentity(config: UniDeskConfig, identitySpec: DeploySshIdentitySpec, 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(identitySpec.id)}, + ${pgLiteral(identitySpec.host)}, ${pgLiteral(identity.privateKey)}, ${pgLiteral(identity.publicKey)}, ${pgLiteral(fingerprint)}, ${pgLiteral(identity.knownHosts)}, - 'operator-local', + ${pgLiteral(`${deploySshIdentitiesConfigPath}#identities.${identitySpec.id}`)}, now() ) ON CONFLICT (id) DO UPDATE SET @@ -164,7 +432,7 @@ ON CONFLICT (id) DO UPDATE SET if (!result.ok) throw new Error(`failed to upsert GitHub SSH identity in PostgreSQL: ${result.stderr || `exit=${result.exitCode}`}`); } -function readGithubIdentityFromDatabase(config: UniDeskConfig): GithubSshIdentity { +function readGithubIdentityFromDatabase(config: UniDeskConfig, identitySpec: DeploySshIdentitySpec): GithubSshIdentity { const sql = ` ${ensureIdentityTableSql()} SELECT json_build_object( @@ -174,7 +442,7 @@ SELECT json_build_object( 'updatedAt', updated_at )::text FROM unidesk_deploy_ssh_identities -WHERE id = ${pgLiteral(identityId)} +WHERE id = ${pgLiteral(identitySpec.id)} LIMIT 1; `; const result = runPsql(config, sql); @@ -187,7 +455,7 @@ LIMIT 1; 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"); + if (githubKnownHostLinesFromText(knownHosts, identitySpec.host).length === 0) throw new Error(`PostgreSQL GitHub SSH identity has no ${identitySpec.host} known_hosts rows`); return { privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, publicKey, @@ -207,6 +475,7 @@ import sys data = json.load(sys.stdin) home_dir = pathlib.Path(str(data.get("homeDir") or os.environ.get("HOME") or "/home/ubuntu")) +host = str(data.get("host") or "github.com") ssh_dir = home_dir / ".ssh" private_key = str(data.get("privateKey") or "") public_key = str(data.get("publicKey") or "").strip() @@ -235,8 +504,8 @@ 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) +subprocess.run(["ssh-keygen", "-R", host, "-f", str(known_hosts_path)], text=True, capture_output=True) +subprocess.run(["ssh-keygen", "-R", f"[{host}]:22", "-f", str(known_hosts_path)], text=True, capture_output=True) existing = known_hosts_path.read_text().splitlines() rows = [] seen = set() @@ -321,78 +590,159 @@ while True: `; } -function distributeGithubIdentity(providerId: string, identity: GithubSshIdentity): { ok: boolean; detail: string; raw: unknown } { +async function distributeGithubIdentity(config: UniDeskConfig, target: DeploySshTargetSpec, identitySpec: DeploySshIdentitySpec, identity: GithubSshIdentity): Promise<{ ok: boolean; detail: string; raw: unknown }> { const payload = JSON.stringify({ privateKey: identity.privateKey, publicKey: identity.publicKey, knownHosts: identity.knownHosts, - homeDir: providerHomeDir(providerId), + homeDir: target.homeDir, + host: identitySpec.host, }); 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", + `home_dir=${shellQuote(target.homeDir)}`, + "export HOME=\"$home_dir\"", + `host=${shellQuote(identitySpec.host)}`, + `egress_mode=${shellQuote(target.egress.mode)}`, + `proxy_url=${target.egress.mode === "http-connect-proxy" ? shellQuote(target.egress.proxyUrl) : "''"}`, "git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py", - "cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'", + "if [ \"$egress_mode\" = \"http-connect-proxy\" ]; then", + " curl -fsSI --max-time 20 -x \"$proxy_url\" \"https://$host\" >/dev/null", + "else", + " curl -fsSI --max-time 20 \"https://$host\" >/dev/null", + "fi", + "if [ \"$egress_mode\" = \"http-connect-proxy\" ]; then", + " 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)", + " 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@$host\" 2>&1 || true)", + "else", + " 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\"", + " auth_output=$(ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -T \"git@$host\" 2>&1 || true)", + "fi", "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 result = await runSshCommandCapture(config, target.route, ["argv", "bash", "-lc", remoteCommand], payload); const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? result.error?.message ?? ""; + const stderr = result.stderr ?? ""; return { - ok: result.status === 0, + ok: result.exitCode === 0, detail: [stdout, stderr].filter(Boolean).join("\n").slice(-2000), - raw: { exitCode: result.status, stdoutTail: stdout.slice(-1200), stderrTail: stderr.slice(-1200) }, + raw: { + targetId: target.targetId, + providerId: target.providerId, + route: target.route, + homeDir: target.homeDir, + egress: target.egress, + identityId: identitySpec.id, + host: identitySpec.host, + exitCode: result.exitCode, + stdoutTail: stdout.slice(-1200), + stderrTail: stderr.slice(-1200), + valuesRedacted: true, + }, }; } -export async function ensureGithubSshIdentityForProvider(config: UniDeskConfig, providerId: string): Promise { - let seededFromLocal = false; - const localIdentity = readLocalGithubIdentity(); - if (localIdentity !== null) { - upsertGithubIdentity(config, localIdentity); - seededFromLocal = true; - } - const identity = readGithubIdentityFromDatabase(config); +function deploySshIdentitySourceStatus(config: DeploySshIdentityConfig, identity: DeploySshIdentitySpec): Record { + const paths = sourcePaths(config, identity); + const sourceStatus = (kind: string, sourceRef: string, path: string): Record => { + const present = existsSync(path); + return { + kind, + sourceRef, + sourceKey: "file", + present, + bytes: present ? statSync(path).size : 0, + }; + }; + const publicKey = existsSync(paths.publicKeyPath) ? readFileSync(paths.publicKeyPath, "utf8").trim() : ""; + return { + privateKey: sourceStatus("privateKey", identity.privateKey.sourceRef, paths.privateKeyPath), + publicKey: { + ...sourceStatus("publicKey", identity.publicKey.sourceRef, paths.publicKeyPath), + fingerprint: publicKey.length > 0 ? publicKeyFingerprint(publicKey) : null, + }, + knownHosts: { + ...sourceStatus("knownHosts", identity.knownHosts.sourceRef, paths.knownHostsPath), + hostRows: existsSync(paths.knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(paths.knownHostsPath, "utf8"), identity.host).length : 0, + }, + }; +} + +export function deploySshIdentityPlan(targetSelection: string | null | undefined, identitySelection: string | null | undefined = null): Record { + const deployConfig = loadDeploySshIdentityConfig(); + const { target, defaulted: targetDefaulted } = selectDeploySshTarget(deployConfig, targetSelection); + const { identity, defaulted: identityDefaulted } = selectDeploySshIdentity(deployConfig, target, identitySelection); + const sourceStatus = deploySshIdentitySourceStatus(deployConfig, identity); + const allSourcesPresent = [sourceStatus.privateKey, sourceStatus.publicKey, sourceStatus.knownHosts].every((item) => asRecord(item, "sourceStatus").present === true); + return { + ok: true, + action: "deploy-ssh-identity-plan", + mutation: false, + identity: { + id: identity.id, + host: identity.host, + sourceStatus, + createFromLocal: identity.createFromLocal === null ? null : { + enabled: identity.createFromLocal.enabled, + privateKeyPath: identity.createFromLocal.privateKeyPath, + publicKeyPath: identity.createFromLocal.publicKeyPath, + knownHostsPath: identity.createFromLocal.knownHostsPath, + }, + }, + target: { + targetId: target.targetId, + providerId: target.providerId, + route: target.route, + homeDir: target.homeDir, + egress: target.egress, + }, + ready: allSourcesPresent || identity.createFromLocal?.enabled === true, + configTruth: configTruth(deployConfig, target, identity, targetDefaulted, identityDefaulted), + valuesRedacted: true, + next: { + ensure: `bun scripts/cli.ts ci github-ssh-identity ensure --target ${target.targetId} --identity ${identity.id} --confirm`, + }, + }; +} + +export async function ensureGithubSshIdentityForProvider(config: UniDeskConfig, providerId: string, identitySelection: string | null | undefined = null): Promise { + const deployConfig = loadDeploySshIdentityConfig(); + const { target, defaulted: targetDefaulted } = selectDeploySshTarget(deployConfig, providerId); + const { identity: identitySpec, defaulted: identityDefaulted } = selectDeploySshIdentity(deployConfig, target, identitySelection); + const source = ensureSourceGithubIdentity(deployConfig, identitySpec); + upsertGithubIdentity(config, identitySpec, source.identity); + const identity = readGithubIdentityFromDatabase(config, identitySpec); const fingerprint = publicKeyFingerprint(identity.publicKey); - const distribution = distributeGithubIdentity(providerId, identity); + const distribution = await distributeGithubIdentity(config, target, identitySpec, identity); if (!distribution.ok) { return { ok: false, - detail: `failed to distribute GitHub SSH identity from PostgreSQL to ${providerId}: ${distribution.detail || "remote ssh failed"}`, + detail: `failed to distribute GitHub SSH identity from YAML sourceRef via PostgreSQL to ${target.targetId}: ${distribution.detail || "remote ssh failed"}`, fingerprint, - seededFromLocal, + seededFromLocal: source.seededFromLocal, + identityId: identitySpec.id, + targetId: target.targetId, + configTruth: configTruth(deployConfig, target, identitySpec, targetDefaulted, identityDefaulted), raw: distribution.raw, }; } return { ok: true, - detail: `GitHub SSH identity distributed from PostgreSQL to ${providerId}; fingerprint=${fingerprint}; seededFromLocal=${seededFromLocal}`, + detail: `GitHub SSH identity distributed from YAML sourceRef via PostgreSQL to ${target.targetId}; fingerprint=${fingerprint}; seededFromLocal=${source.seededFromLocal}`, fingerprint, - seededFromLocal, + seededFromLocal: source.seededFromLocal, + identityId: identitySpec.id, + targetId: target.targetId, + configTruth: configTruth(deployConfig, target, identitySpec, targetDefaulted, identityDefaulted), raw: distribution.raw, }; }