feat(actions): configurable SSH user + insecure-TLS for PVE; real action whitelist + Z wrapper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 21:56:33 +10:00
parent 80ad482d82
commit 169e3b6d5c
4 changed files with 50 additions and 13 deletions

View File

@@ -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" }
]
}

View File

@@ -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 <pubkey>
# # 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 <pubkey>
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

View File

@@ -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();

View File

@@ -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}`));