From 6467f3b4ada725ff52b36556c68b4103beb3cda8 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 25 Feb 2026 22:03:01 +0000 Subject: [PATCH 01/12] Flush using AF_UNSPEC and netfilter package --- internal/netlink/conntrack_linux.go | 33 +++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/internal/netlink/conntrack_linux.go b/internal/netlink/conntrack_linux.go index 9ece2722b..868e25e82 100644 --- a/internal/netlink/conntrack_linux.go +++ b/internal/netlink/conntrack_linux.go @@ -5,6 +5,7 @@ import ( "github.com/mdlayher/netlink" "github.com/ti-mo/netfilter" + "golang.org/x/sys/unix" ) func (n *NetLink) FlushConntrack() error { @@ -14,25 +15,21 @@ func (n *NetLink) FlushConntrack() error { } defer conn.Close() - families := [...]netfilter.ProtoFamily{netfilter.ProtoIPv4, netfilter.ProtoIPv6} - for _, family := range families { - const IPCtnlMsgCtDelete = 2 - request, err := netfilter.MarshalNetlink( - netfilter.Header{ - SubsystemID: netfilter.NFSubsysCTNetlink, - MessageType: netfilter.MessageType(IPCtnlMsgCtDelete), - Family: family, - Flags: netlink.Request | netlink.Acknowledge, - }, - nil) - if err != nil { - return fmt.Errorf("encoding netlink request: %w", err) - } + const ipCtnlMsgCtDelete = netfilter.MessageType(2) + header := netfilter.Header{ + SubsystemID: netfilter.NFSubsysCTNetlink, + MessageType: ipCtnlMsgCtDelete, + Family: unix.AF_UNSPEC, + Flags: netlink.Request | netlink.Acknowledge, + } + request, err := netfilter.MarshalNetlink(header, nil) + if err != nil { + return fmt.Errorf("encoding netlink request: %w", err) + } - _, err = conn.Query(request) - if err != nil { - return fmt.Errorf("querying netlink request: %w", err) - } + _, err = conn.Query(request) + if err != nil { + return fmt.Errorf("querying netlink request: %w", err) } return nil } From dfac2b2f1a5a0d21813eb2c7d54c36759c6ba893 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 25 Feb 2026 22:08:23 +0000 Subject: [PATCH 02/12] Flush conntrack on every firewall enabling --- cmd/gluetun/main.go | 6 +----- internal/firewall/enable.go | 5 +++++ internal/firewall/firewall.go | 6 ++++-- internal/firewall/interfaces.go | 4 ++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index c222a6327..49b19cc72 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -227,7 +227,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, firewallLogger.Patch(log.SetLevel(log.LevelDebug)) } firewallConf, err := firewall.NewConfig(ctx, firewallLogger, cmder, - defaultRoutes, localNetworks) + netLinker, defaultRoutes, localNetworks) if err != nil { return err } @@ -237,10 +237,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, if err != nil { return err } - err = netLinker.FlushConntrack() - if err != nil { - logger.Warnf("flushing conntrack failed: %s", err) - } } // TODO run this in a loop or in openvpn to reload from file without restarting diff --git a/internal/firewall/enable.go b/internal/firewall/enable.go index c0f9aa8d4..28328e26f 100644 --- a/internal/firewall/enable.go +++ b/internal/firewall/enable.go @@ -121,6 +121,11 @@ func (c *Config) enable(ctx context.Context) (err error) { return fmt.Errorf("running user defined post firewall rules: %w", err) } + err = c.netlinker.FlushConntrack() + if err != nil { + c.logger.Warn("flushing conntrack failed: " + err.Error()) + } + return nil } diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 3b9b902a9..aba688317 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -13,6 +13,7 @@ import ( type Config struct { runner CmdRunner + netlinker Netlinker logger Logger defaultRoutes []routing.DefaultRoute localNetworks []routing.LocalNetwork @@ -35,8 +36,8 @@ type Config struct { // NewConfig creates a new Config instance and returns an error // if no iptables implementation is available. func NewConfig(ctx context.Context, logger Logger, - runner CmdRunner, defaultRoutes []routing.DefaultRoute, - localNetworks []routing.LocalNetwork, + runner CmdRunner, netlinker Netlinker, + defaultRoutes []routing.DefaultRoute, localNetworks []routing.LocalNetwork, ) (config *Config, err error) { impl, err := iptables.New(ctx, runner, logger) if err != nil { @@ -45,6 +46,7 @@ func NewConfig(ctx context.Context, logger Logger, return &Config{ runner: runner, + netlinker: netlinker, logger: logger, allowedInputPorts: make(map[uint16]map[string]struct{}), // Obtained from routing diff --git a/internal/firewall/interfaces.go b/internal/firewall/interfaces.go index a1938f96e..3352b3bbd 100644 --- a/internal/firewall/interfaces.go +++ b/internal/firewall/interfaces.go @@ -19,6 +19,10 @@ type Logger interface { Error(s string) } +type Netlinker interface { + FlushConntrack() error +} + type firewallImpl interface { //nolint:interfacebloat SaveAndRestore(ctx context.Context) (restore func(context.Context), err error) AcceptEstablishedRelatedTraffic(ctx context.Context) error From a37354426b786599d2418c87b1827c429d0d2457 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 26 Feb 2026 15:53:07 +0000 Subject: [PATCH 03/12] Fallback to accepting only NEW output public traffic if conntrack netlink isn't supported --- internal/firewall/enable.go | 10 +-- internal/firewall/flush.go | 27 +++++++ internal/firewall/interfaces.go | 4 +- internal/firewall/iptables/firewall.go | 5 ++ internal/firewall/iptables/iptables.go | 73 +++++++++++++++++ internal/firewall/iptables/kernel.go | 47 +++++++++++ internal/firewall/iptables/list.go | 35 +++++++-- internal/firewall/iptables/parse.go | 96 ++++++++++++++++++++--- internal/firewall/iptables/parse_test.go | 10 +-- internal/firewall/iptables/tcp.go | 2 +- internal/netlink/conntrack_linux.go | 7 ++ internal/netlink/conntrack_unspecified.go | 4 + internal/netlink/netlink.go | 12 ++- internal/pmtud/pmtud.go | 2 +- internal/pmtud/tcp/helpers_test.go | 2 +- internal/pmtud/tcp/mss.go | 2 +- 16 files changed, 302 insertions(+), 36 deletions(-) create mode 100644 internal/firewall/flush.go create mode 100644 internal/firewall/iptables/kernel.go diff --git a/internal/firewall/enable.go b/internal/firewall/enable.go index 28328e26f..71fa67c32 100644 --- a/internal/firewall/enable.go +++ b/internal/firewall/enable.go @@ -69,6 +69,11 @@ func (c *Config) enable(ctx context.Context) (err error) { return err } + err = c.flushExistingConnections(ctx) + if err != nil { + return fmt.Errorf("flushing existing connections: %w", err) + } + if err = c.impl.AcceptEstablishedRelatedTraffic(ctx); err != nil { return err } @@ -121,11 +126,6 @@ func (c *Config) enable(ctx context.Context) (err error) { return fmt.Errorf("running user defined post firewall rules: %w", err) } - err = c.netlinker.FlushConntrack() - if err != nil { - c.logger.Warn("flushing conntrack failed: " + err.Error()) - } - return nil } diff --git a/internal/firewall/flush.go b/internal/firewall/flush.go new file mode 100644 index 000000000..010927d1b --- /dev/null +++ b/internal/firewall/flush.go @@ -0,0 +1,27 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + + "github.com/qdm12/gluetun/internal/netlink" +) + +// Note remove is a no-op if conntrack netlink is supported by the kernel. +func (c *Config) flushExistingConnections(ctx context.Context) error { + err := c.netlinker.FlushConntrack() + switch { + case err == nil: + return nil + case errors.Is(err, netlink.ErrConntrackNetlinkNotSupported): + c.logger.Debugf("falling back to marking and filtering unmarked packets because flush conntrack failed: %s", err) + err = c.impl.AcceptOutputPublicOnlyNewTraffic(ctx) + if err != nil { + return fmt.Errorf("accepting only new output public traffic: %w", err) + } + return nil + default: + return fmt.Errorf("flushing conntrack: %w", err) + } +} diff --git a/internal/firewall/interfaces.go b/internal/firewall/interfaces.go index 3352b3bbd..74a2afc3b 100644 --- a/internal/firewall/interfaces.go +++ b/internal/firewall/interfaces.go @@ -14,6 +14,7 @@ type CmdRunner interface { type Logger interface { Debug(s string) + Debugf(format string, args ...any) Info(s string) Warn(s string) Error(s string) @@ -25,8 +26,9 @@ type Netlinker interface { type firewallImpl interface { //nolint:interfacebloat SaveAndRestore(ctx context.Context) (restore func(context.Context), err error) - AcceptEstablishedRelatedTraffic(ctx context.Context) error + AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error AcceptInputThroughInterface(ctx context.Context, intf string) error + AcceptEstablishedRelatedTraffic(ctx context.Context) error AcceptInputToPort(ctx context.Context, intf string, port uint16, remove bool) error AcceptInputToSubnet(ctx context.Context, intf string, subnet netip.Prefix) error AcceptIpv6MulticastOutput(ctx context.Context, intf string) error diff --git a/internal/firewall/iptables/firewall.go b/internal/firewall/iptables/firewall.go index aeedae63b..c1e8a59ff 100644 --- a/internal/firewall/iptables/firewall.go +++ b/internal/firewall/iptables/firewall.go @@ -2,9 +2,12 @@ package iptables import ( "context" + "errors" "sync" ) +var ErrKernelModuleMissing = errors.New("kernel module is missing for this operation") + type Config struct { runner CmdRunner logger Logger @@ -14,6 +17,7 @@ type Config struct { // Fixed state ipTables string ip6Tables string + modules kernelModules } func New(ctx context.Context, runner CmdRunner, logger Logger) (*Config, error) { @@ -32,5 +36,6 @@ func New(ctx context.Context, runner CmdRunner, logger Logger) (*Config, error) logger: logger, ipTables: iptables, ip6Tables: ip6tables, + modules: newKernelModules(), }, nil } diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index 486e9fab3..8b8738043 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -141,12 +141,85 @@ func (c *Config) AcceptOutputThroughInterface(ctx context.Context, intf string, } func (c *Config) AcceptEstablishedRelatedTraffic(ctx context.Context) error { + if !c.modules.nfConntrack.ok { + return fmt.Errorf("%w: %s", ErrKernelModuleMissing, c.modules.nfConntrack.name) + } return c.runMixedIptablesInstructions(ctx, []string{ "--append OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", "--append INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", }) } +// AcceptOutputPublicOnlyNewTraffic adds rules to mark new output connections, and to accept +// established or related packets with this mark only. This effectively forces +// previously established or related traffic to be blocked. +// If remove is true, the rules are removed instead of appended. +// If the relevant kernel modules (nf_conntrack, xt_conntrack and xt_connmark) +// are not available, it returns an error indicating which kernel module is missing. +func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { + err := checkKernelModulesAreOK(c.modules.nfConntrack, c.modules.xtConntrack, c.modules.xtConnmark) + if err != nil { + return fmt.Errorf("checking kernel modules: %w", err) + } + + ipv4PrivatePrefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("127.0.0.0/8"), + } + ipv6PrivatePrefixes := []netip.Prefix{ + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("::1/128"), + } + var ipv4Instructions, ipv6Instructions []string //nolint:prealloc + appendToBoth := func(instruction string) { + ipv4Instructions = append(ipv4Instructions, instruction) + ipv6Instructions = append(ipv6Instructions, instruction) + } + appendToBoth("-N PUBLIC_ONLY") + for _, prefix := range ipv4PrivatePrefixes { + ipv4Instructions = append(ipv4Instructions, fmt.Sprintf( + "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) + } + for _, prefix := range ipv6PrivatePrefixes { + ipv6Instructions = append(ipv6Instructions, fmt.Sprintf( + "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) + } + // Mark new connections with mark 0x567 + appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate NEW -j CONNMARK --set-mark 0x567") + // Drop related/established connections that made it through; marked connections would + // be directly accepted by the first rule in the OUTPUT chain (see below) + appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate RELATED,ESTABLISHED -j DROP") + // Set the PUBLIC_ONLY chain as the second rule in the OUTPUT chain, so that it is evaluated + // after the accept rule below, for performance reasons. + appendToBoth("-I OUTPUT -j PUBLIC_ONLY") + appendToBoth("-I OUTPUT -m conntrack --ctstate RELATED,ESTABLISHED -m connmark --mark 0x567 -j ACCEPT") + + c.iptablesMutex.Lock() + c.ip6tablesMutex.Lock() + defer c.iptablesMutex.Unlock() + defer c.ip6tablesMutex.Unlock() + + restore, err := c.saveAndRestore(ctx) + if err != nil { + return err + } + + err = c.runIptablesInstructionsNoSave(ctx, ipv4Instructions) + if err != nil { + restore(ctx) + return err + } + err = c.runIP6tablesInstructionsNoSave(ctx, ipv6Instructions) + if err != nil { + restore(ctx) + return err + } + return nil +} + func (c *Config) AcceptOutputTrafficToVPN(ctx context.Context, defaultInterface string, connection models.Connection, remove bool, ) error { diff --git a/internal/firewall/iptables/kernel.go b/internal/firewall/iptables/kernel.go new file mode 100644 index 000000000..5b506e425 --- /dev/null +++ b/internal/firewall/iptables/kernel.go @@ -0,0 +1,47 @@ +package iptables + +import ( + "fmt" + "strings" + + "github.com/qdm12/gluetun/internal/mod" +) + +type kernelModules struct { + nfConntrack kernelModule + xtConnmark kernelModule + xtConntrack kernelModule +} + +type kernelModule struct { + name string + ok bool +} + +func newKernelModules() kernelModules { + var m kernelModules + nameToFieldPtr := map[string]*kernelModule{ + "nf_conntrack_netlink": &m.nfConntrack, + "xt_connmark": &m.xtConnmark, + "xt_conntrack": &m.xtConntrack, + } + for name, fieldPtr := range nameToFieldPtr { + fieldPtr.name = name + err := mod.Probe(name) + fieldPtr.ok = err == nil + } + return m +} + +func checkKernelModulesAreOK(modules ...kernelModule) error { + missing := make([]string, 0, len(modules)) + for _, module := range modules { + if !module.ok { + missing = append(missing, module.name) + } + } + if len(missing) > 0 { + return fmt.Errorf("%w: %s", ErrKernelModuleMissing, strings.Join(missing, ", ")) + } + return nil +} diff --git a/internal/firewall/iptables/list.go b/internal/firewall/iptables/list.go index 49f855fe4..38d918c52 100644 --- a/internal/firewall/iptables/list.go +++ b/internal/firewall/iptables/list.go @@ -33,6 +33,8 @@ type chainRule struct { ctstate []string // for example ["RELATED","ESTABLISHED"]. Can be empty. tcpFlags tcpFlags mark mark + connMark mark + setMark uint } type mark struct { @@ -293,6 +295,29 @@ func parseChainRuleOptionalFields(optionalFields []string, rule *chainRule) (err } rule.mark = mark i += consumed + case "connmark": + i++ + connMark, consumed, err := parseMark(optionalFields[i:]) + if err != nil { + return fmt.Errorf("parsing connmark: %w", err) + } + rule.connMark = connMark + i += consumed + case "CONNMARK": + i++ + switch optionalFields[i] { + case "set": + i++ + value, err := parseAny32bNumber(optionalFields[i]) + if err != nil { + return fmt.Errorf("parsing CONNMARK set value: %w", err) + } + rule.setMark = value + i++ + default: + return fmt.Errorf("%w: unexpected %q after CONNMARK", + ErrChainRuleMalformed, optionalFields[i]) + } default: return fmt.Errorf("%w: unexpected optional field: %s", ErrChainRuleMalformed, optionalFields[i]) @@ -422,8 +447,6 @@ func parsePortsCSV(s string) (ports []uint16, err error) { return ports, nil } -var errMarkValueMalformed = errors.New("mark value is malformed") - func parseMark(optionalFields []string) (m mark, consumed int, err error) { switch optionalFields[consumed] { case "match": @@ -433,13 +456,11 @@ func parseMark(optionalFields []string) (m mark, consumed int, err error) { consumed++ } - const base = 0 // auto-detect - const bits = 32 - value, err := strconv.ParseUint(optionalFields[consumed], base, bits) + value, err := parseAny32bNumber(optionalFields[consumed]) if err != nil { - return mark{}, 0, fmt.Errorf("%w: %s", errMarkValueMalformed, optionalFields[consumed]) + return mark{}, 0, fmt.Errorf("value malformed: %w", err) } - m.value = uint(value) + m.value = value consumed++ default: return mark{}, 0, fmt.Errorf("%w: unexpected mark mode field: %s", diff --git a/internal/firewall/iptables/parse.go b/internal/firewall/iptables/parse.go index a18d7714b..fda21aaf9 100644 --- a/internal/firewall/iptables/parse.go +++ b/internal/firewall/iptables/parse.go @@ -9,9 +9,19 @@ import ( "strings" ) +type operation uint8 + +const ( + opNone operation = iota + opAppend + opDelete + opInsert + opReplace +) + type iptablesInstruction struct { table string // defaults to "filter", and can be "nat" for example. - append bool + operation operation chain string // for example INPUT, PREROUTING. Cannot be empty. target string // for example ACCEPT. Can be empty. protocol string // "tcp" or "udp" or "" for all protocols. @@ -25,6 +35,8 @@ type iptablesInstruction struct { ctstate []string // if empty, there is no ctstate tcpFlags tcpFlags mark mark + connMark mark + setMark uint // only used for jump CONNMARK --set-mark } func (i *iptablesInstruction) setDefaults() { @@ -65,6 +77,10 @@ func (i *iptablesInstruction) equalToRule(table, chain string, rule chainRule) ( return false case i.mark != rule.mark: return false + case i.connMark != rule.connMark: + return false + case i.setMark != rule.setMark: + return false default: return true } @@ -113,13 +129,20 @@ func parseInstructionFlag(fields []string, instruction *iptablesInstruction) (co case "-t", "--table": instruction.table = value case "-D", "--delete": - instruction.append = false + instruction.operation = opDelete instruction.chain = value case "-A", "--append": - instruction.append = true + instruction.operation = opAppend + instruction.chain = value + case "-I", "--insert": + instruction.operation = opInsert instruction.chain = value case "-j", "--jump": - instruction.target = value + subConsumed, err := parseJumpFlag(fields[1:], instruction) + if err != nil { + return 0, fmt.Errorf("parsing jump flag: %w", err) + } + consumed += subConsumed case "-p", "--protocol": instruction.protocol = value case "-m", "--match": @@ -128,13 +151,11 @@ func parseInstructionFlag(fields []string, instruction *iptablesInstruction) (co return 0, fmt.Errorf("parsing match module: %w", err) } case "--mark": - const base = 0 // auto-detect - const bits = 32 - value, err := strconv.ParseUint(value, base, bits) + n, err := parseAny32bNumber(value) if err != nil { - return 0, fmt.Errorf("parsing mark value %q: %w", fields[2], err) + return 0, fmt.Errorf("parsing mark value %q: %w", value, err) } - instruction.mark.value = uint(value) + instruction.mark.value = n case "-i", "--in-interface": instruction.inputInterface = value case "-o", "--out-interface": @@ -182,7 +203,7 @@ func preCheckInstructionFields(fields []string) (consumed int, err error) { flag := fields[0] // All flags use one value after the flag, except the following: switch flag { - case "--tcp-flags": // -m can have 1 or 2 values + case "--tcp-flags": const expected = 3 if len(fields) < expected { return 0, fmt.Errorf("%w: flag %q requires at least 2 values, but got %s", @@ -199,6 +220,34 @@ func preCheckInstructionFields(fields []string) (consumed int, err error) { } } +func parseJumpFlag(fields []string, instruction *iptablesInstruction) (consumed int, err error) { + instruction.target = fields[0] + // consumed in the caller already takes fields[0] into account + if instruction.target != "CONNMARK" { + return consumed, nil + } + // consumed already accounts for the "CONNMARK" value + const expectedFields = 3 + if len(fields) < expectedFields { + return 0, fmt.Errorf("%w: jump CONNMARK requires at least two additional values", + ErrIptablesCommandMalformed) + } + switch fields[1] { + case "--set-mark": + n, err := parseAny32bNumber(fields[2]) + if err != nil { + return 0, fmt.Errorf("parsing connmark mark value %q: %w", fields[2], err) + } + consumed++ + instruction.setMark = n + default: + return consumed, fmt.Errorf("%w: unsupported jump CONNMARK with value: %s", + ErrIptablesCommandMalformed, fields[1]) + } + consumed++ + return consumed, nil +} + func parseIPPrefix(value string) (prefix netip.Prefix, err error) { slashIndex := strings.Index(value, "/") if slashIndex >= 0 { @@ -221,6 +270,13 @@ func parsePort(value string) (port uint16, err error) { return uint16(portValue), nil } +func parseAny32bNumber(mark string) (value uint, err error) { + const base = 0 // auto-detect + const bits = 32 + n, err := strconv.ParseUint(mark, base, bits) + return uint(n), err +} + func parseMatchModule(fields []string, instruction *iptablesInstruction) ( consumed int, err error, ) { @@ -234,14 +290,30 @@ func parseMatchModule(fields []string, instruction *iptablesInstruction) ( // parse it twice. case "mark": consumed++ - switch fields[consumed] { - case "!": + switch { + case len(fields[consumed:]) == 0 || strings.HasPrefix(fields[consumed], "-"): + // end or another flag + return consumed, nil + case fields[consumed] == "!": consumed++ instruction.mark.invert = true default: return consumed, fmt.Errorf("%w: unsupported match mark with value: %s", ErrIptablesCommandMalformed, fields[2]) } + case "connmark": + consumed++ + switch { + case len(fields[consumed:]) == 0 || strings.HasPrefix(fields[consumed], "-"): + // end or another flag + return consumed, nil + case fields[consumed] == "!": + consumed++ + instruction.connMark.invert = true + default: + return consumed, fmt.Errorf("%w: unsupported match connmark with value: %s", + ErrIptablesCommandMalformed, fields[2]) + } default: return 0, fmt.Errorf("%w: unknown match value: %s", ErrIptablesCommandMalformed, fields[consumed]) diff --git a/internal/firewall/iptables/parse_test.go b/internal/firewall/iptables/parse_test.go index 51c5ab7cc..cdf66b836 100644 --- a/internal/firewall/iptables/parse_test.go +++ b/internal/firewall/iptables/parse_test.go @@ -33,9 +33,9 @@ func Test_parseIptablesInstruction(t *testing.T) { "one_pair": { s: "-A INPUT", instruction: iptablesInstruction{ - table: "filter", - chain: "INPUT", - append: true, + table: "filter", + chain: "INPUT", + operation: opAppend, }, }, "instruction_A": { @@ -43,7 +43,7 @@ func Test_parseIptablesInstruction(t *testing.T) { instruction: iptablesInstruction{ table: "filter", chain: "INPUT", - append: true, + operation: opAppend, inputInterface: "tun0", protocol: "tcp", source: netip.MustParsePrefix("1.2.3.4/32"), @@ -57,7 +57,7 @@ func Test_parseIptablesInstruction(t *testing.T) { instruction: iptablesInstruction{ table: "nat", chain: "PREROUTING", - append: false, + operation: opDelete, inputInterface: "tun0", protocol: "tcp", destinationPort: 43716, diff --git a/internal/firewall/iptables/tcp.go b/internal/firewall/iptables/tcp.go index 77e5c5f28..99dbde364 100644 --- a/internal/firewall/iptables/tcp.go +++ b/internal/firewall/iptables/tcp.go @@ -64,7 +64,7 @@ func parseTCPFlag(s string) (tcpFlag, error) { return 0, fmt.Errorf("%w: %s", errTCPFlagUnknown, s) } -var ErrMarkMatchModuleMissing = errors.New("kernel is missing the mark module libxt_mark.so") +var ErrMarkMatchModuleMissing = errors.New("libxt_mark.so module is missing") // TempDropOutputTCPRST temporarily drops outgoing TCP RST packets to the specified address and port, // for any TCP packets not marked with the excludeMark given. diff --git a/internal/netlink/conntrack_linux.go b/internal/netlink/conntrack_linux.go index 868e25e82..088320776 100644 --- a/internal/netlink/conntrack_linux.go +++ b/internal/netlink/conntrack_linux.go @@ -1,6 +1,7 @@ package netlink import ( + "errors" "fmt" "github.com/mdlayher/netlink" @@ -8,7 +9,13 @@ import ( "golang.org/x/sys/unix" ) +var ErrConntrackNetlinkNotSupported = errors.New("nf_conntrack_netlink is not supported by the kernel") + func (n *NetLink) FlushConntrack() error { + if !n.conntrackNetlink { + return fmt.Errorf("%w", ErrConntrackNetlinkNotSupported) + } + conn, err := netfilter.Dial(nil) if err != nil { return fmt.Errorf("dialing netfilter: %w", err) diff --git a/internal/netlink/conntrack_unspecified.go b/internal/netlink/conntrack_unspecified.go index d2652b267..5ed2a24ac 100644 --- a/internal/netlink/conntrack_unspecified.go +++ b/internal/netlink/conntrack_unspecified.go @@ -2,6 +2,10 @@ package netlink +import "errors" + +var ErrConntrackNetlinkNotSupported = errors.New("error not implemented") + func (n *NetLink) FlushConntrack() error { panic("not implemented") } diff --git a/internal/netlink/netlink.go b/internal/netlink/netlink.go index 9a26ab08d..bc2950826 100644 --- a/internal/netlink/netlink.go +++ b/internal/netlink/netlink.go @@ -1,14 +1,22 @@ package netlink -import "github.com/qdm12/log" +import ( + "github.com/qdm12/gluetun/internal/mod" + "github.com/qdm12/log" +) type NetLink struct { debugLogger DebugLogger + + // Fixed state + conntrackNetlink bool } func New(debugLogger DebugLogger) *NetLink { + conntrackNetlink := mod.Probe("nf_conntrack_netlink") == nil return &NetLink{ - debugLogger: debugLogger, + debugLogger: debugLogger, + conntrackNetlink: conntrackNetlink, } } diff --git a/internal/pmtud/pmtud.go b/internal/pmtud/pmtud.go index 4505c0e96..63fe6715a 100644 --- a/internal/pmtud/pmtud.go +++ b/internal/pmtud/pmtud.go @@ -74,7 +74,7 @@ func PathMTUDiscover(ctx context.Context, icmpAddrs []netip.Addr, tcpAddrs []net } mtu, err = tcp.PathMTUDiscover(ctx, tcpAddrs, minMTU, maxPossibleMTU, tryTimeout, fw, logger) if err != nil { - if errors.Is(err, iptables.ErrMarkMatchModuleMissing) { + if errors.Is(err, iptables.ErrKernelModuleMissing) { logger.Debugf("aborting TCP path MTU discovery: %s", err) if icmpSuccess { return maxPossibleMTU, nil // only rely on ICMP PMTUD results diff --git a/internal/pmtud/tcp/helpers_test.go b/internal/pmtud/tcp/helpers_test.go index e5a21e059..e0ad925d6 100644 --- a/internal/pmtud/tcp/helpers_test.go +++ b/internal/pmtud/tcp/helpers_test.go @@ -35,7 +35,7 @@ func getFirewall(t *testing.T) *firewall.Config { noopLogger := &noopLogger{} cmder := command.New() var err error - testFirewall, err = firewall.NewConfig(t.Context(), noopLogger, cmder, nil, nil) + testFirewall, err = firewall.NewConfig(t.Context(), noopLogger, cmder, nil, nil, nil) if errors.Is(err, iptables.ErrNotSupported) { t.Skip("iptables not installed, skipping TCP PMTUD tests") } diff --git a/internal/pmtud/tcp/mss.go b/internal/pmtud/tcp/mss.go index bedc2a5d9..f92467aa0 100644 --- a/internal/pmtud/tcp/mss.go +++ b/internal/pmtud/tcp/mss.go @@ -43,7 +43,7 @@ func findHighestMSSDestination(ctx context.Context, familyToFD map[int]fileDescr if result.err != nil { switch { case err != nil: // error already occurred for another findMSS goroutine - case errors.Is(result.err, iptables.ErrMarkMatchModuleMissing): + case errors.Is(result.err, iptables.ErrKernelModuleMissing): err = fmt.Errorf("finding MSS for %s: %w", result.dst, result.err) case dst.Addr().Is6() && errors.Is(result.err, ip.ErrNetworkUnreachable): // silently discard IPv6 network unreachable errors since they are common From f654dece661233d9be8785d314a424b6299866d0 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 26 Feb 2026 18:04:23 +0000 Subject: [PATCH 04/12] Reject output public ip traffic for 1s as another fallback --- internal/firewall/flush.go | 28 ++++++++ internal/firewall/interfaces.go | 1 + internal/firewall/iptables/iptables.go | 92 ++++++++++++++++++++------ internal/firewall/iptables/kernel.go | 10 ++- internal/firewall/iptables/list.go | 5 ++ internal/firewall/iptables/parse.go | 7 +- 6 files changed, 118 insertions(+), 25 deletions(-) diff --git a/internal/firewall/flush.go b/internal/firewall/flush.go index 010927d1b..0ef567ff7 100644 --- a/internal/firewall/flush.go +++ b/internal/firewall/flush.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "time" + "github.com/qdm12/gluetun/internal/firewall/iptables" "github.com/qdm12/gluetun/internal/netlink" ) @@ -18,6 +20,10 @@ func (c *Config) flushExistingConnections(ctx context.Context) error { c.logger.Debugf("falling back to marking and filtering unmarked packets because flush conntrack failed: %s", err) err = c.impl.AcceptOutputPublicOnlyNewTraffic(ctx) if err != nil { + if errors.Is(err, iptables.ErrKernelModuleMissing) { + c.logger.Debugf("falling back to killing connections for one second because marking packets failed: %s", err) + return c.rejectOutputTrafficTemporarily(ctx) + } return fmt.Errorf("accepting only new output public traffic: %w", err) } return nil @@ -25,3 +31,25 @@ func (c *Config) flushExistingConnections(ctx context.Context) error { return fmt.Errorf("flushing conntrack: %w", err) } } + +func (c *Config) rejectOutputTrafficTemporarily(ctx context.Context) error { + remove := false + err := c.impl.RejectOutputPublicTraffic(ctx, remove) + if err != nil { + return fmt.Errorf("rejecting only new output public traffic: %w", err) + } + timer := time.NewTimer(time.Second) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + } + remove = true + // Use [context.Background] to make sure this is removed, even if the context + // passed to this function is canceled. + err = c.impl.RejectOutputPublicTraffic(context.Background(), remove) + if err != nil { + return fmt.Errorf("reverting rejecting only new output public traffic: %w", err) + } + return nil +} diff --git a/internal/firewall/interfaces.go b/internal/firewall/interfaces.go index 74a2afc3b..ed22d6589 100644 --- a/internal/firewall/interfaces.go +++ b/internal/firewall/interfaces.go @@ -27,6 +27,7 @@ type Netlinker interface { type firewallImpl interface { //nolint:interfacebloat SaveAndRestore(ctx context.Context) (restore func(context.Context), err error) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error + RejectOutputPublicTraffic(ctx context.Context, remove bool) error AcceptInputThroughInterface(ctx context.Context, intf string) error AcceptEstablishedRelatedTraffic(ctx context.Context) error AcceptInputToPort(ctx context.Context, intf string, port uint16, remove bool) error diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index 8b8738043..cd6c31006 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -162,31 +162,12 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { return fmt.Errorf("checking kernel modules: %w", err) } - ipv4PrivatePrefixes := []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("172.16.0.0/12"), - netip.MustParsePrefix("192.168.0.0/16"), - netip.MustParsePrefix("127.0.0.0/8"), - } - ipv6PrivatePrefixes := []netip.Prefix{ - netip.MustParsePrefix("fc00::/7"), - netip.MustParsePrefix("fe80::/10"), - netip.MustParsePrefix("::1/128"), - } - var ipv4Instructions, ipv6Instructions []string //nolint:prealloc + ipv4Instructions, ipv6Instructions := makeCreatePublicIPChainInstructions() appendToBoth := func(instruction string) { ipv4Instructions = append(ipv4Instructions, instruction) ipv6Instructions = append(ipv6Instructions, instruction) } - appendToBoth("-N PUBLIC_ONLY") - for _, prefix := range ipv4PrivatePrefixes { - ipv4Instructions = append(ipv4Instructions, fmt.Sprintf( - "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) - } - for _, prefix := range ipv6PrivatePrefixes { - ipv6Instructions = append(ipv6Instructions, fmt.Sprintf( - "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) - } + // Mark new connections with mark 0x567 appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate NEW -j CONNMARK --set-mark 0x567") // Drop related/established connections that made it through; marked connections would @@ -220,6 +201,75 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { return nil } +func (c *Config) RejectOutputPublicTraffic(ctx context.Context, remove bool) error { + removeInstructions := []string{ + "-D OUTPUT -j PUBLIC_ONLY", + "-F PUBLIC_ONLY", + "-X PUBLIC_ONLY", + } + if remove { + return c.runMixedIptablesInstructions(ctx, removeInstructions) + } + + err := checkKernelModulesAreOK(c.modules.nfConntrack, c.modules.nfRejectIPv4, c.modules.xtReject) + if err != nil { + return fmt.Errorf("checking kernel modules: %w", err) + } + + ipv4Instructions, ipv6Instructions := makeCreatePublicIPChainInstructions() + appendToBoth := func(instruction string) { + ipv4Instructions = append(ipv4Instructions, instruction) + ipv6Instructions = append(ipv6Instructions, instruction) + } + + // Block UDP and ICMP, sending back ICMP port unreachable. + appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate RELATED,ESTABLISHED -j REJECT") + // Block TCP by sending back TCP RST packets. + appendToBoth("-A PUBLIC_ONLY -p tcp -m conntrack --ctstate RELATED,ESTABLISHED " + + "-j REJECT --reject-with tcp-reset") + appendToBoth("-I OUTPUT -j PUBLIC_ONLY") + + err = c.runIptablesInstructions(ctx, ipv4Instructions) + if err != nil { + return err + } + err = c.runIP6tablesInstructions(ctx, ipv6Instructions) + if err != nil { + _ = c.runIptablesInstructions(ctx, removeInstructions) + return err + } + return nil +} + +func makeCreatePublicIPChainInstructions() (ipv4Instructions, ipv6Instructions []string) { + ipv4PrivatePrefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("127.0.0.0/8"), + } + ipv6PrivatePrefixes := []netip.Prefix{ + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("::1/128"), + } + + ipv4Instructions = append(ipv4Instructions, "-N PUBLIC_ONLY") + ipv6Instructions = append(ipv6Instructions, "-N PUBLIC_ONLY") + + for _, prefix := range ipv4PrivatePrefixes { + ipv4Instructions = append(ipv4Instructions, fmt.Sprintf( + "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) + } + + for _, prefix := range ipv6PrivatePrefixes { + ipv6Instructions = append(ipv6Instructions, fmt.Sprintf( + "-A PUBLIC_ONLY -d %s -j RETURN", prefix)) + } + + return ipv4Instructions, ipv6Instructions +} + func (c *Config) AcceptOutputTrafficToVPN(ctx context.Context, defaultInterface string, connection models.Connection, remove bool, ) error { diff --git a/internal/firewall/iptables/kernel.go b/internal/firewall/iptables/kernel.go index 5b506e425..60e085146 100644 --- a/internal/firewall/iptables/kernel.go +++ b/internal/firewall/iptables/kernel.go @@ -8,9 +8,11 @@ import ( ) type kernelModules struct { - nfConntrack kernelModule - xtConnmark kernelModule - xtConntrack kernelModule + nfConntrack kernelModule + nfRejectIPv4 kernelModule + xtConnmark kernelModule + xtConntrack kernelModule + xtReject kernelModule } type kernelModule struct { @@ -22,8 +24,10 @@ func newKernelModules() kernelModules { var m kernelModules nameToFieldPtr := map[string]*kernelModule{ "nf_conntrack_netlink": &m.nfConntrack, + "nf_reject_ipv4": &m.nfRejectIPv4, "xt_connmark": &m.xtConnmark, "xt_conntrack": &m.xtConntrack, + "xt_reject": &m.xtReject, } for name, fieldPtr := range nameToFieldPtr { fieldPtr.name = name diff --git a/internal/firewall/iptables/list.go b/internal/firewall/iptables/list.go index 38d918c52..c6f8168ee 100644 --- a/internal/firewall/iptables/list.go +++ b/internal/firewall/iptables/list.go @@ -35,6 +35,7 @@ type chainRule struct { mark mark connMark mark setMark uint + rejectWith string // for example "tcp-reset", only used for REJECT targets } type mark struct { @@ -295,6 +296,10 @@ func parseChainRuleOptionalFields(optionalFields []string, rule *chainRule) (err } rule.mark = mark i += consumed + case "reject-with": + i++ + rule.rejectWith = optionalFields[i] // for example "tcp-reset" + i++ case "connmark": i++ connMark, consumed, err := parseMark(optionalFields[i:]) diff --git a/internal/firewall/iptables/parse.go b/internal/firewall/iptables/parse.go index fda21aaf9..eb3a1923a 100644 --- a/internal/firewall/iptables/parse.go +++ b/internal/firewall/iptables/parse.go @@ -36,7 +36,8 @@ type iptablesInstruction struct { tcpFlags tcpFlags mark mark connMark mark - setMark uint // only used for jump CONNMARK --set-mark + setMark uint // only used for jump CONNMARK --set-mark + rejectWith string // only used for REJECT targets } func (i *iptablesInstruction) setDefaults() { @@ -81,6 +82,8 @@ func (i *iptablesInstruction) equalToRule(table, chain string, rule chainRule) ( return false case i.setMark != rule.setMark: return false + case i.rejectWith != rule.rejectWith: + return false default: return true } @@ -193,6 +196,8 @@ func parseInstructionFlag(fields []string, instruction *iptablesInstruction) (co if err != nil { return 0, fmt.Errorf("parsing TCP flags: %w", err) } + case "--reject-with": + instruction.rejectWith = value // for example "tcp-reset" default: return 0, fmt.Errorf("%w: unknown key %q", ErrIptablesCommandMalformed, flag) } From 302f1f11f73d94e8bfaad2248590c76a091005e8 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 26 Feb 2026 20:49:28 +0000 Subject: [PATCH 05/12] only use kernel modules error as context to an actual error, not as a requirement since some systems don't show what they support reliably --- internal/firewall/iptables/iptables.go | 42 +++++++++++++++++--------- internal/netlink/conntrack_linux.go | 10 +++--- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index cd6c31006..2d5713513 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -141,13 +141,14 @@ func (c *Config) AcceptOutputThroughInterface(ctx context.Context, intf string, } func (c *Config) AcceptEstablishedRelatedTraffic(ctx context.Context) error { - if !c.modules.nfConntrack.ok { - return fmt.Errorf("%w: %s", ErrKernelModuleMissing, c.modules.nfConntrack.name) - } - return c.runMixedIptablesInstructions(ctx, []string{ + err := c.runMixedIptablesInstructions(ctx, []string{ "--append OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", "--append INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", }) + if err != nil && !c.modules.nfConntrack.ok { + return fmt.Errorf("%w: %s", ErrKernelModuleMissing, c.modules.nfConntrack.name) + } + return err } // AcceptOutputPublicOnlyNewTraffic adds rules to mark new output connections, and to accept @@ -157,11 +158,6 @@ func (c *Config) AcceptEstablishedRelatedTraffic(ctx context.Context) error { // If the relevant kernel modules (nf_conntrack, xt_conntrack and xt_connmark) // are not available, it returns an error indicating which kernel module is missing. func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { - err := checkKernelModulesAreOK(c.modules.nfConntrack, c.modules.xtConntrack, c.modules.xtConnmark) - if err != nil { - return fmt.Errorf("checking kernel modules: %w", err) - } - ipv4Instructions, ipv6Instructions := makeCreatePublicIPChainInstructions() appendToBoth := func(instruction string) { ipv4Instructions = append(ipv4Instructions, instruction) @@ -188,16 +184,26 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { return err } + kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, + c.modules.xtConntrack, c.modules.xtConnmark) + err = c.runIptablesInstructionsNoSave(ctx, ipv4Instructions) if err != nil { restore(ctx) + if strings.Contains(err.Error(), "support") && kernelErr != nil { + err = fmt.Errorf("%w: %w", err, kernelErr) + } return err } err = c.runIP6tablesInstructionsNoSave(ctx, ipv6Instructions) if err != nil { restore(ctx) + if strings.Contains(err.Error(), "support") && kernelErr != nil { + err = fmt.Errorf("%w: %w", err, kernelErr) + } return err } + return nil } @@ -211,11 +217,6 @@ func (c *Config) RejectOutputPublicTraffic(ctx context.Context, remove bool) err return c.runMixedIptablesInstructions(ctx, removeInstructions) } - err := checkKernelModulesAreOK(c.modules.nfConntrack, c.modules.nfRejectIPv4, c.modules.xtReject) - if err != nil { - return fmt.Errorf("checking kernel modules: %w", err) - } - ipv4Instructions, ipv6Instructions := makeCreatePublicIPChainInstructions() appendToBoth := func(instruction string) { ipv4Instructions = append(ipv4Instructions, instruction) @@ -229,15 +230,26 @@ func (c *Config) RejectOutputPublicTraffic(ctx context.Context, remove bool) err "-j REJECT --reject-with tcp-reset") appendToBoth("-I OUTPUT -j PUBLIC_ONLY") - err = c.runIptablesInstructions(ctx, ipv4Instructions) + kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, + c.modules.nfRejectIPv4, c.modules.xtReject) + + err := c.runIptablesInstructions(ctx, ipv4Instructions) if err != nil { + if strings.Contains(err.Error(), "support") && kernelErr != nil { + err = fmt.Errorf("%w: %w", err, kernelErr) + } return err } + err = c.runIP6tablesInstructions(ctx, ipv6Instructions) if err != nil { _ = c.runIptablesInstructions(ctx, removeInstructions) + if strings.Contains(err.Error(), "support") && kernelErr != nil { + err = fmt.Errorf("%w: %w", err, kernelErr) + } return err } + return nil } diff --git a/internal/netlink/conntrack_linux.go b/internal/netlink/conntrack_linux.go index 088320776..6b69a2c22 100644 --- a/internal/netlink/conntrack_linux.go +++ b/internal/netlink/conntrack_linux.go @@ -12,12 +12,11 @@ import ( var ErrConntrackNetlinkNotSupported = errors.New("nf_conntrack_netlink is not supported by the kernel") func (n *NetLink) FlushConntrack() error { - if !n.conntrackNetlink { - return fmt.Errorf("%w", ErrConntrackNetlinkNotSupported) - } - conn, err := netfilter.Dial(nil) if err != nil { + if !n.conntrackNetlink { + err = fmt.Errorf("%w: %w", err, ErrConntrackNetlinkNotSupported) + } return fmt.Errorf("dialing netfilter: %w", err) } defer conn.Close() @@ -36,6 +35,9 @@ func (n *NetLink) FlushConntrack() error { _, err = conn.Query(request) if err != nil { + if !n.conntrackNetlink { + err = fmt.Errorf("%w: %w", err, ErrConntrackNetlinkNotSupported) + } return fmt.Errorf("querying netlink request: %w", err) } return nil From af0bc3e22445a3b0024d7d1986a4d8136e2526e6 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 26 Feb 2026 23:18:44 +0000 Subject: [PATCH 06/12] allow custom chain name targets --- internal/firewall/iptables/list.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/firewall/iptables/list.go b/internal/firewall/iptables/list.go index c6f8168ee..8c9a8b732 100644 --- a/internal/firewall/iptables/list.go +++ b/internal/firewall/iptables/list.go @@ -222,10 +222,6 @@ func parseChainRuleField(fieldIndex int, field string, rule *chainRule) (err err return fmt.Errorf("parsing bytes: %w", err) } case targetIndex: - err = checkTarget(field) - if err != nil { - return fmt.Errorf("checking target: %w", err) - } rule.target = field case protocolIndex: rule.protocol, err = parseProtocol(field) From 1fd4cc511aad83195ced7c616fdf0283c884e10b Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Fri, 27 Feb 2026 12:16:54 +0000 Subject: [PATCH 07/12] Fix kernel module names --- internal/firewall/iptables/kernel.go | 2 +- internal/mod/configgz_linux.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/firewall/iptables/kernel.go b/internal/firewall/iptables/kernel.go index 60e085146..a7ed2fea8 100644 --- a/internal/firewall/iptables/kernel.go +++ b/internal/firewall/iptables/kernel.go @@ -27,7 +27,7 @@ func newKernelModules() kernelModules { "nf_reject_ipv4": &m.nfRejectIPv4, "xt_connmark": &m.xtConnmark, "xt_conntrack": &m.xtConntrack, - "xt_reject": &m.xtReject, + "xt_REJECT": &m.xtReject, } for name, fieldPtr := range nameToFieldPtr { fieldPtr.name = name diff --git a/internal/mod/configgz_linux.go b/internal/mod/configgz_linux.go index a8a932e6e..54c304047 100644 --- a/internal/mod/configgz_linux.go +++ b/internal/mod/configgz_linux.go @@ -92,12 +92,12 @@ func moduleNameToKernelFeatureGroups(moduleName string) (featureGroups [][]strin "nf_reject_ipv4": {{"CONFIG_NF_REJECT_IPV4"}}, // Common Netfilter Targets - "xt_log": {{"CONFIG_NETFILTER_XT_TARGET_LOG"}}, - "xt_reject": { + "xt_LOG": {{"CONFIG_NETFILTER_XT_TARGET_LOG"}}, + "xt_REJECT": { {"CONFIG_IP_NF_TARGET_REJECT", "CONFIG_NF_REJECT_IPV4"}, {"CONFIG_NETFILTER_XT_TARGET_REJECT", "CONFIG_NF_REJECT_IPV4"}, }, - "xt_masquerade": {{"CONFIG_NETFILTER_XT_TARGET_MASQUERADE"}}, + "xt_MASQUERADE": {{"CONFIG_NETFILTER_XT_TARGET_MASQUERADE"}}, // Additional Netfilter Matches "xt_addrtype": {{"CONFIG_NETFILTER_XT_MATCH_ADDRTYPE"}}, From bfc8136bc9ee45ccd4088aed3936f3b2ea2ecde6 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Fri, 27 Feb 2026 12:17:12 +0000 Subject: [PATCH 08/12] Fourth fallback, use DROP temporarily instead of REJECT --- internal/firewall/flush.go | 61 +++++++++++++++++--------- internal/firewall/interfaces.go | 1 + internal/firewall/iptables/iptables.go | 22 +++++++--- 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/internal/firewall/flush.go b/internal/firewall/flush.go index 0ef567ff7..f09bc6a20 100644 --- a/internal/firewall/flush.go +++ b/internal/firewall/flush.go @@ -10,33 +10,52 @@ import ( "github.com/qdm12/gluetun/internal/netlink" ) -// Note remove is a no-op if conntrack netlink is supported by the kernel. func (c *Config) flushExistingConnections(ctx context.Context) error { - err := c.netlinker.FlushConntrack() - switch { - case err == nil: - return nil - case errors.Is(err, netlink.ErrConntrackNetlinkNotSupported): - c.logger.Debugf("falling back to marking and filtering unmarked packets because flush conntrack failed: %s", err) - err = c.impl.AcceptOutputPublicOnlyNewTraffic(ctx) - if err != nil { - if errors.Is(err, iptables.ErrKernelModuleMissing) { - c.logger.Debugf("falling back to killing connections for one second because marking packets failed: %s", err) - return c.rejectOutputTrafficTemporarily(ctx) - } - return fmt.Errorf("accepting only new output public traffic: %w", err) + tries := []struct { + name string + f func(ctx context.Context) error + }{ + {name: "flushing conntrack", f: func(_ context.Context) error { + return c.netlinker.FlushConntrack() + }}, + {name: "marking and filtering unmarked packets", f: c.impl.AcceptOutputPublicOnlyNewTraffic}, + {name: "rejecting connections for one second", f: c.rejectOutputTrafficTemporarily}, + {name: "dropping connections for one second", f: c.dropOutputTrafficTemporarily}, + } + errs := make([]error, 0, len(tries)) + for i, try := range tries { + if i > 0 { + c.logger.Debugf("falling back to %s because %s failed: %s", try.name, tries[i-1].name, errs[i-1]) + } + err := try.f(ctx) + if err == nil { + return nil } - return nil - default: - return fmt.Errorf("flushing conntrack: %w", err) + err = fmt.Errorf("%s: %w", try.name, err) + if !errors.Is(err, iptables.ErrKernelModuleMissing) && !errors.Is(err, netlink.ErrConntrackNetlinkNotSupported) { + return err + } + errs = append(errs, err) } + return fmt.Errorf("all tries failed: %v", errs) //nolint:err113 } func (c *Config) rejectOutputTrafficTemporarily(ctx context.Context) error { + return setupThenRevert(ctx, c.impl.RejectOutputPublicTraffic) +} + +func (c *Config) dropOutputTrafficTemporarily(ctx context.Context) error { + return setupThenRevert(ctx, c.impl.DropOutputPublicTraffic) +} + +// setupThenRevert is a helper function to run a setup function that takes a remove boolean argument, +// and then run the same function with remove set to true after one second or when the context is canceled, +// whichever comes first. +func setupThenRevert(ctx context.Context, f func(ctx context.Context, remove bool) error) error { remove := false - err := c.impl.RejectOutputPublicTraffic(ctx, remove) + err := f(ctx, remove) if err != nil { - return fmt.Errorf("rejecting only new output public traffic: %w", err) + return fmt.Errorf("setting up: %w", err) } timer := time.NewTimer(time.Second) select { @@ -47,9 +66,9 @@ func (c *Config) rejectOutputTrafficTemporarily(ctx context.Context) error { remove = true // Use [context.Background] to make sure this is removed, even if the context // passed to this function is canceled. - err = c.impl.RejectOutputPublicTraffic(context.Background(), remove) + err = f(context.Background(), remove) if err != nil { - return fmt.Errorf("reverting rejecting only new output public traffic: %w", err) + return fmt.Errorf("reverting: %w", err) } return nil } diff --git a/internal/firewall/interfaces.go b/internal/firewall/interfaces.go index ed22d6589..5d995ffd8 100644 --- a/internal/firewall/interfaces.go +++ b/internal/firewall/interfaces.go @@ -28,6 +28,7 @@ type firewallImpl interface { //nolint:interfacebloat SaveAndRestore(ctx context.Context) (restore func(context.Context), err error) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error RejectOutputPublicTraffic(ctx context.Context, remove bool) error + DropOutputPublicTraffic(ctx context.Context, remove bool) error AcceptInputThroughInterface(ctx context.Context, intf string) error AcceptEstablishedRelatedTraffic(ctx context.Context) error AcceptInputToPort(ctx context.Context, intf string, port uint16, remove bool) error diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index 2d5713513..e126acbdd 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -208,6 +208,14 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { } func (c *Config) RejectOutputPublicTraffic(ctx context.Context, remove bool) error { + return c.targetOutputPublicTraffic(ctx, "REJECT", remove) +} + +func (c *Config) DropOutputPublicTraffic(ctx context.Context, remove bool) error { + return c.targetOutputPublicTraffic(ctx, "DROP", remove) +} + +func (c *Config) targetOutputPublicTraffic(ctx context.Context, target string, remove bool) error { removeInstructions := []string{ "-D OUTPUT -j PUBLIC_ONLY", "-F PUBLIC_ONLY", @@ -223,11 +231,15 @@ func (c *Config) RejectOutputPublicTraffic(ctx context.Context, remove bool) err ipv6Instructions = append(ipv6Instructions, instruction) } - // Block UDP and ICMP, sending back ICMP port unreachable. - appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate RELATED,ESTABLISHED -j REJECT") - // Block TCP by sending back TCP RST packets. - appendToBoth("-A PUBLIC_ONLY -p tcp -m conntrack --ctstate RELATED,ESTABLISHED " + - "-j REJECT --reject-with tcp-reset") + if target == "REJECT" { + // Block TCP by sending back TCP RST packets. + appendToBoth("-A PUBLIC_ONLY -p tcp -m conntrack --ctstate RELATED,ESTABLISHED " + + "-j REJECT --reject-with tcp-reset") + // Block UDP and ICMP, sending back ICMP port unreachable. + appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate RELATED,ESTABLISHED -j REJECT") + } else { + appendToBoth("-A PUBLIC_ONLY -m conntrack --ctstate RELATED,ESTABLISHED -j " + target) + } appendToBoth("-I OUTPUT -j PUBLIC_ONLY") kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, From 594b1db98bec7a166f86790a07aad462ed94f21b Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Sat, 28 Feb 2026 15:13:23 +0000 Subject: [PATCH 09/12] Require xt_CONNMARK and define its kernel config values --- internal/firewall/iptables/iptables.go | 2 +- internal/firewall/iptables/kernel.go | 2 ++ internal/mod/configgz_linux.go | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index e126acbdd..b3e1106c8 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -185,7 +185,7 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { } kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, - c.modules.xtConntrack, c.modules.xtConnmark) + c.modules.xtConntrack, c.modules.xtConnmark, c.modules.xtCONNMARK) err = c.runIptablesInstructionsNoSave(ctx, ipv4Instructions) if err != nil { diff --git a/internal/firewall/iptables/kernel.go b/internal/firewall/iptables/kernel.go index a7ed2fea8..9f54f0442 100644 --- a/internal/firewall/iptables/kernel.go +++ b/internal/firewall/iptables/kernel.go @@ -11,6 +11,7 @@ type kernelModules struct { nfConntrack kernelModule nfRejectIPv4 kernelModule xtConnmark kernelModule + xtCONNMARK kernelModule xtConntrack kernelModule xtReject kernelModule } @@ -26,6 +27,7 @@ func newKernelModules() kernelModules { "nf_conntrack_netlink": &m.nfConntrack, "nf_reject_ipv4": &m.nfRejectIPv4, "xt_connmark": &m.xtConnmark, + "xt_CONNMARK": &m.xtCONNMARK, "xt_conntrack": &m.xtConntrack, "xt_REJECT": &m.xtReject, } diff --git a/internal/mod/configgz_linux.go b/internal/mod/configgz_linux.go index 54c304047..eb84dc492 100644 --- a/internal/mod/configgz_linux.go +++ b/internal/mod/configgz_linux.go @@ -81,8 +81,11 @@ func moduleNameToKernelFeatureGroups(moduleName string) (featureGroups [][]strin // Netfilter Matches "xt_conntrack": {{"CONFIG_NETFILTER_XT_MATCH_CONNTRACK"}}, "xt_connmark": { - {"CONFIG_NETFILTER_XT_CONNMARK"}, - {"CONFIG_NETFILTER_XT_MATCH_CONNMARK", "CONFIG_NETFILTER_XT_TARGET_CONNMARK"}, + {"CONFIG_NETFILTER_XT_MATCH_CONNMARK"}, + }, + "xt_CONNMARK": { + {"CONFIG_NETFILTER_XT_MATCH_CONNMARK"}, + {"CONFIG_NETFILTER_XT_TARGET_CONNMARK"}, // older kernels }, "xt_mark": { {"CONFIG_NETFILTER_XT_MARK"}, From a62220d7b6a3a3cc8cee1ba637d0b91f8cc9e4b2 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Mon, 2 Mar 2026 23:17:08 +0000 Subject: [PATCH 10/12] give up on kernel modules checks --- internal/firewall/iptables/firewall.go | 2 - internal/firewall/iptables/iptables.go | 31 ++++----------- internal/firewall/iptables/kernel.go | 53 -------------------------- 3 files changed, 7 insertions(+), 79 deletions(-) delete mode 100644 internal/firewall/iptables/kernel.go diff --git a/internal/firewall/iptables/firewall.go b/internal/firewall/iptables/firewall.go index c1e8a59ff..3cb7c285f 100644 --- a/internal/firewall/iptables/firewall.go +++ b/internal/firewall/iptables/firewall.go @@ -17,7 +17,6 @@ type Config struct { // Fixed state ipTables string ip6Tables string - modules kernelModules } func New(ctx context.Context, runner CmdRunner, logger Logger) (*Config, error) { @@ -36,6 +35,5 @@ func New(ctx context.Context, runner CmdRunner, logger Logger) (*Config, error) logger: logger, ipTables: iptables, ip6Tables: ip6tables, - modules: newKernelModules(), }, nil } diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index b3e1106c8..d3671e5cc 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -141,22 +141,18 @@ func (c *Config) AcceptOutputThroughInterface(ctx context.Context, intf string, } func (c *Config) AcceptEstablishedRelatedTraffic(ctx context.Context) error { - err := c.runMixedIptablesInstructions(ctx, []string{ + return c.runMixedIptablesInstructions(ctx, []string{ "--append OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", "--append INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", }) - if err != nil && !c.modules.nfConntrack.ok { - return fmt.Errorf("%w: %s", ErrKernelModuleMissing, c.modules.nfConntrack.name) - } - return err } // AcceptOutputPublicOnlyNewTraffic adds rules to mark new output connections, and to accept // established or related packets with this mark only. This effectively forces // previously established or related traffic to be blocked. // If remove is true, the rules are removed instead of appended. -// If the relevant kernel modules (nf_conntrack, xt_conntrack and xt_connmark) -// are not available, it returns an error indicating which kernel module is missing. +// If the relevant kernel modules are not available, it returns an error indicating +// which kernel module is missing. func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { ipv4Instructions, ipv6Instructions := makeCreatePublicIPChainInstructions() appendToBoth := func(instruction string) { @@ -184,23 +180,14 @@ func (c *Config) AcceptOutputPublicOnlyNewTraffic(ctx context.Context) error { return err } - kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, - c.modules.xtConntrack, c.modules.xtConnmark, c.modules.xtCONNMARK) - err = c.runIptablesInstructionsNoSave(ctx, ipv4Instructions) if err != nil { restore(ctx) - if strings.Contains(err.Error(), "support") && kernelErr != nil { - err = fmt.Errorf("%w: %w", err, kernelErr) - } return err } err = c.runIP6tablesInstructionsNoSave(ctx, ipv6Instructions) if err != nil { restore(ctx) - if strings.Contains(err.Error(), "support") && kernelErr != nil { - err = fmt.Errorf("%w: %w", err, kernelErr) - } return err } @@ -242,22 +229,18 @@ func (c *Config) targetOutputPublicTraffic(ctx context.Context, target string, r } appendToBoth("-I OUTPUT -j PUBLIC_ONLY") - kernelErr := checkKernelModulesAreOK(c.modules.nfConntrack, - c.modules.nfRejectIPv4, c.modules.xtReject) - err := c.runIptablesInstructions(ctx, ipv4Instructions) if err != nil { - if strings.Contains(err.Error(), "support") && kernelErr != nil { - err = fmt.Errorf("%w: %w", err, kernelErr) + if strings.Contains(err.Error(), " support") { + return fmt.Errorf("%w: %w", ErrKernelModuleMissing, err) } - return err } err = c.runIP6tablesInstructions(ctx, ipv6Instructions) if err != nil { _ = c.runIptablesInstructions(ctx, removeInstructions) - if strings.Contains(err.Error(), "support") && kernelErr != nil { - err = fmt.Errorf("%w: %w", err, kernelErr) + if strings.Contains(err.Error(), " support") { + return fmt.Errorf("%w: %w", ErrKernelModuleMissing, err) } return err } diff --git a/internal/firewall/iptables/kernel.go b/internal/firewall/iptables/kernel.go deleted file mode 100644 index 9f54f0442..000000000 --- a/internal/firewall/iptables/kernel.go +++ /dev/null @@ -1,53 +0,0 @@ -package iptables - -import ( - "fmt" - "strings" - - "github.com/qdm12/gluetun/internal/mod" -) - -type kernelModules struct { - nfConntrack kernelModule - nfRejectIPv4 kernelModule - xtConnmark kernelModule - xtCONNMARK kernelModule - xtConntrack kernelModule - xtReject kernelModule -} - -type kernelModule struct { - name string - ok bool -} - -func newKernelModules() kernelModules { - var m kernelModules - nameToFieldPtr := map[string]*kernelModule{ - "nf_conntrack_netlink": &m.nfConntrack, - "nf_reject_ipv4": &m.nfRejectIPv4, - "xt_connmark": &m.xtConnmark, - "xt_CONNMARK": &m.xtCONNMARK, - "xt_conntrack": &m.xtConntrack, - "xt_REJECT": &m.xtReject, - } - for name, fieldPtr := range nameToFieldPtr { - fieldPtr.name = name - err := mod.Probe(name) - fieldPtr.ok = err == nil - } - return m -} - -func checkKernelModulesAreOK(modules ...kernelModule) error { - missing := make([]string, 0, len(modules)) - for _, module := range modules { - if !module.ok { - missing = append(missing, module.name) - } - } - if len(missing) > 0 { - return fmt.Errorf("%w: %s", ErrKernelModuleMissing, strings.Join(missing, ", ")) - } - return nil -} From eb9f1b4e36f8336da7a87b6bf899886c3c3beff9 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Mon, 2 Mar 2026 23:19:53 +0000 Subject: [PATCH 11/12] Revert mod changes --- internal/mod/configgz_linux.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/internal/mod/configgz_linux.go b/internal/mod/configgz_linux.go index eb84dc492..a8a932e6e 100644 --- a/internal/mod/configgz_linux.go +++ b/internal/mod/configgz_linux.go @@ -81,11 +81,8 @@ func moduleNameToKernelFeatureGroups(moduleName string) (featureGroups [][]strin // Netfilter Matches "xt_conntrack": {{"CONFIG_NETFILTER_XT_MATCH_CONNTRACK"}}, "xt_connmark": { - {"CONFIG_NETFILTER_XT_MATCH_CONNMARK"}, - }, - "xt_CONNMARK": { - {"CONFIG_NETFILTER_XT_MATCH_CONNMARK"}, - {"CONFIG_NETFILTER_XT_TARGET_CONNMARK"}, // older kernels + {"CONFIG_NETFILTER_XT_CONNMARK"}, + {"CONFIG_NETFILTER_XT_MATCH_CONNMARK", "CONFIG_NETFILTER_XT_TARGET_CONNMARK"}, }, "xt_mark": { {"CONFIG_NETFILTER_XT_MARK"}, @@ -95,12 +92,12 @@ func moduleNameToKernelFeatureGroups(moduleName string) (featureGroups [][]strin "nf_reject_ipv4": {{"CONFIG_NF_REJECT_IPV4"}}, // Common Netfilter Targets - "xt_LOG": {{"CONFIG_NETFILTER_XT_TARGET_LOG"}}, - "xt_REJECT": { + "xt_log": {{"CONFIG_NETFILTER_XT_TARGET_LOG"}}, + "xt_reject": { {"CONFIG_IP_NF_TARGET_REJECT", "CONFIG_NF_REJECT_IPV4"}, {"CONFIG_NETFILTER_XT_TARGET_REJECT", "CONFIG_NF_REJECT_IPV4"}, }, - "xt_MASQUERADE": {{"CONFIG_NETFILTER_XT_TARGET_MASQUERADE"}}, + "xt_masquerade": {{"CONFIG_NETFILTER_XT_TARGET_MASQUERADE"}}, // Additional Netfilter Matches "xt_addrtype": {{"CONFIG_NETFILTER_XT_MATCH_ADDRTYPE"}}, From 27b8e83aa5dea19e8047315b9b31f7e727e325e5 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Mar 2026 13:35:56 +0000 Subject: [PATCH 12/12] Use ErrKernelModuleMissing when missing kernel module string is detected --- internal/firewall/iptables/ip6tables.go | 3 +++ internal/firewall/iptables/iptables.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/internal/firewall/iptables/ip6tables.go b/internal/firewall/iptables/ip6tables.go index 6e0966993..19ef233e1 100644 --- a/internal/firewall/iptables/ip6tables.go +++ b/internal/firewall/iptables/ip6tables.go @@ -76,6 +76,9 @@ func (c *Config) runIP6tablesInstructionNoSave(ctx context.Context, instruction cmd := exec.CommandContext(ctx, c.ip6Tables, flags...) // #nosec G204 c.logger.Debug(cmd.String()) if output, err := c.runner.Run(cmd); err != nil { + if strings.Contains(output, "missing kernel module") { + err = ErrKernelModuleMissing + } return fmt.Errorf("command failed: \"%s %s\": %s: %w", c.ip6Tables, instruction, output, err) } diff --git a/internal/firewall/iptables/iptables.go b/internal/firewall/iptables/iptables.go index d3671e5cc..4a42e9a7f 100644 --- a/internal/firewall/iptables/iptables.go +++ b/internal/firewall/iptables/iptables.go @@ -92,6 +92,9 @@ func (c *Config) runIptablesInstructionNoSave(ctx context.Context, instruction s cmd := exec.CommandContext(ctx, c.ipTables, flags...) // #nosec G204 c.logger.Debug(cmd.String()) if output, err := c.runner.Run(cmd); err != nil { + if strings.Contains(output, "missing kernel module") { + err = ErrKernelModuleMissing + } return fmt.Errorf("command failed: \"%s %s\": %s: %w", c.ipTables, instruction, output, err) }