mcp: restricted-shell SSH proxy through hubris for management tools

The 5 management tools (get_service_status, tail_log, list_lxcs,
get_lxc_state, ping_service) were registered with sse but all SSH calls
went to per-host targets via a 'mcp-reader' user that didn't exist
anywhere. New design routes every management call through ONE channel:
LXC 105 -> hubris (SSH key + restricted authorized_keys command), then
hubris pct-execs into the right LXC where needed.

Adds mcp/mcp-reader-shell — a strict allowlist wrapper read from
$SSH_ORIGINAL_COMMAND. Rejects shell metacharacters up front and then
matches against a fixed set of read-only patterns (systemctl is-active/
is-enabled, journalctl -u, pct list/status/config, pct exec for the
same subset). Logged to syslog tag mcp-reader.

Authorized_keys line on hubris:
  command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 ... mcp-reader@homelab-mcp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 20:18:07 +02:00
parent 2599c28104
commit b5dfbb68ae
2 changed files with 103 additions and 33 deletions

62
mcp/mcp-reader-shell Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# mcp-reader-shell — restricted SSH command for the homelab-mcp service.
#
# Authorized in /root/.ssh/authorized_keys on hubris via:
# command="/usr/local/bin/mcp-reader-shell",restrict ssh-ed25519 AAAA... mcp-reader@homelab-mcp
#
# `restrict` disables PTY/agent/forwarding/X11. This wrapper then validates
# $SSH_ORIGINAL_COMMAND against a strict read-only allowlist before running
# it. Anything outside the allowlist (interactive shell, file writes, pct
# start/stop/destroy, etc.) is refused.
#
# Distributed via the homelab-context sync — symlink:
# /usr/local/bin/mcp-reader-shell -> /opt/homelab-context/mcp/mcp-reader-shell
# so updates land on the next 5-min pull without a manual re-install.
#
# Argument shapes allowed (Bash glob, after rejecting shell metacharacters):
# systemctl is-active <unit>
# systemctl is-enabled <unit>
# journalctl -u <unit> [-n N] [--no-pager]
# pct list
# pct status <id>
# pct config <id>
# pct exec <id> -- systemctl is-active <unit>
# pct exec <id> -- systemctl is-enabled <unit>
# pct exec <id> -- journalctl -u <unit> [-n N] [--no-pager]
#
# Logs each call to syslog via `logger`. Deny entries are warnings.
set -euo pipefail
set -f # disable glob expansion when we exec the command
CMD="${SSH_ORIGINAL_COMMAND:-}"
deny() {
logger -t mcp-reader -p auth.warning "DENY from=${SSH_CLIENT:-?}: ${CMD:-<empty>}"
echo "mcp-reader: command not allowed" >&2
exit 1
}
if [ -z "$CMD" ]; then
deny
fi
# Reject any shell metacharacter that would let an attacker chain or escape
# from the patterns below.
if [[ "$CMD" =~ [\;\&\|\>\<\`\$\\\(\)\{\}\*\?\~\!] ]]; then
deny
fi
case "$CMD" in
"systemctl is-active "*|"systemctl is-enabled "*) ;;
"journalctl -u "*) ;;
"pct list") ;;
"pct status "*|"pct config "*) ;;
"pct exec "*" -- systemctl is-active "*) ;;
"pct exec "*" -- systemctl is-enabled "*) ;;
"pct exec "*" -- journalctl -u "*) ;;
*) deny ;;
esac
logger -t mcp-reader -p auth.info "ALLOW from=${SSH_CLIENT:-?}: $CMD"
exec $CMD

View File

@@ -30,8 +30,12 @@ from mcp.server.fastmcp import FastMCP
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context")) CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
INVENTORY = CONTEXT_DIR / "inventory.yaml" INVENTORY = CONTEXT_DIR / "inventory.yaml"
HOSTS_DIR = CONTEXT_DIR / "hosts" HOSTS_DIR = CONTEXT_DIR / "hosts"
# All management tools proxy through hubris (the Proxmox host) via a single
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
# hubris validates each command against a strict read-only allowlist.
SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key") SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key")
SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "mcp-reader") SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "root")
HUBRIS_HOST = os.environ.get("HOMELAB_MCP_HUBRIS_HOST", "192.168.8.77")
SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10")) SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10"))
mcp = FastMCP("homelab") mcp = FastMCP("homelab")
@@ -61,36 +65,39 @@ def inventory() -> dict:
return _load_inventory() return _load_inventory()
def _host_ssh_target(name: str) -> str: def _run_via_hubris(remote_host: str, cmd: list[str],
"""Return the ssh target (user@host) the MCP reader uses for `name`.""" timeout: int | None = None) -> subprocess.CompletedProcess:
inv = inventory() """SSH to hubris (single channel, restricted-shell key) and run `cmd`.
h = inv.get("hosts", {}).get(name) If remote_host is an LXC, the command is wrapped in `pct exec <id> --`
if h is None: so hubris executes it inside the LXC. The wrapper at /usr/local/bin/
raise ValueError(f"unknown host: {name}") mcp-reader-shell on hubris validates the final command against an
mesh = h.get("mesh", {}) allowlist before execution.
# Netbird FQDN if available, then lan_ip, then tailscale. """
target = ( inv_hosts = inventory().get("hosts", {})
mesh.get("netbird", {}).get("fqdn") if remote_host == "hubris":
or mesh.get("netbird", {}).get("ip") full = cmd
or h.get("lan_ip") else:
or mesh.get("tailscale", {}).get("fqdn") host_entry = inv_hosts.get(remote_host)
) if host_entry is None:
if not target: raise ValueError(f"unknown host: {remote_host}")
raise ValueError(f"no reachable address for host {name}") pve_id = host_entry.get("pve_id")
return f"{SSH_USER}@{target}" if pve_id is None:
raise ValueError(f"{remote_host} has no pve_id; can't pct-exec into it")
full = ["pct", "exec", str(pve_id), "--", *cmd]
def _ssh(host: str, *cmd: str, timeout: int | None = None) -> subprocess.CompletedProcess: # The restricted shell on hubris reads the joined command from
"""Run a command on a remote host as the restricted mcp-reader user.""" # $SSH_ORIGINAL_COMMAND and validates it as a single string.
target = _host_ssh_target(host) joined = " ".join(full)
full = [ ssh_args = [
"ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes", "ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", f"ConnectTimeout={SSH_TIMEOUT}", "-o", f"ConnectTimeout={SSH_TIMEOUT}",
target, *cmd, f"{SSH_USER}@{HUBRIS_HOST}",
joined,
] ]
return subprocess.run(full, capture_output=True, text=True, return subprocess.run(
timeout=timeout or SSH_TIMEOUT * 3) ssh_args, capture_output=True, text=True,
timeout=timeout or SSH_TIMEOUT * 3,
)
def _service_to_host(service: str) -> str: def _service_to_host(service: str) -> str:
@@ -250,8 +257,8 @@ def get_service_status(service: str) -> dict:
host = _service_to_host(service) host = _service_to_host(service)
inv_svc = inventory()["services"][service] inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service) unit = inv_svc.get("systemd_unit", service)
active = _ssh(host, "systemctl", "is-active", unit) active = _run_via_hubris(host, ["systemctl", "is-active", unit])
enabled = _ssh(host, "systemctl", "is-enabled", unit) enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit])
return { return {
"service": service, "service": service,
"host": host, "host": host,
@@ -267,14 +274,15 @@ def tail_log(service: str, lines: int = 200) -> str:
host = _service_to_host(service) host = _service_to_host(service)
inv_svc = inventory()["services"][service] inv_svc = inventory()["services"][service]
unit = inv_svc.get("systemd_unit", service) unit = inv_svc.get("systemd_unit", service)
proc = _ssh(host, "journalctl", "-u", unit, "-n", str(lines), "--no-pager") proc = _run_via_hubris(host, ["journalctl", "-u", unit,
"-n", str(lines), "--no-pager"])
return proc.stdout return proc.stdout
@mcp.tool() @mcp.tool()
def list_lxcs() -> str: def list_lxcs() -> str:
"""Run `pct list` on hubris.""" """Run `pct list` on hubris."""
return _ssh("hubris", "pct", "list").stdout return _run_via_hubris("hubris", ["pct", "list"]).stdout
@mcp.tool() @mcp.tool()
@@ -286,8 +294,8 @@ def get_lxc_state(lxc: str) -> dict:
pve_id = inv[lxc].get("pve_id") pve_id = inv[lxc].get("pve_id")
if pve_id is None: if pve_id is None:
raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)") raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)")
status = _ssh("hubris", "pct", "status", str(pve_id)) status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)])
cfg = _ssh("hubris", "pct", "config", str(pve_id)) cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)])
return {"lxc": lxc, "pve_id": pve_id, return {"lxc": lxc, "pve_id": pve_id,
"status": status.stdout.strip(), "status": status.stdout.strip(),
"config": cfg.stdout} "config": cfg.stdout}