netbirdio/netbird · error

create NAT output chain: %w

Error message

create NAT output chain: %w

What it means

ensureNATOutputChain lazily creates the netbird-nat-output chain (NAT type, OUTPUT hook, NAT-destination priority) on first AddOutputDNAT and commits it with Flush. 'create NAT output chain' wraps that commit failure; the chain is removed from r.chains on failure so a later call retries creation from a clean slate.

Source

Thrown at client/firewall/nftables/router_linux.go:2000

}

// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use.
func (r *router) ensureNATOutputChain() error {
	if _, exists := r.chains[chainNameNATOutput]; exists {
		return nil
	}

	r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{
		Name:     chainNameNATOutput,
		Table:    r.workTable,
		Hooknum:  nftables.ChainHookOutput,
		Priority: nftables.ChainPriorityNATDest,
		Type:     nftables.ChainTypeNAT,
	})

	if err := r.conn.Flush(); err != nil {
		delete(r.chains, chainNameNATOutput)
		return fmt.Errorf("create NAT output chain: %w", err)
	}
	return nil
}

// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
	ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)

	if _, exists := r.rules[ruleID]; exists {
		return nil
	}

	if err := r.ensureNATOutputChain(); err != nil {
		return err
	}

	protoNum, err := r.af.protoNum(protocol)
	if err != nil {

View on GitHub (pinned to 93e97f4bf1)

Solutions

  1. Check 'sudo nft list chains' for netbird-nat-output; if present but unknown to the agent, remove it or restart the agent to adopt it.
  2. Verify the parent table still exists ('sudo nft list tables').
  3. Ensure root or CAP_NET_ADMIN.
  4. Retry the operation that triggered chain creation; the code path is self-healing after the map rollback.

Example fix

// before
if err := r.conn.Flush(); err != nil {
    delete(r.chains, chainNameNATOutput)
    return fmt.Errorf("create NAT output chain: %w", err)
}

// after: adopt a pre-existing chain instead of failing
if err := r.conn.Flush(); err != nil {
    if errors.Is(err, unix.EEXIST) {
        log.Warnf("NAT output chain already exists, adopting it")
        return nil
    }
    delete(r.chains, chainNameNATOutput)
    return fmt.Errorf("create NAT output chain: %w", err)
}
Defensive patterns

Strategy: retry

Validate before calling

// Confirm the chain is absent before triggering lazy creation
func natOutputChainExists(conn *nftables.Conn, table *nftables.Table) bool {
    _, err := conn.GetChain(table, chainNameNATOutput)
    return err == nil
}

Try / catch

if err := r.conn.Flush(); err != nil {
    if errors.Is(err, unix.EEXIST) {
        // chain already in kernel: adopt instead of failing
        return nil
    }
    delete(r.chains, chainNameNATOutput)
    return fmt.Errorf("create NAT output chain: %w", err)
}

Prevention

When it happens

Trigger: EEXIST when the chain already exists in the kernel but not in the in-memory map (state skew after external changes or restart); the workTable was flushed so NEWCHAIN targets a missing table; EPERM without CAP_NET_ADMIN.

Common situations: Leftover chains from a previous agent run; firewalld or hand-written nftables files defining conflicting chains; containerized agents lacking privileges.

Related errors


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