Derived IPv4 addresses could not survive anything: they changed with the range, and there was no way for a member to come back to the one it had. Addresses are now allocated and recorded as signed facts, which is the first slice of the model in docs/sync-model.md. src/state/ holds one record per author per network, carrying that author's complete current statement, signed with its persistent device key over a length-prefixed canonical encoding. Merging follows the model's rules: a higher version wins, an older one never rolls back a newer, duplicates are idempotent, absence from a snapshot is not deletion, and a same-version conflict is resolved identically on every replica and reported rather than letting replicas diverge. Records are persisted in state.sqlite, with the record and the author's version counter committed in one transaction before anything is announced, and distributed as a State control message that is merged into what the receiver already holds. No vote, deliberately, despite the request. A majority is not a trust root here — anyone with the secret can mint identities — and a quorum would stall with one peer online and diverge across a partition. Signatures plus a deterministic merge converge without either failure mode: two members claiming one address at once are resolved by the lower endpoint id, and the loser allocates again with a higher version. The range moved from the plugin to the agent, defaults to 10.13.37.0/24, and is now agreed rather than configured per member: a joining agent adopts what the network already uses, so --ipv4-range only matters for whoever starts it. The announcement went back to identity only (version 3) since the range travels in signed records now. A release tombstone exists and merges correctly, but nothing emits one yet. 116 tests. The headline ones: an address survives restarting both agents, three members get three distinct addresses, and a member started with a different range adopts the one in use. Confirmed by hand with two CLI agents restarted end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
8.3 KiB
Markdown
149 lines
8.3 KiB
Markdown
# AGENTS.md
|
|
|
|
Rules for anyone — human or automated — changing this repository. Read this
|
|
before touching the code. Do not restate the full design here; follow the links.
|
|
|
|
## What this project is
|
|
|
|
A proof-of-concept agent library for small private mesh networks. A user
|
|
supplies a network name and one shared secret; agents derive the same network
|
|
space independently, find each other, prove membership and exchange control
|
|
messages. Scope and non-scope are in [README.md](README.md).
|
|
|
|
There is no central server, no owner, no registration and no majority vote.
|
|
Design accordingly: a majority is not a root of trust.
|
|
|
|
## Architectural boundaries
|
|
|
|
Keep these separate. Crossing them is the main thing to review for.
|
|
|
|
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
|
The control protocol in `src/proto/` knows nothing about packets, and
|
|
`src/dataplane/` knows nothing about the control protocol; either can be
|
|
replaced on its own. Both may ride on iroh — refusing to would throw away
|
|
iroh's NAT traversal and force the data plane to reimplement it. They use
|
|
different ALPNs and different connections, so a busy or broken data plane
|
|
cannot disturb control traffic.
|
|
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
|
per peer and moves datagrams over it. Addresses, hole punching and relays
|
|
belong to `src/dataplane/transport/`. A plugin announcement says *who*, never
|
|
*where*.
|
|
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
|
|
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
|
|
only after bounding every field. A data plane failure must never stop the
|
|
control plane.
|
|
- **Derived, not claimed.** A peer's overlay address is derived from its public
|
|
key. Outbound packets are routed to the owner of the destination address;
|
|
inbound packets are dropped unless their source is the address derived for
|
|
the peer that sent them. Never trust an address a peer announces.
|
|
- **Signed state is the only durable agreement.** A fact that must survive a
|
|
participant being away goes in `src/state/` as a record signed by its
|
|
author, never in a session. Merging is deterministic, an older version never
|
|
rolls back a newer one, and absence from a snapshot is not deletion. Never
|
|
add a vote or a quorum: a majority is not a trust root here, and it would
|
|
stall with one peer online.
|
|
- **A record and the author's own version counter commit together**, in one
|
|
transaction, **before** the record is announced.
|
|
- **Plugins own their system objects.** A plugin creates and removes its own
|
|
interface and nothing else. Never touch routing, DNS or firewall settings.
|
|
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
|
public key. `NetworkId` is derived from name + secret only. Never conflate
|
|
them, and never let one change the other.
|
|
- **Discovery vs authentication.** Discovery returns *unverified candidates*.
|
|
It never authenticates, never carries control messages and never mutates
|
|
agent state. Membership is decided only by the handshake.
|
|
- **Mandatory state vs disposable cache.** `state.sqlite` is never silently
|
|
reset: damage is a hard error. `cache.sqlite` may be deleted at any time and
|
|
is recreated. A stale cache must never bypass identity or network
|
|
authentication.
|
|
- **Candidate vs observed address vs verified path.** Report these as three
|
|
different things. A missing value is `None`, never invented.
|
|
|
|
## Rules that are not negotiable
|
|
|
|
1. **Tests come first.** Every substantial change ships with tests for the
|
|
normal path *and* the important failures. See [docs/testing.md](docs/testing.md).
|
|
Do not add tests for getters or to move a coverage number.
|
|
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
|
encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without
|
|
treating it as an incompatible protocol change. Bumping the crate version or
|
|
the control protocol version must not change an existing `NetworkId`.
|
|
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
|
output, not into anything exported to the network. `NetworkSecret` and
|
|
derived keys redact themselves and zeroize; keep it that way.
|
|
4. **No panics on untrusted input.** No `unwrap`, `expect` or `panic` on
|
|
anything that came off the network. Clippy enforces this at warn level in
|
|
the library; tests opt out explicitly at the top of each file.
|
|
5. **Bounds before allocation.** Frame lengths are checked against
|
|
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
|
queue has a limit in `src/config.rs`.
|
|
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
|
unknown version rejects one message or one session. It never stops another
|
|
network and never stops the agent. There is no irreversible global error
|
|
flag. A data plane failure must never stop the control plane.
|
|
7. **The library owns no globals.** No global tokio runtime, no global tracing
|
|
subscriber, no signal handlers, no `fork`, no `process::exit`, no global
|
|
mutable state. Several agents must run in one test process.
|
|
8. **SQLite never blocks the executor.** All database work goes through
|
|
`spawn_blocking`. Never hold a transaction or a database lock across a
|
|
network `await`. When signed records land, writing the record and bumping
|
|
the author's own counter must be one transaction, committed **before**
|
|
publishing to the network.
|
|
9. **Shutdown is bounded.** A peer that stops reading must not be able to hold
|
|
up shutdown. Wind tasks down with a grace period and then abort.
|
|
10. **Nothing from a remote announcement becomes a shell command, a filesystem
|
|
path or an OS setting.** The agent never touches interfaces or OS settings
|
|
it did not create.
|
|
|
|
## Where things live
|
|
|
|
| path | responsibility |
|
|
|---------------------|----------------|
|
|
| `src/identity/` | device identity; deterministic network space identity and derived keys |
|
|
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
|
| `src/discovery.rs` | candidate sources; test and static backends |
|
|
| `src/proto/` | framing, message formats, membership handshake |
|
|
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
|
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
|
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
|
| `src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
|
|
| `src/bin/tsunagi.rs`| the command line agent; the only place that owns a runtime, a logger and signals |
|
|
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
|
|
|
Add abstractions only at real substitution or testing boundaries. Do not add a
|
|
trait per struct. Prefer one crate with clear modules over many small crates.
|
|
|
|
## Testing rules
|
|
|
|
- Use real iroh endpoints on loopback, real handshakes, real SQLite in
|
|
temporary directories, and independent agent instances.
|
|
- Discovery may be substituted. **iroh, authentication, message passing and
|
|
persistent storage may not be.**
|
|
- No arbitrary multi-second `sleep` as the primary synchronisation. Wait for a
|
|
specific event or condition under one overall deadline (`wait_event`,
|
|
`wait_until`). `settle()` exists only for asserting that something did *not*
|
|
happen.
|
|
- The default suite must pass with no internet, no DHT, no public relay, no
|
|
administrator rights and no changes to OS network settings. Anything needing
|
|
the internet or root stays out of the default set.
|
|
- The WireGuard packet interface may be substituted (`MemoryTunFactory`). Its
|
|
key handling, announcements, derived addressing, the WireGuard protocol
|
|
itself and address-ownership enforcement may not — the default suite runs
|
|
real handshakes and real encryption.
|
|
- Running several library instances in one process is not a test of several
|
|
system processes; do not describe it as one.
|
|
|
|
## Before you open a change
|
|
|
|
```bash
|
|
cargo fmt --all -- --check
|
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
|
cargo test --locked --workspace --all-targets
|
|
```
|
|
|
|
CI runs these on Linux, macOS and Windows. A CI config existing is not evidence
|
|
that tests ran on every OS — say what you actually ran.
|
|
|
|
Check the real API of the iroh version in `Cargo.lock` before using it. Do not
|
|
invent methods from memory.
|