Skip to content

Commit

Permalink
feat: paid tunnels
Browse files Browse the repository at this point in the history
  • Loading branch information
charlottea98 committed Jun 4, 2024
1 parent c8bc4f1 commit c2aec9f
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 102 deletions.
93 changes: 63 additions & 30 deletions clear-unused-dns.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,75 @@
async function deleteTXTRecords(
recordName: string,
zoneId: string,
apiToken: string,
email: string
) {
const baseUrl = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
async function deleteTXTRecords() {
const baseUrl = `https://api.cloudflare.com/client/v4/zones/${mentimeter_dev_zone_id}/dns_records`;

// Fetch all DNS records
const getRecords = async () => {
const response = await fetch(`${baseUrl}?per_page=1000`, {
headers: {
"Content-Type": "application/json",
"X-Auth-Email": email,
"X-Auth-Key": apiToken,
Authorization: `Bearer ${api_token}`,
},
});
const data = await response.json();
return data.result.filter(
(record: any) =>
record.type === "TXT" && record.name.startsWith(recordName)
record.type === "TXT" && record.name.startsWith("_acme-challenge")
);
};

const recordsToDelete = await getRecords();
await doBatchDelete(baseUrl, recordsToDelete);
}

async function deleteTunnelCNAMERecords() {
const baseUrl = `https://api.cloudflare.com/client/v4/zones/${mentimeter_dev_zone_id}/dns_records`;

// Fetch all DNS records
const getRecords = async () => {
const response = await fetch(`${baseUrl}?per_page=1000`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${api_token}`,
},
});
const data = await response.json();
return data.result.filter(
(record: any) =>
record.type === "CNAME" && record.name.startsWith("tunnel-")
);
};

const recordsToDelete = await getRecords();
await doBatchDelete(baseUrl, recordsToDelete);
}

async function deleteTunnels() {
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${account_id}/cfd_tunnel`;

// Fetch all tunnels
const getRecords = async () => {
const response = await fetch(`${baseUrl}?per_page=1000`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${api_token}`,
},
});
const data = await response.json();
return data.result.filter(
(tunnel: any) => tunnel.name.startsWith("tunnel-") && !tunnel.deleted_at
);
};

const recordsToDelete = await getRecords();
await doBatchDelete(baseUrl, recordsToDelete);
}

async function doBatchDelete(url: string, recordsToDelete: any[]) {
const deleteRecord = async (id: string) => {
const response = await fetch(`${baseUrl}/${id}`, {
const response = await fetch(`${url}/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-Auth-Email": email,
"X-Auth-Key": apiToken,
Authorization: `Bearer ${api_token}`,
},
});

Expand All @@ -40,7 +81,6 @@ async function deleteTXTRecords(
return response.json();
};

const recordsToDelete = await getRecords();
console.log("Records to delete:", recordsToDelete.length);

// Batch deletion, 20 records at a time
Expand All @@ -56,25 +96,18 @@ async function deleteTXTRecords(
await batchDelete(recordsToDelete);
}

const zones = process.argv.slice(2);
const apikey = process.env.CLOUDFLARE_API_KEY;
const email = process.env.CLOUDFLARE_EMAIL;
const api_token = process.env.LINKUP_CF_API_TOKEN;
const account_id = process.env.LINKUP_CLOUDFLARE_ACCOUNT_ID;
const mentimeter_dev_zone_id = process.env.LINKUP_CLOUDFLARE_ZONE_ID;

if (!apikey || !email) {
console.error("Missing Cloudflare API key or email");
if (!api_token || !account_id || !mentimeter_dev_zone_id) {
console.error("Missing Cloudflare API Token, Account ID or Zone ID");
console.error(
"Please set CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL environment variables"
"Run `menti localsecrets` to set the required environment variables"
);
process.exit(1);
}

if (zones.length === 0) {
console.error("No zones specified");
console.error("Usage: clear-unused-dns.ts zone1 zone2 ...");
process.exit(1);
}

for (const zone of zones) {
console.log("Deleting records for zone:", zone);
deleteTXTRecords("_acme-challenge", zone, apikey, email);
}
deleteTXTRecords();
deleteTunnelCNAMERecords();
deleteTunnels();
55 changes: 30 additions & 25 deletions linkup-cli/src/background_booting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use url::Url;

use crate::local_config::{LocalState, ServiceTarget};
use crate::services::local_server::{is_local_server_started, start_local_server};
use crate::services::tunnel::{is_tunnel_running, RealTunnelManager, TunnelManager};
use crate::services::tunnel::{RealTunnelManager, TunnelManager};
use crate::status::print_session_names;
use crate::worker_client::WorkerClient;
use crate::{linkup_file_path, services, LINKUP_LOCALSERVER_PORT};
Expand All @@ -21,6 +21,7 @@ use crate::{CliError, LINKUP_LOCALDNS_INSTALL};
#[cfg_attr(test, mockall::automock)]
pub trait BackgroundServices {
fn boot_background_services(&self, state: LocalState) -> Result<LocalState, CliError>;
fn boot_local_dns(&self, domains: Vec<String>, session_name: String) -> Result<(), CliError>;
}

pub struct RealBackgroundServices;
Expand All @@ -40,19 +41,21 @@ impl BackgroundServices for RealBackgroundServices {
wait_till_ok(format!("{}linkup-check", local_url))?;

let should_run_free = state.linkup.is_paid.is_none() || !state.linkup.is_paid.unwrap();
if state.should_use_tunnel() && should_run_free {
if is_tunnel_running().is_err() {
println!("Starting tunnel...");
if should_run_free {
if state.should_use_tunnel() {
let tunnel_manager = RealTunnelManager {};
let tunnel = tunnel_manager.run_tunnel(&state)?;
state.linkup.tunnel = Some(tunnel);
if tunnel_manager.is_tunnel_running().is_err() {
println!("Starting tunnel...");
let tunnel = tunnel_manager.run_tunnel(&state)?;
state.linkup.tunnel = Some(tunnel);
} else {
println!("Cloudflare tunnel was already running.. Try stopping linkup first if you have problems.");
}
} else {
println!("Cloudflare tunnel was already running.. Try stopping linkup first if you have problems.");
println!(
"Skipping tunnel start... WARNING: not all kinds of requests will work in this mode."
);
}
} else {
println!(
"Skipping tunnel start... WARNING: not all kinds of requests will work in this mode."
);
}

let server_config = ServerConfig::from(&state);
Expand All @@ -72,22 +75,31 @@ impl BackgroundServices for RealBackgroundServices {
state.linkup.session_name = server_session_name;
state.save()?;

if linkup_file_path(LINKUP_LOCALDNS_INSTALL).exists() {
boot_local_dns(state.domain_strings(), state.linkup.session_name.clone())?;
}
if should_run_free {
if linkup_file_path(LINKUP_LOCALDNS_INSTALL).exists() {
self.boot_local_dns(state.domain_strings(), state.linkup.session_name.clone())?;
}

if let Some(tunnel) = &state.linkup.tunnel {
println!("Waiting for tunnel DNS to propagate at {}...", tunnel);
if let Some(tunnel) = &state.linkup.tunnel {
println!("Waiting for tunnel DNS to propagate at {}...", tunnel);

wait_for_dns_ok(tunnel.clone())?;
wait_for_dns_ok(tunnel.clone())?;

println!();
println!();
}
}

print_session_names(&state);

Ok(state)
}

fn boot_local_dns(&self, domains: Vec<String>, session_name: String) -> Result<(), CliError> {
services::caddy::start(domains.clone())?;
services::dnsmasq::start(domains, session_name)?;

Ok(())
}
}

pub fn load_config(
Expand All @@ -110,13 +122,6 @@ pub fn load_config(
Ok(content)
}

pub fn boot_local_dns(domains: Vec<String>, session_name: String) -> Result<(), CliError> {
services::caddy::start(domains.clone())?;
services::dnsmasq::start(domains, session_name)?;

Ok(())
}

pub struct ServerConfig {
pub local: StorableSession,
pub remote: StorableSession,
Expand Down
19 changes: 13 additions & 6 deletions linkup-cli/src/paid_tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct GetTunnelApiResponse {
struct TunnelResultItem {
id: String,
name: String,
deleted_at: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
Expand Down Expand Up @@ -138,13 +139,21 @@ impl PaidTunnelManager for RealPaidTunnelManager {
account_id
);
let (client, headers) = prepare_client_and_headers(&RealSystem)?;
let query_url = format!("{}?name=tunnel-{}", url, tunnel_name);
let query_url = format!("{}?name={}", url, tunnel_name);

let parsed: GetTunnelApiResponse = send_request(&client, &query_url, headers, None, "GET")?;
if parsed.result.is_empty() {
Ok(None)
} else {
Ok(Some(parsed.result[0].id.clone()))
// Check if there exists a tunnel with this name that hasn't been deleted
match parsed
.result
.iter()
.find(|tunnel| tunnel.deleted_at.is_none())
{
Some(tunnel) => Ok(Some(tunnel.id.clone())),
None => Ok(None),
}
}
}

Expand All @@ -158,7 +167,7 @@ impl PaidTunnelManager for RealPaidTunnelManager {
);
let (client, headers) = prepare_client_and_headers(&RealSystem)?;
let body = serde_json::to_string(&CreateTunnelRequest {
name: format!("tunnel-{}", tunnel_name),
name: tunnel_name.to_string(),
tunnel_secret: tunnel_secret.clone(),
})
.map_err(|err| CliError::StatusErr(err.to_string()))?;
Expand All @@ -182,15 +191,13 @@ impl PaidTunnelManager for RealPaidTunnelManager {
);
let (client, headers) = prepare_client_and_headers(&RealSystem)?;
let body = serde_json::to_string(&DNSRecord {
name: format!("tunnel-{}", tunnel_name),
name: tunnel_name.to_string(),
content: format!("{}.cfargotunnel.com", tunnel_id),
r#type: "CNAME".to_string(),
proxied: true,
})
.map_err(|err| CliError::StatusErr(err.to_string()))?;

println!("{}", body);

let _parsed: CreateDNSRecordResponse =
send_request(&client, &url, headers, Some(body), "POST")?;
Ok(())
Expand Down
26 changes: 10 additions & 16 deletions linkup-cli/src/services/tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,10 @@ const LINKUP_CLOUDFLARED_STDERR: &str = "cloudflared-stderr";

const TUNNEL_START_WAIT: u64 = 20;

pub fn is_tunnel_running() -> Result<(), CheckErr> {
if !linkup_file_path(LINKUP_CLOUDFLARED_PID).exists() {
Err(CheckErr::TunnelNotRunning)
} else {
Ok(())
}
}

#[cfg_attr(test, mockall::automock)]
pub trait TunnelManager {
fn run_tunnel(&self, state: &LocalState) -> Result<Url, CliError>;
fn is_tunnel_running(&self) -> Result<(), CheckErr>;
}

pub struct RealTunnelManager;
Expand Down Expand Up @@ -61,6 +54,13 @@ impl TunnelManager for RealTunnelManager {
}
}
}
fn is_tunnel_running(&self) -> Result<(), CheckErr> {
if !linkup_file_path(LINKUP_CLOUDFLARED_PID).exists() {
Err(CheckErr::TunnelNotRunning)
} else {
Ok(())
}
}
}

fn try_run_tunnel(state: &LocalState) -> Result<Url, CliError> {
Expand Down Expand Up @@ -100,7 +100,7 @@ fn try_run_tunnel(state: &LocalState) -> Result<Url, CliError> {
}

let is_paid = state.linkup.is_paid.is_some() && state.linkup.is_paid.unwrap();
let session_name = state.linkup.session_name.clone();
let paid_tunnel_url = state.get_tunnel_url();

let tunnel_url_re =
Regex::new(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com").expect("Failed to compile regex");
Expand All @@ -127,13 +127,7 @@ fn try_run_tunnel(state: &LocalState) -> Result<Url, CliError> {
for line in buf_reader.lines() {
let line = line.unwrap_or_default();
if is_paid {
url = Some(
Url::parse(
format!("https://tunnel-{}.mentimeter.dev", session_name)
.as_str(),
)
.expect("Failed to parse tunnel URL"),
);
url = Some(paid_tunnel_url.clone());
} else if let Some(url_match) = tunnel_url_re.find(&line) {
let found_url =
Url::parse(url_match.as_str()).expect("Failed to parse tunnel URL");
Expand Down
Loading

0 comments on commit c2aec9f

Please sign in to comment.