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.
32 KiB
[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 a192.168.xSite 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=03withdec_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 ~230–245 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 | 245–247 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) | 249–265 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 box —
diagnose 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: 692–715 Mbit/s
- IPsec, 1 stream: 245–268 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, 25–60 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.16–1.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
- "UDM CPU is only ~7%, so it isn't CPU-bound" was WRONG — a sampling
artifact. UniFi's
system-stats.cpurefreshes 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. - 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 (492–692 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: ~16–20% 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
- 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.252route 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. - 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.
- 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.
- 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 ~10–13% here, against 16–20% 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 allowaccess → ping (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 getoutput is the key.unifi/esh-udmpm-api-key→ a 19-line documentation note with the key on akey:line.secret getpiped straight into a header yields a 1396-byte value and the UDM answers400 Bad Requestfrom nginx. Extract withgrep '^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 allowaccess — wan1 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 .42 —
Kokoro-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 NAT — that 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 "addhttpback 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-certis stillana-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:
set portforward enable+set extport <n>— a single mapped port, orset service "<svc>"on the VIP object — constrains the VIP to that service.
All four use form 2. The custom services are narrow: Rustdesk = TCP
21115–21119 + 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, 21115–21119 | 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.