netbirdio/netbird · error
delete output DNAT rule %s: %w
Error message
delete output DNAT rule %s: %w
What it means
Raised in RemoveOutputDNAT (client/firewall/nftables/router_linux.go:2102) when r.conn.DelRule(rule) rejects the delete request. By this point the rule was found in r.rules with a non-zero Handle, so the usual causes are ENOENT (the rule was deleted in the kernel between refreshRulesMap and this call) and EPERM (no CAP_NET_ADMIN), plus EINVAL for a handle that is stale or belongs to a different table generation.
Source
Thrown at client/firewall/nftables/router_linux.go:2102
if err := r.refreshRulesMap(); err != nil {
return fmt.Errorf(refreshRulesMapError, err)
}
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)
rule, exists := r.rules[ruleID]
if !exists {
return nil
}
if rule.Handle == 0 {
log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID)
delete(r.rules, ruleID)
return nil
}
if err := r.conn.DelRule(rule); err != nil {
return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err)
}
if err := r.conn.Flush(); err != nil {
return fmt.Errorf("flush delete output DNAT rule: %w", err)
}
delete(r.rules, ruleID)
return nil
}
// applyNetwork generates nftables expressions for networks (CIDR) or sets
func (r *router) applyNetwork(
network firewall.Network,
setPrefixes []netip.Prefix,
isSource bool,
) ([]expr.Any, error) {
if network.IsSet() {
exprs, err := r.getIpSet(network.Set, setPrefixes, isSource)
if err != nil {View on GitHub (pinned to 93e97f4bf1)
Solutions
- Treat ENOENT from DelRule as success - the desired end state (rule gone) already exists
- Serialize Add/RemoveOutputDNAT behind the router mutex so a second remover sees !exists after the first completes
- Confirm the daemon holds CAP_NET_ADMIN (systemd AmbientCapabilities=CAP_NET_ADMIN or root)
- If handles are stale (table recreated), call refreshRulesMap again or recreate the manager, then retry once
Example fix
// before
if err := r.conn.DelRule(rule); err != nil {
return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err)
}
// after - a rule that is already gone satisfies the removal
if err := r.conn.DelRule(rule); err != nil {
if isNotExistErr(err) {
delete(r.rules, ruleID)
return nil
}
return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err)
} Defensive patterns
Strategy: try-catch
Validate before calling
// Only attempt delete when the id is currently tracked
if !router.HasOutputDNAT(localAddr, protocol, originalPort) {
return nil
} Try / catch
if err := router.RemoveOutputDNAT(localAddr, proto, o, t); err != nil {
if isNotExistErr(rootCause(err)) {
return nil // already gone: goal state reached
}
return fmt.Errorf("teardown output DNAT: %w", err)
} Prevention
- Serialize add/remove of the same DNAT tuple behind one lock
- Treat ENOENT on delete as success in teardown paths
- Re-sync via refreshRulesMap before retrying after external nftables changes
When it happens
Trigger: Concurrent nft client deletes the same rule right after the refresh; two agent goroutines racing to remove the same output-dnat id; running unprivileged; netlink socket reused across table recreation so handles point at dead objects.
Common situations: Duplicate RemoveOutputDNAT calls for the same localAddr/protocol/port tuple (e.g. listener teardown racing reconnect); manual 'nft delete rule' by an admin; nftables library version changing handle semantics.
Related errors
- delete inbound DNAT rule %s: %w
- add output DNAT rule: %w
- flush delete output DNAT rule: %w
- decrement set counter: %w
- error adding set %s: %w
AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16).
Data as JSON: /api/errors/bd04464a96ddf5e6.
Report an issue: GitHub.