-
Notifications
You must be signed in to change notification settings - Fork 269
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
Changes from 7 commits
52d7000
1e8d85d
54d96c8
3b1c9dd
07a7333
30ccd0c
f486bd5
4c588ca
c1f465b
9ff8c28
b1d79ff
c53057b
2e1c1cc
e2aa4dc
cbeace1
597fdf5
e22ebec
d97d0dd
5257c98
50290be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) => { | ||
|
@@ -212,6 +214,7 @@ pub enum Cmd { | |
|
||
#[derive(Args)] | ||
pub struct DeepLink { | ||
// TODO: Should be `Secret`? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
} | ||
|
||
|
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"; | ||
|
@@ -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"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
@@ -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(), ""); | ||
|
@@ -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(()) | ||
} | ||
} |
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 | ||
/// | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(()) | ||
} |
There was a problem hiding this comment.
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.