◐ Off-By-One · answer catalog

shell-20261012-nftables-conntrack-zone-live-migration

1 answer(s)shellbash

Problem class: shell-20261012-nftables-conntrack-zone-live-migration

📦 Source in repository (JSON)

Answer

Zero-Downtime Conntrack-Zone Migration for a Linux Gateway Fleet

Problem class: shell-20261012-nftables-conntrack-zone-live-migration Target: fleet of NAT gateways that must move from a legacy ct zone scheme to a collision-free per-tenant scheme while flows are active, without losing asymmetric NAT state, without ever sharing a zone across tenants, and without leaving a host in a half-migrated state if it crashes between any two commands.

Canonical, tested artifacts are in ~/nft-zone-migration/ (10-stage.nft, 20-cutover.nft, 30-finalize.nft, 40-rollback.nft, zone-pin-reconciler.sh, gw-zone-migrate.sh, solution.md). All .nft files pass nft -c -f (nftables v1.1.6) and both scripts pass bash -n; the only diagnostic was the expected netlink CAP_NET_ADMIN error for a non-root check.


1. Root-cause analysis

1.1 Why zones are load-bearing

A conntrack zone is part of the tuple used to look up an nf_conntrack entry. That entry also stores the NAT decision. Therefore flow identity = (zone, original tuple) and NAT state lives in the entry in that zone. Overlapping <ip-address>/8 tenants can only be separated by distinct zones.

1.2 The legacy defects

  1. Zone derived from meta mark. meta mark is a shared, mutable 32-bit field; QoS, policy-routing, legacy NAT and other rules all write it. A packet reaches ct zone set meta mark carrying an unrelated value, so zone is not a function of tenant. A five-tuple from tenant A can match tenant B's entry — including its NAT binding — i.e. cross-tenant leakage.
  2. In-place renumbering breaks asymmetric NAT. Change the zone of a live flow and the next packet misses, creating a fresh entry with no NAT state. With SNAT-only or DNAT-only mappings the return path black-holes, or worse, collides with another tenant's entry.
  3. Non-atomic, non-idempotent edits. Flush/rebuild across several nft invocations exposes partial rulesets and duplicates rules. A crash between invocations leaves an undefined state.
  4. Zone 0 is a shared default. Unresolved tenant packets fall into zone 0, the cross-tenant bucket we are removing.

1.3 Required invariants


2. Design

Zone from tenant interface, not from mark. The selector reads only iifname/oifname through an ifname -> zone map. Deterministic, injective, immune to mark rewrites.

Generation pinning. At cutover, existing flows keep the old zone. A reconciler mirrors live conntrack into pin_old_tcp / pin_old_udp, keyed by full five-tuple, storing the old zone. raw does: 1. five-tuple in pin_old_* → set stored old zone, return; 2. else new flow → set tenant's new zone from the map; 3. else unresolved tenant → drop.

Because classification happens in raw before NAT, and a conntrack entry has two on-the-wire tuples (original + NAT-translated reply), both tuples are stored. That is what makes asymmetric NAT safe.

Atomicity + journaling. Every datapath change is one nft -f (single atomic kernel transaction). The desired phase is fsync-journaled before the transaction; a boot-time reconcile re-applies idempotently.

init ──stage──> staging ──cutover──> cutover ──drain──> draining ──finalize──> new
                   │                    │
                   └──── rollback ──────┴──> rolling_back ──> init

3. Exact artifacts

3.1 10-stage.nft (idempotent, no datapath change)

add table inet gw

add map inet gw tenant_zone_old { type ifname : integer; }
add map inet gw tenant_zone_new { type ifname : integer; }

add map inet gw pin_old_tcp { type ipv4_addr . inet_service . ipv4_addr . inet_service : integer; flags timeout; timeout 30m; size 524288; }
add map inet gw pin_old_udp { type ipv4_addr . inet_service . ipv4_addr . inet_service : integer; flags timeout; timeout 2m;  size 524288; }
add map inet gw pin_new_tcp { type ipv4_addr . inet_service . ipv4_addr . inet_service : integer; flags timeout; timeout 30m; size 524288; }
add map inet gw pin_new_udp { type ipv4_addr . inet_service . ipv4_addr . inet_service : integer; flags timeout; timeout 2m;  size 524288; }

add element inet gw tenant_zone_old { "lan100" : 3100, "lan101" : 3101, "lan102" : 3102 }
add element inet gw tenant_zone_new { "lan100" : 4100, "lan101" : 4101, "lan102" : 4102 }

add chain inet gw raw { type filter hook prerouting priority raw; policy accept; }

3.2 20-cutover.nft (one atomic transaction)

flush chain inet gw raw

add rule inet gw raw ip saddr . tcp sport . ip daddr . tcp dport @pin_old_tcp ct zone set ip saddr . tcp sport . ip daddr . tcp dport map @pin_old_tcp counter return
add rule inet gw raw ip saddr . udp sport . ip daddr . udp dport @pin_old_udp ct zone set ip saddr . udp sport . ip daddr . udp dport map @pin_old_udp counter return

add rule inet gw raw iifname @tenant_zone_new ct zone set iifname map @tenant_zone_new counter return
add rule inet gw raw oifname @tenant_zone_new ct zone set oifname map @tenant_zone_new counter return

add rule inet gw raw iifname "lan*" counter drop
add rule inet gw raw oifname "lan*" counter drop

3.3 30-finalize.nft

flush chain inet gw raw

add rule inet gw raw iifname @tenant_zone_new ct zone set iifname map @tenant_zone_new counter return
add rule inet gw raw oifname @tenant_zone_new ct zone set oifname map @tenant_zone_new counter return

add rule inet gw raw iifname "lan*" counter drop
add rule inet gw raw oifname "lan*" counter drop

destroy map inet gw pin_old_tcp
destroy map inet gw pin_old_udp
destroy map inet gw pin_new_tcp
destroy map inet gw pin_new_udp

3.4 40-rollback.nft (mirror of cutover)

flush chain inet gw raw

add rule inet gw raw ip saddr . tcp sport . ip daddr . tcp dport @pin_new_tcp ct zone set ip saddr . tcp sport . ip daddr . tcp dport map @pin_new_tcp counter return
add rule inet gw raw ip saddr . udp sport . ip daddr . udp dport @pin_new_udp ct zone set ip saddr . udp sport . ip daddr . udp dport map @pin_new_udp counter return

add rule inet gw raw iifname @tenant_zone_old ct zone set iifname map @tenant_zone_old counter return
add rule inet gw raw oifname @tenant_zone_old ct zone set oifname map @tenant_zone_old counter return

add rule inet gw raw iifname "lan*" counter drop
add rule inet gw raw oifname "lan*" counter drop

3.5 zone-pin-reconciler.sh

#!/usr/bin/env bash
# usage: zone-pin-reconciler.sh <old|new> <snapshot|daemon|clear>
set -euo pipefail

NFT="${NFT:-nft}"
CONNTRACK="${CONNTRACK:-conntrack}"
TABLE="${TABLE:-gw}"

generation="${1:?usage: $0 <old|new> <snapshot|daemon|clear>}"
action="${2:?usage: $0 <old|new> <snapshot|daemon|clear>}"

old_zone=( [100]=3100 [101]=3101 [102]=3102 )
new_zone=( [100]=4100 [101]=4101 [102]=4102 )

case "$generation" in
  old) declare -n ZONE_OF=old_zone ;;
  new) declare -n ZONE_OF=new_zone ;;
  *) echo "unknown generation: $generation" >&2; exit 2 ;;
esac

map_tcp="pin_${generation}_tcp"
map_udp="pin_${generation}_udp"

zones=()
for t in "${!ZONE_OF[@]}"; do zones+=("${ZONE_OF[$t]}"); done

emit_records() {
  local verb="$1" zone="$2"
  awk -v verb="$verb" -v zone="$zone" -v mtcp="$map_tcp" -v mudp="$map_udp" '
    function emit(map, s, sp, d, dp) {
      if (s == "" || sp == "" || d == "" || dp == "") return
      if (verb == "add")
        printf "add element inet gw %s { %s . %s . %s . %s : %s }\n", map, s, sp, d, dp, zone
      else
        printf "delete element inet gw %s { %s . %s . %s . %s }\n", map, s, sp, d, dp
    }
    {
      if ($1 != "tcp" && $1 != "udp") next
      map = ($1 == "tcp") ? mtcp : mudp
      ns=0; nd=0; nsp=0; ndp=0
      for (i=1;i<=NF;i++) {
        if      ($i ~ /^src=/)   S[++ns]   = substr($i,5)
        else if ($i ~ /^dst=/)   D[++nd]   = substr($i,5)
        else if ($i ~ /^sport=/) SP[++nsp] = substr($i,7)
        else if ($i ~ /^dport=/) DP[++ndp] = substr($i,7)
      }
      for (k=1;k<=ns;k++) emit(map, S[k], SP[k], D[k], DP[k])
    }'
}

apply_batch() {
  local verb="$1" zone="$2" batch
  batch="$(mktemp)"
  if ! "$CONNTRACK" -L -z "$zone" -o extended 2>/dev/null \
        | emit_records "$verb" "$zone" > "$batch"; then
    rm -f "$batch"; return 0
  fi
  [ -s "$batch" ] && "$NFT" -f "$batch" || true
  rm -f "$batch"
}

case "$action" in
  snapshot) for z in "${zones[@]}"; do apply_batch add "$z"; done ;;
  clear)    for m in "$map_tcp" "$map_udp"; do "$NFT" flush map inet "$TABLE" "$m"; done ;;
  daemon)
    for z in "${zones[@]}"; do
      (
        "$CONNTRACK" -E -z "$z" -o extended 2>/dev/null |
        while read -r line; do
          case "$line" in
            \[NEW\]*)     printf '%s\n' "$line" | emit_records add    "$z" | "$NFT" -f - || true ;;
            \[DESTROY\]*) printf '%s\n' "$line" | emit_records delete "$z" | "$NFT" -f - || true ;;
          esac
        done
      ) &
    done
    wait
    ;;
esac

Backstop: run ... old snapshot from a 30 s timer in staging/cutover/draining; event buffers can overflow. Use conntrack -E --buffer-size 1048576 and batch nft updates on busy gateways.

3.6 gw-zone-migrate.sh (driver)

The full script is in the artifact directory. Key structure:

preflight() { # syntax-check assets, assert unique non-zero zones, grep all 'ct zone set'
stage()     { write_state staging;  nft -f 10-stage.nft; }
pinner()    { zone-pin-reconciler.sh snapshot old; zone-pin-reconciler.sh daemon old & }
cutover()   { sleep 1; write_state cutover;  nft -f 20-cutover.nft; }   # atomic
drain()     { write_state draining; while [[ $(old_entries) -ne 0 ]]; do sleep 5; done; }
finalize()  { write_state finalizing; nft -f 30-finalize.nft; write_state new; }
rollback()  { # snapshot new -> write_state rolling_back -> nft -f 40-rollback.nft
              # -> wait new zones == 0 -> write_state init
            }
reconcile() { # read state; for cutover/draining: stage, re-snapshot old, apply cutover, resume
            }
verify()    { # assert no duplicate zone ids; dump raw chain + per-zone counts
            }

/etc/systemd/system/gw-zone-migrate-reconcile.service runs gw-zone-migrate.sh reconcile at boot.

3.7 Legacy meta mark hardening

nft list ruleset -a | grep -nE 'meta mark (set|and)|ct mark set'
# reserve bits 24..31 for the platform; legacy rules mask, e.g.
#   meta mark 0x2      ->  ( meta mark & 0x00ffffff ) == 0x2
#   meta mark set 0x2  ->  meta mark set ( meta mark and 0xff000000 ) or 0x2

4. Ordered, idempotent runbook (per gateway)

install -m 0644 10-stage.nft 20-cutover.nft 30-finalize.nft 40-rollback.nft /etc/nftables/zone-migration/
install -m 0755 zone-pin-reconciler.sh gw-zone-migrate.sh /usr/local/sbin/

gw-zone-migrate.sh preflight   # 0. assets parse, zone plan is injective
gw-zone-migrate.sh stage       # 1. maps/chains only, NO datapath change
gw-zone-migrate.sh pinner      # 2. snapshot + conntrack -E stream for old zone
gw-zone-migrate.sh cutover     # 3. ONE atomic transaction
gw-zone-migrate.sh drain       # 4. old generation -> 0
gw-zone-migrate.sh finalize    # 5. remove pin maps + old generation
gw-zone-migrate.sh verify      # 6. isolation + observability

Raw datapath step: nft -c -f .../20-cutover.nft && nft -f .../20-cutover.nft.


5. Rollback procedure

gw-zone-migrate.sh rollback
State Action Result
staging journal init no datapath change
cutover, draining snapshot live new zone into pin_new_*, atomically apply 40-rollback.nft, wait new zones → 0, journal init new flows keep new zone (NAT preserved); new connections return to old zone
new refuse finalized is a one-way door; start a fresh forward migration

40-rollback.nft pins new-generation flows first, then installs the old fallback — the exact mirror of 20-cutover.nft.


6. Crash safety

Every datapath change is one atomic nft -f; the desired phase is fsync-journaled before the transaction; every phase apply is idempotent; boot runs reconcile.

Crash point State seen Recovery
after staging write, before apply staging apply stage
after cutover write, before apply cutover stage → re-snapshot old → apply cutover
during drain draining stage → re-snapshot → apply cutover → resume drain
after finalizing, before apply finalizing apply finalize (destroy map is safe)
during rollback rolling_back stage → re-snapshot new → apply rollback
after finalize new apply finalize (idempotent)

reconcile from cutover/draining re-snapshots the old zone before re-applying, so surviving flows keep old zone + NAT.


7. Verification

7.1 Static

for f in /etc/nftables/zone-migration/*.nft; do
  nft -c -f "$f" 2>&1 | grep -qi 'syntax error' && echo "FAIL $f" || echo "ok $f"
done
nft list map inet gw tenant_zone_old   # {lan100:3100, lan101:3101, lan102:3102}
nft list map inet gw tenant_zone_new   # {lan100:4100, ...}  no overlaps / no 0
nft list ruleset | grep -n 'ct zone set'

7.2 Isolation (two overlapping tenants)

conntrack -L -z 4100 | cut -d' ' -f1-7 | sort > /tmp/t100
conntrack -L -z 4101 | cut -d' ' -f1-7 | sort > /tmp/t101
comm -12 /tmp/t100 /tmp/t101          # MUST be empty
conntrack -L | grep 'zone=0'          # MUST be empty for tenant tuples

7.3 Asymmetric-NAT continuity

conntrack -L -o extended | grep -E 'zone=(3100|3101)'   # before
ping -f -i 0.02 <server> &
gw-zone-migrate.sh cutover
conntrack -L -z 3100     # established flows, original NAT binding
conntrack -L -z 4100     # new flows
gw-zone-migrate.sh drain

7.4 Zero-downtime: ping -c 10000 -i 0.01 <server> and iperf3 across cutover+drain → 0% loss, no resets/rebinding.

7.5 Crash injection: kill -9 the pinner at each phase, systemctl restart gw-zone-migrate-reconcile, re-run 7.2.

7.6 Rollback drill: run rollback after cutover under the same probes; new generation drains to 0, continuity preserved.


8. Cross-tenant leakage checklist


9. Residual risk and notes

Evidence & signatures

# Evidence
- Problem class: shell-20261012-nftables-conntrack-zone-live-migration
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-12T16:23:17.052Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Plan a zero-downtime migration of conntrack zones across a fleet of Linux gateways while flows are active, NAT state is asymmetric, and packet marks can be reused by legacy rules. Produce an ordered, idempotent command sequence and a rollback procedure that prevents cross-tenant state leakage even if a host crashes between any two operations.", "environment": "bash", "language": "shell", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "shell-20261012-nftables-conntrack-zone-live-migration", "provider": "openrouter", "solved_at": "2026-10-12T16:23:17.052Z", "version": "5"}
Generated from the verified corpus · MIT licensedBack to the catalog