feat(auth): shared-secret auth web-app → ops-agent
- 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 <secret> - .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 <noreply@anthropic.com>
This commit is contained in:
parent
d605eb17a5
commit
92d450609c
7 changed files with 90 additions and 8 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/ops_dashboard"
|
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/ops_dashboard"
|
||||||
SEED_USER_EMAIL="admin@example.com"
|
SEED_USER_EMAIL="admin@example.com"
|
||||||
SEED_USER_PASSWORD="changeme"
|
SEED_USER_PASSWORD="changeme"
|
||||||
|
OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret"
|
||||||
|
OPS_AGENT_URL="http://127.0.0.1:3099"
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -3,3 +3,31 @@
|
||||||
Single-user ops dashboard voor jp-visser.nl.
|
Single-user ops dashboard voor jp-visser.nl.
|
||||||
|
|
||||||
See `docs/runbooks/` for setup, deployment, and operational procedures.
|
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 <secret>`.
|
||||||
|
- 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.
|
||||||
|
|
|
||||||
46
app/api/agent/[...path]/route.ts
Normal file
46
app/api/agent/[...path]/route.ts
Normal file
|
|
@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=ops-agent
|
SyslogIdentifier=ops-agent
|
||||||
|
|
||||||
Environment=OPS_AGENT_PORT=4242
|
Environment=OPS_AGENT_PORT=3099
|
||||||
Environment=OPS_AGENT_HOST=127.0.0.1
|
Environment=OPS_AGENT_HOST=127.0.0.1
|
||||||
Environment=OPS_AGENT_WHITELIST_PATH=/etc/ops-agent/commands.yml
|
Environment=OPS_AGENT_WHITELIST_PATH=/etc/ops-agent/commands.yml
|
||||||
Environment=OPS_AGENT_SECRET_PATH=/etc/ops-agent/secret
|
Environment=OPS_AGENT_SECRET_PATH=/etc/ops-agent/secret
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@ DATABASE_URL="postgresql://USER:PASSWORD@postgres:5432/ops_dashboard"
|
||||||
SEED_USER_EMAIL="admin@example.com"
|
SEED_USER_EMAIL="admin@example.com"
|
||||||
SEED_USER_PASSWORD="changeme"
|
SEED_USER_PASSWORD="changeme"
|
||||||
SESSION_SECRET="replace-with-a-long-random-string"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { timingSafeEqual } from 'crypto';
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
const SECRET_PATH = process.env.OPS_AGENT_SECRET_PATH ?? '/etc/ops-agent/secret';
|
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 {
|
export function loadSecret(): void {
|
||||||
if (!fs.existsSync(SECRET_PATH)) {
|
if (!fs.existsSync(SECRET_PATH)) {
|
||||||
|
|
@ -11,19 +12,21 @@ export function loadSecret(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stat = fs.statSync(SECRET_PATH);
|
const stat = fs.statSync(SECRET_PATH);
|
||||||
// Warn if permissions are too open (should be 0600)
|
|
||||||
if ((stat.mode & 0o177) !== 0) {
|
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<void> {
|
export async function authHook(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
if (secret === null) return; // auth disabled
|
if (secretBuf === null) return; // auth disabled
|
||||||
|
|
||||||
const header = req.headers['authorization'] ?? '';
|
const header = req.headers['authorization'] ?? '';
|
||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
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' });
|
await reply.status(401).send({ error: 'unauthorized' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { healthRoutes } from './routes/health.js';
|
||||||
import { execRoutes } from './routes/exec.js';
|
import { execRoutes } from './routes/exec.js';
|
||||||
|
|
||||||
const WHITELIST_PATH = process.env.OPS_AGENT_WHITELIST_PATH ?? '/etc/ops-agent/commands.yml';
|
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';
|
const HOST = process.env.OPS_AGENT_HOST ?? '127.0.0.1';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue