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:
@@ -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.
|
Keep these separate. Crossing them is the main thing to review for.
|
||||||
|
|
||||||
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
- **Control plane vs data plane.** The separation is **logical, not physical**.
|
||||||
The control protocol in `src/proto/` knows nothing about packets, and
|
The control protocol in `crates/tsunagi/src/proto/` knows nothing about packets, and
|
||||||
`src/dataplane/` knows nothing about the control protocol; either can be
|
`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
|
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
|
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
|
different ALPNs and different connections, so a busy or broken data plane
|
||||||
cannot disturb control traffic.
|
cannot disturb control traffic.
|
||||||
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
|
||||||
per peer and moves datagrams over it. Addresses, hole punching and relays
|
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*.
|
*where*.
|
||||||
- **The core never parses a plugin payload.** See `src/dataplane/mod.rs`. Only
|
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
|
||||||
`src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
|
`crates/tsunagi/src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and
|
||||||
only after bounding every field. A data plane failure must never stop the
|
only after bounding every field. A data plane failure must never stop the
|
||||||
control plane.
|
control plane.
|
||||||
- **Derived, not claimed.** A peer's overlay address is derived from its public
|
- **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
|
inbound packets are dropped unless their source is the address derived for
|
||||||
the peer that sent them. Never trust an address a peer announces.
|
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
|
- **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
|
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
|
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
|
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).
|
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.
|
Do not add tests for getters or to move a coverage number.
|
||||||
2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript
|
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
|
treating it as an incompatible protocol change. Bumping the crate version or
|
||||||
the control protocol version must not change an existing `NetworkId`.
|
the control protocol version must not change an existing `NetworkId`.
|
||||||
3. **Secrets never leak.** Not into logs, not into `Debug`, not into status
|
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.
|
the library; tests opt out explicitly at the top of each file.
|
||||||
5. **Bounds before allocation.** Frame lengths are checked against
|
5. **Bounds before allocation.** Frame lengths are checked against
|
||||||
`Limits::max_frame_len` before a buffer is allocated. Every string, list and
|
`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
|
6. **Failure is contained.** A bad signature, wrong secret, malformed packet or
|
||||||
unknown version rejects one message or one session. It never stops another
|
unknown version rejects one message or one session. It never stops another
|
||||||
network and never stops the agent. There is no irreversible global error
|
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 |
|
| path | responsibility |
|
||||||
|---------------------|----------------|
|
|---------------------|----------------|
|
||||||
| `src/identity/` | device identity; deterministic network space identity and derived keys |
|
| `crates/tsunagi/src/identity/` | device identity; deterministic network space identity and derived keys |
|
||||||
| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
| `crates/tsunagi/src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock |
|
||||||
| `src/discovery.rs` | candidate sources; test and static backends |
|
| `crates/tsunagi/src/discovery.rs` | candidate sources; test and static backends |
|
||||||
| `src/proto/` | framing, message formats, membership handshake |
|
| `crates/tsunagi/src/proto/` | framing, message formats, membership handshake |
|
||||||
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||||
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
||||||
| `src/state/` | signed records that outlive a session, their merge rules and address allocation |
|
| `crates/tsunagi/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 |
|
| `crates/tsunagi/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-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 |
|
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||||
|
|
||||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
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
|
## Testing rules
|
||||||
|
|
||||||
|
|||||||
Generated
+17
-3
@@ -3821,12 +3821,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
|||||||
name = "tsunagi"
|
name = "tsunagi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"boringtun",
|
"boringtun",
|
||||||
"bytes",
|
"bytes",
|
||||||
"caps",
|
"caps",
|
||||||
"clap",
|
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"directories",
|
"directories",
|
||||||
"fs4",
|
"fs4",
|
||||||
@@ -3856,6 +3853,23 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tsunagi-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap",
|
||||||
|
"iroh",
|
||||||
|
"netwatch",
|
||||||
|
"simple-dns",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"tsunagi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tun"
|
name = "tun"
|
||||||
version = "0.8.14"
|
version = "0.8.14"
|
||||||
|
|||||||
+23
-83
@@ -1,94 +1,34 @@
|
|||||||
[package]
|
# The system level and its plugins are separate crates, so the boundary
|
||||||
name = "tsunagi"
|
# between them is checked by the compiler rather than by discipline: a plugin
|
||||||
version = "0.1.0"
|
# can reach only what the core makes public, and carries its own version.
|
||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/*"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
license = "MIT OR Apache-2.0"
|
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"
|
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||||
readme = "README.md"
|
|
||||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
|
||||||
categories = ["network-programming"]
|
|
||||||
|
|
||||||
[features]
|
[workspace.lints.rust]
|
||||||
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]
|
|
||||||
missing_docs = "warn"
|
missing_docs = "warn"
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
[lints.clippy]
|
[workspace.lints.clippy]
|
||||||
unwrap_used = "warn"
|
unwrap_used = "warn"
|
||||||
expect_used = "warn"
|
expect_used = "warn"
|
||||||
panic = "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"] }
|
||||||
|
|||||||
@@ -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.
|
/// Picks the publisher for this platform.
|
||||||
fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
|
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())
|
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())
|
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
|
||||||
}
|
}
|
||||||
@@ -1355,7 +1355,6 @@ fn host_section() -> report::Section {
|
|||||||
"implementation",
|
"implementation",
|
||||||
"userspace WireGuard (boringtun); no kernel module needed",
|
"userspace WireGuard (boringtun); no kernel module needed",
|
||||||
));
|
));
|
||||||
#[cfg(feature = "tun-device")]
|
|
||||||
{
|
{
|
||||||
if cfg!(target_os = "linux") {
|
if cfg!(target_os = "linux") {
|
||||||
let tun_path = std::path::Path::new("/dev/net/tun");
|
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
|
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
|
/// 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
|
/// an open file descriptor and goes away with the agent, however the agent
|
||||||
/// goes away.
|
/// 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>> {
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
||||||
use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner};
|
use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner};
|
||||||
let provisioner = NetlinkProvisioner::new()?;
|
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
|
/// Refused here rather than at the first packet, and with the one thing that
|
||||||
/// does work on every platform named.
|
/// 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>> {
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"managing the overlay interface is not implemented on {} yet. \
|
"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())
|
.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) {
|
fn print_event(event: &Event) {
|
||||||
match event {
|
match event {
|
||||||
Event::PeerConnected {
|
Event::PeerConnected {
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"))
|
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
@@ -1,6 +1,6 @@
|
|||||||
# Planned state synchronisation
|
# 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
|
used for one thing: IPv4 overlay addresses. What follows describes the whole
|
||||||
model; the section at the end says exactly which parts exist.
|
model; the section at the end says exactly which parts exist.
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ migrations for this.
|
|||||||
|
|
||||||
## What exists today
|
## 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
|
* signed records, one per author per network, each holding that author's
|
||||||
complete current statement rather than a delta;
|
complete current statement rather than a delta;
|
||||||
|
|||||||
+3
-3
@@ -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
|
overlay claim being rejected, and the core carrying the payload without
|
||||||
interpreting it.
|
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
|
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
|
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
|
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
|
candidate is enough to join, several backends compose, entries are withdrawn
|
||||||
when a network stops, and a forgotten network stays forgotten across a restart.
|
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,
|
itself: role separation, channel binding, identity and network binding,
|
||||||
unambiguous encoding, and rejection under the wrong key.
|
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
|
7748 vector, overlay derivation, announcement validation including the
|
||||||
address-hijack attempt, interface naming, and IP header parsing against
|
address-hijack attempt, interface naming, and IP header parsing against
|
||||||
truncated and nonsense input.
|
truncated and nonsense input.
|
||||||
|
|||||||
Reference in New Issue
Block a user