Split the command line along the line the system draws
`id` had grown into the place where everything was shown and changed, including the secret of every network this device had joined — and it printed them all in its ordinary overview, which is a poor default for output that gets pasted into chats and issue trackers. Now that networks have a command of their own, the boundary is the one the system already has: `id` is this **device**, `network` is what it **belongs to**. A device outlives every network it is in and a network outlives any device in it, so a command that mixed them had to be read twice. `id` keeps the key, the name and the directories, and says how many networks there are without naming their secrets. `network secret` prints one, or all of them, and only when asked. Nothing else ever does. `network join` is the part that was missing entirely. One state directory is one identity and one live agent, so a second `tsunagi up` on it is refused — and until now that refusal was the end of the road: a network could be left while the agent ran but never added. It goes over the control socket, takes effect at once, and is idempotent, saying which of "joined" and "already there" happened. With no agent running it is written to the configuration and starts with the next `up`, and says so rather than implying it is live. The secret travels over an owner-only socket to the agent that stores it anyway, and `Request` has a hand-written `Debug` that redacts it, because a derived one would put it in any log line that printed a request. The lock error from a second `up` now answers the question behind it: add the network to the running agent with one command, or run a genuinely separate agent — a second identity, with its own directories, interface and range — with the other. That is the shape of the thing: one agent per identity, many networks on it, one interface; a second agent is isolated, not a second view of the first. AGENTS.md carries that as a boundary now, since it is the kind of thing a change could quietly break. The local control protocol is 9. Exercised against a running agent: a second `up` refused with both routes named, a network joined into the live agent and answering for status at once, the same one again reported as already there, a same-name network with a different secret joined with the warning, and `id` showing three networks and no secrets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+237
-42
@@ -72,6 +72,25 @@ struct NetworkArgs {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum NetworkAction {
|
||||
/// Joins a network, adding it to the agent that is already running.
|
||||
///
|
||||
/// The state directory belongs to one live agent, so a second `up`
|
||||
/// cannot add a network to it — this can, and takes effect at once.
|
||||
/// With no agent running it is configured and starts with the next
|
||||
/// `tsunagi up`.
|
||||
Join {
|
||||
/// Network name. Must be identical on every participant.
|
||||
#[arg(long, short = 'n')]
|
||||
network: String,
|
||||
|
||||
/// The shared secret, as printed by `tsunagi network secret generate`.
|
||||
#[arg(long, short = 's', env = "TSUNAGI_SECRET")]
|
||||
secret: Option<String>,
|
||||
|
||||
/// Read the shared secret from a file instead of the command line.
|
||||
#[arg(long, conflicts_with = "secret")]
|
||||
secret_file: Option<PathBuf>,
|
||||
},
|
||||
/// Gives up this device's address and name in a network, and forgets it.
|
||||
///
|
||||
/// A signed release goes out first, so the address and name are freed
|
||||
@@ -89,6 +108,24 @@ enum NetworkAction {
|
||||
#[arg(long)]
|
||||
offline: bool,
|
||||
},
|
||||
/// Shows the secret of a network, which is half of its identity.
|
||||
///
|
||||
/// Printed only when asked for, never as part of an overview: these
|
||||
/// reports get pasted into chats and issue trackers.
|
||||
Secret {
|
||||
/// Which network, by id; a unique prefix is enough. All of them if
|
||||
/// omitted.
|
||||
network: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
action: Option<SecretAction>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum SecretAction {
|
||||
/// Prints a fresh random secret, for a network that does not exist yet.
|
||||
Generate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -130,11 +167,6 @@ enum IdAction {
|
||||
#[command(subcommand)]
|
||||
action: Option<KeyAction>,
|
||||
},
|
||||
/// Shows the secret of every network this device has joined.
|
||||
Secret {
|
||||
#[command(subcommand)]
|
||||
action: Option<SecretAction>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
@@ -147,12 +179,6 @@ enum KeyAction {
|
||||
Rotate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum SecretAction {
|
||||
/// Prints a fresh random secret, for a network that does not exist yet.
|
||||
Generate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct StatusArgs {
|
||||
#[command(flatten)]
|
||||
@@ -546,7 +572,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 id secret` is the place that shows one,
|
||||
/// secret must never appear. `tsunagi 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};
|
||||
@@ -963,6 +989,45 @@ impl tsunagi::ipc::unix::ReportSource for AgentControl {
|
||||
})
|
||||
}
|
||||
|
||||
fn join(
|
||||
&self,
|
||||
name: String,
|
||||
secret: String,
|
||||
) -> tsunagi::BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
|
||||
Box::pin(async move {
|
||||
let name = NetworkName::new(&name).map_err(|err| err.to_string())?;
|
||||
let secret = NetworkSecret::decode(&secret).map_err(|err| err.to_string())?;
|
||||
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
|
||||
|
||||
// Read before joining: afterwards "already configured" is true
|
||||
// of everything, and the difference is what the user is told.
|
||||
let before = self
|
||||
.agent
|
||||
.list_networks()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let already = before
|
||||
.iter()
|
||||
.any(|other| other.network_id == keys.network_id());
|
||||
let shared = before
|
||||
.iter()
|
||||
.find(|other| other.name == name && other.network_id != keys.network_id())
|
||||
.map(|other| other.network_id.to_string());
|
||||
|
||||
let network_id = self
|
||||
.agent
|
||||
.join_network(&name, &secret)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(tsunagi::ipc::JoinedReport {
|
||||
name: name.as_str().to_string(),
|
||||
network_id: network_id.to_string(),
|
||||
already_configured: already,
|
||||
name_shared_with: shared,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn leave(
|
||||
&self,
|
||||
network_id: String,
|
||||
@@ -1054,12 +1119,104 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref());
|
||||
match args.action {
|
||||
None => show_networks(&paths, &socket).await,
|
||||
Some(NetworkAction::Join {
|
||||
network,
|
||||
secret,
|
||||
secret_file,
|
||||
}) => {
|
||||
let secret = load_secret(secret.as_deref(), secret_file.as_deref())?;
|
||||
join_network(&paths, &socket, &network, secret).await
|
||||
}
|
||||
Some(NetworkAction::Leave { network, offline }) => {
|
||||
leave_network(&paths, &socket, &network, offline).await
|
||||
}
|
||||
Some(NetworkAction::Secret {
|
||||
network,
|
||||
action: None,
|
||||
}) => show_secrets(&paths, network.as_deref()),
|
||||
Some(NetworkAction::Secret {
|
||||
action: Some(SecretAction::Generate),
|
||||
..
|
||||
}) => {
|
||||
let secret = NetworkSecret::generate();
|
||||
println!("{}", secret.encode().as_str());
|
||||
eprintln!(
|
||||
"\nShare this with every participant, over a channel you trust.\n\
|
||||
Anyone who has it can join the network."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a network: into the running agent if there is one.
|
||||
async fn join_network(
|
||||
paths: &StoragePaths,
|
||||
socket: &std::path::Path,
|
||||
name: &str,
|
||||
secret: NetworkSecret,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// The running agent, because a second `up` cannot have the directory
|
||||
// and because this way the network starts at once instead of at the
|
||||
// next restart.
|
||||
if socket.exists() {
|
||||
let report =
|
||||
tsunagi::ipc::unix::join_network(socket, name, secret.encode().as_str()).await?;
|
||||
if report.already_configured {
|
||||
println!(
|
||||
"`{}` ({}) was already configured; it is running",
|
||||
report.name,
|
||||
short(&report.network_id, 10)
|
||||
);
|
||||
} else {
|
||||
println!("joined `{}` ({})", report.name, report.network_id);
|
||||
}
|
||||
if let Some(other) = &report.name_shared_with {
|
||||
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.",
|
||||
report.name,
|
||||
short(other, 10)
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No agent: configure it, and say when it will take effect rather than
|
||||
// leaving the impression that it is running.
|
||||
let name = NetworkName::new(name)?;
|
||||
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
|
||||
let storage = tsunagi::storage::Storage::open(paths)?;
|
||||
let existing = storage.list_networks().await.unwrap_or_default();
|
||||
let already = existing
|
||||
.iter()
|
||||
.any(|other| other.network_id == keys.network_id());
|
||||
let shared = existing
|
||||
.iter()
|
||||
.find(|other| other.name == name && other.network_id != keys.network_id())
|
||||
.map(|other| other.network_id.to_string());
|
||||
storage
|
||||
.upsert_network(keys.network_id(), name.clone(), secret, true)
|
||||
.await?;
|
||||
storage.release_ownership_lock();
|
||||
|
||||
if already {
|
||||
println!("`{name}` ({}) was already configured", keys.network_id());
|
||||
} else {
|
||||
println!("joined `{name}` ({})", keys.network_id());
|
||||
}
|
||||
if let Some(other) = shared {
|
||||
eprintln!(
|
||||
"\nwarning: `{name}` is also configured with a different secret, as {}.\n\
|
||||
A network is its name *and* its secret, so these two share nothing.",
|
||||
short(&other, 10)
|
||||
);
|
||||
}
|
||||
eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every configured network, live where an agent can say so.
|
||||
async fn show_networks(
|
||||
paths: &StoragePaths,
|
||||
@@ -1261,18 +1418,6 @@ async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(IdAction::Key {
|
||||
action: Some(KeyAction::Rotate),
|
||||
}) => rotate_key(&paths, &socket).await,
|
||||
Some(IdAction::Secret { action: None }) => show_secrets(&paths),
|
||||
Some(IdAction::Secret {
|
||||
action: Some(SecretAction::Generate),
|
||||
}) => {
|
||||
let secret = NetworkSecret::generate();
|
||||
println!("{}", secret.encode().as_str());
|
||||
eprintln!(
|
||||
"\nShare this with every participant, over a channel you trust.\n\
|
||||
Anyone who has it can join the network."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1294,21 +1439,28 @@ 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:
|
||||
// printing them in an overview put them in every pasted report.
|
||||
let networks = stored_networks(paths);
|
||||
let mut section = Section::new("networks");
|
||||
if networks.is_empty() {
|
||||
section.push(Row::new(Health::Info, "none", "no network has been joined"));
|
||||
}
|
||||
for network in &networks {
|
||||
section.push(
|
||||
Row::new(
|
||||
Health::Info,
|
||||
network.name.as_str(),
|
||||
network.network_id.to_string(),
|
||||
)
|
||||
.with_note(format!("secret {}", network.secret.encode().as_str())),
|
||||
);
|
||||
}
|
||||
section.push(match networks.len() {
|
||||
0 => Row::new(Health::Info, "none", "no network has been joined")
|
||||
.with_note("`tsunagi network join --network <name> --secret <secret>` joins one"),
|
||||
count => Row::new(
|
||||
Health::Info,
|
||||
"joined",
|
||||
format!(
|
||||
"{count} network(s): {}",
|
||||
networks
|
||||
.iter()
|
||||
.map(|network| network.name.as_str().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
)
|
||||
.with_note("`tsunagi network` lists them with their ids and addresses"),
|
||||
});
|
||||
out.push(section);
|
||||
|
||||
print_report("tsunagi id", &out)
|
||||
@@ -1408,14 +1560,30 @@ async fn rotate_key(
|
||||
}
|
||||
|
||||
/// The secret of every network this device has joined.
|
||||
fn show_secrets(paths: &StoragePaths) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn show_secrets(
|
||||
paths: &StoragePaths,
|
||||
wanted: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let networks = stored_networks(paths);
|
||||
if networks.is_empty() {
|
||||
eprintln!("no network has been joined");
|
||||
return Ok(());
|
||||
}
|
||||
for network in networks {
|
||||
println!("{} {}", network.name, network.secret.encode().as_str());
|
||||
match wanted {
|
||||
Some(wanted) => {
|
||||
let network = resolve_network(&networks, wanted)?;
|
||||
println!("{}", network.secret.encode().as_str());
|
||||
}
|
||||
None => {
|
||||
for network in networks {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
network.name,
|
||||
network.network_id,
|
||||
network.secret.encode().as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1720,7 +1888,7 @@ fn network_section(
|
||||
)
|
||||
.with_note(
|
||||
"a network is its name *and* its secret, so these two share nothing. \
|
||||
Usually a mistyped secret; `tsunagi id secret` shows which is which.",
|
||||
Usually a mistyped secret; `tsunagi network secret` shows which is which.",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2515,7 +2683,34 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
let agent = Agent::spawn(config).await?;
|
||||
let agent = match Agent::spawn(config).await {
|
||||
Ok(agent) => agent,
|
||||
// One agent per identity, and the state directory is that identity.
|
||||
// It can be in as many networks as you like — but only through the
|
||||
// agent that is already running, so the lock on its own is an
|
||||
// answer to a question nobody asked.
|
||||
Err(tsunagi::Error::StateLocked { path }) => {
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref());
|
||||
if socket.exists() {
|
||||
return Err(format!(
|
||||
"an agent is already running for {}, and one state directory is one \
|
||||
agent.\n\n\
|
||||
To add `{name}` to it — same device, same interface, another network:\n\n \
|
||||
tsunagi network join --network {name} --secret <secret>\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 \
|
||||
--ipv4-range <cidr> --network {name} --secret <secret>\n\n\
|
||||
That is a different identity with its own interface, not this one \
|
||||
with another network. `tsunagi network` lists what this one has.",
|
||||
path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
return Err(tsunagi::Error::StateLocked { path }.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
// From here on every exit goes through `agent.shutdown()`, so the endpoint
|
||||
// is never dropped without being closed.
|
||||
let mut events = agent.subscribe();
|
||||
|
||||
@@ -243,6 +243,36 @@ impl tsunagi::ipc::unix::ReportSource for Control {
|
||||
Box::pin(async move { StatusReport::default() })
|
||||
}
|
||||
|
||||
fn join(
|
||||
&self,
|
||||
name: String,
|
||||
secret: String,
|
||||
) -> BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
|
||||
Box::pin(async move {
|
||||
let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?;
|
||||
let secret =
|
||||
tsunagi::identity::NetworkSecret::decode(&secret).map_err(|e| e.to_string())?;
|
||||
let already = self
|
||||
.0
|
||||
.list_networks()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
.iter()
|
||||
.any(|other| other.name == name);
|
||||
let network_id = self
|
||||
.0
|
||||
.join_network(&name, &secret)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(tsunagi::ipc::JoinedReport {
|
||||
name: name.as_str().to_string(),
|
||||
network_id: network_id.to_string(),
|
||||
already_configured: already,
|
||||
name_shared_with: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn leave(&self, network_id: String) -> BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
|
||||
Box::pin(async move {
|
||||
let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?;
|
||||
@@ -312,3 +342,63 @@ async fn a_client_can_leave_a_network_through_the_running_agent() {
|
||||
agent.shutdown().await;
|
||||
peer.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_can_add_a_network_to_a_running_agent() {
|
||||
// One agent per identity, and it may be in several networks at once —
|
||||
// but the state directory belongs to that one live agent, so a second
|
||||
// `up` cannot add a network to it. Without this there was no way at
|
||||
// all: you could leave a network while running, but not join one.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (first, first_secret) = network("control-join-one");
|
||||
let (second, second_secret) = network("control-join-two");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.join_network(&first, &first_secret).await.unwrap();
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::unix::join_network(
|
||||
&socket_path,
|
||||
second.as_str(),
|
||||
second_secret.encode().as_str(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.name, second.as_str());
|
||||
assert!(!report.already_configured);
|
||||
|
||||
// Running, not merely written down: it is in the agent's own list and
|
||||
// answering for status straight away, on the same identity.
|
||||
let joined: tsunagi::NetworkId = report.network_id.parse().unwrap();
|
||||
assert!(agent.is_active(joined).await);
|
||||
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
|
||||
assert!(agent.network_status(joined).await.is_ok());
|
||||
|
||||
// Joining the same one again is not an error, and says which it was.
|
||||
let again = tsunagi::ipc::unix::join_network(
|
||||
&socket_path,
|
||||
second.as_str(),
|
||||
second_secret.encode().as_str(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(again.already_configured);
|
||||
assert_eq!(again.network_id, report.network_id);
|
||||
|
||||
// A secret that is not one is refused rather than stored.
|
||||
let err = tsunagi::ipc::unix::join_network(&socket_path, "rubbish", "not-a-secret")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!err.to_string().is_empty());
|
||||
assert_eq!(agent.list_networks().await.unwrap().len(), 2);
|
||||
|
||||
control.shutdown().await;
|
||||
agent.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -473,7 +473,8 @@ impl Agent {
|
||||
tracing::warn!(
|
||||
"`{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 id secret` \
|
||||
they share nothing. Check the secret, or use \
|
||||
`tsunagi network secret` \
|
||||
to see which is which.",
|
||||
other.network_id
|
||||
);
|
||||
|
||||
@@ -61,7 +61,11 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// What a client asks for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// `Debug` is written by hand rather than derived: one of these carries a
|
||||
/// network secret, and a derived one would put it in any log line that
|
||||
/// printed a request.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum Request {
|
||||
/// Report what the agent is doing.
|
||||
@@ -78,6 +82,32 @@ pub enum Request {
|
||||
/// only it can publish the release, and only while its sessions are up.
|
||||
/// The network is named by its id, in the text form `status` prints.
|
||||
Leave(String),
|
||||
/// Join a network, or start one that is configured and not running.
|
||||
///
|
||||
/// 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.
|
||||
Join {
|
||||
/// The network name.
|
||||
name: String,
|
||||
/// The shared secret in its `tsn1…` text form.
|
||||
///
|
||||
/// It travels over a socket only its owner can open, to the agent
|
||||
/// that stores it anyway, and never appears in `Debug`.
|
||||
secret: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Request {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Request::Status => f.write_str("Status"),
|
||||
Request::SetHostname(name) => write!(f, "SetHostname({name})"),
|
||||
Request::Leave(network) => write!(f, "Leave({network})"),
|
||||
// The name is not a secret; the secret is.
|
||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the agent answers.
|
||||
@@ -90,10 +120,33 @@ pub enum Response {
|
||||
Hostname(String),
|
||||
/// A network was left.
|
||||
Left(LeftReport),
|
||||
/// A network was joined, or was already there and is now running.
|
||||
Joined(JoinedReport),
|
||||
/// The request could not be served.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// What happened when a network was joined.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JoinedReport {
|
||||
/// The network's name, as given.
|
||||
pub name: String,
|
||||
/// The derived network id, which is what tells two same-named networks
|
||||
/// apart.
|
||||
pub network_id: String,
|
||||
/// Whether this device was already configured for exactly this network.
|
||||
///
|
||||
/// Joining is idempotent, so this is the difference between "added" and
|
||||
/// "it was already there and is now running".
|
||||
pub already_configured: bool,
|
||||
/// Another configured network with the same name but a different
|
||||
/// secret, if there is one.
|
||||
///
|
||||
/// Almost always a mistyped secret, and the one thing that makes two
|
||||
/// sections of a report look like one network.
|
||||
pub name_shared_with: Option<String>,
|
||||
}
|
||||
|
||||
/// What happened when a network was left.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeftReport {
|
||||
|
||||
@@ -16,7 +16,7 @@ use tokio::task::JoinHandle;
|
||||
use crate::BoxFuture;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::{LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport};
|
||||
use super::{JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport};
|
||||
|
||||
/// Builds the report that answers a status request.
|
||||
///
|
||||
@@ -46,6 +46,18 @@ pub trait ReportSource: Send + Sync + 'static {
|
||||
fn leave(&self, _network_id: String) -> BoxFuture<'_, std::result::Result<LeftReport, String>> {
|
||||
Box::pin(async move { Err("this agent cannot leave a network".to_string()) })
|
||||
}
|
||||
|
||||
/// Joins a network, or starts one that is configured and not running.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others: a source that only reports
|
||||
/// says so rather than appearing to have done it.
|
||||
fn join(
|
||||
&self,
|
||||
_name: String,
|
||||
_secret: String,
|
||||
) -> BoxFuture<'_, std::result::Result<JoinedReport, String>> {
|
||||
Box::pin(async move { Err("this agent cannot join a network".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ReportSource for F
|
||||
@@ -178,6 +190,10 @@ async fn handle(mut stream: UnixStream, source: Arc<dyn ReportSource>) -> Result
|
||||
Ok(report) => Response::Left(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::Join { name, secret } => match source.join(name, secret).await {
|
||||
Ok(report) => Response::Joined(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await
|
||||
}
|
||||
@@ -252,6 +268,27 @@ pub async fn leave_network(path: impl AsRef<Path>, network_id: &str) -> Result<L
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to join a network.
|
||||
///
|
||||
/// The one way to add a network to an agent that is already up: the state
|
||||
/// directory belongs to one live agent, so a second `up` cannot.
|
||||
pub async fn join_network(
|
||||
path: impl AsRef<Path>,
|
||||
name: &str,
|
||||
secret: &str,
|
||||
) -> Result<JoinedReport> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::Join {
|
||||
name: name.to_string(),
|
||||
secret: secret.to_string(),
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Joined(report) => Ok(report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the wire format of the local control socket.
|
||||
///
|
||||
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
|
||||
@@ -263,7 +300,7 @@ pub async fn leave_network(path: impl AsRef<Path>, network_id: &str) -> Result<L
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 8]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 9]);
|
||||
|
||||
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
|
||||
let encoded = postcard::to_stdvec(value)
|
||||
|
||||
Reference in New Issue
Block a user