import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { stdin, stdout } from "node:process"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createInterface } from "node:readline/promises"; import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client, } from "@aws-sdk/client-s3"; import { PrismaClient } from "@prisma/client"; import semver from "semver"; import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers"; const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95"; type ReleaseType = "app" | "system"; const DEFAULT_SKU = "jetkvm-v2"; const KNOWN_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; interface SyncClients { prisma: PrismaClient; s3Client: S3Client; } interface SyncConfig { bucketName: string; baseUrl: string; skus?: string[]; } interface ReleaseArtifactInput { url: string; hash: string; compatibleSkus: string[]; } function artifactName(type: ReleaseType): string { return type === "app" ? "jetkvm_app" : "system.tar"; } // Pre-SKU artifacts (no skus/ folder) are only safe on the original jetkvm-v2. // Other SKUs require an explicit skus// upload to opt in. function legacyCompatibleSkus(): string[] { return [DEFAULT_SKU]; } const DEFAULT_ROLLOUT_PERCENTAGE = 10; type ReleaseOutcome = | "created" | "already-synced" | "no-artifacts" | "skipped" | "aborted"; type ReleaseDecision = | { kind: "create"; rolloutPercentage: number } | { kind: "skip" } | { kind: "abort" }; interface LatestExistingRelease { version: string; rolloutPercentage: number; } type SignatureStatus = | { kind: "absent" } | { kind: "valid"; signingFpr: string; rootFpr: string } | { kind: "wrong-root"; signingFpr: string; rootFpr: string } | { kind: "invalid"; reason: string } | { kind: "missing-pubkey"; rootFpr?: string } | { kind: "gpg-unavailable" }; interface ArtifactDisplayInfo { artifact: ReleaseArtifactInput; signature: SignatureStatus; } function shortFpr(fpr: string): string { // Keep the leading 16 hex chars (8 bytes) — enough to be unambiguous in a log // line while staying readable. The full fingerprint is what we actually // compare against; this is just for display. return fpr.slice(0, 16); } function describeSignature(status: SignatureStatus): string { switch (status.kind) { case "absent": return "NO (no .sig file in S3)"; case "valid": return `yes (root ${shortFpr(status.rootFpr)})`; case "wrong-root": return `WRONG ROOT (got ${shortFpr(status.rootFpr)}, expected ${shortFpr(OTA_ROOT_KEY_FPR)})`; case "invalid": return `INVALID (${status.reason})`; case "missing-pubkey": return `cannot verify (OTA root key ${shortFpr(OTA_ROOT_KEY_FPR)} not in local GPG keyring)`; case "gpg-unavailable": return "cannot verify (gpg not installed)"; } } async function downloadObjectToFile( s3Client: S3Client, bucketName: string, key: string, destPath: string, ): Promise { const response = await s3Client.send( new GetObjectCommand({ Bucket: bucketName, Key: key }), ); if (!response.Body) { throw new Error(`Empty body from S3 for key ${key}`); } await pipeline(response.Body as Readable, createWriteStream(destPath)); } function runGpgVerify( sigPath: string, artifactPath: string, ): Promise<{ exitCode: number; statusOutput: string; stderrOutput: string }> { return new Promise((resolve, reject) => { const proc = spawn( "gpg", ["--batch", "--status-fd=1", "--verify", sigPath, artifactPath], { stdio: ["ignore", "pipe", "pipe"] }, ); let statusOutput = ""; let stderrOutput = ""; proc.stdout.on("data", chunk => (statusOutput += chunk.toString())); proc.stderr.on("data", chunk => (stderrOutput += chunk.toString())); proc.on("error", reject); proc.on("close", exitCode => { resolve({ exitCode: exitCode ?? -1, statusOutput, stderrOutput }); }); }); } interface GpgStatus { validSig?: { signingFpr: string; rootFpr: string }; noPubkey?: boolean; // ERRSIG `rc` field. GnuPG documents rc=4 (unsupported algorithm), // rc=9 (missing public key); other codes are possible and we leave // them as raw strings for the caller to format. errSigRc?: string; badSig?: boolean; } const ERRSIG_RC_REASONS: Record = { "4": "unsupported algorithm", "9": "missing public key", }; function describeErrSigRc(rc: string): string { return ERRSIG_RC_REASONS[rc] ?? `gpg error code ${rc}`; } function parseGpgStatus(statusOutput: string): GpgStatus { const result: GpgStatus = {}; for (const rawLine of statusOutput.split("\n")) { const line = rawLine.replace(/^\[GNUPG:\]\s+/, "").trim(); if (line.startsWith("VALIDSIG ")) { // VALIDSIG // // Fields are space-separated; index 10 is the primary key fingerprint. const parts = line.split(/\s+/); if (parts.length >= 11) { result.validSig = { signingFpr: parts[1], rootFpr: parts[10] }; } } else if (line.startsWith("NO_PUBKEY ")) { result.noPubkey = true; } else if (line.startsWith("ERRSIG ")) { // ERRSIG