Skip to content

Encrypt VLAN/VXLAN tunnel traffic with per-tunnel AES-256 keys#118

Open
antoncxx wants to merge 7 commits into
NullNet-ai:mainfrom
antoncxx:feature/vlan-vxlan-encryption-merge-main
Open

Encrypt VLAN/VXLAN tunnel traffic with per-tunnel AES-256 keys#118
antoncxx wants to merge 7 commits into
NullNet-ai:mainfrom
antoncxx:feature/vlan-vxlan-encryption-merge-main

Conversation

@antoncxx

Copy link
Copy Markdown
Contributor

Every VLAN/VXLAN link the server builds now gets its own random AES-256 key, sent to both endpoints alongside the existing VlanSetup/VxlanSetup control messages. VLAN traffic is encrypted/decrypted in nullnet-client's userspace forwarder (AES-256-GCM); VXLAN traffic is protected by a per-tunnel kernel IPsec (XFRM/ESP) SA, since that path is handled entirely by the kernel's native vxlan interface. Each VXLAN tunnel also gets its own UDP dstport (replacing the shared 4789 default) so XFRM policies can tell concurrent tunnels between the same host pair apart.

Same-host tunnels are covered too, for defense-in-depth: the same-host VXLAN veth pair is wrapped in MACsec (802.1AE, GCM-AES-256) keyed with the same per-tunnel key as the cross-host IPsec path, and same-host VLAN traffic is forced through the encrypting userspace forwarder via precise per-access-port OVS flows instead of being switched directly.

@antoncxx
antoncxx force-pushed the feature/vlan-vxlan-encryption-merge-main branch from 997096f to d8be059 Compare July 15, 2026 15:31
Adds per-tunnel AES-256 encryption for all VLAN/VXLAN tunnel traffic:
MACsec (802.1AE, GCM-AES-256) wraps same-host veth-pair tunnels, and
IPsec (XFRM/ESP) encrypts cross-host VXLAN traffic. Also includes the
eBPF firewall and MTU fixes needed to make the cross-host path work
end-to-end: recognize per-tunnel VXLAN dstports in the data plane,
allow ESP so IPsec-wrapped VXLAN isn't dropped, and shrink the overlay
MTU (down to 1080) to leave room for cross-host ESP overhead.

Also fixes a clippy -D warnings failure in nullnet-proxy from a
deprecated openssl API (Asn1StringRef::as_utf8 -> to_string).
@antoncxx
antoncxx force-pushed the feature/vlan-vxlan-encryption-merge-main branch from d8be059 to 5e7734f Compare July 15, 2026 15:32
antoncxx added 3 commits July 15, 2026 17:54
The encryption feature's kernel-level overhead (VXLAN + ESP) needed the
overlay MTU pushed down to 1080 in this environment, and that cost isn't
worth paying everywhere. Add a server-side env var, ENCRYPTION_ENABLED
(default: enabled, preserving current behavior), threaded through the
existing per-edge setup path exactly like dstport already is - both regular
chain edges and egress-gateway edges respect it.

When disabled: the server generates no real key (placeholder zero bytes,
which the client never parses), VLAN falls back to plain userspace
forwarding via a new VlanCipher::Plaintext state (distinct from "unknown
tunnel, drop"), and vxlan-setup.sh skips both the same-host MACsec wrap and
the cross-host XFRM state/policy install, leaving a bare vxlan/veth link.
Teardown needed no changes - its cleanup commands are already safe no-ops
whether or not encryption was ever installed.

Wire protocol gains one field per direction: VlanSetup.encrypted and
VxlanSetup.encrypted, so each endpoint learns the tunnel's encryption state
independently of the server's own env var.
VXLAN_PORTS and PEERS were two independent flat sets, checked with a plain
AND - a packet just needed a known port AND a known peer, not necessarily
the SAME tunnel's port and peer. With multiple concurrent tunnels to
different peers, this meant tunnel B's peer IP could satisfy tunnel A's
port. XFRM's own policy lookup masks this when encryption is on (it's
scoped to the exact src/dst/port/SPI tuple and needs the right key), but
with ENCRYPTION_ENABLED=false the firewall is the only gate, so the gap
becomes real.

VXLAN_PORTS now maps port -> the specific peer it was allocated to (was
port -> marker), and data_plane() checks that pairing for a tunnel's
dynamic dstport instead of independent set membership. The two fixed
shared ports (4789 legacy, 9999 forward-socket) still check "any known
peer" - they aren't tunnel-exclusive, so pairing doesn't apply there.

This narrows cross-tunnel confusion at the firewall layer; it doesn't add
cryptographic anti-spoofing (only XFRM/MACsec do that) - an attacker who
can already forge a peer's real source IP can still hit that peer's own
correctly-paired port.
Both were hand-written without running rustfmt at the time. No behavior
change - cargo fmt --all --check now passes clean across the workspace,
and the standalone ebpf crate was already clean.

clippy -D warnings is clean on nullnet-client, nullnet-ebpf, nullnet-grpc-lib,
and xtask. Could not run it on nullnet-server in this sandbox - its build.rs
shells out to `npm` to build the UI, and npm isn't installed here (pre-existing
gap, unrelated to this repo). Confirmed nullnet-server still builds and its
91 tests still pass.
Comment thread members/nullnet-server/src/env.rs
Comment thread members/nullnet-server/src/net_id_pool.rs
@GyulyVGC

Copy link
Copy Markdown
Member

I left a couple comments.
In particular the only blocker I see is the pool reduction to 40k entries if encryption is enabled, but please correct me if wrong.
The rest is good except that README needs update for the new configs.
I’ve only done a static code review so please confirm that except for the two items above this is ready to merge and now works for all the 4 cases [same host, cross host] x [VLAN, VXLAN]

…EADME

PR NullNet-ai#118 review raised a real blocker: making dedicated dstport allocation
conditional on encryption (previous commit) still capped total concurrent
encrypted VXLAN tunnels at 40k system-wide, down from the ~2M the NetIdPool
actually supports - the UdpPortPool was a single pool shared across every
edge regardless of which two hosts it was between.

XFRM policies already select on the full (src, dst, proto, dport) tuple
(confirmed directly in vxlan-setup.sh's `ip xfrm policy add` calls) - so two
different host pairs reusing the same port number never collide; only
concurrent encrypted tunnels between the *same* two hosts actually need
distinct ports. Orchestrator now keeps one UdpPortPool per (host_a, host_b)
pair (order-independent key) instead of a single global one, so the 40k
ceiling only applies per host pair - for any deployment with more than a
handful of distinct host pairs, this removes the practical cap entirely.

allocate_vxlan_port(net_id, host_a, host_b) replaces the old net_id-only
signature; net_id_ports now remembers which pair's pool to free back into on
teardown. Added direct unit tests for the pairing behavior (distinct within
a pair, reusable across different pairs, order-independent, freed correctly
on teardown).

Also finishes the other review comment: documents ENCRYPTION_ENABLED in
README.md (server env var section).
PR NullNet-ai#118 review raised a real blocker: making dedicated dstport allocation conditional on encryption (previous commit) still capped total concurrent encrypted VXLAN tunnels at 40k system-wide, down from the ~2M the NetIdPool actually supports - the UdpPortPool was a single pool shared across every edge regardless of which two hosts it was between.

XFRM policies already select on the full (src, dst, proto, dport) tuple (confirmed directly in vxlan-setup.sh's ip xfrm policy add calls) - so two different host pairs reusing the same port number never collide; only concurrent encrypted tunnels between the same two hosts actually need distinct ports. Orchestrator now keeps one UdpPortPool per (host_a, host_b) pair (order-independent key) instead of a single global one, so the 40k ceiling only applies per host pair - for any deployment with more than a handful of distinct host pairs, this removes the practical cap entirely.

allocate_vxlan_port(net_id, host_a, host_b) replaces the old net_id-only signature; net_id_ports now remembers which pair's pool to free back into on teardown. Added direct unit tests for the pairing behavior (distinct within a pair, reusable across different pairs, order-independent, freed correctly on teardown).

Also finishes the other review comment: documents ENCRYPTION_ENABLED in README.md (server env var section).
@antoncxx

Copy link
Copy Markdown
Contributor Author

@GyulyVGC

VXLAN Encryption Scaling: Options Considered

Context: encrypting VXLAN tunnels via XFRM needs a way to disambiguate concurrent
tunnels between the same two hosts, since XFRM's policy selector only sees
(src IP, dst IP, proto, dst port) - not the VXLAN VNI. Our current mechanism
(a dedicated UDP port per tunnel) caps concurrent encrypted tunnels at ~40k
per host pair (already fixed from a 40k global regression).

1. Per-host-pair port pool — SHIPPED

Scope the 40k-port pool per (host_a, host_b) pair instead of one global pool.
XFRM already disambiguates by IP pair, so different pairs can safely reuse
port numbers. Real ceiling is now ~40k encrypted tunnels between any single
pair of hosts
, not 40k system-wide. Low risk, already implemented and tested.

2. Mark-based XFRM disambiguation — investigated, doesn't fit

ip xfrm state/policy supports a mark selector, a real pattern (used by
strongSwan) for multiple SAs between the same host pair. Doesn't work cleanly
for us: no native way to mark VXLAN's own kernel-generated encapsulation
packets before XFRM's policy lookup runs (no mark option on vxlan netdevices;
netfilter mangle timing is unreliable for this specific traffic).

3. XFRM interfaces (if_id) — clean mechanism, wrong shape for us

ip link add xfrmN type xfrm ... if_id N auto-tags routed traffic with an ID,
no netfilter timing risk. Kernel 4.19+, well-established (strongSwan). But
designed for routing original app traffic into a dedicated tunnel interface,
not wrapping an existing separate device's (our vxlan device's) traffic.
Adopting it means restructuring how tunnels are built, not a config change.

4. Plain VXLAN + eBPF-based encryption — elegant idea, impractical

eBPF can read the VNI directly from inside the VXLAN header (fixed offset),
already unique at millions scale - would eliminate port-based disambiguation
entirely. But implementing real AES-256-GCM inside an eBPF program from
scratch is high-risk: no standard/stable way to invoke hardware AES-NI, and
crypto correctness bugs in a hostile execution environment are exactly the
kind of thing worth avoiding. Not recommended.

5. Custom kernel module — technically capable, high blast radius

Full access to the kernel's own CryptoAPI (same code XFRM already uses,
hardware-accelerated) plus VNI-based lookup, no BPF verifier constraints.
Real downside: kernel module bugs can crash/corrupt the whole machine or open
privilege-escalation holes (untrusted network input reaching ring-0 code is a
historically severe bug class), plus real ongoing maintenance burden
(kernel-version-specific builds, DKMS, Secure Boot signing).

Lower-risk alternative in this direction: route cross-host VXLAN traffic
through a userspace forwarder instead, the same way VLAN traffic already
works - reuses our already-working, portable AES-256-GCM Rust code and a
plain in-memory key lookup (millions of entries, trivial), with none of the
kernel-module risk or eBPF-crypto risk. Worth a real design discussion if the
per-host-pair fix ever proves insufficient.

6. eBPF crypto kfuncs (bpf_crypto_encrypt/decrypt) — real, but too young

Checked directly: this exists in mainline since kernel 6.8, registered for
TC/XDP program types (our exact hook), wraps the real kernel crypto API
(AEAD/GCM supported), not a reimplementation. Architecturally the right shape.
However: CVE-2026-43306, a kernel-panic DoS bug in this exact subsystem,
affected v6.8-6.13 and shipped in Ubuntu 24.04/Debian 13/Fedora 42/Azure
Linux/WSL2, only patched May 2026, trivially triggerable by any BPF
program using the crypto API. Too immature to build on now - worth
re-checking in 6-12 months once it's had time to harden, and only after
confirming the specific patched kernel version is what's actually deployed.

Bottom line

Option 1 is shipped and should cover any realistic deployment. Options 2-4 are
dead ends or bigger rewrites for uncertain benefit. Options 5 and 6 are the
two credible paths if we ever need to eliminate the port pool entirely -
5's "push VXLAN to userspace" variant is the lower-risk of the two, and 6 is
worth revisiting once it matures.

clippy -D warnings flagged net_id_ports's nested tuple type
(HashMap<u32, ((IpAddr, IpAddr), u16)>) as too complex. Factored into
HostPair and AllocatedPort type aliases, also used for udp_port_pools and
host_pair()'s return type.
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.

2 participants