181 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|