All checks were successful
release-tag / release-image (push) Successful in 1m43s
326 lines
7.9 KiB
Go
326 lines
7.9 KiB
Go
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 belonging to this category
|
|
}
|
|
|
|
type Config struct {
|
|
RedisAddr string
|
|
Sources []Source // grouped by category
|
|
TTLHours int // TTL for block entries in Redis
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
// default single source
|
|
srcs := []Source{{
|
|
Category: "generic",
|
|
URL: []string{"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"},
|
|
}}
|
|
|
|
/*
|
|
ENV format supporting many URLs per category:
|
|
BLOCKLIST_SOURCES="spam:https://a.net|https://b.net,tor:https://c.net;https://d.net"
|
|
*/
|
|
if env := os.Getenv("BLOCKLIST_SOURCES"); env != "" {
|
|
srcs = nil
|
|
for _, spec := range strings.Split(env, ",") {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(spec, ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
cat := strings.TrimSpace(parts[0])
|
|
raw := strings.FieldsFunc(parts[1], func(r rune) bool { return r == '|' || r == ';' })
|
|
var urls []string
|
|
for _, u := range raw {
|
|
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", "redis: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
|
|
// -----------------------------------------------------------------------------
|
|
|
|
type Ranger struct {
|
|
mu sync.RWMutex
|
|
blocks map[string]map[netip.Prefix]struct{}
|
|
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()
|
|
}
|
|
|
|
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
|
|
// -----------------------------------------------------------------------------
|
|
|
|
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)
|
|
continue
|
|
}
|
|
if addr, err := netip.ParseAddr(line); err == nil {
|
|
plen := 32
|
|
if addr.Is6() {
|
|
plen = 128
|
|
}
|
|
cb(netip.PrefixFrom(addr, plen))
|
|
}
|
|
}
|
|
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)
|
|
mux.HandleFunc("/whitelist", s.handleAddWhite)
|
|
mux.HandleFunc("/categories", s.handleCats)
|
|
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", http.StatusBadRequest)
|
|
return
|
|
}
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(body.IP))
|
|
if err != nil {
|
|
http.Error(w, "bad ip", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.rdb.Set(r.Context(), keyWhite(addr), "1", 0).Err(); err != nil {
|
|
http.Error(w, "redis", http.StatusInternalServerError)
|
|
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(2 * 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)
|
|
}
|
|
}
|