From 169e3b6d5c7f956421e3ea1e2aabb8555b0dedc6 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:56:33 +1000 Subject: [PATCH] feat(actions): configurable SSH user + insecure-TLS for PVE; real action whitelist + Z wrapper Co-Authored-By: Claude Opus 4.8 --- config/actions.json | 22 +++++++++++++++++++++- deploy/void-act | 25 +++++++++++++++---------- lib/actions/channels/proxmox.js | 14 +++++++++++++- lib/actions/channels/ssh.js | 2 +- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/config/actions.json b/config/actions.json index eb53f76..fc1c8d1 100644 --- a/config/actions.json +++ b/config/actions.json @@ -1 +1,21 @@ -{ "hosts": {}, "actions": [] } +{ + "hosts": { "z": "192.168.1.124" }, + "actions": [ + { "id": "restart-pihole", "label": "Restart Pi-hole (CT 106)", "kind": "service_restart", "host": "z", "service": "pihole-FTL", "tier": "safe" }, + { "id": "restart-gitea", "label": "Restart Gitea (CT 105)", "kind": "service_restart", "host": "z", "service": "gitea", "tier": "safe" }, + { "id": "restart-n8n", "label": "Restart n8n (CT 110)", "kind": "service_restart", "host": "z", "service": "n8n", "tier": "safe" }, + { "id": "restart-magicmirror", "label": "Restart MagicMirror (CT 111)", "kind": "service_restart", "host": "z", "service": "magicmirror", "tier": "safe" }, + + { "id": "start-ct107", "label": "Start iVentoy (CT 107)", "kind": "guest_power", "node": "z", "vmid": 107, "kindPath": "lxc", "op": "start", "tier": "safe" }, + { "id": "stop-ct107", "label": "Stop iVentoy (CT 107)", "kind": "guest_power", "node": "z", "vmid": 107, "kindPath": "lxc", "op": "stop", "tier": "risky" }, + + { "id": "start-ct110", "label": "Start n8n (CT 110)", "kind": "guest_power", "node": "z", "vmid": 110, "kindPath": "lxc", "op": "start", "tier": "safe" }, + { "id": "stop-ct110", "label": "Stop n8n (CT 110)", "kind": "guest_power", "node": "z", "vmid": 110, "kindPath": "lxc", "op": "stop", "tier": "risky" }, + + { "id": "start-ct111", "label": "Start MagicMirror (CT 111)", "kind": "guest_power", "node": "z", "vmid": 111, "kindPath": "lxc", "op": "start", "tier": "safe" }, + { "id": "stop-ct111", "label": "Stop MagicMirror (CT 111)", "kind": "guest_power", "node": "z", "vmid": 111, "kindPath": "lxc", "op": "stop", "tier": "risky" }, + + { "id": "start-vm200", "label": "Start OpenClaw (VM 200)", "kind": "guest_power", "node": "z", "vmid": 200, "kindPath": "qemu", "op": "start", "tier": "safe" }, + { "id": "stop-vm200", "label": "Stop OpenClaw (VM 200)", "kind": "guest_power", "node": "z", "vmid": 200, "kindPath": "qemu", "op": "stop", "tier": "risky" } + ] +} diff --git a/deploy/void-act b/deploy/void-act index b788e30..b857a9a 100755 --- a/deploy/void-act +++ b/deploy/void-act @@ -1,15 +1,20 @@ #!/usr/bin/env bash -# Forced command for the Void's restricted key. Maps a whitelisted action id to a -# concrete systemctl restart. The id arrives via SSH_ORIGINAL_COMMAND; nothing else -# is honoured. Edit the case list per host. Keep in sync with config/actions.json. +# Forced command for the Void's restricted key on Z (installed in root's +# authorized_keys via command="..."). Maps a whitelisted service-restart action +# id to a FIXED `pct exec ... systemctl restart`. The id arrives via +# SSH_ORIGINAL_COMMAND; nothing else is honoured — no input is interpolated into a +# command. Guest power goes through the Proxmox API, NOT this wrapper. Keep the +# case list in sync with config/actions.json (service_restart entries). # -# Install on each target host: +# Install on Z: # install -m 755 void-act /usr/local/bin/void-act -# # in voidact's ~/.ssh/authorized_keys, prefix the Void's pubkey with: -# command="/usr/local/bin/void-act",no-port-forwarding,no-pty,no-X11-forwarding +# # prefix the Void's pubkey in /root/.ssh/authorized_keys with: +# command="/usr/local/bin/void-act",no-port-forwarding,no-pty,no-X11-forwarding,no-agent-forwarding set -euo pipefail -id="${SSH_ORIGINAL_COMMAND:-}" -case "$id" in - restart-caddy-ct100) exec systemctl restart caddy ;; - *) echo "void-act: refused '$id'" >&2; exit 13 ;; +case "${SSH_ORIGINAL_COMMAND:-}" in + restart-pihole) exec pct exec 106 -- systemctl restart pihole-FTL ;; + restart-gitea) exec pct exec 105 -- systemctl restart gitea ;; + restart-n8n) exec pct exec 110 -- systemctl restart n8n ;; + restart-magicmirror) exec pct exec 111 -- systemctl restart magicmirror ;; + *) echo "void-act: refused '${SSH_ORIGINAL_COMMAND:-}'" >&2; exit 13 ;; esac diff --git a/lib/actions/channels/proxmox.js b/lib/actions/channels/proxmox.js index 6be4aad..429b3f2 100644 --- a/lib/actions/channels/proxmox.js +++ b/lib/actions/channels/proxmox.js @@ -1,3 +1,14 @@ +import { Agent } from 'undici'; + +// PVE uses a self-signed cert on the LAN; opt into skipping verification with +// PROXMOX_INSECURE_TLS=1 (homelab). Cached dispatcher; tests inject fetchImpl. +let insecure; +function tlsDispatcher() { + if (process.env.PROXMOX_INSECURE_TLS !== '1') return undefined; + insecure ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecure; +} + // Proxmox guest power via a SCOPED PVEAPIToken (VM.PowerMgmt on whitelisted guests // only). PVE enforces permissions server-side; this adapter never builds shell commands. export async function powerGuest({ node, vmid, op, kindPath = 'lxc' }, { @@ -8,7 +19,8 @@ export async function powerGuest({ node, vmid, op, kindPath = 'lxc' }, { const url = `${apiUrl}/api2/json/nodes/${node}/${kindPath}/${vmid}/status/${op}`; const res = await fetchImpl(url, { method: 'POST', - headers: { Authorization: `PVEAPIToken=${token}` } + headers: { Authorization: `PVEAPIToken=${token}` }, + dispatcher: tlsDispatcher() }); if (!res.ok) throw new Error(`proxmox ${op} ${vmid} → ${res.status} ${await res.text?.() ?? ''}`); const body = await res.json(); diff --git a/lib/actions/channels/ssh.js b/lib/actions/channels/ssh.js index 1540ae9..b252143 100644 --- a/lib/actions/channels/ssh.js +++ b/lib/actions/channels/ssh.js @@ -7,7 +7,7 @@ const ID_RE = /^[a-z0-9-]+$/; // its OWN whitelist. We pass ONLY the id as a single argv element — no shell. export function restartService({ ip, actionId }, { keyPath = process.env.ACTIONS_SSH_KEY, - user = 'voidact', + user = process.env.ACTIONS_SSH_USER || 'voidact', spawnImpl = nodeSpawn } = {}) { if (!ID_RE.test(actionId || '')) return Promise.reject(new Error(`invalid action id: ${actionId}`));