# Aura Split Tunnel Split tunneling decides, per destination IP, whether a packet travels **through the encrypted VPN** or **egresses directly** (bypassing the tunnel). It lets you keep, say, RFC1918 LAN traffic local while sending the rest through Aura — or the reverse. It is implemented in the `aura-tunnel` crate (`routes.rs`, `router.rs`, `dns.rs`), configured statically via the `[tunnel.split]` section of `client.toml` (`crates/aura-cli/src/config.rs`), and managed live via the `aura route` / `aura status` admin commands (`crates/aura-cli/src/admin.rs`). --- ## Concept: VPN vs DIRECT Every outbound IP packet read from the TUN device is classified into one of two actions (`RouteAction`): - **`Vpn`** — encrypt and send the packet over the Aura connection to the server. - **`Direct`** — let the packet egress directly, bypassing the tunnel. The router (`AuraRouter::run`, `router.rs`) parses each packet's destination IP, classifies it, and dispatches: ``` TUN read --> parse dst IP --> RouteTable.classify(dst) --> Vpn? -> conn.send_packet() \ Direct? -> send_direct() (v1 stub) ``` > **v1 limitation — `Direct` is a stub.** `send_direct` currently **logs and drops** the > packet; real raw-socket / OS-stack re-injection is out of scope for v1. The method is > already `async` and fallible so a real egress path can slot in without changing call sites. > The VPN path is fully functional end-to-end. Packets whose destination cannot be parsed > (not IPv4/IPv6, or too short) are dropped with a trace. The inbound direction is straightforward: decrypted IP packets received from the peer are written back to the TUN device. --- ## Rules The routing table (`RouteTable`, `routes.rs`) holds three things: a set of **CIDR rules**, a set of **domain rules**, and a **default action**. ### CIDR rules A CIDR rule is an `IpNetwork` (e.g. `10.0.0.0/8`) plus an action. CIDR rules are keyed by network, so re-adding the same network **overwrites** its action. ### Domain rules A domain rule is a domain name plus an action. Domains do **not** match IPs directly. Instead `AuraDns` (`dns.rs`) resolves the domain via the system resolver (hickory) and inserts each resulting address as a **host route** — `/32` for IPv4, `/128` for IPv6 — so it participates in the normal longest-prefix match. Resolution results are cached. > Because domain rules become host routes at resolution time, they only take effect once the > domain has been resolved (at startup, or on demand). They reflect the addresses seen at > resolution time and are not continuously re-resolved in v1. ### Default action If no CIDR rule (including resolved domain host routes) matches a destination, the table's **default action** applies. --- ## Longest-prefix precedence `classify(dst_ip)` performs a **longest-prefix match** (`routes.rs`): > Among all CIDR rules whose network contains the destination, the rule with the **largest > prefix length** (most specific) wins. If no rule matches, the default action is returned. This lets a specific range override a broader one regardless of insertion order. IPv4 rules only match IPv4 destinations and IPv6 rules only match IPv6 destinations. Example (from the shipped config): with `default = VPN`, `10.0.0.0/8 = Direct`, and `10.7.0.0/24 = Vpn`: | Destination | Matched rule | Action | |--------------|----------------------|--------| | `10.1.2.3` | `10.0.0.0/8` | Direct | | `10.7.0.9` | `10.7.0.0/24` (more specific, wins over `/8`) | Vpn | | `192.168.1.1`| `192.168.0.0/16` | Direct | | `8.8.8.8` | (none) → default | Vpn | > Edge case: if two rules share the **same** prefix length, the **last-inserted** one wins > (it overwrites the earlier entry, since rules are keyed by network). --- ## Static config: `[tunnel.split]` The split tunnel is configured in `client.toml` under `[tunnel.split]` (`crates/aura-cli/src/config.rs`). `build_route_table` turns it into a `RouteTable`: CIDR rules are applied directly; domain rules are recorded and returned for the client to resolve at startup. ### Schema | Key | Type | Default | Meaning | |------------------------------|-----------------|---------|----------------------------------------------------| | `default` | string | `"VPN"` | Action when no rule matches: `VPN` / `DIRECT` (case-insensitive) | | `[[tunnel.split.direct]]` | array of rules | `[]` | Rules forcing matching destinations to **Direct** | | `[[tunnel.split.vpn]]` | array of rules | `[]` | Rules forcing matching destinations through the **VPN** | Each rule in `direct` / `vpn` is a table with **exactly one** of: | Key | Type | Example | |----------|--------|---------------------| | `cidr` | string | `"192.168.0.0/16"` | | `domain` | string | `"intranet.example.com"` | A rule with both `cidr` and `domain`, or neither, is rejected when the route table is built. ### Example ```toml # Split-tunnel routing: the default action plus per-destination overrides. [tunnel.split] # Default for destinations matching no rule below: "VPN" or "DIRECT". default = "VPN" # Send these directly (bypass the tunnel): RFC1918 ranges stay on the LAN... [[tunnel.split.direct]] cidr = "192.168.0.0/16" [[tunnel.split.direct]] cidr = "10.0.0.0/8" # ...and a corporate domain egresses directly (resolved to host routes at startup). [[tunnel.split.direct]] domain = "intranet.example.com" # Force a more-specific range back through the VPN (longest-prefix wins over 10.0.0.0/8). [[tunnel.split.vpn]] cidr = "10.7.0.0/24" ``` This is the configuration shipped in `config/client.toml.example`. --- ## Live management: `aura route` / `aura status` A running `aura client` (or `aura server`) hosts an **admin socket** — a tiny JSON line-protocol over a **Unix domain socket** (`crates/aura-cli/src/admin.rs`). The `aura route` and `aura status` subcommands connect to it to inspect and mutate the live routing table without restarting the tunnel. The default socket path is `/tmp/aura-admin.sock` (override with `--admin-socket`). > Platform note: the admin socket uses Unix domain sockets (Linux/macOS). On Windows it is a > `cfg`-gated stub that returns an explanatory error (a named-pipe transport is future work), > so the rest of the CLI still compiles there. ### Commands ``` aura route add (--cidr | --domain ) --action [--admin-socket ] aura route list [--admin-socket ] aura route remove --cidr [--admin-socket ] aura status [--admin-socket ] ``` `route add` takes **exactly one** of `--cidr` / `--domain` (they are mutually exclusive, and one is required), plus `--action vpn` or `--action direct`. ```bash # Send a CIDR directly, live. aura route add --cidr 8.8.8.0/24 --action direct # ok # Route a domain through the VPN (resolved into host routes). aura route add --domain example.com --action vpn # ok # Inspect the current rules and default. aura route list # default: vpn # cidr 8.8.8.0/24 direct # domain example.com vpn # Remove a CIDR rule. aura route remove --cidr 8.8.8.0/24 # ok (removed) # or: "ok (nothing to remove)" if it wasn't present # Tunnel status / counters. aura status # Aura tunnel status # peer: client-1 # default: vpn # rules: 1 # rx packets: 0 # tx packets: 0 ``` ### Behavior notes - **`route remove` only removes CIDR rules** — it takes `--cidr` and has no domain form. The library `RouteTable` has no per-rule remove API, so a removal **rebuilds** the table from the surviving rules (preserving the default). Domain rules are re-added on rebuild, but their previously resolved host routes are dropped and re-resolved on demand. - **`route list` enumerates a rule mirror.** The live `RouteTable` is the source of truth for classification but does not expose iteration, so the admin layer keeps a parallel mirror in lockstep with every mutation; `list` echoes that mirror while `classify` still uses the real table. - **`status`** reports the verified peer id, the default action, the total rule count (CIDR + domain), and inbound/outbound packet counters. ### Wire protocol (for reference) One JSON object per line, request then response (`crates/aura-cli/src/admin.rs`): ```text -> {"cmd":"route_add","cidr":"8.8.8.0/24","action":"direct"} <- {"ok":true} -> {"cmd":"route_list"} <- {"ok":true,"default":"vpn","cidrs":[{"cidr":"8.8.8.0/24","action":"direct"}],"domains":[]} -> {"cmd":"route_remove","cidr":"8.8.8.0/24"} <- {"ok":true,"removed":true} -> {"cmd":"status"} <- {"ok":true,"peer_id":"client-1","rx_packets":0,"tx_packets":0,"default":"vpn","rules":1} ``` On error the response is `{"ok":false,"error":"..."}`. --- ## v1 limitations summary - **`Direct` egress is a stub** — `Direct` packets are logged and dropped, not re-injected to the OS stack. The VPN path is fully functional. - **Domain rules are resolved once** (at startup / on demand) into host routes; no continuous re-resolution. - **`route remove` is CIDR-only** and rebuilds the table (domain host routes are re-resolved on demand afterward). - **Admin socket is Unix-only**; Windows is a `cfg`-gated stub. - The server is a **single shared TUN** in v1, and the tunnel resolver `dns` config field is informational (the system resolver is used).