From 35d0a9012549d65b298385777b151c7ca1f3a6ad Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Mon, 4 Aug 2025 16:50:18 +1000 Subject: [PATCH] fix: add machine with UFW firewall (allow ipv6 management traffic) fixes #65 --- internal/machine/cluster.go | 7 +- internal/machine/constants/constants.go | 6 + internal/machine/firewall/iptables_linux.go | 178 +++++++++++++------- internal/machine/machine.go | 3 +- internal/machine/network/ip.go | 2 +- pkg/client/connector/wireguard.go | 4 +- 6 files changed, 129 insertions(+), 71 deletions(-) create mode 100644 internal/machine/constants/constants.go diff --git a/internal/machine/cluster.go b/internal/machine/cluster.go index 196fab55..7a547177 100644 --- a/internal/machine/cluster.go +++ b/internal/machine/cluster.go @@ -15,6 +15,7 @@ import ( "github.com/docker/docker/client" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/caddyconfig" + "github.com/psviderski/uncloud/internal/machine/constants" "github.com/psviderski/uncloud/internal/machine/corroservice" "github.com/psviderski/uncloud/internal/machine/dns" "github.com/psviderski/uncloud/internal/machine/docker" @@ -25,10 +26,6 @@ import ( "google.golang.org/grpc" ) -const ( - APIPort = 51000 -) - // clusterController is the main controller for the machine that is a cluster member. It manages components such as // the WireGuard network, API server listening the WireGuard network, Corrosion service, Docker network and containers, // and others. @@ -120,7 +117,7 @@ func (cc *clusterController) Run(ctx context.Context) error { errGroup, ctx := errgroup.WithContext(ctx) // Start the network API server. Assume the management IP can't be changed when the network is running. - apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(APIPort)) + apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(constants.MachineAPIPort)) listener, err := net.Listen("tcp", apiAddr) if err != nil { return fmt.Errorf("listen API port: %w", err) diff --git a/internal/machine/constants/constants.go b/internal/machine/constants/constants.go new file mode 100644 index 00000000..9568c570 --- /dev/null +++ b/internal/machine/constants/constants.go @@ -0,0 +1,6 @@ +package constants + +const ( + // MachineAPIPort is the port for the Machine API service on the management WireGuard network. + MachineAPIPort = 51000 +) diff --git a/internal/machine/firewall/iptables_linux.go b/internal/machine/firewall/iptables_linux.go index f0e25339..793313ca 100644 --- a/internal/machine/firewall/iptables_linux.go +++ b/internal/machine/firewall/iptables_linux.go @@ -7,6 +7,8 @@ import ( "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" ) @@ -17,86 +19,138 @@ const ( // ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking. func ConfigureIptablesChains() error { - // Ensure iptables UNCLOUD-INPUT chain exists. All existing rules are flushed. - ipt := iptables.GetIptable(iptables.IPv4) - if _, err := ipt.NewChain(UncloudInputChain, iptables.Filter); err != nil { - return fmt.Errorf("create iptables chain '%s': %w", UncloudInputChain, err) - } - if err := ipt.RawCombinedOutput("-t", string(iptables.Filter), "-F", UncloudInputChain); err != nil { - return fmt.Errorf("flush iptables chain '%s': %w", UncloudInputChain, err) + if err := createIptablesChains(); err != nil { + return err } - // Ensure the main iptables 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 iptables rules for chain '%s': %w", 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 iptables rule '%s': %w", strings.Join(addJumpRule, " "), 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 := ipt.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule) + 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 { - ipt := iptables.GetIptable(iptables.IPv4) + ipt4 := iptables.GetIptable(iptables.IPv4) + ipt6 := iptables.GetIptable(iptables.IPv6) - // 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 iptables jump rule from INPUT: %w", 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 iptables chain '%s': %w", UncloudInputChain, err) + for i, ipt := range []*iptables.IPTable{ipt4, ipt6} { + iptBin := "iptables" + if i == 1 { + iptBin = "ip6tables" } - } - // 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 iptables chain '%s': %w", UncloudInputChain, err) + // 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("Deleted %s chain.", "chain", iptBin, UncloudInputChain) } - } else { - slog.Info("Deleted iptables chain.", "chain", UncloudInputChain) } return nil diff --git a/internal/machine/machine.go b/internal/machine/machine.go index b1276e42..ed6dabb1 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -24,6 +24,7 @@ import ( apiproxy "github.com/psviderski/uncloud/internal/machine/api/proxy" "github.com/psviderski/uncloud/internal/machine/caddyconfig" "github.com/psviderski/uncloud/internal/machine/cluster" + "github.com/psviderski/uncloud/internal/machine/constants" "github.com/psviderski/uncloud/internal/machine/corroservice" "github.com/psviderski/uncloud/internal/machine/dns" machinedocker "github.com/psviderski/uncloud/internal/machine/docker" @@ -233,7 +234,7 @@ func NewMachine(config *Config) (*Machine, error) { } // Init a local gRPC proxy server that proxies requests to the local or remote machine API servers. - proxyDirector := apiproxy.NewDirector(config.MachineSockPath, APIPort) + proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort) localProxyServer := grpc.NewServer( grpc.ForceServerCodecV2(proxy.Codec()), grpc.UnknownServiceHandler( diff --git a/internal/machine/network/ip.go b/internal/machine/network/ip.go index 4b6a3833..7be9ad99 100644 --- a/internal/machine/network/ip.go +++ b/internal/machine/network/ip.go @@ -14,7 +14,7 @@ func MachineIP(subnet netip.Prefix) netip.Addr { } // ManagementIP returns the IPv6 address of a peer derived from the first 14 bytes of its public key. -// This address is intended for cluster management traffic. +// This address always starts with fdcc: and is intended for cluster management traffic. func ManagementIP(publicKey secret.Secret) netip.Addr { bytes := [16]byte{0xfd, 0xcc} copy(bytes[2:], publicKey[:14]) diff --git a/pkg/client/connector/wireguard.go b/pkg/client/connector/wireguard.go index a76b3ad4..0a3ca496 100644 --- a/pkg/client/connector/wireguard.go +++ b/pkg/client/connector/wireguard.go @@ -8,7 +8,7 @@ import ( "strconv" "github.com/psviderski/uncloud/internal/cli/config" - machine2 "github.com/psviderski/uncloud/internal/machine" + "github.com/psviderski/uncloud/internal/machine/constants" "github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/machine/network/tunnel" "github.com/psviderski/uncloud/pkg/client" @@ -49,7 +49,7 @@ func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, err } endpoint := netip.AddrPortFrom(endpointAddr, tunnel.DefaultEndpointPort) machineManagementIP := network.ManagementIP(machine.PublicKey) - machineAPIAddr := net.JoinHostPort(machineManagementIP.String(), strconv.Itoa(machine2.APIPort)) + machineAPIAddr := net.JoinHostPort(machineManagementIP.String(), strconv.Itoa(constants.MachineAPIPort)) tunCfg := &tunnel.Config{ LocalAddress: c.user.ManagementIP(),