Split the system level and the command line into a workspace

First step of separating the layers. The library and the binary are now
crates/tsunagi and crates/tsunagi-cli, which means the plugin crate to
come can be told apart from the core by the compiler rather than by
discipline.

Falls out of it immediately: the CLI's dependencies stop being features
of the library. clap, anstream and tracing-subscriber were optional
dependencies behind a `cli` feature that every library user had to
remember to turn off; now they belong to the crate that uses them, and
the library defaults to no features at all.

The one test that drives the binary moved beside it — a library cannot
depend on a binary built from a crate that depends on the library — and
was rewritten against the public API instead of the test harness.

AGENTS.md said to prefer one crate. It now says the system level and its
plugins are separate crates, for the reason above, and that everything
else stays one crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 18:05:42 +01:00
co-authored by Claude Opus 5
parent ca8759c023
commit 60e6b263d1
75 changed files with 237 additions and 171 deletions
+24 -18
View File
@@ -18,18 +18,18 @@ Design accordingly: a majority is not a root of trust.
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
The control protocol in `crates/tsunagi/src/proto/` knows nothing about packets, and
`crates/tsunagi/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
belong to `crates/tsunagi/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
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
`crates/tsunagi/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
@@ -37,7 +37,7 @@ Keep these separate. Crossing them is the main thing to review for.
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
participant being away goes in `crates/tsunagi/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
@@ -65,7 +65,7 @@ Keep these separate. Crossing them is the main thing to review for.
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
encoding** in `crates/tsunagi/src/identity/network.rs` and `crates/tsunagi/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
@@ -76,7 +76,7 @@ Keep these separate. Crossing them is the main thing to review for.
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`.
queue has a limit in `crates/tsunagi/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
@@ -99,19 +99,25 @@ Keep these separate. Crossing them is the main thing to review for.
| 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 |
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi/src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin |
| `crates/tsunagi-cli/` | 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.
trait per struct.
**The system level and its plugins are separate crates**, so that boundary is
checked by the compiler and not by discipline: a plugin can reach only what
`tsunagi` makes public, and carries its own version. Everything else stays
one crate with clear modules — do not split further without a reason of that
kind.
## Testing rules
Generated
+17 -3
View File
@@ -3821,12 +3821,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
name = "tsunagi"
version = "0.1.0"
dependencies = [
"anstream",
"anstyle",
"boringtun",
"bytes",
"caps",
"clap",
"data-encoding",
"directories",
"fs4",
@@ -3856,6 +3853,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "tsunagi-cli"
version = "0.1.0"
dependencies = [
"anstream",
"anstyle",
"clap",
"iroh",
"netwatch",
"simple-dns",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi",
]
[[package]]
name = "tun"
version = "0.8.14"
+23 -83
View File
@@ -1,94 +1,34 @@
[package]
name = "tsunagi"
version = "0.1.0"
# The system level and its plugins are separate crates, so the boundary
# between them is checked by the compiler rather than by discipline: a plugin
# can reach only what the core makes public, and carries its own version.
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
edition = "2024"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
description = "Proof-of-concept library for small private mesh networks: persistent agent identity, deterministic network spaces, iroh-based control plane."
repository = "https://github.com/tsunagi-net/tsunagi"
readme = "README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
[features]
default = ["cli"]
# The `tsunagi` command line binary. Library users can opt out.
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device", "dns-publish"]
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
# in-memory device can be used for tests.
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
# Telling the operating system where to send its questions. Linux only for
# now; the zone and the server work without it.
dns-publish = ["dep:zbus"]
[[bin]]
name = "tsunagi"
path = "src/bin/tsunagi.rs"
required-features = ["cli"]
[dependencies]
clap = { version = "4.5", features = ["derive", "env"], optional = true }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = { version = "1.0", optional = true }
anstyle = { version = "1.0", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
data-encoding = "2.11"
hex = "0.4"
thiserror = "2.0"
tracing = "0.1"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
bytes = "1.12.1"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile = "3.24"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints.rust]
[workspace.lints.rust]
missing_docs = "warn"
unsafe_code = "forbid"
[lints.clippy]
[workspace.lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
[workspace.dependencies]
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
bytes = "1.12.1"
thiserror = "2.0"
tracing = "0.1"
hex = "0.4"
data-encoding = "2.11"
tempfile = "3.24"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "tsunagi-cli"
version = "0.1.0"
description = "The tsunagi command line agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi"
path = "src/main.rs"
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true
tracing-subscriber.workspace = true
clap = { version = "4.5", features = ["derive", "env"] }
# Already in the tree through clap. `anstream` strips the escapes when stdout
# is not a terminal and turns on virtual terminal processing on Windows, so
# colour is never written where it would show up as rubbish.
anstream = "1.0"
anstyle = "1.0"
# Local interface addresses, for the diagnostics in `status`.
netwatch = "0.19.3"
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
simple-dns = "0.12"
[lints]
workspace = true
@@ -526,11 +526,11 @@ impl DnsService {
/// Picks the publisher for this platform.
fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
#[cfg(target_os = "linux")]
{
Arc::new(tsunagi::dns::publish::ResolvedPublisher::new())
}
#[cfg(not(all(feature = "dns-publish", target_os = "linux")))]
#[cfg(not(target_os = "linux"))]
{
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
}
@@ -1355,7 +1355,6 @@ fn host_section() -> report::Section {
"implementation",
"userspace WireGuard (boringtun); no kernel module needed",
));
#[cfg(feature = "tun-device")]
{
if cfg!(target_os = "linux") {
let tun_path = std::path::Path::new("/dev/net/tun");
@@ -1420,15 +1419,6 @@ fn host_section() -> report::Section {
}
}
}
#[cfg(not(feature = "tun-device"))]
host.push(
Row::new(
Health::Degraded,
"interface",
"not built in; the tunnels run but cannot reach the OS",
)
.with_note("rebuild with the `tun-device` feature, or run with `--no-tun`"),
);
host
}
@@ -2199,7 +2189,7 @@ async fn stop_signal() -> &'static str {
/// also the one that cleans up after itself, because the interface is tied to
/// an open file descriptor and goes away with the agent, however the agent
/// goes away.
#[cfg(all(feature = "tun-device", target_os = "linux"))]
#[cfg(target_os = "linux")]
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner};
let provisioner = NetlinkProvisioner::new()?;
@@ -2210,7 +2200,7 @@ fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error
///
/// Refused here rather than at the first packet, and with the one thing that
/// does work on every platform named.
#[cfg(all(feature = "tun-device", not(target_os = "linux")))]
#[cfg(not(target_os = "linux"))]
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
Err(format!(
"managing the overlay interface is not implemented on {} yet. \
@@ -2220,11 +2210,6 @@ fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error
.into())
}
#[cfg(not(feature = "tun-device"))]
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
Err("this build has no interface support; rebuild with the `tun-device` feature or pass --no-tun".into())
}
fn print_event(event: &Event) {
match event {
Event::PeerConnected {
+56
View File
@@ -0,0 +1,56 @@
//! What the command line says about this device, against a running agent.
//!
//! These drive the real binary, which is why they live beside it rather
//! than with the library's own tests: the library cannot depend on a binary
//! built from a crate that depends on it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
// Hold the directory the way a running agent does.
let agent = Agent::spawn(
AgentConfig::new(StoragePaths::under(dir.path()))
.with_transport(TransportPolicy::LocalOnly)
.with_loopback_bind(),
)
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
agent.shutdown().await;
}
+73
View File
@@ -0,0 +1,73 @@
[package]
name = "tsunagi"
version = "0.1.0"
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
readme = "../../README.md"
keywords = ["mesh", "p2p", "iroh", "networking"]
categories = ["network-programming"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[features]
default = []
# A real TUN device, so a plugin can carry actual IP traffic. Needs
# CAP_NET_ADMIN at run time; without it the in-memory device serves tests.
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
# Telling the operating system where to send its DNS questions. Linux only
# for now; the zone and the server work without it.
dns-publish = ["dep:zbus"]
[dependencies]
iroh.workspace = true
tokio.workspace = true
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
hex.workspace = true
data-encoding.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = "0.11"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
# Already in the tree through iroh. A packet codec, not a DNS server: the
# zone logic is ours and a full server framework would be a large dependency
# for answering A records from memory.
simple-dns = "0.12"
# The IANA top-level domain list, compiled in: one function, no
# dependencies, no network. Used only to warn that a zone name shadows a
# real public domain, never to refuse one.
tld = "2.40"
fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
# out of the effective set except during the moments it is used.
[target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = { version = "0.23", optional = true }
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
# default reactor, because the agent brings its own.
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true }
[dev-dependencies]
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -232,46 +232,3 @@ fn the_socket_path_is_derived_and_short_enough() {
control_socket_path(&std::path::PathBuf::from("/somewhere/else"))
);
}
/// Asking who this device is must not need the directory lock.
///
/// The lock belongs to the one agent allowed to *write* the state. `id` only
/// reads, so making it take the lock would mean the question could never be
/// answered while an agent was running — which is exactly when you want to
/// ask it.
#[cfg(feature = "cli")]
#[tokio::test]
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
let dir = TempDir::new().unwrap();
let discovery = SharedMemoryDiscovery::new();
// Hold the directory the way a running agent does.
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let expected = agent.endpoint_id().to_string();
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
.arg("id")
// The same layout `StoragePaths::under` gives the agent above.
.arg("--state-dir")
.arg(dir.path().join("state"))
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.output()
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`id` failed while an agent was running:\n{stderr}"
);
assert!(
stdout.contains(&expected),
"expected {expected} in:\n{stdout}"
);
agent.shutdown().await;
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Planned state synchronisation
**The first slice of this model is now implemented**, in `src/state/`, and is
**The first slice of this model is now implemented**, in `crates/tsunagi/src/state/`, and is
used for one thing: IPv4 overlay addresses. What follows describes the whole
model; the section at the end says exactly which parts exist.
@@ -77,7 +77,7 @@ migrations for this.
## What exists today
Implemented, in `src/state/`:
Implemented, in `crates/tsunagi/src/state/`:
* signed records, one per author per network, each holding that author's
complete current statement rather than a delta;
+3 -3
View File
@@ -50,7 +50,7 @@ keeping the WireGuard identity, shutdown removing every interface, a forged
overlay claim being rejected, and the core carrying the payload without
interpreting it.
Unit tests in `src/state/` cover the signed record model directly: tampering
Unit tests in `crates/tsunagi/src/state/` cover the signed record model directly: tampering
with any field breaks verification, a newer version wins while an older one
never rolls back, two authors claiming one address resolve the same way no
matter the merge order, one key used in two places is reported rather than
@@ -68,11 +68,11 @@ staying short enough to bind.
candidate is enough to join, several backends compose, entries are withdrawn
when a network stops, and a forgotten network stays forgotten across a restart.
Unit tests in `src/proto/handshake.rs` cover the transcript construction
Unit tests in `crates/tsunagi/src/proto/handshake.rs` cover the transcript construction
itself: role separation, channel binding, identity and network binding,
unambiguous encoding, and rejection under the wrong key.
Unit tests in `src/dataplane/wireguard/` cover key clamping against the RFC
Unit tests in `crates/tsunagi/src/dataplane/wireguard/` cover key clamping against the RFC
7748 vector, overlay derivation, announcement validation including the
address-hijack attempt, interface naming, and IP header parsing against
truncated and nonsense input.