Skip to content

Commit

Permalink
feat: paid tunnels v1 (#97)
Browse files Browse the repository at this point in the history
Tested going from paid to free tunnel, and vice versa. Works smoothly
for me at least 🤞

Some extra logging added that shows up when running `RUST_LOG=info cargo
run -- start`. There is more that can be done with logging but will leave that for the linear card DO-1495 "linkup: logger".

Part of DO-1495
Resolves DO-1496
  • Loading branch information
charlottea98 authored Jun 10, 2024
1 parent 929ac76 commit 00f9f2c
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 88 deletions.
32 changes: 31 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 64 additions & 20 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
) {
async function deleteTXTRecords(zoneId: string) {
const baseUrl = `https://api.cloudflare.com/client/v4/zones/${zoneId}/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,18 +96,22 @@ 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"
"Please set LINKUP_CF_API_TOKEN, LINKUP_CLOUDFLARE_ACCOUNT_ID, and LINKUP_CLOUDFLARE_ZONE_ID environment variables"
);
process.exit(1);
}

deleteTunnelCNAMERecords();
deleteTunnels();

const zones = process.argv.slice(2);
if (zones.length === 0) {
console.error("No zones specified");
console.error("Usage: clear-unused-dns.ts zone1 zone2 ...");
Expand All @@ -76,5 +120,5 @@ if (zones.length === 0) {

for (const zone of zones) {
console.log("Deleting records for zone:", zone);
deleteTXTRecords("_acme-challenge", zone, apikey, email);
deleteTXTRecords(zone);
}
3 changes: 2 additions & 1 deletion linkup-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "linkup-cli"
version = "1.0.1"
version = "1.0.2"
edition = "2021"

[[bin]]
Expand Down Expand Up @@ -30,6 +30,7 @@ serde_yaml = "0.9"
thiserror = "1"
url = { version = "2.5", features = ["serde"] }
base64 = "0.22.1"
env_logger = "0.11.3"

[dev-dependencies]
mockall = "0.12.1"
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
21 changes: 14 additions & 7 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 Expand Up @@ -251,7 +258,7 @@ fn create_config_yml(sys: &dyn System, tunnel_id: &str) -> Result<(), CliError>

// Create the directory if it does not exist
if !sys.file_exists(dir_path.as_path()) {
println!("Creating directory: {:?}", dir_path);
log::info!("Creating directory: {:?}", dir_path);
sys.create_dir_all(&dir_path)
.map_err(|err| CliError::StatusErr(err.to_string()))?;
}
Expand Down
Loading

0 comments on commit 00f9f2c

Please sign in to comment.