diff --git a/README.md b/README.md index 950d910..07c16cb 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,6 @@ Design + phasing: [plan.md](plan.md). - [Storage & keyring](docs/storage.md) - [Agents](docs/agents.md) - [gitea-mcp](docs/gitea-mcp.md) +- [gssh-mcp](docs/gssh-mcp.md) - [gssh integration](docs/gssh-integration.md) - [Conventions](docs/conventions.md) diff --git a/cmd/gssh-mcp/client.go b/cmd/gssh-mcp/client.go new file mode 100644 index 0000000..a31a325 --- /dev/null +++ b/cmd/gssh-mcp/client.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ErrUnauthorized is returned when Gssh answers 401. The tool layer +// surfaces this so the operator knows to `sherlock logout gssh` and +// re-auth — a refresh attempt by sherlock is also valid but for now +// we just surface the error verbatim, matching gitea-mcp. +var ErrUnauthorized = errors.New("gssh: 401 unauthorized") + +// gsshAPI is a thin wrapper around the small REST surface Gssh +// exposes alongside its WebSocket routes. The exec WebSocket itself +// goes through internal/wsdatagram (see tools_ssh.go). +type gsshAPI struct { + baseURL string + token string + client *http.Client +} + +func (g *gsshAPI) get(ctx context.Context, path string, query url.Values, into any) error { + full := g.baseURL + path + if len(query) > 0 { + full += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+g.token) + req.Header.Set("Accept", "application/json") + dbg("GET %s", full) + resp, err := g.client.Do(req) + if err != nil { + dbg("GET %s -> transport error: %v", full, err) + return err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if err := checkStatus(resp, req); err != nil { + dbg("GET %s -> %v", full, err) + return err + } + dbg("GET %s -> %d", full, resp.StatusCode) + if into == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(into) +} + +func (g *gsshAPI) post(ctx context.Context, path string, into any) error { + full := g.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, full, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+g.token) + req.Header.Set("Accept", "application/json") + dbg("POST %s", full) + resp, err := g.client.Do(req) + if err != nil { + dbg("POST %s -> transport error: %v", full, err) + return err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if err := checkStatus(resp, req); err != nil { + dbg("POST %s -> %v", full, err) + return err + } + dbg("POST %s -> %d", full, resp.StatusCode) + if into == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(into) +} + +func checkStatus(resp *http.Response, req *http.Request) error { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("%w (token rejected by %s; try `sherlock logout gssh` and retry)", ErrUnauthorized, req.URL.Host) + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("gssh: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/cmd/gssh-mcp/debug.go b/cmd/gssh-mcp/debug.go new file mode 100644 index 0000000..0d8989c --- /dev/null +++ b/cmd/gssh-mcp/debug.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + "os" + "sync" +) + +// debugLog is a process-wide append-only log gssh-mcp writes to when +// SHERLOCK_DEBUG_LOG is set. Identical pattern to gitea-mcp: the +// agent owns stdin/stdout/stderr, so we can't println during a +// failing tool call. +// +// SHERLOCK_DEBUG_LOG=/tmp/gssh-mcp.log +// +// gssh-mcp will tee one line per HTTP request, one line per WS +// exec start/op/exit, and the OAuth bookkeeping at startup. +var ( + dbgOnce sync.Once + dbgLog *log.Logger +) + +func dbg(format string, args ...any) { + dbgOnce.Do(func() { + path := os.Getenv("SHERLOCK_DEBUG_LOG") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + fmt.Fprintf(os.Stderr, "gssh-mcp: SHERLOCK_DEBUG_LOG=%q open: %v\n", path, err) + return + } + dbgLog = log.New(f, "gssh-mcp ", log.LstdFlags|log.Lmicroseconds) + }) + if dbgLog == nil { + return + } + dbgLog.Printf(format, args...) +} diff --git a/cmd/gssh-mcp/gssh_clientid_charlie.go b/cmd/gssh-mcp/gssh_clientid_charlie.go new file mode 100644 index 0000000..f0efd3d --- /dev/null +++ b/cmd/gssh-mcp/gssh_clientid_charlie.go @@ -0,0 +1,27 @@ +//go:build !noembed + +// This file bakes Charlie's homelab defaults for gssh-mcp into the +// binary. Enabled by default; build with `-tags noembed` to drop and +// supply your own values via -ldflags (see main.go). +// +// The client ID is a public OAuth2 client identifier — not a secret; +// it's announced on every authorize request. The Authentik provider +// is configured as Public, so no client_secret is involved. +package main + +func init() { + if gsshIssuer == "" { + // Trailing slash matters: Authentik's discovery document + // publishes `issuer` with a trailing slash and the JWT + // validator compares string-equal. + gsshIssuer = "https://id.alexandru.macocian.me/application/o/sherlock-cli/" + } + if gsshBaseURL == "" { + gsshBaseURL = "https://terminal.alexandru.macocian.me" + } + if gsshClientID == "" { + gsshClientID = "1FN3B6EsTXwlN0qALiCoaKJLM23yuru80bHp5WfS" + } + // gsshClientSecret is intentionally empty — the sherlock-cli + // Authentik provider is Public (PKCE-only). +} diff --git a/cmd/gssh-mcp/main.go b/cmd/gssh-mcp/main.go new file mode 100644 index 0000000..fe75978 --- /dev/null +++ b/cmd/gssh-mcp/main.go @@ -0,0 +1,172 @@ +// Command gssh-mcp is sherlock's MCP server for the Gssh SSH gateway +// (https://terminal.alexandru.macocian.me). It authenticates as the +// operator against Authentik (OIDC + PKCE, loopback callback) and +// uses the resulting JWT bearer token to: +// +// - list the operator's host allow-list, +// - force ephemeral SSH cert creation, and +// - run a single shell command on a host over the headless +// WebSocket exec endpoint, returning stdout/stderr/exit_code. +// +// Two modes mirror gitea-mcp: +// +// gssh-mcp start the stdio MCP server (agents spawn this) +// gssh-mcp --probe run auth + one API call, print result, exit +// +// Configuration is compile-time; Charlie defaults live in +// gssh_clientid_charlie.go and are baked in by default. Use +// `-tags noembed` to retarget via -ldflags. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg" +) + +// serviceName is the wallet key under which gssh tokens live. The +// sherlock CLI's `logout gssh` clears the same entry. +const serviceName = "gssh" + +// Compile-time configuration. Defaults target the Charlie homelab +// (see gssh_clientid_charlie.go). To retarget: +// +// go build -tags noembed \ +// -ldflags "-X main.gsshIssuer=https://id.example/... \ +// -X main.gsshClientID=... \ +// -X main.gsshClientSecret=... \ +// -X main.gsshBaseURL=https://gssh.example.lan" \ +// ./cmd/gssh-mcp +// +// gsshIssuer is the OIDC issuer URL (Authentik provider). +// gsshClientID / gsshClientSecret are the OAuth2 application +// credentials registered with Authentik for sherlock's loopback +// redirect (http://127.0.0.1:6990/callback). +// gsshBaseURL is the public origin of the Gssh deployment. +var ( + gsshIssuer string + gsshClientID string + gsshClientSecret string + gsshBaseURL string +) + +// Version is overwritten at build time via -ldflags "-X main.Version=...". +var Version = "0.0.0-dev" + +// scopes asked from Authentik. `openid` is required for an ID token; +// `profile`/`email` populate user-info claims; the Authentik provider +// is the one that decides what audience and `groups` claim to stamp +// onto the access token Gssh's JwtBearer scheme will validate. +var scopes = []string{"openid", "profile", "email"} + +func tokenPrefix(tok string) string { + if len(tok) <= 8 { + return "(empty)" + } + return tok[:8] + "..." +} + +func main() { + probe := flag.Bool("probe", false, "run auth + one API call, print result, exit") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion { + fmt.Println(Version) + return + } + + if gsshClientID == "" { + fmt.Fprintln(os.Stderr, "gssh-mcp: gsshClientID not configured.") + fmt.Fprintln(os.Stderr, "Build with -ldflags \"-X main.gsshClientID=...\" (and -tags noembed if you don't want the Charlie default) after creating an OAuth2 application in Authentik (redirect URI http://127.0.0.1:6990/callback).") + os.Exit(2) + } + + store, err := keyring.Open() + if err != nil { + fmt.Fprintln(os.Stderr, "gssh-mcp:", err) + os.Exit(3) + } + + agent.EnsurePathContainsSiblings() + + lockPath, err := xdg.RefreshLockPath() + if err != nil { + fmt.Fprintln(os.Stderr, "gssh-mcp:", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg := authn.Config{ + Issuer: gsshIssuer, + ClientID: gsshClientID, + ClientSecret: gsshClientSecret, + Scopes: scopes, + } + ts, err := authn.Ensure(ctx, store, serviceName, cfg, authn.EnsureOptions{ + LockPath: lockPath, + OnAuthURL: func(u string) { + fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:") + fmt.Fprintln(os.Stderr, " ", u) + if err := browser.Open(u); err != nil { + fmt.Fprintln(os.Stderr, "gssh-mcp: open browser:", err) + fmt.Fprintln(os.Stderr, "(visit the URL above manually)") + } + }, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "gssh-mcp: auth:", err) + os.Exit(5) + } + + dbg("startup: pid=%d access_token_prefix=%q scopes=%v id_expires_at=%s", + os.Getpid(), tokenPrefix(ts.AccessToken), ts.Scopes, ts.IDExpiresAt.Format(time.RFC3339)) + + api := &gsshAPI{ + baseURL: strings.TrimRight(gsshBaseURL, "/"), + token: ts.AccessToken, + client: &http.Client{Timeout: 30 * time.Second}, + } + + if *probe { + var u struct { + Data struct { + Name string `json:"Name"` + Account string `json:"Account"` + Roles []string `json:"Roles"` + } `json:"Data"` + Message string `json:"Message"` + } + if err := api.get(ctx, "/api/v1/users/me", nil, &u); err != nil { + fmt.Fprintln(os.Stderr, "gssh-mcp: users/me:", err) + os.Exit(5) + } + fmt.Printf("OK: logged in to %s as %s (account=%s, roles=%v)\n", + gsshBaseURL, u.Data.Name, u.Data.Account, u.Data.Roles) + return + } + + server := mcp.NewServer(&mcp.Implementation{Name: "gssh-mcp", Version: Version}, nil) + registerTools(server, api) + + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, "gssh-mcp:", err) + os.Exit(1) + } +} diff --git a/cmd/gssh-mcp/tools.go b/cmd/gssh-mcp/tools.go new file mode 100644 index 0000000..715152b --- /dev/null +++ b/cmd/gssh-mcp/tools.go @@ -0,0 +1,10 @@ +package main + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// registerTools wires every gssh-mcp tool onto server. Tools live in +// tools_*.go grouped by domain; this file just calls into them. +func registerTools(server *mcp.Server, api *gsshAPI) { + registerSessionTools(server, api) + registerSshTools(server, api) +} diff --git a/cmd/gssh-mcp/tools_session.go b/cmd/gssh-mcp/tools_session.go new file mode 100644 index 0000000..43b9018 --- /dev/null +++ b/cmd/gssh-mcp/tools_session.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerSessionTools(server *mcp.Server, api *gsshAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_hosts", + Description: "List the SSH hosts this operator is allowed to connect to via the Gssh gateway.", + }, listHostsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "initialize_session", + Description: "Force Gssh to mint (or return the cached) ephemeral 5-minute SSH user certificate. Idempotent; safe to call before run_command but normally not needed because run_command does it implicitly.", + }, initializeSessionHandler(api)) +} + +type listHostsInput struct{} + +type listHostsOutput struct { + Hosts []string `json:"hosts"` +} + +func listHostsHandler(api *gsshAPI) mcp.ToolHandlerFor[listHostsInput, listHostsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, _ listHostsInput) (*mcp.CallToolResult, listHostsOutput, error) { + var resp struct { + Data []string `json:"Data"` + Message string `json:"Message"` + } + if err := api.get(ctx, "/api/v1/session/hosts", nil, &resp); err != nil { + return nil, listHostsOutput{}, err + } + return nil, listHostsOutput{Hosts: resp.Data}, nil + } +} + +type initializeSessionInput struct{} + +type initializeSessionOutput struct { + Message string `json:"message"` +} + +func initializeSessionHandler(api *gsshAPI) mcp.ToolHandlerFor[initializeSessionInput, initializeSessionOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, _ initializeSessionInput) (*mcp.CallToolResult, initializeSessionOutput, error) { + var resp struct { + Message string `json:"Message"` + } + if err := api.post(ctx, "/api/v1/session/initialize", &resp); err != nil { + return nil, initializeSessionOutput{}, err + } + return nil, initializeSessionOutput{Message: resp.Message}, nil + } +} diff --git a/cmd/gssh-mcp/tools_ssh.go b/cmd/gssh-mcp/tools_ssh.go new file mode 100644 index 0000000..6bc7a42 --- /dev/null +++ b/cmd/gssh-mcp/tools_ssh.go @@ -0,0 +1,230 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/wsdatagram" +) + +// hardClientTimeout caps the entire run_command tool call. The server +// itself bounds the remote command at 60s; we add 30s headroom for +// WebSocket teardown and to surface a Timeout datagram cleanly. +const hardClientTimeout = 90 * time.Second + +// stdoutCap and stderrCap bound what we return to the LLM. Anything +// past this point is truncated with a flag the agent can act on. +const ( + stdoutCap = 256 * 1024 + stderrCap = 64 * 1024 +) + +func registerSshTools(server *mcp.Server, api *gsshAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "run_command", + Description: "Run a single shell command on the named SSH host through the Gssh gateway (no PTY). " + + "Returns the captured stdout/stderr and the exit code, or a timeout flag if the server-side " + + "60-second cap fires first. stdout is truncated at 256 KiB, stderr at 64 KiB.", + }, runCommandHandler(api)) +} + +type runCommandInput struct { + Host string `json:"host" jsonschema:"the SSH host (must be in list_hosts output)"` + Command string `json:"command" jsonschema:"the shell command to execute on the remote host"` +} + +type runCommandOutput struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode *int `json:"exit_code,omitempty"` + TimedOut bool `json:"timed_out"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` +} + +func runCommandHandler(api *gsshAPI) mcp.ToolHandlerFor[runCommandInput, runCommandOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in runCommandInput) (*mcp.CallToolResult, runCommandOutput, error) { + if strings.TrimSpace(in.Host) == "" { + return nil, runCommandOutput{}, errors.New("run_command: host is required") + } + if strings.TrimSpace(in.Command) == "" { + return nil, runCommandOutput{}, errors.New("run_command: command is required") + } + out, err := execOverWebSocket(ctx, api, in.Host, in.Command) + if err != nil { + return nil, runCommandOutput{}, err + } + return nil, out, nil + } +} + +// execOverWebSocket dials the Gssh exec endpoint, sends a Start +// datagram with the command, demuxes Stdout/Stderr datagrams into +// capped buffers, and returns when an Exit or Timeout datagram +// arrives (or the WS closes for any other reason). +func execOverWebSocket(parent context.Context, api *gsshAPI, host, command string) (runCommandOutput, error) { + ctx, cancel := context.WithTimeout(parent, hardClientTimeout) + defer cancel() + + wsURL := buildWSURL(api.baseURL, host) + dbg("WS dial %s", wsURL) + + headers := http.Header{} + headers.Set("Authorization", "Bearer "+api.token) + + conn, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ + HTTPHeader: headers, + HTTPClient: &http.Client{ + // Don't reuse api.client — it has a 30s timeout, but the + // WS handshake should complete in well under that, and we + // don't want the timeout to fire mid-stream on the + // upgraded connection. + Timeout: 30 * time.Second, + }, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusUnauthorized { + return runCommandOutput{}, fmt.Errorf("%w (Gssh rejected the bearer token on WS upgrade for %s)", ErrUnauthorized, host) + } + if resp != nil { + return runCommandOutput{}, fmt.Errorf("gssh: WS dial %s: HTTP %d: %w", host, resp.StatusCode, err) + } + return runCommandOutput{}, fmt.Errorf("gssh: WS dial %s: %w", host, err) + } + // Per coder/websocket convention, always Close to release the + // goroutine. StatusNormalClosure is the polite signal back to the + // server even on errors; the server already finalised by then. + defer conn.Close(websocket.StatusNormalClosure, "") + + // Lift the per-message size limit. The default 32 KiB is enough + // for one datagram (2 KiB) but the lift is cheap defensive code + // in case the server is upgraded to send larger frames later. + conn.SetReadLimit(64 * 1024) + + // Send the Start datagram. + startFrame, err := wsdatagram.Encode(wsdatagram.Datagram{ + Op: wsdatagram.OpStart, + Payload: []byte(command), + }) + if err != nil { + return runCommandOutput{}, fmt.Errorf("gssh: encode Start: %w", err) + } + if err := conn.Write(ctx, websocket.MessageBinary, startFrame); err != nil { + return runCommandOutput{}, fmt.Errorf("gssh: send Start: %w", err) + } + dbg("WS sent Start (%d bytes command)", len(command)) + + var ( + stdoutBuf bytes.Buffer + stderrBuf bytes.Buffer + out runCommandOutput + ) + + for { + msgType, data, err := conn.Read(ctx) + if err != nil { + // Normal closure after we've already received Exit or + // Timeout: return what we have. Otherwise, surface the + // error along with whatever buffers we accumulated. + status := websocket.CloseStatus(err) + if status == websocket.StatusNormalClosure || status == websocket.StatusGoingAway { + out.Stdout = stdoutBuf.String() + out.Stderr = stderrBuf.String() + if !out.TimedOut && out.ExitCode == nil { + // Server closed cleanly but never sent a terminal + // datagram. Treat as a Gssh protocol violation + // rather than silently making something up. + return out, fmt.Errorf("gssh: WS closed without Exit or Timeout datagram") + } + return out, nil + } + return runCommandOutput{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + StdoutTruncated: out.StdoutTruncated, + StderrTruncated: out.StderrTruncated, + }, fmt.Errorf("gssh: WS read: %w", err) + } + if msgType != websocket.MessageBinary { + dbg("WS ignored non-binary frame (type=%v)", msgType) + continue + } + dg, err := wsdatagram.Decode(data) + if err != nil { + return runCommandOutput{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + }, fmt.Errorf("gssh: decode datagram: %w", err) + } + switch dg.Op { + case wsdatagram.OpStdout: + appendCapped(&stdoutBuf, dg.Payload, stdoutCap, &out.StdoutTruncated) + case wsdatagram.OpStderr: + appendCapped(&stderrBuf, dg.Payload, stderrCap, &out.StderrTruncated) + case wsdatagram.OpExit: + code, perr := strconv.Atoi(string(dg.Payload)) + if perr != nil { + return runCommandOutput{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + }, fmt.Errorf("gssh: parse Exit payload %q: %w", dg.Payload, perr) + } + out.ExitCode = &code + out.Stdout = stdoutBuf.String() + out.Stderr = stderrBuf.String() + dbg("WS got Exit %d", code) + return out, nil + case wsdatagram.OpTimeout: + out.TimedOut = true + out.Stdout = stdoutBuf.String() + out.Stderr = stderrBuf.String() + dbg("WS got Timeout") + return out, nil + default: + dbg("WS ignored unknown op %v", dg.Op) + } + } +} + +// appendCapped writes payload into buf, stopping when cap is reached +// and setting *truncated. Once truncated, further appends are no-ops. +func appendCapped(buf *bytes.Buffer, payload []byte, cap int, truncated *bool) { + if *truncated { + return + } + remaining := cap - buf.Len() + if remaining <= 0 { + *truncated = true + return + } + if len(payload) > remaining { + buf.Write(payload[:remaining]) + *truncated = true + return + } + buf.Write(payload) +} + +// buildWSURL turns the https://gssh.example/.../api/v1/exec/{host} +// REST URL into its wss:// equivalent. +func buildWSURL(baseURL, host string) string { + u, _ := url.Parse(baseURL) + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + } + u.Path = strings.TrimRight(u.Path, "/") + "/api/v1/exec/" + url.PathEscape(host) + return u.String() +} diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index ef73f77..4bdf0a0 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -206,6 +206,7 @@ func knownMCPs() (mcp.Servers, error) { out := mcp.Servers{} for name, spec := range map[string]mcp.Server{ "gitea": {Command: "gitea-mcp"}, + "gssh": {Command: "gssh-mcp"}, } { abs, err := agent.LookPath(spec.Command) if err != nil { diff --git a/docs/gssh-mcp.md b/docs/gssh-mcp.md new file mode 100644 index 0000000..af39de6 --- /dev/null +++ b/docs/gssh-mcp.md @@ -0,0 +1,157 @@ +# gssh-mcp + +sherlock's MCP server for **[Gssh](https://terminal.alexandru.macocian.me)**, +the homelab SSH gateway. Lets an agent run a single shell command on +an allow-listed host without standing up a PTY, using the operator's +own JWT-authenticated session. + +## How auth works + +Two Authentik OIDC providers front the same Gssh deployment: + +1. **`gssh`** (Confidential) — the long-standing provider that powers + Gssh's browser PTY login via server-side OIDC code flow. +2. **`sherlock-cli`** (Public, PKCE) — a separate provider sherlock + authenticates against from the CLI. No client secret, redirect URI + pinned to `http://127.0.0.1:6990/callback`. + +Gssh's JwtBearer scheme accepts JWTs from either provider via +comma-separated `OIDC__ApiAudience` + `OIDC__BearerIssuers`. JWKS +verification is anchored on a single discovery URL — all providers in +an Authentik tenant share the signing key, so one fetch validates +both. + +The `sherlock-cli` provider can be reused by every future +Authentik-fronted MCP: each backing service just adds the +provider's audience to its own `ValidAudiences` and the provider's +issuer to its own `ValidIssuers`. One wallet entry, one OAuth +browser pop, N services. + +## One-time setup + +### Authentik + +Create a second OIDC provider: + +1. **Applications → Providers → Create → OAuth2/OpenID Provider**: + - **Client type:** Public + - **Redirect URI (Strict):** `http://127.0.0.1:6990/callback` + - **Subject mode:** `Based on the User's username` (the default + "hashed user ID" mode produces a sub Gssh can't SSH as) + - **Scope mappings:** include the standard openid/profile/email + **and** a mapping that emits `groups` (Gssh's + `OIDC__AllowedRoles` check reads it). +2. **Applications → Applications → Create** an app that points at the + new provider. Slug = `sherlock-cli` (the slug is the trailing path + segment in the issuer URL). + +### Gssh + +Add the new audience and issuer to Gssh's env (alongside the existing +gssh-provider values): + +```env +OIDC__ApiAudience=, +OIDC__BearerIssuers=https://id.example/application/o/gssh/,https://id.example/application/o/sherlock-cli/ +``` + +## Build & install + +For Charlie (default — Authentik client ID embedded in the binary): + +```bash +cd ~/Dev/charlie/sherlock +go install ./cmd/gssh-mcp +``` + +That's it. The Charlie Authentik `sherlock-cli` client ID lives in +`cmd/gssh-mcp/gssh_clientid_charlie.go` and is baked in unless you +build with `-tags noembed`. + +For other deployments: + +```bash +go build -tags noembed \ + -ldflags "-X main.gsshIssuer=https://id.example/application/o/sherlock-cli/ \ + -X main.gsshClientID= \ + -X main.gsshBaseURL=https://gssh.example" \ + ./cmd/gssh-mcp +``` + +### Knobs + +| Variable / `-X main.…` | Charlie default | +|------------------------|-----------------------------------------------------------------------| +| `gsshIssuer` | `https://id.alexandru.macocian.me/application/o/sherlock-cli/` | +| `gsshBaseURL` | `https://terminal.alexandru.macocian.me` | +| `gsshClientID` | embedded; see `gssh_clientid_charlie.go` | +| `gsshClientSecret` | `""` (provider is Public, PKCE-only — no secret involved) | +| `Version` | `0.0.0-dev` | + +## Verify (without an agent) + +```bash +gssh-mcp --probe +``` + +First run: opens a browser, you click through Authentik's "Authorize +Sherlock-Cli" page, browser shows "Logged in. You may close this +tab.", terminal prints: + +``` +OK: logged in to https://terminal.alexandru.macocian.me as (account=, roles=[...]) +``` + +Subsequent runs are silent until the refresh window opens. + +To force a re-login: `sherlock logout gssh`. + +## Use with an agent + +`sherlock copilot` (or `sherlock claude`) automatically renders an +MCP config that lists `gssh-mcp`. Copilot spawns it as a stdio +subprocess; the first call into a tool triggers the OAuth flow above. + +### Tools + +| Tool | Description | +|-----------------------|----------------------------------------------------------------| +| `list_hosts` | SSH hosts this operator is allowed to connect to. | +| `initialize_session` | Force ephemeral 5-min SSH cert creation (idempotent). | +| `run_command` | Run a single shell command on a host; returns stdout / stderr / exit code (or `timed_out` after the server's 60-second cap). | + +`run_command` opens a fresh WebSocket per call to +`/api/v1/exec/{host}`, sends a single `Start` datagram carrying the +UTF-8 command, then demuxes streamed `Stdout` / `Stderr` datagrams +into capped buffers until an `Exit` (carries ASCII exit code) or +`Timeout` datagram arrives. Wire protocol lives in +`internal/wsdatagram/` and mirrors Gssh's `Models/ShellDatagram.cs` +exactly (fixed 2048-byte frame, 1-byte op + 2-byte length + 2045-byte +payload). + +### Buffer caps + +| Stream | Cap | When exceeded | +|---------|---------|------------------------------| +| stdout | 256 KiB | trailing bytes dropped, `stdout_truncated=true` | +| stderr | 64 KiB | trailing bytes dropped, `stderr_truncated=true` | + +A `timed_out=true` result means the **remote command** exceeded the +server-side 60-second hard cap. The client tool itself has a 90 s +deadline so the agent isn't left hanging if the connection or the +server stall mid-stream. + +## Debugging + +```bash +SHERLOCK_DEBUG_LOG=/tmp/gssh-mcp.log gssh-mcp --probe +tail -f /tmp/gssh-mcp.log +``` + +Every HTTP request, WS dial, Start datagram, and terminal datagram +gets one line. Empty `SHERLOCK_DEBUG_LOG` = no logging, no file +touched. + +When debugging Gssh-side rejections, look at Gssh's container logs — +401s carry the JwtBearer failure reason (audience mismatch, issuer +mismatch, expired, …) inline. diff --git a/go.mod b/go.mod index d71d80d..c2209fa 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( ) require ( + github.com/coder/websocket v1.8.14 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/jsonschema-go v0.4.3 // indirect diff --git a/go.sum b/go.sum index 14d86c0..ae642e7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index df88039..569e93b 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -22,8 +22,10 @@ import ( "encoding/json" "errors" "fmt" + "os" "runtime" "slices" + "strconv" "time" gokeyring "github.com/zalando/go-keyring" @@ -105,19 +107,25 @@ func Open() (Store, error) { } func probe() error { - if err := gokeyring.Set(probeService, probeAccount, probeValue); err != nil { + // Per-PID probe account so concurrent sherlock-spawned MCPs + // (gitea-mcp + gssh-mcp + …) don't race on a shared sentinel: + // without this, two probes can Set/Get/Delete the same key + // in lockstep, and the second Delete returns "secret not found", + // failing Open() on the loser. + account := probeAccount + ":" + strconv.Itoa(os.Getpid()) + if err := gokeyring.Set(probeService, account, probeValue); err != nil { return newUnavailable(fmt.Errorf("set: %w", err)) } - got, err := gokeyring.Get(probeService, probeAccount) + got, err := gokeyring.Get(probeService, account) if err != nil { - _ = gokeyring.Delete(probeService, probeAccount) + _ = gokeyring.Delete(probeService, account) return newUnavailable(fmt.Errorf("get: %w", err)) } if got != probeValue { - _ = gokeyring.Delete(probeService, probeAccount) + _ = gokeyring.Delete(probeService, account) return newUnavailable(fmt.Errorf("readback mismatch: %q", got)) } - if err := gokeyring.Delete(probeService, probeAccount); err != nil { + if err := gokeyring.Delete(probeService, account); err != nil { return newUnavailable(fmt.Errorf("delete: %w", err)) } return nil diff --git a/internal/wsdatagram/wsdatagram.go b/internal/wsdatagram/wsdatagram.go new file mode 100644 index 0000000..ddd9aa0 --- /dev/null +++ b/internal/wsdatagram/wsdatagram.go @@ -0,0 +1,106 @@ +// Package wsdatagram implements the fixed-size 2048-byte binary wire +// protocol spoken by Gssh's headless exec WebSocket endpoint +// (/api/v1/exec/{hostName}). The encoding mirrors the C# server's +// Models/ShellDatagram.cs exactly: +// +// [0] : Operation (byte) +// [1..2] : PayloadLength (uint16, host byte order — little-endian +// in practice, matching the x64 Linux server) +// [3..2047] : Payload (only the first PayloadLength bytes are +// meaningful) +// +// Op codes (Stdin is reserved for future use; the server currently +// ignores it): +// +// Start 0x01 client → server UTF-8 command bytes +// Stdout 0x02 server → client raw stream bytes +// Stderr 0x03 server → client raw stream bytes +// Exit 0x04 server → client ASCII text of integer exit code +// Timeout 0x05 server → client empty +// +// A single dialect is shared between client and server; peers ignore +// op codes they don't implement. +package wsdatagram + +import ( + "encoding/binary" + "fmt" +) + +// FrameSize is the on-the-wire length of every datagram. +const FrameSize = 2048 + +// HeaderSize is the op-code byte plus the length uint16. +const HeaderSize = 3 + +// MaxPayloadSize is FrameSize - HeaderSize. +const MaxPayloadSize = FrameSize - HeaderSize + +// Op is the one-byte operation code at offset 0. +type Op byte + +const ( + OpStart Op = 0x01 + OpStdout Op = 0x02 + OpStderr Op = 0x03 + OpExit Op = 0x04 + OpTimeout Op = 0x05 +) + +func (o Op) String() string { + switch o { + case OpStart: + return "Start" + case OpStdout: + return "Stdout" + case OpStderr: + return "Stderr" + case OpExit: + return "Exit" + case OpTimeout: + return "Timeout" + default: + return fmt.Sprintf("Op(0x%02x)", byte(o)) + } +} + +// Datagram is the decoded view of a single frame. Payload aliases +// into the source buffer when produced by Decode; callers that need +// to retain it past the next Decode must copy. +type Datagram struct { + Op Op + Payload []byte +} + +// Encode writes d into a fresh FrameSize-byte buffer. Trailing bytes +// after the payload are left zero. Returns an error when the payload +// exceeds MaxPayloadSize. +func Encode(d Datagram) ([]byte, error) { + if len(d.Payload) > MaxPayloadSize { + return nil, fmt.Errorf("wsdatagram: payload of %d bytes exceeds %d", len(d.Payload), MaxPayloadSize) + } + buf := make([]byte, FrameSize) + buf[0] = byte(d.Op) + // Host byte order on the server (x64 little-endian); we match + // explicitly so we behave the same on big-endian Go targets. + binary.LittleEndian.PutUint16(buf[1:3], uint16(len(d.Payload))) + copy(buf[HeaderSize:], d.Payload) + return buf, nil +} + +// Decode parses a frame. buf must be exactly FrameSize bytes. The +// returned Payload aliases into buf — copy it if you need to retain +// it past the next Decode call on the same buffer. +func Decode(buf []byte) (Datagram, error) { + if len(buf) != FrameSize { + return Datagram{}, fmt.Errorf("wsdatagram: frame is %d bytes, expected %d", len(buf), FrameSize) + } + length := binary.LittleEndian.Uint16(buf[1:3]) + if int(length) > MaxPayloadSize { + return Datagram{}, fmt.Errorf("wsdatagram: payload length %d exceeds %d", length, MaxPayloadSize) + } + return Datagram{ + Op: Op(buf[0]), + Payload: buf[HeaderSize : HeaderSize+int(length)], + }, nil +} diff --git a/internal/wsdatagram/wsdatagram_test.go b/internal/wsdatagram/wsdatagram_test.go new file mode 100644 index 0000000..96a4911 --- /dev/null +++ b/internal/wsdatagram/wsdatagram_test.go @@ -0,0 +1,68 @@ +package wsdatagram + +import ( + "bytes" + "testing" +) + +func TestEncodeDecodeRoundtrip(t *testing.T) { + cases := []struct { + name string + op Op + payload []byte + }{ + {"start_empty", OpStart, nil}, + {"start_text", OpStart, []byte("echo hi")}, + {"stdout_max", OpStdout, bytes.Repeat([]byte{0xAB}, MaxPayloadSize)}, + {"exit_zero", OpExit, []byte("0")}, + {"timeout", OpTimeout, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + frame, err := Encode(Datagram{Op: c.op, Payload: c.payload}) + if err != nil { + t.Fatalf("encode: %v", err) + } + if len(frame) != FrameSize { + t.Fatalf("frame size = %d, want %d", len(frame), FrameSize) + } + got, err := Decode(frame) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.Op != c.op { + t.Fatalf("op = %v, want %v", got.Op, c.op) + } + if !bytes.Equal(got.Payload, c.payload) { + t.Fatalf("payload = %q, want %q", got.Payload, c.payload) + } + }) + } +} + +func TestEncodeOversizePayloadFails(t *testing.T) { + _, err := Encode(Datagram{Op: OpStdout, Payload: make([]byte, MaxPayloadSize+1)}) + if err == nil { + t.Fatal("expected error for oversized payload") + } +} + +func TestDecodeWrongLengthFails(t *testing.T) { + if _, err := Decode(make([]byte, FrameSize-1)); err == nil { + t.Fatal("expected error for short frame") + } + if _, err := Decode(make([]byte, FrameSize+1)); err == nil { + t.Fatal("expected error for long frame") + } +} + +func TestDecodeCorruptLengthFails(t *testing.T) { + frame := make([]byte, FrameSize) + frame[0] = byte(OpStdout) + // length = MaxPayloadSize + 1, little-endian + frame[1] = byte((MaxPayloadSize + 1) & 0xff) + frame[2] = byte(((MaxPayloadSize + 1) >> 8) & 0xff) + if _, err := Decode(frame); err == nil { + t.Fatal("expected error for corrupt length") + } +}