23 Commits

Author SHA1 Message Date
4d945aabab merge: apply domain lookup changes 2026-02-22 21:58:01 +01:00
ade401d9e6 test: cover lookup handler 2026-02-20 16:40:07 +01:00
adf290e4ac feat: add domain lookup endpoint 2026-02-20 15:53:55 +01:00
0072307bec Add Jenkins pipeline for image publish 2026-01-21 16:56:24 +01:00
d355e7e6a9 fix: handle MaxMind 429 and backoff 2026-01-19 19:52:28 +01:00
8131550603 chore: disable updater healthcheck 2026-01-19 19:47:07 +01:00
2e3500f00a fix: use updater healthcheck script in compose 2026-01-19 19:45:07 +01:00
eab112fa03 chore: add compose healthcheck for asn-updater 2026-01-19 19:41:39 +01:00
733bdd401a chore: simplify updater healthcheck to local data 2026-01-19 19:37:32 +01:00
9025ab694d feat: add healthcheck for asn-header 2026-01-19 19:28:45 +01:00
34e85bb6cd feat: healthcheck requires minimum ASN count 2026-01-19 19:24:17 +01:00
623c81d406 fix: fallback PeeringDB info_type when empty 2026-01-19 19:17:05 +01:00
92d7a19186 docs: add PDB_INFO_TYPE to env example 2026-01-19 19:14:27 +01:00
979e35d680 fix: ensure updater outputs are world-readable 2026-01-19 19:11:02 +01:00
2316af824d fix: write nren_asns in OUT_DIR 2026-01-19 19:03:30 +01:00
346e974be6 fix: add indirect deps to go.mod 2026-01-19 19:02:12 +01:00
d8a4316d32 fix: include go.sum in build 2026-01-19 18:59:25 +01:00
aa514869bc fix: add go.sum for module dependencies 2026-01-19 18:57:40 +01:00
5171dc7d99 feat: add Eduroam Analyzer service 2026-01-19 18:50:51 +01:00
d36a1e7655 Merge pull request 'test/run-scripts' (#1) from test/run-scripts into main
Reviewed-on: #1
2026-01-17 16:51:36 +00:00
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
22 changed files with 1132 additions and 61 deletions

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# 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
# PeeringDB info_type filter (should match PeeringDB values)
PDB_INFO_TYPE=Educational/Research
# minimum ASN entries for healthy /healthz
MIN_ASN_COUNT=10
# retry interval after a failed update (e.g., MaxMind 429)
RETRY_SECONDS=3600

26
.gitignore vendored
View File

@@ -1,27 +1 @@
.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 .env

View File

@@ -1,16 +1,17 @@
FROM python:3.12-alpine FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/asn-header-service
RUN apk add --no-cache ca-certificates tzdata curl tar && update-ca-certificates FROM alpine:3.20
RUN apk add --no-cache wget
RUN adduser -D -H -u 10001 app
USER 10001
WORKDIR /app WORKDIR /app
COPY --from=build /out/asn-header-service /app/asn-header-service
COPY update.py /app/update.py EXPOSE 8080
COPY entrypoint.sh /app/entrypoint.sh ENV ADDR=:8080
ENTRYPOINT ["/app/asn-header-service"]
RUN pip install --no-cache-dir requests==2.32.3 \
&& chmod +x /app/entrypoint.sh
ENV OUT_DIR=/data
VOLUME ["/data"]
ENTRYPOINT ["/app/entrypoint.sh"]

111
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,111 @@
pipeline {
agent any
environment {
GIT_URL = 'https://gitea.mindboost.team/mindboost/education-flagger.git'
GIT_BRANCH = 'pipeline/deploy-image'
REGISTRY_SCHEME = 'https'
REGISTRY_AUTHORITY = 'gitea.mindboost.team'
IMAGE_NAME = 'mindboost/education-flagger'
REGISTRY_CREDENTIALS_ID = 'REGISTRY_CREDENTIALS_ID'
}
stages {
stage('Checkout') {
steps {
script {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${env.GIT_BRANCH}"]],
userRemoteConfigs: [[
url: env.GIT_URL,
credentialsId: 'b5f383be-8c74-40f9-b7e1-3a9c5856df0e'
]]
])
}
}
}
stage('Check Repository') {
steps {
script {
sh 'pwd'
sh 'ls -la'
sh 'git status'
}
}
}
stage('Determine Version') {
steps {
script {
def fullHash = sh(
script: 'git rev-parse HEAD',
returnStdout: true
).trim()
env.IMAGE_TAG = "sha256-${fullHash}"
echo "Resolved image tag: ${env.IMAGE_TAG}"
}
}
}
stage('Check Docker Image with the same tag') {
steps {
script {
def imageExists = sh(
script: "docker images -q ${env.IMAGE_NAME}:${env.IMAGE_TAG} || true",
returnStdout: true
).trim()
if (imageExists) {
echo "Docker Image mit Tag ${env.IMAGE_TAG} existiert bereits. Überspringe Build."
currentBuild.result = 'SUCCESS'
return
} else {
echo "Kein vorhandenes Docker Image gefunden. Baue neues Image..."
}
}
}
}
stage('Build Docker Image') {
when {
expression { currentBuild.result == null }
}
steps {
script {
sh "docker build --rm -t ${env.IMAGE_NAME}:${env.IMAGE_TAG} ."
}
}
}
stage('Push Docker Image') {
when {
expression { currentBuild.result == null }
}
steps {
script {
withCredentials([usernamePassword(
credentialsId: env.REGISTRY_CREDENTIALS_ID,
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASS'
)]) {
def registryEndpoint = "${env.REGISTRY_SCHEME}://${env.REGISTRY_AUTHORITY}"
sh "echo '${REGISTRY_PASS}' | docker login ${env.REGISTRY_AUTHORITY} -u '${REGISTRY_USER}' --password-stdin"
sh "docker tag ${env.IMAGE_NAME}:${env.IMAGE_TAG} ${env.REGISTRY_AUTHORITY}/${env.IMAGE_NAME}:${env.IMAGE_TAG}"
sh "docker push ${env.REGISTRY_AUTHORITY}/${env.IMAGE_NAME}:${env.IMAGE_TAG}"
sh "docker logout ${env.REGISTRY_AUTHORITY}"
}
}
}
}
stage('Cleanup Docker Images') {
steps {
script {
sh 'set -eux; docker image prune -f; docker builder prune -f'
}
}
}
}
}

View File

@@ -1,11 +1,13 @@
# education-flagger # NREN / ASN Detection Service
Forschung und Bildung sind die wichtigste Investition in die Zukunft, und der Zugang zu ihren Netzwerken verdient besondere Unterstützung. Dieses Projekt stellt einen **minimalen Microservice** bereit, um **Hochschul- und Forschungsnetzwerke (NRENs)** anhand der **Autonomous System Number (ASN)** zu erkennen.
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. 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**. Das System dient ausschließlich der **Netzwerk-Klassifikation** und **ersetzt keine Authentifizierung**.
---
## Ziel ## Ziel
- Erkennen, ob eine Anfrage aus einem **Hochschul- oder Forschungsnetz** stammt - Erkennen, ob eine Anfrage aus einem **Hochschul- oder Forschungsnetz** stammt
@@ -15,14 +17,16 @@ Das System dient ausschließlich der **Netzwerk-Klassifikation** und **ersetzt k
- angepasste UI-Hinweise - angepasste UI-Hinweise
- alternative Rate-Limits - alternative Rate-Limits
---
## Funktionsweise (Kurzfassung) ## Funktionsweise (Kurzfassung)
``` ```
Client Client
-> Traefik Traefik
-> ForwardAuth ForwardAuth
-> ASN Detection Service ASN Detection Service
-> Header wird ergänzt Header wird ergänzt
``` ```
1. Die Client-IP wird ermittelt 1. Die Client-IP wird ermittelt
@@ -30,6 +34,8 @@ Client
3. Die ASN wird mit einer NREN-ASN-Liste verglichen 3. Die ASN wird mit einer NREN-ASN-Liste verglichen
4. Das Ergebnis wird als HTTP-Header zurückgegeben 4. Das Ergebnis wird als HTTP-Header zurückgegeben
---
## Datenquellen ## Datenquellen
- **GeoLite2 ASN (MaxMind)** - **GeoLite2 ASN (MaxMind)**
@@ -42,6 +48,8 @@ Client
- Kategorie: `Research and Education` - Kategorie: `Research and Education`
- monatliche Aktualisierung - monatliche Aktualisierung
---
## Bereitgestellte Header ## Bereitgestellte Header
| Header | Beschreibung | | Header | Beschreibung |
@@ -50,6 +58,30 @@ Client
| `X-ASN-ORG` | Organisation (optional) | | `X-ASN-ORG` | Organisation (optional) |
| `X-NREN` | `1` wenn ASN zu einem Hochschul-/Forschungsnetz gehört, sonst `0` | | `X-NREN` | `1` wenn ASN zu einem Hochschul-/Forschungsnetz gehört, sonst `0` |
---
## Domain-Lookup (optional)
Für die Validierung von Institutions-Domains kann ein Lookup genutzt werden:
```
GET /lookup?domain=uni-stuttgart.de
```
Antwort (JSON):
```json
{
"domain": "uni-stuttgart.de",
"nren": true,
"asn": 12345,
"asn_org": "Universitaet Stuttgart",
"ips": ["129.69.1.1"],
"matched_ip": "129.69.1.1"
}
```
---
## Integration ## Integration
Der Service wird als **Traefik ForwardAuth Middleware** eingebunden. Der Service wird als **Traefik ForwardAuth Middleware** eingebunden.
@@ -57,29 +89,30 @@ Die Header werden über `authResponseHeaders` an die eigentliche Anwendung weite
Der Service ist **nicht öffentlich exponiert** und kommuniziert ausschließlich über das interne Docker-Netzwerk. 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.
## Update-Strategie ## Update-Strategie
- monatliche Aktualisierung der ASN-Daten - monatliche Aktualisierung der ASN-Daten
- keine externen Requests während der Anfrageverarbeitung - keine externen Requests während der Anfrageverarbeitung
---
## Healthcheck
- `GET /healthz` liefert `200`, wenn mindestens `MIN_ASN_COUNT` ASNs geladen sind
- Standard: `MIN_ASN_COUNT=10` (konfigurierbar via Env)
---
## Einschränkungen ## Einschränkungen
- Die Erkennung ist **heuristisch** - Die Erkennung ist **heuristisch**
- Es gibt **keine Garantie**, dass jede Anfrage aus einem Hochschulnetz erkannt wird - Es gibt **keine Garantie**, dass jede Anfrage aus einem Hochschulnetz erkannt wird
- Die Information darf **nicht als Authentifizierungsmerkmal** verwendet werden - Die Information darf **nicht als Authentifizierungsmerkmal** verwendet werden
---
## Zusammenfassung ## 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. 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.

118
README_technical.md Normal file
View File

@@ -0,0 +1,118 @@
# 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` |
---
## Domain-Lookup (optional)
Für Backend-Validierung von Institutions-Domains:
```
GET /lookup?domain=uni-stuttgart.de
```
Antwort (JSON):
```json
{
"domain": "uni-stuttgart.de",
"nren": true,
"asn": 12345,
"asn_org": "Universitaet Stuttgart",
"ips": ["129.69.1.1"],
"matched_ip": "129.69.1.1"
}
```
---
## 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
---
## Healthcheck
- `GET /healthz` liefert `200`, wenn mindestens `MIN_ASN_COUNT` ASNs geladen sind
- Standard: `MIN_ASN_COUNT=10` (konfigurierbar via Env)
---
## 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.

27
asn-updater/.gitignore vendored Normal file
View File

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

19
asn-updater/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

18
asn-updater/LICENSE Normal file
View File

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

102
asn-updater/README.md Normal file
View File

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

17
asn-updater/entrypoint.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
INTERVAL_SECONDS="${INTERVAL_SECONDS:-2592000}"
RETRY_SECONDS="${RETRY_SECONDS:-3600}"
echo "[start] updater interval=${INTERVAL_SECONDS}s out_dir=${OUT_DIR:-/data}"
while true; do
echo "[run] update now"
if python /app/update.py; then
echo "[sleep] ${INTERVAL_SECONDS}s"
sleep "${INTERVAL_SECONDS}"
else
echo "[warn] update failed; retry in ${RETRY_SECONDS}s"
sleep "${RETRY_SECONDS}"
fi
done

19
asn-updater/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

View File

@@ -0,0 +1,23 @@
#!/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 [ ! -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
exit 0

133
asn-updater/update.py Normal file
View File

@@ -0,0 +1,133 @@
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)
os.chmod(dst_path, 0o644)
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)
if r.status_code == 429:
existing = os.path.join(OUT_DIR, "GeoLite2-ASN.mmdb")
if os.path.exists(existing):
print("[warn] MaxMind rate limited (429); keeping existing mmdb")
return
raise RuntimeError("MaxMind rate limited (429) and no existing mmdb")
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, info_type: str):
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() -> str:
info_types = [INFO_TYPE]
# Alternate label seen in PeeringDB deployments.
if INFO_TYPE != "Research and Education":
info_types.append("Research and Education")
if INFO_TYPE != "Educational/Research":
info_types.append("Educational/Research")
asns = set()
used_info_type = INFO_TYPE
for info_type in info_types:
asns.clear()
skip = 0
while True:
data = fetch_pdb_page(skip, info_type)
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
if asns:
used_info_type = info_type
break
if not asns:
print(f"[warn] no ASNs found for info_type(s)={info_types}")
out_txt = os.path.join(OUT_DIR, "nren_asns.txt")
with tempfile.NamedTemporaryFile("w", delete=False, dir=OUT_DIR) as f:
for a in sorted(asns):
f.write(f"{a}\n")
tmp_path = f.name
os.replace(tmp_path, out_txt)
os.chmod(out_txt, 0o644)
return used_info_type
def write_meta(info_type: str):
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)
os.chmod(os.path.join(OUT_DIR, "metadata.json"), 0o644)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
download_maxmind_mmdb()
used_info_type = update_nren_asns()
write_meta(used_info_type)
print("[ok] updated mmdb + nren_asns")
if __name__ == "__main__":
main()

44
docker-compose.yml Normal file
View File

@@ -0,0 +1,44 @@
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
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
- proxy
asn-updater:
build: ./asn-updater
container_name: asn-updater
restart: unless-stopped
env_file: .env
healthcheck:
disable: true
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:

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

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module asn-header-service
go 1.22
require github.com/oschwald/maxminddb-golang v1.13.1
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/sys v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

274
main.go Normal file
View File

@@ -0,0 +1,274 @@
package main
import (
"bufio"
"encoding/json"
"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 lookupResponse struct {
Domain string `json:"domain"`
NREN bool `json:"nren"`
ASN *uint `json:"asn,omitempty"`
ASNOrg string `json:"asn_org,omitempty"`
IPs []string `json:"ips"`
MatchedIP string `json:"matched_ip,omitempty"`
Error string `json:"error,omitempty"`
}
type server struct {
db *maxminddb.Reader
nrenASNs map[uint]struct{}
ready atomic.Bool
versionTag string
minASN int
asnCount int
}
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 writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
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 (s *server) lookupHandler(w http.ResponseWriter, r *http.Request) {
if !s.ready.Load() {
writeJSON(w, http.StatusServiceUnavailable, lookupResponse{
NREN: false,
Error: "service not ready",
})
return
}
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
if domain == "" {
writeJSON(w, http.StatusBadRequest, lookupResponse{
NREN: false,
Error: "missing domain",
})
return
}
ips, err := net.LookupIP(domain)
if err != nil || len(ips) == 0 {
writeJSON(w, http.StatusOK, lookupResponse{
Domain: domain,
NREN: false,
Error: "domain lookup failed",
})
return
}
resp := lookupResponse{
Domain: domain,
NREN: false,
IPs: make([]string, 0, len(ips)),
}
var firstASN *uint
var firstOrg string
for _, ip := range ips {
ipStr := ip.String()
resp.IPs = append(resp.IPs, ipStr)
var rec asnRecord
if err := s.db.Lookup(ip, &rec); err != nil || rec.ASN == 0 {
continue
}
if firstASN == nil {
firstASN = new(uint)
*firstASN = rec.ASN
firstOrg = rec.Org
}
if _, ok := s.nrenASNs[rec.ASN]; ok {
asn := rec.ASN
resp.NREN = true
resp.ASN = &asn
resp.ASNOrg = rec.Org
resp.MatchedIP = ipStr
writeJSON(w, http.StatusOK, resp)
return
}
}
if firstASN != nil {
resp.ASN = firstASN
resp.ASNOrg = firstOrg
}
writeJSON(w, http.StatusOK, resp)
}
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")
minASN := getenvInt("MIN_ASN_COUNT", 10)
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)
}
asnCount := len(set)
s := &server{
db: db,
nrenASNs: set,
versionTag: version,
minASN: minASN,
asnCount: asnCount,
}
s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/auth", s.authHandler)
mux.HandleFunc("/lookup", s.lookupHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
if s.asnCount < s.minASN {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
}
log.Printf("listening on %s (asn_count=%d, min_asn=%d)", addr, asnCount, minASN)
log.Fatal(srv.ListenAndServe())
}
func getenv(k, def string) string {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
return v
}
func getenvInt(k string, def int) int {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
parsed, err := strconv.Atoi(v)
if err != nil {
return def
}
return parsed
}

46
main_test.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestLookupMissingDomain(t *testing.T) {
s := &server{
nrenASNs: make(map[uint]struct{}),
}
s.ready.Store(true)
req := httptest.NewRequest(http.MethodGet, "/lookup", nil)
rr := httptest.NewRecorder()
s.lookupHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "missing domain") {
t.Fatalf("expected error message in response")
}
}
func TestLookupServiceNotReady(t *testing.T) {
s := &server{
nrenASNs: make(map[uint]struct{}),
}
s.ready = atomic.Bool{}
s.ready.Store(false)
req := httptest.NewRequest(http.MethodGet, "/lookup?domain=example.com", nil)
rr := httptest.NewRecorder()
s.lookupHandler(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rr.Code)
}
}

View File

@@ -5,7 +5,7 @@ OUT_DIR = os.getenv("OUT_DIR", "/data")
LICENSE_KEY = os.getenv("MAXMIND_LICENSE_KEY", "").strip() LICENSE_KEY = os.getenv("MAXMIND_LICENSE_KEY", "").strip()
PDB_API_KEY = os.getenv("PDB_API_KEY", "").strip() PDB_API_KEY = os.getenv("PDB_API_KEY", "").strip()
PDB_BASE = os.getenv("PDB_BASE", "https://www.peeringdb.com") PDB_BASE = os.getenv("PDB_BASE", "https://www.peeringdb.com")
INFO_TYPE = os.getenv("PDB_INFO_TYPE", "Research and Education") INFO_TYPE = os.getenv("PDB_INFO_TYPE", "Educational/Research")
TIMEOUT = int(os.getenv("HTTP_TIMEOUT", "30")) TIMEOUT = int(os.getenv("HTTP_TIMEOUT", "30"))
LIMIT = int(os.getenv("PDB_LIMIT", "250")) LIMIT = int(os.getenv("PDB_LIMIT", "250"))
@@ -49,7 +49,7 @@ def pdb_headers():
if not PDB_API_KEY: if not PDB_API_KEY:
return {"Accept": "application/json"} return {"Accept": "application/json"}
# PeeringDB API Key (optional) # PeeringDB API Key (optional)
return {"Accept": "application/json", "Authorization": f"api-key {PDB_API_KEY}"} return {"Accept": "application/json", "Authorization": f"Api-Key {PDB_API_KEY}"}
def fetch_pdb_page(skip: int): def fetch_pdb_page(skip: int):
url = f"{PDB_BASE}/api/net" url = f"{PDB_BASE}/api/net"
@@ -105,4 +105,3 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()