Files
sherlock/internal/config/config.go
T
Alexandru Macocian 84958c7ef3
Release / release (push) Successful in 17s
Support for local agents
2026-06-15 19:03:56 +02:00

225 lines
7.5 KiB
Go

// Package config loads sherlock's operator-owned configuration from a
// single TOML file (default $XDG_CONFIG_HOME/sherlock/config.toml).
//
// There are no compiled-in deployment defaults. Every deployment value
// — the Authentik issuer/client IDs and each service's base URL — comes
// from this file. A missing file, a missing service section, or a
// missing required field is a hard error: sherlock and its MCPs refuse
// to start rather than silently target the wrong host.
//
// Shape:
//
// [oauth]
// browser = "firefox" # optional; default OS browser when empty
//
// [providers.sherlock-cli] # a reusable OAuth provider
// issuer = "https://id.example/application/o/sherlock-cli/"
// client_id = "abc123"
//
// [services.gitea] # gitea runs its own OAuth server
// issuer = "https://gitea.example"
// client_id = "def456"
// base_url = "https://gitea.example"
//
// [services.grafana] # grafana reuses the provider above
// provider = "sherlock-cli"
// base_url = "https://grafana.example"
//
// [agents.copilot-local] # a custom wrapper agent
// command = "copilot-local" # binary to exec (defaults to name)
// backend = "copilot" # MCP/-env conventions to follow
//
// A service either references a [providers.<name>] block via `provider`
// or carries its own inline issuer/client_id (as gitea does, since it
// authenticates against its own OAuth server rather than Authentik).
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// EnvConfigPath, when set, overrides the default config-file location.
// Primarily for tests and non-standard installs.
const EnvConfigPath = "SHERLOCK_CONFIG"
// ErrNotFound is returned by Load when no config file exists at the
// resolved path. Callers surface it with a pointer to the install
// script / docs.
var ErrNotFound = errors.New("config: no config file found")
// Provider is a reusable OAuth/OIDC client definition that one or more
// services can share (e.g. the Authentik `sherlock-cli` provider used
// by both grafana and gssh).
type Provider struct {
Issuer string `toml:"issuer"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
}
// Service is one downstream service's configuration. It either names a
// shared Provider via Provider, or supplies its own inline
// Issuer/ClientID/ClientSecret. BaseURL is always required — it's the
// origin the MCP makes API calls against.
type Service struct {
Provider string `toml:"provider"`
Issuer string `toml:"issuer"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
BaseURL string `toml:"base_url"`
}
// OAuth contains global OAuth-flow preferences.
type OAuth struct {
// Browser is an optional executable name or path used to open OAuth
// login URLs. Empty means the OS default browser.
Browser string `toml:"browser"`
}
// Agent is an operator-defined custom agent: a wrapper command plus the
// backend whose MCP-config and env conventions it follows. It lets an
// operator expose, say, a `copilot-local` wrapper (which points the
// Copilot CLI at a self-hosted model) as `sherlock copilot-local` while
// reusing the built-in "copilot" backend's MCP formatting.
//
// Command is the binary sherlock execs; it defaults to the agent's name
// when empty. Backend selects the MCP/-env conventions and must name a
// backend the agent package knows ("copilot" or "claude").
type Agent struct {
Command string `toml:"command"`
Backend string `toml:"backend"`
}
// Config is the whole parsed file.
type Config struct {
OAuth OAuth `toml:"oauth"`
Providers map[string]Provider `toml:"providers"`
Services map[string]Service `toml:"services"`
Agents map[string]Agent `toml:"agents"`
// path is where this config was loaded from, for error messages.
path string
}
// Resolved is the flattened, validated set of values an MCP needs for
// one service: a provider reference (if any) has been dereferenced and
// every required field is guaranteed non-empty.
type Resolved struct {
Issuer string
ClientID string
ClientSecret string
BaseURL string
OAuthBrowser string
}
// DefaultPath returns the config-file path sherlock reads, honouring
// $SHERLOCK_CONFIG, then $XDG_CONFIG_HOME, then $HOME/.config. It does
// not create anything and does not require the file to exist.
func DefaultPath() (string, error) {
if p := os.Getenv(EnvConfigPath); p != "" {
return p, nil
}
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "sherlock", "config.toml"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: resolve home: %w", err)
}
return filepath.Join(home, ".config", "sherlock", "config.toml"), nil
}
// Load reads and parses the config file at DefaultPath(). It returns
// ErrNotFound (wrapped) if the file is absent.
func Load() (*Config, error) {
path, err := DefaultPath()
if err != nil {
return nil, err
}
return LoadFrom(path)
}
// LoadService is a convenience that loads the default config file and
// resolves one service in a single call — the common path for an MCP
// at startup.
func LoadService(name string) (Resolved, error) {
c, err := Load()
if err != nil {
return Resolved{}, err
}
return c.Service(name)
}
// LoadFrom reads and parses the config file at path.
func LoadFrom(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w at %s", ErrNotFound, path)
}
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var c Config
if err := toml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
c.path = path
return &c, nil
}
// Service resolves and validates the configuration for the named
// service: it dereferences a `provider` reference if present, then
// checks that issuer, client_id, and base_url are all set. Any problem
// is returned as a descriptive error naming the file and the field.
func (c *Config) Service(name string) (Resolved, error) {
svc, ok := c.Services[name]
if !ok {
return Resolved{}, fmt.Errorf("config: no [services.%s] section in %s", name, c.path)
}
r := Resolved{
Issuer: svc.Issuer,
ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret,
BaseURL: svc.BaseURL,
OAuthBrowser: c.OAuth.Browser,
}
if svc.Provider != "" {
p, ok := c.Providers[svc.Provider]
if !ok {
return Resolved{}, fmt.Errorf("config: [services.%s] references unknown provider %q (no [providers.%s] in %s)", name, svc.Provider, svc.Provider, c.path)
}
// A service that names a provider must not also override the
// OAuth identity inline — that's almost always a mistake.
if svc.Issuer != "" || svc.ClientID != "" || svc.ClientSecret != "" {
return Resolved{}, fmt.Errorf("config: [services.%s] sets both provider = %q and an inline issuer/client_id; pick one", name, svc.Provider)
}
r.Issuer = p.Issuer
r.ClientID = p.ClientID
r.ClientSecret = p.ClientSecret
}
var missing []string
if r.Issuer == "" {
missing = append(missing, "issuer")
}
if r.ClientID == "" {
missing = append(missing, "client_id")
}
if r.BaseURL == "" {
missing = append(missing, "base_url")
}
if len(missing) > 0 {
return Resolved{}, fmt.Errorf("config: [services.%s] in %s is missing required field(s): %v", name, c.path, missing)
}
return r, nil
}
// Path returns where this config was loaded from.
func (c *Config) Path() string { return c.path }