2 Commits

Author SHA1 Message Date
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
4 changed files with 179 additions and 0 deletions

View File

@@ -60,6 +60,28 @@ Client
---
## 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
Der Service wird als **Traefik ForwardAuth Middleware** eingebunden.

View File

@@ -60,6 +60,28 @@ Client
---
## 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.

89
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"bufio"
"encoding/json"
"log"
"net"
"net/http"
@@ -19,6 +20,16 @@ type asnRecord struct {
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{}
@@ -76,6 +87,12 @@ func remoteIP(r *http.Request) string {
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)
@@ -116,6 +133,77 @@ func (s *server) authHandler(w http.ResponseWriter, r *http.Request) {
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")
@@ -146,6 +234,7 @@ func main() {
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)

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)
}
}