init
All checks were successful
release-tag / release-image (push) Successful in 3m42s

This commit is contained in:
2025-11-08 20:08:58 +01:00
parent 08421c93dc
commit a2d099aef5
4 changed files with 595 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
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
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 }}

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM golang:1.25
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /goprg
EXPOSE 8080
CMD ["/goprg"]

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.send.nrw/sendnrw/traefik-ipblock
go 1.25.3

533
main.go Normal file
View File

@@ -0,0 +1,533 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// --- IP matcher -------------------------------------------------------------
type ipSet struct {
singles map[netip.Addr]struct{}
prefixes []netip.Prefix
}
func newIPSet() *ipSet { return &ipSet{singles: make(map[netip.Addr]struct{})} }
func parseIPOrCIDR(s string) (addr *netip.Addr, pfx *netip.Prefix, err error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil, errors.New("empty entry")
}
if strings.Contains(s, "/") {
pr, err := netip.ParsePrefix(s)
if err != nil {
return nil, nil, fmt.Errorf("invalid CIDR: %w", err)
}
pr = pr.Masked()
return nil, &pr, nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return nil, nil, fmt.Errorf("invalid IP: %w", err)
}
a = a.Unmap()
return &a, nil, nil
}
func (s *ipSet) add(entry string) (string, error) {
if addr, pfx, err := parseIPOrCIDR(entry); err != nil {
return "", err
} else if addr != nil {
s.singles[*addr] = struct{}{}
return addr.String(), nil
} else {
s.prefixes = append(s.prefixes, *pfx)
return pfx.String(), nil
}
}
func (s *ipSet) remove(entry string) bool {
if addr, pfx, err := parseIPOrCIDR(entry); err == nil {
if addr != nil {
if _, ok := s.singles[*addr]; ok {
delete(s.singles, *addr)
return true
}
return false
}
// remove matching prefix
norm := pfx.String()
for i, pr := range s.prefixes {
if pr.String() == norm {
s.prefixes = append(s.prefixes[:i], s.prefixes[i+1:]...)
return true
}
}
}
return false
}
func (s *ipSet) contains(a netip.Addr) bool {
a = a.Unmap()
if _, ok := s.singles[a]; ok {
return true
}
for _, p := range s.prefixes {
if p.Contains(a) {
return true
}
}
return false
}
// --- State & persistence ----------------------------------------------------
type Mode string
const (
ModeBlock Mode = "block" // allow unless in block list
ModeAllow Mode = "allow" // deny unless in allow list
)
type stateFile struct {
Mode Mode `json:"mode"`
Block []string `json:"block"`
Allow []string `json:"allow"`
}
type state struct {
mu sync.RWMutex
mode Mode
block *ipSet
allow *ipSet
path string
}
func newState(path string) *state {
return &state{mode: ModeBlock, block: newIPSet(), allow: newIPSet(), path: path}
}
func (s *state) load() error {
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var sf stateFile
if err := json.Unmarshal(b, &sf); err != nil {
return err
}
if sf.Mode != "" {
s.mode = sf.Mode
}
for _, e := range sf.Block {
_, _ = s.block.add(e)
}
for _, e := range sf.Allow {
_, _ = s.allow.add(e)
}
return nil
}
func (s *state) save() error {
s.mu.RLock()
defer s.mu.RUnlock()
// rebuild normalized lists
block := make([]string, 0, len(s.block.singles)+len(s.block.prefixes))
for a := range s.block.singles {
block = append(block, a.String())
}
for _, p := range s.block.prefixes {
block = append(block, p.String())
}
allow := make([]string, 0, len(s.allow.singles)+len(s.allow.prefixes))
for a := range s.allow.singles {
allow = append(allow, a.String())
}
for _, p := range s.allow.prefixes {
allow = append(allow, p.String())
}
out := stateFile{Mode: s.mode, Block: block, Allow: allow}
data, _ := json.MarshalIndent(out, "", " ")
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// --- Auth logic -------------------------------------------------------------
func firstIPFromXFF(xff string) (netip.Addr, bool) {
// XFF: client, proxy1, proxy2, ... -> take the FIRST as original client
parts := strings.Split(xff, ",")
if len(parts) == 0 {
return netip.Addr{}, false
}
s := strings.TrimSpace(parts[0])
if s == "" {
return netip.Addr{}, false
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return a.Unmap(), true
}
func extractClientIP(r *http.Request) (netip.Addr, error) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if a, ok := firstIPFromXFF(xff); ok {
return a, nil
}
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.Unmap(), nil
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
a, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}, fmt.Errorf("cannot parse client IP: %w", err)
}
return a.Unmap(), nil
}
func decide(st *state, ip netip.Addr) (allowed bool) {
st.mu.RLock()
defer st.mu.RUnlock()
switch st.mode {
case ModeBlock:
return !st.block.contains(ip)
case ModeAllow:
return st.allow.contains(ip)
default:
return true
}
}
// --- Admin auth -------------------------------------------------------------
func basicAuthOK(r *http.Request, user, pass string) bool {
u, p, ok := r.BasicAuth()
if !ok {
return false
}
// constant-time compare
if subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 {
return false
}
if subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
return false
}
return true
}
func requireAdmin(next http.Handler, enabled bool, user, pass string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !enabled {
http.Error(w, "admin disabled: set ADMIN_USER and ADMIN_PASS", http.StatusServiceUnavailable)
return
}
if !basicAuthOK(r, user, pass) {
w.Header().Set("WWW-Authenticate", `Basic realm="ipfilter-admin"`)
http.Error(w, "auth required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// --- HTTP handlers ----------------------------------------------------------
func authHandler(st *state) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, err := extractClientIP(r)
if err != nil {
http.Error(w, "cannot determine client IP", http.StatusForbidden)
return
}
if decide(st, ip) {
// optional: reflect info for debugging
w.Header().Set("X-IPFilter-Client", ip.String())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
return
}
w.Header().Set("X-IPFilter-Client", ip.String())
http.Error(w, "forbidden by ip filter", http.StatusForbidden)
})
}
func adminPage() string {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Traefik IP Filter Admin</title>
<style>
:root{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu;}
body{max-width:900px;margin:40px auto;padding:0 16px}
header{display:flex;align-items:center;gap:12px}
.card{border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin:12px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}
code,kbd{background:#f3f4f6;padding:2px 6px;border-radius:6px}
input,select,button{padding:8px 10px;border-radius:10px;border:1px solid #e5e7eb}
button{cursor:pointer}
.list{display:flex;gap:24px}
.list ul{flex:1;list-style:none;padding-left:0}
li{display:flex;justify-content:space-between;gap:8px;padding:4px 0}
small{color:#6b7280}
</style></head>
<body>
<header><h1>Traefik IP Filter</h1><span class="pill" id="modepill"></span></header>
<p>Manage block/allow lists used by Traefik <code>ForwardAuth</code>. The auth endpoint is <code>/auth</code>. This UI requires HTTP Basic Auth.</p>
<div class="card">
<label for="mode">Mode:</label>
<select id="mode">
<option value="block">Blocklist (default)</option>
<option value="allow">Allowlist</option>
</select>
<button id="savemode">Save</button>
<span id="status"></span>
</div>
<div class="card">
<div class="list">
<div>
<h3>Blocklist</h3>
<form id="faddb"><input id="inpb" placeholder="IP or CIDR (e.g. 1.2.3.4 or 10.0.0.0/8)"><button>Add</button></form>
<ul id="ulb"></ul>
</div>
<div>
<h3>Allowlist</h3>
<form id="fadda"><input id="inpa" placeholder="IP or CIDR (e.g. 2001:db8::/32)"><button>Add</button></form>
<ul id="ula"></ul>
</div>
</div>
<small>Tip: entries are normalized (IPs un-mapped, CIDRs masked). Changes persist to the state file.</small>
</div>
<script>
async function getState(){ const r=await fetch('/api/state'); if(!r.ok) throw new Error('state'); return r.json(); }
function li(entry, kind){ const li=document.createElement('li'); li.innerHTML='<span>'+entry+'</span><span><button data-k="'+kind+'" data-e="'+entry+'">Delete</button></span>'; return li; }
async function refresh(){ const s=await getState(); document.getElementById('mode').value=s.mode; const ulb=document.getElementById('ulb'); ulb.innerHTML=''; s.block.forEach(e=>ulb.appendChild(li(e,'block'))); const ula=document.getElementById('ula'); ula.innerHTML=''; s.allow.forEach(e=>ula.appendChild(li(e,'allow'))); }
async function post(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); if(!r.ok){ const t=await r.text(); throw new Error(t); } }
async function del(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); if(!r.ok){ const t=await r.text(); throw new Error(t); } }
window.addEventListener('click', async (e)=>{ const t=e.target; if(t.tagName==='BUTTON' && t.dataset.k){ try{ await del('/api/'+t.dataset.k+'/delete',{entry:t.dataset.e}); refresh(); }catch(err){ alert(err); } } });
document.getElementById('savemode').onclick=async()=>{ try{ await post('/api/mode',{mode:document.getElementById('mode').value}); refresh(); }catch(err){ alert(err); } };
document.getElementById('faddb').onsubmit=async(e)=>{ e.preventDefault(); try{ await post('/api/block/add',{entry:document.getElementById('inpb').value}); document.getElementById('inpb').value=''; refresh(); }catch(err){ alert(err);} };
document.getElementById('fadda').onsubmit=async(e)=>{ e.preventDefault(); try{ await post('/api/allow/add',{entry:document.getElementById('inpa').value}); document.getElementById('inpa').value=''; refresh(); }catch(err){ alert(err);} };
refresh();
</script>
</body></html>`
}
func adminHandler(st *state, adminEnabled bool, adminUser, adminPass string) http.Handler {
mux := http.NewServeMux()
// UI
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, adminPage())
})
// API
mux.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
st.mu.RLock()
defer st.mu.RUnlock()
resp := struct {
Mode Mode `json:"mode"`
Block []string `json:"block"`
Allow []string `json:"allow"`
}{}
resp.Mode = st.mode
// lists
for a := range st.block.singles {
resp.Block = append(resp.Block, a.String())
}
for _, p := range st.block.prefixes {
resp.Block = append(resp.Block, p.String())
}
for a := range st.allow.singles {
resp.Allow = append(resp.Allow, a.String())
}
for _, p := range st.allow.prefixes {
resp.Allow = append(resp.Allow, p.String())
}
b, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.Write(b)
})
mux.HandleFunc("/api/mode", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Mode Mode `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if req.Mode != ModeBlock && req.Mode != ModeAllow {
http.Error(w, "mode must be 'block' or 'allow'", http.StatusBadRequest)
return
}
st.mu.Lock()
st.mode = req.Mode
st.mu.Unlock()
_ = st.save()
w.WriteHeader(http.StatusNoContent)
})
type entryReq struct {
Entry string `json:"entry"`
}
mux.HandleFunc("/api/block/add", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req entryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
st.mu.Lock()
if norm, err := st.block.add(req.Entry); err != nil {
st.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else {
_ = norm
}
st.mu.Unlock()
_ = st.save()
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/block/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req entryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
st.mu.Lock()
_ = st.block.remove(req.Entry)
st.mu.Unlock()
_ = st.save()
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/allow/add", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req entryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
st.mu.Lock()
if norm, err := st.allow.add(req.Entry); err != nil {
st.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else {
_ = norm
}
st.mu.Unlock()
_ = st.save()
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/allow/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req entryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
st.mu.Lock()
_ = st.allow.remove(req.Entry)
st.mu.Unlock()
_ = st.save()
w.WriteHeader(http.StatusNoContent)
})
return requireAdmin(mux, adminEnabled, adminUser, adminPass)
}
// --- main -------------------------------------------------------------------
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
addr := getenv("LISTEN_ADDR", ":8080")
adminAddr := getenv("ADMIN_ADDR", ":9090") // serve admin on separate port
statePath := getenv("STATE_FILE", "/data/ipfilter.json")
adminUser := os.Getenv("ADMIN_USER")
adminPass := os.Getenv("ADMIN_PASS")
adminEnabled := adminUser != "" && adminPass != ""
// ensure dir
_ = os.MkdirAll(filepath.Dir(statePath), 0o755)
st := newState(statePath)
if err := st.load(); err != nil {
log.Fatalf("load state: %v", err)
}
// Auth server
go func() {
mux := http.NewServeMux()
mux.Handle("/auth", authHandler(st))
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK); w.Write([]byte("ok")) })
s := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Printf("ipfilter auth listening on %s", addr)
log.Fatal(s.ListenAndServe())
}()
// Admin server (separate port so you can expose only inside)
muxA := http.NewServeMux()
muxA.Handle("/", adminHandler(st, adminEnabled, adminUser, adminPass))
sA := &http.Server{Addr: adminAddr, Handler: muxA, ReadHeaderTimeout: 5 * time.Second}
log.Printf("ipfilter admin listening on %s (enabled=%v)", adminAddr, adminEnabled)
log.Fatal(sA.ListenAndServe())
}