Files
edgeguard-native/internal/handlers/forwardproxy.go

230 lines
6.1 KiB
Go

package handlers
import (
"context"
"errors"
"log/slog"
"os/exec"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
)
type ForwardProxyHandler struct {
Repo *forwardproxy.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewForwardProxyHandler(repo *forwardproxy.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *ForwardProxyHandler {
return &ForwardProxyHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("squid: reload after mutation failed", "op", op, "error", err)
}
}
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
base := rg.Group("/forward-proxy")
base.GET("/stats", h.Stats)
g := base.Group("/acls")
g.GET("", h.List)
g.POST("", h.Create)
g.GET("/:id", h.Get)
g.PUT("/:id", h.Update)
g.DELETE("/:id", h.Delete)
}
func (h *ForwardProxyHandler) List(c *gin.Context) {
out, err := h.Repo.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"acls": out})
}
func (h *ForwardProxyHandler) Get(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Repo.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, forwardproxy.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *ForwardProxyHandler) Create(c *gin.Context) {
var req models.ForwardProxyACL
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateACL(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.acl.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *ForwardProxyHandler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.ForwardProxyACL
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateACL(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, forwardproxy.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.acl.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *ForwardProxyHandler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, forwardproxy.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.acl.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
// Stats liefert Squid-Cache-Statistiken via `squidclient mgr:counters`.
// Die Ausgabe enthält HTTP-Header gefolgt von key = value Zeilen.
func (h *ForwardProxyHandler) Stats(c *gin.Context) {
out, err := exec.Command("squidclient", "-h", "127.0.0.1", "-p", "3128", "mgr:counters").Output()
if err != nil {
response.OK(c, gin.H{
"error": "squidclient nicht verfügbar: " + err.Error(),
})
return
}
response.OK(c, gin.H{"stats": parseSquidCounters(string(out))})
}
type squidStats struct {
ClientRequests int64 `json:"client_requests"`
CacheHits int64 `json:"cache_hits"`
CacheHitPct float64 `json:"cache_hit_pct"`
ClientErrors int64 `json:"client_errors"`
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
ServerRequests int64 `json:"server_requests"`
ServerErrors int64 `json:"server_errors"`
}
func parseSquidCounters(out string) squidStats {
// squidclient prefixes an HTTP response header block — skip it.
body := out
if idx := strings.Index(out, "\r\n\r\n"); idx >= 0 {
body = out[idx+4:]
} else if idx := strings.Index(out, "\n\n"); idx >= 0 {
body = out[idx+2:]
}
s := squidStats{}
for _, line := range strings.Split(body, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
switch strings.TrimSpace(k) {
case "client_http.requests":
s.ClientRequests = n
case "client_http.hits":
s.CacheHits = n
case "client_http.errors":
s.ClientErrors = n
case "client_http.kbytes_in":
s.BytesIn = n * 1024
case "client_http.kbytes_out":
s.BytesOut = n * 1024
case "server.all.requests":
s.ServerRequests = n
case "server.all.errors":
s.ServerErrors = n
}
}
if s.ClientRequests > 0 {
s.CacheHitPct = float64(s.CacheHits) / float64(s.ClientRequests) * 100
}
return s
}
// validateACL prüft Name (squid-konform), action, acl_type. Squid
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
// crashen.
func validateACL(a *models.ForwardProxyACL) error {
if a.Name == "" {
return errors.New("name required")
}
switch a.Action {
case "allow", "deny":
default:
return errors.New("action must be allow or deny")
}
switch a.ACLType {
case "src", "dst", "dstdomain", "srcdomain", "port",
"proto", "method", "time", "url_regex", "urlpath_regex",
"dstdom_regex", "srcdom_regex", "browser":
default:
return errors.New("acl_type not supported (allowed: src, dst, dstdomain, srcdomain, port, proto, method, time, url_regex, urlpath_regex, dstdom_regex, srcdom_regex, browser)")
}
if a.Value == "" {
return errors.New("value required")
}
return nil
}