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:
62
mcp/mcp-reader-shell
Executable file
62
mcp/mcp-reader-shell
Executable 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
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user