feat(ssh): hosted SSH clone goes live — persistent host key, port published #5509
6 changed files+116−10
Modified.env.example+7−0View fileUnifiedSplit
@@ -13,6 +13,13 @@ SSH_PORT=2222
1313# Generate with: ssh-keygen -t ed25519 -f /etc/gluecron/ssh_host_key -N ''
1414# Then paste the contents here (or use \\n for newlines in single-line form).
1515SSH_HOST_KEY=
16# Where the auto-generated host key persists when SSH_HOST_KEY is unset.
17# Default: ${GIT_REPOS_PATH}/.gluecron/ssh_host_ed25519_key — rides the
18# git-repos volume so redeploys keep the same host identity.
19SSH_HOST_KEY_FILE=
20# Workflow runner concurrency — how many CI runs execute at once
21# (default 3, clamped 1..16). One box: raise with care.
22WORKFLOW_CONCURRENCY=
1623# ──────────────────────────────────────────────────────────────────────────
1724# Pre-launch strip shown above the wordmark on every page rendered through
1825# layout.tsx ("Gluecron is in final validation. Public signups ... open after
Modifieddocker-compose.standalone.yml+9−1View fileUnifiedSplit
@@ -53,7 +53,10 @@ services:
5353 - PORT=3000
5454 - NODE_ENV=production
5555 - APP_BASE_URL=https://gluecron.com
56 - SSH_PORT=0
56 # SSH clone enabled 2026-08-22 (scorecard move #1 — was pinned 0 since
57 # launch). Host key persists on the git-repos volume via
58 # config.sshHostKeyFile, so redeploys keep the same host identity.
59 - SSH_PORT=2222
5760 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
5861 # Build provenance. The image has no .git, so without these
5962 # src/lib/build-info.ts falls through to "unknown" — which froze the
@@ -75,6 +78,11 @@ services:
7578 - GOOGLE_OAUTH_ALLOWED_DOMAINS=${GOOGLE_OAUTH_ALLOWED_DOMAINS:-}
7679 expose:
7780 - "3000"
81 # Git-over-SSH. Published directly (not through caddy — SSH is not HTTP).
82 # Note docker-published ports bypass ufw on a default Docker install; the
83 # cloud firewall (if any) still needs 2222 open.
84 ports:
85 - "2222:2222"
7886 volumes:
7987 - git-repos:/data/repos
8088 # NO depends_on: postgres. Production's database is Neon (DATABASE_URL);
Modifiedsrc/__tests__/ssh-server.test.ts+43−0View fileUnifiedSplit
@@ -235,3 +235,46 @@ describe("resolveUserByKeyBlob", () => {
235235 }
236236 });
237237});
238
239// ---------------------------------------------------------------------------
240// loadOrGenerateHostKey — persistence (2026-08-22, hosted SSH enablement)
241// ---------------------------------------------------------------------------
242
243import { loadOrGenerateHostKey } from "../lib/ssh-server";
244import { mkdtempSync, rmSync, readFileSync as readFs, writeFileSync as writeFs } from "fs";
245import { tmpdir } from "os";
246import { join } from "path";
247
248describe("loadOrGenerateHostKey", () => {
249 test("env key wins and \n escapes are normalised", () => {
250 const key = loadOrGenerateHostKey({ envKey: "line1\nline2" });
251 expect(key.toString()).toBe("line1\nline2");
252 });
253
254 test("generates once and reuses the persisted key across calls", () => {
255 const dir = mkdtempSync(join(tmpdir(), "ssh-hostkey-"));
256 const file = join(dir, "nested", "host_ed25519_key");
257 try {
258 const first = loadOrGenerateHostKey({ envKey: "", keyFilePath: file });
259 expect(first.toString()).toContain("PRIVATE KEY");
260 expect(readFs(file, "utf8")).toBe(first.toString());
261 const second = loadOrGenerateHostKey({ envKey: "", keyFilePath: file });
262 expect(second.toString()).toBe(first.toString());
263 } finally {
264 rmSync(dir, { recursive: true, force: true });
265 }
266 });
267
268 test("a corrupt key file is regenerated, not served", () => {
269 const dir = mkdtempSync(join(tmpdir(), "ssh-hostkey-"));
270 const file = join(dir, "host_ed25519_key");
271 try {
272 writeFs(file, "not a key at all");
273 const key = loadOrGenerateHostKey({ envKey: "", keyFilePath: file });
274 expect(key.toString()).toContain("PRIVATE KEY");
275 expect(readFs(file, "utf8")).toContain("PRIVATE KEY");
276 } finally {
277 rmSync(dir, { recursive: true, force: true });
278 }
279 });
280});
Modifiedsrc/lib/config.ts+12−0View fileUnifiedSplit
@@ -124,6 +124,18 @@ export const config = {
124124 get sshHostKey() {
125125 return process.env.SSH_HOST_KEY || "";
126126 },
127 /**
128 * Where the auto-generated SSH host key persists when SSH_HOST_KEY is not
129 * set. Defaults under GIT_REPOS_PATH so it rides the git-repos volume and
130 * survives container redeploys — an ephemeral host key would show every
131 * clone a "host key changed" warning after each deploy.
132 */
133 get sshHostKeyFile() {
134 return (
135 process.env.SSH_HOST_KEY_FILE ||
136 `${this.gitReposPath}/.gluecron/ssh_host_ed25519_key`
137 );
138 },
127139 get appBaseUrl() {
128140 return (process.env.APP_BASE_URL || "http://localhost:3000").replace(
129141 /\/+$/,
Modifiedsrc/lib/host-capabilities.ts+3−2View fileUnifiedSplit
@@ -59,8 +59,9 @@ function sshPortFrom(env: EnvLike): number {
5959 * until a provisioner ships — flip it here, once, when it does.
6060 * - sandboxes: same shape as devEnv (`pr-sandbox.markSandboxReady` has no
6161 * caller; PR_SANDBOX_DOMAIN only shapes the URL).
62 * - ssh: `config.sshPort > 0`. Production pins SSH_PORT=0, so the SSH
63 * server never starts there.
62 * - ssh: `config.sshPort > 0`. Production pinned SSH_PORT=0 until
63 * 2026-08-22; docker-compose.standalone.yml now pins 2222 and publishes
64 * the port, so hosted SSH clone is live.
6465 */
6566export function hostCapabilities(
6667 env: EnvLike = process.env as EnvLike
Modifiedsrc/lib/ssh-server.ts+42−7View fileUnifiedSplit
@@ -37,6 +37,8 @@ import { Server as SshServer, utils as sshUtils } from "ssh2";
3737import type { AuthContext, Connection, ServerChannel } from "ssh2";
3838import { spawn } from "child_process";
3939import { generateKeyPairSync } from "crypto";
40import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
41import { dirname } from "path";
4042import { and, eq, sql } from "drizzle-orm";
4143import { db } from "../db";
4244import { repositories, sshKeys, users } from "../db/schema";
@@ -62,20 +64,53 @@ type PushRef = { oldSha: string; newSha: string; refName: string };
6264// Host key
6365// ---------------------------------------------------------------------------
6466
65function loadOrGenerateHostKey(): Buffer {
66 const raw = config.sshHostKey;
67export function loadOrGenerateHostKey(
68 opts: { envKey?: string; keyFilePath?: string } = {}
69): Buffer {
70 const raw = opts.envKey ?? config.sshHostKey;
6771 if (raw) {
6872 // Support \\n escapes (common in env-var values from .env files)
6973 return Buffer.from(raw.replace(/\\n/g, "\n"), "utf8");
7074 }
71 console.warn(
72 "[ssh] SSH_HOST_KEY not set — generating an ephemeral Ed25519 key. " +
73 "Clients will see 'host key changed' on restart. " +
74 "Set SSH_HOST_KEY to a persistent PEM Ed25519 private key in production."
75 );
75
76 // Persistent key file (default: under GIT_REPOS_PATH, i.e. on the
77 // git-repos volume). Before this, an unset SSH_HOST_KEY generated a fresh
78 // key on EVERY container restart — each deploy would have greeted every
79 // clone with "REMOTE HOST IDENTIFICATION HAS CHANGED", which is
80 // indistinguishable from a MITM to the user and would train them to
81 // ignore the one warning that matters. Generate once, persist, reuse.
82 const keyFilePath = opts.keyFilePath ?? config.sshHostKeyFile;
83 try {
84 if (keyFilePath && existsSync(keyFilePath)) {
85 const fromDisk = readFileSync(keyFilePath, "utf8");
86 if (fromDisk.includes("PRIVATE KEY")) return Buffer.from(fromDisk, "utf8");
87 console.warn(
88 `[ssh] ${keyFilePath} exists but does not look like a PEM private key — regenerating`
89 );
90 }
91 } catch (err) {
92 console.warn(
93 `[ssh] could not read host key file ${keyFilePath}:`,
94 err instanceof Error ? err.message : err
95 );
96 }
97
7698 const { privateKey } = generateKeyPairSync("ed25519", {
7799 privateKeyEncoding: { type: "pkcs8", format: "pem" },
78100 });
101 try {
102 if (keyFilePath) {
103 mkdirSync(dirname(keyFilePath), { recursive: true });
104 writeFileSync(keyFilePath, privateKey, { mode: 0o600 });
105 console.log(`[ssh] generated host key persisted to ${keyFilePath}`);
106 }
107 } catch (err) {
108 console.warn(
109 "[ssh] could not persist the generated host key — it is EPHEMERAL and " +
110 "clients will see 'host key changed' after the next restart:",
111 err instanceof Error ? err.message : err
112 );
113 }
79114 return Buffer.from(privateKey, "utf8");
80115}
81116
82117
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts