7 Commits

Author SHA1 Message Date
e3ed622ade Add deploy notes and env explanation 2026-01-15 03:50:18 +01:00
1e7013269e Add example env and healthcheck 2026-01-15 03:47:06 +01:00
45fd4454fa Fix PeeringDB auth header and info_type 2026-01-15 01:27:03 +01:00
5870ab952f Merge technical README 2026-01-14 19:42:34 +01:00
54615ec19a Merge remote README and gitignore 2026-01-14 19:35:08 +01:00
e255600a93 Ignore .DS_Store 2026-01-14 19:28:43 +01:00
8d012b8085 Initial commit 2026-01-14 19:27:03 +01:00
7 changed files with 308 additions and 3 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
# ---> Go # ---> Go
# If you prefer the allow list template instead of the deny list, see community template: # 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 # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
@@ -24,4 +25,3 @@ go.work.sum
# env file # env file
.env .env

19
Dockerfile Normal file
View File

@@ -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

102
README.md
View File

@@ -1,4 +1,102 @@
# education-flagger # education-flagger
Dieses Repo nutzt MaxMind und damit indirekt die Datenbank von PeeringDB um HTTP Anfragen in kurzer Zeit mit Header zu versehen die Aufschluss darauf geben, ob sich der Client in einem Research and Education Netwerk (Eduroam) von DFN, GÉANT, SWITCH oder RENATER befindet. Forschung und Bildung sind die wichtigste Investition in die Zukunft, und der Zugang zu ihren Netzwerken verdient besondere Unterstützung.
Forschung und Bildung ist die wichtigste Investition in die Zukunft.
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.

13
entrypoint.sh Normal file
View File

@@ -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

19
example.env Normal file
View File

@@ -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

49
healthcheck.sh Normal file
View File

@@ -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

107
update.py Normal file
View File

@@ -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()