feat(gui-client): make all modules Linux-friendly (#3737)

Splits up platform-specific modules into `linux.rs` and `windows.rs`
submodules, or `imp` modules within the parent module, so that we can
compile all the platform-independent code (e.g. Tauri calls,
`run_controller`) for Linux.

This will show the Tauri GUI on Linux:


![image](https://github.com/firezone/firezone/assets/13400041/320a4b91-1a37-48dd-94b9-cc280cc09854)

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-02-28 16:09:56 -06:00
committed by GitHub
parent 127b97e588
commit ca95dcdf77
19 changed files with 1163 additions and 959 deletions

View File

@@ -46,21 +46,24 @@ thiserror = { version = "1.0", default-features = false }
tokio = { version = "1.36.0", features = ["time"] }
tracing = { workspace = true }
tracing-log = "0.2"
tracing-panic = "0.1.1"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
url = { version = "2.5.0", features = ["serde"] }
uuid = { version = "1.7.0", features = ["v4"] }
tracing-panic = "0.1.1"
zip = { version = "0.6.6", features = ["deflate", "time"], default-features = false }
# These dependencies are locked behind `cfg(windows)` because they either can't compile at all on Linux, or they need native dependencies like glib that are difficult to get. Try not to add more here.
[target.'cfg(target_os = "linux")'.dependencies]
dirs = "5.0.1"
# Used for infinite `pending` on not-yet-implemented functions
futures = "0.3.30"
[target.'cfg(windows)'.dependencies]
[target.'cfg(target_os = "windows")'.dependencies]
tauri-winrt-notification = "0.1.3"
windows-implement = "0.52.0"
winreg = "0.52.0"
wintun = "0.4.0"
[target.'cfg(windows)'.dependencies.windows]
[target.'cfg(target_os = "windows")'.dependencies.windows]
version = "0.52.0"
features = [
# For implementing COM interfaces

View File

@@ -1,6 +1,6 @@
use anyhow::Result;
use clap::{Args, Parser};
use std::{os::windows::process::CommandExt, path::PathBuf, process::Command};
use std::path::PathBuf;
mod about;
mod auth;
@@ -16,6 +16,8 @@ mod network_changes;
mod resolvers;
mod settings;
mod updates;
#[cfg(target_os = "windows")]
mod wintun_install;
/// Output of `git describe` at compile time
@@ -35,10 +37,6 @@ pub(crate) enum Error {
Gui(#[from] gui::Error),
}
// Hides Powershell's console on Windows
// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// The program's entry point, equivalent to `main`
///
/// When a user runs the Windows client normally without admin permissions, this will happen:
@@ -69,22 +67,8 @@ pub(crate) fn run() -> Result<()> {
// We're already elevated, just run the GUI
run_gui(cli)
} else {
// We're not elevated, ask Powershell to re-launch us, then exit
let current_exe = tauri_utils::platform::current_exe()?;
if current_exe.display().to_string().contains('\"') {
anyhow::bail!("The exe path must not contain double quotes, it makes it hard to elevate with Powershell");
}
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.arg("-Command")
.arg("Start-Process")
.arg("-FilePath")
.arg(format!(r#""{}""#, current_exe.display()))
.arg("-Verb")
.arg("RunAs")
.arg("-ArgumentList")
.arg("elevated")
.spawn()?;
// We're not elevated, ask Powershell / sudo to re-launch us, then exit
elevation::elevate()?;
Ok(())
}
}

View File

@@ -69,6 +69,12 @@ fn start_server_and_connect() -> Result<(minidumper::Client, std::process::Child
let socket_path = known_dirs::runtime()
.context("`known_dirs::runtime` failed")?
.join("crash_handler_pipe");
std::fs::create_dir_all(
socket_path
.parent()
.context("`known_dirs::runtime` should have a parent")?,
)
.context("Failed to create dir for crash_handler_pipe")?;
let mut server = None;
@@ -118,11 +124,13 @@ impl minidumper::ServerHandler for Handler {
.expect("Should be able to find logs dir to put crash dump in")
.join("last_crash.dmp");
if let Some(dir) = dump_path.parent() {
if !dir.try_exists()? {
std::fs::create_dir_all(dir)?;
}
}
// `tracing` is unlikely to work inside the crash handler subprocess, so
// just print to stderr and it may show up on the terminal. This helps in CI / local dev.
eprintln!("Creating minidump at {}", dump_path.display());
let Some(dir) = dump_path.parent() else {
return Err(std::io::ErrorKind::NotFound.into());
};
std::fs::create_dir_all(dir)?;
let file = File::create(&dump_path)?;
Ok((file, dump_path))
}

View File

@@ -10,7 +10,6 @@ pub enum Cmd {
Crash,
Hostname,
NetworkChanges,
Wintun,
}
pub fn run(cmd: Cmd) -> Result<()> {
@@ -19,7 +18,6 @@ pub fn run(cmd: Cmd) -> Result<()> {
Cmd::Crash => crash(),
Cmd::Hostname => hostname(),
Cmd::NetworkChanges => client::network_changes::run_debug(),
Cmd::Wintun => wintun(),
}
}
@@ -48,15 +46,3 @@ fn hostname() -> Result<()> {
);
Ok(())
}
/// Try to load wintun.dll and throw an error if it's not in the right place
fn wintun() -> Result<()> {
tracing_subscriber::fmt::init();
let path = connlib_shared::windows::wintun_dll_path()?;
// SAFETY: Loading a DLL from disk is unsafe. We're responsible for making sure
// we're using the DLL's API correctly.
unsafe { wintun::load_from_path(&path) }?;
tracing::info!(?path, "Loaded wintun.dll");
Ok(())
}

View File

@@ -1,15 +1,22 @@
//! A module for registering, catching, and parsing deep links that are sent over to the app's already-running instance
//! Based on reading some of the Windows code from <https://github.com/FabianLars/tauri-plugin-deep-link>, which is licensed "MIT OR Apache-2.0"
use crate::client::auth::Response as AuthResponse;
use connlib_shared::{control::SecureUrl, BUNDLE_ID};
use connlib_shared::control::SecureUrl;
use secrecy::{ExposeSecret, Secret, SecretString};
use std::{ffi::c_void, io, path::Path, str::FromStr};
use tokio::{io::AsyncReadExt, io::AsyncWriteExt, net::windows::named_pipe};
use windows::Win32::Security as WinSec;
use std::io;
pub(crate) const FZ_SCHEME: &str = "firezone-fd0020211111";
#[cfg(target_os = "linux")]
#[path = "deep_link/linux.rs"]
mod imp;
#[cfg(target_os = "windows")]
#[path = "deep_link/windows.rs"]
mod imp;
// TODO: Replace this all for `anyhow`.
#[cfg_attr(target_os = "linux", allow(dead_code))]
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("named pipe server couldn't start listening, we are probably the second instance")]
@@ -26,6 +33,7 @@ pub enum Error {
/// We got some data but it's not UTF-8
#[error(transparent)]
LinkNotUtf8(std::str::Utf8Error),
#[cfg(target_os = "windows")]
#[error("Couldn't set up security descriptor for deep link server")]
SecurityDescriptor,
/// Error from server's POV
@@ -34,10 +42,13 @@ pub enum Error {
#[error(transparent)]
UrlParse(#[from] url::ParseError),
/// Something went wrong setting up the registry
#[cfg(target_os = "windows")]
#[error(transparent)]
WindowsRegistry(io::Error),
}
pub(crate) use imp::{open, register, Server};
pub(crate) fn parse_auth_callback(url: &Secret<SecureUrl>) -> Option<AuthResponse> {
let url = &url.expose_secret().inner;
match url.host() {
@@ -86,163 +97,6 @@ pub(crate) fn parse_auth_callback(url: &Secret<SecureUrl>) -> Option<AuthRespons
})
}
/// A server for a named pipe, so we can receive deep links from other instances
/// of the client launched by web browsers
pub struct Server {
inner: named_pipe::NamedPipeServer,
}
impl Server {
/// Construct a server, but don't await client connections yet
///
/// Panics if there is no Tokio runtime
pub fn new() -> Result<Self, Error> {
// This isn't air-tight - We recreate the whole server on each loop,
// rather than binding 1 socket and accepting many streams like a normal socket API.
// I can only assume Tokio is following Windows' underlying API.
// We could instead pick an ephemeral TCP port and write that to a file,
// akin to how Unix processes will write their PID to a file to manage long-running instances
// But this doesn't require us to listen on TCP.
let mut server_options = named_pipe::ServerOptions::new();
server_options.first_pipe_instance(true);
// This will allow non-admin clients to connect to us even if we're running as admin
let mut sd = WinSec::SECURITY_DESCRIPTOR::default();
let psd = WinSec::PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut c_void);
// SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading
// or lifetime problems because we only pass pointers to our local vars to
// Win32, and Win32 shouldn't save them anywhere.
unsafe {
// ChatGPT pointed me to these functions, it's better than the official MS docs
WinSec::InitializeSecurityDescriptor(
psd,
windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION,
)
.map_err(|_| Error::SecurityDescriptor)?;
WinSec::SetSecurityDescriptorDacl(psd, true, None, false)
.map_err(|_| Error::SecurityDescriptor)?;
}
let mut sa = WinSec::SECURITY_ATTRIBUTES {
// TODO: Try `size_of_val` here instead
nLength: std::mem::size_of::<WinSec::SECURITY_ATTRIBUTES>()
.try_into()
.unwrap(),
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// TODO: On the IPC branch I found that this will cause prefix issues
// with other named pipes. Change it.
let path = named_pipe_path(BUNDLE_ID);
let sa_ptr = &mut sa as *mut _ as *mut c_void;
// SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading
// or lifetime problems because we only pass pointers to our local vars to
// Win32, and Win32 shouldn't save them anywhere.
let server = unsafe { server_options.create_with_security_attributes_raw(path, sa_ptr) }
.map_err(|_| Error::CantListen)?;
tracing::debug!("server is bound");
Ok(Server { inner: server })
}
/// Await one incoming deep link from a named pipe client
/// Tokio's API is strange, so this consumes the server.
/// I assume this is based on the underlying Windows API.
/// I tried re-using the server and it acted strange. The official Tokio
/// examples are not clear on this.
pub async fn accept(mut self) -> Result<Secret<SecureUrl>, Error> {
self.inner
.connect()
.await
.map_err(Error::ServerCommunications)?;
tracing::debug!("server got connection");
// TODO: Limit the read size here. Our typical callback is 350 bytes, so 4,096 bytes should be more than enough.
// Also, I think `read_to_end` can do partial reads because this is a named pipe,
// not a file. We might need a length-prefixed or newline-terminated format for IPC.
let mut bytes = vec![];
self.inner
.read_to_end(&mut bytes)
.await
.map_err(Error::ServerCommunications)?;
let bytes = Secret::new(bytes);
self.inner.disconnect().ok();
tracing::debug!("Server read");
let s = SecretString::from_str(
std::str::from_utf8(bytes.expose_secret()).map_err(Error::LinkNotUtf8)?,
)
.expect("Infallible");
let url = Secret::new(SecureUrl::from_url(url::Url::parse(s.expose_secret())?));
Ok(url)
}
}
/// Open a deep link by sending it to the already-running instance of the app
pub async fn open(url: &url::Url) -> Result<(), Error> {
let path = named_pipe_path(BUNDLE_ID);
let mut client = named_pipe::ClientOptions::new()
.open(path)
.map_err(Error::Connect)?;
client
.write_all(url.as_str().as_bytes())
.await
.map_err(Error::ClientCommunications)?;
Ok(())
}
/// Registers the current exe as the handler for our deep link scheme.
///
/// This is copied almost verbatim from tauri-plugin-deep-link's `register` fn, with an improvement
/// that we send the deep link to a subcommand so the URL won't confuse `clap`
///
/// * `id` A unique ID for the app, e.g. "com.contoso.todo-list" or "dev.firezone.client"
pub fn register() -> Result<(), Error> {
let exe = tauri_utils::platform::current_exe()
.map_err(Error::CurrentExe)?
.display()
.to_string()
.replace("\\\\?\\", "");
set_registry_values(BUNDLE_ID, &exe).map_err(Error::WindowsRegistry)?;
Ok(())
}
/// Set up the Windows registry to call the given exe when our deep link scheme is used
///
/// All errors from this function are registry-related
fn set_registry_values(id: &str, exe: &str) -> Result<(), io::Error> {
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let base = Path::new("Software").join("Classes").join(FZ_SCHEME);
let (key, _) = hkcu.create_subkey(&base)?;
key.set_value("", &format!("URL:{}", id))?;
key.set_value("URL Protocol", &"")?;
let (icon, _) = hkcu.create_subkey(base.join("DefaultIcon"))?;
icon.set_value("", &format!("{},0", &exe))?;
let (cmd, _) = hkcu.create_subkey(base.join("shell").join("open").join("command"))?;
cmd.set_value("", &format!("{} open-deep-link \"%1\"", &exe))?;
Ok(())
}
/// Returns a valid name for a Windows named pipe
///
/// # Arguments
///
/// * `id` - BUNDLE_ID, e.g. `dev.firezone.client`
fn named_pipe_path(id: &str) -> String {
format!(r"\\.\pipe\{}", id)
}
#[cfg(test)]
mod tests {
use anyhow::Result;

View File

@@ -0,0 +1,30 @@
//! TODO: Not implemented for Linux yet
use super::Error;
use connlib_shared::control::SecureUrl;
use secrecy::Secret;
pub(crate) struct Server {}
impl Server {
pub(crate) fn new() -> Result<Self, Error> {
tracing::warn!("Not implemented yet");
tracing::trace!(scheme = super::FZ_SCHEME, "prevents dead code warning");
Ok(Self {})
}
pub(crate) async fn accept(self) -> Result<Secret<SecureUrl>, Error> {
tracing::warn!("Deep links not implemented yet on Linux");
futures::future::pending().await
}
}
pub(crate) async fn open(_url: &url::Url) -> Result<(), Error> {
tracing::warn!("Not implemented yet");
Ok(())
}
pub(crate) fn register() -> Result<(), Error> {
tracing::warn!("Not implemented yet");
Ok(())
}

View File

@@ -0,0 +1,164 @@
//! A module for registering, catching, and parsing deep links that are sent over to the app's already-running instance
//! Based on reading some of the Windows code from <https://github.com/FabianLars/tauri-plugin-deep-link>, which is licensed "MIT OR Apache-2.0"
use super::{Error, FZ_SCHEME};
use connlib_shared::{control::SecureUrl, BUNDLE_ID};
use secrecy::{ExposeSecret, Secret, SecretString};
use std::{ffi::c_void, io, path::Path, str::FromStr};
use tokio::{io::AsyncReadExt, io::AsyncWriteExt, net::windows::named_pipe};
use windows::Win32::Security as WinSec;
/// A server for a named pipe, so we can receive deep links from other instances
/// of the client launched by web browsers
pub(crate) struct Server {
inner: named_pipe::NamedPipeServer,
}
impl Server {
/// Construct a server, but don't await client connections yet
///
/// Panics if there is no Tokio runtime
pub(crate) fn new() -> Result<Self, Error> {
// This isn't air-tight - We recreate the whole server on each loop,
// rather than binding 1 socket and accepting many streams like a normal socket API.
// I can only assume Tokio is following Windows' underlying API.
// We could instead pick an ephemeral TCP port and write that to a file,
// akin to how Unix processes will write their PID to a file to manage long-running instances
// But this doesn't require us to listen on TCP.
let mut server_options = named_pipe::ServerOptions::new();
server_options.first_pipe_instance(true);
// This will allow non-admin clients to connect to us even if we're running as admin
let mut sd = WinSec::SECURITY_DESCRIPTOR::default();
let psd = WinSec::PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut c_void);
// SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading
// or lifetime problems because we only pass pointers to our local vars to
// Win32, and Win32 shouldn't save them anywhere.
unsafe {
// ChatGPT pointed me to these functions, it's better than the official MS docs
WinSec::InitializeSecurityDescriptor(
psd,
windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION,
)
.map_err(|_| Error::SecurityDescriptor)?;
WinSec::SetSecurityDescriptorDacl(psd, true, None, false)
.map_err(|_| Error::SecurityDescriptor)?;
}
let mut sa = WinSec::SECURITY_ATTRIBUTES {
// TODO: Try `size_of_val` here instead
nLength: std::mem::size_of::<WinSec::SECURITY_ATTRIBUTES>()
.try_into()
.unwrap(),
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
};
// TODO: On the IPC branch I found that this will cause prefix issues
// with other named pipes. Change it.
let path = named_pipe_path(BUNDLE_ID);
let sa_ptr = &mut sa as *mut _ as *mut c_void;
// SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading
// or lifetime problems because we only pass pointers to our local vars to
// Win32, and Win32 shouldn't save them anywhere.
let server = unsafe { server_options.create_with_security_attributes_raw(path, sa_ptr) }
.map_err(|_| Error::CantListen)?;
tracing::debug!("server is bound");
Ok(Server { inner: server })
}
/// Await one incoming deep link from a named pipe client
/// Tokio's API is strange, so this consumes the server.
/// I assume this is based on the underlying Windows API.
/// I tried re-using the server and it acted strange. The official Tokio
/// examples are not clear on this.
pub(crate) async fn accept(mut self) -> Result<Secret<SecureUrl>, Error> {
self.inner
.connect()
.await
.map_err(Error::ServerCommunications)?;
tracing::debug!("server got connection");
// TODO: Limit the read size here. Our typical callback is 350 bytes, so 4,096 bytes should be more than enough.
// Also, I think `read_to_end` can do partial reads because this is a named pipe,
// not a file. We might need a length-prefixed or newline-terminated format for IPC.
let mut bytes = vec![];
self.inner
.read_to_end(&mut bytes)
.await
.map_err(Error::ServerCommunications)?;
let bytes = Secret::new(bytes);
self.inner.disconnect().ok();
tracing::debug!("Server read");
let s = SecretString::from_str(
std::str::from_utf8(bytes.expose_secret()).map_err(Error::LinkNotUtf8)?,
)
.expect("Infallible");
let url = Secret::new(SecureUrl::from_url(url::Url::parse(s.expose_secret())?));
Ok(url)
}
}
/// Open a deep link by sending it to the already-running instance of the app
pub async fn open(url: &url::Url) -> Result<(), Error> {
let path = named_pipe_path(BUNDLE_ID);
let mut client = named_pipe::ClientOptions::new()
.open(path)
.map_err(Error::Connect)?;
client
.write_all(url.as_str().as_bytes())
.await
.map_err(Error::ClientCommunications)?;
Ok(())
}
/// Registers the current exe as the handler for our deep link scheme.
///
/// This is copied almost verbatim from tauri-plugin-deep-link's `register` fn, with an improvement
/// that we send the deep link to a subcommand so the URL won't confuse `clap`
pub fn register() -> Result<(), Error> {
let exe = tauri_utils::platform::current_exe()
.map_err(Error::CurrentExe)?
.display()
.to_string()
.replace("\\\\?\\", "");
set_registry_values(BUNDLE_ID, &exe).map_err(Error::WindowsRegistry)?;
Ok(())
}
/// Set up the Windows registry to call the given exe when our deep link scheme is used
///
/// All errors from this function are registry-related
fn set_registry_values(id: &str, exe: &str) -> Result<(), io::Error> {
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let base = Path::new("Software").join("Classes").join(FZ_SCHEME);
let (key, _) = hkcu.create_subkey(&base)?;
key.set_value("", &format!("URL:{}", id))?;
key.set_value("URL Protocol", &"")?;
let (icon, _) = hkcu.create_subkey(base.join("DefaultIcon"))?;
icon.set_value("", &format!("{},0", &exe))?;
let (cmd, _) = hkcu.create_subkey(base.join("shell").join("open").join("command"))?;
cmd.set_value("", &format!("{} open-deep-link \"%1\"", &exe))?;
Ok(())
}
/// Returns a valid name for a Windows named pipe
///
/// # Arguments
///
/// * `id` - BUNDLE_ID, e.g. `dev.firezone.client`
fn named_pipe_path(id: &str) -> String {
format!(r"\\.\pipe\{}", id)
}

View File

@@ -1,39 +1,75 @@
use crate::client::wintun_install;
use std::str::FromStr;
pub(crate) use imp::{check, elevate};
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("couldn't install wintun.dll")]
DllInstall(#[from] wintun_install::Error),
#[error("couldn't load wintun.dll")]
DllLoad,
#[error("UUID parse error - This should be impossible since the UUID is hard-coded")]
Uuid,
}
#[cfg(target_os = "linux")]
mod imp {
use anyhow::Result;
/// Creates a bogus wintun tunnel to check whether we have permissions to create wintun tunnels.
/// Extracts wintun.dll if needed.
///
/// Returns true if already elevated, false if not elevated, error if we can't be sure
pub(crate) fn check() -> Result<bool, Error> {
// Almost the same as the code in tun_windows.rs in connlib
const TUNNEL_UUID: &str = "72228ef4-cb84-4ca5-a4e6-3f8636e75757";
const TUNNEL_NAME: &str = "Firezone Elevation Check";
let path = match wintun_install::ensure_dll() {
Ok(x) => x,
Err(wintun_install::Error::PermissionDenied) => return Ok(false),
Err(e) => return Err(Error::DllInstall(e)),
};
// SAFETY: Unsafe needed because we're loading a DLL from disk and it has arbitrary C code in it.
// `wintun_install::ensure_dll` checks the hash before we get here. This protects against accidental corruption, but not against attacks. (Because of TOCTOU)
let wintun = unsafe { wintun::load_from_path(path) }.map_err(|_| Error::DllLoad)?;
let uuid = uuid::Uuid::from_str(TUNNEL_UUID).map_err(|_| Error::Uuid)?;
// Wintun hides the exact Windows error, so let's assume the only way Adapter::create can fail is if we're not elevated.
if wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())).is_err() {
return Ok(false);
pub(crate) fn check() -> Result<bool> {
// TODO
Ok(true)
}
pub(crate) fn elevate() -> Result<()> {
todo!()
}
}
#[cfg(target_os = "windows")]
mod imp {
use crate::client::wintun_install;
use anyhow::{Context, Result};
use std::{os::windows::process::CommandExt, str::FromStr};
/// Check if we have elevated privileges, extract wintun.dll if needed.
///
/// Returns true if already elevated, false if not elevated, error if we can't be sure
pub(crate) fn check() -> Result<bool> {
// Almost the same as the code in tun_windows.rs in connlib
const TUNNEL_UUID: &str = "72228ef4-cb84-4ca5-a4e6-3f8636e75757";
const TUNNEL_NAME: &str = "Firezone Elevation Check";
let path = match wintun_install::ensure_dll() {
Ok(x) => x,
Err(wintun_install::Error::PermissionDenied) => return Ok(false),
Err(e) => return Err(e).context("Failed to ensure wintun.dll is installed"),
};
// SAFETY: Unsafe needed because we're loading a DLL from disk and it has arbitrary C code in it.
// `wintun_install::ensure_dll` checks the hash before we get here. This protects against accidental corruption, but not against attacks. (Because of TOCTOU)
let wintun =
unsafe { wintun::load_from_path(path) }.context("Failed to load wintun.dll")?;
let uuid =
uuid::Uuid::from_str(TUNNEL_UUID).context("Impossible: Hard-coded UUID is invalid")?;
// Wintun hides the exact Windows error, so let's assume the only way Adapter::create can fail is if we're not elevated.
if wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())).is_err()
{
return Ok(false);
}
Ok(true)
}
pub(crate) fn elevate() -> Result<()> {
// Hides Powershell's console on Windows
// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
const CREATE_NO_WINDOW: u32 = 0x08000000;
let current_exe = tauri_utils::platform::current_exe()?;
if current_exe.display().to_string().contains('\"') {
anyhow::bail!("The exe path must not contain double quotes, it makes it hard to elevate with Powershell");
}
std::process::Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.arg("-Command")
.arg("Start-Process")
.arg("-FilePath")
.arg(format!(r#""{}""#, current_exe.display()))
.arg("-Verb")
.arg("RunAs")
.arg("-ArgumentList")
.arg("elevated")
.spawn()
.context("Failed to elevate ourselves with `RunAs`")?;
Ok(())
}
Ok(true)
}

View File

@@ -4,7 +4,7 @@
// TODO: `git grep` for unwraps before 1.0, especially this gui module <https://github.com/firezone/firezone/issues/3521>
use crate::client::{
self, about, deep_link, known_dirs, logging, network_changes,
self, about, deep_link, logging, network_changes,
settings::{self, AdvancedSettings},
Failure,
};
@@ -21,6 +21,14 @@ use ControllerRequest as Req;
mod system_tray_menu;
#[cfg(target_os = "linux")]
#[path = "gui/os_linux.rs"]
mod os;
#[cfg(target_os = "windows")]
#[path = "gui/os_windows.rs"]
mod os;
/// The Windows client doesn't use platform APIs to detect network connectivity changes,
/// so we rely on connlib to do so. We have valid use cases for headless Windows clients
/// (IoT devices, point-of-sale devices, etc), so try to reconnect for 30 days if there's
@@ -53,6 +61,7 @@ impl Managed {
}
// TODO: Replace with `anyhow` gradually per <https://github.com/firezone/firezone/pull/3546#discussion_r1477114789>
#[cfg_attr(target_os = "linux", allow(dead_code))]
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error(r#"Couldn't show clickable notification titled "{0}""#)]
@@ -303,9 +312,8 @@ async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> {
settings::apply_advanced_settings_inner(&settings::AdvancedSettings::default()).await?;
// Test log exporting
let path = known_dirs::session()
.context("`known_dirs::session` failed during smoke test")?
.join("smoke_test_log_export.zip");
let path = PathBuf::from("smoke_test_log_export.zip");
let stem = "connlib-smoke-test".into();
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
@@ -585,7 +593,7 @@ impl Controller {
Req::DisconnectedTokenExpired => {
tracing::info!("Token expired");
self.sign_out()?;
show_notification(
os::show_notification(
"Firezone disconnected",
"To access resources, sign in again.",
)?;
@@ -644,7 +652,7 @@ impl Controller {
self.tunnel_ready = true;
self.refresh_system_tray_menu()?;
show_notification(
os::show_notification(
"Firezone connected",
"You are now signed in and able to access resources.",
)?;
@@ -655,7 +663,7 @@ impl Controller {
// We don't need to route through the controller here either, we could
// use the `open` crate directly instead of Tauri's wrapper
// `tauri::api::shell::open`
show_clickable_notification(
os::show_clickable_notification(
&title,
"Click here to download the new version.",
self.ctlr_tx.clone(),
@@ -831,62 +839,3 @@ async fn run_controller(
Ok(())
}
/// Show a notification in the bottom right of the screen
///
/// May say "Windows Powershell" and have the wrong icon in dev mode
/// See <https://github.com/tauri-apps/tauri/issues/3700>
fn show_notification(title: &str, body: &str) -> Result<(), Error> {
tauri_winrt_notification::Toast::new(BUNDLE_ID)
.title(title)
.text1(body)
.show()
.map_err(|_| Error::Notification(title.to_string()))?;
Ok(())
}
/// Show a notification that signals `Controller` when clicked
///
/// May say "Windows Powershell" and have the wrong icon in dev mode
/// See <https://github.com/tauri-apps/tauri/issues/3700>
///
/// Known issue: If the notification times out and goes into the notification center
/// (the little thing that pops up when you click the bell icon), then we may not get the
/// click signal.
///
/// I've seen this reported by people using Powershell, C#, etc., so I think it might
/// be a Windows bug?
/// - <https://superuser.com/questions/1488763/windows-10-notifications-not-activating-the-associated-app-when-clicking-on-it>
/// - <https://stackoverflow.com/questions/65835196/windows-toast-notification-com-not-working>
/// - <https://answers.microsoft.com/en-us/windows/forum/all/notifications-not-activating-the-associated-app/7a3b31b0-3a20-4426-9c88-c6e3f2ac62c6>
///
/// Firefox doesn't have this problem. Maybe they're using a different API.
fn show_clickable_notification(
title: &str,
body: &str,
tx: CtlrTx,
req: ControllerRequest,
) -> Result<(), Error> {
// For some reason `on_activated` is FnMut
let mut req = Some(req);
tauri_winrt_notification::Toast::new(BUNDLE_ID)
.title(title)
.text1(body)
.scenario(tauri_winrt_notification::Scenario::Reminder)
.on_activated(move || {
if let Some(req) = req.take() {
if let Err(error) = tx.blocking_send(req) {
tracing::error!(
?error,
"User clicked on notification, but we couldn't tell `Controller`"
);
}
}
Ok(())
})
.show()
.map_err(|_| Error::ClickableNotification(title.to_string()))?;
Ok(())
}

View File

@@ -0,0 +1,18 @@
use super::{ControllerRequest, CtlrTx, Error};
/// Show a notification in the bottom right of the screen
pub(crate) fn show_notification(_title: &str, _body: &str) -> Result<(), Error> {
// TODO
Ok(())
}
/// Show a notification that signals `Controller` when clicked
pub(crate) fn show_clickable_notification(
_title: &str,
_body: &str,
_tx: CtlrTx,
_req: ControllerRequest,
) -> Result<(), Error> {
// TODO
Ok(())
}

View File

@@ -0,0 +1,61 @@
use super::{ControllerRequest, CtlrTx, Error};
use connlib_shared::BUNDLE_ID;
/// Show a notification in the bottom right of the screen
///
/// May say "Windows Powershell" and have the wrong icon in dev mode
/// See <https://github.com/tauri-apps/tauri/issues/3700>
pub(crate) fn show_notification(title: &str, body: &str) -> Result<(), Error> {
tauri_winrt_notification::Toast::new(BUNDLE_ID)
.title(title)
.text1(body)
.show()
.map_err(|_| Error::Notification(title.to_string()))?;
Ok(())
}
/// Show a notification that signals `Controller` when clicked
///
/// May say "Windows Powershell" and have the wrong icon in dev mode
/// See <https://github.com/tauri-apps/tauri/issues/3700>
///
/// Known issue: If the notification times out and goes into the notification center
/// (the little thing that pops up when you click the bell icon), then we may not get the
/// click signal.
///
/// I've seen this reported by people using Powershell, C#, etc., so I think it might
/// be a Windows bug?
/// - <https://superuser.com/questions/1488763/windows-10-notifications-not-activating-the-associated-app-when-clicking-on-it>
/// - <https://stackoverflow.com/questions/65835196/windows-toast-notification-com-not-working>
/// - <https://answers.microsoft.com/en-us/windows/forum/all/notifications-not-activating-the-associated-app/7a3b31b0-3a20-4426-9c88-c6e3f2ac62c6>
///
/// Firefox doesn't have this problem. Maybe they're using a different API.
pub(crate) fn show_clickable_notification(
title: &str,
body: &str,
tx: CtlrTx,
req: ControllerRequest,
) -> Result<(), Error> {
// For some reason `on_activated` is FnMut
let mut req = Some(req);
tauri_winrt_notification::Toast::new(BUNDLE_ID)
.title(title)
.text1(body)
.scenario(tauri_winrt_notification::Scenario::Reminder)
.on_activated(move || {
if let Some(req) = req.take() {
if let Err(error) = tx.blocking_send(req) {
tracing::error!(
?error,
"User clicked on notification, but we couldn't tell `Controller`"
);
}
}
Ok(())
})
.show()
.map_err(|_| Error::ClickableNotification(title.to_string()))?;
Ok(())
}

View File

@@ -1,355 +1,9 @@
//! A module for getting callbacks from Windows when we gain / lose Internet connectivity
//!
//! # Latency
//!
//! 2 or 3 seconds for the user clicking "Connect" or "Disconnect" on Wi-Fi,
//! or for plugging or unplugging an Ethernet cable.
//!
//! Plugging in Ethernet may take longer since it waits on DHCP.
//! Connecting to Wi-Fi usually notifies while Windows is showing the progress bar
//! in the Wi-Fi menu.
//!
//! DNS server changes are (TODO <https://github.com/firezone/firezone/issues/3343>)
//!
//! # Worker thread
//!
//! `Listener` must live in a worker thread if used from Tauri.
//! `
//! This is because both `Listener` and some feature in Tauri (maybe drag-and-drop) depend on COM.
//! `Listener` works fine if we initialize COM with COINIT_MULTITHREADED, but
//! Tauri initializes COM some other way.
//!
//! In the debug command we don't need a worker thread because we're the only code
//! in the process using COM.
//!
//! I tried disabling file drag-and-drop in tauri.conf.json, that didn't work:
//! - <https://github.com/tauri-apps/tauri/commit/e0e49d87a200d3681d39ff2dd80bee5d408c943e>
//! - <https://tauri.app/v1/api/js/window/#filedropenabled>
//! - <https://tauri.app/v1/api/config/#windowconfig>
//!
//! There is some explanation of the COM threading stuff in MSDN here:
//! - <https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit>
//! - <https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex>
//!
//! Raymond Chen also explains it on his blog: <https://devblogs.microsoft.com/oldnewthing/20191125-00/?p=103135>
#[cfg(target_os = "linux")]
#[path = "network_changes/linux.rs"]
mod imp;
use anyhow::Result;
use std::sync::Arc;
use tokio::{runtime::Runtime, sync::Notify};
use windows::{
core::{ComInterface, Result as WinResult, GUID},
Win32::{
Networking::NetworkListManager::{
INetworkEvents, INetworkEvents_Impl, INetworkListManager, NetworkListManager,
NLM_CONNECTIVITY, NLM_NETWORK_PROPERTY_CHANGE,
},
System::Com,
},
};
#[cfg(target_os = "windows")]
#[path = "network_changes/windows.rs"]
mod imp;
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("Couldn't initialize COM: {0}")]
ComInitialize(windows::core::Error),
#[error("Couldn't stop worker thread")]
CouldntStopWorkerThread,
#[error("Couldn't creat NetworkListManager")]
CreateNetworkListManager(windows::core::Error),
#[error("Couldn't start listening to network events: {0}")]
Listening(windows::core::Error),
#[error("Couldn't stop listening to network events: {0}")]
Unadvise(windows::core::Error),
}
/// Debug subcommand to test network connectivity events
pub(crate) fn run_debug() -> Result<()> {
tracing_subscriber::fmt::init();
// Returns Err before COM is initialized
assert!(get_apartment_type().is_err());
let com_worker = Worker::new()?;
// We have to initialize COM again for the main thread. This doesn't
// seem to be a problem in the main app since Tauri initializes COM for itself.
let _guard = ComGuard::new();
assert_eq!(
get_apartment_type(),
Ok((Com::APTTYPE_MTA, Com::APTTYPEQUALIFIER_NONE))
);
let rt = Runtime::new().unwrap();
tracing::info!("Listening for network events...");
rt.block_on(async move {
loop {
com_worker.notified().await;
// Make sure whatever Tokio thread we're on is associated with COM
// somehow.
assert_eq!(
get_apartment_type()?,
(Com::APTTYPE_MTA, Com::APTTYPEQUALIFIER_NONE)
);
tracing::info!(have_internet = %check_internet()?);
}
})
}
/// Returns true if Windows thinks we have Internet access per [IsConnectedToInternet](https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/nf-netlistmgr-inetworklistmanager-get_isconnectedtointernet)
///
/// Call this when `Listener` notifies you.
pub fn check_internet() -> WinResult<bool> {
// Retrieving the INetworkListManager takes less than half a millisecond, and this
// makes the lifetimes and Send+Sync much simpler for callers, so just retrieve it
// every single time.
// SAFETY: No lifetime problems. TODO: Could threading be a problem?
// I think in practice we'll never call this from two threads, but what if we did?
// Maybe make it a method on a `!Send + !Sync` guard struct?
let network_list_manager: INetworkListManager =
unsafe { Com::CoCreateInstance(&NetworkListManager, None, Com::CLSCTX_ALL) }?;
// SAFETY: `network_list_manager` isn't shared between threads, and the lifetime
// should be good.
let have_internet = unsafe { network_list_manager.IsConnectedToInternet() }?.as_bool();
Ok(have_internet)
}
/// Worker thread that can be joined explicitly, and joins on Drop
pub(crate) struct Worker {
inner: Option<WorkerInner>,
notify: Arc<Notify>,
}
/// Needed so that `Drop` can consume the oneshot Sender and the thread's JoinHandle
struct WorkerInner {
thread: std::thread::JoinHandle<Result<(), Error>>,
stopper: tokio::sync::oneshot::Sender<()>,
}
impl Worker {
pub fn new() -> std::io::Result<Self> {
let notify = Arc::new(Notify::new());
let (stopper, rx) = tokio::sync::oneshot::channel();
let thread = {
let notify = Arc::clone(&notify);
std::thread::Builder::new()
.name("Firezone COM worker".into())
.spawn(move || {
{
let com = ComGuard::new()?;
let _network_change_listener = Listener::new(&com, notify)?;
rx.blocking_recv().ok();
}
tracing::debug!("COM worker thread shut down gracefully");
Ok(())
})?
};
Ok(Self {
inner: Some(WorkerInner { thread, stopper }),
notify,
})
}
/// Same as `drop`, but you can catch errors
pub fn close(&mut self) -> Result<(), Error> {
if let Some(inner) = self.inner.take() {
inner
.stopper
.send(())
.map_err(|_| Error::CouldntStopWorkerThread)?;
match inner.thread.join() {
Err(e) => std::panic::resume_unwind(e),
Ok(x) => x?,
}
}
Ok(())
}
pub async fn notified(&self) {
self.notify.notified().await;
}
}
impl Drop for Worker {
fn drop(&mut self) {
self.close()
.expect("should be able to close Worker cleanly");
}
}
/// Enforces the initialize-use-uninitialize order for `Listener` and COM
///
/// COM is meant to be initialized for a thread, and un-initialized for the same thread,
/// so don't pass this between threads.
struct ComGuard {
dropped: bool,
_unsend_unsync: PhantomUnsendUnsync,
}
/// Marks a type as !Send and !Sync without nightly / unstable features
///
/// <https://stackoverflow.com/questions/62713667/how-to-implement-send-or-sync-for-a-type>
type PhantomUnsendUnsync = std::marker::PhantomData<*const ()>;
impl ComGuard {
/// Initialize a "Multi-threaded apartment" so that Windows COM stuff
/// can be called, and COM callbacks can work.
pub fn new() -> Result<Self, Error> {
// SAFETY: Threading shouldn't be a problem since this is meant to initialize
// COM per-thread anyway.
unsafe { Com::CoInitializeEx(None, Com::COINIT_MULTITHREADED) }
.map_err(Error::ComInitialize)?;
Ok(Self {
dropped: false,
_unsend_unsync: Default::default(),
})
}
}
impl Drop for ComGuard {
fn drop(&mut self) {
if !self.dropped {
self.dropped = true;
// Required, per [CoInitializeEx docs](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex#remarks)
// Safety: Make sure all the COM objects are dropped before we call
// CoUninitialize or the program might segfault.
unsafe { Com::CoUninitialize() };
tracing::debug!("Uninitialized COM");
}
}
}
/// Listens to network connectivity change eents
pub(crate) struct Listener<'a> {
/// The cookies we get back from `Advise`. Can be None if the owner called `close`
///
/// This has to be mutable because we have to hook up the callbacks during
/// Listener's constructor
advise_cookie_net: Option<u32>,
cxn_point_net: Com::IConnectionPoint,
inner: ListenerInner,
/// Hold a reference to a `ComGuard` to enforce the right init-use-uninit order
_com: &'a ComGuard,
}
/// This must be separate because we need to `Clone` that `Notify` and we can't
/// `Clone` the COM objects in `Listener`
// https://kennykerr.ca/rust-getting-started/how-to-implement-com-interface.html
#[windows_implement::implement(INetworkEvents)]
#[derive(Clone)]
struct ListenerInner {
notify: Arc<Notify>,
}
impl<'a> Drop for Listener<'a> {
fn drop(&mut self) {
self.close().unwrap();
}
}
impl<'a> Listener<'a> {
/// Creates a new Listener
///
/// # Arguments
///
/// * `com` - Makes sure that CoInitializeEx was called. `com` have been created
/// on the same thread as `new` is called on.
/// * `notify` - A Tokio `Notify` that will be notified when Windows detects
/// connectivity changes. Some notifications may be spurious.
fn new(com: &'a ComGuard, notify: Arc<Notify>) -> Result<Self, Error> {
// `windows-rs` automatically releases (de-refs) COM objects on Drop:
// https://github.com/microsoft/windows-rs/issues/2123#issuecomment-1293194755
// https://github.com/microsoft/windows-rs/blob/cefdabd15e4a7a7f71b7a2d8b12d5dc148c99adb/crates/samples/windows/wmi/src/main.rs#L22
// SAFETY: TODO
let network_list_manager: INetworkListManager =
unsafe { Com::CoCreateInstance(&NetworkListManager, None, Com::CLSCTX_ALL) }
.map_err(Error::CreateNetworkListManager)?;
let cpc: Com::IConnectionPointContainer =
network_list_manager.cast().map_err(Error::Listening)?;
// SAFETY: TODO
let cxn_point_net =
unsafe { cpc.FindConnectionPoint(&INetworkEvents::IID) }.map_err(Error::Listening)?;
let mut this = Listener {
advise_cookie_net: None,
cxn_point_net,
inner: ListenerInner { notify },
_com: com,
};
let callbacks: INetworkEvents = this.inner.clone().into();
// SAFETY: What happens if Windows sends us a network change event while
// we're dropping Listener?
// Is it safe to Advise on `this` and then immediately move it?
this.advise_cookie_net =
Some(unsafe { this.cxn_point_net.Advise(&callbacks) }.map_err(Error::Listening)?);
// After we call `Advise`, notify. This should avoid a problem if this happens:
//
// 1. Caller spawns a worker thread for Listener, but the worker thread isn't scheduled
// 2. Caller continues setup, checks Internet is connected
// 3. Internet gets disconnected but caller isn't notified
// 4. Worker thread finally gets scheduled, but we never notify that the Internet was lost during setup. Caller is now out of sync with ground truth.
this.inner.notify.notify_one();
Ok(this)
}
/// Like `drop` but you can catch errors
///
/// Unregisters the network change callbacks
pub fn close(&mut self) -> Result<(), Error> {
if let Some(cookie) = self.advise_cookie_net.take() {
// SAFETY: I don't see any memory safety issues.
unsafe { self.cxn_point_net.Unadvise(cookie) }.map_err(Error::Unadvise)?;
tracing::debug!("Unadvised");
}
Ok(())
}
}
impl INetworkEvents_Impl for ListenerInner {
fn NetworkAdded(&self, _networkid: &GUID) -> WinResult<()> {
Ok(())
}
fn NetworkDeleted(&self, _networkid: &GUID) -> WinResult<()> {
Ok(())
}
fn NetworkConnectivityChanged(
&self,
_networkid: &GUID,
_newconnectivity: NLM_CONNECTIVITY,
) -> WinResult<()> {
self.notify.notify_one();
Ok(())
}
fn NetworkPropertyChanged(
&self,
_networkid: &GUID,
_flags: NLM_NETWORK_PROPERTY_CHANGE,
) -> WinResult<()> {
Ok(())
}
}
/// Checks what COM apartment the current thread is in. For debugging only.
fn get_apartment_type() -> WinResult<(Com::APTTYPE, Com::APTTYPEQUALIFIER)> {
let mut apt_type = Com::APTTYPE_CURRENT;
let mut apt_qualifier = Com::APTTYPEQUALIFIER_NONE;
// SAFETY: We just created the variables, and they're out parameters,
// so Windows shouldn't store the pointers.
unsafe { Com::CoGetApartmentType(&mut apt_type, &mut apt_qualifier) }?;
Ok((apt_type, apt_qualifier))
}
pub(crate) use imp::{check_internet, run_debug, Worker};

View File

@@ -0,0 +1,32 @@
//! Not implemented for Linux yet
use anyhow::Result;
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {}
pub(crate) fn run_debug() -> Result<()> {
tracing::warn!("network_changes not implemented yet on Linux");
Ok(())
}
/// TODO: Implement for Linux
pub(crate) fn check_internet() -> Result<bool> {
Ok(true)
}
pub(crate) struct Worker {}
impl Worker {
pub(crate) fn new() -> Result<Self> {
Ok(Self {})
}
pub(crate) fn close(&mut self) -> Result<()> {
Ok(())
}
pub(crate) async fn notified(&self) {
futures::future::pending().await
}
}

View File

@@ -0,0 +1,355 @@
//! A module for getting callbacks from Windows when we gain / lose Internet connectivity
//!
//! # Latency
//!
//! 2 or 3 seconds for the user clicking "Connect" or "Disconnect" on Wi-Fi,
//! or for plugging or unplugging an Ethernet cable.
//!
//! Plugging in Ethernet may take longer since it waits on DHCP.
//! Connecting to Wi-Fi usually notifies while Windows is showing the progress bar
//! in the Wi-Fi menu.
//!
//! DNS server changes are (TODO <https://github.com/firezone/firezone/issues/3343>)
//!
//! # Worker thread
//!
//! `Listener` must live in a worker thread if used from Tauri.
//! `
//! This is because both `Listener` and some feature in Tauri (maybe drag-and-drop) depend on COM.
//! `Listener` works fine if we initialize COM with COINIT_MULTITHREADED, but
//! Tauri initializes COM some other way.
//!
//! In the debug command we don't need a worker thread because we're the only code
//! in the process using COM.
//!
//! I tried disabling file drag-and-drop in tauri.conf.json, that didn't work:
//! - <https://github.com/tauri-apps/tauri/commit/e0e49d87a200d3681d39ff2dd80bee5d408c943e>
//! - <https://tauri.app/v1/api/js/window/#filedropenabled>
//! - <https://tauri.app/v1/api/config/#windowconfig>
//!
//! There is some explanation of the COM threading stuff in MSDN here:
//! - <https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit>
//! - <https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex>
//!
//! Raymond Chen also explains it on his blog: <https://devblogs.microsoft.com/oldnewthing/20191125-00/?p=103135>
use anyhow::Result;
use std::sync::Arc;
use tokio::{runtime::Runtime, sync::Notify};
use windows::{
core::{ComInterface, Result as WinResult, GUID},
Win32::{
Networking::NetworkListManager::{
INetworkEvents, INetworkEvents_Impl, INetworkListManager, NetworkListManager,
NLM_CONNECTIVITY, NLM_NETWORK_PROPERTY_CHANGE,
},
System::Com,
},
};
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("Couldn't initialize COM: {0}")]
ComInitialize(windows::core::Error),
#[error("Couldn't stop worker thread")]
CouldntStopWorkerThread,
#[error("Couldn't creat NetworkListManager")]
CreateNetworkListManager(windows::core::Error),
#[error("Couldn't start listening to network events: {0}")]
Listening(windows::core::Error),
#[error("Couldn't stop listening to network events: {0}")]
Unadvise(windows::core::Error),
}
/// Debug subcommand to test network connectivity events
pub(crate) fn run_debug() -> Result<()> {
tracing_subscriber::fmt::init();
// Returns Err before COM is initialized
assert!(get_apartment_type().is_err());
let com_worker = Worker::new()?;
// We have to initialize COM again for the main thread. This doesn't
// seem to be a problem in the main app since Tauri initializes COM for itself.
let _guard = ComGuard::new();
assert_eq!(
get_apartment_type(),
Ok((Com::APTTYPE_MTA, Com::APTTYPEQUALIFIER_NONE))
);
let rt = Runtime::new().unwrap();
tracing::info!("Listening for network events...");
rt.block_on(async move {
loop {
com_worker.notified().await;
// Make sure whatever Tokio thread we're on is associated with COM
// somehow.
assert_eq!(
get_apartment_type()?,
(Com::APTTYPE_MTA, Com::APTTYPEQUALIFIER_NONE)
);
tracing::info!(have_internet = %check_internet()?);
}
})
}
/// Returns true if Windows thinks we have Internet access per [IsConnectedToInternet](https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/nf-netlistmgr-inetworklistmanager-get_isconnectedtointernet)
///
/// Call this when `Listener` notifies you.
pub fn check_internet() -> Result<bool> {
// Retrieving the INetworkListManager takes less than half a millisecond, and this
// makes the lifetimes and Send+Sync much simpler for callers, so just retrieve it
// every single time.
// SAFETY: No lifetime problems. TODO: Could threading be a problem?
// I think in practice we'll never call this from two threads, but what if we did?
// Maybe make it a method on a `!Send + !Sync` guard struct?
let network_list_manager: INetworkListManager =
unsafe { Com::CoCreateInstance(&NetworkListManager, None, Com::CLSCTX_ALL) }?;
// SAFETY: `network_list_manager` isn't shared between threads, and the lifetime
// should be good.
let have_internet = unsafe { network_list_manager.IsConnectedToInternet() }?.as_bool();
Ok(have_internet)
}
/// Worker thread that can be joined explicitly, and joins on Drop
pub(crate) struct Worker {
inner: Option<WorkerInner>,
notify: Arc<Notify>,
}
/// Needed so that `Drop` can consume the oneshot Sender and the thread's JoinHandle
struct WorkerInner {
thread: std::thread::JoinHandle<Result<(), Error>>,
stopper: tokio::sync::oneshot::Sender<()>,
}
impl Worker {
pub(crate) fn new() -> Result<Self> {
let notify = Arc::new(Notify::new());
let (stopper, rx) = tokio::sync::oneshot::channel();
let thread = {
let notify = Arc::clone(&notify);
std::thread::Builder::new()
.name("Firezone COM worker".into())
.spawn(move || {
{
let com = ComGuard::new()?;
let _network_change_listener = Listener::new(&com, notify)?;
rx.blocking_recv().ok();
}
tracing::debug!("COM worker thread shut down gracefully");
Ok(())
})?
};
Ok(Self {
inner: Some(WorkerInner { thread, stopper }),
notify,
})
}
/// Same as `drop`, but you can catch errors
pub(crate) fn close(&mut self) -> Result<()> {
if let Some(inner) = self.inner.take() {
inner
.stopper
.send(())
.map_err(|_| Error::CouldntStopWorkerThread)?;
match inner.thread.join() {
Err(e) => std::panic::resume_unwind(e),
Ok(x) => x?,
}
}
Ok(())
}
pub(crate) async fn notified(&self) {
self.notify.notified().await;
}
}
impl Drop for Worker {
fn drop(&mut self) {
self.close()
.expect("should be able to close Worker cleanly");
}
}
/// Enforces the initialize-use-uninitialize order for `Listener` and COM
///
/// COM is meant to be initialized for a thread, and un-initialized for the same thread,
/// so don't pass this between threads.
struct ComGuard {
dropped: bool,
_unsend_unsync: PhantomUnsendUnsync,
}
/// Marks a type as !Send and !Sync without nightly / unstable features
///
/// <https://stackoverflow.com/questions/62713667/how-to-implement-send-or-sync-for-a-type>
type PhantomUnsendUnsync = std::marker::PhantomData<*const ()>;
impl ComGuard {
/// Initialize a "Multi-threaded apartment" so that Windows COM stuff
/// can be called, and COM callbacks can work.
pub fn new() -> Result<Self, Error> {
// SAFETY: Threading shouldn't be a problem since this is meant to initialize
// COM per-thread anyway.
unsafe { Com::CoInitializeEx(None, Com::COINIT_MULTITHREADED) }
.map_err(Error::ComInitialize)?;
Ok(Self {
dropped: false,
_unsend_unsync: Default::default(),
})
}
}
impl Drop for ComGuard {
fn drop(&mut self) {
if !self.dropped {
self.dropped = true;
// Required, per [CoInitializeEx docs](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex#remarks)
// Safety: Make sure all the COM objects are dropped before we call
// CoUninitialize or the program might segfault.
unsafe { Com::CoUninitialize() };
tracing::debug!("Uninitialized COM");
}
}
}
/// Listens to network connectivity change eents
struct Listener<'a> {
/// The cookies we get back from `Advise`. Can be None if the owner called `close`
///
/// This has to be mutable because we have to hook up the callbacks during
/// Listener's constructor
advise_cookie_net: Option<u32>,
cxn_point_net: Com::IConnectionPoint,
inner: ListenerInner,
/// Hold a reference to a `ComGuard` to enforce the right init-use-uninit order
_com: &'a ComGuard,
}
/// This must be separate because we need to `Clone` that `Notify` and we can't
/// `Clone` the COM objects in `Listener`
// https://kennykerr.ca/rust-getting-started/how-to-implement-com-interface.html
#[windows_implement::implement(INetworkEvents)]
#[derive(Clone)]
struct ListenerInner {
notify: Arc<Notify>,
}
impl<'a> Drop for Listener<'a> {
fn drop(&mut self) {
self.close().unwrap();
}
}
impl<'a> Listener<'a> {
/// Creates a new Listener
///
/// # Arguments
///
/// * `com` - Makes sure that CoInitializeEx was called. `com` have been created
/// on the same thread as `new` is called on.
/// * `notify` - A Tokio `Notify` that will be notified when Windows detects
/// connectivity changes. Some notifications may be spurious.
fn new(com: &'a ComGuard, notify: Arc<Notify>) -> Result<Self, Error> {
// `windows-rs` automatically releases (de-refs) COM objects on Drop:
// https://github.com/microsoft/windows-rs/issues/2123#issuecomment-1293194755
// https://github.com/microsoft/windows-rs/blob/cefdabd15e4a7a7f71b7a2d8b12d5dc148c99adb/crates/samples/windows/wmi/src/main.rs#L22
// SAFETY: TODO
let network_list_manager: INetworkListManager =
unsafe { Com::CoCreateInstance(&NetworkListManager, None, Com::CLSCTX_ALL) }
.map_err(Error::CreateNetworkListManager)?;
let cpc: Com::IConnectionPointContainer =
network_list_manager.cast().map_err(Error::Listening)?;
// SAFETY: TODO
let cxn_point_net =
unsafe { cpc.FindConnectionPoint(&INetworkEvents::IID) }.map_err(Error::Listening)?;
let mut this = Listener {
advise_cookie_net: None,
cxn_point_net,
inner: ListenerInner { notify },
_com: com,
};
let callbacks: INetworkEvents = this.inner.clone().into();
// SAFETY: What happens if Windows sends us a network change event while
// we're dropping Listener?
// Is it safe to Advise on `this` and then immediately move it?
this.advise_cookie_net =
Some(unsafe { this.cxn_point_net.Advise(&callbacks) }.map_err(Error::Listening)?);
// After we call `Advise`, notify. This should avoid a problem if this happens:
//
// 1. Caller spawns a worker thread for Listener, but the worker thread isn't scheduled
// 2. Caller continues setup, checks Internet is connected
// 3. Internet gets disconnected but caller isn't notified
// 4. Worker thread finally gets scheduled, but we never notify that the Internet was lost during setup. Caller is now out of sync with ground truth.
this.inner.notify.notify_one();
Ok(this)
}
/// Like `drop` but you can catch errors
///
/// Unregisters the network change callbacks
pub fn close(&mut self) -> Result<(), Error> {
if let Some(cookie) = self.advise_cookie_net.take() {
// SAFETY: I don't see any memory safety issues.
unsafe { self.cxn_point_net.Unadvise(cookie) }.map_err(Error::Unadvise)?;
tracing::debug!("Unadvised");
}
Ok(())
}
}
impl INetworkEvents_Impl for ListenerInner {
fn NetworkAdded(&self, _networkid: &GUID) -> WinResult<()> {
Ok(())
}
fn NetworkDeleted(&self, _networkid: &GUID) -> WinResult<()> {
Ok(())
}
fn NetworkConnectivityChanged(
&self,
_networkid: &GUID,
_newconnectivity: NLM_CONNECTIVITY,
) -> WinResult<()> {
self.notify.notify_one();
Ok(())
}
fn NetworkPropertyChanged(
&self,
_networkid: &GUID,
_flags: NLM_NETWORK_PROPERTY_CHANGE,
) -> WinResult<()> {
Ok(())
}
}
/// Checks what COM apartment the current thread is in. For debugging only.
fn get_apartment_type() -> WinResult<(Com::APTTYPE, Com::APTTYPEQUALIFIER)> {
let mut apt_type = Com::APTTYPE_CURRENT;
let mut apt_qualifier = Com::APTTYPEQUALIFIER_NONE;
// SAFETY: We just created the variables, and they're out parameters,
// so Windows shouldn't store the pointers.
unsafe { Com::CoGetApartmentType(&mut apt_type, &mut apt_qualifier) }?;
Ok((apt_type, apt_qualifier))
}

View File

@@ -5,9 +5,16 @@ use std::net::IpAddr;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("can't get system DNS resolvers: {0}")]
#[cfg(target_os = "windows")]
CantGetResolvers(#[from] ipconfig::error::Error),
}
#[cfg(target_os = "linux")]
pub fn get() -> Result<Vec<IpAddr>, Error> {
todo!()
}
#[cfg(target_os = "windows")]
pub fn get() -> Result<Vec<IpAddr>, Error> {
Ok(ipconfig::get_adapters()?
.iter()

View File

@@ -7,16 +7,16 @@ fn main() -> anyhow::Result<()> {
client::run()
}
#[cfg(target_family = "unix")]
#[cfg(target_os = "linux")]
mod client;
#[cfg(target_os = "macos")]
mod client {
pub(crate) fn run() -> anyhow::Result<()> {
println!("The Windows client does not compile on non-Windows platforms yet");
println!("The GUI client does not compile on macOS yet");
Ok(())
}
}
/// Everything is hidden inside the `client` module so that we can exempt the
/// Windows client from static analysis on other platforms where it would throw
/// compile errors.
#[cfg(target_os = "windows")]
mod client;