diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90b22e8..18efc97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ permissions: # Pin sibling path-dep so Cargo.lock --locked stays valid in CI. # Bump this when regenerating the lockfile against a newer rsReticulum. -# Stacked on ratspeak/rsReticulum#26 (ReplyFile); switch back to a main SHA after merge. +# Includes ReplyFile + set_request_handler_ex(..., Option). env: - RSRETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6 + RSRETICULUM_REF: e16bd152256a5caffb704446bbe15530c1b20f48 jobs: test: diff --git a/Cargo.lock b/Cargo.lock index 60d5708..d61cdf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,7 @@ version = "0.1.0" dependencies = [ "bytes", "hex", + "libc", "rmpv", "rns-crypto", "rns-identity", diff --git a/README.md b/README.md index 7b89714..3b61c9e 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ --- -rsNomad is a Rust implementation of Nomad Network **static page and file hosting** +rsNomad is a Rust implementation of Nomad Network **page, file, and media hosting** over Reticulum Links. This is not a fork of NomadNet; it is NomadNet page-server behavior written in a different language, focused on staying interoperable with -Python NomadNet and MeshChat. It is not the source-of-truth implementation — do -not treat it as one. +Python NomadNet **1.4.1** (PyPI) and MeshChat. It is not the source-of-truth +implementation — do not treat it as one. Page hosting uses Reticulum Link request/response on aspect `nomadnetwork.node`. It is **not** LXMF messaging; use [rsLXMF](https://github.com/ratspeak/rsLXMF) for @@ -130,6 +130,7 @@ let node = NomadNode::spawn( display_name: "My Node".into(), announce_interval: Some(Duration::from_secs(3600)), announce_at_start: true, + allow_executable_pages: false, // opt-in Unix CGI / executable .allowed }, ) .await?; @@ -140,10 +141,10 @@ node.reload_routes()?; // required after content CRUD so new routes are served ``` `NomadNode` registers the `nomadnetwork.node` destination, installs a Link -request handler for `/page/...` and `/file/...`, and announces with the display -name as raw UTF-8 app data (canonical NomadNet format). The built-in handler -serves static content only and ignores the request body; use -`decode_request_fields` if your application needs MessagePack form maps. +request handler for `/page/...`, `/file/...`, and `/media`, and announces with +the display name as raw UTF-8 app data (canonical NomadNet format). Form bodies +are decoded for CGI pages when `allow_executable_pages` is enabled; `/media` +uses `decode_media_request` (`path` + `key`). This crate is not published to crates.io. For the full public API (CRUD helpers, stats, announce, error types, limits), generate local docs: @@ -160,7 +161,9 @@ NomadNet-compatible roots: / |-- pages/ | |-- index.mu -| `-- docs/help.mu +| |-- index.mu.allowed # optional identity ACL companion +| |-- docs/help.mu +| `-- header.webp # in-page images via /media `-- files/ `-- manual.pdf ``` @@ -169,20 +172,36 @@ Mapping: - `pages/index.mu` → `/page/index.mu` - `pages/docs/help.mu` → `/page/docs/help.mu` +- `pages/header.webp` → `/media` request with `path` = `header.webp` (WebP only) - `files/manual.pdf` → `/file/manual.pdf` Paths are resolved under each root without following symlink components; `..`, absolute escapes, NUL/backslash, and control characters are rejected. Default -size caps are **512 KiB** for pages and **32 MiB** for files. +size caps are **512 KiB** for pages and **32 MiB** for files/media. **Trust model:** content directories are trusted local storage. Operators must ensure they are not writable by untrusted local users. Symlink components are rejected; hard links under the same volume are not rejected (a hard-linked file inside the root is treated as ordinary content). -Missing `/page/...` routes return a Micron 404 body. Missing `/file/...` routes -are dropped with no reply (NomadNet parity). Unknown path hashes do **not** -rescan the filesystem — call `reload_routes()` after content CRUD. +**ACL (`.allowed`):** a companion file `{resource}.allowed` next to a page, +file, or media path restricts access to listed identity hashes (32 hex chars +per line). Missing companion → allow. Paths ending in `.allowed` are never +served. Deny replies use Micron `not_allowed_page()` for pages/files; media +denies drop silently. Executable `.allowed` scripts run only when +`allow_executable_pages` is enabled (same sandbox as CGI); otherwise they are +read as static lists. + +**CGI:** when `allow_executable_pages` is true (default **false**), Unix pages +with the execute bit are run as processes with a cleared environment +(`PATH` sanitized, `link_id` / `remote_identity` / `field_*` / `var_*`), ~10s +timeout, stdout capped to `max_page_bytes`, stderr discarded, no shell. +Windows never runs CGI. ACL is evaluated before CGI. + +Missing `/page/...` routes return a Micron 404 body. Missing `/file/...` and +bad `/media` requests are dropped with no reply (NomadNet parity). Unknown path +hashes do **not** rescan the filesystem — call `reload_routes()` after content +CRUD. ## Protocol Notes @@ -190,17 +209,19 @@ rescan the filesystem — call `reload_routes()` after content CRUD. - Transport: Reticulum encrypted Link request/response (not LXMF) - Wire path hash: first 16 bytes of SHA-256 of the exact path string - Form data: `decode_request_fields` accepts a MessagePack map of string keys - (e.g. `field_*`, `var_*`) with size/depth caps; the built-in serve handler - currently ignores the request body (static hosting only) + (e.g. `field_*`, `var_*`) with size/depth caps; wired into CGI env when + executable pages are enabled +- Media: `encode_media_request` / `decode_media_request` for `{path, key}` + maps (`key` may be Nil); route string exactly `/media` - Large responses: use normal `Reply` bytes; `LinkManager` upgrades to a response Resource when the packed reply exceeds the Link MDU -- File responses: `/file/...` uses `ReplyFile` — a response Resource with raw - bytes and msgpack metadata `{"name": }` (NomadNet `serve_file` - parity). Images and other binaries are ordinary files under `files/`; there is - no `/image/` route or MIME layer on the wire +- File / media responses: `ReplyFile` — a response Resource with raw bytes and + msgpack metadata `{"name": ...}` (relative path for `/file`, basename for + `/media`) - Announce app data: raw UTF-8 display name, capped at 256 bytes (also accepted by mesh-client discovery) -- Hidden paths: dotfiles and `*.allowed` are not listed or served (NomadNet parity) +- Hidden paths: dotfiles and `*.allowed` are not listed or served as content + (NomadNet parity); `.allowed` companions are enforced as ACLs - Concurrency: in-flight request budget (default 8) plus a fixed-window rate limit (default 60 requests / 10 s). The Link request handler runs synchronously on the link event loop with bounded disk reads. @@ -211,23 +232,28 @@ rescan the filesystem — call `reload_routes()` after content CRUD. | --- | --- | | Static pages | Serve `.mu` (and other text) from `pages/` with 512 KiB default cap | | Static files | Serve binaries from `files/` with 32 MiB default cap as response Resources with filename metadata | +| `/media` WebP | Exact `/media` route; pages-jail WebP only; basename `ReplyFile` metadata | +| `.allowed` ACL | Static identity-hash lists; optional sandboxed executable companions | +| CGI pages | Opt-in (`allow_executable_pages`); Unix-only sandbox; default off | | Announce | Startup + periodic + transport reannounce with display name | -| Form payload decode | Helper only (`decode_request_fields`); not wired into serving | +| Form payload decode | Helpers + CGI env injection when enabled | | Default index | Placeholder Micron page when `index.mu` is missing | -| Path safety | Traversal/symlink rejection, size limits, skip dotfiles/`*.allowed` | +| Path safety | Traversal/symlink rejection, size limits, skip listing dotfiles/`*.allowed` | | Request budget | Bounded in-flight handlers + fixed-window admit limit | -| CGI / executable pages | **Not implemented** (explicit non-goal for v1) | | Markdown CMS | Application concern (e.g. mesh-client UI) — not in this crate | | Chat / forums | Roadmap only | | `nomad-serve-rs` CLI | Planned (optional tools crate) | ## Compatibility Notes -Target clients: Python [NomadNet](https://github.com/markqvist/NomadNet) and MeshChat -browsers, plus [mesh-client](https://github.com/Colorado-Mesh/mesh-client) Nomad tab. +Target clients: Python [NomadNet](https://github.com/markqvist/NomadNet) **1.4.1** +and MeshChat browsers, plus [mesh-client](https://github.com/Colorado-Mesh/mesh-client) +Nomad tab. -v1 focuses on static hosting. Dynamic executable pages (NomadNet CGI-style -`.mu` scripts) are intentionally omitted for security. +Compatibility target for hosting behavior is the NomadNet **1.4.1 PyPI sdist** +(`Node.py`: `serve_page`, `serve_file`, `serve_media`, `request_allowed`). +CGI is **opt-in** and sandboxed (cleared env); Python inherits the parent +environment — an intentional hardening difference. This crate depends on Ratspeak [rsReticulum](https://github.com/ratspeak/rsReticulum) path dependencies during development. It is not compatible with unrelated RNS @@ -238,14 +264,13 @@ Rust stacks (for example TeskesLab `nomadnet-rs` / `rns-net`). Follow-ups (not required for basic hosting): 1. Optional `nomad-tools` binary (`nomad-serve-rs`) for headless static hosting -2. Identity-restricted pages (`.mu.allowed` lists) without process execution -3. Richer Micron helpers / builders -4. Transfer repository ownership to the Ratspeak organization when permissions allow +2. Richer Micron helpers / builders +3. Transfer repository ownership to the Ratspeak organization when permissions allow Application-layer CMS, chat rooms, forums, LXMF image/file attachments, and Micron rendering belong in clients such as mesh-client / rsLXMF, not in this -protocol crate. Images on Nomad nodes are `/file/...` binaries with Resource -filename metadata (already implemented). +protocol crate. In-page images use `/media` WebP under `pages/`; other binaries +remain `/file/...` with Resource filename metadata. ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 4e5d825..48a4a44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,11 +10,19 @@ static hosting release used by mesh-client (#613). - Safe filesystem roots, size caps, Micron 404 / default index - AGPL-3.0-or-later, Ratspeak-shaped README / CI - MessagePack form encode/decode helpers (`encode_request_fields` / - `decode_request_fields`) with shared size caps (decode not yet wired into - the built-in serve handler) + `decode_request_fields`) with shared size caps - `/file/...` response Resource filename metadata (`ReplyFile`, NomadNet `serve_file` parity); default file cap 32 MiB +## Done (NomadNet 1.4.1 PyPI target) + +- `/media` WebP host (`encode_media_request` / `decode_media_request`, + `ReplyFile` + basename metadata) +- `.allowed` identity ACL (static lists; optional sandboxed executable + companions when CGI is enabled) +- Opt-in Unix CGI pages (`NomadNodeConfig.allow_executable_pages`, default off) +- Micron `not_allowed_page()` matching Python `DEFAULT_NOTALLOWED` + ## Near-term - **Clients import existing `nomad-core` constants** (mesh-client sidecar still @@ -27,7 +35,6 @@ static hosting release used by mesh-client (#613). the sidecar; mesh-client product policy such as `force_path_refresh` stays in clients. TS UI/proxy mirrors remain client-side. - Optional `nomad-tools` crate with `nomad-serve-rs` headless binary -- Wire form/`field_*` bodies into serving when dynamic pages are designed - Stronger interop fixtures against Python NomadNet page fetches - Async / `spawn_blocking` serve path if LinkManager gains an async handler API @@ -40,14 +47,15 @@ These belong in clients such as mesh-client, not in the protocol crate: - NomadNet-style chat room apps - Forums and other dynamic Nomad apps - LXMF conversation image/file attachments (rsLXMF + mesh-client UI) -- Nomad browser image preview for `/file/...` rasters +- Nomad browser image preview for `/file/...` rasters and `/media` WebP -## Explicit non-goals (v1) +## Explicit non-goals -- CGI / executable `.mu` page scripts (arbitrary code execution risk) +- Unsandboxed CGI with full parent-env inheritance (Python footgun; we clear env) - Embedding hosting inside `rsLXMF` - Depending on non-Ratspeak RNS stacks (`nomadnet-rs` / `rns-net`) -- Server-side MIME/`/image/` routes (images are ordinary `/file/...` binaries) +- Server-side MIME/`/image/` routes (in-page images use `/media` WebP; other + binaries remain ordinary `/file/...`) ## Ownership diff --git a/crates/nomad-core/Cargo.toml b/crates/nomad-core/Cargo.toml index 0fb8840..a180d99 100644 --- a/crates/nomad-core/Cargo.toml +++ b/crates/nomad-core/Cargo.toml @@ -20,3 +20,6 @@ tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/nomad-core/src/acl.rs b/crates/nomad-core/src/acl.rs new file mode 100644 index 0000000..100bdbf --- /dev/null +++ b/crates/nomad-core/src/acl.rs @@ -0,0 +1,210 @@ +//! `.allowed` companion-file ACL (NomadNet 1.4.1 `request_allowed` parity). + +use std::path::Path; + +use rns_identity::identity::Identity; + +use crate::cgi::{is_unix_executable, run_allowlist_script}; +use crate::paths::reject_if_symlink; + +/// Outcome of an identity allowlist check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AclDecision { + /// No companion, or remote identity hash is listed. + Allow, + /// Companion exists and remote identity is missing/anonymous/not listed, + /// or the resource path itself ends with `.allowed`. + Deny, +} + +/// Enforce NomadNet `{resource}.allowed` rules for `resource_path`. +/// +/// - Paths ending in `.allowed` (case-insensitive) are always denied. +/// - Missing companion → allow. +/// - Static companion: lines of exactly 32 hex chars (whitespace trimmed) are +/// 16-byte identity hashes; allow iff `remote_identity.hash` is listed. +/// - Executable companion: when `allow_executable` is true (Unix), run under +/// the CGI sandbox and parse stdout as the list; otherwise read as a static +/// file even if the execute bit is set. +pub fn request_allowed( + resource_path: &Path, + remote_identity: Option<&Identity>, + allow_executable: bool, +) -> AclDecision { + let path_str = resource_path.to_string_lossy(); + if path_str.to_ascii_lowercase().ends_with(".allowed") { + return AclDecision::Deny; + } + + let allowed_path = { + let mut p = resource_path.as_os_str().to_owned(); + p.push(".allowed"); + std::path::PathBuf::from(p) + }; + + if !allowed_path.is_file() { + return AclDecision::Allow; + } + + let allowed_bytes = match read_allowed_bytes(&allowed_path, allow_executable) { + Ok(b) => b, + Err(e) => { + tracing::warn!( + error = %e, + path = %allowed_path.display(), + "failed to read .allowed companion; denying" + ); + return AclDecision::Deny; + } + }; + + let allowed_list = parse_allowed_hashes(&allowed_bytes); + match remote_identity { + Some(id) if allowed_list.iter().any(|h| h == &id.hash) => AclDecision::Allow, + _ => AclDecision::Deny, + } +} + +fn read_allowed_bytes(allowed_path: &Path, allow_executable: bool) -> std::io::Result> { + if let Err(e) = reject_if_symlink(allowed_path) { + return Err(std::io::Error::other(e.to_string())); + } + + #[cfg(unix)] + { + if allow_executable && is_unix_executable(allowed_path) { + return run_allowlist_script(allowed_path) + .map_err(|e| std::io::Error::other(format!("executable .allowed failed: {e}"))); + } + } + #[cfg(not(unix))] + { + let _ = allow_executable; + } + + std::fs::read(allowed_path) +} + +/// Parse allowlist lines: trim whitespace; keep lines that are exactly 32 hex chars. +pub fn parse_allowed_hashes(input: &[u8]) -> Vec<[u8; 16]> { + let mut out = Vec::new(); + for line in input.split(|b| *b == b'\n' || *b == b'\r') { + let trimmed = trim_ascii_whitespace(line); + if trimmed.len() != 32 { + continue; + } + if !trimmed.iter().all(u8::is_ascii_hexdigit) { + continue; + } + let Ok(s) = std::str::from_utf8(trimmed) else { + continue; + }; + let mut hash = [0u8; 16]; + if hex::decode_to_slice(s, &mut hash).is_ok() { + out.push(hash); + } + } + out +} + +fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|b| !b.is_ascii_whitespace()) + .unwrap_or(bytes.len()); + let end = bytes + .iter() + .rposition(|b| !b.is_ascii_whitespace()) + .map(|i| i + 1) + .unwrap_or(start); + &bytes[start..end] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + fn identity_with_hash(hash: [u8; 16]) -> Identity { + let mut id = Identity::new(); + // Tests only: override truncated hash used by ACL matching. + id.hash = hash; + id + } + + #[test] + fn missing_companion_allows() { + let dir = tempdir().unwrap(); + let resource = dir.path().join("page.mu"); + std::fs::write(&resource, b"> hi\n").unwrap(); + assert_eq!(request_allowed(&resource, None, false), AclDecision::Allow); + } + + #[test] + fn path_ending_allowed_denies() { + let dir = tempdir().unwrap(); + let resource = dir.path().join("page.mu.allowed"); + std::fs::write(&resource, b"deadbeef").unwrap(); + assert_eq!(request_allowed(&resource, None, false), AclDecision::Deny); + } + + #[test] + fn static_list_requires_listed_identity() { + let dir = tempdir().unwrap(); + let resource = dir.path().join("secret.mu"); + std::fs::write(&resource, b"> secret\n").unwrap(); + let hash = [0xab; 16]; + let allowed = dir.path().join("secret.mu.allowed"); + { + let mut f = std::fs::File::create(&allowed).unwrap(); + writeln!(f, " {} ", hex::encode(hash)).unwrap(); + writeln!(f, "not-a-hash").unwrap(); + writeln!(f, "zzzz").unwrap(); + } + assert_eq!(request_allowed(&resource, None, false), AclDecision::Deny); + assert_eq!( + request_allowed(&resource, Some(&identity_with_hash([0x11; 16])), false), + AclDecision::Deny + ); + assert_eq!( + request_allowed(&resource, Some(&identity_with_hash(hash)), false), + AclDecision::Allow + ); + } + + #[test] + fn parse_allowed_hashes_trims_and_filters() { + let input = b" aabbccddeeff00112233445566778899 \n\ + short\n\ + AABBCCDDEEFF00112233445566778899\n\ + not-hex-!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"; + let hashes = parse_allowed_hashes(input); + assert_eq!(hashes.len(), 2); + let expected: [u8; 16] = hex::decode("aabbccddeeff00112233445566778899") + .unwrap() + .try_into() + .unwrap(); + assert_eq!(hashes[0], expected); + assert_eq!(hashes[1], expected); + } + + #[test] + #[cfg(unix)] + fn executable_allowed_reads_as_static_when_cgi_off() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let resource = dir.path().join("x.mu"); + std::fs::write(&resource, b"> x\n").unwrap(); + let hash = [0xcd; 16]; + let allowed = dir.path().join("x.mu.allowed"); + std::fs::write(&allowed, format!("{}\n", hex::encode(hash))).unwrap(); + let mut perms = std::fs::metadata(&allowed).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&allowed, perms).unwrap(); + assert_eq!( + request_allowed(&resource, Some(&identity_with_hash(hash)), false), + AclDecision::Allow + ); + } +} diff --git a/crates/nomad-core/src/cgi.rs b/crates/nomad-core/src/cgi.rs new file mode 100644 index 0000000..c778231 --- /dev/null +++ b/crates/nomad-core/src/cgi.rs @@ -0,0 +1,266 @@ +//! Opt-in sandboxed CGI for executable pages / `.allowed` scripts (Unix). + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use rns_identity::identity::Identity; + +use crate::error::NomadError; + +/// Wall-clock timeout for CGI / executable allowlist scripts. +pub const CGI_TIMEOUT: Duration = Duration::from_secs(10); + +/// Minimal PATH for sandboxed scripts (never inherit the full parent env). +const SANITIZED_PATH: &str = "/usr/bin:/bin"; + +/// True when `path` exists and is executable by the current user (Unix `X_OK`). +#[cfg(unix)] +pub fn is_unix_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + match std::fs::metadata(path) { + Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111) != 0, + Err(_) => false, + } +} + +#[cfg(not(unix))] +pub fn is_unix_executable(_path: &Path) -> bool { + false +} + +/// Run an executable page under a cleared environment. +/// +/// Sets only `PATH` (sanitized), optional `link_id` / `remote_identity` hex, and +/// `field_*` / `var_*` entries from `fields`. Captures stdout (capped), discards +/// stderr, uses no shell, and enforces [`CGI_TIMEOUT`]. +/// +/// The child is placed in its own process group so timeouts / completion can +/// signal the whole tree (descendants cannot hold the stdout pipe open). +#[cfg(unix)] +pub fn run_cgi( + script: &Path, + link_id: [u8; 16], + remote_identity: Option<&Identity>, + fields: &BTreeMap, + max_stdout: usize, +) -> Result, NomadError> { + run_sandboxed(script, link_id, remote_identity, fields, max_stdout) +} + +#[cfg(not(unix))] +pub fn run_cgi( + _script: &Path, + _link_id: [u8; 16], + _remote_identity: Option<&Identity>, + _fields: &BTreeMap, + _max_stdout: usize, +) -> Result, NomadError> { + Err(NomadError::message("CGI is not supported on this platform")) +} + +/// Run an executable `.allowed` script; stdout is the allowlist body. +#[cfg(unix)] +pub fn run_allowlist_script(script: &Path) -> Result, NomadError> { + // Allowlists do not receive link/form context — empty fields, zero link id. + run_sandboxed(script, [0u8; 16], None, &BTreeMap::new(), 64 * 1024) +} + +#[cfg(not(unix))] +pub fn run_allowlist_script(_script: &Path) -> Result, NomadError> { + Err(NomadError::message( + "executable .allowed is not supported on this platform", + )) +} + +#[cfg(unix)] +fn run_sandboxed( + script: &Path, + link_id: [u8; 16], + remote_identity: Option<&Identity>, + fields: &BTreeMap, + max_stdout: usize, +) -> Result, NomadError> { + use std::os::unix::process::CommandExt; + + if !script.is_file() { + return Err(NomadError::NotFound(script.display().to_string())); + } + if !is_unix_executable(script) { + return Err(NomadError::message("script is not executable")); + } + + let mut command = Command::new(script); + command.env_clear(); + command.env("PATH", SANITIZED_PATH); + command.env("link_id", hex::encode(link_id)); + if let Some(id) = remote_identity { + command.env("remote_identity", hex::encode(id.hash)); + } + for (key, value) in fields { + if key.starts_with("field_") || key.starts_with("var_") { + command.env(key, value); + } + } + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + // New process group (pgid == child pid) so we can kill descendants on exit/timeout. + command.process_group(0); + + let mut child = command.spawn().map_err(NomadError::Io)?; + let child_pid = child.id(); + let mut stdout = child + .stdout + .take() + .ok_or_else(|| NomadError::message("CGI missing stdout pipe"))?; + + let reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut limited = (&mut stdout).take(max_stdout.saturating_add(1) as u64); + limited.read_to_end(&mut buf).map(|_| buf) + }); + + let status = match wait_with_timeout(&mut child, CGI_TIMEOUT) { + Ok(status) => { + // Child already reaped by try_wait; kill any descendants still holding pipes. + terminate_process_group(child_pid); + status + } + Err(e) => { + terminate_process_group(child_pid); + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(e); + } + }; + + let buf = reader + .join() + .map_err(|_| NomadError::message("CGI stdout reader panicked"))? + .map_err(NomadError::Io)?; + + if buf.len() > max_stdout { + return Err(NomadError::TooLarge { + size: buf.len(), + max: max_stdout, + }); + } + + // Exit code is ignored (NomadNet 1.4.1 parity) — still log unusual exits. + if !status.success() { + tracing::debug!( + code = ?status.code(), + script = %script.display(), + "CGI exited non-zero; returning captured stdout" + ); + } + Ok(buf) +} + +/// Signal the child's process group (negative pid), then best-effort reap. +#[cfg(unix)] +fn terminate_process_group(child_pid: u32) { + let pid = child_pid as i32; + if pid > 0 { + // SAFETY: killpg with the child's pgid (set via process_group(0)). + unsafe { + libc::killpg(pid, libc::SIGKILL); + } + } +} + +#[cfg(unix)] +fn wait_with_timeout( + child: &mut std::process::Child, + timeout: Duration, +) -> Result { + let start = std::time::Instant::now(); + loop { + match child.try_wait().map_err(NomadError::Io)? { + Some(status) => return Ok(status), + None if start.elapsed() >= timeout => { + return Err(NomadError::message("CGI timed out")); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + #[cfg(unix)] + fn cgi_runs_script_with_env_and_caps_stdout() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let script = dir.path().join("page.mu"); + std::fs::write( + &script, + b"#!/bin/sh\nprintf '%s' \"$field_q-$remote_identity-$link_id\"\n", + ) + .unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + + let mut fields = BTreeMap::new(); + fields.insert("field_q".into(), "hi".into()); + fields.insert("other".into(), "nope".into()); + let id = Identity::new(); + let link = [0x11u8; 16]; + let out = run_cgi(&script, link, Some(&id), &fields, 1024).unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!(text.starts_with("hi-")); + assert!(text.contains(&hex::encode(id.hash))); + assert!(text.contains(&hex::encode(link))); + assert!(!text.contains("nope")); + } + + #[test] + #[cfg(unix)] + fn cgi_rejects_oversized_stdout() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let script = dir.path().join("big.mu"); + std::fs::write( + &script, + b"#!/bin/sh\ndd if=/dev/zero bs=1 count=64 2>/dev/null\n", + ) + .unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + let err = run_cgi(&script, [0u8; 16], None, &BTreeMap::new(), 8).unwrap_err(); + assert!(matches!(err, NomadError::TooLarge { .. })); + } + + #[test] + #[cfg(unix)] + fn cgi_kills_process_group_on_timeout() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let script = dir.path().join("hang.mu"); + // Child sleeps past CGI_TIMEOUT; process group kill must reclaim it. + std::fs::write(&script, b"#!/bin/sh\nsleep 60\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + let err = run_cgi(&script, [0u8; 16], None, &BTreeMap::new(), 1024).unwrap_err(); + assert!( + err.to_string().contains("timed out"), + "expected timeout, got {err}" + ); + } + + #[test] + fn non_executable_is_false_for_missing() { + assert!(!is_unix_executable(Path::new("/no/such/script"))); + } +} diff --git a/crates/nomad-core/src/lib.rs b/crates/nomad-core/src/lib.rs index 020701d..a777267 100644 --- a/crates/nomad-core/src/lib.rs +++ b/crates/nomad-core/src/lib.rs @@ -4,7 +4,9 @@ //! request/response (aspect `nomadnetwork.node`). It is not a fork of Python //! NomadNet and is not the source-of-truth implementation. +mod acl; mod announce; +mod cgi; mod error; mod micron; mod node; @@ -16,18 +18,21 @@ pub use announce::{ MAX_ANNOUNCE_NAME_BYTES, build_nomad_announce_packet, clamp_node_name, nomad_destination_hash, }; pub use error::NomadError; -pub use micron::{MAX_MICRON_TEXT_CHARS, default_index_page, not_found_page, sanitize_micron_text}; +pub use micron::{ + MAX_MICRON_TEXT_CHARS, default_index_page, not_allowed_page, not_found_page, + sanitize_micron_text, +}; pub use node::{NomadNode, NomadNodeConfig, NomadServeStats}; pub use paths::{ DEFAULT_INDEX_ROUTE, FILE_PREFIX, MAX_COMPONENT_BYTES, MAX_PATH_COMPONENTS, MAX_REL_PATH_BYTES, - NOMAD_NODE_ASPECT, PAGE_PREFIX, is_hidden_or_allowlist_name, normalize_file_route, + MEDIA_ROUTE, NOMAD_NODE_ASPECT, PAGE_PREFIX, is_hidden_or_allowlist_name, normalize_file_route, normalize_page_route, path_hash, resolve_under_root, strip_file_prefix, strip_page_prefix, validate_content_relative_path, }; pub use request::{ MAX_REQUEST_BODY_BYTES, MAX_REQUEST_FIELD_KEY_BYTES, MAX_REQUEST_FIELD_VALUE_BYTES, - MAX_REQUEST_FIELDS, MAX_REQUEST_MSGPACK_DEPTH, NomadRequestFields, decode_request_fields, - encode_request_fields, + MAX_REQUEST_FIELDS, MAX_REQUEST_MSGPACK_DEPTH, MediaRequest, NomadRequestFields, + decode_media_request, decode_request_fields, encode_media_request, encode_request_fields, }; pub use storage::{ DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_PAGE_BYTES, MAX_LISTED_ENTRIES, NomadContentRoots, diff --git a/crates/nomad-core/src/micron.rs b/crates/nomad-core/src/micron.rs index 37954ed..53360e2 100644 --- a/crates/nomad-core/src/micron.rs +++ b/crates/nomad-core/src/micron.rs @@ -48,6 +48,13 @@ pub fn not_found_page(route: &str) -> String { ) } +/// Micron body matching Python NomadNet `DEFAULT_NOTALLOWED` (ACL deny). +pub fn not_allowed_page() -> &'static str { + ">Request Not Allowed\n\ + \n\ + You are not authorised to carry out the request.\n" +} + #[cfg(test)] mod tests { use super::*; @@ -84,4 +91,11 @@ mod tests { assert_eq!(out.chars().count(), MAX_MICRON_TEXT_CHARS); assert!(out.is_char_boundary(out.len())); } + + #[test] + fn not_allowed_matches_python_default() { + let page = not_allowed_page(); + assert!(page.starts_with(">Request Not Allowed")); + assert!(page.contains("You are not authorised to carry out the request.")); + } } diff --git a/crates/nomad-core/src/node.rs b/crates/nomad-core/src/node.rs index a64e7cf..de9ac60 100644 --- a/crates/nomad-core/src/node.rs +++ b/crates/nomad-core/src/node.rs @@ -19,15 +19,22 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use crate::acl::{AclDecision, request_allowed}; use crate::announce::{ clamp_node_name, nomad_destination_hash, send_nomad_announce, send_nomad_announce_try, }; +#[cfg(unix)] +use crate::cgi::{is_unix_executable, run_cgi}; use crate::error::NomadError; -use crate::micron::not_found_page; +use crate::micron::{not_allowed_page, not_found_page}; use crate::paths::{ - DEFAULT_INDEX_ROUTE, FILE_PREFIX, NOMAD_NODE_ASPECT, PAGE_PREFIX, normalize_file_route, - normalize_page_route, path_hash, + DEFAULT_INDEX_ROUTE, FILE_PREFIX, MEDIA_ROUTE, NOMAD_NODE_ASPECT, PAGE_PREFIX, + normalize_file_route, normalize_page_route, path_hash, resolve_under_root, strip_file_prefix, + strip_page_prefix, }; +use crate::request::decode_media_request; +#[cfg(unix)] +use crate::request::decode_request_fields; use crate::storage::NomadContentStore; /// Max concurrent request handlers (disk/network budget). @@ -50,6 +57,9 @@ pub struct NomadNodeConfig { pub announce_interval: Option, /// Send an announce immediately after spawn. pub announce_at_start: bool, + /// When true (Unix only), serve `+x` pages via sandboxed CGI and allow + /// executable `.allowed` companions. Default off for security. + pub allow_executable_pages: bool, } impl Default for NomadNodeConfig { @@ -58,6 +68,7 @@ impl Default for NomadNodeConfig { display_name: "Nomad node".into(), announce_interval: Some(Duration::from_secs(3600)), announce_at_start: true, + allow_executable_pages: false, } } } @@ -71,6 +82,8 @@ pub struct NomadServeStats { pub page_hits: u64, /// Successful file replies. pub file_hits: u64, + /// Successful `/media` replies. + pub media_hits: u64, /// Missing routes / missing content. pub not_found_count: u64, /// Wall-clock ms of the last admitted request, if any. @@ -117,6 +130,8 @@ fn rebuild_routes(routes: &mut RouteTable, store: &NomadContentStore) -> Result< } // Always register index even if list was empty before ensure. routes.register(DEFAULT_INDEX_ROUTE.into())?; + // Exact `/media` route (NomadNet 1.4.1 in-page WebP). + routes.register(MEDIA_ROUTE.into())?; Ok(()) } @@ -126,6 +141,7 @@ struct SharedState { routes: RwLock, stats: NomadServeStatsInner, budget: RequestBudget, + allow_executable_pages: bool, } struct RequestBudgetState { @@ -190,6 +206,7 @@ struct NomadServeStatsInner { request_count: AtomicU64, page_hits: AtomicU64, file_hits: AtomicU64, + media_hits: AtomicU64, not_found_count: AtomicU64, last_request_ms: AtomicU64, } @@ -200,6 +217,7 @@ impl NomadServeStatsInner { request_count: AtomicU64::new(0), page_hits: AtomicU64::new(0), file_hits: AtomicU64::new(0), + media_hits: AtomicU64::new(0), not_found_count: AtomicU64::new(0), last_request_ms: AtomicU64::new(0), } @@ -211,6 +229,7 @@ impl NomadServeStatsInner { request_count: self.request_count.load(Ordering::Relaxed), page_hits: self.page_hits.load(Ordering::Relaxed), file_hits: self.file_hits.load(Ordering::Relaxed), + media_hits: self.media_hits.load(Ordering::Relaxed), not_found_count: self.not_found_count.load(Ordering::Relaxed), last_request_ms: if last == 0 { None } else { Some(last) }, } @@ -259,6 +278,7 @@ impl NomadNode { routes: RwLock::new(RouteTable::new()), stats: NomadServeStatsInner::new(), budget: RequestBudget::new(), + allow_executable_pages: config.allow_executable_pages, }); // Pre-register known filesystem pages/files for path-hash lookup. @@ -283,10 +303,8 @@ impl NomadNode { ); let handler_shared = shared.clone(); - // Request body (`_data`) is ignored: static hosting only. Callers that - // need form fields should decode with `decode_request_fields` themselves. - link_mgr.set_request_handler_ex(move |_link_id, path_hash, _data| { - handle_request(&handler_shared, path_hash) + link_mgr.set_request_handler_ex(move |link_id, path_hash, data, remote_identity| { + handle_request(&handler_shared, link_id, path_hash, data, remote_identity) }); let announce_tx = transport_tx.clone(); @@ -452,7 +470,13 @@ fn lookup_route(shared: &SharedState, path_hash_bytes: [u8; 16]) -> Option RequestOutcome { +fn handle_request( + shared: &SharedState, + link_id: [u8; 16], + path_hash_bytes: [u8; 16], + data: Vec, + remote_identity: Option, +) -> RequestOutcome { let Some(_budget) = shared.budget.try_acquire() else { tracing::warn!("nomad request budget exceeded; dropping request"); return RequestOutcome::Drop; @@ -479,54 +503,218 @@ fn handle_request(shared: &SharedState, path_hash_bytes: [u8; 16]) -> RequestOut return RequestOutcome::Reply(not_found_page("/page/unknown").into_bytes()); }; + if route == MEDIA_ROUTE { + return serve_media(shared, &data, remote_identity.as_ref()); + } + if route.starts_with(PAGE_PREFIX) { - match shared.store.read_page_route(&route) { + serve_page(shared, &route, link_id, &data, remote_identity.as_ref()) + } else if route.starts_with(FILE_PREFIX) { + serve_file(shared, &route, remote_identity.as_ref()) + } else { + RequestOutcome::Drop + } +} + +fn deny_pages_or_files() -> RequestOutcome { + RequestOutcome::Reply(not_allowed_page().as_bytes().to_vec()) +} + +fn serve_page( + shared: &SharedState, + route: &str, + link_id: [u8; 16], + data: &[u8], + remote_identity: Option<&Identity>, +) -> RequestOutcome { + let rel = match strip_page_prefix(route) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, route = %route, "invalid page route"); + return RequestOutcome::Drop; + } + }; + let abs = match resolve_under_root(&shared.store.roots().pages_dir, rel) { + Ok(p) => p, + Err(NomadError::NotFound(_)) | Err(NomadError::PathTraversal) => { + shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); + return RequestOutcome::Reply(not_found_page(route).into_bytes()); + } + Err(e) => { + tracing::warn!(error = %e, route = %route, "page path resolve failed"); + return RequestOutcome::Drop; + } + }; + + if request_allowed(&abs, remote_identity, shared.allow_executable_pages) == AclDecision::Deny { + return deny_pages_or_files(); + } + + #[cfg(unix)] + if shared.allow_executable_pages && is_unix_executable(&abs) { + // LinkManager request handlers must return RequestOutcome synchronously + // (no deferred-reply API). CGI therefore runs inline with a process-group + // timeout in `run_cgi`; moving this off-thread without dropping the reply + // requires rsReticulum support. + let fields = decode_request_fields(data) + .map(|f| f.fields) + .unwrap_or_default(); + let max = shared.store.roots().max_page_bytes; + match run_cgi(&abs, link_id, remote_identity, &fields, max) { Ok(bytes) => { shared.stats.page_hits.fetch_add(1, Ordering::Relaxed); - RequestOutcome::Reply(bytes) - } - Err(NomadError::NotFound(_)) => { - shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); - RequestOutcome::Reply(not_found_page(&route).into_bytes()) + return RequestOutcome::Reply(bytes); } Err(e) => { - tracing::warn!(error = %e, route = %route, "nomad page serve failed"); - RequestOutcome::Drop + tracing::warn!(error = %e, route = %route, "nomad CGI page failed"); + return RequestOutcome::Drop; } } - } else if route.starts_with(FILE_PREFIX) { - match shared.store.read_file_route(&route) { - Ok(bytes) => { - shared.stats.file_hits.fetch_add(1, Ordering::Relaxed); - let rel_name = route - .strip_prefix(FILE_PREFIX) - .unwrap_or(route.as_str()) - .to_string(); - let auto_compress = bytes.len() < FILE_AUTO_COMPRESS_MAX_BYTES; - RequestOutcome::ReplyFile { - data: bytes, - metadata: Some(pack_file_name_metadata(&rel_name)), - auto_compress, - } - } - Err(NomadError::NotFound(_)) => { - // Files have no Micron 404 body — drop silently (NomadNet parity). - shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); - RequestOutcome::Drop + } + #[cfg(not(unix))] + { + let _ = (link_id, data); + } + + match shared.store.read_page_route(route) { + Ok(bytes) => { + shared.stats.page_hits.fetch_add(1, Ordering::Relaxed); + RequestOutcome::Reply(bytes) + } + Err(NomadError::NotFound(_)) => { + shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); + RequestOutcome::Reply(not_found_page(route).into_bytes()) + } + Err(e) => { + tracing::warn!(error = %e, route = %route, "nomad page serve failed"); + RequestOutcome::Drop + } + } +} + +fn serve_file( + shared: &SharedState, + route: &str, + remote_identity: Option<&Identity>, +) -> RequestOutcome { + let rel = match strip_file_prefix(route) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, route = %route, "invalid file route"); + return RequestOutcome::Drop; + } + }; + let abs = match resolve_under_root(&shared.store.roots().files_dir, rel) { + Ok(p) => p, + Err(NomadError::NotFound(_)) | Err(NomadError::PathTraversal) => { + shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); + return RequestOutcome::Drop; + } + Err(e) => { + tracing::warn!(error = %e, route = %route, "file path resolve failed"); + return RequestOutcome::Drop; + } + }; + + if request_allowed(&abs, remote_identity, shared.allow_executable_pages) == AclDecision::Deny { + return deny_pages_or_files(); + } + + match shared.store.read_file_route(route) { + Ok(bytes) => { + shared.stats.file_hits.fetch_add(1, Ordering::Relaxed); + let rel_name = route.strip_prefix(FILE_PREFIX).unwrap_or(route).to_string(); + let auto_compress = bytes.len() < FILE_AUTO_COMPRESS_MAX_BYTES; + RequestOutcome::ReplyFile { + data: bytes, + metadata: Some(pack_file_name_metadata(&rel_name)), + auto_compress, } - Err(e) => { - tracing::warn!(error = %e, route = %route, "nomad file serve failed"); - RequestOutcome::Drop + } + Err(NomadError::NotFound(_)) => { + // Files have no Micron 404 body — drop silently (NomadNet parity). + shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); + RequestOutcome::Drop + } + Err(e) => { + tracing::warn!(error = %e, route = %route, "nomad file serve failed"); + RequestOutcome::Drop + } + } +} + +fn serve_media( + shared: &SharedState, + data: &[u8], + remote_identity: Option<&Identity>, +) -> RequestOutcome { + let media = match decode_media_request(data) { + Ok(m) => m, + Err(_) => return RequestOutcome::Drop, + }; + + let rel = media + .path + .trim() + .strip_prefix("/media/") + .unwrap_or(media.path.trim()) + .trim_start_matches('/'); + if rel.is_empty() { + return RequestOutcome::Drop; + } + + let basename = std::path::Path::new(rel) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + let ext_ok = std::path::Path::new(basename) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("webp")); + if !ext_ok { + return RequestOutcome::Drop; + } + + let abs = match resolve_under_root(&shared.store.roots().pages_dir, rel) { + Ok(p) => p, + Err(_) => return RequestOutcome::Drop, + }; + + // NomadNet checks absolute path length ≤ 512. + if abs.to_string_lossy().len() > 512 { + return RequestOutcome::Drop; + } + + if request_allowed(&abs, remote_identity, shared.allow_executable_pages) == AclDecision::Deny { + return RequestOutcome::Drop; + } + + match shared.store.read_media_rel(rel) { + Ok(bytes) => { + shared.stats.media_hits.fetch_add(1, Ordering::Relaxed); + let auto_compress = bytes.len() < FILE_AUTO_COMPRESS_MAX_BYTES; + RequestOutcome::ReplyFile { + data: bytes, + metadata: Some(pack_file_name_metadata(basename)), + auto_compress, } } - } else { - RequestOutcome::Drop + Err(NomadError::TooLarge { .. }) => RequestOutcome::Drop, + Err(NomadError::NotFound(_)) => { + shared.stats.not_found_count.fetch_add(1, Ordering::Relaxed); + RequestOutcome::Drop + } + Err(e) => { + tracing::warn!(error = %e, path = %rel, "nomad media serve failed"); + RequestOutcome::Drop + } } } #[cfg(test)] mod tests { use super::*; + use crate::request::encode_media_request; use crate::storage::NomadContentRoots; use rns_runtime::link_manager::RequestOutcome; use tempfile::TempDir; @@ -535,6 +723,15 @@ mod tests { dir: &TempDir, pages: &[(&str, &[u8])], files: &[(&str, &[u8])], + ) -> Arc { + shared_with_content_opts(dir, pages, files, false) + } + + fn shared_with_content_opts( + dir: &TempDir, + pages: &[(&str, &[u8])], + files: &[(&str, &[u8])], + allow_executable_pages: bool, ) -> Arc { let store = NomadContentStore::new(NomadContentRoots::under(dir.path())).unwrap(); for (path, body) in pages { @@ -549,6 +746,7 @@ mod tests { routes: RwLock::new(RouteTable::new()), stats: NomadServeStatsInner::new(), budget: RequestBudget::new(), + allow_executable_pages, }); { let mut routes = shared.routes.write().unwrap(); @@ -557,6 +755,21 @@ mod tests { shared } + fn call( + shared: &SharedState, + route_hash: [u8; 16], + data: Vec, + remote: Option, + ) -> RequestOutcome { + handle_request(shared, [0u8; 16], route_hash, data, remote) + } + + fn identity_with_hash(hash: [u8; 16]) -> Identity { + let mut id = Identity::new(); + id.hash = hash; + id + } + #[test] fn link_request_handler_serves_page_and_file() { let dir = TempDir::new().unwrap(); @@ -567,7 +780,7 @@ mod tests { ); let page_hash = path_hash("/page/index.mu"); - match handle_request(&shared, page_hash) { + match call(&shared, page_hash, Vec::new(), None) { RequestOutcome::Reply(bytes) => { assert_eq!(bytes, b"> Hello from host\n"); } @@ -575,7 +788,7 @@ mod tests { } let file_hash = path_hash("/file/readme.txt"); - match handle_request(&shared, file_hash) { + match call(&shared, file_hash, Vec::new(), None) { RequestOutcome::ReplyFile { data, metadata, @@ -606,7 +819,7 @@ mod tests { &[("index.mu", b"> ok\n")], &[("photos/pic.png", b"PNG")], ); - match handle_request(&shared, path_hash("/file/photos/pic.png")) { + match call(&shared, path_hash("/file/photos/pic.png"), Vec::new(), None) { RequestOutcome::ReplyFile { metadata, data, .. } => { assert_eq!(data, b"PNG"); let meta = metadata.expect("metadata"); @@ -625,7 +838,7 @@ mod tests { let before = shared.routes.read().unwrap().by_hash.len(); assert!(before >= 1); - match handle_request(&shared, [0u8; 16]) { + match call(&shared, [0u8; 16], Vec::new(), None) { RequestOutcome::Reply(bytes) => { let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Not found")); @@ -636,8 +849,7 @@ mod tests { let after = shared.routes.read().unwrap().by_hash.len(); assert_eq!(before, after, "soft-miss must not wipe the route table"); - // Registered page still serves without requiring a rebuild. - match handle_request(&shared, path_hash("/page/index.mu")) { + match call(&shared, path_hash("/page/index.mu"), Vec::new(), None) { RequestOutcome::Reply(bytes) => assert_eq!(bytes, b"> ok\n"), _ => panic!("expected page reply after unknown-hash miss"), } @@ -651,7 +863,7 @@ mod tests { let mut routes = shared.routes.write().unwrap(); routes.register("/file/gone.bin".into()).unwrap(); } - match handle_request(&shared, path_hash("/file/gone.bin")) { + match call(&shared, path_hash("/file/gone.bin"), Vec::new(), None) { RequestOutcome::Drop => {} _ => panic!("expected Drop for missing file"), } @@ -680,8 +892,6 @@ mod tests { fn request_budget_bounds_window_count() { let budget = RequestBudget::new(); for _ in 0..MAX_REQUESTS_PER_WINDOW { - // Drop immediately so in-flight stays under the concurrency cap; - // window_count still accumulates for the fixed window. assert!(budget.try_acquire().is_some()); } assert!(budget.try_acquire().is_none(), "must reject over window"); @@ -691,9 +901,8 @@ mod tests { fn link_request_handler_skips_unregistered_dotfile_routes() { let dir = TempDir::new().unwrap(); let shared = shared_with_content(&dir, &[("index.mu", b"> ok\n")], &[]); - // Forbidden routes are not registered; handler returns the not-found Micron page. let forbidden = path_hash("/page/.secret.mu"); - match handle_request(&shared, forbidden) { + match call(&shared, forbidden, Vec::new(), None) { RequestOutcome::Reply(bytes) => { let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Not found")); @@ -712,8 +921,7 @@ mod tests { .store .write_page_rel("extra.mu", b"> extra\n") .unwrap(); - // Not registered yet. - match handle_request(&shared, path_hash("/page/extra.mu")) { + match call(&shared, path_hash("/page/extra.mu"), Vec::new(), None) { RequestOutcome::Reply(bytes) => { assert!(String::from_utf8_lossy(&bytes).contains("Not found")); } @@ -723,7 +931,7 @@ mod tests { let mut routes = shared.routes.write().unwrap(); rebuild_routes(&mut routes, &shared.store).unwrap(); } - match handle_request(&shared, path_hash("/page/extra.mu")) { + match call(&shared, path_hash("/page/extra.mu"), Vec::new(), None) { RequestOutcome::Reply(bytes) => assert_eq!(bytes, b"> extra\n"), _ => panic!("expected reply after reload"), } @@ -804,4 +1012,263 @@ mod tests { } assert_eq!(shared_display_name(&shared), "recovered"); } + + #[test] + fn media_route_is_registered() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content(&dir, &[("index.mu", b"> ok\n")], &[]); + assert_eq!( + lookup_route(&shared, path_hash(MEDIA_ROUTE)).as_deref(), + Some(MEDIA_ROUTE) + ); + } + + #[test] + fn media_serves_webp_with_basename_metadata() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content( + &dir, + &[("index.mu", b"> ok\n"), ("img/Hero.WEBP", b"RIFFWEBP")], + &[], + ); + let body = encode_media_request("/media/img/Hero.WEBP"); + match call(&shared, path_hash(MEDIA_ROUTE), body, None) { + RequestOutcome::ReplyFile { + data, + metadata, + auto_compress, + } => { + assert_eq!(data, b"RIFFWEBP"); + assert!(auto_compress); + let meta = metadata.expect("basename metadata"); + let value = rmpv::decode::read_value(&mut &meta[..]).unwrap(); + let map = value.as_map().unwrap(); + assert_eq!(map[0].1.as_slice(), Some(b"Hero.WEBP".as_slice())); + } + other => panic!("expected ReplyFile, got {other:?}"), + } + assert_eq!(shared.stats.media_hits.load(Ordering::Relaxed), 1); + } + + #[test] + fn media_missing_key_drops() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content(&dir, &[("index.mu", b"> ok\n"), ("a.webp", b"x")], &[]); + let map = vec![( + rmpv::Value::String("path".into()), + rmpv::Value::String("a.webp".into()), + )]; + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).unwrap(); + match call(&shared, path_hash(MEDIA_ROUTE), buf, None) { + RequestOutcome::Drop => {} + _ => panic!("expected Drop when key missing"), + } + } + + #[test] + fn media_rejects_non_webp() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content(&dir, &[("index.mu", b"> ok\n"), ("a.png", b"PNG")], &[]); + let body = encode_media_request("a.png"); + match call(&shared, path_hash(MEDIA_ROUTE), body, None) { + RequestOutcome::Drop => {} + _ => panic!("expected Drop for non-webp"), + } + } + + #[test] + fn allowed_denies_anonymous_page_with_not_allowed_body() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content( + &dir, + &[("index.mu", b"> ok\n"), ("secret.mu", b"> no\n")], + &[], + ); + let hash = [0x42u8; 16]; + std::fs::write( + dir.path().join("pages/secret.mu.allowed"), + format!("{}\n", hex::encode(hash)), + ) + .unwrap(); + match call(&shared, path_hash("/page/secret.mu"), Vec::new(), None) { + RequestOutcome::Reply(bytes) => { + let body = String::from_utf8_lossy(&bytes); + assert!(body.contains("Request Not Allowed")); + } + _ => panic!("expected not-allowed micron"), + } + match call( + &shared, + path_hash("/page/secret.mu"), + Vec::new(), + Some(identity_with_hash(hash)), + ) { + RequestOutcome::Reply(bytes) => assert_eq!(bytes, b"> no\n"), + _ => panic!("expected allow for listed identity"), + } + } + + #[test] + fn allowed_denies_file_with_not_allowed_body() { + let dir = TempDir::new().unwrap(); + let shared = + shared_with_content(&dir, &[("index.mu", b"> ok\n")], &[("secret.bin", b"ABC")]); + let hash = [0x7au8; 16]; + std::fs::write( + dir.path().join("files/secret.bin.allowed"), + format!("{}\n", hex::encode(hash)), + ) + .unwrap(); + match call(&shared, path_hash("/file/secret.bin"), Vec::new(), None) { + RequestOutcome::Reply(bytes) => { + assert!(String::from_utf8_lossy(&bytes).contains("Request Not Allowed")); + } + _ => panic!("expected not-allowed micron for file deny"), + } + } + + #[test] + fn media_acl_deny_drops() { + let dir = TempDir::new().unwrap(); + let shared = + shared_with_content(&dir, &[("index.mu", b"> ok\n"), ("lock.webp", b"W")], &[]); + let hash = [0x99u8; 16]; + std::fs::write( + dir.path().join("pages/lock.webp.allowed"), + format!("{}\n", hex::encode(hash)), + ) + .unwrap(); + let body = encode_media_request("lock.webp"); + match call(&shared, path_hash(MEDIA_ROUTE), body, None) { + RequestOutcome::Drop => {} + _ => panic!("media ACL deny must Drop"), + } + let body = encode_media_request("lock.webp"); + match call( + &shared, + path_hash(MEDIA_ROUTE), + body, + Some(identity_with_hash(hash)), + ) { + RequestOutcome::ReplyFile { data, .. } => assert_eq!(data, b"W"), + _ => panic!("listed identity must receive media"), + } + } + + #[test] + #[cfg(unix)] + fn cgi_off_serves_executable_page_as_static() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let shared = shared_with_content(&dir, &[("index.mu", b"> ok\n")], &[]); + let script = dir.path().join("pages/dyn.mu"); + std::fs::write(&script, b"#!/bin/sh\necho SHOULD_NOT_RUN\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + { + let mut routes = shared.routes.write().unwrap(); + rebuild_routes(&mut routes, &shared.store).unwrap(); + } + match call(&shared, path_hash("/page/dyn.mu"), Vec::new(), None) { + RequestOutcome::Reply(bytes) => { + assert_eq!(bytes, b"#!/bin/sh\necho SHOULD_NOT_RUN\n"); + } + _ => panic!("CGI off must read static bytes"), + } + } + + #[test] + #[cfg(unix)] + fn cgi_on_runs_executable_page_with_fields() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let shared = shared_with_content_opts(&dir, &[("index.mu", b"> ok\n")], &[], true); + let script = dir.path().join("pages/dyn.mu"); + std::fs::write(&script, b"#!/bin/sh\nprintf '> %s\\n' \"$field_q\"\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + { + let mut routes = shared.routes.write().unwrap(); + rebuild_routes(&mut routes, &shared.store).unwrap(); + } + let mut fields = std::collections::BTreeMap::new(); + fields.insert("field_q".into(), "cgi-ok".into()); + let data = crate::request::encode_request_fields(&fields); + match call(&shared, path_hash("/page/dyn.mu"), data, None) { + RequestOutcome::Reply(bytes) => { + assert_eq!(bytes, b"> cgi-ok\n"); + } + other => panic!("expected CGI stdout reply, got {other:?}"), + } + } + + #[test] + #[cfg(unix)] + fn acl_runs_before_cgi() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let shared = shared_with_content_opts(&dir, &[("index.mu", b"> ok\n")], &[], true); + let script = dir.path().join("pages/dyn.mu"); + std::fs::write(&script, b"#!/bin/sh\necho ran\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + let hash = [0x55u8; 16]; + std::fs::write( + dir.path().join("pages/dyn.mu.allowed"), + format!("{}\n", hex::encode(hash)), + ) + .unwrap(); + { + let mut routes = shared.routes.write().unwrap(); + rebuild_routes(&mut routes, &shared.store).unwrap(); + } + match call(&shared, path_hash("/page/dyn.mu"), Vec::new(), None) { + RequestOutcome::Reply(bytes) => { + assert!(String::from_utf8_lossy(&bytes).contains("Request Not Allowed")); + } + _ => panic!("ACL must deny before CGI"), + } + } + + #[test] + #[cfg(unix)] + fn executable_allowed_runs_when_cgi_enabled() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let shared = shared_with_content_opts( + &dir, + &[("index.mu", b"> ok\n"), ("gate.mu", b"> in\n")], + &[], + true, + ); + let hash = [0x66u8; 16]; + let allowed = dir.path().join("pages/gate.mu.allowed"); + std::fs::write( + &allowed, + format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", hex::encode(hash)), + ) + .unwrap(); + let mut perms = std::fs::metadata(&allowed).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&allowed, perms).unwrap(); + match call( + &shared, + path_hash("/page/gate.mu"), + Vec::new(), + Some(identity_with_hash(hash)), + ) { + RequestOutcome::Reply(bytes) => assert_eq!(bytes, b"> in\n"), + other => panic!("expected allow via executable .allowed, got {other:?}"), + } + match call(&shared, path_hash("/page/gate.mu"), Vec::new(), None) { + RequestOutcome::Reply(bytes) => { + assert!(String::from_utf8_lossy(&bytes).contains("Request Not Allowed")); + } + _ => panic!("anonymous must still be denied"), + } + } } diff --git a/crates/nomad-core/src/paths.rs b/crates/nomad-core/src/paths.rs index 5496ec2..2acb235 100644 --- a/crates/nomad-core/src/paths.rs +++ b/crates/nomad-core/src/paths.rs @@ -18,6 +18,8 @@ pub const MAX_REL_PATH_BYTES: usize = 1024; pub const PAGE_PREFIX: &str = "/page/"; /// Wire prefix for file routes. pub const FILE_PREFIX: &str = "/file/"; +/// Exact wire route for in-page WebP media (NomadNet 1.4.1 `/media`). +pub const MEDIA_ROUTE: &str = "/media"; /// Default page registered when the pages tree is empty. pub const DEFAULT_INDEX_ROUTE: &str = "/page/index.mu"; @@ -283,6 +285,7 @@ mod tests { fn path_hash_is_truncated_sha256() { let h = path_hash("/page/index.mu"); assert_eq!(h, truncated_hash(b"/page/index.mu")); + assert_eq!(path_hash(MEDIA_ROUTE), truncated_hash(b"/media")); } #[test] diff --git a/crates/nomad-core/src/request.rs b/crates/nomad-core/src/request.rs index 2b9f426..d7e38cc 100644 --- a/crates/nomad-core/src/request.rs +++ b/crates/nomad-core/src/request.rs @@ -1,8 +1,7 @@ -//! Encode and decode NomadNet link request payloads (`field_*` / `var_*` MessagePack maps). +//! Encode and decode NomadNet link request payloads. //! -//! These helpers are available for callers that want to build or interpret form -//! bodies. The built-in [`crate::NomadNode`] request handler serves static -//! content only and currently ignores the request body. +//! Covers form `field_*` / `var_*` MessagePack maps and the `/media` request +//! body (`path` + `key`) used by NomadNet 1.4.1 in-page WebP fetches. use std::collections::BTreeMap; @@ -117,6 +116,83 @@ fn value_as_string(value: &rmpv::Value) -> Option { } } +/// Decoded `/media` request body (`path` required string; `key` present, often Nil). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaRequest { + /// Relative media path under `pages/` (may include a `/media/` prefix). + pub path: String, +} + +/// Encode a NomadNet `/media` request body: msgpack map `{path, key: nil}`. +pub fn encode_media_request(path: &str) -> Vec { + let map = vec![ + ( + rmpv::Value::String("path".into()), + rmpv::Value::String(path.into()), + ), + (rmpv::Value::String("key".into()), rmpv::Value::Nil), + ]; + let mut buf = Vec::new(); + if rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).is_err() { + return Vec::new(); + } + buf +} + +/// Decode a `/media` request body. +/// +/// Requires a MessagePack map containing **both** `"path"` (UTF-8 string) and `"key"` +/// (any value, including Nil). Missing either key, non-map input, or a `path` that is +/// not a MessagePack string yields [`NomadError::InvalidPath`] (caller should Drop). +pub fn decode_media_request(data: &[u8]) -> Result { + if data.len() > MAX_REQUEST_BODY_BYTES { + return Err(NomadError::TooLarge { + size: data.len(), + max: MAX_REQUEST_BODY_BYTES, + }); + } + let value = rmpv::decode::read_value_with_max_depth(&mut &*data, MAX_REQUEST_MSGPACK_DEPTH) + .map_err(|_| NomadError::InvalidPath("media request is not valid msgpack".into()))?; + let rmpv::Value::Map(map) = value else { + return Err(NomadError::InvalidPath( + "media request must be a msgpack map".into(), + )); + }; + + let mut path: Option = None; + let mut has_key = false; + for (k, v) in map { + let Some(name) = value_as_string(&k) else { + continue; + }; + match name.as_str() { + "path" => { + // Strict: only MessagePack strings (reject Binary / bool / int / nil). + let rmpv::Value::String(s) = v else { + return Err(NomadError::InvalidPath( + "media request path must be a msgpack string".into(), + )); + }; + let Some(p) = s.as_str().map(str::to_owned) else { + return Err(NomadError::InvalidPath( + "media request path is not valid UTF-8".into(), + )); + }; + path = Some(p); + } + "key" => has_key = true, + _ => {} + } + } + if !has_key { + return Err(NomadError::InvalidPath("media request missing key".into())); + } + let Some(path) = path else { + return Err(NomadError::InvalidPath("media request missing path".into())); + }; + Ok(MediaRequest { path }) +} + #[cfg(test)] mod tests { use super::*; @@ -318,4 +394,69 @@ mod tests { assert!(parsed.fields.is_empty()); assert_eq!(parsed.raw, buf); } + + #[test] + fn media_request_round_trips_with_nil_key() { + let encoded = encode_media_request("header.webp"); + let parsed = decode_media_request(&encoded).unwrap(); + assert_eq!(parsed.path, "header.webp"); + } + + #[test] + fn media_request_requires_key_field() { + let map = vec![( + rmpv::Value::String("path".into()), + rmpv::Value::String("a.webp".into()), + )]; + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).unwrap(); + let err = decode_media_request(&buf).unwrap_err(); + assert!(matches!(err, NomadError::InvalidPath(_))); + } + + #[test] + fn media_request_requires_path_field() { + let map = vec![(rmpv::Value::String("key".into()), rmpv::Value::Nil)]; + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).unwrap(); + assert!(decode_media_request(&buf).is_err()); + } + + #[test] + fn media_request_accepts_non_nil_key() { + let map = vec![ + ( + rmpv::Value::String("path".into()), + rmpv::Value::String("a.webp".into()), + ), + ( + rmpv::Value::String("key".into()), + rmpv::Value::String("unused".into()), + ), + ]; + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).unwrap(); + assert_eq!(decode_media_request(&buf).unwrap().path, "a.webp"); + } + + #[test] + fn media_request_rejects_non_string_path() { + for path_val in [ + rmpv::Value::Binary(b"a.webp".to_vec()), + rmpv::Value::Boolean(true), + rmpv::Value::Integer(1.into()), + rmpv::Value::Nil, + ] { + let map = vec![ + (rmpv::Value::String("path".into()), path_val), + (rmpv::Value::String("key".into()), rmpv::Value::Nil), + ]; + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(map)).unwrap(); + assert!( + decode_media_request(&buf).is_err(), + "non-string path must be rejected" + ); + } + } } diff --git a/crates/nomad-core/src/storage.rs b/crates/nomad-core/src/storage.rs index ae2ff0a..39c628b 100644 --- a/crates/nomad-core/src/storage.rs +++ b/crates/nomad-core/src/storage.rs @@ -157,6 +157,11 @@ impl NomadContentStore { read_rel(&self.roots.files_dir, self.roots.max_file_bytes, rel) } + /// Read a media asset from `pages/` using the file size cap (WebP `/media`). + pub fn read_media_rel(&self, rel: &str) -> Result, NomadError> { + read_rel(&self.roots.pages_dir, self.roots.max_file_bytes, rel) + } + /// Atomically write a file by content-relative path. pub fn write_file_rel(&self, rel: &str, content: &[u8]) -> Result<(), NomadError> { write_rel(