Files
telemt-api/internal/config/config.go
T
Denozordec d7a63f0da3
Publish telemt-api gateway Docker image / test (push) Failing after 17s
Publish telemt-api gateway Docker image / build-and-push (push) Has been skipped
Add GeoIP support for unique IP aggregation
- Introduced GeoIP configuration options in config.example.yaml to enable geolocation lookups for the /api/agg/unique-ips endpoint.
- Updated the aggregate handler to include optional GeoIP data in responses, enriching unique IP information with country and city details, as well as ASN data if available.
- Enhanced documentation in AGGREGATE.md and README.md to reflect the new GeoIP functionality and its usage.
- Added a dependency on the geoip2-golang library in go.mod for GeoIP lookups.
- Modified tests to accommodate the new GeoIP integration in the aggregate handler.
2026-03-30 01:47:08 +07:00

194 lines
5.5 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"`
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"`
}
// 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"`
}
// 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]
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
}
}
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)
}
}
}
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
}
// 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)
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
}
}
return &Parsed{
Config: c,
Whitelist: wl,
Trusted: tr,
ByAlias: by,
AuthByAlias: auth,
}, nil
}