From 5806666cdfe95bd1f09be4d6fc5570c778116ec2 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 22:27:28 -0700 Subject: [PATCH 1/9] Add tokenless OAuth tunnel connection flow --- crates/cli/src/app.rs | 141 ++++++++++++++++++--- crates/cli/src/ui.rs | 97 ++++++++++++-- crates/daemon/src/remote.rs | 28 +++++ crates/daemon/src/remote_supervisor.rs | 1 + crates/daemon/src/session.rs | 16 ++- crates/daemon/src/tunnel/construct.rs | 160 +++++++++++++++++++++--- crates/daemon/src/tunnel/mod.rs | 6 +- crates/e2e/tests/tui_smoke.rs | 2 +- crates/protocol/src/lib.rs | 7 +- docs/remote-control.md | 20 +-- specs/0095-first-party-named-tunnels.md | 7 +- 11 files changed, 420 insertions(+), 65 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index d2ef4c59..1789ed59 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2747,6 +2747,7 @@ impl ProgramSmartClipGroup { #[derive(Debug, Clone)] pub enum RemoteControlPopup { Choose(RemoteControlChoose), + Name(RemoteControlName), Starting(RemoteControlOk), Ok { ok: RemoteControlOk, @@ -2759,6 +2760,15 @@ pub enum RemoteControlPopup { }, } +#[derive(Debug, Clone)] +pub struct RemoteControlName { + pub choose: RemoteControlChoose, + pub name: String, + /// The generated suggestion is selected conceptually: the first typed + /// character replaces it, while Enter accepts it unchanged. + pub pristine: bool, +} + /// The two buttons on the tunnel-ready view. `Back` is the default /// focus so a reflexive Enter is non-destructive — it returns to the /// chooser without killing the tunnel. @@ -2834,6 +2844,8 @@ pub struct RemoteControlOk { /// LAN URL, when this machine has a private address. `None` on a /// machine with no local network to be reached from. pub lan_url: Option, + /// Browser login link while tunnel.zarvis.ai waits for OAuth. + pub auth_url: Option, } impl From for RemoteControlOk { @@ -2847,6 +2859,7 @@ impl From for RemoteControlOk { provider: r.provider, local_url: r.local_url, lan_url: r.lan_url, + auth_url: r.auth_url, } } } @@ -10193,16 +10206,13 @@ impl App { ).await } "construct" | "zarvis" => { - let subdomain = (!rest.is_empty()).then(|| rest.to_string()); - if subdomain.is_none() { - self.set_status( - "usage: /remote-control construct ".to_string(), - ); + if rest.is_empty() { + self.open_remote_control_popup(None).await; } else { self.start_remote_control_provider( TunnelProvider::Construct, None, - subdomain, + Some(rest.to_string()), ).await; } } @@ -10400,6 +10410,23 @@ impl App { } async fn poll_remote_control_task(&mut self) { + let needs_auth_url = matches!( + self.remote_control_popup.as_ref(), + Some(RemoteControlPopup::Starting(ok)) if ok.auth_url.is_none() + ); + if needs_auth_url { + if let Ok(snapshot) = self + .client + .remote_start(construct_protocol::TunnelProvider::None, None) + .await + { + if let Some(RemoteControlPopup::Starting(ok)) = + self.remote_control_popup.as_mut() + { + ok.auth_url = snapshot.auth_url; + } + } + } let Some(task) = self.remote_control_task.as_mut() else { return; }; @@ -10464,11 +10491,55 @@ impl App { ); return true; } - self.start_remote_control_provider( - opt.provider, - None, - std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), - ).await; + if opt.provider == construct_protocol::TunnelProvider::Construct { + self.remote_control_popup = Some(RemoteControlPopup::Name( + RemoteControlName { + choose: choose.clone(), + name: generated_tunnel_name(), + pristine: true, + }, + )); + } else { + self.start_remote_control_provider(opt.provider, None, None).await; + } + } + _ => {} + }, + RemoteControlPopup::Name(name) => match key.code { + KeyCode::Esc => { + self.remote_control_popup = + Some(RemoteControlPopup::Choose(name.choose.clone())); + } + KeyCode::Enter => { + let tunnel_name = name.name.clone(); + if tunnel_name.is_empty() { + self.set_status("enter a tunnel name".into()); + } else { + self.start_remote_control_provider( + construct_protocol::TunnelProvider::Construct, + None, + Some(tunnel_name), + ).await; + } + } + KeyCode::Backspace => { + if name.pristine { + name.name.clear(); + name.pristine = false; + } else { + name.name.pop(); + } + } + KeyCode::Char(c) + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' => + { + if name.pristine { + name.name.clear(); + name.pristine = false; + } + if name.name.len() < 63 { + name.name.push(c); + } } _ => {} }, @@ -10497,11 +10568,18 @@ impl App { }, _ => {} }, - RemoteControlPopup::Starting(_) => { - if matches!(key.code, KeyCode::Esc) { - self.close_remote_control_popup(); + RemoteControlPopup::Starting(starting) => match key.code { + KeyCode::Esc => self.close_remote_control_popup(), + KeyCode::Char('o') => { + if let Some(url) = starting.auth_url.as_deref() { + match open_url(url) { + Ok(()) => self.set_status("opened tunnel login in browser".into()), + Err(error) => self.set_status(format!("could not open login: {error}")), + } + } } - } + _ => {} + }, } true } @@ -10557,6 +10635,26 @@ impl App { } } +fn generated_tunnel_name() -> String { + const ADJECTIVES: &[&str] = &[ + "bright", "calm", "clever", "gentle", "lucky", "quiet", "swift", "warm", + ]; + const NOUNS: &[&str] = &[ + "badger", "comet", "falcon", "maple", "otter", "panda", "river", "willow", + ]; + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as usize) + .unwrap_or(0) + ^ std::process::id() as usize; + format!( + "{}-{}-{:02}", + ADJECTIVES[seed % ADJECTIVES.len()], + NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()], + (seed / (ADJECTIVES.len() * NOUNS.len())) % 100 + ) +} + /// Best-effort one-line summary of a tool call's args JSON for the /// PTY tool-block header. Prefers a single salient field /// (`command` for shell, `path` for read_file, `query` for search- @@ -11758,6 +11856,19 @@ mod tests { use super::*; use ratatui::layout::Rect; + #[test] + fn generated_tunnel_name_is_human_friendly_and_dns_safe() { + let name = generated_tunnel_name(); + let parts: Vec<&str> = name.split('-').collect(); + assert_eq!(parts.len(), 3, "expected adjective-noun-number: {name}"); + assert_eq!(parts[2].len(), 2, "expected two-digit suffix: {name}"); + assert!( + name.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'), + "name is not DNS safe: {name}" + ); + } + /// Spec 0065 agent presence: the local receipt clock must renew only when /// the daemon's `updated_at_ms` genuinely advances, never on a rebase /// that leaves it unchanged (GAP D — a rebase must not re-trigger the diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index e3de1e1d..e3b5a8d0 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15381,8 +15381,13 @@ fn render_remote_control_popup(f: &mut Frame, app: &mut App) { let total = f.area(); let (title, title_color, body_lines, body_w, body_h) = match popup { - crate::app::RemoteControlPopup::Choose(c) => render_remote_choose(app, c, total.width), - crate::app::RemoteControlPopup::Starting(p) => render_remote_starting(app, p, total.width), + crate::app::RemoteControlPopup::Choose(c) => { + render_remote_choose(app, c, total.width, total.height) + } + crate::app::RemoteControlPopup::Name(n) => render_remote_name(app, n, total.width), + crate::app::RemoteControlPopup::Starting(p) => { + render_remote_starting(app, p, total.width) + } crate::app::RemoteControlPopup::Ok { ok, focus } => { render_remote_ok(app, ok, *focus, total.width) } @@ -15617,6 +15622,7 @@ fn render_remote_choose<'a>( app: &App, c: &crate::app::RemoteControlChoose, area_w: u16, + area_h: u16, ) -> RemotePopupBody<'a> { let mut info = remote_info_lines(app, &c.base); @@ -15709,9 +15715,57 @@ fn render_remote_choose<'a>( Style::default().fg(app.theme.dim), ))); - let (lines, width, height) = compose_qr_and_info(&c.base.qr, info, area_w); + // On a short terminal the local-network QR would push the provider row + // below the clipped modal. Keep local details textual there; the final + // public tunnel URL still gets its QR in the ready view. + let qr = if area_h >= 30 { &c.base.qr } else { "" }; + let (lines, width, height) = compose_qr_and_info(qr, info, area_w); ( - " /remote-control — local network — Esc to close ".to_string(), + " /remote-connect — local network — Esc to close ".to_string(), + app.theme.info, + lines, + width, + height, + ) +} + +fn render_remote_name<'a>( + app: &App, + state: &crate::app::RemoteControlName, + area_w: u16, +) -> RemotePopupBody<'a> { + let mut info = remote_info_lines(app, &state.choose.base); + info.push(Line::raw("")); + info.push(Line::from(Span::styled( + "Choose your tunnel name:", + Style::default().fg(app.theme.text), + ))); + info.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!(" {} ", state.name), + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::REVERSED | Modifier::BOLD), + ), + Span::styled( + "..tunnel.zarvis.ai", + Style::default().fg(app.theme.dim), + ), + ])); + info.push(Line::raw("")); + let guidance = if state.pristine { + "Type to replace the suggestion · Enter continue · Esc back" + } else { + "Lowercase letters, numbers, hyphens · Enter continue · Esc back" + }; + info.extend(wrapped_lines(guidance, Style::default().fg(app.theme.dim))); + + // This is an input step, so the editable name always takes precedence + // over the already-available LAN QR. + let (lines, width, height) = compose_qr_and_info("", info, area_w); + ( + " /remote-connect — tunnel.zarvis.ai — name your tunnel ".to_string(), app.theme.info, lines, width, @@ -15735,14 +15789,33 @@ fn render_remote_starting<'a>( .fg(app.theme.warning) .add_modifier(Modifier::BOLD), ))); - info.push(Line::from(Span::styled( - "The QR updates when it publishes a URL.", - Style::default().fg(app.theme.dim), - ))); + if let Some(auth_url) = p.auth_url.as_deref() { + info.push(Line::from(Span::styled( + "Finish signing in in your browser:", + Style::default().fg(app.theme.text), + ))); + info.extend(wrapped_lines(auth_url, Style::default().fg(app.theme.accent))); + info.push(Line::from(Span::styled( + "o open login again · Esc close", + Style::default().fg(app.theme.dim), + ))); + } else { + info.push(Line::from(Span::styled( + "Opening browser login… The QR updates when the tunnel is ready.", + Style::default().fg(app.theme.dim), + ))); + } - let (lines, width, height) = compose_qr_and_info(&p.qr, info, area_w); + // OAuth is the action the user must take now. A local-network QR is + // unrelated and can hide the login link in compact terminals. + let qr = if p.provider == construct_protocol::TunnelProvider::Construct { + "" + } else { + &p.qr + }; + let (lines, width, height) = compose_qr_and_info(qr, info, area_w); ( - format!(" /remote-control — starting {label}… — Esc to close "), + format!(" /remote-connect — starting {label}… — Esc to close "), app.theme.warning, lines, width, @@ -15799,7 +15872,7 @@ fn render_remote_ok<'a>( ))); let label = p.provider.label(); - let title = format!(" /remote-control — {label} ready (public URL) — Esc to close "); + let title = format!(" /remote-connect — {label} ready (public URL) — Esc to close "); let (lines, width, height) = compose_qr_and_info(&p.qr, info, area_w); (title, app.theme.success, lines, width, height) @@ -15844,7 +15917,7 @@ fn render_remote_err<'a>( .max(keys.chars().count() as u16); let height = 4 + message.lines().count() as u16; ( - format!(" /remote-control — {label} failed — Esc to close "), + format!(" /remote-connect — {label} failed — Esc to close "), app.theme.danger, lines, width, diff --git a/crates/daemon/src/remote.rs b/crates/daemon/src/remote.rs index f4de88e6..77f9a86b 100644 --- a/crates/daemon/src/remote.rs +++ b/crates/daemon/src/remote.rs @@ -48,6 +48,10 @@ pub struct RemoteState { /// override via `/remote-control ` to set their own. password: Arc, tunnel_url: Arc>>, + /// Ephemeral browser authorization URL for an interactive tunnel + /// start. Unlike the tunnel URL, this is deliberately never included + /// in RemoteSnapshot or written to disk. + auth_url: Arc>>, /// Active remote WS connection count. Bumped on accept, /// decremented when the connection task drops. Read by the /// `remote/state` broadcast path on every change so local @@ -182,6 +186,7 @@ impl RemoteState { token: Arc::new(token), password: Arc::new(password), tunnel_url: Arc::new(RwLock::new(None)), + auth_url: Arc::new(RwLock::new(None)), clients: Arc::new(AtomicUsize::new(0)), tunnel_pid: Arc::new(AtomicU32::new(0)), tunnel_provider: Arc::new(AtomicU8::new(TunnelProvider::None.as_u8())), @@ -202,6 +207,7 @@ impl RemoteState { token: Arc::new(snap.token.clone()), password: Arc::new(snap.password.clone()), tunnel_url: Arc::new(RwLock::new(snap.tunnel_url.clone())), + auth_url: Arc::new(RwLock::new(None)), clients: Arc::new(AtomicUsize::new(0)), tunnel_pid: Arc::new(AtomicU32::new(snap.tunnel_pid)), tunnel_provider: Arc::new(AtomicU8::new(snap.tunnel_provider.as_u8())), @@ -396,6 +402,14 @@ impl RemoteState { pub async fn tunnel_url(&self) -> Option { self.tunnel_url.read().await.clone() } + + pub async fn set_auth_url(&self, url: Option) { + *self.auth_url.write().await = url; + } + + pub async fn auth_url(&self) -> Option { + self.auth_url.read().await.clone() + } } fn unix_now() -> u64 { @@ -555,4 +569,18 @@ mod tests { s.set_tunnel_url(None).await; assert_eq!(s.tunnel_url().await, None); } + + #[tokio::test] + async fn browser_auth_url_is_ephemeral_and_not_snapshotted() { + let s = RemoteState::new(); + s.set_auth_url(Some("https://tunnel.zarvis.ai/auth/device/example".into())) + .await; + assert_eq!( + s.auth_url().await.as_deref(), + Some("https://tunnel.zarvis.ai/auth/device/example") + ); + let snapshot = s.snapshot().await; + let restored = RemoteState::from_snapshot(&snapshot); + assert_eq!(restored.auth_url().await, None); + } } diff --git a/crates/daemon/src/remote_supervisor.rs b/crates/daemon/src/remote_supervisor.rs index f7e3c5df..82c11f4d 100644 --- a/crates/daemon/src/remote_supervisor.rs +++ b/crates/daemon/src/remote_supervisor.rs @@ -378,6 +378,7 @@ async fn handle_stop_tunnel( // looks LAN-only, and a subsequent start re-spawns a fresh // tunnel rather than short-circuiting on the dead URL. state.set_tunnel_url(None).await; + state.set_auth_url(None).await; state.set_tunnel_pid(0).await; state.set_tunnel_provider(TunnelProvider::None).await; } diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 86d3397b..ca028408 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1948,15 +1948,22 @@ impl SessionManager { local_url, lan_url, active_provider, + auth_url: state.auth_url().await, }); } - // Provider mode: poll the shared tunnel-url slot. 15s covers a - // cold cloudflared start (1–3s) plus slack for a slow network. + // Provider mode: poll the shared tunnel-url slot. Browser OAuth is + // intentionally allowed much longer than a subprocess-only provider: + // the user may need to switch windows or complete MFA. // We poll instead of wiring a notifier because the call shape is // request/reply over IPC — the caller is already blocked on this // future. - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let wait_seconds = if provider == TunnelProvider::Construct { + 10 * 60 + } else { + 15 + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(wait_seconds); loop { if let Some(u) = state.tunnel_url().await { let qr = crate::remote::render_qr_dense1x2(&u).unwrap_or_default(); @@ -1970,6 +1977,7 @@ impl SessionManager { local_url, lan_url, active_provider: provider, + auth_url: None, }); } if tokio::time::Instant::now() >= deadline { @@ -1992,7 +2000,7 @@ impl SessionManager { match crate::tunnel::preflight(provider).await { Err(detail) => anyhow::bail!("{detail}"), Ok(()) => anyhow::bail!( - "{label} started but published no URL within 15s. Check the daemon log \ + "{label} started but published no URL within {wait_seconds}s. Check the daemon log \ (RUST_LOG=info,construct=debug) for its output." ), } diff --git a/crates/daemon/src/tunnel/construct.rs b/crates/daemon/src/tunnel/construct.rs index 4057a181..05c76b6f 100644 --- a/crates/daemon/src/tunnel/construct.rs +++ b/crates/daemon/src/tunnel/construct.rs @@ -35,6 +35,21 @@ struct Registration { expires_in_seconds: u64, } +#[derive(Deserialize)] +struct AuthRequest { + verification_url: String, + poll_url: String, + poll_token: String, + expires_in_seconds: u64, + interval_seconds: u64, +} + +#[derive(Deserialize)] +struct AuthPoll { + #[serde(default)] + owner_token: Option, +} + pub fn preflight() -> Result<(), String> { let binary = binary(); if which::which(&binary).is_err() { @@ -42,11 +57,6 @@ pub fn preflight() -> Result<(), String> { "{binary} is not on PATH — install wstunnel or set CONSTRUCT_WSTUNNEL_BIN" )); } - if std::env::var("CONSTRUCT_TUNNEL_OWNER_TOKEN").is_err() { - return Err( - "sign in at tunnel.zarvis.ai, then set CONSTRUCT_TUNNEL_OWNER_TOKEN".to_string(), - ); - } Ok(()) } @@ -57,20 +67,13 @@ pub async fn run_once( ) -> Result<()> { let subdomain = requested_subdomain .map(str::to_owned) - .or_else(|| std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok()) - .ok_or_else(|| { - anyhow!( - "a subdomain is required; use `/remote-control construct ` or set \ - CONSTRUCT_TUNNEL_SUBDOMAIN" - ) - })?; + .ok_or_else(|| anyhow!("a tunnel name is required; choose one in `/remote-connect`"))?; validate_subdomain(&subdomain)?; - let owner_token = std::env::var("CONSTRUCT_TUNNEL_OWNER_TOKEN") - .context("CONSTRUCT_TUNNEL_OWNER_TOKEN is not set")?; let api_url = std::env::var("CONSTRUCT_TUNNEL_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); let http = reqwest::Client::new(); + let owner_token = authorize(&http, remote, &api_url).await?; let registration = http .post(&api_url) .bearer_auth(&owner_token) @@ -128,12 +131,8 @@ pub async fn run_once( let public_url = normalize_public_url(®istration.public_url)?; let ready_url = registration.ready_url; let tunnel_token = registration.tunnel_token; - let refresh_after = Duration::from_secs( - registration - .expires_in_seconds - .saturating_sub(60) - .max(1), - ); + let refresh_after = + Duration::from_secs(registration.expires_in_seconds.saturating_sub(60).max(1)); let readiness = async { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { @@ -181,6 +180,108 @@ pub async fn run_once( Ok(()) } +async fn authorize( + http: &reqwest::Client, + remote: &RemoteState, + tunnel_api_url: &str, +) -> Result { + let auth_api_url = auth_api_url(tunnel_api_url)?; + let request = http + .post(auth_api_url) + .send() + .await + .context("start tunnel.zarvis.ai login")? + .error_for_status() + .context("tunnel.zarvis.ai rejected login request")? + .json::() + .await + .context("decode tunnel.zarvis.ai login request")?; + + let verification_url = validate_https_url(&request.verification_url)?; + remote.set_auth_url(Some(verification_url.clone())).await; + tracing::info!(url = %verification_url, "authorize tunnel.zarvis.ai in a browser"); + if let Err(error) = open_browser(&verification_url) { + tracing::info!(%error, url = %verification_url, "could not open login browser; showing URL in remote-connect dialog"); + } + + let interval = Duration::from_secs(request.interval_seconds.clamp(1, 10)); + let deadline = tokio::time::Instant::now() + + Duration::from_secs(request.expires_in_seconds.clamp(1, 10 * 60)); + let result = async { + loop { + let response = match http + .get(&request.poll_url) + .bearer_auth(&request.poll_token) + .send() + .await + { + Ok(response) => response, + Err(error) if tokio::time::Instant::now() < deadline => { + tracing::debug!(%error, "login poll failed; retrying"); + tokio::time::sleep(interval).await; + continue; + } + Err(error) => break Err(error).context("poll tunnel.zarvis.ai login"), + }; + if response.status() == reqwest::StatusCode::ACCEPTED { + if tokio::time::Instant::now() >= deadline { + break Err(anyhow!("tunnel.zarvis.ai login expired; start again")); + } + tokio::time::sleep(interval).await; + continue; + } + let poll = response + .error_for_status() + .context("tunnel.zarvis.ai login failed")? + .json::() + .await + .context("decode tunnel.zarvis.ai login result")?; + match poll.owner_token { + Some(token) if !token.is_empty() => break Ok(token), + _ => break Err(anyhow!("tunnel.zarvis.ai login omitted authorization")), + } + } + } + .await; + remote.set_auth_url(None).await; + result +} + +fn auth_api_url(tunnel_api_url: &str) -> Result { + let mut url = + reqwest::Url::parse(tunnel_api_url).context("invalid Construct tunnel API URL")?; + let path = url.path().trim_end_matches('/'); + let prefix = path + .strip_suffix("/tunnels") + .ok_or_else(|| anyhow!("Construct tunnel API URL must end in /tunnels"))?; + url.set_path(&format!("{prefix}/auth/requests")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]); + command + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = Command::new("xdg-open"); + + command + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("open browser for {url}"))?; + Ok(()) +} + fn binary() -> String { std::env::var("CONSTRUCT_WSTUNNEL_BIN").unwrap_or_else(|_| "wstunnel".to_string()) } @@ -208,6 +309,14 @@ fn normalize_public_url(value: &str) -> Result { Ok(format!("{}/", value.trim_end_matches('/'))) } +fn validate_https_url(value: &str) -> Result { + let url = reqwest::Url::parse(value).context("service returned an invalid HTTPS URL")?; + if url.scheme() != "https" || url.host_str().is_none() { + anyhow::bail!("service returned a non-HTTPS URL"); + } + Ok(value.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +339,15 @@ mod tests { ); assert!(normalize_public_url("http://demo.example").is_err()); } + + #[test] + fn auth_endpoint_is_derived_from_tunnel_endpoint() { + assert_eq!( + auth_api_url("https://tunnel.zarvis.ai/api/v1/tunnels") + .unwrap() + .as_str(), + "https://tunnel.zarvis.ai/api/v1/auth/requests" + ); + assert!(auth_api_url("https://tunnel.zarvis.ai/wrong").is_err()); + } } diff --git a/crates/daemon/src/tunnel/mod.rs b/crates/daemon/src/tunnel/mod.rs index 363f7d44..31614884 100644 --- a/crates/daemon/src/tunnel/mod.rs +++ b/crates/daemon/src/tunnel/mod.rs @@ -37,10 +37,7 @@ use construct_protocol::{RemoteProviderInfo, TunnelProvider}; use crate::remote::{process_alive, RemoteState}; /// Providers the dialog offers, in the order it offers them. -pub const PROVIDERS: [TunnelProvider; 2] = [ - TunnelProvider::Cloudflare, - TunnelProvider::Construct, -]; +pub const PROVIDERS: [TunnelProvider; 2] = [TunnelProvider::Construct, TunnelProvider::Cloudflare]; /// Probe every provider. Read-only — nothing is spawned, so the /// dialog can call this on every open without side effects. @@ -116,6 +113,7 @@ pub async fn run( "adopted tunnel exited; spawning fresh" ); remote.set_tunnel_url(None).await; + remote.set_auth_url(None).await; remote.set_tunnel_pid(0).await; } diff --git a/crates/e2e/tests/tui_smoke.rs b/crates/e2e/tests/tui_smoke.rs index b3ad39b9..f60eba07 100644 --- a/crates/e2e/tests/tui_smoke.rs +++ b/crates/e2e/tests/tui_smoke.rs @@ -84,7 +84,7 @@ async fn tui_remote_control_popup_via_palette() { // depend on the popup's internal alignment (the labels are // padded with spaces so a `user: remote` literal would // mismatch). The popup title is itself a useful needle. - tui.wait_for("/remote-control", Duration::from_secs(15)) + tui.wait_for("/remote-connect", Duration::from_secs(15)) .await .expect("popup title never appeared"); tui.wait_for("user:", Duration::from_secs(5)) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f6e18801..dbf61c50 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2614,7 +2614,7 @@ impl TunnelProvider { match self { TunnelProvider::None => "local network", TunnelProvider::Cloudflare => "Cloudflare", - TunnelProvider::Construct => "Construct", + TunnelProvider::Construct => "tunnel.zarvis.ai", } } } @@ -2729,6 +2729,11 @@ pub struct RemoteStartResult { /// exposed. #[serde(default)] pub active_provider: TunnelProvider, + /// Short-lived browser login URL while an interactive provider is + /// waiting for authorization. It is a one-time request identifier, + /// not an owner credential. Never persisted by the daemon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_url: Option, } /// Params for the `remote.providers` IPC method. No options — the diff --git a/docs/remote-control.md b/docs/remote-control.md index bf5ad64a..99e748b6 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -14,16 +14,16 @@ from the buttons in the dialog. | Command / setting | Purpose | |---|---| +| `/remote-connect` | Guided public-tunnel flow: select `tunnel.zarvis.ai`, accept or edit a generated name, and authorize in the browser. | | `/remote-control` | Open the dialog: bind the listener, show the LAN address + QR, and offer a tunnel. No tunnel is started until you pick one. | | `/remote-control ` | Same, with a user-chosen Basic-auth password. | | `/remote-control cloudflare` | Skip the dialog and start a Cloudflare tunnel directly. | -| `/remote-control construct ` | Start an authenticated first-party tunnel at `..tunnel.zarvis.ai`. `/remote-connect` is an alias. | +| `/remote-control construct ` | Direct form of the first-party flow for a caller that already chose a name. | | `/remote-control stop` | Stop the listener + tunnel entirely and rotate credentials for the next start. | | `/remote-control debug` | Alias for `/remote-control` — kept because the plain dialog is now the local-only resting state. | | `CONSTRUCT_REMOTE_WS_PORT=` | Start the remote WebSocket listener on daemon boot for scripted/headless use. | | `CONSTRUCT_REMOTE_PROVIDER=` | Tunnel provider for the boot-time listener above. Defaults to `cloudflare`. | | `CONSTRUCT_TUNNEL_SUBDOMAIN=` | Static label for a boot-time Construct tunnel. | -| `CONSTRUCT_TUNNEL_OWNER_TOKEN=` | Short-lived owner token obtained after signing in at `tunnel.zarvis.ai`. | | `CONSTRUCT_WSTUNNEL_BIN=` | Optional `wstunnel` executable override. | | `CONSTRUCT_WEBUI_PORT=` | Override the always-on localhost web UI port. Defaults to `5746`. | @@ -44,11 +44,17 @@ Once the tunnel's QR is up, the ready view offers two buttons: and its password. A phone connected over the LAN keeps working. The **Construct** provider uses the separately installed `wstunnel` client. It -registers the selected name with the first-party service, then opens a reverse -tunnel restricted to that registration. The service publishes a stable -`..tunnel.zarvis.ai` URL only after the reverse endpoint answers. -Visitors sign in with GitHub or Google; initially, the social identity must -derive to the same user-id as the tunnel owner. +starts with a human-friendly generated name that you can replace or accept. +Construct then opens a short-lived `tunnel.zarvis.ai` browser login (and shows +the link in the dialog). After GitHub or Google OAuth succeeds, the running +daemon receives authorization directly, registers the selected name, and opens +a reverse tunnel restricted to that registration. No owner token is shown, +copied, placed in an environment variable, or written to a configuration file. + +The service publishes a stable `..tunnel.zarvis.ai` URL only +after the reverse endpoint answers. The ready view displays that URL and its QR +code. Visitors sign in with GitHub or Google; initially, the social identity +must derive to the same user-id as the tunnel owner. To turn remote control off completely — listener included — use `/remote-control stop`. diff --git a/specs/0095-first-party-named-tunnels.md b/specs/0095-first-party-named-tunnels.md index 49c899f9..2e66f080 100644 --- a/specs/0095-first-party-named-tunnels.md +++ b/specs/0095-first-party-named-tunnels.md @@ -11,6 +11,8 @@ Construct's first-party tunnel provider uses a user-selected DNS label and a det The tunnel owner authenticates before registering a name. Registration produces a short-lived capability limited to one runtime reverse endpoint. The hosted service keeps active routes only in memory and may materialize generated restriction data only on ephemeral storage; it does not persist users, tunnels, names, or access-control lists. +Owner authentication is an interactive browser handoff initiated by the running Construct daemon. The service creates a short-lived authorization request with separate high-entropy browser and polling capabilities. Construct opens the browser capability and retains the polling capability only in process memory. After social login, the polling capability returns an owner credential exactly once. The credential is consumed directly for registration and is never displayed, copied, configured through an environment variable, or written to disk. + The hosted service is deployed independently on Oracle Cloud infrastructure. It is not part of the `zarvis.ai` web deployment. DNS delegates `tunnel.zarvis.ai` and `*.tunnel.zarvis.ai` to the tunnel service's reserved public address. The same social identity that owns the tunnel is the initial authorization boundary for browser access. A visitor authenticates with GitHub or Google, and the service derives their user identifier from the provider plus immutable provider subject. Access is allowed only when that identifier equals the hostname's user-id. Sharing and persistent ACLs are non-goals until they have an explicit product design. @@ -19,6 +21,8 @@ User identifiers are an HMAC of the provider and immutable provider subject unde Requested labels use lowercase ASCII letters, digits, and interior hyphens, with the DNS 63-byte limit. Because the user-id namespace separates owners, two users may choose the same label. One owner cannot run two live tunnels at the same label; a second registration replaces or rejects the first atomically. +The interactive client pre-populates the label with a human-friendly random suggestion. The user may accept or edit it before any authorization request or public tunnel is created. + ## Reason Provider subjects are stable and do not require an identity database. HMAC prevents public provider identifiers from being recoverable from hostnames. Owner-equals-visitor authorization gives social login a precise stateless meaning without inventing an invitation system. @@ -27,7 +31,8 @@ Runtime allocation avoids deterministic TCP-port collisions. Short-lived, narrow ## Consequences -- The client needs a social-login owner token and a selected label before starting the provider. +- The client needs a selected label before starting the provider, then completes social login through a browser without exposing the owner token to the user. +- Pending authorization requests are memory-only, single-use, and expire after ten minutes. Losing service state requires starting the login flow again and grants no durable access. - The service must validate the capability on the `wstunnel` upgrade and restrict its reverse bind to the allocated endpoint. - A public hostname is not reported ready until the gateway can reach its reverse endpoint. - Service restarts may briefly interrupt tunnels, but no database restore is required; clients reconnect and register again. From 1bf18d4c7c04c09abf2050d43e7e0901b1c0adab Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 22:40:44 -0700 Subject: [PATCH 2/9] Run Construct tunnels with linked wstunnel --- .github/workflows/release.yml | 2 +- Cargo.lock | 1431 ++++++++++++++++++++++- Cargo.toml | 3 +- THIRD_PARTY_NOTICES.md | 37 + crates/daemon/Cargo.toml | 1 + crates/daemon/src/remote_supervisor.rs | 22 + crates/daemon/src/tunnel/construct.rs | 86 +- crates/daemon/src/tunnel/mod.rs | 22 +- docs/RELEASING.md | 2 +- docs/remote-control.md | 10 +- specs/0095-first-party-named-tunnels.md | 4 +- 11 files changed, 1497 insertions(+), 123 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f5959d..3e63b2ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,7 +95,7 @@ jobs: for b in $BINS; do cp "target/${{ matrix.target }}/release/$b" "$stage/" done - cp LICENSE README.md "$stage/" 2>/dev/null || true + cp LICENSE README.md THIRD_PARTY_NOTICES.md "$stage/" 2>/dev/null || true tar -czf "$stage.tar.gz" "$stage" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$stage.tar.gz" > "$stage.tar.gz.sha256" diff --git a/Cargo.lock b/Cargo.lock index 3343f1e3..c58e3bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -15,6 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -80,7 +81,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -91,14 +92,23 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" @@ -106,6 +116,57 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -152,19 +213,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bb8" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" +dependencies = [ + "futures-util", + "parking_lot", + "portable-atomic", + "tokio", +] + [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", ] [[package]] @@ -173,6 +264,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -220,9 +320,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -261,6 +361,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chromiumoxide" version = "0.7.0" @@ -268,7 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f" dependencies = [ "async-tungstenite", - "base64", + "base64 0.22.1", "cfg-if", "chromiumoxide_cdp", "chromiumoxide_types", @@ -387,6 +498,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -401,6 +522,21 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "construct-adapter-antigravity" version = "0.14.3" @@ -490,7 +626,7 @@ version = "0.14.3" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "construct-client", @@ -517,7 +653,7 @@ version = "0.14.3" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "clap", @@ -572,7 +708,7 @@ name = "construct-daemon" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "clap", "construct-adapter-common", @@ -596,6 +732,7 @@ dependencies = [ "tracing-subscriber", "uuid", "which", + "wstunnel", ] [[package]] @@ -603,7 +740,7 @@ name = "construct-e2e" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chromiumoxide", "construct-client", "construct-protocol", @@ -621,7 +758,7 @@ name = "construct-mcp" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "construct-client", "construct-protocol", "futures", @@ -642,7 +779,7 @@ name = "construct-protocol" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "futures", "portable-pty", @@ -666,6 +803,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -681,6 +838,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -690,6 +856,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.28.1" @@ -734,6 +930,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -754,6 +962,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -800,6 +1035,31 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -829,6 +1089,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -847,7 +1108,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -882,12 +1145,71 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -901,7 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -913,6 +1235,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -946,12 +1289,47 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-socks5" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9545787d8304a71e1bf1b711705070a4c400cce9b332c4a11800627b7c9a2067" +dependencies = [ + "anyhow", + "async-trait", + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastwebsockets" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305d3ba574508e27190906d11707dad683e0494e6b85eae9b044cb2734a5e422" +dependencies = [ + "base64 0.21.7", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "pin-project", + "rand 0.8.6", + "sha1", + "simdutf8", + "thiserror 1.0.69", + "tokio", + "utf-8", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -962,10 +1340,26 @@ dependencies = [ ] [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", "thiserror 1.0.69", @@ -1027,6 +1421,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1129,6 +1532,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1167,10 +1571,41 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1233,6 +1668,101 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1281,19 +1811,27 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1323,7 +1861,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1334,7 +1872,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -1515,6 +2053,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -1528,11 +2086,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1555,6 +2129,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -1567,6 +2190,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "zeroize", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1578,6 +2223,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lab" version = "0.11.0" @@ -1589,6 +2254,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1602,6 +2270,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1657,9 +2331,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -1744,6 +2418,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1754,6 +2445,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nix" version = "0.28.0" @@ -1779,6 +2476,19 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -1789,13 +2499,66 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] [[package]] @@ -1815,6 +2578,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1822,6 +2604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1833,11 +2616,24 @@ dependencies = [ "libc", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1845,6 +2641,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1854,6 +2656,36 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1877,6 +2709,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1978,12 +2819,53 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2045,6 +2927,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppp" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7a2049cd2570bd67bf0228e86bf850f8ceb5190a345c471d03a909da6049e0" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2054,6 +2945,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2064,6 +2966,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2098,7 +3009,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -2135,7 +3046,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -2182,6 +3093,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2220,6 +3142,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -2305,6 +3233,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2316,9 +3257,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -2339,9 +3280,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2349,7 +3290,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2384,6 +3325,22 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2398,6 +3355,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2427,6 +3404,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2450,7 +3436,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2459,6 +3445,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2467,6 +3454,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2500,12 +3508,67 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2555,6 +3618,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2576,6 +3649,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.37" @@ -2594,7 +3680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2605,7 +3691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2671,12 +3757,38 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -2697,12 +3809,38 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", ] [[package]] @@ -2792,6 +3930,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -2799,10 +3964,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2833,7 +3998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.1", "fancy-regex", "filedescriptor", @@ -2924,12 +4089,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2938,6 +4105,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2975,11 +4152,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-fd" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cedf0b897610a4baff98bf6116c060c5cfe7574d4339c50e9d23fe09377641d" +dependencies = [ + "libc", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -3001,6 +4188,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -3122,6 +4320,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3279,6 +4478,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3297,6 +4502,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3317,9 +4528,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "atomic", "getrandom 0.4.2", @@ -3374,6 +4585,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3631,6 +4852,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -3647,6 +4874,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3694,6 +4930,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4077,6 +5324,86 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wstunnel" +version = "10.6.1" +source = "git+https://github.com/erebe/wstunnel.git?rev=5cac9a52338a21e67254cea5d749f9cc170193be#5cac9a52338a21e67254cea5d749f9cc170193be" +dependencies = [ + "ahash", + "anyhow", + "arc-swap", + "async-channel", + "base64 0.22.1", + "bb8", + "bytes", + "clap", + "crossterm 0.29.0", + "derive_more", + "fast-socks5", + "fastwebsockets", + "futures-util", + "hickory-resolver", + "http-body-util", + "httparse", + "hyper", + "hyper-util", + "ipnet", + "jsonwebtoken", + "log", + "nix 0.31.3", + "notify", + "parking_lot", + "pin-project", + "ppp", + "rcgen", + "regex", + "rustls-native-certs", + "rustls-pemfile", + "scopeguard", + "serde", + "serde_regex", + "serde_yaml", + "socket2 0.6.5", + "tokio", + "tokio-fd", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "uuid", + "x509-parser", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.2" @@ -4146,6 +5473,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index ff3891ea..03038649 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ license = "MIT" authors = ["construct contributors"] repository = "https://github.com/construct-worlds/construct" description = "Cross-harness agent fleet TUI and daemon" -rust-version = "1.75" +rust-version = "1.85" [workspace.dependencies] tokio = { version = "1", features = ["full"] } @@ -59,6 +59,7 @@ eventsource-stream = "0.2" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } qrcode = { version = "0.14", default-features = false } httparse = "1" +wstunnel = { git = "https://github.com/erebe/wstunnel.git", rev = "5cac9a52338a21e67254cea5d749f9cc170193be", default-features = false, features = ["clap", "ring"] } chromiumoxide = { version = "0.7", default-features = false, features = ["tokio-runtime"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diffy = "0.4" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..9dfcfbe0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Third-party notices + +## wstunnel + +Construct links the `wstunnel` Rust library from +. + +BSD 3-Clause License + +Copyright (c) 2016-2024, Erèbe - Romain Gerard + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 8e9ba4a4..a6113dbe 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -36,6 +36,7 @@ tokio-tungstenite.workspace = true qrcode.workspace = true httparse.workspace = true reqwest.workspace = true +wstunnel.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/daemon/src/remote_supervisor.rs b/crates/daemon/src/remote_supervisor.rs index 82c11f4d..921fe857 100644 --- a/crates/daemon/src/remote_supervisor.rs +++ b/crates/daemon/src/remote_supervisor.rs @@ -92,6 +92,15 @@ fn load_restore_snapshot(path: &std::path::Path) -> Option { /// resume at all. pub fn restorable_provider(path: &std::path::Path) -> Option { let snap = load_restore_snapshot(path)?; + // The first-party client runs inside the daemon and deliberately keeps + // every registration capability only in memory. An exec-style daemon + // restart therefore cannot adopt or silently recreate that tunnel: doing + // so would require persisting a credential or opening OAuth unexpectedly. + // Switch remote control off and let the user reconnect explicitly. + if snap.tunnel_provider == TunnelProvider::Construct { + let _ = std::fs::remove_file(path); + return None; + } // A snapshot written by a daemon from before providers existed // records a live tunnel PID but no provider, and taking its // `None` at face value would leave that cloudflared orphaned with @@ -522,6 +531,19 @@ mod tests { } } + #[test] + fn in_process_construct_tunnel_requires_explicit_reconnect_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("remote.json"); + let mut snap = snapshot(0, now_secs()); + snap.tunnel_provider = TunnelProvider::Construct; + snap.tunnel_url = Some("https://demo.user.tunnel.zarvis.ai/".into()); + snap.write(&path).unwrap(); + + assert_eq!(restorable_provider(&path), None); + assert!(!path.exists(), "non-adoptable snapshot must be removed"); + } + /// A PID that is guaranteed dead: spawn a trivial process, reap it, /// then reuse its (now-freed) PID. fn dead_pid() -> u32 { diff --git a/crates/daemon/src/tunnel/construct.rs b/crates/daemon/src/tunnel/construct.rs index 05c76b6f..77ff5854 100644 --- a/crates/daemon/src/tunnel/construct.rs +++ b/crates/daemon/src/tunnel/construct.rs @@ -10,9 +10,10 @@ use std::process::Stdio; use std::time::Duration; use anyhow::{anyhow, Context, Result}; +use clap::Parser; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; +use wstunnel::executor::JoinSetTokioExecutor; use crate::remote::RemoteState; @@ -51,15 +52,15 @@ struct AuthPoll { } pub fn preflight() -> Result<(), String> { - let binary = binary(); - if which::which(&binary).is_err() { - return Err(format!( - "{binary} is not on PATH — install wstunnel or set CONSTRUCT_WSTUNNEL_BIN" - )); - } Ok(()) } +#[derive(Parser)] +struct InProcessClient { + #[command(flatten)] + client: wstunnel::config::Client, +} + pub async fn run_once( remote: &RemoteState, local_port: u16, @@ -96,37 +97,18 @@ pub async fn run_once( registration.remote_port ); let auth_header = format!("Authorization: Bearer {}", registration.tunnel_token); - let mut child = Command::new(binary()) - .args([ - "client", - "--log-lvl", - "WARN", - "--remote-to-local", - &reverse, - "--http-headers", - &auth_header, - ®istration.relay_url, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .process_group(0) - .spawn() - .context("spawn wstunnel client")?; - - let pid = child.id().unwrap_or(0); - if pid != 0 { - remote.set_tunnel_pid(pid).await; - } - let stderr = child - .stderr - .take() - .ok_or_else(|| anyhow!("wstunnel stderr not captured"))?; - let drain = tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::debug!(target: "wstunnel", "{line}"); - } - }); + let client = InProcessClient::try_parse_from([ + "construct-wstunnel", + "--remote-to-local", + &reverse, + "--http-headers", + &auth_header, + ®istration.relay_url, + ]) + .context("configure in-process wstunnel client")? + .client; + let executor = JoinSetTokioExecutor::default(); + let tunnel = wstunnel::run_client(client, executor); let public_url = normalize_public_url(®istration.public_url)?; let ready_url = registration.ready_url; @@ -156,28 +138,22 @@ pub async fn run_once( } }; - tokio::pin!(readiness); + tokio::pin!(readiness, tunnel); tokio::select! { ready = &mut readiness => ready?, - status = child.wait() => { - drain.abort(); - let status = status.context("wait for wstunnel client")?; - return Err(anyhow!("wstunnel exited before the tunnel was ready: {status}")); + result = &mut tunnel => { + result.context("run in-process wstunnel client")?; + return Err(anyhow!("wstunnel exited before the tunnel was ready")); } } - let status = tokio::select! { - status = child.wait() => status.context("wait for wstunnel client")?, - _ = tokio::time::sleep(refresh_after) => { - child.start_kill().context("stop wstunnel for capability refresh")?; - child.wait().await.context("wait for refreshing wstunnel client")? + tokio::select! { + result = &mut tunnel => { + result.context("run in-process wstunnel client")?; + Err(anyhow!("wstunnel exited")) } - }; - drain.abort(); - if !status.success() { - return Err(anyhow!("wstunnel exited: {status}")); + _ = tokio::time::sleep(refresh_after) => Ok(()), } - Ok(()) } async fn authorize( @@ -282,10 +258,6 @@ fn open_browser(url: &str) -> Result<()> { Ok(()) } -fn binary() -> String { - std::env::var("CONSTRUCT_WSTUNNEL_BIN").unwrap_or_else(|_| "wstunnel".to_string()) -} - fn validate_subdomain(value: &str) -> Result<()> { let valid = (1..=63).contains(&value.len()) && !value.starts_with('-') diff --git a/crates/daemon/src/tunnel/mod.rs b/crates/daemon/src/tunnel/mod.rs index 31614884..6963ec39 100644 --- a/crates/daemon/src/tunnel/mod.rs +++ b/crates/daemon/src/tunnel/mod.rs @@ -10,22 +10,16 @@ //! provider is a backend module plus a `TunnelProvider` variant, and //! nothing upstream of here has to change. //! -//! Every backend presents the same shape to the supervisor, which is -//! what keeps the rest of the daemon provider-agnostic: +//! Every backend presents the same long-running-future shape to the +//! supervisor, which is what keeps the rest of the daemon provider-agnostic. +//! External providers may use a child process; the first-party provider links +//! `wstunnel` and runs it inside that future: //! -//! 1. Spawn a child process in its own process group, so it survives -//! the daemon's `exec()` on `/construct restart` and the new daemon -//! can adopt it by PID. -//! 2. Record that PID on [`RemoteState`] so stop + restart-adoption -//! can find it. -//! 3. Publish a browser URL on [`RemoteState`] once — and only once — +//! 1. Publish a browser URL on [`RemoteState`] once — and only once — //! the tunnel is actually serving. -//! 4. Die when SIGTERM'd, releasing whatever it registered. -//! -//! Step 4 is why the backend runs its child in the *foreground*: the -//! tunnel then lives exactly as long as a process we hold a PID for, -//! and a crashed daemon can't leave a mapping behind with nothing to -//! withdraw it. +//! 2. End when its supervisor future is cancelled, releasing whatever +//! it registered. Child-process providers additionally record a PID so +//! they can survive and be adopted across an exec-style daemon restart. pub mod cloudflare; pub mod construct; diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b1b6cc75..ffc39286 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -46,7 +46,7 @@ never publish a mislabelled binary. The tarballs use `construct-` names (which `install.sh` and `construct upgrade` expect). - Each tarball contains the single `construct` binary plus `README.md` and `LICENSE`. All adapter and MCP functionality is built into `construct` (`construct __adapter `, `construct __mcp`). + Each tarball contains the single `construct` binary plus `README.md`, `LICENSE`, and `THIRD_PARTY_NOTICES.md`. All adapter and MCP functionality is built into `construct` (`construct __adapter `, `construct __mcp`). 4. Review the release notes. The workflow passes `generate_release_notes: true` to the release step, so GitHub fills the release body automatically from the diff --git a/docs/remote-control.md b/docs/remote-control.md index 99e748b6..0a47812d 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -24,7 +24,6 @@ from the buttons in the dialog. | `CONSTRUCT_REMOTE_WS_PORT=` | Start the remote WebSocket listener on daemon boot for scripted/headless use. | | `CONSTRUCT_REMOTE_PROVIDER=` | Tunnel provider for the boot-time listener above. Defaults to `cloudflare`. | | `CONSTRUCT_TUNNEL_SUBDOMAIN=` | Static label for a boot-time Construct tunnel. | -| `CONSTRUCT_WSTUNNEL_BIN=` | Optional `wstunnel` executable override. | | `CONSTRUCT_WEBUI_PORT=` | Override the always-on localhost web UI port. Defaults to `5746`. | ## The tunnel @@ -43,8 +42,9 @@ Once the tunnel's QR is up, the ready view offers two buttons: - **stop** — stop the tunnel and drop the public URL, but keep the LAN listener and its password. A phone connected over the LAN keeps working. -The **Construct** provider uses the separately installed `wstunnel` client. It -starts with a human-friendly generated name that you can replace or accept. +The **Construct** provider links the `wstunnel` Rust library directly; there is +no separate executable to install or configure. It starts with a human-friendly +generated name that you can replace or accept. Construct then opens a short-lived `tunnel.zarvis.ai` browser login (and shows the link in the dialog). After GitHub or Google OAuth succeeds, the running daemon receives authorization directly, registers the selected name, and opens @@ -76,3 +76,7 @@ loopback while the remote-control listener does not. Tunnel + listener state is persisted under the runtime directory so a daemon restart preserves the active URL, password, and provider when possible; a restart never silently rotates the URL or switches how the machine is exposed. +Cloudflare's child process can be adopted across a daemon restart. A +`tunnel.zarvis.ai` connection runs in-process and keeps its authorization only +in memory, so it stops on restart and requires an explicit `/remote-connect` +and browser authorization afterward. diff --git a/specs/0095-first-party-named-tunnels.md b/specs/0095-first-party-named-tunnels.md index 2e66f080..8f93202f 100644 --- a/specs/0095-first-party-named-tunnels.md +++ b/specs/0095-first-party-named-tunnels.md @@ -23,6 +23,8 @@ Requested labels use lowercase ASCII letters, digits, and interior hyphens, with The interactive client pre-populates the label with a human-friendly random suggestion. The user may accept or edit it before any authorization request or public tunnel is created. +Construct links the `wstunnel` Rust library at a pinned upstream revision and runs the client inside the daemon's supervised async task. No external `wstunnel` executable, PATH entry, environment override, or subprocess is part of the first-party provider. + ## Reason Provider subjects are stable and do not require an identity database. HMAC prevents public provider identifiers from being recoverable from hostnames. Owner-equals-visitor authorization gives social login a precise stateless meaning without inventing an invitation system. @@ -33,6 +35,7 @@ Runtime allocation avoids deterministic TCP-port collisions. Short-lived, narrow - The client needs a selected label before starting the provider, then completes social login through a browser without exposing the owner token to the user. - Pending authorization requests are memory-only, single-use, and expire after ten minutes. Losing service state requires starting the login flow again and grants no durable access. +- Stopping or restarting the daemon cancels the in-process tunnel. Because authorization capabilities are not persisted, reconnecting after a restart is an explicit `/remote-connect` plus browser authorization rather than an automatic background login. - The service must validate the capability on the `wstunnel` upgrade and restrict its reverse bind to the allocated endpoint. - A public hostname is not reported ready until the gateway can reach its reverse endpoint. - Service restarts may briefly interrupt tunnels, but no database restore is required; clients reconnect and register again. @@ -43,4 +46,3 @@ Runtime allocation avoids deterministic TCP-port collisions. Short-lived, narrow - Cross-account sharing, teams, invitations, and durable ACLs. - Reserving a label while its owner is offline. -- Embedding `wstunnel` into the Construct executable; the initial client uses the separately installed binary. From 2b1020a2b43ad74f5766960cf5bba423e9019200 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 22:46:50 -0700 Subject: [PATCH 3/9] Hide internal credentials for Zarvis tunnels --- crates/cli/src/ui.rs | 47 +++++++++++++++++++------ docs/remote-control.md | 5 +-- specs/0095-first-party-named-tunnels.md | 2 ++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index e3b5a8d0..8a28874d 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15464,7 +15464,11 @@ fn remote_kv<'a>(app: &App, label: &str, value: &str, accent: bool) -> (Line<'a> /// "which of these am I looking at?" never needs asking: with a tunnel /// up it's the tunnel URL, otherwise it's the LAN address (or loopback /// on a machine with no LAN). -fn remote_info_lines<'a>(app: &App, p: &crate::app::RemoteControlOk) -> Vec> { +fn remote_info_lines<'a>( + app: &App, + p: &crate::app::RemoteControlOk, + include_credentials: bool, +) -> Vec> { let mut lines: Vec = Vec::new(); if p.tunnel_ready { @@ -15485,11 +15489,14 @@ fn remote_info_lines<'a>(app: &App, p: &crate::app::RemoteControlOk) -> Vec( area_w: u16, area_h: u16, ) -> RemotePopupBody<'a> { - let mut info = remote_info_lines(app, &c.base); + let mut info = remote_info_lines(app, &c.base, true); if let Some(hint) = c.base.hint.as_deref() { info.push(Line::raw("")); @@ -15734,7 +15741,7 @@ fn render_remote_name<'a>( state: &crate::app::RemoteControlName, area_w: u16, ) -> RemotePopupBody<'a> { - let mut info = remote_info_lines(app, &state.choose.base); + let mut info = remote_info_lines(app, &state.choose.base, true); info.push(Line::raw("")); info.push(Line::from(Span::styled( "Choose your tunnel name:", @@ -15781,7 +15788,7 @@ fn render_remote_starting<'a>( area_w: u16, ) -> RemotePopupBody<'a> { let label = p.provider.label(); - let mut info = remote_info_lines(app, p); + let mut info = remote_info_lines(app, p, true); info.push(Line::raw("")); info.push(Line::from(Span::styled( format!("Starting {label} tunnel…"), @@ -15833,7 +15840,8 @@ fn render_remote_ok<'a>( area_w: u16, ) -> RemotePopupBody<'a> { use crate::app::ReadyButton; - let mut info = remote_info_lines(app, p); + let include_credentials = ready_screen_shows_basic_credentials(p.provider); + let mut info = remote_info_lines(app, p, include_credentials); if let Some(hint) = p.hint.as_deref() { info.push(Line::raw("")); @@ -15878,6 +15886,12 @@ fn render_remote_ok<'a>( (title, app.theme.success, lines, width, height) } +fn ready_screen_shows_basic_credentials( + provider: construct_protocol::TunnelProvider, +) -> bool { + provider != construct_protocol::TunnelProvider::Construct +} + /// A provider failed to start. Paint the daemon's diagnostic — which is /// written as the next thing the user should do — instead of a URL that /// would not work. @@ -16012,6 +16026,19 @@ mod remote_popup_tests { let joined = wrap_plain(text, 8).join(" "); assert_eq!(joined, text); } + + #[test] + fn zarvis_ready_screen_hides_gateway_internal_basic_credentials() { + use construct_protocol::TunnelProvider; + + assert!(!ready_screen_shows_basic_credentials( + TunnelProvider::Construct + )); + assert!(ready_screen_shows_basic_credentials( + TunnelProvider::Cloudflare + )); + assert!(ready_screen_shows_basic_credentials(TunnelProvider::None)); + } } #[cfg(test)] diff --git a/docs/remote-control.md b/docs/remote-control.md index 0a47812d..b494d745 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -53,8 +53,9 @@ copied, placed in an environment variable, or written to a configuration file. The service publishes a stable `..tunnel.zarvis.ai` URL only after the reverse endpoint answers. The ready view displays that URL and its QR -code. Visitors sign in with GitHub or Google; initially, the social identity -must derive to the same user-id as the tunnel owner. +code without showing the gateway's internal upstream Basic credentials. +Visitors sign in with GitHub or Google; initially, the social identity must +derive to the same user-id as the tunnel owner. To turn remote control off completely — listener included — use `/remote-control stop`. diff --git a/specs/0095-first-party-named-tunnels.md b/specs/0095-first-party-named-tunnels.md index 8f93202f..370f3ce1 100644 --- a/specs/0095-first-party-named-tunnels.md +++ b/specs/0095-first-party-named-tunnels.md @@ -13,6 +13,8 @@ The tunnel owner authenticates before registering a name. Registration produces Owner authentication is an interactive browser handoff initiated by the running Construct daemon. The service creates a short-lived authorization request with separate high-entropy browser and polling capabilities. Construct opens the browser capability and retains the polling capability only in process memory. After social login, the polling capability returns an owner credential exactly once. The credential is consumed directly for registration and is never displayed, copied, configured through an environment variable, or written to disk. +The public tunnel ready screen shows the public URL and QR but not the remote listener's Basic username or password. Those are gateway-to-upstream credentials for this provider; public visitors authenticate socially and never enter them. LAN and providers without a credential-injecting gateway continue to display the Basic credentials. + The hosted service is deployed independently on Oracle Cloud infrastructure. It is not part of the `zarvis.ai` web deployment. DNS delegates `tunnel.zarvis.ai` and `*.tunnel.zarvis.ai` to the tunnel service's reserved public address. The same social identity that owns the tunnel is the initial authorization boundary for browser access. A visitor authenticates with GitHub or Google, and the service derives their user identifier from the provider plus immutable provider subject. Access is allowed only when that identifier equals the hostname's user-id. Sharing and persistent ACLs are non-goals until they have an explicit product design. From 7610bab2cca5f625d21f5285d1c98295d725f898 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 23:15:34 -0700 Subject: [PATCH 4/9] Use server-assigned private tunnel names --- crates/cli/src/app.rs | 111 ++---------------------- crates/cli/src/ui.rs | 45 ---------- crates/daemon/src/lib.rs | 4 +- crates/daemon/src/remote_supervisor.rs | 2 +- crates/daemon/src/tunnel/construct.rs | 40 ++------- crates/daemon/src/tunnel/mod.rs | 18 +++- docs/remote-control.md | 22 ++--- specs/0095-first-party-named-tunnels.md | 16 ++-- 8 files changed, 50 insertions(+), 208 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 1789ed59..b7e0875b 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2747,7 +2747,6 @@ impl ProgramSmartClipGroup { #[derive(Debug, Clone)] pub enum RemoteControlPopup { Choose(RemoteControlChoose), - Name(RemoteControlName), Starting(RemoteControlOk), Ok { ok: RemoteControlOk, @@ -2760,15 +2759,6 @@ pub enum RemoteControlPopup { }, } -#[derive(Debug, Clone)] -pub struct RemoteControlName { - pub choose: RemoteControlChoose, - pub name: String, - /// The generated suggestion is selected conceptually: the first typed - /// character replaces it, while Enter accepts it unchanged. - pub pristine: bool, -} - /// The two buttons on the tunnel-ready view. `Back` is the default /// focus so a reflexive Enter is non-destructive — it returns to the /// chooser without killing the tunnel. @@ -10186,8 +10176,8 @@ impl App { // dialog's resting state // /remote-control cloudflare [pw] → skip the dialog, // start the tunnel - // /remote-control construct → stable first-party - // tunnel + // /remote-control construct → server-named + // first-party tunnel // /remote-control → dialog + pw= use construct_protocol::TunnelProvider; let (sub, rest) = arg @@ -10206,15 +10196,11 @@ impl App { ).await } "construct" | "zarvis" => { - if rest.is_empty() { - self.open_remote_control_popup(None).await; - } else { - self.start_remote_control_provider( - TunnelProvider::Construct, - None, - Some(rest.to_string()), - ).await; - } + self.start_remote_control_provider( + TunnelProvider::Construct, + None, + None, + ).await; } _ => { // Everything (including any trailing @@ -10491,55 +10477,7 @@ impl App { ); return true; } - if opt.provider == construct_protocol::TunnelProvider::Construct { - self.remote_control_popup = Some(RemoteControlPopup::Name( - RemoteControlName { - choose: choose.clone(), - name: generated_tunnel_name(), - pristine: true, - }, - )); - } else { - self.start_remote_control_provider(opt.provider, None, None).await; - } - } - _ => {} - }, - RemoteControlPopup::Name(name) => match key.code { - KeyCode::Esc => { - self.remote_control_popup = - Some(RemoteControlPopup::Choose(name.choose.clone())); - } - KeyCode::Enter => { - let tunnel_name = name.name.clone(); - if tunnel_name.is_empty() { - self.set_status("enter a tunnel name".into()); - } else { - self.start_remote_control_provider( - construct_protocol::TunnelProvider::Construct, - None, - Some(tunnel_name), - ).await; - } - } - KeyCode::Backspace => { - if name.pristine { - name.name.clear(); - name.pristine = false; - } else { - name.name.pop(); - } - } - KeyCode::Char(c) - if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' => - { - if name.pristine { - name.name.clear(); - name.pristine = false; - } - if name.name.len() < 63 { - name.name.push(c); - } + self.start_remote_control_provider(opt.provider, None, None).await; } _ => {} }, @@ -10635,26 +10573,6 @@ impl App { } } -fn generated_tunnel_name() -> String { - const ADJECTIVES: &[&str] = &[ - "bright", "calm", "clever", "gentle", "lucky", "quiet", "swift", "warm", - ]; - const NOUNS: &[&str] = &[ - "badger", "comet", "falcon", "maple", "otter", "panda", "river", "willow", - ]; - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as usize) - .unwrap_or(0) - ^ std::process::id() as usize; - format!( - "{}-{}-{:02}", - ADJECTIVES[seed % ADJECTIVES.len()], - NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()], - (seed / (ADJECTIVES.len() * NOUNS.len())) % 100 - ) -} - /// Best-effort one-line summary of a tool call's args JSON for the /// PTY tool-block header. Prefers a single salient field /// (`command` for shell, `path` for read_file, `query` for search- @@ -11856,19 +11774,6 @@ mod tests { use super::*; use ratatui::layout::Rect; - #[test] - fn generated_tunnel_name_is_human_friendly_and_dns_safe() { - let name = generated_tunnel_name(); - let parts: Vec<&str> = name.split('-').collect(); - assert_eq!(parts.len(), 3, "expected adjective-noun-number: {name}"); - assert_eq!(parts[2].len(), 2, "expected two-digit suffix: {name}"); - assert!( - name.bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'), - "name is not DNS safe: {name}" - ); - } - /// Spec 0065 agent presence: the local receipt clock must renew only when /// the daemon's `updated_at_ms` genuinely advances, never on a rebase /// that leaves it unchanged (GAP D — a rebase must not re-trigger the diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 8a28874d..85db4c48 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15384,7 +15384,6 @@ fn render_remote_control_popup(f: &mut Frame, app: &mut App) { crate::app::RemoteControlPopup::Choose(c) => { render_remote_choose(app, c, total.width, total.height) } - crate::app::RemoteControlPopup::Name(n) => render_remote_name(app, n, total.width), crate::app::RemoteControlPopup::Starting(p) => { render_remote_starting(app, p, total.width) } @@ -15736,50 +15735,6 @@ fn render_remote_choose<'a>( ) } -fn render_remote_name<'a>( - app: &App, - state: &crate::app::RemoteControlName, - area_w: u16, -) -> RemotePopupBody<'a> { - let mut info = remote_info_lines(app, &state.choose.base, true); - info.push(Line::raw("")); - info.push(Line::from(Span::styled( - "Choose your tunnel name:", - Style::default().fg(app.theme.text), - ))); - info.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - format!(" {} ", state.name), - Style::default() - .fg(app.theme.accent) - .add_modifier(Modifier::REVERSED | Modifier::BOLD), - ), - Span::styled( - "..tunnel.zarvis.ai", - Style::default().fg(app.theme.dim), - ), - ])); - info.push(Line::raw("")); - let guidance = if state.pristine { - "Type to replace the suggestion · Enter continue · Esc back" - } else { - "Lowercase letters, numbers, hyphens · Enter continue · Esc back" - }; - info.extend(wrapped_lines(guidance, Style::default().fg(app.theme.dim))); - - // This is an input step, so the editable name always takes precedence - // over the already-available LAN QR. - let (lines, width, height) = compose_qr_and_info("", info, area_w); - ( - " /remote-connect — tunnel.zarvis.ai — name your tunnel ".to_string(), - app.theme.info, - lines, - width, - height, - ) -} - /// A tunnel is coming up. The LAN QR stays on screen and usable while /// we wait, rather than blanking the dialog for a spinner. fn render_remote_starting<'a>( diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index eb996ae4..4cfbd5c2 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -242,7 +242,7 @@ pub async fn run(socket_override: Option) -> Result<()> { let params = construct_protocol::RemoteStartParams { provider: boot_tunnel_provider(), password: None, - subdomain: std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), + subdomain: None, wait_for_tunnel: true, }; if let Err(e) = mgr.start_remote(Some(port), params).await { @@ -280,7 +280,7 @@ pub async fn run(socket_override: Option) -> Result<()> { let params = construct_protocol::RemoteStartParams { provider, password: None, - subdomain: std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), + subdomain: None, wait_for_tunnel: true, }; // port_hint=None — the supervisor reads the snapshot diff --git a/crates/daemon/src/remote_supervisor.rs b/crates/daemon/src/remote_supervisor.rs index 921fe857..f9f6ec6a 100644 --- a/crates/daemon/src/remote_supervisor.rs +++ b/crates/daemon/src/remote_supervisor.rs @@ -537,7 +537,7 @@ mod tests { let path = dir.path().join("remote.json"); let mut snap = snapshot(0, now_secs()); snap.tunnel_provider = TunnelProvider::Construct; - snap.tunnel_url = Some("https://demo.user.tunnel.zarvis.ai/".into()); + snap.tunnel_url = Some("https://swift-willow-4827.tunnel.zarvis.ai/".into()); snap.write(&path).unwrap(); assert_eq!(restorable_provider(&path), None); diff --git a/crates/daemon/src/tunnel/construct.rs b/crates/daemon/src/tunnel/construct.rs index 77ff5854..df90a960 100644 --- a/crates/daemon/src/tunnel/construct.rs +++ b/crates/daemon/src/tunnel/construct.rs @@ -21,7 +21,7 @@ const DEFAULT_API_URL: &str = "https://tunnel.zarvis.ai/api/v1/tunnels"; #[derive(Serialize)] struct RegisterRequest<'a> { - subdomain: &'a str, + construct_instance_id: &'a str, upstream_username: &'static str, upstream_password: &'a str, } @@ -64,13 +64,8 @@ struct InProcessClient { pub async fn run_once( remote: &RemoteState, local_port: u16, - requested_subdomain: Option<&str>, + construct_instance_id: &str, ) -> Result<()> { - let subdomain = requested_subdomain - .map(str::to_owned) - .ok_or_else(|| anyhow!("a tunnel name is required; choose one in `/remote-connect`"))?; - validate_subdomain(&subdomain)?; - let api_url = std::env::var("CONSTRUCT_TUNNEL_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); let http = reqwest::Client::new(); @@ -79,7 +74,7 @@ pub async fn run_once( .post(&api_url) .bearer_auth(&owner_token) .json(&RegisterRequest { - subdomain: &subdomain, + construct_instance_id, upstream_username: "remote", upstream_password: remote.password(), }) @@ -258,21 +253,6 @@ fn open_browser(url: &str) -> Result<()> { Ok(()) } -fn validate_subdomain(value: &str) -> Result<()> { - let valid = (1..=63).contains(&value.len()) - && !value.starts_with('-') - && !value.ends_with('-') - && value - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); - if !valid { - anyhow::bail!( - "invalid tunnel subdomain `{value}`; use 1–63 lowercase letters, digits, or hyphens" - ); - } - Ok(()) -} - fn normalize_public_url(value: &str) -> Result { let url = reqwest::Url::parse(value).context("service returned an invalid public URL")?; if url.scheme() != "https" || url.host_str().is_none() { @@ -293,21 +273,11 @@ fn validate_https_url(value: &str) -> Result { mod tests { use super::*; - #[test] - fn subdomain_validation_is_dns_label_safe() { - for valid in ["demo", "demo-2", "a"] { - assert!(validate_subdomain(valid).is_ok(), "{valid}"); - } - for invalid in ["", "Demo", "-demo", "demo-", "two.labels", "has space"] { - assert!(validate_subdomain(invalid).is_err(), "{invalid}"); - } - } - #[test] fn public_url_must_be_https() { assert_eq!( - normalize_public_url("https://demo.user.tunnel.zarvis.ai").unwrap(), - "https://demo.user.tunnel.zarvis.ai/" + normalize_public_url("https://swift-willow-4827.tunnel.zarvis.ai").unwrap(), + "https://swift-willow-4827.tunnel.zarvis.ai/" ); assert!(normalize_public_url("http://demo.example").is_err()); } diff --git a/crates/daemon/src/tunnel/mod.rs b/crates/daemon/src/tunnel/mod.rs index 6963ec39..a4716014 100644 --- a/crates/daemon/src/tunnel/mod.rs +++ b/crates/daemon/src/tunnel/mod.rs @@ -112,8 +112,17 @@ pub async fn run( } let mut backoff_secs: u64 = 1; + let construct_instance_id = uuid::Uuid::new_v4().simple().to_string(); loop { - match run_once(provider, &remote, local_port, subdomain.as_deref()).await { + match run_once( + provider, + &remote, + local_port, + subdomain.as_deref(), + &construct_instance_id, + ) + .await + { Ok(()) => { tracing::warn!(provider = label, "tunnel exited cleanly; respawning"); backoff_secs = 1; @@ -144,11 +153,14 @@ async fn run_once( provider: TunnelProvider, remote: &RemoteState, local_port: u16, - subdomain: Option<&str>, + _subdomain: Option<&str>, + construct_instance_id: &str, ) -> anyhow::Result<()> { match provider { TunnelProvider::None => Ok(()), TunnelProvider::Cloudflare => cloudflare::run_once(remote, local_port).await, - TunnelProvider::Construct => construct::run_once(remote, local_port, subdomain).await, + TunnelProvider::Construct => { + construct::run_once(remote, local_port, construct_instance_id).await + } } } diff --git a/docs/remote-control.md b/docs/remote-control.md index b494d745..0dc4de48 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -14,16 +14,15 @@ from the buttons in the dialog. | Command / setting | Purpose | |---|---| -| `/remote-connect` | Guided public-tunnel flow: select `tunnel.zarvis.ai`, accept or edit a generated name, and authorize in the browser. | +| `/remote-connect` | Guided public-tunnel flow: select `tunnel.zarvis.ai` and authorize in the browser. | | `/remote-control` | Open the dialog: bind the listener, show the LAN address + QR, and offer a tunnel. No tunnel is started until you pick one. | | `/remote-control ` | Same, with a user-chosen Basic-auth password. | | `/remote-control cloudflare` | Skip the dialog and start a Cloudflare tunnel directly. | -| `/remote-control construct ` | Direct form of the first-party flow for a caller that already chose a name. | +| `/remote-control construct` | Start the first-party flow directly; the service assigns the name. | | `/remote-control stop` | Stop the listener + tunnel entirely and rotate credentials for the next start. | | `/remote-control debug` | Alias for `/remote-control` — kept because the plain dialog is now the local-only resting state. | | `CONSTRUCT_REMOTE_WS_PORT=` | Start the remote WebSocket listener on daemon boot for scripted/headless use. | | `CONSTRUCT_REMOTE_PROVIDER=` | Tunnel provider for the boot-time listener above. Defaults to `cloudflare`. | -| `CONSTRUCT_TUNNEL_SUBDOMAIN=` | Static label for a boot-time Construct tunnel. | | `CONSTRUCT_WEBUI_PORT=` | Override the always-on localhost web UI port. Defaults to `5746`. | ## The tunnel @@ -43,19 +42,20 @@ Once the tunnel's QR is up, the ready view offers two buttons: and its password. A phone connected over the LAN keeps working. The **Construct** provider links the `wstunnel` Rust library directly; there is -no separate executable to install or configure. It starts with a human-friendly -generated name that you can replace or accept. -Construct then opens a short-lived `tunnel.zarvis.ai` browser login (and shows +no separate executable to install or configure. Construct opens a short-lived +`tunnel.zarvis.ai` browser login (and shows the link in the dialog). After GitHub or Google OAuth succeeds, the running -daemon receives authorization directly, registers the selected name, and opens -a reverse tunnel restricted to that registration. No owner token is shown, +daemon receives authorization directly, and the service assigns a unique, +human-friendly random name before opening a reverse tunnel restricted to that +registration. No owner token is shown, copied, placed in an environment variable, or written to a configuration file. -The service publishes a stable `..tunnel.zarvis.ai` URL only +The service publishes a `.tunnel.zarvis.ai` URL only after the reverse endpoint answers. The ready view displays that URL and its QR code without showing the gateway's internal upstream Basic credentials. -Visitors sign in with GitHub or Google; initially, the social identity must -derive to the same user-id as the tunnel owner. +Visitors sign in with GitHub or Google. The service maps the active name to its +owner and reverse endpoint in memory; the signed-in provider and immutable +provider subject must match the tunnel owner. To turn remote control off completely — listener included — use `/remote-control stop`. diff --git a/specs/0095-first-party-named-tunnels.md b/specs/0095-first-party-named-tunnels.md index 370f3ce1..860562d6 100644 --- a/specs/0095-first-party-named-tunnels.md +++ b/specs/0095-first-party-named-tunnels.md @@ -7,7 +7,7 @@ Scope: Construct's first-party tunnel names, identity, authorization, and runtim ## Decision -Construct's first-party tunnel provider uses a user-selected DNS label and a deterministic, provider-scoped user identifier to form `