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,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<void> {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue