From 72269f5b7c00aaf4e57d8bf3492bda3524d5b739 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 00:27:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Squid=20Forward-Proxy=20=E2=80=94=20vol?= =?UTF-8?q?lst=C3=A4ndig=20(Renderer=20+=20Handler=20+=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 12 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-ctl/render.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/forwardproxy.go | 162 ++++++++++++++ .../services/forwardproxy/forwardproxy.go | 110 ++++++++++ internal/squid/squid.cfg.tpl | 62 ++++++ internal/squid/squid.go | 86 +++++++- management-ui/package.json | 2 +- management-ui/src/App.tsx | 2 + .../src/components/Layout/Sidebar.tsx | 8 +- management-ui/src/i18n/locales/de/common.json | 20 ++ management-ui/src/i18n/locales/en/common.json | 20 ++ .../src/pages/ForwardProxy/index.tsx | 198 ++++++++++++++++++ .../debian/edgeguard-api/DEBIAN/postinst | 2 + 16 files changed, 677 insertions(+), 15 deletions(-) create mode 100644 internal/handlers/forwardproxy.go create mode 100644 internal/services/forwardproxy/forwardproxy.go create mode 100644 internal/squid/squid.cfg.tpl create mode 100644 management-ui/src/pages/ForwardProxy/index.tsx diff --git a/VERSION b/VERSION index 4a4127c..8955a01 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.25 +1.0.26 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index a2e9483..658749d 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -22,6 +22,7 @@ import ( firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" + squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid" wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" @@ -29,6 +30,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" @@ -39,7 +41,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.25" +var version = "1.0.26" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -141,6 +143,7 @@ func main() { secretsBox := secrets.New("") wgIfaces := wgsvc.NewInterfacesRepo(pool) wgPeers := wgsvc.NewPeersRepo(pool) + fwdProxyRepo := forwardproxy.New(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an @@ -182,6 +185,13 @@ func main() { return wgrender.New(pool, secretsBox).Render(ctx) } handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, wgReloader).Register(authed) + + // Squid forward-proxy reload — re-render squid.conf + reload + // squid.service. sudoers im postinst whitelistet das. + squidReloader := func(ctx context.Context) error { + return squidrender.New(pool).Render(ctx) + } + handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, squidReloader).Register(authed) } mountUI(r) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 947b120..3a2412a 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.25" +var version = "1.0.26" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index 6e0556a..7d1d34d 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -54,7 +54,7 @@ func cmdRenderConfig(args []string) int { hap := haproxy.New(pool) fw := firewall.New(pool) - sq := squid.New() + sq := squid.New(pool) wg := wireguard.New(pool, secrets.New("")) ub := unbound.New() if skipReload { diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index f374f64..f405d32 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -21,7 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.25" +var version = "1.0.26" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/forwardproxy.go b/internal/handlers/forwardproxy.go new file mode 100644 index 0000000..494aeb9 --- /dev/null +++ b/internal/handlers/forwardproxy.go @@ -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 +} diff --git a/internal/services/forwardproxy/forwardproxy.go b/internal/services/forwardproxy/forwardproxy.go new file mode 100644 index 0000000..da36db1 --- /dev/null +++ b/internal/services/forwardproxy/forwardproxy.go @@ -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 +} diff --git a/internal/squid/squid.cfg.tpl b/internal/squid/squid.cfg.tpl new file mode 100644 index 0000000..bd690a5 --- /dev/null +++ b/internal/squid/squid.cfg.tpl @@ -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 diff --git a/internal/squid/squid.go b/internal/squid/squid.go index 125324b..4edfd30 100644 --- a/internal/squid/squid.go +++ b/internal/squid/squid.go @@ -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) } diff --git a/management-ui/package.json b/management-ui/package.json index 5ecb669..19179df 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.25", + "version": "1.0.26", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 6256558..85bf0fd 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -21,6 +21,7 @@ const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) const SSLPage = lazy(() => import('./pages/SSL')) const FirewallPage = lazy(() => import('./pages/Firewall')) const WireguardPage = lazy(() => import('./pages/Wireguard')) +const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -101,6 +102,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 41c4c39..e9e86a6 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom' import type { ReactNode } from 'react' import { ApartmentOutlined, + CloudServerOutlined, ClusterOutlined, DashboardOutlined, DatabaseOutlined, @@ -57,8 +58,9 @@ const NAV: NavSection[] = [ { labelKey: 'nav.section.security', items: [ - { path: '/firewall', labelKey: 'nav.firewall', icon: }, - { path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: }, + { path: '/firewall', labelKey: 'nav.firewall', icon: }, + { path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: }, + { path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: }, ], }, { @@ -70,7 +72,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.25' +const VERSION = '1.0.26' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index bfe4c62..182159e 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -13,6 +13,7 @@ "ssl": "SSL-Zertifikate", "vpn": "VPN", "wireguard": "WireGuard", + "forwardProxy": "Forward-Proxy", "firewall": "Firewall", "cluster": "Cluster", "settings": "Einstellungen", @@ -398,6 +399,25 @@ "wg": "WireGuard" } }, + "fwd": { + "title": "Forward-Proxy (Squid)", + "intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.", + "helpTitle": "Tipp zur ACL-Reihenfolge", + "helpBody": "Höhere Priority = wird zuerst geprüft. Beispiel: 'deny .badsite.com' (priority 200) vor 'allow .com' (priority 100). Werte können Listen sein (mehrere Zeilen), Regex je nach acl_type.", + "name": "Name", + "nameExtra": "Squid-konformer Bezeichner — Kleinbuchstaben + _, kein Leerzeichen.", + "aclType": "Typ", + "aclTypeExtra": "Was Squid prüft (Quelle, Domain, Port, …).", + "value": "Wert", + "valueExtra": "Format hängt vom Typ ab — IPs/CIDRs für src/dst, Domain mit führendem . für dstdomain (.example.com matcht auch sub.example.com), Regex für *_regex-Typen.", + "action": "Aktion", + "priority": "Priority", + "priorityExtra": "Höher = wird zuerst geprüft.", + "comment": "Kommentar", + "add": "ACL hinzufügen", + "edit": "ACL bearbeiten", + "deleteConfirm": "ACL {{name}} wirklich löschen?" + }, "common": { "yes": "Ja", "no": "Nein", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 073ac72..b1a75d9 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -13,6 +13,7 @@ "ssl": "SSL certificates", "vpn": "VPN", "wireguard": "WireGuard", + "forwardProxy": "Forward proxy", "firewall": "Firewall", "cluster": "Cluster", "settings": "Settings", @@ -398,6 +399,25 @@ "wg": "WireGuard" } }, + "fwd": { + "title": "Forward proxy (Squid)", + "intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).", + "helpTitle": "ACL ordering tip", + "helpBody": "Higher priority = evaluated first. Example: 'deny .badsite.com' (priority 200) before 'allow .com' (priority 100). Values can be lists (multiple lines), regex depending on acl_type.", + "name": "Name", + "nameExtra": "Squid-conformant identifier — lowercase + _, no spaces.", + "aclType": "Type", + "aclTypeExtra": "What Squid matches (source, domain, port, …).", + "value": "Value", + "valueExtra": "Format depends on type — IPs/CIDRs for src/dst, domain with leading dot for dstdomain (.example.com also matches sub.example.com), regex for *_regex types.", + "action": "Action", + "priority": "Priority", + "priorityExtra": "Higher = evaluated first.", + "comment": "Comment", + "add": "Add ACL", + "edit": "Edit ACL", + "deleteConfirm": "Really delete ACL {{name}}?" + }, "common": { "yes": "Yes", "no": "No", diff --git a/management-ui/src/pages/ForwardProxy/index.tsx b/management-ui/src/pages/ForwardProxy/index.tsx new file mode 100644 index 0000000..272a66e --- /dev/null +++ b/management-ui/src/pages/ForwardProxy/index.tsx @@ -0,0 +1,198 @@ +import { useState } from 'react' +import { + Alert, Button, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typography, message, +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import DataTable from '../../components/DataTable' +import PageHeader from '../../components/PageHeader' +import ActionButtons from '../../components/ActionButtons' +import StatusDot from '../../components/StatusDot' + +const { Text } = Typography + +interface ACL { + id: number + name: string + acl_type: string + value: string + action: 'allow' | 'deny' + priority: number + active: boolean + comment?: string | null + created_at: string + updated_at: string +} + +interface FormValues { + name: string + acl_type: string + value: string + action: 'allow' | 'deny' + priority: number + active: boolean + comment?: string +} + +// ACL-Typen aus Squid's Vokabular — dieselbe Whitelist wie der +// Backend-Validator. Mehr Typen kann Squid (browser, time-of-day, +// arp, ...) — die spielen wir später dazu. +const ACL_TYPES = [ + { value: 'src', label: 'src — Quell-IP/CIDR' }, + { value: 'dst', label: 'dst — Ziel-IP/CIDR' }, + { value: 'dstdomain', label: 'dstdomain — Ziel-Domain (exact)' }, + { value: 'srcdomain', label: 'srcdomain — Quell-Domain (rDNS)' }, + { value: 'port', label: 'port — Ziel-Port' }, + { value: 'proto', label: 'proto — http/https/ftp/...' }, + { value: 'method', label: 'method — GET/POST/CONNECT/...' }, + { value: 'time', label: 'time — Wochentag/Zeit' }, + { value: 'url_regex', label: 'url_regex — kompletter URL-Match' }, + { value: 'urlpath_regex', label: 'urlpath_regex — Path-Teil' }, + { value: 'dstdom_regex', label: 'dstdom_regex — Domain-Regex' }, + { value: 'srcdom_regex', label: 'srcdom_regex — Quell-Domain-Regex' }, + { value: 'browser', label: 'browser — User-Agent-Regex' }, +] + +async function listACLs(): Promise { + const r = await apiClient.get('/forward-proxy/acls') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { acls?: ACL[] }).acls ?? [] +} + +export default function ForwardProxyPage() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['fwd-proxy', 'acls'], queryFn: listACLs }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const upsert = useMutation({ + mutationFn: async (v: FormValues) => { + if (editing) return (await apiClient.put(`/forward-proxy/acls/${editing.id}`, v)).data + return (await apiClient.post('/forward-proxy/acls', v)).data + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/forward-proxy/acls/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) }, + onError: (e: Error) => message.error(e.message), + }) + + const cols: ColumnsType = [ + { title: t('fwd.priority'), dataIndex: 'priority', key: 'priority', width: 90 }, + { title: t('fwd.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, + { title: t('fwd.action'), dataIndex: 'action', key: 'action', + render: (a: string) => {a.toUpperCase()} }, + { title: t('fwd.aclType'), dataIndex: 'acl_type', key: 'acl_type', + render: (s: string) => {s} }, + { title: t('fwd.value'), dataIndex: 'value', key: 'value', + render: (s: string) => {s} }, + { title: t('fwd.comment'), dataIndex: 'comment', key: 'comment', + render: (v?: string | null) => v ?? '—' }, + { title: t('common.active'), dataIndex: 'active', key: 'active', + render: (v: boolean) => }, + { + title: t('common.actions'), key: 'actions', + render: (_, row) => ( + { + setEditing(row) + form.setFieldsValue({ + name: row.name, acl_type: row.acl_type, value: row.value, + action: row.action, priority: row.priority, active: row.active, + comment: row.comment ?? undefined, + }) + }} + onDelete={() => del.mutate(row.id)} + deleteConfirm={t('fwd.deleteConfirm', { name: row.name })} + /> + ), + }, + ] + + return ( +
+ } + title={t('fwd.title')} + subtitle={t('fwd.intro')} + /> + + + + } onClick={() => { + setCreating(true); form.resetFields() + form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' }) + }}> + {t('fwd.add')} + + } + /> + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={620} + destroyOnClose + > +
upsert.mutate(v)}> + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index c159bad..714c57d 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -47,6 +47,8 @@ edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service SUDOERS chmod 0440 /etc/sudoers.d/edgeguard