Setup gssh-mcp
CI / build (push) Failing after 0s

This commit is contained in:
2026-05-30 14:11:01 +02:00
parent 8d159a9bcd
commit 22ca6b3dc7
15 changed files with 978 additions and 5 deletions
+1
View File
@@ -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)
+93
View File
@@ -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
}
+41
View File
@@ -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...)
}
+27
View File
@@ -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).
}
+172
View File
@@ -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)
}
}
+10
View File
@@ -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)
}
+56
View File
@@ -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
}
}
+230
View File
@@ -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()
}
+1
View File
@@ -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 {
+157
View File
@@ -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=<gssh-client-id>,<sherlock-cli-client-id>
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=<CLIENT_ID> \
-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 <name> (account=<username>, 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.
+1
View File
@@ -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
+2
View File
@@ -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=
+13 -5
View File
@@ -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
+106
View File
@@ -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
}
+68
View File
@@ -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")
}
}