Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ CLI, GUI app, and shared library for exposing local environments to the internet
brew install datum-cloud/tap/desktop
```

**nix**

```
# GUI app
nix run github:datum-cloud/app#desktop

# CLI
nix run github:datum-cloud/app#cli -- auth login
nix run github:datum-cloud/app#cli -- tunnel list
```

**Direct download:**

[![Download for macOS](https://img.shields.io/badge/Download-macOS-000000?logo=apple&logoColor=white)](https://github.com/datum-cloud/datum-connect/releases/latest/download/Datum.dmg)
Expand Down
1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ hickory-proto = "0.25.2"
iroh-base.workspace = true
z32 = "1.0.3"
rand.workspace = true
hex.workspace = true
sentry.workspace = true
218 changes: 191 additions & 27 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ mod dns_dev;
mod tunnel_dev;

use lib::{
Advertisment, AdvertismentTicket, ConnectNode, ListenNode, ProxyState, Repo, TcpProxyData,
datum_cloud::{ApiEnv, DatumCloudClient},
Advertisment, AdvertismentTicket, ConnectNode, HeartbeatAgent, ListenNode, ProxyState, Repo,
TcpProxyData, TunnelService, datum_cloud::DatumCloudClient,
};
use n0_error::StdResultExt;
use std::{net::SocketAddr, path::PathBuf};
use tracing::info;
use tracing_subscriber::prelude::*;
Expand Down Expand Up @@ -35,12 +36,12 @@ enum Commands {
/// Local entrypoint that tunnels traffic through the gateway using CONNECT.
TunnelDev(TunnelDevArgs),

/// List configured proxies.
List,

/// Add proxies.
#[clap(subcommand, alias = "ls")]
Add(AddCommands),

/// Manage tunnels (create, list, update, delete) that expose local services to public hostnames.
Tunnel(TunnelArgs),
}

#[derive(Debug, clap::Parser)]
Expand Down Expand Up @@ -132,9 +133,61 @@ pub struct ConnectArgs {
pub ticket: AdvertismentTicket,
}

#[derive(Parser, Debug)]
pub struct TunnelArgs {
/// Project ID to use for this command (overrides the currently selected project).
#[clap(long)]
project: Option<String>,
#[clap(subcommand)]
command: TunnelCommands,
}

#[derive(Subcommand, Debug)]
pub enum TunnelCommands {
/// List all tunnels in the current project.
List,

/// Start a tunnel that exposes a local service to a public hostname.
Listen {
/// Display name for the tunnel (auto-generated if not provided).
#[clap(long)]
label: Option<String>,
/// Local address to expose (host:port, e.g. 127.0.0.1:8080).
#[clap(long)]
endpoint: String,
/// Skip confirmation prompt if tunnel already exists.
#[clap(long, default_value = "false")]
yes: bool,
},

/// Update an existing tunnel.
Update {
/// Tunnel ID (resource name).
#[clap(long)]
id: String,
/// New display name for the tunnel.
#[clap(long)]
label: Option<String>,
/// New local address to expose (host:port, e.g. 127.0.0.1:8080).
#[clap(long)]
endpoint: Option<String>,
},

/// Delete a tunnel.
Delete {
/// Tunnel ID (resource name) to delete.
#[clap(long)]
id: String,
},
}

#[tokio::main]
async fn main() -> n0_error::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with(tracing_subscriber::fmt::layer())
.with(sentry::integrations::tracing::layer())
.init();
Expand Down Expand Up @@ -163,28 +216,6 @@ async fn main() -> n0_error::Result<()> {
let repo = Repo::open_or_create(path).await?;

match args.command {
Commands::List => {
let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?;
let orgs = datum.orgs_and_projects().await?;
for org in orgs {
println!("org: {} {}", org.org.resource_id, org.org.display_name);
for project in org.projects {
println!(
" project: {} {}",
project.resource_id, project.display_name
);
}
}

println!();
let state = repo.load_state().await?;
for p in state.get().proxies.iter() {
println!(
"{} -> {}:{} (enabled: {})",
p.info.resource_id, p.info.data.host, p.info.data.port, p.enabled
)
}
}
Commands::Add(AddCommands::TcpProxy { host, label }) => {
let service = TcpProxyData::from_host_port_str(&host)?;
let advertisment = Advertisment::new(service, label);
Expand Down Expand Up @@ -280,6 +311,139 @@ async fn main() -> n0_error::Result<()> {
Commands::TunnelDev(args) => {
tunnel_dev::serve(args).await?;
}
Commands::Tunnel(TunnelArgs { project, command: args }) => {
let datum = DatumCloudClient::with_datumctl(project).await?;

let node = ListenNode::new(repo.clone()).await?;
let service = TunnelService::new(datum.clone(), node.clone());
let heartbeat = HeartbeatAgent::new(datum.clone(), node.clone());

match args {
TunnelCommands::List => {
let tunnels = service.list_active().await?;
if tunnels.is_empty() {
println!("No tunnels found in current project.");
} else {
for t in tunnels {
let status = if t.accepted && t.programmed {
"ready"
} else if t.accepted {
"accepted"
} else {
"pending"
};
let enabled = if t.enabled { "enabled" } else { "disabled" };
println!("{} [{}] {} -> {}", t.id, status, t.label, t.endpoint);
if !t.hostnames.is_empty() {
for h in &t.hostnames {
println!(" hostname: {}", h);
}
}
println!(" status: {}, {}", enabled, status);
}
}
}
TunnelCommands::Listen { label, endpoint, yes } => {
let endpoint_id = node.endpoint_id();

let existing = service.get_active_by_endpoint(&endpoint).await?;
let tunnel_id = if let Some(t) = existing {
println!("Found existing tunnel for {}:", endpoint);
println!(" id: {}", t.id);
println!(" label: {}", t.label);
println!(" endpoint: {}", t.endpoint);
println!();

// Only update if an explicit label was given and it differs.
if let Some(label) = label.filter(|l| l != &t.label) {
if yes {
println!("Updating tunnel (--yes specified)");
} else {
print!("Update tunnel label to '{}'? [y/N] ", label);
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let updated = service.update_active(&t.id, &label, &endpoint).await?;
println!("Updated tunnel:");
println!(" id: {}", updated.id);
updated.id
} else {
println!("Tunnel already configured correctly.");
t.id
}
} else {
let label = label.unwrap_or_else(|| {
let bytes: [u8; 6] = rand::random();
hex::encode(bytes)
});
let tunnel = service.create_active(&label, &endpoint).await?;
println!("Created tunnel:");
println!(" id: {}", tunnel.id);
println!(" label: {}", tunnel.label);
tunnel.id
};

heartbeat.start().await;
if let Some(ctx) = datum.selected_context() {
heartbeat.register_project(ctx.project_id).await;
}

service.set_enabled_active(&tunnel_id, true).await?;
println!();
println!("Your endpoint ID: {}", endpoint_id);
println!("Setting up tunnel...");
let setup_start = std::time::Instant::now();

let tunnel = loop {
let t = service.get_active(&tunnel_id).await?;
let Some(t) = t else {
n0_error::bail_any!("Tunnel {} not found", tunnel_id);
};
if t.accepted && t.programmed && !t.hostnames.is_empty() {
break t;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
};

let elapsed = setup_start.elapsed().as_secs();
for hostname in &tunnel.hostnames {
println!("Tunnel ready after {} sec: https://{}", elapsed, hostname);
}
println!("Press Ctrl+C to stop...");

tokio::signal::ctrl_c().await?;
println!();
println!("Disabling tunnel...");
service.set_enabled_active(&tunnel_id, false).await?;
println!("Tunnel disabled.");
}
TunnelCommands::Update { id, label, endpoint } => {
let current = service.get_active(&id).await?;
let current = current.std_context("Tunnel not found")?;
let new_label = label.unwrap_or(current.label);
let new_endpoint = endpoint.unwrap_or(current.endpoint);
let tunnel = service.update_active(&id, &new_label, &new_endpoint).await?;
println!("Updated tunnel {}:", tunnel.id);
println!(" label: {}", tunnel.label);
println!(" endpoint: {}", tunnel.endpoint);
if !tunnel.hostnames.is_empty() {
println!(" hostnames:");
for h in &tunnel.hostnames {
println!(" {}", h);
}
}
}
TunnelCommands::Delete { id } => {
service.delete_active(&id).await?;
println!("Deleted tunnel {}", id);
}
}
}
}
Ok(())
}
Loading