Files
tsunagi/src/ipc/unix.rs
T
tsunagiandClaude Opus 5 43e8ac8159 Make identity something you can look at and change
`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.

There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.

Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.

The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.

Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.

The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.

The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:59:39 +01:00

261 lines
8.9 KiB
Rust

//! A Unix socket adapter for the local control interface.
//!
//! One of possibly several adapters; see [`super`]. It serves exactly the
//! requests in [`Request`] and nothing else, and it is reachable only by a
//! process that can open a file inside the agent's owner-only state
//! directory.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::error::{Error, Result};
use super::{MAX_MESSAGE_LEN, Request, Response, StatusReport};
/// Builds the report that answers a status request.
///
/// Supplied by the caller, because only the caller knows which plugins are
/// running and what they can report. That is what keeps this module free of
/// any knowledge of them.
pub trait ReportSource: Send + Sync + 'static {
/// Produces a fresh report.
fn report(&self) -> BoxFuture<'_, StatusReport>;
/// Changes the name the agent answers to, returning the accepted form.
///
/// Defaulted to a refusal so that a source which only reports — the
/// closure impl below, and every test that uses it — stays valid and
/// says plainly that it cannot do this, rather than appearing to.
fn set_hostname(
&self,
_hostname: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
}
}
impl<F> ReportSource for F
where
F: Fn() -> BoxFuture<'static, StatusReport> + Send + Sync + 'static,
{
fn report(&self) -> BoxFuture<'_, StatusReport> {
(self)()
}
}
/// Serves the local control interface on a Unix socket.
#[derive(Debug)]
pub struct ControlSocket {
path: PathBuf,
task: Option<JoinHandle<()>>,
}
impl ControlSocket {
/// Binds the socket and starts serving.
///
/// A socket file left behind by a crashed agent is replaced, but only
/// after checking that nothing is listening on it, so two live agents
/// never fight over one path.
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
crate::storage::create_dir(parent)?;
}
if path.exists() {
match UnixStream::connect(&path).await {
Ok(_) => {
return Err(Error::StateLocked { path: path.clone() });
}
// Nothing is listening, so the file is a leftover.
Err(_) => {
std::fs::remove_file(&path).map_err(|source| Error::Io {
path: path.clone(),
source,
})?;
}
}
}
let listener = UnixListener::bind(&path).map_err(|source| Error::Io {
path: path.clone(),
source,
})?;
restrict(&path)?;
let task = tokio::spawn(serve(listener, source));
Ok(Self {
path,
task: Some(task),
})
}
/// The path being served.
pub fn path(&self) -> &Path {
&self.path
}
/// Stops serving and removes the socket file.
pub async fn shutdown(mut self) {
if let Some(task) = self.task.take() {
task.abort();
let _ = task.await;
}
let _ = std::fs::remove_file(&self.path);
}
}
impl Drop for ControlSocket {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(unix)]
fn restrict(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
async fn serve(listener: UnixListener, source: Arc<dyn ReportSource>) {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let source = Arc::clone(&source);
tokio::spawn(async move {
if let Err(err) = handle(stream, source).await {
tracing::debug!(%err, "local control request failed");
}
});
}
}
async fn handle(mut stream: UnixStream, source: Arc<dyn ReportSource>) -> Result<()> {
let request: Request = read_message(&mut stream).await?;
let response = match request {
Request::Status => Response::Status(Box::new(source.report().await)),
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
Ok(accepted) => Response::Hostname(accepted),
Err(reason) => Response::Error(reason),
},
};
write_message(&mut stream, &response).await
}
/// Asks a running agent for its status.
pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
let path = path.as_ref();
let mut stream = UnixStream::connect(path)
.await
.map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
write_message(&mut stream, &Request::Status).await?;
match read_message::<Response>(&mut stream).await? {
Response::Status(report) => Ok(*report),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to answer to a different name.
///
/// Returns the name it accepted, which is the canonical form of what was
/// asked for and may differ from it.
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
let path = path.as_ref();
let mut stream = UnixStream::connect(path)
.await
.map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
write_message(&mut stream, &Request::SetHostname(hostname.to_string())).await?;
match read_message::<Response>(&mut stream).await? {
Response::Hostname(accepted) => Ok(accepted),
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
/// instead of being read as a length. The encoding is postcard, which is not
/// self-describing: adding a field to a report changes how the bytes parse,
/// and without this a client one build ahead of its agent reports something
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
/// nothing about the actual problem, that the two are different builds.
///
/// 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', 3]);
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
let encoded = postcard::to_stdvec(value)
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
if encoded.len() > MAX_MESSAGE_LEN {
return Err(Error::Storage("control message is too large".into()));
}
let len = encoded.len() as u32;
stream
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
.await
.map_err(io_error)?;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(io_error)?;
stream.write_all(&encoded).await.map_err(io_error)?;
stream.flush().await.map_err(io_error)
}
async fn read_message<T: for<'de> serde::Deserialize<'de>>(stream: &mut UnixStream) -> Result<T> {
let mut header = [0u8; 4];
stream.read_exact(&mut header).await.map_err(io_error)?;
let version = u32::from_be_bytes(header);
if version != CONTROL_PROTOCOL {
return Err(Error::Storage(format!(
"the other end speaks control protocol {version:#010x} and this build speaks \
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
agent with the binary you are running now"
)));
}
stream.read_exact(&mut header).await.map_err(io_error)?;
let len = u32::from_be_bytes(header) as usize;
// Checked before allocating, exactly as on the network.
if len > MAX_MESSAGE_LEN {
return Err(Error::Storage(format!(
"control message of {len} bytes exceeds the {MAX_MESSAGE_LEN} byte limit"
)));
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await.map_err(io_error)?;
postcard::from_bytes(&payload)
.map_err(|err| Error::Storage(format!("cannot decode a control message: {err}")))
}
fn io_error(source: std::io::Error) -> Error {
Error::Io {
path: PathBuf::from("<local control socket>"),
source,
}
}