]> git.muszr.me - geoauth.git/commitdiff
geoauth: country forward-auth (RIR data), build wiring, go on the toolbox
authorMustafa Abdul-Kader <mustafa@muszr.me>
Wed, 7 Oct 2026 02:30:11 +0000 (21:30 -0500)
committerMustafa Abdul-Kader <mustafa@muszr.me>
Wed, 7 Oct 2026 02:30:11 +0000 (21:30 -0500)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUijbArLRyCRTa8TZm4qsG

APKBUILD [new file with mode: 0644]
geoauth.apko.yaml [new file with mode: 0644]
go.mod [new file with mode: 0644]
main.go [new file with mode: 0644]
main_test.go [new file with mode: 0644]

diff --git a/APKBUILD b/APKBUILD
new file mode 100644 (file)
index 0000000..8c48f94
--- /dev/null
+++ b/APKBUILD
@@ -0,0 +1,25 @@
+# Maintainer: srv <mustafa@muszr.me>
+# geoauth: Traefik forward-auth by country from the RIR allocation files (see main.go). Static Go binary, no dependencies.
+pkgname=geoauth
+pkgver=0.1.0
+pkgrel=0
+pkgdesc="Forward-auth that admits clients by country"
+arch="x86_64"
+license="MIT"
+makedepends="go"
+options="net"
+source="main.go main_test.go go.mod"
+builddir="$srcdir"
+
+build() {
+       CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o geoauth .
+}
+
+check() {
+       CGO_ENABLED=0 go test ./...
+}
+
+package() {
+       install -Dm755 geoauth "$pkgdir"/usr/bin/geoauth
+}
+sha512sums=""
diff --git a/geoauth.apko.yaml b/geoauth.apko.yaml
new file mode 100644 (file)
index 0000000..33974c8
--- /dev/null
@@ -0,0 +1,20 @@
+# geoauth: static Go binary; CA bundle for the HTTPS fetches. No shell, no package manager.
+contents:
+  repositories:
+    - https://dl-cdn.alpinelinux.org/alpine/edge/main
+    - https://dl-cdn.alpinelinux.org/alpine/edge/community
+  keyring:
+    - https://alpinelinux.org/keys/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub
+    - https://alpinelinux.org/keys/alpine-devel@lists.alpinelinux.org-61666e3f.rsa.pub
+  packages:
+    - ca-certificates-bundle
+    - geoauth                    # ours (images/geoauth/APKBUILD)
+accounts:
+  groups:
+    - {groupname: geoauth, gid: 65532}
+  users:
+    - {username: geoauth, uid: 65532, gid: 65532}
+  run-as: 65532
+entrypoint:
+  command: /usr/bin/geoauth
+archs: [x86_64]
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..5a0db01
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module geoauth
+
+go 1.22
diff --git a/main.go b/main.go
new file mode 100644 (file)
index 0000000..fb63d1a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,270 @@
+// geoauth: a Traefik forward-auth service that admits clients by country.
+//
+// Traefik asks it about every request (forwardAuth middleware) with the client address in X-Forwarded-For; it answers
+// 200 (admit) or 403. The country data is the Regional Internet Registries' public allocation files (ARIN, RIPE, APNIC,
+// LACNIC, AFRINIC): public domain, nothing is sent anywhere, no licence, no runtime plugin. Refreshed daily; the last good
+// copy is kept if a refresh fails. Private, loopback and link-local addresses (the cluster, the house, the VPN) and any
+// ALLOW_CIDRS are always admitted. Until the first successful load it answers 503 and admits no public address.
+package main
+
+import (
+       "bufio"
+       "fmt"
+       "io"
+       "log"
+       "net"
+       "net/http"
+       "net/netip"
+       "os"
+       "sort"
+       "strconv"
+       "strings"
+       "sync/atomic"
+       "time"
+)
+
+var defaultSources = []string{
+       "https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest",
+       "https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest",
+       "https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest",
+       "https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest",
+       "https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest",
+}
+
+type span struct{ lo, hi [2]uint64 } // inclusive, as (high, low) 64-bit halves; IPv4 lives in ::ffff:0:0/96
+
+type db struct {
+       spans  []span
+       loaded time.Time
+}
+
+var (
+       current                  atomic.Pointer[db]
+       allowed, denied          atomic.Uint64
+       extra                    []netip.Prefix
+       countries                map[string]bool
+)
+
+func key(a netip.Addr) [2]uint64 {
+       b := a.As16()
+       var k [2]uint64
+       for i := 0; i < 8; i++ {
+               k[0] = k[0]<<8 | uint64(b[i])
+               k[1] = k[1]<<8 | uint64(b[8+i])
+       }
+       return k
+}
+
+func less(a, b [2]uint64) bool { return a[0] < b[0] || (a[0] == b[0] && a[1] < b[1]) }
+
+func add(k [2]uint64, n uint64) [2]uint64 { // k + n, n small enough for 64-bit arithmetic on the low half
+       lo := k[1] + n
+       if lo < k[1] {
+               k[0]++
+       }
+       k[1] = lo
+       return k
+}
+
+// parse reads one delegation file and returns the spans of the wanted countries.
+func parse(r io.Reader, want map[string]bool) []span {
+       var out []span
+       sc := bufio.NewScanner(r)
+       sc.Buffer(make([]byte, 1<<20), 1<<20)
+       for sc.Scan() {
+               f := strings.Split(sc.Text(), "|")
+               if len(f) < 7 || !want[f[1]] || (f[6] != "allocated" && f[6] != "assigned") {
+                       continue
+               }
+               switch f[2] {
+               case "ipv4":
+                       a, err1 := netip.ParseAddr(f[3])
+                       n, err2 := strconv.ParseUint(f[4], 10, 64)
+                       if err1 != nil || err2 != nil || n == 0 {
+                               continue
+                       }
+                       lo := key(netip.AddrFrom16(a.As16()))
+                       out = append(out, span{lo, add(lo, n-1)})
+               case "ipv6":
+                       a, err1 := netip.ParseAddr(f[3])
+                       bits, err2 := strconv.Atoi(f[4])
+                       if err1 != nil || err2 != nil || bits < 0 || bits > 128 {
+                               continue
+                       }
+                       lo := key(a)
+                       hi := lo
+                       for i := 0; i < 128-bits; i++ { // set the host bits
+                               if i < 64 {
+                                       hi[1] |= 1 << uint(i)
+                               } else {
+                                       hi[0] |= 1 << uint(i-64)
+                               }
+                       }
+                       out = append(out, span{lo, hi})
+               }
+       }
+       return out
+}
+
+func merge(s []span) []span {
+       sort.Slice(s, func(i, j int) bool { return less(s[i].lo, s[j].lo) })
+       var out []span
+       for _, x := range s {
+               if n := len(out); n > 0 && !less(add(out[n-1].hi, 1), x.lo) { // overlaps or touches
+                       if less(out[n-1].hi, x.hi) {
+                               out[n-1].hi = x.hi
+                       }
+                       continue
+               }
+               out = append(out, x)
+       }
+       return out
+}
+
+func (d *db) contains(a netip.Addr) bool {
+       k := key(netip.AddrFrom16(a.As16()))
+       i := sort.Search(len(d.spans), func(i int) bool { return less(k, d.spans[i].lo) || k == d.spans[i].lo }) // first lo >= k
+       if i < len(d.spans) && d.spans[i].lo == k {
+               return true
+       }
+       return i > 0 && !less(d.spans[i-1].hi, k)
+}
+
+func fetch(url string) ([]span, error) {
+       c := http.Client{Timeout: 3 * time.Minute}
+       resp, err := c.Get(url)
+       if err != nil {
+               return nil, err
+       }
+       defer resp.Body.Close()
+       if resp.StatusCode != 200 {
+               return nil, fmt.Errorf("%s: %s", url, resp.Status)
+       }
+       return parse(resp.Body, countries), nil
+}
+
+func refresh(sources []string) {
+       var all []span
+       for _, u := range sources {
+               s, err := fetch(u)
+               if err != nil {
+                       log.Printf("refresh failed, keeping the previous data: %v", err)
+                       return
+               }
+               all = append(all, s...)
+       }
+       m := merge(all)
+       if len(m) == 0 {
+               log.Printf("refresh produced no ranges, keeping the previous data")
+               return
+       }
+       current.Store(&db{spans: m, loaded: time.Now()})
+       log.Printf("loaded %d ranges for %v", len(m), keys(countries))
+}
+
+func keys(m map[string]bool) []string {
+       var k []string
+       for x := range m {
+               k = append(k, x)
+       }
+       sort.Strings(k)
+       return k
+}
+
+func clientAddr(r *http.Request) (netip.Addr, bool) {
+       v := r.Header.Get("X-Forwarded-For")
+       if v != "" {
+               parts := strings.Split(v, ",")
+               v = strings.TrimSpace(parts[len(parts)-1]) // Traefik (no trusted proxies) sets this itself
+       } else if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+               v = h
+       }
+       a, err := netip.ParseAddr(v)
+       if err != nil {
+               return netip.Addr{}, false
+       }
+       return a.Unmap(), true
+}
+
+func admit(a netip.Addr) bool {
+       if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() {
+               return true
+       }
+       for _, p := range extra {
+               if p.Contains(a) {
+                       return true
+               }
+       }
+       d := current.Load()
+       return d != nil && d.contains(a)
+}
+
+func main() {
+       countries = map[string]bool{}
+       for _, c := range strings.Split(getenv("COUNTRIES", "US"), ",") {
+               countries[strings.ToUpper(strings.TrimSpace(c))] = true
+       }
+       for _, c := range strings.Split(getenv("ALLOW_CIDRS", ""), ",") {
+               if c = strings.TrimSpace(c); c != "" {
+                       p, err := netip.ParsePrefix(c)
+                       if err != nil {
+                               log.Fatalf("ALLOW_CIDRS: %v", err)
+                       }
+                       extra = append(extra, p)
+               }
+       }
+       sources := defaultSources
+       if s := os.Getenv("SOURCES"); s != "" {
+               sources = strings.Split(s, ",")
+       }
+       every, _ := time.ParseDuration(getenv("REFRESH", "24h"))
+       go func() {
+               for {
+                       refresh(sources)
+                       t := every
+                       if current.Load() == nil {
+                               t = time.Minute // keep trying until the first load works
+                       }
+                       time.Sleep(t)
+               }
+       }()
+
+       mux := http.NewServeMux()
+       ready := func(w http.ResponseWriter, _ *http.Request) {
+               if current.Load() == nil {
+                       http.Error(w, "no data yet", http.StatusServiceUnavailable)
+                       return
+               }
+               fmt.Fprintln(w, "ok")
+       }
+       mux.HandleFunc("/healthz", ready)
+       mux.HandleFunc("/readyz", ready)
+       mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
+               n, ts := 0, int64(0)
+               if d := current.Load(); d != nil {
+                       n, ts = len(d.spans), d.loaded.Unix()
+               }
+               fmt.Fprintf(w, "geoauth_allowed_total %d\ngeoauth_denied_total %d\ngeoauth_ranges %d\ngeoauth_last_refresh_timestamp_seconds %d\n", allowed.Load(), denied.Load(), n, ts)
+       })
+       mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+               a, ok := clientAddr(r)
+               if !ok || current.Load() == nil && !(a.IsPrivate() || a.IsLoopback()) || !admit(a) {
+                       denied.Add(1)
+                       log.Printf("deny %v", a)
+                       http.Error(w, "this service is not available in your region", http.StatusForbidden)
+                       return
+               }
+               allowed.Add(1)
+               w.WriteHeader(http.StatusOK)
+       })
+       addr := getenv("LISTEN", "[::]:8080")
+       log.Printf("geoauth listening on %s for %v", addr, keys(countries))
+       log.Fatal((&http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}).ListenAndServe())
+}
+
+func getenv(k, d string) string {
+       if v := os.Getenv(k); v != "" {
+               return v
+       }
+       return d
+}
diff --git a/main_test.go b/main_test.go
new file mode 100644 (file)
index 0000000..33dc012
--- /dev/null
@@ -0,0 +1,69 @@
+package main
+
+import (
+       "net/http/httptest"
+       "net/netip"
+       "strings"
+       "testing"
+)
+
+const sample = `2|arin|20260101|4|19700101|20260101|-0500
+arin|*|ipv4|*|2|summary
+arin|US|ipv4|8.8.8.0|256|20240101|allocated|x
+arin|US|ipv4|8.8.9.0|256|20240101|allocated|x
+arin|CA|ipv4|24.0.0.0|256|20240101|allocated|x
+arin|US|ipv4|9.0.0.0|256|20240101|reserved|x
+arin|US|ipv6|2001:4860::|32|20240101|allocated|x
+arin|US|ipv4|1.178.0.0|512|20250113|assigned|x
+`
+
+func load(t *testing.T) {
+       t.Helper()
+       countries = map[string]bool{"US": true}
+       current.Store(&db{spans: merge(parse(strings.NewReader(sample), countries))})
+}
+
+func TestLookup(t *testing.T) {
+       load(t)
+       d := current.Load()
+       if len(d.spans) != 3 { // 8.8.8.0/23 merged, 1.178.0.0/23, 2001:4860::/32
+               t.Fatalf("spans=%d", len(d.spans))
+       }
+       for ip, want := range map[string]bool{
+               "8.8.8.8": true, "8.8.9.255": true, "8.8.10.0": false, "8.8.7.255": false, "24.0.0.5": false, "9.0.0.1": false,
+               "1.178.1.255": true, "1.178.2.0": false, "2001:4860:4860::8888": true, "2001:4861::1": false, "::ffff:8.8.8.8": true,
+       } {
+               if got := d.contains(netip.MustParseAddr(ip).Unmap()); got != want {
+                       t.Errorf("%s: got %v want %v", ip, got, want)
+               }
+       }
+}
+
+func TestAdmit(t *testing.T) {
+       load(t)
+       extra = []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}
+       for ip, want := range map[string]bool{"10.42.0.7": true, "fd6a:73fa:3511::1": true, "127.0.0.1": true, "203.0.113.9": true, "8.8.4.4": false, "8.8.8.8": true} {
+               if got := admit(netip.MustParseAddr(ip)); got != want {
+                       t.Errorf("admit %s: got %v want %v", ip, got, want)
+               }
+       }
+}
+
+func TestHandler(t *testing.T) {
+       load(t)
+       extra = nil
+       h := func(xff string) int {
+               r := httptest.NewRequest("GET", "/", nil)
+               if xff != "" {
+                       r.Header.Set("X-Forwarded-For", xff)
+               }
+               a, ok := clientAddr(r)
+               if !ok || !admit(a) {
+                       return 403
+               }
+               return 200
+       }
+       if h("8.8.8.8") != 200 || h("24.0.0.5") != 403 || h("not-an-ip") != 403 || h("") != 403 || h("1.1.1.1, 8.8.8.8") != 200 {
+               t.Fatal("handler decisions wrong")
+       }
+}