config.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5// Package appconfig loads cooked-mcp configuration from TOML and environment variables.
  6package appconfig
  7
  8import (
  9	"context"
 10	"errors"
 11	"fmt"
 12	"net"
 13	"net/url"
 14	"os"
 15	"path/filepath"
 16
 17	"github.com/BurntSushi/toml"
 18
 19	"git.secluded.site/cooked-mcp/internal/configvalue"
 20)
 21
 22const defaultBaseURL = "https://cooked.wiki"
 23
 24var userHomeDir = os.UserHomeDir
 25
 26// FileConfig is the TOML configuration shape.
 27type FileConfig struct {
 28	User   UserConfig   `toml:"user"`
 29	Cooked CookedConfig `toml:"cooked"`
 30	MCP    MCPConfig    `toml:"mcp"`
 31}
 32
 33// UserConfig contains Cooked login credentials.
 34type UserConfig struct {
 35	Name     string `toml:"name"`
 36	Password string `toml:"password"`
 37}
 38
 39// CookedConfig contains Cooked API configuration.
 40type CookedConfig struct {
 41	BaseURL string `toml:"base_url"`
 42}
 43
 44// MCPConfig contains MCP transport configuration.
 45type MCPConfig struct {
 46	HTTPAddr  string `toml:"http_addr"`
 47	HTTPToken string `toml:"http_token"`
 48}
 49
 50// Config is fully resolved runtime configuration.
 51type Config struct {
 52	Username  string
 53	Password  string
 54	BaseURL   *url.URL
 55	HTTPAddr  string
 56	HTTPToken string
 57}
 58
 59// Load reads and resolves configuration. Missing default config files are ignored.
 60func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Config, error) {
 61	fileConfig, err := loadFile(path)
 62	if err != nil {
 63		return Config{}, err
 64	}
 65
 66	username, err := requiredConfigValue(
 67		ctx,
 68		resolver,
 69		"COOKED_LOGIN_USERNAME",
 70		fileConfig.User.Name,
 71		"Cooked username",
 72	)
 73	if err != nil {
 74		return Config{}, err
 75	}
 76
 77	password, err := requiredConfigValue(
 78		ctx,
 79		resolver,
 80		"COOKED_LOGIN_PASSWORD",
 81		fileConfig.User.Password,
 82		"Cooked password",
 83	)
 84	if err != nil {
 85		return Config{}, err
 86	}
 87
 88	baseURL, err := baseURL(ctx, resolver, fileConfig.Cooked.BaseURL)
 89	if err != nil {
 90		return Config{}, err
 91	}
 92
 93	return Config{
 94		Username: username,
 95		Password: password,
 96		BaseURL:  baseURL,
 97	}, nil
 98}
 99
100// DefaultPath returns the default TOML configuration path.
101func DefaultPath() (string, error) {
102	if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
103		return filepath.Join(configHome, "cooked-mcp", "config.toml"), nil
104	}
105
106	home, err := userHomeDir()
107	if err != nil {
108		return "", fmt.Errorf("resolve default config path: %w", err)
109	}
110	if home == "" {
111		return "", fmt.Errorf("resolve default config path: home directory is empty")
112	}
113
114	return filepath.Join(home, ".config", "cooked-mcp", "config.toml"), nil
115}
116
117func loadFile(path string) (FileConfig, error) {
118	explicitPath := path != ""
119	if path == "" {
120		defaultPath, err := DefaultPath()
121		if err != nil {
122			return FileConfig{}, err
123		}
124		path = defaultPath
125	}
126
127	var fileConfig FileConfig
128	if _, err := os.Stat(path); err != nil {
129		if errors.Is(err, os.ErrNotExist) && !explicitPath {
130			return fileConfig, nil
131		}
132		if errors.Is(err, os.ErrNotExist) {
133			return FileConfig{}, fmt.Errorf("config file %q does not exist", path)
134		}
135		return FileConfig{}, fmt.Errorf("stat config file: %w", err)
136	}
137
138	if _, err := toml.DecodeFile(path, &fileConfig); err != nil {
139		return FileConfig{}, fmt.Errorf("decode config file: %w", err)
140	}
141
142	return fileConfig, nil
143}
144
145func requiredConfigValue(
146	ctx context.Context,
147	resolver *configvalue.Resolver,
148	envName, config, description string,
149) (string, error) {
150	if os.Getenv(envName) != "" || config != "" {
151		return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
152	}
153
154	return "", fmt.Errorf("missing %s configuration", description)
155}
156
157func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string) (*url.URL, error) {
158	envBaseURL := os.Getenv("COOKED_BASE_URL")
159	baseURLString := envBaseURL
160	if envBaseURL == "" {
161		baseURLString = defaultBaseURL
162	}
163	if envBaseURL == "" && config != "" {
164		resolved, err := resolver.ResolveOrThrow(ctx, config, "Cooked base URL")
165		if err != nil {
166			return nil, err
167		}
168		baseURLString = resolved
169	}
170
171	parsed, err := url.Parse(baseURLString)
172	if err != nil {
173		return nil, fmt.Errorf("parse Cooked base URL: %w", err)
174	}
175
176	if parsed.Scheme != "http" && parsed.Scheme != "https" {
177		return nil, fmt.Errorf("cooked base URL must be an HTTP URL")
178	}
179	if parsed.Host == "" {
180		return nil, fmt.Errorf("cooked base URL must include a host")
181	}
182	if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
183		return nil, fmt.Errorf("cooked base URL must use https unless it is localhost")
184	}
185
186	return parsed, nil
187}
188
189func isLoopbackHost(host string) bool {
190	if host == "localhost" {
191		return true
192	}
193
194	parsed := net.ParseIP(host)
195	return parsed != nil && parsed.IsLoopback()
196}