From 92d450609c4916a23de919788c4e6877bcbcbb69 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:22:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20shared-secret=20auth=20web-app=20?= =?UTF-8?q?=E2=86=92=20ops-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops-agent/src/auth.ts: constant-time compare via timingSafeEqual to prevent timing attacks; store secret as Buffer - ops-agent/src/index.ts + ops-agent.service: bind on 127.0.0.1:3099 (was 4242, per plan) - app/api/agent/[...path]/route.ts: Next.js proxy route that verifies ops_session cookie then forwards requests to agent with Authorization: Bearer - .env.example + deploy/ops-dashboard.env.example: add OPS_AGENT_SECRET and OPS_AGENT_URL - README.md: rotation procedure for the shared secret Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 ++ README.md | 28 ++++++++++++++++++ app/api/agent/[...path]/route.ts | 46 ++++++++++++++++++++++++++++++ deploy/ops-agent/ops-agent.service | 2 +- deploy/ops-dashboard.env.example | 3 ++ ops-agent/src/auth.ts | 15 ++++++---- ops-agent/src/index.ts | 2 +- 7 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 app/api/agent/[...path]/route.ts diff --git a/.env.example b/.env.example index 2f3ed88..48a9a2a 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/ops_dashboard" SEED_USER_EMAIL="admin@example.com" SEED_USER_PASSWORD="changeme" +OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" +OPS_AGENT_URL="http://127.0.0.1:3099" diff --git a/README.md b/README.md index 3a98ffc..641ce52 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,31 @@ Single-user ops dashboard voor jp-visser.nl. See `docs/runbooks/` for setup, deployment, and operational procedures. + +## Ops-agent auth + +The web-app communicates with the ops-agent via a shared secret stored in +`/etc/ops-agent/secret` (mode 0640, owner `root:ops-agent`). + +- The ops-agent reads the secret at startup via `OPS_AGENT_SECRET_PATH`. +- Every request from the web-app carries `Authorization: Bearer `. +- The agent validates using a constant-time comparison to prevent timing attacks. +- The web-app reads the secret value from the `OPS_AGENT_SECRET` environment variable. + +### Secret rotation procedure + +1. Generate a new secret on the server: + ``` + openssl rand -hex 32 | sudo tee /etc/ops-agent/secret + sudo chown root:ops-agent /etc/ops-agent/secret + sudo chmod 0640 /etc/ops-agent/secret + ``` +2. Update `OPS_AGENT_SECRET` in the web-app's environment file + (`/srv/ops/ops-dashboard.env`) with the new value. +3. Restart both services: + ``` + sudo systemctl restart ops-agent + sudo docker compose -f /srv/ops/docker-compose.ops-dashboard.yml restart ops-dashboard + ``` +4. Verify the dashboard is operational and that `systemctl status ops-agent` shows + the service running without errors. diff --git a/app/api/agent/[...path]/route.ts b/app/api/agent/[...path]/route.ts new file mode 100644 index 0000000..cf300a1 --- /dev/null +++ b/app/api/agent/[...path]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' +const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const { path } = await params + const subpath = path.join('/') + const body = await request.text() + + let agentResponse: Response + try { + agentResponse = await fetch(`${AGENT_URL}/agent/v1/${subpath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AGENT_SECRET}`, + }, + body, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'agent unreachable' + return Response.json({ error: message }, { status: 502 }) + } + + const contentType = agentResponse.headers.get('Content-Type') ?? 'application/json' + return new Response(agentResponse.body, { + status: agentResponse.status, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/deploy/ops-agent/ops-agent.service b/deploy/ops-agent/ops-agent.service index 69c02bf..20ce2dd 100644 --- a/deploy/ops-agent/ops-agent.service +++ b/deploy/ops-agent/ops-agent.service @@ -14,7 +14,7 @@ StandardOutput=journal StandardError=journal SyslogIdentifier=ops-agent -Environment=OPS_AGENT_PORT=4242 +Environment=OPS_AGENT_PORT=3099 Environment=OPS_AGENT_HOST=127.0.0.1 Environment=OPS_AGENT_WHITELIST_PATH=/etc/ops-agent/commands.yml Environment=OPS_AGENT_SECRET_PATH=/etc/ops-agent/secret diff --git a/deploy/ops-dashboard.env.example b/deploy/ops-dashboard.env.example index c83c373..f93bcc2 100644 --- a/deploy/ops-dashboard.env.example +++ b/deploy/ops-dashboard.env.example @@ -3,3 +3,6 @@ DATABASE_URL="postgresql://USER:PASSWORD@postgres:5432/ops_dashboard" SEED_USER_EMAIL="admin@example.com" SEED_USER_PASSWORD="changeme" SESSION_SECRET="replace-with-a-long-random-string" +# Shared secret for ops-agent auth — must match /etc/ops-agent/secret on the host +OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" +OPS_AGENT_URL="http://127.0.0.1:3099" diff --git a/ops-agent/src/auth.ts b/ops-agent/src/auth.ts index 6d4fda8..da6f24d 100644 --- a/ops-agent/src/auth.ts +++ b/ops-agent/src/auth.ts @@ -1,9 +1,10 @@ import fs from 'fs'; +import { timingSafeEqual } from 'crypto'; import { FastifyRequest, FastifyReply } from 'fastify'; const SECRET_PATH = process.env.OPS_AGENT_SECRET_PATH ?? '/etc/ops-agent/secret'; -let secret: string | null = null; +let secretBuf: Buffer | null = null; export function loadSecret(): void { if (!fs.existsSync(SECRET_PATH)) { @@ -11,19 +12,21 @@ export function loadSecret(): void { return; } const stat = fs.statSync(SECRET_PATH); - // Warn if permissions are too open (should be 0600) if ((stat.mode & 0o177) !== 0) { - console.warn(`[auth] Warning: ${SECRET_PATH} has loose permissions — expected 0600`); + console.warn(`[auth] Warning: ${SECRET_PATH} has loose permissions — expected 0640`); } - secret = fs.readFileSync(SECRET_PATH, 'utf8').trim(); + secretBuf = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf8').trim()); } export async function authHook(req: FastifyRequest, reply: FastifyReply): Promise { - if (secret === null) return; // auth disabled + if (secretBuf === null) return; // auth disabled const header = req.headers['authorization'] ?? ''; const token = header.startsWith('Bearer ') ? header.slice(7) : ''; - if (token !== secret) { + const tokenBuf = Buffer.from(token); + const valid = + tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf); + if (!valid) { await reply.status(401).send({ error: 'unauthorized' }); } } diff --git a/ops-agent/src/index.ts b/ops-agent/src/index.ts index 936950a..73cf770 100644 --- a/ops-agent/src/index.ts +++ b/ops-agent/src/index.ts @@ -6,7 +6,7 @@ import { healthRoutes } from './routes/health.js'; import { execRoutes } from './routes/exec.js'; const WHITELIST_PATH = process.env.OPS_AGENT_WHITELIST_PATH ?? '/etc/ops-agent/commands.yml'; -const PORT = parseInt(process.env.OPS_AGENT_PORT ?? '4242', 10); +const PORT = parseInt(process.env.OPS_AGENT_PORT ?? '3099', 10); const HOST = process.env.OPS_AGENT_HOST ?? '127.0.0.1'; async function main() {