From b5dfbb68aed6a155d23bde99aa4c8410163ba946 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 20:18:07 +0200 Subject: [PATCH] mcp: restricted-shell SSH proxy through hubris for management tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- mcp/mcp-reader-shell | 62 +++++++++++++++++++++++++++++++++++++ mcp/server.py | 74 ++++++++++++++++++++++++-------------------- 2 files changed, 103 insertions(+), 33 deletions(-) create mode 100755 mcp/mcp-reader-shell diff --git a/mcp/mcp-reader-shell b/mcp/mcp-reader-shell new file mode 100755 index 0000000..3c9d91e --- /dev/null +++ b/mcp/mcp-reader-shell @@ -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 +# systemctl is-enabled +# journalctl -u [-n N] [--no-pager] +# pct list +# pct status +# pct config +# pct exec -- systemctl is-active +# pct exec -- systemctl is-enabled +# pct exec -- journalctl -u [-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:-}" + 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 diff --git a/mcp/server.py b/mcp/server.py index 59c1bfa..c483746 100755 --- a/mcp/server.py +++ b/mcp/server.py @@ -30,8 +30,12 @@ from mcp.server.fastmcp import FastMCP CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context")) INVENTORY = CONTEXT_DIR / "inventory.yaml" 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_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")) mcp = FastMCP("homelab") @@ -61,36 +65,39 @@ def inventory() -> dict: return _load_inventory() -def _host_ssh_target(name: str) -> str: - """Return the ssh target (user@host) the MCP reader uses for `name`.""" - inv = inventory() - h = inv.get("hosts", {}).get(name) - if h is None: - raise ValueError(f"unknown host: {name}") - mesh = h.get("mesh", {}) - # Netbird FQDN if available, then lan_ip, then tailscale. - target = ( - mesh.get("netbird", {}).get("fqdn") - or mesh.get("netbird", {}).get("ip") - or h.get("lan_ip") - or mesh.get("tailscale", {}).get("fqdn") - ) - if not target: - raise ValueError(f"no reachable address for host {name}") - return f"{SSH_USER}@{target}" - - -def _ssh(host: str, *cmd: str, timeout: int | None = None) -> subprocess.CompletedProcess: - """Run a command on a remote host as the restricted mcp-reader user.""" - target = _host_ssh_target(host) - full = [ +def _run_via_hubris(remote_host: str, cmd: list[str], + timeout: int | None = None) -> subprocess.CompletedProcess: + """SSH to hubris (single channel, restricted-shell key) and run `cmd`. + If remote_host is an LXC, the command is wrapped in `pct exec --` + so hubris executes it inside the LXC. The wrapper at /usr/local/bin/ + mcp-reader-shell on hubris validates the final command against an + allowlist before execution. + """ + inv_hosts = inventory().get("hosts", {}) + if remote_host == "hubris": + full = cmd + else: + host_entry = inv_hosts.get(remote_host) + if host_entry is None: + raise ValueError(f"unknown host: {remote_host}") + pve_id = host_entry.get("pve_id") + 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] + # The restricted shell on hubris reads the joined command from + # $SSH_ORIGINAL_COMMAND and validates it as a single string. + joined = " ".join(full) + ssh_args = [ "ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", f"ConnectTimeout={SSH_TIMEOUT}", - target, *cmd, + f"{SSH_USER}@{HUBRIS_HOST}", + joined, ] - return subprocess.run(full, capture_output=True, text=True, - timeout=timeout or SSH_TIMEOUT * 3) + return subprocess.run( + ssh_args, capture_output=True, text=True, + timeout=timeout or SSH_TIMEOUT * 3, + ) def _service_to_host(service: str) -> str: @@ -250,8 +257,8 @@ def get_service_status(service: str) -> dict: host = _service_to_host(service) inv_svc = inventory()["services"][service] unit = inv_svc.get("systemd_unit", service) - active = _ssh(host, "systemctl", "is-active", unit) - enabled = _ssh(host, "systemctl", "is-enabled", unit) + active = _run_via_hubris(host, ["systemctl", "is-active", unit]) + enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit]) return { "service": service, "host": host, @@ -267,14 +274,15 @@ def tail_log(service: str, lines: int = 200) -> str: host = _service_to_host(service) inv_svc = inventory()["services"][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 @mcp.tool() def list_lxcs() -> str: """Run `pct list` on hubris.""" - return _ssh("hubris", "pct", "list").stdout + return _run_via_hubris("hubris", ["pct", "list"]).stdout @mcp.tool() @@ -286,8 +294,8 @@ def get_lxc_state(lxc: str) -> dict: pve_id = inv[lxc].get("pve_id") if pve_id is None: raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)") - status = _ssh("hubris", "pct", "status", str(pve_id)) - cfg = _ssh("hubris", "pct", "config", str(pve_id)) + status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)]) + cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)]) return {"lxc": lxc, "pve_id": pve_id, "status": status.stdout.strip(), "config": cfg.stdout}