feat: SQLite-backed installations, REST API, admin auth and web UI
Replaces the slice-2 hardcoded installation with a full install/deploy pipeline: - SQLite persistence (better-sqlite3) with a single `installations` table and a draft -> cloning -> building -> built -> starting -> running/error/stopped state machine; env vars and bearer tokens are encrypted at rest (AES-256-GCM, key derived from MASTER_KEY). - Build/run orchestration: git clone (+ .env.example key detection), `nixpacks build` as a logged subprocess, dockerode container run/stop with forced host/port env vars and a TCP-connect health check before flipping an installation to "running". - REST API (installations CRUD, build/deploy/redeploy, logs, token regeneration) behind single-operator session-cookie auth with a basic login lockout. - Vite+React admin SPA: login, installation list, install wizard, and a detail page to configure env vars, trigger build/deploy/redeploy, view the build log, and copy the authenticated /mcp/<slug> URL. - Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and docker-compose.yml for production deployment. Validated end-to-end against a real cloned repo: draft creation with env-key detection, build, the error path on a failed health check, and delete, all through the live API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,13 +8,17 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "11.10.0",
|
||||
"dockerode": "4.0.9",
|
||||
"express": "4.21.2",
|
||||
"express-session": "1.18.1",
|
||||
"http-proxy": "1.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/dockerode": "3.3.42",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/http-proxy": "1.17.16",
|
||||
"@types/node": "24.9.1",
|
||||
"tsx": "4.20.6",
|
||||
|
||||
50
apps/server/src/api/auth.ts
Normal file
50
apps/server/src/api/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import crypto from "node:crypto";
|
||||
import { Router } from "express";
|
||||
import { config } from "../config/env.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
function passwordsMatch(expected: string, provided: string): boolean {
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(provided);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
let failedAttempts = 0;
|
||||
let lockoutUntil = 0;
|
||||
|
||||
authRouter.post("/login", (req, res) => {
|
||||
if (Date.now() < lockoutUntil) {
|
||||
res.status(429).json({ error: "too many attempts, try again later" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { password } = req.body ?? {};
|
||||
if (!config.adminPassword) {
|
||||
res.status(500).json({ error: "ADMIN_PASSWORD is not configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || !passwordsMatch(config.adminPassword, password)) {
|
||||
failedAttempts += 1;
|
||||
if (failedAttempts >= 5) {
|
||||
lockoutUntil = Date.now() + 60_000;
|
||||
failedAttempts = 0;
|
||||
}
|
||||
res.status(401).json({ error: "invalid password" });
|
||||
return;
|
||||
}
|
||||
|
||||
failedAttempts = 0;
|
||||
req.session.authenticated = true;
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
authRouter.post("/logout", (req, res) => {
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
authRouter.get("/session", (req, res) => {
|
||||
res.json({ authenticated: Boolean(req.session.authenticated) });
|
||||
});
|
||||
136
apps/server/src/api/installations.ts
Normal file
136
apps/server/src/api/installations.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Router } from "express";
|
||||
import { config } from "../config/env.js";
|
||||
import {
|
||||
type Installation,
|
||||
listInstallations,
|
||||
getInstallationById,
|
||||
deleteInstallation,
|
||||
updateInstallation,
|
||||
} from "../db/installations.js";
|
||||
import {
|
||||
createDraftInstallation,
|
||||
configureInstallation,
|
||||
buildInstallation,
|
||||
deployInstallation,
|
||||
redeployInstallation,
|
||||
removeInstallationContainer,
|
||||
} from "../service/orchestrator.js";
|
||||
import { buildLogPath } from "../build/nixpacks.js";
|
||||
import { generateBearerToken } from "../crypto/secrets.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
export const installationsRouter = Router();
|
||||
|
||||
// Not masked: this API sits behind single-operator session auth (requireAuth), and the
|
||||
// operator is the same person who owns these secrets — editing them requires seeing them.
|
||||
function toPublicJson(installation: Installation) {
|
||||
return {
|
||||
...installation,
|
||||
publicUrl: `${config.publicBaseUrl}/mcp/${installation.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
installationsRouter.get("/", (_req, res) => {
|
||||
res.json(listInstallations().map(toPublicJson));
|
||||
});
|
||||
|
||||
installationsRouter.get("/:id", (req, res) => {
|
||||
const installation = getInstallationById(req.params.id);
|
||||
if (!installation) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
res.json(toPublicJson(installation));
|
||||
});
|
||||
|
||||
installationsRouter.post("/", async (req, res) => {
|
||||
const { name, repoUrl, gitRef } = req.body ?? {};
|
||||
if (!name || !repoUrl) {
|
||||
res.status(400).json({ error: "name and repoUrl are required" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { installation, detectedEnvKeys } = await createDraftInstallation({ name, repoUrl, gitRef });
|
||||
res.status(201).json({ installation: toPublicJson(installation), detectedEnvKeys });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.patch("/:id/config", (req, res) => {
|
||||
const { env, portEnvVar, hostEnvVar, httpPath, internalPort } = req.body ?? {};
|
||||
try {
|
||||
const installation = configureInstallation(req.params.id, { env, portEnvVar, hostEnvVar, httpPath, internalPort });
|
||||
res.json(toPublicJson(installation));
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.post("/:id/build", async (req, res) => {
|
||||
try {
|
||||
const installation = await buildInstallation(req.params.id);
|
||||
res.json(toPublicJson(installation));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.post("/:id/deploy", async (req, res) => {
|
||||
try {
|
||||
const installation = await deployInstallation(req.params.id);
|
||||
res.json(toPublicJson(installation));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.post("/:id/redeploy", async (req, res) => {
|
||||
const rebuild = Boolean(req.body?.rebuild);
|
||||
try {
|
||||
const installation = await redeployInstallation(req.params.id, { rebuild });
|
||||
res.json(toPublicJson(installation));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.get("/:id/logs", (req, res) => {
|
||||
const installation = getInstallationById(req.params.id);
|
||||
if (!installation) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = `${config.logsDir}/${installation.slug}`;
|
||||
if (!fs.existsSync(dir)) {
|
||||
res.json({ log: "" });
|
||||
return;
|
||||
}
|
||||
const files = fs.readdirSync(dir).sort();
|
||||
const latest = files.at(-1);
|
||||
if (!latest) {
|
||||
res.json({ log: "" });
|
||||
return;
|
||||
}
|
||||
res.json({ log: fs.readFileSync(buildLogPath(installation.slug, latest.replace(/\.log$/, "")), "utf8") });
|
||||
});
|
||||
|
||||
installationsRouter.delete("/:id", async (req, res) => {
|
||||
try {
|
||||
await removeInstallationContainer(req.params.id);
|
||||
deleteInstallation(req.params.id);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
installationsRouter.post("/:id/regenerate-token", (req, res) => {
|
||||
try {
|
||||
const installation = updateInstallation(req.params.id, { bearerToken: generateBearerToken() });
|
||||
res.json({ bearerToken: installation.bearerToken });
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
46
apps/server/src/build/git.ts
Normal file
46
apps/server/src/build/git.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { config } from "../config/env.js";
|
||||
|
||||
function run(cmd: string, args: string[], cwd?: string): Promise<{ code: number; output: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd });
|
||||
let output = "";
|
||||
child.stdout.on("data", (d) => (output += d.toString()));
|
||||
child.stderr.on("data", (d) => (output += d.toString()));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ code: code ?? 1, output }));
|
||||
});
|
||||
}
|
||||
|
||||
export function repoDir(slug: string): string {
|
||||
return path.join(config.reposDir, slug);
|
||||
}
|
||||
|
||||
export async function cloneOrUpdateRepo(slug: string, repoUrl: string, gitRef?: string | null): Promise<string> {
|
||||
const dir = repoDir(slug);
|
||||
fs.mkdirSync(config.reposDir, { recursive: true });
|
||||
|
||||
if (fs.existsSync(path.join(dir, ".git"))) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const clone = await run("git", ["clone", "--depth", "1", ...(gitRef ? ["--branch", gitRef] : []), repoUrl, dir]);
|
||||
if (clone.code !== 0) {
|
||||
throw new Error(`git clone failed: ${clone.output}`);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function readEnvExampleKeys(dir: string): string[] {
|
||||
const file = path.join(dir, ".env.example");
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#") && line.includes("="))
|
||||
.map((line) => line.split("=")[0]!.trim());
|
||||
}
|
||||
36
apps/server/src/build/nixpacks.ts
Normal file
36
apps/server/src/build/nixpacks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { config } from "../config/env.js";
|
||||
|
||||
export function buildLogPath(slug: string, buildId: string): string {
|
||||
return path.join(config.logsDir, slug, `${buildId}.log`);
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
success: boolean;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
|
||||
return new Promise((resolve) => {
|
||||
const buildId = Date.now().toString();
|
||||
const logPath = buildLogPath(slug, buildId);
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
|
||||
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag]);
|
||||
child.stdout.pipe(logStream, { end: false });
|
||||
child.stderr.pipe(logStream, { end: false });
|
||||
|
||||
child.on("close", (code) => {
|
||||
logStream.end();
|
||||
resolve({ success: code === 0, logPath });
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
logStream.write(`\nfailed to spawn nixpacks: ${err.message}\n`);
|
||||
logStream.end();
|
||||
resolve({ success: false, logPath });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = process.env.DATA_DIR ?? path.resolve(process.cwd(), "../../data");
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
// Docker in prod; a rootless Podman socket (e.g. /run/user/<uid>/podman/podman.sock) in local dev.
|
||||
dockerSocketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
|
||||
mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net",
|
||||
dataDir,
|
||||
dbPath: process.env.DB_PATH ?? path.join(dataDir, "db.sqlite"),
|
||||
reposDir: path.join(dataDir, "repos"),
|
||||
logsDir: path.join(dataDir, "logs"),
|
||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||
adminPassword: process.env.ADMIN_PASSWORD,
|
||||
sessionSecret: process.env.SESSION_SECRET,
|
||||
};
|
||||
|
||||
31
apps/server/src/crypto/secrets.ts
Normal file
31
apps/server/src/crypto/secrets.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
function masterKey(): Buffer {
|
||||
const raw = process.env.MASTER_KEY;
|
||||
if (!raw || raw.length < 32) {
|
||||
throw new Error("MASTER_KEY env var must be set to a string of at least 32 characters");
|
||||
}
|
||||
return crypto.createHash("sha256").update(raw).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", masterKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
||||
}
|
||||
|
||||
export function decryptSecret(encoded: string): string {
|
||||
const raw = Buffer.from(encoded, "base64");
|
||||
const iv = raw.subarray(0, 12);
|
||||
const authTag = raw.subarray(12, 28);
|
||||
const ciphertext = raw.subarray(28);
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", masterKey(), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
export function generateBearerToken(): string {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
32
apps/server/src/db/index.ts
Normal file
32
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { config } from "../config/env.js";
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
|
||||
export const db = new Database(config.dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo_url TEXT NOT NULL,
|
||||
git_ref TEXT,
|
||||
status TEXT NOT NULL,
|
||||
status_detail TEXT,
|
||||
image_tag TEXT,
|
||||
container_id TEXT,
|
||||
container_name TEXT NOT NULL,
|
||||
internal_port INTEGER NOT NULL DEFAULT 8080,
|
||||
http_path TEXT NOT NULL DEFAULT '/mcp',
|
||||
port_env_var TEXT NOT NULL DEFAULT 'PORT',
|
||||
host_env_var TEXT NOT NULL DEFAULT 'HOST',
|
||||
bearer_token_encrypted TEXT,
|
||||
env_json_encrypted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
192
apps/server/src/db/installations.ts
Normal file
192
apps/server/src/db/installations.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import crypto from "node:crypto";
|
||||
import { db } from "./index.js";
|
||||
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
||||
|
||||
export type InstallationStatus =
|
||||
| "draft"
|
||||
| "cloning"
|
||||
| "building"
|
||||
| "built"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "error";
|
||||
|
||||
export interface Installation {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
gitRef: string | null;
|
||||
status: InstallationStatus;
|
||||
statusDetail: string | null;
|
||||
imageTag: string | null;
|
||||
containerId: string | null;
|
||||
containerName: string;
|
||||
internalPort: number;
|
||||
httpPath: string;
|
||||
portEnvVar: string;
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
repo_url: string;
|
||||
git_ref: string | null;
|
||||
status: InstallationStatus;
|
||||
status_detail: string | null;
|
||||
image_tag: string | null;
|
||||
container_id: string | null;
|
||||
container_name: string;
|
||||
internal_port: number;
|
||||
http_path: string;
|
||||
port_env_var: string;
|
||||
host_env_var: string;
|
||||
bearer_token_encrypted: string | null;
|
||||
env_json_encrypted: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function rowToInstallation(row: Row): Installation {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
repoUrl: row.repo_url,
|
||||
gitRef: row.git_ref,
|
||||
status: row.status,
|
||||
statusDetail: row.status_detail,
|
||||
imageTag: row.image_tag,
|
||||
containerId: row.container_id,
|
||||
containerName: row.container_name,
|
||||
internalPort: row.internal_port,
|
||||
httpPath: row.http_path,
|
||||
portEnvVar: row.port_env_var,
|
||||
hostEnvVar: row.host_env_var,
|
||||
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
||||
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
export function createInstallation(input: { name: string; slug: string; repoUrl: string; gitRef?: string }): Installation {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO installations
|
||||
(id, slug, name, repo_url, git_ref, status, container_name, created_at, updated_at)
|
||||
VALUES (@id, @slug, @name, @repoUrl, @gitRef, 'draft', @containerName, @now, @now)`,
|
||||
).run({
|
||||
id,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
repoUrl: input.repoUrl,
|
||||
gitRef: input.gitRef ?? null,
|
||||
containerName: `mcp-${input.slug}`,
|
||||
now,
|
||||
});
|
||||
return getInstallationById(id)!;
|
||||
}
|
||||
|
||||
export function getInstallationById(id: string): Installation | undefined {
|
||||
const row = db.prepare("SELECT * FROM installations WHERE id = ?").get(id) as Row | undefined;
|
||||
return row ? rowToInstallation(row) : undefined;
|
||||
}
|
||||
|
||||
export function getInstallationBySlug(slug: string): Installation | undefined {
|
||||
const row = db.prepare("SELECT * FROM installations WHERE slug = ?").get(slug) as Row | undefined;
|
||||
return row ? rowToInstallation(row) : undefined;
|
||||
}
|
||||
|
||||
export function listInstallations(): Installation[] {
|
||||
const rows = db.prepare("SELECT * FROM installations ORDER BY created_at DESC").all() as Row[];
|
||||
return rows.map(rowToInstallation);
|
||||
}
|
||||
|
||||
export interface InstallationUpdate {
|
||||
status?: InstallationStatus;
|
||||
statusDetail?: string | null;
|
||||
imageTag?: string | null;
|
||||
containerId?: string | null;
|
||||
internalPort?: number;
|
||||
httpPath?: string;
|
||||
portEnvVar?: string;
|
||||
hostEnvVar?: string;
|
||||
env?: Record<string, string>;
|
||||
bearerToken?: string | null;
|
||||
gitRef?: string | null;
|
||||
}
|
||||
|
||||
export function updateInstallation(id: string, patch: InstallationUpdate): Installation {
|
||||
const current = getInstallationById(id);
|
||||
if (!current) throw new Error(`installation ${id} not found`);
|
||||
|
||||
const next = {
|
||||
status: patch.status ?? current.status,
|
||||
statusDetail: patch.statusDetail !== undefined ? patch.statusDetail : current.statusDetail,
|
||||
imageTag: patch.imageTag !== undefined ? patch.imageTag : current.imageTag,
|
||||
containerId: patch.containerId !== undefined ? patch.containerId : current.containerId,
|
||||
internalPort: patch.internalPort ?? current.internalPort,
|
||||
httpPath: patch.httpPath ?? current.httpPath,
|
||||
portEnvVar: patch.portEnvVar ?? current.portEnvVar,
|
||||
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
||||
env: patch.env ?? current.env,
|
||||
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
|
||||
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE installations SET
|
||||
status = @status,
|
||||
status_detail = @statusDetail,
|
||||
image_tag = @imageTag,
|
||||
container_id = @containerId,
|
||||
internal_port = @internalPort,
|
||||
http_path = @httpPath,
|
||||
port_env_var = @portEnvVar,
|
||||
host_env_var = @hostEnvVar,
|
||||
env_json_encrypted = @envJsonEncrypted,
|
||||
bearer_token_encrypted = @bearerTokenEncrypted,
|
||||
git_ref = @gitRef,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id`,
|
||||
).run({
|
||||
id,
|
||||
status: next.status,
|
||||
statusDetail: next.statusDetail,
|
||||
imageTag: next.imageTag,
|
||||
containerId: next.containerId,
|
||||
internalPort: next.internalPort,
|
||||
httpPath: next.httpPath,
|
||||
portEnvVar: next.portEnvVar,
|
||||
hostEnvVar: next.hostEnvVar,
|
||||
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
||||
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
||||
gitRef: next.gitRef,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return getInstallationById(id)!;
|
||||
}
|
||||
|
||||
export function deleteInstallation(id: string): void {
|
||||
db.prepare("DELETE FROM installations WHERE id = ?").run(id);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import net from "node:net";
|
||||
import Docker from "dockerode";
|
||||
import { config } from "../config/env.js";
|
||||
import type { Installation } from "../installations.js";
|
||||
import type { Installation } from "../db/installations.js";
|
||||
|
||||
const docker = new Docker({ socketPath: config.dockerSocketPath });
|
||||
|
||||
@@ -11,15 +12,19 @@ export async function ensureNetwork(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallationContainer(installation: Installation): Promise<void> {
|
||||
await ensureNetwork(config.mcpNetworkName);
|
||||
|
||||
const existing = docker.getContainer(installation.containerName);
|
||||
export async function removeContainerIfExists(containerName: string): Promise<void> {
|
||||
try {
|
||||
await existing.remove({ force: true });
|
||||
await docker.getContainer(containerName).remove({ force: true });
|
||||
} catch {
|
||||
// container didn't exist yet, nothing to clean up
|
||||
// didn't exist, nothing to clean up
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallationContainer(installation: Installation): Promise<string> {
|
||||
if (!installation.imageTag) throw new Error("installation has no built image yet");
|
||||
|
||||
await ensureNetwork(config.mcpNetworkName);
|
||||
await removeContainerIfExists(installation.containerName);
|
||||
|
||||
const env = {
|
||||
...installation.env,
|
||||
@@ -38,6 +43,11 @@ export async function runInstallationContainer(installation: Installation): Prom
|
||||
});
|
||||
|
||||
await container.start();
|
||||
return container.id;
|
||||
}
|
||||
|
||||
export async function stopInstallationContainer(containerName: string): Promise<void> {
|
||||
await removeContainerIfExists(containerName);
|
||||
}
|
||||
|
||||
export async function getContainerIp(containerName: string): Promise<string> {
|
||||
@@ -49,3 +59,35 @@ export async function getContainerIp(containerName: string): Promise<string> {
|
||||
}
|
||||
return network.IPAddress;
|
||||
}
|
||||
|
||||
export async function waitForContainerHealth(
|
||||
installation: Installation,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const ip = await getContainerIp(installation.containerName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: ip, port: installation.internalPort, timeout: 1000 });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
socket.once("timeout", () => reject(new Error("connect timeout")));
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`container did not start listening on ${installation.portEnvVar}=${installation.internalPort} within ${timeoutMs}ms` +
|
||||
(lastError ? ` (last error: ${lastError.message})` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import { config } from "./config/env.js";
|
||||
import "./db/index.js";
|
||||
import { handleMcpProxyRequest } from "./proxy/mcpProxy.js";
|
||||
import { authRouter } from "./api/auth.js";
|
||||
import { installationsRouter } from "./api/installations.js";
|
||||
import { requireAuth } from "./middleware/requireAuth.js";
|
||||
|
||||
if (!config.sessionSecret) {
|
||||
throw new Error("SESSION_SECRET env var must be set");
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 1000 * 60 * 60 * 12,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Bearer-token-gated MCP reverse proxy, public (auth handled per-installation).
|
||||
app.all("/mcp/:slug", handleMcpProxyRequest);
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/installations", requireAuth, installationsRouter);
|
||||
|
||||
const webDist = path.resolve(import.meta.dirname, "../../web/dist");
|
||||
app.use(express.static(webDist));
|
||||
app.get(/^\/(?!api|mcp|health).*/, (_req, res) => {
|
||||
res.sendFile(path.join(webDist, "index.html"), (err) => {
|
||||
if (err) res.status(404).end();
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`remote-mcp-manager listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export interface Installation {
|
||||
slug: string;
|
||||
imageTag: string;
|
||||
containerName: string;
|
||||
internalPort: number;
|
||||
httpPath: string;
|
||||
portEnvVar: string;
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string;
|
||||
}
|
||||
|
||||
// Slice 2: hardcoded registry, replaced by SQLite-backed storage in slice 3.
|
||||
export const installations: Installation[] = [
|
||||
{
|
||||
slug: "social",
|
||||
imageTag: "localhost/mcpmgr-test-social:latest",
|
||||
containerName: "mcp-social",
|
||||
internalPort: 8080,
|
||||
httpPath: "/mcp",
|
||||
portEnvVar: "PORT",
|
||||
hostEnvVar: "HOST",
|
||||
env: {},
|
||||
bearerToken: "dev-slice2-token",
|
||||
},
|
||||
];
|
||||
|
||||
export function findInstallationBySlug(slug: string): Installation | undefined {
|
||||
return installations.find((i) => i.slug === slug);
|
||||
}
|
||||
9
apps/server/src/middleware/requireAuth.ts
Normal file
9
apps/server/src/middleware/requireAuth.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session.authenticated) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "authentication required" });
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import httpProxy from "http-proxy";
|
||||
import { findInstallationBySlug } from "../installations.js";
|
||||
import { getInstallationBySlug } from "../db/installations.js";
|
||||
import { getContainerIp } from "../docker/containers.js";
|
||||
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
proxy.on("error", (err, _req, res) => {
|
||||
if (!res.headersSent) {
|
||||
if (!(res as Response).headersSent) {
|
||||
(res as Response).status(502).json({ error: "proxy error", detail: err.message });
|
||||
}
|
||||
});
|
||||
@@ -20,9 +20,9 @@ function tokensMatch(expected: string, provided: string): boolean {
|
||||
|
||||
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
||||
const { slug } = req.params;
|
||||
const installation = findInstallationBySlug(slug);
|
||||
if (!installation) {
|
||||
res.status(404).json({ error: "unknown installation" });
|
||||
const installation = getInstallationBySlug(slug);
|
||||
if (!installation || installation.status !== "running" || !installation.bearerToken) {
|
||||
res.status(404).json({ error: "unknown or unavailable installation" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
101
apps/server/src/service/orchestrator.ts
Normal file
101
apps/server/src/service/orchestrator.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type Installation,
|
||||
createInstallation,
|
||||
updateInstallation,
|
||||
getInstallationById,
|
||||
getInstallationBySlug,
|
||||
slugify,
|
||||
} from "../db/installations.js";
|
||||
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
||||
import { runNixpacksBuild } from "../build/nixpacks.js";
|
||||
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
|
||||
import { generateBearerToken } from "../crypto/secrets.js";
|
||||
|
||||
export interface DraftResult {
|
||||
installation: Installation;
|
||||
detectedEnvKeys: string[];
|
||||
}
|
||||
|
||||
export async function createDraftInstallation(input: {
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
gitRef?: string;
|
||||
}): Promise<DraftResult> {
|
||||
const slug = slugify(input.name);
|
||||
if (getInstallationBySlug(slug)) {
|
||||
throw new Error(`an installation with slug "${slug}" already exists`);
|
||||
}
|
||||
const installation = createInstallation({ name: input.name, slug, repoUrl: input.repoUrl, gitRef: input.gitRef });
|
||||
|
||||
try {
|
||||
updateInstallation(installation.id, { status: "cloning" });
|
||||
const dir = await cloneOrUpdateRepo(slug, input.repoUrl, input.gitRef);
|
||||
const detectedEnvKeys = readEnvExampleKeys(dir);
|
||||
const updated = updateInstallation(installation.id, { status: "draft", statusDetail: null });
|
||||
return { installation: updated, detectedEnvKeys };
|
||||
} catch (err) {
|
||||
const updated = updateInstallation(installation.id, { status: "error", statusDetail: (err as Error).message });
|
||||
return { installation: updated, detectedEnvKeys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function configureInstallation(
|
||||
id: string,
|
||||
patch: {
|
||||
env?: Record<string, string>;
|
||||
portEnvVar?: string;
|
||||
hostEnvVar?: string;
|
||||
httpPath?: string;
|
||||
internalPort?: number;
|
||||
},
|
||||
): Installation {
|
||||
return updateInstallation(id, patch);
|
||||
}
|
||||
|
||||
export async function buildInstallation(id: string): Promise<Installation> {
|
||||
const installation = getInstallationById(id);
|
||||
if (!installation) throw new Error("installation not found");
|
||||
|
||||
updateInstallation(id, { status: "building", statusDetail: null });
|
||||
const imageTag = `mcpmgr/${installation.slug}:latest`;
|
||||
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
|
||||
|
||||
if (!result.success) {
|
||||
return updateInstallation(id, {
|
||||
status: "error",
|
||||
statusDetail: `build failed, see log at ${result.logPath}`,
|
||||
});
|
||||
}
|
||||
|
||||
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
||||
}
|
||||
|
||||
export async function deployInstallation(id: string): Promise<Installation> {
|
||||
let installation = getInstallationById(id);
|
||||
if (!installation) throw new Error("installation not found");
|
||||
|
||||
updateInstallation(id, { status: "starting", statusDetail: null });
|
||||
|
||||
try {
|
||||
await runInstallationContainer(installation);
|
||||
await waitForContainerHealth(installation);
|
||||
|
||||
const bearerToken = installation.bearerToken ?? generateBearerToken();
|
||||
return updateInstallation(id, { status: "running", statusDetail: null, bearerToken });
|
||||
} catch (err) {
|
||||
return updateInstallation(id, { status: "error", statusDetail: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeployInstallation(id: string, opts: { rebuild?: boolean } = {}): Promise<Installation> {
|
||||
if (opts.rebuild) {
|
||||
await buildInstallation(id);
|
||||
}
|
||||
return deployInstallation(id);
|
||||
}
|
||||
|
||||
export async function removeInstallationContainer(id: string): Promise<void> {
|
||||
const installation = getInstallationById(id);
|
||||
if (!installation) return;
|
||||
await stopInstallationContainer(installation.containerName);
|
||||
}
|
||||
7
apps/server/src/types/session.d.ts
vendored
Normal file
7
apps/server/src/types/session.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
authenticated?: boolean;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user