use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use cliclack::{confirm, input, intro, outro, outro_cancel, outro_note, select, spinner};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
#[derive(Parser)]
#[command(name = "cftun")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Cloudflare Tunnel as a free ngrok alternative for webhooks")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Create {
name: Option<String>,
hostname: Option<String>,
local: Option<String>,
},
#[command(visible_aliases = ["ls"])]
List,
#[command(name = "status")]
Status,
Run {
name: Option<String>,
},
Show {
name: Option<String>,
},
Import {
name: Option<String>,
hostname: Option<String>,
local: Option<String>,
},
Update {
name: Option<String>,
#[arg(long)]
hostname: Option<String>,
#[arg(long)]
local: Option<String>,
},
Delete {
name: Option<String>,
#[arg(long)]
cleanup: bool,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct TunnelConfig {
tunnel: String,
#[serde(rename = "credentials-file")]
credentials_file: PathBuf,
#[serde(default)]
ingress: Vec<IngressRule>,
}
#[derive(Debug, Serialize, Deserialize)]
struct IngressRule {
hostname: String,
service: String,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct Metadata {
#[serde(default)]
tunnels: BTreeMap<String, TunnelMeta>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct TunnelMeta {
uuid: String,
hostname: String,
service: String,
config_path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CloudflaredTunnel {
id: String,
name: String,
#[serde(rename = "created_at")]
created_at: String,
#[serde(rename = "deleted_at", default)]
#[allow(dead_code)]
deleted_at: String,
#[serde(default)]
connections: Vec<serde_yaml::Value>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
which::which("cloudflared").map_err(|_| {
anyhow!(
"cloudflared not found in PATH. Install it first:\n macOS: brew install cloudflared\n Windows: winget install --id Cloudflare.cloudflared\n Linux: see https://github.com/cloudflare/cloudflared/releases"
)
})?;
ctrlc::set_handler(|| {}).map_err(|e| anyhow!("setting Ctrl-C handler: {e}"))?;
match cli.command {
Commands::Create { name, hostname, local } => {
create(name.as_deref(), hostname.as_deref(), local.as_deref())
}
Commands::List => list(),
Commands::Show { name } => show(name.as_deref()),
Commands::Import { name, hostname, local } => {
import(name.as_deref(), hostname.as_deref(), local.as_deref())
}
Commands::Update { name, hostname, local } => {
update(name.as_deref(), hostname.as_deref(), local.as_deref())
}
Commands::Status => status(),
Commands::Run { name } => run(name.as_deref()),
Commands::Delete { name, cleanup } => delete(name.as_deref(), cleanup),
}
}
fn cftun_dir() -> Result<PathBuf> {
let dir = dirs::home_dir()
.ok_or_else(|| anyhow!("could not find home directory"))?
.join(".cloudflared")
.join("cftun");
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
Ok(dir)
}
fn metadata_path() -> Result<PathBuf> {
Ok(cftun_dir()?.join("tunnels.yaml"))
}
fn credentials_file(uuid: &str) -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".cloudflared")
.join(format!("{uuid}.json"))
}
fn load_metadata() -> Result<Metadata> {
let path = metadata_path()?;
if !path.exists() {
return Ok(Metadata::default());
}
let content = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
serde_yaml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
}
fn save_metadata(meta: &Metadata) -> Result<()> {
let path = metadata_path()?;
let content = serde_yaml::to_string(meta).context("serializing metadata")?;
fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn normalize_service(local: &str) -> String {
if local.starts_with("http://") || local.starts_with("https://") {
local.to_string()
} else if let Some(port) = local.parse::<u16>().ok() {
let scheme = if port == 443 || port == 8443 {
"https"
} else {
"http"
};
format!("{scheme}://localhost:{port}")
} else {
format!("http://localhost:{local}")
}
}
fn run_cloudflared(args: &[&str]) -> Result<std::process::Output> {
let output = Command::new("cloudflared")
.args(args)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
.with_context(|| format!("running cloudflared {}", args.join(" ")))?;
Ok(output)
}
fn cloudflared_ok(args: &[&str]) -> Result<String> {
let output = run_cloudflared(args)?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
anyhow::bail!(
"cloudflared {} failed ({}):\nstdout: {}\nstderr: {}",
args.join(" "),
output.status,
stdout.trim(),
stderr.trim()
);
}
Ok(stdout.into_owned())
}
fn route_dns(uuid: &str, hostname: &str) -> Result<()> {
cloudflared_ok(&["tunnel", "route", "dns", "--overwrite-dns", uuid, hostname])
.with_context(|| format!("routing DNS {hostname} to tunnel {uuid}"))?;
Ok(())
}
fn warn_dns_cleanup_unsupported(hostname: &str) {
eprintln!(
"{} cloudflared cannot delete DNS hostname routes from this CLI. Delete '{}' in Cloudflare DNS if you want the record gone.",
"!".yellow(),
hostname
);
}
fn parse_uuid(output: &str) -> Option<String> {
output
.lines()
.find(|l| l.contains("with id"))
.and_then(|l| l.split_whitespace().rev().next())
.map(|s| s.to_string())
}
fn fetch_cloudflared_tunnels() -> Result<Vec<CloudflaredTunnel>> {
let output = run_cloudflared(&["tunnel", "list", "-o", "json"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("cloudflared tunnel list failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let array_start = stdout.find('[').unwrap_or(0);
let array_end = stdout.rfind(']').map(|i| i + 1).unwrap_or(stdout.len());
let json = &stdout[array_start..array_end];
serde_json::from_str(json).context("parsing cloudflared tunnel list JSON")
}
fn write_tunnel_config(
meta: &mut Metadata,
name: &str,
uuid: &str,
hostname: &str,
local: &str,
) -> Result<TunnelMeta> {
let service = normalize_service(local);
let config_path = cftun_dir()?.join(format!("{name}.yaml"));
let cfg = TunnelConfig {
tunnel: uuid.to_string(),
credentials_file: credentials_file(uuid),
ingress: vec![
IngressRule {
hostname: hostname.to_string(),
service: service.clone(),
},
IngressRule {
hostname: String::new(),
service: "http_status:404".to_string(),
},
],
};
let yaml = serde_yaml::to_string(&cfg).context("serializing tunnel config")?;
fs::write(&config_path, yaml).with_context(|| format!("writing {}", config_path.display()))?;
let tunnel_meta = TunnelMeta {
uuid: uuid.to_string(),
hostname: hostname.to_string(),
service: service.clone(),
config_path: config_path.clone(),
};
meta.tunnels.insert(name.to_string(), tunnel_meta.clone());
save_metadata(meta)?;
Ok(tunnel_meta)
}
fn rewrite_config(path: &PathBuf, uuid: &str, hostname: &str, service: &str) -> Result<()> {
let cfg = TunnelConfig {
tunnel: uuid.to_string(),
credentials_file: credentials_file(uuid),
ingress: vec![
IngressRule {
hostname: hostname.to_string(),
service: service.to_string(),
},
IngressRule {
hostname: String::new(),
service: "http_status:404".to_string(),
},
],
};
let yaml = serde_yaml::to_string(&cfg).context("serializing tunnel config")?;
fs::write(path, yaml).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn tunnel_select_prompt(message: &str, meta: &Metadata) -> Result<String> {
if meta.tunnels.is_empty() {
anyhow::bail!("no tunnels managed by cftun. create one with `cftun create`");
}
let mut sel = select(message).filter_mode().max_rows(10);
for (n, t) in &meta.tunnels {
sel = sel.item(
n.clone(),
format!("{} https://{} {}", n.bold(), t.hostname, t.service.dimmed()),
"",
);
}
Ok(sel.interact()?)
}
fn unmanaged_tunnel_select_prompt(
message: &str,
cf_tunnels: &[CloudflaredTunnel],
meta: &Metadata,
) -> Result<String> {
let managed: HashSet<&String> = meta.tunnels.keys().collect();
let unmanaged: Vec<&CloudflaredTunnel> = cf_tunnels
.iter()
.filter(|t| !managed.contains(&t.name))
.collect();
if unmanaged.is_empty() {
anyhow::bail!("no unmanaged cloudflared tunnels found. create one with `cloudflared tunnel create <name>` or `cftun create`");
}
let mut sel = select(message).filter_mode().max_rows(10);
for t in unmanaged {
let status = if t.connections.is_empty() { "offline" } else { "online" };
sel = sel.item(
t.name.clone(),
format!("{} {} {}", t.name.bold(), t.id.dimmed(), status.dimmed()),
"",
);
}
Ok(sel.interact()?)
}
fn create(name: Option<&str>, hostname: Option<&str>, local: Option<&str>) -> Result<()> {
let interactive = name.is_none() || hostname.is_none() || local.is_none();
let mut meta = load_metadata()?;
let cf_tunnels = fetch_cloudflared_tunnels().unwrap_or_default();
if interactive {
intro("Create a new tunnel")?;
}
let name = match name {
Some(n) => n.to_string(),
None => {
let managed: HashSet<String> = meta.tunnels.keys().cloned().collect();
let cf_names: HashSet<String> = cf_tunnels.iter().map(|t| t.name.clone()).collect();
input("Tunnel name")
.placeholder("e.g. my-dev-tunnel")
.validate(move |s: &String| {
let s = s.trim();
if s.is_empty() {
Err("Name is required".to_string())
} else if managed.contains(s) {
Err("Already managed by cftun".to_string())
} else if cf_names.contains(s) {
Err("Exists in cloudflared — import it instead".to_string())
} else {
Ok(())
}
})
.interact()?
}
};
if cf_tunnels.iter().any(|t| t.name == name) {
anyhow::bail!(
"a cloudflared tunnel named '{}' already exists. pick a different name or import it with `cftun import {} <hostname> <port/url>`",
name,
name
);
}
if meta.tunnels.contains_key(&name) {
anyhow::bail!(
"tunnel '{}' already exists in cftun. run `cftun list` to see it",
name
);
}
let hostname = match hostname {
Some(h) => h.to_string(),
None => {
input("Public hostname")
.placeholder("e.g. webhook.example.com")
.validate(|s: &String| {
let s = s.trim();
if s.is_empty() {
Err("Hostname is required".to_string())
} else if !s.contains('.') {
Err("Must be a valid domain name".to_string())
} else {
Ok(())
}
})
.interact()?
}
};
let local = match local {
Some(l) => l.to_string(),
None => {
input("Local service")
.placeholder("e.g. 3000 or http://localhost:3000")
.autocomplete(vec![
"3000".to_string(),
"8080".to_string(),
"8443".to_string(),
"http://localhost:3000".to_string(),
"http://localhost:8080".to_string(),
])
.interact()?
}
};
if interactive {
let service = normalize_service(&local);
let yes = confirm(format!(
"Create tunnel '{}' → https://{} → {}?",
name, hostname, service
))
.interact()?;
if !yes {
outro_cancel("Cancelled.")?;
return Ok(());
}
}
if interactive {
let sp = spinner();
sp.start(format!("Creating tunnel '{}'...", name));
let output = cloudflared_ok(&["tunnel", "create", &name])?;
let uuid = parse_uuid(&output)
.ok_or_else(|| anyhow!("could not parse tunnel UUID from cloudflared output"))?;
sp.stop(format!("Created: {}", uuid));
let sp = spinner();
sp.start(format!("Routing DNS {} → {}...", hostname, uuid));
route_dns(&uuid, &hostname)?;
sp.stop("DNS route ensured");
let tm = write_tunnel_config(&mut meta, &name, &uuid, &hostname, &local)?;
outro_note(
format!("Tunnel '{}' is ready!", name),
format!(
"Public URL: https://{}\nLocal target: {}\nRun it with: cftun run {}",
tm.hostname, tm.service, name
),
)?;
} else {
println!("{} Creating cloudflared tunnel '{}'...", "→".cyan(), name);
let output = cloudflared_ok(&["tunnel", "create", &name])?;
let uuid = parse_uuid(&output)
.ok_or_else(|| anyhow!("could not parse tunnel UUID from cloudflared output"))?;
println!(" Created tunnel UUID: {}", uuid.dimmed());
println!("{} Routing {} → {}...", "→".cyan(), hostname, uuid);
route_dns(&uuid, &hostname)?;
let tm = write_tunnel_config(&mut meta, &name, &uuid, &hostname, &local)?;
println!("{} Tunnel '{}' ready.", "✓".green(), name);
println!(
" Public URL: {}",
format!("https://{}", tm.hostname).green().underline()
);
println!(" Local target: {}", tm.service.dimmed());
println!(" Run it with: {}", format!("cftun run {}", name).cyan());
}
Ok(())
}
fn update(name: Option<&str>, hostname: Option<&str>, local: Option<&str>) -> Result<()> {
let mut meta = load_metadata()?;
let interactive = name.is_none() || (hostname.is_none() && local.is_none());
if interactive {
intro("Update a tunnel")?;
}
let name = match name {
Some(n) => n.to_string(),
None => {
if meta.tunnels.is_empty() {
outro_cancel("No tunnels to update. Create one with `cftun create`.")?;
return Ok(());
}
tunnel_select_prompt("Select a tunnel to update", &meta)?
}
};
let t = meta.tunnels.get(&name).ok_or_else(|| {
anyhow!(
"tunnel '{}' not found. run `cftun list` to see available tunnels (or import it with `cftun import <name> <hostname> <port/url>`)",
name
)
})?;
let old_hostname = t.hostname.clone();
let old_service = t.service.clone();
let uuid = t.uuid.clone();
let config_path = t.config_path.clone();
let hostname = match hostname {
Some(h) => Some(h.to_string()),
None => {
if interactive {
let h: String = input("New hostname (Enter keeps current)")
.default_input(&old_hostname)
.required(false)
.interact()?;
let h = h.trim();
if h.is_empty() || h == old_hostname {
None
} else {
Some(h.to_string())
}
} else {
None
}
}
};
let local = match local {
Some(l) => Some(l.to_string()),
None => {
if interactive {
let l: String = input("New local service (Enter keeps current)")
.default_input(&old_service)
.required(false)
.autocomplete(vec![
"3000".to_string(),
"8080".to_string(),
"8443".to_string(),
"http://localhost:3000".to_string(),
"http://localhost:8080".to_string(),
])
.interact()?;
let l = l.trim();
if l.is_empty() || l == old_service {
None
} else {
Some(l.to_string())
}
} else {
None
}
}
};
if hostname.is_none() && local.is_none() {
if interactive {
outro_cancel("No changes to make.")?;
return Ok(());
} else {
anyhow::bail!("provide at least one of --hostname or --local");
}
}
let new_hostname = hostname.unwrap_or_else(|| old_hostname.clone());
let new_service = local.as_deref().map(normalize_service).unwrap_or_else(|| old_service.clone());
let hostname_changed = new_hostname != old_hostname;
if interactive {
let yes = confirm(format!(
"Update tunnel '{}'? https://{} → {}",
name, new_hostname, new_service
))
.interact()?;
if !yes {
outro_cancel("Cancelled.")?;
return Ok(());
}
}
if interactive {
if hostname_changed {
let sp = spinner();
sp.start(format!("Updating DNS route: {} → {}...", new_hostname, uuid));
route_dns(&uuid, &new_hostname)?;
sp.stop("DNS route updated");
warn_dns_cleanup_unsupported(&old_hostname);
}
let sp = spinner();
sp.start("Writing config...");
rewrite_config(&config_path, &uuid, &new_hostname, &new_service)?;
let t = meta.tunnels.get_mut(&name).unwrap();
t.hostname = new_hostname.clone();
t.service = new_service.clone();
save_metadata(&meta)?;
sp.stop("Config updated");
outro_note(
format!("Tunnel '{}' updated!", name),
format!("Public URL: https://{}\nLocal target: {}", new_hostname, new_service),
)?;
} else {
if hostname_changed {
println!(
"{} Updating DNS route: {} → {}...",
"→".cyan(),
new_hostname,
uuid
);
route_dns(&uuid, &new_hostname)?;
warn_dns_cleanup_unsupported(&old_hostname);
}
rewrite_config(&config_path, &uuid, &new_hostname, &new_service)?;
let t = meta.tunnels.get_mut(&name).unwrap();
t.hostname = new_hostname.clone();
t.service = new_service.clone();
save_metadata(&meta)?;
println!("{} Tunnel '{}' updated.", "✓".green(), name);
println!(
" Public URL: {}",
format!("https://{}", new_hostname).green().underline()
);
println!(" Local target: {}", new_service.dimmed());
}
Ok(())
}
fn delete(name: Option<&str>, cleanup_arg: bool) -> Result<()> {
let mut meta = load_metadata()?;
let interactive = name.is_none();
if interactive {
intro("Delete a tunnel")?;
}
let name = match name {
Some(n) => n.to_string(),
None => {
if meta.tunnels.is_empty() {
outro_cancel("No tunnels managed by cftun.")?;
return Ok(());
}
tunnel_select_prompt("Select a tunnel to delete", &meta)?
}
};
let t = meta.tunnels.get(&name).ok_or_else(|| {
anyhow!(
"tunnel '{}' not found in cftun metadata. run `cftun list` to see available tunnels (or import it with `cftun import {} <hostname> <port/url>`)",
name,
name
)
})?;
let uuid = t.uuid.clone();
let hostname = t.hostname.clone();
let config_path = t.config_path.clone();
let cleanup = if interactive {
let choice = select("Cleanup mode")
.item("local", "Remove from cftun only", "keep the tunnel in Cloudflare")
.item(
"full",
"Full cleanup",
"delete tunnel and config file; DNS may need manual cleanup",
)
.interact()?;
let verb = if choice == "full" { "Delete" } else { "Remove" };
let yes = confirm(format!("{verb} tunnel '{}'?", name)).interact()?;
if !yes {
outro_cancel("Cancelled.")?;
return Ok(());
}
choice == "full"
} else {
cleanup_arg
};
meta.tunnels.remove(&name);
if interactive {
if cleanup {
let sp = spinner();
sp.start(format!("Deleting tunnel '{}' from Cloudflare...", name));
let _ = run_cloudflared(&["tunnel", "delete", &uuid]);
sp.stop("Deleted");
warn_dns_cleanup_unsupported(&hostname);
if config_path.exists() {
sp.start("Removing config file...");
fs::remove_file(&config_path)
.with_context(|| format!("deleting {}", config_path.display()))?;
sp.stop("Removed");
}
save_metadata(&meta)?;
outro(format!("Tunnel '{}' deleted; check DNS for stale hostname routes.", name))?;
} else {
save_metadata(&meta)?;
outro(format!(
"Removed '{}' from cftun. Still exists in Cloudflare.",
name
))?;
}
} else {
if cleanup {
println!("{} Deleting cloudflared tunnel '{}'...", "→".cyan(), name);
let _ = run_cloudflared(&["tunnel", "delete", &uuid]);
warn_dns_cleanup_unsupported(&hostname);
if config_path.exists() {
fs::remove_file(&config_path)
.with_context(|| format!("deleting {}", config_path.display()))?;
}
save_metadata(&meta)?;
println!("{} Tunnel '{}' removed.", "✓".green(), name);
} else {
save_metadata(&meta)?;
println!(
"{} Tunnel '{}' removed from cftun metadata. Use {} to also delete from Cloudflare.",
"✓".green(),
name,
"--cleanup".cyan()
);
}
}
Ok(())
}
fn list() -> Result<()> {
let cf = fetch_cloudflared_tunnels().unwrap_or_default();
let meta = load_metadata()?;
let known: HashSet<&String> = meta.tunnels.keys().collect();
println!("{} Tunnels managed by cftun:", "●".cyan());
if meta.tunnels.is_empty() {
println!(" (none)");
} else {
for (name, t) in &meta.tunnels {
let online = cf.iter().any(|c| c.id == t.uuid && !c.connections.is_empty());
let status = if online { "online".green() } else { "offline".dimmed() };
println!(
" {} {} → {} ({}) [{}]",
"•".cyan(),
name.bold(),
format!("https://{}", t.hostname).green(),
t.service.dimmed(),
status
);
}
}
println!();
let others: Vec<&CloudflaredTunnel> = cf.iter().filter(|c| !known.contains(&c.name)).collect();
if !others.is_empty() {
println!("{} Other cloudflared tunnels:", "●".cyan());
for t in &others {
let online = if t.connections.is_empty() {
"offline".dimmed()
} else {
"online".green()
};
println!(" {} {} ({}) [{}]", "•".cyan(), t.name.bold(), t.id.dimmed(), online);
}
println!();
}
println!(
" Create one with: {}",
"cftun create".cyan()
);
if !others.is_empty() {
println!(
" Or adopt an existing tunnel with: {}",
"cftun import <name> <hostname> <port/url>".cyan()
);
}
Ok(())
}
fn status() -> Result<()> {
let cf = fetch_cloudflared_tunnels()?;
let meta = load_metadata()?;
println!("{}", "Cloudflared tunnel status".bold());
println!(" {}{}", "Total tunnels: ".dimmed(), cf.len());
println!(" {}{}", "Managed by cftun: ".dimmed(), meta.tunnels.len());
println!(
" {}{}",
"Currently online: ".dimmed(),
cf.iter().filter(|t| !t.connections.is_empty()).count()
);
println!();
for t in &cf {
let online = !t.connections.is_empty();
let status = if online {
"online".green().bold()
} else {
"offline".dimmed()
};
let managed = meta.tunnels.get(&t.name);
println!(" {} {} - {}", status, t.name.bold(), t.id.dimmed());
if let Some(m) = managed {
println!(
" {} → {}",
format!("https://{}", m.hostname).green(),
m.service.dimmed()
);
}
println!(
" Created: {} | Connections: {}",
t.created_at.dimmed(),
t.connections.len()
);
}
Ok(())
}
fn show(name: Option<&str>) -> Result<()> {
let meta = load_metadata()?;
let name = match name {
Some(name) => name.to_string(),
None => tunnel_select_prompt("Select a tunnel to show", &meta)?,
};
let t = meta.tunnels.get(&name).ok_or_else(|| {
anyhow!(
"tunnel '{}' not found. run `cftun list` to see available tunnels (or import it with `cftun import <name> <hostname> <port/url>`)",
name
)
})?;
let content =
fs::read_to_string(&t.config_path).with_context(|| format!("reading {}", t.config_path.display()))?;
println!(
"{} Config for '{}' ({}):",
"●".cyan(),
name.bold(),
t.config_path.display()
);
println!("{}", content);
Ok(())
}
fn import(name: Option<&str>, hostname: Option<&str>, local: Option<&str>) -> Result<()> {
let interactive = name.is_none() || hostname.is_none() || local.is_none();
let mut meta = load_metadata()?;
if interactive {
intro("Import a tunnel")?;
}
let cf = fetch_cloudflared_tunnels().context("fetching cloudflared tunnels")?;
let name = match name {
Some(name) => name.to_string(),
None => unmanaged_tunnel_select_prompt("Select a tunnel to import", &cf, &meta)?,
};
if meta.tunnels.contains_key(&name) {
anyhow::bail!("tunnel '{}' is already managed by cftun", name);
}
let existing = cf
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| {
anyhow!(
"no cloudflared tunnel named '{}' found. run `cftun list` to see existing tunnels",
name
)
})?;
let hostname = match hostname {
Some(hostname) => hostname.to_string(),
None => input("Public hostname")
.placeholder("e.g. webhook.example.com")
.validate(|s: &String| {
let s = s.trim();
if s.is_empty() {
Err("Hostname is required".to_string())
} else if !s.contains('.') {
Err("Must be a valid domain name".to_string())
} else {
Ok(())
}
})
.interact()?,
};
let local = match local {
Some(local) => local.to_string(),
None => input("Local service")
.placeholder("e.g. 3000 or http://localhost:3000")
.autocomplete(vec![
"3000".to_string(),
"8080".to_string(),
"8443".to_string(),
"http://localhost:3000".to_string(),
"http://localhost:8080".to_string(),
])
.interact()?,
};
println!("{} Routing DNS {} → {}...", "→".cyan(), hostname, existing.id);
route_dns(&existing.id, &hostname)?;
let tm = write_tunnel_config(&mut meta, &name, &existing.id, &hostname, &local)?;
println!("{} Tunnel '{}' imported.", "✓".green(), name);
println!(
" Public URL: {}",
format!("https://{}", tm.hostname).green().underline()
);
println!(" Local target: {}", tm.service.dimmed());
println!(" Run it with: {}", format!("cftun run {}", name).cyan());
Ok(())
}
fn run(name: Option<&str>) -> Result<()> {
let meta = load_metadata()?;
let name = match name {
Some(name) => name.to_string(),
None => tunnel_select_prompt("Select a tunnel to run", &meta)?,
};
let t = meta.tunnels.get(&name).ok_or_else(|| {
anyhow!(
"tunnel '{}' not found. run `cftun list` to see available tunnels (or import it with `cftun import <name> <hostname> <port/url>`)",
name
)
})?;
println!(
"{} Starting tunnel '{}' → {} → {}\n",
"→".cyan(),
name,
format!("https://{}", t.hostname).green(),
t.service.dimmed()
);
if let Err(err) = route_dns(&t.uuid, &t.hostname) {
eprintln!(
"{} Could not ensure DNS route before starting: {:#}",
"!".yellow(),
err
);
eprintln!(
" If Cloudflare shows 1033, run: cloudflared tunnel route dns --overwrite-dns {} {}",
t.uuid, t.hostname
);
}
let mut child = Command::new("cloudflared")
.args(&["tunnel", "--config", &t.config_path.to_string_lossy(), "run"])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| "running cloudflared tunnel")?;
let status = child.wait().context("waiting for cloudflared process")?;
if !status.success() {
anyhow::bail!("cloudflared exited with {}", status);
}
Ok(())
}