4.1 KiB
gssh integration
How gssh-mcp will reuse the existing gssh server. Decided in Phase-0 planning; implementation in Phase 3.
Why reuse
The gssh server at gssh.alexandru.macocian.me already:
- Authenticates inbound requests via Authentik JWTs (
HttpContext.User). - Resolves the per-user host allow-list (
SessionController.GetHosts→SshConnectorService.Hosts). - Establishes SSH sessions internally using the host's CA-signed credentials (the operator never sees an SSH key).
- Streams stdin/stdout as a binary WebSocket and accepts text control messages for terminal resize (
SessionSocketRoute).
That's the entire job of "let an authorized human run a command on a Charlie host". Building a parallel SSH broker inside sherlock would duplicate the CA integration, the host-allow-list logic, and the audit trail — and put another piece of cert-handling code in our blast radius. We do not do that.
What gssh-mcp actually is
A thin Go HTTP+WebSocket client to the existing gssh server, wrapped in a stdio MCP. It:
- Reads
GSSH_TOKENfrom env at startup (an Authentik JWT foraud=gssh, written into the env by sherlock at agent spawn from the operator's stored TokenSet). - Calls
POST /api/v1/session/initializewithAuthorization: Bearer <jwt>to materialise the user's session on the gssh server. - Caches the list of permitted hosts from
GET /api/v1/session/hosts. - Exposes MCP tools:
ssh.list_hosts()— returns the cached host list.ssh.run(host, command, timeout?)— opens a WebSocket to the session route forhost, writescommand + "; echo __SHERLOCK_DONE__$?\n", reads until it sees the sentinel, returns{stdout, exit_code}.ssh.put_file(host, path, content)(later) — same shell channel, base64-decode +teeon the far side, sentinel as above.
- On 401, re-reads the keyring (calling
authn.EnsureFresh, whichflock-serialises against concurrent MCP refreshes) and retries once.
No SSH key handling. No cert minting. No ~/Dev/gssh shell-out.
Endpoints sherlock relies on (gssh contract)
| Method | Path | Used for |
|---|---|---|
POST |
/api/v1/session/initialize |
Materialise the user's session before opening the WebSocket. |
GET |
/api/v1/session/hosts |
Per-user host allow-list. |
GET |
/api/v1/users/me |
Sanity check / debug. |
WS |
/<session-socket-route>?hostName=<host> (exact path defined in gssh's routing) |
Bidirectional binary stream = stdin/stdout of the SSH session. Text frames are JSON control messages (e.g. ResizeMessage). |
If a gssh release ever changes one of these, gssh-mcp is the only thing in sherlock that needs to follow.
Token shape
- Issuer:
https://id.alexandru.macocian.me/application/o/gssh/ - Audience:
gssh.alexandru.macocian.me - Service kind in the registry:
oidc-federated exchange.mode = "passthrough"is safe because gssh already accepts the operator's Authentik ID token (same audience model gssh's web UI uses today). Switch torfc8693only if we ever introduce per-tool scope splitting (e.g. read-only vs write).
Completion sentinel — open detail for Phase 3
The gssh WebSocket is interactive (PTY-style). gssh-mcp needs a deterministic way to know a command has finished. Two options:
- Client-side wrap:
gssh-mcpsendsprintf '%s\n' "<cmd>; echo __SHERLOCK_DONE__$?" | <session-shell>and parses for the marker. - Server-side one-shot mode: add a small endpoint / route flag on the gssh server that runs a single command and closes the socket with the exit code in the close frame. Cleaner, but it's a gssh change.
Phase 0 decision: try (1) first because it requires no gssh change; revisit (2) if we hit edge cases (TTY echo, prompt clutter, multi-line stdout truncation).
Out of scope
gssh-mcpdoes NOT speak gssh's WebSocket protocol "directly" by hand-rolling SSH packet framing. It just speaks the documented/api/v1/session/*HTTP + WS surface.gssh-mcpdoes NOT shell out to/mnt/seagate/Dev/gssh(which is the C# server source, not a client binary).gssh-mcpdoes NOT enforce per-host policy locally. The gssh server already does that against JWT claims; duplicating it client-side is a footgun.