257 lines
8.7 KiB
Go
257 lines
8.7 KiB
Go
|
|
// Package config loads broker configuration from environment variables and
|
||
|
|
// command-line flags, applies defaults, and validates the result.
|
||
|
|
//
|
||
|
|
// Precedence: flags win over environment variables. Empty environment values
|
||
|
|
// are treated as unset, so operators can neutralise an exported variable
|
||
|
|
// without needing to unset it. Required fields have no default and must be
|
||
|
|
// supplied one way or the other. Validation is strict — the process must not
|
||
|
|
// start with invalid config.
|
||
|
|
package config
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Default values for optional fields. Exposed so tests and callers can
|
||
|
|
// reference them without re-hardcoding.
|
||
|
|
const (
|
||
|
|
DefaultListen = ":8080"
|
||
|
|
DefaultForgejoOAuthScopes = "read:user write:repository write:issue write:notification read:organization"
|
||
|
|
DefaultForgejoMCPBinary = "forgejo-mcp"
|
||
|
|
DefaultStorePath = "/data/broker.db"
|
||
|
|
DefaultMaxSessions = 100
|
||
|
|
DefaultSessionIdleTimeout = 15 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
// Config holds fully resolved and validated broker configuration.
|
||
|
|
type Config struct {
|
||
|
|
PublicURL string
|
||
|
|
Listen string
|
||
|
|
ForgejoURL string
|
||
|
|
ForgejoOAuthClientID string
|
||
|
|
ForgejoOAuthClientSecret string
|
||
|
|
ForgejoOAuthScopes string
|
||
|
|
ForgejoMCPBinary string
|
||
|
|
StorePath string
|
||
|
|
MaxSessions int
|
||
|
|
SessionIdleTimeout time.Duration
|
||
|
|
Debug bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load resolves configuration from environment variables and the provided
|
||
|
|
// command-line arguments (typically os.Args[1:]), then validates the result.
|
||
|
|
// out receives flag-package output (usage text on -h / parse errors).
|
||
|
|
//
|
||
|
|
// Returns flag.ErrHelp when the user requested help — callers should treat
|
||
|
|
// that as a normal exit, not a configuration failure.
|
||
|
|
func Load(args []string, out io.Writer) (*Config, error) {
|
||
|
|
cfg, err := fromEnv()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
fs := flag.NewFlagSet("fjmcp-broker", flag.ContinueOnError)
|
||
|
|
fs.SetOutput(out)
|
||
|
|
cfg.bindFlags(fs)
|
||
|
|
|
||
|
|
if err := fs.Parse(args); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := cfg.Validate(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return cfg, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func fromEnv() (*Config, error) {
|
||
|
|
cfg := &Config{
|
||
|
|
PublicURL: envOr("FJMCP_BROKER_PUBLIC_URL", ""),
|
||
|
|
Listen: envOr("FJMCP_BROKER_LISTEN", DefaultListen),
|
||
|
|
ForgejoURL: envOr("FORGEJO_URL", ""),
|
||
|
|
ForgejoOAuthClientID: envOr("FORGEJO_OAUTH_CLIENT_ID", ""),
|
||
|
|
ForgejoOAuthClientSecret: envOr("FORGEJO_OAUTH_CLIENT_SECRET", ""),
|
||
|
|
ForgejoOAuthScopes: envOr("FORGEJO_OAUTH_SCOPES", DefaultForgejoOAuthScopes),
|
||
|
|
ForgejoMCPBinary: envOr("FJMCP_BROKER_MCP_BINARY", DefaultForgejoMCPBinary),
|
||
|
|
StorePath: envOr("FJMCP_BROKER_STORE", DefaultStorePath),
|
||
|
|
MaxSessions: DefaultMaxSessions,
|
||
|
|
SessionIdleTimeout: DefaultSessionIdleTimeout,
|
||
|
|
}
|
||
|
|
|
||
|
|
if v, ok := lookupNonEmpty("FJMCP_BROKER_MAX_SESSIONS"); ok {
|
||
|
|
n, err := strconv.Atoi(v)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("FJMCP_BROKER_MAX_SESSIONS: %w", err)
|
||
|
|
}
|
||
|
|
cfg.MaxSessions = n
|
||
|
|
}
|
||
|
|
if v, ok := lookupNonEmpty("FJMCP_BROKER_IDLE_TIMEOUT"); ok {
|
||
|
|
d, err := time.ParseDuration(v)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("FJMCP_BROKER_IDLE_TIMEOUT: %w", err)
|
||
|
|
}
|
||
|
|
cfg.SessionIdleTimeout = d
|
||
|
|
}
|
||
|
|
if v, ok := lookupNonEmpty("FJMCP_BROKER_DEBUG"); ok {
|
||
|
|
b, err := strconv.ParseBool(v)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("FJMCP_BROKER_DEBUG: %w", err)
|
||
|
|
}
|
||
|
|
cfg.Debug = b
|
||
|
|
}
|
||
|
|
return cfg, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Config) bindFlags(fs *flag.FlagSet) {
|
||
|
|
fs.StringVar(&c.PublicURL, "public-url", c.PublicURL,
|
||
|
|
"Public issuer URL, e.g. https://mcp.example.com (REQUIRED; env FJMCP_BROKER_PUBLIC_URL)")
|
||
|
|
fs.StringVar(&c.Listen, "listen", c.Listen,
|
||
|
|
"HTTP listen address (env FJMCP_BROKER_LISTEN)")
|
||
|
|
fs.StringVar(&c.ForgejoURL, "forgejo-url", c.ForgejoURL,
|
||
|
|
"Upstream Forgejo instance URL (REQUIRED; env FORGEJO_URL)")
|
||
|
|
fs.StringVar(&c.ForgejoOAuthClientID, "forgejo-oauth-client-id", c.ForgejoOAuthClientID,
|
||
|
|
"Forgejo OAuth client ID (REQUIRED; env FORGEJO_OAUTH_CLIENT_ID)")
|
||
|
|
fs.StringVar(&c.ForgejoOAuthClientSecret, "forgejo-oauth-client-secret", c.ForgejoOAuthClientSecret,
|
||
|
|
"Forgejo OAuth client secret (REQUIRED; env FORGEJO_OAUTH_CLIENT_SECRET)")
|
||
|
|
fs.StringVar(&c.ForgejoOAuthScopes, "forgejo-oauth-scopes", c.ForgejoOAuthScopes,
|
||
|
|
"Space-separated OAuth scopes requested from Forgejo (env FORGEJO_OAUTH_SCOPES)")
|
||
|
|
fs.StringVar(&c.ForgejoMCPBinary, "forgejo-mcp-binary", c.ForgejoMCPBinary,
|
||
|
|
"Path to the forgejo-mcp binary (env FJMCP_BROKER_MCP_BINARY)")
|
||
|
|
fs.StringVar(&c.StorePath, "store-path", c.StorePath,
|
||
|
|
"SQLite store file path (env FJMCP_BROKER_STORE)")
|
||
|
|
fs.IntVar(&c.MaxSessions, "max-sessions", c.MaxSessions,
|
||
|
|
"Maximum concurrent MCP sessions (env FJMCP_BROKER_MAX_SESSIONS)")
|
||
|
|
fs.DurationVar(&c.SessionIdleTimeout, "session-idle-timeout", c.SessionIdleTimeout,
|
||
|
|
"Idle timeout before a session subprocess is reaped (env FJMCP_BROKER_IDLE_TIMEOUT)")
|
||
|
|
fs.BoolVar(&c.Debug, "debug", c.Debug,
|
||
|
|
"Verbose logging (env FJMCP_BROKER_DEBUG)")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate checks that required fields are present and all values are
|
||
|
|
// internally consistent. Errors are aggregated so the operator sees every
|
||
|
|
// problem at once.
|
||
|
|
func (c *Config) Validate() error {
|
||
|
|
var errs []error
|
||
|
|
|
||
|
|
if c.PublicURL == "" {
|
||
|
|
errs = append(errs, errors.New("public-url is required (--public-url or FJMCP_BROKER_PUBLIC_URL)"))
|
||
|
|
}
|
||
|
|
if c.ForgejoURL == "" {
|
||
|
|
errs = append(errs, errors.New("forgejo-url is required (--forgejo-url or FORGEJO_URL)"))
|
||
|
|
}
|
||
|
|
if c.ForgejoOAuthClientID == "" {
|
||
|
|
errs = append(errs, errors.New("forgejo-oauth-client-id is required (--forgejo-oauth-client-id or FORGEJO_OAUTH_CLIENT_ID)"))
|
||
|
|
}
|
||
|
|
if c.ForgejoOAuthClientSecret == "" {
|
||
|
|
errs = append(errs, errors.New("forgejo-oauth-client-secret is required (--forgejo-oauth-client-secret or FORGEJO_OAUTH_CLIENT_SECRET)"))
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.PublicURL != "" {
|
||
|
|
if err := validateIssuerURL("public-url", c.PublicURL); err != nil {
|
||
|
|
errs = append(errs, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if c.ForgejoURL != "" {
|
||
|
|
if err := validateIssuerURL("forgejo-url", c.ForgejoURL); err != nil {
|
||
|
|
errs = append(errs, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.StorePath == "" {
|
||
|
|
errs = append(errs, errors.New("store-path must not be empty"))
|
||
|
|
} else if err := checkStorePathWritable(c.StorePath); err != nil {
|
||
|
|
errs = append(errs, fmt.Errorf("store-path: %w", err))
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.Listen == "" {
|
||
|
|
errs = append(errs, errors.New("listen must not be empty"))
|
||
|
|
}
|
||
|
|
if c.MaxSessions <= 0 {
|
||
|
|
errs = append(errs, fmt.Errorf("max-sessions must be > 0, got %d", c.MaxSessions))
|
||
|
|
}
|
||
|
|
if c.SessionIdleTimeout <= 0 {
|
||
|
|
errs = append(errs, fmt.Errorf("session-idle-timeout must be > 0, got %s", c.SessionIdleTimeout))
|
||
|
|
}
|
||
|
|
|
||
|
|
return errors.Join(errs...)
|
||
|
|
}
|
||
|
|
|
||
|
|
// validateIssuerURL rejects malformed URLs, enforces http/https scheme, and
|
||
|
|
// disallows plain http for non-loopback hosts. Publishing an http issuer for
|
||
|
|
// a non-loopback host is a classic OAuth misconfiguration that undermines
|
||
|
|
// the whole point of TLS on the AS.
|
||
|
|
func validateIssuerURL(field, raw string) error {
|
||
|
|
u, err := url.Parse(raw)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%s: %w", field, err)
|
||
|
|
}
|
||
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||
|
|
return fmt.Errorf("%s: scheme must be http or https, got %q", field, u.Scheme)
|
||
|
|
}
|
||
|
|
if u.Host == "" {
|
||
|
|
return fmt.Errorf("%s: missing host in %q", field, raw)
|
||
|
|
}
|
||
|
|
if u.Scheme == "http" && !isLoopback(u.Hostname()) {
|
||
|
|
return fmt.Errorf("%s: http scheme only allowed for loopback hosts, got %q", field, u.Hostname())
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func isLoopback(host string) bool {
|
||
|
|
switch host {
|
||
|
|
case "localhost", "127.0.0.1", "::1":
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// checkStorePathWritable verifies the store's parent directory exists and
|
||
|
|
// the current process can create files in it. The store file itself is not
|
||
|
|
// created here — that is the storage layer's responsibility.
|
||
|
|
func checkStorePathWritable(path string) error {
|
||
|
|
abs, err := filepath.Abs(path)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("invalid path %q: %w", path, err)
|
||
|
|
}
|
||
|
|
dir := filepath.Dir(abs)
|
||
|
|
info, err := os.Stat(dir)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("parent directory %q: %w", dir, err)
|
||
|
|
}
|
||
|
|
if !info.IsDir() {
|
||
|
|
return fmt.Errorf("parent %q is not a directory", dir)
|
||
|
|
}
|
||
|
|
f, err := os.CreateTemp(dir, ".fjmcp-broker-probe-*")
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("parent directory %q not writable: %w", dir, err)
|
||
|
|
}
|
||
|
|
name := f.Name()
|
||
|
|
_ = f.Close()
|
||
|
|
_ = os.Remove(name)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func envOr(key, fallback string) string {
|
||
|
|
if v, ok := lookupNonEmpty(key); ok {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
|
||
|
|
// lookupNonEmpty returns the env value and true only when the variable is
|
||
|
|
// set AND non-empty. An exported-but-empty variable is treated as unset,
|
||
|
|
// matching common shell usage.
|
||
|
|
func lookupNonEmpty(key string) (string, bool) {
|
||
|
|
v, ok := os.LookupEnv(key)
|
||
|
|
if !ok || v == "" {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
return v, true
|
||
|
|
}
|