Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions apps/api/src/api/servers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import { testSshConnection } from "../../utils/ssh";
import { isPort, SERVER_HOST_RE } from "../../utils/validate";
import { created, fail, ok } from "../response";

const redactServer = <T extends { sshKey?: string | null; sshPassword?: string | null }>(server: T): T => {
if (server.sshKey) server.sshKey = "[redacted]";
if (server.sshPassword) server.sshPassword = "[redacted]";
return server;
};

export const serversRoutes = new Elysia()
.get("/servers", async () => ok(await listServers()))
.get("/servers", async () => ok((await listServers()).map((server) => redactServer(server))))
.post("/servers", async ({ body, set }: any) => {
if (!body?.name || !body?.host) {
set.status = 400;
Expand All @@ -26,17 +32,19 @@ export const serversRoutes = new Elysia()
return fail("port must be between 1 and 65535");
}
return created(
await createServer({
name: body.name,
host: body.host,
port: body.port ? Number(body.port) : body.mode === "ssh" ? 22 : 2375,
mode: body.mode ?? "ssh",
sshUser: body.sshUser ?? "root",
sshKey: body.sshKey,
sshKeyId: body.sshKeyId,
sshPassword: body.sshPassword,
authToken: body.authToken,
}),
redactServer(
await createServer({
name: body.name,
host: body.host,
port: body.port ? Number(body.port) : body.mode === "ssh" ? 22 : 2375,
mode: body.mode ?? "ssh",
sshUser: body.sshUser ?? "root",
sshKey: body.sshKey,
sshKeyId: body.sshKeyId,
sshPassword: body.sshPassword,
authToken: body.authToken,
}),
),
);
})
.get("/servers/:id", async ({ params: { id }, set }) => {
Expand All @@ -45,7 +53,7 @@ export const serversRoutes = new Elysia()
set.status = 404;
return fail("Server not found");
}
return ok(server);
return ok(redactServer(server));
})
.get("/servers/:id/stats", async ({ params: { id }, set }) => {
const server = await getServerById(id);
Expand Down Expand Up @@ -166,8 +174,8 @@ export const serversRoutes = new Elysia()
return fail("Server not found");
}
if (server.mode === "ssh") {
const passed = await testSshConnection(server);
return ok({ ok: passed, mode: "ssh" });
const result = await testSshConnection(server);
return ok({ ...result, mode: "ssh" });
}
return ok({ ok: server.status === "connected", mode: server.mode });
})
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/databases/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ describe("database input validation", () => {
expect(result.ok).toBe(true);
});

it("preserves explicit backup settings including disabled backup", () => {
const result = validateDatabaseCreate({
name: "orders",
type: "postgresql",
publicAccess: false,
backupEnabled: false,
backupSchedule: "0 0 * * *",
backupRetention: 14,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.input.backupEnabled).toBe(false);
expect(result.input.backupSchedule).toBe("0 0 * * *");
expect(result.input.backupRetention).toBe(14);
}
});

it("validates IPv4 CIDRs and rejects IPv6 ranges", () => {
expect(isValidCidr("10.0.0.0/8")).toBe(true);
expect(isValidCidr("203.0.113.4")).toBe(true);
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/databases/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const validateDatabaseCreate = (
publicAccess,
allowPublicAccessFromAnywhere,
allowedCidrs,
backupEnabled: body.backupEnabled !== undefined ? Boolean(body.backupEnabled) : true,
backupSchedule:
typeof body.backupSchedule === "string" && body.backupSchedule.trim()
? body.backupSchedule.trim()
: "0 */6 * * *",
backupRetention: typeof body.backupRetention === "number" && body.backupRetention > 0 ? body.backupRetention : 7,
},
};
};
37 changes: 25 additions & 12 deletions apps/api/src/db/repo/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { decryptValue, encryptValue } from "../../utils/crypto";
import { getDb } from "../db-provider";
import { servers } from "../schema";
import { formatTimestamp, getRowsAffected, now } from "./helpers";
import { resolveServerSshKey } from "./ssh-keys";

const mapServer = (row: typeof servers.$inferSelect): Server => ({
id: row.id,
Expand Down Expand Up @@ -37,6 +38,14 @@ const mapServer = (row: typeof servers.$inferSelect): Server => ({
updatedAt: formatTimestamp(row.updatedAt),
});

const hydrateSshKey = async <T extends { sshKeyId?: string | null; sshKey?: string | null }>(server: T): Promise<T> => {
if (server.sshKeyId && !server.sshKey) {
const pooled = await resolveServerSshKey({ sshKeyId: server.sshKeyId });
if (pooled) server.sshKey = pooled;
}
return server;
};

const parseJsonObject = (value: unknown): Record<string, unknown> => {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value;
if (typeof value !== "string") return {};
Expand Down Expand Up @@ -78,7 +87,7 @@ export const createServer = async (input: CreateServerInput): Promise<Server> =>
})
.execute();
const [row] = await db.select().from(servers).where(eq(servers.id, id)).execute();
return mapServer(row);
return hydrateSshKey(mapServer(row));
};

export interface ServerConnection {
Expand Down Expand Up @@ -111,15 +120,19 @@ export const listServerConnections = async (): Promise<ServerConnection[]> => {
})
.from(servers)
.execute();
return rows.map((row) => ({
...row,
mode: row.mode as ServerMode,
sshKey:
row.sshKey && row.sshKeyIv && row.sshKeyTag
? decryptValue(row.sshKey, row.sshKeyIv, row.sshKeyTag, config.envEncryptionKey)
: (row.sshKey ?? null),
sshKeyId: row.sshKeyId ?? null,
}));
return Promise.all(
rows.map((row) =>
hydrateSshKey({
...row,
mode: row.mode as ServerMode,
sshKey:
row.sshKey && row.sshKeyIv && row.sshKeyTag
? decryptValue(row.sshKey, row.sshKeyIv, row.sshKeyTag, config.envEncryptionKey)
: (row.sshKey ?? null),
sshKeyId: row.sshKeyId ?? null,
}),
),
);
};

export const ensureLocalServer = async (): Promise<Server> => {
Expand Down Expand Up @@ -151,13 +164,13 @@ export const ensureLocalServer = async (): Promise<Server> => {
export const listServers = async (): Promise<Server[]> => {
const db = await getDb();
const rows = await db.select().from(servers).orderBy(servers.name).execute();
return rows.map(mapServer);
return Promise.all(rows.map((row) => hydrateSshKey(mapServer(row))));
};

export const getServerById = async (id: string): Promise<Server | null> => {
const db = await getDb();
const [row] = await db.select().from(servers).where(eq(servers.id, id)).execute();
return row ? mapServer(row) : null;
return row ? hydrateSshKey(mapServer(row)) : null;
};

export const updateServerStatus = async (
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/utils/__tests__/ingress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { describe, expect, it, mock } from "bun:test";
import { ingressSite, projectServerSite, shouldRouteViaIngress } from "../ingress";

describe("ingress route shapes", () => {
it("writes a :80 listener site when the project server is behind the ingress", () => {
it("writes an HTTP-only hostname site when the project server is behind the ingress", () => {
const snippet = projectServerSite("app.example.com", 3000, ["deploy-dep-1"], true);
expect(snippet).toContain(":80 {");
expect(snippet).toContain("http://app.example.com {");
expect(snippet).not.toContain(":80 {");
expect(snippet).toContain("reverse_proxy deploy-dep-1:3000");
});

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/utils/ingress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const projectServerSite = (
const targets = containers.map((c) => `${c}:${port}`).join(" ");
const proxy = caddyReverseProxy(targets);
if (viaIngress) {
return caddySite(":80", proxy);
return caddySite(`http://${hostname}`, proxy);
}
return caddySite(hostname, proxy);
};
Expand Down
113 changes: 73 additions & 40 deletions apps/api/src/utils/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,6 @@ export const ensureSshKey = (server: {
return writeKeyToDisk(server.host, server.sshKey, server.id);
};

export const ensureSshKeyAsync = async (server: {
host: string;
port?: number;
sshUser?: string | null;
sshKey?: string | null;
sshKeyId?: string | null;
id?: string;
}): Promise<string | null> => {
if (server.sshKeyId) {
const { resolveServerSshKey } = await import("../db/repo/ssh-keys");
const resolved = await resolveServerSshKey({
sshKeyId: server.sshKeyId,
sshKey: server.sshKey ?? undefined,
sshKeyIv: undefined,
sshKeyTag: undefined,
});
if (resolved) return writeKeyToDisk(server.host, resolved, server.id);
}
if (server.sshKey) return writeKeyToDisk(server.host, server.sshKey, server.id);
return null;
};

const writeKeyToDisk = (host: string, sshKey: string, id?: string): string | null => {
if (!existsSync(SSH_KEYS_DIR)) {
mkdirSync(SSH_KEYS_DIR, { recursive: true, mode: 0o700 });
Expand Down Expand Up @@ -80,32 +58,76 @@ export const getDockerSshTarget = (
return `ssh://${user}@${server.host}:${port}`;
};

export const testSshConnection = (server: {
export interface SshConnectionResult {
ok: boolean;
ssh: boolean;
docker: boolean;
detail: string;
}

const probeDockerOverSsh = (server: {
host: string;
port?: number;
sshUser?: string | null;
sshKey?: string | null;
id?: string;
}): Promise<boolean> => {
return new Promise((resolve) => {
}): Promise<{ ok: boolean; detail: string }> =>
new Promise((resolve) => {
const target = getDockerSshTarget(server);
const child = spawn("docker", ["-H", target, "info", "--format", "{{.ServerVersion}}"], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 15_000,
});
let output = "";
let errors = "";
child.stdout?.on("data", (chunk) => {
output += String(chunk);
});
child.stderr?.on("data", (chunk) => {
errors += String(chunk);
});
child.on("close", (code) => {
resolve(code === 0 && output.trim().length > 0);
const version = output.trim();
if (code === 0 && version) {
resolve({ ok: true, detail: `Docker ${version}` });
return;
}
resolve({ ok: false, detail: (errors || output).trim() || `docker info exited with code ${code}` });
});
child.on("error", () => resolve(false));
child.on("error", (err) => resolve({ ok: false, detail: err instanceof Error ? err.message : String(err) }));
child.on("timeout", () => {
child.kill();
resolve(false);
resolve({ ok: false, detail: "Timed out contacting the remote Docker daemon" });
});
});

export const testSshConnection = async (server: {
host: string;
port?: number;
sshUser?: string | null;
sshKey?: string | null;
id?: string;
}): Promise<SshConnectionResult> => {
const probe = await execRemoteCommand(server, "echo dequel-ok").catch((err: unknown) => ({
code: 1,
stdout: "",
stderr: err instanceof Error ? err.message : String(err),
}));
if (probe.code !== 0 || probe.stdout.trim() !== "dequel-ok") {
return {
ok: false,
ssh: false,
docker: false,
detail: probe.stderr || probe.stdout || `ssh exited with code ${probe.code}`,
};
}
const docker = await probeDockerOverSsh(server);
return {
ok: docker.ok,
ssh: true,
docker: docker.ok,
detail: docker.ok ? docker.detail : `SSH connected, but Docker is unavailable: ${docker.detail}`,
};
};

export const execDockerSshCommand = (
Expand Down Expand Up @@ -165,22 +187,26 @@ export const syncRemoteCaddyRoute = (
server: Server | { host: string; port?: number; sshUser?: string | null },
filename: string,
content: string,
): Promise<boolean> => {
return new Promise((resolve) => {
): Promise<void> =>
new Promise((resolve, reject) => {
const rejectRoute = (reason: string) =>
reject(new Error(`Failed to sync Caddy route ${filename} to ${server.host}: ${reason}`));

if (!/^[a-zA-Z0-9._-]+$/.test(filename) || filename.includes("..")) {
resolve(false);
rejectRoute("invalid route filename");
return;
}

const blockPattern = /^([^\n{]+?)\s*\{/gm;
let match: RegExpExecArray | null;
while ((match = blockPattern.exec(content)) !== null) {
const domainLine = match[1].trim();
const domains = domainLine.split(",").map((d) => d.trim());
const domains = match[1]
.trim()
.split(",")
.map((d) => d.trim());
for (const d of domains) {
if (/^:\d+$/.test(d)) {
console.error(`Rejected catch-all Caddy route in ${filename}: "${d}" — must include a hostname`);
resolve(false);
rejectRoute(`route block "${d}" must include a hostname`);
return;
}
}
Expand All @@ -200,20 +226,27 @@ export const syncRemoteCaddyRoute = (
"ConnectTimeout=10",
...keyArgs,
`${user}@${server.host}`,
`sudo mkdir -p /etc/caddy/routes && sudo tee /etc/caddy/routes/${filename} > /dev/null && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)`,
`sudo mkdir -p /etc/caddy/routes && sudo tee /etc/caddy/routes/${filename} > /dev/null && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload --config /etc/caddy/Caddyfile || true)`,
],
{ stdio: ["pipe", "pipe", "pipe"] },
);

sshCmd.stdin?.write(content);
sshCmd.stdin?.end();

let stderr = "";
sshCmd.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
sshCmd.on("close", (code) => {
resolve(code === 0);
if (code === 0) {
resolve();
return;
}
rejectRoute(stderr.trim() || `ssh exited with code ${code}`);
});
sshCmd.on("error", () => resolve(false));
sshCmd.on("error", (err) => rejectRoute(err.message));
});
};

export const removeRemoteCaddyRoute = (
server: Server | { host: string; port?: number; sshUser?: string | null; sshKey?: string | null; id?: string },
Expand All @@ -239,7 +272,7 @@ export const removeRemoteCaddyRoute = (
"ConnectTimeout=10",
...keyArgs,
`${user}@${server.host}`,
`sudo rm -f /etc/caddy/routes/${filename} && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)`,
`sudo rm -f /etc/caddy/routes/${filename} && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload --config /etc/caddy/Caddyfile || true)`,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
Expand Down
Loading
Loading