Skip to content

feat(nat): Add NAT64 egress by generalizing the NAT66 tier into galactic-nat - #525

Merged
privateip merged 12 commits into
mainfrom
feat/nat64-egress
Sep 13, 2026
Merged

privateip merged 12 commits into
mainfrom
feat/nat64-egress

Conversation

@privateip

@privateip privateip commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Implements enhancements#879. Depends on network#22 (merged; go.mod points at it).

IPv6-only instances get a path to the IPv4 internet. galactic-nat66 becomes galactic-nat, serving NAT66, NAT64, or both from one session table, one port allocator, and one CRD.

Where this follows the design, and where it doesn't

Shared, as designed: the session table, port allocator, tenant accounting, counters, CRD, and route installation. conn_key gains a family byte so a NAT64 row's IPv4-mapped addresses cannot alias a real IPv6 flow in ::ffff:0:0/96.

Split, against the letter of the design: the two translation cores are separate eBPF programs behind a tail-call dispatcher, not branches in one program. NAT66 is pure PAT — the IPv6 header stays an IPv6 header. NAT64 synthesizes an IPv4 header, computes a header checksum IPv6 has no counterpart for, and takes a checksum delta across a pseudo-header whose family changed. Inlining both into one program is a verifier-complexity risk, and the design's own "keep the families structurally separate" requirement makes that arrangement worst. Tail calls give each leaf its own instruction budget.

Scope reduced: TCP and UDP only. ICMP translation and fragment handling are absent, so this is not yet a complete RFC 6146 implementation and PMTUD across the translator does not work. Each gap increments its own named drop counter rather than passing silently.

Test plan

  • task lint — clean
  • task test:unit
  • Datapath suite as root, all 5 programs verifier-accepted: NAT64 forward and return translate real packets, with both checksums verified against independent full recomputes rather than the datapath's own arithmetic; tenant isolation across families; limit fail-closed; fragment/options/unavailable drops
  • NAT66's existing regression suite passes unmodified against the generalized binary — the design's validation step 5
  • task test:e2e — needs a Kind cluster
  • NAT64 end-to-end — not possible in containerlab: the lab's transit mesh is IPv6-only with no IPv4 upstream, so enabling NAT64 there would produce shards that translate correctly and then blackhole, indistinguishable from a broken deployment. The lab stays NAT66-only and deploy/containerlab/resources/galactic-nat/README.md records what turning it on would require.

@privateip
privateip requested a review from a team as a code owner September 11, 2026 17:59

@ecv ecv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diff exceeds auto-review threshold (7536 lines across 98 files) — flagging for human review.

scotwells
scotwells previously approved these changes Sep 12, 2026
privateip and others added 9 commits September 12, 2026 22:26
NAT64 and NAT66 are the same function -- stateful egress PAT with a
VRF-scoped session table, port allocation, and decap/re-encap on the return
path -- over different address families, so this generalizes the existing
datapath rather than adding a second, near-duplicate one beside it.

nat66prog becomes natprog and nat66.c becomes nat.c, now holding five
programs instead of one: a dispatcher, nat_ingress, that classifies a
packet and tail-calls one of four translation leaves (nat66_forward,
nat66_return, nat64_forward, nat64_return).

The split is not stylistic. Holding both families' full translation paths
in one program -- each with its own header synthesis, checksum handling,
and an unrolled port-allocation probe loop, all force-inlined -- is a
verifier complexity risk with no upside, and it is the arrangement this
design's own "keep the two families structurally separate" requirement
makes worst. Tail calls give each leaf its own instruction budget and keep
a defect in one family's translation from growing the other's program.

Shared, and the whole reason to combine rather than duplicate: conn_key
and conn_value, the port allocator, the tenant session accounting, and the
drop counters. conn_key gains a family byte so a NAT64 row, whose IPv4
addresses are stored IPv4-mapped, cannot alias a genuine IPv6 flow in
::ffff:0:0/96 -- the separation is structural rather than merely unlikely.

Not shared, because they are genuinely different work: NAT64 rewrites the
network header across address families (RFC 7915), which means synthesizing
an IPv4 header, computing an IPv4 header checksum that has no IPv6
counterpart, and taking a checksum delta across a pseudo-header whose
*family* changed rather than only whose addresses did.

Per-tenant session limits are enforced in the datapath at flow-creation
time, against one budget shared across both families. A tenant at its
ceiling has the triggering packet dropped, never an established session
evicted. The limit check is read-only and the increment happens only once a
masquerade port is actually claimed, so a failed allocation never consumes
budget. tenant_state also separates the two admission-failure causes an
operator would act on differently -- at limit, versus this shard not
serving the family -- since aggregating them makes the common "why did this
connection fail" question unanswerable from counters alone.

Scope is TCP and UDP. ICMP translation and fragment handling are absent by
decision, not oversight, and each is counted under its own drop reason
rather than passed silently, so the gap reads off a counter.

serves_v6/serves_v4 gate the whole thing: a shard with no IPv4 fields set
never executes an instruction of NAT64 translation, and its dispatch is the
NAT66-only program's. Drop reasons 0-8 keep their previous indices so an
existing counter series stays the same series.

Also fixes a pre-existing NAT66 forward-path defect, found by the
regression test added here rather than sought out. strip_outer_header
widened the head past the link header and then reclaimed 14 bytes, which
exposes the old packet's bytes at that offset -- the tail of the outer
destination address and the front of the inner IPv6 header -- rather than a
link header, and never wrote one. Every decapsulated packet left the
forward path with those bytes as its EtherType, and eth_type_trans
dispatches an XDP_PASS frame on exactly that field, so a NAT66-translated
packet reached no protocol handler at all. The header is now carried across
the move. This is a behavior change to NAT66 rather than an additive one,
and is called out separately for that reason; it is not reachable in any
deployment today, galactic-nat66 having only ever run in containerlab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e, and CNI

Renames the whole component from galactic-nat66 to galactic-nat, and
extends every layer above the datapath to describe a shard that may serve
NAT66, NAT64, or both.

Config gains the IPv4 half as an all-or-nothing pair: a public IPv4 address
with no NAT64 prefix has nothing to translate for, and a prefix with no
address has nothing to translate into. Either alone yields a shard that
drops every NAT64 packet sent to it, so both are rejected at startup rather
than at the first packet. The IPv6 public address stops being
unconditionally required, a shard now being allowed to serve NAT64 alone --
what stays required is that it serve something, since a shard serving
neither family loads a datapath that claims nothing and presents as a
blackhole rather than as the misconfiguration it is.

The prefix must be a /96 with no bits below its length. That is the only
RFC 6052 length placing the embedded IPv4 address in one aligned four-byte
run, which is what lets the datapath extract it without a bit-shuffle on
the hot path.

NATConfig.ServesNAT66/ServesNAT64 give that decision one home, so the
binary and the reconciler read the same answer instead of each
re-deriving it from which fields happen to be non-empty.

EgressShardReconciler publishes the shard's SID, both masquerade addresses,
and the NAT64 prefix. It deliberately does not advertise the IPv4 address:
a NAT64 reply arrives from the IPv4 internet rather than across this
fabric, so a BGPAdvertisement into the EVPN mesh would not make it
reachable by the party that needs to reach it. Publishing it in status is
what makes that underlay prerequisite checkable rather than implicit.

CNI now installs two egress routes per VRF: the existing ::/0 default and,
where the fabric has NAT64, a more-specific route for the NAT64 prefix.
Both point at the same shard SID -- a shard decides which translation a
packet gets from its inner destination, not from which route carried it --
so the second route exists to make that prefix reachable where no default
covers it. The two really are independent: a fabric may offer NAT64 without
NAT66, and then no default route exists for that traffic to fall into.
EgressDefaultRouteAdd becomes a thin wrapper over a prefix-general
EgressPrefixRouteAdd, leaving its existing callers and tests untouched.

The binary enables IPv4 forwarding on the uplink when serving NAT64. Without
it the kernel drops every translated packet after the datapath has already
counted it as successfully translated, which reads as a healthy shard with
a silently broken path.

A periodic resync recomputes per-tenant session counts from the connection
table. The datapath cannot keep that count accurate alone: nat_conn_table
is an LRU map, so the kernel evicts rows with nothing to decrement, and
nothing ages an idle flow out. Left alone the count only climbs until every
tenant reads as over limit -- a per-tenant ceiling degrading into a dead
shard. Counting forward rows alone is what makes the total right, every
flow owning one forward row and one reverse row with only the forward row
carrying a VRFID.

Metrics now break connection counts down by family and expose each tenant's
sessions, limit, and the two admission-failure causes separately. Both
families are reported even at zero, a family absent from the output reading
as "not scraped" rather than "no flows".

The session limit defaults to unlimited on purpose. A ceiling nobody chose
becomes an outage nobody can explain, since this design collects the
admission-failure counters but does not surface them anywhere a tenant or
an on-call engineer reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ic-nat

Follows the binary and package renames through every asset that names
them: config/galactic-nat/, containers/galactic-nat/, docs/nat/, the
containerlab overlay, and the Taskfile.

The manifests and the configuration reference now describe a shard as
serving NAT66, NAT64, or both, rather than as a NAT66 shard with required
fields. The DaemonSet base documents each new environment variable
alongside the two things about them that are not inferable from the field
names: the NAT64 pair is all-or-nothing, and nothing in this repo makes
GALACTIC_NAT_SHARD_PUB_ADDR4 reachable, since a NAT64 reply arrives from
the IPv4 internet rather than across the fabric.

The containerlab overlay stays NAT66-only, and says why. This lab's
transit mesh is IPv6-only with no IPv4 upstream, so enabling NAT64 there
would produce shards that translate outbound traffic correctly and then
blackhole it -- indistinguishable, from every counter this component
exposes, from a NAT64 deployment that is simply broken. The README records
what turning it on would require and which validation the lab therefore
does not provide.

Also adds galactic-nat to .github/workflows/publish.yaml, which never
built it. config/galactic-nat/base/daemonset.yaml has always referenced
ghcr.io/datum-cloud/galactic-nat while no job published that image and no
bundle entry stamped its tag, so the component could not be applied from a
published bundle at all. This is the same failure the repository already
hit once with galactic-router, recorded in CLAUDE.md's own history note --
a manifest advertising an image nothing builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the docs/nat66 -> docs/nat move through the index and the
architecture docs, and drops "NAT66" from the phrases that now describe a
tier serving both address families.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The IPv6 and IPv4 masquerade addresses are the same kind of thing -- one
public address per family a shard serves -- so the pair reads as a pair:
GALACTIC_NAT_SHARD_PUB_ADDR6 alongside _ADDR4, --nat-shard-pub-addr6
alongside --nat-shard-pub-addr4, shard_pub_addr6 alongside
shard_pub_addr4.

The unsuffixed name was carried over from when NAT66 was the only thing
this component did, and leaving it made the asymmetry look deliberate:
readers had to know that the bare name meant IPv6 specifically rather
than "the shard's address" generally. EgressShard's own status fields were
already symmetric (shardAddressIPv6/shardAddressIPv4), so the inconsistency
was internal to this repo, not just cosmetic.

Renamed through every layer that names it -- the eBPF struct field, the
map layer, the config struct, the flag, the env var, the manifests, the
lab overlay, and the docs -- rather than only the env var, since a
half-applied convention is worse than either one applied consistently.

No deployment is broken that this PR had not already broken: every
GALACTIC_NAT66_* variable is renamed here regardless, so a shard's
environment has to be rewritten either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the session ceiling, the per-tenant accounting map behind it, the
userspace resync that kept its counts honest, and the metrics that exposed
them.

The feature did not work per tenant. Egress route installation registers
the operator-configured shard SID verbatim, so every tenant VRF
encapsulates to the same destination and the datapath reads the same SRv6
Argument for all of them. Confirmed on the wire in the gvpc lab: three
tenants in three VPCs all encapsulated to one address, and one counter
absorbed all of them. What shipped was therefore a per-shard ceiling
wearing per-tenant clothes -- set it to any finite value and the first
tenant to reach it locks out every other tenant on that shard.

Keeping it would have meant carrying the machinery for a guarantee it
cannot make. Fixing it properly needs the Argument threaded through route
installation, and that needs a design decision about which Argument a
shard SID should carry, since shard-SID Argument space is per-shard-locator
while attachment Arguments are allocated per BGPRouter. That is not MVP
work.

The isolation the limit depended on is unaffected and still correct where
it is exercised: conn_key keeps its family byte and its tenant_arg, and the
datapath honors whatever Argument arrives. Only the accounting built on top
of it is gone.

DROP_REASON_NAT64_SHARD_UNAVAILABLE survives as a shard-wide counter. It
was the one admission-failure cause that never depended on per-tenant
state, and it stays the signal for a half-configured NAT64 shard. The
remaining drop reasons keep their indices; only the trailing tenant-limit
reason is removed, so no existing counter series changes meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rgument alone

Two tenants on different nodes could share one connection row, and the
second tenant's replies were then re-encapsulated toward the first tenant's
node. That is cross-tenant delivery, not a dropped packet.

The Argument was carrying tenant identity alone, and cannot. Arguments are
allocated per BGPRouter -- allocateArgument scans one router's own
BGPVRFInstances -- so two tenants on different nodes holding the same value
is ordinary rather than exceptional. Today it is worse than that: nothing
writes a per-tenant Argument into a shard SID at all, so every tenant
presents whichever constant the operator configured.

Tenant identity is now composed from two fields that are each already in
the packet: the SRv6 outer header's source, unique per worker node, and the
Argument, unique per node. Neither identifies a tenant fabric-wide; together
they do. No new identifier, no allocation change, and no control-plane round
trip on the attach path.

This does not need the Argument to be correct to help. It is the half of the
fix that is safe to make now; threading a per-tenant Argument through route
installation is the other half, and needs the shard to advertise a prefix
covering its Argument space rather than the single /128 it advertises today.
Until that lands, the outer source is doing all the work, which closes every
cross-node collision and leaves only same-node ones.

The probe loop now mutates the reverse key in place instead of copying it
per iteration. A copy inside an unrolled loop is a second whole key on the
stack, and with the key 16 bytes wider the program no longer fits in BPF's
512-byte stack. The caller has no use for the key afterward, so there was
nothing to preserve.

Decoding leaves an all-zero encapsulation source as the zero Addr rather
than "::", so a reverse row -- which carries no such source, its uniqueness
coming from the shard's own address and masquerade port -- stays
distinguishable from a forward row and the wire round trip stays symmetric.

The regression test fails without this, both tenants receiving the same
masquerade port and the table holding two rows where four belong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ULA

Every existing lab tenant is given a site-unique ipv6_subnet, so no two of
them ever present the same inner source address to a shard and the
connection key's collision path is never reached. The datapath change that
key exists for was covered only by a unit test.

ns70 gives iad and sjc the identical subnet, so a pod on each holds
fd20:70:ffff::100:0. Both sites resolve dfw's shard SID first -- a node
cannot resolve its own self-originated SID -- so both flows arrive at
dfw-worker's shard encapsulated from different nodes. Sending an identical
5-tuple from each makes every field of the connection key identical except
the encapsulation source.

That is not a contrived shape. ULAs are RFC 4193 locally assigned and not
globally coordinated, so two tenants picking the same prefix is a real
possibility, and it is exactly the case the SRv6 Argument was supposed to
disambiguate and does not, because nothing yet writes a per-tenant Argument
into a shard SID.

verify:nat-collision drives it and asserts the two tenants receive distinct
masquerade ports. Confirmed live: 37584 and 37585, four connection rows
rather than two, no drops, and a repeat of both flows reusing their own rows
rather than each other's. The ports come out adjacent because identical hash
inputs give both flows the same probe starting point and the second claims
the next candidate -- the allocator separating them only because the keys
already differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ds exist

verify:nat-sharding reads EgressShards and BGPAdvertisements. That proves
the control plane agrees with itself and nothing about whether a packet is
translated -- a shard can report Ready with an attached datapath while every
packet it handles is silently discarded downstream, which is close to what
was actually happening before the forward path's link header was fixed.

verify:nat-egress drives a real packet from a dfw tenant and reads what
comes out on sjc's uplink. It asserts the three things that have each been
broken at some point: the EtherType (a decapsulated frame needs its link
header rebuilt, or the kernel dispatches it on stale packet bytes and it
reaches no protocol handler at all), the source address, and the masquerade
port's range. It also requires the connection table to have grown and the
shard to have recorded no drops.

The sender and the capture are deliberately on different sites: a node
cannot resolve its own self-originated shard SID, so dfw's tenants fall
through to sjc's shard. A single-site version of this test would pass
against a shard that never receives anything.

The shard address is read from EgressShard status rather than hardcoded, so
the test exercises the reconciler's published value and cannot drift from
whichever address the shard is actually configured with.

Each assertion was checked to fire on the input it is meant to reject, not
only to pass on good input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h run

The check sent from a fixed source port, so every run presented the same
5-tuple. Connection rows live in a pinned map and survive a DaemonSet
restart, so a rerun found the existing rows and re-asserted the two ports an
earlier run had allocated -- without touching the allocator at all. Observed
directly: after rebuilding and rolling the DaemonSets, the check reported
the same pair of ports as the previous run.

That is the failure mode where a test keeps passing after the code it covers
stops working. Varying the source port per run makes each run create new
flows, so the separation is proven fresh rather than remembered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scotwells
scotwells previously approved these changes Sep 13, 2026
…with

Every containerlab operation on the gvpc topology after a successful deploy
failed to resolve three of its own nodes:

  Failed to list containers based on topology: could not get container for
  node iad-worker2: Node: iad-worker2. containers not found.

Kind creates iad's extra workers sequentially as iad-worker2/3/4, and
deploy:rename-control renames them to iad-worker-rr/iad-gateway1/
iad-gateway2 to match the Kubernetes node names their kubeadmConfigPatches
set. gvpc.clab.yaml named the pre-rename containers. Deploy itself worked,
because links are wired once while the old names still apply -- which is
what the file's own comment claimed made the rename "purely cosmetic". It
is not cosmetic for inspect or destroy, which resolve the topology against
whatever is running now.

Fixed by naming the containers here as they end up, and moving
deploy:rename-control ahead of deploy:topology so they already carry those
names when containerlab attaches to them as ext-containers. The alternative
-- dropping the rename -- would have left the container and Kubernetes node
names disagreeing, which is what the rename exists to fix and what makes
`docker exec <node name>` work across the lab's scripts.

Verified against the running lab: all 13 nodes resolve, no errors, and the
NAT verify tasks still pass. The reordering itself is not exercised until
the next full deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
galactic-cni's credential-refresh container crash-looped on startup, taking
the DaemonSet rollout with it:

  R8 has pointer with unsupported alu operation, pointer arithmetic with it
  prohibited for !root
  load program: permission denied

"!root" is the tell. The container does run as root; what it lacks is
CAP_PERFMON, without which the verifier applies its unprivileged ruleset and
refuses pointer+scalar arithmetic on packet data regardless of uid.

config/galactic-gateway and config/galactic-nat both grant PERFMON alongside
BPF and document why. This container never did, and did not need to: usid.c
stayed inside what unprivileged BPF permits until the GRO-merged decap path
began offsetting a packet pointer by a computed header length
(bf50804, "Forward GRO-merged return traffic through SRv6 decap"). The
program had been loading on the strength of not yet doing anything that
required the capability, which is luck rather than a property to depend on.

Confirmed by patching the running DaemonSet: the container reaches Ready
with no restarts and logs "eBPF uSID datapath loaded, pinned, and attached".

This is an independent regression rather than anything to do with the NAT
work carried on this branch -- it is fixed here because it blocks deploying
the branch at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@privateip

Copy link
Copy Markdown
Collaborator Author

@scotwells this PR is now ready for final review

@privateip
privateip merged commit 89bf144 into main Sep 13, 2026
13 checks passed
@privateip
privateip deleted the feat/nat64-egress branch September 13, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prototype: galactic-nat64 generalized from galactic-nat66 (design from #866)

3 participants