CrowdSec's blacklist chain runs at priority 0. Our loopback accept rule needs to run first so local traffic isn't blocked. Also cleans up .bak files after successful SafeApply.
266 lines
8.4 KiB
Go
266 lines
8.4 KiB
Go
package nftables
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/storage"
|
|
)
|
|
|
|
const defaultRulesPath = "/etc/nftables.d/wild-central.nft"
|
|
|
|
// Manager handles nftables rule generation and application
|
|
type Manager struct {
|
|
rulesPath string
|
|
}
|
|
|
|
// NewManager creates a new nftables manager
|
|
func NewManager(rulesPath string) *Manager {
|
|
if rulesPath == "" {
|
|
rulesPath = defaultRulesPath
|
|
}
|
|
return &Manager{rulesPath: rulesPath}
|
|
}
|
|
|
|
// GetRulesPath returns the nftables rules file path
|
|
func (m *Manager) GetRulesPath() string {
|
|
return m.rulesPath
|
|
}
|
|
|
|
// Generate creates nftables rules that track HAProxy listening ports plus any extra ports.
|
|
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use
|
|
// and enables strict WAN filtering when wanInterface is configured.
|
|
// extraTCPPorts and extraUDPPorts are user-specified ports to allow beyond the HAProxy set.
|
|
func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPorts []int, wanInterface string) string {
|
|
// Merge and deduplicate TCP ports (HAProxy + extra TCP)
|
|
seen := make(map[int]bool)
|
|
var tcpPorts []int
|
|
for _, p := range haproxyPorts {
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
tcpPorts = append(tcpPorts, p)
|
|
}
|
|
}
|
|
for _, p := range extraTCPPorts {
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
tcpPorts = append(tcpPorts, p)
|
|
}
|
|
}
|
|
|
|
// Build UDP exception list: DNS + DHCP hardcoded, plus user extras
|
|
udpBase := []int{53, 67, 68}
|
|
seenUDP := make(map[int]bool)
|
|
for _, p := range udpBase {
|
|
seenUDP[p] = true
|
|
}
|
|
udpPorts := append([]int{}, udpBase...)
|
|
for _, p := range extraUDPPorts {
|
|
if !seenUDP[p] {
|
|
seenUDP[p] = true
|
|
udpPorts = append(udpPorts, p)
|
|
}
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString("# Wild Central nftables rules\n")
|
|
sb.WriteString("# Managed by Wild Central — do not edit manually\n\n")
|
|
|
|
// Delete and recreate the table so re-applying never accumulates stale set elements.
|
|
// The first line ensures the table exists (so delete never errors on first run).
|
|
sb.WriteString("table inet wild-central {}\n")
|
|
sb.WriteString("delete table inet wild-central\n\n")
|
|
|
|
sb.WriteString("table inet wild-central {\n")
|
|
sb.WriteString(" # TCP ports allowed through the firewall (HAProxy + extra TCP)\n")
|
|
sb.WriteString(" set allowed_tcp_ports {\n")
|
|
sb.WriteString(" type inet_service\n")
|
|
sb.WriteString(" flags interval\n")
|
|
if len(tcpPorts) > 0 {
|
|
fmt.Fprintf(&sb, " elements = { %s }\n", portsToStr(tcpPorts))
|
|
}
|
|
sb.WriteString(" }\n\n")
|
|
|
|
sb.WriteString(" # UDP ports allowed through the firewall (DNS/DHCP + extra UDP)\n")
|
|
sb.WriteString(" set allowed_udp_ports {\n")
|
|
sb.WriteString(" type inet_service\n")
|
|
sb.WriteString(" flags interval\n")
|
|
if len(udpPorts) > 0 {
|
|
fmt.Fprintf(&sb, " elements = { %s }\n", portsToStr(udpPorts))
|
|
}
|
|
sb.WriteString(" }\n\n")
|
|
|
|
sb.WriteString(" chain input {\n")
|
|
sb.WriteString(" # Priority -1 so loopback accept runs before CrowdSec's blacklist chain (priority 0)\n")
|
|
sb.WriteString(" type filter hook input priority -1; policy accept;\n\n")
|
|
sb.WriteString(" iif \"lo\" accept\n")
|
|
sb.WriteString(" ct state { established, related } accept\n")
|
|
|
|
if wanInterface != "" {
|
|
sb.WriteString("\n # Strict WAN filtering: only allow listed TCP and UDP ports\n")
|
|
fmt.Fprintf(&sb, " iif \"%s\" tcp dport != @allowed_tcp_ports drop\n", wanInterface)
|
|
fmt.Fprintf(&sb, " iif \"%s\" udp dport != @allowed_udp_ports drop\n", wanInterface)
|
|
}
|
|
|
|
sb.WriteString(" }\n")
|
|
sb.WriteString("}\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// portsToStr converts a slice of ports to a comma-separated string
|
|
func portsToStr(ports []int) string {
|
|
strs := make([]string, len(ports))
|
|
for i, p := range ports {
|
|
strs[i] = fmt.Sprintf("%d", p)
|
|
}
|
|
return strings.Join(strs, ", ")
|
|
}
|
|
|
|
// ValidateRules tests an nftables rules file for syntax errors.
|
|
// Uses sudo to allow the wildcloud user to run the read-only check.
|
|
func (m *Manager) ValidateRules(rulesPath string) error {
|
|
cmd := exec.Command("sudo", "nft", "-c", "-f", rulesPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("nftables validation failed: %w (output: %s)", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateWANInterface checks that the specified WAN interface exists on the system.
|
|
// Returns nil if empty (no WAN filtering) or if the interface exists.
|
|
func ValidateWANInterface(name string) error {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if _, err := net.InterfaceByName(name); err != nil {
|
|
return fmt.Errorf("WAN interface %q not found: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SafeApply validates, backs up, writes, and applies nftables rules.
|
|
// On apply failure, rolls back to the previous rules.
|
|
func (m *Manager) SafeApply(content string) error {
|
|
// Validate syntax
|
|
tmpFile := "/tmp/wild-central-nft-safeapply.tmp"
|
|
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("writing validation file: %w", err)
|
|
}
|
|
defer os.Remove(tmpFile)
|
|
|
|
if err := m.ValidateRules(tmpFile); err != nil {
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Backup current rules
|
|
if storage.FileExists(m.rulesPath) {
|
|
_ = storage.CopyFile(m.rulesPath, m.rulesPath+".bak")
|
|
}
|
|
|
|
// Write atomically
|
|
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("write failed: %w", err)
|
|
}
|
|
|
|
// Apply to kernel
|
|
if err := m.ApplyRules(); err != nil {
|
|
m.rollback()
|
|
return fmt.Errorf("apply failed (rolled back): %w", err)
|
|
}
|
|
|
|
// Verify rules loaded
|
|
if err := m.verify(); err != nil {
|
|
m.rollback()
|
|
return fmt.Errorf("verification failed (rolled back): %w", err)
|
|
}
|
|
|
|
os.Remove(m.rulesPath + ".bak")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) rollback() {
|
|
bakPath := m.rulesPath + ".bak"
|
|
if storage.FileExists(bakPath) {
|
|
_ = os.Rename(bakPath, m.rulesPath)
|
|
_ = m.ApplyRules()
|
|
slog.Warn("rolled back to previous rules", "component", "nftables", "path", m.rulesPath)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) verify() error {
|
|
status, err := m.GetStatus()
|
|
if err != nil || status == "" {
|
|
return fmt.Errorf("nftables table not loaded after apply")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteRules validates the content then writes the nftables rules file.
|
|
// Validation uses a temp file in /tmp (world-writable) to avoid needing
|
|
// write permission on the /etc/nftables.d/ directory itself.
|
|
func (m *Manager) WriteRules(content string) error {
|
|
tempFile := "/tmp/wild-central-nft-validate.tmp"
|
|
|
|
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("writing temp rules: %w", err)
|
|
}
|
|
defer os.Remove(tempFile)
|
|
|
|
if err := m.ValidateRules(tempFile); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("writing rules file: %w", err)
|
|
}
|
|
|
|
slog.Info("nftables rules written", "component", "nftables", "path", m.rulesPath)
|
|
return nil
|
|
}
|
|
|
|
// ApplyRules loads the rules file into the kernel via a systemd oneshot service.
|
|
// The wildcloud user has polkit permission to start wild-central-nftables-reload.service.
|
|
func (m *Manager) ApplyRules() error {
|
|
cmd := exec.Command("systemctl", "start", "wild-central-nftables-reload.service")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output))
|
|
}
|
|
slog.Info("nftables rules applied", "component", "nftables")
|
|
return nil
|
|
}
|
|
|
|
// WriteDisabledRules writes a rules file that flushes the wild-central table,
|
|
// removing all Wild Central firewall rules from the kernel when applied.
|
|
func (m *Manager) WriteDisabledRules() error {
|
|
content := "# Wild Central nftables rules — firewall disabled\n" +
|
|
"# Managed by Wild Central — do not edit manually\n\n" +
|
|
"table inet wild-central {}\n" +
|
|
"delete table inet wild-central\n"
|
|
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("writing disabled rules file: %w", err)
|
|
}
|
|
slog.Info("nftables rules disabled", "component", "nftables", "path", m.rulesPath)
|
|
return nil
|
|
}
|
|
|
|
// GetStatus returns the current wild-central nftables table as a string.
|
|
// Uses sudo to allow the wildcloud user to read kernel state.
|
|
func (m *Manager) GetStatus() (string, error) {
|
|
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Table may not exist yet — not an error
|
|
return "", nil
|
|
}
|
|
return string(output), nil
|
|
}
|