fix(firewall; Linux): delete unmarked conntrack entries on firewall activation

Add DeleteUnmarkedConnections() to purge conntrack entries with mark=0
when firewall is activated. This forces applications with existing
connections to reconnect, allowing DNAT rules (like SPN) to apply.

Without this, connections established while Portmaster was paused or
stopped would bypass DNAT because netfilter's nat table is only
traversed for new connections.

Loopback connections are excluded from deletion to avoid disconnecting
local services.

https://github.com/safing/portmaster-shadow/issues/42
This commit is contained in:
Alexandr Stelnykovych
2026-05-06 14:33:23 +03:00
parent 8d627bc1bc
commit 315fc254a5
2 changed files with 98 additions and 0 deletions
@@ -34,6 +34,91 @@ func TeardownNFCT() {
}
}
// DeleteUnmarkedConnections deletes all conntrack entries with connmark=0,
// excluding loopback connections.
// These entries represent connections established while Portmaster was not
// running or was paused and therefore never received a verdict mark.
//
// The Linux netfilter nat table applies DNAT only to the first packet of a NEW
// connection. ESTABLISHED connections bypass the nat table entirely, so any
// routing decision (e.g. MarkRerouteSPN) would never take
// effect for them. Removing their conntrack entries forces applications to
// reconnect; the resulting SYN is processed by NFQUEUE as a new connection and
// the correct DNAT rule fires.
//
// Loopback connections (source or destination is 127.x.x.x / ::1) are skipped.
// They always carry connmark=0 because Portmaster never saves a permanent mark
// for loopback-destined packets. Flushing them would needlessly disconnect apps
// talking to local services (databases, dev servers, local APIs, etc.).
//
// Connections already processed by Portmaster carry a non-zero connmark and
// are handled via CONNMARK --restore-mark; they are unaffected.
func DeleteUnmarkedConnections() error {
if nfct == nil {
return errors.New("nfq: nfct not initialized")
}
deleted := deleteUnmarkedConnections(nfct, ct.IPv4)
if netenv.IPv6Enabled() {
deleted += deleteUnmarkedConnections(nfct, ct.IPv6)
}
log.Infof("nfq: deleted %d unmarked conntrack entries to force re-evaluation on firewall activation", deleted)
return nil
}
func deleteUnmarkedConnections(nfct *ct.Nfct, f ct.Family) (deleted int) {
filter := ct.FilterAttr{
Mark: []byte{0x00, 0x00, 0x00, 0x00},
MarkMask: []byte{0xFF, 0xFF, 0xFF, 0xFF},
}
connections, err := nfct.Query(ct.Conntrack, f, filter)
if err != nil {
log.Warningf("nfq: error querying unmarked conntrack entries: %s", err)
return 0
}
var lastErr error
for _, connection := range connections {
if isLoopbackConnection(connection) {
continue
}
if err := nfct.Delete(ct.Conntrack, f, connection); err != nil {
lastErr = err
} else {
deleted++
}
}
if lastErr != nil {
log.Warningf("nfq: some unmarked conntrack entries could not be deleted, last error: %s", lastErr)
}
return deleted
}
// isLoopbackConnection reports whether a conntrack entry involves a loopback address.
func isLoopbackConnection(c ct.Con) bool {
if c.Origin != nil {
if c.Origin.Src != nil && c.Origin.Src.IsLoopback() {
return true
}
if c.Origin.Dst != nil && c.Origin.Dst.IsLoopback() {
return true
}
}
if c.Reply != nil {
if c.Reply.Src != nil && c.Reply.Src.IsLoopback() {
return true
}
if c.Reply.Dst != nil && c.Reply.Dst.IsLoopback() {
return true
}
}
return false
}
// DeleteAllMarkedConnection deletes all marked entries from the conntrack table.
func DeleteAllMarkedConnection() error {
if nfct == nil {
@@ -171,8 +171,21 @@ func activateNfqueueFirewall() error {
if err := nfq.InitNFCT(); err != nil {
return err
}
// Remove stale conntrack entries carrying Portmaster marks.
// This is required to prevent conflicts with existing entries if Portmaster was not cleanly stopped,
// and to ensure a clean state on firewall activation.
_ = nfq.DeleteAllMarkedConnection()
// Force re-evaluation of connections that bypassed Portmaster while it was
// stopped or paused. Without this, DNAT rules (SPN) would
// never apply to already-established connections, as the nat table is only
// traversed for new connections.
//
// NOTE: This will disconnect all existing non-loopback connections with mark=0!
//
// TODO: normally, this is only necessary when DNAT-based routing features are active (e.g. SPN)
_ = nfq.DeleteUnmarkedConnections()
return nil
}