diff --git a/burrow-gtk/Cargo.toml b/burrow-gtk/Cargo.toml index 21cb52e..b12577a 100644 --- a/burrow-gtk/Cargo.toml +++ b/burrow-gtk/Cargo.toml @@ -11,6 +11,8 @@ relm4 = { version = "0.6", features = ["libadwaita", "gnome_44"]} burrow = { version = "*", path = "../burrow/" } tokio = { version = "1.35.0", features = ["time", "sync"] } gettext-rs = { version = "0.7.0", features = ["gettext-system"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [build-dependencies] anyhow = "1.0" diff --git a/burrow-gtk/src/account_store.rs b/burrow-gtk/src/account_store.rs new file mode 100644 index 0000000..6aee78b --- /dev/null +++ b/burrow-gtk/src/account_store.rs @@ -0,0 +1,139 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub id: String, + pub kind: AccountKind, + pub title: String, + pub authority: Option, + pub account: String, + pub identity: String, + pub hostname: Option, + pub tailnet: Option, + pub note: Option, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccountKind { + WireGuard, + Tor, + Tailnet, +} + +impl AccountKind { + pub fn title(self) -> &'static str { + match self { + Self::WireGuard => "WireGuard", + Self::Tor => "Tor", + Self::Tailnet => "Tailnet", + } + } + + fn sort_rank(self) -> u8 { + match self { + Self::Tailnet => 0, + Self::Tor => 1, + Self::WireGuard => 2, + } + } +} + +pub fn load() -> Result> { + let path = storage_path()?; + if !path.exists() { + return Ok(Vec::new()); + } + let data = + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_slice(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +pub fn upsert(mut record: AccountRecord) -> Result> { + let mut accounts = load()?; + let now = timestamp(); + record.updated_at = now; + if record.created_at == 0 { + record.created_at = now; + } + + if let Some(index) = accounts.iter().position(|account| account.id == record.id) { + accounts[index] = record; + } else { + accounts.push(record); + } + accounts.sort_by(|lhs, rhs| { + lhs.kind + .sort_rank() + .cmp(&rhs.kind.sort_rank()) + .then_with(|| lhs.title.to_lowercase().cmp(&rhs.title.to_lowercase())) + }); + persist(&accounts)?; + Ok(accounts) +} + +pub fn new_record( + kind: AccountKind, + title: String, + authority: Option, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + note: Option, +) -> AccountRecord { + let now = timestamp(); + AccountRecord { + id: format!("{}-{now}", kind.title().to_ascii_lowercase()), + kind, + title, + authority, + account, + identity, + hostname, + tailnet, + note, + created_at: now, + updated_at: now, + } +} + +fn persist(accounts: &[AccountRecord]) -> Result<()> { + let path = storage_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let data = serde_json::to_vec_pretty(accounts).context("failed to encode account store")?; + std::fs::write(&path, data).with_context(|| format!("failed to write {}", path.display())) +} + +fn storage_path() -> Result { + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home) + .join("burrow") + .join("accounts.json")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("accounts.json")); + } + Ok(std::env::temp_dir().join("burrow-accounts.json")) +} + +fn timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} diff --git a/burrow-gtk/src/components/app.rs b/burrow-gtk/src/components/app.rs index 62c98c0..7354825 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -1,24 +1,19 @@ use super::*; use anyhow::Context; -use std::time::Duration; - -const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { - daemon_client: Arc>>, - settings_screen: Controller, - switch_screen: AsyncController, + _home_screen: AsyncController, } #[derive(Debug)] pub enum AppMsg { None, - PostInit, } impl App { pub fn run() { let app = RelmApp::new(config::ID); + relm4::set_global_css(APP_CSS); Self::setup_gresources().unwrap(); Self::setup_i18n().unwrap(); @@ -49,7 +44,7 @@ impl AsyncComponent for App { view! { adw::Window { set_title: Some("Burrow"), - set_default_size: (640, 480), + set_default_size: (900, 760), } } @@ -58,100 +53,84 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let daemon_client = Arc::new(Mutex::new(DaemonClient::new().await.ok())); - - let switch_screen = switch_screen::SwitchScreen::builder() - .launch(switch_screen::SwitchScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) - .forward(sender.input_sender(), |_| AppMsg::None); - - let settings_screen = settings_screen::SettingsScreen::builder() - .launch(settings_screen::SettingsScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) + let home_screen = home_screen::HomeScreen::builder() + .launch(()) .forward(sender.input_sender(), |_| AppMsg::None); let widgets = view_output!(); - let view_stack = adw::ViewStack::new(); - view_stack.add_titled(switch_screen.widget(), None, "Switch"); - view_stack.add_titled(settings_screen.widget(), None, "Settings"); - - let view_switcher_bar = adw::ViewSwitcherBar::builder().stack(&view_stack).build(); - view_switcher_bar.set_reveal(true); - - // When libadwaita 1.4 support becomes more avaliable, this approach is more appropriate - // - // let toolbar = adw::ToolbarView::new(); - // toolbar.add_top_bar( - // &adw::HeaderBar::builder() - // .title_widget(>k::Label::new(Some("Burrow"))) - // .build(), - // ); - // toolbar.add_bottom_bar(&view_switcher_bar); - // toolbar.set_content(Some(&view_stack)); - // root.set_content(Some(&toolbar)); - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append( &adw::HeaderBar::builder() .title_widget(>k::Label::new(Some("Burrow"))) .build(), ); - content.append(&view_stack); - content.append(&view_switcher_bar); + content.append(home_screen.widget()); root.set_content(Some(&content)); - sender.input(AppMsg::PostInit); - - let model = App { - daemon_client, - switch_screen, - settings_screen, - }; + let model = App { _home_screen: home_screen }; AsyncComponentParts { model, widgets } } async fn update( &mut self, - _msg: Self::Input, + msg: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root, ) { - loop { - tokio::time::sleep(RECONNECT_POLL_TIME).await; - { - let mut daemon_client = self.daemon_client.lock().await; - let mut disconnected_daemon_client = false; - - if let Some(daemon_client) = daemon_client.as_mut() { - if let Err(_e) = daemon_client.send_command(DaemonCommand::ServerInfo).await { - disconnected_daemon_client = true; - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonDisconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - } - - if disconnected_daemon_client || daemon_client.is_none() { - match DaemonClient::new().await { - Ok(new_daemon_client) => { - *daemon_client = Some(new_daemon_client); - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - Err(_e) => { - // TODO: Handle Error - } - } - } - } + match msg { + AppMsg::None => {} } } } + +const APP_CSS: &str = r#" +.empty-state { + border-radius: 18px; + padding: 22px; + background: alpha(@card_bg_color, 0.72); +} + +.summary-card { + border-radius: 18px; + padding: 14px; + background: alpha(@card_bg_color, 0.72); +} + +.network-card { + border-radius: 10px; + padding: 16px; + box-shadow: 0 2px 6px alpha(black, 0.14); +} + +.wireguard-card { + background: linear-gradient(135deg, #3277d8, #174ea6); +} + +.tailnet-card { + background: linear-gradient(135deg, #31b891, #147d69); +} + +.network-card-kind, +.network-card-title, +.network-card-detail { + color: white; +} + +.network-card-kind { + opacity: 0.86; + font-weight: 700; +} + +.network-card-title { + font-size: 1.22em; + font-weight: 700; +} + +.network-card-detail { + opacity: 0.92; + font-family: monospace; +} +"#; diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs new file mode 100644 index 0000000..0bfdda2 --- /dev/null +++ b/burrow-gtk/src/components/home_screen.rs @@ -0,0 +1,1178 @@ +use super::*; +use crate::account_store::{self, AccountKind, AccountRecord}; +use std::time::Duration; + +pub struct HomeScreen { + daemon_banner: adw::Banner, + network_status: gtk::Label, + network_cards: gtk::Box, + account_status: gtk::Label, + account_rows: gtk::Box, + tunnel_status: gtk::Label, + tunnel_button: gtk::Button, + tunnel_state: Option, + tailnet_session_id: Option, + tailnet_running: bool, +} + +#[derive(Debug)] +pub enum HomeScreenMsg { + EnsureDaemon, + Refresh, + TunnelAction, + OpenWireGuard, + OpenTor, + OpenTailnet, + AddWireGuard { + title: String, + account: String, + identity: String, + config: String, + }, + SaveTor { + title: String, + account: String, + identity: String, + note: String, + }, + DiscoverTailnet(String), + ProbeTailnet(String), + StartTailnetLogin { + authority: String, + account: String, + identity: String, + hostname: Option, + }, + PollTailnetLogin, + CancelTailnetLogin, + AddTailnet { + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + }, +} + +#[relm4::component(pub, async)] +impl AsyncComponent for HomeScreen { + type Init = (); + type Input = HomeScreenMsg; + type Output = (); + type CommandOutput = (); + + view! { + gtk::ScrolledWindow { + set_vexpand: true, + + adw::Clamp { + set_maximum_size: 900, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 24, + set_margin_all: 24, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 16, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 6, + set_hexpand: true, + + gtk::Label { + add_css_class: "title-1", + set_xalign: 0.0, + set_label: "Burrow", + }, + + gtk::Label { + add_css_class: "heading", + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Networks and accounts", + }, + }, + + #[name(add_button)] + gtk::MenuButton { + add_css_class: "flat", + set_icon_name: "list-add-symbolic", + set_tooltip_text: Some("Add"), + set_valign: Align::Start, + }, + }, + + #[name(daemon_banner)] + adw::Banner { + set_title: "Starting Burrow daemon", + set_revealed: false, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Networks", + }, + + #[name(network_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Stored daemon networks and their active account selectors", + }, + }, + + gtk::ScrolledWindow { + set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never), + set_min_content_height: 190, + + #[name(network_cards)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 14, + }, + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Accounts", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Per-network identities and sign-in state", + }, + }, + + #[name(account_rows)] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + set_margin_all: 0, + set_valign: Align::Center, + }, + + #[name(account_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "", + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Tunnel", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Current daemon tunnel state", + }, + }, + + #[name(tunnel_status)] + gtk::Label { + set_xalign: 0.0, + set_label: "Checking daemon status", + }, + + #[name(tunnel_button)] + gtk::Button { + add_css_class: "suggested-action", + set_label: "Start", + set_halign: Align::Start, + connect_clicked => HomeScreenMsg::TunnelAction, + }, + }, + } + } + } + } + + async fn init( + _: Self::Init, + _root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + let widgets = view_output!(); + configure_add_popover(&widgets.add_button, &sender); + + let refresh_sender = sender.input_sender().clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + refresh_sender.emit(HomeScreenMsg::Refresh); + } + }); + + let model = HomeScreen { + daemon_banner: widgets.daemon_banner.clone(), + network_status: widgets.network_status.clone(), + network_cards: widgets.network_cards.clone(), + account_status: widgets.account_status.clone(), + account_rows: widgets.account_rows.clone(), + tunnel_status: widgets.tunnel_status.clone(), + tunnel_button: widgets.tunnel_button.clone(), + tunnel_state: None, + tailnet_session_id: None, + tailnet_running: false, + }; + + sender.input(HomeScreenMsg::EnsureDaemon); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + msg: Self::Input, + sender: AsyncComponentSender, + root: &Self::Root, + ) { + match msg { + HomeScreenMsg::EnsureDaemon => self.ensure_daemon().await, + HomeScreenMsg::Refresh => self.refresh().await, + HomeScreenMsg::TunnelAction => self.perform_tunnel_action().await, + HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), + HomeScreenMsg::OpenTor => open_tor_window(root, &sender), + HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), + HomeScreenMsg::AddWireGuard { + title, + account, + identity, + config, + } => self.add_wireguard(title, account, identity, config).await, + HomeScreenMsg::SaveTor { title, account, identity, note } => { + self.save_tor(title, account, identity, note) + } + HomeScreenMsg::DiscoverTailnet(email) => self.discover_tailnet(email).await, + HomeScreenMsg::ProbeTailnet(authority) => self.probe_tailnet(authority).await, + HomeScreenMsg::StartTailnetLogin { + authority, + account, + identity, + hostname, + } => { + self.start_tailnet_login(authority, account, identity, hostname, sender) + .await; + } + HomeScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await, + HomeScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await, + HomeScreenMsg::AddTailnet { + authority, + account, + identity, + hostname, + tailnet, + } => { + self.add_tailnet(authority, account, identity, hostname, tailnet) + .await; + } + } + } +} + +impl HomeScreen { + async fn ensure_daemon(&mut self) { + self.daemon_banner.set_title("Starting Burrow daemon"); + self.daemon_banner.set_revealed(true); + match daemon_api::ensure_daemon().await { + Ok(()) => { + self.daemon_banner.set_revealed(false); + self.refresh().await; + } + Err(error) => { + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_state = None; + self.tunnel_status.set_label("Daemon unavailable"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + self.network_status + .set_label("Stored daemon networks are unavailable until the daemon starts."); + self.render_networks(&[]); + } + } + } + + async fn refresh(&mut self) { + match daemon_api::tunnel_state().await { + Ok(state) => { + self.daemon_banner.set_revealed(false); + self.tunnel_state = Some(state); + match state { + daemon_api::TunnelState::Running => { + self.tunnel_status.set_label("Connected"); + self.tunnel_button.set_label("Stop"); + } + daemon_api::TunnelState::Stopped => { + self.tunnel_status.set_label("Disconnected"); + self.tunnel_button.set_label("Start"); + } + } + self.tunnel_button.set_sensitive(true); + } + Err(error) => { + self.tunnel_state = None; + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_status.set_label("Unknown"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + } + } + + match daemon_api::list_networks().await { + Ok(networks) => { + self.render_networks(&networks); + self.network_status.set_label(if networks.is_empty() { + "Stored daemon networks and their active account selectors" + } else { + "Stored daemon networks and their active account selectors" + }); + } + Err(error) => { + self.render_networks(&[]); + self.network_status + .set_label(&format!("Unable to read daemon networks: {error}")); + } + } + + match account_store::load() { + Ok(accounts) => { + self.account_status.set_label(""); + self.render_accounts(&accounts); + } + Err(error) => { + self.render_accounts(&[]); + self.account_status + .set_label(&format!("Unable to read account store: {error}")); + } + } + } + + async fn perform_tunnel_action(&mut self) { + match self.tunnel_state { + Some(daemon_api::TunnelState::Running) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Disconnecting..."); + if let Err(error) = daemon_api::stop_tunnel().await { + self.tunnel_status + .set_label(&format!("Stop failed: {error}")); + } + self.refresh().await; + } + Some(daemon_api::TunnelState::Stopped) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Connecting..."); + if let Err(error) = daemon_api::start_tunnel().await { + self.tunnel_status + .set_label(&format!("Start failed: {error}")); + } + self.refresh().await; + } + None => self.ensure_daemon().await, + } + } + + async fn add_wireguard( + &mut self, + title: String, + account: String, + identity: String, + config: String, + ) { + if config.trim().is_empty() { + self.network_status + .set_label("Paste a WireGuard configuration before adding a network."); + return; + } + match daemon_api::add_wireguard(config).await { + Ok(id) => { + let title = daemon_api::normalized(&title, &format!("WireGuard {id}")); + let record = account_store::new_record( + AccountKind::WireGuard, + title, + None, + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, &format!("network-{id}")), + None, + None, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("WireGuard account save failed: {error}")), + } + self.network_status + .set_label(&format!("Added WireGuard network #{id}.")); + self.refresh().await; + } + Err(error) => self + .network_status + .set_label(&format!("Unable to add WireGuard network: {error}")), + } + } + + fn save_tor(&mut self, title: String, account: String, identity: String, note: String) { + let record = account_store::new_record( + AccountKind::Tor, + daemon_api::normalized( + &title, + &format!("Tor {}", daemon_api::normalized(&identity, "linux")), + ), + Some("arti://local".to_owned()), + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, "linux"), + None, + None, + Some(note), + ); + match account_store::upsert(record) { + Ok(accounts) => { + self.account_status.set_label("Saved Tor account."); + self.render_accounts(&accounts); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tor account: {error}")), + } + } + + async fn discover_tailnet(&mut self, email: String) { + let Ok(email) = daemon_api::require_value(&email, "Email address") else { + self.account_status + .set_label("Enter an email address before Tailnet discovery."); + return; + }; + + self.account_status.set_label("Finding Tailnet server..."); + match daemon_api::discover_tailnet(email).await { + Ok(discovery) => { + let kind = if discovery.managed { + "managed authority" + } else { + "custom authority" + }; + let issuer = discovery + .oidc_issuer + .map(|issuer| format!(" OIDC: {issuer}.")) + .unwrap_or_default(); + self.account_status.set_label(&format!( + "Discovered {kind}: {}.{issuer}", + discovery.authority + )); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet discovery failed: {error}")), + } + } + + async fn probe_tailnet(&mut self, authority: String) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before checking it."); + return; + }; + + self.account_status.set_label("Checking Tailnet server..."); + match daemon_api::probe_tailnet(authority).await { + Ok(probe) => { + let detail = probe + .detail + .unwrap_or_else(|| format!("HTTP {}", probe.status_code)); + self.account_status + .set_label(&format!("{}: {detail}", probe.summary)); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet probe failed: {error}")), + } + } + + async fn start_tailnet_login( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + sender: AsyncComponentSender, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before sign-in."); + return; + }; + + self.account_status.set_label("Starting Tailnet sign-in..."); + match daemon_api::start_tailnet_login(authority, account, identity, hostname).await { + Ok(status) => { + self.apply_login_status(&status); + if let Some(auth_url) = status.auth_url.as_deref() { + if let Err(error) = open_auth_url(auth_url) { + self.account_status.set_label(&format!( + "{} Open this URL manually: {auth_url}. Browser launch failed: {error}", + self.account_status.text() + )); + } + } + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet sign-in failed: {error}")), + } + } + + async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender) { + let Some(session_id) = self.tailnet_session_id.clone() else { + return; + }; + if self.tailnet_running { + return; + } + + tokio::time::sleep(Duration::from_secs(1)).await; + match daemon_api::tailnet_login_status(session_id).await { + Ok(status) => { + self.apply_login_status(&status); + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => { + self.account_status + .set_label(&format!("Tailnet sign-in status failed: {error}")); + self.tailnet_session_id = None; + } + } + } + + async fn cancel_tailnet_login(&mut self) { + let Some(session_id) = self.tailnet_session_id.clone() else { + self.account_status + .set_label("No Tailnet sign-in is active."); + return; + }; + match daemon_api::cancel_tailnet_login(session_id).await { + Ok(()) => { + self.tailnet_session_id = None; + self.tailnet_running = false; + self.account_status.set_label("Tailnet sign-in cancelled."); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to cancel Tailnet sign-in: {error}")), + } + } + + async fn add_tailnet( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before saving."); + return; + }; + if self.tailnet_session_id.is_some() && !self.tailnet_running { + self.account_status + .set_label("Finish browser sign-in before saving this Tailnet account."); + return; + } + + let stored_authority = daemon_api::normalized_optional(&authority) + .unwrap_or_else(|| daemon_api::default_tailnet_authority().to_owned()); + let stored_account = daemon_api::normalized(&account, "default"); + let stored_identity = daemon_api::normalized(&identity, "linux"); + let stored_hostname = hostname.clone(); + let stored_tailnet = tailnet.clone(); + + match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await { + Ok(id) => { + let title = stored_tailnet + .clone() + .or(stored_hostname.clone()) + .unwrap_or_else(|| format!("Tailnet {id}")); + let record = account_store::new_record( + AccountKind::Tailnet, + title, + Some(stored_authority), + stored_account, + stored_identity, + stored_hostname, + stored_tailnet, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("Tailnet account save failed: {error}")), + } + self.account_status + .set_label(&format!("Saved Tailnet account and network #{id}.")); + self.refresh().await; + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tailnet account: {error}")), + } + } + + fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { + self.tailnet_session_id = Some(status.session_id.clone()); + self.tailnet_running = status.running; + + let mut parts = Vec::new(); + if status.running { + parts.push("Signed In".to_owned()); + } else if status.needs_login { + parts.push("Browser Sign-In Required".to_owned()); + } else { + parts.push("Checking Sign-In".to_owned()); + } + if !status.backend_state.is_empty() { + parts.push(format!("State: {}", status.backend_state)); + } + if let Some(tailnet_name) = &status.tailnet_name { + parts.push(format!("Tailnet: {tailnet_name}")); + } + if let Some(self_dns_name) = &status.self_dns_name { + parts.push(self_dns_name.clone()); + } + if !status.tailnet_ips.is_empty() { + parts.push(status.tailnet_ips.join(", ")); + } + if !status.health.is_empty() { + parts.push(status.health.join(" / ")); + } + self.account_status.set_label(&parts.join("\n")); + } + + fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) { + while let Some(child) = self.network_cards.first_child() { + self.network_cards.remove(&child); + } + + if networks.is_empty() { + self.network_cards.append(&empty_networks_view()); + return; + } + + for network in networks { + self.network_cards.append(&network_card(network)); + } + } + + fn render_accounts(&self, accounts: &[AccountRecord]) { + while let Some(child) = self.account_rows.first_child() { + self.account_rows.remove(&child); + } + + if accounts.is_empty() { + self.account_rows.append(&empty_accounts_view()); + return; + } + + for account in accounts { + self.account_rows.append(&account_card(account)); + } + } +} + +fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender) { + let popover = gtk::Popover::new(); + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 4); + box_.set_margin_all(6); + + for (label, msg) in [ + ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), + ("Save Tor Account", HomeScreenMsg::OpenTor), + ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), + ] { + let item = gtk::Button::with_label(label); + item.add_css_class("flat"); + item.set_halign(Align::Fill); + let input = sender.input_sender().clone(); + item.connect_clicked(move |_| input.emit(msg_from_template(&msg))); + box_.append(&item); + } + + popover.set_child(Some(&box_)); + button.set_popover(Some(&popover)); +} + +fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { + match msg { + HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, + HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, + HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, + _ => unreachable!(), + } +} + +fn network_card(network: &daemon_api::NetworkSummary) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 10); + card.add_css_class("network-card"); + if network.title.to_ascii_lowercase().contains("wireguard") { + card.add_css_class("wireguard-card"); + } else { + card.add_css_class("tailnet-card"); + } + card.set_size_request(360, 175); + card.set_margin_bottom(8); + + let kind = if network.title.to_ascii_lowercase().contains("wireguard") { + "WireGuard" + } else { + "Tailnet" + }; + let kind_label = gtk::Label::new(Some(kind)); + kind_label.add_css_class("network-card-kind"); + kind_label.set_xalign(0.0); + + let title = gtk::Label::new(Some(&network.title)); + title.add_css_class("network-card-title"); + title.set_xalign(0.0); + title.set_wrap(true); + + let spacer = gtk::Box::new(gtk::Orientation::Vertical, 0); + spacer.set_vexpand(true); + + let detail = gtk::Label::new(Some(&network.detail)); + detail.add_css_class("network-card-detail"); + detail.set_xalign(0.0); + detail.set_wrap(true); + detail.set_lines(4); + + card.append(&kind_label); + card.append(&title); + card.append(&spacer); + card.append(&detail); + card +} + +fn empty_networks_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_size_request(520, 175); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Networks Yet")); + title.add_css_class("title-3"); + title.set_xalign(0.0); + let detail = gtk::Label::new(Some( + "Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_xalign(0.0); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn empty_accounts_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Accounts Yet")); + title.add_css_class("title-3"); + title.set_justify(gtk::Justification::Center); + let detail = gtk::Label::new(Some( + "Save a Tor account or sign in to Tailnet to keep network identities ready on this device.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_justify(gtk::Justification::Center); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn account_card(account: &AccountRecord) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 8); + card.add_css_class("summary-card"); + card.set_hexpand(true); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let title = gtk::Label::new(Some(&account.title)); + title.add_css_class("title-3"); + title.set_xalign(0.0); + title.set_hexpand(true); + let kind = gtk::Label::new(Some(account.kind.title())); + kind.add_css_class("dim-label"); + header.append(&title); + header.append(&kind); + card.append(&header); + + append_account_value(&card, "Account", &account.account); + append_account_value(&card, "Identity", &account.identity); + if let Some(authority) = &account.authority { + append_account_value(&card, "Authority", authority); + } + if let Some(hostname) = &account.hostname { + append_account_value(&card, "Hostname", hostname); + } + if let Some(tailnet) = &account.tailnet { + append_account_value(&card, "Tailnet", tailnet); + } + if let Some(note) = &account.note { + let note_label = gtk::Label::new(Some(note)); + note_label.add_css_class("dim-label"); + note_label.set_wrap(true); + note_label.set_xalign(0.0); + card.append(¬e_label); + } + + card +} + +fn append_account_value(card: >k::Box, label: &str, value: &str) { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let key = gtk::Label::new(Some(label)); + key.add_css_class("dim-label"); + key.set_xalign(0.0); + key.set_width_chars(9); + let value = gtk::Label::new(Some(value)); + value.set_xalign(0.0); + value.set_wrap(true); + value.set_hexpand(true); + row.append(&key); + row.append(&value); + card.append(&row); +} + +fn open_wireguard_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "WireGuard", 560, 620); + let content = sheet_content( + &window, + "Import WireGuard", + "Import a tunnel and optional account metadata.", + ); + + let title = gtk::Entry::new(); + title.set_placeholder_text(Some("Title")); + let account = gtk::Entry::new(); + account.set_placeholder_text(Some("Account")); + let identity = gtk::Entry::new(); + identity.set_placeholder_text(Some("Identity")); + let text = gtk::TextView::new(); + text.set_monospace(true); + text.set_wrap_mode(gtk::WrapMode::WordChar); + + let editor = gtk::ScrolledWindow::new(); + editor.set_min_content_height(220); + editor.set_child(Some(&text)); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("WireGuard Configuration")); + content.append(&editor); + + let add = gtk::Button::with_label("Add Network"); + add.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + add.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddWireGuard { + title: title.text().to_string(), + account: account.text().to_string(), + identity: identity.text().to_string(), + config: text_view_text(&text), + }); + window_for_click.close(); + }); + content.append(&add); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tor_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tor", 520, 540); + let content = sheet_content( + &window, + "Configure Tor", + "Store Arti account and identity preferences.", + ); + + let title = entry_with_text("Title", "Default Tor"); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let addresses = entry_with_text("Virtual Addresses", "100.64.0.2/32"); + let dns = entry_with_text("DNS Resolvers", "1.1.1.1, 1.0.0.1"); + let mtu = entry_with_text("MTU", "1400"); + let listen = entry_with_text("Transparent Listener", "127.0.0.1:9040"); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("Tor Preferences")); + content.append(&addresses); + content.append(&dns); + content.append(&mtu); + content.append(&listen); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + let note = [ + format!( + "Addresses: {}", + normalized_entry(&addresses, "100.64.0.2/32") + ), + format!("DNS: {}", normalized_entry(&dns, "1.1.1.1, 1.0.0.1")), + format!("MTU: {}", normalized_entry(&mtu, "1400")), + format!("Listen: {}", normalized_entry(&listen, "127.0.0.1:9040")), + ] + .join(" - "); + input.emit(HomeScreenMsg::SaveTor { + title: normalized_entry(&title, "Default Tor"), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + note, + }); + window_for_click.close(); + }); + content.append(&save); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tailnet", 560, 680); + let content = sheet_content( + &window, + "Connect Tailnet", + "Save Tailnet authority, identity defaults, and login material.", + ); + + let email = gtk::Entry::new(); + email.set_placeholder_text(Some("Email address")); + let authority = entry_with_text("Server URL", daemon_api::default_tailnet_authority()); + let tailnet = gtk::Entry::new(); + tailnet.set_placeholder_text(Some("Tailnet")); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let hostname = entry_with_text("Hostname", &hostname_fallback()); + + content.append(§ion_label("Connection")); + content.append(&email); + content.append(&authority); + content.append(&tailnet); + content.append(§ion_label("Identity")); + content.append(&account); + content.append(&identity); + content.append(&hostname); + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let discover = gtk::Button::with_label("Refresh Server Lookup"); + let probe = gtk::Button::with_label("Check Server"); + let sign_in = gtk::Button::with_label("Start Sign-In"); + actions.append(&discover); + actions.append(&probe); + actions.append(&sign_in); + content.append(§ion_label("Authentication")); + content.append(&actions); + + let input = sender.input_sender().clone(); + let email_for_click = email.clone(); + discover.connect_clicked(move |_| { + input.emit(HomeScreenMsg::DiscoverTailnet( + email_for_click.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_probe = authority.clone(); + probe.connect_clicked(move |_| { + input.emit(HomeScreenMsg::ProbeTailnet( + authority_for_probe.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_login = authority.clone(); + let account_for_login = account.clone(); + let identity_for_login = identity.clone(); + let hostname_for_login = hostname.clone(); + sign_in.connect_clicked(move |_| { + input.emit(HomeScreenMsg::StartTailnetLogin { + authority: authority_for_login.text().to_string(), + account: normalized_entry(&account_for_login, "default"), + identity: normalized_entry(&identity_for_login, "linux"), + hostname: daemon_api::normalized_optional(&hostname_for_login.text()), + }); + }); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddTailnet { + authority: authority.text().to_string(), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + hostname: daemon_api::normalized_optional(&hostname.text()), + tailnet: daemon_api::normalized_optional(&tailnet.text()), + }); + window_for_click.close(); + }); + + let cancel = gtk::Button::with_label("Cancel Sign-In"); + let input = sender.input_sender().clone(); + cancel.connect_clicked(move |_| { + input.emit(HomeScreenMsg::CancelTailnetLogin); + }); + + content.append(&save); + content.append(&cancel); + + window.set_child(Some(&content)); + window.present(); +} + +fn sheet_window(root: >k::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window { + let window = gtk::Window::builder() + .title(title) + .default_width(width) + .default_height(height) + .modal(true) + .build(); + if let Some(root) = root.root() { + if let Ok(parent) = root.downcast::() { + window.set_transient_for(Some(&parent)); + } + } + window +} + +fn sheet_content(window: >k::Window, title: &str, detail: &str) -> gtk::Box { + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_all(18); + + let summary = gtk::Box::new(gtk::Orientation::Horizontal, 12); + summary.add_css_class("summary-card"); + + let copy = gtk::Box::new(gtk::Orientation::Vertical, 4); + copy.set_hexpand(true); + + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-3"); + title_label.set_xalign(0.0); + + let detail_label = gtk::Label::new(Some(detail)); + detail_label.add_css_class("dim-label"); + detail_label.set_wrap(true); + detail_label.set_xalign(0.0); + + copy.append(&title_label); + copy.append(&detail_label); + summary.append(©); + + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(Align::Start) + .build(); + close.add_css_class("flat"); + let window_for_click = window.clone(); + close.connect_clicked(move |_| window_for_click.close()); + summary.append(&close); + + content.append(&summary); + content +} + +fn section_label(label: &str) -> gtk::Label { + let section = gtk::Label::new(Some(label)); + section.add_css_class("heading"); + section.set_xalign(0.0); + section +} + +fn entry_with_text(placeholder: &str, value: &str) -> gtk::Entry { + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some(placeholder)); + entry.set_text(value); + entry +} + +fn normalized_entry(entry: >k::Entry, fallback: &str) -> String { + daemon_api::normalized(&entry.text(), fallback) +} + +fn hostname_fallback() -> String { + std::env::var("HOSTNAME").unwrap_or_else(|_| "linux".to_owned()) +} + +fn text_view_text(text_view: >k::TextView) -> String { + let buffer = text_view.buffer(); + buffer + .text(&buffer.start_iter(), &buffer.end_iter(), true) + .to_string() +} + +fn open_auth_url(url: &str) -> anyhow::Result<()> { + gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) + .map_err(anyhow::Error::from) +} diff --git a/burrow-gtk/src/components/mod.rs b/burrow-gtk/src/components/mod.rs index b134809..8e60fa7 100644 --- a/burrow-gtk/src/components/mod.rs +++ b/burrow-gtk/src/components/mod.rs @@ -1,6 +1,6 @@ use super::*; +use crate::daemon_api; use adw::prelude::*; -use burrow::{DaemonClient, DaemonCommand, DaemonResponseData}; use gtk::Align; use relm4::{ component::{ @@ -9,13 +9,9 @@ use relm4::{ }, prelude::*, }; -use std::sync::Arc; -use tokio::sync::Mutex; mod app; -mod settings; -mod settings_screen; -mod switch_screen; +mod home_screen; pub use app::*; -pub use settings::{DaemonGroupMsg, DiagGroupMsg}; +pub use home_screen::{HomeScreen, HomeScreenMsg}; diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs new file mode 100644 index 0000000..4ff8bf5 --- /dev/null +++ b/burrow-gtk/src/daemon_api.rs @@ -0,0 +1,420 @@ +use anyhow::{anyhow, Context, Result}; +use burrow::{ + control::{TailnetConfig, TailnetProvider}, + grpc_defs::{ + Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, + TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, + }, + BurrowClient, +}; +use std::{path::PathBuf, sync::OnceLock}; +use tokio::time::{timeout, Duration}; + +const RPC_TIMEOUT: Duration = Duration::from_secs(3); +const MANAGED_TAILSCALE_AUTHORITY: &str = "https://controlplane.tailscale.com"; +static EMBEDDED_DAEMON_STARTED: OnceLock<()> = OnceLock::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelState { + Running, + Stopped, +} + +#[derive(Debug, Clone)] +pub struct NetworkSummary { + pub id: i32, + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct TailnetDiscovery { + pub authority: String, + pub managed: bool, + pub oidc_issuer: Option, +} + +#[derive(Debug, Clone)] +pub struct TailnetProbe { + pub summary: String, + pub detail: Option, + pub status_code: i32, +} + +#[derive(Debug, Clone)] +pub struct TailnetLoginStatus { + pub session_id: String, + pub backend_state: String, + pub auth_url: Option, + pub running: bool, + pub needs_login: bool, + pub tailnet_name: Option, + pub self_dns_name: Option, + pub tailnet_ips: Vec, + pub health: Vec, +} + +pub fn default_tailnet_authority() -> &'static str { + MANAGED_TAILSCALE_AUTHORITY +} + +pub fn configure_client_paths() -> Result<()> { + if std::env::var_os("BURROW_SOCKET_PATH").is_none() { + std::env::set_var("BURROW_SOCKET_PATH", default_socket_path()?); + } + Ok(()) +} + +pub async fn ensure_daemon() -> Result<()> { + configure_client_paths()?; + if daemon_available().await { + return Ok(()); + } + + let socket_path = socket_path()?; + let db_path = database_path()?; + ensure_parent(&socket_path)?; + ensure_parent(&db_path)?; + + if EMBEDDED_DAEMON_STARTED.get().is_none() { + tokio::task::spawn_blocking(move || { + burrow::spawn_in_process_with_paths(Some(socket_path), Some(db_path)); + }) + .await + .context("failed to join embedded daemon startup")?; + let _ = EMBEDDED_DAEMON_STARTED.set(()); + } + + tunnel_state() + .await + .map(|_| ()) + .context("Burrow daemon started but did not accept tunnel status RPCs") +} + +pub fn infer_tailnet_provider(authority: &str) -> TailnetProvider { + let normalized = authority.trim().trim_end_matches('/').to_ascii_lowercase(); + if normalized == "controlplane.tailscale.com" + || normalized == "http://controlplane.tailscale.com" + || normalized == MANAGED_TAILSCALE_AUTHORITY + { + TailnetProvider::Tailscale + } else { + TailnetProvider::Headscale + } +} + +pub async fn daemon_available() -> bool { + tunnel_state().await.is_ok() +} + +fn socket_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_SOCKET_PATH") { + return Ok(PathBuf::from(path)); + } + default_socket_path() +} + +fn default_socket_path() -> Result { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return Ok(PathBuf::from(runtime_dir).join("burrow.sock")); + } + let uid = std::env::var("UID").unwrap_or_else(|_| "1000".to_owned()); + Ok(PathBuf::from(format!("/tmp/burrow-{uid}.sock"))) +} + +fn database_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_DB_PATH") { + return Ok(PathBuf::from(path)); + } + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home).join("burrow").join("burrow.db")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("burrow.db")); + } + Ok(std::env::temp_dir().join("burrow.db")) +} + +fn ensure_parent(path: &PathBuf) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + Ok(()) +} + +pub async fn tunnel_state() -> Result { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_status(Empty {})) + .await + .context("timed out connecting to Burrow daemon")?? + .into_inner(); + let status = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow tunnel status")?? + .context("Burrow daemon ended the status stream without a state")?; + Ok(match status.state() { + State::Running => TunnelState::Running, + State::Stopped => TunnelState::Stopped, + }) +} + +pub async fn start_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_start(Empty {})) + .await + .context("timed out starting Burrow tunnel")??; + Ok(()) +} + +pub async fn stop_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_stop(Empty {})) + .await + .context("timed out stopping Burrow tunnel")??; + Ok(()) +} + +pub async fn list_networks() -> Result> { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.networks_client.network_list(Empty {})) + .await + .context("timed out connecting to Burrow network list")?? + .into_inner(); + let response = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow network list")?? + .context("Burrow daemon ended the network stream without a snapshot")?; + Ok(response.network.iter().map(summarize_network).collect()) +} + +pub async fn add_wireguard(config: String) -> Result { + add_network(NetworkType::WireGuard, config.into_bytes()).await +} + +pub async fn add_tailnet( + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, +) -> Result { + let provider = infer_tailnet_provider(&authority); + let config = TailnetConfig { + provider, + authority: Some(authority), + account: Some(account), + identity: Some(identity), + hostname, + tailnet, + }; + let payload = serde_json::to_vec_pretty(&config)?; + add_network(NetworkType::Tailnet, payload).await +} + +pub async fn discover_tailnet(email: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .discover(TailnetDiscoverRequest { email }), + ) + .await + .context("timed out discovering Tailnet authority")?? + .into_inner(); + + Ok(TailnetDiscovery { + authority: response.authority, + managed: response.managed, + oidc_issuer: optional(response.oidc_issuer), + }) +} + +pub async fn probe_tailnet(authority: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .probe(TailnetProbeRequest { authority }), + ) + .await + .context("timed out probing Tailnet authority")?? + .into_inner(); + + Ok(TailnetProbe { + summary: response.summary, + detail: optional(response.detail), + status_code: response.status_code, + }) +} + +pub async fn start_tailnet_login( + authority: String, + account_name: String, + identity_name: String, + hostname: Option, +) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client.tailnet_client.login_start(TailnetLoginStartRequest { + account_name, + identity_name, + hostname: hostname.unwrap_or_default(), + authority, + }), + ) + .await + .context("timed out starting Tailnet sign-in")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn tailnet_login_status(session_id: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_status(TailnetLoginStatusRequest { session_id }), + ) + .await + .context("timed out reading Tailnet sign-in status")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn cancel_tailnet_login(session_id: String) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_cancel(TailnetLoginCancelRequest { session_id }), + ) + .await + .context("timed out cancelling Tailnet sign-in")??; + Ok(()) +} + +async fn add_network(network_type: NetworkType, payload: Vec) -> Result { + let id = next_network_id().await?; + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client.networks_client.network_add(Network { + id, + r#type: network_type.into(), + payload, + }), + ) + .await + .context("timed out saving network to Burrow daemon")??; + Ok(id) +} + +async fn next_network_id() -> Result { + let networks = list_networks().await?; + Ok(networks.iter().map(|network| network.id).max().unwrap_or(0) + 1) +} + +fn summarize_network(network: &Network) -> NetworkSummary { + match network.r#type() { + NetworkType::WireGuard => summarize_wireguard(network), + NetworkType::Tailnet => summarize_tailnet(network), + } +} + +fn summarize_wireguard(network: &Network) -> NetworkSummary { + let payload = String::from_utf8_lossy(&network.payload); + let detail = payload + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('[')) + .unwrap_or("Stored WireGuard configuration") + .to_owned(); + NetworkSummary { + id: network.id, + title: format!("WireGuard {}", network.id), + detail, + } +} + +fn summarize_tailnet(network: &Network) -> NetworkSummary { + match TailnetConfig::from_slice(&network.payload) { + Ok(config) => { + let title = config + .tailnet + .clone() + .or(config.hostname.clone()) + .unwrap_or_else(|| "Tailnet".to_owned()); + let authority = config + .authority + .unwrap_or_else(|| "default authority".to_owned()); + let account = config.account.unwrap_or_else(|| "default".to_owned()); + NetworkSummary { + id: network.id, + title, + detail: format!("{authority} - account {account}"), + } + } + Err(error) => NetworkSummary { + id: network.id, + title: "Tailnet".to_owned(), + detail: format!("Unable to read Tailnet payload: {error}"), + }, + } +} + +fn decode_tailnet_status( + response: burrow::grpc_defs::TailnetLoginStatusResponse, +) -> TailnetLoginStatus { + TailnetLoginStatus { + session_id: response.session_id, + backend_state: response.backend_state, + auth_url: optional(response.auth_url), + running: response.running, + needs_login: response.needs_login, + tailnet_name: optional(response.tailnet_name), + self_dns_name: optional(response.self_dns_name), + tailnet_ips: response.tailnet_ips, + health: response.health, + } +} + +fn optional(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn normalized(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_owned() + } else { + trimmed.to_owned() + } +} + +pub fn normalized_optional(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn require_value(value: &str, label: &str) -> Result { + normalized_optional(value).ok_or_else(|| anyhow!("{label} is required")) +} diff --git a/burrow-gtk/src/main.rs b/burrow-gtk/src/main.rs index 6f91e2a..b47b63e 100644 --- a/burrow-gtk/src/main.rs +++ b/burrow-gtk/src/main.rs @@ -1,11 +1,15 @@ use anyhow::Result; pub mod components; -mod diag; +mod account_store; +mod daemon_api; // Generated using meson mod config; fn main() { + if let Err(error) = daemon_api::configure_client_paths() { + eprintln!("failed to configure Burrow daemon paths: {error}"); + } components::App::run(); } diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index c60f131..f369ea9 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,11 +1,11 @@ use std::{ ffi::{c_char, CStr}, path::PathBuf, - sync::Arc, + sync::{Arc, Mutex}, thread, }; -use once_cell::sync::OnceCell; +use once_cell::sync::{Lazy, OnceCell}; use tokio::{ runtime::{Builder, Handle}, sync::Notify, @@ -14,25 +14,35 @@ use tracing::error; use crate::daemon::daemon_main; -static BURROW_NOTIFY: OnceCell> = OnceCell::new(); static BURROW_HANDLE: OnceCell = OnceCell::new(); +static BURROW_READY: OnceCell<()> = OnceCell::new(); +static BURROW_SPAWN_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[no_mangle] pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c_char) { + let path_buf = if path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) + }; + let db_path_buf = if db_path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) + }; + spawn_in_process_with_paths(path_buf, db_path_buf); +} + +pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { crate::tracing::initialize(); - let notify = BURROW_NOTIFY.get_or_init(|| Arc::new(Notify::new())); + let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); + if BURROW_READY.get().is_some() { + return; + } + + let notify = Arc::new(Notify::new()); let handle = BURROW_HANDLE.get_or_init(|| { - let path_buf = if path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) - }; - let db_path_buf = if db_path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) - }; let sender = notify.clone(); let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -62,4 +72,5 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c let receiver = notify.clone(); handle.block_on(async move { receiver.notified().await }); + let _ = BURROW_READY.set(()); } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 15b6a19..7867d18 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -16,10 +16,10 @@ pub(crate) mod tracing; #[cfg(target_os = "linux")] pub mod usernet; -#[cfg(target_vendor = "apple")] -pub use daemon::apple::spawn_in_process; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub use daemon::apple::{spawn_in_process, spawn_in_process_with_paths}; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, - DaemonStartOptions, + rpc::grpc_defs, rpc::BurrowClient, rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, + DaemonCommand, DaemonResponseData, DaemonStartOptions, }; diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index ef73d2b..582b0a2 100644 --- a/docs/GTK_APP.md +++ b/docs/GTK_APP.md @@ -15,7 +15,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo apt install -y clang meson cmake pkg-config libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils + sudo apt install -y clang meson cmake pkg-config libssl-dev libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils ``` 2. Install flatpak builder (Optional) @@ -38,7 +38,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib + sudo dnf install -y clang ninja-build cmake meson openssl-devel gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib ``` 2. Install flatpak builder (Optional) @@ -61,7 +61,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo xbps-install -Sy gcc clang meson cmake pkg-config gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib + sudo xbps-install -Sy gcc clang meson cmake pkg-config openssl-devel gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib ``` 2. Install flatpak builder (Optional) @@ -88,6 +88,12 @@ flatpak install --user \ ## Building +With Nix, enter the focused GTK shell before running the Meson build: + +```bash +nix develop .#gtk +``` +
General @@ -139,6 +145,16 @@ flatpak install --user \ ## Running +The GTK app mirrors the Apple home surface: a Burrow header, Networks carousel, +Accounts section, Tunnel action, and the same add flows for WireGuard, Tor, and +Tailnet. It talks to the daemon over the same gRPC API used by Apple clients for +network storage, tunnel state, Tailnet discovery, authority probing, browser +sign-in, and Tailnet payloads. + +On Linux the GTK app first looks for a daemon on the configured gRPC socket. If +none is reachable, it starts an embedded user-scoped daemon with a socket under +`XDG_RUNTIME_DIR` and a database under `XDG_DATA_HOME` before refreshing the UI. +
General diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md index 1227444..a34a609 100644 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -44,6 +44,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. - The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. - Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. +- Non-Apple presentation clients should follow the same daemon-first lifecycle pattern: connect to a managed daemon when present, or start a user-scoped embedded daemon before issuing RPCs, without adding platform-local control-plane paths. ## Contributor Playbook @@ -54,6 +55,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - daemon unavailable behavior - successful RPC path - error propagation through the UI +- Keep Linux GTK and Apple clients visually and functionally aligned around the same daemon-backed home surface: Networks, Accounts, Tunnel, and add flows should remain corresponding views over the daemon API. ## Alternatives Considered @@ -63,6 +65,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to ## Impact on Other Work - Governs the Tailnet refactor and future Apple runtime work. +- Governs Linux GTK daemon startup parity where the same daemon API is reused from a user-scoped presentation process. - Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. ## Decision diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md index fea4aba..36458ef 100644 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -37,6 +37,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Burrow-owned authority when explicitly applicable - Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. - The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. +- Platform clients consume the same daemon gRPC surface for Tailnet discovery, authority probing, browser sign-in, and saved network payloads. macOS/iOS SwiftUI and Linux GTK may differ in presentation and local credential stores, but neither should introduce a second control-plane path. ## Security and Operational Considerations @@ -48,6 +49,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. - Store the authority explicitly in payloads and infer provider internally only when needed. +- Keep Linux GTK and Apple clients at functional parity by routing Tailnet add/discover/probe/login through `TailnetControl` and `Networks` RPCs instead of platform-local HTTP or legacy JSON daemon commands. - Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. ## Alternatives Considered @@ -58,7 +60,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h ## Impact on Other Work - Refines BEP-0002’s Tailscale-shaped control-plane work. -- Constrains the Tailnet Apple refactor and future daemon control-plane storage. +- Constrains the Tailnet Apple and Linux GTK refactors plus future daemon control-plane storage. ## Decision @@ -68,4 +70,5 @@ Pending. - `burrow/src/control/` - `Apple/UI/Networks/` +- `burrow-gtk/src/` - `proto/burrow.proto`