package production import ( "fmt" "os/exec" "strings" ) // FirewallConfig holds the configuration for UFW firewall rules type FirewallConfig struct { SSHPort int // default 22 IsNameserver bool // enables port 53 TCP+UDP AnyoneORPort int // 0 = disabled, typically 9001 WireGuardPort int // default 51820 TURNEnabled bool // enables TURN relay ports (3478/udp, 443/udp, relay range) TURNRelayStart int // start of TURN relay port range (default 49152) TURNRelayEnd int // end of TURN relay port range (default 65535) } // FirewallProvisioner manages UFW firewall setup type FirewallProvisioner struct { config FirewallConfig } // NewFirewallProvisioner creates a new firewall provisioner func NewFirewallProvisioner(config FirewallConfig) *FirewallProvisioner { if config.SSHPort == 0 { config.SSHPort = 22 } if config.WireGuardPort == 0 { config.WireGuardPort = 51820 } return &FirewallProvisioner{ config: config, } } // IsInstalled checks if UFW is available func (fp *FirewallProvisioner) IsInstalled() bool { _, err := exec.LookPath("ufw") return err == nil } // Install installs UFW if not present func (fp *FirewallProvisioner) Install() error { if fp.IsInstalled() { return nil } cmd := exec.Command("apt-get", "install", "-y", "ufw") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to install ufw: %w\n%s", err, string(output)) } return nil } // GenerateRules returns the list of UFW commands to apply func (fp *FirewallProvisioner) GenerateRules() []string { rules := []string{ // Reset to clean state "ufw --force reset", // Default policies "ufw default deny incoming", "ufw default allow outgoing", // SSH (always required) fmt.Sprintf("ufw allow %d/tcp", fp.config.SSHPort), // WireGuard (always required for mesh) fmt.Sprintf("ufw allow %d/udp", fp.config.WireGuardPort), // Public web services "ufw allow 80/tcp", // ACME / HTTP redirect "ufw allow 443/tcp", // HTTPS (Caddy → Gateway) } // DNS (only for nameserver nodes) if fp.config.IsNameserver { rules = append(rules, "ufw allow 53/tcp") rules = append(rules, "ufw allow 53/udp") } // Anyone relay ORPort if fp.config.AnyoneORPort > 0 { rules = append(rules, fmt.Sprintf("ufw allow %d/tcp", fp.config.AnyoneORPort)) } // TURN relay (only for nodes running TURN servers) if fp.config.TURNEnabled { rules = append(rules, "ufw allow 3478/udp") // TURN standard port rules = append(rules, "ufw allow 443/udp") // TURN TLS port (does not conflict with Caddy TCP 443) if fp.config.TURNRelayStart > 0 && fp.config.TURNRelayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", fp.config.TURNRelayStart, fp.config.TURNRelayEnd)) } } // Allow all traffic from WireGuard subnet (inter-node encrypted traffic) rules = append(rules, "ufw allow from 10.0.0.0/8") // Enable firewall rules = append(rules, "ufw --force enable") // Accept all WireGuard traffic before conntrack can classify it as "invalid". // UFW's built-in "ct state invalid → DROP" runs before user rules like // "allow from 10.0.0.0/8". Packets arriving through the WireGuard tunnel // can be misclassified as "invalid" by conntrack due to reordering/jitter // (especially between high-latency peers), causing silent packet drops. // Inserting at position 1 in INPUT ensures this runs before UFW chains. rules = append(rules, "iptables -I INPUT 1 -i wg0 -s 10.0.0.0/8 -j ACCEPT") return rules } // Setup applies all firewall rules. Idempotent — safe to call multiple times. func (fp *FirewallProvisioner) Setup() error { if err := fp.Install(); err != nil { return err } rules := fp.GenerateRules() for _, rule := range rules { parts := strings.Fields(rule) cmd := exec.Command(parts[0], parts[1:]...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply firewall rule '%s': %w\n%s", rule, err, string(output)) } } return nil } // IsActive checks if UFW is active func (fp *FirewallProvisioner) IsActive() bool { cmd := exec.Command("ufw", "status") output, err := cmd.CombinedOutput() if err != nil { return false } return strings.Contains(string(output), "Status: active") } // AddWebRTCRules dynamically adds TURN port rules without a full firewall reset. // Used when enabling WebRTC on a namespace. func (fp *FirewallProvisioner) AddWebRTCRules(relayStart, relayEnd int) error { rules := []string{ "ufw allow 3478/udp", "ufw allow 443/udp", } if relayStart > 0 && relayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", relayStart, relayEnd)) } for _, rule := range rules { parts := strings.Fields(rule) cmd := exec.Command(parts[0], parts[1:]...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to add firewall rule '%s': %w\n%s", rule, err, string(output)) } } return nil } // RemoveWebRTCRules dynamically removes TURN port rules without a full firewall reset. // Used when disabling WebRTC on a namespace. func (fp *FirewallProvisioner) RemoveWebRTCRules(relayStart, relayEnd int) error { rules := []string{ "ufw delete allow 3478/udp", "ufw delete allow 443/udp", } if relayStart > 0 && relayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw delete allow %d:%d/udp", relayStart, relayEnd)) } for _, rule := range rules { parts := strings.Fields(rule) cmd := exec.Command(parts[0], parts[1:]...) // Ignore errors on delete — rule may not exist cmd.CombinedOutput() } return nil } // GetStatus returns the current UFW status func (fp *FirewallProvisioner) GetStatus() (string, error) { cmd := exec.Command("ufw", "status", "verbose") output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to get ufw status: %w\n%s", err, string(output)) } return string(output), nil }