Rename the CLI package to tsunagi and its executable to tsng
The command line package is now `tsunagi` and builds the `tsng` executable, which the packages, the unit, the installers and the workflows install and run. The core library package becomes `tsunagi-core`, keeping the library name `tsunagi`, so no code changes `use tsunagi::...`. Directories follow (crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now `tsng ...`. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
bb38562b0a
commit
9966b50407
120 files changed
+631
-635
No files matched your search
@@ -1,47 +0,0 @@
|
||||
[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"
|
||||
# The binary and the library share a name, which `cargo doc` cannot map to
|
||||
# two directories. The command line is documented by `--help`, not rustdoc,
|
||||
# so the library keeps the name and this opts out.
|
||||
doc = false
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
|
||||
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
|
||||
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
|
||||
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"
|
||||
|
||||
# Resolving a group name to its gid for `up --control-group`, through a safe
|
||||
# wrapper (no unsafe of our own). Unix only.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["user"] }
|
||||
|
||||
[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
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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,113 @@
|
||||
[package]
|
||||
name = "tsunagi-core"
|
||||
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
|
||||
|
||||
[lib]
|
||||
name = "tsunagi"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# A real TUN device, so a plugin can carry actual IP traffic. On Linux it
|
||||
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
|
||||
# `wintun.dll` must be reachable. Without either the in-memory device serves
|
||||
# tests. The platform-specific dependencies are declared per target, so
|
||||
# enabling this only pulls what the target it is built for actually uses.
|
||||
tun-device = [
|
||||
"dep:tun",
|
||||
"dep:rtnetlink",
|
||||
"dep:caps",
|
||||
"dep:futures-util",
|
||||
]
|
||||
# Telling the operating system where to send its DNS questions. systemd-resolved
|
||||
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
|
||||
# server work without it either way.
|
||||
dns-publish = ["dep:zbus"]
|
||||
# The test harness in `testing`, for this crate's own tests and for a
|
||||
# protocol crate's. Not on by default: it is only of use to a test.
|
||||
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1.9"
|
||||
iroh.workspace = true
|
||||
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||
# on Windows. Both live behind the same `net` feature.
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tokio-stream = "0.1"
|
||||
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"
|
||||
netdev = "0.46"
|
||||
mainline = "8.0"
|
||||
futures-lite = "2.6"
|
||||
tun = { version = "0.8", features = ["async"], optional = true }
|
||||
# Only for the `testing` harness, which a protocol crate's tests use too.
|
||||
tempfile = { version = "3.24", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], 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 }
|
||||
|
||||
# Windows-only interface provisioning. The Wintun adapter itself is created
|
||||
# and driven through the `tun` dependency, whose safe wrapper is the only way
|
||||
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
|
||||
# (already in the tree through `netwatch`) reads the interface table through a
|
||||
# safe wrapper, which is how the provisioner observes an adapter by name
|
||||
# before deciding what to change. Creating an adapter needs the process to be
|
||||
# elevated and `wintun.dll` to be reachable at run time.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Its own tests use the harness it publishes.
|
||||
tsunagi-core = { path = ".", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
@@ -582,7 +582,7 @@ impl Agent {
|
||||
"`{name}` is already configured with a different secret, as {}. \
|
||||
Joining with this one adds a second network under the same name; \
|
||||
they share nothing. Check the secret, or use \
|
||||
`tsunagi network secret` \
|
||||
`tsng network secret` \
|
||||
to see which is which.",
|
||||
other.network_id
|
||||
);
|
||||
@@ -1218,7 +1218,7 @@ impl Runtime {
|
||||
network = %self.network_id,
|
||||
exit_node = %via.fmt_short(),
|
||||
"the exit node is gone or no longer offers to be one; internet traffic stays \
|
||||
routed through it and is dropped until it returns or `tsunagi exit-node off`"
|
||||
routed through it and is dropped until it returns or `tsng exit-node off`"
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -214,7 +214,7 @@ pub enum Request {
|
||||
///
|
||||
/// Asked of the running agent because that is the only way to add a
|
||||
/// network to an agent that is already up: the state directory belongs
|
||||
/// to one live agent, so a second `tsunagi up` cannot.
|
||||
/// to one live agent, so a second `tsng up` cannot.
|
||||
Join {
|
||||
/// The network name.
|
||||
name: String,
|
||||
@@ -764,7 +764,7 @@ where
|
||||
///
|
||||
/// A local answer comes from memory, so anything this slow means the agent is
|
||||
/// wedged rather than busy. Saying so beats waiting: unbounded, one wedged
|
||||
/// runtime leaves `tsunagi status` hanging with nothing on screen and no way
|
||||
/// runtime leaves `tsng status` hanging with nothing on screen and no way
|
||||
/// out but Ctrl-C.
|
||||
pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
File renamed without changes.
@@ -210,7 +210,7 @@ impl Drop for ControlSocket {
|
||||
/// Accepts one client at a time, keeping a fresh instance ready for the next.
|
||||
///
|
||||
/// A named pipe server instance serves a single client, so a new instance is
|
||||
/// created as soon as one is taken — otherwise a second `tsunagi status` while
|
||||
/// created as soon as one is taken — otherwise a second `tsng status` while
|
||||
/// the first is mid-flight would find nothing listening.
|
||||
async fn serve(
|
||||
name: String,
|
||||
@@ -294,7 +294,7 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
|
||||
format!(
|
||||
"the agent's control pipe exists, but Windows denied access. \
|
||||
Run this command as the same Windows user and with the same \
|
||||
elevation as `tsunagi up` (use an administrator terminal if \
|
||||
elevation as `tsng up` (use an administrator terminal if \
|
||||
the agent is elevated). Windows error: {err}"
|
||||
),
|
||||
),
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -90,7 +90,7 @@ fn classify(what: &str, text: &str) -> String {
|
||||
|| lower.contains("run as administrator")
|
||||
|| lower.contains("permissiondenied")
|
||||
{
|
||||
format!("{what}: Windows refused; run tsunagi from an elevated (administrator) process")
|
||||
format!("{what}: Windows refused; run tsng from an elevated (administrator) process")
|
||||
} else if lower.contains("is not recognized") || lower.contains("commandnotfound") {
|
||||
format!("{what}: the NetTCPIP/NetSecurity PowerShell modules are not available: {text}")
|
||||
} else if text.is_empty() {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
-6
@@ -15,7 +15,7 @@
|
||||
//! Grant it with:
|
||||
//!
|
||||
//! ```text
|
||||
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
||||
//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng
|
||||
//! ```
|
||||
//!
|
||||
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
|
||||
@@ -171,7 +171,7 @@ mod linux_impl {
|
||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||
OverlayError::Unavailable(format!(
|
||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||
Privilege::how_to_grant("tsunagi")
|
||||
Privilege::how_to_grant("tsng")
|
||||
))
|
||||
})?;
|
||||
Ok(Self { raised: true })
|
||||
@@ -204,10 +204,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_grant_instructions_name_the_program() {
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
|
||||
assert!(
|
||||
text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi")
|
||||
);
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsng");
|
||||
assert!(text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsng"));
|
||||
assert!(text.contains("--no-tun"), "the fallback is offered too");
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -483,15 +483,15 @@ mod system {
|
||||
if denied {
|
||||
let hint = if cfg!(target_os = "windows") {
|
||||
"Open PowerShell or Command Prompt with 'Run as administrator' and \
|
||||
start `tsunagi up` there."
|
||||
start `tsng up` there."
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
||||
"Start `tsng up` as root or grant CAP_NET_ADMIN with \
|
||||
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"Creating a utun interface needs root on macOS; start `tsunagi up` \
|
||||
"Creating a utun interface needs root on macOS; start `tsng up` \
|
||||
with `sudo`, or install it as a root LaunchDaemon."
|
||||
} else {
|
||||
"Start `tsunagi up` with the privileges required to create a TUN interface."
|
||||
"Start `tsng up` with the privileges required to create a TUN interface."
|
||||
};
|
||||
return OverlayError::Unavailable(format!(
|
||||
"permission denied while creating the TUN interface `{name}`. \
|
||||
@@ -539,7 +539,7 @@ mod system {
|
||||
let message = open_error("tsun0", error).to_string();
|
||||
assert!(message.contains("permission denied"), "{message}");
|
||||
assert!(message.contains("tsun0"), "{message}");
|
||||
assert!(message.contains("tsunagi up"), "{message}");
|
||||
assert!(message.contains("tsng up"), "{message}");
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(message.contains("Run as administrator"), "{message}");
|
||||
#[cfg(target_os = "linux")]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,173 @@
|
||||
//! Scenario 1: deterministic network identity.
|
||||
//!
|
||||
//! The same name and secret must yield the same network space on different
|
||||
//! devices, and nothing else — hostname, device key, restart — may change it.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::testing::{TestAgent, config_with, network};
|
||||
|
||||
#[test]
|
||||
fn derivation_is_a_pure_function_of_name_and_secret() {
|
||||
let name = NetworkName::new("home").unwrap();
|
||||
let other = NetworkName::new("Home").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let other_secret = NetworkSecret::generate();
|
||||
|
||||
let a = NetworkKeys::derive(&name, &secret);
|
||||
let b = NetworkKeys::derive(&name, &secret);
|
||||
assert_eq!(a.network_id(), b.network_id());
|
||||
assert_eq!(a.discovery_key(), b.discovery_key());
|
||||
assert_eq!(a.descriptor(), b.descriptor());
|
||||
|
||||
// A different name is a different space. Names are used verbatim, so case
|
||||
// matters.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&other, &secret).network_id()
|
||||
);
|
||||
// A different secret is a different space.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&name, &other_secret).network_id()
|
||||
);
|
||||
// Separated key material: the discovery key is not the network id.
|
||||
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_carries_no_creator_time_or_secret() {
|
||||
let name = NetworkName::new("shared").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let first = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
let second = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
|
||||
// Two independently built descriptors are byte identical: no random
|
||||
// creator id, no creation timestamp, no owner signature.
|
||||
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
|
||||
|
||||
let encoded = first.to_canonical_bytes();
|
||||
let secret_bytes = secret.encode();
|
||||
assert!(
|
||||
!encoded
|
||||
.windows(secret_bytes.len())
|
||||
.any(|window| window == secret_bytes.as_bytes()),
|
||||
"the secret must never appear in the public descriptor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_validated_not_silently_normalised() {
|
||||
assert!(NetworkName::new("").is_err());
|
||||
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("ho\nme").is_err());
|
||||
assert!(NetworkName::new("a".repeat(65)).is_err());
|
||||
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_not_truncated_or_normalised() {
|
||||
let secret = NetworkSecret::generate();
|
||||
let text = secret.encode();
|
||||
let round_tripped = NetworkSecret::decode(&text).unwrap();
|
||||
assert_eq!(secret, round_tripped);
|
||||
|
||||
// Short secrets are rejected rather than stretched.
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
|
||||
|
||||
// Debug output must not leak the secret.
|
||||
let rendered = format!("{secret:?}");
|
||||
assert_eq!(rendered, "NetworkSecret(<redacted>)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_devices_and_hostnames_agree_on_the_network_id() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("agreement");
|
||||
|
||||
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
a.agent.endpoint_id(),
|
||||
b.agent.endpoint_id(),
|
||||
"different devices must have different endpoint ids"
|
||||
);
|
||||
assert_eq!(a.agent.hostname(), "alpha");
|
||||
assert_eq!(b.agent.hostname(), "beta");
|
||||
|
||||
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_keeps_the_device_and_network_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stable");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = first.agent.endpoint_id();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = first.stop().await;
|
||||
|
||||
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened.endpoint_id(),
|
||||
device_id,
|
||||
"restarting must not mint a new peer"
|
||||
);
|
||||
let networks = reopened.list_networks().await.unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, network_id);
|
||||
assert!(networks[0].active, "auto-start networks come back up");
|
||||
|
||||
reopened.shutdown().await;
|
||||
drop(reopened);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_the_secret_keeps_the_device_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let device_id = agent.endpoint_id();
|
||||
|
||||
let name = NetworkName::new("rotating").unwrap();
|
||||
let old = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old, new, "a new secret is a new network space");
|
||||
assert_eq!(
|
||||
agent.endpoint_id(),
|
||||
device_id,
|
||||
"rotating the network secret must not change the persistent iroh id"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
|
||||
rustls = "0.23"
|
||||
@@ -33,7 +33,7 @@ netdev = "0.46"
|
||||
caps = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi = { path = "../tsunagi", features = ["testing"] }
|
||||
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||
# The agent control client and the report types: the GUI is a thin client over
|
||||
# the same local control interface the CLI uses, so it reuses these directly
|
||||
# and never re-implements the protocol.
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
|
||||
# Native, lightweight GUI — no web engine. eframe re-exports egui.
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
|
||||
|
||||
@@ -11,7 +11,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -30,7 +30,7 @@ rand = "0.10"
|
||||
boringtun = { version = "0.7.1", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi = { path = "../tsunagi", features = ["testing"] }
|
||||
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The local control interface: a client asking a running agent for status.
|
||||
//!
|
||||
//! Uses a real Unix socket on a temporary path, the real agent and the real
|
||||
//! WireGuard data plane, so what a `tsunagi status` client would see is what
|
||||
//! WireGuard data plane, so what a `tsng status` client would see is what
|
||||
//! is checked here.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "macros"] }
|
||||
serde.workspace = true
|
||||
@@ -26,7 +26,7 @@ boringtun = { version = "0.7.1", default-features = false }
|
||||
netdev = "0.46"
|
||||
|
||||
[dev-dependencies]
|
||||
tsunagi = { path = "../tsunagi", features = ["testing"] }
|
||||
tsunagi-core = { path = "../tsunagi-core", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
+23
-90
@@ -1,110 +1,43 @@
|
||||
[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"]
|
||||
description = "The tsunagi command line agent."
|
||||
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. On Linux it
|
||||
# needs CAP_NET_ADMIN; on Windows the process must run elevated and
|
||||
# `wintun.dll` must be reachable. Without either the in-memory device serves
|
||||
# tests. The platform-specific dependencies are declared per target, so
|
||||
# enabling this only pulls what the target it is built for actually uses.
|
||||
tun-device = [
|
||||
"dep:tun",
|
||||
"dep:rtnetlink",
|
||||
"dep:caps",
|
||||
"dep:futures-util",
|
||||
]
|
||||
# Telling the operating system where to send its DNS questions. systemd-resolved
|
||||
# on Linux, the Name Resolution Policy Table on Windows; the zone and the
|
||||
# server work without it either way.
|
||||
dns-publish = ["dep:zbus"]
|
||||
# The test harness in `testing`, for this crate's own tests and for a
|
||||
# protocol crate's. Not on by default: it is only of use to a test.
|
||||
testing = ["dep:tempfile", "dep:tracing-subscriber"]
|
||||
[[bin]]
|
||||
name = "tsng"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
arc-swap = "1.9"
|
||||
tsunagi-core = { path = "../tsunagi-core", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
tsunagi-wireguard = { path = "../tsunagi-wireguard", version = "0.1.0" }
|
||||
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
|
||||
tsunagi-tcp-tls = { path = "../tsunagi-tcp-tls", version = "0.1.0" }
|
||||
iroh.workspace = true
|
||||
# `net` for the local control interface: a Unix socket on Unix, a named pipe
|
||||
# on Windows. Both live behind the same `net` feature.
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tokio-stream = "0.1"
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
|
||||
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"
|
||||
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"
|
||||
netdev = "0.46"
|
||||
mainline = "8.0"
|
||||
futures-lite = "2.6"
|
||||
tun = { version = "0.8", features = ["async"], optional = true }
|
||||
# Only for the `testing` harness, which a protocol crate's tests use too.
|
||||
tempfile = { version = "3.24", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], 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 }
|
||||
|
||||
# Windows-only interface provisioning. The Wintun adapter itself is created
|
||||
# and driven through the `tun` dependency, whose safe wrapper is the only way
|
||||
# in — loading `wintun.dll` is an `unsafe` call this crate forbids. `netdev`
|
||||
# (already in the tree through `netwatch`) reads the interface table through a
|
||||
# safe wrapper, which is how the provisioner observes an adapter by name
|
||||
# before deciding what to change. Creating an adapter needs the process to be
|
||||
# elevated and `wintun.dll` to be reachable at run time.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
# Resolving a group name to its gid for `up --control-group`, through a safe
|
||||
# wrapper (no unsafe of our own). Unix only.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["user"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Its own tests use the harness it publishes.
|
||||
tsunagi = { path = ".", features = ["testing"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "test-util"] }
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
simple-dns = "0.12"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The `tsunagi` command line agent.
|
||||
//! The `tsng` command line agent.
|
||||
//!
|
||||
//! This binary owns everything the library deliberately refuses to do: it
|
||||
//! starts the tokio runtime, installs a logging subscriber and handles
|
||||
@@ -34,7 +34,7 @@ use tsunagi_wireguard::{
|
||||
|
||||
/// A small agent for private mesh networks.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "tsunagi", version, about, long_about = None)]
|
||||
#[command(name = "tsng", version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Log filter, for example `info` or `tsunagi=debug`.
|
||||
///
|
||||
@@ -69,7 +69,7 @@ enum Command {
|
||||
Protocols,
|
||||
/// Makes a network, or joins one — the command that does the work.
|
||||
///
|
||||
/// The same as `tsunagi network join`, at the top level because it is
|
||||
/// The same as `tsng network join`, at the top level because it is
|
||||
/// what gets typed: `up` runs the agent, this decides what it is in.
|
||||
Join(JoinArgs),
|
||||
/// Shows the networks this device belongs to, and leaves them.
|
||||
@@ -79,7 +79,7 @@ enum Command {
|
||||
/// Lists devices offering to be an exit node, and sends all this
|
||||
/// device's internet traffic through one.
|
||||
///
|
||||
/// The other half of `tsunagi network exit-node`, which is what makes a
|
||||
/// The other half of `tsng network exit-node`, which is what makes a
|
||||
/// device offer itself. A device uses one exit node at a time.
|
||||
ExitNode(ExitNodeArgs),
|
||||
/// Removes everything this device has stored and starts over.
|
||||
@@ -205,8 +205,8 @@ enum NetworkAction {
|
||||
///
|
||||
/// The state directory belongs to one live agent, so this is how a
|
||||
/// network is added to it, and it takes effect at once. With no agent
|
||||
/// running it is configured and starts with the next `tsunagi up`.
|
||||
/// `tsunagi join` is the same command, spelled shorter.
|
||||
/// running it is configured and starts with the next `tsng up`.
|
||||
/// `tsng join` is the same command, spelled shorter.
|
||||
Join(JoinArgs),
|
||||
/// Stops serving a network, keeping everything so it can be resumed.
|
||||
///
|
||||
@@ -214,7 +214,7 @@ enum NetworkAction {
|
||||
/// signed state all stay. Sessions close and the address comes off the
|
||||
/// interface, and nothing is announced — to the others this device is
|
||||
/// simply away, as if it had been switched off. It stays stopped
|
||||
/// across restarts until `tsunagi network start`.
|
||||
/// across restarts until `tsng network start`.
|
||||
Stop {
|
||||
/// Exact network name, full id or unique id prefix.
|
||||
network: String,
|
||||
@@ -471,7 +471,7 @@ struct UpArgs {
|
||||
|
||||
/// A protocol setting, as `key=value` or `protocol:key=value`.
|
||||
///
|
||||
/// Repeat for several. `tsunagi protocols` lists what each one takes.
|
||||
/// Repeat for several. `tsng protocols` lists what each one takes.
|
||||
#[arg(
|
||||
short = 'o',
|
||||
long = "protocol-option",
|
||||
@@ -498,13 +498,13 @@ struct UpArgs {
|
||||
/// addresses; questions are taken on loopback of both families, over
|
||||
/// UDP and TCP.
|
||||
///
|
||||
/// Overrides a remembered opt-out. Use --no-dns or `tsunagi dns off` to disable.
|
||||
/// Overrides a remembered opt-out. Use --no-dns or `tsng dns off` to disable.
|
||||
#[arg(long, conflicts_with = "no_dns", help_heading = "System")]
|
||||
dns: bool,
|
||||
|
||||
/// Disable local DNS and remember this choice for future starts.
|
||||
///
|
||||
/// Use --dns or `tsunagi dns on` to enable it again.
|
||||
/// Use --dns or `tsng dns on` to enable it again.
|
||||
#[arg(long, conflicts_with = "dns", help_heading = "System")]
|
||||
no_dns: bool,
|
||||
|
||||
@@ -715,7 +715,7 @@ fn resolve_secret(
|
||||
[one] => Ok((one.secret.clone(), SecretOrigin::Stored)),
|
||||
several => Err(format!(
|
||||
"this device is in {} networks called `{name}`, so the name alone does not say \
|
||||
which. Give --secret, or `tsunagi network` lists them with their ids.",
|
||||
which. Give --secret, or `tsng network` lists them with their ids.",
|
||||
several.len()
|
||||
)
|
||||
.into()),
|
||||
@@ -841,7 +841,7 @@ fn control_socket(
|
||||
return tsunagi::ipc::default_control_socket_path();
|
||||
}
|
||||
// When the user did not choose a state directory, prefer a running system
|
||||
// service's socket, so `sudo tsunagi status` reaches the packaged daemon —
|
||||
// service's socket, so `sudo tsng status` reaches the packaged daemon —
|
||||
// which binds there via TSUNAGI_CONTROL_SOCKET — with no flags. Only when it
|
||||
// is actually present, so a per-user agent still uses its own socket.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -979,7 +979,7 @@ fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section
|
||||
/// The networks this device belongs to, named but not described.
|
||||
///
|
||||
/// No secrets: this is part of `status`, and a status report is somewhere a
|
||||
/// secret must never appear. `tsunagi network secret` is the place that shows one,
|
||||
/// secret must never appear. `tsng network secret` is the place that shows one,
|
||||
/// because asking for it there is deliberate.
|
||||
fn configured_networks_section(paths: &StoragePaths) -> report::Section {
|
||||
use report::{Health, Row, Section};
|
||||
@@ -1299,7 +1299,7 @@ fn update(state: &Arc<std::sync::Mutex<DnsState>>, edit: impl FnOnce(&mut DnsSta
|
||||
/// What a protocol is called, what it speaks, and what it takes.
|
||||
///
|
||||
/// A registry rather than a lookup on the plugins themselves, because
|
||||
/// `tsunagi protocols` has to answer before anything is constructed, and
|
||||
/// `tsng protocols` has to answer before anything is constructed, and
|
||||
/// because this is the list `--protocol` resolves against.
|
||||
struct ProtocolSpec {
|
||||
/// The name on the wire, which is what peers compare.
|
||||
@@ -1411,7 +1411,7 @@ fn show_protocols() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
out.push(section);
|
||||
}
|
||||
print_report("tsunagi protocols", &out)
|
||||
print_report("tsng protocols", &out)
|
||||
}
|
||||
|
||||
/// Serves the local control socket from the running agent.
|
||||
@@ -1734,7 +1734,7 @@ fn resolve_network<'a>(
|
||||
) -> Result<&'a tsunagi::storage::StoredNetwork, String> {
|
||||
let wanted = wanted.trim_matches(|c: char| c.is_ascii_whitespace());
|
||||
if wanted.is_empty() {
|
||||
return Err("select a network by name or id; `tsunagi network` lists them".to_string());
|
||||
return Err("select a network by name or id; `tsng network` lists them".to_string());
|
||||
}
|
||||
if let Some(network) = networks
|
||||
.iter()
|
||||
@@ -1755,7 +1755,7 @@ fn resolve_network<'a>(
|
||||
[one] => Ok(one),
|
||||
[] => Err(format!(
|
||||
"no configured network matches name or id `{wanted}`; \
|
||||
`tsunagi network` lists them"
|
||||
`tsng network` lists them"
|
||||
)),
|
||||
several => {
|
||||
let candidates = several
|
||||
@@ -1771,7 +1771,7 @@ fn resolve_network<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// `tsunagi network`: what this device belongs to, and leaving it.
|
||||
/// `tsng network`: what this device belongs to, and leaving it.
|
||||
async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
@@ -1810,7 +1810,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
|
||||
}
|
||||
}
|
||||
|
||||
/// `tsunagi join`: make a network or join one.
|
||||
/// `tsng join`: make a network or join one.
|
||||
async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
@@ -1883,7 +1883,7 @@ async fn join_network(
|
||||
eprintln!(
|
||||
"\nwarning: `{}` is also configured with a different secret, as {}.\n\
|
||||
A network is its name *and* its secret, so these two share nothing.\n\
|
||||
If that was a mistyped secret, `tsunagi network leave` removes one.",
|
||||
If that was a mistyped secret, `tsng network leave` removes one.",
|
||||
report.name,
|
||||
short(other, 10)
|
||||
);
|
||||
@@ -1935,7 +1935,7 @@ async fn join_network(
|
||||
if origin == SecretOrigin::Generated {
|
||||
println!(" secret {}", secret.encode().as_str());
|
||||
}
|
||||
eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`.");
|
||||
eprintln!("\nNo agent is running here, so it starts with the next `tsng up`.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1955,15 +1955,15 @@ async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSe
|
||||
println!(" secret {}", secret.encode().as_str());
|
||||
println!(
|
||||
"\nRun this on the other machine:\n\n \
|
||||
tsunagi join --network {name} --secret {}",
|
||||
tsng join --network {name} --secret {}",
|
||||
secret.encode().as_str()
|
||||
);
|
||||
match endpoint {
|
||||
Some(endpoint) => println!(
|
||||
"\nStart its agent with `tsunagi up`; DHT discovery is enabled by default.\n\n \
|
||||
Optional manual bootstrap: tsunagi up --peer {endpoint}"
|
||||
"\nStart its agent with `tsng up`; DHT discovery is enabled by default.\n\n \
|
||||
Optional manual bootstrap: tsng up --peer {endpoint}"
|
||||
),
|
||||
None => println!("\nIts agent has to be running: `tsunagi up`."),
|
||||
None => println!("\nIts agent has to be running: `tsng up`."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1999,28 +1999,28 @@ async fn show_networks(
|
||||
None => "running · no address agreed yet".to_string(),
|
||||
},
|
||||
format!(
|
||||
"`tsunagi network stop {}` pauses it, `leave` gives it up",
|
||||
"`tsng network stop {}` pauses it, `leave` gives it up",
|
||||
short(&id, 10)
|
||||
),
|
||||
),
|
||||
Some(_) => (
|
||||
"stopped".to_string(),
|
||||
format!(
|
||||
"kept as it was; `tsunagi network start {}` resumes it",
|
||||
"kept as it was; `tsng network start {}` resumes it",
|
||||
short(&id, 10)
|
||||
),
|
||||
),
|
||||
None if network.auto_start => (
|
||||
"configured · starts with the agent".to_string(),
|
||||
format!(
|
||||
"`tsunagi network stop {}` keeps it from starting",
|
||||
"`tsng network stop {}` keeps it from starting",
|
||||
short(&id, 10)
|
||||
),
|
||||
),
|
||||
None => (
|
||||
"stopped".to_string(),
|
||||
format!(
|
||||
"kept as it was; `tsunagi network start {}` resumes it",
|
||||
"kept as it was; `tsng network start {}` resumes it",
|
||||
short(&id, 10)
|
||||
),
|
||||
),
|
||||
@@ -2049,7 +2049,7 @@ async fn show_networks(
|
||||
));
|
||||
}
|
||||
out.push(section);
|
||||
print_report("tsunagi networks", &out)
|
||||
print_report("tsng networks", &out)
|
||||
}
|
||||
|
||||
/// Stops serving a network, or starts serving it again.
|
||||
@@ -2114,7 +2114,7 @@ async fn exit_offer_command(
|
||||
storage.set_exit_node(network.network_id, enabled).await?;
|
||||
storage.release_ownership_lock();
|
||||
println!(
|
||||
"exit node {} for `{}` ({}) with the next `tsunagi up`",
|
||||
"exit node {} for `{}` ({}) with the next `tsng up`",
|
||||
if enabled {
|
||||
"will be offered"
|
||||
} else {
|
||||
@@ -2153,7 +2153,7 @@ async fn exit_offer_command(
|
||||
}
|
||||
println!(
|
||||
"\nMembers can now send all their internet traffic through this device, and it \
|
||||
can see it. `tsunagi network exit-node {wanted} off` stops."
|
||||
can see it. `tsng network exit-node {wanted} off` stops."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2235,12 +2235,12 @@ fn resolve_exit_node<'a>(
|
||||
[] => match named.first() {
|
||||
Some((network, peer)) => Err(format!(
|
||||
"`{}` is connected but does not offer to be an exit node. On that device: \
|
||||
tsunagi network exit-node {} on",
|
||||
tsng network exit-node {} on",
|
||||
peer_label(peer),
|
||||
network.name
|
||||
)),
|
||||
None => Err(format!(
|
||||
"no connected device matches `{wanted}`. `tsunagi exit-node` lists the \
|
||||
"no connected device matches `{wanted}`. `tsng exit-node` lists the \
|
||||
ones that offer to be an exit node"
|
||||
)),
|
||||
},
|
||||
@@ -2262,7 +2262,7 @@ fn resolve_exit_node<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// `tsunagi exit-node`: list, use or stop using an exit node.
|
||||
/// `tsng exit-node`: list, use or stop using an exit node.
|
||||
async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use report::{Health, Report, Row, Section};
|
||||
|
||||
@@ -2272,7 +2272,7 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
|
||||
if !tsunagi::ipc::is_serving(&socket).await {
|
||||
return Err(
|
||||
"no agent is running: an exit node is used through the running agent, which \
|
||||
changes the routes of this device. Start it with `tsunagi up`."
|
||||
changes the routes of this device. Start it with `tsng up`."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -2308,16 +2308,14 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
|
||||
"none",
|
||||
"no connected device offers to be an exit node",
|
||||
)
|
||||
.with_note("on that device: tsunagi network exit-node <network> on"),
|
||||
.with_note("on that device: tsng network exit-node <network> on"),
|
||||
);
|
||||
}
|
||||
let mut out = Report::new();
|
||||
out.push(section);
|
||||
print_report("tsunagi exit-node", &out)?;
|
||||
print_report("tsng exit-node", &out)?;
|
||||
if any {
|
||||
println!(
|
||||
"\n`tsunagi exit-node <name|id>` uses one; `tsunagi exit-node off` stops."
|
||||
);
|
||||
println!("\n`tsng exit-node <name|id>` uses one; `tsng exit-node off` stops.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2364,7 +2362,7 @@ async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error:
|
||||
}
|
||||
eprintln!(
|
||||
"\nThat device can see everything that is not encrypted. If it goes away the \
|
||||
traffic is dropped, not sent the old way; `tsunagi exit-node off` stops."
|
||||
traffic is dropped, not sent the old way; `tsng exit-node off` stops."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2399,8 +2397,8 @@ async fn set_active(
|
||||
if !report.active {
|
||||
eprintln!(
|
||||
"\nNothing was announced: to the others this device is away, and the address \
|
||||
and name it holds stay reserved for it. `tsunagi network start {}` resumes \
|
||||
it; `tsunagi network leave` is the one that gives them up.",
|
||||
and name it holds stay reserved for it. `tsng network start {}` resumes \
|
||||
it; `tsng network leave` is the one that gives them up.",
|
||||
short(&id, 10)
|
||||
);
|
||||
}
|
||||
@@ -2412,7 +2410,7 @@ async fn set_active(
|
||||
storage.set_auto_start(network.network_id, active).await?;
|
||||
storage.release_ownership_lock();
|
||||
println!(
|
||||
"`{name}` ({}) will {} with the next `tsunagi up`",
|
||||
"`{name}` ({}) will {} with the next `tsng up`",
|
||||
short(&id, 10),
|
||||
if active { "start" } else { "stay stopped" }
|
||||
);
|
||||
@@ -2511,7 +2509,7 @@ fn forget_protocol_state(paths: &StoragePaths, network: tsunagi::NetworkId) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tsunagi wipe`: back to a device that has never joined anything.
|
||||
/// `tsng wipe`: back to a device that has never joined anything.
|
||||
async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
@@ -2532,7 +2530,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let networks = stored_networks(&paths);
|
||||
if !args.yes {
|
||||
println!("`tsunagi wipe --yes` would remove:\n");
|
||||
println!("`tsng wipe --yes` would remove:\n");
|
||||
for entry in plan.entries() {
|
||||
println!(" {}", entry.display());
|
||||
}
|
||||
@@ -2543,7 +2541,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
println!(
|
||||
"\nNobody is told. Leave each network first — start the agent and run\n\
|
||||
`tsunagi network leave <id>` — to free the address and name it holds\n\
|
||||
`tsng network leave <id>` — to free the address and name it holds\n\
|
||||
for the others. Afterwards this device is a stranger: a new identity,\n\
|
||||
no networks, and no way to sign anything for the old ones."
|
||||
);
|
||||
@@ -2572,7 +2570,7 @@ async fn wipe(args: WipeArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `tsunagi dns`: the local resolver, and turning it on or off.
|
||||
/// `tsng dns`: the local resolver, and turning it on or off.
|
||||
async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
@@ -2628,7 +2626,7 @@ async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
},
|
||||
)?;
|
||||
println!("{} for future starts", if enable { "on" } else { "off" });
|
||||
eprintln!("\nNo agent is running here, so it takes effect with the next `tsunagi up`.");
|
||||
eprintln!("\nNo agent is running here, so it takes effect with the next `tsng up`.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2663,10 +2661,10 @@ async fn show_dns(
|
||||
out.push(section);
|
||||
}
|
||||
}
|
||||
print_report("tsunagi dns", &out)
|
||||
print_report("tsng dns", &out)
|
||||
}
|
||||
|
||||
/// `tsunagi id`: what this device is, and what changes it.
|
||||
/// `tsng id`: what this device is, and what changes it.
|
||||
async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
@@ -2703,13 +2701,13 @@ async fn show_identity(
|
||||
out.push(device);
|
||||
|
||||
// What this device *is*, not what it belongs to. The networks are
|
||||
// `tsunagi network`, and their secrets are asked for by name there:
|
||||
// `tsng network`, and their secrets are asked for by name there:
|
||||
// printing them in an overview put them in every pasted report.
|
||||
let networks = stored_networks(paths);
|
||||
let mut section = Section::new("networks");
|
||||
section.push(match networks.len() {
|
||||
0 => Row::new(Health::Info, "none", "no network has been joined")
|
||||
.with_note("`tsunagi join --network <name>` makes or joins one"),
|
||||
.with_note("`tsng join --network <name>` makes or joins one"),
|
||||
count => Row::new(
|
||||
Health::Info,
|
||||
"joined",
|
||||
@@ -2722,11 +2720,11 @@ async fn show_identity(
|
||||
.join(", ")
|
||||
),
|
||||
)
|
||||
.with_note("`tsunagi network` lists them with their ids and addresses"),
|
||||
.with_note("`tsng network` lists them with their ids and addresses"),
|
||||
});
|
||||
out.push(section);
|
||||
|
||||
print_report("tsunagi id", &out)
|
||||
print_report("tsng id", &out)
|
||||
}
|
||||
|
||||
/// The name this device answers to.
|
||||
@@ -2938,7 +2936,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
out.push(host_section(&observed));
|
||||
out.push(addresses_section().await);
|
||||
print_report("tsunagi status", &out)
|
||||
print_report("tsng status", &out)
|
||||
}
|
||||
|
||||
/// The local resolver: whether it answers, and whether the system asks it.
|
||||
@@ -3017,7 +3015,7 @@ fn dns_absent_section() -> report::Section {
|
||||
"no local resolver for any network",
|
||||
)
|
||||
.with_note(
|
||||
"members resolve by address only. `tsunagi up --dns` serves \
|
||||
"members resolve by address only. `tsng up --dns` serves \
|
||||
`<hostname>.<network>` from signed state, so a member that is switched \
|
||||
off still resolves.",
|
||||
),
|
||||
@@ -3160,7 +3158,7 @@ fn network_section(
|
||||
)
|
||||
.with_note(
|
||||
"a network is its name *and* its secret, so these two share nothing. \
|
||||
Usually a mistyped secret; `tsunagi network secret` shows which is which.",
|
||||
Usually a mistyped secret; `tsng network secret` shows which is which.",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3377,7 +3375,7 @@ fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec<report::Row> {
|
||||
let row = if !exit.via_online {
|
||||
Row::new(Health::Broken, "using exit", format!("{name} · OFFLINE")).with_note(
|
||||
"internet traffic is dropped until it returns or you run \
|
||||
`tsunagi exit-node off`",
|
||||
`tsng exit-node off`",
|
||||
)
|
||||
} else {
|
||||
match &exit.client_rules {
|
||||
@@ -3535,7 +3533,7 @@ fn host_section(observed: &Observed) -> report::Section {
|
||||
.collect();
|
||||
host.push(
|
||||
Row::new(Health::Info, "protocols", protocols.join(", "))
|
||||
.with_note("run `tsunagi protocols` to see what each one takes"),
|
||||
.with_note("run `tsng protocols` to see what each one takes"),
|
||||
);
|
||||
host.push(Row::new(
|
||||
Health::Info,
|
||||
@@ -3727,7 +3725,7 @@ fn print_report(title: &str, out: &report::Report) -> Result<(), Box<dyn std::er
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The shape of what `tsunagi status` reports.
|
||||
/// The shape of what `tsng status` reports.
|
||||
///
|
||||
/// Findings are built first and rendered second, so what is reported is
|
||||
/// decided separately from how it looks and can be tested without a
|
||||
@@ -3869,7 +3867,7 @@ mod report {
|
||||
|
||||
/// Whether anything in the report was graded at all.
|
||||
///
|
||||
/// A report of plain facts — `tsunagi id` — has nothing to summarise,
|
||||
/// A report of plain facts — `tsng id` — has nothing to summarise,
|
||||
/// and "everything checked out" under a list of identifiers would be
|
||||
/// claiming something that was never checked.
|
||||
fn has_checks(&self) -> bool {
|
||||
@@ -4075,7 +4073,7 @@ mod report {
|
||||
|
||||
#[test]
|
||||
fn a_report_of_plain_facts_claims_nothing_at_the_end() {
|
||||
// `tsunagi id` reports identifiers, not checks. Summarising them
|
||||
// `tsng id` reports identifiers, not checks. Summarising them
|
||||
// as fine would assert something that was never tested.
|
||||
let mut report = Report::new();
|
||||
let mut section = Section::new("device");
|
||||
@@ -4213,7 +4211,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let known: Vec<&str> = PROTOCOLS.iter().map(|spec| spec.name).collect();
|
||||
return Err(format!(
|
||||
"this build has no protocol called `{name}`; it has {}. \
|
||||
Run `tsunagi protocols` to see what each one takes.",
|
||||
Run `tsng protocols` to see what each one takes.",
|
||||
known.join(", ")
|
||||
)
|
||||
.into());
|
||||
@@ -4232,7 +4230,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.any(|spec| !settings_for(spec, std::slice::from_ref(setting)).is_empty())
|
||||
{
|
||||
return Err(format!(
|
||||
"no selected protocol takes `{}`; run `tsunagi protocols` to see what they do",
|
||||
"no selected protocol takes `{}`; run `tsng protocols` to see what they do",
|
||||
setting.key
|
||||
)
|
||||
.into());
|
||||
@@ -4343,13 +4341,13 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"an agent is already running for {}, and one state directory is one \
|
||||
agent — it is the device, not a network.\n\n\
|
||||
To add a network to it:\n\n \
|
||||
tsunagi join --network <name>\n\n\
|
||||
tsng join --network <name>\n\n\
|
||||
To run a second, separate agent instead, give it everything of its \
|
||||
own:\n\n \
|
||||
tsunagi up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
|
||||
tsng up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
|
||||
--ipv4-range <cidr>\n\n\
|
||||
That is a different identity with its own interface, not this one \
|
||||
with another network. `tsunagi network` lists what this one has.",
|
||||
with another network. `tsng network` lists what this one has.",
|
||||
path.display()
|
||||
)
|
||||
.into());
|
||||
@@ -4403,14 +4401,14 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
protocol_names.join(", ")
|
||||
);
|
||||
// What this device belongs to is a separate question from whether its
|
||||
// agent is running, and `tsunagi join` answers it at any time.
|
||||
// agent is running, and `tsng join` answers it at any time.
|
||||
let configured = agent.list_networks().await.unwrap_or_default();
|
||||
let running = configured.iter().filter(|network| network.active).count();
|
||||
println!(
|
||||
" networks {}",
|
||||
match configured.len() {
|
||||
0 => "none yet · `tsunagi join --network <name>` makes or joins one".to_string(),
|
||||
total => format!("{running} of {total} running · `tsunagi network` lists them"),
|
||||
0 => "none yet · `tsng join --network <name>` makes or joins one".to_string(),
|
||||
total => format!("{running} of {total} running · `tsng network` lists them"),
|
||||
}
|
||||
);
|
||||
println!(" state {}", paths.state_dir.display());
|
||||
@@ -4424,7 +4422,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
}));
|
||||
|
||||
// Serve `tsunagi status` for as long as this agent runs. Failing to bind
|
||||
// Serve `tsng status` for as long as this agent runs. Failing to bind
|
||||
// is not fatal: the agent itself works fine without it.
|
||||
let control = {
|
||||
let agent = agent.clone();
|
||||
@@ -4458,7 +4456,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(socket)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("warning: `tsunagi status` will not work: {err}");
|
||||
eprintln!("warning: `tsng status` will not work: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -5476,7 +5474,7 @@ mod status_tests {
|
||||
assert_eq!(out.worst(), Health::Broken, "{text}");
|
||||
assert!(text.contains("OFFLINE"), "{text}");
|
||||
assert!(text.contains("dropped until it returns"), "{text}");
|
||||
assert!(text.contains("tsunagi exit-node off"), "{text}");
|
||||
assert!(text.contains("tsng exit-node off"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5630,14 +5628,13 @@ mod network_tests {
|
||||
(vec!["--reach", "local", "--dht"], false),
|
||||
(vec!["--reach", "direct"], true),
|
||||
] {
|
||||
let cli =
|
||||
super::Cli::try_parse_from(["tsunagi", "up"].into_iter().chain(flags)).unwrap();
|
||||
let cli = super::Cli::try_parse_from(["tsng", "up"].into_iter().chain(flags)).unwrap();
|
||||
let super::Command::Up(args) = cli.command else {
|
||||
panic!("expected up");
|
||||
};
|
||||
assert_eq!(super::dht_enabled(&args), expected);
|
||||
}
|
||||
assert!(super::Cli::try_parse_from(["tsunagi", "up", "--dht", "--no-dht"]).is_err());
|
||||
assert!(super::Cli::try_parse_from(["tsng", "up", "--dht", "--no-dht"]).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
@@ -5814,7 +5811,7 @@ mod secret_tests {
|
||||
|
||||
#[test]
|
||||
fn a_name_this_device_already_has_resumes_it() {
|
||||
// Otherwise `tsunagi join --network lab` would invent a stranger with
|
||||
// Otherwise `tsng join --network lab` would invent a stranger with
|
||||
// the same name every time, which is the confusion this whole
|
||||
// report format exists to prevent.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -103,7 +103,7 @@ impl Running {
|
||||
|
||||
/// Runs another `tsunagi` command against this agent's directory.
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.args(args)
|
||||
.arg("--state-dir")
|
||||
.arg(self.dir.path().join("state"))
|
||||
@@ -174,7 +174,7 @@ fn spawn_child(
|
||||
dns_port: Option<u16>,
|
||||
dns_flag: Option<&str>,
|
||||
) -> std::process::Child {
|
||||
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
|
||||
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsng"));
|
||||
command
|
||||
.arg("up")
|
||||
.arg("--state-dir")
|
||||
@@ -338,7 +338,7 @@ fn explicit_dns_choices_override_the_saved_setting_and_survive_restart() {
|
||||
|
||||
#[test]
|
||||
fn opposing_dns_flags_are_rejected() {
|
||||
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.args(["up", "--dns", "--no-dns"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -1,173 +1,56 @@
|
||||
//! Scenario 1: deterministic network identity.
|
||||
//! What the command line says about this device, against a running agent.
|
||||
//!
|
||||
//! The same name and secret must yield the same network space on different
|
||||
//! devices, and nothing else — hostname, device key, restart — may change it.
|
||||
//! 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::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::testing::{TestAgent, config_with, network};
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
|
||||
#[test]
|
||||
fn derivation_is_a_pure_function_of_name_and_secret() {
|
||||
let name = NetworkName::new("home").unwrap();
|
||||
let other = NetworkName::new("Home").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let other_secret = NetworkSecret::generate();
|
||||
/// 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();
|
||||
|
||||
let a = NetworkKeys::derive(&name, &secret);
|
||||
let b = NetworkKeys::derive(&name, &secret);
|
||||
assert_eq!(a.network_id(), b.network_id());
|
||||
assert_eq!(a.discovery_key(), b.discovery_key());
|
||||
assert_eq!(a.descriptor(), b.descriptor());
|
||||
// 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();
|
||||
|
||||
// A different name is a different space. Names are used verbatim, so case
|
||||
// matters.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&other, &secret).network_id()
|
||||
);
|
||||
// A different secret is a different space.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&name, &other_secret).network_id()
|
||||
);
|
||||
// Separated key material: the discovery key is not the network id.
|
||||
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
|
||||
}
|
||||
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.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();
|
||||
|
||||
#[test]
|
||||
fn descriptor_carries_no_creator_time_or_secret() {
|
||||
let name = NetworkName::new("shared").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let first = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
let second = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
|
||||
// Two independently built descriptors are byte identical: no random
|
||||
// creator id, no creation timestamp, no owner signature.
|
||||
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
|
||||
|
||||
let encoded = first.to_canonical_bytes();
|
||||
let secret_bytes = secret.encode();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
!encoded
|
||||
.windows(secret_bytes.len())
|
||||
.any(|window| window == secret_bytes.as_bytes()),
|
||||
"the secret must never appear in the public descriptor"
|
||||
output.status.success(),
|
||||
"`id` failed while an agent was running:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_validated_not_silently_normalised() {
|
||||
assert!(NetworkName::new("").is_err());
|
||||
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("ho\nme").is_err());
|
||||
assert!(NetworkName::new("a".repeat(65)).is_err());
|
||||
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_not_truncated_or_normalised() {
|
||||
let secret = NetworkSecret::generate();
|
||||
let text = secret.encode();
|
||||
let round_tripped = NetworkSecret::decode(&text).unwrap();
|
||||
assert_eq!(secret, round_tripped);
|
||||
|
||||
// Short secrets are rejected rather than stretched.
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
|
||||
|
||||
// Debug output must not leak the secret.
|
||||
let rendered = format!("{secret:?}");
|
||||
assert_eq!(rendered, "NetworkSecret(<redacted>)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_devices_and_hostnames_agree_on_the_network_id() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("agreement");
|
||||
|
||||
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
a.agent.endpoint_id(),
|
||||
b.agent.endpoint_id(),
|
||||
"different devices must have different endpoint ids"
|
||||
);
|
||||
assert_eq!(a.agent.hostname(), "alpha");
|
||||
assert_eq!(b.agent.hostname(), "beta");
|
||||
|
||||
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_keeps_the_device_and_network_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stable");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = first.agent.endpoint_id();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = first.stop().await;
|
||||
|
||||
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened.endpoint_id(),
|
||||
device_id,
|
||||
"restarting must not mint a new peer"
|
||||
);
|
||||
let networks = reopened.list_networks().await.unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, network_id);
|
||||
assert!(networks[0].active, "auto-start networks come back up");
|
||||
|
||||
reopened.shutdown().await;
|
||||
drop(reopened);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_the_secret_keeps_the_device_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let device_id = agent.endpoint_id();
|
||||
|
||||
let name = NetworkName::new("rotating").unwrap();
|
||||
let old = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old, new, "a new secret is a new network space");
|
||||
assert_eq!(
|
||||
agent.endpoint_id(),
|
||||
device_id,
|
||||
"rotating the network secret must not change the persistent iroh id"
|
||||
assert!(
|
||||
stdout.contains(&expected),
|
||||
"expected {expected} in:\n{stdout}"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ impl Running {
|
||||
}
|
||||
|
||||
fn run_in(dir: &TempDir, args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.args(args)
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
@@ -51,7 +51,7 @@ fn read_networks(dir: &TempDir) -> Vec<tsunagi::storage::StoredNetwork> {
|
||||
/// anything has been decided.
|
||||
fn start_bare(port: u16) -> Running {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.arg("up")
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
@@ -111,7 +111,7 @@ fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
|
||||
assert!(secret.starts_with("tsn1"), "{out}");
|
||||
let command = out
|
||||
.lines()
|
||||
.find(|line| line.contains("tsunagi join --network spontaneous"))
|
||||
.find(|line| line.contains("tsng join --network spontaneous"))
|
||||
.expect("a command to send");
|
||||
assert!(command.contains(secret), "with the secret in it: {out}");
|
||||
// And where to find this device, for an agent that is not up yet.
|
||||
@@ -244,7 +244,7 @@ fn up_is_the_agent_and_takes_no_network_at_all() {
|
||||
// second way to do the same thing, and the one that cannot be undone
|
||||
// without a restart.
|
||||
for argument in ["--network", "--secret"] {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsng"))
|
||||
.args(["up", argument, "whatever"])
|
||||
.arg("--state-dir")
|
||||
.arg(TempDir::new().unwrap().path().join("state"))
|
||||
Reference in new issue
Block a user