use std::io::{BufRead, IsTerminal, Write};
use cctop_core::cloudflare::login::Login;
use cctop_core::cloudflare::{self, Api, Pasted};
use cctop_core::tunnel::{self, Account};
pub const HELP: &str = "\
cctop tunnel — your own Cloudflare tunnel, for a link that stays the same
USAGE:
cctop tunnel setup --browser [--hostname <NAME>]
cctop tunnel setup [--zone <DOMAIN>] [--hostname <NAME>]
cctop tunnel status
cctop tunnel remove
`cctop serve --tunnel` and the dashboard's tunnel use a trycloudflare quick
tunnel: nothing to set up, but a new address every run, no Server-Sent Events
(which the live table is), and at most 200 requests in flight. A tunnel on
your own free Cloudflare account has none of those limits and keeps one
hostname. It needs a domain whose DNS is on Cloudflare.
setup With --browser, log in to Cloudflare in the browser and pick the
domain there: nothing to make or paste. Over ssh, the address to
open is printed. Without it, paste an API token (cctop prints a
link that makes one with the three permissions it needs) and pick
a domain. Either way cctop creates the tunnel and its DNS records.
A tunnel token from the dashboard works too. Piped, one token is
read from stdin.
status What is connected.
remove Delete what setup created on Cloudflare — the DNS records and the
tunnel, by the ids it stored, nothing else — and forget it.
CCTOP_TUNNEL_TOKEN (a tunnel token) and CCTOP_TUNNEL_HOSTNAME connect a tunnel
without a config file, for a service; they win over what setup stored.
";
pub fn run(argv: &[String]) -> anyhow::Result<i32> {
match argv.first().map(String::as_str) {
Some("setup") => setup(&argv[1..]),
Some("status") => Ok(status()),
Some("remove") => Ok(remove()),
Some("-h" | "--help") | None => {
print!("{HELP}");
Ok(0)
}
Some(other) => {
eprintln!("cctop tunnel: unknown command '{other}'\n\n{HELP}");
Ok(2)
}
}
}
fn status() -> i32 {
let Some(account) = tunnel::account() else {
println!(
"No Cloudflare account connected: --tunnel uses a quick tunnel, at a new \
address each run.\n`cctop tunnel setup` connects one."
);
return 0;
};
let from = match account.from_env {
true => "CCTOP_TUNNEL_TOKEN",
false => "config.toml",
};
match &account.hostname {
Some(host) => println!("Connected: https://{host} (from {from})"),
None => println!(
"Connected (from {from}); the hostname comes from the tunnel's configuration \
when it connects"
),
}
if let Some(share) = &account.share_hostname {
println!("Share hostname: https://{share}");
}
if let Some(id) = &account.tunnel_id {
println!("Tunnel: {id}");
}
if tunnel::in_use(&account) {
println!("A cctop on this machine is serving over it now.");
}
0
}
fn remove() -> i32 {
let Some(account) = tunnel::account() else {
println!("No Cloudflare account is connected; nothing to remove.");
return 0;
};
if account.from_env {
eprintln!(
"cctop: that tunnel comes from CCTOP_TUNNEL_TOKEN, not from setup: unset it \
to stop using it. Delete the tunnel itself in the Cloudflare dashboard."
);
return 1;
}
if tunnel::in_use(&account) {
eprintln!(
"cctop: a cctop on this machine is serving over this tunnel. Stop it first, \
then run this again."
);
return 1;
}
let left = cloudflare::remove(&account);
if let Err(e) = tunnel::clear_account() {
eprintln!("cctop: could not update config.toml: {e}");
return 1;
}
match (account.api_token.is_some(), left.0.is_empty()) {
(false, _) => println!(
"Forgotten. The tunnel was made in the Cloudflare dashboard, so it is still \
there; delete it there if you no longer want it."
),
(true, true) => println!("Removed: the tunnel and its DNS records are gone."),
(true, false) => {
println!("Forgotten, but these are left on Cloudflare to delete by hand:");
for item in &left.0 {
println!(" - {item}");
}
}
}
0
}
fn setup(argv: &[String]) -> anyhow::Result<i32> {
let mut zone_given: Option<String> = None;
let mut hostname_given: Option<String> = None;
let mut browser = false;
let mut it = argv.iter();
while let Some(flag) = it.next() {
let mut value = || {
it.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match flag.as_str() {
"--zone" => zone_given = Some(value()?),
"--hostname" => hostname_given = Some(value()?),
"--browser" => browser = true,
"-h" | "--help" => {
print!("{HELP}");
return Ok(0);
}
other => anyhow::bail!("unknown option '{other}'\n\n{HELP}"),
}
}
if let Some(account) = tunnel::account()
&& !account.from_env
{
let at = account
.hostname
.as_deref()
.unwrap_or("its configured hostname");
eprintln!(
"cctop: a tunnel is already connected ({at}). `cctop tunnel remove` first to \
connect another."
);
return Ok(1);
}
let interactive = std::io::stdin().is_terminal();
if browser {
if zone_given.is_some() {
eprintln!("cctop: with --browser the domain is picked in the browser; drop --zone.");
return Ok(1);
}
let Some(account) = log_in(hostname_given, interactive)? else {
return Ok(1);
};
return connected(&account);
}
let pasted = match interactive {
true => {
eprintln!(
"A tunnel on your own Cloudflare account keeps one hostname, so a link or a \
phone shortcut keeps working.\n\n\
1. Make an API token (the link fills in the permissions and the name):\n\
\x20 {}\n\
\x20 It needs: {}.\n\
2. Paste it below. A tunnel token from the dashboard works too.\n",
cloudflare::token_link(),
cloudflare::PERMISSIONS.join(", ")
);
eprint!("Token (not shown as you paste): ");
read_secret()?
}
false => read_line()?,
};
if pasted.is_empty() {
eprintln!("cctop: no token given; nothing written.");
return Ok(1);
}
let account = match cloudflare::classify(&pasted) {
Pasted::Tunnel(token) => {
if let Err(e) = cloudflare::check_tunnel_token(&token) {
eprintln!("cctop: {e}");
return Ok(1);
}
let hostname = match (hostname_given, interactive) {
(Some(h), _) => Some(h),
(None, true) => {
eprint!(
"Hostname the tunnel serves (Enter to take it from the tunnel's \
configuration): "
);
Some(read_line()?).filter(|h| !h.is_empty())
}
(None, false) => None,
};
Account {
token,
hostname,
..Account::default()
}
}
Pasted::Api(token) => match create(&token, zone_given, hostname_given, interactive)? {
Some(account) => account,
None => return Ok(1),
},
};
connected(&account)
}
fn connected(account: &Account) -> anyhow::Result<i32> {
tunnel::save_account(account)?;
match &account.hostname {
Some(host) => eprintln!("Connected: https://{host}"),
None => eprintln!("Connected; the hostname is learned when the tunnel first connects."),
}
eprintln!("`cctop serve --tunnel`, and t in the dashboard's serve panel, now use it.");
Ok(0)
}
fn log_in(hostname_given: Option<String>, interactive: bool) -> anyhow::Result<Option<Account>> {
let login = Login::new();
let opened = !cctop_core::clipboard::over_ssh() && cctop_serve::open_in_browser(login.url());
match opened {
true => eprintln!(
"A browser should have opened Cloudflare's login at:\n\n{}\n\nIf it did not, \
open that address yourself. Log in and pick the domain for the tunnel.",
login.url()
),
false => eprintln!(
"Open this address in a browser, log in to Cloudflare and pick the domain for \
the tunnel:\n\n{}\n\nLeave this running: the login comes back here.",
login.url()
),
}
eprintln!("\nWaiting for the login…");
let cert = match login.wait(&|| false) {
Ok(cert) => cert,
Err(e) => return Ok(refuse(&e)),
};
let api = Api::with(&cert.auth());
let zone = api.login_zone(&cert);
if let Some(zone) = &zone {
eprintln!("Logged in: the tunnel goes on {}.", zone.name);
}
let machine = cloudflare::machine_label();
let (zone, hostname) = match (zone, hostname_given) {
(Some(zone), Some(hostname)) => (zone, hostname),
(Some(zone), None) => {
let suggested = match cloudflare::suggest_hostname(&api, &zone, &machine) {
Ok(name) => name,
Err(e) => return Ok(refuse(&e)),
};
let hostname = match interactive {
true => {
eprint!("Hostname [{suggested}]: ");
Some(read_line()?)
.filter(|h| !h.is_empty())
.unwrap_or(suggested)
}
false => suggested,
};
(zone, hostname)
}
(None, given) => {
let hostname = match (given, interactive) {
(Some(hostname), _) => hostname,
(None, true) => {
eprint!(
"Logged in. The address for the dashboard, on the domain you picked \
(like cctop.example.com): "
);
read_line()?
}
(None, false) => {
eprintln!(
"cctop: logged in, but the domain's name could not be read; pass \
--hostname <NAME> on it."
);
return Ok(None);
}
};
match cloudflare::login_zone_from(&cert.zone_id, &cert.account_id, &hostname) {
Ok(zone) => (zone, hostname),
Err(e) => return Ok(refuse(&e)),
}
}
};
eprintln!("Creating the tunnel and its DNS records…");
match cloudflare::create(&api, &zone, &hostname, &machine) {
Ok(account) => Ok(Some(account)),
Err(e) => Ok(refuse(&e)),
}
}
fn create(
token: &str,
zone_given: Option<String>,
hostname_given: Option<String>,
interactive: bool,
) -> anyhow::Result<Option<Account>> {
let api = Api::new(token);
eprintln!("Checking the token with Cloudflare…");
let zones = match api
.verify()
.and_then(|()| api.zones())
.and_then(cloudflare::usable)
{
Ok(zones) => zones,
Err(e) => return Ok(refuse(&e)),
};
let zone = match zone_given {
Some(name) => match zones.iter().find(|z| z.name.eq_ignore_ascii_case(&name)) {
Some(zone) => zone.clone(),
None => {
let names: Vec<&str> = zones.iter().map(|z| z.name.as_str()).collect();
eprintln!(
"cctop: {name} is not an active domain on this account; it has {}",
names.join(", ")
);
return Ok(None);
}
},
None if zones.len() == 1 || !interactive => zones[0].clone(),
None => {
eprintln!("Which domain?");
for (i, zone) in zones.iter().enumerate() {
eprintln!(" {}. {}", i + 1, zone.name);
}
eprint!("Number [1]: ");
let answer = read_line()?;
let pick = match answer.is_empty() {
true => 0,
false => answer.parse::<usize>().unwrap_or(0).saturating_sub(1),
};
zones.get(pick).unwrap_or(&zones[0]).clone()
}
};
let machine = cloudflare::machine_label();
let hostname = match hostname_given {
Some(h) => h,
None => {
let suggested = match cloudflare::suggest_hostname(&api, &zone, &machine) {
Ok(name) => name,
Err(e) => return Ok(refuse(&e)),
};
match interactive {
true => {
eprint!("Hostname [{suggested}]: ");
Some(read_line()?)
.filter(|h| !h.is_empty())
.unwrap_or(suggested)
}
false => suggested,
}
}
};
eprintln!("Creating the tunnel and its DNS records…");
match cloudflare::create(&api, &zone, &hostname, &machine) {
Ok(account) => Ok(Some(account)),
Err(e) => Ok(refuse(&e)),
}
}
fn refuse(error: &cloudflare::Error) -> Option<Account> {
eprintln!("cctop: {error}");
eprintln!("Nothing was written, and --tunnel still uses a quick tunnel.");
None
}
fn read_line() -> anyhow::Result<String> {
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn read_secret() -> anyhow::Result<String> {
struct Restore(libc::termios);
impl Drop for Restore {
fn drop(&mut self) {
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0) };
}
}
let mut original: libc::termios = unsafe { std::mem::zeroed() };
let restore = match unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut original) } {
0 => {
let mut quiet = original;
quiet.c_lflag &= !libc::ECHO;
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &quiet) };
Some(Restore(original))
}
_ => None,
};
let line = read_line();
drop(restore);
eprintln!();
let _ = std::io::stderr().flush();
line
}