Skip to content
Merged
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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ chrono = { version = "0.4", features = ["clock"] }
# OS error codes used in the accept-loop backoff to distinguish EMFILE/ENFILE
# (resource exhaustion → log error) from transient errors (log debug).
libc = "0.2"
# Vendor OpenSSL source so the binary builds without system libssl-dev headers
# on Linux. native-tls (pulled transitively through arti-client → tor-rtcompat)
# links against OpenSSL on Linux; without this feature flag the build fails on
# any machine that lacks the -dev package. macOS and Windows are unaffected
# (they use Security.framework and SChannel respectively), but the `vendored`
# feature is a no-op on those targets so there is no downside to enabling it
# unconditionally. Build-time cost is ~60 s on first compile; subsequent
# incremental builds are fast because the OpenSSL objects are cached.
openssl = { version = "0.10", features = ["vendored"] }

[dev-dependencies]
tempfile = "3"
Expand Down
21 changes: 19 additions & 2 deletions src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@ fn validate(cfg: &Config) -> Result<()> {
// level: LogLevel — invalid levels are already rejected by serde at parse time (4.2).

// [site]
if cfg.site.index_file.contains(std::path::MAIN_SEPARATOR) {
// `index_file` must be a bare filename, not a path.
// Use Path::components() rather than checking for MAIN_SEPARATOR:
// on Windows both `/` and `\` are valid separators, so a string-contains
// check on `\` alone misses "sub/index.html" written with forward slashes.
if std::path::Path::new(&cfg.site.index_file)
.components()
.count()
> 1
{
errors.push("[site] index_file must be a filename only, not a path".into());
}
{
Expand All @@ -49,7 +57,16 @@ fn validate(cfg: &Config) -> Result<()> {
{
errors.push("[site] directory must not contain '..' components".into());
}
if cfg.site.directory.contains(std::path::MAIN_SEPARATOR) {
// Count only Normal components so this check is independent from the
// is_absolute() guard above (a RootDir component would double-trigger).
// As with index_file, Path::components() handles both `/` and `\` on
// Windows, making the check correct on all platforms.
if dir_path
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count()
> 1
{
errors.push("[site] directory must be a directory name only, not a path".into());
}
}
Expand Down
11 changes: 10 additions & 1 deletion src/console/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,16 @@ pub fn render_dashboard(state: &AppState, requests: u64, errors: u64, config: &C
|| match &state.tor_status {
TorStatus::Disabled => dim("(disabled)"),
TorStatus::Starting => dim("(bootstrapping…)"),
TorStatus::Ready => dim("(reading…)"),
// fix 3.11 — this branch is unreachable in practice because
// set_onion() sets Ready and Some(addr) atomically. If it fires,
// an invariant has been violated; the honest label is "unavailable".
TorStatus::Ready => {
debug_assert!(
false,
"TorStatus::Ready with no onion_address — invariant violated"
);
dim("(address unavailable)")
}
TorStatus::Failed(_) => dim("(unavailable)"),
},
|addr| format!("http://{addr}"),
Expand Down
22 changes: 22 additions & 0 deletions src/console/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ pub fn start(
metrics: SharedMetrics,
mut shutdown: watch::Receiver<bool>,
) -> Result<tokio::sync::mpsc::UnboundedReceiver<KeyEvent>> {
// On Windows, the console host must have VT (Virtual Terminal) escape-
// sequence processing enabled before we write any ANSI colour codes.
// Windows Terminal and modern ConHost (Win 10 1903+) enable it
// automatically, but older ConHost versions (Windows Server 2016/2019 with
// default settings) do not. Without this, colour escape sequences appear
// as literal characters (e.g. "^[[32m") rather than being interpreted.
//
// Failure is non-fatal: the terminal is still functional, just monochrome.
// We warn so the operator knows why colours are missing rather than
// silently degrading.
#[cfg(windows)]
if let Err(e) = execute!(
stdout(),
crossterm::terminal::EnableVirtualTerminalProcessing
) {
log::warn!(
"Could not enable Windows VT processing: {e}. \
ANSI colours may not render correctly. \
Upgrade to Windows Terminal or Windows 10 1903+ for full colour support."
);
}

// 4.1 — map crossterm io errors to AppError::Console.
terminal::enable_raw_mode()
.map_err(|e| AppError::Console(format!("Failed to enable raw mode: {e}")))?;
Expand Down
4 changes: 0 additions & 4 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ pub enum AppError {
#[error("Server startup error: {0}")]
ServerStartup(String),

/// An error originating in the Tor / Arti subsystem.
#[error("Tor error: {0}")]
Tor(String),

/// Console / terminal I/O error (crossterm or raw-mode operations).
#[error("Console error: {0}")]
Console(String),
Expand Down
105 changes: 100 additions & 5 deletions src/runtime/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,16 +192,23 @@ async fn normal_run(data_dir: PathBuf, settings_path: &Path) -> Result<()> {
};

// 8. Start Tor (if enabled).
// tor::init() spawns a Tokio task and returns immediately.
// tor::init() spawns a Tokio task and returns its JoinHandle.
// fix 3.1 — we store the handle and await it during shutdown so active
// Tor circuits get a chance to close cleanly (max 5 s).
// fix 3.6 — pass config.server.bind so the local proxy connect uses the
// correct loopback address (e.g. ::1 on IPv6-only machines).
// 2.10 — pass shutdown_rx so Tor's stream loop exits on clean shutdown.
if config.tor.enabled {
tor::init(
let tor_handle = if config.tor.enabled {
Some(tor::init(
data_dir.clone(),
bind_port,
config.server.bind,
Arc::clone(&state),
shutdown_rx.clone(),
);
}
))
} else {
None
};

// 9. Start console UI.
let key_rx = start_console(&config, &state, &metrics, shutdown_rx.clone()).await?;
Expand All @@ -223,6 +230,13 @@ async fn normal_run(data_dir: PathBuf, settings_path: &Path) -> Result<()> {
// 2.10 — wait for the HTTP server to drain in-flight connections (max 5 s).
let _ = tokio::time::timeout(Duration::from_secs(5), server_handle).await;

// fix 3.1 — await the Tor task so active circuits can send a clean
// RELAY_END cell before the runtime tears down. The shutdown watch
// channel was already signalled above; this just drains the task.
if let Some(handle) = tor_handle {
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}

log::info!("RustHost shut down cleanly.");
logging::flush();
console::cleanup();
Expand Down Expand Up @@ -273,6 +287,58 @@ async fn start_console(
}
}

// ─── SIGTERM helper ───────────────────────────────────────────────────────────
//
// `tokio::select!` does not support `#[cfg(...)]` on individual arms; the macro
// expands its arms textually before cfg evaluation, so a guarded arm produces a
// parse error. The solution is a cross-platform helper with identical call-site
// syntax on every target:
//
// • Unix — registers a SIGTERM handler and awaits the first delivery.
// • non-Unix — awaits `std::future::pending()`, which never resolves.
//
// Both variants share the name `next_sigterm()` and return `()`, so a single
// unconditional `select!` arm covers all platforms. The caller pins the
// returned future outside its loop so the Unix `Signal` handle (and its OS-level
// signal pipe) is created exactly once for the lifetime of the event loop.
//
// Failure to register the Unix handler (e.g. signal limit reached) is logged as
// a warning and the function falls back to `pending()` — the process remains
// functional, just without SIGTERM-triggered graceful shutdown.

/// On Unix, resolve once when `SIGTERM` is delivered; fall back to pending
/// forever if the signal stream cannot be registered.
///
/// See the module-level comment above for the cross-platform design rationale.
#[cfg(unix)]
async fn next_sigterm() {
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut stream) => {
// recv() returns Option<()>; None means the stream was dropped,
// which cannot happen here. Either way we return so the select!
// arm fires and the graceful shutdown path runs.
stream.recv().await;
}
Err(e) => {
log::warn!(
"Could not register SIGTERM handler: {e}. \
Send Ctrl-C or use --signal-file to stop the process."
);
std::future::pending::<()>().await;
}
}
}

/// On non-Unix platforms, pend forever so the `select!` arm is always
/// present in the source but never fires.
///
/// See the module-level comment above for the cross-platform design rationale.
#[cfg(not(unix))]
async fn next_sigterm() {
std::future::pending::<()>().await
}

async fn event_loop(
key_rx: Option<mpsc::UnboundedReceiver<events::KeyEvent>>,
config: &Arc<Config>,
Expand All @@ -288,6 +354,25 @@ async fn event_loop(
let ctrl_c = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c);

// SIGTERM handling — cross-platform design note
// ─────────────────────────────────────────────
// `tokio::select!` is a declarative macro that expands its arms textually;
// it does not honour `#[cfg(...)]` attributes placed on individual arms.
// Putting `#[cfg(unix)] _ = sigterm.recv() => { … }` inside the macro
// causes a parse error ("no rules expected `}`") on every platform.
//
// Solution: a platform-unified helper function `next_sigterm()` with
// identical call-site syntax on all targets:
// • Unix — awaits the next SIGTERM delivery from the OS.
// • non-Unix — awaits `std::future::pending()` (never resolves).
// Both branches return `()` so `select!` sees one unconditional arm.
//
// The future is pinned here, outside the loop, so the Unix `Signal` handle
// (and its internal OS registration) is created exactly once and reused
// across every `select!` iteration — same pattern as `ctrl_c` above.
let sigterm = next_sigterm();
tokio::pin!(sigterm);

loop {
// Build a future that yields the next key, or pends forever once the
// channel closes (avoids repeated None-match after input task death).
Expand Down Expand Up @@ -326,6 +411,16 @@ async fn event_loop(
}
break;
}
// Graceful shutdown on SIGTERM.
// On Unix this arm fires when the OS delivers SIGTERM, covering
// `systemctl stop`, `docker stop`, launchd unload, and any process
// supervisor that sends SIGTERM before SIGKILL.
// On non-Unix platforms `next_sigterm()` pends forever, so this
// arm is syntactically present but never selected.
() = &mut sigterm => {
log::info!("SIGTERM received — shutting down gracefully.");
break;
}
}
}
Ok(())
Expand Down
10 changes: 9 additions & 1 deletion src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ pub mod state;
pub fn open_browser(url: &str) {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(url).spawn();
// `explorer.exe <url>` is unreliable — on some Windows configurations it
// opens File Explorer instead of the default browser. `cmd /c start`
// delegates to the Windows shell association table, which always picks the
// correct handler. The empty-string third argument is required to prevent
// `start` from treating the URL (which may contain special chars) as the
// window title.
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer").arg(url).spawn();
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
21 changes: 14 additions & 7 deletions src/server/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,20 @@ pub async fn handle(
}
Resolved::Redirect(location) => {
let body = format!("Redirecting to {location}");
let mut hdr = String::new();
let _ = std::fmt::write(
&mut hdr,
format_args!(
"HTTP/1.1 301 Moved Permanently\r\n Location: {location}\r\n Content-Type: text/plain\r\n Content-Length: {len}\r\n Connection: close\r\n \r\n",
len = body.len()
),
// The original format_args! string used source indentation that
// injected leading spaces after each \r\n, producing
// "folded header" lines (RFC 7230 §3.2.6 deprecated,
// RFC 9112 §5.1 forbidden). Strict HTTP clients reject them.
// Use a raw string with explicit \r\n\ continuations so the
// indentation is NOT part of the emitted bytes.
let body_len = body.len();
let hdr = format!(
"HTTP/1.1 301 Moved Permanently\r\n\
Location: {location}\r\n\
Content-Type: text/plain\r\n\
Content-Length: {body_len}\r\n\
Connection: close\r\n\
\r\n"
);
stream.write_all(hdr.as_bytes()).await?;
if !is_head {
Expand Down
24 changes: 17 additions & 7 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,21 +231,31 @@ fn bind_with_fallback(addr: IpAddr, port: u16, fallback: bool) -> Result<(TcpLis
})
}

/// Return `true` when `e` represents file-descriptor exhaustion (`EMFILE` or
/// `ENFILE`) on Unix platforms.
/// Return `true` when `e` represents file-descriptor exhaustion.
///
/// On non-Unix targets (Windows) where these error codes have no equivalent,
/// always returns `false`.
/// On Unix this matches `EMFILE` (24, per-process FD limit) and `ENFILE`
/// (23, system-wide FD limit), both specified by POSIX and identical on
/// Linux, macOS, FreeBSD, and other POSIX-conformant systems.
///
/// On Windows this matches `WSAEMFILE` (10024), the Winsock equivalent of
/// `EMFILE` — it fires when the per-process socket descriptor table is full.
///
/// On all other targets the function always returns `false`.
fn is_fd_exhaustion(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
// EMFILE (24): too many open files for the process.
// ENFILE (23): too many open files system-wide.
// Both values are specified by POSIX and identical on Linux, macOS,
// FreeBSD, and other POSIX-conformant systems.
matches!(e.raw_os_error(), Some(libc::EMFILE | libc::ENFILE))
}
#[cfg(not(unix))]
#[cfg(windows)]
{
// WSAEMFILE (10024): per-process socket handle limit reached.
// This is the Windows Sockets equivalent of POSIX EMFILE and fires
// when the process has exhausted its socket descriptor table.
matches!(e.raw_os_error(), Some(10_024))
}
#[cfg(not(any(unix, windows)))]
{
let _ = e;
false
Expand Down
Loading
Loading