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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}}
|
||||||
{{- 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}}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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' })
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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' })
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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' })
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user