diff --git a/burrow-gtk/Cargo.toml b/burrow-gtk/Cargo.toml index b12577a..21cb52e 100644 --- a/burrow-gtk/Cargo.toml +++ b/burrow-gtk/Cargo.toml @@ -11,8 +11,6 @@ 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 deleted file mode 100644 index 6aee78b..0000000 --- a/burrow-gtk/src/account_store.rs +++ /dev/null @@ -1,139 +0,0 @@ -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 7354825..62c98c0 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -1,19 +1,24 @@ use super::*; use anyhow::Context; +use std::time::Duration; + +const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { - _home_screen: AsyncController, + daemon_client: Arc>>, + settings_screen: Controller, + switch_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(); @@ -44,7 +49,7 @@ impl AsyncComponent for App { view! { adw::Window { set_title: Some("Burrow"), - set_default_size: (900, 760), + set_default_size: (640, 480), } } @@ -53,84 +58,100 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let home_screen = home_screen::HomeScreen::builder() - .launch(()) + 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), + }) .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(home_screen.widget()); + content.append(&view_stack); + content.append(&view_switcher_bar); root.set_content(Some(&content)); - let model = App { _home_screen: home_screen }; + sender.input(AppMsg::PostInit); + + let model = App { + daemon_client, + switch_screen, + settings_screen, + }; AsyncComponentParts { model, widgets } } async fn update( &mut self, - msg: Self::Input, + _msg: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root, ) { - match msg { - AppMsg::None => {} + 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 + } + } + } + } } } } - -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 deleted file mode 100644 index 0bfdda2..0000000 --- a/burrow-gtk/src/components/home_screen.rs +++ /dev/null @@ -1,1178 +0,0 @@ -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 8e60fa7..b134809 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,9 +9,13 @@ use relm4::{ }, prelude::*, }; +use std::sync::Arc; +use tokio::sync::Mutex; mod app; -mod home_screen; +mod settings; +mod settings_screen; +mod switch_screen; pub use app::*; -pub use home_screen::{HomeScreen, HomeScreenMsg}; +pub use settings::{DaemonGroupMsg, DiagGroupMsg}; diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs deleted file mode 100644 index 4ff8bf5..0000000 --- a/burrow-gtk/src/daemon_api.rs +++ /dev/null @@ -1,420 +0,0 @@ -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 b47b63e..6f91e2a 100644 --- a/burrow-gtk/src/main.rs +++ b/burrow-gtk/src/main.rs @@ -1,15 +1,11 @@ use anyhow::Result; pub mod components; -mod account_store; -mod daemon_api; +mod diag; // 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 f369ea9..c60f131 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, Mutex}, + sync::Arc, thread, }; -use once_cell::sync::{Lazy, OnceCell}; +use once_cell::sync::OnceCell; use tokio::{ runtime::{Builder, Handle}, sync::Notify, @@ -14,35 +14,25 @@ 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 _guard = BURROW_SPAWN_LOCK.lock().unwrap(); - if BURROW_READY.get().is_some() { - return; - } - - let notify = Arc::new(Notify::new()); + let notify = BURROW_NOTIFY.get_or_init(|| 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(); @@ -72,5 +62,4 @@ pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Optio 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 7867d18..15b6a19 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(any(target_os = "linux", target_vendor = "apple"))] -pub use daemon::apple::{spawn_in_process, spawn_in_process_with_paths}; +#[cfg(target_vendor = "apple")] +pub use daemon::apple::spawn_in_process; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::grpc_defs, rpc::BurrowClient, rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, - DaemonCommand, DaemonResponseData, DaemonStartOptions, + rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, + DaemonStartOptions, }; diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index 582b0a2..ef73d2b 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 libssl-dev libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils + sudo apt install -y clang meson cmake pkg-config 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 openssl-devel gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib + sudo dnf install -y clang ninja-build cmake meson 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 openssl-devel gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib + sudo xbps-install -Sy gcc clang meson cmake pkg-config gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib ``` 2. Install flatpak builder (Optional) @@ -88,12 +88,6 @@ flatpak install --user \ ## Building -With Nix, enter the focused GTK shell before running the Meson build: - -```bash -nix develop .#gtk -``` -
General @@ -145,16 +139,6 @@ nix develop .#gtk ## 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 a34a609..1227444 100644 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -44,7 +44,6 @@ 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 @@ -55,7 +54,6 @@ 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 @@ -65,7 +63,6 @@ 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 36458ef..fea4aba 100644 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -37,7 +37,6 @@ 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 @@ -49,7 +48,6 @@ 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 @@ -60,7 +58,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 and Linux GTK refactors plus future daemon control-plane storage. +- Constrains the Tailnet Apple refactor and future daemon control-plane storage. ## Decision @@ -70,5 +68,4 @@ Pending. - `burrow/src/control/` - `Apple/UI/Networks/` -- `burrow-gtk/src/` - `proto/burrow.proto` diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 9670694..a7adb48 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -2,11 +2,6 @@ let cfg = config.services.burrow.zulip; - realmSignupDomain = - let - parts = lib.splitString "@" cfg.administratorEmail; - in - if builtins.length parts == 2 then builtins.elemAt parts 1 else cfg.domain; yamlFormat = pkgs.formats.yaml { }; composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { services = { @@ -357,7 +352,6 @@ services: USE_X_FORWARDED_HOST = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True - CSRF_TRUSTED_ORIGINS = ["https://${cfg.domain}"] SOCIAL_AUTH_REDIRECT_IS_HTTPS = True SOCIAL_AUTH_SAML_REDIRECT_IS_HTTPS = True SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://${cfg.domain}" @@ -390,7 +384,7 @@ services: }, } SOCIAL_AUTH_SYNC_ATTRS_DICT = { - "": { + "authentik": { "saml": { "role": "zulip_role", }, @@ -460,70 +454,17 @@ EOF chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } - wait_for_zulip_supervisor() { + bootstrap_realm_if_needed() { + local realm_exists local attempts=0 - while ! podman exec burrow-zulip_zulip_1 supervisorctl status >/dev/null 2>&1; do + while ! podman exec burrow-zulip_zulip_1 test -r /etc/zulip/zulip-secrets.conf >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 90 ]; then - echo "error: Zulip supervisor did not become ready" >&2 + echo "error: Zulip did not finish generating production secrets" >&2 exit 1 fi sleep 2 done - } - - patch_uwsgi_scheme_handling() { - wait_for_zulip_supervisor - podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/zulip-include/trusted-proto <<'EOF' -map \$remote_addr \$trusted_x_forwarded_proto { - default \$scheme; - 127.0.0.1 \$http_x_forwarded_proto; - ::1 \$http_x_forwarded_proto; - 172.31.1.1 \$http_x_forwarded_proto; -} -map \$remote_addr \$trusted_x_forwarded_for { - default \"\"; - 127.0.0.1 \$http_x_forwarded_for; - ::1 \$http_x_forwarded_for; - 172.31.1.1 \$http_x_forwarded_for; -} -map \$remote_addr \$x_proxy_misconfiguration { - default \"\"; -} -EOF -cat > /etc/nginx/uwsgi_params <<'EOF' -uwsgi_param QUERY_STRING \$query_string; -uwsgi_param REQUEST_METHOD \$request_method; -uwsgi_param CONTENT_TYPE \$content_type; -uwsgi_param CONTENT_LENGTH \$content_length; -uwsgi_param REQUEST_URI \$request_uri; -uwsgi_param PATH_INFO \$document_uri; -uwsgi_param DOCUMENT_ROOT \$document_root; -uwsgi_param SERVER_PROTOCOL \$server_protocol; -uwsgi_param REQUEST_SCHEME \$trusted_x_forwarded_proto; -uwsgi_param HTTPS on; -uwsgi_param REMOTE_ADDR \$remote_addr; -uwsgi_param REMOTE_PORT \$remote_port; -uwsgi_param SERVER_ADDR \$server_addr; -uwsgi_param SERVER_PORT \$server_port; -uwsgi_param SERVER_NAME \$server_name; -uwsgi_param HTTP_X_REAL_IP \$remote_addr; -uwsgi_param HTTP_X_FORWARDED_PROTO \$trusted_x_forwarded_proto; -uwsgi_param HTTP_X_FORWARDED_SSL \"\"; -uwsgi_param HTTP_X_PROXY_MISCONFIGURATION \$x_proxy_misconfiguration; - -# This value is the default, and is provided for explicitness; it must -# be longer than the configured 55s harakiri timeout in uwsgi -uwsgi_read_timeout 60s; - -uwsgi_pass django; -EOF -supervisorctl restart nginx zulip-django >/dev/null" - } - - bootstrap_realm_if_needed() { - wait_for_zulip_supervisor - local realm_exists realm_exists="$( podman exec burrow-zulip_zulip_1 bash -lc \ @@ -553,23 +494,6 @@ supervisorctl restart nginx zulip-django >/dev/null" podman exec burrow-zulip_zulip_1 su zulip -c "$create_realm_cmd" } - reconcile_realm_policy() { - wait_for_zulip_supervisor - local realm_id - realm_id="$( - podman exec burrow-zulip_zulip_1 bash -lc \ - "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ - | awk '$NF == "https://${cfg.domain}" { print $1 }' - )" - - podman exec burrow-zulip_zulip_1 su zulip -c \ - "/home/zulip/deployments/current/manage.py realm_domain --op add -r $realm_id ${realmSignupDomain} --allow-subdomains --automated" \ - >/dev/null 2>&1 || true - - podman exec burrow-zulip_zulip_1 su zulip -c \ - "/home/zulip/deployments/current/manage.py shell -c 'from zerver.models import Realm; realm = Realm.objects.get(id=$realm_id); realm.invite_required = False; realm.save(update_fields=[\"invite_required\"])'" - } - if [ ! -e .initialized ]; then compose pull compose run --rm -T zulip app:init @@ -579,8 +503,6 @@ supervisorctl restart nginx zulip-django >/dev/null" ensure_zulip_data_layout compose up -d zulip bootstrap_realm_if_needed - reconcile_realm_policy - patch_uwsgi_scheme_handling ''; }; };