Files
telemt-api/internal/config/config.go
T
Denozordec 2d7b06260e
Publish telemt-api gateway Docker image / test (push) Successful in 34s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s
Implement Mihomo external-controller support in configuration and gateway
- Added optional Mihomo configuration fields in `config.compose.yaml` and `config.example.yaml` for enhanced integration with the Mihomo external-controller.
- Updated the `Gateway` to handle Mihomo API requests, including proxying and error handling for Mihomo-specific endpoints.
- Enhanced the documentation in `GATEWAY_RUN.md` to guide users on configuring Mihomo integration.
- Introduced new utility functions in the web client for interacting with Mihomo API endpoints, improving the overall user experience.
- Updated the sidebar in the Svelte components to include a link to the Mihomo section, enhancing navigation.
2026-03-31 00:32:19 +07:00

273 lines
8.6 KiB
Go

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"`
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"`
}
// 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 <secret>)
}
// 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]")
}
}
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
}