Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

server-targets.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

server-targets.tsBlame339 lines · 1 contributor
783dd46Claude1/**
2 * Server-target driver (Block ST).
3 *
4 * Drives the SSH/scp subprocess pipeline that takes a `server_targets` row
5 * and either tests the connection or runs a deploy on the box. All process
6 * spawning is funnelled through a single injectable seam (`__setSpawnForTests`)
7 * so the test suite can drive the lib without actually shelling out.
8 *
9 * Design notes:
10 * - We use the host's `ssh`/`scp` CLI rather than a Node SSH library. It
11 * keeps the surface tiny (no new deps) and lets the operator manage
12 * ciphers/algorithms via /etc/ssh/ssh_config like any other server tool.
13 * - The private key is materialised into a Bun temp file with mode 0600,
14 * used for the call, then unlinked in a `finally`. Never touches /tmp
15 * persistently and never sits on disk longer than the call.
16 * - Host-key verification uses TOFU: on first connect (status='unverified'
17 * and no host_fingerprint) we accept-new and record the fingerprint.
18 * Subsequent connects pin against that fingerprint. A mismatch aborts.
19 *
20 * Public surface:
21 * - `testConnection(target)` → returns ok/fail + pinned fingerprint
22 * - `deployToTarget(target, ctx)`→ uploads env, runs deploy_script
23 * - `materializeEnv(env)` → render env-vars map to a .env body
24 */
25
26import { mkdtemp, writeFile, rm } from "fs/promises";
27import { tmpdir } from "os";
28import path from "path";
29import { decryptValue, renderDotenv } from "./server-targets-crypto";
30import type { ServerTarget } from "../db/schema";
31
32export interface SpawnResult {
33 exitCode: number;
34 stdout: string;
35 stderr: string;
36}
37
38/**
39 * Subprocess seam. Production calls `Bun.spawn`; tests override.
40 *
41 * Returns the captured stdout/stderr + exit code. `stdin` is optional; when
42 * provided, it's written and closed before the process is awaited so we can
43 * pipe a .env body in without a temp file.
44 */
45export type SpawnFn = (
46 cmd: string[],
47 opts: { cwd?: string; stdin?: string; env?: Record<string, string> }
48) => Promise<SpawnResult>;
49
50const defaultSpawn: SpawnFn = async (cmd, opts) => {
51 const proc = Bun.spawn(cmd, {
52 cwd: opts.cwd,
53 env: { ...process.env, ...(opts.env || {}) },
54 stdin: opts.stdin ? "pipe" : "ignore",
55 stdout: "pipe",
56 stderr: "pipe",
57 });
58 if (opts.stdin) {
59 proc.stdin?.write(opts.stdin);
60 proc.stdin?.end();
61 }
62 const [stdout, stderr] = await Promise.all([
63 new Response(proc.stdout).text(),
64 new Response(proc.stderr).text(),
65 ]);
66 const exitCode = await proc.exited;
67 return { exitCode, stdout, stderr };
68};
69
70let _spawn: SpawnFn = defaultSpawn;
71export function __setSpawnForTests(fn: SpawnFn | null): void {
72 _spawn = fn ?? defaultSpawn;
73}
74
75/**
76 * Run a fn with the decrypted SSH private key written to a 0600 file in a
77 * private temp dir. The dir is rm -rf'd in `finally`, no matter what.
78 */
79async function withKeyFile<T>(
80 encryptedKey: string,
81 fn: (keyPath: string) => Promise<T>
82): Promise<T> {
83 const dec = decryptValue(encryptedKey);
84 if (!dec.ok) {
85 throw new Error(`decrypt private key: ${dec.error}`);
86 }
87 const dir = await mkdtemp(path.join(tmpdir(), "gluecron-st-"));
88 const keyPath = path.join(dir, "id");
89 await writeFile(keyPath, dec.plaintext, { mode: 0o600 });
90 try {
91 return await fn(keyPath);
92 } finally {
93 await rm(dir, { recursive: true, force: true }).catch(() => {});
94 }
95}
96
97/**
98 * Build the common `ssh` arg list. On a target with no pinned fingerprint we
99 * pass `accept-new`; once pinned we require strict host-key checking via the
100 * known-hosts file we materialise alongside the key.
101 *
102 * Returns the args up to (but not including) the remote command — the
103 * caller appends `[user@host, ...cmd]` or scp paths.
104 */
105function sshBaseArgs(target: ServerTarget, keyPath: string): string[] {
106 const knownHostsArg = target.hostFingerprint
107 ? // Pinned: hand ssh a known_hosts line via -o KnownHostsCommand-ish
108 // bypass — easier route is a temp file passed via UserKnownHostsFile.
109 // We can't easily inline a fingerprint without the full key, so we
110 // fall back to strict checking against a per-target known_hosts file
111 // written next to the key. With no host_fingerprint we accept-new.
112 ["-o", "StrictHostKeyChecking=yes"]
113 : ["-o", "StrictHostKeyChecking=accept-new"];
114 return [
115 "-i",
116 keyPath,
117 "-o",
118 "BatchMode=yes",
119 "-o",
120 "ConnectTimeout=10",
121 "-o",
122 `UserKnownHostsFile=${path.join(path.dirname(keyPath), "known_hosts")}`,
123 ...knownHostsArg,
124 "-p",
125 String(target.port),
126 ];
127}
128
129/**
130 * Test connectivity. Returns the host fingerprint (SHA256 of the host key)
131 * captured via `ssh-keyscan` so the caller can pin it on the row.
132 *
133 * Flow:
134 * 1. ssh-keyscan -p <port> <host> → host key text
135 * 2. ssh-keygen -lf <keyfile> → SHA256:<fp> fingerprint
136 * 3. ssh -i <key> user@host 'echo gluecron' → confirms key works
137 *
138 * Any step's non-zero exit collapses to {ok:false}. Never throws.
139 */
140export async function testConnection(
141 target: ServerTarget
142): Promise<
143 | { ok: true; fingerprint: string }
144 | { ok: false; error: string; stage: "scan" | "fingerprint" | "auth" }
145> {
146 try {
147 return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => {
148 const dir = path.dirname(keyPath);
149 const scan = await _spawn(
150 ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host],
151 {}
152 );
153 if (scan.exitCode !== 0 || !scan.stdout.trim()) {
154 return {
155 ok: false as const,
156 error: scan.stderr.trim() || "ssh-keyscan returned nothing",
157 stage: "scan" as const,
158 };
159 }
160 const knownHostsPath = path.join(dir, "known_hosts");
161 await writeFile(knownHostsPath, scan.stdout);
162
163 const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {});
164 if (fp.exitCode !== 0) {
165 return {
166 ok: false as const,
167 error: fp.stderr.trim() || "ssh-keygen failed",
168 stage: "fingerprint" as const,
169 };
170 }
171 // Output format: "<bits> SHA256:<base64> comment (TYPE)"
172 const match = fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/);
173 const fingerprint = match ? match[0] : fp.stdout.trim().split(/\s+/)[1] || "";
174
175 const probe = await _spawn(
176 [
177 "ssh",
178 ...sshBaseArgs(target, keyPath),
179 `${target.sshUser}@${target.host}`,
180 "echo gluecron-ok",
181 ],
182 {}
183 );
184 if (probe.exitCode !== 0 || !probe.stdout.includes("gluecron-ok")) {
185 return {
186 ok: false as const,
187 error: probe.stderr.trim() || "ssh probe failed",
188 stage: "auth" as const,
189 };
190 }
191 return { ok: true as const, fingerprint };
192 });
193 } catch (err) {
194 return {
195 ok: false,
196 error: err instanceof Error ? err.message : String(err),
197 stage: "auth",
198 };
199 }
200}
201
202export interface DeployContext {
203 commitSha?: string;
204 ref?: string;
205 /** Decrypted env-var map (KEY → value). Render to .env before upload. */
206 env: Record<string, string>;
207}
208
209export interface DeployResult {
210 ok: boolean;
211 exitCode: number;
212 stdout: string;
213 stderr: string;
214}
215
216/**
217 * Run the deploy on the target box.
218 *
219 * Sequence:
220 * 1. Write env map → /tmp/gluecron-<rand>.env (locally), scp to
221 * `<deploy_path>/.env.gluecron` on the box (0600 via umask).
222 * 2. ssh user@host 'cd <deploy_path> && set -a && . ./.env.gluecron \
223 * && set +a && <deploy_script>'
224 * 3. Capture stdout+stderr+exit and return.
225 *
226 * Never throws — every failure is folded into the DeployResult with the
227 * stderr line that explains why. Caller is responsible for inserting the
228 * `server_target_deployments` row.
229 */
230export async function deployToTarget(
231 target: ServerTarget,
232 ctx: DeployContext
233): Promise<DeployResult> {
234 try {
235 return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => {
236 const dir = path.dirname(keyPath);
237
238 // Materialise known_hosts up front so we can run ssh in strict mode
239 // for a target that's already been pinned.
240 if (target.hostFingerprint) {
241 // We don't have the raw host key, only the fingerprint, so we
242 // re-scan and verify the fingerprint matches before writing the
243 // known_hosts file. A mismatch is a hard abort.
244 const scan = await _spawn(
245 ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host],
246 {}
247 );
248 if (scan.exitCode !== 0 || !scan.stdout.trim()) {
249 return {
250 ok: false,
251 exitCode: scan.exitCode,
252 stdout: "",
253 stderr: `ssh-keyscan: ${scan.stderr.trim() || "no host key returned"}`,
254 };
255 }
256 const knownHostsPath = path.join(dir, "known_hosts");
257 await writeFile(knownHostsPath, scan.stdout);
258 const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {});
259 const live = (fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/) || [""])[0];
260 if (!live || live !== target.hostFingerprint) {
261 return {
262 ok: false,
263 exitCode: 255,
264 stdout: "",
265 stderr: `host key fingerprint mismatch — pinned=${target.hostFingerprint} live=${live || "<none>"}. Aborting deploy.`,
266 };
267 }
268 }
269
270 const dotenv = renderDotenv(ctx.env);
271 const envPath = path.join(dir, "deploy.env");
272 await writeFile(envPath, dotenv, { mode: 0o600 });
273
274 // scp the .env up. The remote path is fixed for predictability.
275 const remoteEnv = `${target.deployPath.replace(/\/$/, "")}/.env.gluecron`;
276 const scp = await _spawn(
277 [
278 "scp",
279 ...sshBaseArgs(target, keyPath),
280 envPath,
281 `${target.sshUser}@${target.host}:${remoteEnv}`,
282 ],
283 {}
284 );
285 if (scp.exitCode !== 0) {
286 return {
287 ok: false,
288 exitCode: scp.exitCode,
289 stdout: scp.stdout,
290 stderr: `scp env: ${scp.stderr.trim()}`,
291 };
292 }
293
294 const commitExport = ctx.commitSha
295 ? `export GLUECRON_COMMIT_SHA='${ctx.commitSha.replace(/'/g, "")}'; `
296 : "";
297 const refExport = ctx.ref
298 ? `export GLUECRON_REF='${ctx.ref.replace(/'/g, "")}'; `
299 : "";
300 const remoteCmd =
301 `cd '${target.deployPath.replace(/'/g, "'\\''")}' && ` +
302 `set -a && . ./.env.gluecron && set +a && ` +
303 commitExport +
304 refExport +
305 target.deployScript;
306
307 const run = await _spawn(
308 [
309 "ssh",
310 ...sshBaseArgs(target, keyPath),
311 `${target.sshUser}@${target.host}`,
312 remoteCmd,
313 ],
314 {}
315 );
316 return {
317 ok: run.exitCode === 0,
318 exitCode: run.exitCode,
319 stdout: run.stdout,
320 stderr: run.stderr,
321 };
322 });
323 } catch (err) {
324 return {
325 ok: false,
326 exitCode: -1,
327 stdout: "",
328 stderr: err instanceof Error ? err.message : String(err),
329 };
330 }
331}
332
333export { renderDotenv } from "./server-targets-crypto";
334
335/** Test-only access to internal seams. */
336export const __test = {
337 sshBaseArgs,
338 withKeyFile,
339};