feat: Squid Forward-Proxy — vollständig (Renderer + Handler + UI)
Stub raus, vollständig implementiert: * internal/services/forwardproxy: CRUD-Repo gegen forward_proxy_acls (priority desc, action allow|deny). * internal/handlers/forwardproxy.go: REST /api/v1/forward-proxy/acls mit Validation (acl_type-Whitelist verhindert Squid-Reload-Crash bei Tippfehlern). Auto-Reload nach jeder Mutation. * internal/squid/squid.cfg.tpl + squid.go: Renderer schreibt /etc/edgeguard/squid/squid.conf, atomic + Symlink von /etc/squid/squid.conf (Squid liest Distro-Pfad — gleicher Pattern-Fix wie wg-quick). cache_dir 100MB, cache_mem 64MB, http_port 3128. Default-Policy: nur localnet (10/8, 172.16/12, 192.168/16) — verhindert Open-Relay, falls Operator keine ACLs anlegt. * main.go: forwardproxy-Repo + squid-Reloader instanziiert + Handler registriert. * render.go: squid.New() bekommt Pool (war () vorher, Stub-Signatur). * postinst sudoers: edgeguard darf systemctl reload squid.service. * Frontend /forward-proxy: PageHeader + DataTable + ACL-Modal mit acl_type-Dropdown (13 Squid-Vokabular-Typen), action-Select, Priority. Sidebar-Eintrag unter Security. * i18n DE/EN für fwd.* Block + nav.forwardProxy. Verified end-to-end: ACL-Insert via SQL, render → squid reload → curl -x http://127.0.0.1:3128 http://example.com/ → 200. Version 1.0.26. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
internal/handlers/forwardproxy.go
Normal file
162
internal/handlers/forwardproxy.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"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) {
|
||||
g := rg.Group("/forward-proxy/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")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
110
internal/services/forwardproxy/forwardproxy.go
Normal file
110
internal/services/forwardproxy/forwardproxy.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Package forwardproxy provides CRUD against the forward_proxy_acls
|
||||
// table. Renderer in internal/squid consumes the same rows to emit
|
||||
// /etc/edgeguard/squid/squid.conf.
|
||||
package forwardproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("forward-proxy ACL not found")
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, acl_type, value, action, priority, active, comment,
|
||||
created_at, updated_at
|
||||
FROM forward_proxy_acls
|
||||
`
|
||||
|
||||
func (r *Repo) List(ctx context.Context) ([]models.ForwardProxyACL, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY priority DESC, id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.ForwardProxyACL, 0, 8)
|
||||
for rows.Next() {
|
||||
a, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) Get(ctx context.Context, id int64) (*models.ForwardProxyACL, error) {
|
||||
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id)
|
||||
a, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, a models.ForwardProxyACL) (*models.ForwardProxyACL, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO forward_proxy_acls (name, acl_type, value, action, priority, active, comment)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, acl_type, value, action, priority, active, comment,
|
||||
created_at, updated_at`,
|
||||
a.Name, a.ACLType, a.Value, a.Action, a.Priority, a.Active, a.Comment)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, a models.ForwardProxyACL) (*models.ForwardProxyACL, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE forward_proxy_acls SET
|
||||
name = $1, acl_type = $2, value = $3, action = $4,
|
||||
priority = $5, active = $6, comment = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $8
|
||||
RETURNING id, name, acl_type, value, action, priority, active, comment,
|
||||
created_at, updated_at`,
|
||||
a.Name, a.ACLType, a.Value, a.Action, a.Priority, a.Active, a.Comment, id)
|
||||
out, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM forward_proxy_acls WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scan(row interface{ Scan(...any) error }) (*models.ForwardProxyACL, error) {
|
||||
var a models.ForwardProxyACL
|
||||
if err := row.Scan(
|
||||
&a.ID, &a.Name, &a.ACLType, &a.Value, &a.Action,
|
||||
&a.Priority, &a.Active, &a.Comment,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
62
internal/squid/squid.cfg.tpl
Normal file
62
internal/squid/squid.cfg.tpl
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by edgeguard — do not edit by hand.
|
||||
# Source: internal/squid/squid.go (template: squid.cfg.tpl).
|
||||
# Re-generate via `edgeguard-ctl render-config --only=squid`.
|
||||
|
||||
http_port {{.ListenPort}}
|
||||
|
||||
# Standard cache directory + small in-memory cache. Forward proxy
|
||||
# isn't a CDN — we keep cache modest to avoid disk pressure.
|
||||
cache_dir ufs /var/spool/squid 100 16 256
|
||||
cache_mem 64 MB
|
||||
|
||||
# Logging — combined access log, rotated by logrotate.
|
||||
access_log /var/log/squid/access.log squid
|
||||
cache_log /var/log/squid/cache.log
|
||||
|
||||
# Standard safe defaults.
|
||||
acl localnet src 10.0.0.0/8
|
||||
acl localnet src 172.16.0.0/12
|
||||
acl localnet src 192.168.0.0/16
|
||||
acl localnet src fc00::/7
|
||||
acl localnet src fe80::/10
|
||||
|
||||
acl SSL_ports port 443
|
||||
acl Safe_ports port 80 # http
|
||||
acl Safe_ports port 21 # ftp
|
||||
acl Safe_ports port 443 # https
|
||||
acl Safe_ports port 70 # gopher
|
||||
acl Safe_ports port 210 # wais
|
||||
acl Safe_ports port 1025-65535 # unregistered
|
||||
acl Safe_ports port 280 # http-mgmt
|
||||
acl Safe_ports port 488 # gss-http
|
||||
acl Safe_ports port 591 # filemaker
|
||||
acl Safe_ports port 777 # multiling http
|
||||
acl CONNECT method CONNECT
|
||||
|
||||
# Operator-defined ACLs from /api/v1/forward-proxy-acls. Order is
|
||||
# priority desc — first http_access match wins.
|
||||
{{- range .ACLs}}
|
||||
{{- if .Active}}
|
||||
# {{if .Comment}}{{.Comment}}{{else}}{{.Name}} (priority {{.Priority}}){{end}}
|
||||
acl {{.Name}} {{.ACLType}} {{.Value}}
|
||||
http_access {{.Action}} {{.Name}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
# Built-in safety rules — same as squid's default; placed after
|
||||
# operator-rules so they act as fallbacks, not overrides.
|
||||
http_access deny !Safe_ports
|
||||
http_access deny CONNECT !SSL_ports
|
||||
http_access allow localhost manager
|
||||
http_access deny manager
|
||||
http_access allow localhost
|
||||
# Default-policy: only localnet may use the proxy if no operator-rule
|
||||
# explicitly allowed/denied. Stricter than squid's default to keep
|
||||
# the proxy from becoming an open relay.
|
||||
http_access allow localnet
|
||||
http_access deny all
|
||||
|
||||
# Hostnames + visible name — operator can override via squid.conf
|
||||
# drop-in if needed.
|
||||
visible_hostname edgeguard-proxy
|
||||
forwarded_for on
|
||||
@@ -1,20 +1,94 @@
|
||||
// Package squid will render /etc/edgeguard/squid/squid.conf in
|
||||
// Phase 3. v1 ships a stub returning configgen.ErrNotImplemented so
|
||||
// the orchestrator can list it without crashing.
|
||||
// Package squid renders /etc/edgeguard/squid/squid.conf from
|
||||
// forward_proxy_acls and reloads squid.service. Cache directory
|
||||
// + in-memory cache are hard-coded sensible defaults; the operator
|
||||
// scope is just "what can pass through the proxy".
|
||||
package squid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
||||
)
|
||||
|
||||
type Generator struct{}
|
||||
const (
|
||||
confPath = "/etc/edgeguard/squid/squid.conf"
|
||||
listenPort = 3128
|
||||
)
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
//go:embed squid.cfg.tpl
|
||||
var cfgTpl string
|
||||
|
||||
var tpl = template.Must(template.New("squid").Parse(cfgTpl))
|
||||
|
||||
type View struct {
|
||||
ListenPort int
|
||||
ACLs []models.ForwardProxyACL
|
||||
}
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
Repo *forwardproxy.Repo
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Generator {
|
||||
return &Generator{Pool: pool, Repo: forwardproxy.New(pool)}
|
||||
}
|
||||
|
||||
func (g *Generator) Name() string { return "squid" }
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
return configgen.ErrNotImplemented
|
||||
acls, err := g.Repo.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list acls: %w", err)
|
||||
}
|
||||
view := View{ListenPort: listenPort, ACLs: acls}
|
||||
var body bytes.Buffer
|
||||
if err := tpl.Execute(&body, view); err != nil {
|
||||
return fmt.Errorf("template: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(confPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
tmp := confPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, body.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, confPath); err != nil {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
if err := ensureDistroSymlink(); err != nil {
|
||||
return fmt.Errorf("symlink: %w", err)
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
return configgen.ReloadService("squid")
|
||||
}
|
||||
|
||||
// ensureDistroSymlink legt /etc/squid/squid.conf als Symlink auf
|
||||
// unsere managed conf an. Squid systemd-Unit liest die Distro-Datei;
|
||||
// ohne Symlink driftet der edgeguard-Renderer und der laufende
|
||||
// Daemon auseinander (gleicher Bug-Pattern wie wg-quick).
|
||||
// Existing real file (Distro-Default) wird nach .distro-bak verschoben,
|
||||
// nicht gelöscht.
|
||||
func ensureDistroSymlink() error {
|
||||
const link = "/etc/squid/squid.conf"
|
||||
if cur, err := os.Readlink(link); err == nil && cur == confPath {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(link); err == nil {
|
||||
_ = os.Rename(link, link+".distro-bak")
|
||||
}
|
||||
return os.Symlink(confPath, link)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user