This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal 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
8
Dockerfile
Normal 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
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.send.nrw/sendnrw/traefik-ipblock
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
533
main.go
Normal file
533
main.go
Normal 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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user