From 4fda2bdef354ae235a2907f604632f5998add102 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 14:44:26 -0500 Subject: [PATCH 01/16] Add CLI tunnel management with listen command - Add 'tunnel' subcommand to datum-connect CLI with: - 'tunnel list': read-only listing of tunnels (no side effects) - 'tunnel listen': create/update and run tunnel in foreground - 'tunnel update': update tunnel label/endpoint - 'tunnel delete': delete a tunnel - Add 'nix run .#connect' app to flake.nix - Split find_connector_readonly for list operations - Remove side effects from tunnel list (no patching Connector) - Listen command: - Generates random label if not provided - Confirms before updating existing tunnel - Handles Ctrl+C to disable tunnel on exit --- cli/src/main.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 9 +++ lib/src/tunnels.rs | 31 ++++++++- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index f53e45a..280761b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,8 +5,10 @@ mod tunnel_dev; use lib::{ Advertisment, AdvertismentTicket, ConnectNode, ListenNode, ProxyState, Repo, TcpProxyData, + TunnelService, datum_cloud::{ApiEnv, DatumCloudClient}, }; +use n0_error::StdResultExt; use std::{net::SocketAddr, path::PathBuf}; use tracing::info; use tracing_subscriber::prelude::*; @@ -41,6 +43,10 @@ enum Commands { /// Add proxies. #[clap(subcommand, alias = "ls")] Add(AddCommands), + + /// Manage tunnels (create, list, update, delete) that expose local services to public hostnames. + #[clap(subcommand)] + Tunnel(TunnelCommands), } #[derive(Debug, clap::Parser)] @@ -132,9 +138,52 @@ pub struct ConnectArgs { pub ticket: AdvertismentTicket, } +#[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, + /// 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, + /// New local address to expose (host:port, e.g. 127.0.0.1:8080). + #[clap(long)] + endpoint: Option, + }, + + /// 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("info")), + ) .with(tracing_subscriber::fmt::layer()) .with(sentry::integrations::tracing::layer()) .init(); @@ -280,6 +329,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(()) } diff --git a/flake.nix b/flake.nix index fb5869a..dac7ddc 100644 --- a/flake.nix +++ b/flake.nix @@ -161,6 +161,15 @@ type = "app"; program = "${script}/bin/desktop-app"; }; + + apps.cli = let + script = pkgs.writeShellScriptBin "cli" '' + exec ${self.packages.${system}.cli}/bin/datum-connect "$@" + ''; + in { + type = "app"; + program = "${script}/bin/cli"; + }; } ); } diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 5dc92f3..854cc01 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -138,6 +138,12 @@ impl TunnelService { Ok(tunnels.into_iter().find(|tunnel| tunnel.id == tunnel_id)) } + pub async fn get_active_by_endpoint(&self, endpoint: &str) -> Result> { + let tunnels = self.list_active().await?; + let normalized = normalize_endpoint(endpoint); + Ok(tunnels.into_iter().find(|tunnel| tunnel.endpoint == normalized)) + } + pub async fn create_active(&self, label: &str, endpoint: &str) -> Result { let Some(selected) = self.datum.selected_context() else { n0_error::bail_any!("No project selected"); @@ -179,7 +185,7 @@ impl TunnelService { } pub async fn list_project(&self, project_id: &str) -> Result> { - let connector = self.find_connector(project_id).await?; + let connector = self.find_connector_readonly(project_id).await?; let Some(connector) = connector else { return Ok(Vec::new()); }; @@ -792,6 +798,29 @@ impl TunnelService { }) } + async fn find_connector_readonly(&self, project_id: &str) -> Result> { + let pcp = self.datum.project_control_plane_client(project_id).await?; + let client = pcp.client(); + let connectors: Api = Api::namespaced(client, DEFAULT_PCP_NAMESPACE); + let endpoint_id = self.listen.endpoint_id().to_string(); + let selector = format!("{CONNECTOR_SELECTOR_FIELD}={endpoint_id}"); + let list = connectors + .list(&ListParams::default().fields(&selector)) + .await + .std_context("Failed to list connectors")?; + if list.items.is_empty() { + return Ok(None); + } + if list.items.len() > 1 { + debug!( + %selector, + count = list.items.len(), + "Multiple connectors found for endpoint, using first" + ); + } + Ok(Some(list.items.into_iter().next().unwrap())) + } + async fn find_connector(&self, project_id: &str) -> Result> { let pcp = self.datum.project_control_plane_client(project_id).await?; let client = pcp.client(); From dc053c0aa8ec2a60af4b3ee52be0d693213d25c8 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 15:12:05 -0500 Subject: [PATCH 02/16] Add auth command with login, logout, list, and switch - Add 'auth' subcommand to CLI with: - 'auth status': Show current authentication and selected context - 'auth login': Log in via browser OAuth with account picker - 'auth logout': Log out and clear credentials - 'auth list': Show current authenticated user - 'auth switch': Log out current user and prompt for new login Also add is_authenticated(), login(), logout() methods to DatumCloudClient. --- cli/src/main.rs | 66 +++++++++++++++++++++++++++++++++++++ lib/src/datum_cloud.rs | 13 ++++++++ lib/src/datum_cloud/auth.rs | 1 + 3 files changed, 80 insertions(+) diff --git a/cli/src/main.rs b/cli/src/main.rs index 280761b..308faad 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -44,6 +44,10 @@ enum Commands { #[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), @@ -138,6 +142,24 @@ pub struct ConnectArgs { pub ticket: AdvertismentTicket, } +#[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. @@ -251,6 +273,50 @@ 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?; + 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?; + println!("Switched to new user"); + } + } + } Commands::Serve => { let node = ListenNode::new(repo).await?; let endpoint_id = node.endpoint_id(); diff --git a/lib/src/datum_cloud.rs b/lib/src/datum_cloud.rs index 61a8a2f..3c27c3b 100644 --- a/lib/src/datum_cloud.rs +++ b/lib/src/datum_cloud.rs @@ -96,6 +96,19 @@ impl DatumCloudClient { self.auth.load() } + pub async fn is_authenticated(&self) -> Result { + 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 { self.session.selected_context() } diff --git a/lib/src/datum_cloud/auth.rs b/lib/src/datum_cloud/auth.rs index 66fbe7a..0f9ad1b 100644 --- a/lib/src/datum_cloud/auth.rs +++ b/lib/src/datum_cloud/auth.rs @@ -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"); From d7525078147d2961fc70832e1949636b7889a913 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 16:23:00 -0500 Subject: [PATCH 03/16] docs: Add nix instructions --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 5c5805f..db00dee 100644 --- a/README.md +++ b/README.md @@ -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) From 7d3910b8a856d682ca6dad8d0203ae815bd6017c Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 16:23:20 -0500 Subject: [PATCH 04/16] chore: update nix targets --- flake.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index dac7ddc..5d309f6 100644 --- a/flake.nix +++ b/flake.nix @@ -151,7 +151,7 @@ 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 @@ -159,16 +159,16 @@ ''; in { type = "app"; - program = "${script}/bin/desktop-app"; + program = "${script}/bin/datum-desktop"; }; apps.cli = let - script = pkgs.writeShellScriptBin "cli" '' + script = pkgs.writeShellScriptBin "datum-connect-cli" '' exec ${self.packages.${system}.cli}/bin/datum-connect "$@" ''; in { type = "app"; - program = "${script}/bin/cli"; + program = "${script}/bin/datum-connect-cli"; }; } ); From 67c222fc30156b5015d33ea29b90aface3df6543 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 16:37:10 -0500 Subject: [PATCH 05/16] fix: show which user auth'd --- cli/src/main.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 308faad..aedae35 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -289,7 +289,15 @@ async fn main() -> n0_error::Result<()> { } AuthCommands::Login => { datum.login().await?; - println!("Login successful"); + 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?; @@ -313,7 +321,15 @@ async fn main() -> n0_error::Result<()> { datum.logout().await?; println!("Switching users..."); datum.login().await?; - println!("Switched to new user"); + if let Ok(state) = datum.auth_state().get() { + println!( + "Switched to {} ({})", + state.profile.display_name(), + state.profile.email + ); + } else { + println!("Switched to new user"); + } } } } From b8b3b518f8fbb998e82d649672dfe497c21940ab Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 27 Mar 2026 16:41:51 -0500 Subject: [PATCH 06/16] chore: don't show INFO logs by default --- cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index aedae35..1bf787a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -204,7 +204,7 @@ async fn main() -> n0_error::Result<()> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .with(tracing_subscriber::fmt::layer()) .with(sentry::integrations::tracing::layer()) From f772e639bcf2eb2c4b65d3b6189e37c5caf2d573 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Tue, 31 Mar 2026 14:26:59 -0500 Subject: [PATCH 07/16] fix: don't abort tunnel delete when connector is missing delete_project returned early when find_connector returned None, skipping deletion of HTTPProxy/ConnectorAdvertisement/TrafficProtectionPolicy. Connector lookup is only needed for post-deletion cleanup (deciding whether to delete the shared connector). Move it into an Option and gate the cleanup block on Some, so resource deletion always proceeds. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/src/tunnels.rs | 70 ++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/lib/src/tunnels.rs b/lib/src/tunnels.rs index 854cc01..cf3661a 100644 --- a/lib/src/tunnels.rs +++ b/lib/src/tunnels.rs @@ -694,13 +694,7 @@ impl TunnelService { tunnel_id: &str, ) -> Result { let connector = self.find_connector(project_id).await?; - let Some(connector) = connector else { - return Ok(TunnelDeleteOutcome { - project_id: project_id.to_string(), - connector_deleted: false, - }); - }; - let connector_name = connector.name_any(); + let connector_name = connector.as_ref().map(|c| c.name_any()); let pcp = self.datum.project_control_plane_client(project_id).await?; let client = pcp.client(); @@ -754,41 +748,43 @@ impl TunnelService { warn!(%tunnel_id, "Failed to remove proxy state: {err:#}"); } - let remaining = proxies - .list(&ListParams::default()) - .await - .std_context("Failed to list remaining HTTPProxy objects")?; let mut connector_deleted = false; - let mut remaining_for_connector = remaining - .items - .into_iter() - .filter(|proxy| proxy_uses_connector(proxy, &connector_name)) - .peekable(); - if remaining_for_connector.peek().is_none() { - let ad_selector = format!("{ADVERTISEMENT_CONNECTOR_FIELD}={connector_name}"); - let ads_list = ads - .list(&ListParams::default().fields(&ad_selector)) + if let Some(connector_name) = connector_name { + let remaining = proxies + .list(&ListParams::default()) .await - .std_context("Failed to list remaining ConnectorAdvertisements")?; - for ad in ads_list.items { - if let Some(name) = ad.metadata.name.clone() - && let Err(err) = ads.delete(&name, &DeleteParams::default()).await - { - warn!(%name, "Failed to delete connector advertisement: {err:#}"); + .std_context("Failed to list remaining HTTPProxy objects")?; + let mut remaining_for_connector = remaining + .items + .into_iter() + .filter(|proxy| proxy_uses_connector(proxy, &connector_name)) + .peekable(); + if remaining_for_connector.peek().is_none() { + let ad_selector = format!("{ADVERTISEMENT_CONNECTOR_FIELD}={connector_name}"); + let ads_list = ads + .list(&ListParams::default().fields(&ad_selector)) + .await + .std_context("Failed to list remaining ConnectorAdvertisements")?; + for ad in ads_list.items { + if let Some(name) = ad.metadata.name.clone() + && let Err(err) = ads.delete(&name, &DeleteParams::default()).await + { + warn!(%name, "Failed to delete connector advertisement: {err:#}"); + } } - } - if connectors - .get_opt(&connector_name) - .await - .std_context("Failed to load Connector")? - .is_some() - { - connectors - .delete(&connector_name, &DeleteParams::default()) + if connectors + .get_opt(&connector_name) .await - .std_context("Failed to delete Connector")?; - connector_deleted = true; + .std_context("Failed to load Connector")? + .is_some() + { + connectors + .delete(&connector_name, &DeleteParams::default()) + .await + .std_context("Failed to delete Connector")?; + connector_deleted = true; + } } } From 25d2a9af40471cee0be2be43a664203104d7421c Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Tue, 31 Mar 2026 14:28:46 -0500 Subject: [PATCH 08/16] fix: correct tunnel listen label handling and start heartbeat agent Three interrelated bugs fixed in the tunnel listen command: - Random label was generated before checking for an existing tunnel, so re-running listen on the same endpoint always triggered the update prompt. Moved label generation into the create-new path only; existing tunnels reuse their stored label unless --label is explicitly provided and differs. - Default label format changed from tunnel- (collides with resource ID format) to 12 hex chars of random entropy (e.g. a3f9c2e1b047). Adds hex as a dependency. - tunnel listen was missing the HeartbeatAgent that continuously patches status.connectionDetails on the connector (relay URL, addresses, public key). Without it the gateway has no routing info and tunnels never carry traffic. Now starts the heartbeat and registers the project before enabling the tunnel, then polls until accepted+programmed before printing the hostname. Also simplifies tunnel delete output: connector cleanup is an internal detail, so "Deleted tunnel " replaces "(connector deleted: false)". Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Cargo.lock | 1 + cli/Cargo.toml | 1 + cli/src/main.rs | 66 ++++++++++++++++++++++++++++++------------------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b3e5bd..bd2c29b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1682,6 +1682,7 @@ dependencies = [ "async-trait", "clap", "dotenv", + "hex", "hickory-proto", "hickory-server", "humantime", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 51d3ccd..abf2dc7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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 \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index 1bf787a..43ceb39 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,8 +4,8 @@ mod dns_dev; mod tunnel_dev; use lib::{ - Advertisment, AdvertismentTicket, ConnectNode, ListenNode, ProxyState, Repo, TcpProxyData, - TunnelService, + Advertisment, AdvertismentTicket, ConnectNode, HeartbeatAgent, ListenNode, ProxyState, Repo, + TcpProxyData, TunnelService, datum_cloud::{ApiEnv, DatumCloudClient}, }; use n0_error::StdResultExt; @@ -414,7 +414,8 @@ async fn main() -> n0_error::Result<()> { 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()); + let service = TunnelService::new(datum.clone(), node.clone()); + let heartbeat = HeartbeatAgent::new(datum.clone(), node.clone()); match args { TunnelCommands::List => { @@ -443,11 +444,7 @@ async fn main() -> n0_error::Result<()> { } 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); @@ -455,12 +452,13 @@ async fn main() -> n0_error::Result<()> { println!(" label: {}", t.label); println!(" endpoint: {}", t.endpoint); println!(); - - if t.endpoint != endpoint || t.label != label { + + // 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 to label='{}', endpoint='{}'? [y/N] ", label, endpoint); + 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)?; @@ -478,27 +476,43 @@ async fn main() -> n0_error::Result<()> { 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:"); 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); - } + 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!("Press Ctrl+C to stop and disable the tunnel..."); - + 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..."); @@ -522,8 +536,8 @@ async fn main() -> n0_error::Result<()> { } } TunnelCommands::Delete { id } => { - let result = service.delete_active(&id).await?; - println!("Deleted tunnel {} (connector deleted: {})", id, result.connector_deleted); + service.delete_active(&id).await?; + println!("Deleted tunnel {}", id); } } } From 1e5a61abe3ee472d2a5a6e4b11979c5a2bddd85f Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Tue, 31 Mar 2026 15:35:37 -0500 Subject: [PATCH 09/16] feat: add project selection, tunnel --project flag, and projects command - After auth login/switch, prompt user to select an org and project and persist the selection as the active context - Store the selected context in config.yml instead of a separate file - Add --project flag to the tunnel command to override the active project for a single invocation - Add projects list and projects switch commands for managing the active project outside of the auth flow - Fix tunnel listen to print id and label after creation --- cli/src/main.rs | 156 +++++++++++++++++++++++++++++++++++++++++++++- lib/src/config.rs | 6 ++ lib/src/repo.rs | 26 +++++--- 3 files changed, 175 insertions(+), 13 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 43ceb39..b890392 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,7 +5,7 @@ mod tunnel_dev; use lib::{ Advertisment, AdvertismentTicket, ConnectNode, HeartbeatAgent, ListenNode, ProxyState, Repo, - TcpProxyData, TunnelService, + SelectedContext, TcpProxyData, TunnelService, datum_cloud::{ApiEnv, DatumCloudClient}, }; use n0_error::StdResultExt; @@ -49,8 +49,11 @@ enum Commands { Auth(AuthCommands), /// Manage tunnels (create, list, update, delete) that expose local services to public hostnames. + Tunnel(TunnelArgs), + + /// Manage Datum Cloud projects. #[clap(subcommand)] - Tunnel(TunnelCommands), + Projects(ProjectsCommands), } #[derive(Debug, clap::Parser)] @@ -142,6 +145,15 @@ pub struct ConnectArgs { pub ticket: AdvertismentTicket, } +#[derive(Subcommand, Debug)] +pub enum ProjectsCommands { + /// List all available projects across your organizations. + List, + + /// Switch the active project. + Switch, +} + #[derive(Subcommand, Debug)] pub enum AuthCommands { /// Show current authentication status. @@ -160,6 +172,15 @@ pub enum AuthCommands { Switch, } +#[derive(Parser, Debug)] +pub struct TunnelArgs { + /// Project ID to use for this command (overrides the currently selected project). + #[clap(long)] + project: Option, + #[clap(subcommand)] + command: TunnelCommands, +} + #[derive(Subcommand, Debug)] pub enum TunnelCommands { /// List all tunnels in the current project. @@ -298,6 +319,7 @@ async fn main() -> n0_error::Result<()> { } else { println!("Login successful"); } + select_project_interactive(&datum).await?; } AuthCommands::Logout => { datum.logout().await?; @@ -330,6 +352,30 @@ async fn main() -> n0_error::Result<()> { } else { println!("Switched to new user"); } + select_project_interactive(&datum).await?; + } + } + } + Commands::Projects(args) => { + let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?; + match args { + ProjectsCommands::List => { + let orgs = datum.orgs_and_projects().await?; + let selected = datum.selected_context(); + for org in &orgs { + println!("{} ({})", org.org.display_name, org.org.resource_id); + for project in &org.projects { + let active = selected + .as_ref() + .map(|ctx| ctx.project_id == project.resource_id) + .unwrap_or(false); + let marker = if active { " *" } else { "" }; + println!(" {} ({}){}", project.display_name, project.resource_id, marker); + } + } + } + ProjectsCommands::Switch => { + select_project_interactive(&datum).await?; } } } @@ -411,8 +457,16 @@ async fn main() -> n0_error::Result<()> { Commands::TunnelDev(args) => { tunnel_dev::serve(args).await?; } - Commands::Tunnel(args) => { + Commands::Tunnel(TunnelArgs { project, command: args }) => { let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?; + + if let Some(project_id) = project { + let orgs = datum.orgs_and_projects().await?; + let ctx = resolve_project_context(&orgs, &project_id) + .ok_or_else(|| n0_error::anyerr!("project '{}' not found", project_id))?; + datum.set_selected_context(Some(ctx)).await?; + } + let node = ListenNode::new(repo.clone()).await?; let service = TunnelService::new(datum.clone(), node.clone()); let heartbeat = HeartbeatAgent::new(datum.clone(), node.clone()); @@ -482,6 +536,8 @@ async fn main() -> n0_error::Result<()> { }); let tunnel = service.create_active(&label, &endpoint).await?; println!("Created tunnel:"); + println!(" id: {}", tunnel.id); + println!(" label: {}", tunnel.label); tunnel.id }; @@ -544,3 +600,97 @@ async fn main() -> n0_error::Result<()> { } Ok(()) } + +/// Prompt the user to select an org and project, then persist it as the active context. +async fn select_project_interactive(datum: &DatumCloudClient) -> n0_error::Result<()> { + use lib::datum_cloud::OrganizationWithProjects; + use std::io::{BufRead, Write}; + + let orgs = datum.orgs_and_projects().await?; + if orgs.is_empty() { + println!("No organizations found. Create a project at https://app.datum.net first."); + return Ok(()); + } + + // Flatten to (org_ref, project_index) for a simple numbered list. + let mut entries: Vec<(&OrganizationWithProjects, usize)> = Vec::new(); + for org in &orgs { + for pi in 0..org.projects.len() { + entries.push((org, pi)); + } + } + + if entries.is_empty() { + println!("No projects found. Create a project at https://app.datum.net first."); + return Ok(()); + } + + if entries.len() == 1 { + let (org, pi) = entries[0]; + let project = &org.projects[pi]; + let ctx = SelectedContext { + org_id: org.org.resource_id.clone(), + org_name: org.org.display_name.clone(), + project_id: project.resource_id.clone(), + project_name: project.display_name.clone(), + org_type: org.org.r#type.clone(), + }; + println!("Selected project: {} / {}", ctx.org_name, ctx.project_name); + datum.set_selected_context(Some(ctx)).await?; + return Ok(()); + } + + println!("\nSelect a project:"); + for (i, (org, pi)) in entries.iter().enumerate() { + let project = &org.projects[*pi]; + println!(" [{}] {} / {}", i + 1, org.org.display_name, project.display_name); + } + print!("Enter number [1-{}]: ", entries.len()); + std::io::stdout().flush().ok(); + + let stdin = std::io::stdin(); + let line = stdin + .lock() + .lines() + .next() + .ok_or_else(|| n0_error::anyerr!("no input"))??; + let choice: usize = line + .trim() + .parse() + .map_err(|_| n0_error::anyerr!("invalid selection"))?; + if choice < 1 || choice > entries.len() { + return Err(n0_error::anyerr!("selection out of range")); + } + + let (org, pi) = entries[choice - 1]; + let project = &org.projects[pi]; + let ctx = SelectedContext { + org_id: org.org.resource_id.clone(), + org_name: org.org.display_name.clone(), + project_id: project.resource_id.clone(), + project_name: project.display_name.clone(), + org_type: org.org.r#type.clone(), + }; + println!("Selected project: {} / {}", ctx.org_name, ctx.project_name); + datum.set_selected_context(Some(ctx)).await?; + Ok(()) +} + +/// Find a project by ID across all orgs and build a `SelectedContext` for it. +fn resolve_project_context( + orgs: &[lib::datum_cloud::OrganizationWithProjects], + project_id: &str, +) -> Option { + for org in orgs { + if let Some(project) = org.projects.iter().find(|p| p.resource_id == project_id) { + return Some(SelectedContext { + org_id: org.org.resource_id.clone(), + org_name: org.org.display_name.clone(), + project_id: project.resource_id.clone(), + project_name: project.display_name.clone(), + org_type: org.org.r#type.clone(), + }); + } + } + None +} diff --git a/lib/src/config.rs b/lib/src/config.rs index 386d7bb..e35e886 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -7,6 +7,8 @@ use std::{ use n0_error::{Result, StackResultExt, StdResultExt}; use serde::{Deserialize, Serialize}; +use crate::SelectedContext; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DiscoveryMode { @@ -49,6 +51,10 @@ pub struct Config { /// Useful for local development (e.g. 127.0.0.1:53535). #[serde(default)] pub dns_resolver: Option, + + /// The currently selected org/project context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_context: Option, } impl Config { diff --git a/lib/src/repo.rs b/lib/src/repo.rs index c3975ab..bbd4159 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -21,7 +21,6 @@ impl Repo { const OAUTH_FILE: &str = "oauth.yml"; const AUTH_FILE: &str = "auth.yml"; const STATE_FILE: &str = "state.yml"; - const SELECTED_CONTEXT_FILE: &str = "selected_context.yml"; pub fn default_location() -> PathBuf { match std::env::var("DATUM_CONNECT_REPO") { @@ -75,21 +74,28 @@ impl Repo { &self, selected: Option<&crate::SelectedContext>, ) -> Result<()> { - let path = self.0.join(Self::SELECTED_CONTEXT_FILE); - let data = serde_yml::to_string(&selected).anyerr()?; - tokio::fs::write(path, data).await?; - Ok(()) + let path = self.0.join(Self::CONFIG_FILE); + let mut config = if path.exists() { + let data = tokio::fs::read_to_string(&path) + .await + .context("reading config file")?; + serde_yml::from_str(&data).std_context("parsing config file")? + } else { + crate::config::Config::default() + }; + config.selected_context = selected.cloned(); + config.write(path).await } pub async fn read_selected_context(&self) -> Result> { - let path = self.0.join(Self::SELECTED_CONTEXT_FILE); + let path = self.0.join(Self::CONFIG_FILE); if path.exists() { let data = tokio::fs::read_to_string(path) .await - .context("failed to read selected context file")?; - let selected: Option = - serde_yml::from_str(&data).std_context("failed to parse selected context file")?; - return Ok(selected); + .context("reading config file")?; + let config: crate::config::Config = + serde_yml::from_str(&data).std_context("parsing config file")?; + return Ok(config.selected_context); } Ok(None) } From 1c37095c7dc0c285463fa8dbd1731276d703564e Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Thu, 9 Apr 2026 21:47:19 +0000 Subject: [PATCH 10/16] fix: normalize loopback hostnames in tunnel auth check The gateway sends `CONNECT localhost:` regardless of whether the tunnel was registered with `localhost` or `127.0.0.1`, causing auth to fail with Forbidden and the caller to see "upstream connect error or disconnect/reset before headers." Normalize `localhost`, `127.0.0.1`, and `::1` to a canonical form on both sides of the host comparison in `tcp_proxy_exists`. Co-Authored-By: Claude Sonnet 4.6 --- lib/src/node.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/src/node.rs b/lib/src/node.rs index 2d43425..0d18d5e 100644 --- a/lib/src/node.rs +++ b/lib/src/node.rs @@ -177,9 +177,11 @@ impl StateWrapper { fn tcp_proxy_exists(&self, host: &str, port: u16) -> bool { // Strip scheme from incoming host (e.g., "http://127.0.0.1" -> "127.0.0.1") // The gateway may send the host with scheme, but local state stores without - let normalized_host = strip_host_scheme(host); + let normalized_host = normalize_loopback(strip_host_scheme(host)); let exists = self.get().proxies.iter().any(|a| { - a.enabled && a.info.service().host == normalized_host && a.info.service().port == port + a.enabled + && normalize_loopback(&a.info.service().host) == normalized_host + && a.info.service().port == port }); if !exists { debug!( @@ -198,6 +200,15 @@ fn strip_host_scheme(host: &str) -> &str { .unwrap_or(host) } +/// Normalize loopback hostnames so that "localhost", "127.0.0.1", and "::1" compare equal. +/// The gateway may use either form regardless of how the tunnel was registered. +fn normalize_loopback(host: &str) -> &str { + match host { + "localhost" | "::1" => "127.0.0.1", + _ => host, + } +} + impl AuthHandler for StateWrapper { async fn authorize<'a>( &'a self, From 4cc53a26c14cfb894de4ceee5636e0ad2673ede0 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Fri, 10 Apr 2026 15:50:17 +0000 Subject: [PATCH 11/16] chore: update nix deps for webkitgtk 4.1, libsoup 3, and xdo --- flake.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 5d309f6..d33e07e 100644 --- a/flake.nix +++ b/flake.nix @@ -27,10 +27,11 @@ linuxPackages = with pkgs; lib.optionals stdenv.isLinux [ # For web/desktop rendering - webkitgtk + webkitgtk_4_1 gtk3 - libsoup + libsoup_3 # X11 dependencies + xdo xorg.libX11 xorg.libXcursor xorg.libXrandr From 3bc027b60d0ac6ba38f1b36ffcfff95977586f83 Mon Sep 17 00:00:00 2001 From: Gianluca Arbezzano Date: Fri, 15 May 2026 17:15:51 +0200 Subject: [PATCH 12/16] chore: remove auth and project related commands This commit removes all the features that are covered by the datumctl and it makes the authentication of datum-connect to work on top of the datumctl --- cli/src/main.rs | 251 +---------------------------- flake.nix | 3 +- lib/src/datum_cloud.rs | 42 ++++- lib/src/datum_cloud/auth.rs | 96 +++++++++-- lib/src/datum_cloud/datumctl.rs | 272 ++++++++++++++++++++++++++++++++ lib/src/datum_cloud/env.rs | 46 +++++- 6 files changed, 442 insertions(+), 268 deletions(-) create mode 100644 lib/src/datum_cloud/datumctl.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index b890392..0bcb5bd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,8 +5,7 @@ mod tunnel_dev; use lib::{ Advertisment, AdvertismentTicket, ConnectNode, HeartbeatAgent, ListenNode, ProxyState, Repo, - SelectedContext, TcpProxyData, TunnelService, - datum_cloud::{ApiEnv, DatumCloudClient}, + TcpProxyData, TunnelService, datum_cloud::DatumCloudClient, }; use n0_error::StdResultExt; use std::{net::SocketAddr, path::PathBuf}; @@ -37,23 +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), - /// Authenticate with Datum Cloud (login, logout, status). - #[clap(subcommand)] - Auth(AuthCommands), - /// Manage tunnels (create, list, update, delete) that expose local services to public hostnames. Tunnel(TunnelArgs), - - /// Manage Datum Cloud projects. - #[clap(subcommand)] - Projects(ProjectsCommands), } #[derive(Debug, clap::Parser)] @@ -145,33 +133,6 @@ pub struct ConnectArgs { pub ticket: AdvertismentTicket, } -#[derive(Subcommand, Debug)] -pub enum ProjectsCommands { - /// List all available projects across your organizations. - List, - - /// Switch the active project. - Switch, -} - -#[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(Parser, Debug)] pub struct TunnelArgs { /// Project ID to use for this command (overrides the currently selected project). @@ -255,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); @@ -294,91 +233,6 @@ 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"); - } - select_project_interactive(&datum).await?; - } - 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"); - } - select_project_interactive(&datum).await?; - } - } - } - Commands::Projects(args) => { - let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?; - match args { - ProjectsCommands::List => { - let orgs = datum.orgs_and_projects().await?; - let selected = datum.selected_context(); - for org in &orgs { - println!("{} ({})", org.org.display_name, org.org.resource_id); - for project in &org.projects { - let active = selected - .as_ref() - .map(|ctx| ctx.project_id == project.resource_id) - .unwrap_or(false); - let marker = if active { " *" } else { "" }; - println!(" {} ({}){}", project.display_name, project.resource_id, marker); - } - } - } - ProjectsCommands::Switch => { - select_project_interactive(&datum).await?; - } - } - } Commands::Serve => { let node = ListenNode::new(repo).await?; let endpoint_id = node.endpoint_id(); @@ -458,14 +312,7 @@ async fn main() -> n0_error::Result<()> { tunnel_dev::serve(args).await?; } Commands::Tunnel(TunnelArgs { project, command: args }) => { - let datum = DatumCloudClient::with_repo(ApiEnv::default(), repo.clone()).await?; - - if let Some(project_id) = project { - let orgs = datum.orgs_and_projects().await?; - let ctx = resolve_project_context(&orgs, &project_id) - .ok_or_else(|| n0_error::anyerr!("project '{}' not found", project_id))?; - datum.set_selected_context(Some(ctx)).await?; - } + let datum = DatumCloudClient::with_datumctl(project).await?; let node = ListenNode::new(repo.clone()).await?; let service = TunnelService::new(datum.clone(), node.clone()); @@ -600,97 +447,3 @@ async fn main() -> n0_error::Result<()> { } Ok(()) } - -/// Prompt the user to select an org and project, then persist it as the active context. -async fn select_project_interactive(datum: &DatumCloudClient) -> n0_error::Result<()> { - use lib::datum_cloud::OrganizationWithProjects; - use std::io::{BufRead, Write}; - - let orgs = datum.orgs_and_projects().await?; - if orgs.is_empty() { - println!("No organizations found. Create a project at https://app.datum.net first."); - return Ok(()); - } - - // Flatten to (org_ref, project_index) for a simple numbered list. - let mut entries: Vec<(&OrganizationWithProjects, usize)> = Vec::new(); - for org in &orgs { - for pi in 0..org.projects.len() { - entries.push((org, pi)); - } - } - - if entries.is_empty() { - println!("No projects found. Create a project at https://app.datum.net first."); - return Ok(()); - } - - if entries.len() == 1 { - let (org, pi) = entries[0]; - let project = &org.projects[pi]; - let ctx = SelectedContext { - org_id: org.org.resource_id.clone(), - org_name: org.org.display_name.clone(), - project_id: project.resource_id.clone(), - project_name: project.display_name.clone(), - org_type: org.org.r#type.clone(), - }; - println!("Selected project: {} / {}", ctx.org_name, ctx.project_name); - datum.set_selected_context(Some(ctx)).await?; - return Ok(()); - } - - println!("\nSelect a project:"); - for (i, (org, pi)) in entries.iter().enumerate() { - let project = &org.projects[*pi]; - println!(" [{}] {} / {}", i + 1, org.org.display_name, project.display_name); - } - print!("Enter number [1-{}]: ", entries.len()); - std::io::stdout().flush().ok(); - - let stdin = std::io::stdin(); - let line = stdin - .lock() - .lines() - .next() - .ok_or_else(|| n0_error::anyerr!("no input"))??; - let choice: usize = line - .trim() - .parse() - .map_err(|_| n0_error::anyerr!("invalid selection"))?; - if choice < 1 || choice > entries.len() { - return Err(n0_error::anyerr!("selection out of range")); - } - - let (org, pi) = entries[choice - 1]; - let project = &org.projects[pi]; - let ctx = SelectedContext { - org_id: org.org.resource_id.clone(), - org_name: org.org.display_name.clone(), - project_id: project.resource_id.clone(), - project_name: project.display_name.clone(), - org_type: org.org.r#type.clone(), - }; - println!("Selected project: {} / {}", ctx.org_name, ctx.project_name); - datum.set_selected_context(Some(ctx)).await?; - Ok(()) -} - -/// Find a project by ID across all orgs and build a `SelectedContext` for it. -fn resolve_project_context( - orgs: &[lib::datum_cloud::OrganizationWithProjects], - project_id: &str, -) -> Option { - for org in orgs { - if let Some(project) = org.projects.iter().find(|p| p.resource_id == project_id) { - return Some(SelectedContext { - org_id: org.org.resource_id.clone(), - org_name: org.org.display_name.clone(), - project_id: project.resource_id.clone(), - project_name: project.display_name.clone(), - org_type: org.org.r#type.clone(), - }); - } - } - None -} diff --git a/flake.nix b/flake.nix index d33e07e..7a67322 100644 --- a/flake.nix +++ b/flake.nix @@ -39,8 +39,7 @@ ]; cargoOutputHashes = { - "iroh-proxy-utils-0.1.0" = "sha256-tI26vv7fvNR18KsUJvBTXZ0c7Wc/63Qq88NAWuWMoHs="; - "dioxus-primitives-0.0.1" = "sha256-tI26vv7fvNR18KsUJvBTXZ0c7Wc/63Qq88NAWuWMoHs="; + "dioxus-primitives-0.0.1" = "sha256-T/ZdVqgWDLpdNzf3GlBeQVLbs4eJbqdgDkrUSzMycR4="; }; in diff --git a/lib/src/datum_cloud.rs b/lib/src/datum_cloud.rs index 3c27c3b..e650cb2 100644 --- a/lib/src/datum_cloud.rs +++ b/lib/src/datum_cloud.rs @@ -19,6 +19,7 @@ pub use self::{ }; mod auth; +pub mod datumctl; mod env; const ORGS_PROJECTS_DEDUP_WINDOW: StdDuration = StdDuration::from_secs(2); @@ -35,7 +36,7 @@ pub struct DatumCloudClient { impl DatumCloudClient { pub async fn with_repo(env: ApiEnv, repo: Repo) -> Result { - let auth = AuthClient::with_repo(env, repo.clone()).await?; + let auth = AuthClient::with_repo(env.clone(), repo.clone()).await?; let session = SessionStateWrapper::from_repo(Some(repo)).await?; let http = reqwest::Client::builder() .user_agent(datum_http_user_agent()) @@ -54,7 +55,7 @@ impl DatumCloudClient { } pub async fn new(env: ApiEnv) -> Result { - let auth = AuthClient::new(env).await?; + let auth = AuthClient::new(env.clone()).await?; let session = SessionStateWrapper::empty(); let http = reqwest::Client::builder() .user_agent(datum_http_user_agent()) @@ -72,15 +73,48 @@ impl DatumCloudClient { Ok(client) } + /// Build a client that delegates auth + selected-project lookup to a sibling `datumctl` + /// install. Reads `~/.datumctl/config` for the active session and selected context, and + /// shells out to `datumctl auth get-token` per API call. Does not run its own OAuth + /// flow and does not write any state to disk. + /// + /// `project_override` pins a specific project (e.g. CLI `--project` flag) instead of + /// using whatever is set in datumctl's current-context. + pub async fn with_datumctl(project_override: Option) -> Result { + let resolved = datumctl::resolve(project_override).await?; + let env = ApiEnv::from_api_url(&resolved.api_url); + let auth = AuthClient::with_datumctl( + env.clone(), + resolved.profile, + Some(resolved.session_name), + ); + let session = SessionStateWrapper::empty(); + session + .set_selected_context(Some(resolved.selected_context)) + .await?; + let http = reqwest::Client::builder() + .user_agent(datum_http_user_agent()) + .build() + .anyerr()?; + Ok(Self { + env, + auth, + http, + session, + orgs_projects_fetch_gate: Arc::new(Mutex::new(None)), + _session_task: None, + }) + } + pub fn login_state(&self) -> LoginState { self.auth.login_state() } - pub fn api_url(&self) -> &'static str { + pub fn api_url(&self) -> &str { self.env.api_url() } - pub fn web_url(&self) -> &'static str { + pub fn web_url(&self) -> &str { self.env.web_url() } diff --git a/lib/src/datum_cloud/auth.rs b/lib/src/datum_cloud/auth.rs index 0f9ad1b..c1e0099 100644 --- a/lib/src/datum_cloud/auth.rs +++ b/lib/src/datum_cloud/auth.rs @@ -31,6 +31,7 @@ const LOGIN_TIMEOUT: Duration = Duration::from_secs(60); /// Refresh auth or relogin if access token is valid for less than 30min const REFRESH_AUTH_WHEN: Duration = Duration::from_secs(60 * 30); +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthProvider { pub issuer_url: String, pub client_id: String, @@ -136,7 +137,8 @@ pub struct StatelessClient { impl StatelessClient { pub async fn new(env: ApiEnv) -> Result { - Self::with_provider(env, env.auth_provider()).await + let provider = env.auth_provider(); + Self::with_provider(env, provider).await } pub async fn with_provider(env: ApiEnv, provider: AuthProvider) -> Result { @@ -532,19 +534,28 @@ pub struct AuthClient { env: ApiEnv, /// OIDC client with JWKs. Swapped before each login/refresh so we always have fresh keys /// (avoids "No matching key found" when Datum Cloud rotates signing keys; datum-cloud/app#121). - client: Arc>, + /// `None` for datumctl-backed clients, which never run their own OAuth flow. + client: Option>>, _refresh_task: Option>>, + datumctl: Option>, +} + +#[derive(Debug, Clone)] +pub(super) struct DatumctlAuth { + pub profile: UserProfile, + pub session_name: Option, } impl AuthClient { pub async fn with_repo(env: ApiEnv, repo: Repo) -> Result { let auth = AuthStateWrapper::from_repo(repo, env.oauth_storage_key()).await?; - let auth_client = Arc::new(StatelessClient::new(env).await?); + let auth_client = Arc::new(StatelessClient::new(env.clone()).await?); let mut client = Self { state: auth, env, - client: Arc::new(ArcSwap::new(auth_client)), + client: Some(Arc::new(ArcSwap::new(auth_client))), _refresh_task: None, + datumctl: None, }; client.start_refresh_loop(); Ok(client) @@ -552,23 +563,51 @@ impl AuthClient { pub async fn new(env: ApiEnv) -> Result { let auth = AuthStateWrapper::empty(); - let auth_client = Arc::new(StatelessClient::new(env).await?); + let auth_client = Arc::new(StatelessClient::new(env.clone()).await?); let mut client = Self { state: auth, env, - client: Arc::new(ArcSwap::new(auth_client)), + client: Some(Arc::new(ArcSwap::new(auth_client))), _refresh_task: None, + datumctl: None, }; client.start_refresh_loop(); Ok(client) } + /// Build an AuthClient that sources tokens from a sibling `datumctl` install. Does not + /// run its own OAuth flow; `login`/`logout`/`refresh`/`refresh_profile` will error. + pub(super) fn with_datumctl( + env: ApiEnv, + profile: UserProfile, + session_name: Option, + ) -> Self { + Self { + state: AuthStateWrapper::empty(), + env, + client: None, + _refresh_task: None, + datumctl: Some(Arc::new(DatumctlAuth { + profile, + session_name, + })), + } + } + + fn is_datumctl(&self) -> bool { + self.datumctl.is_some() + } + /// Fetch fresh OIDC provider metadata (including JWKs) and swap in a new client. /// Call before login/refresh to avoid "No matching key found" when keys rotate. async fn ensure_fresh_client(&self) -> Result> { - let fresh = - Arc::new(StatelessClient::with_provider(self.env, self.env.auth_provider()).await?); - self.client.store(fresh.clone()); + let client = self.client.as_ref().std_context( + "OIDC flow is disabled on datumctl-backed AuthClient (manage credentials with datumctl)", + )?; + let fresh = Arc::new( + StatelessClient::with_provider(self.env.clone(), self.env.auth_provider()).await?, + ); + client.store(fresh.clone()); Ok(fresh) } @@ -643,6 +682,23 @@ impl AuthClient { } pub async fn load_refreshed(&self) -> Result> { + if let Some(datumctl) = self.datumctl.clone() { + let token = + super::datumctl::fetch_access_token(datumctl.session_name.as_deref()).await?; + let new_auth = AuthState { + tokens: AuthTokens { + access_token: AccessToken::new(token), + refresh_token: None, + issued_at: chrono::Utc::now(), + // datumctl handles refresh; we always fetch fresh, so the expiry is + // only used to keep `login_state()` reporting `Valid`. + expires_in: Duration::from_secs(3600), + }, + profile: datumctl.profile.clone(), + }; + self.state.set(Some(new_auth)).await?; + return Ok(self.state.load()); + } let state = self.state.load(); match state.get() { Err(_) => Ok(state), @@ -655,11 +711,21 @@ impl AuthClient { } pub async fn logout(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "logout is managed by datumctl. Run `datumctl auth logout`." + ); + } self.state.set(None).await?; Ok(()) } pub async fn login(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "login is managed by datumctl. Run `datumctl auth login`." + ); + } let auth = self.state.load(); let auth = match auth.get() { Err(_) => { @@ -700,6 +766,11 @@ impl AuthClient { } pub async fn refresh(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "token refresh is managed by datumctl. Tokens are re-fetched on demand." + ); + } let auth = self.state.load(); let auth = auth.get()?; let client = self.ensure_fresh_client().await?; @@ -717,11 +788,18 @@ impl AuthClient { /// Refresh the user profile from the API without refreshing tokens pub async fn refresh_profile(&self) -> Result<()> { + if self.is_datumctl() { + n0_error::bail_any!( + "user profile is managed by datumctl. Re-run `datumctl auth login` to update." + ); + } let auth = self.state.load(); let auth = auth.get()?; let user_id = auth.profile.user_id.clone(); let new_profile = self .client + .as_ref() + .std_context("OIDC client unavailable")? .load() .fetch_user_profile(&auth.tokens, &user_id) .await?; diff --git a/lib/src/datum_cloud/datumctl.rs b/lib/src/datum_cloud/datumctl.rs new file mode 100644 index 0000000..f3666ce --- /dev/null +++ b/lib/src/datum_cloud/datumctl.rs @@ -0,0 +1,272 @@ +//! Bridge for sourcing auth + selected project from a colocated `datumctl` install. +//! +//! `datum-connect` doesn't run its own OAuth flow when this bridge is used; it reads the +//! datumctl config file for the active session and selected context, and shells out to +//! `datumctl auth get-token` for access tokens. + +use std::path::PathBuf; +use std::process::Stdio; + +use n0_error::{Result, StdResultExt}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use crate::SelectedContext; +use crate::datum_cloud::auth::UserProfile; + +const DATUMCTL_BIN: &str = "datumctl"; +const CONFIG_DIR: &str = ".datumctl"; +const CONFIG_FILE: &str = "config"; + +/// Subset of the datumctl YAML config we depend on. Unknown fields are ignored so schema +/// additions on the datumctl side don't break us. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DatumctlConfig { + #[serde(default, rename = "active-session")] + pub active_session: Option, + #[serde(default, rename = "current-context")] + pub current_context: Option, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub contexts: Vec, + #[serde(default)] + pub cache: Cache, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Session { + pub name: String, + pub endpoint: Endpoint, + #[serde(default, rename = "user-email")] + pub user_email: Option, + #[serde(default, rename = "user-name")] + pub user_name: Option, + #[serde(default, rename = "user-key")] + pub user_key: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Endpoint { + pub server: String, + #[serde(default, rename = "auth-hostname")] + pub auth_hostname: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Context { + pub name: String, + #[serde(default, rename = "organization-id")] + pub organization_id: Option, + #[serde(default, rename = "project-id")] + pub project_id: Option, + #[serde(default)] + pub session: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Cache { + #[serde(default)] + pub organizations: Vec, + #[serde(default)] + pub projects: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CachedOrg { + pub id: String, + #[serde(default, rename = "display-name")] + pub display_name: Option, + #[serde(default, rename = "type")] + pub org_type: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CachedProject { + pub id: String, + #[serde(default, rename = "display-name")] + pub display_name: Option, + #[serde(default, rename = "org-id")] + pub org_id: Option, +} + +/// Everything `datum-connect` needs to talk to the Datum API: an active session, the +/// resolved selected context, and (optionally) a project override. +#[derive(Debug, Clone)] +pub struct ResolvedSession { + pub session_name: String, + pub api_url: String, + pub profile: UserProfile, + pub selected_context: SelectedContext, +} + +fn config_path() -> Result { + let home = dirs_next::home_dir() + .std_context("Could not determine home directory for datumctl config")?; + Ok(home.join(CONFIG_DIR).join(CONFIG_FILE)) +} + +/// Read and parse the datumctl config file. Fails with a friendly message if the file is +/// missing (user hasn't run `datumctl auth login` yet). +pub async fn read_config() -> Result { + let path = config_path()?; + let data = match tokio::fs::read_to_string(&path).await { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + n0_error::bail_any!( + "datumctl config not found at {}. Run `datumctl auth login` first.", + path.display() + ); + } + Err(err) => { + return Err(err) + .with_std_context(|_| format!("Failed to read datumctl config at {}", path.display())); + } + }; + let config: DatumctlConfig = serde_yml::from_str(&data) + .with_std_context(|_| format!("Failed to parse datumctl config at {}", path.display()))?; + Ok(config) +} + +/// Resolve the active session and selected context from the datumctl config. +/// +/// `project_override` lets the caller pin a specific project (e.g. `datum-connect tunnel +/// --project=...`). When set, the org is looked up via the cache; if the project isn't in +/// the cache we trust the user and pass through with the project_id alone. +pub async fn resolve(project_override: Option) -> Result { + let config = read_config().await?; + resolve_from_config(&config, project_override) +} + +pub fn resolve_from_config( + config: &DatumctlConfig, + project_override: Option, +) -> Result { + let current_name = config + .current_context + .as_deref() + .filter(|s| !s.is_empty()) + .std_context( + "No current context set in datumctl. Run `datumctl project switch` to pick one.", + )?; + let context = config + .contexts + .iter() + .find(|c| c.name == current_name) + .with_std_context(|_| { + format!( + "datumctl current-context '{current_name}' not present in contexts list. \ + Re-run `datumctl project switch`." + ) + })?; + + let session_name = context + .session + .as_deref() + .or(config.active_session.as_deref()) + .filter(|s| !s.is_empty()) + .std_context("datumctl current context is not bound to a session. Run `datumctl auth login`.")?; + let session = config + .sessions + .iter() + .find(|s| s.name == session_name) + .with_std_context(|_| { + format!("datumctl session '{session_name}' is referenced but missing. Re-run `datumctl auth login`.") + })?; + + let org_id = context + .organization_id + .clone() + .filter(|s| !s.is_empty()) + .std_context("Selected context has no organization. Use `datumctl project switch` to pick a project.")?; + + let project_id = match project_override { + Some(pid) => pid, + None => context + .project_id + .clone() + .filter(|s| !s.is_empty()) + .std_context( + "Selected context has no project. Use `datumctl project switch` to pick one, \ + or pass `--project`.", + )?, + }; + + let org = config.cache.organizations.iter().find(|o| o.id == org_id); + let project = config.cache.projects.iter().find(|p| p.id == project_id); + + let selected_context = SelectedContext { + org_id: org_id.clone(), + org_name: org + .and_then(|o| o.display_name.clone()) + .unwrap_or_else(|| org_id.clone()), + project_id: project_id.clone(), + project_name: project + .and_then(|p| p.display_name.clone()) + .unwrap_or_else(|| project_id.clone()), + org_type: org + .and_then(|o| o.org_type.clone()) + .unwrap_or_else(|| "personal".to_string()), + }; + + let profile = UserProfile { + user_id: session.user_key.clone().unwrap_or_default(), + email: session.user_email.clone().unwrap_or_default(), + first_name: session.user_name.clone(), + last_name: None, + avatar_url: None, + registration_approval: None, + }; + + Ok(ResolvedSession { + session_name: session.name.clone(), + api_url: session.endpoint.server.trim_end_matches('/').to_string(), + profile, + selected_context, + }) +} + +/// Shell out to `datumctl auth get-token [--session=NAME]` and return the trimmed token. +/// Hard fails if the binary is missing or exits non-zero. +pub async fn fetch_access_token(session_name: Option<&str>) -> Result { + let mut cmd = Command::new(DATUMCTL_BIN); + cmd.arg("auth").arg("get-token"); + if let Some(name) = session_name { + cmd.arg("--session").arg(name); + } + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let output = cmd.output().await.map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + n0_error::anyerr!( + "`datumctl` binary not found on PATH. Install datumctl to use datum-connect." + ) + } else { + n0_error::anyerr!("Failed to spawn datumctl: {err}") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + n0_error::bail_any!( + "datumctl auth get-token exited with status {}. Try `datumctl auth login`.", + output.status + ); + } else { + n0_error::bail_any!("datumctl auth get-token failed: {stderr}"); + } + } + + let token = String::from_utf8(output.stdout) + .std_context("datumctl returned non-UTF8 token output")? + .trim() + .to_string(); + if token.is_empty() { + n0_error::bail_any!("datumctl returned an empty access token"); + } + Ok(token) +} diff --git a/lib/src/datum_cloud/env.rs b/lib/src/datum_cloud/env.rs index 2efc635..9168c2f 100644 --- a/lib/src/datum_cloud/env.rs +++ b/lib/src/datum_cloud/env.rs @@ -15,10 +15,19 @@ const PROD_CLIENT_ID: &str = "360628348109527815"; const PROD_WEB_URL: &str = "https://cloud.datum.net"; /// Environment for Datum API and auth. Use [`ApiEnv::from_env()`] or `ApiEnv::default()` to respect `DATUM_API_ENV`. -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum ApiEnv { Staging, Production, + /// Custom endpoint resolved at runtime (e.g. from datumctl's session config). + /// `auth_provider` is best-effort; embedded OAuth flows will use it but datumctl-backed + /// clients never trigger those. + Custom { + api_url: String, + web_url: String, + auth_provider: AuthProvider, + oauth_storage_key: String, + }, } impl ApiEnv { @@ -30,25 +39,53 @@ impl ApiEnv { } } + /// Map a known API URL to a built-in variant, falling back to a `Custom` env with a + /// stubbed auth provider. The stubbed provider is only valid for clients that never + /// trigger embedded OIDC (i.e. datumctl-backed clients). + pub fn from_api_url(api_url: &str) -> Self { + let trimmed = api_url.trim_end_matches('/'); + if trimmed == PROD_API_URL { + ApiEnv::Production + } else if trimmed == STAGING_API_URL { + ApiEnv::Staging + } else { + ApiEnv::Custom { + api_url: trimmed.to_string(), + web_url: trimmed.to_string(), + auth_provider: AuthProvider { + issuer_url: trimmed.to_string(), + client_id: String::new(), + client_secret: None, + }, + oauth_storage_key: "custom".to_string(), + } + } + } + /// Storage key for per-env OAuth state (e.g. "staging", "production"). - pub fn oauth_storage_key(&self) -> &'static str { + pub fn oauth_storage_key(&self) -> &str { match self { ApiEnv::Staging => "staging", ApiEnv::Production => "production", + ApiEnv::Custom { + oauth_storage_key, .. + } => oauth_storage_key, } } - pub fn api_url(&self) -> &'static str { + pub fn api_url(&self) -> &str { match self { ApiEnv::Staging => STAGING_API_URL, ApiEnv::Production => PROD_API_URL, + ApiEnv::Custom { api_url, .. } => api_url, } } - pub fn web_url(&self) -> &'static str { + pub fn web_url(&self) -> &str { match self { ApiEnv::Staging => STAGING_WEB_URL, ApiEnv::Production => PROD_WEB_URL, + ApiEnv::Custom { web_url, .. } => web_url, } } @@ -64,6 +101,7 @@ impl ApiEnv { client_id: PROD_CLIENT_ID.to_string(), client_secret: None, }, + ApiEnv::Custom { auth_provider, .. } => auth_provider.clone(), } } } From 3826628bdbe6b23f1ca0913989974c81272f7012 Mon Sep 17 00:00:00 2001 From: Gianluca Arbezzano Date: Sun, 17 May 2026 23:36:36 +0200 Subject: [PATCH 13/16] feat: read user-id from jwt token --- lib/src/datum_cloud/datumctl.rs | 63 ++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/src/datum_cloud/datumctl.rs b/lib/src/datum_cloud/datumctl.rs index f3666ce..8237dab 100644 --- a/lib/src/datum_cloud/datumctl.rs +++ b/lib/src/datum_cloud/datumctl.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::process::Stdio; +use data_encoding::BASE64URL_NOPAD; use n0_error::{Result, StdResultExt}; use serde::{Deserialize, Serialize}; use tokio::process::Command; @@ -135,7 +136,14 @@ pub async fn read_config() -> Result { /// the cache we trust the user and pass through with the project_id alone. pub async fn resolve(project_override: Option) -> Result { let config = read_config().await?; - resolve_from_config(&config, project_override) + let mut resolved = resolve_from_config(&config, project_override)?; + // datumctl's session.user-key holds the user's email, but the IAM API at + // /apis/iam.miloapis.com/v1alpha1/users/{user_id} requires the OIDC subject. + // Pull a fresh access token and read its `sub` claim. + let token = fetch_access_token(Some(&resolved.session_name)).await?; + resolved.profile.user_id = decode_jwt_sub(&token) + .std_context("Failed to extract user ID from datumctl access token")?; + Ok(resolved) } pub fn resolve_from_config( @@ -270,3 +278,56 @@ pub async fn fetch_access_token(session_name: Option<&str>) -> Result { } Ok(token) } + +/// Extract the `sub` claim from a JWT access token without verifying its signature. +/// We rely on datumctl to have already validated the token; we only need the subject +/// to build IAM API paths like `/apis/iam.miloapis.com/v1alpha1/users/{sub}`. +fn decode_jwt_sub(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + n0_error::bail_any!( + "access token is not a JWT (expected 3 dot-separated segments, got {})", + parts.len() + ); + } + let payload_bytes = BASE64URL_NOPAD + .decode(parts[1].as_bytes()) + .std_context("Failed to base64url-decode JWT payload")?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .std_context("Failed to parse JWT payload as JSON")?; + payload + .get("sub") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .std_context("JWT payload is missing `sub` claim") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_jwt(payload_json: &str) -> String { + let header = BASE64URL_NOPAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload = BASE64URL_NOPAD.encode(payload_json.as_bytes()); + let sig = BASE64URL_NOPAD.encode(b"sig"); + format!("{header}.{payload}.{sig}") + } + + #[test] + fn decode_jwt_sub_reads_subject_claim() { + let token = make_jwt(r#"{"sub":"user-abc123","email":"a@b.com"}"#); + assert_eq!(decode_jwt_sub(&token).unwrap(), "user-abc123"); + } + + #[test] + fn decode_jwt_sub_rejects_non_jwt() { + assert!(decode_jwt_sub("not-a-jwt").is_err()); + assert!(decode_jwt_sub("a.b").is_err()); + } + + #[test] + fn decode_jwt_sub_rejects_missing_sub() { + let token = make_jwt(r#"{"email":"a@b.com"}"#); + assert!(decode_jwt_sub(&token).is_err()); + } +} From e38f5be439d2f005d185b426512770cddccba988 Mon Sep 17 00:00:00 2001 From: Gianluca Arbezzano Date: Sun, 17 May 2026 23:38:13 +0200 Subject: [PATCH 14/16] chore: static cli --- flake.nix | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/flake.nix b/flake.nix index 7a67322..f08a751 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,22 @@ targets = [ "wasm32-unknown-unknown" ]; }; + # Toolchain used for the static (musl) CLI build. Kept separate from + # the dev toolchain so the dev shell isn't forced to download musl + # targets it doesn't need. + muslTarget = + if pkgs.stdenv.hostPlatform.isAarch64 then "aarch64-unknown-linux-musl" + else "x86_64-unknown-linux-musl"; + + rustMuslToolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [ muslTarget ]; + }; + + rustMuslPlatform = pkgs.makeRustPlatform { + cargo = rustMuslToolchain; + rustc = rustMuslToolchain; + }; + # Platform-specific packages darwinPackages = with pkgs; lib.optionals stdenv.isDarwin [ apple-sdk_15 @@ -106,6 +122,87 @@ }; }; + # Fully statically linked Linux CLI (musl + static openssl). + # Build with: nix build .#cli-static + # + # We host-build with the default gnu stdenv and cross-target musl + # via cargo's --target flag. Earlier versions of this derivation + # tried `.override { stdenv = muslPkgs.stdenv }` so that + # cargoBuildHook would auto-pick --target=...-musl, but the + # combination of makeRustPlatform + stdenv override + the install + # hook ended up writing the gnu-built binary into $out/bin (only + # the store path name carried the "-musl" suffix). Driving the + # build and install phases ourselves removes that ambiguity. + packages.cli-static = let + muslPkgs = + if pkgs.stdenv.hostPlatform.isAarch64 then pkgs.pkgsCross.aarch64-multiplatform-musl + else pkgs.pkgsCross.musl64; + muslCC = muslPkgs.stdenv.cc; + muslOpenssl = muslPkgs.pkgsStatic.openssl; + linkerEnvVar = + "CARGO_TARGET_" + (pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] muslTarget)) + "_LINKER"; + in rustMuslPlatform.buildRustPackage { + pname = "datum-connect-cli-static"; + version = "0.1.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = cargoOutputHashes; + }; + + nativeBuildInputs = [ + pkgs.pkg-config + muslCC + ]; + + # openssl-sys is pulled in transitively (sentry -> native-tls). + # Provide the musl-built static openssl so the C link succeeds. + buildInputs = [ muslOpenssl ]; + + # Tell openssl-sys + pkg-config to statically link, and point them + # at the musl-built openssl (not the host one). + OPENSSL_STATIC = "1"; + OPENSSL_LIB_DIR = "${muslOpenssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${muslOpenssl.dev}/include"; + PKG_CONFIG_ALL_STATIC = "1"; + + # Force the C runtime to be linked statically against musl. + CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static"; + + # Use the musl gcc as the linker for the musl target. + ${linkerEnvVar} = "${muslCC}/bin/${muslCC.targetPrefix}cc"; + + buildPhase = '' + runHook preBuild + cargo build -j $NIX_BUILD_CORES \ + --target ${muslTarget} \ + --frozen \ + --release \ + -p datum-connect + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + install -Dm755 target/${muslTarget}/release/datum-connect \ + $out/bin/datum-connect + runHook postInstall + ''; + + doCheck = false; + + # The musl-static binary has no nix-store deps to rewrite. + dontPatchELF = true; + + meta = with pkgs.lib; { + description = "Datum Connect CLI (statically linked)"; + mainProgram = "datum-connect"; + platforms = platforms.linux; + }; + }; + devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ # Rust toolchain with WASM support From 567489f8cd390cffe5941bccbe19229e1cac65f3 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Mon, 18 May 2026 14:31:08 +0000 Subject: [PATCH 15/16] fix: use xdotool for libxdo and wire RUSTFLAGS for linker search path pkgs.xdo is a standalone CLI tool with no library output; libxdo-sys requires libxdo.so which is provided by pkgs.xdotool. Also replace the explicit PKG_CONFIG_PATH override (which prevented nix from auto-populating it) with RUSTFLAGS=-L so the linker can find libxdo regardless of whether pkg-config resolves it. --- flake.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index f08a751..433f964 100644 --- a/flake.nix +++ b/flake.nix @@ -47,7 +47,7 @@ gtk3 libsoup_3 # X11 dependencies - xdo + xdotool xorg.libX11 xorg.libXcursor xorg.libXrandr @@ -229,8 +229,8 @@ # Environment variables RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; - # For OpenSSL on macOS - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + RUSTFLAGS = pkgs.lib.optionalString pkgs.stdenv.isLinux + "-L${pkgs.xdotool}/lib"; shellHook = '' echo "🚀 Dioxus development environment loaded" From 16e632b357d3a31ac04bec4a4c4564e3fba7177e Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Mon, 18 May 2026 14:35:55 +0000 Subject: [PATCH 16/16] chore: fix all compiler warnings - Remove unused FAVICON_LIGHT_32 static in auth.rs - Remove unused NativeIcon import in main.rs - Gate FAVICON_*_196 constants behind #[cfg(target_os = "macos")] - Move title_bar_bg binding inside its #[cfg(target_os = "macos")] block - Drop unnecessary mut from five Signal bindings in App() - Prefix unused tx/checker locals with _ in fake-update path - Add #[allow(dead_code)] to SelectSize::Small (implemented but unconstructed) - Remove unused listen_node() method from AppState --- lib/src/datum_cloud/auth.rs | 2 -- ui/src/components/select/component.rs | 1 + ui/src/main.rs | 33 ++++++++++++++------------- ui/src/state.rs | 4 ---- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/lib/src/datum_cloud/auth.rs b/lib/src/datum_cloud/auth.rs index c1e0099..a0e1860 100644 --- a/lib/src/datum_cloud/auth.rs +++ b/lib/src/datum_cloud/auth.rs @@ -894,8 +894,6 @@ mod redirect_server { static LOGIN_SUCCESS_PNG: &[u8] = include_bytes!("../../../ui/assets/images/login-success.png"); static ALLIANCE_NO1_REGULAR_TTF: &[u8] = include_bytes!("../../../ui/assets/fonts/AllianceNo1-Regular.ttf"); - static FAVICON_LIGHT_32: &[u8] = - include_bytes!("../../../ui/assets/icons/favicon-light-32x32.png"); static FAVICON_DARK_32: &[u8] = include_bytes!("../../../ui/assets/icons/favicon-dark-32x32.png"); diff --git a/ui/src/components/select/component.rs b/ui/src/components/select/component.rs index b20e967..e11e3bf 100644 --- a/ui/src/components/select/component.rs +++ b/ui/src/components/select/component.rs @@ -17,6 +17,7 @@ pub enum SelectAlign { #[derive(Clone, Copy, PartialEq, Eq)] pub enum SelectSize { Default, + #[allow(dead_code)] Small, } diff --git a/ui/src/main.rs b/ui/src/main.rs index d3e1d7c..9f2ce1b 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -13,7 +13,7 @@ use crate::views::{ #[cfg(feature = "desktop")] use dioxus_desktop::{ trayicon::{ - menu::{IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem}, + menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem}, Icon, TrayIcon, TrayIconBuilder, }, use_muda_event_handler, use_window, @@ -88,8 +88,10 @@ pub struct DiagnosticsContext { pub prompted: Signal, } -// Assets for favicons +// Assets for favicons (macOS title bar only) +#[cfg(target_os = "macos")] const FAVICON_DARK_196: Asset = asset!("/assets/icons/favicon-dark-196x196.png"); +#[cfg(target_os = "macos")] const FAVICON_LIGHT_196: Asset = asset!("/assets/icons/favicon-light-196x196.png"); #[cfg(all(feature = "desktop", target_os = "macos"))] @@ -171,7 +173,7 @@ fn main() { .with_has_shadow(true) .with_fullsize_content_view(true); - let mut config = Config::new() + let config = Config::new() // Make "close" behave like hide, so the tray icon can restore it. .with_close_behaviour(WindowCloseBehaviour::WindowHides) .with_window(window_builder); @@ -210,14 +212,13 @@ fn init_tracing() { #[component] fn TitleBar() -> Element { let IsLoginPageSignal(is_login_page) = consume_context::(); - let title_bar_bg = if is_login_page() { - "h-[32px] flex items-center pl-20 z-50 cursor-default bg-[var(--midnight-fjord)]" - } else { - "h-[32px] flex items-center pl-20 z-50 cursor-default bg-background" - }; - #[cfg(target_os = "macos")] { + let title_bar_bg = if is_login_page() { + "h-[32px] flex items-center pl-20 z-50 cursor-default bg-[var(--midnight-fjord)]" + } else { + "h-[32px] flex items-center pl-20 z-50 cursor-default bg-background" + }; rsx! { div { class: "{title_bar_bg}", @@ -254,13 +255,13 @@ fn TitleBar() -> Element { #[component] fn App() -> Element { let mut app_state_ready = use_signal(|| false); - let mut installing_update = + let installing_update = use_signal(|| std::env::var("DATUM_UPDATE_FAKE_INSTALLING").as_deref() == Ok("1")); - let mut manual_update_check = use_signal(|| false); - let mut update_check_in_progress = use_signal(|| false); - let mut update_downloading = use_signal(|| false); + let manual_update_check = use_signal(|| false); + let update_check_in_progress = use_signal(|| false); + let update_downloading = use_signal(|| false); let mut update_ready = use_signal(|| None::); - let mut has_pending_install = use_signal(|| false); + let has_pending_install = use_signal(|| false); let mut pending_update_info = use_signal(|| None::); let mut install_now_trigger = use_signal(|| false); let update_channel = use_signal(|| lib::UpdateChannel::infer_from_installed_version()); @@ -391,8 +392,8 @@ fn App() -> Element { tracing::info!("DATUM_UPDATE_FAKE=1: injected fake update ready"); // Still run the loop to handle Later/Install now, but skip real updates - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); - let checker = lib::UpdateChecker::new(repo.clone()); + let (_tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); + let _checker = lib::UpdateChecker::new(repo.clone()); let mut last_periodic_check = std::time::Instant::now(); loop { tokio::select! { diff --git a/ui/src/state.rs b/ui/src/state.rs index b0da2d8..c2a10e5 100644 --- a/ui/src/state.rs +++ b/ui/src/state.rs @@ -48,10 +48,6 @@ impl AppState { &self.heartbeat } - pub fn listen_node(&self) -> &ListenNode { - &self.node().listen - } - pub fn tunnel_service(&self) -> TunnelService { TunnelService::new(self.datum.clone(), self.node.listen.clone()) }