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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +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.
env:
RSRETICULUM_REF: d6d59dc6b506f13d8a0707e887716f847c7ac07f
RSRETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6

jobs:
test:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Mapping:

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 **4 MiB** for files.
size caps are **512 KiB** for pages and **32 MiB** for files.

**Trust model:** content directories are trusted local storage. Operators must
ensure they are not writable by untrusted local users. Symlink components are
Expand All @@ -194,6 +194,10 @@ rescan the filesystem — call `reload_routes()` after content CRUD.
currently ignores the request body (static hosting only)
- 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": <relative path>}` (NomadNet `serve_file`
parity). Images and other binaries are ordinary files under `files/`; there is
no `/image/` route or MIME layer on the wire
- 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)
Expand All @@ -206,7 +210,7 @@ rescan the filesystem — call `reload_routes()` after content CRUD.
| Area | Current behavior |
| --- | --- |
| Static pages | Serve `.mu` (and other text) from `pages/` with 512 KiB default cap |
| Static files | Serve binaries from `files/` with 4 MiB default cap |
| Static files | Serve binaries from `files/` with 32 MiB default cap as response Resources with filename metadata |
| Announce | Startup + periodic + transport reannounce with display name |
| Form payload decode | Helper only (`decode_request_fields`); not wired into serving |
| Default index | Placeholder Micron page when `index.mu` is missing |
Expand Down Expand Up @@ -236,11 +240,12 @@ 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. Upstream Resource filename metadata improvements in rsReticulum if needed
5. Transfer repository ownership to the Ratspeak organization when permissions allow
4. Transfer repository ownership to the Ratspeak organization when permissions allow

Application-layer CMS, chat rooms, and forums belong in clients such as
mesh-client, not in this protocol crate.
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).

## Contributing

Expand Down
6 changes: 5 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ static hosting release used by mesh-client (#613).
- 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)
- `/file/...` response Resource filename metadata (`ReplyFile`, NomadNet
`serve_file` parity); default file cap 32 MiB

## Near-term

Expand All @@ -27,7 +29,6 @@ static hosting release used by mesh-client (#613).
- 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
- Resource response filename metadata parity (may require rsReticulum upstream)
- Async / `spawn_blocking` serve path if LinkManager gains an async handler API

## Later (application / mesh-client)
Expand All @@ -38,12 +39,15 @@ These belong in clients such as mesh-client, not in the protocol crate:
- Theme and navigation editors
- 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

## Explicit non-goals (v1)

- CGI / executable `.mu` page scripts (arbitrary code execution risk)
- Embedding hosting inside `rsLXMF`
- Depending on non-Ratspeak RNS stacks (`nomadnet-rs` / `rns-net`)
- Server-side MIME/`/image/` routes (images are ordinary `/file/...` binaries)

## Ownership

Expand Down
54 changes: 49 additions & 5 deletions crates/nomad-core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};

use rns_identity::identity::Identity;
use rns_runtime::link_manager::{LinkManager, RequestOutcome, register_destination};
use rns_runtime::link_manager::{
LinkManager, RequestOutcome, pack_file_name_metadata, register_destination,
};
use rns_transport::messages::TransportMessage;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
Expand All @@ -35,6 +37,9 @@ const MAX_REQUESTS_PER_WINDOW: u64 = 60;
const REQUEST_WINDOW: Duration = Duration::from_secs(10);
/// Timeout for awaited announce sends on the periodic ticker.
const ANNOUNCE_SEND_TIMEOUT: Duration = Duration::from_secs(5);
/// NomadNet registers file handlers with `auto_compress = 32_000_000`
/// (compress responses under this size).
const FILE_AUTO_COMPRESS_MAX_BYTES: usize = 32_000_000;

/// Configuration for [`NomadNode::spawn`].
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -493,7 +498,16 @@ fn handle_request(shared: &SharedState, path_hash_bytes: [u8; 16]) -> RequestOut
match shared.store.read_file_route(&route) {
Ok(bytes) => {
shared.stats.file_hits.fetch_add(1, Ordering::Relaxed);
RequestOutcome::Reply(bytes)
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).
Expand Down Expand Up @@ -562,10 +576,20 @@ mod tests {

let file_hash = path_hash("/file/readme.txt");
match handle_request(&shared, file_hash) {
RequestOutcome::Reply(bytes) => {
assert_eq!(bytes, b"file-bytes");
RequestOutcome::ReplyFile {
data,
metadata,
auto_compress,
} => {
assert_eq!(data, b"file-bytes");
assert!(auto_compress);
let meta = metadata.expect("file responses include filename metadata");
let value = rmpv::decode::read_value(&mut &meta[..]).unwrap();
let map = value.as_map().expect("metadata map");
assert_eq!(map[0].0.as_str(), Some("name"));
assert_eq!(map[0].1.as_slice(), Some(b"readme.txt".as_slice()));
}
_ => panic!("expected file reply from link request handler"),
_ => panic!("expected ReplyFile from link request handler"),
}

let stats = shared.stats.snapshot();
Expand All @@ -574,6 +598,26 @@ mod tests {
assert_eq!(stats.request_count, 2);
}

#[test]
fn file_reply_preserves_nested_relative_name() {
let dir = TempDir::new().unwrap();
let shared = shared_with_content(
&dir,
&[("index.mu", b"> ok\n")],
&[("photos/pic.png", b"PNG")],
);
match handle_request(&shared, path_hash("/file/photos/pic.png")) {
RequestOutcome::ReplyFile { metadata, data, .. } => {
assert_eq!(data, b"PNG");
let meta = metadata.expect("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"photos/pic.png".as_slice()));
}
_ => panic!("expected ReplyFile for nested file"),
}
}

#[test]
fn unknown_path_hash_does_not_clear_or_rescan_routes() {
let dir = TempDir::new().unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/nomad-core/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ use crate::paths::{

/// Default max page body (matches mesh-client client limit).
pub const DEFAULT_MAX_PAGE_BYTES: usize = 512 * 1024;
/// Default max file body (matches mesh-client client limit).
pub const DEFAULT_MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
/// Default max file body (NomadNet `auto_compress = 32_000_000` bound).
pub const DEFAULT_MAX_FILE_BYTES: usize = 32 * 1024 * 1024;
/// Cap directory walk size to bound enumeration DoS.
pub const MAX_LISTED_ENTRIES: usize = 10_000;
/// Cap recursion depth when listing content.
Expand Down