From 8a5b2df524d60b202e9ec355d28e87a0b2817aa4 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 18:24:50 -0700 Subject: [PATCH 1/2] feat(remote): add authenticated Construct tunnels --- Cargo.lock | 1 + crates/cli/src/app.rs | 45 ++++- crates/cli/src/ui.rs | 3 + crates/client/src/lib.rs | 15 +- crates/daemon/Cargo.toml | 1 + crates/daemon/src/lib.rs | 5 +- crates/daemon/src/remote_supervisor.rs | 17 +- crates/daemon/src/session.rs | 1 + crates/daemon/src/tunnel/construct.rs | 233 ++++++++++++++++++++++++ crates/daemon/src/tunnel/mod.rs | 13 +- crates/e2e/tests/remote_control.rs | 4 + crates/protocol/src/lib.rs | 10 + docs/remote-control.md | 13 +- specs/0095-first-party-named-tunnels.md | 38 ++++ 14 files changed, 386 insertions(+), 13 deletions(-) create mode 100644 crates/daemon/src/tunnel/construct.rs create mode 100644 specs/0095-first-party-named-tunnels.md diff --git a/Cargo.lock b/Cargo.lock index 6a5c7d79..f021997a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,6 +582,7 @@ dependencies = [ "libc", "nix 0.29.0", "qrcode", + "reqwest", "serde", "serde_json", "tempfile", diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index cef0d29d..d2ef4c59 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -10159,8 +10159,8 @@ impl App { "tasks" => { self.open_tasks_popup().await; } - "remote-control" | "remote" => { - // Subcommand dispatch. `stop`, `debug`, and `cloudflare` + "remote-control" | "remote-connect" | "remote" => { + // Subcommand dispatch. `stop`, `debug`, and provider names // are reserved keywords; anything else is a literal // password override (so a user who wants the password // `stop` has to pick another word). @@ -10173,6 +10173,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 → dialog + pw= use construct_protocol::TunnelProvider; let (sub, rest) = arg @@ -10184,8 +10186,25 @@ impl App { "stop" => self.stop_remote_control().await, "" | "debug" => self.open_remote_control_popup(rest_pw).await, "cloudflare" | "cloudflared" => { - self.start_remote_control_provider(TunnelProvider::Cloudflare, rest_pw) - .await + self.start_remote_control_provider( + TunnelProvider::Cloudflare, + rest_pw, + None, + ).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(), + ); + } else { + self.start_remote_control_provider( + TunnelProvider::Construct, + None, + subdomain, + ).await; + } } _ => { // Everything (including any trailing @@ -10339,13 +10358,19 @@ impl App { &mut self, provider: construct_protocol::TunnelProvider, password: Option, + subdomain: Option, ) { if let Some(task) = self.remote_control_task.take() { task.abort(); } match self .client - .remote_start_with_wait(provider, password.clone(), false) + .remote_start_named_with_wait( + provider, + password.clone(), + subdomain.clone(), + false, + ) .await { Ok(r) => { @@ -10367,7 +10392,9 @@ impl App { let client = self.client.clone(); self.remote_control_task = Some(tokio::spawn(async move { - let result = client.remote_start_with_wait(provider, password, true).await; + let result = client + .remote_start_named_with_wait(provider, password, subdomain, true) + .await; (provider, result) })); } @@ -10437,7 +10464,11 @@ impl App { ); return true; } - self.start_remote_control_provider(opt.provider, None).await; + self.start_remote_control_provider( + opt.provider, + None, + std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), + ).await; } _ => {} }, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 6c752b07..d3df729c 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15647,6 +15647,9 @@ fn render_remote_choose<'a>( construct_protocol::TunnelProvider::Cloudflare => { "Anyone with the URL. Rotates each run.".to_string() } + construct_protocol::TunnelProvider::Construct => { + "Stable name. Visitors sign in with GitHub or Google.".to_string() + } construct_protocol::TunnelProvider::None => String::new(), }, app.theme.dim, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 7fc82032..89eafbc2 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -297,17 +297,30 @@ impl Client { provider: construct_protocol::TunnelProvider, password: Option, ) -> Result { - self.remote_start_with_wait(provider, password, true).await + self.remote_start_named_with_wait(provider, password, None, true) + .await } pub async fn remote_start_with_wait( &self, provider: construct_protocol::TunnelProvider, password: Option, wait_for_tunnel: bool, + ) -> Result { + self.remote_start_named_with_wait(provider, password, None, wait_for_tunnel) + .await + } + + pub async fn remote_start_named_with_wait( + &self, + provider: construct_protocol::TunnelProvider, + password: Option, + subdomain: Option, + wait_for_tunnel: bool, ) -> Result { let params = construct_protocol::RemoteStartParams { provider, password, + subdomain, wait_for_tunnel, }; self.request(ipc_method::REMOTE_START, ¶ms).await diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 28226d33..8e9ba4a4 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -35,6 +35,7 @@ base64.workspace = true tokio-tungstenite.workspace = true qrcode.workspace = true httparse.workspace = true +reqwest.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index e78c7936..eb996ae4 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -242,6 +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(), wait_for_tunnel: true, }; if let Err(e) = mgr.start_remote(Some(port), params).await { @@ -279,6 +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(), wait_for_tunnel: true, }; // port_hint=None — the supervisor reads the snapshot @@ -386,11 +388,12 @@ fn boot_tunnel_provider() -> construct_protocol::TunnelProvider { }; match raw.trim().to_ascii_lowercase().as_str() { "cloudflare" | "cloudflared" => TunnelProvider::Cloudflare, + "construct" | "zarvis" => TunnelProvider::Construct, "none" | "off" | "lan" => TunnelProvider::None, other => { tracing::warn!( value = %other, - "CONSTRUCT_REMOTE_PROVIDER is not one of cloudflare|none; \ + "CONSTRUCT_REMOTE_PROVIDER is not one of cloudflare|construct|none; \ defaulting to cloudflare" ); TunnelProvider::Cloudflare diff --git a/crates/daemon/src/remote_supervisor.rs b/crates/daemon/src/remote_supervisor.rs index 60ba8c73..f7e3c5df 100644 --- a/crates/daemon/src/remote_supervisor.rs +++ b/crates/daemon/src/remote_supervisor.rs @@ -137,6 +137,8 @@ pub struct StartRequest { /// this field — `/remote-stop` + `/remote-control ` /// is the recommended way to change the password mid-session. pub password: Option, + /// Optional stable name requested from a named tunnel provider. + pub subdomain: Option, pub respond: oneshot::Sender>, } @@ -185,6 +187,7 @@ pub async fn run(manager: Arc, mut rx: mpsc::UnboundedReceiver, provider: TunnelProvider, password: Option, + subdomain: Option, ) -> Result { // Fast path: listener already installed (previous request, or // boot-time env-var startup). Reuse it and only kick the @@ -225,6 +229,17 @@ async fn handle_start( bind_and_install(manager, ws_task, port_hint, password).await? }; + // The listener is shared, but providers are mutually exclusive. If + // the user chooses a different provider, stop the old child before + // starting the new one so its URL can never be mislabeled as the + // newly requested provider. + if provider != TunnelProvider::None + && tunnel_task.is_some() + && state.tunnel_provider() != provider + { + handle_stop_tunnel(manager, tunnel_task).await; + } + // Spawn the provider's tunnel at most once per current RemoteState // lifetime. The tunnel itself is restart-on-death inside // `tunnel::run`, so we don't track health here — only whether we @@ -241,7 +256,7 @@ async fn handle_start( let adopt_pid = state.tunnel_pid(); let st = state.clone(); let handle = tokio::spawn(async move { - crate::tunnel::run(provider, st, port, adopt_pid).await; + crate::tunnel::run(provider, st, port, adopt_pid, subdomain).await; }); *tunnel_task = Some(handle); } else { diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 9a57777a..6393dcda 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1821,6 +1821,7 @@ impl SessionManager { port_hint, provider: params.provider, password: params.password.clone(), + subdomain: params.subdomain.clone(), respond: tx, }, )) diff --git a/crates/daemon/src/tunnel/construct.rs b/crates/daemon/src/tunnel/construct.rs new file mode 100644 index 00000000..4057a181 --- /dev/null +++ b/crates/daemon/src/tunnel/construct.rs @@ -0,0 +1,233 @@ +//! Construct's authenticated, stable-name tunnel backend. +//! +//! The control plane authenticates the tunnel owner, allocates an +//! ephemeral reverse port, and returns a short-lived capability that +//! permits exactly that reverse binding. `wstunnel` carries the bytes; +//! the service's browser gateway supplies social login and maps the +//! stable hostname to the runtime-only port. + +use std::process::Stdio; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use crate::remote::RemoteState; + +const DEFAULT_API_URL: &str = "https://tunnel.zarvis.ai/api/v1/tunnels"; + +#[derive(Serialize)] +struct RegisterRequest<'a> { + subdomain: &'a str, + upstream_username: &'static str, + upstream_password: &'a str, +} + +#[derive(Deserialize)] +struct Registration { + public_url: String, + relay_url: String, + remote_port: u16, + tunnel_token: String, + ready_url: String, + expires_in_seconds: u64, +} + +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" + )); + } + 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(()) +} + +pub async fn run_once( + remote: &RemoteState, + local_port: u16, + requested_subdomain: Option<&str>, +) -> 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" + ) + })?; + 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 registration = http + .post(&api_url) + .bearer_auth(&owner_token) + .json(&RegisterRequest { + subdomain: &subdomain, + upstream_username: "remote", + upstream_password: remote.password(), + }) + .send() + .await + .context("contact Construct tunnel service")? + .error_for_status() + .context("Construct tunnel registration rejected")? + .json::() + .await + .context("decode Construct tunnel registration")?; + + let reverse = format!( + "tcp://127.0.0.1:{}:127.0.0.1:{local_port}", + 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 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 readiness = async { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let ready = http + .get(&ready_url) + .bearer_auth(&tunnel_token) + .send() + .await + .map(|response| response.status().is_success()) + .unwrap_or(false); + if ready { + remote.set_tunnel_url(Some(public_url)).await; + return Ok::<(), anyhow::Error>(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "Construct tunnel did not become reachable within 15s" + )); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }; + + tokio::pin!(readiness); + 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}")); + } + } + + 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")? + } + }; + drain.abort(); + if !status.success() { + return Err(anyhow!("wstunnel exited: {status}")); + } + 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('-') + && !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() { + anyhow::bail!("service returned a non-HTTPS public URL"); + } + Ok(format!("{}/", value.trim_end_matches('/'))) +} + +#[cfg(test)] +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/" + ); + 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 7de0d6b0..363f7d44 100644 --- a/crates/daemon/src/tunnel/mod.rs +++ b/crates/daemon/src/tunnel/mod.rs @@ -28,6 +28,7 @@ //! withdraw it. pub mod cloudflare; +pub mod construct; use std::time::Duration; @@ -36,7 +37,10 @@ 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; 1] = [TunnelProvider::Cloudflare]; +pub const PROVIDERS: [TunnelProvider; 2] = [ + TunnelProvider::Cloudflare, + TunnelProvider::Construct, +]; /// Probe every provider. Read-only — nothing is spawned, so the /// dialog can call this on every open without side effects. @@ -54,6 +58,7 @@ pub async fn probe(provider: TunnelProvider) -> RemoteProviderInfo { let detail = match provider { TunnelProvider::None => None, TunnelProvider::Cloudflare => cloudflare::preflight().err(), + TunnelProvider::Construct => construct::preflight().err(), }; RemoteProviderInfo { provider, @@ -78,6 +83,7 @@ pub async fn run( remote: RemoteState, local_port: u16, adopt_pid: u32, + subdomain: Option, ) { if provider == TunnelProvider::None { return; @@ -115,7 +121,7 @@ pub async fn run( let mut backoff_secs: u64 = 1; loop { - match run_once(provider, &remote, local_port).await { + match run_once(provider, &remote, local_port, subdomain.as_deref()).await { Ok(()) => { tracing::warn!(provider = label, "tunnel exited cleanly; respawning"); backoff_secs = 1; @@ -138,6 +144,7 @@ pub async fn preflight(provider: TunnelProvider) -> Result<(), String> { match provider { TunnelProvider::None => Ok(()), TunnelProvider::Cloudflare => cloudflare::preflight(), + TunnelProvider::Construct => construct::preflight(), } } @@ -145,9 +152,11 @@ async fn run_once( provider: TunnelProvider, remote: &RemoteState, local_port: u16, + subdomain: Option<&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, } } diff --git a/crates/e2e/tests/remote_control.rs b/crates/e2e/tests/remote_control.rs index 682b1203..dd2964d8 100644 --- a/crates/e2e/tests/remote_control.rs +++ b/crates/e2e/tests/remote_control.rs @@ -154,6 +154,10 @@ async fn remote_providers_describes_every_provider() { listed.contains(&TunnelProvider::Cloudflare), "cloudflare must be offered even when it isn't installed: {listed:?}" ); + assert!( + listed.contains(&TunnelProvider::Construct), + "construct must be offered even when it is not configured: {listed:?}" + ); assert!( !listed.contains(&TunnelProvider::None), "`None` is the absence of a provider, not a button: {listed:?}" diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 652ff3dc..2c8ada8e 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2565,6 +2565,8 @@ pub struct RemoteStateNotificationPayload { /// /// - `Cloudflare` — a `cloudflared` quick tunnel. Public internet, /// ephemeral URL that nobody can guess. Needs no account. +/// - `Construct` — Construct's authenticated first-party service, +/// with a user-chosen stable name under `tunnel.zarvis.ai`. /// /// The enum keeps a `None` variant and room for more providers because /// the daemon models "reach the LAN" and "reach past it" as one axis; @@ -2580,6 +2582,7 @@ pub enum TunnelProvider { #[default] None, Cloudflare, + Construct, } impl TunnelProvider { @@ -2589,6 +2592,7 @@ impl TunnelProvider { match self { TunnelProvider::None => 0, TunnelProvider::Cloudflare => 1, + TunnelProvider::Construct => 2, } } @@ -2599,6 +2603,7 @@ impl TunnelProvider { pub fn from_u8(v: u8) -> Self { match v { 1 => TunnelProvider::Cloudflare, + 2 => TunnelProvider::Construct, _ => TunnelProvider::None, } } @@ -2608,6 +2613,7 @@ impl TunnelProvider { match self { TunnelProvider::None => "local network", TunnelProvider::Cloudflare => "Cloudflare", + TunnelProvider::Construct => "Construct", } } } @@ -2631,6 +2637,10 @@ pub struct RemoteStartParams { pub provider: TunnelProvider, #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, + /// Stable first label requested from providers that support named + /// tunnels. Ignored by providers with provider-assigned names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdomain: Option, /// Tunnel mode normally waits for the provider's URL before /// replying. Interactive clients can set this false to get an /// immediate local result, paint a progress dialog, then poll diff --git a/docs/remote-control.md b/docs/remote-control.md index 98652108..bf5ad64a 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -17,10 +17,14 @@ from the buttons in the dialog. | `/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 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_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`. | ## The tunnel @@ -39,6 +43,13 @@ 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 +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. + 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 new file mode 100644 index 00000000..375dbacd --- /dev/null +++ b/specs/0095-first-party-named-tunnels.md @@ -0,0 +1,38 @@ +# 0095-first-party-named-tunnels + +Status: accepted +Date: 2026-07-14 +Area: architecture +Scope: Construct's first-party tunnel names, identity, authorization, and runtime state + +## Decision + +Construct's first-party tunnel provider uses a user-selected DNS label and a deterministic, provider-scoped user identifier to form `