diff --git a/docs/public/operations/configuration.md b/docs/public/operations/configuration.md index b934b0b5..fab272b2 100644 --- a/docs/public/operations/configuration.md +++ b/docs/public/operations/configuration.md @@ -145,6 +145,22 @@ For more details, see [Backup & Restore](backup-restore.md). OIDC is configured in the Settings page under the **Authentication** tab. See [Authentication](authentication.md) for full setup instructions. +## Prometheus metrics + +The `/api/metrics` endpoint exposes metrics in Prometheus exposition format. It requires a service account API token with the `metrics.read` permission. + +Create a service account in **Settings > Service Accounts** with `metrics.read` permission, then configure your Prometheus scrape config: + +```yaml +scrape_configs: + - job_name: vectorflow + scheme: https + metrics_path: /api/metrics + bearer_token: "vf_your_service_account_key" + static_configs: + - targets: ["vectorflow.example.com"] +``` + ## Ports reference | Service | Default Port | Description | diff --git a/src/app/api/_lib/__tests__/ip-rate-limit.test.ts b/src/app/api/_lib/__tests__/ip-rate-limit.test.ts new file mode 100644 index 00000000..3023417a --- /dev/null +++ b/src/app/api/_lib/__tests__/ip-rate-limit.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { checkIpRateLimit } from "../ip-rate-limit"; + +function makeRequest( + ip?: string, + headers?: Record, +): Request { + const h: Record = { ...headers }; + if (ip) h["x-forwarded-for"] = ip; + return new Request("http://localhost/api/test", { headers: h }); +} + +describe("checkIpRateLimit", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns null when under the limit", () => { + const result = checkIpRateLimit(makeRequest("1.2.3.4"), "enroll", 10); + expect(result).toBeNull(); + }); + + it("returns 429 response when limit exceeded", () => { + for (let i = 0; i < 10; i++) { + checkIpRateLimit(makeRequest("5.6.7.8"), "enroll", 10); + } + const result = checkIpRateLimit(makeRequest("5.6.7.8"), "enroll", 10); + expect(result).not.toBeNull(); + expect(result!.status).toBe(429); + }); + + it("includes Retry-After header on 429", () => { + for (let i = 0; i < 5; i++) { + checkIpRateLimit(makeRequest("9.0.1.2"), "setup", 5); + } + const result = checkIpRateLimit(makeRequest("9.0.1.2"), "setup", 5); + expect(result!.headers.get("Retry-After")).toBeTruthy(); + }); + + it("isolates limits between different IPs", () => { + for (let i = 0; i < 10; i++) { + checkIpRateLimit(makeRequest("10.0.0.1"), "enroll", 10); + } + expect(checkIpRateLimit(makeRequest("10.0.0.1"), "enroll", 10)).not.toBeNull(); + expect(checkIpRateLimit(makeRequest("10.0.0.2"), "enroll", 10)).toBeNull(); + }); + + it("extracts IP from x-forwarded-for (rightmost entry)", () => { + const req = makeRequest(undefined, { + "x-forwarded-for": "203.0.113.50, 70.41.3.18, 150.172.238.178", + }); + for (let i = 0; i < 10; i++) { + checkIpRateLimit(req, "enroll", 10); + } + // Rightmost entry is the proxy-appended IP + const blocked = checkIpRateLimit( + makeRequest("150.172.238.178"), + "enroll", + 10, + ); + expect(blocked).not.toBeNull(); + }); + + it("falls back to x-real-ip when x-forwarded-for missing", () => { + const req = new Request("http://localhost/api/test", { + headers: { "x-real-ip": "192.168.1.1" }, + }); + for (let i = 0; i < 5; i++) { + checkIpRateLimit(req, "setup", 5); + } + const result = checkIpRateLimit(req, "setup", 5); + expect(result).not.toBeNull(); + }); + + it("uses 'unknown' key when no IP headers present", () => { + const req = new Request("http://localhost/api/test"); + const result = checkIpRateLimit(req, "enroll", 10); + expect(result).toBeNull(); + }); + + it("resets after window expires", () => { + for (let i = 0; i < 10; i++) { + checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10); + } + expect(checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10)).not.toBeNull(); + + vi.advanceTimersByTime(61_000); + + expect(checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10)).toBeNull(); + }); +}); diff --git a/src/app/api/_lib/ip-rate-limit.ts b/src/app/api/_lib/ip-rate-limit.ts new file mode 100644 index 00000000..d3bb4e1e --- /dev/null +++ b/src/app/api/_lib/ip-rate-limit.ts @@ -0,0 +1,41 @@ +import { rateLimiter } from "@/app/api/v1/_lib/rate-limiter"; + +function getClientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const parts = forwarded.split(","); + return parts[parts.length - 1].trim(); + } + + const realIp = request.headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + + return "unknown"; +} + +/** + * Check an IP-keyed rate limit for unauthenticated endpoints. + * Returns a 429 Response if the limit is exceeded, or null if allowed. + */ +export function checkIpRateLimit( + request: Request, + endpoint: string, + limit: number, +): Response | null { + const ip = getClientIp(request); + const key = `ip:${endpoint}:${ip}`; + + const result = rateLimiter.checkKey(key, limit); + + if (!result.allowed) { + return new Response(JSON.stringify({ error: "Too many requests" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(result.retryAfter), + }, + }); + } + + return null; +} diff --git a/src/app/api/agent/enroll/route.ts b/src/app/api/agent/enroll/route.ts index c4f7c15c..50976a62 100644 --- a/src/app/api/agent/enroll/route.ts +++ b/src/app/api/agent/enroll/route.ts @@ -5,6 +5,7 @@ import { verifyEnrollmentToken, generateNodeToken } from "@/server/services/agen import { fireEventAlert } from "@/server/services/event-alerts"; import { debugLog } from "@/lib/logger"; import { nodeMatchesGroup } from "@/lib/node-group-utils"; +import { checkIpRateLimit } from "@/app/api/_lib/ip-rate-limit"; const enrollSchema = z.object({ token: z.string().min(1), @@ -15,6 +16,9 @@ const enrollSchema = z.object({ }); export async function POST(request: Request) { + const rateLimited = checkIpRateLimit(request, "enroll", 10); + if (rateLimited) return rateLimited; + try { const body = await request.json(); const parsed = enrollSchema.safeParse(body); diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts new file mode 100644 index 00000000..8ea0e94e --- /dev/null +++ b/src/app/api/health/__tests__/route.test.ts @@ -0,0 +1,34 @@ +import { vi, describe, it, expect } from "vitest"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + $queryRaw: vi.fn(), + }, +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/prisma"; + +describe("GET /api/health", () => { + it("returns { status: 'ok' } with 200 when DB is reachable", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "ok" }); + expect(body).not.toHaveProperty("db"); + }); + + it("returns { status: 'error' } with 503 when DB is unreachable", async () => { + vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("ECONNREFUSED")); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body).toEqual({ status: "error" }); + expect(body).not.toHaveProperty("db"); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index a85fdffb..4ae43680 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -3,11 +3,8 @@ import { prisma } from "@/lib/prisma"; export async function GET() { try { await prisma.$queryRaw`SELECT 1`; - return Response.json({ status: "ok", db: "connected" }); + return Response.json({ status: "ok" }); } catch { - return Response.json( - { status: "error", db: "disconnected" }, - { status: 503 }, - ); + return Response.json({ status: "error" }, { status: 503 }); } } diff --git a/src/app/api/metrics/__tests__/route.test.ts b/src/app/api/metrics/__tests__/route.test.ts index d1d2c25c..c03b4b11 100644 --- a/src/app/api/metrics/__tests__/route.test.ts +++ b/src/app/api/metrics/__tests__/route.test.ts @@ -1,27 +1,24 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; -// ── Hoisted mocks (available inside vi.mock factories) ────────── const { mockCollectMetrics, mockAuthenticateApiKey } = vi.hoisted(() => ({ mockCollectMetrics: vi.fn(), mockAuthenticateApiKey: vi.fn(), })); -// ── Mock PrometheusMetricsService ─────────────────────────────── vi.mock("@/server/services/prometheus-metrics", () => ({ PrometheusMetricsService: class { collectMetrics = mockCollectMetrics; }, })); -// ── Mock authenticateApiKey ───────────────────────────────────── vi.mock("@/server/middleware/api-auth", () => ({ authenticateApiKey: (...args: unknown[]) => mockAuthenticateApiKey(...args), + hasPermission: (ctx: { permissions: string[] }, perm: string) => + ctx.permissions.includes(perm), })); import { GET } from "@/app/api/metrics/route"; -// ─── Helpers ──────────────────────────────────────────────────── - function makeRequest(headers?: Record): Request { return new Request("http://localhost:3000/api/metrics", { method: "GET", @@ -29,52 +26,21 @@ function makeRequest(headers?: Record): Request { }); } -// ─── Tests ────────────────────────────────────────────────────── - describe("GET /api/metrics", () => { beforeEach(() => { vi.clearAllMocks(); - // Default: auth not required - delete process.env.METRICS_AUTH_REQUIRED; - }); - - it("returns metrics with correct content type when auth disabled (default)", async () => { - const metricsOutput = '# HELP vectorflow_node_status Node status\nvectorflow_node_status{node_id="n1"} 1\n'; - mockCollectMetrics.mockResolvedValue(metricsOutput); - - const response = await GET(makeRequest()); - - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe( - "text/plain; version=0.0.4; charset=utf-8", - ); - const body = await response.text(); - expect(body).toBe(metricsOutput); - expect(mockAuthenticateApiKey).not.toHaveBeenCalled(); - }); - - it("does not require auth header when METRICS_AUTH_REQUIRED is unset", async () => { - mockCollectMetrics.mockResolvedValue(""); - - const response = await GET(makeRequest()); - expect(response.status).toBe(200); - expect(mockAuthenticateApiKey).not.toHaveBeenCalled(); }); - it("returns 401 when auth required and no token provided", async () => { - process.env.METRICS_AUTH_REQUIRED = "true"; + it("returns 401 when no auth header provided", async () => { mockAuthenticateApiKey.mockResolvedValue(null); const response = await GET(makeRequest()); expect(response.status).toBe(401); - const body = await response.text(); - expect(body).toContain("Unauthorized"); expect(mockAuthenticateApiKey).toHaveBeenCalledWith(null); }); - it("returns 401 when auth required and invalid token provided", async () => { - process.env.METRICS_AUTH_REQUIRED = "true"; + it("returns 401 when invalid token provided", async () => { mockAuthenticateApiKey.mockResolvedValue(null); const response = await GET( @@ -85,13 +51,27 @@ describe("GET /api/metrics", () => { expect(mockAuthenticateApiKey).toHaveBeenCalledWith("Bearer invalid_token"); }); - it("returns metrics when auth required and valid token provided", async () => { - process.env.METRICS_AUTH_REQUIRED = "true"; + it("returns 401 when token lacks metrics.read permission", async () => { + mockAuthenticateApiKey.mockResolvedValue({ + serviceAccountId: "sa-1", + serviceAccountName: "deploy-bot", + environmentId: "env-1", + permissions: ["pipelines.deploy"], + }); + + const response = await GET( + makeRequest({ Authorization: "Bearer vf_deploy_token" }), + ); + + expect(response.status).toBe(401); + }); + + it("returns metrics when valid token provided", async () => { mockAuthenticateApiKey.mockResolvedValue({ serviceAccountId: "sa-1", serviceAccountName: "prom-scraper", environmentId: "env-1", - permissions: ["read"], + permissions: ["metrics.read"], }); mockCollectMetrics.mockResolvedValue("vectorflow_node_status 1\n"); @@ -100,20 +80,28 @@ describe("GET /api/metrics", () => { ); expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/plain; version=0.0.4; charset=utf-8", + ); const body = await response.text(); expect(body).toBe("vectorflow_node_status 1\n"); }); it("returns 500 when collectMetrics throws", async () => { + mockAuthenticateApiKey.mockResolvedValue({ + serviceAccountId: "sa-1", + serviceAccountName: "prom-scraper", + environmentId: "env-1", + permissions: ["metrics.read"], + }); mockCollectMetrics.mockRejectedValue(new Error("Service crash")); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const response = await GET(makeRequest()); + const response = await GET( + makeRequest({ Authorization: "Bearer vf_valid_token" }), + ); expect(response.status).toBe(500); - const body = await response.text(); - expect(body).toContain("Internal Server Error"); - consoleSpy.mockRestore(); }); }); diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index 85eb4237..f98028a0 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -1,38 +1,30 @@ import { PrometheusMetricsService } from "@/server/services/prometheus-metrics"; -import { authenticateApiKey } from "@/server/middleware/api-auth"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; const service = new PrometheusMetricsService(); /** * GET /api/metrics — Prometheus exposition format endpoint. * - * Auth model (D022): unauthenticated by default. - * Set METRICS_AUTH_REQUIRED=true to require a valid Bearer token - * via authenticateApiKey (same as the V1 REST API). + * Requires a valid service account Bearer token with `metrics.read` permission. + * Configure your Prometheus scraper with: bearer_token: "vf_" */ export async function GET(request: Request) { - // ── Opt-in auth ─────────────────────────────────────────────── - const authRequired = process.env.METRICS_AUTH_REQUIRED === "true"; - - if (authRequired) { - const authHeader = request.headers.get("authorization"); - const ctx = await authenticateApiKey(authHeader); - if (!ctx) { - return new Response("Unauthorized\n", { - status: 401, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); - } + const authHeader = request.headers.get("authorization"); + const ctx = await authenticateApiKey(authHeader); + if (!ctx || !hasPermission(ctx, "metrics.read")) { + return new Response("Unauthorized\n", { + status: 401, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); } - // ── Collect and return metrics ──────────────────────────────── try { const metricsText = await service.collectMetrics(); return new Response(metricsText, { status: 200, headers: { - "Content-Type": - "text/plain; version=0.0.4; charset=utf-8", + "Content-Type": "text/plain; version=0.0.4; charset=utf-8", }, }); } catch (error) { diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts index 355e0e58..7b05d199 100644 --- a/src/app/api/setup/route.ts +++ b/src/app/api/setup/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { isSetupRequired, completeSetup } from "@/server/services/setup"; +import { checkIpRateLimit } from "@/app/api/_lib/ip-rate-limit"; + +export async function GET(request: Request) { + const rateLimited = checkIpRateLimit(request, "setup", 5); + if (rateLimited) return rateLimited; -export async function GET() { try { const setupRequired = await isSetupRequired(); return NextResponse.json({ setupRequired }); @@ -11,6 +15,9 @@ export async function GET() { } export async function POST(request: Request) { + const rateLimited = checkIpRateLimit(request, "setup", 5); + if (rateLimited) return rateLimited; + try { // CSRF protection: verify origin matches host const origin = request.headers.get("origin"); diff --git a/src/app/api/v1/_lib/rate-limiter.ts b/src/app/api/v1/_lib/rate-limiter.ts index 0a9e2793..96e7c7eb 100644 --- a/src/app/api/v1/_lib/rate-limiter.ts +++ b/src/app/api/v1/_lib/rate-limiter.ts @@ -59,6 +59,37 @@ export class RateLimiter { }; } + /** Rate-limit by an explicit key (no tier suffix appended). */ + checkKey(key: string, limit: number): RateLimitResult { + const now = Date.now(); + const cutoff = now - WINDOW_MS; + + let window = this.windows.get(key); + if (!window) { + window = { timestamps: [] }; + this.windows.set(key, window); + } + + window.timestamps = window.timestamps.filter((t) => t > cutoff); + + if (window.timestamps.length >= limit) { + const oldestInWindow = window.timestamps[0]; + const retryAfter = Math.ceil((oldestInWindow + WINDOW_MS - now) / 1000); + return { + allowed: false, + remaining: 0, + retryAfter: Math.max(retryAfter, 1), + }; + } + + window.timestamps.push(now); + return { + allowed: true, + remaining: limit - window.timestamps.length, + retryAfter: 0, + }; + } + /** Periodic cleanup of stale windows (call from a setInterval). */ cleanup(): void { const cutoff = Date.now() - WINDOW_MS; diff --git a/src/app/api/v1/docs/route.ts b/src/app/api/v1/docs/route.ts new file mode 100644 index 00000000..2925b1ed --- /dev/null +++ b/src/app/api/v1/docs/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +const SWAGGER_UI_VERSION = "5.21.0"; +const CDN_HOST = "cdn.jsdelivr.net"; + +const CSP = [ + "default-src 'self'", + `script-src ${CDN_HOST} 'unsafe-inline'`, + `style-src ${CDN_HOST} 'unsafe-inline'`, + `img-src data: ${CDN_HOST}`, +].join("; "); + +function buildSwaggerHtml(specUrl: string): string { + return ` + + + + + VectorFlow API v1 — Documentation + + + + +
+ + + +`; +} + +/** + * GET /api/v1/docs + * + * Serves Swagger UI pointing at the OpenAPI spec. + * Requires a valid NextAuth session (logged-in users only). + */ +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const url = new URL(request.url); + const specUrl = `${url.protocol}//${url.host}/api/v1/openapi.json`; + + return new NextResponse(buildSwaggerHtml(specUrl), { + status: 200, + headers: { + "Content-Type": "text/html", + "Content-Security-Policy": CSP, + }, + }); +} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index ac16c6d9..341fd6e5 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -1,12 +1,7 @@ import { NextResponse } from "next/server"; +import { auth } from "@/auth"; import { generateOpenAPISpec } from "@/app/api/v1/_lib/openapi-spec"; -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - // Cache the serialized spec at module level so repeated requests are cheap let _specJson: string | null = null; @@ -20,28 +15,20 @@ function getSpecJson(): string { /** * GET /api/v1/openapi.json * - * Public endpoint (no auth required) — returns the VectorFlow OpenAPI 3.1 - * specification as JSON. CORS headers allow external tooling (Swagger UI, - * Postman, etc.) to fetch the spec without credentials. + * Returns the VectorFlow OpenAPI 3.1 specification as JSON. + * Requires a valid NextAuth session (logged-in users only). */ -export function GET() { +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + return new NextResponse(getSpecJson(), { status: 200, - headers: { - "Content-Type": "application/json", - ...CORS_HEADERS, - }, - }); -} - -/** - * OPTIONS /api/v1/openapi.json - * - * CORS preflight handler. - */ -export function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: CORS_HEADERS, + headers: { "Content-Type": "application/json" }, }); } diff --git a/src/app/api/webhooks/git/route.ts b/src/app/api/webhooks/git/route.ts index 02f31484..f2440504 100644 --- a/src/app/api/webhooks/git/route.ts +++ b/src/app/api/webhooks/git/route.ts @@ -9,8 +9,12 @@ import { executePromotion } from "@/server/services/promotion-service"; import { getProvider } from "@/server/services/git-providers"; import type { GitWebhookEvent } from "@/server/services/git-providers"; import { toFilenameSlug } from "@/server/services/git-sync"; +import { checkIpRateLimit } from "@/app/api/_lib/ip-rate-limit"; export async function POST(req: NextRequest) { + const rateLimited = checkIpRateLimit(req, "webhook", 30); + if (rateLimited) return rateLimited; + const body = await req.text(); // 1. Find environments with gitOps webhook configured.