Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ builds the GitHub release body from the matching `## <version>` 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
Expand Down
18 changes: 13 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions docs/setup-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub struct ServeConfig {
pub hosts: Vec<String>,
#[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<String>,
}

impl Default for ServeConfig {
Expand All @@ -53,6 +57,7 @@ impl Default for ServeConfig {
grant: vec![],
hosts: vec![],
audit: AuditConfig::default(),
user: None,
}
}
}
Expand Down
161 changes: 161 additions & 0 deletions src/logging.rs
Original file line number Diff line number Diff line change
@@ -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<Target>,
/// `Some` while an open is still owed, i.e. the drop has not happened yet.
pending: Mutex<Option<PathBuf>>,
}

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<std::fs::File> {
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<Self> {
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<Option<PathBuf>> {
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<usize> {
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<Option<PathBuf>> {
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);
}
}
Loading
Loading