[GH-ISSUE #2907] [macOS] Site split tunneling not applied for amnezia-awg (AmneziaWG 1.x) connections, but works for amnezia-awg2 #1322

Open
opened 2026-10-13 20:40:12 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @m8ig on GitHub (Jul 30, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2907

Summary

On macOS, site-based split tunneling ("All sites except the listed ones") is silently ignored when connected to a server whose container is amnezia-awg, while the exact same site list is applied correctly when connected to a server whose container is amnezia-awg2.

The app reports the correct route mode for both connections. The difference appears only in what the privileged daemon actually installs into the system routing table: 2100 exclusion host routes in one case, zero in the other.

Both measurements were taken on the same machine, in the same session, on the same network, with the same site list, minutes apart — the only variable was which server was connected.

The same site list works correctly with the same amnezia-awg server on iOS, which suggests this is specific to the macOS route-installation path rather than to the protocol or to the configuration.

Environment

App AmneziaVPN 5.0.0.5 (4d28f81)
OS macOS Tahoe 26.5.2 (arm64)
Split tunneling mode Sites: All sites except the listed ones (VpnAllExceptSites, route mode 2)
App split tunneling disabled
Site list size ~2100 IPv4 entries

Server A — self-hosted, created by the app, container amnezia-awg2 (AmneziaWG 2.0)
Server B — third-party config imported from a .conf file, container amnezia-awg (AmneziaWG 1.x)

Steps to reproduce

  1. Add a self-hosted AmneziaWG 2.0 server (container amnezia-awg2).
  2. Import a third-party AmneziaWG 1.x .conf (container amnezia-awg).
  3. Enable site split tunneling in All sites except the listed ones mode and load a list of IPs.
  4. Connect to server A. Inspect the routing table.
  5. Connect to server B. Inspect the routing table again.

Expected

Host routes for every excluded IP are installed via the physical gateway in both cases, so excluded destinations bypass the tunnel.

Actual

Connected to server A (amnezia-awg2) — split tunneling works. Excluded IPs get host routes via the physical gateway:

0/1                <tunnel>            UScg    utun6
128.0/1            <tunnel>            USc     utun6
default            <gateway>           UGScg   en0
<excluded-ip-1>/32 <gateway>           UGSc    en0
<excluded-ip-2>/32 <gateway>           UGSc    en0
...

Counted with netstat -rn -f inet | awk -v gw=<gateway> '$2==gw && $1 ~ /\/32$/' | wc -l:
2101 host routes via the physical gateway — 2100 exclusions plus the route to the VPN endpoint.

Connected to server B (amnezia-awg) — split tunneling is not applied. The only host route present is the one to the VPN endpoint itself; there are zero exclusion routes:

0/1                <tunnel>            UScg    utun6
128.0/1            <tunnel>            USc     utun6
default            <gateway>           UGScg   en0
<vpn-endpoint>/32  <gateway>           UGSc    en0

Same command, same gateway: 1 host route — the VPN endpoint itself. 0 exclusions.

All traffic goes through the tunnel, including the destinations that should be excluded.

Log evidence

The app requests the same route mode in both cases, so the setting is clearly reaching the connection logic.

Server B — amnezia-awg (split tunneling not applied):

[DEBUG] Amnezia : "Trying to connect to VPN, server id is <id-B>, container is amnezia-awg, route mode is" amnezia::route_mode_ns::VpnAllExceptSites
[DEBUG] Amnezia : "Site split tunneling is enabled, route mode is 2"
[DEBUG] Amnezia : "App split tunneling is disabled, route mode is 0"
[DEBUG] Amnezia LocalSocketController : Daemon connected
[DEBUG] Amnezia LocalSocketController : Parse command: connected
[DEBUG] Amnezia LocalSocketController : Handshake completed with: <redacted>
[DEBUG] Amnezia : Connection state: 'Connected'

Server A — amnezia-awg2 (split tunneling applied) produces the same two split-tunneling lines:

[DEBUG] Amnezia : "Trying to connect to VPN, server id is <id-A>, container is amnezia-awg2, route mode is" amnezia::route_mode_ns::VpnAllExceptSites
[DEBUG] Amnezia : "Site split tunneling is enabled, route mode is 2"
[DEBUG] Amnezia : "App split tunneling is disabled, route mode is 0"

No warnings or errors are logged in either case. The failure is silent.

Cross-platform note

The same third-party amnezia-awg config, with the same site list, applies split tunneling correctly in AmneziaVPN on iOS. Only the macOS client fails to apply it.

This points at the macOS implementation (privileged daemon writing system routes) rather than at the protocol version or the imported configuration itself.

  • #2441 — [macOS] site-based split tunneling enabled but traffic still goes through full tunnel
  • #1704 — application split tunneling does not work for an imported WireGuard config while working for an AmneziaWG server

This report differs in that split tunneling does work on the same machine, in the same session, with the same list — but only for the amnezia-awg2 container, which isolates the variable to the container/protocol type.

Originally created by @m8ig on GitHub (Jul 30, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2907 ## Summary On macOS, site-based split tunneling ("All sites except the listed ones") is silently ignored when connected to a server whose container is **`amnezia-awg`**, while the exact same site list is applied correctly when connected to a server whose container is **`amnezia-awg2`**. The app reports the correct route mode for *both* connections. The difference appears only in what the privileged daemon actually installs into the system routing table: **2100 exclusion host routes** in one case, **zero** in the other. Both measurements were taken on the same machine, in the same session, on the same network, with the same site list, minutes apart — the only variable was which server was connected. The same site list works correctly with the **same `amnezia-awg` server** on **iOS**, which suggests this is specific to the macOS route-installation path rather than to the protocol or to the configuration. ## Environment | | | |---|---| | App | AmneziaVPN **5.0.0.5** (`4d28f81`) | | OS | macOS Tahoe **26.5.2** (arm64) | | Split tunneling mode | Sites: *All sites except the listed ones* (`VpnAllExceptSites`, route mode `2`) | | App split tunneling | disabled | | Site list size | ~2100 IPv4 entries | Server A — self-hosted, created by the app, container **`amnezia-awg2`** (AmneziaWG 2.0) Server B — third-party config imported from a `.conf` file, container **`amnezia-awg`** (AmneziaWG 1.x) ## Steps to reproduce 1. Add a self-hosted AmneziaWG 2.0 server (container `amnezia-awg2`). 2. Import a third-party AmneziaWG 1.x `.conf` (container `amnezia-awg`). 3. Enable site split tunneling in *All sites except the listed ones* mode and load a list of IPs. 4. Connect to server A. Inspect the routing table. 5. Connect to server B. Inspect the routing table again. ## Expected Host routes for every excluded IP are installed via the physical gateway in both cases, so excluded destinations bypass the tunnel. ## Actual **Connected to server A (`amnezia-awg2`)** — split tunneling works. Excluded IPs get host routes via the physical gateway: ``` 0/1 <tunnel> UScg utun6 128.0/1 <tunnel> USc utun6 default <gateway> UGScg en0 <excluded-ip-1>/32 <gateway> UGSc en0 <excluded-ip-2>/32 <gateway> UGSc en0 ... ``` Counted with `netstat -rn -f inet | awk -v gw=<gateway> '$2==gw && $1 ~ /\/32$/' | wc -l`: **2101** host routes via the physical gateway — 2100 exclusions plus the route to the VPN endpoint. **Connected to server B (`amnezia-awg`)** — split tunneling is not applied. The only host route present is the one to the VPN endpoint itself; there are **zero** exclusion routes: ``` 0/1 <tunnel> UScg utun6 128.0/1 <tunnel> USc utun6 default <gateway> UGScg en0 <vpn-endpoint>/32 <gateway> UGSc en0 ``` Same command, same gateway: **1** host route — the VPN endpoint itself. **0** exclusions. All traffic goes through the tunnel, including the destinations that should be excluded. ## Log evidence The app requests the same route mode in both cases, so the setting is clearly reaching the connection logic. Server B — `amnezia-awg` (split tunneling **not** applied): ``` [DEBUG] Amnezia : "Trying to connect to VPN, server id is <id-B>, container is amnezia-awg, route mode is" amnezia::route_mode_ns::VpnAllExceptSites [DEBUG] Amnezia : "Site split tunneling is enabled, route mode is 2" [DEBUG] Amnezia : "App split tunneling is disabled, route mode is 0" [DEBUG] Amnezia LocalSocketController : Daemon connected [DEBUG] Amnezia LocalSocketController : Parse command: connected [DEBUG] Amnezia LocalSocketController : Handshake completed with: <redacted> [DEBUG] Amnezia : Connection state: 'Connected' ``` Server A — `amnezia-awg2` (split tunneling applied) produces the same two split-tunneling lines: ``` [DEBUG] Amnezia : "Trying to connect to VPN, server id is <id-A>, container is amnezia-awg2, route mode is" amnezia::route_mode_ns::VpnAllExceptSites [DEBUG] Amnezia : "Site split tunneling is enabled, route mode is 2" [DEBUG] Amnezia : "App split tunneling is disabled, route mode is 0" ``` No warnings or errors are logged in either case. The failure is silent. ## Cross-platform note The **same** third-party `amnezia-awg` config, with the **same** site list, applies split tunneling correctly in AmneziaVPN on **iOS**. Only the macOS client fails to apply it. This points at the macOS implementation (privileged daemon writing system routes) rather than at the protocol version or the imported configuration itself. ## Possibly related - #2441 — [macOS] site-based split tunneling enabled but traffic still goes through full tunnel - #1704 — application split tunneling does not work for an imported WireGuard config while working for an AmneziaWG server This report differs in that split tunneling **does** work on the same machine, in the same session, with the same list — but only for the `amnezia-awg2` container, which isolates the variable to the container/protocol type.
Author
Owner

@anton-vinogradov commented on GitHub (Aug 3, 2026):

Correcting my earlier comment: the claim that the firewall table is filled while the routes are missing is wrong.

Both halves come from the same field. On macOS WireguardUtilsMacos::addInterface fills the allownets pf table from config.m_excludedAddresses, and Daemon::activate walks the very same list to install exclusion routes. They cannot diverge. I checked this on my machine after a clean reconnect: the pf table and the routing table hold exactly the entries of my exclusion list plus the server endpoint, and nothing else. My earlier dump was taken after a helper script of mine had added its own entries, which is what misled me.

What I can add instead is a likely cause for the difference you measured, found by reading the code. I have not verified it with a build.

VpnConnection::appendSplitTunnelingConfig in client/vpnConnection.cpp turns site split tunneling off for WireGuard and AmneziaWG unless the config looks like a full tunnel:

allowSiteBasedSplitTunneling = false;
...
QJsonArray allowedIpsJsonArray = configData.value(configKey::allowedIps).toArray();
if (allowedIpsJsonArray.contains("0.0.0.0/0") && allowedIpsJsonArray.contains("::/0")) {
    allowSiteBasedSplitTunneling = true;
}

The check needs both entries to be present as exact array elements. The array is built by splitting the AllowedIPs line on the literal ", " — a comma followed by one space — in ImportController and again in this function. So the check fails for a config that carries any of these:

  • AllowedIPs = 0.0.0.0/0 without ::/0, which is common in third-party configs
  • AllowedIPs = 0.0.0.0/0,::/0 without the space, which yields one element instead of two
  • any extra spacing around the separator

A server created by the app is not affected, because WireguardConfigurator writes exactly {"0.0.0.0/0", "::/0"}. That matches what you measured: the app-made amnezia-awg2 server works, the imported amnezia-awg config does not.

When the flag ends up false, the site list is left empty but routeMode is not reset to VpnAllSites. The daemon then receives route mode 2 with an empty site list, so the kill switch turns on block drop out all while only the server endpoint is allowed. Nothing is logged.

A fix would be to accept a config as a full tunnel on 0.0.0.0/0 alone, split AllowedIPs on , with the parts trimmed, reset routeMode when site split tunneling cannot be applied, and log a warning instead of failing silently.

<!-- gh-comment-id:5168908893 --> @anton-vinogradov commented on GitHub (Aug 3, 2026): Correcting my earlier comment: the claim that the firewall table is filled while the routes are missing is wrong. Both halves come from the same field. On macOS `WireguardUtilsMacos::addInterface` fills the `allownets` pf table from `config.m_excludedAddresses`, and `Daemon::activate` walks the very same list to install exclusion routes. They cannot diverge. I checked this on my machine after a clean reconnect: the pf table and the routing table hold exactly the entries of my exclusion list plus the server endpoint, and nothing else. My earlier dump was taken after a helper script of mine had added its own entries, which is what misled me. What I can add instead is a likely cause for the difference you measured, found by reading the code. I have not verified it with a build. `VpnConnection::appendSplitTunnelingConfig` in `client/vpnConnection.cpp` turns site split tunneling off for WireGuard and AmneziaWG unless the config looks like a full tunnel: ```cpp allowSiteBasedSplitTunneling = false; ... QJsonArray allowedIpsJsonArray = configData.value(configKey::allowedIps).toArray(); if (allowedIpsJsonArray.contains("0.0.0.0/0") && allowedIpsJsonArray.contains("::/0")) { allowSiteBasedSplitTunneling = true; } ``` The check needs both entries to be present as exact array elements. The array is built by splitting the `AllowedIPs` line on the literal `", "` — a comma followed by one space — in `ImportController` and again in this function. So the check fails for a config that carries any of these: - `AllowedIPs = 0.0.0.0/0` without `::/0`, which is common in third-party configs - `AllowedIPs = 0.0.0.0/0,::/0` without the space, which yields one element instead of two - any extra spacing around the separator A server created by the app is not affected, because `WireguardConfigurator` writes exactly `{"0.0.0.0/0", "::/0"}`. That matches what you measured: the app-made `amnezia-awg2` server works, the imported `amnezia-awg` config does not. When the flag ends up false, the site list is left empty but `routeMode` is not reset to `VpnAllSites`. The daemon then receives route mode 2 with an empty site list, so the kill switch turns on `block drop out all` while only the server endpoint is allowed. Nothing is logged. A fix would be to accept a config as a full tunnel on `0.0.0.0/0` alone, split `AllowedIPs` on `,` with the parts trimmed, reset `routeMode` when site split tunneling cannot be applied, and log a warning instead of failing silently.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#1322
No description provided.