Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linux): make deep link auth work #4102

Merged
merged 20 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all this stuff is unrelated to deep linking but happened to be changed on this branch.

# 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)

Comment on lines +94 to +97
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not complete yet but it's more than nothing.

# Token storage

([#2740](https://github.com/firezone/firezone/issues/2740))
Expand Down
5 changes: 4 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 @@ -212,6 +214,7 @@ pub enum Cmd {

#[derive(Args)]
pub struct DeepLink {
// TODO: Should be `Secret`?
Copy link
Member

@jamilbk jamilbk Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but we can wait until the two-process arch is done before this

pub url: url::Url,
}

Expand Down
104 changes: 56 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,24 @@ 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: &SecretString) -> Result<AuthResponse> {
// TODO: Delete this before opening PR
tracing::debug!(secret = url.expose_secret(), "Parsing URL");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops

let url = Url::parse(url.expose_secret())?;
ReactorScram marked this conversation as resolved.
Show resolved Hide resolved
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 +54,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 +116,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
///
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-instance doesn't work on Linux yet. It's in #3884

/// 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
Loading