--- /dev/null
+// 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
+}
--- /dev/null
+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")
+ }
+}