Tray: copy a network secret via ipc; use text buttons, not missing glyphs

Two fixes.

Secret copy: a new local control request returns a configured network's secret
in its tsn1 text form, so the tray's "Copy secret" button can put it on the
clipboard. The agent reads it from local state (same source as the CLI's
`network secret show`); it crosses only the owner/group socket and is wrapped in
a RedactedText type whose Debug prints "<redacted>", so it can never land in a
log. In the GUI the worker fetches it and hands it to the UI thread (which owns
the clipboard) through a one-shot slot kept out of the status snapshot, so it is
copied once and never stored in the cloned snapshot. Control protocol bumped
16 -> 17.

Icons: the button glyphs (⧉ ⟳ ✎ ✔ ✕) are not in egui's bundled fonts and showed
as tofu squares, so they are now plain text: copy / refresh / random / edit /
save / cancel. No icon font to embed.

Checks: fmt, clippy --workspace --all-targets -D warnings, and the tsunagi lib,
tsunagi-cli and wg-quic local_control tests pass on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
UltradesuandClaude Opus 4.8 committed 2026-10-02 15:48:32 +01:00
1 parent 8f5ce21ce9
commit de56258eb1
6 files changed
+138 -12

No files matched your search

+11
View File
@@ -1448,6 +1448,17 @@ impl tsunagi::ipc::ReportSource for AgentControl {
}) })
} }
fn network_secret(&self, network_id: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
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( fn join(
&self, &self,
name: String, name: String,
+39 -3
View File
@@ -33,6 +33,8 @@ pub(crate) enum Command {
Leave { network_id: String }, Leave { network_id: String },
/// Change the hostname this device announces. /// Change the hostname this device announces.
SetHostname(String), 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. /// The latest the worker knows, read by the UI each frame.
@@ -57,6 +59,10 @@ pub(crate) struct AgentClient {
socket: PathBuf, socket: PathBuf,
tx: mpsc::UnboundedSender<Command>, tx: mpsc::UnboundedSender<Command>,
shared: Arc<Mutex<Snapshot>>, shared: Arc<Mutex<Snapshot>>,
/// 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<Mutex<Option<String>>>,
} }
impl AgentClient { impl AgentClient {
@@ -68,8 +74,25 @@ impl AgentClient {
) -> Self { ) -> Self {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let shared = Arc::new(Mutex::new(Snapshot::default())); let shared = Arc::new(Mutex::new(Snapshot::default()));
runtime.spawn(worker(socket.clone(), rx, Arc::clone(&shared), ctx)); let pending_copy = Arc::new(Mutex::new(None));
Self { socket, tx, shared } 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<String> {
lock(&self.pending_copy).take()
} }
/// The control socket this client talks to. /// The control socket this client talks to.
@@ -100,6 +123,7 @@ async fn worker(
socket: PathBuf, socket: PathBuf,
mut rx: mpsc::UnboundedReceiver<Command>, mut rx: mpsc::UnboundedReceiver<Command>,
shared: Arc<Mutex<Snapshot>>, shared: Arc<Mutex<Snapshot>>,
pending_copy: Arc<Mutex<Option<String>>>,
ctx: egui::Context, ctx: egui::Context,
) { ) {
refresh(&socket, &shared, &ctx).await; refresh(&socket, &shared, &ctx).await;
@@ -113,7 +137,7 @@ async fn worker(
} }
command = rx.recv() => { command = rx.recv() => {
let Some(command) = command else { return }; 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; refresh(&socket, &shared, &ctx).await;
} }
} }
@@ -139,6 +163,7 @@ async fn run(
socket: &std::path::Path, socket: &std::path::Path,
command: Command, command: Command,
shared: &Arc<Mutex<Snapshot>>, shared: &Arc<Mutex<Snapshot>>,
pending_copy: &Arc<Mutex<Option<String>>>,
ctx: &egui::Context, ctx: &egui::Context,
) { ) {
lock(shared).busy = true; lock(shared).busy = true;
@@ -172,6 +197,17 @@ async fn run(
.await .await
.map(|accepted| format!("hostname: {accepted}")) .map(|accepted| format!("hostname: {accepted}"))
.map_err(|err| err.to_string()), .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()),
}
}
}; };
{ {
+6
View File
@@ -76,6 +76,12 @@ impl eframe::App for App {
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic); ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
self.show_devices_windows(ctx, &snapshot); 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. // Keep ticking so fresh status and tray actions are picked up.
ctx.request_repaint_after(Duration::from_millis(500)); ctx.request_repaint_after(Duration::from_millis(500));
} }
+1 -1
View File
@@ -130,7 +130,7 @@ pub(crate) fn copy_label(ui: &mut egui::Ui, display: &str, value: &str) {
/// A tiny clipboard button that copies `value`. /// A tiny clipboard button that copies `value`.
pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) { pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) {
if ui if ui
.small_button("⧉") .small_button("copy")
.on_hover_text("copy to clipboard") .on_hover_text("copy to clipboard")
.clicked() .clicked()
{ {
+15 -7
View File
@@ -64,7 +64,7 @@ pub(crate) fn draw(
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading("tsunagi"); ui.heading("tsunagi");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 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); agent.send(Command::Refresh);
} }
if snapshot.busy { 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 { match &mut state.host_edit {
None => { None => {
ui.label(&report.hostname); 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()); 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 response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
let submit = (response.lost_focus() let submit = (response.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))) && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui.small_button("✔").clicked(); || ui.small_button("save").clicked();
if submit { if submit {
agent.send(Command::SetHostname(buffer.trim().to_string())); agent.send(Command::SetHostname(buffer.trim().to_string()));
state.host_edit = None; state.host_edit = None;
} else if ui.small_button("✕").clicked() { } else if ui.small_button("cancel").clicked() {
state.host_edit = None; state.host_edit = None;
} }
} }
@@ -186,9 +186,8 @@ fn draw_network(
ui.add_space(6.0); ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| { egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// The name copies on click.
ui.strong(network.name.as_str()); 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| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let leave = let leave =
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE)) egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
@@ -255,6 +254,15 @@ fn draw_network(
if ui.button("Show devices").clicked() { if ui.button("Show devices").clicked() {
state.open_devices.insert(network.network_id.clone()); 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), .password(true),
); );
if ui if ui
.button("⟳") .button("random")
.on_hover_text("Generate a new random secret") .on_hover_text("Generate a new random secret")
.clicked() .clicked()
{ {
+66 -1
View File
@@ -73,6 +73,31 @@ use crate::error::{Error, Result};
/// Largest accepted local control message. /// Largest accepted local control message.
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024; 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<String>) -> 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("<redacted>")
}
}
/// Where the control socket for a state directory lives. /// Where the control socket for a state directory lives.
/// ///
/// A Unix socket path is limited to around 100 bytes, which a state directory /// A Unix socket path is limited to around 100 bytes, which a state directory
@@ -195,6 +220,13 @@ pub enum Request {
#[serde(default)] #[serde(default)]
broadcast: Option<bool>, broadcast: Option<bool>,
}, },
/// 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 { impl std::fmt::Debug for Request {
@@ -215,6 +247,7 @@ impl std::fmt::Debug for Request {
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}") write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
} }
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"), Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
} }
} }
} }
@@ -239,6 +272,8 @@ pub enum Response {
/// ///
/// `None` means it is not serving at all. /// `None` means it is not serving at all.
Dns(Option<DnsReport>), Dns(Option<DnsReport>),
/// A network's shared secret, redacted in `Debug`.
Secret(RedactedText),
/// The request could not be served. /// The request could not be served.
Error(String), Error(String),
} }
@@ -579,6 +614,17 @@ pub trait ReportSource: Send + Sync + 'static {
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> { ) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
Box::pin(async move { Err("this agent cannot serve DNS".to_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<String, String>> {
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
}
} }
impl<F> ReportSource for F impl<F> 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 /// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape. /// 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. /// Reads one request off an accepted stream, answers it, writes the response.
/// ///
@@ -667,6 +713,10 @@ where
Ok(report) => Response::Dns(report), Ok(report) => Response::Dns(report),
Err(reason) => Response::Error(reason), 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 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<Path>, network_id: &str) -> Result<String> {
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<S, T>(stream: &mut S, value: &T) -> Result<()> pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
where where
S: AsyncWrite + Unpin, S: AsyncWrite + Unpin,