// SPDX-License-Identifier: AGPL-3.0-or-later use crate::manifest::{DEV_PROXY_PORT, LOCAL_APP_URL, LOOPBACK_HOST}; use crate::paths::{DEV_STATE_DIR, ROOT, which}; use crate::proc::wait_tcp; use anyhow::{Context, Result, bail}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use url::Url; const LOCAL_ENV_START: &str = "# BEGIN fluxer-dev public URL"; const LOCAL_ENV_END: &str = "# END fluxer-dev public URL"; const DEFAULT_TOKEN_FILE_NAME: &str = "cloudflare-tunnel-token"; const DEFAULT_PUBLIC_URL_FILE_NAME: &str = "cloudflare-tunnel-public-url"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublicUrl { pub base_url: String, pub host: String, pub scheme: String, pub public_port: u16, pub websocket_scheme: String, } pub fn parse_public_url(raw: &str) -> Result { let url = Url::parse(raw).with_context(|| format!("invalid public URL: {raw}"))?; let scheme = url.scheme().to_owned(); if scheme != "http" && scheme != "https" { bail!("Public URL must use http or https: {raw}"); } let host = url .host_str() .ok_or_else(|| anyhow::anyhow!("Public URL must include a host: {raw}"))? .to_owned(); let path = url.path(); if path != "/" && !path.is_empty() { bail!("Public URL must not include a path: {raw}"); } if url.query().is_some() || url.fragment().is_some() { bail!("Public URL must not include a query string or fragment: {raw}"); } let public_port = url .port_or_known_default() .ok_or_else(|| anyhow::anyhow!("Public URL has no known port: {raw}"))?; let mut normalized = url; normalized.set_path(""); normalized.set_query(None); normalized.set_fragment(None); Ok(PublicUrl { base_url: normalized.as_str().trim_end_matches('/').to_owned(), host, scheme: scheme.clone(), public_port, websocket_scheme: if scheme == "https" { "wss" } else { "ws" }.to_owned(), }) } pub fn public_url_env(public_url: &str) -> Result> { let parsed = parse_public_url(public_url)?; let base = parsed.base_url.as_str(); let gateway_base = format!("{}://{}", parsed.websocket_scheme, parsed.host); let localhost_origins = "http://localhost,http://localhost:8088"; Ok(vec![ ("FLUXER_BASE_DOMAIN".to_owned(), parsed.host.clone()), ("FLUXER_PUBLIC_SCHEME".to_owned(), parsed.scheme.clone()), ( "FLUXER_PUBLIC_PORT".to_owned(), parsed.public_port.to_string(), ), ("FLUXER_STATIC_CDN_DOMAIN".to_owned(), parsed.host.clone()), ("FLUXER_PUBLIC_URL".to_owned(), base.to_owned()), ("FLUXER_API_ENDPOINT".to_owned(), format!("{base}/api")), ( "FLUXER_API_CLIENT_ENDPOINT".to_owned(), format!("{base}/api"), ), ("FLUXER_APP_ENDPOINT".to_owned(), base.to_owned()), ( "FLUXER_GATEWAY_ENDPOINT".to_owned(), format!("{gateway_base}/gateway"), ), ("FLUXER_MEDIA_ENDPOINT".to_owned(), format!("{base}/media")), ("FLUXER_S3_PUBLIC_ENDPOINT".to_owned(), base.to_owned()), ("FLUXER_S3_FORCE_PATH_STYLE".to_owned(), "true".to_owned()), ("FLUXER_STATIC_CDN_ENDPOINT".to_owned(), base.to_owned()), ("FLUXER_ADMIN_ENDPOINT".to_owned(), format!("{base}/admin")), ( "FLUXER_MARKETING_ENDPOINT".to_owned(), std::env::var("FLUXER_MARKETING_ENDPOINT") .unwrap_or_else(|_| "https://fluxer.app".to_owned()), ), ( "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT".to_owned(), format!("{base}/media"), ), ( "FLUXER_LIVEKIT_URL".to_owned(), format!("{gateway_base}/livekit"), ), ( "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT".to_owned(), format!("{base}/media"), ), ( "FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT".to_owned(), format!("{base}/media"), ), ( "FLUXER_GATEWAY_STATIC_CDN_ENDPOINT".to_owned(), base.to_owned(), ), ( "FLUXER_ADMIN_OAUTH_REDIRECT_URI".to_owned(), format!("{base}/admin/oauth2_callback"), ), ("FLUXER_PASSKEY_RP_ID".to_owned(), parsed.host.clone()), ( "FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS".to_owned(), format!("{localhost_origins},{base}"), ), ]) } pub fn apply_public_url_env(public_url: &str) -> Result<()> { for (key, value) in public_url_env(public_url)? { unsafe { std::env::set_var(key, value); } } Ok(()) } pub fn public_url_env_text(public_url: &str) -> Result { let mut text = String::new(); text.push_str(LOCAL_ENV_START); text.push('\n'); text.push_str("# Generated by `fluxer-dev tunnel configure`; safe to delete.\n"); for (key, value) in public_url_env(public_url)? { text.push_str(&key); text.push('='); text.push_str(&value); text.push('\n'); } text.push_str(LOCAL_ENV_END); text.push('\n'); Ok(text) } pub fn default_token_file() -> PathBuf { DEV_STATE_DIR.join(DEFAULT_TOKEN_FILE_NAME) } pub fn default_public_url_file() -> PathBuf { DEV_STATE_DIR.join(DEFAULT_PUBLIC_URL_FILE_NAME) } pub fn write_cloudflare_public_url_file(public_url: &str) -> Result { fs::create_dir_all(DEV_STATE_DIR.as_path()) .with_context(|| format!("failed to create {}", DEV_STATE_DIR.display()))?; let parsed = parse_public_url(public_url)?; let path = default_public_url_file(); fs::write(&path, format!("{}\n", parsed.base_url)) .with_context(|| format!("failed to write {}", path.display()))?; println!("Saved Cloudflare tunnel public URL to {}", path.display()); Ok(path) } pub fn write_cloudflare_token_file(token: &str) -> Result { fs::create_dir_all(DEV_STATE_DIR.as_path()) .with_context(|| format!("failed to create {}", DEV_STATE_DIR.display()))?; let path = default_token_file(); fs::write(&path, format!("{}\n", token.trim())) .with_context(|| format!("failed to write {}", path.display()))?; set_private_file_mode(&path)?; println!("Saved Cloudflare tunnel token to {}", path.display()); Ok(path) } pub fn resolve_cloudflare_public_url(public_url_arg: Option<&str>) -> Result { if let Some(public_url) = public_url_arg { return Ok(parse_public_url(public_url)?.base_url); } for key in [ "FLUXER_CLOUDFLARE_TUNNEL_PUBLIC_URL", "CLOUDFLARE_TUNNEL_PUBLIC_URL", ] { if let Ok(public_url) = std::env::var(key) { let public_url = public_url.trim(); if !public_url.is_empty() { return Ok(parse_public_url(public_url)?.base_url); } } } if let Ok(public_url) = std::env::var("FLUXER_PUBLIC_URL") { let public_url = public_url.trim(); if !public_url.is_empty() && public_url != LOCAL_APP_URL { return Ok(parse_public_url(public_url)?.base_url); } } let path = default_public_url_file(); if let Ok(public_url) = fs::read_to_string(&path) { let public_url = public_url.trim(); if !public_url.is_empty() { return Ok(parse_public_url(public_url)?.base_url); } } bail!( "Missing Cloudflare tunnel public URL. Run `pnpm dev:tunnel:configure --public-url https://...` or pass `pnpm dev --cloudflare-tunnel --public-url https://...`." ); } pub fn apply_cloudflare_public_url_env(public_url_arg: Option<&str>) -> Result { let public_url = resolve_cloudflare_public_url(public_url_arg)?; apply_public_url_env(&public_url)?; Ok(public_url) } pub async fn run_cloudflare_tunnel( token_arg: Option, token_file: Option, ) -> Result { let token = resolve_cloudflare_token(token_arg, token_file.as_deref())?; wait_cloudflare_tunnel_origin().await?; let public_url = display_public_url(); println!("Starting Cloudflare Tunnel for {public_url} -> http://127.0.0.1:8088"); if let Some(binary) = resolve_cloudflared_binary() { let status = Command::new(binary) .args(["tunnel", "run", "--token", token.as_str()]) .status() .context("failed to start cloudflared")?; return Ok(status.code().unwrap_or(1)); } if running_inside_devcontainer() { let container = std::fs::read_to_string("/etc/hostname") .context("failed to read the current devcontainer hostname")?; let container = container.trim(); if container.is_empty() || !container .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) { bail!("Current devcontainer hostname is invalid"); } let docker = docker_command(); let mut command = Command::new(&docker[0]); command.args(&docker[1..]); let status = command .args([ "run", "--rm", "--network", &format!("container:{container}"), "cloudflare/cloudflared:latest", "tunnel", "run", "--token", token.as_str(), ]) .status() .context("failed to start cloudflare/cloudflared Docker image")?; return Ok(status.code().unwrap_or(1)); } bail!( "cloudflared is not installed. Install cloudflared or run from the devcontainer with Docker available." ); } async fn wait_cloudflare_tunnel_origin() -> Result<()> { let timeout = std::env::var("FLUXER_CLOUDFLARE_TUNNEL_ORIGIN_READY_TIMEOUT") .ok() .and_then(|value| value.parse().ok()) .unwrap_or(30); if let Err(error) = wait_tcp("Fluxer dev proxy", LOOPBACK_HOST, DEV_PROXY_PORT, timeout).await { bail!( "Cloudflare Tunnel origin is not reachable at http://{LOOPBACK_HOST}:{DEV_PROXY_PORT}: {error}\nStart the full stack with `pnpm dev:tunnel`; `pnpm dev:tunnel:run` only starts cloudflared and expects the dev proxy to already be running." ); } Ok(()) } fn resolve_cloudflare_token( token_arg: Option, token_file: Option<&Path>, ) -> Result { if let Some(token) = token_arg { let token = token.trim(); if !token.is_empty() { return Ok(token.to_owned()); } } for key in [ "FLUXER_CLOUDFLARE_TUNNEL_TOKEN", "CLOUDFLARE_TUNNEL_TOKEN", "TUNNEL_TOKEN", ] { if let Ok(token) = std::env::var(key) { let token = token.trim(); if !token.is_empty() { return Ok(token.to_owned()); } } } let path = token_file .map(Path::to_path_buf) .unwrap_or_else(default_token_file); if let Ok(token) = fs::read_to_string(&path) { let token = token.trim(); if !token.is_empty() { return Ok(token.to_owned()); } } bail!( "Missing Cloudflare tunnel token. Set FLUXER_CLOUDFLARE_TUNNEL_TOKEN or write {}.", path.display() ); } fn display_public_url() -> String { if let Ok(public_url) = std::env::var("FLUXER_PUBLIC_URL") { let public_url = public_url.trim(); if !public_url.is_empty() && public_url != LOCAL_APP_URL { return public_url.to_owned(); } } if let Ok(public_url) = fs::read_to_string(default_public_url_file()) { let public_url = public_url.trim(); if !public_url.is_empty() { return public_url.to_owned(); } } LOCAL_APP_URL.to_owned() } fn resolve_cloudflared_binary() -> Option { if let Ok(binary) = std::env::var("FLUXER_CLOUDFLARED_BIN") && !binary.trim().is_empty() { return Some(PathBuf::from(binary)); } which("cloudflared") } fn running_inside_devcontainer() -> bool { ROOT.starts_with("/workspaces") && Path::new("/var/run/docker.sock").exists() && which("docker").is_some() } fn docker_command() -> Vec { if docker_socket_is_writable() || which("sudo").is_none() { return vec![PathBuf::from("docker")]; } vec![PathBuf::from("sudo"), PathBuf::from("docker")] } fn docker_socket_is_writable() -> bool { std::os::unix::net::UnixStream::connect("/var/run/docker.sock").is_ok() } #[cfg(unix)] fn set_private_file_mode(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let mut permissions = fs::metadata(path)?.permissions(); permissions.set_mode(0o600); fs::set_permissions(path, permissions)?; Ok(()) } #[cfg(not(unix))] fn set_private_file_mode(_path: &Path) -> Result<()> { Ok(()) }