Files
sherlock/cmd/gssh-mcp/main.go
T
amacocian 28dcd5b8e6
CI / build (push) Failing after 1s
Grafana MCP
2026-06-13 11:43:26 +02:00

181 lines
5.8 KiB
Go

// 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,
}
holder := authn.NewTokenHolder()
src := authn.NewTokenSource(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)")
}
},
}, holder.Set)
ts, err := src.Start(ctx)
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))
// Keep the short-lived access token fresh for the life of the
// session. Renewals are pushed into holder via the callback above;
// both the REST and WebSocket paths read the current token through
// holder.AccessToken.
go func() { _ = src.Run(ctx) }()
api := &gsshAPI{
baseURL: strings.TrimRight(gsshBaseURL, "/"),
token: holder.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)
}
}