From: Mustafa Abdul-Kader Date: Wed, 7 Oct 2026 02:30:11 +0000 (-0500) Subject: geoauth: country forward-auth (RIR data), build wiring, go on the toolbox X-Git-Tag: v0.2.0~3 X-Git-Url: http://git.muszr.me/?a=commitdiff_plain;h=33a82bbb6ef310abda578e8d64b4698be6ff5f58;p=geoauth.git geoauth: country forward-auth (RIR data), build wiring, go on the toolbox Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KUijbArLRyCRTa8TZm4qsG --- 33a82bbb6ef310abda578e8d64b4698be6ff5f58 diff --git a/APKBUILD b/APKBUILD new file mode 100644 index 0000000..8c48f94 --- /dev/null +++ b/APKBUILD @@ -0,0 +1,25 @@ +# Maintainer: srv +# 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 index 0000000..33974c8 --- /dev/null +++ b/geoauth.apko.yaml @@ -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 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 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 index 0000000..33dc012 --- /dev/null +++ b/main_test.go @@ -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") + } +}