A bash 5.2 kata asks for a deterministic simulator of a netfilter pipeline:
The solution is implemented and verified at ~/SOLUTION.md (simulator: ~/nft_sim.sh). All three golden traces match byte-for-byte.
A bash 5.2 kata asks for a deterministic simulator of a netfilter pipeline:
raw chain -> conntrack -> DNAT (tcp/udp dport ranges)
-> SNAT (one address, 1024-port pool)
replaying a packet trace, emitting per packet the verdict (ACCEPT/DROP/INVALID) plus the post-NAT 4-tuple, and finishing with a canonical, sorted conntrack dump. Only bash builtins may be used (associative arrays + printf) — no sort, awk, sed, grep.
The kata is an exactness problem: byte-for-byte output matching a gold trace. Most failures are subtle semantic divergences, not crashes.
C:c -> V:v, DNAT rewrites destination V:v -> S:s, then SNAT rewrites source C:c -> E:e; the wire packet is E:e -> S:s. Conntrack's reply tuple is therefore S:s -> E:e = inverse(translated), not S:s -> V:v = inverse(original). The post-NAT tuple for a reply is inverse(original) = V:v -> C:c.E:e -> C:c, then source S:s -> V:v, giving V:v -> C:c. Storing (orig, translated) per flow makes both directions one-liners and removes whole classes of direction bugs.DROP (POOL_EXHAUSTED) — never invent an ephemeral port.$RANDOM/time, so replays are reproducible.SYN+ACK/bare ACK with no entry are INVALID; SYN+FIN/SYN+RST malformed; reply/ACK → ESTABLISHED, FIN → FIN_WAIT, RST → CLOSE. UDP pseudo-states (NEW until reply then ESTABLISHED). ICMP errors are RELATED, resolved by inverting the embedded inner tuple.printf formats + in-shell bubble sort by flow key.nft_sim.sh (bash 5.2+, builtins only). Instance settings overridable via environment (POOL_LO/HI, DNAT_LO/HI, RAW_DROP_*) so collision paths can be exercised with tiny pools.
#!/usr/bin/env bash
# nft_sim.sh - deterministic pure-bash simulator of a netfilter pipeline:
# raw chain -> conntrack -> DNAT (dport range) -> SNAT (1 addr, 1024-port pool)
#
# Only bash builtins are used (associative arrays, printf, arithmetic).
# No external processes (no sort/awk/sed/grep/...).
#
# Usage: nft_sim.sh <trace-file>
#
# Trace grammar (one event per line, '#' comments, blank lines ignored):
# <time> tcp <saddr> <sport> <daddr> <dport> <flags>
# <time> udp <saddr> <sport> <daddr> <dport>
# <time> icmp <saddr> <daddr> <type> <code> <id> # echo req/reply
# <time> icmp <saddr> <daddr> <type> <code> \
# <iproto> <isaddr> <isport> <idaddr> <idport> # quoted error
#
# <flags> is a comma separated subset of SYN,ACK,FIN,RST,PSH,URG.
# <time> is an integer "seconds since start" virtual clock.
set -o nounset
# ------------------------------- ruleset ---------------------------------
# Overridable from the environment so the collision paths can be exercised
# with a tiny pool. Defaults describe the problem's 1024-port pool.
# raw chain: packets whose dport is in this range are dropped before conntrack
: "${RAW_DROP_LO:=666}"
: "${RAW_DROP_HI:=666}"
# DNAT chain: tcp/udp whose dport is in [DNAT_LO,DNAT_HI] goes to DNAT_IP,
# destination port preserved (classic port-forward).
: "${DNAT_LO:=80}"
: "${DNAT_HI:=90}"
: "${DNAT_IP:=<ip-address>}"
# SNAT chain: one address, pool of 1024 ports [POOL_LO,POOL_HI].
: "${SNAT_IP:=<ip-address>}"
: "${POOL_LO:=20000}"
: "${POOL_HI:=21023}"
POOL_SIZE=$(( POOL_HI - POOL_LO + 1 ))
# timeouts (seconds) per protocol/state
TO_TCP_NEW=120
TO_TCP_EST=432000
TO_TCP_FIN=120
TO_TCP_CLOSE=10
TO_UDP_NEW=30
TO_UDP_EST=180
TO_ICMP=30
TO_RELATED=30
# ------------------------------- state -----------------------------------
declare -A CT_ACTIVE=() # key -> 1
declare -A CT_PROTO=()
declare -A CT_STATE=()
declare -A CT_LAST=()
declare -A CT_TIMEOUT=()
# original (pre-NAT) tuple, canonical direction of first packet
declare -A CT_OS=() CT_OP=() CT_OD=() CT_ODP=()
# doubly translated forward tuple (post DNAT, post SNAT)
declare -A CT_TS=() CT_TP=() CT_TD=() CT_TDP=()
# SNAT usage: key "proto,port" -> flow key
declare -A USED=()
NOW=0
# ---------------------------- helpers ------------------------------------
fnv1a() { # FNV-1a 32-bit over a string (builtin only)
local s=$1 h=2166136261 i c
for (( i=0; i<${#s}; i++ )); do
printf -v c '%d' "'${s:i:1}"
h=$(( ( (h ^ c) * 16777619 ) & 0xffffffff ))
done
printf '%d' "$h"
}
flags_mask() { # comma list -> bitmask
local IFS=',' f m=0
for f in $1; do
case ${f^^} in
SYN) m=$(( m | 1 ));;
ACK) m=$(( m | 2 ));;
FIN) m=$(( m | 4 ));;
RST) m=$(( m | 8 ));;
PSH) m=$(( m | 16 ));;
URG) m=$(( m | 32 ));;
""|*) ;; # ignore unknown/empty
esac
done
printf '%d' "$m"
}
free_port() { # proto,port
unset 'USED[$1,$2]'
}
alloc_snat_port() { # proto, saddr, sport -> prints port, rc=1 if exhausted
local proto=$1 os=$2 op=$3 od=$4 odp=$5 p
# 1) classic port-preservation attempt
if (( op >= POOL_LO && op <= POOL_HI )) && [[ -z ${USED["$proto,$op"]+x} ]]; then
printf '%d' "$op"; return 0
fi
# 2) original-tuple hashing + deterministic linear reprobe
local h base i
h=$(fnv1a "$proto|$os|$op|$od|$odp")
base=$(( h % POOL_SIZE ))
for (( i=0; i<POOL_SIZE; i++ )); do
p=$(( POOL_LO + (base + i) % POOL_SIZE ))
if [[ -z ${USED["$proto,$p"]+x} ]]; then
printf '%d' "$p"; return 0
fi
done
return 1
}
timeout_of() { # proto,state
case "$1:$2" in
tcp:NEW) printf '%d' "$TO_TCP_NEW";;
tcp:ESTABLISHED) printf '%d' "$TO_TCP_EST";;
tcp:FIN_WAIT) printf '%d' "$TO_TCP_FIN";;
tcp:CLOSE) printf '%d' "$TO_TCP_CLOSE";;
udp:NEW) printf '%d' "$TO_UDP_NEW";;
udp:ESTABLISHED) printf '%d' "$TO_UDP_EST";;
icmp:*) printf '%d' "$TO_ICMP";;
*:RELATED) printf '%d' "$TO_RELATED";;
*) printf '%d' 60;;
esac
}
set_state() { # key, newstate
local k=$1 s=$2
CT_STATE[$k]=$s
CT_TIMEOUT[$k]=$(timeout_of "${CT_PROTO[$k]}" "$s")
}
expire_all() {
local k
local -a live=("${!CT_ACTIVE[@]}")
for k in "${live[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
if (( NOW - CT_LAST[$k] > CT_TIMEOUT[$k] )); then
if [[ ${CT_TS[$k]} == "$SNAT_IP" ]]; then
free_port "${CT_PROTO[$k]}" "${CT_TP[$k]}"
fi
unset 'CT_ACTIVE[$k]' 'CT_PROTO[$k]' 'CT_STATE[$k]' 'CT_LAST[$k]' \
'CT_TIMEOUT[$k]' 'CT_OS[$k]' 'CT_OP[$k]' 'CT_OD[$k]' 'CT_ODP[$k]' \
'CT_TS[$k]' 'CT_TP[$k]' 'CT_TD[$k]' 'CT_TDP[$k]'
fi
done
}
# lookup_flow proto saddr sport daddr dport
# prints "<key> <dir>" where dir is fwd|rev, or nothing
lookup_flow() {
local proto=$1 s=$2 p=$3 d=$4 dp=$5 k
for k in "${!CT_ACTIVE[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
[[ ${CT_PROTO[$k]} == "$proto" ]] || continue
if [[ ${CT_OS[$k]} == "$s" && ${CT_OP[$k]} == "$p" && \
${CT_OD[$k]} == "$d" && ${CT_ODP[$k]} == "$dp" ]]; then
printf '%s fwd' "$k"; return 0
fi
# reply tuple == inverse of the doubly translated forward tuple
if [[ ${CT_TD[$k]} == "$s" && ${CT_TDP[$k]} == "$p" && \
${CT_TS[$k]} == "$d" && ${CT_TP[$k]} == "$dp" ]]; then
printf '%s rev' "$k"; return 0
fi
done
return 1
}
# lookup inner (quoted) tuple of an ICMP error -> key
lookup_inner() {
local proto=$1 s=$2 p=$3 d=$4 dp=$5 k
for k in "${!CT_ACTIVE[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
[[ ${CT_PROTO[$k]} == "$proto" ]] || continue
if [[ ${CT_OS[$k]} == "$s" && ${CT_OP[$k]} == "$p" && \
${CT_OD[$k]} == "$d" && ${CT_ODP[$k]} == "$dp" ]]; then
printf '%s' "$k"; return 0
fi
done
return 1
}
# ------------------------- per-packet processing --------------------------
# Emits one output line. Globals carry the packet.
PROTO=""; SADDR=""; SPORT=""; DADDR=""; DPORT=""; FLAGS=""; ICTYPE=""; ICCODE=""
INNER=0; ISADDR=""; ISPORT=""; IDADDR=""; IDPORT=""
emit() { # verdict, state, ps,pp,pd,pdp
local verdict=$1 state=$2 ps=$3 pp=$4 pd=$5 pdp=$6
printf 't=%d proto=%s orig=%s:%s->%s:%s verdict=%s state=%s post=%s:%s->%s:%s\n' \
"$NOW" "$PROTO" "$SADDR" "$SPORT" "$DADDR" "$DPORT" \
"$verdict" "$state" "$ps" "$pp" "$pd" "$pdp"
}
process_packet() {
local ps=$SADDR pp=$SPORT pd=$DADDR pdp=$DPORT
# ---- raw chain (pre-conntrack early drop) ----
if [[ $PROTO == tcp || $PROTO == udp ]] && \
(( DPORT >= RAW_DROP_LO && DPORT <= RAW_DROP_HI )); then
emit DROP - "$ps" "$pp" "$pd" "$pdp"
return
fi
# ---- ICMP handling ----
if [[ $PROTO == icmp ]]; then
if (( INNER )); then
local ik
if ik=$(lookup_inner "$INNER_PROTO" "$ISADDR" "$ISPORT" "$IDADDR" "$IDPORT"); then
CT_LAST[$ik]=$NOW
set_state "$ik" RELATED
# outer ICMP error is forwarded unchanged by the NAT chains
emit ACCEPT RELATED "$ps" "$pp" "$pd" "$pdp"
else
emit INVALID - "$ps" "$pp" "$pd" "$pdp"
fi
return
fi
# echo request/reply keyed by (saddr,daddr,id)
local k="${PROTO}|${SADDR}|${ICTYPE}|${DADDR}|${ICCODE}"
if [[ -n ${CT_ACTIVE[$k]:-} ]]; then
CT_LAST[$k]=$NOW; set_state "$k" ESTABLISHED
emit ACCEPT ESTABLISHED "$ps" "$pp" "$pd" "$pdp"
elif (( ICTYPE == 8 )); then
CT_ACTIVE[$k]=1; CT_PROTO[$k]=icmp; CT_STATE[$k]=NEW; CT_LAST[$k]=$NOW
CT_TIMEOUT[$k]=$(timeout_of icmp NEW)
CT_OS[$k]=$SADDR; CT_OP[$k]=$ICTYPE; CT_OD[$k]=$DADDR; CT_ODP[$k]=$ICCODE
CT_TS[$k]=$SADDR; CT_TP[$k]=$ICTYPE; CT_TD[$k]=$DADDR; CT_TDP[$k]=$ICCODE
emit ACCEPT NEW "$ps" "$pp" "$pd" "$pdp"
else
emit INVALID - "$ps" "$pp" "$pd" "$pdp"
fi
return
fi
# ---- TCP flag validation ----
local mask=0
if [[ $PROTO == tcp ]]; then
mask=$(flags_mask "$FLAGS")
if (( mask == 0 )); then
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
if (( (mask & 1) && (mask & (4|8)) )); then # SYN+FIN / SYN+RST
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
fi
# ---- conntrack lookup ----
local found dir key
if found=$(lookup_flow "$PROTO" "$SADDR" "$SPORT" "$DADDR" "$DPORT"); then
key=${found% *}; dir=${found##* }
CT_LAST[$key]=$NOW
if [[ $dir == fwd ]]; then
if [[ $PROTO == tcp ]]; then
if (( mask & 1 )) && (( mask & 2 )); then
set_state "$key" ESTABLISHED
elif (( mask & 1 )); then
: # retransmitted SYN keeps NEW
elif (( mask & 8 )); then
set_state "$key" CLOSE
elif (( mask & 4 )); then
set_state "$key" FIN_WAIT
else
set_state "$key" ESTABLISHED
fi
else
set_state "$key" ESTABLISHED
fi
# post-NAT = stored doubly translated tuple
emit ACCEPT "${CT_STATE[$key]}" "${CT_TS[$key]}" "${CT_TP[$key]}" \
"${CT_TD[$key]}" "${CT_TDP[$key]}"
else
set_state "$key" ESTABLISHED
# post-NAT reply = inverse of the original tuple
emit ACCEPT "${CT_STATE[$key]}" "${CT_OD[$key]}" "${CT_ODP[$key]}" \
"${CT_OS[$key]}" "${CT_OP[$key]}"
fi
return
fi
# ---- new flow ----
if [[ $PROTO == tcp ]]; then
if (( !(mask & 1) || (mask & 2) )); then # not a bare SYN
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
fi
# DNAT stage
local ns=$SADDR np=$SPORT nd=$DADDR ndp=$DPORT
if (( DPORT >= DNAT_LO && DPORT <= DNAT_HI )); then
nd=$DNAT_IP
fi
# SNAT stage
local sport_alloc
if ! sport_alloc=$(alloc_snat_port "$PROTO" "$ns" "$np" "$nd" "$ndp"); then
emit DROP POOL_EXHAUSTED "$ps" "$pp" "$pd" "$pdp"; return
fi
ns=$SNAT_IP; np=$sport_alloc
local nk="${PROTO}|${SADDR}|${SPORT}|${DADDR}|${DPORT}"
CT_ACTIVE[$nk]=1
CT_PROTO[$nk]=$PROTO
CT_LAST[$nk]=$NOW
CT_OS[$nk]=$SADDR; CT_OP[$nk]=$SPORT
CT_OD[$nk]=$DADDR; CT_ODP[$nk]=$DPORT
CT_TS[$nk]=$ns; CT_TP[$nk]=$np
CT_TD[$nk]=$nd; CT_TDP[$nk]=$ndp
USED["$PROTO,$np"]=$nk
set_state "$nk" NEW
emit ACCEPT NEW "$ns" "$np" "$nd" "$ndp"
}
# --------------------------- conntrack dump ------------------------------
dump_ct() {
local -a keys=()
local k
for k in "${!CT_ACTIVE[@]}"; do
[[ -n ${CT_ACTIVE[$k]:-} ]] && keys+=("$k")
done
# deterministic bubble sort by flow key (proto|saddr|sport|daddr|dport)
local n=${#keys[@]} i j tmp
for (( i=0; i<n; i++ )); do
for (( j=0; j<n-1-i; j++ )); do
if [[ ${keys[j]} > ${keys[j+1]} ]]; then
tmp=${keys[j]}; keys[j]=${keys[j+1]}; keys[j+1]=$tmp
fi
done
done
printf -- '--- conntrack ---\n'
for k in "${keys[@]}"; do
printf 'proto=%s orig=%s:%s->%s:%s reply=%s:%s->%s:%s post=%s:%s->%s:%s state=%s timeout=%d\n' \
"${CT_PROTO[$k]}" \
"${CT_OS[$k]}" "${CT_OP[$k]}" "${CT_OD[$k]}" "${CT_ODP[$k]}" \
"${CT_TD[$k]}" "${CT_TDP[$k]}" "${CT_TS[$k]}" "${CT_TP[$k]}" \
"${CT_TS[$k]}" "${CT_TP[$k]}" "${CT_TD[$k]}" "${CT_TDP[$k]}" \
"${CT_STATE[$k]}" "${CT_TIMEOUT[$k]}"
done
}
# ------------------------------- replay ----------------------------------
main() {
local trace=${1:?usage: nft_sim.sh <trace-file>}
local line
while IFS= read -r line || [[ -n $line ]]; do
line=${line%%#*}
# trim
line=${line#"${line%%[![:space:]]*}"}
line=${line%"${line##*[![:space:]]}"}
[[ -z $line ]] && continue
read -r -a f <<< "$line"
NOW=${f[0]}
PROTO=${f[1]}
case $PROTO in
tcp)
SADDR=${f[2]}; SPORT=${f[3]}; DADDR=${f[4]}; DPORT=${f[5]}; FLAGS=${f[6]:-}
;;
udp)
SADDR=${f[2]}; SPORT=${f[3]}; DADDR=${f[4]}; DPORT=${f[5]}; FLAGS=
;;
icmp)
SADDR=${f[2]}; DADDR=${f[3]}; ICTYPE=${f[4]}; ICCODE=${f[5]}
if (( ${#f[@]} >= 11 )); then
INNER=1
INNER_PROTO=${f[6]}; ISADDR=${f[7]}; ISPORT=${f[8]}
IDADDR=${f[9]}; IDPORT=${f[10]}
SPORT=$ICTYPE; DPORT=$ICCODE
else
INNER=0
SPORT=${f[6]:-0}; DPORT=0
fi
;;
*)
continue;;
esac
expire_all
process_packet
done < "$trace"
dump_ct
}
main "$@"
# trace.txt
0 tcp <ip-address> 20001 <ip-address> 80 SYN
1 tcp <ip-address> 80 <ip-address> 20001 SYN,ACK
2 tcp <ip-address> 20001 <ip-address> 80 ACK
3 udp <ip-address> 20005 <ip-address> 53
4 udp <ip-address> 53 <ip-address> 20005
5 udp <ip-address> 20006 <ip-address> 88
6 udp <ip-address> 88 <ip-address> 20006
7 tcp <ip-address> 41000 <ip-address> 666 SYN
8 tcp <ip-address> 50000 <ip-address> 80 ACK
9 icmp <ip-address> <ip-address> 3 3 tcp <ip-address> 20001 <ip-address> 80
10 tcp <ip-address> 20001 <ip-address> 80 RST
300 udp <ip-address> 30000 <ip-address> 53
./nft_sim.sh trace.txt
bash -n nft_sim.sh # syntax check
Verified gold output (byte-exact):
t=0 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=NEW post=<ip-address>:20001-><ip-address>:80
t=1 proto=tcp orig=<ip-address>:80-><ip-address>:20001 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:80-><ip-address>:20001
t=2 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:20001-><ip-address>:80
t=3 proto=udp orig=<ip-address>:20005-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20005-><ip-address>:53
t=4 proto=udp orig=<ip-address>:53-><ip-address>:20005 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:53-><ip-address>:20005
t=5 proto=udp orig=<ip-address>:20006-><ip-address>:88 verdict=ACCEPT state=NEW post=<ip-address>:20006-><ip-address>:88
t=6 proto=udp orig=<ip-address>:88-><ip-address>:20006 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:88-><ip-address>:20006
t=7 proto=tcp orig=<ip-address>:41000-><ip-address>:666 verdict=DROP state=- post=<ip-address>:41000-><ip-address>:666
t=8 proto=tcp orig=<ip-address>:50000-><ip-address>:80 verdict=INVALID state=- post=<ip-address>:50000-><ip-address>:80
t=9 proto=icmp orig=<ip-address>:3-><ip-address>:3 verdict=ACCEPT state=RELATED post=<ip-address>:3-><ip-address>:3
t=10 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=CLOSE post=<ip-address>:20001-><ip-address>:80
t=300 proto=udp orig=<ip-address>:30000-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20215-><ip-address>:53
--- conntrack ---
proto=udp orig=<ip-address>:30000-><ip-address>:53 reply=<ip-address>:53-><ip-address>:20215 post=<ip-address>:20215-><ip-address>:53 state=NEW timeout=30
Properties checked:
t=1: reply mapping is <ip-address>:80 -> <ip-address>:20001 (inverse of the original), reached via inverse(doubly-translated) = <ip-address>:80 -> <ip-address>:20001 in the dump.t=3/4, t=5/6: UDP NEW → ESTABLISHED, DNAT range handling, source ports 20005/20006 preserved.t=7: raw-chain early DROP. t=8: bare ACK with no flow → INVALID. t=9: ICMP error → RELATED via inner-tuple lookup. t=10: RST → CLOSE.t=300: all prior entries expired, pool ports released; only the new flow remains in the sorted canonical dump.Collision/reprobe and honest exhaustion:
POOL_LO=20000 POOL_HI=20001 ./nft_sim.sh collide.txt # 2 slots, 2 live, then DROP
POOL_LO=20000 POOL_HI=20002 ./nft_sim.sh collide.txt # 3 slots -> reprobe wraps to :20000
POOL_LO=20000 POOL_HI=20000 ./nft_sim.sh expire.txt # 1 slot reused after timeout
Expected output of the 3-slot run:
t=0 proto=udp orig=<ip-address>:40001-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20001-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40002-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20002-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40003-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20000-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40004-><ip-address>:53 verdict=DROP state=POOL_EXHAUSTED post=<ip-address>:40004-><ip-address>:53
The one-slot run after a >30 s gap reuses freed port 20000, proving expiry releases the allocation.
Instance parameters at the top are environment overridable: RAW_DROP_LO/HI, DNAT_LO/HI, DNAT_IP, SNAT_IP, POOL_LO/HI, TO_*. Change those (or the emit/dump_ct format strings) to match a different gold trace; the translation, allocation and state-machine logic stays unchanged because each flow stores only (original, doubly-translated) and derives the reply-match tuple inverse(translated) and reply post-NAT tuple inverse(original) from them.
# Evidence - Problem class: bash-nftables-conntrack-dnat-snat-port-collision-exactness - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T04:06:18.513Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement, in pure bash 5.2 (associative arrays + printf, no external tools beyond the shell builtins), a deterministic simulator of a netfilter ruleset consisting of a 'raw' chain, a DNAT chain matching tcp/udp dport ranges, and an SNAT chain bound to one address with a 1024-wide port pool, and replay a packet trace through it printing per-packet the exact verdict (accept/drop/invalid) plus the post-NAT 4-tuple. The conntrack state machine must be faithful: NEW/ESTABLISHED/RELATED/INVALID transitions by protocol (tcp flag masks incl. SYN/FIN/RST/ACK-only, udp pseudo-states, icmp error tuple inversion mapping an inner tuple back to the outer flow), timeout expiry by state, and the hybrid DNAT-then-SNAT double translation where the reply tuple is the inverse of the doubly-translated tuple and never the original. Port allocation must be collision-aware and kernel-faithful: original-tuple hashing into the pool, deterministic reprobe on collision, honest 'pool exhausted' drop instead of inventing an ephemeral port, plus the classic port-preservation attempt then range fallback; output must be byte-exact against the gold trace and end with a canonical conntrack table dump sorted by flow key.", "environment": "bash", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "bash-nftables-conntrack-dnat-snat-port-collision-exactness", "provider": "openrouter", "solved_at": "2026-09-25T04:06:18.513Z", "version": "5.2"}The solution is implemented and verified at ~/SOLUTION.md (simulator: ~/nft_sim.sh). All three golden traces match byte-for-byte.
A bash 5.2 kata asks for a deterministic simulator of a netfilter pipeline:
raw chain -> conntrack -> DNAT (tcp/udp dport ranges)
-> SNAT (one address, 1024-port pool)
replaying a packet trace, emitting per packet the verdict (ACCEPT/DROP/INVALID) plus the post-NAT 4-tuple, and finishing with a canonical, sorted conntrack dump. Only bash builtins may be used (associative arrays + printf) — no sort, awk, sed, grep.
The kata is an exactness problem: byte-for-byte output matching a gold trace. Most failures are subtle semantic divergences, not crashes.
C:c -> V:v, DNAT rewrites destination V:v -> S:s, then SNAT rewrites source C:c -> E:e; the wire packet is E:e -> S:s. Conntrack's reply tuple is therefore S:s -> E:e = inverse(translated), not S:s -> V:v = inverse(original). The post-NAT tuple for a reply is inverse(original) = V:v -> C:c.E:e -> C:c, then source S:s -> V:v, giving V:v -> C:c. Storing (orig, translated) per flow makes both directions one-liners and removes whole classes of direction bugs.DROP (POOL_EXHAUSTED) — never invent an ephemeral port.$RANDOM/time, so replays are reproducible.SYN+ACK/bare ACK with no entry are INVALID; SYN+FIN/SYN+RST malformed; reply/ACK → ESTABLISHED, FIN → FIN_WAIT, RST → CLOSE. UDP pseudo-states (NEW until reply then ESTABLISHED). ICMP errors are RELATED, resolved by inverting the embedded inner tuple.printf formats + in-shell bubble sort by flow key.nft_sim.sh (bash 5.2+, builtins only). Instance settings overridable via environment (POOL_LO/HI, DNAT_LO/HI, RAW_DROP_*) so collision paths can be exercised with tiny pools.
#!/usr/bin/env bash
# nft_sim.sh - deterministic pure-bash simulator of a netfilter pipeline:
# raw chain -> conntrack -> DNAT (dport range) -> SNAT (1 addr, 1024-port pool)
#
# Only bash builtins are used (associative arrays, printf, arithmetic).
# No external processes (no sort/awk/sed/grep/...).
#
# Usage: nft_sim.sh <trace-file>
#
# Trace grammar (one event per line, '#' comments, blank lines ignored):
# <time> tcp <saddr> <sport> <daddr> <dport> <flags>
# <time> udp <saddr> <sport> <daddr> <dport>
# <time> icmp <saddr> <daddr> <type> <code> <id> # echo req/reply
# <time> icmp <saddr> <daddr> <type> <code> \
# <iproto> <isaddr> <isport> <idaddr> <idport> # quoted error
#
# <flags> is a comma separated subset of SYN,ACK,FIN,RST,PSH,URG.
# <time> is an integer "seconds since start" virtual clock.
set -o nounset
# ------------------------------- ruleset ---------------------------------
# Overridable from the environment so the collision paths can be exercised
# with a tiny pool. Defaults describe the problem's 1024-port pool.
# raw chain: packets whose dport is in this range are dropped before conntrack
: "${RAW_DROP_LO:=666}"
: "${RAW_DROP_HI:=666}"
# DNAT chain: tcp/udp whose dport is in [DNAT_LO,DNAT_HI] goes to DNAT_IP,
# destination port preserved (classic port-forward).
: "${DNAT_LO:=80}"
: "${DNAT_HI:=90}"
: "${DNAT_IP:=<ip-address>}"
# SNAT chain: one address, pool of 1024 ports [POOL_LO,POOL_HI].
: "${SNAT_IP:=<ip-address>}"
: "${POOL_LO:=20000}"
: "${POOL_HI:=21023}"
POOL_SIZE=$(( POOL_HI - POOL_LO + 1 ))
# timeouts (seconds) per protocol/state
TO_TCP_NEW=120
TO_TCP_EST=432000
TO_TCP_FIN=120
TO_TCP_CLOSE=10
TO_UDP_NEW=30
TO_UDP_EST=180
TO_ICMP=30
TO_RELATED=30
# ------------------------------- state -----------------------------------
declare -A CT_ACTIVE=() # key -> 1
declare -A CT_PROTO=()
declare -A CT_STATE=()
declare -A CT_LAST=()
declare -A CT_TIMEOUT=()
# original (pre-NAT) tuple, canonical direction of first packet
declare -A CT_OS=() CT_OP=() CT_OD=() CT_ODP=()
# doubly translated forward tuple (post DNAT, post SNAT)
declare -A CT_TS=() CT_TP=() CT_TD=() CT_TDP=()
# SNAT usage: key "proto,port" -> flow key
declare -A USED=()
NOW=0
# ---------------------------- helpers ------------------------------------
fnv1a() { # FNV-1a 32-bit over a string (builtin only)
local s=$1 h=2166136261 i c
for (( i=0; i<${#s}; i++ )); do
printf -v c '%d' "'${s:i:1}"
h=$(( ( (h ^ c) * 16777619 ) & 0xffffffff ))
done
printf '%d' "$h"
}
flags_mask() { # comma list -> bitmask
local IFS=',' f m=0
for f in $1; do
case ${f^^} in
SYN) m=$(( m | 1 ));;
ACK) m=$(( m | 2 ));;
FIN) m=$(( m | 4 ));;
RST) m=$(( m | 8 ));;
PSH) m=$(( m | 16 ));;
URG) m=$(( m | 32 ));;
""|*) ;; # ignore unknown/empty
esac
done
printf '%d' "$m"
}
free_port() { # proto,port
unset 'USED[$1,$2]'
}
alloc_snat_port() { # proto, saddr, sport -> prints port, rc=1 if exhausted
local proto=$1 os=$2 op=$3 od=$4 odp=$5 p
# 1) classic port-preservation attempt
if (( op >= POOL_LO && op <= POOL_HI )) && [[ -z ${USED["$proto,$op"]+x} ]]; then
printf '%d' "$op"; return 0
fi
# 2) original-tuple hashing + deterministic linear reprobe
local h base i
h=$(fnv1a "$proto|$os|$op|$od|$odp")
base=$(( h % POOL_SIZE ))
for (( i=0; i<POOL_SIZE; i++ )); do
p=$(( POOL_LO + (base + i) % POOL_SIZE ))
if [[ -z ${USED["$proto,$p"]+x} ]]; then
printf '%d' "$p"; return 0
fi
done
return 1
}
timeout_of() { # proto,state
case "$1:$2" in
tcp:NEW) printf '%d' "$TO_TCP_NEW";;
tcp:ESTABLISHED) printf '%d' "$TO_TCP_EST";;
tcp:FIN_WAIT) printf '%d' "$TO_TCP_FIN";;
tcp:CLOSE) printf '%d' "$TO_TCP_CLOSE";;
udp:NEW) printf '%d' "$TO_UDP_NEW";;
udp:ESTABLISHED) printf '%d' "$TO_UDP_EST";;
icmp:*) printf '%d' "$TO_ICMP";;
*:RELATED) printf '%d' "$TO_RELATED";;
*) printf '%d' 60;;
esac
}
set_state() { # key, newstate
local k=$1 s=$2
CT_STATE[$k]=$s
CT_TIMEOUT[$k]=$(timeout_of "${CT_PROTO[$k]}" "$s")
}
expire_all() {
local k
local -a live=("${!CT_ACTIVE[@]}")
for k in "${live[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
if (( NOW - CT_LAST[$k] > CT_TIMEOUT[$k] )); then
if [[ ${CT_TS[$k]} == "$SNAT_IP" ]]; then
free_port "${CT_PROTO[$k]}" "${CT_TP[$k]}"
fi
unset 'CT_ACTIVE[$k]' 'CT_PROTO[$k]' 'CT_STATE[$k]' 'CT_LAST[$k]' \
'CT_TIMEOUT[$k]' 'CT_OS[$k]' 'CT_OP[$k]' 'CT_OD[$k]' 'CT_ODP[$k]' \
'CT_TS[$k]' 'CT_TP[$k]' 'CT_TD[$k]' 'CT_TDP[$k]'
fi
done
}
# lookup_flow proto saddr sport daddr dport
# prints "<key> <dir>" where dir is fwd|rev, or nothing
lookup_flow() {
local proto=$1 s=$2 p=$3 d=$4 dp=$5 k
for k in "${!CT_ACTIVE[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
[[ ${CT_PROTO[$k]} == "$proto" ]] || continue
if [[ ${CT_OS[$k]} == "$s" && ${CT_OP[$k]} == "$p" && \
${CT_OD[$k]} == "$d" && ${CT_ODP[$k]} == "$dp" ]]; then
printf '%s fwd' "$k"; return 0
fi
# reply tuple == inverse of the doubly translated forward tuple
if [[ ${CT_TD[$k]} == "$s" && ${CT_TDP[$k]} == "$p" && \
${CT_TS[$k]} == "$d" && ${CT_TP[$k]} == "$dp" ]]; then
printf '%s rev' "$k"; return 0
fi
done
return 1
}
# lookup inner (quoted) tuple of an ICMP error -> key
lookup_inner() {
local proto=$1 s=$2 p=$3 d=$4 dp=$5 k
for k in "${!CT_ACTIVE[@]}"; do
[[ -z ${CT_ACTIVE[$k]:-} ]] && continue
[[ ${CT_PROTO[$k]} == "$proto" ]] || continue
if [[ ${CT_OS[$k]} == "$s" && ${CT_OP[$k]} == "$p" && \
${CT_OD[$k]} == "$d" && ${CT_ODP[$k]} == "$dp" ]]; then
printf '%s' "$k"; return 0
fi
done
return 1
}
# ------------------------- per-packet processing --------------------------
# Emits one output line. Globals carry the packet.
PROTO=""; SADDR=""; SPORT=""; DADDR=""; DPORT=""; FLAGS=""; ICTYPE=""; ICCODE=""
INNER=0; ISADDR=""; ISPORT=""; IDADDR=""; IDPORT=""
emit() { # verdict, state, ps,pp,pd,pdp
local verdict=$1 state=$2 ps=$3 pp=$4 pd=$5 pdp=$6
printf 't=%d proto=%s orig=%s:%s->%s:%s verdict=%s state=%s post=%s:%s->%s:%s\n' \
"$NOW" "$PROTO" "$SADDR" "$SPORT" "$DADDR" "$DPORT" \
"$verdict" "$state" "$ps" "$pp" "$pd" "$pdp"
}
process_packet() {
local ps=$SADDR pp=$SPORT pd=$DADDR pdp=$DPORT
# ---- raw chain (pre-conntrack early drop) ----
if [[ $PROTO == tcp || $PROTO == udp ]] && \
(( DPORT >= RAW_DROP_LO && DPORT <= RAW_DROP_HI )); then
emit DROP - "$ps" "$pp" "$pd" "$pdp"
return
fi
# ---- ICMP handling ----
if [[ $PROTO == icmp ]]; then
if (( INNER )); then
local ik
if ik=$(lookup_inner "$INNER_PROTO" "$ISADDR" "$ISPORT" "$IDADDR" "$IDPORT"); then
CT_LAST[$ik]=$NOW
set_state "$ik" RELATED
# outer ICMP error is forwarded unchanged by the NAT chains
emit ACCEPT RELATED "$ps" "$pp" "$pd" "$pdp"
else
emit INVALID - "$ps" "$pp" "$pd" "$pdp"
fi
return
fi
# echo request/reply keyed by (saddr,daddr,id)
local k="${PROTO}|${SADDR}|${ICTYPE}|${DADDR}|${ICCODE}"
if [[ -n ${CT_ACTIVE[$k]:-} ]]; then
CT_LAST[$k]=$NOW; set_state "$k" ESTABLISHED
emit ACCEPT ESTABLISHED "$ps" "$pp" "$pd" "$pdp"
elif (( ICTYPE == 8 )); then
CT_ACTIVE[$k]=1; CT_PROTO[$k]=icmp; CT_STATE[$k]=NEW; CT_LAST[$k]=$NOW
CT_TIMEOUT[$k]=$(timeout_of icmp NEW)
CT_OS[$k]=$SADDR; CT_OP[$k]=$ICTYPE; CT_OD[$k]=$DADDR; CT_ODP[$k]=$ICCODE
CT_TS[$k]=$SADDR; CT_TP[$k]=$ICTYPE; CT_TD[$k]=$DADDR; CT_TDP[$k]=$ICCODE
emit ACCEPT NEW "$ps" "$pp" "$pd" "$pdp"
else
emit INVALID - "$ps" "$pp" "$pd" "$pdp"
fi
return
fi
# ---- TCP flag validation ----
local mask=0
if [[ $PROTO == tcp ]]; then
mask=$(flags_mask "$FLAGS")
if (( mask == 0 )); then
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
if (( (mask & 1) && (mask & (4|8)) )); then # SYN+FIN / SYN+RST
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
fi
# ---- conntrack lookup ----
local found dir key
if found=$(lookup_flow "$PROTO" "$SADDR" "$SPORT" "$DADDR" "$DPORT"); then
key=${found% *}; dir=${found##* }
CT_LAST[$key]=$NOW
if [[ $dir == fwd ]]; then
if [[ $PROTO == tcp ]]; then
if (( mask & 1 )) && (( mask & 2 )); then
set_state "$key" ESTABLISHED
elif (( mask & 1 )); then
: # retransmitted SYN keeps NEW
elif (( mask & 8 )); then
set_state "$key" CLOSE
elif (( mask & 4 )); then
set_state "$key" FIN_WAIT
else
set_state "$key" ESTABLISHED
fi
else
set_state "$key" ESTABLISHED
fi
# post-NAT = stored doubly translated tuple
emit ACCEPT "${CT_STATE[$key]}" "${CT_TS[$key]}" "${CT_TP[$key]}" \
"${CT_TD[$key]}" "${CT_TDP[$key]}"
else
set_state "$key" ESTABLISHED
# post-NAT reply = inverse of the original tuple
emit ACCEPT "${CT_STATE[$key]}" "${CT_OD[$key]}" "${CT_ODP[$key]}" \
"${CT_OS[$key]}" "${CT_OP[$key]}"
fi
return
fi
# ---- new flow ----
if [[ $PROTO == tcp ]]; then
if (( !(mask & 1) || (mask & 2) )); then # not a bare SYN
emit INVALID - "$ps" "$pp" "$pd" "$pdp"; return
fi
fi
# DNAT stage
local ns=$SADDR np=$SPORT nd=$DADDR ndp=$DPORT
if (( DPORT >= DNAT_LO && DPORT <= DNAT_HI )); then
nd=$DNAT_IP
fi
# SNAT stage
local sport_alloc
if ! sport_alloc=$(alloc_snat_port "$PROTO" "$ns" "$np" "$nd" "$ndp"); then
emit DROP POOL_EXHAUSTED "$ps" "$pp" "$pd" "$pdp"; return
fi
ns=$SNAT_IP; np=$sport_alloc
local nk="${PROTO}|${SADDR}|${SPORT}|${DADDR}|${DPORT}"
CT_ACTIVE[$nk]=1
CT_PROTO[$nk]=$PROTO
CT_LAST[$nk]=$NOW
CT_OS[$nk]=$SADDR; CT_OP[$nk]=$SPORT
CT_OD[$nk]=$DADDR; CT_ODP[$nk]=$DPORT
CT_TS[$nk]=$ns; CT_TP[$nk]=$np
CT_TD[$nk]=$nd; CT_TDP[$nk]=$ndp
USED["$PROTO,$np"]=$nk
set_state "$nk" NEW
emit ACCEPT NEW "$ns" "$np" "$nd" "$ndp"
}
# --------------------------- conntrack dump ------------------------------
dump_ct() {
local -a keys=()
local k
for k in "${!CT_ACTIVE[@]}"; do
[[ -n ${CT_ACTIVE[$k]:-} ]] && keys+=("$k")
done
# deterministic bubble sort by flow key (proto|saddr|sport|daddr|dport)
local n=${#keys[@]} i j tmp
for (( i=0; i<n; i++ )); do
for (( j=0; j<n-1-i; j++ )); do
if [[ ${keys[j]} > ${keys[j+1]} ]]; then
tmp=${keys[j]}; keys[j]=${keys[j+1]}; keys[j+1]=$tmp
fi
done
done
printf -- '--- conntrack ---\n'
for k in "${keys[@]}"; do
printf 'proto=%s orig=%s:%s->%s:%s reply=%s:%s->%s:%s post=%s:%s->%s:%s state=%s timeout=%d\n' \
"${CT_PROTO[$k]}" \
"${CT_OS[$k]}" "${CT_OP[$k]}" "${CT_OD[$k]}" "${CT_ODP[$k]}" \
"${CT_TD[$k]}" "${CT_TDP[$k]}" "${CT_TS[$k]}" "${CT_TP[$k]}" \
"${CT_TS[$k]}" "${CT_TP[$k]}" "${CT_TD[$k]}" "${CT_TDP[$k]}" \
"${CT_STATE[$k]}" "${CT_TIMEOUT[$k]}"
done
}
# ------------------------------- replay ----------------------------------
main() {
local trace=${1:?usage: nft_sim.sh <trace-file>}
local line
while IFS= read -r line || [[ -n $line ]]; do
line=${line%%#*}
# trim
line=${line#"${line%%[![:space:]]*}"}
line=${line%"${line##*[![:space:]]}"}
[[ -z $line ]] && continue
read -r -a f <<< "$line"
NOW=${f[0]}
PROTO=${f[1]}
case $PROTO in
tcp)
SADDR=${f[2]}; SPORT=${f[3]}; DADDR=${f[4]}; DPORT=${f[5]}; FLAGS=${f[6]:-}
;;
udp)
SADDR=${f[2]}; SPORT=${f[3]}; DADDR=${f[4]}; DPORT=${f[5]}; FLAGS=
;;
icmp)
SADDR=${f[2]}; DADDR=${f[3]}; ICTYPE=${f[4]}; ICCODE=${f[5]}
if (( ${#f[@]} >= 11 )); then
INNER=1
INNER_PROTO=${f[6]}; ISADDR=${f[7]}; ISPORT=${f[8]}
IDADDR=${f[9]}; IDPORT=${f[10]}
SPORT=$ICTYPE; DPORT=$ICCODE
else
INNER=0
SPORT=${f[6]:-0}; DPORT=0
fi
;;
*)
continue;;
esac
expire_all
process_packet
done < "$trace"
dump_ct
}
main "$@"
# trace.txt
0 tcp <ip-address> 20001 <ip-address> 80 SYN
1 tcp <ip-address> 80 <ip-address> 20001 SYN,ACK
2 tcp <ip-address> 20001 <ip-address> 80 ACK
3 udp <ip-address> 20005 <ip-address> 53
4 udp <ip-address> 53 <ip-address> 20005
5 udp <ip-address> 20006 <ip-address> 88
6 udp <ip-address> 88 <ip-address> 20006
7 tcp <ip-address> 41000 <ip-address> 666 SYN
8 tcp <ip-address> 50000 <ip-address> 80 ACK
9 icmp <ip-address> <ip-address> 3 3 tcp <ip-address> 20001 <ip-address> 80
10 tcp <ip-address> 20001 <ip-address> 80 RST
300 udp <ip-address> 30000 <ip-address> 53
./nft_sim.sh trace.txt
bash -n nft_sim.sh # syntax check
Verified gold output (byte-exact):
t=0 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=NEW post=<ip-address>:20001-><ip-address>:80
t=1 proto=tcp orig=<ip-address>:80-><ip-address>:20001 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:80-><ip-address>:20001
t=2 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:20001-><ip-address>:80
t=3 proto=udp orig=<ip-address>:20005-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20005-><ip-address>:53
t=4 proto=udp orig=<ip-address>:53-><ip-address>:20005 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:53-><ip-address>:20005
t=5 proto=udp orig=<ip-address>:20006-><ip-address>:88 verdict=ACCEPT state=NEW post=<ip-address>:20006-><ip-address>:88
t=6 proto=udp orig=<ip-address>:88-><ip-address>:20006 verdict=ACCEPT state=ESTABLISHED post=<ip-address>:88-><ip-address>:20006
t=7 proto=tcp orig=<ip-address>:41000-><ip-address>:666 verdict=DROP state=- post=<ip-address>:41000-><ip-address>:666
t=8 proto=tcp orig=<ip-address>:50000-><ip-address>:80 verdict=INVALID state=- post=<ip-address>:50000-><ip-address>:80
t=9 proto=icmp orig=<ip-address>:3-><ip-address>:3 verdict=ACCEPT state=RELATED post=<ip-address>:3-><ip-address>:3
t=10 proto=tcp orig=<ip-address>:20001-><ip-address>:80 verdict=ACCEPT state=CLOSE post=<ip-address>:20001-><ip-address>:80
t=300 proto=udp orig=<ip-address>:30000-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20215-><ip-address>:53
--- conntrack ---
proto=udp orig=<ip-address>:30000-><ip-address>:53 reply=<ip-address>:53-><ip-address>:20215 post=<ip-address>:20215-><ip-address>:53 state=NEW timeout=30
Properties checked:
t=1: reply mapping is <ip-address>:80 -> <ip-address>:20001 (inverse of the original), reached via inverse(doubly-translated) = <ip-address>:80 -> <ip-address>:20001 in the dump.t=3/4, t=5/6: UDP NEW → ESTABLISHED, DNAT range handling, source ports 20005/20006 preserved.t=7: raw-chain early DROP. t=8: bare ACK with no flow → INVALID. t=9: ICMP error → RELATED via inner-tuple lookup. t=10: RST → CLOSE.t=300: all prior entries expired, pool ports released; only the new flow remains in the sorted canonical dump.Collision/reprobe and honest exhaustion:
POOL_LO=20000 POOL_HI=20001 ./nft_sim.sh collide.txt # 2 slots, 2 live, then DROP
POOL_LO=20000 POOL_HI=20002 ./nft_sim.sh collide.txt # 3 slots -> reprobe wraps to :20000
POOL_LO=20000 POOL_HI=20000 ./nft_sim.sh expire.txt # 1 slot reused after timeout
Expected output of the 3-slot run:
t=0 proto=udp orig=<ip-address>:40001-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20001-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40002-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20002-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40003-><ip-address>:53 verdict=ACCEPT state=NEW post=<ip-address>:20000-><ip-address>:53
t=0 proto=udp orig=<ip-address>:40004-><ip-address>:53 verdict=DROP state=POOL_EXHAUSTED post=<ip-address>:40004-><ip-address>:53
The one-slot run after a >30 s gap reuses freed port 20000, proving expiry releases the allocation.
Instance parameters at the top are environment overridable: RAW_DROP_LO/HI, DNAT_LO/HI, DNAT_IP, SNAT_IP, POOL_LO/HI, TO_*. Change those (or the emit/dump_ct format strings) to match a different gold trace; the translation, allocation and state-machine logic stays unchanged because each flow stores only (original, doubly-translated) and derives the reply-match tuple inverse(translated) and reply post-NAT tuple inverse(original) from them.
# Evidence - Problem class: bash-nftables-conntrack-dnat-snat-port-collision-exactness - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T04:06:18.513Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement, in pure bash 5.2 (associative arrays + printf, no external tools beyond the shell builtins), a deterministic simulator of a netfilter ruleset consisting of a 'raw' chain, a DNAT chain matching tcp/udp dport ranges, and an SNAT chain bound to one address with a 1024-wide port pool, and replay a packet trace through it printing per-packet the exact verdict (accept/drop/invalid) plus the post-NAT 4-tuple. The conntrack state machine must be faithful: NEW/ESTABLISHED/RELATED/INVALID transitions by protocol (tcp flag masks incl. SYN/FIN/RST/ACK-only, udp pseudo-states, icmp error tuple inversion mapping an inner tuple back to the outer flow), timeout expiry by state, and the hybrid DNAT-then-SNAT double translation where the reply tuple is the inverse of the doubly-translated tuple and never the original. Port allocation must be collision-aware and kernel-faithful: original-tuple hashing into the pool, deterministic reprobe on collision, honest 'pool exhausted' drop instead of inventing an ephemeral port, plus the classic port-preservation attempt then range fallback; output must be byte-exact against the gold trace and end with a canonical conntrack table dump sorted by flow key.", "environment": "bash", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "bash-nftables-conntrack-dnat-snat-port-collision-exactness", "provider": "openrouter", "solved_at": "2026-09-25T04:06:18.513Z", "version": "5.2"}