diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 8060cb0..5471697 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -1448,6 +1448,17 @@ impl tsunagi::ipc::ReportSource for AgentControl { }) } + fn network_secret(&self, network_id: String) -> tsunagi::BoxFuture<'_, Result> { + Box::pin(async move { + let networks = stored_networks(&self.paths); + let network = networks + .iter() + .find(|network| network.network_id.to_string() == network_id) + .ok_or_else(|| format!("no configured network with id {network_id}"))?; + Ok(network.secret.encode().as_str().to_owned()) + }) + } + fn join( &self, name: String, diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 4f3ac7d..749e3fe 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -33,6 +33,8 @@ pub(crate) enum Command { Leave { network_id: String }, /// Change the hostname this device announces. SetHostname(String), + /// Fetch a network's shared secret and place it on the clipboard. + CopySecret { network_id: String }, } /// The latest the worker knows, read by the UI each frame. @@ -57,6 +59,10 @@ pub(crate) struct AgentClient { socket: PathBuf, tx: mpsc::UnboundedSender, shared: Arc>, + /// A secret fetched for the clipboard, taken by the UI thread which owns + /// the clipboard; kept out of [`Snapshot`] so it is copied once, not every + /// frame, and never travels in the cloned snapshot. + pending_copy: Arc>>, } impl AgentClient { @@ -68,8 +74,25 @@ impl AgentClient { ) -> Self { let (tx, rx) = mpsc::unbounded_channel(); let shared = Arc::new(Mutex::new(Snapshot::default())); - runtime.spawn(worker(socket.clone(), rx, Arc::clone(&shared), ctx)); - Self { socket, tx, shared } + let pending_copy = Arc::new(Mutex::new(None)); + runtime.spawn(worker( + socket.clone(), + rx, + Arc::clone(&shared), + Arc::clone(&pending_copy), + ctx, + )); + Self { + socket, + tx, + shared, + pending_copy, + } + } + + /// Takes any secret the worker fetched for the clipboard. + pub(crate) fn take_pending_copy(&self) -> Option { + lock(&self.pending_copy).take() } /// The control socket this client talks to. @@ -100,6 +123,7 @@ async fn worker( socket: PathBuf, mut rx: mpsc::UnboundedReceiver, shared: Arc>, + pending_copy: Arc>>, ctx: egui::Context, ) { refresh(&socket, &shared, &ctx).await; @@ -113,7 +137,7 @@ async fn worker( } command = rx.recv() => { let Some(command) = command else { return }; - run(&socket, command, &shared, &ctx).await; + run(&socket, command, &shared, &pending_copy, &ctx).await; refresh(&socket, &shared, &ctx).await; } } @@ -139,6 +163,7 @@ async fn run( socket: &std::path::Path, command: Command, shared: &Arc>, + pending_copy: &Arc>>, ctx: &egui::Context, ) { lock(shared).busy = true; @@ -172,6 +197,17 @@ async fn run( .await .map(|accepted| format!("hostname: {accepted}")) .map_err(|err| err.to_string()), + Command::CopySecret { network_id } => { + match ipc::network_secret(socket, &network_id).await { + // The secret goes only to the clipboard slot, never into the + // banner text or any log line. + Ok(secret) => { + *lock(pending_copy) = Some(secret); + Ok("secret copied to clipboard".to_string()) + } + Err(err) => Err(err.to_string()), + } + } }; { diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index bcf368d..69f80fc 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -76,6 +76,12 @@ impl eframe::App for App { ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic); self.show_devices_windows(ctx, &snapshot); + // A secret the worker fetched for the clipboard: copy it here, on the + // UI thread that owns the clipboard, exactly once. + if let Some(secret) = self.agent.take_pending_copy() { + ctx.copy_text(secret); + } + // Keep ticking so fresh status and tray actions are picked up. ctx.request_repaint_after(Duration::from_millis(500)); } diff --git a/crates/tsunagi-tray/src/format.rs b/crates/tsunagi-tray/src/format.rs index 437da7d..307c86a 100644 --- a/crates/tsunagi-tray/src/format.rs +++ b/crates/tsunagi-tray/src/format.rs @@ -130,7 +130,7 @@ pub(crate) fn copy_label(ui: &mut egui::Ui, display: &str, value: &str) { /// A tiny clipboard button that copies `value`. pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) { if ui - .small_button("⧉") + .small_button("copy") .on_hover_text("copy to clipboard") .clicked() { diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index c52b92c..ebb7af8 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -64,7 +64,7 @@ pub(crate) fn draw( ui.horizontal(|ui| { ui.heading("tsunagi"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("⟳").on_hover_text("Refresh").clicked() { + if ui.button("refresh").clicked() { agent.send(Command::Refresh); } if snapshot.busy { @@ -148,7 +148,7 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo match &mut state.host_edit { None => { ui.label(&report.hostname); - if ui.small_button("✎").on_hover_text("rename").clicked() { + if ui.small_button("edit").clicked() { state.host_edit = Some(report.hostname.clone()); } } @@ -156,11 +156,11 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0)); let submit = (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - || ui.small_button("✔").clicked(); + || ui.small_button("save").clicked(); if submit { agent.send(Command::SetHostname(buffer.trim().to_string())); state.host_edit = None; - } else if ui.small_button("✕").clicked() { + } else if ui.small_button("cancel").clicked() { state.host_edit = None; } } @@ -186,9 +186,8 @@ fn draw_network( ui.add_space(6.0); egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - // The name copies on click. ui.strong(network.name.as_str()); - format::copy_label(ui, "⧉", &network.name); + format::copy_button(ui, &network.name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let leave = egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE)) @@ -255,6 +254,15 @@ fn draw_network( if ui.button("Show devices").clicked() { state.open_devices.insert(network.network_id.clone()); } + if ui + .button("Copy secret") + .on_hover_text("copy this network's secret to the clipboard") + .clicked() + { + agent.send(Command::CopySecret { + network_id: network.network_id.clone(), + }); + } }); }); }); @@ -280,7 +288,7 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { .password(true), ); if ui - .button("⟳") + .button("random") .on_hover_text("Generate a new random secret") .clicked() { diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index fcae9cd..1e78c13 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -73,6 +73,31 @@ use crate::error::{Error, Result}; /// Largest accepted local control message. pub const MAX_MESSAGE_LEN: usize = 1024 * 1024; +/// A string the control protocol must carry but that must never appear in a +/// log: it serialises transparently (the text crosses the socket intact) but +/// its `Debug` is redacted. Used for a network secret handed back to a local +/// client that asked to copy it. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RedactedText(String); + +impl RedactedText { + /// Wraps a string. + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } + + /// Unwraps to the plain string. + pub fn into_string(self) -> String { + self.0 + } +} + +impl std::fmt::Debug for RedactedText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + /// Where the control socket for a state directory lives. /// /// A Unix socket path is limited to around 100 bytes, which a state directory @@ -195,6 +220,13 @@ pub enum Request { #[serde(default)] broadcast: Option, }, + /// Ask for a configured network's shared secret, in its `tsn1…` text form, + /// so a local GUI or script can copy it to re-share. The reply travels only + /// over the owner/group socket and the secret is never logged. + Secret { + /// Which network, by the id text `status` prints. + network_id: String, + }, } impl std::fmt::Debug for Request { @@ -215,6 +247,7 @@ impl std::fmt::Debug for Request { write!(f, "Dns {{ enable: {enable}, port: {port:?} }}") } Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: }}"), + Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"), } } } @@ -239,6 +272,8 @@ pub enum Response { /// /// `None` means it is not serving at all. Dns(Option), + /// A network's shared secret, redacted in `Debug`. + Secret(RedactedText), /// The request could not be served. Error(String), } @@ -579,6 +614,17 @@ pub trait ReportSource: Send + Sync + 'static { ) -> BoxFuture<'_, std::result::Result, String>> { Box::pin(async move { Err("this agent cannot serve DNS".to_string()) }) } + + /// Returns a configured network's shared secret in its `tsn1…` text form. + /// + /// Defaulted to a refusal, like the others. An implementor reads it from + /// local state; it must never be logged. + fn network_secret( + &self, + _network_id: String, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { Err("this agent cannot read secrets".to_string()) }) + } } impl ReportSource for F @@ -609,7 +655,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5); /// /// 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', 16]); +pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]); /// Reads one request off an accepted stream, answers it, writes the response. /// @@ -667,6 +713,10 @@ where Ok(report) => Response::Dns(report), Err(reason) => Response::Error(reason), }, + Request::Secret { network_id } => match source.network_secret(network_id).await { + Ok(secret) => Response::Secret(RedactedText::new(secret)), + Err(reason) => Response::Error(reason), + }, }; write_message(&mut stream, &response).await } @@ -821,6 +871,21 @@ pub async fn set_dns( } } +/// Asks a running agent for a configured network's shared secret, in its +/// `tsn1…` text form, so a local client can copy it. The secret is never +/// logged by this path. +pub async fn network_secret(path: impl AsRef, network_id: &str) -> Result { + let path = path.as_ref(); + let request = Request::Secret { + network_id: network_id.to_string(), + }; + match exchange(path, &request, EXCHANGE_TIMEOUT).await? { + Response::Secret(secret) => Ok(secret.into_string()), + Response::Error(reason) => Err(Error::Storage(reason)), + other => Err(Error::Storage(format!("unexpected answer: {other:?}"))), + } +} + pub(crate) async fn write_message(stream: &mut S, value: &T) -> Result<()> where S: AsyncWrite + Unpin,