diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d34804dac..f26f10d1a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1949,6 +1949,7 @@ dependencies = [ "humantime", "nix 0.28.0", "resolv-conf", + "sd-notify", "secrecy", "serde", "serde_json", diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index 391908d06..c0d6fdbdf 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -23,8 +23,9 @@ connlib-shared = { workspace = true } dirs = "5.0.1" firezone-cli-utils = { workspace = true } futures = "0.3.30" -nix = { version = "0.28.0", features = ["user"] } +nix = { version = "0.28.0", features = ["fs", "user"] } resolv-conf = "0.7.0" +sd-notify = "0.4.1" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 serde_json = "1.0.115" secrecy = { workspace = true } tokio-util = { version = "0.7.10", features = ["codec"] } diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs index 963131a65..9ec2e5c27 100644 --- a/rust/headless-client/src/lib.rs +++ b/rust/headless-client/src/lib.rs @@ -50,7 +50,7 @@ struct Cli { // TODO: It isn't good for security to pass the token as a CLI arg. // If we pass it as an env var, we should remove it immediately so that // child processes don't inherit it. Reading it from a file is probably safest. - #[arg(env = "FIREZONE_TOKEN", hide = true)] + #[arg(env = "FIREZONE_TOKEN", hide = true, long)] pub token: Option, /// Identifier used by the portal to identify and display the device. @@ -73,14 +73,21 @@ struct Cli { impl Cli { fn command(&self) -> Cmd { // Needed for backwards compatibility with old Docker images - self.command.unwrap_or(Cmd::Standalone) + self.command.unwrap_or(Cmd::Auto) } } #[derive(clap::Subcommand, Clone, Copy)] enum Cmd { + /// If there is a token on disk, run in standalone mode. Otherwise, run as an IPC daemon. This will be removed in a future version. + #[command(hide = true)] + Auto, /// Listen for IPC connections and act as a privileged tunnel process for a GUI client + #[command(hide = true)] IpcService, - /// Act as a CLI-only Client, don't listen for IPC connections + /// Act as a CLI-only Client Standalone, + /// Act as an IPC client for development + #[command(hide = true)] + StubIpcClient, } diff --git a/rust/headless-client/src/linux.rs b/rust/headless-client/src/linux.rs index 96e55c4d7..7bc4e06e3 100644 --- a/rust/headless-client/src/linux.rs +++ b/rust/headless-client/src/linux.rs @@ -1,5 +1,5 @@ use super::{Cli, Cmd}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use connlib_client_shared::{file_logger, Callbacks, Session, Sockets}; use connlib_shared::{ @@ -10,31 +10,107 @@ use connlib_shared::{ use firezone_cli_utils::setup_global_subscriber; use futures::{SinkExt, StreamExt}; use secrecy::SecretString; -use std::{ - future, - net::IpAddr, - path::{Path, PathBuf}, - str::FromStr, - task::Poll, -}; +use std::{future, net::IpAddr, path::PathBuf, str::FromStr, task::Poll}; use tokio::{ net::{UnixListener, UnixStream}, signal::unix::SignalKind, }; use tokio_util::codec::LengthDelimitedCodec; +// The Client currently must run as root to control DNS +// Root group and user are used to check file ownership on the token +const ROOT_GROUP: u32 = 0; +const ROOT_USER: u32 = 0; + +/// The path for our Unix Domain Socket +/// +/// Docker keeps theirs in `/run` and also appears to use filesystem permissions +/// for security, so we're following their lead. `/run` and `/var/run` are symlinked +/// on some systems, `/run` should be the newer version. +const SOCK_PATH: &str = "/run/firezone-client.sock"; + pub async fn run() -> Result<()> { let cli = Cli::parse(); let (layer, _handle) = cli.log_dir.as_deref().map(file_logger::layer).unzip(); setup_global_subscriber(layer); match cli.command() { - Cmd::IpcService => run_daemon(cli).await, - Cmd::Standalone => run_standalone(cli).await, + Cmd::Auto => { + if let Some(token) = token(&cli)? { + run_standalone(cli, &token).await + } else { + run_ipc_service(cli).await + } + } + Cmd::IpcService => run_ipc_service(cli).await, + Cmd::Standalone => { + let token = token(&cli)?.context("Need a token to run as standalone Client")?; + run_standalone(cli, &token).await + } + Cmd::StubIpcClient => run_debug_ipc_client(cli).await, } } -async fn run_standalone(cli: Cli) -> Result<()> { +/// Try to retrieve the token from CLI arg, env var, or disk +/// +/// Sync because we do blocking file I/O +fn token(cli: &Cli) -> Result> { + let path = PathBuf::from("/etc") + .join(connlib_shared::BUNDLE_ID) + .join("token.txt"); + + if let Some(token) = &cli.token { + // Token was provided in CLI args or env var + // Not very secure, but we do get the token + tracing::info!( + ?path, + "Found token in environment or CLI args, ignoring any token that may be on disk." + ); + return Ok(Some(token.clone().into())); + } + + let Ok(stat) = nix::sys::stat::fstatat(None, &path, nix::fcntl::AtFlags::empty()) else { + // File doesn't exist or can't be read + tracing::info!( + ?path, + "No token found in CLI args, in environment, or on disk" + ); + return Ok(None); + }; + if stat.st_uid != ROOT_USER { + bail!( + "Token file `{}` should be owned by root user", + path.display() + ); + } + if stat.st_gid != ROOT_GROUP { + bail!( + "Token file `{}` should be owned by root group", + path.display() + ); + } + if stat.st_mode & 0o177 != 0 { + bail!( + "Token file `{}` should have mode 0o400 or 0x600", + path.display() + ); + } + + let Ok(bytes) = std::fs::read(&path) else { + // We got the metadata a second ago, but can't read the file itself. + // Pretty strange, would have to be a disk fault or TOCTOU. + tracing::info!(?path, "Token file existed but now is unreadable"); + return Ok(None); + }; + let s = String::from_utf8(bytes)?; + let token = s.trim().to_string(); + + tracing::info!(?path, "Loaded token from disk"); + Ok(Some(token.into())) +} + +async fn run_standalone(cli: Cli, token: &SecretString) -> Result<()> { + tracing::info!("Running in standalone mode"); let max_partition_time = cli.max_partition_time.map(|d| d.into()); let callbacks = CallbackHandler; @@ -45,26 +121,8 @@ async fn run_standalone(cli: Cli) -> Result<()> { None => connlib_shared::device_id::get().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id, }; - let token = match cli.token { - Some(x) => x, - None => { - let path = PathBuf::from("/etc") - .join(connlib_shared::BUNDLE_ID) - .join("token.txt"); - let bytes = tokio::fs::read(path).await?; - let s = String::from_utf8(bytes)?; - s.trim().to_string() - } - }; - let (private_key, public_key) = keypair(); - let login = LoginUrl::client( - cli.api_url, - &SecretString::from(token), - firezone_id, - None, - public_key.to_bytes(), - )?; + let login = LoginUrl::client(cli.api_url, token, firezone_id, None, public_key.to_bytes())?; let session = Session::connect( login, @@ -174,37 +232,50 @@ fn parse_resolvectl_output(s: &str) -> Vec { .collect() } -async fn run_daemon(_cli: Cli) -> Result<()> { - let sock_path = dirs::runtime_dir() - .context("Failed to get `runtime_dir`")? - .join("dev.firezone.client_ipc"); - ipc_listen(&sock_path).await +async fn run_debug_ipc_client(_cli: Cli) -> Result<()> { + tracing::info!(pid = std::process::id(), "run_debug_ipc_client"); + let stream = UnixStream::connect(SOCK_PATH) + .await + .with_context(|| format!("couldn't connect to UDS at {SOCK_PATH}"))?; + let mut stream = IpcStream::new(stream, LengthDelimitedCodec::new()); + + stream.send(serde_json::to_string("Hello")?.into()).await?; + Ok(()) } -async fn ipc_listen(sock_path: &Path) -> Result<()> { +async fn run_ipc_service(_cli: Cli) -> Result<()> { + tracing::info!("run_daemon"); + ipc_listen().await +} + +async fn ipc_listen() -> Result<()> { + // Find the `firezone` group + let fz_gid = nix::unistd::Group::from_name("firezone") + .context("can't get group by name")? + .context("firezone group must exist on the system")? + .gid; + // Remove the socket if a previous run left it there - tokio::fs::remove_file(sock_path).await.ok(); - let listener = UnixListener::bind(sock_path).unwrap(); + tokio::fs::remove_file(SOCK_PATH).await.ok(); + let listener = UnixListener::bind(SOCK_PATH).context("Couldn't bind UDS")?; + std::os::unix::fs::chown(SOCK_PATH, Some(ROOT_USER), Some(fz_gid.into())) + .context("can't set firezone as the group for the UDS")?; + sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?; loop { tracing::info!("Listening for GUI to connect over IPC..."); - let (stream, _) = listener.accept().await.unwrap(); - let cred = stream.peer_cred().unwrap(); + let (stream, _) = listener.accept().await?; + let cred = stream.peer_cred()?; tracing::info!( uid = cred.uid(), gid = cred.gid(), pid = cred.pid(), "Got an IPC connection" ); - // TODO: Check that the user is in the `firezone` group - // For now, to make it work well in CI where that group isn't created, - // just check if it matches our own UID. - let actual_peer_uid = cred.uid(); - let expected_peer_uid = nix::unistd::Uid::current().as_raw(); - if actual_peer_uid != expected_peer_uid { - tracing::warn!("Connection from un-authorized user, ignoring"); - continue; - } + + // I'm not sure if we can enforce group membership here - Docker + // might just be enforcing it with filesystem permissions. + // Checking the secondary groups of another user looks complicated. let stream = IpcStream::new(stream, LengthDelimitedCodec::new()); if let Err(error) = handle_ipc_client(stream).await { diff --git a/scripts/tests/lib.sh b/scripts/tests/lib.sh index 0c0f6dea9..008a1b173 100755 --- a/scripts/tests/lib.sh +++ b/scripts/tests/lib.sh @@ -62,3 +62,13 @@ function assert_process_state { assert_equals "$(process_state "$process_name")" "$expected_state" } + +function create_token_file { + CONFIG_DIR=/etc/dev.firezone.client + TOKEN_PATH="$CONFIG_DIR/token.txt" + + sudo mkdir "$CONFIG_DIR" + sudo touch "$TOKEN_PATH" + sudo chmod 600 "$TOKEN_PATH" + echo "n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE" | sudo tee "$TOKEN_PATH" > /dev/null +} diff --git a/scripts/tests/linux-group.sh b/scripts/tests/linux-group.sh index d41817a96..891c532c2 100755 --- a/scripts/tests/linux-group.sh +++ b/scripts/tests/linux-group.sh @@ -3,20 +3,32 @@ # The integration tests call this to test security for Linux IPC. # Only users in the `firezone` group should be able to control the privileged tunnel process. -set -euo pipefail +source "./scripts/tests/lib.sh" +BINARY_NAME=firezone-linux-client FZ_GROUP="firezone" +SERVICE_NAME=firezone-client +export RUST_LOG=info +# Copy the Linux Client out of its container +docker compose exec client cat firezone-linux-client > "$BINARY_NAME" +chmod u+x "$BINARY_NAME" +sudo mv "$BINARY_NAME" "/usr/bin/$BINARY_NAME" + +sudo cp "scripts/tests/systemd/$SERVICE_NAME.service" /usr/lib/systemd/system/ + +# The firezone group must exist before the daemon starts sudo groupadd "$FZ_GROUP" +sudo systemctl start "$SERVICE_NAME" -# Make sure we don't belong to the group yet -(groups | grep "$FZ_GROUP") && exit 1 - -# TODO: Expect Firezone to reject our commands here - +# Add ourselves to the firezone group sudo gpasswd --add "$USER" "$FZ_GROUP" -# Start a new login shell to update our groups, and check again -sudo su --login "$USER" --command groups | grep "$FZ_GROUP" +echo "# Expect Firezone to accept our commands if we run with 'su --login'" +sudo su --login "$USER" --command RUST_LOG="$RUST_LOG" "$BINARY_NAME" stub-ipc-client -# TODO: Expect Firezone to accept our commands if we run with `su --login` +echo "# Expect Firezone to reject our command if we run without 'su --login'" +"$BINARY_NAME" stub-ipc-client && exit 1 + +# Explicitly exiting is needed when we're intentionally having commands fail +exit 0 diff --git a/scripts/tests/systemd/dns-systemd-resolved.sh b/scripts/tests/systemd/dns-systemd-resolved.sh index 9bd7aab40..f9594c44a 100755 --- a/scripts/tests/systemd/dns-systemd-resolved.sh +++ b/scripts/tests/systemd/dns-systemd-resolved.sh @@ -1,24 +1,18 @@ #!/usr/bin/env bash # Test Linux DNS control using `systemd-resolved` directly inside the CI runner -set -euox pipefail +source "./scripts/tests/lib.sh" BINARY_NAME=firezone-linux-client -CONFIG_DIR=/etc/dev.firezone.client SERVICE_NAME=firezone-client -TOKEN_PATH="$CONFIG_DIR/token.txt" +# Copy the Linux Client out of its container docker compose exec client cat firezone-linux-client > "$BINARY_NAME" chmod u+x "$BINARY_NAME" sudo chown root:root "$BINARY_NAME" sudo mv "$BINARY_NAME" "/usr/bin/$BINARY_NAME" -# TODO: Check whether this is redundant with the systemd service file -sudo setcap cap_net_admin+eip "/usr/bin/$BINARY_NAME" -sudo mkdir "$CONFIG_DIR" -sudo touch "$TOKEN_PATH" -sudo chmod 600 "$TOKEN_PATH" -echo "n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE" | sudo tee "$TOKEN_PATH" > /dev/null +create_token_file sudo cp "scripts/tests/systemd/$SERVICE_NAME.service" /usr/lib/systemd/system/ diff --git a/scripts/tests/systemd/firezone-client.service b/scripts/tests/systemd/firezone-client.service index c8e3de23c..04f6a7cc8 100644 --- a/scripts/tests/systemd/firezone-client.service +++ b/scripts/tests/systemd/firezone-client.service @@ -37,6 +37,7 @@ Environment="FIREZONE_DNS_CONTROL=systemd-resolved" Environment="FIREZONE_ID=D0455FDE-8F65-4960-A778-B934E4E85A5F" Environment="RUST_LOG=info" +# TODO: Make subcommands explicit once PR #4628 merges ExecStart=firezone-linux-client Type=notify # Unfortunately we may need root to control DNS diff --git a/website/src/app/kb/user-guides/linux-client/readme.mdx b/website/src/app/kb/user-guides/linux-client/readme.mdx index aef48e2d9..b9dd71b7f 100644 --- a/website/src/app/kb/user-guides/linux-client/readme.mdx +++ b/website/src/app/kb/user-guides/linux-client/readme.mdx @@ -35,17 +35,18 @@ Once you have a token, you can start the Linux Client using the following command: ``` -sudo ./linux-client-x64 +sudo FIREZONE_TOKEN= ./linux-client-x64 ``` Set some environment variables to configure it: ``` -FIREZONE_NAME="Development Webserver" -FIREZONE_ID="some unique identifier" -DNS_CONTROL="systemd-resolved" # or "etc-resolv-conf" -LOG_DIR="./" -sudo -E ./linux-client-x64 +export FIREZONE_NAME="Development Webserver" +export FIREZONE_ID="some unique identifier" +export FIREZONE_TOKEN= +export DNS_CONTROL="systemd-resolved" # or "etc-resolv-conf" +export LOG_DIR="./" +sudo -E ./linux-client-x64 ``` See [below](#environment-variable-reference) for a full list of environment @@ -56,18 +57,25 @@ A sample output of the help command is shown below: ``` > sudo ./linux-client-x64 -h -Usage: linux-client-x64 [OPTIONS] --firezone-id [MAX_PARTITION_TIME] +Usage: linux-client-x64 [OPTIONS] [COMMAND] -Arguments: - Token generated by the portal to authorize websocket connection [env: FIREZONE_TOKEN=] - [MAX_PARTITION_TIME] Maximum length of time to retry connecting to the portal if we're having internet issues or it's down [env: MAX_PARTITION_TIME=] [default: 30d] +Commands: + standalone Act as a CLI-only Client + help Print this message or the help of the given subcommand(s) Options: - -n, --firezone-name Friendly name to display in the UI [env: FIREZONE_NAME=] - -i, --firezone-id Identifier generated by the portal to identify and display the device [env: FIREZONE_ID=] - -l, --log-dir File logging directory. Should be a path that's writeable by the current user [env: LOG_DIR=] - -h, --help Print help - -V, --version Print version + --token + Token generated by the portal to authorize websocket connection [env: FIREZONE_TOKEN=] + -i, --firezone-id + Identifier used by the portal to identify and display the device [env: FIREZONE_ID=] + -l, --log-dir + File logging directory. Should be a path that's writeable by the current user [env: LOG_DIR=] + -m, --max-partition-time + Maximum length of time to retry connecting to the portal if we're having internet issues or it's down. Accepts human times. e.g. "5m" or "1h" or "30d" [env: MAX_PARTITION_TIME=] + -h, --help + Print help + -V, --version + Print version ``` ### Split DNS