diff --git a/app/internal/firewall/firewall_linux.go b/app/internal/firewall/firewall_linux.go index d841617..accb16e 100644 --- a/app/internal/firewall/firewall_linux.go +++ b/app/internal/firewall/firewall_linux.go @@ -111,7 +111,13 @@ func setupNFTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redi if match := nftDestinationMatch(family, listenAddr); match != nil { args = append(args, match...) } - args = append(args, "udp", "dport", nftPortExpr(portRange), "redirect", "to", fmt.Sprintf(":%d", ports[0].Start)) + if family == "ip6" && listenAddr.IP != nil && !listenAddr.IP.IsUnspecified() { + args = append(args, "udp", "dport", nftPortExpr(portRange), "dnat", "to", + fmt.Sprintf("[%s]:%d", listenAddr.IP.String(), ports[0].Start)) + } else { + args = append(args, "udp", "dport", nftPortExpr(portRange), "redirect", "to", + fmt.Sprintf(":%d", ports[0].Start)) + } if err := nft(args...); err != nil { _ = cleanup.Close() return nil, err @@ -142,7 +148,19 @@ func setupIPTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redi _ = ipt("-t", "nat", "-F", chainName) _ = ipt("-t", "nat", "-X", chainName) }) - if err := ipt("-t", "nat", "-A", chainName, "-p", "udp", "-j", "REDIRECT", "--to-ports", fmt.Sprintf("%d", ports[0].Start)); err != nil { + var targetArgs []string + if bin == "ip6tables" && listenAddr.IP != nil && !listenAddr.IP.IsUnspecified() { + targetArgs = []string{ + "-t", "nat", "-A", chainName, "-p", "udp", "-j", "DNAT", + "--to-destination", fmt.Sprintf("[%s]:%d", listenAddr.IP.String(), ports[0].Start), + } + } else { + targetArgs = []string{ + "-t", "nat", "-A", chainName, "-p", "udp", "-j", "REDIRECT", + "--to-ports", fmt.Sprintf("%d", ports[0].Start), + } + } + if err := ipt(targetArgs...); err != nil { _ = cleanup.Close() return nil, err } diff --git a/app/internal/firewall/firewall_linux_test.go b/app/internal/firewall/firewall_linux_test.go index f6003ce..fc46f97 100644 --- a/app/internal/firewall/firewall_linux_test.go +++ b/app/internal/firewall/firewall_linux_test.go @@ -78,6 +78,140 @@ func TestSetupUDPPortRedirectWithRunnerIPTablesFallback(t *testing.T) { require.True(t, foundDelete) } +func TestSetupUDPPortRedirectWithRunnerNFTablesIPv6Specific(t *testing.T) { + runner := &fakeRunner{paths: map[string]bool{"nft": true}} + addr := &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 20000} + ports := eUtils.PortUnion{{20000, 20002}} + + cleanup, err := setupUDPPortRedirectWithRunner(runner, addr, ports) + require.NoError(t, err) + require.NotNil(t, cleanup) + + for _, cmd := range runner.cmds { + hasUDP := false + for _, arg := range cmd { + if arg == "udp" { + hasUDP = true + break + } + } + if !hasUDP { + continue + } + hasDnat := false + hasRedirect := false + for _, arg := range cmd { + if arg == "dnat" { + hasDnat = true + } + if arg == "redirect" { + hasRedirect = true + } + } + require.True(t, hasDnat, "IPv6 specific address rule should use dnat: %v", cmd) + require.False(t, hasRedirect, "IPv6 specific address rule must not use redirect: %v", cmd) + require.Contains(t, cmd, "[2001:db8::1]:20000") + } + + require.NoError(t, cleanup.Close()) +} + +func TestSetupUDPPortRedirectWithRunnerNFTablesIPv6Unspecified(t *testing.T) { + runner := &fakeRunner{paths: map[string]bool{"nft": true}} + addr := &net.UDPAddr{IP: net.IPv6unspecified, Port: 20000} + ports := eUtils.PortUnion{{20000, 20002}} + + cleanup, err := setupUDPPortRedirectWithRunner(runner, addr, ports) + require.NoError(t, err) + require.NotNil(t, cleanup) + + for _, cmd := range runner.cmds { + hasDnat := false + for _, arg := range cmd { + if arg == "dnat" { + hasDnat = true + break + } + } + require.False(t, hasDnat, "IPv6 unspecified address rule must not use dnat: %v", cmd) + } + + require.NoError(t, cleanup.Close()) +} + +func TestSetupUDPPortRedirectWithRunnerIPTablesIPv6Specific(t *testing.T) { + runner := &fakeRunner{paths: map[string]bool{"ip6tables": true}} + addr := &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 20000} + ports := eUtils.PortUnion{{20000, 20002}} + + cleanup, err := setupUDPPortRedirectWithRunner(runner, addr, ports) + require.NoError(t, err) + require.NotNil(t, cleanup) + + foundTarget := false + for _, cmd := range runner.cmds { + hasDNAT := false + hasREDIRECT := false + for _, arg := range cmd { + if arg == "DNAT" { + hasDNAT = true + } + if arg == "REDIRECT" { + hasREDIRECT = true + } + } + require.False(t, hasREDIRECT, "IPv6 specific address rule must not use REDIRECT: %v", cmd) + if hasDNAT { + foundTarget = true + require.Contains(t, cmd, "[2001:db8::1]:20000") + } + } + require.True(t, foundTarget, "expected a DNAT rule for specific IPv6 bind") + + require.NoError(t, cleanup.Close()) +} + +func TestSetupUDPPortRedirectWithRunnerIPTablesIPv6Unspecified(t *testing.T) { + runner := &fakeRunner{paths: map[string]bool{"iptables": true, "ip6tables": true}} + addr := &net.UDPAddr{IP: net.IPv6unspecified, Port: 20000} + ports := eUtils.PortUnion{{20000, 20002}} + + cleanup, err := setupUDPPortRedirectWithRunner(runner, addr, ports) + require.NoError(t, err) + require.NotNil(t, cleanup) + + for _, cmd := range runner.cmds { + for _, arg := range cmd { + require.NotEqual(t, "DNAT", arg, "unspecified address rule must not use DNAT: %v", cmd) + } + } + + require.NoError(t, cleanup.Close()) +} + +func TestSetupUDPPortRedirectWithRunnerIPTablesIPv4Specific(t *testing.T) { + runner := &fakeRunner{paths: map[string]bool{"iptables": true}} + addr := &net.UDPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 20000} + ports := eUtils.PortUnion{{20000, 20002}} + + cleanup, err := setupUDPPortRedirectWithRunner(runner, addr, ports) + require.NoError(t, err) + require.NotNil(t, cleanup) + + foundRedirect := false + for _, cmd := range runner.cmds { + for _, arg := range cmd { + require.NotEqual(t, "DNAT", arg, "IPv4 rule must keep REDIRECT, not DNAT: %v", cmd) + if arg == "REDIRECT" { + foundRedirect = true + } + } + } + require.True(t, foundRedirect, "expected REDIRECT rule for IPv4 bind") + + require.NoError(t, cleanup.Close()) +} + func TestSetupUDPPortRedirectWithRunnerRollback(t *testing.T) { runner := &fakeRunner{ paths: map[string]bool{"iptables": true},