first commit
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
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
This commit is contained in:
244
internal/conversion/geoip.go
Normal file
244
internal/conversion/geoip.go
Normal file
@@ -0,0 +1,244 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user