PROTOCOLS-FUN . FIREWALL PATH

Packet Fate

netfilter · conntrack
rev 2026.08
verdicts accept · drop · reject
hooks five, in a fixed order
the question who sent the RST

Every other page here is an anatomy: a header, a field list, the bytes. This one is a path. That is why it never fit the shape - a firewall is not a protocol, it is the thing that happens to every protocol, in between the pages. A packet arrives and something decides its fate, and the decision is invisible in the packet itself. So the recurring question is not "what do these bytes mean" but "which box did this, and why did it not tell me". Start with the one everybody has lost an afternoon to: connection reset by peer, where the peer swears it sent nothing. Every packet below is real, with every checksum computed. Hover a field to light its bytes, click a field marked + for its lookup table.

symptomECONNRESETmessageconnection reset by peertruthoften not the peer

The error names the peer. The peer is frequently innocent. A RST is four bytes of flag in a header anyone on the path can write, the source address is trivially forged, and nothing in the packet records who really produced it. Six things send resets, and they are distinguishable on the wire.

the six suspects
sourcewhenwhat it looks like
the peer's kernelNothing is listening on the port.RST+ACK, one RTT after your SYN, TTL consistent with the peer's other packets. The honest case.
the peer's applicationSO_LINGER with l_linger=0, or close() called with unread data still in the receive queue.RST mid-conversation instead of FIN. TTL still matches the peer. Common in proxies avoiding TIME_WAIT.
a REJECT rule-j REJECT --reject-with tcp-reset on the peer host or a firewall in front of it.Immediate, and it answers the SYN. TTL matches whichever box holds the rule, which may be several hops short of the peer.
an injected RSTA DPI or censorship middlebox that wants the connection dead.Source address forged as the peer. TTL is wrong. Often arrives before the peer's real reply, and the peer keeps talking afterwards.
conntrack or NATThe flow sat idle past a middlebox timeout and the state entry was evicted.Dies after a consistent idle period. The next packet either draws a RST or vanishes entirely.
a load balancerIdle timeout, backend health check failure, or connection draining during a deploy.RST rather than a graceful FIN, often in bursts correlated with a rollout.
an injected RST, byte by byte

A middlebox three hops from the client, forging 203.0.113.5:443. Every field is what the client actually sees. Byte 8 is the one that gives it away.

the TTL is wrong because the packet was born closer. The real peer is 12 hops away, so its packets arrive with TTL 52 - they left with 64 and lost one per router. The injector sits 3 hops out, so its forged RST arrives with 61. It can spoof the source address perfectly and still not fix this, because the hop count is applied by the routers in between, not by the sender. Compare the RST's TTL against a packet you know came from the real peer, in the same capture: tcpdump -nnvvi any 'host 203.0.113.5' and read the ttl field on each. A gap of several is the tell.
the caveats that keep this honest. Compare against the same peer in the same capture, never an absolute: initial TTL is 64 on Linux and macOS, 128 on Windows, 255 on a lot of network gear, so an unfamiliar value alone means nothing. A route change mid-connection moves the real TTL too. A careful injector can copy the TTL from the flow it is watching, and some do. The stronger signal is duplication: if the peer's genuine response arrives after the RST, or the connection keeps producing traffic the RST should have ended, nothing on the peer sent that reset.
blind injection got hard, on-path injection did not. RFC 5961 tightened acceptance: an in-window RST that is not exactly RCV.NXT no longer kills the connection, it draws a challenge ACK instead. An off-path attacker now has to guess the exact sequence number rather than land anywhere in the window. None of that helps against a box that can see the flow, which is what a DPI middlebox is - it reads the real sequence numbers off the wire and writes a perfectly acceptable reset.
a reset is never answered with a reset. RFC 9293 §3.5.2 forbids it, which is the only reason a forged RST between two hosts does not become a packet storm. The same section fixes the sequence arithmetic used above: an incoming segment with no ACK bit draws a reset carrying sequence zero, with the ACK field set to the incoming sequence number plus the segment length. A SYN counts as one byte, which is why this RST acknowledges 0x12345679 against a SYN of 0x12345678.
DROPsilence, then a timeoutREJECTan error, immediately

This is the difference between "my app hung for two minutes" and "my app failed instantly with a clear error", and it is one word in one firewall rule. DROP discards the packet and sends nothing, so the client learns only when its own retransmit timer gives up. REJECT sends an explicit refusal, so the client fails on the next round trip.

DROP: the client talks to nobody

There is no packet to inspect, which is the entire problem. The capture shows only your own SYNs leaving, on a doubling timer.

// tcpdump on the client. nothing comes back, ever. 09:14:02.001 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 09:14:03.002 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +1s 09:14:05.006 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +2s 09:14:09.014 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +4s 09:14:17.030 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +8s 09:14:33.062 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +16s 09:15:05.126 192.0.2.10.51234 > 203.0.113.5.443: Flags [S], seq 305419896 +32s // +64s of waiting, then ETIMEDOUT
connect timeoutnet.ipv4.tcp_syn_retries, default 6. Backoff doubles: 1, 2, 4, 8, 16, 32, 64 seconds, so the total is 127 seconds before connect() returns ETIMEDOUT. If your hang is always roughly two minutes, this is why, and something is dropping your SYN.
mid-stream timeoutnet.ipv4.tcp_retries2, default 15, on an already established connection. Roughly 924 seconds, about 15 minutes, before the kernel gives up. If a connection wedges for a quarter of an hour and then errors, packets stopped getting through and nobody said so.
why anyone dropsA DROP gives a scanner nothing: no reply distinguishes a filtered port from an unrouted address. That is the whole argument for it, and the price is paid by legitimate clients who wait out the full timer.
REJECT: an ICMP refusal, byte by byte

The default for -j REJECT is icmp-port-unreachable: ICMP type 3, code 3. It quotes the packet that caused it, and the quote is more useful than the error.

the quoted TTL is a free hop count. An ICMP error carries the original IP header as the rejecting host received it, so its TTL has already been decremented by every router in between. You sent 64. Byte 36 says 52. The box that refused you is therefore 12 hops away - and if that does not match where you think your firewall is, the rule is not where you think it is. The quoted source and destination addresses also survive NAT in a way your own capture does not, so this is often the only place you can see what a middlebox rewrote.
eight bytes of transport is exactly enough, and no accident. The quote carries the original IP header plus the first 8 bytes after it, which for TCP is source port, destination port, and sequence number. That is precisely the four-tuple plus enough sequence context for the kernel to find the socket and fail the right connect(). It is also why an ICMP error cannot be validated deeply: 8 bytes is not enough to prove the sender saw the whole segment, which is what made blind ICMP attacks against TCP practical enough for RFC 5927 to exist.
the packet that provoked it

The client's SYN, shown without options for clarity - a real one carries MSS, SACK-permitted, timestamps and window scale. Bytes 0 through 19 here are what get quoted back inside the ICMP above, with only the TTL and checksum differing.

what REJECT can send
--reject-withon the wireuse
icmp-port-unreachabletype 3 code 3The default. Indistinguishable from a host with nothing listening, which is usually what you want.
icmp-host-unreachabletype 3 code 1Claims the host is gone. Clients may cache this and back off harder.
icmp-net-unreachabletype 3 code 0Claims the whole network is unreachable. Rarely appropriate from a host firewall.
icmp-proto-unreachabletype 3 code 2The protocol number is not supported at all.
icmp-admin-prohibitedtype 3 code 13Says out loud that a policy blocked this. Honest, and tells a scanner a firewall exists.
tcp-reseta TCP RSTTCP only. Looks exactly like a closed port - and is suspect number three on the previous tab.

The ip6tables spelling differs: ICMPv6 type 1 code 4 is icmp6-port-unreachable, and the default there is the same idea with different numbers. nftables writes all of this as reject with icmp type port-unreachable or reject with tcp reset, and a bare reject picks the family-appropriate default.

REJECT is a target, not a verdict. It generates a packet and then drops the original, so it is terminal in the same way DROP is - nothing after it in the chain runs. It also cannot be used everywhere: the generated reply has to be routable back, so REJECT is unavailable in POSTROUTING, and --reject-with tcp-reset is rejected outright unless the rule already matched -p tcp.
the one ICMP you must never drop: type 3 code 4. Fragmentation-needed-and-DF-set is how Path MTU Discovery works, and its last four bytes carry the next-hop MTU (RFC 1191). Blanket-blocking ICMP kills it, and the failure is diabolical: the handshake succeeds, small requests succeed, and anything large hangs forever, because the sender never learns to send smaller packets. If small requests work and big ones stall, you are looking at a PMTU black hole, not a firewall rule about port 443.
hooks5orderingsigned integer prioritytablesa naming convention

Netfilter is five points in the IP stack where registered functions get to look at a packet and return a verdict. Everything else - tables, chains, conntrack, NAT - is code registered at one of those five points with a priority number that decides who runs first. Read the path downward; a packet enters at the top and takes one branch at the routing decision.

the path an inbound packet takes
NIC / driver
Interrupt, then NAPI poll into an sk_buff. Nothing filterable yet.
XDP
Earliest possible drop, before the skb exists. eBPF territory, not netfilter.
tc ingress
Classifier and action hooks. Still ahead of netfilter.
PREROUTING
NF_INET_PRE_ROUTING. Every arriving packet, before any routing decision has been made.
-400
conntrack defragmentation. IPv4 fragments are reassembled here so later rules see whole headers.
-300
raw table. The only place early enough to set NOTRACK and skip conntrack entirely.
-200
conntrack looks the packet up and attaches state: NEW, ESTABLISHED, RELATED or INVALID.
-150
mangle table. TOS, TTL, marks.
-100
nat table, DNAT. Destination rewritten before routing, which is the only reason port forwarding can work.
routing decision
Is the destination address local? The answer picks the branch below. DNAT already happened, so this routes on the new destination.
INPUT
NF_INET_LOCAL_IN, for packets addressed to this host. filter at priority 0 is where a host firewall lives. Then the socket, then userspace.
FORWARD
NF_INET_FORWARD, for packets routed through this host. Requires net.ipv4.ip_forward=1. filter at priority 0.
POSTROUTING
NF_INET_POST_ROUTING. Everything leaving, forwarded or locally generated.
+100
nat table, SNAT and MASQUERADE. Source rewritten after routing, so the rule can know which interface was chosen.
INT_MAX
conntrack confirm. The new entry is only committed to the table here, at the very last moment, once the packet has survived everything.
tc egress, NIC
Queueing discipline, then the wire.

Locally generated traffic skips the first half: the socket routes first, then hits OUTPUT (NF_INET_LOCAL_OUT, with raw, conntrack, mangle and DNAT all registered there too), then joins the same POSTROUTING as everything else.

priorities, which are the real ordering

From include/uapi/linux/netfilter_ipv4.h. Lower runs first. nftables lets you write these names directly in a base chain declaration.

constantvaluenft namewhat registers there
NF_IP_PRI_CONNTRACK_DEFRAG-400-IPv4 defragmentation for conntrack
NF_IP_PRI_RAW-300rawthe raw table; NOTRACK
NF_IP_PRI_CONNTRACK-200-state lookup and assignment
NF_IP_PRI_MANGLE-150manglethe mangle table
NF_IP_PRI_NAT_DST-100dstnatDNAT, at PREROUTING and OUTPUT
NF_IP_PRI_FILTER0filterthe filter table; almost every rule you write
NF_IP_PRI_SECURITY50securitySELinux and friends
NF_IP_PRI_NAT_SRC100srcnatSNAT and MASQUERADE, at POSTROUTING
NF_IP_PRI_CONNTRACK_CONFIRMINT_MAX-commit the conntrack entry, last of all
"tables" are a naming convention over these integers. There is no mechanism in the kernel that makes raw run before mangle - raw is registered at -300 and mangle at -150, and that is the whole of it. iptables hides the numbers and gives you five fixed table names; nftables exposes them, which is why an nft base chain must state its hook and priority explicitly and why you can slot a chain between any two of the rows above. Same engine, and since iptables-nft the same backend: on a current distro iptables is a compatibility front end writing nftables rules.
the kernel and the tool disagree about the names. The hook constants are NF_INET_LOCAL_IN and NF_INET_LOCAL_OUT; iptables calls the same two hooks INPUT and OUTPUT. Kernel traces, nft monitor trace output and the tunables reference all use the LOCAL_ spelling, so the two vocabularies collide exactly when you are debugging. They are the same hook.
a packet you dropped in FORWARD never reaches INPUT. The branch at the routing decision is exclusive: local delivery goes to INPUT, transit goes to FORWARD, and nothing visits both. Rules written in the wrong one of the two are the most common reason a ruleset "does nothing" - on a router, traffic through the box is never seen by INPUT.
modulenf_conntrackkeyed ona tuple pairstates4, plus untracked

Conntrack is what turns a packet filter into a stateful firewall: it remembers flows so a rule can say "replies to things we started" without enumerating them. It also introduces a table that can fill, entries that can expire, and a vocabulary that looks like TCP's but is not.

the four states
ctstatemeansgotcha
NEWA packet conntrack has no entry for, that looks like the start of a flow.Not the same as "has the SYN bit". A stray mid-stream ACK for a flow conntrack forgot is also NEW, which is why loose rulesets accept things they did not mean to.
ESTABLISHEDTraffic has been seen in both directions on this tuple.Nothing to do with TCP ESTABLISHED. A UDP flow becomes conntrack-ESTABLISHED the moment one reply comes back, and TCP is ESTABLISHED here well before the three-way handshake finishes.
RELATEDA new flow that an existing entry expects: an ICMP error quoting a tracked flow, or an FTP data channel opened by the helper.Helpers parse application payloads in the kernel to predict these. That is exactly as dangerous as it sounds, and why nf_conntrack_helper now defaults to off.
INVALIDCannot be associated with anything: out-of-window TCP, malformed headers, ICMP errors for flows conntrack never saw.Drop these explicitly. Left unhandled they can slip through a rule that only tests for NEW, and they pollute captures during debugging.
UNTRACKEDDeliberately exempted with -j NOTRACK in the raw table.Costs you all stateful matching for that traffic. Used on very high rate flows to keep the table from filling.
what an entry actually holds
// conntrack -L, one flow, wrapped for width tcp 6 431997 ESTABLISHED src=192.0.2.10 dst=203.0.113.5 sport=51234 dport=443 original tuple src=203.0.113.5 dst=192.0.2.10 sport=443 dport=51234 reply tuple, inverted [ASSURED] mark=0 use=1 // 431997 = seconds left. ASSURED = traffic seen both ways, so this entry // survives table pressure that would evict an unassured one.

The entry stores two tuples, not one. The reply tuple is what NAT rewrites, and it is how a translated packet finds its way back: the return traffic is matched against the reply tuple and un-translated automatically. That is why NAT only has to run once per flow.

timeouts, and the one that bites
sysctldefaultnote
nf_conntrack_tcp_timeout_established432000Five days. Generous on purpose, and almost never what the middleboxes between you and the peer use.
nf_conntrack_tcp_timeout_syn_sent120Matches the client's own 127-second connect timeout closely enough to be confusing.
nf_conntrack_tcp_timeout_time_wait120
nf_conntrack_tcp_timeout_close_wait60
nf_conntrack_udp_timeout30Rises to 120 once the flow is assured. This is why long-lived UDP needs keepalives.
nf_conntrack_maxRAM-sizedOften around 256K. When it fills, new flows are dropped and dmesg says nf_conntrack: table full, dropping packet.
the five-day default is a trap, because you are not the one who forgets. Your kernel will hold an idle SSH session for days. The NAT gateway, cloud load balancer or corporate firewall in the middle typically evicts at 5 to 60 minutes, and when it does, the next packet in either direction matches nothing. What happens next is the middlebox's choice: silence, and your connection wedges until tcp_retries2 expires 15 minutes later; or a RST, and you get suspect number five from the first tab. The fix is not a firewall rule, it is TCPKeepAlive or an application-level ping under the shortest timeout on the path. The signature is that it always dies after the same idle interval.
conntrack is stateful, and state is a resource. Every tracked flow is memory, and the table is the target of the cheapest attack in the book: open flows, never complete them, walk away. conntrack -S reports per-CPU insert_failed and drop; those counters moving is the difference between "the network is slow" and "we are out of table". See Attack & Mitigation for the flood side of this, and the Linux network stack tunables for nf_conntrack_max and its hashsize.
DNATPREROUTING, before routingSNATPOSTROUTING, after routingrunsonce per flow

The two positions are not arbitrary and are worth memorising, because every confusing NAT bug follows from them. Destination translation has to happen before the routing decision, or the packet would be routed to the address you are trying to change. Source translation has to happen after it, or the rule could not know which interface, and therefore which source address, was selected.

PREROUTING
priority -100: DNAT, REDIRECT. Rewrites the destination. Port forwarding and load balancing live here.
routing decision
Made on the new destination address. This is why DNAT can send a packet down a completely different path than it was addressed to.
FORWARD
filter sees the post-DNAT destination. A rule written against the original public address will not match here.
POSTROUTING
priority +100: SNAT, MASQUERADE. Rewrites the source, now that the outgoing interface is known.
SNATRewrites the source to an address you name. Use it when the address is static: it is cheaper, and it survives an interface flap.
MASQUERADESNAT that looks up the outgoing interface's current address per packet. For DHCP or dial-up uplinks. It also drops all conntrack entries for the interface when it goes down, on the assumption the address changed.
DNATRewrites the destination, optionally to a different port. REDIRECT is the special case that means "to this machine".
the replyNever needs a rule. Conntrack stored the inverted reply tuple, so return traffic is un-translated automatically. Writing a second rule for the reverse direction is a classic and harmless mistake, and a sign the mental model is wrong.
NAT runs on the first packet of a flow only. The translation is recorded in the conntrack entry, and every later packet is rewritten from that entry without consulting the nat table again. Two consequences people hit constantly. First: editing a NAT rule does nothing to connections already open - they keep using the mapping recorded when they started, and you have to flush conntrack to see the change. Second: if conntrack loses the entry, the mapping is gone, and the remaining packets of a live connection arrive un-translated and unroutable. Everything on this tab is downstream of the previous one.
filter sees post-DNAT addresses, and that surprises everybody. Port-forward 203.0.113.5:443 to 10.0.0.7:8443 and then write -A FORWARD -d 203.0.113.5 -p tcp --dport 443 -j ACCEPT, and nothing matches: by the time FORWARD runs, the packet is addressed to 10.0.0.7:8443. The rule has to be written against the translated address. The only chain that sees the original is PREROUTING itself.
hairpin NAT is the same rule applied twice. A client inside the network reaching the public address of a server also inside it will DNAT correctly and then reply directly to the client, whose kernel drops the answer because it came from an address it never contacted. The fix is an extra SNAT so the server replies via the gateway, which is why hairpinning always looks like one rule too many.
iptablestable implies hooknftableshook is declaredbackendthe same since iptables-nft

One rule, both spellings, token by token. It rejects new inbound connections to 443 with a reset - which is to say, it is the third suspect from the first tab, written out.

iptables
iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW -j REJECT --reject-with tcp-reset
tokenkinddoes
-A INPUTchainAppend to the INPUT chain. The filter table is implied by omission, and filter+INPUT means NF_INET_LOCAL_IN at priority 0. The hook is never written down.
-p tcpmatchProtocol number 6. Also unlocks the tcp match extension, without which --dport is a syntax error.
--dport 443match extBelongs to the implicit tcp extension loaded by -p tcp, not to iptables itself.
-m conntrackmatch moduleLoads the conntrack matcher. The older -m state is a deprecated alias.
--ctstate NEWmatch extOnly flows conntrack has no entry for. Established traffic is unaffected, so this rule cannot break connections already open.
-j REJECTtargetTerminal. Generates a reply, then drops.
--reject-with tcp-resettarget optSend a RST instead of the default ICMP port-unreachable. Legal only because -p tcp matched.
nftables
// the base chain declares what iptables leaves implicit nft add table inet filter nft add chain inet filter input '{ type filter hook input priority filter; policy drop; }' nft add rule inet filter input tcp dport 443 ct state new reject with tcp reset
tokenkinddoes
inetfamilyOne ruleset for IPv4 and IPv6 at once. iptables needs two separate binaries and two rulesets to do this.
type filter hook inputbase chainThe hook, stated. type can also be nat or route.
priority filterbase chainThe integer from the previous tab, by name: filter is 0. Write -150 or mangle and the chain slots in there instead.
policy dropbase chainThe verdict when no rule matches. Only base chains have one.
tcp dport 443expressionNo -p tcp needed: the protocol is implied by the header being tested.
ct state newexpressionSame conntrack state, lowercase. Sets can be inline: ct state { established, related } accept.
reject with tcp resetverdictSame generated packet as above.
the ordering rule that outranks everything: first match wins, and the chain policy is last. Both syntaxes evaluate top to bottom and stop at the first terminal verdict. A permissive rule above a restrictive one silently wins, which is why -I (insert at the top) and -A (append at the bottom) are the two most consequential characters in the command. If a rule seems to do nothing, check what is above it before you check the rule.
they are the same engine now. Since iptables-nft, the iptables command is a translation layer that writes nftables rules into the kernel. iptables -L shows what iptables wrote; nft list ruleset shows everything, including rules from other tools. On a modern box with Docker, Kubernetes or firewalld installed, those two commands routinely disagree - and nft list ruleset is the one telling the truth.
questionwhich rule ate itcheapest answercounters

Guessing at rulesets is a waste of an afternoon. Every one of these answers a specific question, roughly in order of how much they cost to run.

counters: which rule is firing
// per-rule packet and byte counts. zero them, reproduce, look again. iptables -Z ; iptables -L -v -n --line-numbers nft reset counters ; nft -a list ruleset -a shows handles for surgical deletes

A rule with a moving counter is doing something. A DROP rule whose counter matches your attempts exactly is your answer, and you did not need a packet capture to find it.

trace: follow one packet through every hook
// nftables. mark the traffic, then watch it traverse the whole path. nft add rule inet filter prerouting meta nftrace set 1 nft monitor trace // iptables equivalent, raw table, then read the kernel ring buffer iptables -t raw -A PREROUTING -p tcp --dport 443 -j TRACE xtables-monitor --trace modern; older kernels log to dmesg

This is the only tool that shows the packet arriving at each hook in order and names the rule that finally decided. Narrow the match first - tracing everything on a busy host produces more output than you can read.

conntrack: watch state appear and expire
conntrack -E live event stream: NEW, UPDATE, DESTROY conntrack -L -p tcp --dport 443 current entries for one service conntrack -S per-cpu: insert_failed, invalid, drop cat /proc/sys/net/netfilter/nf_conntrack_count

A DESTROY event arriving well before your connection failed is the idle-timeout story from the conntrack tab, confirmed rather than guessed.

the wire: is anything coming back at all
// -v prints ttl. that is the whole point on this page. tcpdump -nnvvi any 'host 203.0.113.5 and (tcp[tcpflags] & tcp-rst != 0)' tcpdump -nnvi any 'icmp and icmp[icmptype] == 3' ss -tan state syn-sent stuck outbound handshakes nstat -az | grep -iE 'listendrops|listenoverflows|retrans'
run the capture on both ends. This is the single highest-value move on this whole page. If the client sees a RST and the server's capture shows it never sent one, the argument is over: something in between wrote that packet. If the server never saw the SYN at all, nothing on the server is at fault and the problem is upstream. Almost every "connection reset by peer" ticket is settled by two simultaneous captures and nothing else.
check the counters you did not think to check. nstat and netstat -s expose drops that no firewall rule accounts for: ListenOverflows means the accept queue filled and the kernel silently discarded a completed handshake, which looks exactly like a firewall drop from the client. net.ipv4.conf.*.rp_filter in strict mode drops packets whose source would not route back out the arriving interface, which looks like a firewall drop and appears in no ruleset at all - the classic asymmetric-routing false alarm.
usesymptom firstthenthe tab that explains it

What the failure looks like from the application, and where to go next.

symptommost likelycheck
Hangs about 127 seconds, then ETIMEDOUTYour SYN is being DROPped, somewhere.tcp_syn_retries arithmetic on the Drop tab. Capture both ends: did the SYN arrive?
Instant "connection refused"Nothing listening, or a REJECT rule. Both look identical to the client.ss -ltn on the server. If a listener exists, a rule sent that.
"Connection reset by peer", peer denies itInjected RST, or a REJECT rule short of the peer.TTL of the RST versus the peer's other packets. Capture both ends.
Dies after a fixed idle period, every timeConntrack or NAT state expiry in the middle.The interval is the middlebox's timeout. Add keepalives below it.
Handshake fine, large transfers hangPMTU black hole: ICMP type 3 code 4 is being filtered.Stop dropping ICMP. Test with ping -M do -s 1472.
Wedges roughly 15 minutes, then errorsAn established flow stopped getting through mid-stream.tcp_retries2. Something changed on the path after the connection opened.
Works to the host, not through itRules written in INPUT on a box that forwards.The routing-decision branch on the Hook Path tab. Transit never visits INPUT.
Port forward reaches nothingFORWARD rule written against the pre-DNAT address.filter sees post-DNAT addresses. See the NAT tab.
Changed a NAT rule, nothing changedOpen flows still use the mapping recorded at their first packet.Flush conntrack, or test with a brand new connection.
Intermittent drops under load, no rule matchesConntrack table full, or accept-queue overflow.conntrack -S, dmesg | grep conntrack, nstat | grep -i overflow.
One direction works, the other silently failsStrict rp_filter with asymmetric routing.Appears in no ruleset. sysctl net.ipv4.conf.all.rp_filter.
iptables looks empty, traffic still blockedRules written by another tool through nftables.nft list ruleset, not iptables -L.
the page between the pages
the bytes are documented; the fate is not. Every other breakdown here can tell you what a field means, because the meaning is written in an RFC. Nothing can tell you from the packet alone why it stopped, because the decision was made by a ruleset on a machine you may not own and left no trace in the packet. That asymmetry is the whole reason this page exists: the RST bit is fully specified on the TCP page, and none of that specification tells you who set it. The answer is always the same shape - capture at both ends and compare, then use the TTL, the ICMP quote, and the counters to place the decision on a specific box.
where to go from here. The RST bit in its header, the ICMP unreachable message in full, the TTL field this page leans on so hard, eBPF for the hooks that run before netfilter gets a say, Attack & Mitigation for what happens when the flood is deliberate, and the Linux network stack tunables for every sysctl named here.