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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to RustChan will be documented in this file.
---
## [1.1.0]

## 🌐 New: ChanNet API (Port 7070)

RustChan can now talk to other RustChans. Introducing the **ChanNet API** — a two-layer federation and gateway system living entirely on port 7070.

**Layer 1 — Federation** (`/chan/export`, `/chan/import`, `/chan/refresh`, `/chan/poll`): nodes sync with each other via ZIP snapshots. Push your posts out, pull theirs in, keep your mirror fresh.

**Layer 2 — RustWave Gateway** (`/chan/command`): the [RustWave](https://github.com/a2kiti/rustwave) audio transport client gets its own command interface. Send a typed JSON command, get a ZIP back. Supported commands: `full_export`, `board_export`, `thread_export`, `archive_export`, `force_refresh`, and `reply_push` (the only one that actually writes anything).

Text only — no images, no media, no binary data cross this interface by design. Full schema docs in `channet_api_reference.docx`.

---

## Architecture Refactor

This release restructures the codebase for maintainability. No user-facing
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.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ serde_json = "1"
# toml 1.0: serde parsing is built-in, no feature flag needed.
toml = "1.0"

argon2 = "0.5"
sha2 = "0.10"
argon2 = "0.5"
sha2 = "0.10"
subtle = "2"
hex = "0.4"
# rand_core 0.6 replaces rand = "0.8" entirely.
# We only need OsRng + RngCore, both of which live in rand_core.
Expand Down
4 changes: 2 additions & 2 deletions clippy_reports/summary.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
dev-check summary — Tue 17 Mar 2026 12:34:13 PDT
Duration: 163s
dev-check summary — Tue 17 Mar 2026 17:44:42 PDT
Duration: 177s
Passed: 11
Failed: 0
Skipped: 0
25 changes: 10 additions & 15 deletions src/chan_net/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,17 @@ pub async fn do_import(state: &AppState, bytes: bytes::Bytes) -> Result<usize, A
unpack_snapshot(&bytes).map_err(|e| AppError::BadRequest(e.to_string()))?;

// ── 2. Ed25519 signature check ───────────────────────────────────────────
// Verification is not yet implemented. If a signature is present we log a
// warning and continue rather than silently ignoring it. A future phase
// will verify the signature and reject snapshots that fail verification.
// Verification is not yet implemented. Reject any signed snapshot rather
// than silently accepting unverified data. A signed snapshot without
// verification offers zero authenticity guarantee and exposes the
// chan_net_posts table to arbitrary data injection.
//
// SECURITY NOTE: Accepting signed snapshots without verification means
// signature presence currently offers no authenticity guarantee. Do NOT
// promote this instance to production without completing Ed25519
// verification (see channet_build_plan.md § 6.3).
if let Some(ref sig) = metadata.signature {
tracing::warn!(
tx_id = %metadata.tx_id,
signature = %sig,
"Snapshot carries an Ed25519 signature — verification not yet \
implemented; signature will not be checked until Phase N. \
Proceeding without verification."
);
// This guard must be removed only when Phase N (Ed25519 verification) is
// fully implemented and tested (see channet_build_plan.md § 6.3).
if metadata.signature.is_some() {
return Err(AppError::BadRequest(
"Ed25519 signature verification is not yet implemented. Signed snapshots are rejected until Phase N is complete.".into(),
));
}

// ── 3. Ledger check — must happen BEFORE any DB write ───────────────────
Expand Down
44 changes: 42 additions & 2 deletions src/chan_net/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,39 @@ async fn json_body_limit_error(req: axum::http::Request<axum::body::Body>, next:
response
}

// ─── ChanNet API key middleware ───────────────────────────────────────────────

/// Middleware that enforces the pre-shared `X-ChanNet-Key` header on sensitive
/// `ChanNet` endpoints (/chan/refresh and /chan/poll).
///
/// FIX[High-9]: These endpoints were previously unauthenticated. Any process
/// that could reach the `ChanNet` bind address could trigger a full DB snapshot
/// push (refresh) or pull-and-import from a remote node (poll) with no
/// credentials. The API key is configured via `CHAN_NET_API_KEY` / settings.toml.
///
/// If `chan_net_api_key` is empty the request is rejected with 403 Forbidden
/// (the feature is intentionally disabled rather than wide open).
async fn verify_chan_api_key(req: axum::extract::Request, next: Next) -> Response {
use subtle::ConstantTimeEq as _;
let expected = &crate::config::CONFIG.chan_net_api_key;
if expected.is_empty() {
// API key not configured — refuse the request to prevent accidental
// exposure when an operator forgets to set the key.
return StatusCode::FORBIDDEN.into_response();
}
let provided = req
.headers()
.get("X-ChanNet-Key")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// Constant-time comparison to prevent timing side-channels.
if provided.as_bytes().ct_eq(expected.as_bytes()).into() {
next.run(req).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}

/// Build the `ChanNet` router.
///
/// All `/chan/*` routes are wired here. `DefaultBodyLimit` is applied
Expand Down Expand Up @@ -170,7 +203,14 @@ pub fn chan_router(state: AppState) -> Router {
"/chan/import",
post(import::chan_import).layer(DefaultBodyLimit::max(CONFIG.chan_net_max_body)),
)
.route("/chan/refresh", post(refresh::chan_refresh))
.route("/chan/poll", post(poll::chan_poll))
// FIX[High-9]: /chan/refresh and /chan/poll now require X-ChanNet-Key.
.route(
"/chan/refresh",
post(refresh::chan_refresh).layer(middleware::from_fn(verify_chan_api_key)),
)
.route(
"/chan/poll",
post(poll::chan_poll).layer(middleware::from_fn(verify_chan_api_key)),
)
.with_state(state)
}
11 changes: 11 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ struct SettingsFile {
/// Address to bind the second `ChanNet` TCP listener.
/// Default: 127.0.0.1:7070 (loopback-only; not exposed to the internet).
chan_net_bind: Option<String>,
/// Pre-shared API key required for /chan/refresh and /chan/poll endpoints.
/// Must be at least 32 characters. Leave empty to disable the endpoints.
/// Set via `CHAN_NET_API_KEY` environment variable or `settings.toml`.
chan_net_api_key: Option<String>,
}

fn load_settings_file() -> SettingsFile {
Expand Down Expand Up @@ -307,6 +311,9 @@ pub struct Config {
pub chan_net_max_body: usize,
/// Maximum request body size for `/chan/command` (raw JSON). Default: 8 KiB.
pub chan_net_command_max_body: usize,
/// Pre-shared key required on X-ChanNet-Key header for /chan/refresh and
/// /chan/poll. An empty string means those endpoints are disabled entirely.
pub chan_net_api_key: String,
}

impl Config {
Expand Down Expand Up @@ -474,6 +481,10 @@ impl Config {
chan_net_bind,
chan_net_max_body,
chan_net_command_max_body,
chan_net_api_key: std::env::var("CHAN_NET_API_KEY")
.ok()
.or(s.chan_net_api_key)
.unwrap_or_default(),
}
}

Expand Down
15 changes: 10 additions & 5 deletions src/db/boards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,13 +414,18 @@ pub fn delete_board(conn: &rusqlite::Connection, id: i64) -> Result<Vec<String>>

/// Per-board thread and post counts for the terminal stats display.
pub fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> {
// FIX[High-11]: Replace N+1 correlated subqueries (2 subqueries × boards)
// with a single LEFT JOIN … GROUP BY pass. For a forum with 20 boards the
// old query executed 41 SQL statements; this executes 1.
let Ok(mut stmt) = conn.prepare(
"SELECT b.short_name, \
(SELECT COUNT(*) FROM threads WHERE board_id = b.id) AS tc, \
(SELECT COUNT(*) FROM posts p \
JOIN threads t ON p.thread_id = t.id \
WHERE t.board_id = b.id) AS pc \
FROM boards b ORDER BY b.short_name",
COUNT(DISTINCT t.id) AS tc, \
COUNT(DISTINCT p.id) AS pc \
FROM boards b \
LEFT JOIN threads t ON t.board_id = b.id \
LEFT JOIN posts p ON p.thread_id = t.id \
GROUP BY b.id \
ORDER BY b.short_name",
) else {
return vec![];
};
Expand Down
11 changes: 8 additions & 3 deletions src/db/chan_net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,17 @@ pub fn insert_board_if_absent(conn: &Connection, short_name: &str, title: &str)
return Ok(id);
}

conn.execute(
// FIX[High-7]: Use INSERT … RETURNING id instead of last_insert_rowid().
// last_insert_rowid() is connection-local; in a multi-connection pool another
// write on the same connection between the INSERT and this call would return
// the wrong row ID.
let id: i64 = conn.query_row(
"INSERT INTO boards (short_name, title, description, nsfw, max_threads, bump_limit)
VALUES (?1, ?2, '', 0, 100, 300)",
VALUES (?1, ?2, '', 0, 100, 300) RETURNING id",
rusqlite::params![short_name, title],
|r| r.get(0),
)?;
Ok(conn.last_insert_rowid())
Ok(id)
}

// ── insert_post_if_absent ─────────────────────────────────────────────────────
Expand Down
83 changes: 40 additions & 43 deletions src/db/posts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ pub fn verify_deletion_token(
/// # Errors
/// Returns an error if the database operation fails.
pub fn edit_post(
conn: &rusqlite::Connection,
conn: &mut rusqlite::Connection,
post_id: i64,
token: &str,
new_body: &str,
Expand All @@ -382,54 +382,51 @@ pub fn edit_post(
edit_window_secs
};

// BEGIN IMMEDIATE acquires the write lock now, preventing any concurrent
// writer from modifying the post between our SELECT and UPDATE.
conn.execute_batch("BEGIN IMMEDIATE")
// FIX[High-6]: Use rusqlite's typed transaction API instead of raw
// execute_batch("BEGIN IMMEDIATE"). Raw execute_batch does not update
// rusqlite's internal transaction-tracking state; a subsequent call to
// unchecked_transaction() on the same pooled connection would attempt to
// start a nested transaction on top of an already-committed one, causing
// SQLITE_ERROR: cannot start a transaction within a transaction.
let tx = conn
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.context("Failed to begin IMMEDIATE transaction for edit_post")?;

let result: Result<bool> = (|| {
// FIX[HIGH-2]: Fetch token and created_at in a single round-trip.
let row: Option<(String, i64)> = conn
.query_row(
"SELECT deletion_token, created_at FROM posts WHERE id = ?1",
params![post_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;

let Some((stored_token, created_at)) = row else {
return Ok(false); // post does not exist
};

if !constant_time_eq(stored_token.as_bytes(), token.as_bytes()) {
return Ok(false);
}

let now = chrono::Utc::now().timestamp();
if now.saturating_sub(created_at) > window {
return Ok(false);
}
// FIX[HIGH-2]: Fetch token and created_at in a single round-trip inside
// the IMMEDIATE transaction so no concurrent writer can modify the post
// between our SELECT and UPDATE.
let row: Option<(String, i64)> = tx
.query_row(
"SELECT deletion_token, created_at FROM posts WHERE id = ?1",
params![post_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.context("Failed to query post for edit")?;

conn.execute(
"UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4",
params![new_body, new_body_html, now, post_id],
)?;
let Some((stored_token, created_at)) = row else {
return Ok(false); // post does not exist
};

// Belt-and-suspenders: confirm the row was actually written.
Ok(conn.changes() > 0)
})();
if !constant_time_eq(stored_token.as_bytes(), token.as_bytes()) {
return Ok(false);
}

match result {
Ok(updated) => {
conn.execute_batch("COMMIT")
.context("Failed to commit edit_post transaction")?;
Ok(updated)
}
Err(e) => {
let _ = conn.execute_batch("ROLLBACK");
Err(e)
}
let now = chrono::Utc::now().timestamp();
if now.saturating_sub(created_at) > window {
return Ok(false);
}

tx.execute(
"UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4",
params![new_body, new_body_html, now, post_id],
)
.context("Failed to update post body")?;

let updated = tx.changes() > 0;
tx.commit()
.context("Failed to commit edit_post transaction")?;
Ok(updated)
}

/// Constant-time byte slice comparison to prevent timing side-channel attacks.
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/admin/moderation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ pub async fn admin_ban_and_delete(
"ban",
None,
&board_short,
&format!("inline ban — ip_hash={reason}… reason={}", &ip_hash_log),
&format!("inline ban — ip_hash={}… reason={reason}", &ip_hash_log),
);

// Delete post (or whole thread if OP)
Expand Down
Loading
Loading