repo-sync/config/config.go
James Coleman 5af1dd8928
Some checks failed
Go package / build (push) Has been cancelled
first commit
2026-07-28 19:22:20 -05:00

513 lines
19 KiB
Go

package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
)
// Domain roles: the online domain is the writable upstream cache, offline
// domains are read-only published trees that fall back to the online tree.
const (
RoleOnline = "online"
RoleOffline = "offline"
)
// HTTPConfig configures the mirror server listener.
type HTTPConfig struct {
BindAddr string `mapstructure:"bind_addr" yaml:"bind_addr"`
Port uint `mapstructure:"port" yaml:"port" validate:"gt=0,lte=65535"`
ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout" yaml:"read_header_timeout" validate:"gt=0"`
ReadTimeout time.Duration `mapstructure:"read_timeout" yaml:"read_timeout" validate:"gt=0"`
WriteTimeout time.Duration `mapstructure:"write_timeout" yaml:"write_timeout" validate:"gt=0"`
IdleTimeout time.Duration `mapstructure:"idle_timeout" yaml:"idle_timeout" validate:"gt=0"`
// DirectoryIndexes enables directory listings: upstream index pages are
// passed through, and the root serves a generated index of the mounts
// when no catch-all mount covers it. When disabled, every directory
// request answers with a static notice page instead.
DirectoryIndexes bool `mapstructure:"directory_indexes" yaml:"directory_indexes"`
}
// ListenAddr renders the bind address and port for net.Listen, bracketing
// IPv6 addresses as required.
func (h HTTPConfig) ListenAddr() string {
return net.JoinHostPort(h.BindAddr, strconv.Itoa(int(h.Port)))
}
// LogConfig configures the server's logging output.
type LogConfig struct {
Level string `mapstructure:"level" yaml:"level" validate:"oneof=debug info warn error"`
Type string `mapstructure:"type" yaml:"type" validate:"oneof=console json"`
Outputs []string `mapstructure:"outputs" yaml:"outputs" validate:"required,min=1,dive,required"`
}
// DomainConfig maps a request Host header to a filesystem root and role.
type DomainConfig struct {
Domain string `mapstructure:"domain" yaml:"domain" validate:"required"`
Root string `mapstructure:"root" yaml:"root" validate:"required"`
Role string `mapstructure:"role" yaml:"role" validate:"required,oneof=online offline"`
}
// MountRepoConfig is a repository kept synchronized regardless of client
// traffic, identified by its request path and format.
type MountRepoConfig struct {
Path string `mapstructure:"path" yaml:"path" validate:"required"`
Type string `mapstructure:"type" yaml:"type" validate:"required,oneof=rpm deb arch apk"`
}
// MountConfig maps a base request path onto an upstream mirror URL.
type MountConfig struct {
Path string `mapstructure:"path" yaml:"path" validate:"required"`
Upstream string `mapstructure:"upstream" yaml:"upstream" validate:"required,url"`
Repos []MountRepoConfig `mapstructure:"repos" yaml:"repos" validate:"omitempty,dive"`
}
// CrawlerConfig tunes the background synchronization behavior.
type CrawlerConfig struct {
Workers int `mapstructure:"workers" yaml:"workers" validate:"gt=0"`
ConcurrentCrawls int `mapstructure:"concurrent_crawls" yaml:"concurrent_crawls" validate:"gt=0"`
RequestTimeout time.Duration `mapstructure:"request_timeout" yaml:"request_timeout" validate:"gt=0"`
UserAgent string `mapstructure:"user_agent" yaml:"user_agent"`
PruneGrace time.Duration `mapstructure:"prune_grace" yaml:"prune_grace" validate:"gte=0"`
RefreshSchedule []time.Duration `mapstructure:"refresh_schedule" yaml:"refresh_schedule" validate:"required,min=1,dive,gt=0"`
// MissingMode decides what a synchronization does with a package the
// repository metadata lists but the upstream does not serve. The mode
// names are the mirror's own; the fetch package parses them.
MissingMode string `mapstructure:"missing_mode" yaml:"missing_mode" validate:"omitempty,oneof=fail retry ignore"`
// MissingRetries is how many consecutive runs must miss a file before
// the retry mode accepts it as gone.
MissingRetries int `mapstructure:"missing_retries" yaml:"missing_retries" validate:"gte=0"`
// DiscoverCache is how long a discovery crawl's results are reused
// before the tree is scanned again.
DiscoverCache time.Duration `mapstructure:"discover_cache" yaml:"discover_cache" validate:"gte=0"`
}
// TraceConfig describes the mirror this instance publishes, and is written
// into each synchronized or crawled repository as a trace file when
// enabled. The fields follow the trace convention Debian archives
// established, which downstream mirrors and mirror checkers read to learn
// who runs a mirror, where it is, and when it last synchronized.
type TraceConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
// Host names this mirror in the trace, and is also the trace file's
// name. It defaults to the system hostname.
Host string `mapstructure:"host" yaml:"host"`
Maintainer string `mapstructure:"maintainer" yaml:"maintainer"`
Sponsor string `mapstructure:"sponsor" yaml:"sponsor"`
Country string `mapstructure:"country" yaml:"country"`
Location string `mapstructure:"location" yaml:"location"`
Throughput string `mapstructure:"throughput" yaml:"throughput"`
}
// HostName returns the name this mirror is traced under, falling back to
// the system hostname when none is configured. The fallback lives here
// rather than in the defaults so it also applies to the built-in
// configuration, which is what a run without a config file reads.
func (t TraceConfig) HostName() string {
if t.Host != "" {
return t.Host
}
if name, err := os.Hostname(); err == nil && name != "" {
return name
}
return "unknown"
}
// Config is the mirror server configuration.
type Config struct {
HTTP HTTPConfig `mapstructure:"http" yaml:"http"`
Log LogConfig `mapstructure:"log" yaml:"log"`
StatePath string `mapstructure:"state_path" yaml:"state_path" validate:"required"`
StateFlushInterval time.Duration `mapstructure:"state_flush_interval" yaml:"state_flush_interval" validate:"gt=0"`
RepoCrawlInterval time.Duration `mapstructure:"repo_crawl_interval" yaml:"repo_crawl_interval" validate:"gt=0"`
FailureRetryInterval time.Duration `mapstructure:"failure_retry_interval" yaml:"failure_retry_interval" validate:"gt=0"`
CrawlCheckInterval time.Duration `mapstructure:"crawl_check_interval" yaml:"crawl_check_interval" validate:"gt=0"`
GenericMaxAge time.Duration `mapstructure:"generic_max_age" yaml:"generic_max_age" validate:"gt=0"`
NegativeCacheTTL time.Duration `mapstructure:"negative_cache_ttl" yaml:"negative_cache_ttl" validate:"gt=0"`
TransientErrorTTL time.Duration `mapstructure:"transient_error_ttl" yaml:"transient_error_ttl" validate:"gt=0"`
Domains []DomainConfig `mapstructure:"domains" yaml:"domains" validate:"omitempty,dive"`
Mounts []MountConfig `mapstructure:"mounts" yaml:"mounts" validate:"omitempty,dive"`
Crawler CrawlerConfig `mapstructure:"crawler" yaml:"crawler"`
Trace TraceConfig `mapstructure:"trace" yaml:"trace"`
domainsByName map[string]int
onlineIndex int
mountOrder []int
}
// C is the active server configuration. A reload builds and validates a
// replacement in full before repointing C at it, so readers always see a
// complete configuration; requests already in flight finish against the
// one they picked up.
var C *Config
// C starts out holding the built-in defaults so it is never nil. Commands
// that run without a configuration file, and packages exercised by tests
// that never call Init, read those defaults instead of guarding for nil.
func init() {
C = defaults()
}
// defaults builds a configuration from the registered defaults alone,
// consulting no file. Nothing here can fail: the values are static and the
// indexes start empty, so validation is left to Init.
func defaults() *Config {
v := viper.New()
setDefaults(v)
c := &Config{}
// Unmarshaling static defaults into their own struct cannot fail.
_ = v.Unmarshal(c)
c.StatePath = "/etc/repo-sync/state.yaml"
c.domainsByName = map[string]int{}
c.onlineIndex = -1
return c
}
// loadConfig reads and validates a configuration file. Init installs the
// result; callers work with the C singleton rather than loading their own.
func loadConfig(configPath string) (*Config, error) {
v := viper.New()
setDefaults(v)
file := findConfig(configPath)
if file != "" {
v.SetConfigFile(file)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("read config %s: %w", file, err)
}
}
c := &Config{}
if err := v.Unmarshal(c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
// Default the state file next to the config file, so ./config.yaml
// keeps state in ./state.yaml, or to the system path without one.
if c.StatePath == "" {
if file != "" {
c.StatePath = filepath.Join(filepath.Dir(file), "state.yaml")
} else {
c.StatePath = "/etc/repo-sync/state.yaml"
}
}
if err := c.finalize(); err != nil {
return nil, err
}
return c, nil
}
// Init loads and validates the configuration, installs it as the active
// configuration, and applies its logging settings. A failed reload leaves
// the previous configuration in place.
func Init(configPath string) error {
c, err := loadConfig(configPath)
if err != nil {
return err
}
install(c)
return nil
}
// InitServer is Init with the mirror server's additional requirements
// checked before the configuration is installed, so a bad reload leaves
// the running server on the configuration it is already serving.
func InitServer(configPath string) error {
c, err := loadConfig(configPath)
if err != nil {
return err
}
if err := c.RequireServer(); err != nil {
return err
}
install(c)
return nil
}
// install publishes a configuration and applies the settings derived from
// it.
func install(c *Config) {
C = c
SetupLogging(c.Log)
}
// findConfig resolves the configuration file path: the explicit flag, the
// working directory, the user config directory, then the system directory.
func findConfig(explicit string) string {
if explicit != "" {
return explicit
}
candidates := []string{"config.yaml"}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, ".config", "repo-sync", "config.yaml"))
}
candidates = append(candidates, "/etc/repo-sync/config.yaml")
for _, name := range candidates {
if info, err := os.Stat(name); err == nil && info.Mode().IsRegular() {
return name
}
}
return ""
}
// setDefaults registers every configuration default.
func setDefaults(v *viper.Viper) {
v.SetDefault("state_flush_interval", 30*time.Second)
v.SetDefault("repo_crawl_interval", 4*time.Hour)
v.SetDefault("failure_retry_interval", time.Hour)
v.SetDefault("crawl_check_interval", 30*time.Minute)
v.SetDefault("generic_max_age", 6*time.Hour)
v.SetDefault("negative_cache_ttl", time.Hour)
v.SetDefault("transient_error_ttl", time.Minute)
v.SetDefault("http.port", 8080)
v.SetDefault("http.directory_indexes", true)
v.SetDefault("http.read_header_timeout", 10*time.Second)
v.SetDefault("http.read_timeout", 5*time.Minute)
v.SetDefault("http.write_timeout", 30*time.Minute)
v.SetDefault("http.idle_timeout", 2*time.Minute)
v.SetDefault("log.level", "info")
v.SetDefault("log.type", "console")
v.SetDefault("log.outputs", []string{"console"})
v.SetDefault("crawler.workers", 4)
v.SetDefault("crawler.concurrent_crawls", 4)
v.SetDefault("crawler.request_timeout", 2*time.Minute)
v.SetDefault("crawler.user_agent", Name+"/"+Version)
v.SetDefault("crawler.prune_grace", 0)
v.SetDefault("crawler.missing_mode", "retry")
v.SetDefault("crawler.missing_retries", 3)
v.SetDefault("crawler.discover_cache", 72*time.Hour)
v.SetDefault("crawler.refresh_schedule", []time.Duration{
12 * time.Hour, 24 * time.Hour, 48 * time.Hour,
72 * time.Hour, 96 * time.Hour, 120 * time.Hour,
})
v.SetDefault("trace.enabled", false)
}
// finalize canonicalizes, validates, and indexes a parsed configuration.
func (c *Config) finalize() error {
c.canonicalize()
if err := validateConfig(c); err != nil {
return err
}
if err := c.indexDomains(); err != nil {
return err
}
return c.indexMounts()
}
// canonicalize normalizes user-supplied values before validation.
func (c *Config) canonicalize() {
c.StatePath = expandHome(strings.TrimSpace(c.StatePath))
for i := range c.Domains {
c.Domains[i].Domain = strings.ToLower(strings.TrimSpace(c.Domains[i].Domain))
c.Domains[i].Role = strings.ToLower(strings.TrimSpace(c.Domains[i].Role))
c.Domains[i].Root = expandHome(strings.TrimSpace(c.Domains[i].Root))
}
// Trace values are written verbatim into a published file, so trim them
// and drop anything that would break the one-field-per-line format.
c.Trace.Host = TraceValue(c.Trace.Host)
c.Trace.Maintainer = TraceValue(c.Trace.Maintainer)
c.Trace.Sponsor = TraceValue(c.Trace.Sponsor)
c.Trace.Country = TraceValue(c.Trace.Country)
c.Trace.Location = TraceValue(c.Trace.Location)
c.Trace.Throughput = TraceValue(c.Trace.Throughput)
for i := range c.Mounts {
c.Mounts[i].Path = cleanRequestPath(c.Mounts[i].Path)
c.Mounts[i].Upstream = strings.TrimRight(strings.TrimSpace(c.Mounts[i].Upstream), "/")
for j := range c.Mounts[i].Repos {
c.Mounts[i].Repos[j].Path = cleanRequestPath(c.Mounts[i].Repos[j].Path)
c.Mounts[i].Repos[j].Type = strings.ToLower(strings.TrimSpace(c.Mounts[i].Repos[j].Type))
}
}
}
// cleanRequestPath normalizes a request path to a single leading slash with
// no trailing slash.
func cleanRequestPath(p string) string {
return path.Clean("/" + strings.Trim(strings.TrimSpace(p), "/"))
}
// TraceValue normalizes a trace field for publication. Each field occupies
// one line of the trace file, so embedded newlines are folded to spaces
// rather than trimmed away, which would let a configured value forge
// additional trace fields.
func TraceValue(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
// Drop the remaining control characters outright.
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
}
// expandHome resolves a leading ~ against the user's home directory.
func expandHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(p, "~"))
}
}
return p
}
// validateConfig applies the declarative validation rules, reporting
// failures by their YAML key names.
func validateConfig(c *Config) error {
validate := validator.New()
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("mapstructure"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
err := validate.Struct(c)
if err == nil {
return nil
}
var verrs validator.ValidationErrors
if !errors.As(err, &verrs) {
return err
}
var fields []string
for _, verr := range verrs {
fields = append(fields, fmt.Sprintf("%s (%s)", strings.ToLower(verr.Namespace()), verr.Tag()))
}
return fmt.Errorf("invalid configuration: %s", strings.Join(fields, ", "))
}
// indexDomains builds the host lookup and enforces exactly one online
// domain.
func (c *Config) indexDomains() error {
c.domainsByName = map[string]int{}
c.onlineIndex = -1
for i := range c.Domains {
d := c.Domains[i]
if _, ok := c.domainsByName[d.Domain]; ok {
return fmt.Errorf("domain %s is configured more than once", d.Domain)
}
c.domainsByName[d.Domain] = i
if d.Role == RoleOnline {
if c.onlineIndex != -1 {
return fmt.Errorf("only one online domain may be configured")
}
c.onlineIndex = i
}
}
return nil
}
// RequireServer reports whether the configuration carries what the mirror
// server needs. Domains and mounts are only meaningful to the server, so
// they are checked here rather than at load; the sync commands run against
// a configuration that has neither.
func (c *Config) RequireServer() error {
if len(c.Domains) == 0 {
return errors.New("at least one domain must be configured")
}
if c.onlineIndex == -1 {
return errors.New("one online domain must be configured")
}
if len(c.Mounts) == 0 {
return errors.New("at least one mount must be configured")
}
return nil
}
// indexMounts validates mount uniqueness and upstream URLs, orders mounts
// longest path first for prefix lookup, and checks configured repositories
// sit under their mount.
func (c *Config) indexMounts() error {
seen := map[string]bool{}
for i := range c.Mounts {
m := &c.Mounts[i]
if seen[m.Path] {
return fmt.Errorf("mount path %s is configured more than once", m.Path)
}
seen[m.Path] = true
u, err := url.Parse(m.Upstream)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("mount %s upstream must include scheme and host", m.Path)
}
// Request URLs are built by joining a relative path onto the
// upstream; a query or fragment would swallow that path, so reject
// them rather than compose a malformed URL later.
if u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("mount %s upstream must not contain a query or fragment", m.Path)
}
for _, repo := range m.Repos {
if !pathUnder(repo.Path, m.Path) {
return fmt.Errorf("repository %s is not under mount %s", repo.Path, m.Path)
}
}
}
c.mountOrder = make([]int, len(c.Mounts))
for i := range c.mountOrder {
c.mountOrder[i] = i
}
sort.Slice(c.mountOrder, func(a, b int) bool {
return len(c.Mounts[c.mountOrder[a]].Path) > len(c.Mounts[c.mountOrder[b]].Path)
})
return nil
}
// pathUnder reports whether a request path equals or sits below a base
// path.
func pathUnder(p, base string) bool {
if base == "/" {
return true
}
return p == base || strings.HasPrefix(p, base+"/")
}
// Domain looks up the configuration for a request Host header. The port is
// stripped and IPv6 literals are unbracketed before the lookup.
func (c *Config) Domain(host string) (DomainConfig, bool) {
host = strings.ToLower(strings.TrimSpace(host))
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
i, ok := c.domainsByName[host]
if !ok {
return DomainConfig{}, false
}
return c.Domains[i], true
}
// OnlineDomain returns the single writable cache domain.
func (c *Config) OnlineDomain() DomainConfig {
return c.Domains[c.onlineIndex]
}
// MountFor finds the longest mount matching a request path.
func (c *Config) MountFor(reqPath string) (MountConfig, bool) {
for _, i := range c.mountOrder {
if pathUnder(reqPath, c.Mounts[i].Path) {
return c.Mounts[i], true
}
}
return MountConfig{}, false
}