mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
chore(linux): only allow IPC connections from members of the firezone group (#4628)
```[tasklist] ### Before merging - [x] Update KB ``` Maybe not a feature since Linux IPC isn't available to users yet? I think it's okay if the new `linux-group` test fails in compatibility, since it wasn't implemented at all back then. Closes #4659 Closes #4660 --------- Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com> Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1949,6 +1949,7 @@ dependencies = [
|
||||
"humantime",
|
||||
"nix 0.28.0",
|
||||
"resolv-conf",
|
||||
"sd-notify",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -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<Option<SecretString>> {
|
||||
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<IpAddr> {
|
||||
.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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,17 +35,18 @@ Once you have a token, you can start the Linux Client using the following
|
||||
command:
|
||||
|
||||
```
|
||||
sudo ./linux-client-x64 <TOKEN>
|
||||
sudo FIREZONE_TOKEN=<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 <TOKEN>
|
||||
export FIREZONE_NAME="Development Webserver"
|
||||
export FIREZONE_ID="some unique identifier"
|
||||
export FIREZONE_TOKEN=<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 <FIREZONE_ID> <TOKEN> [MAX_PARTITION_TIME]
|
||||
Usage: linux-client-x64 [OPTIONS] [COMMAND]
|
||||
|
||||
Arguments:
|
||||
<TOKEN> 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 <FIREZONE_NAME> Friendly name to display in the UI [env: FIREZONE_NAME=]
|
||||
-i, --firezone-id <FIREZONE_ID> Identifier generated by the portal to identify and display the device [env: FIREZONE_ID=]
|
||||
-l, --log-dir <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>
|
||||
Token generated by the portal to authorize websocket connection [env: FIREZONE_TOKEN=]
|
||||
-i, --firezone-id <FIREZONE_ID>
|
||||
Identifier used by the portal to identify and display the device [env: FIREZONE_ID=]
|
||||
-l, --log-dir <LOG_DIR>
|
||||
File logging directory. Should be a path that's writeable by the current user [env: LOG_DIR=]
|
||||
-m, --max-partition-time <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
|
||||
|
||||
Reference in New Issue
Block a user