225 lines
7.5 KiB
Go
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 }
|