From 29b99f0cf6e4a745b7ba8b23c38ce4eda58539cd Mon Sep 17 00:00:00 2001 From: mrigbozurike Date: Mon, 5 Oct 2026 08:09:51 +0100 Subject: [PATCH] serve: drop to [serve].user after bind when started with root privileges Signed-off-by: mrigbozurike --- CHANGELOG.md | 12 ++ SECURITY.md | 18 ++- docs/cli.md | 1 + docs/configuration.md | 1 + docs/setup-linux.md | 82 ++++++++++++ src/config.rs | 5 + src/logging.rs | 161 ++++++++++++++++++++++++ src/main.rs | 46 ++++--- src/privdrop.rs | 283 ++++++++++++++++++++++++++++++++++++------ src/server/mod.rs | 60 +++++++-- 10 files changed, 599 insertions(+), 70 deletions(-) create mode 100644 src/logging.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b5ca1..488388e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ builds the GitHub release body from the matching `## ` section. ## Unreleased +### Added +- **`[serve].user` / `--user`: drop privileges after binding.** When rdc is started with root + privileges and a target account is configured, it binds the port, switches user + (`initgroups`/`setgid`/`setuid`, then verifies that regaining root fails) and only then opens + the audit log — in that account's state directory, derived from its passwd entry rather than + from `HOME`, which the process never modifies — and serves. Any `--log-file` is opened after + the drop too, as that account, so root never creates a file in a directory the account + controls or follows a symlink left there. uid 0 and gid 0 targets are refused, and so is a + start whose root privileges came from a setuid bit rather than from being root. Unix only; a + separate account can only see the desktop on X11, which the Linux setup guide explains, and + that path is untested on real hardware. + ## 0.5.0 — 2026-10-07 ### Changed diff --git a/SECURITY.md b/SECURITY.md index 35f5dae..e2842bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,11 +33,19 @@ TLS: the tailnet's WireGuard layer provides encryption and the identity. - The config file is the allowlist. On Unix the daemon refuses to start if `config.toml` or its directory is owned by someone else or writable by group/others, since editing it is equivalent to desktop access; `RDC_INSECURE_CONFIG=1` overrides with a warning. -- The daemon does not run with root privileges: started with a real or effective uid of 0 (a - system unit, `sudo`, a setuid binary), `rdc serve` exits. It never needs them (the port is - unprivileged, everything else happens inside the desktop session), and a remote-control surface - should not hold them. On Windows, `rdc service install` creates the logon task at standard - integrity unless `--elevated` is passed. +- The daemon does not serve with root privileges. Started with a real or effective uid of 0 (a + system unit, `sudo`, a setuid binary), `rdc serve` exits — unless it was started as real root + *and* `[serve].user` names an ordinary account to drop to, in which case it binds the port, + replaces its supplementary groups and its gid and uid with that account's, verifies that + regaining root now fails, and only then opens the audit log, any `--log-file`, and accepts + requests. Privileges that arrived from a setuid bit never qualify: the caller would be + choosing the account, and through `--log-file` the file to create. A failed drop is fatal. It never needs + root anyway (the port is unprivileged, everything else happens inside the desktop session), + and a remote-control surface should not hold it. The config file is read before the drop, from + the *starting* user's directory, so under `sudo` the allowlist is root-owned and the dropped + account cannot edit it; see [Running as a dedicated user](docs/setup-linux.md#running-as-a-dedicated-user). + On Windows, `rdc service install` creates the logon task at standard integrity unless + `--elevated` is passed. - `rdc serve` installs no service: only `rdc service install` creates a unit, LaunchAgent or scheduled task, and `rdc service uninstall` removes it. `rdc serve` does write the audit log (and any `--log-file`) under your user's state directory; nothing else outlives the process. diff --git a/docs/cli.md b/docs/cli.md index 9695567..d157f2d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,6 +22,7 @@ empty allowlist. | `--port N` | listen port | | `--bind IP` | listen address (must be a Tailscale address) | | `--dev-loopback` | bind 127.0.0.1 and skip authentication for loopback. **Testing only.** | +| `--user NAME` | Unix: if started with root privileges, drop to this account after binding (default `[serve].user`). rdc refuses to serve as root otherwise. See [Running as a dedicated user](setup-linux.md#running-as-a-dedicated-user). | ### `rdc service install [--elevated] | uninstall | status` diff --git a/docs/configuration.md b/docs/configuration.md index 6f58e3a..920467e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,7 @@ url = "http://100.64.10.20:7770" | `grant` | `[]` | `[[serve.grant]]` blocks, same shape as the table form of `allow` | | `audit` | enabled | Audit log settings, see below | | `hosts` | `[]` | Extra accepted `Host` header names; the node's own IPs, MagicDNS name and hostname are always accepted | +| `user` | unset | Unix: account to drop to right after binding, when the daemon was started with root privileges. Without it `rdc serve` refuses to run with them. Set `[serve.audit].path` too, or the log lands somewhere `rdc audit` will not look. See [Running as a dedicated user](setup-linux.md#running-as-a-dedicated-user). | Command-line equivalents: `rdc serve --port 7771 --bind 100.x.y.z --allow a@b --allow tag:ops=view`. `--allow` flags are **added** to the config grants; `who=cap,cap` limits capabilities, a bare diff --git a/docs/setup-linux.md b/docs/setup-linux.md index 0407399..a64e8e1 100644 --- a/docs/setup-linux.md +++ b/docs/setup-linux.md @@ -119,6 +119,88 @@ something the sandbox blocks (the journal will show `EPERM` or a mount error), o `systemctl --user edit dev.rdc.daemon` rather than editing the generated files, so the change survives the next `rdc service install`. +## Running as a dedicated user + +`rdc serve` refuses to run with root privileges. If you need to start it from a root context +anyway — a system unit, a provisioning script — `[serve].user` (or `--user`) names an account to +become instead. The start has to be a real root start: root privileges that came from a setuid +bit are refused outright, because then the person who ran rdc, not the administrator, would be +choosing which account it becomes. The daemon binds the listener, switches to that account with +`initgroups`/`setgid`/`setuid`, checks that regaining root is now impossible, and only then +opens the audit log and accepts the first request. A failed drop is fatal; it never falls back +to serving as root. + +### The config file stays owned by root + +This is the part that makes the model worth having. rdc reads its config from the config +directory of the user it is *started* as, so under `sudo` that is `/root/.config/rdc/config.toml`, +and it refuses to start from a config file that anyone but that user can write (see +[File permissions](configuration.md#file-permissions)). Keep the file root-owned: the account +the daemon drops to then **cannot edit its own allowlist**, which is exactly what you want from +an account whose whole job is to be remotely driven. + +Two consequences follow, and both have bitten people: + +- Do **not** use `sudo -E`. It keeps your `HOME`, so rdc reads *your* config file, sees it is + owned by uid 1000 while the process is uid 0, and refuses to start. +- Set `[serve.audit].path` explicitly. The daemon writes the log as the dropped account, so by + default it lands in that account's state directory — while `rdc audit`, run by you, looks in + *yours* and finds nothing. Naming the path in the root-owned config makes `sudo rdc audit` + read the same setting and find the file. Otherwise you need `rdc audit --path …` every time. + +### Whether the account can see anything + +The drop is only half the story; the display server decides the rest. + +| Session | Can a separate account capture and drive it? | +|---|---| +| X11 | Yes, given `DISPLAY` and permission to the X server (`xhost +SI:localuser:rdc`, or an `XAUTHORITY` file that account may read). This is the setup where a dedicated user is meaningful. | +| Wayland | No. The compositor's socket in `$XDG_RUNTIME_DIR` belongs to the logged-in user and the portal talks only to that session. Run rdc as the desktop user; the root refusal still protects you. | +| macOS | No (see [macOS setup](setup-macos.md)). Same advice as Wayland. | + +### Worked example (X11) + +```sh +# 1. The account. No login, no password, its own group. +sudo useradd --system --create-home --shell /usr/sbin/nologin rdc + +# 2. Somewhere it can write the audit log. +sudo install -d -o rdc -g rdc -m 750 /var/log/rdc + +# 3. Root's config: the allowlist the rdc account cannot edit. +sudo install -d -m 700 /root/.config/rdc +sudo tee /root/.config/rdc/config.toml >/dev/null <<'TOML' +[serve] +allow = ["you@example.com"] +user = "rdc" + +[serve.audit] +path = "/var/log/rdc/audit.jsonl" +TOML +sudo chmod 600 /root/.config/rdc/config.toml + +# 4. From the desktop session, let that account reach the X server. +# Once per session; add it to your session startup to make it stick. +xhost +SI:localuser:rdc + +# 5. Start it. Plain sudo, not sudo -E, and pass the display explicitly. +sudo DISPLAY=:0 rdc serve + +# 6. Read the log as root, which reads the same config and so the same path. +sudo rdc audit -n 20 +``` + +`rdc doctor` shows the process user, and the daemon logs the drop (`dropped privileges to rdc +(uid …, gid …)`) as its first action after binding. + +A `--log-file`, if you use one, is opened *after* the drop, by the account that will own it. +Opening it as root would create a root-owned file in a directory that account controls — or +follow a symlink it had left there — so until the drop happens those lines go to stderr. If the +target account cannot write the path, rdc says so and exits rather than carrying on silently. + +This whole path has been exercised as root on a headless Linux box, but not yet on a real X11 +desktop; please report what you find. + ## Persistence is opt-in Nothing rdc does installs itself anywhere. `rdc serve` runs until you stop it and leaves no unit, diff --git a/src/config.rs b/src/config.rs index 9479438..0174585 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,10 @@ pub struct ServeConfig { pub hosts: Vec, #[serde(default)] pub audit: AuditConfig, + /// Unix: the account to drop to right after binding, when the daemon was started with root + /// privileges. Without it, `rdc serve` refuses to run with them at all. + #[serde(default)] + pub user: Option, } impl Default for ServeConfig { @@ -53,6 +57,7 @@ impl Default for ServeConfig { grant: vec![], hosts: vec![], audit: AuditConfig::default(), + user: None, } } } diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..ec654d7 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,161 @@ +//! Where log lines go, and — when privileges are about to be dropped — when the file is opened. +//! +//! `--log-file` is normally opened at startup, which is fine when rdc runs as the user that +//! will keep running it. It is not fine when `rdc serve` is about to drop privileges: the +//! documented setup has the *target* account owning the log directory, so a root process +//! opening a path there creates a root-owned file, and follows any symlink that account has +//! put in its place. Either way root has touched a file chosen by a less privileged account. +//! +//! So when a drop is coming, the path is remembered and nothing is opened. Lines written in the +//! meantime go to stderr, and [`open_deferred`] opens the file once the process is the target +//! account — at which point the open is subject to that account's own permissions, and a +//! symlink to somewhere it cannot write simply fails. + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +enum Target { + Stderr, + File(std::fs::File), +} + +/// The process's log destination, swappable once. +pub struct Sink { + target: Mutex, + /// `Some` while an open is still owed, i.e. the drop has not happened yet. + pending: Mutex>, +} + +static SINK: OnceLock<&'static Sink> = OnceLock::new(); + +/// Open with the same restrictions used everywhere rdc creates a file: owner-only, append. +fn open_private(path: &Path) -> io::Result { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(path) +} + +impl Sink { + /// Open the file now. + pub fn open(path: &Path) -> io::Result { + Ok(Self { target: Mutex::new(Target::File(open_private(path)?)), pending: Mutex::new(None) }) + } + + /// Log to stderr for now, and open `path` later, from [`open_deferred`]. + pub fn deferred(path: &Path) -> Self { + Self { target: Mutex::new(Target::Stderr), pending: Mutex::new(Some(path.to_path_buf())) } + } + + /// Open a deferred path, if one is still owed. Returns what was opened. + pub fn open_deferred(&self) -> io::Result> { + let Some(path) = self.pending.lock().unwrap().take() else { + return Ok(None); + }; + let file = open_private(&path)?; + *self.target.lock().unwrap() = Target::File(file); + Ok(Some(path)) + } +} + +impl Write for &Sink { + fn write(&mut self, buf: &[u8]) -> io::Result { + match &mut *self.target.lock().unwrap() { + Target::Stderr => io::stderr().write(buf), + Target::File(f) => f.write(buf), + } + } + fn flush(&mut self) -> io::Result<()> { + match &mut *self.target.lock().unwrap() { + Target::Stderr => io::stderr().flush(), + Target::File(f) => f.flush(), + } + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for &'static Sink { + type Writer = &'static Sink; + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +/// Make `sink` the process's log destination. Called once, before the subscriber is built. +pub fn install(sink: Sink) -> &'static Sink { + let leaked: &'static Sink = Box::leak(Box::new(sink)); + let _ = SINK.set(leaked); + leaked +} + +/// Open the deferred log file, now that privileges have been dropped. `Ok(None)` when there was +/// nothing to open, which is the usual case. +pub fn open_deferred() -> io::Result> { + match SINK.get() { + Some(s) => s.open_deferred(), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("rdc-logging-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn a_deferred_log_file_is_not_opened_until_privileges_are_dropped() { + let dir = tmp("deferred"); + let path = dir.join("serve.log"); + let sink = Sink::deferred(&path); + + writeln!(&sink, "while still root").unwrap(); + assert!(!path.exists(), "the file must not be created before the drop"); + + assert_eq!(sink.open_deferred().unwrap().as_deref(), Some(path.as_path())); + writeln!(&sink, "after the drop").unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.contains("after the drop"), "{body:?}"); + assert!(!body.contains("while still root"), "early lines belong on stderr, not in the file"); + // Opening again is a no-op: the debt is settled. + assert_eq!(sink.open_deferred().unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_undeferred_log_file_is_opened_straight_away() { + let dir = tmp("immediate"); + let path = dir.join("serve.log"); + let sink = Sink::open(&path).unwrap(); + assert!(path.exists()); + writeln!(&sink, "first line").unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("first line")); + assert_eq!(sink.open_deferred().unwrap(), None, "nothing was deferred"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn the_log_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("mode"); + let path = dir.join("serve.log"); + let sink = Sink::deferred(&path); + sink.open_deferred().unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/main.rs b/src/main.rs index ebcea1c..e1fa11f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod config; mod desktop; mod doctor; mod keys; +mod logging; mod mcp; mod permissions; mod privdrop; @@ -57,6 +58,10 @@ enum Cmd { /// Bind 127.0.0.1 and skip authentication for loopback. Testing only. #[arg(long)] dev_loopback: bool, + /// Unix: if started with root privileges, drop to this account right after binding the + /// port (default `[serve].user`). rdc refuses to serve as root without it. + #[arg(long)] + user: Option, }, /// Collect audit entries streamed from `rdc serve` instances and show them live in a browser. AuditView { @@ -207,21 +212,17 @@ async fn main() -> Result<()> { let to_file = cli.log_file.is_some(); match &cli.log_file { Some(p) => { - if let Some(dir) = p.parent() { - std::fs::create_dir_all(dir).ok(); - } - let mut opts = std::fs::OpenOptions::new(); - opts.create(true).append(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let file = opts.open(p).with_context(|| format!("opening log file {}", p.display()))?; + // When `serve` is going to drop privileges, the file is opened after the drop, by + // the account that will own it; see `logging`. + let sink = if serve_will_drop_privileges(&cli.cmd) { + logging::Sink::deferred(p) + } else { + logging::Sink::open(p).with_context(|| format!("opening log file {}", p.display()))? + }; tracing_subscriber::fmt() .with_env_filter(filter) .with_ansi(false) - .with_writer(std::sync::Mutex::new(file)) + .with_writer(logging::install(sink)) .init(); } None => tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init(), @@ -236,14 +237,29 @@ async fn main() -> Result<()> { result } +/// Will this command hand the process to another account? Decided before logging is set up, +/// because it decides when the log file may be opened. +fn serve_will_drop_privileges(cmd: &Cmd) -> bool { + match cmd { + // A config error is not reported here; `run` loads the config properly and will fail + // with a good message. All that matters now is whether a drop is possible. + Cmd::Serve { user, .. } => user.is_some() || config::load().ok().and_then(|c| c.serve.user).is_some(), + _ => false, + } +} + async fn run(cli: Cli) -> Result<()> { #[cfg(target_os = "windows")] desktop::local::set_dpi_aware(); let cfg = config::load()?; + let log_file = cli.log_file.clone(); match cli.cmd { - Cmd::Serve { bind, port, allow, audit_stream, dev_loopback } => { - privdrop::refuse_root()?; + Cmd::Serve { bind, port, allow, audit_stream, dev_loopback, user } => { + let user = user.or_else(|| cfg.serve.user.clone()); + if user.is_none() { + privdrop::refuse_root()?; + } config::enforce_permissions()?; let desktop: Arc = Arc::new(LocalDesktop::new()?); let ts = tailscale::Tailscale::detect(); @@ -266,6 +282,8 @@ async fn run(cli: Cli) -> Result<()> { audit, hosts: cfg.serve.hosts.clone(), dev_loopback, + user, + log_file: log_file.clone(), }, ) .await diff --git a/src/privdrop.rs b/src/privdrop.rs index 266335d..6960978 100644 --- a/src/privdrop.rs +++ b/src/privdrop.rs @@ -1,18 +1,25 @@ -//! Refuse to serve a desktop with root privileges. +//! Refuse to serve a desktop with root privileges, or drop to a dedicated account first. //! //! rdc never needs root: the port is unprivileged and everything else it does happens inside a //! desktop session. If someone starts it as root anyway (a system unit, `sudo` out of habit, a -//! binary that someone made setuid), the remote-control surface would run with full privileges -//! for no benefit, so the daemon exits instead. +//! binary that someone made setuid), the choice is: exit, or become the account named by +//! `[serve].user` for the rest of the process's life. //! //! Both the real and the effective uid matter. A setuid-root binary launched by an ordinary //! user has `uid != 0` but `euid == 0` and can do everything root can; the reverse (root that //! has dropped only its effective uid) can regain it with `seteuid`. Either one being 0 counts. //! +//! [`plan`] decides and touches nothing, so every case is unit-tested without root; [`apply`] +//! does the irreversible part. Neither writes to the environment: mutating `HOME` in a process +//! that has already started its async runtime is undefined behaviour in Rust, and the variable +//! would be wrong anyway (it still describes whoever invoked us). Paths that depend on the +//! account come from its passwd entry instead — see [`Account::state_dir`]. +//! //! On Windows there is no uid; the scheduled task's run level is the equivalent control //! (`rdc service install`, see docs/setup-windows.md). use anyhow::Result; +use std::path::PathBuf; /// Who this process is, as far as this module cares. The ids are `None` on platforms that do /// not have them, which is why [`Current::is_root`] is false there rather than guessing. @@ -42,22 +49,101 @@ impl Current { } } -/// Decide, without touching the process. Pure so every case is testable. -pub fn check(current: &Current) -> Result<()> { - if current.is_root() { - anyhow::bail!( +/// A resolved passwd entry for the account to drop to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Account { + pub name: String, + pub uid: u32, + pub gid: u32, + pub home: PathBuf, +} + +impl Account { + /// Where this account's rdc state (the audit log) belongs, computed from its home directory + /// rather than from `HOME`/`XDG_STATE_HOME`, which still describe the invoking user. + pub fn state_dir(&self) -> PathBuf { + if cfg!(target_os = "macos") { + self.home.join("Library/Application Support/rdc") + } else { + self.home.join(".local/state/rdc") + } + } +} + +/// What to do before accepting the first request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Plan { + /// Already an ordinary user; nothing to do. + Keep, + /// Switch to this account (only ever chosen when we have root privileges). + DropTo(Account), +} + +/// Decide, without touching the process. `target` is `[serve].user` / `--user`; `lookup` +/// resolves a name to a passwd entry, injected so the whole table is testable. +pub fn plan_with(current: &Current, target: Option<&str>, lookup: impl Fn(&str) -> Result) -> Result { + let target = target.map(str::trim).filter(|t| !t.is_empty()); + // No uids on this platform: there is nothing to drop from or to. + if current.uid.is_none() && current.euid.is_none() { + return match target { + None => Ok(Plan::Keep), + Some(t) => anyhow::bail!( + "[serve].user = {t:?} is not supported on this platform: dropping privileges needs POSIX user ids. \ + On Windows, control the daemon's privileges with the scheduled task's run level instead — \ + `rdc service install` registers it at standard integrity (see docs/setup-windows.md)." + ), + }; + } + match (current.is_root(), target) { + (false, None) => Ok(Plan::Keep), + (false, Some(t)) if t == current.name => Ok(Plan::Keep), + (false, Some(t)) => anyhow::bail!( + "[serve].user = {t:?} but rdc is running as {} and cannot switch users without root privileges; \ + start it as {t} directly, or remove the setting", + current.name + ), + (true, None) => anyhow::bail!( "rdc serve refuses to run with root privileges ({}): it does not need them, and a remote-control \ - surface should not hold them. Run it as the desktop user, for example with `rdc service install` \ - from that account.", + surface should not hold them. Run it as the desktop user, or set [serve].user (or --user) to the \ + account it should drop to after binding the port.", current.describe() - ); + ), + (true, Some(t)) => { + // Only a start that was *already* root may choose the account. Root privileges + // that arrived from a setuid bit belong to whoever ran the binary, and that person + // also controls `--user`, `--log-file` and their own config file — so honouring the + // setting here would let any local user become any account of their choosing. + if current.uid != Some(0) { + anyhow::bail!( + "rdc has root privileges it was not started with ({}), which means a setuid binary. \ + [serve].user is refused in that case, because whoever started rdc would be choosing \ + the account to become. Remove the setuid bit (`chmod u-s`) and start rdc as root, or \ + run it as the desktop user.", + current.describe() + ); + } + let acct = lookup(t)?; + if acct.uid == 0 || acct.gid == 0 { + anyhow::bail!( + "[serve].user = {t:?} resolves to uid {} gid {}; rdc refuses to serve a desktop with root \ + privileges, so the target account must be an ordinary one", + acct.uid, + acct.gid + ); + } + Ok(Plan::DropTo(acct)) + } } - Ok(()) } -/// Exit early if this process has root privileges. +/// [`plan_with`] using the real passwd database. +pub fn plan(current: &Current, target: Option<&str>) -> Result { + plan_with(current, target, lookup) +} + +/// Exit early if this process has root privileges, where no drop target can apply. pub fn refuse_root() -> Result<()> { - check(¤t()) + plan_with(¤t(), None, lookup).map(|_| ()) } #[cfg(unix)] @@ -74,77 +160,192 @@ pub fn current() -> Current { Current { uid: None, euid: None, name: std::env::var("USERNAME").unwrap_or_else(|_| "unknown".into()) } } +#[cfg(unix)] +pub fn lookup(name: &str) -> Result { + unix::lookup(name) +} + +#[cfg(not(unix))] +pub fn lookup(name: &str) -> Result { + anyhow::bail!("cannot look up user {name:?}: this platform has no passwd database") +} + +/// Carry out `plan`. After `DropTo` returns, the process is that account irrevocably: real, +/// effective and saved ids are set and supplementary groups replaced. The environment is left +/// alone; callers derive account-specific paths from the [`Account`]. +pub fn apply(p: &Plan) -> Result<()> { + match p { + Plan::Keep => Ok(()), + #[cfg(unix)] + Plan::DropTo(acct) => unix::drop_to(acct), + #[cfg(not(unix))] + Plan::DropTo(acct) => anyhow::bail!("dropping to {} is not supported on this platform", acct.name), + } +} + #[cfg(unix)] mod unix { - use std::ffi::CStr; + use super::*; + use anyhow::Context; + use std::ffi::{CStr, CString}; + + pub fn lookup(name: &str) -> Result { + let cname = CString::new(name).context("user name contains a NUL byte")?; + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0u8; 16 * 1024]; + let mut out: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: getpwnam_r writes only into `pwd` and `buf`, both sized and owned here; + // `out` is left NULL when there is no such user, which we check before reading `pwd`. + let rc = unsafe { + libc::getpwnam_r(cname.as_ptr(), &mut pwd, buf.as_mut_ptr() as *mut libc::c_char, buf.len(), &mut out) + }; + if rc != 0 { + anyhow::bail!("getpwnam({name}): {}", std::io::Error::from_raw_os_error(rc)); + } + if out.is_null() { + anyhow::bail!("no such user: {name}"); + } + // A passwd entry may legitimately carry a NULL home directory (some NSS backends do); + // treat it as "no home" rather than dereferencing it. + let home = if pwd.pw_dir.is_null() { + PathBuf::new() + } else { + // SAFETY: pw_dir points into `buf`, which outlives this read, and is NUL-terminated. + PathBuf::from(unsafe { CStr::from_ptr(pwd.pw_dir) }.to_string_lossy().into_owned()) + }; + Ok(Account { name: name.to_string(), uid: pwd.pw_uid, gid: pwd.pw_gid, home }) + } pub fn name_of(uid: libc::uid_t) -> Option { let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; let mut buf = vec![0u8; 16 * 1024]; let mut out: *mut libc::passwd = std::ptr::null_mut(); - // SAFETY: getpwuid_r writes only into `pwd` and `buf`, both sized and owned here; - // `out` is left NULL when there is no such user, which we check before dereferencing. + // SAFETY: as in `lookup`. let rc = unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr() as *mut libc::c_char, buf.len(), &mut out) }; if rc != 0 || out.is_null() || pwd.pw_name.is_null() { return None; } - // SAFETY: pw_name points into `buf`, which outlives this read, and is NUL-terminated. + // SAFETY: pw_name points into `buf` and is NUL-terminated. Some(unsafe { CStr::from_ptr(pwd.pw_name) }.to_string_lossy().into_owned()) } + + fn errno(what: &str) -> anyhow::Error { + anyhow::anyhow!("{what}: {}", std::io::Error::last_os_error()) + } + + pub fn drop_to(acct: &Account) -> Result<()> { + if acct.uid == 0 || acct.gid == 0 { + anyhow::bail!("{} is uid {} gid {}; refusing", acct.name, acct.uid, acct.gid); + } + let cuser = CString::new(acct.name.as_str())?; + // Order matters: supplementary groups while still root, then gid, then uid last. + // glibc and libSystem apply the id change to every thread of the process, so this is + // safe after the async runtime has started its workers. + // SAFETY: plain libc calls with values from a passwd entry we just resolved. + unsafe { + if libc::initgroups(cuser.as_ptr(), acct.gid as _) != 0 { + return Err(errno("initgroups")); + } + if libc::setgid(acct.gid) != 0 { + return Err(errno("setgid")); + } + if libc::setuid(acct.uid) != 0 { + return Err(errno("setuid")); + } + // Prove it stuck: regaining root must now be impossible. + if libc::setuid(0) == 0 || libc::geteuid() != acct.uid || libc::getegid() != acct.gid { + anyhow::bail!("privilege drop to {} did not take; refusing to continue", acct.name); + } + } + tracing::info!("dropped privileges to {} (uid {}, gid {})", acct.name, acct.uid, acct.gid); + Ok(()) + } } #[cfg(test)] mod tests { use super::*; - fn who(uid: Option, euid: Option) -> Current { - Current { uid, euid, name: "alice".into() } + fn me() -> Current { + Current { uid: Some(1000), euid: Some(1000), name: "alice".into() } + } + fn root() -> Current { + Current { uid: Some(0), euid: Some(0), name: "root".into() } + } + fn no_uids() -> Current { + Current { uid: None, euid: None, name: "USER".into() } + } + fn fake(name: &str) -> Result { + Ok(match name { + "rdc" => Account { name: "rdc".into(), uid: 999, gid: 999, home: "/home/rdc".into() }, + "root" => Account { name: "root".into(), uid: 0, gid: 0, home: "/root".into() }, + "wheelie" => Account { name: "wheelie".into(), uid: 999, gid: 0, home: "/home/wheelie".into() }, + _ => anyhow::bail!("no such user: {name}"), + }) } #[test] - fn ordinary_user_passes() { - assert!(check(&who(Some(1000), Some(1000))).is_ok()); + fn ordinary_user_keeps_going() { + assert_eq!(plan_with(&me(), None, fake).unwrap(), Plan::Keep); + assert_eq!(plan_with(&me(), Some("alice"), fake).unwrap(), Plan::Keep); + assert_eq!(plan_with(&me(), Some(" "), fake).unwrap(), Plan::Keep); } #[test] - fn real_root_is_refused() { - let e = check(&who(Some(0), Some(0))).unwrap_err().to_string(); + fn ordinary_user_cannot_switch() { + let e = plan_with(&me(), Some("rdc"), fake).unwrap_err().to_string(); + assert!(e.contains("cannot switch users without root"), "{e}"); + } + + #[test] + fn root_must_name_a_target() { + let e = plan_with(&root(), None, fake).unwrap_err().to_string(); assert!(e.contains("refuses to run with root privileges"), "{e}"); + assert!(matches!(plan_with(&root(), Some("rdc"), fake).unwrap(), Plan::DropTo(a) if a.uid == 999)); } #[test] - fn setuid_root_is_refused() { - // A setuid-root binary run by an ordinary user: real uid 1000, effective uid 0. - let c = who(Some(1000), Some(0)); + fn setuid_root_may_not_drop() { + // Real uid 1000, effective 0: a setuid-root copy run by an ordinary user. That user + // controls --user, --log-file and their own config, so honouring --user would let them + // become any account (and, through --log-file, own any file). Only a real root start + // may drop; setuid-root is refused like any other root. + let c = Current { uid: Some(1000), euid: Some(0), name: "alice".into() }; assert!(c.is_root()); - assert!(check(&c).is_err()); - assert_eq!(c.describe(), "alice (uid 1000, euid 0)"); + assert!(plan_with(&c, None, fake).is_err()); + assert!(plan_with(&c, Some("rdc"), fake).is_err(), "setuid-root must not be able to pick an account"); } #[test] - fn root_with_dropped_euid_is_refused() { - // Real uid 0 keeps the right to seteuid(0) back, so this is still root. - let c = who(Some(0), Some(1000)); - assert!(c.is_root()); - assert!(check(&c).is_err()); + fn a_privileged_target_is_refused() { + for (who, why) in [("root", "uid 0"), ("wheelie", "gid 0")] { + let e = plan_with(&root(), Some(who), fake).unwrap_err().to_string(); + assert!(e.contains("uid 0") || e.contains("gid 0"), "{why}: {e}"); + } + assert!(plan_with(&root(), Some("nobody-here"), fake).is_err()); } #[test] - fn platform_without_uids_is_not_root_and_prints_only_a_name() { - let c = who(None, None); - assert!(!c.is_root()); - assert!(check(&c).is_ok()); - assert_eq!(c.describe(), "alice"); + fn platforms_without_uids_say_so() { + assert_eq!(plan_with(&no_uids(), None, fake).unwrap(), Plan::Keep); + let e = plan_with(&no_uids(), Some("rdc"), fake).unwrap_err().to_string(); + assert!(e.contains("not supported on this platform"), "{e}"); + assert!(!e.contains("without root"), "the Unix wording would be misleading here: {e}"); } #[test] - fn describe_collapses_equal_ids() { - assert_eq!(who(Some(1000), Some(1000)).describe(), "alice (uid 1000)"); + fn state_dir_follows_the_target_home_not_the_environment() { + let a = Account { name: "rdc".into(), uid: 999, gid: 999, home: "/home/rdc".into() }; + let d = a.state_dir(); + assert!(d.starts_with("/home/rdc"), "{d:?}"); + assert!(d.ends_with("rdc")); } #[cfg(unix)] #[test] - fn resolves_uid_zero_to_root() { + fn lookup_resolves_root_and_rejects_unknown() { + assert_eq!(unix::lookup("root").unwrap().uid, 0); + assert!(unix::lookup("rdc-no-such-user-xyz").is_err()); assert_eq!(unix::name_of(0).as_deref(), Some("root")); } } diff --git a/src/server/mod.rs b/src/server/mod.rs index ca722a9..240ce28 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -89,9 +89,31 @@ pub struct ServeOpts { /// Extra `Host` names to accept besides this node's own addresses and names. pub hosts: Vec, pub dev_loopback: bool, + /// See [`crate::privdrop`]: the account to become after binding, if we have root privileges. + pub user: Option, + /// `--log-file`, if any. Opened before the drop, so its ownership has to be handed over. + pub log_file: Option, } pub async fn serve(desktop: Arc, ts: Tailscale, opts: ServeOpts) -> Result<()> { + // Decide before anything else is set up, so a misconfiguration fails immediately; the drop + // itself happens the moment the listener exists. + let drop = crate::privdrop::plan(&crate::privdrop::current(), opts.user.as_deref())?; + // Account-specific paths come from the passwd entry, not from HOME, which still describes + // whoever invoked us (and must not be rewritten in a running async process). + let mut audit_cfg = opts.audit.clone(); + if let crate::privdrop::Plan::DropTo(acct) = &drop { + tracing::warn!("started with root privileges; will drop to {} after binding", acct.name); + if audit_cfg.path.is_none() { + if acct.home.as_os_str().is_empty() { + anyhow::bail!( + "{} has no home directory, so there is nowhere to put the audit log after dropping to it; set [serve.audit].path to a directory that account can write", + acct.name + ); + } + audit_cfg.path = Some(acct.state_dir().join("audit.jsonl")); + } + } // On macOS this pops the Screen Recording / Accessibility prompts on the console the first // time; screenshots show only the wallpaper until the user grants and we are restarted. for (name, granted) in crate::permissions::request() { @@ -110,15 +132,6 @@ pub async fn serve(desktop: Arc, ts: Tailscale, opts: ServeOpts) -> for g in &opts.grants { tracing::info!("allow {}", g.describe()); } - let mut audit = audit::Audit::open(&opts.audit, &listen.node).context("opening the audit log")?; - match audit.path() { - Some(p) => tracing::info!("audit log: {}", p.display()), - None => tracing::warn!("audit log disabled by config"), - } - if let Some(url) = &opts.audit.stream { - audit.stream_to(url, opts.audit.stream_token.as_deref()).context("audit stream")?; - tracing::info!("audit stream: {url}"); - } let addr = listen.addr; let auth = auth::Auth::new( Arc::new(ts), @@ -129,10 +142,37 @@ pub async fn serve(desktop: Arc, ts: Tailscale, opts: ServeOpts) -> if opts.dev_loopback { tracing::warn!("--dev-loopback: requests from 127.0.0.1 are NOT authenticated"); } + let listener = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("bind {addr}"))?; + + // Bound. Nothing from here on needs root, so shed it before the first request and before + // any file is created with the wrong owner. + crate::privdrop::apply(&drop).context("dropping privileges")?; + + // Only now open `--log-file`, as the account that will own it. Opening it as root would + // create a root-owned file in a directory that account controls — or follow a symlink it + // left there. + if let Some(p) = crate::logging::open_deferred().with_context(|| { + format!( + "opening the log file after dropping privileges (the target account needs to be able to write {})", + opts.log_file.as_deref().unwrap_or(std::path::Path::new("it")).display() + ) + })? { + tracing::info!("log file: {}", p.display()); + } + + let mut audit = audit::Audit::open(&audit_cfg, &listen.node) + .with_context(|| format!("opening the audit log at {}", audit_cfg.resolved_path().display()))?; + match audit.path() { + Some(p) => tracing::info!("audit log: {}", p.display()), + None => tracing::warn!("audit log disabled by config"), + } + if let Some(url) = &audit_cfg.stream { + audit.stream_to(url, audit_cfg.stream_token.as_deref()).context("audit stream")?; + tracing::info!("audit stream: {url}"); + } let audit = Arc::new(audit); let state = AppState { desktop, auth: Arc::new(auth), audit: audit.clone() }; let app = routes::router(state); - let listener = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("bind {addr}"))?; tracing::info!("rdc serving on http://{addr}"); axum::serve(listener, app.into_make_service_with_connect_info::()) .with_graceful_shutdown(async {