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
49 changes: 49 additions & 0 deletions apps/api/src/api/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import { RATE_LIMIT_RULES, getClientIp, rateLimitMiddleware, rateLimiter } from "../rate-limit";

describe("Rate Limiting Middleware", () => {
beforeEach(() => {
rateLimiter.reset();
process.env.TEST_RATE_LIMIT = "true";
});

it("extracts client IP from headers with priority", () => {
const req1 = new Request("http://localhost/test", {
headers: { "x-real-ip": "1.1.1.1", "cf-connecting-ip": "2.2.2.2" },
});
expect(getClientIp(req1)).toBe("1.1.1.1");

const req2 = new Request("http://localhost/test", {
headers: { "x-forwarded-for": "2.2.2.2, 3.3.3.3" },
});
expect(getClientIp(req2)).toBe("2.2.2.2");

const req3 = new Request("http://localhost/test", {
headers: { "cf-connecting-ip": "4.4.4.4" },
});
expect(getClientIp(req3)).toBe("4.4.4.4");

const req4 = new Request("http://localhost/test");
expect(getClientIp(req4)).toBe("127.0.0.1");
});

it("enforces rate limits and sets 429 Retry-After headers when exceeded", async () => {
const app = new Elysia().use(rateLimitMiddleware).post("/api/auth/login", () => ({ success: true }));

const headers = { "x-real-ip": "198.51.100.1" };

for (let i = 0; i < RATE_LIMIT_RULES["/api/auth/login"].max; i++) {
const res = await app.handle(new Request("http://localhost/api/auth/login", { method: "POST", headers }));
expect(res.status).toBe(200);
expect(res.headers.get("RateLimit-Limit")).toBe(String(RATE_LIMIT_RULES["/api/auth/login"].max));
}

const blocked = await app.handle(new Request("http://localhost/api/auth/login", { method: "POST", headers }));
expect(blocked.status).toBe(429);
expect(blocked.headers.get("RateLimit-Remaining")).toBe("0");
expect(blocked.headers.get("Retry-After")).toBeDefined();
const body = await blocked.json();
expect(body.status).toBe("error");
});
});
41 changes: 41 additions & 0 deletions apps/api/src/api/__tests__/server-validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { validateServerCreate } from "../servers/validate";

describe("validateServerCreate", () => {
test("rejects inline ssh keys", () => {
expect(
validateServerCreate({
name: "node-1",
host: "10.0.0.1",
sshKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
sshKeyId: "key-1",
}),
).toContain("SSH key pool");
});

test("rejects ssh mode without pool key or password", () => {
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1" })).toContain("sshKeyId");
});

test("accepts ssh mode with pool key", () => {
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1", sshKeyId: "key-1" })).toBeNull();
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1", sshPassword: "secret" })).toBeNull();
});

test("rejects non-string sshPassword values", () => {
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1", sshPassword: { pw: true } })).toContain(
"sshPassword",
);
});

test("agent mode needs no ssh credentials", () => {
expect(validateServerCreate({ name: "homelab", host: "10.0.0.1", mode: "agent" })).toBeNull();
});

test("keeps host/mode/port validation", () => {
expect(validateServerCreate({ name: "node-1", host: "bad_host", sshKeyId: "k" })).toContain("host");
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1", mode: "docker", sshKeyId: "k" })).toContain("mode");
expect(validateServerCreate({ name: "node-1", host: "10.0.0.1", port: 99999, sshKeyId: "k" })).toContain("port");
expect(validateServerCreate({ name: "", host: "10.0.0.1", sshKeyId: "k" })).toContain("name and host");
});
});
21 changes: 21 additions & 0 deletions apps/api/src/api/audit-logs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Elysia } from "elysia";
import { countAuditLogs, listAuditLogs } from "../../db/repo";
import { ok } from "../response";

export const auditLogsRoutes = new Elysia().get("/audit-logs", async ({ query }: any) => {
const rawLimit = Number(query?.limit);
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 100) : 50;
const rawOffset = Number(query?.offset);
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0;
const filters = {
actorType: query?.actorType,
actorId: query?.actorId,
action: query?.action,
resourceType: query?.resourceType,
resourceId: query?.resourceId,
limit,
offset,
};
const [items, total] = await Promise.all([listAuditLogs(filters), countAuditLogs(filters)]);
return ok({ items, total, limit, offset });
});
76 changes: 75 additions & 1 deletion apps/api/src/api/backups/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { randomUUID } from "node:crypto";
import { Elysia } from "elysia";
import { BackupOrchestrator } from "../../backup/orchestrator";
import { runRestoreTest } from "../../backup/restore-test";
import type { BackupTarget, StorageConfig } from "../../backup/types";
import { S3_BACKUP_PREFIX } from "../../backup/types";
import { deleteBackupRecord, getBackupRecord, listBackupRecords } from "../../db/repo/backups";
import { getDatabaseById } from "../../db/repo/databases";
import { createRestoreTestRecord, listRestoreTests, updateRestoreTestRecord } from "../../db/repo/restore-tests";
import { getBackupStorageSettings } from "../../db/repo/settings";
import { created, fail, ok } from "../response";

Expand Down Expand Up @@ -117,13 +120,84 @@ export const backupRoutes = new Elysia({ prefix: "/backups" })
return ok({ deleted: true });
})
.post("/:id/restore", async ({ params, set }) => {
const record = await getBackupRecord(params.id);
if (!record) {
set.status = 404;
return fail("Backup not found");
}
let serverId: string | null = null;
if (record.targetId !== "internal") {
const db = await getDatabaseById(record.targetId);
serverId = db?.serverId ?? null;
}
const event = await createRestoreTestRecord({
id: randomUUID(),
backupId: record.id,
targetId: record.targetId,
serverId,
kind: "restore",
});
let restoreError: unknown = null;
try {
const storage = await loadStorageConfig();
const orchestrator = new BackupOrchestrator(storage);
await orchestrator.restore(params.id, resolveTarget);
return ok({ restored: true });
} catch (error) {
restoreError = error;
}
try {
await updateRestoreTestRecord(event.id, {
status: restoreError ? "failed" : "passed",
error: restoreError ? String(restoreError) : null,
completedAt: new Date(),
});
} catch (eventError) {
console.error(`[backups] failed to record restore event ${event.id}:`, eventError);
}
if (restoreError) {
set.status = 500;
return fail(String(restoreError));
}
return ok({ restored: true });
})
.post("/:id/restore-test", async ({ params, body, set }: any) => {
try {
const record = await getBackupRecord(params.id);
if (!record) {
set.status = 404;
return fail("Backup not found");
}
const test = await runRestoreTest({ backupId: params.id, serverId: body?.serverId ?? null });
if (test?.status !== "passed") {
set.status = 500;
return fail(test?.error || "Restore test failed");
}
return created(test);
} catch (error) {
set.status = 500;
return fail(String(error));
}
})
.get("/restore-tests", async ({ query }: any) => {
const limit = query?.limit ? Number(query.limit) : undefined;
return ok(
await listRestoreTests({
backupId: query?.backupId,
targetId: query?.targetId,
status: query?.status,
kind: query?.kind,
limit: Number.isInteger(limit) && (limit as number) > 0 ? limit : undefined,
}),
);
})
.get("/:id/restore-tests", async ({ params, query }: any) => {
const limit = query?.limit ? Number(query.limit) : undefined;
return ok(
await listRestoreTests({
backupId: params.id,
status: query?.status,
kind: query?.kind,
limit: Number.isInteger(limit) && (limit as number) > 0 ? limit : undefined,
}),
);
});
4 changes: 4 additions & 0 deletions apps/api/src/api/deployments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { basename, join } from "node:path";
import { Elysia } from "elysia";
import { validateRemoteDeployment } from "../../agents/deployments";
import {
captureProjectGithubToken,
countDeployments,
createDeployment,
getDeploymentById,
Expand Down Expand Up @@ -131,6 +132,9 @@ export const deploymentsRoutes = new Elysia()
commitSha,
clearCache,
});
if (projectId) {
await captureProjectGithubToken(projectId, gitUrl, request.headers.get("cookie"));
}
await dispatchDeployment(deployment, project, server);
return created(deployment);
}
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/api/domains/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const domainsRoutes = new Elysia()
type: body.type ?? "custom",
targetService: body.targetService || null,
targetPort,
cloudflareProxied: Boolean(body.cloudflareProxied),
});
const { validateDomain, resolveServerIp } = await import("../../utils/dns");
const project = await getProjectById(params.id);
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Elysia } from "elysia";
import { fixdiagRoutes } from "../fixdiag/routes";
import { rateLimitMiddleware } from "./rate-limit";
import { agentRoutes } from "./agents";
import { alertsRoutes } from "./alerts";
import { apiKeysRoutes } from "./api-keys";
import { auditLogsRoutes } from "./audit-logs";
import { authRoutes } from "./auth";
import { authMiddleware } from "./auth-middleware";
import { backupRoutes } from "./backups";
Expand Down Expand Up @@ -53,8 +55,10 @@ export const apiRoutes = new Elysia({
}
return fail(message);
})
.use(rateLimitMiddleware)
.use(authRoutes)
.use(authMiddleware)
.use(auditLogsRoutes)
.use(agentRoutes)
.use(healthRoutes)
.use(projectsRoutes)
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/api/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { Elysia } from "elysia";
import {
captureProjectGithubToken,
createProject,
deleteProjectCascade,
getProjectById,
Expand Down Expand Up @@ -58,7 +59,7 @@ export const projectsRoutes = new Elysia()
}
return ok(project);
})
.post("/projects", async ({ body, set }: any) => {
.post("/projects", async ({ body, request, set }: any) => {
if (!body?.name) {
set.status = 400;
return fail("name is required");
Expand Down Expand Up @@ -117,6 +118,11 @@ export const projectsRoutes = new Elysia()
project_type: project.projectType,
source_type: project.sourceType,
}).catch(() => {});
await captureProjectGithubToken(project.id, body.repoUrl, request?.headers?.get("cookie") ?? null).then(
(captured) => {
if (captured) project.hasGithubToken = true;
},
);
return created(project);
})
.patch("/projects/:id", async ({ params: { id }, body, set }: any) => {
Expand Down
Loading
Loading