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>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user