46513354c0
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>
242 lines
9.5 KiB
Markdown
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).
|