diff --git a/cmd/check-ip/blacklist.go b/cmd/check-ip/blacklist.go index fc0e93c..d5cc5be 100644 --- a/cmd/check-ip/blacklist.go +++ b/cmd/check-ip/blacklist.go @@ -107,3 +107,21 @@ func (s *Sources) Datasets() ( } return g, whitelist, inbound, outbound } + +// isBlocked returns true if ip is in cohort and not in whitelist. +func isBlocked(ip string, whitelist, cohort *dataset.View[ipcohort.Cohort]) bool { + if cohort == nil { + return false + } + if whitelist != nil && whitelist.Load().Contains(ip) { + return false + } + return cohort.Load().Contains(ip) +} + +func cohortSize(ds *dataset.View[ipcohort.Cohort]) int { + if ds == nil { + return 0 + } + return ds.Load().Size() +} diff --git a/cmd/check-ip/geo.go b/cmd/check-ip/geo.go new file mode 100644 index 0000000..16eb278 --- /dev/null +++ b/cmd/check-ip/geo.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/therootcompany/golib/net/geoip" +) + +// setupGeo parses geoip-conf (if given) and returns a Databases ready to Init. +// Returns nil if no geoip flags were provided. +func setupGeo(confPath, cityPath, asnPath string) (*geoip.Databases, error) { + if confPath == "" && cityPath == "" && asnPath == "" { + return nil, nil + } + if confPath != "" { + cfg, err := geoip.ParseConf(confPath) + if err != nil { + return nil, fmt.Errorf("geoip-conf: %w", err) + } + dbDir := cfg.DatabaseDirectory + if dbDir == "" { + if dbDir, err = geoip.DefaultCacheDir(); err != nil { + return nil, fmt.Errorf("geoip cache dir: %w", err) + } + } + if err := os.MkdirAll(dbDir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", dbDir, err) + } + if cityPath == "" { + cityPath = filepath.Join(dbDir, geoip.CityEdition+".mmdb") + } + if asnPath == "" { + asnPath = filepath.Join(dbDir, geoip.ASNEdition+".mmdb") + } + return geoip.New(cfg.AccountID, cfg.LicenseKey).NewDatabases(cityPath, asnPath), nil + } + return geoip.NewDatabases(cityPath, asnPath), nil +} diff --git a/cmd/check-ip/main.go b/cmd/check-ip/main.go index 697f603..86333ec 100644 --- a/cmd/check-ip/main.go +++ b/cmd/check-ip/main.go @@ -4,16 +4,10 @@ import ( "context" "flag" "fmt" - "net/netip" "os" - "path/filepath" "strings" "time" - "github.com/oschwald/geoip2-golang" - "github.com/therootcompany/golib/net/dataset" - "github.com/therootcompany/golib/net/geoip" - "github.com/therootcompany/golib/net/ipcohort" ) // inbound blocklist @@ -31,9 +25,9 @@ const ( func defaultBlocklistDir() string { base, err := os.UserCacheDir() if err != nil { - return filepath.Join(os.Getenv("HOME"), ".cache", "bitwire-it") + return os.Getenv("HOME") + "/.cache/bitwire-it" } - return filepath.Join(base, "bitwire-it") + return base + "/bitwire-it" } func main() { @@ -56,7 +50,6 @@ func main() { } // First arg is either a local file or the IP to check. - // If it looks like a file, treat it as the inbound list; otherwise use default cache dir. var dataPath, ipStr string if flag.NArg() >= 2 || strings.HasSuffix(flag.Arg(0), ".txt") || strings.HasSuffix(flag.Arg(0), ".csv") { dataPath = flag.Arg(0) @@ -71,7 +64,8 @@ func main() { dataPath = defaultBlocklistDir() } - // Blocklist source. + // -- Blocklist ---------------------------------------------------------- + var src *Sources switch { case *gitURL != "": @@ -96,191 +90,51 @@ func main() { ) } - // Build typed datasets from the source. - // blGroup.Init() calls src.Fetch() which handles initial git clone and HTTP download. - blGroup, whitelistDS, inboundDS, outboundDS := src.Datasets() + blGroup, whitelist, inbound, outbound := src.Datasets() if err := blGroup.Init(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } fmt.Fprintf(os.Stderr, "Loaded inbound=%d outbound=%d\n", - cohortSize(inboundDS), cohortSize(outboundDS)) + cohortSize(inbound), cohortSize(outbound)) - // GeoIP datasets. - resolvedCityPath := *cityDBPath - resolvedASNPath := *asnDBPath + // -- GeoIP (optional) -------------------------------------------------- - var cityDS, asnDS *dataset.Dataset[geoip2.Reader] - - if *geoipConf != "" { - cfg, err := geoip.ParseConf(*geoipConf) - if err != nil { - fmt.Fprintf(os.Stderr, "error: geoip-conf: %v\n", err) - os.Exit(1) - } - dbDir := cfg.DatabaseDirectory - if dbDir == "" { - if dbDir, err = geoip.DefaultCacheDir(); err != nil { - fmt.Fprintf(os.Stderr, "error: geoip cache dir: %v\n", err) - os.Exit(1) - } - } - if err := os.MkdirAll(dbDir, 0o755); err != nil { - fmt.Fprintf(os.Stderr, "error: mkdir %s: %v\n", dbDir, err) - os.Exit(1) - } - d := geoip.New(cfg.AccountID, cfg.LicenseKey) - if resolvedCityPath == "" { - resolvedCityPath = filepath.Join(dbDir, geoip.CityEdition+".mmdb") - } - if resolvedASNPath == "" { - resolvedASNPath = filepath.Join(dbDir, geoip.ASNEdition+".mmdb") - } - cityDS = newGeoIPDataset(d, geoip.CityEdition, resolvedCityPath) - asnDS = newGeoIPDataset(d, geoip.ASNEdition, resolvedASNPath) - } else { - // Manual paths: no auto-download, just open existing files. - if resolvedCityPath != "" { - cityDS = newGeoIPDataset(nil, "", resolvedCityPath) - } - if resolvedASNPath != "" { - asnDS = newGeoIPDataset(nil, "", resolvedASNPath) - } + geo, err := setupGeo(*geoipConf, *cityDBPath, *asnDBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if err := geo.Init(); err != nil { + fmt.Fprintf(os.Stderr, "error: geoip: %v\n", err) + os.Exit(1) } - if cityDS != nil { - if err := cityDS.Init(); err != nil { - fmt.Fprintf(os.Stderr, "error: city DB: %v\n", err) - os.Exit(1) - } - } - if asnDS != nil { - if err := asnDS.Init(); err != nil { - fmt.Fprintf(os.Stderr, "error: ASN DB: %v\n", err) - os.Exit(1) - } - } + // -- Background refresh ------------------------------------------------ - // Keep everything fresh in the background. ctx, cancel := context.WithCancel(context.Background()) defer cancel() go blGroup.Run(ctx, 47*time.Minute) - if cityDS != nil { - go cityDS.Run(ctx, 47*time.Minute) - } - if asnDS != nil { - go asnDS.Run(ctx, 47*time.Minute) - } + geo.Run(ctx, 47*time.Minute) - // Check and report. - blockedInbound := containsInbound(ipStr, whitelistDS, inboundDS) - blockedOutbound := containsOutbound(ipStr, whitelistDS, outboundDS) + // -- Check and report -------------------------------------------------- + + blockedIn := isBlocked(ipStr, whitelist, inbound) + blockedOut := isBlocked(ipStr, whitelist, outbound) switch { - case blockedInbound && blockedOutbound: + case blockedIn && blockedOut: fmt.Printf("%s is BLOCKED (inbound + outbound)\n", ipStr) - case blockedInbound: + case blockedIn: fmt.Printf("%s is BLOCKED (inbound)\n", ipStr) - case blockedOutbound: + case blockedOut: fmt.Printf("%s is BLOCKED (outbound)\n", ipStr) default: fmt.Printf("%s is allowed\n", ipStr) } + geo.PrintInfo(os.Stdout, ipStr) - printGeoInfo(ipStr, cityDS, asnDS) - - if blockedInbound || blockedOutbound { + if blockedIn || blockedOut { os.Exit(1) } } - -// newGeoIPDataset creates a Dataset[geoip2.Reader]. If d is nil, only -// opens the existing file (no download). Close is wired to Reader.Close. -func newGeoIPDataset(d *geoip.Downloader, edition, path string) *dataset.Dataset[geoip2.Reader] { - var syncer dataset.Syncer - if d != nil { - syncer = d.NewCacher(edition, path) - } else { - syncer = dataset.NopSyncer{} - } - ds := dataset.New(syncer, func() (*geoip2.Reader, error) { - return geoip2.Open(path) - }) - ds.Name = edition - ds.Close = func(r *geoip2.Reader) { r.Close() } - return ds -} - -func containsInbound(ip string, - whitelist, inbound *dataset.View[ipcohort.Cohort], -) bool { - if whitelist != nil && whitelist.Load().Contains(ip) { - return false - } - if inbound == nil { - return false - } - return inbound.Load().Contains(ip) -} - -func containsOutbound(ip string, - whitelist, outbound *dataset.View[ipcohort.Cohort], -) bool { - if whitelist != nil && whitelist.Load().Contains(ip) { - return false - } - if outbound == nil { - return false - } - return outbound.Load().Contains(ip) -} - -func printGeoInfo(ipStr string, cityDS, asnDS *dataset.Dataset[geoip2.Reader]) { - ip, err := netip.ParseAddr(ipStr) - if err != nil { - return - } - stdIP := ip.AsSlice() - - if cityDS != nil { - r := cityDS.Load() - if rec, err := r.City(stdIP); err == nil { - city := rec.City.Names["en"] - country := rec.Country.Names["en"] - iso := rec.Country.IsoCode - var parts []string - if city != "" { - parts = append(parts, city) - } - if len(rec.Subdivisions) > 0 { - if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != city { - parts = append(parts, sub) - } - } - if country != "" { - parts = append(parts, fmt.Sprintf("%s (%s)", country, iso)) - } - if len(parts) > 0 { - fmt.Printf(" Location: %s\n", strings.Join(parts, ", ")) - } - } - } - - if asnDS != nil { - r := asnDS.Load() - if rec, err := r.ASN(stdIP); err == nil && rec.AutonomousSystemNumber != 0 { - fmt.Printf(" ASN: AS%d %s\n", - rec.AutonomousSystemNumber, rec.AutonomousSystemOrganization) - } - } -} - -func cohortSize(ds *dataset.View[ipcohort.Cohort]) int { - if ds == nil { - return 0 - } - if c := ds.Load(); c != nil { - return c.Size() - } - return 0 -} diff --git a/net/geoip/databases.go b/net/geoip/databases.go new file mode 100644 index 0000000..5cf3112 --- /dev/null +++ b/net/geoip/databases.go @@ -0,0 +1,113 @@ +package geoip + +import ( + "context" + "fmt" + "io" + "net/netip" + "strings" + "time" + + "github.com/oschwald/geoip2-golang" + "github.com/therootcompany/golib/net/dataset" +) + +// Databases pairs city and ASN datasets. All methods are nil-safe no-ops so +// callers need not check whether geoip was configured. +type Databases struct { + City *dataset.Dataset[geoip2.Reader] + ASN *dataset.Dataset[geoip2.Reader] +} + +// NewDatabases creates Databases for the given paths without a Downloader +// (uses whatever is already on disk). +func NewDatabases(cityPath, asnPath string) *Databases { + return &Databases{ + City: newDataset(nil, CityEdition, cityPath), + ASN: newDataset(nil, ASNEdition, asnPath), + } +} + +// NewDatabases creates Databases backed by this Downloader. +func (d *Downloader) NewDatabases(cityPath, asnPath string) *Databases { + return &Databases{ + City: newDataset(d, CityEdition, cityPath), + ASN: newDataset(d, ASNEdition, asnPath), + } +} + +func newDataset(d *Downloader, edition, path string) *dataset.Dataset[geoip2.Reader] { + var syncer dataset.Syncer + if d != nil { + syncer = d.NewCacher(edition, path) + } else { + syncer = dataset.NopSyncer{} + } + ds := dataset.New(syncer, func() (*geoip2.Reader, error) { + return geoip2.Open(path) + }) + ds.Name = edition + ds.Close = func(r *geoip2.Reader) { r.Close() } + return ds +} + +// Init downloads (if needed) and opens both databases. Returns the first error. +// No-op on nil receiver. +func (dbs *Databases) Init() error { + if dbs == nil { + return nil + } + if err := dbs.City.Init(); err != nil { + return err + } + return dbs.ASN.Init() +} + +// Run starts background refresh goroutines for both databases. +// No-op on nil receiver. +func (dbs *Databases) Run(ctx context.Context, interval time.Duration) { + if dbs == nil { + return + } + go dbs.City.Run(ctx, interval) + go dbs.ASN.Run(ctx, interval) +} + +// PrintInfo writes city and ASN info for ip to w. +// No-op on nil receiver or unparseable IP. +func (dbs *Databases) PrintInfo(w io.Writer, ip string) { + if dbs == nil { + return + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return + } + stdIP := addr.AsSlice() + + if rec, err := dbs.City.Load().City(stdIP); err == nil { + city := rec.City.Names["en"] + country := rec.Country.Names["en"] + iso := rec.Country.IsoCode + var parts []string + if city != "" { + parts = append(parts, city) + } + if len(rec.Subdivisions) > 0 { + if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != city { + parts = append(parts, sub) + } + } + if country != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", country, iso)) + } + if len(parts) > 0 { + fmt.Fprintf(w, " Location: %s\n", strings.Join(parts, ", ")) + } + } + + if rec, err := dbs.ASN.Load().ASN(stdIP); err == nil && rec.AutonomousSystemNumber != 0 { + fmt.Fprintf(w, " ASN: AS%d %s\n", + rec.AutonomousSystemNumber, rec.AutonomousSystemOrganization) + } +} diff --git a/net/geoip/go.mod b/net/geoip/go.mod index 3a91846..7ca9a0e 100644 --- a/net/geoip/go.mod +++ b/net/geoip/go.mod @@ -4,5 +4,6 @@ go 1.26.0 require ( github.com/oschwald/geoip2-golang v1.13.0 + github.com/therootcompany/golib/net/dataset v0.0.0 github.com/therootcompany/golib/net/httpcache v0.0.0 )