From 4e85e00d5ab31ca02180d0ba1d544abf1ac5b06a Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 9 Sep 2026 11:34:21 -0600 Subject: [PATCH] feat(cmd/check-ip): reference CLI exercising ipcohort + geoip + dataset --- cmd/check-ip/go.mod | 21 +++ cmd/check-ip/go.sum | 32 ++++ cmd/check-ip/main.go | 393 +++++++++++++++++++++++++++++++++++++++++ cmd/check-ip/server.go | 185 +++++++++++++++++++ 4 files changed, 631 insertions(+) create mode 100644 cmd/check-ip/go.mod create mode 100644 cmd/check-ip/go.sum create mode 100644 cmd/check-ip/main.go create mode 100644 cmd/check-ip/server.go diff --git a/cmd/check-ip/go.mod b/cmd/check-ip/go.mod new file mode 100644 index 0000000..fb0399a --- /dev/null +++ b/cmd/check-ip/go.mod @@ -0,0 +1,21 @@ +module github.com/therootcompany/golib/cmd/check-ip + +go 1.26.5 + +require ( + github.com/therootcompany/golib/net/geoip v0.5.0 + github.com/therootcompany/golib/net/gitshallow v0.9.1 + github.com/therootcompany/golib/net/httpcache v0.5.1 + github.com/therootcompany/golib/net/ipcohort v0.9.0 + github.com/therootcompany/golib/net/iplist v0.8.5 + github.com/therootcompany/golib/sync/dataset v0.5.0 +) + +require ( + github.com/oschwald/geoip2-golang v1.13.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + github.com/therootcompany/golib/https v0.9.0 // indirect + github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect +) diff --git a/cmd/check-ip/go.sum b/cmd/check-ip/go.sum new file mode 100644 index 0000000..22ce811 --- /dev/null +++ b/cmd/check-ip/go.sum @@ -0,0 +1,32 @@ +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/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/therootcompany/golib/https v0.9.0 h1:D9gh0K6ljkvF805JUbylITn8SHPMo08q5PglQGXsK8c= +github.com/therootcompany/golib/https v0.9.0/go.mod h1:ilExqR/Uh7oB36oy9mwuNhANcFh+zdCfD3uJiZ11YAY= +github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0 h1:R5zAPun5p3JPlNfcMD9es4LD9sqh6nWDNTflZBeeUJo= +github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0/go.mod h1:CsntOAhhIMdzSAYuwuRZtfkdoiER9+TfSgYOIDWN71o= +github.com/therootcompany/golib/net/geoip v0.5.0 h1:dmduwHLX3RCe2NExhOKcFZ4m1ML2C3gQKnNyaBhNE88= +github.com/therootcompany/golib/net/geoip v0.5.0/go.mod h1:BLf5g25UhvI/+/x/WpM1NTVs4ZMnjySoHv+LXfALT6M= +github.com/therootcompany/golib/net/gitshallow v0.9.1 h1:VI/Q4jViKvVuJ+lzZrD7suhSXLfNNhUcd4ZJrkpTP/Q= +github.com/therootcompany/golib/net/gitshallow v0.9.1/go.mod h1:S/3OK7wMOqJJjP79U9Qw/l3jjEyrb/zsjJ8HAPAX/Fc= +github.com/therootcompany/golib/net/httpcache v0.5.1 h1:vQM758tBwPEqCo7ojpSeL3fHqgq29LYmvD2hvDTBfKY= +github.com/therootcompany/golib/net/httpcache v0.5.1/go.mod h1:mKSQJWpGSCDBzWkTCwT7dO1SCVCsFmdkRgKr39fNS1U= +github.com/therootcompany/golib/net/ipcohort v0.9.0 h1:j3AaoW2u4XQJz1ya6fixv3pb3KgxIpZV8t0082/kS3E= +github.com/therootcompany/golib/net/ipcohort v0.9.0/go.mod h1:Ljcnt2xOryVDGIVV5Kzt5hj2LhW4lBAWo5OeeVYU9ys= +github.com/therootcompany/golib/net/iplist v0.8.5 h1:swn/hcCt6fAG3tYApmxh1OlDak2PjQdczldZWaH1Eus= +github.com/therootcompany/golib/net/iplist v0.8.5/go.mod h1:vK5Na53RcLdtp/IPM/NX9BudyKhXp+8T+elmWBVWzeU= +github.com/therootcompany/golib/sync/dataset v0.5.0 h1:4i3+7Izy/vnpK6IW5m9u4UqLA9RM1IjUsedbZo3MW6E= +github.com/therootcompany/golib/sync/dataset v0.5.0/go.mod h1:HMa25n3eqYnjRcPXny0IUGU2wFKD5/x2g9UUkt3iAmI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/check-ip/main.go b/cmd/check-ip/main.go new file mode 100644 index 0000000..f17bf0e --- /dev/null +++ b/cmd/check-ip/main.go @@ -0,0 +1,393 @@ +// check-ip runs an HTTP API that reports whether an IP appears in the +// configured blocklist repo and enriches the response with MaxMind +// GeoLite2 City + ASN data. +package main + +import ( + "context" + "encoding/base64" + "encoding/csv" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/therootcompany/golib/net/geoip" + "github.com/therootcompany/golib/net/gitshallow" + "github.com/therootcompany/golib/net/httpcache" + "github.com/therootcompany/golib/net/ipcohort" + "github.com/therootcompany/golib/net/iplist" + "github.com/therootcompany/golib/sync/dataset" +) + +// Replaced by goreleaser / ldflags at build time. +var ( + name = "check-ip" + version = "0.0.0-dev" + commit = "0000000" + date = "0001-01-01" + licenseYear = "2026" + licenseOwner = "AJ ONeal" + licenseType = "MPL-2.0" +) + +const ( + defaultBlocklistRepo = "https://github.com/bitwire-it/ipblocklist.git" + refreshInterval = 47 * time.Minute +) + +// printVersion writes version, copyright, and license info to w. +func printVersion(w io.Writer) { + _, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date) + _, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner) + _, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType) +} + +// isTTYish reports whether f is a terminal device. +func isTTYish(f *os.File) bool { + fi, err := f.Stat() + if err != nil { + return false + } + m := os.ModeDevice | os.ModeCharDevice + return fi.Mode()&m == m +} + +// parseFormat validates and returns the output format. +func parseFormat(s string) (string, error) { + switch s { + case "", "pretty", "tsv", "csv", "json": + return s, nil + default: + return "", fmt.Errorf("invalid format %q: want pretty, tsv, csv, json", s) + } +} + +// commafy formats n with comma thousands separators (e.g. 3406727 -> "3,406,727"). +func commafy(n int) string { + s := strconv.Itoa(n) + neg := "" + if n < 0 { + neg, s = "-", s[1:] + } + if len(s) <= 3 { + return neg + s + } + var b strings.Builder + head := len(s) % 3 + if head > 0 { + b.WriteString(s[:head]) + b.WriteByte(',') + } + for i := head; i < len(s); i += 3 { + b.WriteString(s[i : i+3]) + if i+3 < len(s) { + b.WriteByte(',') + } + } + return neg + b.String() +} + +// IPCheck holds the parsed CLI config and the loaded data sources used by +// the HTTP handler. +type IPCheck struct { + Bind string + GeoIPConfPath string + GeoIPURL string + RepoURL string + CacheDir string + WhitelistPath string + AsyncLoad bool + Format string + + // GeoIPBasicAuth is the pre-encoded Authorization header value for + // MaxMind downloads. + GeoIPBasicAuth string + + inbound *dataset.View[ipcohort.Cohort] + outbound *dataset.View[ipcohort.Cohort] + whitelist atomic.Pointer[ipcohort.Cohort] + geo *dataset.View[geoip.Databases] +} + +func main() { + cfg := IPCheck{} + fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + fs.StringVar(&cfg.Bind, "serve", "", "bind address for the HTTP API, e.g. :8080") + fs.StringVar(&cfg.GeoIPConfPath, "geoip-conf", "", "path to GeoIP.conf (default: ./GeoIP.conf or ~/.config/maxmind/GeoIP.conf)") + fs.StringVar(&cfg.RepoURL, "blocklist-repo", defaultBlocklistRepo, "git URL of the blocklist repo (must match bitwire-it layout)") + fs.StringVar(&cfg.CacheDir, "cache-dir", "", "cache parent dir, holds bitwire-it/ and maxmind/ subdirs (default: ~/.cache)") + fs.StringVar(&cfg.GeoIPURL, "geoip-url", "", "GeoIP download URL (overrides geoip.DownloadBase)") + fs.StringVar(&cfg.WhitelistPath, "whitelist", "", "path to a file of IPs and/or CIDRs (one per line) that override block decisions") + fs.BoolVar(&cfg.AsyncLoad, "async-load", false, "with --serve: start the HTTP server immediately and populate blocklists+whitelist in the background (/healthz returns 503 until ready). Ignored in CLI mode.") + fs.StringVar(&cfg.Format, "format", "", "output format: pretty, tsv, csv, json (default: auto)") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [flags] [ip...]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --serve [flags]\n", os.Args[0]) + fs.PrintDefaults() + } + + if len(os.Args) > 1 { + switch os.Args[1] { + case "-V", "-version", "--version", "version": + printVersion(os.Stdout) + os.Exit(0) + case "help", "-help", "--help": + printVersion(os.Stdout) + fmt.Fprintln(os.Stdout) + fs.SetOutput(os.Stdout) + fs.Usage() + os.Exit(0) + } + } + + if err := fs.Parse(os.Args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + os.Exit(1) + } + ips := fs.Args() + if cfg.Bind == "" && len(ips) == 0 { + fmt.Fprintln(os.Stderr, "error: provide at least one IP argument or --serve ") + fs.Usage() + os.Exit(1) + } + if cfg.CacheDir == "" { + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("cache-dir: %v", err) + } + cfg.CacheDir = filepath.Join(home, ".cache") + } + format, err := parseFormat(cfg.Format) + if err != nil { + log.Fatalf("format: %v", err) + } + cfg.Format = format + + // GeoIP config discovery: explicit --geoip-conf wins; otherwise check the + // default locations. If found, parse it and stash the basic-auth header + // value for later MaxMind downloads. + if cfg.GeoIPConfPath == "" { + for _, p := range geoip.DefaultConfPaths() { + if _, err := os.Stat(p); err == nil { + cfg.GeoIPConfPath = p + break + } + } + } + if cfg.GeoIPConfPath != "" { + data, err := os.ReadFile(cfg.GeoIPConfPath) + if err != nil { + log.Fatalf("geoip-conf: %v", err) + } + conf, err := geoip.ParseConf(string(data)) + if err != nil { + log.Fatalf("geoip-conf: %v", err) + } + cfg.GeoIPBasicAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(conf.AccountID+":"+conf.LicenseKey)) + } + // Allow overriding the license key via env var. + if envKey := os.Getenv("MAXMIND_LICENSE_KEY"); envKey != "" { + if cfg.GeoIPConfPath == "" { + log.Fatalf("MAXMIND_LICENSE_KEY requires --geoip-conf for AccountID") + } + cfg.GeoIPBasicAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(cfg.GeoIPBasicAuth[6:]+":"+envKey)) + } + + // Blocklists: git repo with inbound + outbound IP cohort files. + repo := gitshallow.New(cfg.RepoURL, filepath.Join(cfg.CacheDir, "bitwire-it"), 1, "") + repo.MaxAge = refreshInterval + blocklists := dataset.NewSet(repo) + asyncServe := cfg.AsyncLoad && cfg.Bind != "" + addCohort := func(s *dataset.Set, loader func(context.Context) (*ipcohort.Cohort, error)) *dataset.View[ipcohort.Cohort] { + if asyncServe { + return dataset.AddInitial(s, &ipcohort.Cohort{}, loader) + } + return dataset.Add(s, loader) + } + cfg.inbound = addCohort(blocklists, func(_ context.Context) (*ipcohort.Cohort, error) { + return ipcohort.LoadFiles( + repo.FilePath("tables/inbound/single_ips.txt"), + repo.FilePath("tables/inbound/networks.txt"), + ) + }) + cfg.outbound = addCohort(blocklists, func(_ context.Context) (*ipcohort.Cohort, error) { + return ipcohort.LoadFiles( + repo.FilePath("tables/outbound/single_ips.txt"), + repo.FilePath("tables/outbound/networks.txt"), + ) + }) + loadBlocklists := func() { + fmt.Fprint(os.Stderr, "Loading blocklists... ") + t := time.Now() + if err := blocklists.Load(context.Background()); err != nil { + fmt.Fprintln(os.Stderr) + log.Printf("blocklists: %v", err) + if !asyncServe { + os.Exit(1) + } + return + } + fmt.Fprintf(os.Stderr, "%s (inbound=%s, outbound=%s)\n", + time.Since(t).Round(time.Millisecond), + commafy(cfg.inbound.Value().Size()), + commafy(cfg.outbound.Value().Size()), + ) + } + if !asyncServe { + loadBlocklists() + } + + // GeoIP: download the City + ASN tar.gz archives via httpcache + // conditional GETs. geoip.Open extracts in-memory — no .mmdb files + // are written to disk. + if cfg.GeoIPBasicAuth == "" { + log.Fatalf("geoip-conf: not found; set --geoip-conf or place GeoIP.conf in a default location.\n"+ + "GeoLite2 registration and the AccountID/LicenseKey needed for GeoIP.conf are free at:\n"+ + " https://www.maxmind.com/en/geolite2/signup\n"+ + "Then create a license key and write ./GeoIP.conf (or ~/.config/maxmind/GeoIP.conf):\n"+ + " AccountID \n"+ + " LicenseKey \n"+ + " EditionIDs GeoLite2-City GeoLite2-ASN\n"+ + "Default search paths: %v", geoip.DefaultConfPaths()) + } + maxmindDir := filepath.Join(cfg.CacheDir, "maxmind") + authHeader := http.Header{"Authorization": []string{cfg.GeoIPBasicAuth}} + downloadBase := geoip.DownloadBase + if cfg.GeoIPURL != "" { + downloadBase = strings.TrimRight(cfg.GeoIPURL, "/") + } + cityCacher := httpcache.New( + downloadBase+"/GeoLite2-City/download?suffix=tar.gz", + filepath.Join(maxmindDir, geoip.TarGzName(geoip.CityEdition))) + cityCacher.Header = authHeader + cityCacher.MaxAge = 3 * 24 * time.Hour + asnCacher := httpcache.New( + downloadBase+"/GeoLite2-ASN/download?suffix=tar.gz", + filepath.Join(maxmindDir, geoip.TarGzName(geoip.ASNEdition))) + asnCacher.Header = authHeader + asnCacher.MaxAge = 3 * 24 * time.Hour + geoSet := dataset.NewSet(cityCacher, asnCacher) + cfg.geo = dataset.Add(geoSet, func(_ context.Context) (*geoip.Databases, error) { + return geoip.Open(maxmindDir) + }) + fmt.Fprint(os.Stderr, "Loading geoip... ") + tGeo := time.Now() + if err := geoSet.Load(context.Background()); err != nil { + fmt.Fprintln(os.Stderr) + log.Fatalf("geoip: %v", err) + } + fmt.Fprintf(os.Stderr, "%s\n", time.Since(tGeo).Round(time.Millisecond)) + defer func() { _ = cfg.geo.Value().Close() }() + + // Whitelist: combined IPs + CIDRs in one file, refreshed on an interval. + // A match here overrides any block decision from the blocklists. + // Uses net/iplist.Source — the refreshed-IP-list primitive net/ippolicy + // builds on — instead of a dataset wrapper; iplist owns the refresh. + if cfg.WhitelistPath != "" { + t := time.Now() + whitelistSrc, err := iplist.NewSource(context.Background(), iplist.SourceConfig{ + Source: cfg.WhitelistPath, + RefreshInterval: refreshInterval, + Optional: true, + }) + if err != nil { + log.Fatalf("whitelist: %v", err) + } + store := func() { + coh, perr := ipcohort.Parse(whitelistSrc.Entries()) + if perr != nil { + log.Printf("whitelist: %v", perr) + } + cfg.whitelist.Store(coh) + } + store() + whitelistSrc.Subscribe(func(e iplist.SourceEvent) { + if e.Err != nil { + log.Printf("whitelist refresh: %v", e.Err) + return + } + store() + }) + fmt.Fprintf(os.Stderr, "Loading whitelist... %s (entries=%s)\n", + time.Since(t).Round(time.Millisecond), + commafy(cfg.whitelist.Load().Size()), + ) + } + + // Blank line separates the stderr "Loading ..." block from the real + // output (stdout results for CLI mode, or the stderr "listening on" + // log for serve mode). + fmt.Fprintln(os.Stderr) + + // Auto-detect format: pretty on TTY, tsv when piped. + if cfg.Format == "" { + if isTTYish(os.Stdout) { + cfg.Format = "pretty" + } else { + cfg.Format = "tsv" + } + } + + for _, ip := range ips { + res := cfg.lookup(ip) + switch cfg.Format { + case "json": + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(res) + case "csv": + w := csv.NewWriter(os.Stdout) + w.Write([]string{"ip", "blocked", "blocked_inbound", "blocked_outbound", "allowlisted", "city", "region", "country", "country_iso", "asn", "asn_org"}) + w.Write([]string{res.IP, strconv.FormatBool(res.Blocked), strconv.FormatBool(res.BlockedInbound), strconv.FormatBool(res.BlockedOutbound), strconv.FormatBool(res.Allowlisted), res.Geo.City, res.Geo.Region, res.Geo.Country, res.Geo.CountryISO, strconv.Itoa(int(res.Geo.ASN)), res.Geo.ASNOrg}) + w.Flush() + case "tsv": + fmt.Fprintf(os.Stdout, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + res.IP, + strconv.FormatBool(res.Blocked), + strconv.FormatBool(res.BlockedInbound), + strconv.FormatBool(res.BlockedOutbound), + strconv.FormatBool(res.Allowlisted), + res.Geo.City, + res.Geo.Region, + res.Geo.Country, + res.Geo.CountryISO, + strconv.Itoa(int(res.Geo.ASN)), + res.Geo.ASNOrg, + ) + default: + cfg.writePretty(os.Stdout, res) + } + } + if cfg.Bind == "" { + return + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if asyncServe { + go loadBlocklists() + } + go blocklists.Tick(ctx, refreshInterval, func(err error) { + log.Printf("blocklists refresh: %v", err) + }) + go geoSet.Tick(ctx, refreshInterval, func(err error) { + log.Printf("geoip refresh: %v", err) + }) + if err := cfg.serve(ctx); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/cmd/check-ip/server.go b/cmd/check-ip/server.go new file mode 100644 index 0000000..d49803c --- /dev/null +++ b/cmd/check-ip/server.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/therootcompany/golib/net/geoip" + "github.com/therootcompany/golib/net/ipcohort" + "github.com/therootcompany/golib/sync/dataset" +) + +// Result is the JSON verdict for a single IP. +type Result struct { + IP string `json:"ip"` + Blocked bool `json:"blocked"` + BlockedInbound bool `json:"blocked_inbound"` + BlockedOutbound bool `json:"blocked_outbound"` + Allowlisted bool `json:"allowlisted,omitzero"` + Geo geoip.Info `json:"geo,omitzero"` +} + +// lookup builds a Result for ip against the currently loaded blocklists +// and GeoIP databases. +func (c *IPCheck) lookup(ip string) Result { + res := Result{IP: ip, Geo: c.geo.Value().Lookup(ip)} + addr, err := netip.ParseAddr(ip) + if err != nil { + res.Blocked = true + res.BlockedInbound = true + res.BlockedOutbound = true + return res + } + if cur := c.whitelist.Load(); cur != nil && cur.ContainsAddr(addr) { + res.Allowlisted = true + return res + } + res.BlockedInbound = c.inbound.Value().ContainsAddr(addr) + res.BlockedOutbound = c.outbound.Value().ContainsAddr(addr) + res.Blocked = res.BlockedInbound || res.BlockedOutbound + return res +} + +// writePretty renders res as space-aligned plain text. +func (c *IPCheck) writePretty(w io.Writer, res Result) { + switch { + case res.Allowlisted: + fmt.Fprintf(w, "%s is ALLOWED (whitelist)\n", res.IP) + case res.BlockedInbound && res.BlockedOutbound: + fmt.Fprintf(w, "%s is BLOCKED (inbound + outbound)\n", res.IP) + case res.BlockedInbound: + fmt.Fprintf(w, "%s is BLOCKED (inbound)\n", res.IP) + case res.BlockedOutbound: + fmt.Fprintf(w, "%s is BLOCKED (outbound)\n", res.IP) + default: + fmt.Fprintf(w, "%s is allowed\n", res.IP) + } + var parts []string + if res.Geo.City != "" { + parts = append(parts, res.Geo.City) + } + if res.Geo.Region != "" { + parts = append(parts, res.Geo.Region) + } + if res.Geo.Country != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", res.Geo.Country, res.Geo.CountryISO)) + } + if len(parts) > 0 { + fmt.Fprintf(w, " Location: %s\n", strings.Join(parts, ", ")) + } + if res.Geo.ASN != 0 { + fmt.Fprintf(w, " ASN: AS%d %s\n", res.Geo.ASN, res.Geo.ASNOrg) + } +} + +func (c *IPCheck) handle(w http.ResponseWriter, r *http.Request) { + ip := strings.TrimSpace(r.URL.Query().Get("ip")) + if ip == "" { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first, _, _ := strings.Cut(xff, ",") + ip = strings.TrimSpace(first) + } else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + ip = host + } else { + ip = r.RemoteAddr + } + } + res := c.lookup(ip) + + if r.URL.Query().Get("format") == "json" || + strings.Contains(r.Header.Get("Accept"), "application/json") { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(res) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + c.writePretty(w, res) +} + +type dsStatus struct { + Loaded bool `json:"loaded"` + Size int `json:"size,omitzero"` + LoadedAt time.Time `json:"loaded_at,omitzero"` +} + +// healthz reports per-dataset load state and an overall ready flag. +// Returns 200 when all required sets (inbound, outbound, geoip) are +// loaded, 503 while any are still empty. +func (c *IPCheck) healthz(w http.ResponseWriter, _ *http.Request) { + cohortStatus := func(v *dataset.View[ipcohort.Cohort]) dsStatus { + s := dsStatus{LoadedAt: v.LoadedAt()} + if cur := v.Value(); cur != nil { + s.Loaded, s.Size = true, cur.Size() + } + return s + } + + datasets := map[string]dsStatus{ + "inbound": cohortStatus(c.inbound), + "outbound": cohortStatus(c.outbound), + "geoip": { + Loaded: c.geo.Value() != nil, + LoadedAt: c.geo.LoadedAt(), + }, + } + if cur := c.whitelist.Load(); cur != nil { + datasets["whitelist"] = dsStatus{Loaded: true, Size: cur.Size()} + } + + ready := datasets["inbound"].Loaded && datasets["outbound"].Loaded && datasets["geoip"].Loaded + resp := struct { + Ready bool `json:"ready"` + Version string `json:"version"` + Datasets map[string]dsStatus `json:"datasets"` + }{ready, version, datasets} + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if !ready { + w.WriteHeader(http.StatusServiceUnavailable) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(resp) +} + +func (c *IPCheck) serve(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("GET /check", c.handle) + mux.HandleFunc("GET /healthz", c.healthz) + mux.HandleFunc("GET /{$}", c.handle) + + srv := &http.Server{ + Addr: c.Bind, + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { return ctx }, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 16, // 64KB + } + go func() { + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + }() + + log.Printf("listening on %s", c.Bind) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +}