From 9a98fc7a955b8ff58ec079585ce309d22be30732 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Tue, 4 Aug 2026 05:07:39 +0000 Subject: [PATCH 1/5] feat(server): add Tailscale serve as a fourth hosting option Adds a "tailscale" coordinator_exposure variant alongside Direct URL, Cloudflare Tunnel, and NGINX. Unlike the bundled cloudflared sidecar, this detects and drives a system `tailscale` CLI (tailscale serve needs the privileged tailscaled daemon and a logged-in tailnet, so it can't be a sidecar). Backend (src-tauri/src/tailscale.rs): - `start` runs `tailscale serve --bg --https=443 https+insecure://127.0.0.1:`, putting the loopback frostd behind the machine's stable MagicDNS name on the tailnet with auto-provisioned public TLS. `https+insecure` is the tailnet equivalent of cloudflared's --no-tls-verify; frostd's Noise layer still authenticates end-to-end. - Uses `serve` (tailnet-only), never `funnel` (public internet). - MagicDNS name + readiness read from `tailscale status --json`; `available` / `detail` explain why it's not ready (not installed / signed out / offline / MagicDNS off). Binary resolved from PATH then per-OS install locations. - `stop`/`stop_serve_blocking` turn off the 443 mapping (it lives in the daemon, not a child process) on stop, when the sidecar stops, and on app exit. - Commands: start_tailscale_serve, stop_tailscale_serve, tailscale_status; AppState gains a `tailscale` handle. Frontend (SessionSetup.tsx, ipc/commands.ts): - Tailscale exposure tab + TailscaleExposure panel (serve/stop, shows the stable URL, guides install/sign-in via `detail`). The `.ts.net` URL is stable, so it saves and reuses like a normal server (not ephemeral). - Participant server-URL guidance and cert notes mention the Tailscale form. Backend builds, 2 new unit tests pass, tsc clean, no new clippy warnings. Needs a live run on a Tailscale-signed-in machine to confirm the serve invocation against the current CLI. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 64 +++--- src-tauri/src/commands/server.rs | 36 +++- src-tauri/src/lib.rs | 13 ++ src-tauri/src/state.rs | 4 + src-tauri/src/tailscale.rs | 347 +++++++++++++++++++++++++++++++ src/ipc/commands.ts | 21 +- src/screens/SessionSetup.tsx | 131 +++++++++++- 7 files changed, 575 insertions(+), 41 deletions(-) create mode 100644 src-tauri/src/tailscale.rs diff --git a/TODO.md b/TODO.md index 15c6e0c..7959ebe 100644 --- a/TODO.md +++ b/TODO.md @@ -53,40 +53,36 @@ current build depends on them. identity. An identicon derived *from* the pubkey is the one variant that strengthens rather than weakens this, which argues for it. -- [ ] **Tailscale `serve` as a fourth hosting option** — alongside Direct URL, - Cloudflare Tunnel, and NGINX in Session Configuration. - - **Feasibility/impact (2026-08-01):** effort Med, impact Med–High, not gated - by the testnet-send validation. Slots in as a fourth `coordinator_exposure` - variant reusing the existing exposure plumbing + a status probe; no crypto - change (frostd's Noise layer still authenticates end-to-end). Structural - difference from cloudflared: **detect-and-drive a system `tailscale` CLI, - do NOT bundle** — it needs the `tailscaled` daemon (privileged) and a - logged-in tailnet, so the sidecar-spawn pattern doesn't apply. Read the - stable MagicDNS hostname back via `tailscale status --json` as the saved - server URL. Verdict: **do** — best fix for the disposable-quick-tunnel URL - pain (stable, savable, auto-TLS, tailnet-scoped). - - Why it is attractive: `tailscale serve https / http://127.0.0.1:` - exposes the loopback frostd over the tailnet with a **stable** MagicDNS - hostname and an automatically-provisioned, publicly-valid TLS certificate. - That fixes the two things that hurt most about quick tunnels: the URL is - **not disposable** (so it can be saved as a group's server and reused), and - there is no cert-trust step. Access is also restricted to the tailnet rather - than the whole internet, which is a strictly better default for a signing - server. (`tailscale funnel` would expose it publicly if a participant is - outside the tailnet.) - - Open questions: - - Detect an existing `tailscale` binary/daemon, or bundle it? Bundling is - heavier than `cloudflared` and the daemon needs privileges — detection - plus a clear "install Tailscale" path is likely the right first cut. - - Every participant must be on the tailnet (or the coordinator uses Funnel). - That is a real constraint to surface in the UI, not bury. - - Reuse the existing exposure plumbing: this is a new `Exposure` variant - plus a status probe; the trust model is unchanged (frostd's Noise layer - still authenticates end-to-end, so the transport only provides - reachability). +- [x] **Tailscale `serve` as a fourth hosting option** — DONE + (`feat/tailscale-serve`). A fourth `coordinator_exposure` variant + (`"tailscale"`) in Session Configuration, alongside Direct URL, Cloudflare + Tunnel, and NGINX. + + Implementation: `src-tauri/src/tailscale.rs` **detects and drives a system + `tailscale` CLI** (not bundled — needs the privileged `tailscaled` daemon + + a logged-in tailnet). `tailscale serve --bg --https=443 + https+insecure://127.0.0.1:` puts the loopback frostd behind the + machine's stable MagicDNS name on the tailnet, with auto-provisioned public + TLS (so participants connect with system roots — no cert-trust step). We use + `serve` (tailnet-only), never `funnel` (public). The MagicDNS name is read + from `tailscale status --json` (`Self.DNSName`), and `available`/`detail` + surface *why* it's not ready (not installed / signed out / offline). + Commands `start_tailscale_serve` / `stop_tailscale_serve` / + `tailscale_status`; `AppState.tailscale` handle; serve is torn down when the + sidecar stops and on app exit (`stop_serve_blocking`, since the mapping + lives in the daemon). UI: a Tailscale tab + `TailscaleExposure` in + `SessionSetup.tsx`; the stable `.ts.net` URL is (correctly) *not* treated as + ephemeral, so it saves + reuses like a normal server. `https+insecure` is the + tailnet equivalent of cloudflared's `--no-tls-verify`; frostd's Noise layer + still authenticates end-to-end. Compile-verified + unit tests; **needs a + live run** on a machine with Tailscale installed/signed-in to confirm the + `serve` invocation against the current CLI. + + Possible follow-ups: bundle/guide an install path if detection proves too + bare; surface the "all participants must be on the tailnet" constraint even + more prominently; optional `funnel` toggle for a participant outside the + tailnet (explicitly public — would need the same loud warning as the + Cloudflare tunnel). ## Voting (coinholder polling) diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 5c6ce14..1efef76 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -5,6 +5,7 @@ use tauri::{AppHandle, Manager, State}; use crate::error::AppResult; use crate::sidecar::{self, SidecarStatus}; use crate::state::{AppState, Settings}; +use crate::tailscale::{self, TailscaleStatus}; use crate::tunnel::{self, TunnelStatus}; #[tauri::command] @@ -169,9 +170,10 @@ pub async fn start_sidecar( #[tauri::command] pub async fn stop_sidecar(state: State<'_, AppState>) -> AppResult<()> { - // The tunnel points at the embedded server; stopping the server makes it - // dead weight, so tear it down too. + // The tunnel and the Tailscale serve mapping both point at the embedded + // server; stopping the server makes them dead weight, so tear them down too. let _ = tunnel::stop(&state).await; + let _ = tailscale::stop(&state).await; sidecar::stop(&state).await } @@ -205,6 +207,36 @@ pub async fn tunnel_status(state: State<'_, AppState>) -> AppResult` URL participants on the tailnet can use. Requires +/// a system Tailscale that is installed, signed in, and online. +#[tauri::command] +pub async fn start_tailscale_serve(state: State<'_, AppState>) -> AppResult { + let port = { + let guard = state.sidecar.lock().await; + match guard.as_ref() { + Some(handle) => handle.port, + None => { + return Err(crate::error::AppError::new( + "tailscale", + "start the embedded server before starting Tailscale serve", + )) + } + } + }; + tailscale::start(&state, port).await +} + +#[tauri::command] +pub async fn stop_tailscale_serve(state: State<'_, AppState>) -> AppResult<()> { + tailscale::stop(&state).await +} + +#[tauri::command] +pub async fn tailscale_status(state: State<'_, AppState>) -> AppResult { + Ok(tailscale::status(&state).await) +} + #[tauri::command] pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult { sidecar::status(&state).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b58bc33..eed709f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod commands; pub mod error; pub mod sidecar; pub mod state; +pub mod tailscale; pub mod tunnel; use state::AppState; @@ -104,6 +105,9 @@ pub fn run() { commands::server::start_tunnel, commands::server::stop_tunnel, commands::server::tunnel_status, + commands::server::start_tailscale_serve, + commands::server::stop_tailscale_serve, + commands::server::tailscale_status, commands::dkg::start_dkg, commands::dkg::cancel_ceremony, commands::signing::create_signing_session, @@ -127,6 +131,15 @@ pub fn run() { let _ = handle.child.kill(); } } + // Tailscale `serve` lives in the tailscaled daemon, not a + // child process, so turn off the mapping synchronously if we + // set one — otherwise it outlives the app pointing at a dead + // port. + if let Ok(mut guard) = state.tailscale.try_lock() { + if guard.take().is_some() { + crate::tailscale::stop_serve_blocking(); + } + } } } }); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d4e4d6e..8188a55 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -120,6 +120,9 @@ pub struct AppState { pub sidecar: Mutex>, /// Optional Cloudflare quick tunnel exposing the embedded server publicly. pub tunnel: Mutex>, + /// Optional Tailscale `serve` mapping exposing the embedded server over the + /// coordinator's tailnet with a stable MagicDNS URL. + pub tailscale: Mutex>, /// Cancellation token for the in-flight wallet sync of each group, so a /// "Sync Now" can abandon a stalled sync and restart it cleanly. pub sync_cancels: Mutex>, @@ -146,6 +149,7 @@ impl AppState { ceremonies: Mutex::new(HashMap::new()), sidecar: Mutex::new(None), tunnel: Mutex::new(None), + tailscale: Mutex::new(None), sync_cancels: Mutex::new(HashMap::new()), last_activity: AtomicI64::new(now_millis()), } diff --git a/src-tauri/src/tailscale.rs b/src-tauri/src/tailscale.rs new file mode 100644 index 0000000..fab2f98 --- /dev/null +++ b/src-tauri/src/tailscale.rs @@ -0,0 +1,347 @@ +//! Optional Tailscale `serve` hosting for the embedded frostd server. +//! +//! Unlike the Cloudflare quick tunnel (a bundled `cloudflared` sidecar), this +//! **detects and drives a system `tailscale` CLI** — it is deliberately *not* +//! bundled, because `tailscale serve` needs the privileged `tailscaled` daemon +//! running and the machine signed in to a tailnet, neither of which the +//! sidecar-spawn pattern can provide. The user installs Tailscale and signs in; +//! Cyze just runs the CLI. +//! +//! `tailscale serve --bg https+insecure://127.0.0.1:` puts the loopback +//! frostd behind the machine's stable MagicDNS name over the tailnet, on port +//! 443 with an automatically-provisioned, publicly-valid TLS certificate. That +//! fixes the two things that hurt about quick tunnels: the URL is **stable** +//! (savable as a group's server and reused across launches) and there is **no +//! self-signed-cert trust step** (participants connect with system roots). The +//! `https+insecure` scheme is the tailnet-side equivalent of cloudflared's +//! `--no-tls-verify`: it tells Tailscale not to verify frostd's self-signed +//! backend certificate, exactly as the tunnel already does. +//! +//! We use `serve` (tailnet-only), never `funnel` (public internet): access is +//! scoped to the coordinator's tailnet, a strictly better default for a signing +//! server. frostd's Noise layer still authenticates participants end-to-end, so +//! the transport only ever provides reachability. + +use std::path::PathBuf; +use std::process::Stdio; + +use serde::Serialize; +use tokio::process::Command; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +/// The `serve` flag selecting the tailnet HTTPS port. Passed as a single +/// combined `--https=443` token because `tailscale serve` does not reliably +/// accept the space-separated form. 443 is Tailscale's HTTPS default, so the +/// served URL carries no explicit port. +const SERVE_HTTPS_FLAG: &str = "--https=443"; + +/// Records that Cyze started `tailscale serve` and the stable URL it produced. +/// There is no child process to hold: `serve --bg` configures the `tailscaled` +/// daemon and returns, so tearing down means calling `serve … off`, not killing +/// a process. +pub struct TailscaleHandle { + pub public_url: String, + pub port: u16, +} + +#[derive(Serialize, Clone)] +pub struct TailscaleStatus { + /// The `tailscale` CLI was found, the daemon is running, the machine is + /// signed in and online, and a MagicDNS name is available — i.e. `serve` + /// can be started. + pub available: bool, + /// Cyze currently has `serve` active in front of the embedded server. + pub serving: bool, + /// The stable tailnet URL participants connect to (present while serving). + pub public_url: Option, + /// The local frostd port being served (present while serving). + pub port: Option, + /// The machine's MagicDNS name (without the trailing dot), when known — + /// shown even before serving so the user can see where they'll be reachable. + pub dns_name: Option, + /// Human-readable status, especially *why* Tailscale is unavailable + /// (not installed, daemon stopped, signed out) so the UI can guide the user. + pub detail: Option, +} + +impl TailscaleStatus { + fn unavailable(detail: impl Into) -> Self { + TailscaleStatus { + available: false, + serving: false, + public_url: None, + port: None, + dns_name: None, + detail: Some(detail.into()), + } + } +} + +/// Candidate `tailscale` binary locations: PATH first (bare name; the OS +/// resolves it), then the well-known per-platform install paths the GUI apps use +/// but which are often not on a GUI-launched app's PATH (notably macOS). +fn tailscale_candidates() -> Vec { + let mut c = vec![PathBuf::from("tailscale")]; + if cfg!(target_os = "macos") { + c.push(PathBuf::from( + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + )); + c.push(PathBuf::from("/usr/local/bin/tailscale")); + c.push(PathBuf::from("/opt/homebrew/bin/tailscale")); + } else if cfg!(target_os = "windows") { + c.push(PathBuf::from( + r"C:\Program Files\Tailscale\tailscale.exe", + )); + } else { + c.push(PathBuf::from("/usr/bin/tailscale")); + c.push(PathBuf::from("/usr/local/bin/tailscale")); + } + c +} + +/// Resolve a working `tailscale` binary by trying each candidate with `version`. +/// Returns the first that runs, or `None` if Tailscale is not installed. +async fn resolve_bin() -> Option { + for bin in tailscale_candidates() { + let ok = Command::new(&bin) + .arg("version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if ok { + return Some(bin); + } + } + None +} + +/// Run a `tailscale` subcommand and capture its output. Short-lived commands +/// only (`status`, `serve`), so we wait for completion rather than streaming. +async fn run(bin: &PathBuf, args: &[&str]) -> AppResult { + Command::new(bin) + .args(args) + .output() + .await + .map_err(|e| AppError::new("tailscale", format!("running `tailscale {}`: {e}", args.join(" ")))) +} + +/// Probe the daemon via `tailscale status --json`, returning the MagicDNS name +/// (trailing dot stripped) when the machine is signed in and online. `Ok(Err)` +/// carries a user-facing reason it is not ready; the outer `Err` is an I/O +/// failure running the CLI. +async fn probe_dns_name(bin: &PathBuf) -> AppResult> { + let out = run(bin, &["status", "--json"]).await?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Ok(Err(format!( + "Tailscale is installed but not ready: {}", + first_line(&stderr).unwrap_or("run `tailscale up` and sign in") + ))); + } + let json: serde_json::Value = serde_json::from_slice(&out.stdout) + .map_err(|e| AppError::new("tailscale", format!("parsing status JSON: {e}")))?; + + let backend = json.get("BackendState").and_then(|v| v.as_str()).unwrap_or(""); + if backend != "Running" { + // NeedsLogin / Stopped / NoState — the actionable states. + let hint = match backend { + "NeedsLogin" | "NoState" => "sign in with `tailscale up`", + "Stopped" => "start Tailscale (`tailscale up`)", + _ => "start Tailscale and sign in", + }; + return Ok(Err(format!("Tailscale is not connected — {hint}."))); + } + + let self_node = json.get("Self"); + let online = self_node + .and_then(|s| s.get("Online")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let dns = self_node + .and_then(|s| s.get("DNSName")) + .and_then(|v| v.as_str()) + .map(|s| s.trim_end_matches('.').to_string()) + .filter(|s| !s.is_empty()); + + match (online, dns) { + (true, Some(name)) => Ok(Ok(name)), + (false, _) => Ok(Err( + "This machine is signed in but shows offline in the tailnet.".into(), + )), + (_, None) => Ok(Err( + "MagicDNS is not enabled for this tailnet — enable it in the Tailscale admin console." + .into(), + )), + } +} + +fn first_line(s: &str) -> Option<&str> { + s.lines().map(str::trim).find(|l| !l.is_empty()) +} + +/// Turn off any HTTPS serve on 443 (best-effort). Used before starting (to clear +/// a stale mapping) and on stop. Surgical — only touches the 443 mount Cyze uses, +/// not the user's other serve config. +async fn serve_off(bin: &PathBuf) { + let _ = run(bin, &["serve", SERVE_HTTPS_FLAG, "off"]).await; +} + +/// Start `tailscale serve` in front of the embedded server on `port`, returning +/// the stable tailnet URL. Requires Tailscale installed, connected, and online. +pub async fn start(state: &AppState, port: u16) -> AppResult { + if state.tailscale.lock().await.is_some() { + return Err(AppError::new("tailscale", "Tailscale serve is already running")); + } + + let bin = resolve_bin().await.ok_or_else(|| { + AppError::new( + "tailscale", + "Tailscale CLI not found. Install Tailscale and sign in, then try again.", + ) + })?; + + let dns_name = match probe_dns_name(&bin).await? { + Ok(name) => name, + Err(reason) => return Err(AppError::new("tailscale", reason)), + }; + + // Clear any stale 443 mapping from a previous run/crash, then serve the + // loopback frostd. `https+insecure` skips verification of frostd's + // self-signed backend cert (the tailnet edge presents a real cert outward). + serve_off(&bin).await; + let target = format!("https+insecure://127.0.0.1:{port}"); + let out = run( + &bin, + &["serve", "--bg", SERVE_HTTPS_FLAG, &target], + ) + .await?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(AppError::new( + "tailscale", + format!( + "`tailscale serve` failed: {}", + first_line(&stderr).unwrap_or("unknown error (is the daemon running?)") + ), + )); + } + + let public_url = format!("https://{dns_name}"); + *state.tailscale.lock().await = Some(TailscaleHandle { + public_url: public_url.clone(), + port, + }); + + Ok(TailscaleStatus { + available: true, + serving: true, + public_url: Some(public_url), + port: Some(port), + dns_name: Some(dns_name), + detail: None, + }) +} + +/// Stop serving: drop our handle and turn off the 443 serve mapping. Best-effort +/// on the CLI side — if Tailscale is gone the mapping is moot anyway. +pub async fn stop(state: &AppState) -> AppResult<()> { + state.tailscale.lock().await.take(); + if let Some(bin) = resolve_bin().await { + serve_off(&bin).await; + } + Ok(()) +} + +/// Report Tailscale availability and whether Cyze is currently serving. Safe to +/// call any time (drives the UI); never errors — availability problems are +/// reported in `detail`. +pub async fn status(state: &AppState) -> TailscaleStatus { + // Snapshot our own serve handle first, then release the lock before probing. + let (serving, public_url, port) = match state.tailscale.lock().await.as_ref() { + Some(h) => (true, Some(h.public_url.clone()), Some(h.port)), + None => (false, None, None), + }; + + let bin = match resolve_bin().await { + Some(b) => b, + None => { + let mut s = TailscaleStatus::unavailable( + "Tailscale is not installed. Install it from tailscale.com and sign in.", + ); + // Preserve any active serve we already recorded, even if the CLI + // moved (unlikely) — the URL is still what participants use. + s.serving = serving; + s.public_url = public_url; + s.port = port; + return s; + } + }; + + match probe_dns_name(&bin).await { + Ok(Ok(dns_name)) => TailscaleStatus { + available: true, + serving, + public_url, + port, + dns_name: Some(dns_name), + detail: None, + }, + Ok(Err(reason)) => { + let mut s = TailscaleStatus::unavailable(reason); + s.serving = serving; + s.public_url = public_url; + s.port = port; + s + } + Err(e) => { + let mut s = TailscaleStatus::unavailable(e.message); + s.serving = serving; + s.public_url = public_url; + s.port = port; + s + } + } +} + +/// Synchronously turn off the 443 serve mapping, for use in the app-exit handler +/// (which is not async). `serve --bg` lives in the `tailscaled` daemon and would +/// otherwise outlive the app, leaving a mapping pointing at a dead frostd port. +/// Best-effort: resolves the binary and runs the off command, ignoring failures. +pub fn stop_serve_blocking() { + use std::process::Command as StdCommand; + for bin in tailscale_candidates() { + let ran = StdCommand::new(&bin) + .args(["serve", SERVE_HTTPS_FLAG, "off"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ran { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidates_include_path_and_platform_paths() { + let c = tailscale_candidates(); + assert_eq!(c[0], PathBuf::from("tailscale"), "PATH lookup must be tried first"); + assert!(c.len() > 1, "should include platform-specific fallbacks"); + } + + #[test] + fn first_line_trims_and_skips_blanks() { + assert_eq!(first_line("\n \n hello \nworld"), Some("hello")); + assert_eq!(first_line(" "), None); + } +} diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 2939caf..f7dacaf 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -34,7 +34,7 @@ export interface Settings { trusted_certs: Record; /** Active session profile: "coordinator" | "participant". */ session_role: string | null; - /** Coordinator server exposure: "direct" | "tunnel" | "nginx". */ + /** Coordinator server exposure: "direct" | "tunnel" | "tailscale" | "nginx". */ coordinator_exposure: string | null; /** True once first-run Session Configuration has been saved. */ session_configured: boolean | null; @@ -65,6 +65,21 @@ export interface TunnelStatus { port: number | null; } +export interface TailscaleStatus { + /** Tailscale is installed, signed in, online — serve can be started. */ + available: boolean; + /** Cyze currently has `serve` active in front of the embedded server. */ + serving: boolean; + /** Stable tailnet URL participants connect to (present while serving). */ + public_url: string | null; + /** Local frostd port being served (present while serving). */ + port: number | null; + /** This machine's MagicDNS name, when known (shown even before serving). */ + dns_name: string | null; + /** Human-readable status, especially why Tailscale is unavailable. */ + detail: string | null; +} + // Keystore export const keystoreStatus = () => invoke("keystore_status"); /** Defer the idle auto-lock; called on user activity (throttled). */ @@ -342,6 +357,10 @@ export const exportSidecarCert = () => invoke("export_sidecar_cert"); export const startTunnel = () => invoke("start_tunnel"); export const stopTunnel = () => invoke("stop_tunnel"); export const tunnelStatus = () => invoke("tunnel_status"); +export const startTailscaleServe = () => + invoke("start_tailscale_serve"); +export const stopTailscaleServe = () => invoke("stop_tailscale_serve"); +export const tailscaleStatus = () => invoke("tailscale_status"); // Ceremonies export type Ciphersuite = "ed25519" | "redpallas"; diff --git a/src/screens/SessionSetup.tsx b/src/screens/SessionSetup.tsx index 773c5a7..12471b8 100644 --- a/src/screens/SessionSetup.tsx +++ b/src/screens/SessionSetup.tsx @@ -10,6 +10,10 @@ import { tunnelStatus, startTunnel, stopTunnel, + tailscaleStatus, + startTailscaleServe, + stopTailscaleServe, + TailscaleStatus, setServerUrl, setSessionConfig, testServerConnection, @@ -18,7 +22,7 @@ import { } from "../ipc/commands"; type Role = "coordinator" | "participant"; -type Exposure = "direct" | "tunnel" | "nginx"; +type Exposure = "direct" | "tunnel" | "tailscale" | "nginx"; /** A server whose address is not stable across restarts, so it must never be * remembered as a reusable "last-used server". Today that means a TryCloudflare @@ -177,6 +181,14 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) { const sidecar = useQuery({ queryKey: ["sidecar"], queryFn: sidecarStatus }); const tunnel = useQuery({ queryKey: ["tunnel"], queryFn: tunnelStatus }); + // Poll Tailscale status only while its tab is selected — it shells out to the + // `tailscale` CLI, so there's no reason to probe it when the user isn't looking. + const tailscale = useQuery({ + queryKey: ["tailscale"], + queryFn: tailscaleStatus, + enabled: exposure === "tailscale", + refetchInterval: exposure === "tailscale" ? 5000 : false, + }); const start = useMutation({ // Always loopback: remote signers come in over the tunnel/proxy, not the LAN. @@ -192,6 +204,7 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["sidecar"] }); queryClient.invalidateQueries({ queryKey: ["tunnel"] }); + queryClient.invalidateQueries({ queryKey: ["tailscale"] }); }, }); const openTunnel = useMutation({ @@ -203,6 +216,15 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) { mutationFn: stopTunnel, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tunnel"] }), }); + const openTailscale = useMutation({ + mutationFn: startTailscaleServe, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), + onError: (e) => setError((e as unknown as AppError).message), + }); + const closeTailscale = useMutation({ + mutationFn: stopTailscaleServe, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), + }); const running = sidecar.data?.running; const port = sidecar.data?.port ?? 2744; @@ -245,6 +267,7 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) {
setExposure("direct")} label="Direct URL / IP" /> setExposure("tunnel")} label="Cloudflare Tunnel" /> + setExposure("tailscale")} label="Tailscale" /> setExposure("nginx")} label="NGINX reverse proxy" />
@@ -280,6 +303,21 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) {

))} + {exposure === "tailscale" && + (running ? ( + openTailscale.mutate()} + onStop={() => closeTailscale.mutate()} + pending={openTailscale.isPending} + /> + ) : ( +

+ Start the server (Step 1), then publish it to your tailnet here. +

+ ))} + {exposure === "nginx" && } @@ -457,6 +495,85 @@ function TunnelExposure({ ); } +function TailscaleExposure({ + status, + loading, + onServe, + onStop, + pending, +}: { + status: TailscaleStatus | null; + loading: boolean; + onServe: () => void; + onStop: () => void; + pending: boolean; +}) { + const serving = status?.serving ?? false; + const url = status?.public_url ?? null; + + return ( +
+

+ Publishes this server to your tailnet at a{" "} + stable *.ts.net address with + an automatic, publicly-trusted TLS certificate — so the URL can be saved + and reused across launches, with no cert-trust step. Access is limited to + your tailnet (not the public internet). Requires{" "} + + Tailscale + {" "} + installed and signed in on this machine, and every participant on the same + tailnet. +

+ + {loading && !status ? ( +

Checking Tailscale…

+ ) : serving && url ? ( + <> +
+ serving on tailnet +
+ +
{url}
+
+ + +
+
+ + This address is stable — save it as the group's server and reuse it + next time. Participants must be on your tailnet to reach it. + +
+ + ) : status?.available ? ( + <> + {status.dns_name && ( +

+ This machine:{" "} + https://{status.dns_name} +

+ )} + + + ) : ( +
+ + {status?.detail ?? + "Tailscale is not available. Install it and sign in, then reopen this tab."} + +
+ )} +
+ ); +} + function NginxExposure({ port }: { port: number }) { const conf = useMemo( () => @@ -591,8 +708,13 @@ function ParticipantPath() { • https://long-random-words.trycloudflare.com{" "}  — a Cloudflare tunnel
+ • https://their-machine.tailnet.ts.net{" "} +  — a Tailscale address (you must be on the same tailnet) +
A Cloudflare tunnel URL is disposable: the coordinator - gets a new one each time they restart it, so always use the latest. + gets a new one each time they restart it, so always use the latest. A + Tailscale .ts.net address is{" "} + stable — save it once and reuse it.
@@ -609,8 +731,9 @@ function ParticipantPath() { Self-signed server? Trust its certificate

- Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel or - an NGINX/domain server, which use publicly trusted TLS). Paste the + Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel, a + Tailscale address, or an NGINX/domain server, which use publicly trusted + TLS). Paste the certificate PEM the coordinator shared and confirm the fingerprint with them out-of-band before trusting it.

From a64b339b9de3296f0f5360eff04a329f372f57a8 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Tue, 4 Aug 2026 12:01:03 +0000 Subject: [PATCH 2/5] docs: add combined UAT checklist for pipelined sync and Tailscale serve One check-off document covering both in-flight features, each part labelled with the branch it needs (feat/sync-optimizations, feat/tailscale-serve). Part A mirrors the pipelined-sync validation gate (stock-vs-pipelined equality, incremental, cancel/resume, reorg, send-after-sync, flag-off regression). Part B covers Tailscale detection states, publish, tailnet reachability with no cert step, stable save/reuse, teardown on stop/quit, tailnet-only scoping, and an end-to-end ceremony. Co-Authored-By: Claude Opus 4.8 --- docs/UAT.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/UAT.md diff --git a/docs/UAT.md b/docs/UAT.md new file mode 100644 index 0000000..3fab098 --- /dev/null +++ b/docs/UAT.md @@ -0,0 +1,141 @@ +# UAT — pipelined sync & Tailscale serve + +User-acceptance checklists for the two in-flight features. Each part names the +branch it needs; until both merge to `main`, test each on its own branch build +(they don't depend on each other). + +- **Part A — Pipelined sync** → branch `feat/sync-optimizations` +- **Part B — Tailscale serve** → branch `feat/tailscale-serve` + +Build fresh and launch the built binary each time (`npm run tauri build`, or +`cargo build` + `npm run tauri dev`) — never a previously installed bundle. + +--- + +# Part A — Experimental pipelined sync (`feat/sync-optimizations`) + +Goal: prove the pipelined driver produces the **same wallet state** as the stock +driver, only faster. Testnet first. Off by default; opt in via `settings.json` +(`/settings.json`) → `"experimental_pipelined_sync": true|false`. +Toggling takes effect on the next sync (Sync Now / relaunch). See +`docs/SYNC_OPTIMIZATION.md` for the design. + +## A0. Setup +- [ ] Fresh build launched (not an installed bundle). +- [ ] A **testnet** group with real history (funded a few times, ≥1 send). +- [ ] Know how to edit `experimental_pipelined_sync` in `settings.json`. + +## A1. Baseline — stock driver (control) +- [ ] `experimental_pipelined_sync` is `false`/absent. +- [ ] Delete the group's wallet db (force full rescan) and sync to tip; note the + rough wall-clock time. +- [ ] Record: total balance + Orchard/Ironwood split; received-note count; + transaction history (count + amounts); scanned-to height (= chain tip). + +## A2. Pipelined — clean-state equality (the core test) +- [ ] Set `experimental_pipelined_sync` to `true`. +- [ ] Delete the wallet db again (same start as A1) and sync to tip. +- [ ] Log shows **"using experimental pipelined sync driver"** (not a fallback). +- [ ] Balance **byte-identical to A1** — total, Orchard, and Ironwood all match. +- [ ] Received-note count matches A1. +- [ ] Transaction history matches A1 (txids, amounts, memos). +- [ ] Scanned-to height reaches the chain tip. +- [ ] Wall-clock sync time is **≤ A1** (bigger win on a high-latency link). + +## A3. Incremental sync +- [ ] From tip, receive a new testnet payment, then Sync Now → only new blocks + scanned, new note appears, balance rises by the expected amount. +- [ ] Sync again with no activity → quick, balance unchanged (no drift/double-count). + +## A4. Cancellation / resume +- [ ] Start a full rescan (delete db), then cancel mid-sync (Sync Now / navigate away). +- [ ] App stays responsive; no panic; at most an expected "cancelled". +- [ ] Sync again → resumes and completes at the same balance/height as A2. + +## A5. Reorg tolerance (best-effort) +- [ ] If a reorg occurs during a sync, log shows "chain reorg detected … rewinding" + and the sync still finishes at the correct tip/balance. (Opportunistic.) + +## A6. Send after a pipelined sync (funds path) +- [ ] After a pipelined sync, build + FROST-sign + broadcast a small testnet send. +- [ ] Node accepts it (no branch-id / MissingSpendAuthSig / selection errors). +- [ ] After confirmation, a re-sync shows the spend and reduced balance. + +## A7. Regression — flag off still works +- [ ] Set the flag back to `false`, sync once → stock path works normally. + +## A — Sign-off +- [ ] A1 vs A2 identical across balance/notes/history/height. +- [ ] A3, A4, A6 pass on testnet. No panics, no stuck syncs, UI responsive. +- [ ] Only then: consider flipping the default, and repeat A1/A2/A6 once on + **mainnet** with a small balance before recommending broadly. + +--- + +# Part B — Tailscale serve hosting (`feat/tailscale-serve`) + +Goal: a coordinator can publish the embedded frostd to their tailnet at a stable +`*.ts.net` URL, participants on the same tailnet connect with no cert-trust step, +and the mapping is cleaned up correctly. `serve` is tailnet-only (not public). + +## B0. Setup +- [ ] Fresh build launched on the **coordinator** machine. +- [ ] Tailscale installed and **signed in** on the coordinator (`tailscale status` + shows Running + online). +- [ ] A **second device on the same tailnet** to act as a participant (another + Cyze install, or at least a browser/curl to hit the URL). +- [ ] (For B6) a device **not** on the tailnet, to confirm scoping. + +## B1. Detection states (before serving) +Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance +matches reality: +- [ ] **Signed in & online** → tab shows this machine's `https://.ts.net` + and a **"Publish to tailnet"** button. +- [ ] **Signed out** (`tailscale logout`) → shows an actionable message + (sign in with `tailscale up`), no Publish button. +- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message. +- [ ] **Not installed** (test machine without Tailscale, or rename the binary) → + shows "not installed, install from tailscale.com". + +## B2. Happy path — publish +- [ ] Start the embedded server (Step 1). +- [ ] Tailscale tab → **Publish to tailnet** → badge **"serving on tailnet"** and + a stable `https://.ts.net` URL (no port). +- [ ] `tailscale serve status` on the coordinator shows the 443 → 127.0.0.1: + mapping (confirms the CLI invocation succeeded — the one flagged risk). +- [ ] Copy URL works. + +## B3. Reachability from a tailnet participant +- [ ] On the participant device, open the URL / paste it into Participant setup and + **Test connection** → succeeds, `tls` reported as **public** (no cert import), + reasonable latency. +- [ ] No certificate-trust step was needed anywhere. + +## B4. Stable save & reuse +- [ ] Save the `.ts.net` URL as the server (it is **not** treated as ephemeral). +- [ ] Fully quit and relaunch Cyze; re-publish; the URL is the **same** as before. +- [ ] The saved server still connects after relaunch (contrast: a Cloudflare quick + tunnel would have a new URL). + +## B5. Teardown paths +- [ ] **Stop serving** button → badge clears; from the participant the URL no + longer reaches frostd; `tailscale serve status` shows the mapping gone. +- [ ] Re-publish, then **Stop server** (Step 1) → serve mapping is also torn down + (sidecar stop cascades to Tailscale). +- [ ] Re-publish, then **quit the app** → after quit, `tailscale serve status` + shows no leftover 443 mapping (exit cleanup ran). + +## B6. Tailnet scoping (not public) +- [ ] From a device **not** on the tailnet, the `.ts.net` URL does **not** resolve/ + connect (confirms `serve`, not `funnel` — access is tailnet-scoped). + +## B7. End-to-end ceremony over Tailscale +- [ ] With serve up and a participant joined via the `.ts.net` URL, run a real + **signing** (or DKG) ceremony to completion over the tailnet transport. + +## B — Sign-off +- [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit. +- [ ] B6 confirms tailnet-only scoping. +- [ ] B7 completes a real ceremony over the transport. +- [ ] Note the Tailscale CLI version tested here: ____________ (so we know which + `serve` grammar was validated). From 26ed1276522555b4ac962c8b768e4b0124b1c0bd Mon Sep 17 00:00:00 2001 From: blocknodes Date: Thu, 6 Aug 2026 00:42:27 +0000 Subject: [PATCH 3/5] feat(tailscale): Get Tailscale + one-click sign-in to cut setup friction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce the two Tailscale onboarding steps to buttons in the Tailscale exposure panel: - "Get Tailscale" (shown when the CLI isn't installed) opens https://tailscale.com/download in the system browser. - "Sign in to Tailscale" (shown when installed but signed out) runs `tailscale up`, captures the login URL from its output, and opens it so the user can authenticate; the panel then auto-updates to the connected state via the existing status poll. If `up` needs elevated rights, that message is surfaced instead of hanging. Backend: TailscaleStatus gains `installed` (CLI present regardless of sign-in) so the UI can pick Get-Tailscale vs Sign-in; `tailscale::sign_in` spawns `tailscale up`, extracts the login URL (bounded read, reaps in background); new commands `tailscale_sign_in` and `open_url` (http(s)-only, OS default handler — no deprecated shell API, no new plugin). Unit test for the login-URL parser. UAT: docs/UAT.md Part B gains B1a (Get Tailscale opens the download page) and B1b (Sign in drives `tailscale up` and the tab auto-updates), plus sign-off. Backend builds, 3 tailscale tests pass, tsc clean, no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- docs/UAT.md | 27 +++++++-- src-tauri/src/commands/server.rs | 43 ++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/tailscale.rs | 98 ++++++++++++++++++++++++++++++++ src/ipc/commands.ts | 12 ++++ src/screens/SessionSetup.tsx | 74 ++++++++++++++++++++---- 6 files changed, 242 insertions(+), 14 deletions(-) diff --git a/docs/UAT.md b/docs/UAT.md index 3fab098..282e60e 100644 --- a/docs/UAT.md +++ b/docs/UAT.md @@ -91,11 +91,28 @@ Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance matches reality: - [ ] **Signed in & online** → tab shows this machine's `https://.ts.net` and a **"Publish to tailnet"** button. -- [ ] **Signed out** (`tailscale logout`) → shows an actionable message - (sign in with `tailscale up`), no Publish button. -- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message. +- [ ] **Signed out** (`tailscale logout`) → shows a "not connected" message and a + **"Sign in to Tailscale"** button (not the Publish button). +- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message + with the **Sign in** button. - [ ] **Not installed** (test machine without Tailscale, or rename the binary) → - shows "not installed, install from tailscale.com". + shows "not installed" and a **"Get Tailscale"** button. + +## B1a. Get Tailscale (not-installed friction) +- [ ] On a machine without Tailscale, click **Get Tailscale** → the system default + browser opens `https://tailscale.com/download` (not an in-app webview). +- [ ] Install Tailscale, then reopen the tab → it now shows the **Sign in** state + (installed, not yet connected). + +## B1b. Sign in to Tailscale (signed-out friction) +- [ ] With Tailscale installed but signed out, click **Sign in to Tailscale**. +- [ ] Either a browser opens to `login.tailscale.com` automatically, **or** an + "open the sign-in page" link appears — clicking it opens the login URL. +- [ ] Complete auth in the browser; within a few seconds the tab **auto-updates** + to the signed-in state (shows the `.ts.net` name + Publish button) with no + manual refresh. +- [ ] (Linux note) If `tailscale up` needs elevated rights on this host, the tab + surfaces that instead of hanging — the operator/sudo message is shown. ## B2. Happy path — publish - [ ] Start the embedded server (Step 1). @@ -134,6 +151,8 @@ matches reality: **signing** (or DKG) ceremony to completion over the tailnet transport. ## B — Sign-off +- [ ] B1a/B1b: **Get Tailscale** opens the download page and **Sign in** drives + `tailscale up` to a connected state, with the tab auto-updating. - [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit. - [ ] B6 confirms tailnet-only scoping. - [ ] B7 completes a real ceremony over the transport. diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index ea76a08..8c6d560 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -268,6 +268,49 @@ pub async fn tailscale_status(state: State<'_, AppState>) -> AppResult AppResult { + tailscale::sign_in().await +} + +/// Open a URL in the user's default browser (e.g. the Tailscale download or the +/// sign-in link) via the OS default handler. Restricted to http(s) so it can +/// only ever launch a browser, never an arbitrary program or file. +#[tauri::command] +pub async fn open_url(url: String) -> AppResult<()> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(crate::error::AppError::new( + "open", + "refusing to open a non-http(s) URL", + )); + } + #[cfg(target_os = "linux")] + let mut command = { + let mut c = tokio::process::Command::new("xdg-open"); + c.arg(&url); + c + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut c = tokio::process::Command::new("open"); + c.arg(&url); + c + }; + #[cfg(target_os = "windows")] + let mut command = { + // `start` is a cmd builtin; the empty "" is its window-title argument. + let mut c = tokio::process::Command::new("cmd"); + c.args(["/C", "start", "", &url]); + c + }; + command + .spawn() + .map_err(|e| crate::error::AppError::new("open", format!("opening browser: {e}")))?; + Ok(()) +} + #[tauri::command] pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult { sidecar::status(&state).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d53675f..cbf8f44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,8 @@ pub fn run() { commands::server::start_tailscale_serve, commands::server::stop_tailscale_serve, commands::server::tailscale_status, + commands::server::tailscale_sign_in, + commands::server::open_url, commands::dkg::start_dkg, commands::dkg::cancel_ceremony, commands::signing::create_signing_session, diff --git a/src-tauri/src/tailscale.rs b/src-tauri/src/tailscale.rs index fab2f98..24d34fe 100644 --- a/src-tauri/src/tailscale.rs +++ b/src-tauri/src/tailscale.rs @@ -48,6 +48,10 @@ pub struct TailscaleHandle { #[derive(Serialize, Clone)] pub struct TailscaleStatus { + /// The `tailscale` CLI was found on this machine (regardless of whether it is + /// signed in). Drives whether the UI offers "Get Tailscale" (install) vs + /// "Sign in to Tailscale". + pub installed: bool, /// The `tailscale` CLI was found, the daemon is running, the machine is /// signed in and online, and a MagicDNS name is available — i.e. `serve` /// can be started. @@ -69,6 +73,7 @@ pub struct TailscaleStatus { impl TailscaleStatus { fn unavailable(detail: impl Into) -> Self { TailscaleStatus { + installed: false, available: false, serving: false, public_url: None, @@ -79,6 +84,15 @@ impl TailscaleStatus { } } +/// Result of triggering Tailscale sign-in. +#[derive(Serialize, Clone)] +pub struct SignInResult { + /// A URL the user must open to finish authenticating. `None` means sign-in + /// completed without needing one (already signed in, or a desktop Tailscale + /// app opened the browser itself) — the status will flip to available shortly. + pub login_url: Option, +} + /// Candidate `tailscale` binary locations: PATH first (bare name; the OS /// resolves it), then the well-known per-platform install paths the GUI apps use /// but which are often not on a GUI-launched app's PATH (notably macOS). @@ -238,6 +252,7 @@ pub async fn start(state: &AppState, port: u16) -> AppResult { }); Ok(TailscaleStatus { + installed: true, available: true, serving: true, public_url: Some(public_url), @@ -257,6 +272,69 @@ pub async fn stop(state: &AppState) -> AppResult<()> { Ok(()) } +/// Trigger Tailscale sign-in by running `tailscale up`. When the machine isn't +/// signed in yet, `tailscale up` prints a login URL and waits for the user to +/// authenticate in a browser; we capture that URL (with a short timeout) and hand +/// it back so the UI can open it, letting the `up` process finish in the +/// background. When it's already signed in (or a desktop app handles the browser), +/// no URL is produced and the status poll picks up the change. +pub async fn sign_in() -> AppResult { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let bin = resolve_bin().await.ok_or_else(|| { + AppError::new( + "tailscale", + "Tailscale CLI not found. Install Tailscale first, then sign in.", + ) + })?; + + let mut child = Command::new(&bin) + .arg("up") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| AppError::new("tailscale", format!("running `tailscale up`: {e}")))?; + + // `tailscale up` prints the login URL to stderr. Read lines until we see it or + // the process finishes its output, bounded so we never hang the command. + let stderr = child.stderr.take(); + let login_url = if let Some(stderr) = stderr { + let mut lines = BufReader::new(stderr).lines(); + let mut found = None; + // Loop ends when the pattern fails to match: a timeout, EOF (process done + // printing), or a read error — any of which means "stop looking". + while let Ok(Ok(Some(line))) = + tokio::time::timeout(std::time::Duration::from_secs(15), lines.next_line()).await + { + if let Some(u) = extract_login_url(&line) { + found = Some(u); + break; + } + } + found + } else { + None + }; + + // Reap the child in the background so it can finish authenticating (or exit) + // without leaving a zombie, and without us blocking on it here. + tokio::spawn(async move { + let _ = child.wait().await; + }); + + Ok(SignInResult { login_url }) +} + +/// Extract a `https://login.tailscale.com/...` URL from a line, if present. +fn extract_login_url(line: &str) -> Option { + let start = line.find("https://login.tailscale.com")?; + let rest = &line[start..]; + let end = rest + .find(|c: char| c.is_whitespace()) + .unwrap_or(rest.len()); + Some(rest[..end].to_string()) +} + /// Report Tailscale availability and whether Cyze is currently serving. Safe to /// call any time (drives the UI); never errors — availability problems are /// reported in `detail`. @@ -282,8 +360,11 @@ pub async fn status(state: &AppState) -> TailscaleStatus { } }; + // The binary was found, so Tailscale is installed even when it's not yet + // signed in / online. match probe_dns_name(&bin).await { Ok(Ok(dns_name)) => TailscaleStatus { + installed: true, available: true, serving, public_url, @@ -293,6 +374,7 @@ pub async fn status(state: &AppState) -> TailscaleStatus { }, Ok(Err(reason)) => { let mut s = TailscaleStatus::unavailable(reason); + s.installed = true; s.serving = serving; s.public_url = public_url; s.port = port; @@ -300,6 +382,7 @@ pub async fn status(state: &AppState) -> TailscaleStatus { } Err(e) => { let mut s = TailscaleStatus::unavailable(e.message); + s.installed = true; s.serving = serving; s.public_url = public_url; s.port = port; @@ -344,4 +427,19 @@ mod tests { assert_eq!(first_line("\n \n hello \nworld"), Some("hello")); assert_eq!(first_line(" "), None); } + + #[test] + fn extract_login_url_pulls_the_auth_link() { + let line = "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/abc123def "; + assert_eq!( + extract_login_url(line).as_deref(), + Some("https://login.tailscale.com/a/abc123def") + ); + assert_eq!(extract_login_url("Success."), None); + // Stops at whitespace, so trailing prose doesn't get glued on. + assert_eq!( + extract_login_url("visit https://login.tailscale.com/a/x then return").as_deref(), + Some("https://login.tailscale.com/a/x") + ); + } } diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 735e022..47f988c 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -68,6 +68,9 @@ export interface TunnelStatus { } export interface TailscaleStatus { + /** The tailscale CLI is present (may still be signed out). Drives whether the + * UI offers "Get Tailscale" vs "Sign in". */ + installed: boolean; /** Tailscale is installed, signed in, online — serve can be started. */ available: boolean; /** Cyze currently has `serve` active in front of the embedded server. */ @@ -368,6 +371,15 @@ export const startTailscaleServe = () => invoke("start_tailscale_serve"); export const stopTailscaleServe = () => invoke("stop_tailscale_serve"); export const tailscaleStatus = () => invoke("tailscale_status"); +/** Result of triggering Tailscale sign-in. */ +export interface SignInResult { + /** URL to open to finish authenticating, or null if none was needed. */ + login_url: string | null; +} +/** Run `tailscale up`; returns a login URL to open when auth is needed. */ +export const tailscaleSignIn = () => invoke("tailscale_sign_in"); +/** Open a URL in the default browser (http/https only). */ +export const openUrl = (url: string) => invoke("open_url", { url }); // Ceremonies export type Ciphersuite = "ed25519" | "redpallas"; diff --git a/src/screens/SessionSetup.tsx b/src/screens/SessionSetup.tsx index 12471b8..de4a50a 100644 --- a/src/screens/SessionSetup.tsx +++ b/src/screens/SessionSetup.tsx @@ -13,6 +13,8 @@ import { tailscaleStatus, startTailscaleServe, stopTailscaleServe, + tailscaleSignIn, + openUrl, TailscaleStatus, setServerUrl, setSessionConfig, @@ -510,6 +512,25 @@ function TailscaleExposure({ }) { const serving = status?.serving ?? false; const url = status?.public_url ?? null; + const [loginUrl, setLoginUrl] = useState(null); + const [signInNote, setSignInNote] = useState(null); + + const signIn = useMutation({ + mutationFn: tailscaleSignIn, + onSuccess: (r) => { + if (r.login_url) { + setLoginUrl(r.login_url); + setSignInNote(null); + openUrl(r.login_url).catch(() => {}); + } else { + // No URL needed: already signed in, or a desktop app opened the browser. + setLoginUrl(null); + setSignInNote( + "Signing in… if a browser didn't open, finish it in the Tailscale app. This updates automatically." + ); + } + }, + }); return (
@@ -519,11 +540,8 @@ function TailscaleExposure({ an automatic, publicly-trusted TLS certificate — so the URL can be saved and reused across launches, with no cert-trust step. Access is limited to your tailnet (not the public internet). Requires{" "} - - Tailscale - {" "} - installed and signed in on this machine, and every participant on the same - tailnet. + Tailscale installed and signed in on this machine, and + every participant on the same tailnet.

{loading && !status ? ( @@ -562,12 +580,48 @@ function TailscaleExposure({ {pending ? "Publishing…" : "Publish to tailnet"} + ) : status?.installed ? ( + // Installed but not signed in / not online: offer one-click sign-in. +
+
+ {status?.detail ?? "Tailscale isn't connected yet."} +
+ + {loginUrl && ( +

+ Finish signing in in your browser:{" "} + { + e.preventDefault(); + openUrl(loginUrl).catch(() => {}); + }} + > + open the sign-in page + + . This tab updates automatically once you're connected. +

+ )} + {signInNote && ( +

+ {signInNote} +

+ )} +
) : ( -
- - {status?.detail ?? - "Tailscale is not available. Install it and sign in, then reopen this tab."} - + // Not installed: link to the download. +
+
+ + {status?.detail ?? + "Tailscale isn't installed on this machine."} + +
+
)}
From 06d176c3d07de49660dd5f2053f276b6c366c809 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Thu, 6 Aug 2026 04:19:16 +0000 Subject: [PATCH 4/5] fix(ui): Tailscale on Server screen, resilient Get-Tailscale, tidy join list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a shared TailscalePanel and surface it under "Host a server here" on the 1 · Setup → Server screen, alongside the Cloudflare tunnel option. - Reuse the same panel in Session Configuration (drops the duplicated inline TailscaleExposure and its now-dead query/mutations). - Stop swallowing browser-open failures: Get Tailscale / sign-in links now surface errors and always show a copyable fallback link. - Rebuild the participant "I'm joining" URL examples as an aligned two-column grid instead of a
/  blob. Co-Authored-By: Claude Opus 4.8 --- src/components/TailscalePanel.tsx | 202 +++++++++++++++++++++++++++ src/screens/ServerSettings.tsx | 6 + src/screens/SessionSetup.tsx | 222 +++++------------------------- 3 files changed, 241 insertions(+), 189 deletions(-) create mode 100644 src/components/TailscalePanel.tsx diff --git a/src/components/TailscalePanel.tsx b/src/components/TailscalePanel.tsx new file mode 100644 index 0000000..6339a6c --- /dev/null +++ b/src/components/TailscalePanel.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + tailscaleStatus, + startTailscaleServe, + stopTailscaleServe, + tailscaleSignIn, + openUrl, + AppError, +} from "../ipc/commands"; + +const DOWNLOAD_URL = "https://tailscale.com/download"; + +function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +/** Open a URL in the system browser, surfacing failure instead of swallowing it. + * Auto-open can silently do nothing (no `xdg-open`, headless session, blocked + * handler); when it does, the caller shows the link so the user can copy it. */ +function useBrowserOpen() { + const [failedUrl, setFailedUrl] = useState(null); + const open = (url: string) => { + setFailedUrl(null); + openUrl(url).catch(() => setFailedUrl(url)); + }; + return { open, failedUrl }; +} + +/** Shared Tailscale "publish this server to your tailnet" panel, used both on the + * Server screen and in Session Configuration. Owns its own status polling and + * serve/sign-in mutations so callers just drop it in. + * + * `active` gates polling: the Session Configuration screen only wants to shell + * out to the `tailscale` CLI while its tab is showing. */ +export default function TailscalePanel({ + serverRunning, + active = true, +}: { + serverRunning: boolean; + active?: boolean; +}) { + const queryClient = useQueryClient(); + const browser = useBrowserOpen(); + const [signInNote, setSignInNote] = useState(null); + const enabled = active && serverRunning; + const status = useQuery({ + queryKey: ["tailscale"], + queryFn: tailscaleStatus, + enabled, + refetchInterval: enabled ? 5000 : false, + }); + + const serve = useMutation({ + mutationFn: startTailscaleServe, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), + }); + const stopServe = useMutation({ + mutationFn: stopTailscaleServe, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), + }); + const signIn = useMutation({ + mutationFn: tailscaleSignIn, + onSuccess: (r) => { + if (r.login_url) { + setSignInNote(null); + browser.open(r.login_url); + } else { + // No URL needed: already signed in, or a desktop app opened the browser. + setSignInNote( + "Signing in… if a browser didn't open, finish it in the Tailscale app. This updates automatically." + ); + } + }, + }); + + const s = status.data ?? null; + const serving = s?.serving ?? false; + const url = s?.public_url ?? null; + const loginUrl = signIn.data?.login_url ?? null; + + return ( +
+

+ Publishes this server to your tailnet at a{" "} + stable *.ts.net address with + an automatic, publicly-trusted TLS certificate — so the URL can be saved + and reused across launches, with no cert-trust step. Access is limited to + your tailnet (not the public internet). Requires{" "} + Tailscale installed and signed in on this machine, and + every participant on the same tailnet. +

+ + {serve.isError && ( +
{(serve.error as unknown as AppError).message}
+ )} + + {!serverRunning ? ( +

+ Start the embedded server first, then publish it to your tailnet here. +

+ ) : status.isLoading && !s ? ( +

Checking Tailscale…

+ ) : serving && url ? ( + <> +
+ serving on tailnet +
+ +
{url}
+
+ + +
+
+ + This address is stable — save it as the group's server and reuse it + next time. Participants must be on your tailnet to reach it. + +
+ + ) : s?.available ? ( + <> + {s.dns_name && ( +

+ This machine: https://{s.dns_name} +

+ )} + + + ) : s?.installed ? ( + // Installed but not signed in / not online: offer one-click sign-in. +
+
+ {s?.detail ?? "Tailscale isn't connected yet."} +
+ + {loginUrl && ( +

+ Finish signing in in your browser:{" "} + { + e.preventDefault(); + browser.open(loginUrl); + }} + > + open the sign-in page + + . This tab updates automatically once you're connected. +

+ )} + {signInNote && ( +

+ {signInNote} +

+ )} + {browser.failedUrl && ( +

+ Couldn't open your browser automatically. Copy this link:{" "} + {browser.failedUrl}{" "} + +

+ )} +
+ ) : ( + // Not installed: link to the download. +
+
+ {s?.detail ?? "Tailscale isn't installed on this machine."} +
+ +

+ {browser.failedUrl + ? "Couldn't open your browser automatically. Copy this link:" + : "Or open this link:"}{" "} + {DOWNLOAD_URL}{" "} + +

+
+ )} +
+ ); +} diff --git a/src/screens/ServerSettings.tsx b/src/screens/ServerSettings.tsx index 511f4aa..c930c3a 100644 --- a/src/screens/ServerSettings.tsx +++ b/src/screens/ServerSettings.tsx @@ -15,6 +15,7 @@ import { AppError, } from "../ipc/commands"; import { useTauriEvent } from "../ipc/events"; +import TailscalePanel from "../components/TailscalePanel"; function Collapsible({ title, @@ -285,6 +286,11 @@ export default function ServerSettings() { )} + +

+ Tailnet access (Tailscale) +

+ queryClient.invalidateQueries({ queryKey: ["tunnel"] }), }); - const openTailscale = useMutation({ - mutationFn: startTailscaleServe, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), - onError: (e) => setError((e as unknown as AppError).message), - }); - const closeTailscale = useMutation({ - mutationFn: stopTailscaleServe, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }), - }); - const running = sidecar.data?.running; const port = sidecar.data?.port ?? 2744; @@ -305,20 +282,9 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) {

))} - {exposure === "tailscale" && - (running ? ( - openTailscale.mutate()} - onStop={() => closeTailscale.mutate()} - pending={openTailscale.isPending} - /> - ) : ( -

- Start the server (Step 1), then publish it to your tailnet here. -

- ))} + {exposure === "tailscale" && ( + + )} {exposure === "nginx" && } @@ -497,137 +463,6 @@ function TunnelExposure({ ); } -function TailscaleExposure({ - status, - loading, - onServe, - onStop, - pending, -}: { - status: TailscaleStatus | null; - loading: boolean; - onServe: () => void; - onStop: () => void; - pending: boolean; -}) { - const serving = status?.serving ?? false; - const url = status?.public_url ?? null; - const [loginUrl, setLoginUrl] = useState(null); - const [signInNote, setSignInNote] = useState(null); - - const signIn = useMutation({ - mutationFn: tailscaleSignIn, - onSuccess: (r) => { - if (r.login_url) { - setLoginUrl(r.login_url); - setSignInNote(null); - openUrl(r.login_url).catch(() => {}); - } else { - // No URL needed: already signed in, or a desktop app opened the browser. - setLoginUrl(null); - setSignInNote( - "Signing in… if a browser didn't open, finish it in the Tailscale app. This updates automatically." - ); - } - }, - }); - - return ( -
-

- Publishes this server to your tailnet at a{" "} - stable *.ts.net address with - an automatic, publicly-trusted TLS certificate — so the URL can be saved - and reused across launches, with no cert-trust step. Access is limited to - your tailnet (not the public internet). Requires{" "} - Tailscale installed and signed in on this machine, and - every participant on the same tailnet. -

- - {loading && !status ? ( -

Checking Tailscale…

- ) : serving && url ? ( - <> -
- serving on tailnet -
- -
{url}
-
- - -
-
- - This address is stable — save it as the group's server and reuse it - next time. Participants must be on your tailnet to reach it. - -
- - ) : status?.available ? ( - <> - {status.dns_name && ( -

- This machine:{" "} - https://{status.dns_name} -

- )} - - - ) : status?.installed ? ( - // Installed but not signed in / not online: offer one-click sign-in. -
-
- {status?.detail ?? "Tailscale isn't connected yet."} -
- - {loginUrl && ( -

- Finish signing in in your browser:{" "} - { - e.preventDefault(); - openUrl(loginUrl).catch(() => {}); - }} - > - open the sign-in page - - . This tab updates automatically once you're connected. -

- )} - {signInNote && ( -

- {signInNote} -

- )} -
- ) : ( - // Not installed: link to the download. -
-
- - {status?.detail ?? - "Tailscale isn't installed on this machine."} - -
- -
- )} -
- ); -} - function NginxExposure({ port }: { port: number }) { const conf = useMemo( () => @@ -749,26 +584,35 @@ function ParticipantPath() { placeholder="https://…" style={{ width: "100%" }} /> -
- Paste the address the coordinator is sharing right now. It looks like one - of: -
- • https://frost.example.com{" "} -  — a domain / NGINX server -
- • https://203.0.113.7:2744{" "} -  — a direct IP and port -
- • https://long-random-words.trycloudflare.com{" "} -  — a Cloudflare tunnel -
- • https://their-machine.tailnet.ts.net{" "} -  — a Tailscale address (you must be on the same tailnet) -
- A Cloudflare tunnel URL is disposable: the coordinator - gets a new one each time they restart it, so always use the latest. A - Tailscale .ts.net address is{" "} - stable — save it once and reuse it. +
+

+ Paste the address the coordinator is sharing right now. It looks like + one of: +

+
+ https://frost.example.com + a domain / NGINX server + https://203.0.113.7:2744 + a direct IP and port + https://long-random-words.trycloudflare.com + a Cloudflare tunnel + https://their-machine.tailnet.ts.net + a Tailscale address (you must be on the same tailnet) +
+

+ A Cloudflare tunnel URL is disposable: the coordinator + gets a new one each time they restart it, so always use the latest. A + Tailscale .ts.net address is{" "} + stable — save it once and reuse it. +

From 614bb764c8ae6361e0cb877772f4ec41900c8c4d Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sat, 15 Aug 2026 23:41:07 +0000 Subject: [PATCH 5/5] docs(uat): cover log viewer + this turn's Tailscale fixes - Part B: note the two Tailscale entry points (Session Config + Server screen); add copyable-link fallback checks to B1a/B1b (the Get-Tailscale "nothing happens" fix); new B1c for the Server-screen Tailscale sub-section; new B8 for the cleaned-up participant "I'm joining" URL list. - New Part C: in-app Diagnostics log card (Copy all / Refresh / Clear / Live), live-update, persistence boundary, and a no-secrets spot-check. Steps verified against the actual UI (LogsCard labels, TailscalePanel states). Co-Authored-By: Claude Opus 4.8 --- docs/UAT.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/UAT.md b/docs/UAT.md index 282e60e..2e1b4a2 100644 --- a/docs/UAT.md +++ b/docs/UAT.md @@ -87,8 +87,12 @@ and the mapping is cleaned up correctly. `serve` is tailnet-only (not public). - [ ] (For B6) a device **not** on the tailnet, to confirm scoping. ## B1. Detection states (before serving) -Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance -matches reality: +The Tailscale panel now appears in **two** places (same component, same +behavior): **Zcash → Session Configuration → Coordinator → Tailscale** tab, and +**1 · Setup → Server → Host a server here → Tailnet access (Tailscale)**. Run B1 +on the Session Configuration tab; B1c re-checks the Server-screen copy. + +Verify the guidance matches reality: - [ ] **Signed in & online** → tab shows this machine's `https://.ts.net` and a **"Publish to tailnet"** button. - [ ] **Signed out** (`tailscale logout`) → shows a "not connected" message and a @@ -101,6 +105,12 @@ matches reality: ## B1a. Get Tailscale (not-installed friction) - [ ] On a machine without Tailscale, click **Get Tailscale** → the system default browser opens `https://tailscale.com/download` (not an in-app webview). +- [ ] The download URL is **also shown as copyable text** beside the button + (`Copy link` works), so it's reachable even if the browser didn't open. +- [ ] **Fallback path:** if the browser does **not** open automatically, the line + reads "Couldn't open your browser automatically…" — not silent (the earlier + bug was that clicking did nothing). Copy the link and it opens Tailscale's + download page. - [ ] Install Tailscale, then reopen the tab → it now shows the **Sign in** state (installed, not yet connected). @@ -111,9 +121,23 @@ matches reality: - [ ] Complete auth in the browser; within a few seconds the tab **auto-updates** to the signed-in state (shows the `.ts.net` name + Publish button) with no manual refresh. +- [ ] If the browser can't be opened automatically, a **"Couldn't open your + browser automatically. Copy this link:"** line with the login URL + a + `Copy link` button appears (no silent no-op). - [ ] (Linux note) If `tailscale up` needs elevated rights on this host, the tab surfaces that instead of hanging — the operator/sudo message is shown. +## B1c. Second entry point — Server screen +- [ ] Go to **1 · Setup → Server**, expand **Host a server here**. Under the + Cloudflare tunnel section there is a **"Tailnet access (Tailscale)"** + sub-section. +- [ ] It shows the **same** state as the Session Configuration tab did in B1 + (not-installed / signed-out / ready-to-publish), driven by the same status. +- [ ] With the embedded server **not** started, it prompts to start the server + first; with it started, the Publish/Sign-in/Get-Tailscale action matches B1. +- [ ] Publishing from **either** screen and stopping from the other stays + consistent (one shared serve mapping, not two). + ## B2. Happy path — publish - [ ] Start the embedded server (Step 1). - [ ] Tailscale tab → **Publish to tailnet** → badge **"serving on tailnet"** and @@ -150,11 +174,61 @@ matches reality: - [ ] With serve up and a participant joined via the `.ts.net` URL, run a real **signing** (or DKG) ceremony to completion over the tailnet transport. +## B8. Participant "I'm joining" URL list (formatting) +- [ ] As a **participant**: Zcash → Session Configuration → **I'm joining** → + "Connect to the coordinator's server". +- [ ] The four example addresses (domain, direct IP, Cloudflare, Tailscale) render + as a **clean two-column list** — example URLs in the left column, their + descriptions aligned in the right — not a run-on line with `•`/stray spacing + (the earlier messy layout). +- [ ] The block reads correctly at a narrow window width (no horizontal overflow, + descriptions stay aligned). + ## B — Sign-off - [ ] B1a/B1b: **Get Tailscale** opens the download page and **Sign in** drives - `tailscale up` to a connected state, with the tab auto-updating. + `tailscale up` to a connected state, with the tab auto-updating; the + copyable-link fallback shows when the browser can't be opened. +- [ ] B1c: the Server-screen Tailscale sub-section mirrors the Session + Configuration tab and shares one serve mapping. - [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit. - [ ] B6 confirms tailnet-only scoping. - [ ] B7 completes a real ceremony over the transport. +- [ ] B8: the participant join-URL list is cleanly aligned. - [ ] Note the Tailscale CLI version tested here: ____________ (so we know which `serve` grammar was validated). + +--- + +# Part C — In-app log viewer (on `main`; present on both branches) + +Goal: the app captures its own `tracing` output to an in-memory buffer and shows +it in the UI, so a tester can copy logs and share them back without hunting for a +terminal. Bounded (~3000 lines), in-memory only, cleared on restart. Lives at +**Zcash → Wallet Settings**, the **"Diagnostics log"** card near the bottom. + +## C1. Card shows live output +- [ ] Open **Zcash → Wallet Settings** and find the **Diagnostics log** card. +- [ ] It already contains startup lines (the buffer captures from app start, so + it is not empty on first open). The header shows an **"N lines · this + session"** count. +- [ ] The **Live** toggle is on by default: do something that logs — e.g. **Sync + Now** on the active wallet — and within a couple of seconds new lines appear + **without** clicking anything. **Refresh** forces an immediate update. +- [ ] Lines are oldest-first; the view stays pinned to the newest line while Live, + unless you scroll up to read older output. +- [ ] Un-checking **Live** stops the auto-updates (count holds until Refresh). + +## C2. Copy & clear +- [ ] **Copy all** → button flips to "Copied!"; paste into a scratch file and + confirm it matches the text shown (Copy all is disabled when empty). +- [ ] **Clear** empties the buffer (button disabled when already empty); + subsequent activity repopulates it. + +## C3. Persistence boundary +- [ ] Fully quit and relaunch Cyze → the card starts fresh (in-memory only, not + persisted across runs). Only new-session lines are present. + +## C — Sign-off +- [ ] Card is populated from app start, auto-updates on activity while Live, and + Copy all / Refresh / Clear work. Buffer resets on relaunch. Spot-check the + captured lines expose nothing sensitive (no passphrases / key material).