Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
245 lines
5.6 KiB
Go
245 lines
5.6 KiB
Go
package conversion
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/subconverter-go/internal/logging"
|
|
)
|
|
|
|
// GeoIPInfo represents a subset of geoip info exposed to scripts.
|
|
type GeoIPInfo struct {
|
|
IP string `json:"ip"`
|
|
CountryCode string `json:"country_code"`
|
|
CountryName string `json:"country_name"`
|
|
Region string `json:"region"`
|
|
City string `json:"city"`
|
|
ISP string `json:"isp"`
|
|
ASN string `json:"asn"`
|
|
Latitude float64 `json:"latitude,omitempty"`
|
|
Longitude float64 `json:"longitude,omitempty"`
|
|
}
|
|
|
|
// GeoIPResolver resolves geo-location information for a given address.
|
|
type GeoIPResolver interface {
|
|
Lookup(ctx context.Context, address string) (*GeoIPInfo, error)
|
|
}
|
|
|
|
const (
|
|
defaultGeoIPBaseURL = "https://api.ip.sb/geoip"
|
|
geoIPRequestTimeout = 3 * time.Second
|
|
geoIPUserAgent = "subconverter-go/geoip"
|
|
classificationPrivate = "PRIVATE"
|
|
classificationUnspecified = "UNSPECIFIED"
|
|
)
|
|
|
|
type httpGeoIPResolver struct {
|
|
baseURL string
|
|
client *http.Client
|
|
logger *logging.Logger
|
|
}
|
|
|
|
func newDefaultGeoIPResolver(logger *logging.Logger) GeoIPResolver {
|
|
return &httpGeoIPResolver{
|
|
baseURL: defaultGeoIPBaseURL,
|
|
client: &http.Client{
|
|
Timeout: geoIPRequestTimeout,
|
|
},
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetBaseURL allows overriding the base geoip endpoint (used for tests).
|
|
func (r *httpGeoIPResolver) SetBaseURL(base string) {
|
|
if base != "" {
|
|
r.baseURL = base
|
|
}
|
|
}
|
|
|
|
func (r *httpGeoIPResolver) Lookup(ctx context.Context, address string) (*GeoIPInfo, error) {
|
|
target, err := normalizeGeoIPTarget(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if info := classifySpecialIP(target); info != nil {
|
|
return info, nil
|
|
}
|
|
|
|
if r == nil {
|
|
return nil, errors.New("geoip resolver is not configured")
|
|
}
|
|
|
|
endpoint, err := buildGeoIPURL(r.baseURL, target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", geoIPUserAgent)
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return &GeoIPInfo{IP: target}, fmt.Errorf("geoip lookup failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
var payload GeoIPInfo
|
|
decoder := json.NewDecoder(resp.Body)
|
|
if err := decoder.Decode(&payload); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if payload.IP == "" {
|
|
payload.IP = target
|
|
}
|
|
return &payload, nil
|
|
}
|
|
|
|
func normalizeGeoIPTarget(input string) (string, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return "", errors.New("empty address")
|
|
}
|
|
|
|
// If input is a URL, extract host part.
|
|
if strings.Contains(trimmed, "://") {
|
|
if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" {
|
|
trimmed = parsed.Host
|
|
}
|
|
}
|
|
|
|
// Remove port if present.
|
|
if host, _, err := net.SplitHostPort(trimmed); err == nil && host != "" {
|
|
trimmed = host
|
|
}
|
|
|
|
trimmed = strings.Trim(trimmed, "[]")
|
|
return trimmed, nil
|
|
}
|
|
|
|
func buildGeoIPURL(baseURL, target string) (string, error) {
|
|
if baseURL == "" {
|
|
return "", errors.New("geoip base url is empty")
|
|
}
|
|
|
|
parsed, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Ensure trailing slash and append escaped target.
|
|
parsed.Path = path.Join(parsed.Path, url.PathEscape(target))
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func classifySpecialIP(target string) *GeoIPInfo {
|
|
ip := net.ParseIP(target)
|
|
if ip == nil {
|
|
return nil
|
|
}
|
|
|
|
switch {
|
|
case ip.IsLoopback(), ip.IsUnspecified():
|
|
return &GeoIPInfo{
|
|
IP: ip.String(),
|
|
CountryCode: classificationUnspecified,
|
|
CountryName: "Unspecified",
|
|
ISP: "Localhost",
|
|
}
|
|
case ip.IsPrivate(), ip.IsLinkLocalMulticast(), ip.IsLinkLocalUnicast():
|
|
return &GeoIPInfo{
|
|
IP: ip.String(),
|
|
CountryCode: classificationPrivate,
|
|
CountryName: "Private Network",
|
|
ISP: "Local Network",
|
|
}
|
|
case ip.IsMulticast():
|
|
return &GeoIPInfo{
|
|
IP: ip.String(),
|
|
CountryCode: classificationUnspecified,
|
|
CountryName: "Multicast",
|
|
ISP: "Multicast",
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (ce *ConversionEngine) SetGeoIPResolver(resolver GeoIPResolver) {
|
|
if resolver == nil {
|
|
ce.geoResolver = newDefaultGeoIPResolver(ce.logger)
|
|
} else {
|
|
ce.geoResolver = resolver
|
|
}
|
|
ce.geoipCache = sync.Map{}
|
|
}
|
|
|
|
func (ce *ConversionEngine) lookupGeoIP(address string) (*GeoIPInfo, error) {
|
|
target, err := normalizeGeoIPTarget(address)
|
|
if err != nil || target == "" {
|
|
return &GeoIPInfo{IP: target}, err
|
|
}
|
|
|
|
if value, ok := ce.geoipCache.Load(target); ok {
|
|
if info, ok := value.(*GeoIPInfo); ok {
|
|
return info, nil
|
|
}
|
|
}
|
|
|
|
resolver := ce.geoResolver
|
|
if resolver == nil {
|
|
resolver = newDefaultGeoIPResolver(ce.logger)
|
|
ce.geoResolver = resolver
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), geoIPRequestTimeout)
|
|
defer cancel()
|
|
|
|
info, err := resolver.Lookup(ctx, target)
|
|
if err != nil {
|
|
ce.logger.WithError(err).Debug("geoip lookup failed")
|
|
if classified := classifySpecialIP(target); classified != nil {
|
|
info = classified
|
|
} else if info == nil {
|
|
info = &GeoIPInfo{IP: target}
|
|
}
|
|
}
|
|
|
|
if info != nil {
|
|
ce.geoipCache.Store(target, info)
|
|
return info, nil
|
|
}
|
|
|
|
return &GeoIPInfo{IP: target}, err
|
|
}
|
|
|
|
func (ce *ConversionEngine) geoIPJSON(address string) string {
|
|
info, err := ce.lookupGeoIP(address)
|
|
if err != nil {
|
|
ce.logger.WithError(err).Debug("geoip lookup returned error")
|
|
}
|
|
data, err := json.Marshal(info)
|
|
if err != nil {
|
|
ce.logger.WithError(err).Warn("failed to marshal geoip info")
|
|
return "{}"
|
|
}
|
|
return string(data)
|
|
}
|