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

  1. Treat ENOENT from DelRule as success - the desired end state (rule gone) already exists
  2. Serialize Add/RemoveOutputDNAT behind the router mutex so a second remover sees !exists after the first completes
  3. Confirm the daemon holds CAP_NET_ADMIN (systemd AmbientCapabilities=CAP_NET_ADMIN or root)
  4. 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

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


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/bd04464a96ddf5e6. Report an issue: GitHub.