This commit is contained in:
2026-02-03 02:23:23 +01:00
parent cc0a3e38ac
commit d8755b7f07
13 changed files with 196 additions and 228 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ name: Docker
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
@@ -24,7 +24,7 @@ jobs:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.5.0 uses: sigstore/cosign-installer@v3.5.0
with: with:
cosign-release: 'v2.1.1' cosign-release: "v2.1.1"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
+3 -4
View File
@@ -2,12 +2,11 @@ name: Go
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -16,7 +15,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.25.1' go-version: "1.25.6"
- name: Cache Go Modules - name: Cache Go Modules
uses: actions/cache@v3 uses: actions/cache@v3
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25.1-alpine AS builder FROM golang:1.25.6-alpine AS builder
WORKDIR /build WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+11 -14
View File
@@ -1,24 +1,21 @@
module ipinfo module ipinfo
go 1.25.1 go 1.25.6
require ( require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/likexian/whois v1.15.6 github.com/likexian/whois v1.15.7
github.com/likexian/whois-parser v1.24.20 github.com/likexian/whois-parser v1.24.21
github.com/miekg/dns v1.1.68 github.com/miekg/dns v1.1.72
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/net v0.44.0 golang.org/x/net v0.49.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/likexian/gokit v0.25.16 // indirect
github.com/likexian/gokit v0.25.15 // indirect golang.org/x/mod v0.31.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/sync v0.19.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/tools v0.40.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
) )
+26 -26
View File
@@ -1,34 +1,34 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/likexian/gokit v0.25.15 h1:QjospM1eXhdMMHwZRpMKKAHY/Wig9wgcREmLtf9NslY= github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
github.com/likexian/gokit v0.25.15/go.mod h1:S2QisdsxLEHWeD/XI0QMVeggp+jbxYqUxMvSBil7MRg= github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
github.com/likexian/whois v1.15.6 h1:hizngFHJTNQDlhwhU+FEGyPGxy8bRnf25gHDNrSB4Ag= github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
github.com/likexian/whois v1.15.6/go.mod h1:vx3kt3sZ4mx4XFgpaNp3GXQCZQIzAoyrUAkRtJwoM2I= github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
github.com/likexian/whois-parser v1.24.20 h1:oxEkRi0GxgqWQRLDMJpXU1EhgWmLmkqEFZ2ChXTeQLE= github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
github.com/likexian/whois-parser v1.24.20/go.mod h1:rAtaofg2luol09H+ogDzGIfcG8ig1NtM5R16uQADDz4= github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -7
View File
@@ -10,7 +10,7 @@ import (
"ipinfo/internal/db" "ipinfo/internal/db"
"github.com/likexian/whois-parser" whoisparser "github.com/likexian/whois-parser"
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.org/x/net/publicsuffix" "golang.org/x/net/publicsuffix"
) )
@@ -95,7 +95,6 @@ func LookupASNData(geoIP *db.GeoIPManager, targetASN uint) (*ASNDataResponse, er
var ipv4Prefixes, ipv6Prefixes []string var ipv4Prefixes, ipv6Prefixes []string
for _, prefix := range prefixes { for _, prefix := range prefixes {
// Filter out bogon prefixes before adding them to the list.
if !IsBogon(prefix.IP) { if !IsBogon(prefix.IP) {
prefixStr := prefix.String() prefixStr := prefix.String()
if strings.Contains(prefixStr, ":") { if strings.Contains(prefixStr, ":") {
@@ -130,13 +129,13 @@ func queryDns(domain string, recordType uint16) ([]dns.RR, error) {
m.SetQuestion(dns.Fqdn(domain), recordType) m.SetQuestion(dns.Fqdn(domain), recordType)
m.RecursionDesired = true m.RecursionDesired = true
r, _, err := c.Exchange(m, "1.1.1.1:53") // Using Cloudflare's public resolver r, _, err := c.Exchange(m, "1.1.1.1:53")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if r.Rcode != dns.RcodeSuccess { if r.Rcode != dns.RcodeSuccess {
return nil, nil // No error, just no records found return nil, nil
} }
return r.Answer, nil return r.Answer, nil
@@ -154,10 +153,10 @@ func LookupDomainData(domain string) (*DomainDataResponse, error) {
} }
whoisRaw, err := performWhoisWithFallback(eTLD) whoisRaw, err := performWhoisWithFallback(eTLD)
var whoisResult interface{} var whoisResult any
if err != nil { if err != nil {
slog.Error("whois lookup failed after fallback", "domain", eTLD, "err", err) slog.Error("whois lookup failed completely", "domain", eTLD, "err", err)
whoisResult = fmt.Sprintf("whois lookup failed: %v", err) whoisResult = nil
} else { } else {
parsed, parseErr := whoisparser.Parse(whoisRaw) parsed, parseErr := whoisparser.Parse(whoisRaw)
if parseErr != nil { if parseErr != nil {
+23 -8
View File
@@ -10,18 +10,20 @@ import (
"time" "time"
"github.com/likexian/whois" "github.com/likexian/whois"
"github.com/likexian/whois-parser" whoisparser "github.com/likexian/whois-parser"
) )
// performWhoisWithFallback attempts a WHOIS query and falls back to IPv4 if it suspects an IPv6 issue. // performWhoisWithFallback attempts a WHOIS query and falls back to manual lookup if the default fails.
func performWhoisWithFallback(domain string) (string, error) { func performWhoisWithFallback(domain string) (string, error) {
result, err := whois.Whois(domain) c := whois.NewClient()
c.SetTimeout(5 * time.Second)
result, err := c.Whois(domain)
if err == nil { if err == nil {
return result, nil return result, nil
} }
if strings.Contains(err.Error(), "dial tcp [") && strings.Contains(err.Error(), "]:43") { slog.Warn("standard whois lookup failed, attempting fallback", "domain", domain, "err", err)
slog.Warn("whois failed with potential ipv6 issue, falling back to ipv4", "domain", domain, "err", err)
serverHost, serverErr := getWhoisServerForDomain(domain) serverHost, serverErr := getWhoisServerForDomain(domain)
if serverErr != nil { if serverErr != nil {
@@ -39,13 +41,26 @@ func performWhoisWithFallback(domain string) (string, error) {
if ip.To4() != nil { if ip.To4() != nil {
ipv4Server := ip.String() ipv4Server := ip.String()
slog.Info("retrying whois query with explicit ipv4 address", "domain", domain, "server", ipv4Server) slog.Info("retrying whois query with explicit ipv4 address", "domain", domain, "server", ipv4Server)
return queryWhoisServer(domain, ipv4Server) res, err := queryWhoisServer(domain, ipv4Server)
if err == nil {
return res, nil
} }
slog.Warn("fallback query to ipv4 server failed", "server", ipv4Server, "err", err)
} }
slog.Warn("no ipv4 address found for whois server during fallback", "server", serverHost)
} }
return "", err for _, ip := range ips {
if ip.To4() == nil {
ipv6Server := ip.String()
slog.Info("retrying whois query with ipv6 address", "domain", domain, "server", ipv6Server)
res, err := queryWhoisServer(domain, ipv6Server)
if err == nil {
return res, nil
}
}
}
return "", fmt.Errorf("all whois attempts failed: %w", err)
} }
// getWhoisServerForDomain finds the authoritative WHOIS server for a domain by querying IANA. // getWhoisServerForDomain finds the authoritative WHOIS server for a domain by querying IANA.
+16 -32
View File
@@ -1,12 +1,10 @@
package db package db
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"os"
"sync" "sync"
"time" "time"
@@ -25,7 +23,7 @@ type GeoIPManager struct {
// NewGeoIPManager creates a new GeoIPManager // NewGeoIPManager creates a new GeoIPManager
func NewGeoIPManager() (*GeoIPManager, error) { func NewGeoIPManager() (*GeoIPManager, error) {
manager := &GeoIPManager{ manager := &GeoIPManager{
httpClient: &http.Client{Timeout: 2 * time.Minute}, httpClient: &http.Client{Timeout: 5 * time.Minute},
} }
if err := manager.Initialize(); err != nil { if err := manager.Initialize(); err != nil {
return nil, fmt.Errorf("initializing geoip manager: %w", err) return nil, fmt.Errorf("initializing geoip manager: %w", err)
@@ -33,19 +31,24 @@ func NewGeoIPManager() (*GeoIPManager, error) {
return manager, nil return manager, nil
} }
// Initialize initializes the GeoIPManager by opening the database files // Initialize initializes the GeoIPManager by opening the database files.
func (g *GeoIPManager) Initialize() error { func (g *GeoIPManager) Initialize() error {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() cityErr := g.openDB(CityDBPath)
asnErr := g.openDB(ASNDBPath)
g.mu.Unlock()
if err := g.openDB(CityDBPath); err != nil { if cityErr != nil || asnErr != nil {
return err slog.Info("databases missing or invalid, performing initial update")
if err := g.UpdateDatabases(); err != nil {
return fmt.Errorf("initial update failed: %w", err)
} }
if err := g.openDB(ASNDBPath); err != nil { } else {
return err g.mu.Lock()
}
g.buildASNPrefixMap() g.buildASNPrefixMap()
g.mu.Unlock()
}
return nil return nil
} }
@@ -65,30 +68,11 @@ func (g *GeoIPManager) Close() {
} }
} }
// openDB opens a MaxMind DB file, downloading it if it doesn't exist. // openDB opens a MaxMind DB file.
func (g *GeoIPManager) openDB(path string) error { func (g *GeoIPManager) openDB(path string) error {
db, err := maxminddb.Open(path) db, err := maxminddb.Open(path)
if err == nil {
if path == CityDBPath {
g.cityDB = db
} else {
g.asnDB = db
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("%w: failed to open %s: %v", ErrDatabaseOpen, path, err)
}
slog.Warn("database not found, attempting initial download", "path", path)
if err := g.DownloadDatabases(context.Background()); err != nil {
return fmt.Errorf("%w: %v", ErrDownloadFailed, err)
}
db, err = maxminddb.Open(path)
if err != nil { if err != nil {
return fmt.Errorf("%w: failed to open %s after download: %v", ErrDatabaseOpen, path, err) return err
} }
if path == CityDBPath { if path == CityDBPath {
+2 -2
View File
@@ -4,8 +4,8 @@ import "errors"
// Constants for database names and paths // Constants for database names and paths
const ( const (
CityDBName = "GeoLite2-City" CityDBName = "dbip-city-lite"
ASNDBName = "GeoLite2-ASN" ASNDBName = "dbip-asn-lite"
DBExtension = ".mmdb" DBExtension = ".mmdb"
CityDBPath = CityDBName + DBExtension CityDBPath = CityDBName + DBExtension
ASNDBPath = ASNDBName + DBExtension ASNDBPath = ASNDBName + DBExtension
+57 -48
View File
@@ -8,7 +8,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/oschwald/maxminddb-golang" "github.com/oschwald/maxminddb-golang"
@@ -37,7 +36,8 @@ func (g *GeoIPManager) StartUpdater(ctx context.Context, updateInterval time.Dur
// UpdateDatabases downloads new databases and reloads them into the manager. // UpdateDatabases downloads new databases and reloads them into the manager.
func (g *GeoIPManager) UpdateDatabases() error { func (g *GeoIPManager) UpdateDatabases() error {
if err := g.DownloadDatabases(context.Background()); err != nil { tmpFiles, err := g.downloadToTemp(context.Background())
if err != nil {
return err return err
} }
@@ -46,68 +46,80 @@ func (g *GeoIPManager) UpdateDatabases() error {
if g.cityDB != nil { if g.cityDB != nil {
_ = g.cityDB.Close() _ = g.cityDB.Close()
g.cityDB = nil
} }
if g.asnDB != nil { if g.asnDB != nil {
_ = g.asnDB.Close() _ = g.asnDB.Close()
g.asnDB = nil
}
for targetPath, tmpPath := range tmpFiles {
if err := os.Rename(tmpPath, targetPath); err != nil {
slog.Error("failed to replace database file", "target", targetPath, "tmp", tmpPath, "err", err)
}
} }
var openErr error var openErr error
g.cityDB, openErr = maxminddb.Open(CityDBPath) g.cityDB, openErr = maxminddb.Open(CityDBPath)
if openErr != nil { if openErr != nil {
return fmt.Errorf("reopening city database: %w", openErr) slog.Error("failed to reopen city database", "err", openErr)
} }
g.asnDB, openErr = maxminddb.Open(ASNDBPath) g.asnDB, openErr = maxminddb.Open(ASNDBPath)
if openErr != nil { if openErr != nil {
return fmt.Errorf("reopening asn database: %w", openErr) slog.Error("failed to reopen asn database", "err", openErr)
} }
g.buildASNPrefixMap() g.buildASNPrefixMap()
slog.Info("successfully reloaded databases") slog.Info("successfully updated and reloaded databases")
return nil return nil
} }
// DownloadDatabases downloads all configured GeoIP database editions. // downloadToTemp downloads the current month's DB-IP databases to temporary files.
func (g *GeoIPManager) DownloadDatabases(ctx context.Context) error { func (g *GeoIPManager) downloadToTemp(ctx context.Context) (map[string]string, error) {
accountID := os.Getenv("GEOIPUPDATE_ACCOUNT_ID") now := time.Now()
licenseKey := os.Getenv("GEOIPUPDATE_LICENSE_KEY") dateStr := now.Format("2006-01")
if accountID == "" || licenseKey == "" {
return fmt.Errorf("GEOIPUPDATE_ACCOUNT_ID and GEOIPUPDATE_LICENSE_KEY must be set") targets := map[string]string{
} CityDBPath: fmt.Sprintf("dbip-city-lite-%s", dateStr),
ASNDBPath: fmt.Sprintf("dbip-asn-lite-%s", dateStr),
editionIDs := os.Getenv("GEOIPUPDATE_EDITION_IDS")
if editionIDs == "" {
editionIDs = "GeoLite2-City GeoLite2-ASN"
} }
results := make(map[string]string)
var firstError error var firstError error
for _, editionID := range strings.Fields(editionIDs) {
if err := g.downloadEdition(ctx, accountID, licenseKey, editionID); err != nil { for localPath, urlName := range targets {
slog.Error("failed to download edition", "edition", editionID, "err", err) downloadURL := fmt.Sprintf("https://download.db-ip.com/free/%s.mmdb.gz", urlName)
tmpPath := localPath + ".tmp"
if err := g.downloadFile(ctx, downloadURL, tmpPath); err != nil {
slog.Error("failed to download database", "url", downloadURL, "err", err)
if firstError == nil { if firstError == nil {
firstError = err firstError = err
} }
continue
} }
results[localPath] = tmpPath
} }
return firstError
if firstError != nil {
for _, tmp := range results {
_ = os.Remove(tmp)
}
return nil, firstError
}
return results, nil
} }
// downloadEdition downloads a specific GeoIP database edition. // downloadFile downloads a file from a URL, decompresses it, and saves it to destPath.
func (g *GeoIPManager) downloadEdition(ctx context.Context, accountID, licenseKey, editionID string) error { func (g *GeoIPManager) downloadFile(ctx context.Context, url, destPath string) error {
dbPath := editionID + DBExtension slog.Info("checking for updates", "url", url)
slog.Info("checking for updates", "database", dbPath)
hash, err := fileMD5(dbPath) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("could not calculate md5 for %s: %w", dbPath, err)
}
downloadURL := fmt.Sprintf("https://updates.maxmind.com/geoip/databases/%s/update?db_md5=%s", editionID, hash)
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("could not create request: %w", err) return fmt.Errorf("could not create request: %w", err)
} }
req.SetBasicAuth(accountID, licenseKey)
resp, err := g.httpClient.Do(req) resp, err := g.httpClient.Do(req)
if err != nil { if err != nil {
@@ -119,16 +131,11 @@ func (g *GeoIPManager) downloadEdition(ctx context.Context, accountID, licenseKe
} }
}() }()
if resp.StatusCode == http.StatusNotModified {
slog.Info("database is already up to date", "database", dbPath)
return nil
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) return fmt.Errorf("received non-200 status code: %d", resp.StatusCode)
return fmt.Errorf("received non-200 status code: %d - %s", resp.StatusCode, string(body))
} }
slog.Info("downloading and decompressing new version", "database", dbPath) slog.Info("downloading and decompressing", "destination", destPath)
gzr, err := gzip.NewReader(resp.Body) gzr, err := gzip.NewReader(resp.Body)
if err != nil { if err != nil {
@@ -140,26 +147,28 @@ func (g *GeoIPManager) downloadEdition(ctx context.Context, accountID, licenseKe
} }
}() }()
tmpPath := dbPath + ".tmp" outFile, err := os.Create(destPath)
outFile, err := os.Create(tmpPath)
if err != nil { if err != nil {
return fmt.Errorf("could not create temporary file: %w", err) return fmt.Errorf("could not create temporary file: %w", err)
} }
defer func() {
closeFile := func() error {
if err := outFile.Close(); err != nil { if err := outFile.Close(); err != nil {
slog.Error("failed to close output file", "err", err) return fmt.Errorf("failed to close output file: %w", err)
}
return nil
} }
}()
if _, err := io.Copy(outFile, gzr); err != nil { if _, err := io.Copy(outFile, gzr); err != nil {
_ = os.Remove(tmpPath) _ = closeFile()
_ = os.Remove(destPath)
return fmt.Errorf("could not decompress and write db file: %w", err) return fmt.Errorf("could not decompress and write db file: %w", err)
} }
if err := os.Rename(tmpPath, dbPath); err != nil { if err := closeFile(); err != nil {
return fmt.Errorf("could not replace database file: %w", err) return err
} }
slog.Info("successfully downloaded and updated", "database", dbPath) slog.Info("successfully downloaded", "file", destPath)
return nil return nil
} }
-27
View File
@@ -1,27 +0,0 @@
package db
import (
"crypto/md5"
"fmt"
"io"
"os"
)
// fileMD5 calculates the MD5 hash of a file.
func fileMD5(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("Error closing file: %v\n", err)
}
}()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
+11 -12
View File
@@ -47,18 +47,17 @@ func handleDomainLookup(w http.ResponseWriter, _ *http.Request, domain string) {
// handleASNLookup handles ASN lookup requests. // handleASNLookup handles ASN lookup requests.
func handleASNLookup(w http.ResponseWriter, _ *http.Request, path string, geoIP *db.GeoIPManager) { func handleASNLookup(w http.ResponseWriter, _ *http.Request, path string, geoIP *db.GeoIPManager) {
var asnStr string upperPath := strings.ToUpper(path)
lowerPath := strings.ToLower(path) cleanPath := path
if strings.HasPrefix(lowerPath, "asn/") { if strings.HasPrefix(upperPath, "ASN") {
asnStr = path[4:] cleanPath = path[3:]
} else if strings.HasPrefix(lowerPath, "as") { } else if strings.HasPrefix(upperPath, "AS") {
asnStr = path[2:] cleanPath = path[2:]
} else {
sendJSONError(w, "Invalid ASN query format. Use /asn/<number> or /AS<number>.", http.StatusBadRequest)
return
} }
asnStr := strings.Trim(cleanPath, "/ ")
asn, err := strconv.ParseUint(asnStr, 10, 32) asn, err := strconv.ParseUint(asnStr, 10, 32)
if err != nil || asn == 0 { if err != nil || asn == 0 {
sendJSONError(w, "Invalid ASN: must be a positive number.", http.StatusBadRequest) sendJSONError(w, "Invalid ASN: must be a positive number.", http.StatusBadRequest)
@@ -86,12 +85,12 @@ func handleIPLookup(w http.ResponseWriter, r *http.Request, path string, geoIP *
switch len(parts) { switch len(parts) {
case 0: case 0:
ipAddress = GetRealIP(r) // No more "common." prefix ipAddress = GetRealIP(r)
case 1: case 1:
if parts[0] == "" { if parts[0] == "" {
ipAddress = GetRealIP(r) // No more "common." prefix ipAddress = GetRealIP(r)
} else if _, ok := fieldMap[parts[0]]; ok { } else if _, ok := fieldMap[parts[0]]; ok {
ipAddress = GetRealIP(r) // No more "common." prefix ipAddress = GetRealIP(r)
field = parts[0] field = parts[0]
} else { } else {
ipAddress = parts[0] ipAddress = parts[0]
+5 -12
View File
@@ -1,6 +1,6 @@
# ipinfo # ipinfo
`ipinfo` is a powerful and efficient IP information service written in Go. It fetches GeoIP data to provide detailed information about an IP address, including geographical location, ASN, and related network details. The service automatically updates its GeoIP databases to ensure accuracy and reliability. `ipinfo` is a powerful and efficient IP information service written in Go. It fetches GeoIP data to provide detailed information about an IP address, including geographical location, ASN, and related network details. The service automatically updates its GeoIP databases (provided by DB-IP) to ensure accuracy and reliability.
## Features ## Features
@@ -9,7 +9,7 @@
- **Hostname Lookup**: Retrieves the hostname associated with the IP address. - **Hostname Lookup**: Retrieves the hostname associated with the IP address.
- **Domain WHOIS**: Fetches structured WHOIS data for any domain. - **Domain WHOIS**: Fetches structured WHOIS data for any domain.
- **Domain DNS Records**: Retrieves common DNS records (A, AAAA, CNAME, MX, TXT, NS). - **Domain DNS Records**: Retrieves common DNS records (A, AAAA, CNAME, MX, TXT, NS).
- **Automatic Database Updates**: Keeps GeoIP databases up-to-date daily. - **Automatic Database Updates**: Keeps GeoIP databases up-to-date monthly.
## Example Endpoints ## Example Endpoints
@@ -41,6 +41,7 @@ $ curl https://ip.albert.lol/9.9.9.9/city
``` ```
### Get details about an ASN ### Get details about an ASN
```sh ```sh
$ curl https://ip.albert.lol/AS19281 $ curl https://ip.albert.lol/AS19281
{ {
@@ -75,6 +76,7 @@ $ curl https://ip.albert.lol/AS19281
``` ```
### Get WHOIS and DNS records for a domain ### Get WHOIS and DNS records for a domain
```sh ```sh
$ curl https://ip.albert.lol/example.com $ curl https://ip.albert.lol/example.com
{ {
@@ -121,11 +123,7 @@ $ curl https://ip.albert.lol/example.com
git clone https://github.com/skidoodle/ipinfo git clone https://github.com/skidoodle/ipinfo
cd ipinfo cd ipinfo
docker build -t ipinfo:main . docker build -t ipinfo:main .
docker run \ docker run -p 3000:3000 ipinfo:main
-p 3000:3000
-e GEOIPUPDATE_ACCOUNT_ID=${GEOIPUPDATE_ACCOUNT_ID} \
-e GEOIPUPDATE_LICENSE_KEY=${GEOIPUPDATE_LICENSE_KEY} \
ipinfo:main
``` ```
### Without Docker ### Without Docker
@@ -148,9 +146,6 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
environment:
GEOIPUPDATE_ACCOUNT_ID: ${GEOIPUPDATE_ACCOUNT_ID}
GEOIPUPDATE_LICENSE_KEY: ${GEOIPUPDATE_LICENSE_KEY}
``` ```
### Docker Run ### Docker Run
@@ -161,8 +156,6 @@ docker run \
--name=ipinfo \ --name=ipinfo \
--restart=unless-stopped \ --restart=unless-stopped \
-p 3000:3000 \ -p 3000:3000 \
-e GEOIPUPDATE_ACCOUNT_ID=${GEOIPUPDATE_ACCOUNT_ID} \
-e GEOIPUPDATE_LICENSE_KEY=${GEOIPUPDATE_LICENSE_KEY} \
ghcr.io/skidoodle/ipinfo:main ghcr.io/skidoodle/ipinfo:main
``` ```