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/docs/UAT.md b/docs/UAT.md new file mode 100644 index 0000000..2e1b4a2 --- /dev/null +++ b/docs/UAT.md @@ -0,0 +1,234 @@ +# 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) +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 + **"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" 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). +- [ ] 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). + +## 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. +- [ ] 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 + 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. + +## 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; 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). diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 17a7709..88a6cc4 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] @@ -200,9 +201,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 } @@ -236,6 +238,79 @@ 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) +} + +/// Trigger `tailscale up`; returns a login URL to open when authentication is +/// needed (the poll then flips to available once sign-in completes). +#[tauri::command] +pub async fn tailscale_sign_in() -> 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(()) +} + /// The in-memory application log (oldest line first), for display + copy in the /// app. Captured from `tracing` since app start; bounded and not persisted. #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f84ddbb..c608103 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; pub mod logbuf; pub mod sidecar; pub mod state; +pub mod tailscale; pub mod tunnel; use state::AppState; @@ -111,6 +112,11 @@ 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::server::tailscale_sign_in, + commands::server::open_url, commands::server::get_logs, commands::server::clear_logs, commands::dkg::start_dkg, @@ -136,6 +142,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 7f8cc3b..43e9ddc 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -126,6 +126,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>, @@ -158,6 +161,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()), sync_gate: Mutex::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..24d34fe --- /dev/null +++ b/src-tauri/src/tailscale.rs @@ -0,0 +1,445 @@ +//! 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 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. + 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 { + installed: false, + available: false, + serving: false, + public_url: None, + port: None, + dns_name: None, + detail: Some(detail.into()), + } + } +} + +/// 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). +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 { + installed: true, + 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(()) +} + +/// 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`. +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; + } + }; + + // 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, + port, + dns_name: Some(dns_name), + detail: None, + }, + Ok(Err(reason)) => { + let mut s = TailscaleStatus::unavailable(reason); + s.installed = true; + s.serving = serving; + s.public_url = public_url; + s.port = port; + s + } + Err(e) => { + let mut s = TailscaleStatus::unavailable(e.message); + s.installed = true; + 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); + } + + #[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/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/ipc/commands.ts b/src/ipc/commands.ts index 4efc01c..9daf048 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; @@ -67,6 +67,24 @@ export interface TunnelStatus { port: number | null; } +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. */ + 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). */ @@ -349,6 +367,19 @@ 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"); +/** 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 }); /** The in-app application log (oldest line first), captured from tracing since * app start. In-memory and bounded; cleared on restart. */ export const getLogs = () => invoke("get_logs"); 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: ["sidecar"] }); queryClient.invalidateQueries({ queryKey: ["tunnel"] }); + queryClient.invalidateQueries({ queryKey: ["tailscale"] }); }, }); const openTunnel = useMutation({ @@ -203,7 +205,6 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) { mutationFn: stopTunnel, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tunnel"] }), }); - const running = sidecar.data?.running; const port = sidecar.data?.port ?? 2744; @@ -245,6 +246,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 +282,10 @@ function CoordinatorPath({ savedExposure }: { savedExposure: string | null }) {

))} + {exposure === "tailscale" && ( + + )} + {exposure === "nginx" && } @@ -578,21 +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 -
- A Cloudflare tunnel URL is disposable: the coordinator - gets a new one each time they restart it, so always use the latest. +
+

+ 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. +

@@ -609,8 +629,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.