diff --git a/README.md b/README.md index 30c0e05..4474f6d 100644 --- a/README.md +++ b/README.md @@ -16,5 +16,6 @@ agent CLIs with generated MCP config, and lets MCPs authenticate lazily. - [gitea-mcp](docs/gitea-mcp.md) - [grafana-mcp](docs/grafana-mcp.md) - [gssh-mcp](docs/gssh-mcp.md) +- [searxng-mcp](docs/searxng-mcp.md) - [gssh integration](docs/gssh-integration.md) - [Conventions](docs/conventions.md) diff --git a/VERSION b/VERSION index 9faa1b7..c946ee6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/cmd/searxng-mcp/client.go b/cmd/searxng-mcp/client.go new file mode 100644 index 0000000..682ab1c --- /dev/null +++ b/cmd/searxng-mcp/client.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +var ErrUnauthorized = errors.New("searxng: bearer rejected") + +type searxngAPI struct { + baseURL string + token func(context.Context) (string, error) + client *http.Client +} + +type searchParams struct { + Query string + Categories string + Engines string + Language string + TimeRange string + SafeSearch *int + Page int + Limit int +} + +type searxngSearchResponse struct { + Query string `json:"query"` + NumberOfResults int `json:"number_of_results"` + Results []searxngResult `json:"results"` + Suggestions []string `json:"suggestions,omitempty"` + Answers []string `json:"answers,omitempty"` + Corrections []string `json:"corrections,omitempty"` +} + +type searxngResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content,omitempty"` + Engine string `json:"engine,omitempty"` + Engines []string `json:"engines,omitempty"` + Category string `json:"category,omitempty"` + Score float64 `json:"score,omitempty"` + PublishedDate string `json:"publishedDate,omitempty"` + ImgSrc string `json:"img_src,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` +} + +func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) { + q := url.Values{ + "format": []string{"json"}, + "q": []string{p.Query}, + } + setIf(q, "categories", p.Categories) + setIf(q, "engines", p.Engines) + setIf(q, "language", p.Language) + setIf(q, "time_range", p.TimeRange) + if p.SafeSearch != nil { + q.Set("safesearch", strconv.Itoa(*p.SafeSearch)) + } + if p.Page > 0 { + q.Set("pageno", strconv.Itoa(p.Page)) + } + + full := s.baseURL + "/search?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return searxngSearchResponse{}, err + } + tok, err := s.token(ctx) + if err != nil { + return searxngSearchResponse{}, err + } + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Accept", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return searxngSearchResponse{}, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if err := checkStatus(resp, req); err != nil { + return searxngSearchResponse{}, err + } + if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") { + return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct) + } + + var out searxngSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err) + } + if p.Limit > 0 && len(out.Results) > p.Limit { + out.Results = out.Results[:p.Limit] + } + return out, nil +} + +func checkStatus(resp *http.Response, req *http.Request) error { + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host) + } + if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect { + return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location")) + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +func setIf(q url.Values, k, v string) { + if v != "" { + q.Set(k, v) + } +} diff --git a/cmd/searxng-mcp/main.go b/cmd/searxng-mcp/main.go new file mode 100644 index 0000000..41e47f4 --- /dev/null +++ b/cmd/searxng-mcp/main.go @@ -0,0 +1,149 @@ +// Command searxng-mcp is sherlock's MCP server for SearXNG. It +// authenticates as the operator against Authentik (OIDC + PKCE, +// loopback callback) and sends the resulting bearer token to the +// Caddy-protected SearXNG JSON search API. +// +// Two modes: +// +// searxng-mcp start the stdio MCP server (agents spawn this) +// searxng-mcp --probe run auth + one search, print result, exit +// +// Deployment configuration (the Authentik issuer/client ID and the +// SearXNG base URL) comes entirely from the sherlock config file; there +// are no compiled-in defaults. See docs/configuration.md. +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/amacocian/sherlock/internal/agent" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/config" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg" +) + +// serviceName is the wallet key under which SearXNG tokens live, and +// the [services.] section read from the config file. The +// sherlock CLI's `logout searxng` clears the same wallet entry. +const serviceName = "searxng" + +// 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 in `sherlock status`. +var scopes = []string{"openid", "profile", "email"} + +func main() { + probe := flag.Bool("probe", false, "run auth + one SearXNG search, print result, exit") + probeQuery := flag.String("probe-query", "sherlock", "query to use with --probe") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion { + fmt.Println(Version) + return + } + + svc, err := config.LoadService(serviceName) + if err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp:", err) + if errors.Is(err, config.ErrNotFound) { + fmt.Fprintln(os.Stderr, "Create it (see docs/configuration.md) or run the sherlock install script.") + } + os.Exit(2) + } + + store, err := keyring.Open() + if err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp:", err) + os.Exit(3) + } + + agent.EnsurePathContainsSiblings() + + lockPath, err := xdg.RefreshLockPath() + if err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp:", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg := authn.Config{ + Issuer: svc.Issuer, + ClientID: svc.ClientID, + ClientSecret: svc.ClientSecret, + Scopes: scopes, + } + holder := authn.NewTokenHolder() + src := authn.NewTokenSource(store, serviceName, cfg, authn.EnsureOptions{ + LockPath: lockPath, + OnAuthURL: func(u string) { + fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:") + fmt.Fprintln(os.Stderr, " ", u) + if err := browser.Open(u); err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp: open browser:", err) + fmt.Fprintln(os.Stderr, "(visit the URL above manually)") + } + }, + }, holder.Set).Lifetime(ctx) + + // Lazy auth: the token getter triggers the (possibly browser-opening) + // OAuth flow on the first tool call and launches the background + // renewer. Auth stays off the MCP startup path so the handshake + // completes instantly. + token := func(reqCtx context.Context) (string, error) { + if err := src.EnsureStarted(reqCtx); err != nil { + return "", fmt.Errorf("searxng auth: %w", err) + } + return holder.AccessToken(), nil + } + + api := &searxngAPI{ + baseURL: strings.TrimRight(svc.BaseURL, "/"), + token: token, + client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } + + if *probe { + // --probe authenticates eagerly: that's the whole point of a probe. + if err := src.EnsureStarted(ctx); err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp: auth:", err) + os.Exit(5) + } + resp, err := api.search(ctx, searchParams{Query: *probeQuery, Limit: 1}) + if err != nil { + fmt.Fprintln(os.Stderr, "searxng-mcp: probe:", err) + os.Exit(5) + } + fmt.Printf("OK: searched %s for %q (%d result(s))\n", svc.BaseURL, *probeQuery, len(resp.Results)) + return + } + + server := mcp.NewServer(&mcp.Implementation{Name: "searxng-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, "searxng-mcp:", err) + os.Exit(1) + } +} diff --git a/cmd/searxng-mcp/tools.go b/cmd/searxng-mcp/tools.go new file mode 100644 index 0000000..42a464d --- /dev/null +++ b/cmd/searxng-mcp/tools.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "errors" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerTools(server *mcp.Server, api *searxngAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "search_web", + Description: "Search the operator's OAuth-protected SearXNG instance and return JSON search results.", + }, searchWebHandler(api)) +} + +type searchWebInput struct { + Query string `json:"query" jsonschema:"search query"` + Categories string `json:"categories,omitempty" jsonschema:"optional comma-separated SearXNG categories, e.g. general, images, news"` + Engines string `json:"engines,omitempty" jsonschema:"optional comma-separated SearXNG engines"` + Language string `json:"language,omitempty" jsonschema:"optional SearXNG language code"` + TimeRange string `json:"time_range,omitempty" jsonschema:"optional time range: day, month, year"` + SafeSearch *int `json:"safesearch,omitempty" jsonschema:"optional SearXNG safesearch value: 0, 1, or 2"` + Page int `json:"page,omitempty" jsonschema:"optional result page number, default 1"` + Limit int `json:"limit,omitempty" jsonschema:"max results to return (1-50, default 10)"` +} + +type searchWebOutput struct { + Query string `json:"query"` + NumberOfResults int `json:"number_of_results,omitempty"` + Results []searxngResult `json:"results"` + Suggestions []string `json:"suggestions,omitempty"` + Answers []string `json:"answers,omitempty"` + Corrections []string `json:"corrections,omitempty"` +} + +func searchWebHandler(api *searxngAPI) mcp.ToolHandlerFor[searchWebInput, searchWebOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in searchWebInput) (*mcp.CallToolResult, searchWebOutput, error) { + query := strings.TrimSpace(in.Query) + if query == "" { + return nil, searchWebOutput{}, errors.New("search_web requires query") + } + limit := clampInt(in.Limit, 1, 50, 10) + page := clampInt(in.Page, 1, 100, 1) + if in.SafeSearch != nil && (*in.SafeSearch < 0 || *in.SafeSearch > 2) { + return nil, searchWebOutput{}, errors.New("safesearch must be 0, 1, or 2") + } + resp, err := api.search(ctx, searchParams{ + Query: query, + Categories: strings.TrimSpace(in.Categories), + Engines: strings.TrimSpace(in.Engines), + Language: strings.TrimSpace(in.Language), + TimeRange: strings.TrimSpace(in.TimeRange), + SafeSearch: in.SafeSearch, + Page: page, + Limit: limit, + }) + if err != nil { + return nil, searchWebOutput{}, err + } + return nil, searchWebOutput{ + Query: resp.Query, + NumberOfResults: resp.NumberOfResults, + Results: resp.Results, + Suggestions: resp.Suggestions, + Answers: resp.Answers, + Corrections: resp.Corrections, + }, nil + } +} + +func clampInt(v, min, max, dflt int) int { + if v <= 0 { + return dflt + } + if v < min { + return min + } + if v > max { + return max + } + return v +} diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index db15fe5..7282cac 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -132,6 +132,11 @@ func runStatus(store keyring.Store, _ []string) { if len(ts.Scopes) > 0 { fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " ")) } + if !ts.AccessExpAt.IsZero() { + fmt.Printf(" access until %s (%s)\n", + ts.AccessExpAt.Format(time.RFC3339), + humanizeUntil(ts.AccessExpAt)) + } if !ts.IDExpiresAt.IsZero() { fmt.Printf(" id token until %s (%s)\n", ts.IDExpiresAt.Format(time.RFC3339), @@ -220,6 +225,7 @@ func knownMCPs() (mcp.Servers, error) { "gitea": {Command: "gitea-mcp"}, "grafana": {Command: "grafana-mcp"}, "gssh": {Command: "gssh-mcp"}, + "searxng": {Command: "searxng-mcp"}, } for name, spec := range specs { abs, err := agent.LookPath(spec.Command) diff --git a/cmd/sherlock/update.go b/cmd/sherlock/update.go index 3537321..1bc75c9 100644 --- a/cmd/sherlock/update.go +++ b/cmd/sherlock/update.go @@ -78,9 +78,10 @@ func runUpdate(args []string) { } // Non-interactive install against the cache checkout (the installer - // syncs it to the latest tag and bakes that version). Config is left - // untouched — SeedConfig is off, so an update never opens an editor - // or clobbers config. + // syncs it to the latest tag and bakes that version). SeedConfig is + // off, so update never creates a config or opens an editor, but the + // installer may append marked templates for newly shipped config + // sections. Existing values are never clobbered. if err := installer.Install(context.Background(), installer.Options{ Local: false, SeedConfig: false, diff --git a/completions/sherlock.fish b/completions/sherlock.fish index ecc73fd..82d69f0 100644 --- a/completions/sherlock.fish +++ b/completions/sherlock.fish @@ -26,6 +26,7 @@ complete -c sherlock -n '__fish_seen_subcommand_from run' -f -a 'claude' -d 'An complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gitea' -d 'Forget the gitea session' complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'grafana' -d 'Forget the grafana session' complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gssh' -d 'Forget the gssh session' +complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'searxng' -d 'Forget the searxng session' # ── `sherlock update [--force]` ────────────────────────────────────── complete -c sherlock -n '__fish_seen_subcommand_from update' -l force -s f -f -d 'Reinstall the latest release even if not newer' diff --git a/config.example.toml b/config.example.toml index 6485c41..9bda31c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -36,3 +36,7 @@ base_url = "https://grafana.example.com" [services.gssh] provider = "sherlock-cli" base_url = "https://terminal.example.com" + +[services.searxng] +provider = "sherlock-cli" +base_url = "https://search.example.com" diff --git a/docs/agents.md b/docs/agents.md index cc47573..2fde687 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -13,8 +13,9 @@ equivalent. Unknown agent names exit with usage errors. ## Spawn behavior On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`, -`gssh-mcp`), skips missing ones with a warning, renders a 0600 `.mcp.json` file -under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target agent. +`gssh-mcp`, `searxng-mcp`), skips missing ones with a warning, renders a 0600 +`.mcp.json` file under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target +agent. The child mostly inherits the parent environment. The Claude profile strips `ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed diff --git a/docs/auth-model.md b/docs/auth-model.md index e6baef6..e9987f2 100644 --- a/docs/auth-model.md +++ b/docs/auth-model.md @@ -5,7 +5,7 @@ master session: each MCP authenticates for the service it calls. ## Lifecycle -For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks +For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks `internal/authn` for a token on first use: 1. Fresh wallet entry: return it. @@ -15,17 +15,18 @@ For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return. Long-running MCPs use `TokenSource` and `TokenHolder` so requests always read -the latest bearer. Refresh never opens a browser; if refresh can no longer -recover, the next invocation performs a fresh login. +the latest bearer. Refresh follows access-token expiry, never opens a browser, +and falls back to a fresh login on the next invocation if refresh can no longer +recover. ## Service identity The config supplies issuer, client ID/secret, and base URL. Scopes are owned by each MCP because they follow the tool surface, not the deployment. -Gitea uses Gitea's OAuth2 server. Grafana and Gssh normally reuse the shared -Authentik `sherlock-cli` provider. Tokens are stored and refreshed per service -and are not reused across unrelated services. +Gitea uses Gitea's OAuth2 server. Grafana, Gssh, and SearXNG normally reuse the +shared Authentik `sherlock-cli` provider. Tokens are stored and refreshed per +service and are not reused across unrelated services. ## User controls diff --git a/docs/configuration.md b/docs/configuration.md index bca9efa..61a3259 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,6 +5,10 @@ required fields are hard errors. Default source: [`config.example.toml`](../config.example.toml). +Setup and update compare an existing config against the shipped example. Missing +provider/service sections are appended as marked templates; existing sections +and values are not rewritten. + ## Location Resolved in order: diff --git a/docs/conventions.md b/docs/conventions.md index 4eaa73c..0589e55 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -30,9 +30,10 @@ Repository rules that should stay true as the project changes. ## Naming -- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`. +- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`, `searxng-mcp`. - Agent names match the wrapped CLI: `copilot`, `claude`. -- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`. +- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`, + `searxng`. ## Commits and CI diff --git a/docs/installation.md b/docs/installation.md index 4e1f546..bffa8a2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,8 +15,9 @@ around the same command. The installer clones or updates a cached source checkout, checks out the latest release tag, seeds `config.toml` from `config.example.toml` without overwriting -existing values, optionally opens the config in an editor, installs `sherlock` -plus MCP binaries, and installs shell completions when supported. +existing values, appends marked templates for missing service/provider sections, +optionally opens the config in an editor, installs `sherlock` plus MCP binaries, +and installs shell completions when supported. One implementation backs both bootstrap setup and `sherlock update`. @@ -32,7 +33,8 @@ selects the editor. ## Updating Use `sherlock update` to install a newer release, or `sherlock update --force` -to reinstall the latest release. Updates rebuild binaries and completions but do -not touch existing config. +to reinstall the latest release. Updates rebuild binaries and completions, and +may append marked config templates for newly shipped service/provider sections. +They do not rewrite existing config values. Re-running setup is safe: it preserves config and reinstalls binaries. diff --git a/docs/searxng-mcp.md b/docs/searxng-mcp.md new file mode 100644 index 0000000..1c17738 --- /dev/null +++ b/docs/searxng-mcp.md @@ -0,0 +1,23 @@ +# searxng-mcp + +Stdio MCP for a SearXNG instance. It exposes web search through SearXNG's JSON +`/search` API. Exact tool schemas live in `cmd/searxng-mcp/tools.go`. + +## Auth + +`searxng-mcp` uses sherlock-managed OAuth with wallet key `searxng`, normally +through the shared Authentik `sherlock-cli` provider. It sends a fresh bearer to +the Caddy-protected SearXNG origin on every search request. + +Caddy must accept that OAuth bearer for API requests. Browser SSO redirects or +session cookies alone are not enough for an MCP subprocess. SearXNG must also +enable JSON output for `/search?format=json`. + +Config is `[services.searxng]` with provider or inline OAuth identity plus +`base_url`. `sherlock logout searxng` clears the session. + +## Operation + +When `searxng-mcp` is installed, `sherlock ` includes it in the generated +MCP config. The first tool call authenticates lazily. `searxng-mcp --probe` +verifies auth against Caddy and one SearXNG JSON search without an agent. diff --git a/internal/authn/login.go b/internal/authn/login.go index 6ec7389..0b407c2 100644 --- a/internal/authn/login.go +++ b/internal/authn/login.go @@ -205,6 +205,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider, AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, IDExpiresAt: idTok.Expiry, + AccessExpAt: accessExpiry(tok.Expiry, idTok.Expiry), RefreshExpAt: refreshExpiry(tok), Issuer: cfg.Issuer, ClientID: cfg.ClientID, @@ -239,12 +240,20 @@ func splitScope(s string) []string { if i > start { out = append(out, s[start:i]) } + start = i + 1 } } return out } +func accessExpiry(access, fallback time.Time) time.Time { + if !access.IsZero() { + return access + } + return fallback +} + // refreshExpiry pulls Authentik's `refresh_expires_in` (an extension // to RFC 6749). If absent we conservatively assume 30 days, matching // Authentik's default. diff --git a/internal/authn/login_test.go b/internal/authn/login_test.go index abc70ad..7e29ecb 100644 --- a/internal/authn/login_test.go +++ b/internal/authn/login_test.go @@ -246,6 +246,9 @@ func TestLogin_HappyPath(t *testing.T) { if res.Tokens.ClientID != "test-client" { t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID) } + if res.Tokens.AccessExpAt.IsZero() { + t.Fatal("AccessExpAt was not persisted") + } if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") { t.Fatalf("Issuer = %q", res.Tokens.Issuer) } diff --git a/internal/authn/refresh.go b/internal/authn/refresh.go index a4271ef..2243205 100644 --- a/internal/authn/refresh.go +++ b/internal/authn/refresh.go @@ -17,8 +17,8 @@ import ( "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" ) -// RefreshSkew is how much headroom EnsureFresh leaves before the ID -// token actually expires. Refreshing 30s ahead of expiry avoids +// RefreshSkew is how much headroom EnsureFresh leaves before the +// bearer token actually expires. Refreshing 30s ahead of expiry avoids // shipping a token that will be rejected by clock-skewed resource // servers. const RefreshSkew = 30 * time.Second @@ -39,7 +39,7 @@ type EnsureFreshOptions struct { } // EnsureFresh returns the stored TokenSet for service, refreshing it -// against its OAuth issuer if the ID token has < RefreshSkew left. +// against its OAuth issuer if the bearer token has < RefreshSkew left. // Cross-process safe: takes an exclusive flock on opts.LockPath, // re-reads the keyring after acquiring the lock, and skips the // refresh if a concurrent process already rotated the tokens. @@ -68,7 +68,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts if ts.Empty() { return ts, keyring.ErrNoTokens } - if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) { + if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) { return ts, nil } @@ -93,7 +93,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts if err != nil { return ts, err } - if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) { + if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) { return ts, nil } if ts.RefreshToken == "" { @@ -151,7 +151,18 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key out.RefreshToken = tok.RefreshToken } out.IDExpiresAt = idTok.Expiry + out.AccessExpAt = accessExpiry(tok.Expiry, idTok.Expiry) out.RefreshExpAt = refreshExpiry(tok) out.Scopes = grantedScopes(tok, ts.Scopes) return out, nil } + +func expiryForRefresh(ts keyring.TokenSet) time.Time { + if !ts.AccessExpAt.IsZero() { + return ts.AccessExpAt + } + if ts.AccessToken != "" && ts.RefreshToken != "" { + return time.Time{} + } + return ts.IDExpiresAt +} diff --git a/internal/authn/source.go b/internal/authn/source.go index 727cde8..b27e616 100644 --- a/internal/authn/source.go +++ b/internal/authn/source.go @@ -197,7 +197,7 @@ func (s *TokenSource) Run(ctx context.Context) error { } // nextWait computes how long to sleep before the next proactive -// renewal: skew before the cached token expires, clamped to minWait so +// renewal: skew before the bearer token expires, clamped to minWait so // a very short TTL doesn't spin. func (s *TokenSource) nextWait() time.Duration { s.mu.Lock() @@ -206,7 +206,7 @@ func (s *TokenSource) nextWait() time.Duration { if cur.Empty() { return s.minWait } - wait := cur.IDExpiresAt.Sub(s.clock()) - s.skew + wait := expiryForRefresh(cur).Sub(s.clock()) - s.skew if wait < s.minWait { return s.minWait } diff --git a/internal/authn/source_test.go b/internal/authn/source_test.go index fc85126..ca33f46 100644 --- a/internal/authn/source_test.go +++ b/internal/authn/source_test.go @@ -73,13 +73,16 @@ func TestTokenSource_Start_DeliversInitialToken(t *testing.T) { func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) { stub := newStubAuthentik(t) store := fakekeyring.New() - // Seed a STALE token (already expired) with a refresh token so the - // renewal path fires immediately. + // Seed a stale bearer with a still-fresh ID token. This mirrors + // providers that issue short-lived access tokens and longer-lived ID + // tokens; refresh must follow the bearer lifetime because MCPs send + // the access token to services. _ = store.Set("gitea", keyring.TokenSet{ IDToken: "old", AccessToken: "at-stale", RefreshToken: "rt-1", - IDExpiresAt: time.Now().Add(-time.Minute), + AccessExpAt: time.Now().Add(-time.Minute), + IDExpiresAt: time.Now().Add(time.Hour), Issuer: stub.srv.URL, ClientID: "test-client", Scopes: []string{"openid"}, @@ -114,6 +117,9 @@ func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) { if stored.AccessToken != "at-refreshed" { t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken) } + if stored.AccessExpAt.IsZero() { + t.Fatal("wallet access token expiry was not persisted") + } cancel() select { diff --git a/internal/installer/helpers.go b/internal/installer/helpers.go index 17f9c2b..937178a 100644 --- a/internal/installer/helpers.go +++ b/internal/installer/helpers.go @@ -53,6 +53,114 @@ func seedConfig(opts *Options, example, cfgPath string) error { return os.WriteFile(cfgPath, data, 0o600) } +// appendMissingConfigSections compares the operator config with the +// shipped example and appends any missing [providers.*] or [services.*] +// sections as clearly marked templates. It never rewrites existing +// sections, so operator edits are preserved. +func appendMissingConfigSections(opts *Options, example, cfgPath string) ([]string, error) { + cfg, err := os.ReadFile(cfgPath) + if err != nil { + if os.IsNotExist(err) { + opts.warn("no config found at %s; run setup with --config-only to create one.", cfgPath) + return nil, nil + } + return nil, err + } + exampleBody, err := os.ReadFile(example) + if err != nil { + return nil, err + } + + existing := sectionSet(string(cfg)) + blocks := configSectionBlocks(string(exampleBody)) + var missing []configSectionBlock + for _, b := range blocks { + if !existing[b.Name] { + missing = append(missing, b) + } + } + if len(missing) == 0 { + return nil, nil + } + + var out strings.Builder + out.WriteString(string(cfg)) + if len(cfg) > 0 && cfg[len(cfg)-1] != '\n' { + out.WriteByte('\n') + } + for _, b := range missing { + out.WriteString("\n# Added by sherlock because this section was missing from config.toml.\n") + out.WriteString("# Template copied from config.example.toml; fill placeholders before use.\n") + out.WriteString(strings.TrimSpace(b.Body)) + out.WriteByte('\n') + } + if err := os.WriteFile(cfgPath, []byte(out.String()), 0o600); err != nil { + return nil, err + } + + names := make([]string, 0, len(missing)) + for _, b := range missing { + names = append(names, b.Name) + } + return names, nil +} + +type configSectionBlock struct { + Name string + Body string +} + +func sectionSet(body string) map[string]bool { + out := map[string]bool{} + for _, line := range strings.Split(body, "\n") { + if name, ok := configSectionName(line); ok { + out[name] = true + } + } + return out +} + +func configSectionBlocks(body string) []configSectionBlock { + lines := strings.Split(body, "\n") + var blocks []configSectionBlock + for i := 0; i < len(lines); i++ { + name, ok := configSectionName(lines[i]) + if !ok { + continue + } + start := i + end := len(lines) + for j := i + 1; j < len(lines); j++ { + if _, ok := configSectionName(lines[j]); ok { + end = j + break + } + } + blocks = append(blocks, configSectionBlock{ + Name: name, + Body: strings.Join(lines[start:end], "\n"), + }) + i = end - 1 + } + return blocks +} + +func configSectionName(line string) (string, bool) { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "[") { + return "", false + } + end := strings.IndexByte(s, ']') + if end < 0 { + return "", false + } + name := s[1:end] + if strings.HasPrefix(name, "providers.") || strings.HasPrefix(name, "services.") { + return name, true + } + return "", false +} + // pickEditor resolves the editor command: $VISUAL, then $EDITOR, then // the first of sensible-editor/nano/vi found on PATH. Returns "" if none. func pickEditor() string { diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 386cb25..255dab4 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -31,7 +31,7 @@ const devVersion = "0.0.0-dev" // binaries are the cmd/ packages installed. `go install ./cmd/...` // covers exactly these (the setup entry lives outside cmd/, so it is // never installed). -var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp"} +var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp", "searxng-mcp"} // Options controls one Install run. type Options struct { @@ -49,7 +49,8 @@ type Options struct { // SeedConfig copies config.example.toml to the operator's config // path when none exists yet (never clobbers a filled-in file). SeedConfig bool - // OpenEditor opens $VISUAL/$EDITOR on the config after seeding. + // OpenEditor opens $VISUAL/$EDITOR on the config after seeding and + // appending any missing section templates. OpenEditor bool // ConfigOnly stops after the config step (no build/install). ConfigOnly bool @@ -97,14 +98,26 @@ func Install(ctx context.Context, opts Options) error { return fmt.Errorf("config.example.toml not found in the source (%s): %w", example, err) } + cfgPath, err := config.DefaultPath() + if err != nil { + return err + } if opts.SeedConfig { - cfgPath, err := config.DefaultPath() - if err != nil { - return err - } if err := seedConfig(&opts, example, cfgPath); err != nil { return err } + } + + addedSections, err := appendMissingConfigSections(&opts, example, cfgPath) + if err != nil { + return err + } + if len(addedSections) > 0 { + opts.warn("%s was missing section(s): %s. Appended marked templates from config.example.toml; fill placeholders before use.", + cfgPath, strings.Join(addedSections, ", ")) + } + + if opts.SeedConfig { if opts.OpenEditor { if err := openEditor(&opts, cfgPath); err != nil { return err diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go index 7c9b287..d79ea6e 100644 --- a/internal/installer/installer_test.go +++ b/internal/installer/installer_test.go @@ -3,6 +3,7 @@ package installer import ( "os" "path/filepath" + "strings" "testing" ) @@ -70,6 +71,68 @@ func TestSeedConfig_DoesNotClobber(t *testing.T) { } } +func TestAppendMissingConfigSections(t *testing.T) { + dir := t.TempDir() + example := filepath.Join(dir, "config.example.toml") + cfg := filepath.Join(dir, "config.toml") + if err := os.WriteFile(example, []byte(` +[providers.sherlock-cli] +issuer = "https://id.example/" +client_id = "REPLACE" + +[services.gitea] +issuer = "https://gitea.example" +client_id = "gitea" +base_url = "https://gitea.example" + +[services.searxng] +provider = "sherlock-cli" +base_url = "https://search.example" +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg, []byte(` +[providers.sherlock-cli] +issuer = "https://id.real/" +client_id = "real" + +[services.gitea] +issuer = "https://gitea.real" +client_id = "gitea-real" +base_url = "https://gitea.real" +`), 0o600); err != nil { + t.Fatal(err) + } + + opts := &Options{Out: io_discard{}, Err: io_discard{}} + added, err := appendMissingConfigSections(opts, example, cfg) + if err != nil { + t.Fatalf("appendMissingConfigSections: %v", err) + } + if len(added) != 1 || added[0] != "services.searxng" { + t.Fatalf("added = %v, want [services.searxng]", added) + } + b, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + body := string(b) + if !strings.Contains(body, "Added by sherlock") { + t.Fatalf("missing marker comment in %q", body) + } + if strings.Count(body, "[services.searxng]") != 1 { + t.Fatalf("searxng section count wrong in %q", body) + } + + added, err = appendMissingConfigSections(opts, example, cfg) + if err != nil { + t.Fatalf("second appendMissingConfigSections: %v", err) + } + if len(added) != 0 { + t.Fatalf("second added = %v, want none", added) + } +} + func TestFishCompletionsDir(t *testing.T) { tmp := t.TempDir() t.Setenv("__fish_config_dir", "") diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 569e93b..2cac1b6 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -139,6 +139,7 @@ type TokenSet struct { IDToken string `json:"id_token"` AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` + AccessExpAt time.Time `json:"access_expires_at,omitempty"` IDExpiresAt time.Time `json:"id_expires_at"` RefreshExpAt time.Time `json:"refresh_expires_at"` Issuer string `json:"issuer"`