fix(access): restrict public clients to read-only
Co-Authored-By: GitHub Copilot <noreply@github.com>
This commit is contained in:
parent
a9055b8218
commit
0332783a78
|
|
@ -6,6 +6,7 @@ Notable changes to this project are documented here.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Public clients are view-only: the UI hides editing controls and the server rejects remote writes; localhost clients retain editing access.
|
||||||
- Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint.
|
- Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint.
|
||||||
- The world can be split into shards that each own a strip of columns. People and animals that walk across a shard border are transferred with their inventory and stay reachable at the same address.
|
- The world can be split into shards that each own a strip of columns. People and animals that walk across a shard border are transferred with their inventory and stay reachable at the same address.
|
||||||
- Jobs and trading: every shard founds markets, farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at the market, get paid and go home.
|
- Jobs and trading: every shard founds markets, farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at the market, get paid and go home.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ The world is split into vertical strips of columns called shards. Each shard is
|
||||||
|
|
||||||
People, animals and carts that cross a shard border are migrated with their whole state and inventory into the neighbouring shard's Picard actor system. A cluster-wide directory maps each entity address to its current shard and actor path, so world messages continue to reach it after migration without actor-owned forwarding timers or transfer messages. Shards connect directly through Picard's authenticated remoting endpoint (`/remoting`); the gateway continues to use `/control` for simulation coordination. The gateway generates a shared cluster token and passes it to its shard processes for peer authentication.
|
People, animals and carts that cross a shard border are migrated with their whole state and inventory into the neighbouring shard's Picard actor system. A cluster-wide directory maps each entity address to its current shard and actor path, so world messages continue to reach it after migration without actor-owned forwarding timers or transfer messages. Shards connect directly through Picard's authenticated remoting endpoint (`/remoting`); the gateway continues to use `/control` for simulation coordination. The gateway generates a shared cluster token and passes it to its shard processes for peer authentication.
|
||||||
|
|
||||||
|
## Public access
|
||||||
|
|
||||||
|
Clients outside localhost can view the simulation but cannot edit it: the UI hides editing controls, and the server rejects remote write requests and WebSocket edit commands. When exposing the app through a reverse proxy, keep the app listener on loopback and ensure the proxy sets `X-Forwarded-For` or `X-Real-IP`; forwarded client addresses are trusted only when the proxy connects from loopback.
|
||||||
|
|
||||||
## Economy
|
## Economy
|
||||||
|
|
||||||
Every shard founds markets and workplaces: farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at a market, get paid and go home. Farmers sow fields that ripen into crops; gatherers fell trees, quarry stone and hunt; crafters buy materials and make tools and carts. Markets are actors with their own coins and stock; prices follow supply and demand, and buyers and sellers haggle. Merchants carry surplus goods to markets on other shards. Markets, workplaces, carts and crop fields are drawn on the map, and the side panel shows each person's job and coins.
|
Every shard founds markets and workplaces: farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at a market, get paid and go home. Farmers sow fields that ripen into crops; gatherers fell trees, quarry stone and hunt; crafters buy materials and make tools and carts. Markets are actors with their own coins and stock; prices follow supply and demand, and buyers and sellers haggle. Merchants carry surplus goods to markets on other shards. Markets, workplaces, carts and crop fields are drawn on the map, and the side panel shows each person's job and coins.
|
||||||
|
|
|
||||||
95
src/main.rs
95
src/main.rs
|
|
@ -4,14 +4,15 @@ mod world;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{DefaultBodyLimit, Path, Request, State};
|
use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Request, State};
|
||||||
use axum::http::{StatusCode, header};
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Json, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use cluster::gateway::{GatewayOptions, Msg, Outputs};
|
use cluster::gateway::{GatewayOptions, Msg, Outputs};
|
||||||
use cluster::shard::ShardOptions;
|
use cluster::shard::ShardOptions;
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
|
|
@ -36,7 +37,7 @@ struct App {
|
||||||
tx: mpsc::UnboundedSender<Msg>,
|
tx: mpsc::UnboundedSender<Msg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejects cross-site requests (including WebSocket upgrades), which could otherwise edit the world.
|
/// Rejects cross-site requests and remote write requests.
|
||||||
async fn same_origin(req: Request, next: Next) -> Response {
|
async fn same_origin(req: Request, next: Next) -> Response {
|
||||||
let h = req.headers();
|
let h = req.headers();
|
||||||
if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
|
if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
|
||||||
|
|
@ -49,9 +50,42 @@ async fn same_origin(req: Request, next: Next) -> Response {
|
||||||
return StatusCode::FORBIDDEN.into_response();
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let peer = req
|
||||||
|
.extensions()
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|ConnectInfo(address)| *address);
|
||||||
|
if !can_edit(peer, h)
|
||||||
|
&& (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws")
|
||||||
|
{
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn can_edit(peer: Option<SocketAddr>, headers: &HeaderMap) -> bool {
|
||||||
|
let Some(peer) = peer else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !peer.ip().is_loopback() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_ips = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.or_else(|| headers.get("x-real-ip"));
|
||||||
|
let Some(client_ips) = client_ips else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Ok(client_ips) = client_ips.to_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let addresses = client_ips.split(',').map(str::trim).collect::<Vec<_>>();
|
||||||
|
!addresses.is_empty()
|
||||||
|
&& addresses
|
||||||
|
.iter()
|
||||||
|
.all(|address| address.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback()))
|
||||||
|
}
|
||||||
|
|
||||||
fn json(body: String) -> impl IntoResponse {
|
fn json(body: String) -> impl IntoResponse {
|
||||||
([(header::CONTENT_TYPE, "application/json")], body)
|
([(header::CONTENT_TYPE, "application/json")], body)
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +114,10 @@ async fn state(State(app): State<App>) -> impl IntoResponse {
|
||||||
json(app.state.borrow().to_string())
|
json(app.state.borrow().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn access(ConnectInfo(peer): ConnectInfo<SocketAddr>, headers: HeaderMap) -> Json<bool> {
|
||||||
|
Json(can_edit(Some(peer), &headers))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_script(State(app): State<App>) -> String {
|
async fn get_script(State(app): State<App>) -> String {
|
||||||
app.script.lock().unwrap().clone()
|
app.script.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
@ -121,8 +159,14 @@ async fn post_edit(State(app): State<App>, body: String) -> StatusCode {
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ws(upgrade: WebSocketUpgrade, State(app): State<App>) -> Response {
|
async fn ws(
|
||||||
upgrade.on_upgrade(move |socket| client(socket, app))
|
upgrade: WebSocketUpgrade,
|
||||||
|
State(app): State<App>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let can_edit = can_edit(Some(peer), &headers);
|
||||||
|
upgrade.on_upgrade(move |socket| client(socket, app, can_edit))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "console")]
|
#[cfg(feature = "console")]
|
||||||
|
|
@ -188,7 +232,7 @@ async fn picard_client(mut socket: WebSocket, app: App, shard: usize) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes every new snapshot to the client; text frames from the client are edit commands.
|
/// Pushes every new snapshot to the client; text frames from the client are edit commands.
|
||||||
async fn client(mut socket: WebSocket, app: App) {
|
async fn client(mut socket: WebSocket, app: App, can_edit: bool) {
|
||||||
let mut rx = app.state.clone();
|
let mut rx = app.state.clone();
|
||||||
loop {
|
loop {
|
||||||
let snapshot = rx.borrow_and_update().to_string();
|
let snapshot = rx.borrow_and_update().to_string();
|
||||||
|
|
@ -204,7 +248,7 @@ async fn client(mut socket: WebSocket, app: App) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
msg = socket.recv() => match msg {
|
msg = socket.recv() => match msg {
|
||||||
Some(Ok(Message::Text(t))) if t.len() <= 256 => {
|
Some(Ok(Message::Text(t))) if can_edit && t.len() <= 256 => {
|
||||||
let _ = app.tx.send(Msg::Edit(t.as_str().to_owned()));
|
let _ = app.tx.send(Msg::Edit(t.as_str().to_owned()));
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_)) | Err(_)) | None => return,
|
Some(Ok(Message::Close(_)) | Err(_)) | None => return,
|
||||||
|
|
@ -344,6 +388,7 @@ async fn main() {
|
||||||
};
|
};
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
|
.route("/api/access", get(access))
|
||||||
.route("/api/terrain", get(terrain))
|
.route("/api/terrain", get(terrain))
|
||||||
.route("/api/state", get(state))
|
.route("/api/state", get(state))
|
||||||
.route("/api/script", get(get_script).post(post_script))
|
.route("/api/script", get(get_script).post(post_script))
|
||||||
|
|
@ -360,5 +405,37 @@ async fn main() {
|
||||||
.with_state(app);
|
.with_state(app);
|
||||||
|
|
||||||
println!("Simulation running at http://{addr} with {shards} shards");
|
println!("Simulation running at http://{addr} with {shards} shards");
|
||||||
axum::serve(listener, router).await.unwrap();
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod access_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_clients_can_edit_without_a_proxy_header() {
|
||||||
|
let peer = "127.0.0.1:7878".parse().unwrap();
|
||||||
|
assert!(can_edit(Some(peer), &HeaderMap::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwarded_remote_clients_are_read_only() {
|
||||||
|
let peer = "127.0.0.1:7878".parse().unwrap();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-forwarded-for", "203.0.113.9".parse().unwrap());
|
||||||
|
assert!(!can_edit(Some(peer), &headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_peers_cannot_spoof_a_loopback_forwarded_address() {
|
||||||
|
let peer = "203.0.113.8:7878".parse().unwrap();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-forwarded-for", "127.0.0.1".parse().unwrap());
|
||||||
|
assert!(!can_edit(Some(peer), &headers));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,14 @@ fn App() -> impl IntoView {
|
||||||
let tool = RwSignal::new("inspect".to_string());
|
let tool = RwSignal::new("inspect".to_string());
|
||||||
let param = RwSignal::new(String::new());
|
let param = RwSignal::new(String::new());
|
||||||
let amount = RwSignal::new("5".to_string());
|
let amount = RwSignal::new("5".to_string());
|
||||||
|
let can_edit = RwSignal::new(false);
|
||||||
// (selection counter, people at the time of selection); the counter forces editor rows to rebuild.
|
// (selection counter, people at the time of selection); the counter forces editor rows to rebuild.
|
||||||
let editing = RwSignal::new((0u64, Vec::<Person>::new()));
|
let editing = RwSignal::new((0u64, Vec::<Person>::new()));
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
can_edit.set(api::get_json::<bool>("/api/access").await.unwrap_or(false));
|
||||||
|
});
|
||||||
|
|
||||||
// The server pushes a snapshot per tick; terrain is refetched only when its revision changes.
|
// The server pushes a snapshot per tick; terrain is refetched only when its revision changes.
|
||||||
let last_rev = StoredValue::new(None::<u32>);
|
let last_rev = StoredValue::new(None::<u32>);
|
||||||
api::connect(move |text| {
|
api::connect(move |text| {
|
||||||
|
|
@ -333,6 +338,10 @@ fn App() -> impl IntoView {
|
||||||
)}</div>
|
)}</div>
|
||||||
}
|
}
|
||||||
}))}
|
}))}
|
||||||
|
<Show
|
||||||
|
when=move || can_edit.get()
|
||||||
|
fallback=|| view! { <p class="readonly">"View-only access"</p> }
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<button on:click=move |_| {
|
<button on:click=move |_| {
|
||||||
api::edit(format!("pause {}", u8::from(!paused())));
|
api::edit(format!("pause {}", u8::from(!paused())));
|
||||||
|
|
@ -465,6 +474,7 @@ fn App() -> impl IntoView {
|
||||||
|
|
||||||
<h3>"Behaviour script (Rune)"</h3>
|
<h3>"Behaviour script (Rune)"</h3>
|
||||||
<ScriptEditor />
|
<ScriptEditor />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<h3>"Events"</h3>
|
<h3>"Events"</h3>
|
||||||
<div class="log">
|
<div class="log">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue