feat(ops-agent): extend whitelist with destructive commands + preconditions
Adds docker_compose_restart/build/up, git_pull (guarded by git_status_clean precondition), systemctl_restart (via sudo), caddy_validate, caddy_reload, and caddy_write_config (atomic stdin→Caddyfile.new→Caddyfile write). - CommandDef gains preconditions[] and stdin_from_body fields - exec route checks git_status_clean before git_pull; returns 409 on dirty tree with a clear message - stdin field in ExecBody is piped to child stdin for caddy_write_config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
30f1b452a8
commit
234b2d1a58
3 changed files with 108 additions and 2 deletions
|
|
@ -70,3 +70,72 @@ commands:
|
||||||
- -c
|
- -c
|
||||||
- "for f in /data/caddy/certificates/*/*.crt; do [ -f \"$f\" ] || continue; echo \"CERTFILE:$f\"; openssl x509 -noout -subject -issuer -dates -in \"$f\" 2>&1; echo \"CERTEND\"; done"
|
- "for f in /data/caddy/certificates/*/*.crt; do [ -f \"$f\" ] || continue; echo \"CERTFILE:$f\"; openssl x509 -noout -subject -issuer -dates -in \"$f\" 2>&1; echo \"CERTEND\"; done"
|
||||||
description: "List TLS cert info (subject, issuer, validity dates) from Caddy certificate store"
|
description: "List TLS cert info (subject, issuer, validity dates) from Caddy certificate store"
|
||||||
|
|
||||||
|
# ── Destructive / write commands ──────────────────────────────────────────
|
||||||
|
|
||||||
|
docker_compose_restart:
|
||||||
|
cmd: ["docker", "compose", "restart"]
|
||||||
|
cwd: "/srv/scrum4me/compose"
|
||||||
|
args:
|
||||||
|
allowed:
|
||||||
|
- scrum4me-web
|
||||||
|
- worker-idea
|
||||||
|
- ops-dashboard
|
||||||
|
- caddy
|
||||||
|
- postgres
|
||||||
|
description: "Restart a docker compose service (ops-agent user must be in the docker group)"
|
||||||
|
|
||||||
|
docker_compose_build:
|
||||||
|
cmd: ["docker", "compose", "build"]
|
||||||
|
cwd: "/srv/scrum4me/compose"
|
||||||
|
args:
|
||||||
|
allowed:
|
||||||
|
- scrum4me-web
|
||||||
|
- worker-idea
|
||||||
|
- ops-dashboard
|
||||||
|
description: "Build a docker compose service image"
|
||||||
|
|
||||||
|
docker_compose_up:
|
||||||
|
cmd: ["docker", "compose", "up", "-d"]
|
||||||
|
cwd: "/srv/scrum4me/compose"
|
||||||
|
args:
|
||||||
|
allowed:
|
||||||
|
- scrum4me-web
|
||||||
|
- worker-idea
|
||||||
|
- ops-dashboard
|
||||||
|
description: "Start or recreate a docker compose service in detached mode"
|
||||||
|
|
||||||
|
git_pull:
|
||||||
|
cmd: ["git", "pull", "--ff-only"]
|
||||||
|
cwd_pattern: "/srv/"
|
||||||
|
preconditions:
|
||||||
|
- git_status_clean
|
||||||
|
description: "Fast-forward pull — refused when working tree is dirty"
|
||||||
|
|
||||||
|
systemctl_restart:
|
||||||
|
# Requires /etc/sudoers.d/ops-agent (see deploy/ops-agent/sudoers).
|
||||||
|
cmd: ["sudo", "/usr/bin/systemctl", "restart"]
|
||||||
|
args:
|
||||||
|
allowed:
|
||||||
|
- scrum4me-web
|
||||||
|
- ops-agent
|
||||||
|
- caddy
|
||||||
|
description: "Restart an allowed systemd service via sudo"
|
||||||
|
|
||||||
|
caddy_validate:
|
||||||
|
cmd: ["caddy", "validate", "--config", "/srv/scrum4me/caddy/Caddyfile"]
|
||||||
|
description: "Validate /srv/scrum4me/caddy/Caddyfile without reloading"
|
||||||
|
|
||||||
|
caddy_reload:
|
||||||
|
cmd: ["caddy", "reload", "--config", "/srv/scrum4me/caddy/Caddyfile"]
|
||||||
|
description: "Reload Caddy with /srv/scrum4me/caddy/Caddyfile"
|
||||||
|
|
||||||
|
caddy_write_config:
|
||||||
|
# Writes stdin to Caddyfile.new first; mv is atomic on the same filesystem.
|
||||||
|
# ops-agent user must own /srv/scrum4me/caddy/.
|
||||||
|
cmd:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- "cat > /srv/scrum4me/caddy/Caddyfile.new && mv /srv/scrum4me/caddy/Caddyfile.new /srv/scrum4me/caddy/Caddyfile"
|
||||||
|
stdin_from_body: true
|
||||||
|
description: "Atomically replace /srv/scrum4me/caddy/Caddyfile (write stdin to .new, then mv)"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,20 @@ import { getCommand, validateArgs, validateCwd } from '../whitelist.js';
|
||||||
interface ExecBody {
|
interface ExecBody {
|
||||||
command_key: string;
|
command_key: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
|
/** Config content piped to stdin for commands with stdin_from_body: true */
|
||||||
|
stdin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkGitStatusClean(cwd: string | undefined): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('git', ['status', '--porcelain'], { shell: false, cwd });
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('close', () => resolve(output.trim() === ''));
|
||||||
|
child.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function auditLog(
|
function auditLog(
|
||||||
|
|
@ -27,7 +41,7 @@ function auditLog(
|
||||||
|
|
||||||
export async function execRoutes(app: FastifyInstance): Promise<void> {
|
export async function execRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => {
|
app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => {
|
||||||
const { command_key, args = [] } = req.body;
|
const { command_key, args = [], stdin } = req.body;
|
||||||
|
|
||||||
const def = getCommand(command_key);
|
const def = getCommand(command_key);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
|
|
@ -46,6 +60,21 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
|
||||||
return reply.status(400).send({ error: argError });
|
return reply.status(400).send({ error: argError });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cwd = def.cwd_pattern ? args[0] : def.cwd;
|
||||||
|
|
||||||
|
if (def.preconditions) {
|
||||||
|
for (const precondition of def.preconditions) {
|
||||||
|
if (precondition === 'git_status_clean') {
|
||||||
|
const clean = await checkGitStatusClean(cwd);
|
||||||
|
if (!clean) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
error: 'working tree is not clean; commit or stash changes before pulling',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
|
@ -57,7 +86,6 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
|
||||||
};
|
};
|
||||||
|
|
||||||
const [bin, ...staticArgs] = def.cmd;
|
const [bin, ...staticArgs] = def.cmd;
|
||||||
const cwd = def.cwd_pattern ? args[0] : def.cwd;
|
|
||||||
const effectiveArgs = def.cwd_pattern ? args.slice(1) : args;
|
const effectiveArgs = def.cwd_pattern ? args.slice(1) : args;
|
||||||
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
|
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
|
||||||
const child = spawn(bin, [...staticArgs, ...effectiveArgs], {
|
const child = spawn(bin, [...staticArgs, ...effectiveArgs], {
|
||||||
|
|
@ -65,6 +93,11 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (def.stdin_from_body && stdin != null) {
|
||||||
|
child.stdin!.write(stdin, 'utf8');
|
||||||
|
}
|
||||||
|
child.stdin!.end();
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export interface CommandDef {
|
||||||
cwd_pattern?: string;
|
cwd_pattern?: string;
|
||||||
args?: ArgsConfig;
|
args?: ArgsConfig;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Named preconditions that must pass before the command runs (e.g. 'git_status_clean') */
|
||||||
|
preconditions?: string[];
|
||||||
|
/** When true, the caller's 'stdin' body field is piped to the child process */
|
||||||
|
stdin_from_body?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Whitelist = Record<string, CommandDef>;
|
export type Whitelist = Record<string, CommandDef>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue