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
694 changes: 274 additions & 420 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "rusthost"
version = "1.3.1"
edition = "2021"
# Bumped from 1.86 → 1.90 to match the highest MSRV in the transitive dep tree:
# arti-client 0.40 and most tor-* crates require 1.89
# arti-client 0.41 and most tor-* crates require 1.90
# typed-index-collections 3.5 (via tor-netdir) requires 1.90
rust-version = "1.90"
description = "Single-binary, zero-setup static site hosting appliance with Tor support"
Expand Down Expand Up @@ -62,13 +62,13 @@ tokio = { version = "1", features = [

# default-features = false removes the implicit "native-tls" default that
# would otherwise pull in openssl-sys and break cross-compilation.
arti-client = { version = "0.40", default-features = false, features = [
arti-client = { version = "0.41", default-features = false, features = [
"tokio",
"rustls",
"onion-service-service",
] }
tor-hsservice = { version = "0.40", default-features = false }
tor-cell = { version = "0.40", default-features = false }
tor-hsservice = { version = "0.41", default-features = false }
tor-cell = { version = "0.41", default-features = false }
futures = "0.3"

# Terminal auto-launcher (Phase 0): TTY detection uses std::io::IsTerminal
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ RustHost is intentionally a **static public content server**. It does not provid
- Per-IP and global connection limits
- Optional HTTP to HTTPS redirect server
- Custom `404` and `503` pages
- Strict Clippy and test coverage in the repo itself
- Strict Clippy and test coverage in the repo itself, including a real HTTP stress suite for mixed static assets

## Project Scope

Expand Down Expand Up @@ -212,6 +212,11 @@ cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
```

The integration test suite includes `tests/html_stress.rs`, which serves the
fixture tree under `tests/fixtures/html_stress/` through the real server and
checks bursty keep-alive traffic, concurrent clients, range requests, directory
listings, percent-encoded paths, and mixed HTML/CSS/JS/SVG assets.

`unsafe` Rust is forbidden in this project.

## Documentation
Expand Down
36 changes: 18 additions & 18 deletions docs/arti-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ spawning the system C Tor binary as a subprocess to running Arti, the
official Tor Project Rust implementation, fully in-process. Use this as a
step-by-step reference for applying the same migration to other projects.

> **Tested against:** `arti-client 0.40`, `tor-hsservice 0.40`, `tor-cell 0.40`
> on Rust 1.86 (macOS arm64 + x86, Linux x86_64).
> **Tested against:** `arti-client 0.41.0`, `tor-hsservice 0.41.0`, `tor-cell 0.41.0`
> on Rust 1.90 (macOS arm64 + x86, Linux x86_64).

---

Expand Down Expand Up @@ -52,15 +52,15 @@ Project and versioned together.

### MSRV

Arti 0.40+ requires Rust **1.86**. If your project targets 1.85 or lower,
bump it:
Arti 0.41+ requires Rust **1.90** in this codebase. If your project targets
1.89 or lower, bump it:

```toml
# Before
rust-version = "1.85"
rust-version = "1.89"

# After
rust-version = "1.86"
rust-version = "1.90"
```

### Add dependencies
Expand All @@ -70,27 +70,27 @@ Add these six entries to `[dependencies]`:
```toml
# arti-client: high-level Tor client. Features needed for onion service hosting:
# tokio — Tokio async runtime backend (required)
# native-tls — TLS for connecting to Tor relays (required)
# rustls — TLS for connecting to Tor relays (required)
# onion-service-service — enables *hosting* onion services
arti-client = { version = "0.40", features = [
arti-client = { version = "0.41", features = [
"tokio",
"native-tls",
"rustls",
"onion-service-service",
] }

# tor-hsservice: lower-level onion service types used directly:
# OnionServiceConfigBuilder, handle_rend_requests, HsId, StreamRequest
tor-hsservice = { version = "0.40" }
tor-hsservice = { version = "0.41" }

# tor-cell: needed to construct the Connected message passed to
# StreamRequest::accept(Connected) — see the stream proxying section
tor-cell = { version = "0.40" }
tor-cell = { version = "0.41" }

# futures: StreamExt::next() for iterating the stream of incoming connections
futures = "0.3"

# sha3 + data-encoding: used to encode HsId → "${base32}.onion" manually.
# HsId does not implement std::fmt::Display in arti-client 0.40 — see the
# HsId does not implement std::fmt::Display in arti-client 0.41 — see the
# "Getting the onion address" section for the full explanation.
sha3 = "0.10"
data-encoding = "2"
Expand Down Expand Up @@ -139,7 +139,7 @@ use tor_hsservice::{config::OnionServiceConfigBuilder, handle_rend_requests, HsI

Notes on the import changes:
- `TorClientConfig` is **not** imported — it is not used directly. The builder is accessed via `TorClientConfigBuilder` instead (see the config section below).
- `HsId` is imported from `tor_hsservice`, **not** from `arti_client`. In arti 0.40, the re-export in `arti_client` is gated behind `feature = "onion-service-client"` and `feature = "experimental-api"` — neither of which is enabled in this setup. `tor_hsservice::HsId` is the ungated path.
- `HsId` is imported from `tor_hsservice`, **not** from `arti_client`. In arti 0.41, the re-export in `arti_client` is gated behind `feature = "onion-service-client"` and `feature = "experimental-api"` — neither of which is enabled in this setup. `tor_hsservice::HsId` is the ungated path.

---

Expand Down Expand Up @@ -299,7 +299,7 @@ is no subprocess stderr to collect and no child process to kill on panic.
**Pitfall 1:** `onion_name()` is deprecated. Use `onion_address()` instead.

**Pitfall 2:** `HsId` does **not** implement `std::fmt::Display` in
`arti-client 0.40`. Neither `format!("{}", hsid)` nor `.to_string()` compile,
`arti-client 0.41`. Neither `format!("{}", hsid)` nor `.to_string()` compile,
regardless of whether you got the `HsId` from `onion_name()` or
`onion_address()`:

Expand Down Expand Up @@ -330,7 +330,7 @@ Import `HsId` from `tor_hsservice` instead — it is ungated there.
// Wrong — deprecated
let onion_name = onion_service.onion_name()?.to_string(); // ← deprecated + E0599

// Wrong — onion_address() returns HsId, which still has no Display in 0.40
// Wrong — onion_address() returns HsId, which still has no Display in 0.41
let onion_name = format!(
"{}",
onion_service.onion_address().ok_or("...")? // ← E0599
Expand Down Expand Up @@ -378,7 +378,7 @@ fn hsid_to_onion_address(hsid: HsId) -> String {
```

This implements the [v3 onion address spec](https://spec.torproject.org/rend-spec/overview.html)
directly. `HsId: AsRef<[u8; 32]>` is stable across arti 0.40+, so it will
directly. `HsId: AsRef<[u8; 32]>` is stable across arti 0.41+, so it will
keep working regardless of whether `Display` is ever added to `HsId`.

The address is available immediately when `launch_onion_service` returns —
Expand Down Expand Up @@ -641,7 +641,7 @@ async fn set_onion(state: &SharedState, addr: String) {

| File | Change |
|---|---|
| `Cargo.toml` | `rust-version` 1.85 → 1.86; add `arti-client`, `tor-hsservice`, `tor-cell`, `futures`, `sha3`, `data-encoding` |
| `Cargo.toml` | `rust-version` 1.89 → 1.90; add `arti-client`, `tor-hsservice`, `tor-cell`, `futures`, `sha3`, `data-encoding` |
| `src/tor/mod.rs` | Complete rewrite (see above) |
| `src/tor/torrc.rs` | Delete |
| `src/config/defaults.rs` | Update `[tor]` comment block |
Expand All @@ -664,7 +664,7 @@ exact fix for each one.
| `E0277: CfgPath: From<PathBuf>` | Used `.storage().cache_dir(PathBuf)` | Use `TorClientConfigBuilder::from_directories(state, cache)` |
| `E0599: no method named 'from_directories'` | Called `TorClientConfig::builder().from_directories(…)` as a method chain | `from_directories` is an associated function — call it as `TorClientConfigBuilder::from_directories(…).build()?` directly |
| `deprecated: onion_name` | Called `.onion_name()` | Use `.onion_address()` instead |
| `E0599: HsId doesn't implement Display` | Called `format!("{}", hsid)` or `.to_string()` on `HsId` | `HsId` has no `Display` in arti 0.40. Add `sha3 = "0.10"` and `data-encoding = "2"` deps and encode the address manually with `hsid_to_onion_address(hsid)` using `HsId: AsRef<[u8; 32]>` |
| `E0599: HsId doesn't implement Display` | Called `format!("{}", hsid)` or `.to_string()` on `HsId` | `HsId` has no `Display` in arti 0.41. Add `sha3 = "0.10"` and `data-encoding = "2"` deps and encode the address manually with `hsid_to_onion_address(hsid)` using `HsId: AsRef<[u8; 32]>` |
| `E0425: cannot find type 'HsId' in crate 'arti_client'` | Wrote `arti_client::HsId` in function signature | `HsId` is feature-gated in `arti_client`. Import it from `tor_hsservice::HsId` instead — it is ungated there |
| `E0061: accept() takes 1 argument` | Called `stream_req.accept()` with no args | Use `stream_req.accept(Connected::new_empty())` |
| `warn(unused_imports): sync::Arc` | Pre-existing unused import unmasked | Remove `Arc` from the import line |
Expand Down
16 changes: 10 additions & 6 deletions src/runtime/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,18 @@ async fn one_shot_serve(dir: PathBuf, port: u16, tor_enabled: bool, headless: bo
};
use std::num::NonZeroU16;

let dir_str = dir.to_string_lossy().into_owned();
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
// Use "." when the served path has no leaf name (for example `/`), so the
// resulting `data_dir.join(site.directory)` still resolves back to `dir`.
let site_dir = canonical_dir
.file_name()
.and_then(|name| name.to_str())
.map_or_else(|| ".".to_owned(), str::to_owned);

// Use the parent of `dir` as the data_dir so relative paths stay sane.
let data_dir = dir
.canonicalize()
.unwrap_or_else(|_| dir.clone())
let data_dir = canonical_dir
.parent()
.map_or_else(|| dir.clone(), Path::to_path_buf);
.map_or_else(|| canonical_dir.clone(), Path::to_path_buf);

let config = Arc::new(crate::config::Config {
server: ServerConfig {
Expand All @@ -141,7 +145,7 @@ async fn one_shot_serve(dir: PathBuf, port: u16, tor_enabled: bool, headless: bo
trusted_proxies: None,
},
site: SiteConfig {
directory: dir_str,
directory: site_dir,
index_file: "index.html".into(),
enable_directory_listing: true,
expose_dotfiles: false,
Expand Down
40 changes: 32 additions & 8 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use std::{
time::Duration,
};
use tokio::{
io::AsyncWriteExt as _,
net::TcpListener,
sync::{oneshot, watch, Semaphore},
task::JoinSet,
Expand Down Expand Up @@ -104,6 +105,29 @@ fn try_acquire_per_ip(
}
}
}

const OVERLOAD_RESPONSE: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 0\r\n\r\n";

fn reject_overloaded_connection(
stream: tokio::net::TcpStream,
peer_ip: IpAddr,
label: &'static str,
) {
tokio::spawn(async move {
let mut stream = stream;
if let Err(e) = stream.write_all(OVERLOAD_RESPONSE).await {
log::debug!(
"Could not send overload response for {label} connection from {peer_ip}: {e}"
);
return;
}
if let Err(e) = stream.shutdown().await {
log::debug!(
"Could not close overload response for {label} connection from {peer_ip}: {e}"
);
}
});
}
// ─── Server context ───────────────────────────────────────────────────────────
/// Shared references prepared once before the accept loop starts.
///
Expand Down Expand Up @@ -211,8 +235,8 @@ impl ServerContext {
let peer_ip = peer.ip();
let ip_guard = if let Some(limit) = self.max_per_ip {
let Ok(ip_guard) = try_acquire_per_ip(&self.per_ip_map, peer_ip, limit) else {
log::warn!("Per-IP limit ({limit}) reached for {peer_ip}; dropping connection");
drop(stream);
log::warn!("Per-IP limit ({limit}) reached for {peer_ip}; sending 503 response");
reject_overloaded_connection(stream, peer_ip, "HTTP");
return true;
};
Some(ip_guard)
Expand All @@ -221,10 +245,10 @@ impl ServerContext {
};
let Ok(permit) = Arc::clone(&self.semaphore).try_acquire_owned() else {
log::warn!(
"Connection limit ({}) reached; rejecting connection from {peer_ip}",
"Connection limit ({}) reached; sending 503 response to {peer_ip}",
self.max_conns
);
drop(stream);
reject_overloaded_connection(stream, peer_ip, "HTTP");
return true;
};
let site = Arc::clone(&self.canonical_root);
Expand Down Expand Up @@ -490,9 +514,9 @@ pub async fn run_https(
let ip_guard = if let Some(limit) = ctx.max_per_ip {
let Ok(ip_guard) = try_acquire_per_ip(&ctx.per_ip_map, peer_ip, limit) else {
log::warn!(
"Per-IP limit ({limit}) reached for {peer_ip}; dropping TLS connection"
"Per-IP limit ({limit}) reached for {peer_ip}; sending 503 response"
);
drop(tcp_stream);
reject_overloaded_connection(tcp_stream, peer_ip, "HTTPS");
continue;
};
Some(ip_guard)
Expand All @@ -501,10 +525,10 @@ pub async fn run_https(
};
let Ok(permit) = Arc::clone(&ctx.semaphore).try_acquire_owned() else {
log::warn!(
"Connection limit ({}) reached; rejecting TLS connection from {peer_ip}",
"Connection limit ({}) reached; sending 503 response to {peer_ip}",
ctx.max_conns
);
drop(tcp_stream);
reject_overloaded_connection(tcp_stream, peer_ip, "HTTPS");
continue;
};
let site = Arc::clone(&ctx.canonical_root);
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/html_stress/assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/fixtures/html_stress/gallery/thumb-a.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
thumbnail-a
1 change: 1 addition & 0 deletions tests/fixtures/html_stress/gallery/thumb-b.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
thumbnail-b
51 changes: 51 additions & 0 deletions tests/fixtures/html_stress/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustHost Stress Suite</title>
<meta name="description" content="Static asset mix for load and regression testing.">
<link rel="stylesheet" href="/styles/app.css">
<script defer src="/scripts/app.js"></script>
</head>
<body>
<main class="shell">
<header class="hero">
<h1>RustHost Stress Suite</h1>
<p id="status">The app should serve this page, its linked assets, and nested pages without failing under bursty keep-alive traffic.</p>
</header>

<nav aria-label="fixture navigation">
<a href="/pages/about.html">About page</a>
<a href="/pages/nested/index.html">Nested page</a>
<a href="/pages/space%20name.html">Space name page</a>
<a href="/pages/huge.html">Generated large page</a>
<a href="/gallery/">Gallery listing</a>
</nav>

<section class="cards" aria-label="asset summary">
<article>
<h2>HTML</h2>
<p>Root, nested, and spaced filenames.</p>
</article>
<article>
<h2>CSS</h2>
<p>One stylesheet with obvious markers.</p>
</article>
<article>
<h2>JS</h2>
<p>One defer-loaded script with a window flag.</p>
</article>
<article>
<h2>Images</h2>
<p>SVG asset for content-type coverage.</p>
</article>
</section>

<figure class="brand">
<img src="/assets/logo.svg" alt="RustHost mark" width="96" height="96">
<figcaption>Static content with real browser-like references.</figcaption>
</figure>
</main>
</body>
</html>
12 changes: 12 additions & 0 deletions tests/fixtures/html_stress/pages/about.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>About</title>
</head>
<body>
<h1>About this fixture</h1>
<p>This page exercises a normal nested HTML route.</p>
<p><a href="/">Back to root</a></p>
</body>
</html>
11 changes: 11 additions & 0 deletions tests/fixtures/html_stress/pages/huge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Huge page placeholder</title>
</head>
<body>
<h1>Huge page placeholder</h1>
<p>This placeholder is replaced at test time with a much larger body so range requests and streaming get exercised.</p>
</body>
</html>
11 changes: 11 additions & 0 deletions tests/fixtures/html_stress/pages/nested/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nested index</title>
</head>
<body>
<h1>Nested index page</h1>
<p>This file checks that nested index lookup still works.</p>
</body>
</html>
11 changes: 11 additions & 0 deletions tests/fixtures/html_stress/pages/space name.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Space Name</title>
</head>
<body>
<h1>Filename with a space</h1>
<p>The request path should decode <code>%20</code> correctly.</p>
</body>
</html>
Loading
Loading