From 5171dc7d997ea3264ad66cef1e194c2a279bea77 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Mon, 19 Jan 2026 18:50:51 +0100 Subject: [PATCH] feat: add Eduroam Analyzer service --- .env.example | 11 +++ .gitignore | 1 + Dockerfile | 16 ++++ README.md | 89 +++++++++++++++++++++ README_technical.md | 89 +++++++++++++++++++++ asn-updater/.gitignore | 27 +++++++ asn-updater/Dockerfile | 19 +++++ asn-updater/LICENSE | 18 +++++ asn-updater/README.md | 102 ++++++++++++++++++++++++ asn-updater/entrypoint.sh | 13 +++ asn-updater/example.env | 19 +++++ asn-updater/healthcheck.sh | 49 ++++++++++++ asn-updater/update.py | 107 +++++++++++++++++++++++++ docker-compose.yml | 36 +++++++++ go.mod | 6 ++ main.go | 158 +++++++++++++++++++++++++++++++++++++ 16 files changed, 760 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 README_technical.md create mode 100644 asn-updater/.gitignore create mode 100644 asn-updater/Dockerfile create mode 100644 asn-updater/LICENSE create mode 100644 asn-updater/README.md create mode 100644 asn-updater/entrypoint.sh create mode 100644 asn-updater/example.env create mode 100644 asn-updater/healthcheck.sh create mode 100644 asn-updater/update.py create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1221fb --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# MaxMind (create a free GeoLite2 license key in your MaxMind account) +MAXMIND_LICENSE_KEY=your_maxmind_license_key + +# PeeringDB (optional; reduces rate limits) +PDB_API_KEY=your_peeringdb_api_key + +# existing Traefik/proxy network name (must already exist) +PROXY_NETWORK=proxy + +# update interval in seconds (30 days) +UPDATE_INTERVAL_SECONDS=2592000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b239585 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/asn-header-service + +FROM alpine:3.20 +RUN adduser -D -H -u 10001 app +USER 10001 +WORKDIR /app +COPY --from=build /out/asn-header-service /app/asn-header-service +EXPOSE 8080 +ENV ADDR=:8080 +ENTRYPOINT ["/app/asn-header-service"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5112f55 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# NREN / ASN Detection Service + +Dieses Projekt stellt einen **minimalen Microservice** bereit, um **Hochschul- und Forschungsnetzwerke (NRENs)** anhand der **Autonomous System Number (ASN)** zu erkennen. + +Der Zweck ist es, **Anfragen aus Hochschulnetzen (z. B. eduroam)** zu identifizieren, um **Research-bezogene Services kostenlos oder bevorzugt bereitzustellen**. + +Das System dient ausschließlich der **Netzwerk-Klassifikation** und **ersetzt keine Authentifizierung**. + +--- + +## Ziel + +- Erkennen, ob eine Anfrage aus einem **Hochschul- oder Forschungsnetz** stammt +- Bereitstellung eines **Header-Hinweises** für nachgelagerte Services +- Grundlage für Entscheidungen wie: + - kostenfreie Research-Features + - angepasste UI-Hinweise + - alternative Rate-Limits + +--- + +## Funktionsweise (Kurzfassung) + +``` +Client + → Traefik + → ForwardAuth + → ASN Detection Service + → Header wird ergänzt +``` + +1. Die Client-IP wird ermittelt +2. Die zugehörige ASN wird lokal nachgeschlagen +3. Die ASN wird mit einer NREN-ASN-Liste verglichen +4. Das Ergebnis wird als HTTP-Header zurückgegeben + +--- + +## Datenquellen + +- **GeoLite2 ASN (MaxMind)** + - kostenlos + - lokal + - monatliche Aktualisierung + +- **NREN-ASN-Liste** + - abgeleitet aus PeeringDB + - Kategorie: `Research and Education` + - monatliche Aktualisierung + +--- + +## Bereitgestellte Header + +| Header | Beschreibung | +|------|-------------| +| `X-ASN` | ASN der Client-IP | +| `X-ASN-ORG` | Organisation (optional) | +| `X-NREN` | `1` wenn ASN zu einem Hochschul-/Forschungsnetz gehört, sonst `0` | + +--- + +## Integration + +Der Service wird als **Traefik ForwardAuth Middleware** eingebunden. +Die Header werden über `authResponseHeaders` an die eigentliche Anwendung weitergereicht. + +Der Service ist **nicht öffentlich exponiert** und kommuniziert ausschließlich über das interne Docker-Netzwerk. + +--- + +## Update-Strategie + +- monatliche Aktualisierung der ASN-Daten +- keine externen Requests während der Anfrageverarbeitung + +--- + +## Einschränkungen + +- Die Erkennung ist **heuristisch** +- Es gibt **keine Garantie**, dass jede Anfrage aus einem Hochschulnetz erkannt wird +- Die Information darf **nicht als Authentifizierungsmerkmal** verwendet werden + +--- + +## Zusammenfassung + +Dieses Projekt ermöglicht eine **performante, datenschutzfreundliche Erkennung von Hochschulnetzen**, um **Research-Angebote kontextabhängig bereitzustellen**, ohne Nutzer zu identifizieren oder externe Dienste zur Laufzeit zu kontaktieren. diff --git a/README_technical.md b/README_technical.md new file mode 100644 index 0000000..5112f55 --- /dev/null +++ b/README_technical.md @@ -0,0 +1,89 @@ +# NREN / ASN Detection Service + +Dieses Projekt stellt einen **minimalen Microservice** bereit, um **Hochschul- und Forschungsnetzwerke (NRENs)** anhand der **Autonomous System Number (ASN)** zu erkennen. + +Der Zweck ist es, **Anfragen aus Hochschulnetzen (z. B. eduroam)** zu identifizieren, um **Research-bezogene Services kostenlos oder bevorzugt bereitzustellen**. + +Das System dient ausschließlich der **Netzwerk-Klassifikation** und **ersetzt keine Authentifizierung**. + +--- + +## Ziel + +- Erkennen, ob eine Anfrage aus einem **Hochschul- oder Forschungsnetz** stammt +- Bereitstellung eines **Header-Hinweises** für nachgelagerte Services +- Grundlage für Entscheidungen wie: + - kostenfreie Research-Features + - angepasste UI-Hinweise + - alternative Rate-Limits + +--- + +## Funktionsweise (Kurzfassung) + +``` +Client + → Traefik + → ForwardAuth + → ASN Detection Service + → Header wird ergänzt +``` + +1. Die Client-IP wird ermittelt +2. Die zugehörige ASN wird lokal nachgeschlagen +3. Die ASN wird mit einer NREN-ASN-Liste verglichen +4. Das Ergebnis wird als HTTP-Header zurückgegeben + +--- + +## Datenquellen + +- **GeoLite2 ASN (MaxMind)** + - kostenlos + - lokal + - monatliche Aktualisierung + +- **NREN-ASN-Liste** + - abgeleitet aus PeeringDB + - Kategorie: `Research and Education` + - monatliche Aktualisierung + +--- + +## Bereitgestellte Header + +| Header | Beschreibung | +|------|-------------| +| `X-ASN` | ASN der Client-IP | +| `X-ASN-ORG` | Organisation (optional) | +| `X-NREN` | `1` wenn ASN zu einem Hochschul-/Forschungsnetz gehört, sonst `0` | + +--- + +## Integration + +Der Service wird als **Traefik ForwardAuth Middleware** eingebunden. +Die Header werden über `authResponseHeaders` an die eigentliche Anwendung weitergereicht. + +Der Service ist **nicht öffentlich exponiert** und kommuniziert ausschließlich über das interne Docker-Netzwerk. + +--- + +## Update-Strategie + +- monatliche Aktualisierung der ASN-Daten +- keine externen Requests während der Anfrageverarbeitung + +--- + +## Einschränkungen + +- Die Erkennung ist **heuristisch** +- Es gibt **keine Garantie**, dass jede Anfrage aus einem Hochschulnetz erkannt wird +- Die Information darf **nicht als Authentifizierungsmerkmal** verwendet werden + +--- + +## Zusammenfassung + +Dieses Projekt ermöglicht eine **performante, datenschutzfreundliche Erkennung von Hochschulnetzen**, um **Research-Angebote kontextabhängig bereitzustellen**, ohne Nutzer zu identifizieren oder externe Dienste zur Laufzeit zu kontaktieren. diff --git a/asn-updater/.gitignore b/asn-updater/.gitignore new file mode 100644 index 0000000..e658cae --- /dev/null +++ b/asn-updater/.gitignore @@ -0,0 +1,27 @@ +.DS_Store +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env diff --git a/asn-updater/Dockerfile b/asn-updater/Dockerfile new file mode 100644 index 0000000..9f3c6fc --- /dev/null +++ b/asn-updater/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-alpine + +RUN apk add --no-cache ca-certificates tzdata curl tar && update-ca-certificates +WORKDIR /app + +COPY update.py /app/update.py +COPY entrypoint.sh /app/entrypoint.sh +COPY healthcheck.sh /app/healthcheck.sh + +RUN pip install --no-cache-dir requests==2.32.3 \ + && chmod +x /app/entrypoint.sh /app/healthcheck.sh + +ENV OUT_DIR=/data +VOLUME ["/data"] + +ENTRYPOINT ["/app/entrypoint.sh"] + +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 \ + CMD /app/healthcheck.sh diff --git a/asn-updater/LICENSE b/asn-updater/LICENSE new file mode 100644 index 0000000..bc5612b --- /dev/null +++ b/asn-updater/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 mindboost + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/asn-updater/README.md b/asn-updater/README.md new file mode 100644 index 0000000..a38b00a --- /dev/null +++ b/asn-updater/README.md @@ -0,0 +1,102 @@ +# education-flagger + +Forschung und Bildung sind die wichtigste Investition in die Zukunft, und der Zugang zu ihren Netzwerken verdient besondere Unterstützung. + +Dieses Repo stellt einen **minimalen Microservice** bereit, um **Hochschul- und Forschungsnetzwerke (NRENs)** anhand der **Autonomous System Number (ASN)** zu erkennen. Ziel ist es, **Zugriff oder bevorzugte Behandlung** für Nutzer aus Research- und Education-Netzen zu ermöglichen, ohne personenbezogene Daten zu verarbeiten. + +Das System dient ausschließlich der **Netzwerk-Klassifikation** und **ersetzt keine Authentifizierung**. + +## Ziel + +- Erkennen, ob eine Anfrage aus einem **Hochschul- oder Forschungsnetz** stammt +- Bereitstellung eines **Header-Hinweises** für nachgelagerte Services +- Grundlage für Entscheidungen wie: + - kostenfreie Research-Features + - angepasste UI-Hinweise + - alternative Rate-Limits + +## Funktionsweise (Kurzfassung) + +``` +Client + -> Traefik + -> ForwardAuth + -> ASN Detection Service + -> Header wird ergänzt +``` + +1. Die Client-IP wird ermittelt +2. Die zugehörige ASN wird lokal nachgeschlagen +3. Die ASN wird mit einer NREN-ASN-Liste verglichen +4. Das Ergebnis wird als HTTP-Header zurückgegeben + +## Datenquellen + +- **GeoLite2 ASN (MaxMind)** + - kostenlos + - lokal + - monatliche Aktualisierung + +- **NREN-ASN-Liste** + - abgeleitet aus PeeringDB + - Kategorie: `Research and Education` + - monatliche Aktualisierung + +## Bereitgestellte Header + +| Header | Beschreibung | +|------|-------------| +| `X-ASN` | ASN der Client-IP | +| `X-ASN-ORG` | Organisation (optional) | +| `X-NREN` | `1` wenn ASN zu einem Hochschul-/Forschungsnetz gehört, sonst `0` | + +## Integration + +Der Service wird als **Traefik ForwardAuth Middleware** eingebunden. +Die Header werden über `authResponseHeaders` an die eigentliche Anwendung weitergereicht. + +Der Service ist **nicht öffentlich exponiert** und kommuniziert ausschließlich über das interne Docker-Netzwerk. + +Die dafür vorgesehenen Labels sind: + + # Middleware Definition (ForwardAuth -> asn-header) + - "traefik.http.middlewares.asn-enrich.forwardauth.address=http://asn-header:8080/auth" + - "traefik.http.middlewares.asn-enrich.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.asn-enrich.forwardauth.authResponseHeaders=X-ASN,X-ASN-ORG,X-NREN" + + # Middleware am Router aktivieren + - "traefik.http.routers.web.middlewares=asn-enrich@docker" + +Bitte füge diese zu dem Service hinzu, bei welchem man die gewünschten Header möchte. + +## Run/Deploy (kurz) + +1. `example.env` kopieren und als `.env` befüllen (mindestens `MAXMIND_LICENSE_KEY`). +2. Den Updater-Container starten und `OUT_DIR` als Volume mounten (z. B. `/data`). +3. Den ASN-Detection-Service so starten, dass er **denselben** `OUT_DIR` liest. +4. Traefik ForwardAuth aktivieren und `authResponseHeaders` durchreichen. +5. Nach dem ersten Update sollten `GeoLite2-ASN.mmdb` und `nren_asns.txt` im `OUT_DIR` liegen. + +## example.env (kurz erklärt) + +- `MAXMIND_LICENSE_KEY`: notwendig für den GeoLite2 Download. +- `PDB_API_KEY`: optional, reduziert PeeringDB Rate-Limits. +- `OUT_DIR`: gemeinsamer Datenpfad zwischen Updater und Detection-Service. +- `PDB_BASE`, `PDB_INFO_TYPE`, `PDB_LIMIT`: PeeringDB Filter. +- `HTTP_TIMEOUT`: Timeout pro HTTP-Request. +- `INTERVAL_SECONDS`: Update-Intervall (Standard 30 Tage). + +## Update-Strategie + +- monatliche Aktualisierung der ASN-Daten +- keine externen Requests während der Anfrageverarbeitung + +## Einschränkungen + +- Die Erkennung ist **heuristisch** +- Es gibt **keine Garantie**, dass jede Anfrage aus einem Hochschulnetz erkannt wird +- Die Information darf **nicht als Authentifizierungsmerkmal** verwendet werden + +## Zusammenfassung + +Dieses Projekt ermöglicht eine **performante, datenschutzfreundliche Erkennung von Hochschulnetzen**, um **Research-Angebote kontextabhängig bereitzustellen**, ohne Nutzer zu identifizieren oder externe Dienste zur Laufzeit zu kontaktieren. diff --git a/asn-updater/entrypoint.sh b/asn-updater/entrypoint.sh new file mode 100644 index 0000000..c4594bf --- /dev/null +++ b/asn-updater/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +INTERVAL_SECONDS="${INTERVAL_SECONDS:-2592000}" +echo "[start] updater interval=${INTERVAL_SECONDS}s out_dir=${OUT_DIR:-/data}" + +while true; do + echo "[run] update now" + python /app/update.py + echo "[sleep] ${INTERVAL_SECONDS}s" + sleep "${INTERVAL_SECONDS}" +done + diff --git a/asn-updater/example.env b/asn-updater/example.env new file mode 100644 index 0000000..866b001 --- /dev/null +++ b/asn-updater/example.env @@ -0,0 +1,19 @@ +# Required +MAXMIND_LICENSE_KEY= + +# Optional (helps with rate limits) +PDB_API_KEY= + +# Output data location shared with the detection service +OUT_DIR=/data + +# PeeringDB settings +PDB_BASE=https://www.peeringdb.com +PDB_INFO_TYPE=Educational/Research +PDB_LIMIT=250 + +# HTTP settings +HTTP_TIMEOUT=30 + +# Update interval (seconds, default 30 days) +INTERVAL_SECONDS=2592000 diff --git a/asn-updater/healthcheck.sh b/asn-updater/healthcheck.sh new file mode 100644 index 0000000..97a7601 --- /dev/null +++ b/asn-updater/healthcheck.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +OUT_DIR="${OUT_DIR:-/data}" +PDB_BASE="${PDB_BASE:-https://www.peeringdb.com}" +INFO_TYPE="${PDB_INFO_TYPE:-Educational/Research}" + +if [ -z "${MAXMIND_LICENSE_KEY:-}" ]; then + echo "[health] MAXMIND_LICENSE_KEY missing" >&2 + exit 1 +fi + +if [ ! -d "${OUT_DIR}" ]; then + echo "[health] OUT_DIR missing: ${OUT_DIR}" >&2 + exit 1 +fi + +if [ ! -s "${OUT_DIR}/GeoLite2-ASN.mmdb" ]; then + echo "[health] GeoLite2-ASN.mmdb missing in ${OUT_DIR}" >&2 + exit 1 +fi + +if [ ! -s "${OUT_DIR}/nren_asns.txt" ]; then + echo "[health] nren_asns.txt missing in ${OUT_DIR}" >&2 + exit 1 +fi + +mm_url="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" +mm_code="$(curl -fsS -o /dev/null -w "%{http_code}" "${mm_url}" || true)" +if [ "${mm_code}" != "200" ]; then + echo "[health] MaxMind download not accessible (status ${mm_code})" >&2 + exit 1 +fi + +pdb_code="000" +pdb_url="${PDB_BASE}/api/net" +pdb_args="--get --data-urlencode info_type=${INFO_TYPE} --data-urlencode limit=1 --data-urlencode skip=0 --data-urlencode fields=asn,status,info_type" +if [ -n "${PDB_API_KEY:-}" ]; then + pdb_code="$(curl -fsS -o /dev/null -w "%{http_code}" -H "Accept: application/json" -H "Authorization: Api-Key ${PDB_API_KEY}" ${pdb_args} "${pdb_url}" || true)" +else + pdb_code="$(curl -fsS -o /dev/null -w "%{http_code}" -H "Accept: application/json" ${pdb_args} "${pdb_url}" || true)" +fi + +if [ "${pdb_code}" != "200" ] && [ "${pdb_code}" != "429" ]; then + echo "[health] PeeringDB not accessible (status ${pdb_code})" >&2 + exit 1 +fi + +exit 0 diff --git a/asn-updater/update.py b/asn-updater/update.py new file mode 100644 index 0000000..c2fb954 --- /dev/null +++ b/asn-updater/update.py @@ -0,0 +1,107 @@ +import os, time, json, tarfile, tempfile, shutil +import requests + +OUT_DIR = os.getenv("OUT_DIR", "/data") +LICENSE_KEY = os.getenv("MAXMIND_LICENSE_KEY", "").strip() +PDB_API_KEY = os.getenv("PDB_API_KEY", "").strip() +PDB_BASE = os.getenv("PDB_BASE", "https://www.peeringdb.com") +INFO_TYPE = os.getenv("PDB_INFO_TYPE", "Educational/Research") +TIMEOUT = int(os.getenv("HTTP_TIMEOUT", "30")) +LIMIT = int(os.getenv("PDB_LIMIT", "250")) + +def atomic_replace(src_path: str, dst_path: str) -> None: + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + tmp = dst_path + ".tmp" + shutil.copyfile(src_path, tmp) + os.replace(tmp, dst_path) + +def download_maxmind_mmdb() -> None: + if not LICENSE_KEY: + raise RuntimeError("MAXMIND_LICENSE_KEY missing") + + # Offizieller GeoLite2 Download-Mechanismus per license_key + edition_id + url = ( + "https://download.maxmind.com/app/geoip_download" + f"?edition_id=GeoLite2-ASN&license_key={LICENSE_KEY}&suffix=tar.gz" + ) + + with tempfile.TemporaryDirectory() as td: + tgz = os.path.join(td, "GeoLite2-ASN.tar.gz") + r = requests.get(url, timeout=TIMEOUT) + r.raise_for_status() + with open(tgz, "wb") as f: + f.write(r.content) + + mmdb_found = None + with tarfile.open(tgz, "r:gz") as tar: + for member in tar.getmembers(): + if member.name.endswith("GeoLite2-ASN.mmdb"): + tar.extract(member, path=td) + mmdb_found = os.path.join(td, member.name) + break + + if not mmdb_found or not os.path.exists(mmdb_found): + raise RuntimeError("GeoLite2-ASN.mmdb not found in archive") + + atomic_replace(mmdb_found, os.path.join(OUT_DIR, "GeoLite2-ASN.mmdb")) + +def pdb_headers(): + if not PDB_API_KEY: + return {"Accept": "application/json"} + # PeeringDB API Key (optional) + return {"Accept": "application/json", "Authorization": f"Api-Key {PDB_API_KEY}"} + +def fetch_pdb_page(skip: int): + url = f"{PDB_BASE}/api/net" + params = { + "info_type": INFO_TYPE, + "limit": LIMIT, + "skip": skip, + "fields": "asn,status,info_type", + } + r = requests.get(url, params=params, headers=pdb_headers(), timeout=TIMEOUT) + r.raise_for_status() + j = r.json() + return j.get("data", []) + +def update_nren_asns() -> None: + asns = set() + skip = 0 + while True: + data = fetch_pdb_page(skip) + for obj in data: + if obj.get("status") != "ok": + continue + asn = obj.get("asn") + if isinstance(asn, int) and asn > 0: + asns.add(asn) + if len(data) < LIMIT: + break + skip += LIMIT + time.sleep(1.1) # sehr konservativ + + out_txt = os.path.join(OUT_DIR, "nren_asns.txt") + with tempfile.NamedTemporaryFile("w", delete=False) as f: + for a in sorted(asns): + f.write(f"{a}\n") + tmp_path = f.name + os.replace(tmp_path, out_txt) + +def write_meta(): + meta = { + "updated_at_unix": int(time.time()), + "info_type": INFO_TYPE, + "pdb_base": PDB_BASE, + } + with open(os.path.join(OUT_DIR, "metadata.json"), "w") as f: + json.dump(meta, f, indent=2) + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + download_maxmind_mmdb() + update_nren_asns() + write_meta() + print("[ok] updated mmdb + nren_asns") + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cdd6463 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + asn-header: + build: . + container_name: asn-header + restart: unless-stopped + env_file: .env + environment: + MMDB_PATH: /data/GeoLite2-ASN.mmdb + ASN_LIST_PATH: /data/nren_asns.txt + ADDR: ":8080" + volumes: + - asn_data:/data:ro + networks: + - proxy + + asn-updater: + build: ./asn-updater + container_name: asn-updater + restart: unless-stopped + env_file: .env + environment: + OUT_DIR: /data + PDB_INFO_TYPE: "Research and Education" + INTERVAL_SECONDS: "${UPDATE_INTERVAL_SECONDS}" + volumes: + - asn_data:/data + networks: + - proxy + +networks: + proxy: + external: true + name: ${PROXY_NETWORK} + +volumes: + asn_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..815e898 --- /dev/null +++ b/go.mod @@ -0,0 +1,6 @@ +module asn-header-service + +go 1.22 + +require github.com/oschwald/maxminddb-golang v1.13.1 + diff --git a/main.go b/main.go new file mode 100644 index 0000000..bc7485d --- /dev/null +++ b/main.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +type asnRecord struct { + ASN uint `maxminddb:"autonomous_system_number"` + Org string `maxminddb:"autonomous_system_organization"` +} + +type server struct { + db *maxminddb.Reader + nrenASNs map[uint]struct{} + ready atomic.Bool + versionTag string +} + +func loadASNSet(path string) (map[uint]struct{}, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + set := make(map[uint]struct{}, 4096) + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + v, err := strconv.ParseUint(line, 10, 32) + if err != nil { + continue + } + set[uint(v)] = struct{}{} + } + return set, sc.Err() +} + +func firstForwardedFor(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return "" + } + parts := strings.Split(xff, ",") + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[0]) +} + +func remoteIP(r *http.Request) string { + // Prefer XFF (because Traefik is proxy) + ip := firstForwardedFor(r) + if ip != "" { + return ip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func (s *server) authHandler(w http.ResponseWriter, r *http.Request) { + if !s.ready.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + ipStr := remoteIP(r) + parsed := net.ParseIP(ipStr) + if parsed == nil { + // Always 200: we enrich, not block + w.Header().Set("X-NREN", "0") + w.WriteHeader(http.StatusOK) + return + } + + var rec asnRecord + if err := s.db.Lookup(parsed, &rec); err != nil || rec.ASN == 0 { + w.Header().Set("X-NREN", "0") + w.WriteHeader(http.StatusOK) + return + } + + w.Header().Set("X-ASN", strconv.FormatUint(uint64(rec.ASN), 10)) + if rec.Org != "" { + // optional: keep it short; some org strings can be long + w.Header().Set("X-ASN-ORG", rec.Org) + } + + _, ok := s.nrenASNs[rec.ASN] + if ok { + w.Header().Set("X-NREN", "1") + } else { + w.Header().Set("X-NREN", "0") + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Service", s.versionTag) + w.WriteHeader(http.StatusOK) +} + +func main() { + mmdbPath := getenv("MMDB_PATH", "/data/GeoLite2-ASN.mmdb") + asnListPath := getenv("ASN_LIST_PATH", "/data/nren_asns.txt") + addr := getenv("ADDR", ":8080") + version := getenv("VERSION_TAG", "asn-header-service") + + db, err := maxminddb.Open(mmdbPath) + if err != nil { + log.Fatalf("failed to open mmdb: %v", err) + } + defer db.Close() + + set, err := loadASNSet(asnListPath) + if err != nil { + log.Fatalf("failed to load asn list: %v", err) + } + + s := &server{db: db, nrenASNs: set, versionTag: version} + s.ready.Store(true) + + mux := http.NewServeMux() + mux.HandleFunc("/auth", s.authHandler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 2 * time.Second, + } + + log.Printf("listening on %s (asn_count=%d)", addr, len(set)) + log.Fatal(srv.ListenAndServe()) +} + +func getenv(k, def string) string { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return def + } + return v +} +