@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user