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
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
248 changes: 247 additions & 1 deletion cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ mod tunnel_dev;

use lib::{
Advertisment, AdvertismentTicket, ConnectNode, DiscoveryMode, ListenNode, ProxyState, Repo,
TcpProxyData,
TcpProxyData, TunnelService,
datum_cloud::{ApiEnv, DatumCloudClient},
};
use n0_error::StdResultExt;
use std::{
net::{IpAddr, SocketAddr},
path::PathBuf,
Expand Down Expand Up @@ -48,6 +49,14 @@ enum Commands {
/// Add proxies.
#[clap(subcommand, alias = "ls")]
Add(AddCommands),

/// Authenticate with Datum Cloud (login, logout, status).
#[clap(subcommand)]
Auth(AuthCommands),

/// Manage tunnels (create, list, update, delete) that expose local services to public hostnames.
#[clap(subcommand)]
Tunnel(TunnelCommands),
}

#[derive(Debug, clap::Parser)]
Expand Down Expand Up @@ -179,9 +188,70 @@ pub enum DiscoveryModeArg {
Hybrid,
}

#[derive(Subcommand, Debug)]
pub enum AuthCommands {
/// Show current authentication status.
Status,

/// Log in to Datum Cloud (opens browser for OAuth).
Login,

/// Log out and clear stored credentials.
Logout,

/// List all locally authenticated users.
List,

/// Switch to a different authenticated user (clears current and prompts for new login).
Switch,
}

#[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 @@ -249,6 +319,66 @@ async fn main() -> n0_error::Result<()> {
.await?;
println!("OK.");
}
Commands::Auth(args) => {
let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?;
match args {
AuthCommands::Status => {
if datum.is_authenticated().await? {
println!("Authenticated");
if let Some(ctx) = datum.selected_context() {
println!(" org: {}", ctx.org_id);
println!(" project: {}", ctx.project_id);
}
} else {
println!("Not authenticated");
}
}
AuthCommands::Login => {
datum.login().await?;
if let Ok(state) = datum.auth_state().get() {
println!(
"Logged in as {} ({})",
state.profile.display_name(),
state.profile.email
);
} else {
println!("Login successful");
}
}
AuthCommands::Logout => {
datum.logout().await?;
println!("Logged out");
}
AuthCommands::List => {
let is_auth = datum.is_authenticated().await?;
if is_auth {
println!("Current user (active):");
if let Some(ctx) = datum.selected_context() {
println!(" org: {}", ctx.org_id);
println!(" project: {}", ctx.project_id);
}
} else {
println!("No authenticated users");
}
println!();
println!("Note: Multi-user storage not yet implemented. Use 'auth switch' to log in as a different user.");
}
AuthCommands::Switch => {
datum.logout().await?;
println!("Switching users...");
datum.login().await?;
if let Ok(state) = datum.auth_state().get() {
println!(
"Switched to {} ({})",
state.profile.display_name(),
state.profile.email
);
} else {
println!("Switched to new user");
}
}
}
}
Commands::Serve => {
let node = ListenNode::new(repo).await?;
let endpoint_id = node.endpoint_id();
Expand Down Expand Up @@ -374,6 +504,122 @@ async fn main() -> n0_error::Result<()> {
Commands::TunnelDev(args) => {
tunnel_dev::serve(args).await?;
}
Commands::Tunnel(args) => {
let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?;
let node = ListenNode::new(repo.clone()).await?;
let service = TunnelService::new(datum, 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 label = label.unwrap_or_else(|| {
let random: u16 = rand::random();
format!("tunnel-{}", random)
});

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!();

if t.endpoint != endpoint || t.label != label {
if yes {
println!("Updating tunnel (--yes specified)");
} else {
print!("Update tunnel to label='{}', endpoint='{}'? [y/N] ", label, endpoint);
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 tunnel = service.create_active(&label, &endpoint).await?;
println!("Created tunnel:");
tunnel.id
};

let tunnel = service.set_enabled_active(&tunnel_id, true).await?;
println!();
println!("Tunnel is running:");
println!(" id: {}", tunnel.id);
println!(" label: {}", tunnel.label);
println!(" endpoint: {}", tunnel.endpoint);
if !tunnel.hostnames.is_empty() {
println!(" hostnames:");
for h in &tunnel.hostnames {
println!(" {}", h);
}
}
println!();
println!("Your endpoint ID: {}", endpoint_id);
println!("Press Ctrl+C to stop and disable the tunnel...");

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 } => {
let result = service.delete_active(&id).await?;
println!("Deleted tunnel {} (connector deleted: {})", id, result.connector_deleted);
}
}
}
}
Ok(())
}
13 changes: 11 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,24 @@
formatter = pkgs.nixpkgs-fmt;

apps.desktop = let
script = pkgs.writeShellScriptBin "desktop-app" ''
script = pkgs.writeShellScriptBin "datum-desktop" ''
cd "$PWD/ui"
export DATUM_CONNECT_PUBLISH_TICKETS=1
export RUST_LOG=info,lib::heartbeat=debug,lib::tunnels=debug
exec ${pkgs.dioxus-cli}/bin/dx serve --platform desktop
'';
in {
type = "app";
program = "${script}/bin/desktop-app";
program = "${script}/bin/datum-desktop";
};

apps.cli = let
script = pkgs.writeShellScriptBin "datum-connect-cli" ''
exec ${self.packages.${system}.cli}/bin/datum-connect "$@"
'';
in {
type = "app";
program = "${script}/bin/datum-connect-cli";
};
}
);
Expand Down
13 changes: 13 additions & 0 deletions lib/src/datum_cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ impl DatumCloudClient {
self.auth.load()
}

pub async fn is_authenticated(&self) -> Result<bool> {
let state = self.auth.load_refreshed().await?;
Ok(state.get().is_ok())
}

pub async fn login(&self) -> Result<()> {
self.auth.login().await
}

pub async fn logout(&self) -> Result<()> {
self.auth.logout().await
}

pub fn selected_context(&self) -> Option<SelectedContext> {
self.session.selected_context()
}
Expand Down
1 change: 1 addition & 0 deletions lib/src/datum_cloud/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ impl StatelessClient {
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("offline_access".to_string()))
.add_extra_param("prompt", "select_account")
.set_pkce_challenge(pkce_challenge)
.url();
debug!(auth_uri=%self.oidc.auth_uri(), "attempting login");
Expand Down
Loading
Loading