Skip to content

Commit

Permalink
feat(linux): make deep link auth work (#4102)
Browse files Browse the repository at this point in the history
Right now it only works on my dev VM, not on my test VMs, due to #4053
and #4103, but it passes tests and should be safe to merge.

There's one doc fix and one script fix which are unrelated and could be
their own PRs, but they'd be tiny, so I left them in here.

Ref #4106 and #3713 for the plan to fix all this by splitting the tunnel
process off so that the GUI runs as a normal user.
  • Loading branch information
ReactorScram committed Mar 13, 2024
1 parent 32d18ab commit 52cde61
Show file tree
Hide file tree
Showing 13 changed files with 281 additions and 122 deletions.
13 changes: 5 additions & 8 deletions rust/gui-client/build.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
#!/bin/sh
#!/usr/bin/env bash

# The Windows client obviously doesn't build for *nix, but this
# script is helpful for doing UI work on those platforms for the
# Windows client.
set -e
set -euo pipefail

# Copy frontend dependencies
cp node_modules/flowbite/dist/flowbite.min.js src/

# Compile TypeScript
tsc
pnpm tsc

# Compile CSS
tailwindcss -i src/input.css -o src/output.css
pnpm tailwindcss -i src/input.css -o src/output.css

# Compile Rust and bundle
tauri build
pnpm tauri build
8 changes: 7 additions & 1 deletion rust/gui-client/docs/manual_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ If the client stops running while signed in, then the token may be stored in Win

# Resetting state

This is a list of all the on-disk state that you need to delete / reset to test a first-time install / first-time run of the Firezone client.
This is a list of all the on-disk state that you need to delete / reset to test a first-time install / first-time run of the Firezone GUI client.

## Windows

- Dir `%LOCALAPPDATA%/dev.firezone.client/` (Config, logs, webview cache, wintun.dll etc.)
- Dir `%PROGRAMDATA%/dev.firezone.client/` (Device ID file)
Expand All @@ -89,6 +91,10 @@ This is a list of all the on-disk state that you need to delete / reset to test
- Registry key `Computer\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{e9245bc1-b8c1-44ca-ab1d-c6aad4f13b9c}` (IP address and DNS server for our tunnel interface)
- Windows Credential Manager, "Windows Credentials", "Generic Credentials", `dev.firezone.client/token`

## Linux

- Dir `$HOME/.local/share/applications` (.desktop file for deep links. This dir may not even exist by default on distros like Debian)

# Token storage

([#2740](https://github.com/firezone/firezone/issues/2740))
Expand Down
8 changes: 7 additions & 1 deletion rust/gui-client/src-tauri/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ pub(crate) fn run() -> Result<()> {
Some(Cmd::Elevated) => run_gui(cli),
Some(Cmd::OpenDeepLink(deep_link)) => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(deep_link::open(&deep_link.url))?;
if let Err(error) = rt.block_on(deep_link::open(&deep_link.url)) {
tracing::error!(?error, "Error in `OpenDeepLink`");
}
Ok(())
}
Some(Cmd::SmokeTest) => {
Expand Down Expand Up @@ -161,6 +163,9 @@ struct Cli {
/// If true, show a fake update notification that opens the Firezone release page when clicked
#[arg(long, hide = true)]
test_update_notification: bool,
/// Disable deep link registration and handling, for headless CI environments
#[arg(long, hide = true)]
no_deep_links: bool,
}

impl Cli {
Expand Down Expand Up @@ -212,6 +217,7 @@ pub enum Cmd {

#[derive(Args)]
pub struct DeepLink {
// TODO: Should be `Secret`?
pub url: url::Url,
}

Expand Down
102 changes: 54 additions & 48 deletions rust/gui-client/src-tauri/src/client/deep_link.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! A module for registering, catching, and parsing deep links that are sent over to the app's already-running instance

use crate::client::auth::Response as AuthResponse;
use anyhow::{bail, Context, Result};
use secrecy::{ExposeSecret, SecretString};
use std::io;
use url::Url;

pub(crate) const FZ_SCHEME: &str = "firezone-fd0020211111";
Expand All @@ -26,42 +26,22 @@ mod imp;
pub enum Error {
#[error("named pipe server couldn't start listening, we are probably the second instance")]
CantListen,
/// Error from client's POV
#[error(transparent)]
ClientCommunications(io::Error),
/// Error while connecting to the server
#[error(transparent)]
Connect(io::Error),
/// Something went wrong finding the path to our own exe
#[error(transparent)]
CurrentExe(io::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
#[error(transparent)]
ServerCommunications(io::Error),
#[error(transparent)]
UrlParse(#[from] url::ParseError),
/// Something went wrong setting up the registry
#[cfg(target_os = "windows")]
#[error(transparent)]
WindowsRegistry(io::Error),
Other(#[from] anyhow::Error),
}

pub(crate) use imp::{open, register, Server};

pub(crate) fn parse_auth_callback(url: &SecretString) -> Option<AuthResponse> {
let url = Url::parse(url.expose_secret()).ok()?;
match url.host() {
Some(url::Host::Domain("handle_client_sign_in_callback")) => {}
_ => return None,
pub(crate) fn parse_auth_callback(url_secret: &SecretString) -> Result<AuthResponse> {
let url = Url::parse(url_secret.expose_secret())?;
if Some(url::Host::Domain("handle_client_sign_in_callback")) != url.host() {
bail!("URL host should be `handle_client_sign_in_callback`");
}
if url.path() != "/" {
return None;
// Sometimes I get an empty path, might be a glitch in Firefox Linux aarch64?
match url.path() {
"/" => {}
"" => {}
_ => bail!("URL path should be `/` or empty"),
}

let mut actor_name = None;
Expand All @@ -72,54 +52,58 @@ pub(crate) fn parse_auth_callback(url: &SecretString) -> Option<AuthResponse> {
match key.as_ref() {
"actor_name" => {
if actor_name.is_some() {
// actor_name must appear exactly once
return None;
bail!("`actor_name` should appear exactly once");
}
actor_name = Some(value.to_string());
}
"fragment" => {
if fragment.is_some() {
// must appear exactly once
return None;
bail!("`fragment` should appear exactly once");
}
fragment = Some(SecretString::new(value.to_string()));
}
"state" => {
if state.is_some() {
// must appear exactly once
return None;
bail!("`state` should appear exactly once");
}
state = Some(SecretString::new(value.to_string()));
}
_ => {}
}
}

Some(AuthResponse {
actor_name: actor_name?,
fragment: fragment?,
state: state?,
Ok(AuthResponse {
actor_name: actor_name.context("URL should have `actor_name`")?,
fragment: fragment.context("URL should have `fragment`")?,
state: state.context("URL should have `state`")?,
})
}

#[cfg(test)]
mod tests {
use anyhow::Result;
use anyhow::{Context, Result};
use secrecy::{ExposeSecret, SecretString};

#[test]
fn parse_auth_callback() -> Result<()> {
// Positive cases
let input = "firezone://handle_client_sign_in_callback/?actor_name=Reactor+Scram&fragment=a_very_secret_string&state=a_less_secret_string&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input).unwrap();
let actual = parse_callback_wrapper(input)?;

assert_eq!(actual.actor_name, "Reactor Scram");
assert_eq!(actual.fragment.expose_secret(), "a_very_secret_string");
assert_eq!(actual.state.expose_secret(), "a_less_secret_string");

let input = "firezone-fd0020211111://handle_client_sign_in_callback?account_name=Firezone&account_slug=firezone&actor_name=Reactor+Scram&fragment=a_very_secret_string&identity_provider_identifier=1234&state=a_less_secret_string";
let actual = parse_callback_wrapper(input)?;

assert_eq!(actual.actor_name, "Reactor Scram");
assert_eq!(actual.fragment.expose_secret(), "a_very_secret_string");
assert_eq!(actual.state.expose_secret(), "a_less_secret_string");

// Empty string "" `actor_name` is fine
let input = "firezone://handle_client_sign_in_callback/?actor_name=&fragment=&state=&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input).unwrap();
let actual = parse_callback_wrapper(input)?;

assert_eq!(actual.actor_name, "");
assert_eq!(actual.fragment.expose_secret(), "");
Expand All @@ -130,22 +114,44 @@ mod tests {
// URL host is wrong
let input = "firezone://not_handle_client_sign_in_callback/?actor_name=Reactor+Scram&fragment=a_very_secret_string&state=a_less_secret_string&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input);
assert!(actual.is_none());
assert!(actual.is_err());

// `actor_name` is not just blank but totally missing
let input = "firezone://handle_client_sign_in_callback/?fragment=&state=&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input);
assert!(actual.is_none());
assert!(actual.is_err());

// URL is nonsense
let input = "?????????";
let actual_result = parse_callback_wrapper(input);
assert!(actual_result.is_none());
assert!(actual_result.is_err());

Ok(())
}

fn parse_callback_wrapper(s: &str) -> Option<super::AuthResponse> {
fn parse_callback_wrapper(s: &str) -> Result<super::AuthResponse> {
super::parse_auth_callback(&SecretString::new(s.to_owned()))
}

/// Tests the named pipe or Unix domain socket, doesn't test the URI scheme itself
///
/// Will fail if any other Firezone Client instance is running
/// Will fail with permission error if Firezone already ran as sudo
#[tokio::test]
async fn socket_smoke_test() -> Result<()> {
let server = super::Server::new().context("Couldn't start Server")?;
let server_task = tokio::spawn(async move {
let bytes = server.accept().await?;
Ok::<_, anyhow::Error>(bytes)
});
let id = uuid::Uuid::new_v4().to_string();
let expected_url = url::Url::parse(&format!("bogus-test-schema://{id}"))?;
super::open(&expected_url).await?;

let bytes = server_task.await??;
let s = std::str::from_utf8(bytes.expose_secret())?;
let url = url::Url::parse(s)?;
assert_eq!(url, expected_url);
Ok(())
}
}
122 changes: 107 additions & 15 deletions rust/gui-client/src-tauri/src/client/deep_link/linux.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,121 @@
//! TODO: Not implemented for Linux yet
use crate::client::known_dirs;
use anyhow::{bail, Context, Result};
use secrecy::{ExposeSecret, Secret};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{UnixListener, UnixStream},
};

use super::Error;
use secrecy::SecretString;
const SOCK_NAME: &str = "deep_link.sock";

pub(crate) struct Server {}
pub(crate) struct Server {
listener: UnixListener,
}

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 {})
/// Create a new deep link server to make sure we're the only instance
///
/// Still uses `thiserror` so we can catch the deep_link `CantListen` error
pub(crate) fn new() -> Result<Self, super::Error> {
let dir = known_dirs::runtime().context("couldn't find runtime dir")?;
let path = dir.join(SOCK_NAME);
// TODO: This breaks single instance. Can we enforce it some other way?
std::fs::remove_file(&path).ok();
std::fs::create_dir_all(&dir).context("Can't create dir for deep link socket")?;

let listener = UnixListener::bind(&path).context("Couldn't bind listener Unix socket")?;

// Figure out who we were before `sudo`, if using sudo
if let Ok(username) = std::env::var("SUDO_USER") {
// chown so that when the non-privileged browser launches us,
// we can send a message to our privileged main process
std::process::Command::new("chown")
.arg(username)
.arg(&path)
.status()
.context("couldn't chown Unix domain socket")?;
}

Ok(Self { listener })
}

pub(crate) async fn accept(self) -> Result<SecretString, Error> {
tracing::warn!("Deep links not implemented yet on Linux");
futures::future::pending().await
/// Await one incoming deep link
///
/// To match the Windows API, this consumes the `Server`.
pub(crate) async fn accept(self) -> Result<Secret<Vec<u8>>> {
tracing::debug!("deep_link::accept");
let (mut stream, _) = self.listener.accept().await?;
tracing::debug!("Accepted Unix domain socket connection");

// TODO: Limit reads to 4,096 bytes. Partial reads will probably never happen
// since it's a local socket transferring very small data.
let mut bytes = vec![];
stream
.read_to_end(&mut bytes)
.await
.context("failed to read incoming deep link over Unix socket stream")?;
let bytes = Secret::new(bytes);
tracing::debug!(
len = bytes.expose_secret().len(),
"Got data from Unix domain socket"
);
Ok(bytes)
}
}

pub(crate) async fn open(_url: &url::Url) -> Result<(), Error> {
tracing::warn!("Not implemented yet");
pub(crate) async fn open(url: &url::Url) -> Result<()> {
crate::client::logging::debug_command_setup()?;

let dir = known_dirs::runtime().context("deep_link::open couldn't find runtime dir")?;
let path = dir.join(SOCK_NAME);
let mut stream = UnixStream::connect(&path).await?;

stream.write_all(url.to_string().as_bytes()).await?;

Ok(())
}

pub(crate) fn register() -> Result<(), Error> {
tracing::warn!("Not implemented yet");
/// Register a URI scheme so that browser can deep link into our app for auth
///
/// Performs blocking I/O (Waits on `xdg-desktop-menu` subprocess)
pub(crate) fn register() -> Result<()> {
// Write `$HOME/.local/share/applications/firezone-client.desktop`
// According to <https://wiki.archlinux.org/title/Desktop_entries>, that's the place to put
// per-user desktop entries.
let dir = dirs::data_local_dir()
.context("can't figure out where to put our desktop entry")?
.join("applications");
std::fs::create_dir_all(&dir)?;

// Don't use atomic writes here - If we lose power, we'll just rewrite this file on
// the next boot anyway.
let path = dir.join("firezone-client.desktop");
let exe = std::env::current_exe().context("failed to find our own exe path")?;
let content = format!(
"[Desktop Entry]
Version=1.0
Name=Firezone
Comment=Firezone GUI Client
Exec={} open-deep-link %U
Terminal=false
Type=Application
MimeType=x-scheme-handler/{}
Categories=Network;
",
exe.display(),
super::FZ_SCHEME
);
std::fs::write(&path, content).context("failed to write desktop entry file")?;

// Run `xdg-desktop-menu install` with that desktop file
let xdg_desktop_menu = "xdg-desktop-menu";
let status = std::process::Command::new(xdg_desktop_menu)
.arg("install")
.arg(&path)
.status()
.with_context(|| format!("failed to run `{xdg_desktop_menu}`"))?;
if !status.success() {
bail!("failed to register our deep link scheme")
}
Ok(())
}
Loading

0 comments on commit 52cde61

Please sign in to comment.