diff --git a/SECURITY.md b/SECURITY.md
index 0666fa02..0d19489b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -149,6 +149,17 @@ On Linux, clipboard requires access to Wayland sockets (`/run/user/UID/wayland-0
FreeBSD does not currently have sandboxing enabled. A full Capsicum sandbox using `cap_enter()` with `libcasper` for privileged process lookup is planned — see [ROADMAP.md](ROADMAP.md) for details.
+### Root Uid Drop
+
+Until Capsicum lands, the primary containment on FreeBSD is a root privilege drop: when started as root (e.g. `sudo rustnet`), the process drops to the invoking user (`SUDO_UID`/`SUDO_GID`) or `nobody` after the BPF capture devices are open, via `setresuid`/`setresgid`. Already-open capture and log/export descriptors keep working, and pre-created export files are handed over to the target user. Note that `doas` does not set `SUDO_UID`, so doas users get the `nobody` fallback.
+
+Trade-off: process attribution uses `sockstat`, which as a non-root user only sees the target user's sockets. Use `--no-uid-drop` if you need attribution for other users' processes.
+
+```
+--no-uid-drop Keep running as root instead of dropping to
+ SUDO_UID/SUDO_GID (or nobody) after initialization
+```
+
## Privilege Drop and Job Object Sandboxing (Windows)
On Windows, RustNet removes dangerous privileges from the process token and applies a Job Object to prevent child process creation after initialization.
diff --git a/SECURITY.zh-CN.md b/SECURITY.zh-CN.md
index 4718f52f..945ad230 100644
--- a/SECURITY.zh-CN.md
+++ b/SECURITY.zh-CN.md
@@ -146,6 +146,17 @@ fd 带内到达),但 lsof 回退路径(PKTAP 不可用时启用,例如
FreeBSD 当前未启用沙箱。计划使用 `cap_enter()` 配合 `libcasper` 实现完整的 Capsicum 沙箱,用于特权进程查找——详见 [ROADMAP.md](ROADMAP.md)。
+### root uid 降权
+
+在 Capsicum 落地之前,FreeBSD 上的主要隔离手段是 root 降权:以 root 启动时(如 `sudo rustnet`),BPF 捕获设备打开后,进程通过 `setresuid`/`setresgid` 降权到调用用户(`SUDO_UID`/`SUDO_GID`)或 `nobody`。已打开的捕获和日志/导出文件描述符继续有效,预创建的导出文件会移交给目标用户。注意 `doas` 不设置 `SUDO_UID`,因此 doas 用户会回退到 `nobody`。
+
+权衡:进程归属使用 `sockstat`,非 root 用户只能看到目标用户的 socket。如需归属其他用户的进程,请使用 `--no-uid-drop`。
+
+```
+--no-uid-drop 初始化后保持 root 运行,
+ 不降权到 SUDO_UID/SUDO_GID(或 nobody)
+```
+
## 权限剥离与 Job Object 沙箱(Windows)
在 Windows 上,RustNet 在初始化后从进程令牌中移除危险特权,并应用 Job Object 阻止子进程创建。
diff --git a/USAGE.md b/USAGE.md
index 2d39c187..9cc03070 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -104,7 +104,7 @@ Options:
--no-sandbox Disable Landlock sandboxing (Linux only)
--sandbox-strict Require full sandbox enforcement or exit (Linux only)
--no-uid-drop Keep running as root instead of dropping to
- SUDO_UID/SUDO_GID (or nobody) after initialization (Linux and macOS)
+ SUDO_UID/SUDO_GID (or nobody) after initialization (Linux, macOS, and FreeBSD)
-h, --help Print help
-V, --version Print version
```
diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md
index cf942232..5c3633fb 100644
--- a/USAGE.zh-CN.md
+++ b/USAGE.zh-CN.md
@@ -104,7 +104,7 @@ Options:
--no-sandbox 禁用 Landlock 沙箱(仅限 Linux)
--sandbox-strict 要求完整沙箱强制执行,否则退出(仅限 Linux)
--no-uid-drop 初始化后保持 root 运行,不降权到
- SUDO_UID/SUDO_GID(或 nobody)(仅限 Linux 和 macOS)
+ SUDO_UID/SUDO_GID(或 nobody)(仅限 Linux、macOS 和 FreeBSD)
-h, --help 打印帮助
-V, --version 打印版本
```
diff --git a/src/app.rs b/src/app.rs
index fabb86e1..58d24fa7 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -801,10 +801,15 @@ impl App {
/// parser threads in the worker phase is what places the untrusted-input DPI
/// code inside the sandbox — even when rustnet runs as root.
///
- /// Returns a receiver that signals when process detection initialization
- /// (including eBPF loading) is complete. The caller should wait on this
- /// before applying the sandbox so eBPF has finished loading.
- pub fn start(&mut self) -> Result> {
+ /// Returns two receivers that signal when privileged initialization is
+ /// complete: the first when process detection (including eBPF loading) is
+ /// ready, the second when the capture thread has opened the capture
+ /// device (or failed to). The caller should wait on both before applying
+ /// the sandbox or dropping root: the capture open runs on a background
+ /// thread and still needs the privileges.
+ pub fn start(
+ &mut self,
+ ) -> Result<(std::sync::mpsc::Receiver<()>, std::sync::mpsc::Receiver<()>)> {
info!("Starting network monitor application");
// Shared connection tracker (active + historic tables, RTT, QUIC)
@@ -812,7 +817,7 @@ impl App {
// Phase 1: privileged init. Start the capture pipeline (opens the raw
// socket and stashes the packet receiver for the worker phase).
- self.start_packet_capture_pipeline()?;
+ let capture_ready_rx = self.start_packet_capture_pipeline()?;
// Create channel to signal when process detection (incl. eBPF) is ready
let (process_ready_tx, process_ready_rx) = std::sync::mpsc::sync_channel(1);
@@ -820,7 +825,7 @@ impl App {
// Start process enrichment thread (but delay for PKTAP detection on macOS)
self.start_process_enrichment_conditional(tracker.clone(), process_ready_tx)?;
- Ok(process_ready_rx)
+ Ok((process_ready_rx, capture_ready_rx))
}
/// Start the worker threads: the DPI packet processors plus enrichment,
@@ -873,17 +878,20 @@ impl App {
/// capture thread (which opens the raw socket). The receiver is stashed in
/// `self.packet_rx` for [`App::start_packet_processors`], which runs in the
/// worker phase after the sandbox has been applied.
- fn start_packet_capture_pipeline(&mut self) -> Result<()> {
+ ///
+ /// Returns a receiver that fires once the capture thread has finished its
+ /// privileged setup (capture device opened, or setup failed).
+ fn start_packet_capture_pipeline(&mut self) -> Result> {
// Create packet channel — sender batches packets, receiver gets Vec per batch
let (packet_tx, packet_rx) = channel::bounded::>(MAX_PACKET_QUEUE);
// Start capture thread
- self.start_capture_thread(packet_tx)?;
+ let capture_ready_rx = self.start_capture_thread(packet_tx)?;
// Stash the receiver; the processor threads are spawned post-sandbox.
self.packet_rx = Some(packet_rx);
- Ok(())
+ Ok(capture_ready_rx)
}
/// Spawn the DPI packet-processor threads, draining the channel stashed by
@@ -910,8 +918,15 @@ impl App {
Ok(())
}
- /// Start packet capture thread
- fn start_capture_thread(&self, packet_tx: Sender>) -> Result<()> {
+ /// Start packet capture thread.
+ ///
+ /// Returns a receiver that fires once the capture device has been opened
+ /// (or the open failed). Callers use it to keep root privileges alive
+ /// until the open, which needs them, has actually happened.
+ fn start_capture_thread(
+ &self,
+ packet_tx: Sender>,
+ ) -> Result> {
// Validate interface exists before spawning thread (fail fast)
crate::network::capture::validate_interface(&self.config.interface)?;
@@ -930,6 +945,10 @@ impl App {
let pcap_export_file = self.config.pcap_export_file.clone();
capture_failed.store(false, Ordering::Relaxed);
+ // Fires once the privileged part of capture setup is done (device
+ // opened or open failed), so the main thread can drop privileges.
+ let (capture_ready_tx, capture_ready_rx) = std::sync::mpsc::sync_channel::<()>(1);
+
thread::Builder::new()
.name("pcap_tx".to_string())
.spawn(move || {
@@ -1008,6 +1027,10 @@ impl App {
None
};
+ // Privileged setup is complete; the main thread may now
+ // drop root / apply the sandbox.
+ let _ = capture_ready_tx.send(());
+
let mut reader = PacketReader::new(capture);
let mut packets_read = 0u64;
let mut last_log = Instant::now();
@@ -1134,6 +1157,7 @@ impl App {
}
Err(e) => {
capture_failed.store(true, Ordering::Relaxed);
+ let _ = capture_ready_tx.send(());
let error_msg = format!("{}", e);
// Check if this is a privilege error
@@ -1156,7 +1180,7 @@ impl App {
})
.expect("Failed to spawn pcap_tx thread");
- Ok(())
+ Ok(capture_ready_rx)
}
/// Start a packet processor thread
diff --git a/src/cli.rs b/src/cli.rs
index c4436e1d..6aaf0f52 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -211,5 +211,17 @@ pub fn build_cli() -> Command {
.action(clap::ArgAction::SetTrue),
);
+ #[cfg(target_os = "freebsd")]
+ let cmd = cmd.arg(
+ Arg::new("no-uid-drop")
+ .long("no-uid-drop")
+ .help(
+ "Keep running as root instead of dropping to SUDO_UID/SUDO_GID (or nobody) \
+ after initialization. Keeping root lets sockstat attribute other users' \
+ processes",
+ )
+ .action(clap::ArgAction::SetTrue),
+ );
+
cmd
}
diff --git a/src/main.rs b/src/main.rs
index 25c2fc00..d2f973a8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -134,13 +134,14 @@ fn main() -> Result<()> {
info!("Kubernetes attribution mode: {}", mode);
}
- // Resolve the identity to drop root to after privileged init (Linux and
- // macOS): the invoking sudo user, or nobody when started as plain root.
+ // Resolve the identity to drop root to after privileged init (Linux,
+ // macOS, and FreeBSD): the invoking sudo user, or nobody when started as
+ // plain root.
// Resolved before the export files are pre-created so they can be chowned
// to the target user: they are created root-owned 0600 and are reopened by
// path at runtime (libpcap savefile, sidecar JSONL appends), which would
// fail after the drop if they stayed owned by root.
- #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
let uid_drop_target = if matches.get_flag("no-uid-drop") {
info!("Root uid drop disabled by --no-uid-drop");
None
@@ -170,9 +171,9 @@ fn main() -> Result<()> {
let file = precreate_private_file(path).map_err(|e| {
anyhow::anyhow!("Failed to pre-create {} file '{}': {}", label, path, e)
})?;
- #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
chown_to_uid_drop_target(&file, uid_drop_target, label, path);
- #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
let _ = file;
}
}
@@ -182,7 +183,7 @@ fn main() -> Result<()> {
let file = precreate_private_file(pcapng_path).map_err(|e| {
anyhow::anyhow!("Failed to pre-create PCAPNG file '{}': {}", pcapng_path, e)
})?;
- #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
chown_to_uid_drop_target(&file, uid_drop_target, "PCAPNG", pcapng_path);
output_handles.pcapng_export = Some(file);
}
@@ -194,7 +195,7 @@ fn main() -> Result<()> {
// Create and start the application
let mut app = app::App::new_with_output_handles(config.clone(), output_handles)?;
- let process_ready_rx = app.start()?;
+ let (process_ready_rx, capture_ready_rx) = app.start()?;
info!("Application started");
// Wait for process detection (including eBPF loading) to complete before
@@ -211,6 +212,21 @@ fn main() -> Result<()> {
}
}
+ // Also wait for the capture thread to finish opening the capture device.
+ // The open runs on a background thread and needs the startup privileges;
+ // without this synchronization the uid drop (Linux/FreeBSD) or sandbox
+ // could win the race and the open would fail with EPERM, leaving the UI
+ // running with no traffic.
+ match capture_ready_rx.recv_timeout(std::time::Duration::from_secs(10)) {
+ Ok(()) => info!("Packet capture initialized, safe to apply sandbox"),
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
+ warn!("Timed out waiting for packet capture init, applying sandbox anyway");
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
+ warn!("Capture thread exited early, applying sandbox anyway");
+ }
+ }
+
// Apply Landlock sandbox (Linux only)
// This must be done AFTER process detection is initialized because:
// - eBPF programs need to be loaded first (requires CAP_BPF + CAP_PERFMON)
@@ -382,6 +398,22 @@ fn main() -> Result<()> {
#[cfg(all(target_os = "macos", not(feature = "macos-sandbox")))]
let _ = uid_dropped;
+ // Drop root privileges (FreeBSD only). Done after process detection init,
+ // when the BPF capture fds are open and nothing needs root anymore. There
+ // is no sandbox on FreeBSD yet (Capsicum is planned), so until then this
+ // is the primary containment.
+ #[cfg(target_os = "freebsd")]
+ if let Some(target) = uid_drop_target {
+ match network::platform::privdrop::drop_to(target) {
+ Ok(()) => info!(
+ "Dropped root privileges to uid {} gid {} (verified); sockstat process \
+ attribution is now limited to that user's processes",
+ target.uid, target.gid
+ ),
+ Err(e) => warn!("Failed to drop root uid/gid: {}", e),
+ }
+ }
+
// Apply Seatbelt sandbox (macOS only)
// This must be done AFTER app.start() because:
// - Packet capture handles need to be opened first (BPF/PKTAP fds survive the sandbox)
@@ -643,7 +675,7 @@ fn setup_logging(level: LevelFilter) -> Result<()> {
/// be reopened by path after root privileges are dropped. Best-effort: on
/// failure the drop still happens and the affected export degrades with a
/// runtime warning when the reopen fails.
-#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
fn chown_to_uid_drop_target(
file: &fs::File,
target: Option,
diff --git a/src/network/platform/freebsd/mod.rs b/src/network/platform/freebsd/mod.rs
index 382dd86f..6d23a7c1 100644
--- a/src/network/platform/freebsd/mod.rs
+++ b/src/network/platform/freebsd/mod.rs
@@ -2,5 +2,6 @@
// Process attribution (sockstat) lives in the rustnet-host crate.
mod interface_stats;
+pub mod privdrop;
pub use interface_stats::FreeBSDStatsProvider;
diff --git a/src/network/platform/freebsd/privdrop.rs b/src/network/platform/freebsd/privdrop.rs
new file mode 100644
index 00000000..1d75c6eb
--- /dev/null
+++ b/src/network/platform/freebsd/privdrop.rs
@@ -0,0 +1,186 @@
+//! Root privilege drop (setuid) for FreeBSD
+//!
+//! `sudo rustnet` keeps euid 0 for its whole lifetime, even though root is
+//! only needed during initialization to open the BPF capture devices. FreeBSD
+//! has no sandbox layer yet (a Capsicum sandbox is planned, see ROADMAP.md),
+//! so until then this drop is the primary containment: a compromise of the
+//! packet-parsing code no longer runs as root.
+//!
+//! The process drops to the invoking sudo user (`SUDO_UID`/`SUDO_GID`), or to
+//! `nobody` (65534) when started as plain root. `doas` does not set these
+//! variables, so doas users get the `nobody` fallback. Already-open file
+//! descriptors (capture devices, log and export files) remain valid across
+//! the drop.
+//!
+//! # Trust model
+//!
+//! `SUDO_UID`/`SUDO_GID` are environment variables and thus caller-controlled,
+//! but they can only ever select which unprivileged identity we become:
+//! uid 0 and unparseable values are rejected and fall back to `nobody`, so a
+//! forged value cannot retain or regain privilege.
+//!
+//! # Trade-offs
+//!
+//! Process attribution on FreeBSD execs `sockstat`, which as a non-root user
+//! only sees the target user's sockets, so attribution for other users'
+//! processes is lost after the drop. `--no-uid-drop` keeps the old keep-root
+//! behavior.
+
+use anyhow::{Result, anyhow};
+
+/// The uid/gid pair to drop to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DropTarget {
+ pub uid: libc::uid_t,
+ pub gid: libc::gid_t,
+}
+
+/// FreeBSD `nobody` user and group, both 65534. Used when rustnet runs as
+/// plain root (no sudo, or doas) or SUDO_UID/SUDO_GID are unusable.
+const NOBODY: DropTarget = DropTarget {
+ uid: 65534,
+ gid: 65534,
+};
+
+/// Resolve the identity to drop to, or `None` when not running as root
+/// (nothing to drop; the bpf-group path never has euid 0).
+pub fn resolve_drop_target() -> Option {
+ // SAFETY: geteuid() has no failure mode and takes no pointers.
+ if unsafe { libc::geteuid() } != 0 {
+ return None;
+ }
+ Some(target_from_env(
+ std::env::var("SUDO_UID").ok().as_deref(),
+ std::env::var("SUDO_GID").ok().as_deref(),
+ ))
+}
+
+/// Pick the target from SUDO_UID/SUDO_GID; both must parse to a nonzero id,
+/// otherwise fall back to `nobody`. Split out from `resolve_drop_target` for
+/// testability (no process-global env manipulation in tests).
+fn target_from_env(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> DropTarget {
+ let parse = |v: Option<&str>| v.and_then(|s| s.parse::().ok()).filter(|&id| id != 0);
+ match (parse(sudo_uid), parse(sudo_gid)) {
+ (Some(uid), Some(gid)) => DropTarget { uid, gid },
+ _ => NOBODY,
+ }
+}
+
+/// Change the file's owner to the drop target.
+///
+/// Used on pre-created export files (0600, root-owned) so they can still be
+/// reopened by name after the uid drop, e.g. libpcap's `pcap_dump_open` and
+/// the per-event sidecar JSONL appends. Operates on the already-open fd
+/// (`fchown`), never on the path, so it cannot be redirected by a
+/// symlink/rename swap after the O_NOFOLLOW create.
+pub fn chown_to_target(file: &std::fs::File, target: DropTarget) -> std::io::Result<()> {
+ use std::os::fd::AsRawFd;
+ // SAFETY: fchown on a valid owned fd; no pointers involved.
+ let rc = unsafe { libc::fchown(file.as_raw_fd(), target.uid, target.gid) };
+ if rc == 0 {
+ Ok(())
+ } else {
+ Err(std::io::Error::last_os_error())
+ }
+}
+
+/// Irreversibly drop root to `target`: supplementary groups first, then gid,
+/// then uid (all three of real/effective/saved so root cannot be regained).
+///
+/// Must be called while euid is 0. Process credentials are shared by all
+/// threads on FreeBSD, so threads spawned before the drop (capture,
+/// enrichment) are covered too.
+pub fn drop_to(target: DropTarget) -> Result<()> {
+ if target.uid == 0 || target.gid == 0 {
+ return Err(anyhow!("refusing to 'drop' to uid 0 / gid 0"));
+ }
+
+ // SAFETY: setgroups reads `ngroups` gid_t values from the pointer; we pass
+ // exactly one element and its valid address.
+ let gids = [target.gid];
+ if unsafe { libc::setgroups(1, gids.as_ptr()) } != 0 {
+ return Err(anyhow!(
+ "setgroups failed: {}",
+ std::io::Error::last_os_error()
+ ));
+ }
+
+ // gid before uid: once uid 0 is gone, setresgid would fail.
+ // SAFETY: setresgid/setresuid take plain integers.
+ if unsafe { libc::setresgid(target.gid, target.gid, target.gid) } != 0 {
+ return Err(anyhow!(
+ "setresgid({}) failed: {}",
+ target.gid,
+ std::io::Error::last_os_error()
+ ));
+ }
+ if unsafe { libc::setresuid(target.uid, target.uid, target.uid) } != 0 {
+ return Err(anyhow!(
+ "setresuid({}) failed: {}",
+ target.uid,
+ std::io::Error::last_os_error()
+ ));
+ }
+
+ // Verify the drop stuck and root cannot be regained.
+ let (mut ruid, mut euid, mut suid) = (0, 0, 0);
+ let (mut rgid, mut egid, mut sgid) = (0, 0, 0);
+ // SAFETY: getresuid/getresgid write to the three provided out-pointers.
+ unsafe {
+ libc::getresuid(&mut ruid, &mut euid, &mut suid);
+ libc::getresgid(&mut rgid, &mut egid, &mut sgid);
+ }
+ if [ruid, euid, suid] != [target.uid; 3] || [rgid, egid, sgid] != [target.gid; 3] {
+ return Err(anyhow!(
+ "uid/gid drop verification failed (uids {ruid}/{euid}/{suid}, gids {rgid}/{egid}/{sgid})"
+ ));
+ }
+ // SAFETY: setuid takes a plain integer. With ruid/euid/suid all nonzero
+ // this must fail; success means the drop is unsound.
+ if unsafe { libc::setuid(0) } == 0 {
+ return Err(anyhow!("uid drop verification failed: setuid(0) succeeded"));
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_target_from_env_uses_sudo_ids() {
+ assert_eq!(
+ target_from_env(Some("1001"), Some("1001")),
+ DropTarget {
+ uid: 1001,
+ gid: 1001
+ }
+ );
+ }
+
+ #[test]
+ fn test_target_from_env_rejects_root_and_garbage() {
+ // uid 0 must never be a drop target
+ assert_eq!(target_from_env(Some("0"), Some("0")), NOBODY);
+ // unparseable values fall back to nobody
+ assert_eq!(target_from_env(Some("abc"), Some("1001")), NOBODY);
+ assert_eq!(target_from_env(Some("-1"), Some("1001")), NOBODY);
+ // both must be present (doas sets neither)
+ assert_eq!(target_from_env(Some("1001"), None), NOBODY);
+ assert_eq!(target_from_env(None, None), NOBODY);
+ }
+
+ #[test]
+ fn test_drop_to_rejects_root_target() {
+ assert!(drop_to(DropTarget { uid: 0, gid: 0 }).is_err());
+ }
+
+ #[test]
+ fn test_resolve_drop_target_none_when_not_root() {
+ // Tests don't run as root in CI; as non-root there is nothing to drop.
+ if unsafe { libc::geteuid() } != 0 {
+ assert!(resolve_drop_target().is_none());
+ }
+ }
+}
diff --git a/src/network/platform/mod.rs b/src/network/platform/mod.rs
index 53f9b7cf..55509247 100644
--- a/src/network/platform/mod.rs
+++ b/src/network/platform/mod.rs
@@ -29,6 +29,10 @@ mod windows;
// Re-export interface-stats providers and the sandbox entry points.
#[cfg(target_os = "freebsd")]
pub use freebsd::FreeBSDStatsProvider;
+// FreeBSD has no sandbox module yet (Capsicum planned); the uid drop is the
+// containment layer in the meantime.
+#[cfg(target_os = "freebsd")]
+pub use freebsd::privdrop;
#[cfg(target_os = "linux")]
pub use linux::LinuxStatsProvider;
// Not gated on the `landlock` feature: the sandbox module always compiles on
diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs
index 2e1e310c..3e0e2d31 100644
--- a/src/ui/terminal.rs
+++ b/src/ui/terminal.rs
@@ -20,9 +20,6 @@ pub fn setup_terminal(backend: B) -> Result::Error: Send + Sync + 'static,
{
- let mut terminal = RatatuiTerminal::new(backend)?;
- terminal.clear()?;
- terminal.hide_cursor()?;
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
@@ -30,6 +27,15 @@ where
crossterm::event::EnableMouseCapture
)?;
install_panic_hook();
+ let mut terminal = RatatuiTerminal::new(backend)?;
+ // Clear via the backend, not `Terminal::clear()`: since ratatui 0.30 the
+ // latter queries the cursor position (ESC[6n) and errors out after a 2s
+ // timeout on terminals that never reply (e.g. the FreeBSD vt console),
+ // aborting startup. The backend clear is a plain clear-screen write. It
+ // also runs after entering the alternate screen, so the user's primary
+ // screen is left untouched.
+ terminal.backend_mut().clear()?;
+ terminal.hide_cursor()?;
Ok(terminal)
}