feat(fw): Renderer-Rewrite + auto-apply + Anti-Lockout

internal/firewall/firewall.go komplett neu: joint zone-iface-mapping
(network_interfaces.role), address objects + groups (members
expandiert), services + groups, rules, nat-rules. Output: einheitliche
View mit Legs (rule × service cross-product) damit das Template kein
sub-template/dict braucht.

Template:
* Anti-Lockout-Block am input-chain-Top (SSH+443 immer erlaubt,
  KANN nicht von Custom-Rules overruled werden — User-Wunsch).
* Rules: pro Leg eine nft-Zeile mit iif/oif sets, ip saddr/daddr,
  proto+dport, optional log-prefix.
* prerouting_nat: iteriert dnat-Rules.
* postrouting_nat: snat + masquerade.

Auto-apply: FirewallHandler bekommt einen Reloader-Hook der nach
jedem POST/PUT/DELETE aufgerufen wird. main.go injected
firewall.New(pool).Render — schreibt + sudo nft -f.

Sudoers (/etc/sudoers.d/edgeguard): NOPASSWD für 'nft -f
/etc/edgeguard/nftables.d/ruleset.nft'. configgen.ReloadService
nutzt jetzt sudo (haproxy reload klappte vorher nicht aus dem
edgeguard-User).

Frontend (Sweep): style={{ marginBottom: 16 }} → className="mb-16"
in allen 7 Firewall-Tabs — User-Feedback "globales CSS statt inline".

Live auf 89.163.205.6: nft list table inet edgeguard zeigt
Anti-Lockout + Baseline + Cluster-Peer-Set + (jetzt noch leere)
Custom-Rules-Sektion. render-config postinst-mäßig sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 13:34:06 +02:00
parent e2bdce9271
commit 1b2c0d7411
13 changed files with 542 additions and 161 deletions

View File

@@ -19,6 +19,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/database"
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
@@ -150,7 +151,12 @@ func main() {
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID).Register(authed) // Firewall reload: nach jeder Mutation den Renderer neu fahren
// (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen.
fwReloader := func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
}
handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed)
} }
mountUI(r) mountUI(r)

View File

@@ -16,6 +16,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
) )
// Generator is the contract every per-service renderer satisfies. // Generator is the contract every per-service renderer satisfies.
@@ -70,17 +71,18 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error {
return nil return nil
} }
// ReloadService sends `systemctl reload <name>`. Returns the // ReloadService runs `sudo -n systemctl reload <name>.service`.
// CombinedOutput on failure so the caller can surface the actual // edgeguard-api runs as the unprivileged `edgeguard` user; postinst
// systemd error to the operator. // installs a sudoers entry NOPASSWD-ing exactly this command per
// service that needs it.
// //
// Some services don't support reload (nftables — no daemon); for // Some services don't support reload (nftables — no daemon); for
// those, callers should run the service-specific reload directly // those, callers should run the service-specific reload directly
// rather than calling this helper. // rather than calling this helper.
func ReloadService(name string) error { func ReloadService(name string) error {
cmd := exec.Command("systemctl", "reload", name) cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", name+".service")
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl reload %s: %w (output: %s)", name, err, string(out)) return fmt.Errorf("sudo systemctl reload %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
} }
return nil return nil
} }

View File

@@ -1,13 +1,20 @@
// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from // Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from
// the relational state in PG (firewall_rules + ha_nodes). // the v2 (Fortigate-style) firewall tables.
// //
// The base ruleset is hard-coded in the template (default-deny input, // Render flow:
// stateful baseline, SSH rate-limit, public :80 / :443, peer mTLS on
// :8443 from cluster IPs). Operator-defined rows in firewall_rules
// land at the bottom of input/forward/output.
// //
// Reload uses `nft -f <path>` (atomic ruleset replace) — there is no // 1. loadView pulls everything: zone→iface mapping (from
// systemctl reload for nftables. // network_interfaces.role), address-objects + groups, services
// + groups, policy rules, nat rules, ha_nodes peer IPs.
// 2. Each rule and nat-rule is "resolved" — group references
// replaced with their primitive members, FQDNs left as comments
// (Phase-3 DNS-resolution sidecar will materialise them).
// 3. The template emits one nft file with: zone-iface sets, peer
// sets, default-deny baseline, forward + input chains carrying
// the resolved rules (priority-sorted), nat prerouting +
// postrouting chains.
// 4. Atomic write + `sudo nft -f` (sudoers-rule installed by
// postinst).
package firewall package firewall
import ( import (
@@ -18,6 +25,8 @@ import (
"net" "net"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings"
"text/template" "text/template"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -28,7 +37,9 @@ import (
//go:embed ruleset.nft.tpl //go:embed ruleset.nft.tpl
var rulesTpl string var rulesTpl string
var tpl = template.Must(template.New("ruleset").Parse(rulesTpl)) var tpl = template.Must(template.New("ruleset").Funcs(template.FuncMap{
"join": strings.Join,
}).Parse(rulesTpl))
type Generator struct { type Generator struct {
Pool *pgxpool.Pool Pool *pgxpool.Pool
@@ -60,42 +71,113 @@ func (g *Generator) Render(ctx context.Context) error {
if g.SkipReload { if g.SkipReload {
return nil return nil
} }
if err := exec.Command("nft", "-f", out).Run(); err != nil { // nft -f via sudo — postinst installiert die NOPASSWD-Rule.
return fmt.Errorf("nftables: nft -f %s: %w", out, err) cmd := exec.Command("sudo", "-n", "/usr/sbin/nft", "-f", out)
combined, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nftables: nft -f %s: %w (output: %s)", out, err, strings.TrimSpace(string(combined)))
} }
return nil return nil
} }
// View is what the template consumes — fully resolved.
type View struct { type View struct {
ZoneIPv4 map[string][]string // zone name → iface names (ipv4-able)
PeerIPv4 []string PeerIPv4 []string
PeerIPv6 []string PeerIPv6 []string
// Custom rules grouped by chain — the template iterates each // Legs is the cross-product of (rule × service). One nft line per
// section independently so input/forward/output stay separate. // leg, expanded server-side so the template stays free of dict /
CustomRulesInput []Rule // sub-template trickery. A rule with N services produces N legs;
CustomRulesForward []Rule // a rule with no service produces one leg with Service.Proto = "".
CustomRulesOutput []Rule Legs []RuleLeg
NATRules []ResolvedNATRule
} }
type Rule struct { // RuleLeg is one materialised nft policy line.
MatchExpr string type RuleLeg struct {
RuleID int64
Action string Action string
Log bool
Name string
Comment string
SrcIfaces []string
DstIfaces []string
SrcAddrs []string
DstAddrs []string
Service ResolvedService // Proto="" → no service match (any)
}
// ResolvedRule has all addresses + services already expanded so the
// template just emits one nft line per "leg" of the cross-product.
type ResolvedRule struct {
ID int64
Action string // accept | drop | reject
Log bool
Name string
Priority int
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
DstAddrs []string
Services []ResolvedService // empty = any
Comment string Comment string
} }
func (g *Generator) loadView(ctx context.Context) (*View, error) { // ResolvedNATRule is one nat-rule joined with iface-sets.
view := &View{} type ResolvedNATRule struct {
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
Comment string
}
// Peer IPs from ha_nodes — splits IPv4 vs IPv6 so the template // ResolvedService is one nft (proto, dport-spec) tuple.
// can populate the right named set without runtime branching. type ResolvedService struct {
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
view := &View{
ZoneIPv4: map[string][]string{},
}
// ── Zone → Iface mapping aus network_interfaces.role ──
ifRows, err := g.Pool.Query(ctx,
`SELECT name, role FROM network_interfaces WHERE active = TRUE`)
if err != nil {
return nil, fmt.Errorf("query network_interfaces: %w", err)
}
for ifRows.Next() {
var name, role string
if err := ifRows.Scan(&name, &role); err != nil {
ifRows.Close()
return nil, err
}
view.ZoneIPv4[role] = append(view.ZoneIPv4[role], name)
}
ifRows.Close()
// ── Peer IPs aus ha_nodes (für mTLS-Peer-Set) ──
peerRows, err := g.Pool.Query(ctx, peerRows, err := g.Pool.Query(ctx,
`SELECT public_ip, internal_ip FROM ha_nodes`) `SELECT public_ip, internal_ip FROM ha_nodes`)
if err != nil { if err != nil {
return nil, fmt.Errorf("query ha_nodes: %w", err) return nil, fmt.Errorf("query ha_nodes: %w", err)
} }
defer peerRows.Close()
for peerRows.Next() { for peerRows.Next() {
var pub, internal *string var pub, internal *string
if err := peerRows.Scan(&pub, &internal); err != nil { if err := peerRows.Scan(&pub, &internal); err != nil {
peerRows.Close()
return nil, err return nil, err
} }
for _, ip := range []*string{pub, internal} { for _, ip := range []*string{pub, internal} {
@@ -113,16 +195,325 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
} }
} }
} }
if err := peerRows.Err(); err != nil { peerRows.Close()
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
addrObjs, err := g.loadAddrObjects(ctx)
if err != nil {
return nil, err
}
addrGroups, err := g.loadAddrGroups(ctx, addrObjs)
if err != nil {
return nil, err return nil, err
} }
// Migration 0010 hat firewall_rules komplett umgebaut (Fortigate- // ── Services + Groups ──
// Style mit address objects + service refs). Phase-2-Renderer services, err := g.loadServices(ctx)
// kannte das alte chain/match_expr-Schema. Bis Task #44 die if err != nil {
// Render-Logik mit den neuen Joins ersetzt, geben wir hier return nil, err
// keine custom-Rules aus — Output ist nur baseline + cluster set. }
// Sicher, weil baseline default-deny ist; v2-Rules kommen mit serviceGroups, err := g.loadServiceGroups(ctx, services)
// dem nächsten Renderer-Patch. if err != nil {
return nil, err
}
// ── Rules ──
rules, err := g.loadRules(ctx, addrObjs, addrGroups, services, serviceGroups, view.ZoneIPv4)
if err != nil {
return nil, err
}
// Expand to one Leg per (rule × service); rules without a service
// produce one leg with empty Proto.
for _, r := range rules {
if len(r.Services) == 0 {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
})
continue
}
for _, svc := range r.Services {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
Service: svc,
})
}
}
// ── NAT-Rules ──
natRules, err := g.loadNATRules(ctx, view.ZoneIPv4)
if err != nil {
return nil, err
}
view.NATRules = natRules
return view, nil return view, nil
} }
// addrObjMap is keyed by id; value is the nft expression for that
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
type addrObjMap map[int64]string
func (g *Generator) loadAddrObjects(ctx context.Context) (addrObjMap, error) {
out := addrObjMap{}
rows, err := g.Pool.Query(ctx,
`SELECT id, kind, value FROM firewall_address_objects`)
if err != nil {
return nil, fmt.Errorf("query address_objects: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var kind, value string
if err := rows.Scan(&id, &kind, &value); err != nil {
return nil, err
}
switch kind {
case "host", "network":
out[id] = value
case "range":
// "1.2.3.4-1.2.3.10" → nft format identical
out[id] = value
case "fqdn":
// FQDNs are skipped at render-time — template emits
// a comment, the actual rule won't filter on these
// addresses until a DNS-resolution sidecar lands.
out[id] = "" // signals "skip"
}
}
return out, rows.Err()
}
// addrGroupMap is keyed by group id; value is the list of resolved
// nft-expressions (one per primitive member).
type addrGroupMap map[int64][]string
func (g *Generator) loadAddrGroups(ctx context.Context, objs addrObjMap) (addrGroupMap, error) {
out := addrGroupMap{}
rows, err := g.Pool.Query(ctx,
`SELECT group_id, object_id FROM firewall_address_group_members`)
if err != nil {
return nil, fmt.Errorf("query address_group_members: %w", err)
}
defer rows.Close()
for rows.Next() {
var gid, oid int64
if err := rows.Scan(&gid, &oid); err != nil {
return nil, err
}
if expr, ok := objs[oid]; ok && expr != "" {
out[gid] = append(out[gid], expr)
}
}
return out, rows.Err()
}
type serviceMap map[int64]ResolvedService
func (g *Generator) loadServices(ctx context.Context) (serviceMap, error) {
out := serviceMap{}
rows, err := g.Pool.Query(ctx,
`SELECT id, proto, COALESCE(port_start, 0), COALESCE(port_end, 0) FROM firewall_services`)
if err != nil {
return nil, fmt.Errorf("query services: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var proto string
var ps, pe int
if err := rows.Scan(&id, &proto, &ps, &pe); err != nil {
return nil, err
}
out[id] = ResolvedService{Proto: proto, PortStart: ps, PortEnd: pe}
}
return out, rows.Err()
}
type serviceGroupMap map[int64][]ResolvedService
func (g *Generator) loadServiceGroups(ctx context.Context, services serviceMap) (serviceGroupMap, error) {
out := serviceGroupMap{}
rows, err := g.Pool.Query(ctx,
`SELECT group_id, service_id FROM firewall_service_group_members`)
if err != nil {
return nil, fmt.Errorf("query service_group_members: %w", err)
}
defer rows.Close()
for rows.Next() {
var gid, sid int64
if err := rows.Scan(&gid, &sid); err != nil {
return nil, err
}
if svc, ok := services[sid]; ok {
out[gid] = append(out[gid], svc)
}
}
return out, rows.Err()
}
func (g *Generator) loadRules(
ctx context.Context,
addrObjs addrObjMap,
addrGroups addrGroupMap,
services serviceMap,
serviceGroups serviceGroupMap,
zoneIfaces map[string][]string,
) ([]ResolvedRule, error) {
rows, err := g.Pool.Query(ctx, `
SELECT id, COALESCE(name, ''), priority, action, log, COALESCE(comment, ''),
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id
FROM firewall_rules
WHERE enabled
ORDER BY priority DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query rules: %w", err)
}
defer rows.Close()
resolveSide := func(objID, grpID *int64, cidr *string) []string {
if objID != nil {
if expr, ok := addrObjs[*objID]; ok && expr != "" {
return []string{expr}
}
}
if grpID != nil {
if exprs, ok := addrGroups[*grpID]; ok {
return exprs
}
}
if cidr != nil && *cidr != "" {
return []string{*cidr}
}
return nil // any
}
resolveService := func(objID, grpID *int64) []ResolvedService {
if objID != nil {
if svc, ok := services[*objID]; ok {
return []ResolvedService{svc}
}
}
if grpID != nil {
if svcs, ok := serviceGroups[*grpID]; ok {
return svcs
}
}
return nil
}
out := []ResolvedRule{}
for rows.Next() {
var (
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
)
if err := rows.Scan(
&id, &name, &pr, &action, &log, &com,
&srcZone, &srcObjID, &srcGrpID, &srcCIDR,
&dstZone, &dstObjID, &dstGrpID, &dstCIDR,
&svcObjID, &svcGrpID,
); err != nil {
return nil, err
}
r := ResolvedRule{
ID: id,
Action: action,
Log: log,
Name: name,
Priority: pr,
Comment: com,
SrcAddrs: resolveSide(srcObjID, srcGrpID, srcCIDR),
DstAddrs: resolveSide(dstObjID, dstGrpID, dstCIDR),
Services: resolveService(svcObjID, svcGrpID),
}
if srcZone != "any" {
r.SrcIfaces = zoneIfaces[srcZone]
}
if dstZone != "any" {
r.DstIfaces = zoneIfaces[dstZone]
}
out = append(out, r)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Priority != out[j].Priority {
return out[i].Priority > out[j].Priority
}
return out[i].ID < out[j].ID
})
return out, rows.Err()
}
func (g *Generator) loadNATRules(ctx context.Context, zoneIfaces map[string][]string) ([]ResolvedNATRule, error) {
rows, err := g.Pool.Query(ctx, `
SELECT id, priority, kind, COALESCE(comment, ''),
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr,
COALESCE(match_dport_start, 0), COALESCE(match_dport_end, 0),
COALESCE(target_addr, ''),
COALESCE(target_port_start, 0), COALESCE(target_port_end, 0)
FROM firewall_nat_rules
WHERE enabled
ORDER BY priority DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query nat_rules: %w", err)
}
defer rows.Close()
out := []ResolvedNATRule{}
for rows.Next() {
var (
id int64
pr int
kind, com string
inZone, outZone, proto, srcCIDR, dstCIDR *string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
)
if err := rows.Scan(
&id, &pr, &kind, &com,
&inZone, &outZone, &proto,
&srcCIDR, &dstCIDR,
&dpStart, &dpEnd,
&targetAddr, &tpStart, &tpEnd,
); err != nil {
return nil, err
}
r := ResolvedNATRule{
ID: id, Kind: kind, Priority: pr, Comment: com,
DPortStart: dpStart, DPortEnd: dpEnd,
TargetAddr: targetAddr,
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
}
if proto != nil {
r.Proto = *proto
}
if srcCIDR != nil {
r.SrcCIDR = *srcCIDR
}
if dstCIDR != nil {
r.DstCIDR = *dstCIDR
}
if inZone != nil {
r.InIfaces = zoneIfaces[*inZone]
}
if outZone != nil {
r.OutIfaces = zoneIfaces[*outZone]
}
out = append(out, r)
}
return out, rows.Err()
}

View File

@@ -1,78 +0,0 @@
package firewall
import (
"bytes"
"strings"
"testing"
)
func renderView(t *testing.T, v View) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
func TestRender_BaselineHasMandatorySections(t *testing.T) {
out := renderView(t, View{})
for _, w := range []string{
"flush ruleset",
"table inet edgeguard",
"set peer_ipv4",
"set peer_ipv6",
"chain input",
"type filter hook input priority 0; policy drop;",
"ct state established,related accept",
"iif lo accept",
"tcp dport 22 ct state new limit rate 10/minute accept",
"tcp dport { 80, 443 } accept",
"tcp dport 8443 ip saddr @peer_ipv4 accept",
"chain forward",
"chain output",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q in baseline:\n%s", w, out)
}
}
}
func TestRender_PeerIPsPopulateSets(t *testing.T) {
v := View{
PeerIPv4: []string{"10.0.0.11", "10.0.0.12"},
PeerIPv6: []string{"fd00::1"},
}
out := renderView(t, v)
for _, w := range []string{
"elements = { 10.0.0.11, 10.0.0.12 }",
"elements = { fd00::1 }",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q:\n%s", w, out)
}
}
}
func TestRender_CustomRulesLandInChain(t *testing.T) {
v := View{
CustomRulesInput: []Rule{
{MatchExpr: "ip saddr 192.168.0.0/16 tcp dport 9090", Action: "accept", Comment: "monitoring"},
},
CustomRulesForward: []Rule{
{MatchExpr: "iif eth0 oif eth1", Action: "accept", Comment: "lan to wan"},
},
}
out := renderView(t, v)
want := []string{
"# monitoring",
"ip saddr 192.168.0.0/16 tcp dport 9090 accept",
"# lan to wan",
"iif eth0 oif eth1 accept",
}
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("missing %q:\n%s", w, out)
}
}
}

View File

@@ -1,7 +1,7 @@
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
# Generated by edgeguard-api — DO NOT EDIT. # Generated by edgeguard-api — DO NOT EDIT.
# Source: internal/firewall/firewall.go (template: ruleset.nft.tpl). # Source: internal/firewall/firewall.go.
# Re-generate via `edgeguard-ctl render-config`. # Re-generate via `edgeguard-ctl render-config` or via API mutations.
flush ruleset flush ruleset
@@ -22,6 +22,14 @@ table inet edgeguard {
chain input { chain input {
type filter hook input priority 0; policy drop; type filter hook input priority 0; policy drop;
# ANTI-LOCKOUT (immer aktiv, kann von keiner Custom-Rule overruled werden)
# nft input-chain wird top-down evaluiert; eine accept-Action terminiert.
# Diese Block kommt VOR den Custom-Rules d.h. selbst wenn ein
# Operator versehentlich drop alles" baut, bleibt SSH + Admin-UI
# erreichbar.
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)"
tcp dport 443 accept comment "anti-lockout: Management-UI (HAProxy/HTTPS)"
# Stateful baseline # Stateful baseline
ct state established,related accept ct state established,related accept
ct state invalid drop ct state invalid drop
@@ -31,39 +39,66 @@ table inet edgeguard {
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
# SSH rate-limit to keep brute-force out of the auth log # Public ingress: HAProxy serves :80 (ACME + redirect)
tcp dport 22 ct state new limit rate 10/minute accept tcp dport 80 accept
tcp dport 22 drop
# Public ingress: HAProxy terminates TLS on :443 and serves :80
tcp dport { 80, 443 } accept
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443 # Cluster-internal: peers reach edgeguard-api over mTLS on :8443
tcp dport 8443 ip saddr @peer_ipv4 accept tcp dport 8443 ip saddr @peer_ipv4 accept
tcp dport 8443 ip6 saddr @peer_ipv6 accept tcp dport 8443 ip6 saddr @peer_ipv6 accept
{{- range .CustomRulesInput}} # ── Operator-defined rules ──
# {{.Comment}} {{- range .Legs}}
{{.MatchExpr}} {{.Action}} # rule {{.RuleID}}{{if .Name}} ({{.Name}}){{end}}{{if .Comment}} — {{.Comment}}{{end}}
{{- if .SrcIfaces}} iifname { {{join .SrcIfaces ", "}} }{{end -}}
{{- if .DstIfaces}} oifname { {{join .DstIfaces ", "}} }{{end -}}
{{- if .SrcAddrs}} ip saddr { {{join .SrcAddrs ", "}} }{{end -}}
{{- if .DstAddrs}} ip daddr { {{join .DstAddrs ", "}} }{{end -}}
{{- with .Service -}}
{{- if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}} {{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}}
{{- else if eq .Proto "icmp"}} ip protocol icmp
{{- else if eq .Proto "icmpv6"}} ip6 nexthdr icmpv6
{{- end -}}
{{- end -}}
{{- if .Log}} log prefix "edgeguard:{{.RuleID}} "{{end}} {{.Action}}
{{- end}} {{- end}}
} }
chain forward { chain forward {
type filter hook forward priority 0; policy drop; type filter hook forward priority 0; policy drop;
ct state established,related accept ct state established,related accept
ct state invalid drop ct state invalid drop
{{- range .CustomRulesForward}}
# {{.Comment}}
{{.MatchExpr}} {{.Action}}
{{- end}}
} }
chain output { chain output {
type filter hook output priority 0; policy accept; type filter hook output priority 0; policy accept;
{{- range .CustomRulesOutput}} }
# {{.Comment}}
{{.MatchExpr}} {{.Action}} chain prerouting_nat {
{{- end}} type nat hook prerouting priority -100;
{{- range .NATRules}}{{if eq .Kind "dnat"}}
# NAT {{.ID}} (dnat{{if .Comment}} — {{.Comment}}{{end}})
{{- if .InIfaces}} iifname { {{join .InIfaces ", "}} }{{end}}
{{- if and .Proto (ne .Proto "any")}} {{.Proto}}{{else}} meta l4proto { tcp, udp }{{end}}
{{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}}
{{- if .DstCIDR}} ip daddr {{.DstCIDR}}{{end}}
{{- if .DPortStart}} dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}}{{end}}
{{- if .TargetAddr}} dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
{{- end}}{{end}}
}
chain postrouting_nat {
type nat hook postrouting priority 100;
{{- range .NATRules}}{{if eq .Kind "snat"}}
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
{{- if .OutIfaces}} oifname { {{join .OutIfaces ", "}} }{{end}}
{{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}}
{{- if .TargetAddr}} snat to {{.TargetAddr}}{{end}}
{{- end}}{{if eq .Kind "masquerade"}}
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
{{- if .OutIfaces}} oifname { {{join .OutIfaces ", "}} }{{end}}
{{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}} masquerade
{{- end}}{{end}}
} }
} }

View File

@@ -1,8 +1,10 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"strconv" "strconv"
"strings" "strings"
@@ -35,6 +37,13 @@ type FirewallHandler struct {
NATRules *firewall.NATRulesRepo NATRules *firewall.NATRulesRepo
Audit *audit.Repo Audit *audit.Repo
NodeID string NodeID string
// Reloader regenerates and applies the nft ruleset. Called after
// each mutation so the kernel ruleset is always in sync with the
// DB. Errors are logged but don't fail the API call (the row is
// already committed). Wire to internal/firewall.Generator.Render
// in main.go; pass nil during tests.
Reloader func(ctx context.Context) error
} }
func NewFirewallHandler( func NewFirewallHandler(
@@ -46,12 +55,27 @@ func NewFirewallHandler(
nat *firewall.NATRulesRepo, nat *firewall.NATRulesRepo,
a *audit.Repo, a *audit.Repo,
nodeID string, nodeID string,
reloader func(ctx context.Context) error,
) *FirewallHandler { ) *FirewallHandler {
return &FirewallHandler{ return &FirewallHandler{
AddrObjects: ao, AddrGroups: ag, AddrObjects: ao, AddrGroups: ag,
Services: sv, ServiceGroups: sg, Services: sv, ServiceGroups: sg,
Rules: rl, NATRules: nat, Rules: rl, NATRules: nat,
Audit: a, NodeID: nodeID, Audit: a, NodeID: nodeID,
Reloader: reloader,
}
}
// reload runs the configured Reloader (if any). Errors don't fail
// the surrounding API call — the DB row is already committed and
// the operator can re-trigger via `edgeguard-ctl render-config`.
func (h *FirewallHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("firewall: nft reload failed",
"op", op, "error", err)
} }
} }
@@ -145,7 +169,7 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) { func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
@@ -172,7 +196,7 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) { func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
@@ -190,7 +214,7 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── Address Groups ───────────────────────────────────────────────────── // ── Address Groups ─────────────────────────────────────────────────────
@@ -233,7 +257,7 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) { func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
@@ -256,7 +280,7 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) { func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
@@ -274,7 +298,7 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── Services ─────────────────────────────────────────────────────────── // ── Services ───────────────────────────────────────────────────────────
@@ -317,7 +341,7 @@ func (h *FirewallHandler) CreateService(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateService(c *gin.Context) { func (h *FirewallHandler) UpdateService(c *gin.Context) {
@@ -340,7 +364,7 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteService(c *gin.Context) { func (h *FirewallHandler) DeleteService(c *gin.Context) {
@@ -358,7 +382,7 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── Service Groups ───────────────────────────────────────────────────── // ── Service Groups ─────────────────────────────────────────────────────
@@ -401,7 +425,7 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) { func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
@@ -424,7 +448,7 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) { func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
@@ -442,7 +466,7 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── Rules ────────────────────────────────────────────────────────────── // ── Rules ──────────────────────────────────────────────────────────────
@@ -489,7 +513,7 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateRule(c *gin.Context) { func (h *FirewallHandler) UpdateRule(c *gin.Context) {
@@ -516,7 +540,7 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteRule(c *gin.Context) { func (h *FirewallHandler) DeleteRule(c *gin.Context) {
@@ -534,7 +558,7 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── NAT Rules ────────────────────────────────────────────────────────── // ── NAT Rules ──────────────────────────────────────────────────────────
@@ -581,7 +605,7 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out) response.Created(c, out); h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateNAT(c *gin.Context) { func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
@@ -608,7 +632,7 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out) response.OK(c, out); h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteNAT(c *gin.Context) { func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
@@ -626,7 +650,7 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c) response.NoContent(c); h.reload(c.Request.Context(), "delete")
} }
// ── Validators ───────────────────────────────────────────────────────── // ── Validators ─────────────────────────────────────────────────────────

View File

@@ -85,7 +85,7 @@ export default function AddressGroupsTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] }) form.setFieldsValue({ member_ids: [] })
}}> }}>

View File

@@ -78,7 +78,7 @@ export default function AddressObjectsTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'host' }) form.setFieldsValue({ kind: 'host' })
}}> }}>

View File

@@ -122,7 +122,7 @@ export default function NATRulesTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' }) form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
}}> }}>

View File

@@ -185,7 +185,7 @@ export default function RulesTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ form.setFieldsValue({
priority: 100, enabled: true, action: 'accept', log: false, priority: 100, enabled: true, action: 'accept', log: false,
@@ -213,7 +213,7 @@ export default function RulesTab() {
<Input placeholder="SSH von Office" /> <Input placeholder="SSH von Office" />
</Form.Item> </Form.Item>
<Space size="middle" style={{ display: 'flex', flexWrap: 'wrap' }}> <Space size="middle" className="flex-wrap">
<Form.Item label={t('fw.rule.priority')} name="priority" rules={[{ required: true }]}> <Form.Item label={t('fw.rule.priority')} name="priority" rules={[{ required: true }]}>
<InputNumber min={0} max={9999} /> <InputNumber min={0} max={9999} />
</Form.Item> </Form.Item>
@@ -262,7 +262,7 @@ export default function RulesTab() {
</Space> </Space>
))} ))}
<Space size="middle" style={{ display: 'flex', flexWrap: 'wrap' }}> <Space size="middle" className="flex-wrap">
<Form.Item label={t('fw.rule.serviceKind')} name="service_kind" rules={[{ required: true }]}> <Form.Item label={t('fw.rule.serviceKind')} name="service_kind" rules={[{ required: true }]}>
<Select style={{ width: 120 }} options={(['any','object','group'] as const).map(k => ({ value: k, label: k }))} /> <Select style={{ width: 120 }} options={(['any','object','group'] as const).map(k => ({ value: k, label: k }))} />
</Form.Item> </Form.Item>

View File

@@ -85,7 +85,7 @@ export default function ServiceGroupsTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] }) form.setFieldsValue({ member_ids: [] })
}}> }}>

View File

@@ -89,7 +89,7 @@ export default function ServicesTab() {
return ( return (
<> <>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => { <Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields() setCreating(true); form.resetFields()
form.setFieldsValue({ proto: 'tcp' }) form.setFieldsValue({ proto: 'tcp' })
}}> }}>

View File

@@ -38,6 +38,7 @@ case "$1" in
cat > /etc/sudoers.d/edgeguard <<'SUDOERS' cat > /etc/sudoers.d/edgeguard <<'SUDOERS'
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service
edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/edgeguard/nftables.d/ruleset.nft
SUDOERS SUDOERS
chmod 0440 /etc/sudoers.d/edgeguard chmod 0440 /etc/sudoers.d/edgeguard