package firewall import ( "fmt" "log/slog" "strconv" "strings" "github.com/docker/docker/libnetwork/iptables" "github.com/psviderski/uncloud/internal/machine/constants" "github.com/psviderski/uncloud/internal/machine/corroservice" "github.com/psviderski/uncloud/internal/machine/network" ) const ( DockerUserChain = "DOCKER-USER" UncloudInputChain = "UNCLOUD-INPUT" ) // ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking. func ConfigureIptablesChains() error { if err := createIptablesChains(); err != nil { return err } ipt4 := iptables.GetIptable(iptables.IPv4) ipt6 := iptables.GetIptable(iptables.IPv6) // Allow WireGuard traffic to the machine. acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"} err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule) if err != nil { return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err) } // Allow cluster machines to access Machine API via the management IPv6 WireGuard network. acceptMachineAPIRule := []string{ "-i", network.WireGuardInterfaceName, "-s", "fdcc::/16", "-p", "tcp", "--dport", strconv.Itoa(constants.MachineAPIPort), "-j", "ACCEPT", } // Allow Corrosion gossip traffic from cluster machines via the management IPv6 WireGuard network. acceptCorrosionGossipRule := []string{ "-i", network.WireGuardInterfaceName, "-s", "fdcc::/16", "-p", "udp", "--dport", strconv.Itoa(corroservice.DefaultGossipPort), "-j", "ACCEPT", } for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} { if err = ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil { return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err) } } return nil } // createIptablesChains ensures UNCLOUD-INPUT iptables and ip6tables chains exist and // there are jump rules from the main INPUT chains. func createIptablesChains() error { ipt4 := iptables.GetIptable(iptables.IPv4) ipt6 := iptables.GetIptable(iptables.IPv6) for i, ipt := range []*iptables.IPTable{ipt4, ipt6} { iptBin := "iptables" if i == 1 { iptBin = "ip6tables" } // Ensure UNCLOUD-INPUT chain exists. All existing rules are flushed. if _, err := ipt.NewChain(UncloudInputChain, iptables.Filter); err != nil { return fmt.Errorf("create %s chain '%s': %w", iptBin, UncloudInputChain, err) } if err := ipt.RawCombinedOutput("-t", string(iptables.Filter), "-F", UncloudInputChain); err != nil { return fmt.Errorf("flush %s chain '%s': %w", iptBin, UncloudInputChain, err) } // Ensure the main INPUT chain has a jump rule to the UNCLOUD-INPUT chain before any DROP/REJECT rules. jumpRule := []string{"-m", "comment", "--comment", "Uncloud-managed", "-j", UncloudInputChain} if !ipt.Exists(iptables.Filter, "INPUT", jumpRule...) { // Look for the first DROP/REJECT rule in the INPUT chain. out, err := ipt.Raw("-t", string(iptables.Filter), "-L", "INPUT", "--line-numbers") if err != nil { return fmt.Errorf("get %s rules for chain '%s': %w", iptBin, UncloudInputChain, err) } firstRejectRuleNum := 0 for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } if fields[1] == "DROP" || fields[1] == "REJECT" { if ruleNum, err := strconv.Atoi(fields[0]); err == nil { firstRejectRuleNum = ruleNum break } } } var addJumpRule []string if firstRejectRuleNum > 0 { addJumpRule = append([]string{"-t", string(iptables.Filter), "-I", "INPUT", strconv.Itoa(firstRejectRuleNum)}, jumpRule...) } else { addJumpRule = append([]string{"-t", string(iptables.Filter), "-A", "INPUT"}, jumpRule...) } if err = ipt.RawCombinedOutput(addJumpRule...); err != nil { return fmt.Errorf("add %s rule '%s': %w", iptBin, strings.Join(addJumpRule, " "), err) } } } return nil } // CleanupIptablesChains removes the custom iptables chains and rules created by ConfigureIptablesChains. func CleanupIptablesChains() error { ipt4 := iptables.GetIptable(iptables.IPv4) ipt6 := iptables.GetIptable(iptables.IPv6) for i, ipt := range []*iptables.IPTable{ipt4, ipt6} { iptBin := "iptables" if i == 1 { iptBin = "ip6tables" } // First, remove the jump rule from INPUT chain to UNCLOUD-INPUT. jumpRule := []string{"-m", "comment", "--comment", "Uncloud-managed", "-j", UncloudInputChain} if err := ipt.ProgramRule(iptables.Filter, "INPUT", iptables.Delete, jumpRule); err != nil { return fmt.Errorf("delete %s jump rule from INPUT: %w", iptBin, err) } // Flush all rules from UNCLOUD-INPUT chain as it must be empty before deletion. if err := ipt.RawCombinedOutput("-t", string(iptables.Filter), "-F", UncloudInputChain); err != nil { // Chain might not exist which is fine. if !strings.Contains(err.Error(), "No chain") { return fmt.Errorf("flush %s chain '%s': %w", iptBin, UncloudInputChain, err) } } // Delete the UNCLOUD-INPUT chain. if err := ipt.RawCombinedOutput("-t", string(iptables.Filter), "-X", UncloudInputChain); err != nil { // Chain might not exist which is fine. if !strings.Contains(err.Error(), "No chain") { return fmt.Errorf("delete %s chain '%s': %w", iptBin, UncloudInputChain, err) } } else { slog.Info(fmt.Sprintf("Deleted %s chain.", iptBin), "chain", UncloudInputChain) } } return nil }