- Add storage.WriteFileAtomic (temp + rename) and storage.CopyFile helpers - Convert all 8 production config writers to atomic writes: config/state.yaml, dnsmasq, nftables, domains, wireguard (config + secrets + peers + wg0.conf), tunnel/cloudflared - Add sync.Mutex to Reconciler to serialize concurrent Reconcile() calls triggered by domain registration goroutines - Add state.yaml backup (.bak) before every write; LoadState falls back to backup if primary is corrupted - Reconciler refuses to use empty config on corruption (only on first run when no state file exists yet)
194 lines
6.4 KiB
Go
194 lines
6.4 KiB
Go
package nftables
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/storage"
|
|
)
|
|
|
|
const defaultRulesPath = "/etc/nftables.d/wild-cloud.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 Cloud nftables rules\n")
|
|
sb.WriteString("# Managed by Wild Cloud Central API — 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-cloud {}\n")
|
|
sb.WriteString("delete table inet wild-cloud\n\n")
|
|
|
|
sb.WriteString("table inet wild-cloud {\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(" type filter hook input priority filter; 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
|
|
}
|
|
|
|
// 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-cloud-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-cloud-nftables-reload.service.
|
|
func (m *Manager) ApplyRules() error {
|
|
cmd := exec.Command("systemctl", "start", "wild-cloud-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-cloud table,
|
|
// removing all Wild Cloud firewall rules from the kernel when applied.
|
|
func (m *Manager) WriteDisabledRules() error {
|
|
content := "# Wild Cloud nftables rules — firewall disabled\n" +
|
|
"# Managed by Wild Cloud Central API — do not edit manually\n\n" +
|
|
"table inet wild-cloud {}\n" +
|
|
"delete table inet wild-cloud\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-cloud 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-cloud")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Table may not exist yet — not an error
|
|
return "", nil
|
|
}
|
|
return string(output), nil
|
|
}
|