- 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.
194 lines
5.5 KiB
Go
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
|
|
}
|