package config import ( "fmt" "net/netip" "net/url" "os" "regexp" "strings" "gopkg.in/yaml.v3" ) var aliasRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) // Config is the gateway YAML configuration. type Config struct { Listen string `yaml:"listen"` // ExposeUpstreamErrors puts RoundTrip error text in JSON 502 bodies (error.detail). For private ops only. ExposeUpstreamErrors bool `yaml:"expose_upstream_errors"` AllowAll bool `yaml:"allow_all"` WhitelistCIDRs []string `yaml:"whitelist_cidrs"` TrustedProxies []string `yaml:"trusted_proxies"` CorsAllowedOrigins []string `yaml:"cors_allowed_origins"` Servers []Server `yaml:"servers"` Aggregate *AggregateConfig `yaml:"aggregate"` GeoIP *GeoIPConfig `yaml:"geoip"` Radar *RadarConfig `yaml:"radar"` } // RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar). type RadarConfig struct { // StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty. StatusesURL string `yaml:"statuses_url"` // HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000. HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"` // PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway". PingFrom string `yaml:"ping_from"` } // GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional). // Нужен хотя бы один из: database_path (City) или asn_database_path (ASN). Country-only DB не используется — страна в City. type GeoIPConfig struct { Enabled bool `yaml:"enabled"` DatabasePath string `yaml:"database_path"` // GeoLite2-City.mmdb DownloadURL string `yaml:"download_url"` // если city-файла нет; .mmdb или .gz AsnDatabasePath string `yaml:"asn_database_path"` // GeoLite2-ASN.mmdb AsnDownloadURL string `yaml:"asn_download_url"` // если asn-файла нет } // AggregateConfig controls default scope of /api/agg/* (optional). type AggregateConfig struct { // IncludeAliases limits aggregation to these server aliases; empty means all servers. IncludeAliases []string `yaml:"include_aliases"` // CacheTTLMs is in-memory cache TTL for successful GET /api/agg/* responses (milliseconds). 0 disables. CacheTTLMs uint64 `yaml:"cache_ttl_ms"` } // Server maps a URL alias to an upstream base URL. type Server struct { Alias string `yaml:"alias"` BaseURL string `yaml:"base_url"` PathPrefix string `yaml:"path_prefix"` AuthorizationEnv string `yaml:"authorization_env"` // Mihomo external-controller (optional): REST + WebSocket at controller root. MihomoBaseURL string `yaml:"mihomo_base_url"` MihomoBaseURLEnv string `yaml:"mihomo_base_url_env"` MihomoAuthorizationEnv string `yaml:"mihomo_authorization_env"` } // Mihomo holds resolved external-controller upstream for a server alias. type Mihomo struct { Base *url.URL Auth string // full Authorization header value (e.g. Bearer ) } // Load reads and validates configuration from path. func Load(path string) (*Config, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } var c Config if err := yaml.Unmarshal(raw, &c); err != nil { return nil, fmt.Errorf("parse yaml: %w", err) } if err := c.Validate(); err != nil { return nil, err } return &c, nil } // Validate checks required fields and formats. func (c *Config) Validate() error { if c.Listen == "" { c.Listen = ":8080" } seen := make(map[string]struct{}) for i := range c.Servers { s := &c.Servers[i] s.Alias = strings.TrimSpace(s.Alias) s.BaseURL = strings.TrimSpace(s.BaseURL) s.PathPrefix = strings.TrimSpace(s.PathPrefix) s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv) s.MihomoBaseURL = strings.TrimSpace(s.MihomoBaseURL) s.MihomoBaseURLEnv = strings.TrimSpace(s.MihomoBaseURLEnv) s.MihomoAuthorizationEnv = strings.TrimSpace(s.MihomoAuthorizationEnv) if s.Alias == "" { return fmt.Errorf("servers[%d]: alias is required", i) } if !aliasRe.MatchString(s.Alias) { return fmt.Errorf("servers[%d]: alias %q must match %s", i, s.Alias, aliasRe.String()) } if _, ok := seen[s.Alias]; ok { return fmt.Errorf("duplicate alias %q", s.Alias) } if s.Alias == "agg" { return fmt.Errorf("servers[%d]: alias %q is reserved for /api/agg/", i, s.Alias) } seen[s.Alias] = struct{}{} if s.BaseURL == "" { return fmt.Errorf("servers[%d]: base_url is required", i) } u, err := url.Parse(s.BaseURL) if err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("servers[%d]: invalid base_url %q", i, s.BaseURL) } if s.PathPrefix == "" { s.PathPrefix = "/v1" } s.PathPrefix = strings.TrimSuffix(s.PathPrefix, "/") if !strings.HasPrefix(s.PathPrefix, "/") { s.PathPrefix = "/" + s.PathPrefix } hasMihomoURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != "" if hasMihomoURL && s.MihomoAuthorizationEnv == "" { return fmt.Errorf("servers[%d]: mihomo_authorization_env is required when mihomo url is set", i) } if !hasMihomoURL && s.MihomoAuthorizationEnv != "" { return fmt.Errorf("servers[%d]: mihomo_base_url or mihomo_base_url_env is required when mihomo_authorization_env is set", i) } if s.MihomoBaseURL != "" { mu, err := url.Parse(s.MihomoBaseURL) if err != nil || mu.Scheme == "" || mu.Host == "" { return fmt.Errorf("servers[%d]: invalid mihomo_base_url %q", i, s.MihomoBaseURL) } } } if len(c.Servers) == 0 { return fmt.Errorf("at least one server entry is required") } for i, s := range c.WhitelistCIDRs { if _, err := parseCIDROrIP(s); err != nil { return fmt.Errorf("whitelist_cidrs[%d]: %w", i, err) } } for i, s := range c.TrustedProxies { if _, err := parseCIDROrIP(s); err != nil { return fmt.Errorf("trusted_proxies[%d]: %w", i, err) } } if c.Aggregate != nil { for i, a := range c.Aggregate.IncludeAliases { a = strings.TrimSpace(a) if a == "" { return fmt.Errorf("aggregate.include_aliases[%d]: empty entry", i) } if _, ok := seen[a]; !ok { return fmt.Errorf("aggregate.include_aliases[%d]: unknown server alias %q", i, a) } } if c.Aggregate.CacheTTLMs > 60000 { return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]") } } if c.Radar != nil { if c.Radar.HTTPTimeoutMs > 600000 { return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]") } if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" { pu, err := url.Parse(u) if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" { return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL) } } } return nil } // parseCIDROrIP accepts a CIDR ("10.0.0.0/8") or a single IP ("87.103.241.8" → /32 or /128). func parseCIDROrIP(s string) (netip.Prefix, error) { s = strings.TrimSpace(s) if s == "" { return netip.Prefix{}, fmt.Errorf("empty") } if p, err := netip.ParsePrefix(s); err == nil { return p, nil } addr, err := netip.ParseAddr(s) if err != nil { return netip.Prefix{}, fmt.Errorf("%w (use CIDR like %s/32 for IPv4)", err, s) } return addr.Prefix(addr.BitLen()) } // Parsed holds compiled CIDR lists and server map. type Parsed struct { Config *Config Whitelist []netip.Prefix Trusted []netip.Prefix ByAlias map[string]*Server AuthByAlias map[string]string // non-empty Authorization value per alias MihomoByAlias map[string]*Mihomo } // Parse compiles CIDRs and resolves authorization from environment. func (c *Config) Parse() (*Parsed, error) { var wl []netip.Prefix for _, s := range c.WhitelistCIDRs { p, err := parseCIDROrIP(s) if err != nil { return nil, err } wl = append(wl, p) } var tr []netip.Prefix for _, s := range c.TrustedProxies { p, err := parseCIDROrIP(s) if err != nil { return nil, err } tr = append(tr, p) } by := make(map[string]*Server, len(c.Servers)) auth := make(map[string]string) mihomo := make(map[string]*Mihomo) for i := range c.Servers { s := &c.Servers[i] by[s.Alias] = s if s.AuthorizationEnv != "" { v := os.Getenv(s.AuthorizationEnv) if v == "" { return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.AuthorizationEnv) } auth[s.Alias] = v } mu, err := resolveMihomo(s) if err != nil { return nil, err } if mu != nil { mihomo[s.Alias] = mu } } return &Parsed{ Config: c, Whitelist: wl, Trusted: tr, ByAlias: by, AuthByAlias: auth, MihomoByAlias: mihomo, }, nil } // resolveMihomo returns non-nil only when Mihomo is configured for this server. func resolveMihomo(s *Server) (*Mihomo, error) { hasURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != "" if !hasURL { return nil, nil } if s.MihomoAuthorizationEnv == "" { return nil, fmt.Errorf("server %q: mihomo_authorization_env is required when mihomo url is set", s.Alias) } var urlStr string if s.MihomoBaseURLEnv != "" { v := strings.TrimSpace(os.Getenv(s.MihomoBaseURLEnv)) if v != "" { urlStr = v } } if urlStr == "" && s.MihomoBaseURL != "" { urlStr = s.MihomoBaseURL } if urlStr == "" { return nil, fmt.Errorf("server %q: mihomo controller url is empty (set mihomo_base_url or env %q)", s.Alias, s.MihomoBaseURLEnv) } u, err := url.Parse(urlStr) if err != nil || u.Scheme == "" || u.Host == "" { return nil, fmt.Errorf("server %q: invalid mihomo controller url %q", s.Alias, urlStr) } authVal := os.Getenv(s.MihomoAuthorizationEnv) if strings.TrimSpace(authVal) == "" { return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.MihomoAuthorizationEnv) } return &Mihomo{Base: u, Auth: authVal}, nil }