import { existsSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import type { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import type { ApiRouteDeps } from "@opengeni/core"; // The get. install-serving routes. These are // UNAUTHENTICATED (see http/auth.ts isAuthExempt — the `installExemptPaths` set) // so a fresh machine with no credentials can `curl -fsSL https://get./install.sh`, // read it first, then pipe to sh. They serve the IN-REPO committed script bodies // (agent/install/*) verbatim — a single branded, audit-greppable trust root. // // The script BODIES contain NO secrets (POSIX sh, the device-flow captures the // loud consent). The release-binary asset routes serve the agent BAKED into THIS // control-plane image (the per-SHA Linux musl binary + its `.sha256`/`.minisig`) // when present, and otherwise 302-redirect to the matching GitHub Release asset. // // "The agent ships inside the control-plane" (the owned decision): for every // deployed env (preview/staging/managed-prod) the API image — already built // per-SHA by GitHub Actions from the PR branch — bakes the SIGNED `opengeni-agent` // binary matching that EXACT control-plane SHA into agent/install/baked/ (a CI step // signs + COPYs; the signing key never enters the Docker build). install.sh then // pulls a binary that is guaranteed in lockstep with the API it enrolls against — // zero drift, zero new store. GitHub Releases remains the PUBLIC archive + the // self-update channel + the install.sh fallback (mac/windows, and any asset this // image did not bake), reached by the 302 below. // The committed install artifacts, resolved relative to this module so the API // (run from source under /app via bun) locates the sibling agent/install/ dir at // runtime. apps/api/src/routes -> ../../../../agent/install. const INSTALL_DIR = [ new URL("./assets/agent-install/", import.meta.url), new URL("../../../../agent/install/", import.meta.url), ].find((candidate) => existsSync(candidate)) ?? new URL("../../../../agent/install/", import.meta.url); // The baked release-binary dir (a sibling of the committed scripts). The build's // signing step writes the per-SHA Linux musl binaries + their `.sha256`/`.minisig` // siblings here; in a plain `docker build` (or a source checkout) it holds only a // `.gitkeep`, so every asset falls through to the GitHub-Releases redirect. const BAKED_DIR = new URL("baked/", INSTALL_DIR); // The static text artifacts served verbatim, with their content types. Each is // read once at first request and memoized (committed files; immutable per deploy). const TEXT_ASSETS: Record = { "/install.sh": { file: "install.sh", contentType: "text/x-shellscript; charset=utf-8", }, "/install.ps1": { file: "install.ps1", contentType: "text/plain; charset=utf-8", }, "/uninstall.sh": { file: "uninstall.sh", contentType: "text/x-shellscript; charset=utf-8", }, "/opengeni-agent-minisign.pub": { file: "opengeni-agent-minisign.pub", contentType: "text/plain; charset=utf-8", }, }; // Non-executable resources needed to assemble a complete platform bundle. These // stay committed beside the installer rather than depending on a release/CDN so // an exact-SHA local or private deployment remains self-contained. const COMMITTED_BINARY_ASSETS: Record = { "OpenGeni-Agent.icns": { file: "OpenGeni-Agent.icns", contentType: "image/icns", }, }; const assetCache = new Map(); async function loadAsset(file: string): Promise { const cached = assetCache.get(file); if (cached !== undefined) { return cached; } const body = await readFile(new URL(file, INSTALL_DIR), "utf8"); assetCache.set(file, body); return body; } // "The agent ships inside the control-plane" (cont.): the committed install // scripts default their release-asset base URL to the public archive // (get.opengeni.ai) so a from-source / standalone copy still works. But a // DEPLOYED control plane must serve a script that pulls the agent from ITSELF — // the per-SHA binary baked into THIS image, via the /agent/* routes below — so // `curl https:///install.sh | sh` installs the exact agent that // matches the API it enrolls against, with NO dependency on a public CDN (which a // private/air-gapped deploy may not even resolve). When a public base URL is // configured we therefore rewrite each script's release-asset AND connection-API // default markers to that origin before serving. The user's // OPENGENI_INSTALL_BASE_URL / OPENGENI_API_URL overrides still win at run time; // only the built-in defaults change. The marker lines are kept shape-stable in // agent/install/install.{sh,ps1}. type DefaultRewrite = { from: string; to: string }; const DEFAULT_BASE_REWRITES: Record DefaultRewrite[]> = { "install.sh": (base) => [ { from: 'OPENGENI_INSTALL_DEFAULT_BASE_URL="https://get.opengeni.ai"', to: `OPENGENI_INSTALL_DEFAULT_BASE_URL="${base}"`, }, { from: 'OPENGENI_API_DEFAULT_URL="https://app.opengeni.ai"', to: `OPENGENI_API_DEFAULT_URL="${base}"`, }, ], "install.ps1": (base) => [ { from: "$OpengeniInstallDefaultBaseUrl = 'https://get.opengeni.ai'", to: `$OpengeniInstallDefaultBaseUrl = '${base}'`, }, { from: "$OpengeniApiDefaultUrl = 'https://app.opengeni.ai'", to: `$OpengeniApiDefaultUrl = '${base}'`, }, ], }; // Rewrite a served script's default release-asset and connection-API URLs to this // deployment's own public origin. A no-op when no public base URL is configured, // when the URL is not absolute http(s), or when either marker is absent (script // drift — fail safe to serving it verbatim rather than splitting the two origins). function rewriteDefaultBaseUrl( file: string, body: string, publicBaseUrl: string | undefined, ): string { if (!publicBaseUrl || !/^https?:\/\//.test(publicBaseUrl)) { return body; } const base = publicBaseUrl.replace(/\/+$/, ""); const rules = DEFAULT_BASE_REWRITES[file]?.(base); if (!rules || rules.some((rule) => !body.includes(rule.from))) { return body; } return rules.reduce((rewritten, rule) => rewritten.replace(rule.from, rule.to), body); } // The content type for a baked release asset. The binary is an // application/octet-stream download; its `.sha256`/`.minisig` sidecars are short // text the install script parses line-by-line. function bakedContentType(asset: string): string { if (asset.endsWith(".sha256") || asset.endsWith(".minisig")) { return "text/plain; charset=utf-8"; } return "application/octet-stream"; } // Read a baked asset's bytes, or `null` when it is not baked into THIS image (the // signal to fall through to the GitHub-Releases redirect). The asset name is // already validated against ASSET_NAME by the caller (no traversal possible), and // BAKED_DIR is a fixed sibling, so the resolved path cannot escape the dir. async function readBaked(asset: string): Promise { const url = new URL(asset, BAKED_DIR); try { const info = await stat(url); if (!info.isFile()) { return null; } } catch { return null; } // Return a standalone ArrayBuffer (a valid Response BodyInit). `readFile`'s // Buffer may be a view into a larger pooled allocation, so copy out the exact // byte range with `.slice` rather than handing over the backing store. const buf = await readFile(url); return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } // `/agent/latest/` and `/agent/v/` (+ the `.sha256` / `.minisig` // siblings the install script fetches) — see agent/install/install.sh asset_url(). // `` and the version segment are constrained so the redirect cannot be used // as an open redirector: only the agent asset-name shape + a `v`-prefixed version. const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; // The install script's version path segment is the literal `v` (e.g. v1.2.3). const VERSION_SEG = /^v[A-Za-z0-9][A-Za-z0-9._-]*$/; export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void { const releasesBase = deps.settings.agentReleasesBaseUrl.replace(/\/+$/, ""); const stableAgentTag = `agent-v${deps.settings.agentStableVersion}`; for (const [path, { file, contentType }] of Object.entries(TEXT_ASSETS)) { app.get(path, async (c) => { // Serve the committed body, but rewrite the install scripts' default // asset base URL to THIS deployment's origin so the agent is pulled from // the same control plane it enrolls against (see rewriteDefaultBaseUrl). const body = rewriteDefaultBaseUrl(file, await loadAsset(file), deps.settings.publicBaseUrl); return c.text(body, 200, { "content-type": contentType, // Short cache: the edge serves the latest committed copy; new installs // should pick up script fixes promptly, but a brief cache absorbs bursts. "cache-control": "public, max-age=300", }); }); } // Serve a release-binary asset: the BAKED per-SHA file if THIS image carries it // (the Linux musl binary + sidecars that match the control plane exactly), // otherwise 302 to the GitHub Release at `redirectUrl` (mac/windows + any // un-baked asset). The baked path makes the agent the API enrolls against // identical to the API that serves it — no version skew, no extra hop. async function serveAsset(asset: string, redirectUrl: string): Promise { const committed = COMMITTED_BINARY_ASSETS[asset]; if (committed) { const buf = await readFile(new URL(committed.file, INSTALL_DIR)); return new Response(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), { status: 200, headers: { "content-type": committed.contentType, "cache-control": "public, max-age=3600", "x-opengeni-agent-source": "committed", }, }); } const baked = await readBaked(asset); if (baked !== null) { return new Response(baked, { status: 200, headers: { "content-type": bakedContentType(asset), // The baked artifact is immutable for this image SHA: the binary, its // checksum, and its signature never change once built. A long cache is // safe; new installs land when a new image (new SHA) rolls out. "cache-control": "public, max-age=3600", "x-opengeni-agent-source": "baked", }, }); } // Not baked here → the GitHub Release is the source of truth (the public // archive + the documented install.sh fallback). 302 so the client refetches. return new Response(null, { status: 302, headers: { location: redirectUrl }, }); } // `latest` → the BAKED binary if present, else the operator-selected immutable // `agent-v` release. We deliberately do NOT use GitHub's repo-global // `releases/latest` alias here: in this monorepo that alias is shadowed by // frequent changesets package releases, which carry no agent binaries. The // explicit stable-version setting also makes promotion and rollback auditable // without moving or deleting a provider tag. app.get("/agent/latest/:asset", async (c) => { const asset = c.req.param("asset"); if (!ASSET_NAME.test(asset)) { throw new HTTPException(400, { message: "invalid asset name" }); } return serveAsset(asset, `${releasesBase}/download/${stableAgentTag}/${asset}`); }); // Signed self-update channel manifests. Each deployment exposes its own // immutable promotion pointer, so an enrolled machine never depends on the // public get.opengeni.ai hostname. The manifest and signature are ordinary // assets on the selected immutable agent release. for (const [channel, version] of [ ["stable", deps.settings.agentStableVersion], ["beta", deps.settings.agentBetaVersion], ] as const) { app.get(`/agent/${channel}/:manifestAsset`, (c) => { const manifestAsset = c.req.param("manifestAsset"); if (manifestAsset !== "manifest.json" && manifestAsset !== "manifest.json.minisig") { throw new HTTPException(404, { message: "update manifest asset not found", }); } if (!version) { throw new HTTPException(404, { message: `${channel} agent channel is not configured`, }); } return new Response(null, { status: 302, headers: { location: `${releasesBase}/download/agent-v${version}/${manifestAsset}`, }, }); }); } // The version segment is the literal `v` (e.g. `v1.2.3`) — Hono cannot bind // a param glued to a literal prefix, so the whole segment is the param and the // `v` prefix is validated/stripped here. The release tag is `agent-v`. app.get("/agent/:versionSeg/:asset", async (c) => { const versionSeg = c.req.param("versionSeg"); const asset = c.req.param("asset"); // `/agent/latest/` is handled by the more specific route above; any // other version segment must be the `v` shape. if (!VERSION_SEG.test(versionSeg) || !ASSET_NAME.test(asset)) { throw new HTTPException(400, { message: "invalid version or asset name", }); } return serveAsset(asset, `${releasesBase}/download/agent-${versionSeg}/${asset}`); }); } // The path prefixes/exact paths the install routes own — exported so the auth // middleware can exempt them (they must be reachable with no credentials). export const installExactPaths: ReadonlySet = new Set(Object.keys(TEXT_ASSETS)); export function isInstallRedirectPath(path: string): boolean { return ( path.startsWith("/agent/latest/") || path.startsWith("/agent/v") || path.startsWith("/agent/stable/") || path.startsWith("/agent/beta/") ); }