Files
AuraVPN/docs/split-tunnel.md
T
xah30 46513354c0 docs: add protocol, PKI, and split-tunnel documentation
docs/protocol.md, docs/pki.md, docs/split-tunnel.md — written from the actual
implementation (pinned handshake order, ML-KEM-768/FIPS 203, seq||AEAD records
with replay window, QUIC/H3 mimicry) including honest v1 limitations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:40:19 +03:00

242 lines
9.5 KiB
Markdown

# 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 <CIDR> | --domain <DOMAIN>) --action <vpn|direct> [--admin-socket <PATH>]
aura route list [--admin-socket <PATH>]
aura route remove --cidr <CIDR> [--admin-socket <PATH>]
aura status [--admin-socket <PATH>]
```
`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).