Files
esh-pfi-infrastructure/persistent-memory.d/2026-08-23-anaheim-ipsec-tunnel-ceiling.md
vh 22ae9cd480 ops(ana-gw): disable ACME and retract the all-port VIP claim
The ACME client is unbound from wan1, which removes the HTTP-01
challenge listener that had been holding port 80 open irrespective of
allowaccess. An external sweep of fifty-five ports against the WAN
address now finds nothing open at all, while the internal interface
still serves the GUI and SSH and retains its certificate, which
remains valid until late October and simply stops renewing.

The previous note's claim that four virtual IPs were unrestricted
all-port static NATs is withdrawn. A FortiOS virtual IP can be scoped
either by an explicit forwarded port or by a service binding on the
object, and the earlier parse inspected only the former. All four use
the latter, and the custom services behind them are narrow. None of
the fourteen is unrestricted.

Ground truth from outside is recorded in place of further config
reading, listing what each public address actually exposes. Three
configured mappings answer nothing at all and are noted as tidy-up
candidates for the migration rather than as exposure.
2026-08-23 16:02:20 -07:00

32 KiB
Raw Permalink Blame History

[2026-08-23] Anaheim's IPsec tunnel delivers ~25% of a verified 2 Gbps circuit

SUPERSEDED 2026-08-23 (same day, later session) — read the CORRECTION at the bottom before acting on anything here. The headline is wrong (the relevant ceiling is NH3's 1 Gbps uplink, not Anaheim's 2 Gbps), the aggregate number is wrong (692 Mbit/s at 8 streams, not ~550 — the original stopped measuring at 4), and the proposed remedy is impossible: UniFi's manual site-to-site IPsec does not implement AES-GCM at all. The per-stream observation and the parallelise-your-transfers mitigation are the parts that survive.

The operator noticed site-to-site transfers were slow for a datacenter fiber handoff and asked whether WireGuard was the limit. It is not WireGuard, and the circuit is fine.

Measured

ana-docker -> internet, 8 parallel     2,153 Mbit/s   <- the 2 Gbps handoff, delivering
ANA <-> NH3 through the tunnel, 4 par.   460 Mbit/s
FortiGate's own recorded peak            554 Mbit/s
ANA <-> NH3, single stream               227 Mbit/s
ANA <-> ESH, single stream               249-265 Mbit/s
ESH <-> NH3 (never touches ana-gw)       545-557 Mbit/s  on a SINGLE stream

Method: stdlib TCP probe (no ssh, no crypto, no compression) between site endpoints; raw circuit measured with 8 parallel HTTPS fetches from Hetzner Ashburn. Host NICs are virtio with no reported cap, so no host-side ceiling.

What it is not

  • Not WireGuard. Both Anaheim tunnels are IPsec on ana-gw (pfi-ana-nh3 -> 70.230.226.88, ana-eshudm-dyn -> the ESH UDM). WireGuard on ana-wg is remote-access only and is not in this path. Traceroute confirms: both slow paths have hop 1 = 10.250.50.1 (the FortiGate); the fast ESH<->NH3 path rides a 192.168.x Site Magic overlay and never touches it.
  • Not CPU or crypto exhaustion. FortiGate CPU was 100% idle across all 8 cores during the tests, and both live tunnels report npu_flag=03 with dec_npuid=1 enc_npuid=1 — encrypt and decrypt are hardware-offloaded.
  • Not a 250 Mbit/s cap. That was the first number and it is misleading — single-stream TCP. Four parallel streams doubled it. Quote the aggregate.
  • Not the interface. wan1: rxe=0 txe=0 rxd=0 txd=0, no collisions.

Most likely cause

Both tunnels negotiate aes256-sha1 in phase 1 and phase 2 (dhgrp 14, IKEv2). AES-CBC + SHA1 is a two-pass operation; FortiGate NPUs are markedly faster on AES-GCM, which combines encryption and authentication in one pass. The datasheet IPsec headline for an 80F assumes GCM with large packets, not CBC+SHA1 at the 1438-byte tunnel MTU this link negotiates. The ~4x shortfall is consistent with that.

Not executed

Changing the proposal is a production-edge change requiring a matching change at the far end (NH3 UDM and the ESH UDM), and each tunnel drops while it renegotiates. Left for the operator. See the index entry for authorization state.

Immediate mitigation, no config change

Per-flow is the weak axis: a single stream over Site Magic gets 557 Mbit/s, a single stream through IPsec gets 227. Anything moving bulk data across the Anaheim link should parallelise — that alone roughly doubles throughput today.

Practical consequence already observed

/mnt/smithy mounted on ana-ml2 reads at 24.7 MB/s sequential vs 98.3 MB/s from nh3-dev (same file, same mount) — that gap is this tunnel, not NFS and not the NAS. See 2026-08-23-smithy-mount-ana-ml2.

Access note

ana-gw is a FortiGate-80F, FortiOS 7.2.10, at 10.250.0.1. sshpass is absent on nh3-dev; connect with paramiko via uv run --with paramiko. Password is vaulted at fortigate/ana-gw-infra-ops-password. diagnose vpn tunnel list prints live ESP session keys — never paste its output into althing, a booth, or a commit.


CORRECTION (2026-08-23, later session): the cutover was attempted and the remedy does not exist

The operator authorised the AES-GCM cutover, NH3 side first. It cannot be done, and the measurements taken while trying show there is very little left to win.

AES-GCM is unavailable on the far end — not a naming problem

The NH3 edge is a UDM Pro SE terminating pfi-nh3-ana (networkconf _id 697d64414c85dd2b6669b00a, ifname vti64). Its UniFi API validates the crypto enum and rejected every GCM spelling tried — aes256gcm, aes256gcm128, aes256gcm16, aes-256-gcm, aes256-gcm, aes256gcm12, gcm, aes128gcm128 — all HTTP 400 api.err.InvalidPayload, nothing applied.

The control that makes this conclusive: the identical request body with ipsec_esp_encryption: "aes256" returns HTTP 200 rc:ok. So the 400s are the enum rejecting the value, not a malformed body. Corroborating: zero case-insensitive gcm matches across 7.3 MB of UniFi OS UI bundles.

Accepted enum (probed): aes128, aes192, aes256, 3des → 200; des, chacha20poly1305 → 400. There is no AEAD option. Both Anaheim tunnels land on UniFi far ends, so this blocks the ESH tunnel too.

The FortiGate side was widened and is GCM-capable: phase2 pfi-ana-nh3 now reads set proposal aes256-sha1 aes256gcm. Left in place deliberately — it is functionally identical while the peer only offers CBC, and reverting it would cost another SA renegotiation for a cosmetic gain. Phase 1 was never touched; IKE protects the control channel only and has no bearing on data throughput.

The numbers that retire this as a problem

Measured NH3→ANA through the tunnel, and NH3→ESH over Site Magic (WireGuard) on the same UDM and the same uplink, with the same stdlib TCP probe:

streams IPsec NH3→ANA WireGuard NH3→ESH
1 245 Mbit/s 557 Mbit/s
4 471 Mbit/s 767 Mbit/s
8 692 Mbit/s 795 Mbit/s

NH3's WAN is a 1 Gbps link (uplink.speed = 1000, port capable of 10G) — that, not Anaheim's 2 Gbps, is the ceiling for anything crossing this tunnel. So the tunnel does ~69% of the achievable uplink at 8 streams, and the IPsec-vs-WireGuard gap collapses from 2.3× at one stream to 15% at eight.

Re-architecting the transport (site-to-site WireGuard via ana-wg, since FortiOS has no WireGuard) would chase that last 15%. Not worth it.

What the constraint actually is

A per-stream limit (~245 Mbit/s), not an aggregate crypto ceiling. Both endpoints are idle at load — FortiGate CPU 100% idle with npu_flag=03 (offloaded both directions), UDM CPU ~7% with load1 moving 0.70 → 1.55. The shape is per-SA/per-flow serialisation, and WireGuard shows the same shape from a higher floor (557 → 795 is only 1.43× scaling).

Actionable consequence

Anything moving bulk data across this link should parallelise — 245 → 692 Mbit/s, a 2.8× win with no config change. For single-stream workloads that cannot be parallelised at the application layer, NFS nconnect=N is the lever: it opens N TCP connections per mount, converting a single-stream workload into a parallel one. The /mnt/smithy mount on ana-ml2 reading at 24.7 MB/s (~200 Mbit/s, i.e. exactly the single-stream ceiling) is the live example — remounting with nconnect=8 is the obvious test.

Foot-gun recorded

Probing the enum by PUTting candidate values applies the accepted ones. A probe loop here timed out with 3des briefly live on the NH3 side, which the FortiGate would not accept — a short tunnel outage until aes256 was restored (~1 minute, confirmed by the SA counters resetting). If you enumerate a UniFi config enum this way, restore the known-good value after every 200, not at the end of the loop. Post-change verification: the UDM object was diffed field-by-field against its pre-change snapshot and is byte-identical.


FOLLOW-UP (2026-08-23): what the per-stream limit actually is

The correction above called the constraint "per-SA/per-flow serialisation". That was a hand-wave. Measured properly, it is a hard per-flow rate cap of ~230245 Mbit/s with a very deep buffer in front of it — not a tuning problem, not loss, not window size.

The evidence: pin the send buffer and sweep it

Single stream NH3 → ana-docker, SO_SNDBUF pinned, ss -ti sampled in flight:

in-flight cap throughput RTT in flight minRTT retrans
256 KB 224 Mbit/s 7.8 ms 5.3 ms 0
416 KB 225 Mbit/s 11.8 ms 6.6 ms 0
416 KB 245247 Mbit/s 12.0 ms 5.6 ms 0
~3.3 MB (autotuned) 245 Mbit/s 107 ms 5.5 ms 0

Throughput is flat across a 13× range of in-flight data while RTT scales with it. That is the signature of a fixed service rate with a standing queue: the window controls only how much queue you build, never how fast you go. Had this been window-limited, throughput would have risen with the buffer. Had it been congestion, there would be retransmits — there are essentially none (retrans:0, 0% ping loss).

So net.ipv4.tcp_* tuning, window scaling and congestion-control choice are all red herrings here. Do not go there.

Bufferbloat: one bulk stream wrecks latency for everything else

Measured on the same tunnel, ping to ana-docker:

  • idle: 6.9 ms avg
  • during a single bulk TCP stream: 102 ms avg, 136 ms max, 0% loss

15× latency inflation from one transfer. This is the operationally important finding — any interactive traffic sharing the Anaheim link (ssh, RDP, althing, VoIP) degrades badly whenever anything moves bulk data, and it takes only one stream to do it. Parallelising transfers makes throughput better and this worse. If it starts biting, the fix is an AQM/shaper on the tunnel (or rate-limiting bulk jobs), not more buffer.

Where the cap lives — strong inference, not proof

Three paths, and the FortiGate is the only variable:

path single-stream
FortiGate ↔ NH3 UDM (IPsec) 245 Mbit/s
FortiGate ↔ ESH UDM (IPsec) 249265 Mbit/s
NH3 UDM ↔ ESH UDM (WireGuard, no FortiGate) 557 Mbit/s

Present in both slow paths, absent from the fast one. Aggregate over the same SA reaches 692 Mbit/s, so it cannot be the SA or the crypto engine as a whole — many flows spread out fine, one flow does not.

The mechanism that fits is FortiGate NPU IPsec offload being per-session: each firewall session is bound to one crypto engine, so a single TCP flow is capped at one engine's rate while many sessions spread across engines. This is inference from the throughput shape, not something confirmed on the boxdiagnose sys session list was not captured for a TCP flow (the filter caught only traceroute UDP probes). A single-stream control through ana-gw without IPsec returned 290 Mbit/s to Hetzner Ashburn, but at ~60 ms RTT that is window-limited and does not discriminate. If this matters, the clean test is a non-IPsec single stream between two Anaheim VLANs at low RTT.

Relevant to the FortiGate cutover decision: if the per-flow cap is the FortiGate's IPsec path, replacing the box plausibly lifts single-stream throughput toward the WireGuard figure. That is a point in favour of the cutover, and it is cheap to verify afterwards by re-running the sweep.


FOLLOW-UP 2 (2026-08-23): it is NOT a capacity problem, and it IS specific to IPsec

Operator asked directly whether the 80F "can't handle the traffic". It can. Two new measurements settle the shape of this, and correct an overstatement in FOLLOW-UP 1 (which pointed at the FortiGate on evidence that was confounded — every slow path was both IPsec and FortiGate, so protocol and box could not be separated by that argument).

The 80F routes a single flow at line rate when IPsec is not involved

ana-ml2 → pfi-pve, inter-VLAN through ana-gw (traceroute hop 1 = 10.250.50.1), 0.36 ms RTT, no tunnel:

streams throughput
1 940.2 Mbit/s
8 939.3 Mbit/s

Single stream saturates 1 GbE. So the box does not cap single sessions in general, and there is no per-session ceiling in its plain forwarding path. The ~250 Mbit/s per-flow cap is specific to the IPsec datapath.

Both IPsec tunnels converge on the same numbers despite different far ends

Measured today with the same probe:

tunnel far-end gateway RTT 1 stream 8 streams
NH3 ↔ ANA UDM Pro SE 6.7 ms 245 Mbit/s 692 Mbit/s
ESH ↔ ANA UDM Pro Max 3.9 ms 268 Mbit/s 715 Mbit/s

Different gateway hardware, different sites, different uplinks, and RTT differing by 1.7× — yet single-stream differs by only 9%. If this were window-limited the 3.9 ms path would be ~1.7× faster. It is not, which is independent confirmation of a rate cap rather than a BDP effect.

Capacity summary — the box has headroom it will not give one flow

  • plain routing, 1 stream: 940 Mbit/s (line rate)
  • plain routing to internet, 8 streams: 2,153 Mbit/s
  • IPsec, 8 streams: 692715 Mbit/s
  • IPsec, 1 stream: 245268 Mbit/s
  • CPU 100% idle throughout; IPsec NPU-offloaded (npu_flag=03)

Within a single SA, 8 sessions get ~2.9× what 1 session gets, so the datapath distributes work by inner session — consistent with IPsec offload binding a session to one crypto engine.

What is still NOT separated

Whether the cap belongs to the 80F's IPsec offload or to UniFi's IPsec implementation. Both tunnels have a UDM at the far end, and both UDMs run the same UniFi firmware, so identical caps are explainable either way. The Pro Max being only 9% faster than the Pro SE argues against the UniFi side (a beefier CPU should show more), but that is suggestive, not conclusive.

The test that closes it: an IPsec tunnel whose endpoints do not include the 80F — e.g. a temporary UDM↔UDM IPsec tunnel between NH3 and ESH, measured single-stream. If it also caps ~250, the FortiGate is exonerated and replacing it buys nothing on this axis. If it runs near the 557 Mbit/s that UDM↔UDM WireGuard achieves, the 80F is the limiter. Bears directly on the pending FortiGate cutover — worth running before that decision, not after.


FOLLOW-UP 3 (2026-08-23): WireGuard over the same internet path does 767 Mbit/s on ONE stream

Operator asked for a WireGuard test from ana-wg to NH3 over the public internet. It is the test that separates the path from the crypto, and the answer is unambiguous. It also overturns FOLLOW-UP 1's "re-architecting the transport is not worth it" — that conclusion compared 8-stream numbers and was wrong for single-stream workloads.

Setup (fully torn down afterwards)

ana-wg (10.250.50.252, Debian 12 LXC, 4 cores) already has an internet-reachable WireGuard endpoint: wg0 on UDP 31337, published by FortiGate VIP wg-to-ana-wg (extip 38.120.12.42 → 10.250.50.252:31337, policy 46, service WireGuard-LEET). No FortiGate change was needed. A temporary wgt0 was created on nh3-dev (10.30.10.200/32) as a fourth peer on wg0, measured, then removed — ana-wg is back to its original 3 peers and the keys were shredded. wireguard-tools was installed on nh3-dev and left in place (benign, and wanted if this becomes permanent).

In this topology neither gateway does crypto: the FortiGate and the NH3 UDM only NAT/forward UDP, and Linux does WireGuard at both ends.

The full comparison

path crypto performed by 1 stream 8 streams
IPsec NH3↔ANA FortiGate + UDM 245 Mbit/s 692 Mbit/s
IPsec ESH↔ANA FortiGate + UDM 268 Mbit/s 715 Mbit/s
WireGuard NH3→ana-wg (same internet path) Linux + Linux 767 Mbit/s 763 Mbit/s
WireGuard NH3↔ESH (Site Magic) UDM + UDM 557 Mbit/s 795 Mbit/s
plain routing through the 80F (inter-VLAN) none 940 Mbit/s 939 Mbit/s

One stream equals eight streams over Linux WireGuard (767 ≈ 763). There is no per-flow penalty at all, and a single flow already saturates the path. So the ~245 Mbit/s per-flow cap is not the ISP, not the circuit, not the NH3 uplink and not the physical path — all of which sustain 767 on one flow.

Per-flow penalty ranks by implementation:

  • Linux WireGuard — none (767 → 763, flat)
  • UDM WireGuard — mild, ~1.4× (557 → 795)
  • IPsec on this pair — severe, ~2.8× (245 → 692)

Latency under load — the same story

path idle during ONE bulk stream
IPsec NH3↔ANA 6.9 ms 102 ms avg, 136 ms max
WireGuard NH3→ana-wg 6.2 ms 12.7 ms avg, 23 ms max

WireGuard carries 3.1× the single-stream throughput with 8× less latency inflation on the same wire.

Attribution — still not fully separated, and it no longer matters much

Both IPsec measurements have a FortiGate and a UDM doing IPsec, so this still does not isolate which one imposes the 2.8× penalty. Closing that would need Linux↔Linux IPsec or UDM↔UDM IPsec on the same path. But the practical decision no longer depends on the answer, because the fix is the same either way and it is already demonstrated.

Recommendation (supersedes FOLLOW-UP 1)

A WireGuard site-to-site between NH3 and Anaheim, terminated on ana-wg, is worth real consideration: 3.1× single-stream, flat scaling, far better latency under load, and it reuses infrastructure that already exists and is already internet-reachable. It is also the architecture already proven for NH3↔ESH. Open questions before committing: routing/failover if ana-wg (an LXC) is down, whether it replaces or parallels the IPsec tunnel, and firewall policy for the new transit. ana-wg CPU was only ~40% busy across 4 cores at 767 Mbit/s, so it has headroom.

AND: nconnect=8 on /mnt/smithy remains worth doing regardless — it is the same lever (turn one flow into many) and brokkr-smithy-dev has given standing approval to apply it once the FortiGate work settles, with no need to ask again.


RESOLVED (2026-08-23): it is the UDM's software AES-CBC. The FortiGate is exonerated.

Operator's theory — the UDM does IPsec in software with no crypto offload, so the cost of the cipher itself is the limit — is correct, and it is now demonstrated rather than inferred. He also correctly pointed out that UDM↔UDM Site Magic is WireGuard, not IPsec, so that row never said anything about UniFi's IPsec performance. It didn't, and I had leaned on it.

The controlled experiment: vary cipher cost, hold everything else

AES-128 is 10 rounds, AES-256 is 14. If software crypto is the binding constraint, throughput must rise when the cipher gets cheaper. If the limit were the FortiGate's NPU, it would not move at all — hardware crypto is not cipher-cost-sensitive in that range. Run A/B/A, single stream, 2560 s each:

condition ESP cipher single-stream UDM CPU
A aes256-cbc + sha1 232.3 Mbit/s 35.4%
B aes128-cbc + sha1 281.8, 274.9 Mbit/s 35.5%
A again aes256-cbc + sha1 244.9, 242.5 Mbit/s

~1.161.20× faster on the cheaper cipher at identical CPU. Same bytes of CPU work, more payload through it. That is the signature of CPU-bound software crypto, and it rules out the FortiGate's NPU as the limiter.

Correcting two of my own earlier claims

  1. "UDM CPU is only ~7%, so it isn't CPU-bound" was WRONG — a sampling artifact. UniFi's system-stats.cpu refreshes on the device report interval; 4-second sample windows were reading stale values. Under a sustained 60 s single-stream load it reads 35.4%, with load1 rising 0.60 → 1.17. On a 4-core UDM Pro SE that is ≈1.4 cores — one core saturated on crypto plus overhead. Always drive load for ≥60 s before trusting a UniFi CPU figure.
  2. The "FortiGate per-session NPU offload" hypothesis is REFUTED, not merely unproven. It predicts no change from a cipher swap; a 20% change was measured.

Why the numbers all line up now

  • 1 stream = 1 core of UDM crypto → ~240 Mbit/s on AES-256-CBC.
  • 8 streams = ~3 usable cores → ~692 Mbit/s, ≈2.9× the single-stream figure on a 4-core box. Aggregate is noisy (492692 across repeats on a live link) and is not cipher-sensitive, consistent with it being bounded by the path/ uplink rather than crypto once several cores are engaged.
  • AES-CBC is the specific villain: it is serial. Each block depends on the previous one, so the ARM AES instructions cannot pipeline across blocks. GCM (CTR-based) and ChaCha20-Poly1305 both parallelise freely. That is why the same UDM does 557 Mbit/s single-stream on WireGuard and only 240 on IPsec.
  • This retroactively vindicates the GCM cutover as the right idea aimed at the right box — GCM would have removed the serial dependency on the constrained end. UniFi simply does not offer it, which is what made it impossible.

Options this opens

  • AES-128 instead of AES-256: ~1620% for free, no topology change, one API call per end. 128-bit is not the weak link here (SHA1 integrity is more dated, and unchanged either way). Operator's call — not adopted, restored to aes256.
  • WireGuard site-to-site via ana-wg: 767 Mbit/s single-stream (3.1×), and it sidesteps the UDM's IPsec datapath entirely. Still the biggest win available.
  • Replacing the FortiGate will not help this — it was never the constraint. Worth knowing before the cutover.

State left behind

UDM network object verified byte-identical to its pre-test snapshot (aes256/sha1). Tunnel up, selectors 1/1. FortiGate phase2 pfi-ana-nh3 is left as aes256-sha1 aes256gcm aes128-sha1 — a permissive superset; the peer offers only aes256 so the extra entries are inert, but narrowing it back to aes256-sha1 is one line if the looser list is unwanted.


FOLLOW-UP 4 (2026-08-23): a downstream WireGuard terminator costs nothing to forward through

Operator's point: FortiOS has no WireGuard, so a WireGuard site-to-site must terminate on a box behind the edge. Correct — and ana-wg (LXC, CT 113 on pfi-pve, 10.250.50.252) already is that box.

This closes a gap in FOLLOW-UP 3. That 767 Mbit/s figure was measured with traffic terminating on ana-wg. Real traffic must be forwarded onward to other Anaheim hosts, which was never measured. Now it is:

topology 1 stream 8 streams
IPsec, FortiGate ↔ UDM (today) 245 Mbit/s 692 Mbit/s
WG terminating on ana-wg 767 Mbit/s 763 Mbit/s
WG transit: nh3 → wg → ana-wg → forward → ana-docker 763.8 Mbit/s 790.4 Mbit/s

Forwarding through the LXC is free (763.8 vs 767). The downstream-VM architecture delivers the full 3.1× single-stream for real transit traffic, not just for traffic landing on the tunnel box.

ana-wg while forwarding 764 Mbit/s: ~22% busy across 4 cores (77.8% idle), so roughly 0.9 cores. Note /proc/loadavg inside this LXC reports the host's load, not the container's — do not read it as ana-wg's own. For contrast the UDM burns 35.4% of its 4 cores to move 240 Mbit/s, so ana-wg has ample headroom.

Design consequences of terminating downstream — the parts that need decisions

  1. Anaheim hosts must route to ana-wg, not to the FortiGate. The 763.8 figure was obtained with an explicit 10.30.10.200/32 via 10.250.50.252 route on ana-docker. Without that, a host sends 10.100.0.0/16 to its default gateway (ana-gw), which routes it back out the same interface to ana-wg — a LAN hairpin crossing the FortiGate twice. The hairpin variant was NOT measured. Options: DHCP option 121 pushing the route fleet-wide, a dedicated transit VLAN for ana-wg, or accept the hairpin.
  2. New single point of failure. Today site-to-site dies only when the edge dies, which is total anyway. A downstream terminator fails independently. Mitigation: keep the IPsec tunnel configured as a higher-metric fallback route so it takes over when ana-wg is down.
  3. ana-wg is an LXC on pfi-pve, so its ~0.9 cores and NIC traffic land on the hypervisor shared with the rest of the Anaheim VMs.
  4. The NH3 end needs a terminator too, and there are two shapes:
    • Linux VM at NH3 (nh3-dev or a dedicated VM on nh3-pve) — this is what was measured: 764 Mbit/s.
    • NH3 UDM's existing WireGuard server (PFI-NH3-WG, wireguard-server on UDP 31337) accepting ana-wg as a peer — plausible but untested, and UniFi's WireGuard shows a per-flow penalty (557 Mbit/s single-stream on Site Magic), so expect ~557 rather than 764. Still 2.3× today.

Standing recommendation

Worth doing, but it is a project, not a config tweak — routing, failover and policy all need deciding. The cheap wins remain available meanwhile and are independent: nconnect=8 on NFS mounts (approved by brokkr-smithy-dev, pending the FortiGate work settling) and AES-128 for ~20%.


LANDED (2026-08-23): AES-128 on both tunnels; FortiGate public admin closed

Operator directed: adopt AES-128 on both Anaheim tunnels, make-before-break, then close the FortiGate's WAN and SSH admin surfaces. All done and verified.

Context that retires the WireGuard-in-a-VM design work: the FortiGate is being replaced by OPNsense on a Dell R420, which gives WireGuard on the edge device itself. The downstream-terminator architecture (FOLLOW-UP 4) is therefore moot — do not scope it. This also un-parks the OPNsense migration, which auto-memory recorded as PARKED pending "hardware acquisition"; the R420 is that trigger.

What changed

Make-before-break on the FortiGate first, so neither tunnel dropped waiting on a far end:

phase2 proposal now
pfi-ana-nh3 aes256-sha1 aes256gcm aes128-sha1
ana-eshudm-dyn aes256-sha1 aes128-sha1

Then each UDM flipped to ipsec_esp_encryption: aes128:

tunnel UDM object before after
NH3 ↔ ANA pfi-nh3-ana 697d64414c85dd2b6669b00a @ 10.100.0.1 245 Mbit/s 269.7
ESH ↔ ANA esh-ana 697723b9b9d4266dddf2bcc7 @ 10.0.0.1 268 Mbit/s 304.3

Single-stream gain ~1013% here, against 1620% in the earlier controlled A/B — the difference is live-link variance, not a different result. Both UDM objects were diffed field-by-field against pre-change snapshots: the only field that moved on either is ipsec_esp_encryption.

The FortiGate proposal lists were deliberately left permissive (still accepting aes256). The peers offer only aes128 so the extra entries are inert, and keeping them means a UDM reverting does not strand the tunnel. Narrowing to aes128-sha1 alone is a one-liner if the looser list is unwanted.

Admin surfaces closed

wan1 allowaccessping (https + ssh removed) and infra-ops trusthost → 10.0.0.0/8 only (the 8 wide-open ranges unset). Verified 443 and 22 closed from both NH3 and ESH; management over the tunnel at 10.250.0.1 still works. Sequencing that matters: the close was executed over the TUNNEL path, not over WAN — removing ssh from allowaccess while connected over WAN kills the session mid-command.

Consequence to hold in mind: ana-gw now has no out-of-band management path. If both tunnels drop it is console-only until someone is on site.

Gotcha: the two UDM vault items have DIFFERENT shapes

  • unifi/pfi-udmse-api-key → a bare 32-char key. secret get output is the key.
  • unifi/esh-udmpm-api-key → a 19-line documentation note with the key on a key: line. secret get piped straight into a header yields a 1396-byte value and the UDM answers 400 Bad Request from nginx. Extract with grep '^key:' | awk '{print $2}'.

The ESH key's first-ever confirmed WRITE happened here (auto-memory recorded it as read-verified only): a control PUT of the unchanged object returned rc:ok, then the real change did too. That key has a full read+write admin role.


CORRECTION (2026-08-23): port 80 on the WAN IP is the FortiOS ACME listener

The claim in the previous section that .42:80 was an ISP transparent proxy was WRONG, and so was the earlier warning that ACME renewal would fail with port 80 absent from allowaccess. Operator pushed back asking where the port-80 map terminated. It terminates on the FortiGate itself.

What it is: the FortiOS ACME HTTP-01 challenge listener. config system acme has set interface "wan1", and FortiOS opens port 80 on that interface to answer Let's Encrypt challenges independently of allowaccesswan1 allowaccess reads ping only and the port is still open. Every non-challenge request returns a fixed 403 whose body is literally:

<!DOCTYPE html><html><head><title>ACME Access Only</title></head><body>ACME Access Only</body></html>

Not a DNAT. The full VIP table has 14 entries; only two land on .42Kokoro-In (:8880 → 10.250.50.51) and wg-to-ana-wg (:31337 → 10.250.50.252). Worth noting separately: four VIPs are all-port static NATthat claim was WRONG, see the correction below. All fourteen VIPs are scoped.

The methodology error that produced the wrong answer — worth not repeating

The sniffer filter used was dst host 38.120.12.42 and tcp port 80. dst host matches only inbound packets, so outbound SYN-ACKs were excluded by construction; concluding "the box sends no SYN-ACK" from that capture was unsound. Re-run with the bidirectional host 38.120.12.42 and tcp port 80 it immediately shows wan1 out 38.120.12.42.80 -> <scanner>: syn ack.

Rule: when testing whether a box answers, the sniffer filter must be bidirectional. dst host silently answers a different question.

Consequences

  • ACME renewal will work with allowaccess ping. The earlier "add http back or the cert expires" warning is retracted — FortiOS opens the challenge port itself. Cert valid to 2026-10-27, renewal attempt ~2026-09-27.
  • It is not an admin surface — static 403, no auth, no GUI.
  • Its practical value is now low: WAN admin is closed, so the cert only serves the internal GUI at 10.250.0.1, where the name would not match anyway. Killing it (config system acme → unset interface) would close the last WAN listener at the cost of cert renewal. Operator's call; not done.

CLOSED OUT (2026-08-23): ACME disabled; and the "all-port VIP" alarm was FALSE

ACME disabled — the WAN IP now exposes nothing

config system acme / unset interface (the account object is left in place; with no interface bound there is no listener). Verified:

  • External scan of 38.120.12.42 across 55 ports: no open TCP ports at all.
  • Internal GUI at 10.250.0.1 still answers 200, SSH still works.
  • admin-server-cert is still ana-fw.pfi — the existing cert is untouched and serves the internal GUI until 2026-10-27; it simply will not auto-renew.

Reverse with config system acme / set interface "wan1".

RETRACTION: the four VIPs are NOT all-port

A previous section claimed Rustdesk, https-to-tacticalrmm, web-to-webhost and web-to-sfcontainer were unrestricted all-port static NATs. They are not. A FortiOS VIP can be scoped two different ways and the parser used only checked one:

  1. set portforward enable + set extport <n> — a single mapped port, or
  2. set service "<svc>" on the VIP object — constrains the VIP to that service.

All four use form 2. The custom services are narrow: Rustdesk = TCP 2111521119 + UDP 21116 (the standard RustDesk range), ssh-mapped-2223 = TCP 2223 only. Every one of the 14 VIPs is scoped; none is unrestricted.

Lesson: absence of portforward does NOT mean all-port on a FortiOS VIP — check service too. Better still, do what settled it here: scan from outside rather than reading config.

Ground-truth public exposure (external TCP scan, post-change)

IP open maps to
38.120.12.41 nothing
38.120.12.42 nothing the FortiGate itself — fully closed
38.120.12.43 80, 443 sf-ana-container 10.250.150.100 (SureFire tenant)
38.120.12.44 22, 80, 443, 8025, 2111521119 gitea (→222), traefik, mailrise, RustDesk
38.120.12.45 80, 443, 2223 pfi-ana-webhost 10.250.50.52 (2223→22)
38.120.12.46 443 pfi-tacticalrmm 10.250.50.57

Configured-but-closed: 8443 (mattermost-calls), 8444 (webdav-nas), 8880 (Kokoro-In) — VIPs exist, nothing listening behind them. Worth a tidy-up during the OPNsense translation but not exposure.