diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..7f76155 --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,67 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + build-args: | + CONTENT_REPO=https://git.send.nrw/b1tsblog/blogcontent.git + CONTENT_REF=main + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} + - name: Build and push StarCitizen + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + build-args: | + CONTENT_REPO=https://git.send.nrw/b1tsblog/sccontent.git + CONTENT_REF=main + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:sc-${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ffc999 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# -------- Dockerfile (Multi-Stage Build) -------- +# 1. Builder-Stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/ipblock-api + +# 2. Runtime-Stage +FROM alpine:3.20 + +# HTTPS-Callouts in Alpine brauchen ca-certificates +RUN apk add --no-cache ca-certificates + +COPY --from=builder /bin/ipblock-api /bin/ipblock-api + +# Default listens on :8080 – siehe main.go +EXPOSE 8080 + +# Environment defaults; können per compose überschrieben werden +ENV REDIS_ADDR=redis:6379 \ + TTL_HOURS=720 + +ENTRYPOINT [\"/bin/ipblock-api\"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..70c791a --- /dev/null +++ b/compose.yml @@ -0,0 +1,30 @@ +# -------- docker-compose.yml -------- +version: "3.9" + +services: + api: + build: . + container_name: ipblock-api + depends_on: + - redis + environment: + # Beispiel – mehrere Listen in einer Kategorie „spam“ + BLOCKLIST_SOURCES: | + spam:https://ipv64.net/blocklists/ipv64_blocklist_firehole_l1.txt|https://rules.emergingthreats.net/blocklist/compromised-ips.txt + # Redis-Adresse schon per Docker-Netzwerk korrekt: + REDIS_ADDR: redis:6379 + TTL_HOURS: "720" + ports: + - "8080:8080" # : + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ipblock-redis + command: ["redis-server", "--save", "", "--appendonly", "no"] # reine In-Memory-Instanz + #volumes: + #- redis-data:/data # falls du doch Persistence willst + restart: unless-stopped + +#volumes: + #redis-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b66121 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.send.nrw/sendnrw/flod + +go 1.24.3 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/redis/go-redis/v9 v9.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d570ba --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= +github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5b20850 --- /dev/null +++ b/main.go @@ -0,0 +1,329 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/netip" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// ----------------------------------------------------------------------------- +// CONFIGURATION +// ----------------------------------------------------------------------------- + +type Source struct { + Category string // e.g. "spam", "tor", "malware" + URL []string // one or many URLs that belong to the same category +} + +type Config struct { + RedisAddr string + Sources []Source // each Source now groups URLs per category + TTLHours int // Redis TTL for block entries +} + +func loadConfig() Config { + // --- default one‑category fallback -------------------------------------- + srcs := []Source{{ + Category: "generic", + URL: []string{"https://ipv64.net/blocklists/ipv64_blocklist_firehole_l1.txt"}, + }} + + /* + ENV syntax supporting multiple URLs per category: + + BLOCKLIST_SOURCES="spam:https://a.net|https://b.net,tor:https://c.net;https://d.net" + + – categories separated by comma + – URLs inside category separated by | or ; + */ + + if env := os.Getenv("BLOCKLIST_SOURCES"); env != "" { + srcs = nil // override default + for _, spec := range strings.Split(env, ",") { + spec = strings.TrimSpace(spec) + if spec == "" { + continue + } + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 { + continue // malformed fragment + } + cat := strings.TrimSpace(parts[0]) + rawURLs := strings.FieldsFunc(parts[1], func(r rune) bool { return r == '|' || r == ';' }) + var urls []string + for _, u := range rawURLs { + if u = strings.TrimSpace(u); u != "" { + urls = append(urls, u) + } + } + if len(urls) > 0 { + srcs = append(srcs, Source{Category: cat, URL: urls}) + } + } + } + + ttl := 720 // 30 days + if env := os.Getenv("TTL_HOURS"); env != "" { + fmt.Sscanf(env, "%d", &ttl) + } + + return Config{ + RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), + Sources: srcs, + TTLHours: ttl, + } +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// ----------------------------------------------------------------------------- +// REDIS KEY HELPERS +// ----------------------------------------------------------------------------- + +func keyBlock(cat string, p netip.Prefix) string { return "bl:" + cat + ":" + p.String() } +func keyWhite(a netip.Addr) string { return "wl:" + a.String() } + +// ----------------------------------------------------------------------------- +// IN‑MEMORY RANGER – per‑category CIDR map +// ----------------------------------------------------------------------------- + +type Ranger struct { + mu sync.RWMutex + blocks map[string]map[netip.Prefix]struct{} // cat → set(prefix) + whites map[netip.Addr]struct{} +} + +func newRanger() *Ranger { + return &Ranger{ + blocks: make(map[string]map[netip.Prefix]struct{}), + whites: make(map[netip.Addr]struct{}), + } +} + +func (r *Ranger) resetBlocks(m map[string]map[netip.Prefix]struct{}) { + r.mu.Lock() + r.blocks = m + r.mu.Unlock() +} + +func (r *Ranger) addWhite(a netip.Addr) { + r.mu.Lock() + r.whites[a] = struct{}{} + r.mu.Unlock() +} + +// blockedInCats – returns slice of categories in which IP is blocked +func (r *Ranger) blockedInCats(a netip.Addr, cats []string) []string { + r.mu.RLock() + defer r.mu.RUnlock() + + if _, ok := r.whites[a]; ok { + return nil + } + + if len(cats) == 0 { + for c := range r.blocks { + cats = append(cats, c) + } + } + + var res []string + for _, cat := range cats { + if m, ok := r.blocks[cat]; ok { + for p := range m { + if p.Contains(a) { + res = append(res, cat) + break + } + } + } + } + sort.Strings(res) + return res +} + +// ----------------------------------------------------------------------------- +// SYNC WORKER – fetch lists → Redis + Ranger +// ----------------------------------------------------------------------------- + +func syncOnce(ctx context.Context, cfg Config, rdb *redis.Client, ranger *Ranger) error { + expiry := time.Duration(cfg.TTLHours) * time.Hour + newBlocks := make(map[string]map[netip.Prefix]struct{}) + + for _, src := range cfg.Sources { + for _, url := range src.URL { + if err := processURL(ctx, url, func(p netip.Prefix) { + if _, ok := newBlocks[src.Category]; !ok { + newBlocks[src.Category] = make(map[netip.Prefix]struct{}) + } + newBlocks[src.Category][p] = struct{}{} + _ = rdb.Set(ctx, keyBlock(src.Category, p), "1", expiry).Err() + }); err != nil { + return err + } + } + } + + ranger.resetBlocks(newBlocks) + return nil +} + +func processURL(ctx context.Context, url string, cb func(netip.Prefix)) error { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s -> %s", url, resp.Status) + } + return parseStream(resp.Body, cb) +} + +func parseStream(r io.Reader, cb func(netip.Prefix)) error { + s := bufio.NewScanner(r) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if p, err := netip.ParsePrefix(line); err == nil { + cb(p) + } + } + return s.Err() +} + +// ----------------------------------------------------------------------------- +// HTTP SERVER +// ----------------------------------------------------------------------------- + +type Server struct { + cfg Config + ranger *Ranger + rdb *redis.Client +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/check/", s.handleCheck) // GET /check/?cats=spam,tor + mux.HandleFunc("/whitelist", s.handleAddWhite) // POST {"ip":"1.2.3.4"} + mux.HandleFunc("/categories", s.handleCats) // GET all categories + return mux +} + +func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { + ipStr := strings.TrimPrefix(r.URL.Path, "/check/") + addr, err := netip.ParseAddr(ipStr) + if err != nil { + http.Error(w, "bad ip", http.StatusBadRequest) + return + } + + var cats []string + if q := strings.TrimSpace(r.URL.Query().Get("cats")); q != "" { + cats = strings.Split(q, ",") + } + + blocked := s.ranger.blockedInCats(addr, cats) + writeJSON(w, map[string]any{ + "ip": ipStr, + "blocked": len(blocked) > 0, + "categories": blocked, + }) +} + +func (s *Server) handleAddWhite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + IP string `json:"ip"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", 400) + return + } + addr, err := netip.ParseAddr(strings.TrimSpace(body.IP)) + if err != nil { + http.Error(w, "bad ip", 400) + return + } + if err := s.rdb.Set(r.Context(), keyWhite(addr), "1", 0).Err(); err != nil { + http.Error(w, "redis", 500) + return + } + s.ranger.addWhite(addr) + writeJSON(w, map[string]string{"status": "whitelisted"}) +} + +func (s *Server) handleCats(w http.ResponseWriter, _ *http.Request) { + s.ranger.mu.RLock() + cats := make([]string, 0, len(s.ranger.blocks)) + for c := range s.ranger.blocks { + cats = append(cats, c) + } + s.ranger.mu.RUnlock() + sort.Strings(cats) + writeJSON(w, map[string]any{"categories": cats}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// ----------------------------------------------------------------------------- +// MAIN +// ----------------------------------------------------------------------------- + +func main() { + cfg := loadConfig() + + rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr}) + ctx := context.Background() + if err := rdb.Ping(ctx).Err(); err != nil { + log.Fatalf("redis: %v", err) + } + + ranger := newRanger() + if err := syncOnce(ctx, cfg, rdb, ranger); err != nil { + log.Println("initial sync:", err) + } + + go func() { + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for range ticker.C { + if err := syncOnce(ctx, cfg, rdb, ranger); err != nil { + log.Println("sync:", err) + } + } + }() + + srv := &Server{cfg: cfg, ranger: ranger, rdb: rdb} + log.Println("listening on :8080") + if err := http.ListenAndServe(":8080", srv.routes()); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +}