Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit783dd46unknown_key

feat(server-targets): admin-only SSH deploy targets with encrypted env vars

feat(server-targets): admin-only SSH deploy targets with encrypted env vars

New section at /admin/servers for registering boxes Gluecron can SSH into.
Each target stores an encrypted SSH private key (AES-256-GCM via the new
SERVER_TARGETS_KEY), a TOFU-pinned host fingerprint, an optional repo+branch
to watch, and a per-target list of env vars. Push to a watched branch
materialises the env vars as ./.env.gluecron on the box, sources it, then
runs the configured deploy script. Same flow available as a manual
"Deploy now" button. Customer-facing scoping arrives in a follow-up block.

- drizzle/0073_server_targets.sql: server_targets, server_target_env,
  server_target_deployments, server_target_audit
- src/lib/server-targets-crypto.ts: AES-256-GCM + isValidEnvName + renderDotenv
- src/lib/server-targets.ts: ssh/scp subprocess driver with mismatched-pin abort
- src/lib/server-target-store.ts: thin DB helpers + audit logging
- src/routes/admin-server-targets.tsx: list/new/detail/env/test/deploy/delete
- src/hooks/post-receive.ts: fan out pushes to matching targets, fire-and-forget
- 21 new tests covering crypto, dotenv rendering, SSH command shape,
  fingerprint-mismatch abort, missing-key cleanup
Claude committed on May 27, 2026Parent: 9b3a183
11 files changed+20000783dd463cbda1bec3b438f46059087e10fb253a1
11 changed files+2000−0
Modified.env.example+5−0View fileUnifiedSplit
7070VOYAGE_API_KEY=
7171# AES-256-GCM key (hex, 64 chars) for encrypting workflow secrets at rest.
7272WORKFLOW_SECRETS_KEY=
73# AES-256-GCM key (hex, 64 chars) for encrypting server-target SSH private
74# keys and per-target env vars at rest (Block ST — /admin/servers).
75# Generate with: openssl rand -hex 32. If unset, all server-target
76# operations refuse cleanly (no panics, no plaintext fallbacks).
77SERVER_TARGETS_KEY=
7378# Set to "production" or "test" to override NODE_ENV for Bun.
7479BUN_ENV=
7580# Injected at build time; the deployed git commit SHA for the platform-status endpoint.
Addeddrizzle/0073_server_targets.sql+104−0View fileUnifiedSplit
1-- Gluecron migration 0073: server targets (Block ST).
2--
3-- Admin-only first-class concept for "boxes Gluecron can push deploys to".
4-- A target owns:
5-- - SSH connection info (host, user, port, encrypted private key)
6-- - A host-key fingerprint pinned on first successful connection (TOFU)
7-- - A deploy_script that runs on the box when a watched branch is pushed
8-- - A set of env vars (server_target_env) materialised as a .env file
9-- uploaded before each deploy and sourced by the script
10--
11-- Customer-facing rollout (Block 2) reuses these tables with the addition
12-- of owner_user_id scoping + an `auth_method` enum. v1 is admin-only:
13-- created_by tracks the operator who registered the target.
14--
15-- Wrapped in DO blocks so partial replays are safe.
16
17--> statement-breakpoint
18DO $$
19BEGIN
20 CREATE TABLE IF NOT EXISTS "server_targets" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22 "name" text NOT NULL,
23 "host" text NOT NULL,
24 "port" integer NOT NULL DEFAULT 22,
25 "ssh_user" text NOT NULL,
26 "encrypted_private_key" text NOT NULL,
27 "host_fingerprint" text,
28 "deploy_path" text NOT NULL DEFAULT '/var/www/app',
29 "deploy_script" text NOT NULL DEFAULT 'bash deploy.sh',
30 "watched_repository_id" uuid REFERENCES "repositories"("id") ON DELETE SET NULL,
31 "watched_branch" text,
32 "status" text NOT NULL DEFAULT 'unverified',
33 "last_seen_at" timestamptz,
34 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
35 "created_at" timestamptz NOT NULL DEFAULT now(),
36 "updated_at" timestamptz NOT NULL DEFAULT now()
37 );
38 CREATE UNIQUE INDEX IF NOT EXISTS "server_targets_name_uq" ON "server_targets"("name");
39 CREATE INDEX IF NOT EXISTS "server_targets_watch_idx"
40 ON "server_targets"("watched_repository_id", "watched_branch")
41 WHERE "watched_repository_id" IS NOT NULL;
42EXCEPTION WHEN OTHERS THEN
43 RAISE NOTICE 'server_targets create failed (%); feature will be unavailable', SQLERRM;
44END $$;
45
46--> statement-breakpoint
47DO $$
48BEGIN
49 CREATE TABLE IF NOT EXISTS "server_target_env" (
50 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51 "target_id" uuid NOT NULL REFERENCES "server_targets"("id") ON DELETE CASCADE,
52 "name" text NOT NULL,
53 "encrypted_value" text NOT NULL,
54 "is_secret" boolean NOT NULL DEFAULT true,
55 "updated_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
56 "created_at" timestamptz NOT NULL DEFAULT now(),
57 "updated_at" timestamptz NOT NULL DEFAULT now()
58 );
59 CREATE UNIQUE INDEX IF NOT EXISTS "server_target_env_uq"
60 ON "server_target_env"("target_id", "name");
61EXCEPTION WHEN OTHERS THEN
62 RAISE NOTICE 'server_target_env create failed (%);', SQLERRM;
63END $$;
64
65--> statement-breakpoint
66DO $$
67BEGIN
68 CREATE TABLE IF NOT EXISTS "server_target_deployments" (
69 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
70 "target_id" uuid NOT NULL REFERENCES "server_targets"("id") ON DELETE CASCADE,
71 "commit_sha" text,
72 "ref" text,
73 "status" text NOT NULL DEFAULT 'pending',
74 "exit_code" integer,
75 "stdout" text,
76 "stderr" text,
77 "triggered_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
78 "trigger_source" text NOT NULL DEFAULT 'push',
79 "started_at" timestamptz NOT NULL DEFAULT now(),
80 "finished_at" timestamptz
81 );
82 CREATE INDEX IF NOT EXISTS "server_target_deployments_target_idx"
83 ON "server_target_deployments"("target_id", "started_at" DESC);
84EXCEPTION WHEN OTHERS THEN
85 RAISE NOTICE 'server_target_deployments create failed (%);', SQLERRM;
86END $$;
87
88--> statement-breakpoint
89DO $$
90BEGIN
91 CREATE TABLE IF NOT EXISTS "server_target_audit" (
92 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
93 "target_id" uuid REFERENCES "server_targets"("id") ON DELETE SET NULL,
94 "actor_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
95 "action" text NOT NULL,
96 "detail" text,
97 "ip" text,
98 "created_at" timestamptz NOT NULL DEFAULT now()
99 );
100 CREATE INDEX IF NOT EXISTS "server_target_audit_target_idx"
101 ON "server_target_audit"("target_id", "created_at" DESC);
102EXCEPTION WHEN OTHERS THEN
103 RAISE NOTICE 'server_target_audit create failed (%);', SQLERRM;
104END $$;
Addedsrc/__tests__/server-targets-crypto.test.ts+135−0View fileUnifiedSplit
1/**
2 * Pure-function tests for src/lib/server-targets-crypto.ts.
3 *
4 * Mirror of the workflow-secrets-crypto suite: round-trip, IV randomness,
5 * tamper detection, env-name validation, dotenv rendering.
6 */
7
8import { describe, it, expect, afterEach } from "bun:test";
9import {
10 encryptValue,
11 decryptValue,
12 getMasterKey,
13 isValidEnvName,
14 renderDotenv,
15} from "../lib/server-targets-crypto";
16
17const TEST_KEY =
18 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
19const original = process.env.SERVER_TARGETS_KEY;
20
21afterEach(() => {
22 if (original === undefined) delete process.env.SERVER_TARGETS_KEY;
23 else process.env.SERVER_TARGETS_KEY = original;
24});
25
26describe("getMasterKey", () => {
27 it("returns null when env is unset", () => {
28 delete process.env.SERVER_TARGETS_KEY;
29 expect(getMasterKey()).toBeNull();
30 });
31
32 it("returns null when env is not 32 bytes", () => {
33 process.env.SERVER_TARGETS_KEY = "abcd";
34 expect(getMasterKey()).toBeNull();
35 });
36
37 it("returns 32-byte buffer when env is valid hex", () => {
38 process.env.SERVER_TARGETS_KEY = TEST_KEY;
39 const key = getMasterKey();
40 expect(key).not.toBeNull();
41 expect(key!.length).toBe(32);
42 });
43
44 it("ignores non-hex input", () => {
45 process.env.SERVER_TARGETS_KEY = "zzzz".repeat(16);
46 expect(getMasterKey()).toBeNull();
47 });
48});
49
50describe("encrypt/decrypt round-trip", () => {
51 it("recovers the original plaintext", () => {
52 process.env.SERVER_TARGETS_KEY = TEST_KEY;
53 const enc = encryptValue("hello-server-target");
54 expect(enc.ok).toBe(true);
55 if (!enc.ok) return;
56 const dec = decryptValue(enc.ciphertext);
57 expect(dec.ok).toBe(true);
58 if (!dec.ok) return;
59 expect(dec.plaintext).toBe("hello-server-target");
60 });
61
62 it("uses a fresh IV per encryption so identical plaintexts diverge", () => {
63 process.env.SERVER_TARGETS_KEY = TEST_KEY;
64 const a = encryptValue("same");
65 const b = encryptValue("same");
66 expect(a.ok && b.ok).toBe(true);
67 if (!a.ok || !b.ok) return;
68 expect(a.ciphertext).not.toBe(b.ciphertext);
69 });
70
71 it("rejects encrypt when key missing", () => {
72 delete process.env.SERVER_TARGETS_KEY;
73 const enc = encryptValue("anything");
74 expect(enc.ok).toBe(false);
75 });
76
77 it("detects tampered ciphertext via GCM auth tag", () => {
78 process.env.SERVER_TARGETS_KEY = TEST_KEY;
79 const enc = encryptValue("payload");
80 if (!enc.ok) throw new Error("setup failed");
81 // Flip a byte inside the ciphertext portion (after IV+tag) — base64
82 // decode → mutate → re-encode.
83 const buf = Buffer.from(enc.ciphertext, "base64");
84 buf[buf.length - 1] ^= 0xff;
85 const tampered = buf.toString("base64");
86 const dec = decryptValue(tampered);
87 expect(dec.ok).toBe(false);
88 });
89
90 it("rejects too-short blob", () => {
91 process.env.SERVER_TARGETS_KEY = TEST_KEY;
92 const dec = decryptValue(Buffer.from("short").toString("base64"));
93 expect(dec.ok).toBe(false);
94 });
95});
96
97describe("isValidEnvName", () => {
98 it("accepts conventional env names", () => {
99 expect(isValidEnvName("FOO")).toBe(true);
100 expect(isValidEnvName("FOO_BAR")).toBe(true);
101 expect(isValidEnvName("_PRIVATE")).toBe(true);
102 expect(isValidEnvName("A1_B2")).toBe(true);
103 });
104
105 it("rejects lowercase, dashes, leading digits, empty", () => {
106 expect(isValidEnvName("foo")).toBe(false);
107 expect(isValidEnvName("FOO-BAR")).toBe(false);
108 expect(isValidEnvName("1FOO")).toBe(false);
109 expect(isValidEnvName("")).toBe(false);
110 expect(isValidEnvName(undefined)).toBe(false);
111 expect(isValidEnvName(null)).toBe(false);
112 });
113});
114
115describe("renderDotenv", () => {
116 it("renders alphabetised KEY='value' lines", () => {
117 const out = renderDotenv({ BAR: "two", FOO: "one" });
118 expect(out).toBe("BAR='two'\nFOO='one'\n");
119 });
120
121 it("escapes embedded single quotes", () => {
122 const out = renderDotenv({ FOO: "a'b" });
123 // a'b → 'a'\''b' — POSIX single-quote escape.
124 expect(out).toBe("FOO='a'\\''b'\n");
125 });
126
127 it("returns empty string for empty map", () => {
128 expect(renderDotenv({})).toBe("");
129 });
130
131 it("never leaves a value unquoted", () => {
132 const out = renderDotenv({ KEY: "has spaces and $vars" });
133 expect(out).toBe("KEY='has spaces and $vars'\n");
134 });
135});
Addedsrc/__tests__/server-targets.test.ts+176−0View fileUnifiedSplit
1/**
2 * Driver-level tests for src/lib/server-targets.ts.
3 *
4 * We never actually shell out — `__setSpawnForTests` lets us inspect the
5 * command line the driver builds and feed back canned exit codes. Covers:
6 * - testConnection happy path (returns SHA256 fingerprint)
7 * - deployToTarget happy path (env file is materialised + sourced)
8 * - host-key fingerprint mismatch aborts the deploy
9 * - missing SERVER_TARGETS_KEY collapses to ok:false (no throw)
10 */
11
12import { describe, it, expect, afterEach, beforeEach } from "bun:test";
13import {
14 __setSpawnForTests,
15 deployToTarget,
16 testConnection,
17 type SpawnResult,
18} from "../lib/server-targets";
19import { encryptValue } from "../lib/server-targets-crypto";
20import type { ServerTarget } from "../db/schema";
21
22const TEST_KEY =
23 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
24const HOST_FP = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
25
26const originalKey = process.env.SERVER_TARGETS_KEY;
27
28beforeEach(() => {
29 process.env.SERVER_TARGETS_KEY = TEST_KEY;
30});
31
32afterEach(() => {
33 __setSpawnForTests(null);
34 if (originalKey === undefined) delete process.env.SERVER_TARGETS_KEY;
35 else process.env.SERVER_TARGETS_KEY = originalKey;
36});
37
38function buildTarget(overrides: Partial<ServerTarget> = {}): ServerTarget {
39 const enc = encryptValue("-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n");
40 if (!enc.ok) throw new Error("encrypt failed in test setup: " + enc.error);
41 return {
42 id: "00000000-0000-0000-0000-000000000001",
43 name: "test-box",
44 host: "192.0.2.10",
45 port: 22,
46 sshUser: "deploy",
47 encryptedPrivateKey: enc.ciphertext,
48 hostFingerprint: null,
49 deployPath: "/var/www/app",
50 deployScript: "echo deployed",
51 watchedRepositoryId: null,
52 watchedBranch: null,
53 status: "unverified",
54 lastSeenAt: null,
55 createdBy: null,
56 createdAt: new Date(),
57 updatedAt: new Date(),
58 ...overrides,
59 } as ServerTarget;
60}
61
62describe("testConnection", () => {
63 it("returns the fingerprint and ok=true on a clean run", async () => {
64 const calls: Array<string[]> = [];
65 __setSpawnForTests(async (cmd) => {
66 calls.push(cmd);
67 const [bin] = cmd;
68 if (bin === "ssh-keyscan") {
69 return ok("192.0.2.10 ssh-ed25519 AAAA...\n");
70 }
71 if (bin === "ssh-keygen") {
72 return ok(`256 ${HOST_FP} root@box (ED25519)\n`);
73 }
74 if (bin === "ssh") {
75 return ok("gluecron-ok\n");
76 }
77 return fail("unexpected cmd: " + cmd.join(" "));
78 });
79
80 const result = await testConnection(buildTarget());
81 expect(result.ok).toBe(true);
82 if (!result.ok) return;
83 expect(result.fingerprint).toBe(HOST_FP);
84 expect(calls.length).toBe(3);
85 expect(calls[0][0]).toBe("ssh-keyscan");
86 expect(calls[2][0]).toBe("ssh");
87 });
88
89 it("returns ok=false at the scan stage when ssh-keyscan fails", async () => {
90 __setSpawnForTests(async (cmd) => {
91 if (cmd[0] === "ssh-keyscan") return fail("Connection refused");
92 return ok("");
93 });
94 const result = await testConnection(buildTarget());
95 expect(result.ok).toBe(false);
96 if (result.ok) return;
97 expect(result.stage).toBe("scan");
98 });
99
100 it("returns ok=false at auth stage when ssh probe fails", async () => {
101 __setSpawnForTests(async (cmd) => {
102 if (cmd[0] === "ssh-keyscan") return ok("192.0.2.10 ssh-ed25519 AAAA\n");
103 if (cmd[0] === "ssh-keygen") return ok(`256 ${HOST_FP} (ED25519)\n`);
104 if (cmd[0] === "ssh") return fail("Permission denied (publickey)");
105 return fail("?");
106 });
107 const result = await testConnection(buildTarget());
108 expect(result.ok).toBe(false);
109 if (result.ok) return;
110 expect(result.stage).toBe("auth");
111 });
112});
113
114describe("deployToTarget", () => {
115 it("scp's the env file then ssh-runs the deploy script", async () => {
116 const calls: Array<string[]> = [];
117 __setSpawnForTests(async (cmd) => {
118 calls.push(cmd);
119 if (cmd[0] === "scp") return ok("");
120 if (cmd[0] === "ssh") return ok("done\n");
121 return fail("?");
122 });
123 const result = await deployToTarget(buildTarget(), {
124 env: { FOO: "1", BAR: "two" },
125 commitSha: "abc1234",
126 ref: "refs/heads/main",
127 });
128 expect(result.ok).toBe(true);
129 expect(result.exitCode).toBe(0);
130
131 // Two calls: scp, then ssh.
132 expect(calls.length).toBe(2);
133 expect(calls[0][0]).toBe("scp");
134 // scp target ends at deploy_path/.env.gluecron
135 const scpDst = calls[0][calls[0].length - 1];
136 expect(scpDst).toBe("deploy@192.0.2.10:/var/www/app/.env.gluecron");
137
138 expect(calls[1][0]).toBe("ssh");
139 const remoteCmd = calls[1][calls[1].length - 1];
140 expect(remoteCmd).toContain("cd '/var/www/app'");
141 expect(remoteCmd).toContain(". ./.env.gluecron");
142 expect(remoteCmd).toContain("export GLUECRON_COMMIT_SHA='abc1234'");
143 expect(remoteCmd).toContain("echo deployed");
144 });
145
146 it("aborts deploy when pinned fingerprint doesn't match live", async () => {
147 __setSpawnForTests(async (cmd) => {
148 if (cmd[0] === "ssh-keyscan") return ok("192.0.2.10 ssh-ed25519 AAAA\n");
149 if (cmd[0] === "ssh-keygen") return ok("256 SHA256:DIFFERENT (ED25519)\n");
150 return fail("should not get here");
151 });
152 const result = await deployToTarget(
153 buildTarget({ hostFingerprint: HOST_FP }),
154 { env: {} }
155 );
156 expect(result.ok).toBe(false);
157 expect(result.stderr).toContain("fingerprint mismatch");
158 });
159
160 it("collapses to ok=false when SERVER_TARGETS_KEY is missing", async () => {
161 const target = buildTarget(); // build while key is present so encrypt works
162 delete process.env.SERVER_TARGETS_KEY; // now drop it — decrypt must fail cleanly
163 const result = await deployToTarget(target, { env: {} });
164 expect(result.ok).toBe(false);
165 expect(result.exitCode).toBe(-1);
166 });
167});
168
169// ─── helpers ────────────────────────────────────────────────────────────────
170
171function ok(stdout: string): SpawnResult {
172 return { exitCode: 0, stdout, stderr: "" };
173}
174function fail(stderr: string): SpawnResult {
175 return { exitCode: 1, stdout: "", stderr };
176}
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
8787import adminRoutes from "./routes/admin";
8888import adminDeploysRoutes from "./routes/admin-deploys";
8989import adminDeploysPageRoutes from "./routes/admin-deploys-page";
90import adminServerTargetsRoutes from "./routes/admin-server-targets";
9091import adminOpsRoutes from "./routes/admin-ops";
9192import adminSelfHostRoutes from "./routes/admin-self-host";
9293import adminDiagnoseRoutes from "./routes/admin-diagnose";
564565app.route("/", adminAdvancementRoutes);
565566app.route("/", adminDeploysRoutes);
566567app.route("/", adminDeploysPageRoutes);
568app.route("/", adminServerTargetsRoutes);
567569// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
568570app.route("/", advisoriesRoutes);
569571app.route("/", aiChangelogRoutes);
Modifiedsrc/db/schema.ts+137−0View fileUnifiedSplit
38063806
38073807export type DevEnv = typeof devEnvs.$inferSelect;
38083808export type NewDevEnv = typeof devEnvs.$inferInsert;
3809
3810// ---------------------------------------------------------------------------
3811// Block ST — Server targets (drizzle/0073_server_targets.sql)
3812//
3813// Admin-managed remote boxes Gluecron can SSH into and run a deploy script
3814// on. Each target carries an encrypted private SSH key, a pinned host
3815// fingerprint, an optional repo+branch to watch, and a per-target list of
3816// env vars materialised on the box at deploy time. Customer-facing rollout
3817// is gated to a follow-up block (Block 2) that scopes by owner_user_id.
3818// ---------------------------------------------------------------------------
3819
3820export const serverTargets = pgTable(
3821 "server_targets",
3822 {
3823 id: uuid("id").primaryKey().defaultRandom(),
3824 name: text("name").notNull(),
3825 host: text("host").notNull(),
3826 port: integer("port").default(22).notNull(),
3827 sshUser: text("ssh_user").notNull(),
3828 encryptedPrivateKey: text("encrypted_private_key").notNull(),
3829 hostFingerprint: text("host_fingerprint"),
3830 deployPath: text("deploy_path").default("/var/www/app").notNull(),
3831 deployScript: text("deploy_script").default("bash deploy.sh").notNull(),
3832 watchedRepositoryId: uuid("watched_repository_id").references(
3833 () => repositories.id,
3834 { onDelete: "set null" }
3835 ),
3836 watchedBranch: text("watched_branch"),
3837 status: text("status").default("unverified").notNull(),
3838 lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
3839 createdBy: uuid("created_by").references(() => users.id, {
3840 onDelete: "set null",
3841 }),
3842 createdAt: timestamp("created_at", { withTimezone: true })
3843 .defaultNow()
3844 .notNull(),
3845 updatedAt: timestamp("updated_at", { withTimezone: true })
3846 .defaultNow()
3847 .notNull(),
3848 },
3849 (table) => [
3850 uniqueIndex("server_targets_name_uq").on(table.name),
3851 index("server_targets_watch_idx").on(
3852 table.watchedRepositoryId,
3853 table.watchedBranch
3854 ),
3855 ]
3856);
3857
3858export type ServerTarget = typeof serverTargets.$inferSelect;
3859export type NewServerTarget = typeof serverTargets.$inferInsert;
3860
3861export const serverTargetEnv = pgTable(
3862 "server_target_env",
3863 {
3864 id: uuid("id").primaryKey().defaultRandom(),
3865 targetId: uuid("target_id")
3866 .notNull()
3867 .references(() => serverTargets.id, { onDelete: "cascade" }),
3868 name: text("name").notNull(),
3869 encryptedValue: text("encrypted_value").notNull(),
3870 isSecret: boolean("is_secret").default(true).notNull(),
3871 updatedBy: uuid("updated_by").references(() => users.id, {
3872 onDelete: "set null",
3873 }),
3874 createdAt: timestamp("created_at", { withTimezone: true })
3875 .defaultNow()
3876 .notNull(),
3877 updatedAt: timestamp("updated_at", { withTimezone: true })
3878 .defaultNow()
3879 .notNull(),
3880 },
3881 (table) => [uniqueIndex("server_target_env_uq").on(table.targetId, table.name)]
3882);
3883
3884export type ServerTargetEnv = typeof serverTargetEnv.$inferSelect;
3885export type NewServerTargetEnv = typeof serverTargetEnv.$inferInsert;
3886
3887export const serverTargetDeployments = pgTable(
3888 "server_target_deployments",
3889 {
3890 id: uuid("id").primaryKey().defaultRandom(),
3891 targetId: uuid("target_id")
3892 .notNull()
3893 .references(() => serverTargets.id, { onDelete: "cascade" }),
3894 commitSha: text("commit_sha"),
3895 ref: text("ref"),
3896 status: text("status").default("pending").notNull(),
3897 exitCode: integer("exit_code"),
3898 stdout: text("stdout"),
3899 stderr: text("stderr"),
3900 triggeredBy: uuid("triggered_by").references(() => users.id, {
3901 onDelete: "set null",
3902 }),
3903 triggerSource: text("trigger_source").default("push").notNull(),
3904 startedAt: timestamp("started_at", { withTimezone: true })
3905 .defaultNow()
3906 .notNull(),
3907 finishedAt: timestamp("finished_at", { withTimezone: true }),
3908 },
3909 (table) => [
3910 index("server_target_deployments_target_idx").on(
3911 table.targetId,
3912 table.startedAt
3913 ),
3914 ]
3915);
3916
3917export type ServerTargetDeployment =
3918 typeof serverTargetDeployments.$inferSelect;
3919export type NewServerTargetDeployment =
3920 typeof serverTargetDeployments.$inferInsert;
3921
3922export const serverTargetAudit = pgTable(
3923 "server_target_audit",
3924 {
3925 id: uuid("id").primaryKey().defaultRandom(),
3926 targetId: uuid("target_id").references(() => serverTargets.id, {
3927 onDelete: "set null",
3928 }),
3929 actorId: uuid("actor_id").references(() => users.id, {
3930 onDelete: "set null",
3931 }),
3932 action: text("action").notNull(),
3933 detail: text("detail"),
3934 ip: text("ip"),
3935 createdAt: timestamp("created_at", { withTimezone: true })
3936 .defaultNow()
3937 .notNull(),
3938 },
3939 (table) => [
3940 index("server_target_audit_target_idx").on(table.targetId, table.createdAt),
3941 ]
3942);
3943
3944export type ServerTargetAudit = typeof serverTargetAudit.$inferSelect;
3945export type NewServerTargetAudit = typeof serverTargetAudit.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+96−0View fileUnifiedSplit
2727import { indexChangedFiles } from "../lib/semantic-index";
2828import { enqueuePreviewBuild } from "../lib/branch-previews";
2929import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
30import {
31 findTargetsForPush,
32 finishDeployRow,
33 resolveEnv,
34 startDeployRow,
35} from "../lib/server-target-store";
36import { deployToTarget } from "../lib/server-targets";
3037
3138interface PushRef {
3239 oldSha: string;
171178 }
172179 }
173180
181 // 5b. Block ST — Server targets. After core post-receive work, fire
182 // deploys against any `server_targets` row whose
183 // (watched_repository_id, watched_branch) matches a pushed ref.
184 // Fire-and-forget; deploy results land in `server_target_deployments`
185 // and the UI at /admin/servers/:id surfaces them.
186 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
187 console.warn("[server-targets] dispatch error:", err)
188 );
189
174190 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
175191 // main, fire the local deploy via scripts/self-deploy.sh. The script
176192 // forks into the background, so this call returns immediately (git
606622 }
607623}
608624
625/**
626 * Block ST — fan out the push to any server targets that watch this
627 * (repo, branch). One sequential deploy per target so a slow box can't
628 * stall the next push, but multiple matching targets run in parallel.
629 * Every failure is contained to its own deploy row + console warn —
630 * nothing here can break the push path.
631 */
632async function fireServerTargetDeploys(
633 owner: string,
634 repo: string,
635 refs: PushRef[]
636): Promise<void> {
637 const liveRefs = refs.filter(
638 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
639 );
640 if (liveRefs.length === 0) return;
641
642 let repositoryId = "";
643 try {
644 const [row] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 repositoryId = row?.id || "";
651 } catch {
652 return;
653 }
654 if (!repositoryId) return;
655
656 await Promise.all(
657 liveRefs.map(async (ref) => {
658 const branch = ref.refName.replace("refs/heads/", "");
659 let targets;
660 try {
661 targets = await findTargetsForPush({ repositoryId, branch });
662 } catch {
663 return;
664 }
665 if (!targets.length) return;
666
667 await Promise.all(
668 targets.map(async (target) => {
669 try {
670 const env = await resolveEnv(target.id);
671 const deployId = await startDeployRow({
672 targetId: target.id,
673 commitSha: ref.newSha,
674 ref: ref.refName,
675 triggerSource: "push",
676 });
677 const result = await deployToTarget(target, {
678 commitSha: ref.newSha,
679 ref: ref.refName,
680 env,
681 });
682 if (deployId) {
683 await finishDeployRow({
684 id: deployId,
685 exitCode: result.exitCode,
686 stdout: result.stdout,
687 stderr: result.stderr,
688 });
689 }
690 console.log(
691 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
692 );
693 } catch (err) {
694 console.warn(
695 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
696 );
697 }
698 })
699 );
700 })
701 );
702}
703
609704/** Test-only access to internal helpers. */
610705export const __test = {
611706 triggerCrontechDeploy,
616711 fireSemanticIndex,
617712 firePreviewBuilds,
618713 fireDocDriftCheck,
714 fireServerTargetDeploys,
619715};
Addedsrc/lib/server-target-store.ts+338−0View fileUnifiedSplit
1/**
2 * DB-side helpers for server targets — exists to keep route + hook code
3 * thin and to centralise the audit-log call so every mutation logs.
4 */
5
6import { and, desc, eq } from "drizzle-orm";
7import { db } from "../db";
8import {
9 serverTargetAudit,
10 serverTargetDeployments,
11 serverTargetEnv,
12 serverTargets,
13 type ServerTarget,
14 type NewServerTarget,
15 type ServerTargetEnv,
16} from "../db/schema";
17import { decryptValue, encryptValue, isValidEnvName } from "./server-targets-crypto";
18
19export async function listTargets(): Promise<ServerTarget[]> {
20 return db
21 .select()
22 .from(serverTargets)
23 .orderBy(desc(serverTargets.createdAt));
24}
25
26export async function getTarget(
27 id: string
28): Promise<ServerTarget | null> {
29 const [row] = await db
30 .select()
31 .from(serverTargets)
32 .where(eq(serverTargets.id, id))
33 .limit(1);
34 return row ?? null;
35}
36
37export async function getTargetByName(
38 name: string
39): Promise<ServerTarget | null> {
40 const [row] = await db
41 .select()
42 .from(serverTargets)
43 .where(eq(serverTargets.name, name))
44 .limit(1);
45 return row ?? null;
46}
47
48export interface CreateTargetInput {
49 name: string;
50 host: string;
51 port?: number;
52 sshUser: string;
53 privateKey: string;
54 deployPath?: string;
55 deployScript?: string;
56 watchedRepositoryId?: string | null;
57 watchedBranch?: string | null;
58 createdBy: string;
59}
60
61export async function createTarget(
62 input: CreateTargetInput
63): Promise<{ ok: true; target: ServerTarget } | { ok: false; error: string }> {
64 const enc = encryptValue(input.privateKey);
65 if (!enc.ok) return { ok: false, error: enc.error };
66 const insert: NewServerTarget = {
67 name: input.name,
68 host: input.host,
69 port: input.port ?? 22,
70 sshUser: input.sshUser,
71 encryptedPrivateKey: enc.ciphertext,
72 deployPath: input.deployPath ?? "/var/www/app",
73 deployScript: input.deployScript ?? "bash deploy.sh",
74 watchedRepositoryId: input.watchedRepositoryId ?? null,
75 watchedBranch: input.watchedBranch ?? null,
76 createdBy: input.createdBy,
77 };
78 try {
79 const [row] = await db.insert(serverTargets).values(insert).returning();
80 if (!row) return { ok: false, error: "insert returned no row" };
81 await logAudit({
82 targetId: row.id,
83 actorId: input.createdBy,
84 action: "target.created",
85 detail: `${row.sshUser}@${row.host}:${row.port}`,
86 });
87 return { ok: true, target: row };
88 } catch (err) {
89 return {
90 ok: false,
91 error: err instanceof Error ? err.message : String(err),
92 };
93 }
94}
95
96export async function deleteTarget(
97 id: string,
98 actorId: string
99): Promise<void> {
100 await db.delete(serverTargets).where(eq(serverTargets.id, id));
101 await logAudit({
102 targetId: id,
103 actorId,
104 action: "target.deleted",
105 });
106}
107
108export async function recordPin(
109 targetId: string,
110 fingerprint: string,
111 actorId: string
112): Promise<void> {
113 await db
114 .update(serverTargets)
115 .set({
116 hostFingerprint: fingerprint,
117 status: "verified",
118 lastSeenAt: new Date(),
119 updatedAt: new Date(),
120 })
121 .where(eq(serverTargets.id, targetId));
122 await logAudit({
123 targetId,
124 actorId,
125 action: "target.fingerprint_pinned",
126 detail: fingerprint,
127 });
128}
129
130// --- env vars ---------------------------------------------------------------
131
132export async function listEnv(targetId: string): Promise<ServerTargetEnv[]> {
133 return db
134 .select()
135 .from(serverTargetEnv)
136 .where(eq(serverTargetEnv.targetId, targetId))
137 .orderBy(serverTargetEnv.name);
138}
139
140/**
141 * Decrypt the env vars for a target into a KEY→value map. Skips rows whose
142 * ciphertext fails to decrypt (logged) — a corrupt row should not abort a
143 * deploy, but the missing var becomes obvious in the run log.
144 */
145export async function resolveEnv(
146 targetId: string
147): Promise<Record<string, string>> {
148 const rows = await listEnv(targetId);
149 const out: Record<string, string> = {};
150 for (const row of rows) {
151 const dec = decryptValue(row.encryptedValue);
152 if (dec.ok) out[row.name] = dec.plaintext;
153 else console.warn(`[server-targets] decrypt env ${row.name}: ${dec.error}`);
154 }
155 return out;
156}
157
158export async function upsertEnv(input: {
159 targetId: string;
160 name: string;
161 value: string;
162 isSecret?: boolean;
163 actorId: string;
164}): Promise<{ ok: true } | { ok: false; error: string }> {
165 if (!isValidEnvName(input.name)) {
166 return {
167 ok: false,
168 error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, _)",
169 };
170 }
171 const enc = encryptValue(input.value);
172 if (!enc.ok) return { ok: false, error: enc.error };
173 const now = new Date();
174 try {
175 const [existing] = await db
176 .select()
177 .from(serverTargetEnv)
178 .where(
179 and(
180 eq(serverTargetEnv.targetId, input.targetId),
181 eq(serverTargetEnv.name, input.name)
182 )
183 )
184 .limit(1);
185 if (existing) {
186 await db
187 .update(serverTargetEnv)
188 .set({
189 encryptedValue: enc.ciphertext,
190 isSecret: input.isSecret ?? existing.isSecret,
191 updatedBy: input.actorId,
192 updatedAt: now,
193 })
194 .where(eq(serverTargetEnv.id, existing.id));
195 } else {
196 await db.insert(serverTargetEnv).values({
197 targetId: input.targetId,
198 name: input.name,
199 encryptedValue: enc.ciphertext,
200 isSecret: input.isSecret ?? true,
201 updatedBy: input.actorId,
202 });
203 }
204 await logAudit({
205 targetId: input.targetId,
206 actorId: input.actorId,
207 action: "env.upserted",
208 detail: input.name,
209 });
210 return { ok: true };
211 } catch (err) {
212 return {
213 ok: false,
214 error: err instanceof Error ? err.message : String(err),
215 };
216 }
217}
218
219export async function deleteEnv(input: {
220 targetId: string;
221 name: string;
222 actorId: string;
223}): Promise<void> {
224 await db
225 .delete(serverTargetEnv)
226 .where(
227 and(
228 eq(serverTargetEnv.targetId, input.targetId),
229 eq(serverTargetEnv.name, input.name)
230 )
231 );
232 await logAudit({
233 targetId: input.targetId,
234 actorId: input.actorId,
235 action: "env.deleted",
236 detail: input.name,
237 });
238}
239
240// --- deployments + audit ----------------------------------------------------
241
242export interface RecordDeployInput {
243 targetId: string;
244 commitSha?: string | null;
245 ref?: string | null;
246 triggeredBy?: string | null;
247 triggerSource: "push" | "manual";
248}
249
250export async function startDeployRow(
251 input: RecordDeployInput
252): Promise<string | null> {
253 try {
254 const [row] = await db
255 .insert(serverTargetDeployments)
256 .values({
257 targetId: input.targetId,
258 commitSha: input.commitSha ?? null,
259 ref: input.ref ?? null,
260 triggeredBy: input.triggeredBy ?? null,
261 triggerSource: input.triggerSource,
262 status: "running",
263 })
264 .returning();
265 return row?.id ?? null;
266 } catch {
267 return null;
268 }
269}
270
271export async function finishDeployRow(input: {
272 id: string;
273 exitCode: number;
274 stdout: string;
275 stderr: string;
276}): Promise<void> {
277 try {
278 await db
279 .update(serverTargetDeployments)
280 .set({
281 status: input.exitCode === 0 ? "success" : "failed",
282 exitCode: input.exitCode,
283 stdout: input.stdout.slice(0, 1_000_000),
284 stderr: input.stderr.slice(0, 1_000_000),
285 finishedAt: new Date(),
286 })
287 .where(eq(serverTargetDeployments.id, input.id));
288 } catch {
289 /* ignore */
290 }
291}
292
293export async function recentDeploys(
294 targetId: string,
295 limit = 20
296): Promise<Array<typeof serverTargetDeployments.$inferSelect>> {
297 return db
298 .select()
299 .from(serverTargetDeployments)
300 .where(eq(serverTargetDeployments.targetId, targetId))
301 .orderBy(desc(serverTargetDeployments.startedAt))
302 .limit(limit);
303}
304
305export async function logAudit(input: {
306 targetId?: string | null;
307 actorId?: string | null;
308 action: string;
309 detail?: string | null;
310 ip?: string | null;
311}): Promise<void> {
312 try {
313 await db.insert(serverTargetAudit).values({
314 targetId: input.targetId ?? null,
315 actorId: input.actorId ?? null,
316 action: input.action,
317 detail: input.detail ?? null,
318 ip: input.ip ?? null,
319 });
320 } catch {
321 /* never fail the caller because of audit */
322 }
323}
324
325export async function findTargetsForPush(input: {
326 repositoryId: string;
327 branch: string;
328}): Promise<ServerTarget[]> {
329 return db
330 .select()
331 .from(serverTargets)
332 .where(
333 and(
334 eq(serverTargets.watchedRepositoryId, input.repositoryId),
335 eq(serverTargets.watchedBranch, input.branch)
336 )
337 );
338}
Addedsrc/lib/server-targets-crypto.ts+140−0View fileUnifiedSplit
1/**
2 * Server-target crypto primitives (no I/O, no DB).
3 *
4 * Mirrors `workflow-secrets-crypto.ts` — AES-256-GCM under a 32-byte master
5 * key sourced from `SERVER_TARGETS_KEY` (hex). Used for two ciphertext
6 * columns:
7 * - `server_targets.encrypted_private_key` (the SSH private key)
8 * - `server_target_env.encrypted_value` (per-target env var values)
9 *
10 * Both are addressed through the same primitives because they share the
11 * same threat model: an attacker who reads the DB without the master key
12 * cannot connect to or impersonate the target.
13 *
14 * Every fn returns `{ok:true,...}` / `{ok:false,error}` — never throws.
15 */
16
17import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
18
19const ALGO = "aes-256-gcm";
20const IV_LEN = 12;
21const TAG_LEN = 16;
22const KEY_LEN = 32;
23
24export function getMasterKey(): Buffer | null {
25 const hex = process.env.SERVER_TARGETS_KEY;
26 if (!hex) return null;
27 const trimmed = hex.trim();
28 if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null;
29 let buf: Buffer;
30 try {
31 buf = Buffer.from(trimmed, "hex");
32 } catch {
33 return null;
34 }
35 if (buf.length !== KEY_LEN) return null;
36 return buf;
37}
38
39export function encryptValue(
40 plaintext: string
41): { ok: true; ciphertext: string } | { ok: false; error: string } {
42 const key = getMasterKey();
43 if (!key) {
44 return {
45 ok: false,
46 error:
47 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
48 };
49 }
50 if (typeof plaintext !== "string") {
51 return { ok: false, error: "plaintext must be a string" };
52 }
53 try {
54 const iv = randomBytes(IV_LEN);
55 const cipher = createCipheriv(ALGO, key, iv);
56 const enc = Buffer.concat([
57 cipher.update(plaintext, "utf8"),
58 cipher.final(),
59 ]);
60 const tag = cipher.getAuthTag();
61 if (tag.length !== TAG_LEN) {
62 return { ok: false, error: "unexpected auth tag length" };
63 }
64 const blob = Buffer.concat([iv, tag, enc]);
65 return { ok: true, ciphertext: blob.toString("base64") };
66 } catch (err) {
67 return {
68 ok: false,
69 error: `encrypt failed: ${err instanceof Error ? err.message : String(err)}`,
70 };
71 }
72}
73
74export function decryptValue(
75 ciphertext: string
76): { ok: true; plaintext: string } | { ok: false; error: string } {
77 const key = getMasterKey();
78 if (!key) {
79 return {
80 ok: false,
81 error:
82 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
83 };
84 }
85 if (typeof ciphertext !== "string" || ciphertext.length === 0) {
86 return { ok: false, error: "ciphertext must be a non-empty string" };
87 }
88 let blob: Buffer;
89 try {
90 blob = Buffer.from(ciphertext, "base64");
91 } catch {
92 return { ok: false, error: "ciphertext is not valid base64" };
93 }
94 if (blob.length < IV_LEN + TAG_LEN) {
95 return { ok: false, error: "ciphertext blob too short" };
96 }
97 const iv = blob.subarray(0, IV_LEN);
98 const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
99 const enc = blob.subarray(IV_LEN + TAG_LEN);
100 try {
101 const decipher = createDecipheriv(ALGO, key, iv);
102 decipher.setAuthTag(tag);
103 const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
104 return { ok: true, plaintext: dec.toString("utf8") };
105 } catch (err) {
106 return {
107 ok: false,
108 error: `decrypt failed: ${err instanceof Error ? err.message : String(err)}`,
109 };
110 }
111}
112
113/**
114 * Validate an env var name. Same rule as POSIX-ish env: leading
115 * letter/underscore, then letters/digits/underscores. We're a little
116 * stricter than POSIX in that we require uppercase + digits + underscore
117 * so KEY=value lines in the materialised .env file are predictable.
118 */
119export function isValidEnvName(name: unknown): name is string {
120 return typeof name === "string" && /^[A-Z_][A-Z0-9_]*$/.test(name);
121}
122
123/**
124 * Render an env-vars map as a `.env` file body — `KEY=value\n` lines with
125 * values single-quoted and embedded single quotes escaped. This matches the
126 * common `set -a; source /path/to/file; set +a` deploy-script pattern and
127 * is what `materializeEnv` produces before scp'ing it to the box.
128 */
129export function renderDotenv(env: Record<string, string>): string {
130 const keys = Object.keys(env).sort();
131 return (
132 keys
133 .map((k) => {
134 const v = env[k] ?? "";
135 const quoted = "'" + v.replace(/'/g, "'\\''") + "'";
136 return `${k}=${quoted}`;
137 })
138 .join("\n") + (keys.length ? "\n" : "")
139 );
140}
Addedsrc/lib/server-targets.ts+339−0View fileUnifiedSplit
1/**
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};
Addedsrc/routes/admin-server-targets.tsx+528−0View fileUnifiedSplit
1/**
2 * Block ST — Admin server targets UI + mutation endpoints.
3 *
4 * GET /admin/servers — list targets
5 * GET /admin/servers/new — new-target form
6 * POST /admin/servers — create
7 * GET /admin/servers/:id — detail (env vars + recent deploys)
8 * POST /admin/servers/:id/env — upsert an env var
9 * POST /admin/servers/:id/env/:name/delete — delete an env var
10 * POST /admin/servers/:id/test — test connection (pin fingerprint)
11 * POST /admin/servers/:id/deploy — manual deploy
12 * POST /admin/servers/:id/delete — delete target
13 *
14 * v1 is site-admin only. Customer scoping arrives in a follow-up block.
15 */
16
17import { Hono } from "hono";
18import { eq } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isSiteAdmin } from "../lib/admin";
25import {
26 createTarget,
27 deleteEnv,
28 deleteTarget,
29 finishDeployRow,
30 getTarget,
31 listEnv,
32 listTargets,
33 recentDeploys,
34 recordPin,
35 resolveEnv,
36 startDeployRow,
37 upsertEnv,
38} from "../lib/server-target-store";
39import { deployToTarget, testConnection } from "../lib/server-targets";
40
41const admin = new Hono<AuthEnv>();
42admin.use("*", softAuth);
43
44async function gate(c: any): Promise<{ userId: string } | Response> {
45 const user = c.get("user");
46 if (!user) return c.redirect("/login?next=/admin/servers");
47 if (!(await isSiteAdmin(user.id))) {
48 return c.html(
49 <Layout title="Forbidden" user={user}>
50 <main style="max-width:640px;margin:40px auto;padding:0 20px">
51 <h1>403 — site admin required</h1>
52 <p>The server-targets section is admin-only in v1.</p>
53 </main>
54 </Layout>,
55 403
56 );
57 }
58 return { userId: user.id };
59}
60
61function flash(msg: string | undefined, kind: "ok" | "err"): any {
62 if (!msg) return null;
63 const bg = kind === "ok" ? "#0f2a18" : "#2a1212";
64 const border = kind === "ok" ? "#1f5a32" : "#5a1f1f";
65 const fg = kind === "ok" ? "#7fe1a3" : "#ffb4b4";
66 return (
67 <div
68 style={`background:${bg};border:1px solid ${border};color:${fg};padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:14px`}
69 >
70 {msg}
71 </div>
72 );
73}
74
75const wrap =
76 "max-width:980px;margin:32px auto;padding:0 20px;color:#e5e7eb;font-family:system-ui,sans-serif";
77const card =
78 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:18px 20px;margin-bottom:18px";
79const inputStyle =
80 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:8px 10px;border-radius:6px;font-family:ui-monospace,monospace";
81const btn =
82 "background:#1f6feb;border:0;color:#fff;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:14px";
83const btnDanger =
84 "background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
85const btnSecondary =
86 "background:#1f2937;border:0;color:#e5e7eb;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
87const label = "display:block;font-size:13px;color:#9ca3af;margin:10px 0 4px";
88
89// ─── GET /admin/servers ─────────────────────────────────────────────────────
90
91admin.get("/admin/servers", async (c) => {
92 const g = await gate(c);
93 if (g instanceof Response) return g;
94 const user = c.get("user")!;
95 const targets = await listTargets();
96 const ok = c.req.query("ok") ?? undefined;
97 const err = c.req.query("err") ?? undefined;
98
99 return c.html(
100 <Layout title="Server targets — admin" user={user}>
101 <main style={wrap}>
102 <h1 style="margin:0 0 6px;font-size:24px">Server targets</h1>
103 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
104 Boxes Gluecron can SSH into. Admin-only. A push to a watched
105 branch fires the target's deploy script with its env vars
106 materialised on the box.
107 </p>
108 {flash(ok, "ok")}
109 {flash(err, "err")}
110
111 <div style="margin-bottom:16px">
112 <a href="/admin/servers/new" style={btn + ";text-decoration:none"}>
113 + New target
114 </a>
115 </div>
116
117 <div style={card}>
118 {targets.length === 0 ? (
119 <p style="color:#9ca3af;margin:0">
120 No targets yet. Add your first box.
121 </p>
122 ) : (
123 <table style="width:100%;border-collapse:collapse;font-size:14px">
124 <thead>
125 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
126 <th style="padding:8px 6px">Name</th>
127 <th style="padding:8px 6px">Host</th>
128 <th style="padding:8px 6px">Watch</th>
129 <th style="padding:8px 6px">Status</th>
130 <th style="padding:8px 6px;text-align:right"></th>
131 </tr>
132 </thead>
133 <tbody>
134 {targets.map((t) => (
135 <tr style="border-bottom:1px solid #1f2937">
136 <td style="padding:10px 6px">
137 <a
138 href={`/admin/servers/${t.id}`}
139 style="color:#7aa2f7;text-decoration:none;font-weight:600"
140 >
141 {t.name}
142 </a>
143 </td>
144 <td style="padding:10px 6px;font-family:ui-monospace,monospace;color:#cbd5e1">
145 {t.sshUser}@{t.host}:{t.port}
146 </td>
147 <td style="padding:10px 6px;color:#9ca3af;font-size:13px">
148 {t.watchedRepositoryId && t.watchedBranch
149 ? `${t.watchedBranch}`
150 : "—"}
151 </td>
152 <td style="padding:10px 6px">
153 <span
154 style={
155 "padding:2px 8px;border-radius:99px;font-size:12px;" +
156 (t.status === "verified"
157 ? "background:#0f2a18;color:#7fe1a3"
158 : "background:#2a2410;color:#e1c47f")
159 }
160 >
161 {t.status}
162 </span>
163 </td>
164 <td style="padding:10px 6px;text-align:right">
165 <a href={`/admin/servers/${t.id}`} style="color:#9ca3af;font-size:13px;text-decoration:none">
166 open →
167 </a>
168 </td>
169 </tr>
170 ))}
171 </tbody>
172 </table>
173 )}
174 </div>
175 </main>
176 </Layout>
177 );
178});
179
180// ─── GET /admin/servers/new ─────────────────────────────────────────────────
181
182admin.get("/admin/servers/new", async (c) => {
183 const g = await gate(c);
184 if (g instanceof Response) return g;
185 const user = c.get("user")!;
186 const repos = await db
187 .select({
188 id: repositories.id,
189 name: repositories.name,
190 ownerName: users.username,
191 })
192 .from(repositories)
193 .innerJoin(users, eq(repositories.ownerId, users.id))
194 .limit(200);
195
196 return c.html(
197 <Layout title="New server target" user={user}>
198 <main style={wrap}>
199 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
200 <h1 style="margin:0 0 16px;font-size:22px">New server target</h1>
201 <form method="post" action="/admin/servers" style={card}>
202 <label style={label}>Name (unique identifier)</label>
203 <input name="name" required pattern="[a-z0-9-]+" placeholder="crontech-prod-1" style={inputStyle} />
204
205 <label style={label}>Host</label>
206 <input name="host" required placeholder="1.2.3.4 or box.crontech.ai" style={inputStyle} />
207
208 <div style="display:flex;gap:14px">
209 <div style="flex:1">
210 <label style={label}>SSH user</label>
211 <input name="ssh_user" required placeholder="deploy" style={inputStyle} />
212 </div>
213 <div style="width:120px">
214 <label style={label}>Port</label>
215 <input name="port" type="number" value="22" style={inputStyle} />
216 </div>
217 </div>
218
219 <label style={label}>Private SSH key (OpenSSH PEM)</label>
220 <textarea
221 name="private_key"
222 required
223 rows={8}
224 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."
225 style={inputStyle + ";font-size:12px"}
226 />
227 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
228 Encrypted at rest with <code>SERVER_TARGETS_KEY</code>. Never
229 displayed again after save.
230 </p>
231
232 <label style={label}>Deploy path on the box</label>
233 <input name="deploy_path" value="/var/www/app" style={inputStyle} />
234
235 <label style={label}>Deploy script</label>
236 <input name="deploy_script" value="bash deploy.sh" style={inputStyle} />
237 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
238 Runs inside the deploy path. <code>./.env.gluecron</code> is
239 sourced first so all env vars are available.
240 </p>
241
242 <div style="display:flex;gap:14px;margin-top:6px">
243 <div style="flex:1">
244 <label style={label}>Watched repo (optional)</label>
245 <select name="watched_repository_id" style={inputStyle}>
246 <option value="">— none —</option>
247 {repos.map((r) => (
248 <option value={r.id}>{r.ownerName}/{r.name}</option>
249 ))}
250 </select>
251 </div>
252 <div style="flex:1">
253 <label style={label}>Watched branch</label>
254 <input name="watched_branch" placeholder="main" style={inputStyle} />
255 </div>
256 </div>
257
258 <div style="margin-top:18px">
259 <button type="submit" style={btn}>Create target</button>
260 </div>
261 </form>
262 </main>
263 </Layout>
264 );
265});
266
267// ─── POST /admin/servers ────────────────────────────────────────────────────
268
269admin.post("/admin/servers", async (c) => {
270 const g = await gate(c);
271 if (g instanceof Response) return g;
272 const form = await c.req.parseBody();
273 const name = String(form.name || "").trim();
274 const host = String(form.host || "").trim();
275 const sshUser = String(form.ssh_user || "").trim();
276 const port = Number(form.port || 22);
277 const privateKey = String(form.private_key || "");
278 const deployPath = String(form.deploy_path || "/var/www/app").trim();
279 const deployScript = String(form.deploy_script || "bash deploy.sh");
280 const watchedRepositoryId = String(form.watched_repository_id || "") || null;
281 const watchedBranch = String(form.watched_branch || "").trim() || null;
282
283 if (!name || !host || !sshUser || !privateKey) {
284 return c.redirect("/admin/servers?err=missing+required+fields");
285 }
286 if (!/^[a-z0-9-]+$/.test(name)) {
287 return c.redirect("/admin/servers?err=name+must+be+lowercase+alphanumeric+with+dashes");
288 }
289
290 const out = await createTarget({
291 name,
292 host,
293 port,
294 sshUser,
295 privateKey,
296 deployPath,
297 deployScript,
298 watchedRepositoryId,
299 watchedBranch,
300 createdBy: g.userId,
301 });
302 if (!out.ok) {
303 return c.redirect(`/admin/servers?err=${encodeURIComponent(out.error)}`);
304 }
305 return c.redirect(`/admin/servers/${out.target.id}?ok=created`);
306});
307
308// ─── GET /admin/servers/:id ─────────────────────────────────────────────────
309
310admin.get("/admin/servers/:id", async (c) => {
311 const g = await gate(c);
312 if (g instanceof Response) return g;
313 const user = c.get("user")!;
314 const id = c.req.param("id");
315 const target = await getTarget(id);
316 if (!target) return c.notFound();
317
318 const envRows = await listEnv(id);
319 const deploys = await recentDeploys(id, 10);
320 const ok = c.req.query("ok") ?? undefined;
321 const err = c.req.query("err") ?? undefined;
322
323 return c.html(
324 <Layout title={`${target.name} — server target`} user={user}>
325 <main style={wrap}>
326 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
327 <h1 style="margin:0 0 4px;font-size:22px">{target.name}</h1>
328 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px;font-family:ui-monospace,monospace">
329 {target.sshUser}@{target.host}:{target.port} · {target.deployPath}
330 </p>
331 {flash(ok, "ok")}
332 {flash(err, "err")}
333
334 {/* Connection / status */}
335 <div style={card}>
336 <h2 style="margin:0 0 10px;font-size:16px">Connection</h2>
337 <p style="margin:0 0 10px;font-size:14px">
338 Status: <strong>{target.status}</strong>
339 {target.hostFingerprint && (
340 <span style="color:#9ca3af;font-family:ui-monospace,monospace;font-size:12px">
341 {" "}· pinned {target.hostFingerprint}
342 </span>
343 )}
344 </p>
345 <form method="post" action={`/admin/servers/${target.id}/test`} style="display:inline-block;margin-right:8px">
346 <button type="submit" style={btnSecondary}>Test connection</button>
347 </form>
348 <form method="post" action={`/admin/servers/${target.id}/deploy`} style="display:inline-block;margin-right:8px">
349 <button type="submit" style={btn}>Deploy now</button>
350 </form>
351 <form method="post" action={`/admin/servers/${target.id}/delete`} style="display:inline-block;float:right" onsubmit="return confirm('Delete this target?')">
352 <button type="submit" style={btnDanger}>Delete</button>
353 </form>
354 </div>
355
356 {/* Env vars */}
357 <div style={card}>
358 <h2 style="margin:0 0 10px;font-size:16px">Environment variables</h2>
359 <p style="margin:0 0 12px;color:#9ca3af;font-size:13px">
360 Encrypted at rest. Materialised as <code>./.env.gluecron</code>
361 on the box and sourced before the deploy script runs.
362 </p>
363
364 {envRows.length === 0 ? (
365 <p style="color:#6b7280;font-size:14px;margin:0 0 12px">No env vars yet.</p>
366 ) : (
367 <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:12px">
368 <tbody>
369 {envRows.map((row) => (
370 <tr style="border-bottom:1px solid #1f2937">
371 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#cbd5e1;width:35%">{row.name}</td>
372 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#6b7280">
373 {row.isSecret ? "••••••••" : "(non-secret)"}
374 </td>
375 <td style="padding:8px 6px;color:#6b7280;font-size:12px">
376 {row.isSecret ? "secret" : "value"}
377 </td>
378 <td style="padding:8px 6px;text-align:right">
379 <form method="post" action={`/admin/servers/${target.id}/env/${encodeURIComponent(row.name)}/delete`} style="display:inline">
380 <button type="submit" style={btnDanger}>Delete</button>
381 </form>
382 </td>
383 </tr>
384 ))}
385 </tbody>
386 </table>
387 )}
388
389 <form method="post" action={`/admin/servers/${target.id}/env`} style="display:grid;grid-template-columns:1fr 2fr auto;gap:8px;align-items:center">
390 <input name="name" placeholder="VAR_NAME" required pattern="[A-Z_][A-Z0-9_]*" style={inputStyle} />
391 <input name="value" placeholder="value" required style={inputStyle} />
392 <button type="submit" style={btn}>Save</button>
393 <label style="grid-column:1/-1;font-size:12px;color:#9ca3af;display:flex;gap:6px;align-items:center;margin-top:2px">
394 <input type="checkbox" name="is_secret" value="1" checked />
395 Treat as secret (mask in UI)
396 </label>
397 </form>
398 </div>
399
400 {/* Recent deploys */}
401 <div style={card}>
402 <h2 style="margin:0 0 10px;font-size:16px">Recent deploys</h2>
403 {deploys.length === 0 ? (
404 <p style="color:#6b7280;font-size:14px;margin:0">No deploys yet.</p>
405 ) : (
406 <table style="width:100%;border-collapse:collapse;font-size:13px">
407 <thead>
408 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
409 <th style="padding:6px">When</th>
410 <th style="padding:6px">Commit</th>
411 <th style="padding:6px">Source</th>
412 <th style="padding:6px">Status</th>
413 </tr>
414 </thead>
415 <tbody>
416 {deploys.map((d) => (
417 <tr style="border-bottom:1px solid #1f2937">
418 <td style="padding:6px;color:#9ca3af">{d.startedAt.toISOString()}</td>
419 <td style="padding:6px;font-family:ui-monospace,monospace">{d.commitSha?.slice(0, 7) ?? "—"}</td>
420 <td style="padding:6px;color:#9ca3af">{d.triggerSource}</td>
421 <td style={`padding:6px;color:${d.status === "success" ? "#7fe1a3" : d.status === "failed" ? "#ffb4b4" : "#e1c47f"}`}>
422 {d.status} {d.exitCode != null ? `(${d.exitCode})` : ""}
423 </td>
424 </tr>
425 ))}
426 </tbody>
427 </table>
428 )}
429 </div>
430 </main>
431 </Layout>
432 );
433});
434
435// ─── POST /admin/servers/:id/env ────────────────────────────────────────────
436
437admin.post("/admin/servers/:id/env", async (c) => {
438 const g = await gate(c);
439 if (g instanceof Response) return g;
440 const id = c.req.param("id");
441 const target = await getTarget(id);
442 if (!target) return c.notFound();
443 const form = await c.req.parseBody();
444 const name = String(form.name || "").trim();
445 const value = String(form.value || "");
446 const isSecret = !!form.is_secret;
447 const out = await upsertEnv({
448 targetId: id,
449 name,
450 value,
451 isSecret,
452 actorId: g.userId,
453 });
454 if (!out.ok) {
455 return c.redirect(`/admin/servers/${id}?err=${encodeURIComponent(out.error)}`);
456 }
457 return c.redirect(`/admin/servers/${id}?ok=saved+${encodeURIComponent(name)}`);
458});
459
460admin.post("/admin/servers/:id/env/:name/delete", async (c) => {
461 const g = await gate(c);
462 if (g instanceof Response) return g;
463 const id = c.req.param("id");
464 const name = c.req.param("name");
465 await deleteEnv({ targetId: id, name, actorId: g.userId });
466 return c.redirect(`/admin/servers/${id}?ok=deleted+${encodeURIComponent(name)}`);
467});
468
469// ─── POST /admin/servers/:id/test ───────────────────────────────────────────
470
471admin.post("/admin/servers/:id/test", async (c) => {
472 const g = await gate(c);
473 if (g instanceof Response) return g;
474 const id = c.req.param("id");
475 const target = await getTarget(id);
476 if (!target) return c.notFound();
477 const result = await testConnection(target);
478 if (result.ok) {
479 await recordPin(id, result.fingerprint, g.userId);
480 return c.redirect(`/admin/servers/${id}?ok=connection+verified`);
481 }
482 return c.redirect(
483 `/admin/servers/${id}?err=${encodeURIComponent(`${result.stage}: ${result.error}`)}`
484 );
485});
486
487// ─── POST /admin/servers/:id/deploy ─────────────────────────────────────────
488
489admin.post("/admin/servers/:id/deploy", async (c) => {
490 const g = await gate(c);
491 if (g instanceof Response) return g;
492 const id = c.req.param("id");
493 const target = await getTarget(id);
494 if (!target) return c.notFound();
495 const env = await resolveEnv(id);
496 const deployId = await startDeployRow({
497 targetId: id,
498 triggeredBy: g.userId,
499 triggerSource: "manual",
500 });
501 const result = await deployToTarget(target, { env });
502 if (deployId) {
503 await finishDeployRow({
504 id: deployId,
505 exitCode: result.exitCode,
506 stdout: result.stdout,
507 stderr: result.stderr,
508 });
509 }
510 if (result.ok) {
511 return c.redirect(`/admin/servers/${id}?ok=deploy+succeeded`);
512 }
513 return c.redirect(
514 `/admin/servers/${id}?err=${encodeURIComponent(`deploy exit ${result.exitCode}: ${result.stderr.slice(0, 200)}`)}`
515 );
516});
517
518// ─── POST /admin/servers/:id/delete ─────────────────────────────────────────
519
520admin.post("/admin/servers/:id/delete", async (c) => {
521 const g = await gate(c);
522 if (g instanceof Response) return g;
523 const id = c.req.param("id");
524 await deleteTarget(id, g.userId);
525 return c.redirect("/admin/servers?ok=deleted");
526});
527
528export default admin;
0529