diff --git a/README.md b/README.md index 3b5c2d3c..6083abd2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ [![GoDoc](https://godoc.org/go.sia.tech/explored?status.svg)](https://godoc.org/go.sia.tech/explored) `explored` is an explorer for Sia. + +## Required Disclosure + +`explored` uses the IP2Location LITE database for IP geolocation. \ No newline at end of file diff --git a/explorer/types.go b/explorer/types.go index 57733697..027c5fc7 100644 --- a/explorer/types.go +++ b/explorer/types.go @@ -231,8 +231,9 @@ type HostScan struct { // Host represents a host and the information gathered from scanning it. type Host struct { - PublicKey types.PublicKey `json:"publicKey"` - NetAddress string `json:"netAddress"` + PublicKey types.PublicKey `json:"publicKey"` + NetAddress string `json:"netAddress"` + CountryCode string `json:"countryCode"` KnownSince time.Time `json:"knownSince"` LastScan time.Time `json:"lastScan"` diff --git a/go.mod b/go.mod index f6aaa199..b62b0836 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/ip2location/ip2location-go v8.3.0+incompatible // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect diff --git a/go.sum b/go.sum index 6ec573e3..19629693 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ip2location/ip2location-go v8.3.0+incompatible h1:QwUE+FlSbo6bjOWZpv2Grb57vJhWYFNPyBj2KCvfWaM= +github.com/ip2location/ip2location-go v8.3.0+incompatible/go.mod h1:3JUY1TBjTx1GdA7oRT7Zeqfc0bg3lMMuU5lXmzdpuME= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/internal/geoip/IP2LOCATION-LITE-DB1.BIN b/internal/geoip/IP2LOCATION-LITE-DB1.BIN new file mode 100755 index 00000000..4574d4bf Binary files /dev/null and b/internal/geoip/IP2LOCATION-LITE-DB1.BIN differ diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go new file mode 100644 index 00000000..b79ceacf --- /dev/null +++ b/internal/geoip/geoip.go @@ -0,0 +1,72 @@ +package geoip + +import ( + _ "embed" + "errors" + "net" + "os" + + "github.com/ip2location/ip2location-go" +) + +//go:embed IP2LOCATION-LITE-DB1.BIN +var ip2LocationDB []byte + +// A Locator maps IP addresses to their location. +type Locator interface { + // Close closes the Locator. + Close() error + // CountryCode maps IP addresses to ISO 3166-1 A-2 country codes. + CountryCode(ip *net.IPAddr) (string, error) +} + +type ip2Location struct { + path string + db *ip2location.DB +} + +// Close implements Locator. +func (ip *ip2Location) Close() error { + ip.db.Close() + return os.Remove(ip.path) +} + +// CountryCode implements Locator. +func (ip *ip2Location) CountryCode(addr *net.IPAddr) (string, error) { + if ip == nil { + return "", errors.New("nil IP") + } + + loc, err := ip.db.Get_country_short(addr.String()) + if err != nil { + return "", err + } + return loc.Country_short, nil +} + +// NewIP2LocationLocator returns a Locator that uses an underlying IP2Location +// database. If no path is provided, a default embedded LITE database is used. +func NewIP2LocationLocator(path string) (Locator, error) { + // Unfortuantely, ip2location.OpenDB only accepts a file path. So we need + // to write the embedded file to a temporary file on disk, and use that + // instead. + if path == "" { + f, err := os.CreateTemp("", "geoip") + if err != nil { + return nil, err + } else if _, err := f.Write(ip2LocationDB); err != nil { + return nil, err + } else if err := f.Sync(); err != nil { + return nil, err + } else if err := f.Close(); err != nil { + return nil, err + } + path = f.Name() + } + + db, err := ip2location.OpenDB(path) + if err != nil { + return nil, err + } + return &ip2Location{path: path, db: db}, nil +} diff --git a/persist/sqlite/addresses.go b/persist/sqlite/addresses.go index 73166838..d4368bb5 100644 --- a/persist/sqlite/addresses.go +++ b/persist/sqlite/addresses.go @@ -3,6 +3,7 @@ package sqlite import ( "database/sql" "fmt" + "net" "time" "go.sia.tech/core/types" @@ -100,8 +101,8 @@ WHERE ev.event_id = ?`, eventID).Scan(decode(&m.SiacoinOutput.StateElement.ID), } // Hosts returns the hosts with the given public keys. -func (s *Store) Hosts(pks []types.PublicKey) (result []explorer.Host, err error) { - err = s.transaction(func(tx *txn) error { +func (st *Store) Hosts(pks []types.PublicKey) (result []explorer.Host, err error) { + err = st.transaction(func(tx *txn) error { var encoded []any for _, pk := range pks { encoded = append(encoded, encode(pk)) @@ -119,6 +120,22 @@ func (s *Store) Hosts(pks []types.PublicKey) (result []explorer.Host, err error) if err := rows.Scan(decode(&host.PublicKey), &host.NetAddress, decode(&host.KnownSince), decode(&host.LastScan), &host.LastScanSuccessful, decode(&host.LastAnnouncement), &host.TotalScans, &host.SuccessfulInteractions, &host.FailedInteractions, &s.AcceptingContracts, decode(&s.MaxDownloadBatchSize), decode(&s.MaxDuration), decode(&s.MaxReviseBatchSize), &s.NetAddress, decode(&s.RemainingStorage), decode(&s.SectorSize), decode(&s.TotalStorage), decode(&s.Address), decode(&s.WindowSize), decode(&s.Collateral), decode(&s.MaxCollateral), decode(&s.BaseRPCPrice), decode(&s.ContractPrice), decode(&s.DownloadBandwidthPrice), decode(&s.SectorAccessPrice), decode(&s.StoragePrice), decode(&s.UploadBandwidthPrice), &s.EphemeralAccountExpiry, decode(&s.MaxEphemeralAccountBalance), decode(&s.RevisionNumber), &s.Version, &s.Release, &s.SiaMuxPort, decode(&p.UID), &p.Validity, decode(&p.HostBlockHeight), decode(&p.UpdatePriceTableCost), decode(&p.AccountBalanceCost), decode(&p.FundAccountCost), decode(&p.LatestRevisionCost), decode(&p.SubscriptionMemoryCost), decode(&p.SubscriptionNotificationCost), decode(&p.InitBaseCost), decode(&p.MemoryTimeCost), decode(&p.DownloadBandwidthCost), decode(&p.UploadBandwidthCost), decode(&p.DropSectorsBaseCost), decode(&p.DropSectorsUnitCost), decode(&p.HasSectorBaseCost), decode(&p.ReadBaseCost), decode(&p.ReadLengthCost), decode(&p.RenewContractCost), decode(&p.RevisionBaseCost), decode(&p.SwapSectorBaseCost), decode(&p.WriteBaseCost), decode(&p.WriteLengthCost), decode(&p.WriteStoreCost), decode(&p.TxnFeeMinRecommended), decode(&p.TxnFeeMaxRecommended), decode(&p.ContractPrice), decode(&p.CollateralCost), decode(&p.MaxCollateral), decode(&p.MaxDuration), decode(&p.WindowSize), decode(&p.RegistryEntriesLeft), decode(&p.RegistryEntriesTotal)); err != nil { return err } + + // We should fill in country code information if we can but still + // return the host even if we are unable to. + hostname, _, err := net.SplitHostPort(host.NetAddress) + // if we can parse the net address + if err == nil { + resolved, err := net.ResolveIPAddr("ip", hostname) + // if we can resolve the address + if err == nil { + countryCode, err := st.locator.CountryCode(resolved) + if err == nil { + host.CountryCode = countryCode + } + } + } + result = append(result, host) } return nil diff --git a/persist/sqlite/scan_test.go b/persist/sqlite/scan_test.go index 14ccfeee..76312a7f 100644 --- a/persist/sqlite/scan_test.go +++ b/persist/sqlite/scan_test.go @@ -145,6 +145,7 @@ func TestScan(t *testing.T) { host2 := dbHosts[1] testutil.Equal(t, "host2.NetAddress", hosts[1].NetAddress, host2.NetAddress) testutil.Equal(t, "host2.PublicKey", hosts[1].PublicKey, host2.PublicKey) + testutil.Equal(t, "host2.CountryCode", "CA", host2.CountryCode) testutil.Equal(t, "host2.TotalScans", 1, host2.TotalScans) testutil.Equal(t, "host2.SuccessfulInteractions", 1, host2.SuccessfulInteractions) testutil.Equal(t, "host2.FailedInteractions", 0, host2.FailedInteractions) diff --git a/persist/sqlite/store.go b/persist/sqlite/store.go index 1f79e421..3fa085a9 100644 --- a/persist/sqlite/store.go +++ b/persist/sqlite/store.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + _ "embed" "encoding/hex" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "time" "github.com/mattn/go-sqlite3" + "go.sia.tech/explored/internal/geoip" "go.uber.org/zap" "lukechampine.com/frand" ) @@ -19,6 +21,8 @@ type ( Store struct { db *sql.DB log *zap.Logger + + locator geoip.Locator } ) @@ -53,6 +57,7 @@ func (s *Store) transaction(fn func(*txn) error) error { // Close closes the underlying database. func (s *Store) Close() error { + s.locator.Close() return s.db.Close() } @@ -103,9 +108,16 @@ func OpenDatabase(fp string, log *zap.Logger) (*Store, error) { if err != nil { return nil, err } + // use default included ip2location database + locator, err := geoip.NewIP2LocationLocator("") + if err != nil { + return nil, err + } + store := &Store{ - db: db, - log: log, + db: db, + log: log, + locator: locator, } if err := store.init(); err != nil { return nil, fmt.Errorf("failed to initialize database: %w", err)