Sidecar: auth token for the website-facing API
config.rs loads all runtime settings from an external sidecar.toml (path via $UOLINK_CONFIG), with env-var overrides (UOLINK_WEB_TOKEN, UOLINK_WEB_BIND, UOLINK_SHARD_BIND, UOLINK_DB_PATH). Nothing is compiled into the binary. On first run the file is generated with a random 24-byte auth token, so the sidecar is secured out of the box and the operator just copies the token to the website. An axum middleware rejects any request to a non-/health route that does not present the token, as Authorization: Bearer, X-Api-Key, or ?token= (the last so browser WebSocket clients, which cannot set handshake headers, can authenticate). The comparison is constant-time. An empty token disables auth and is only tolerated on a loopback bind; binding to 0.0.0.0 with no token logs a warning. Verified: /health open (200); /history 401 without a token, 401 with a wrong one, 200 with the right one via either Bearer or X-Api-Key; an authed shard query falls through to 503 when no shard is connected; WS rejected (401) with a bad ?token= and upgraded (101) with the right one. sidecar.toml is gitignored (holds the secret); sidecar.toml.example is committed as the reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,14 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -30,11 +33,13 @@ pub struct AppState {
|
||||
pub shard: ShardHandle,
|
||||
pub rpc: Rpc,
|
||||
pub store: Store,
|
||||
/// Shared secret the website must present. Empty disables auth (loopback dev only).
|
||||
pub token: Arc<String>,
|
||||
}
|
||||
|
||||
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
// Everything except /health is behind the auth check.
|
||||
let protected = Router::new()
|
||||
.route("/ws", get(ws_upgrade))
|
||||
// Queries (shard reply correlated by reqId).
|
||||
.route("/char/:account/:slot", get(char_by_slot))
|
||||
@@ -49,6 +54,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
// History, read from SQLite rather than the shard.
|
||||
.route("/history", get(history))
|
||||
.route("/economy", get(economy))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.merge(protected)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
@@ -61,6 +71,62 @@ async fn health() -> impl IntoResponse {
|
||||
"ok"
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
|
||||
/// Rejects any request that does not present the configured token. Skipped entirely when no token
|
||||
/// is configured (loopback dev). The token may arrive as `Authorization: Bearer <t>`, `X-Api-Key:
|
||||
/// <t>`, or `?token=<t>` (the last so browser WebSocket clients, which can't set headers, still
|
||||
/// authenticate).
|
||||
async fn auth(State(st): State<AppState>, req: Request, next: Next) -> Response {
|
||||
if st.token.is_empty() {
|
||||
return next.run(req).await; // auth disabled
|
||||
}
|
||||
|
||||
let provided = extract_token(&req);
|
||||
match provided {
|
||||
Some(t) if constant_time_eq(t.as_bytes(), st.token.as_bytes()) => next.run(req).await,
|
||||
_ => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "missing or invalid auth token"})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_token(req: &Request) -> Option<String> {
|
||||
// Authorization: Bearer <token>
|
||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
// X-Api-Key: <token>
|
||||
if let Some(v) = req.headers().get("x-api-key").and_then(|h| h.to_str().ok()) {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
// ?token=<token>
|
||||
if let Some(q) = req.uri().query() {
|
||||
for pair in q.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("token=") {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Length-independent, early-return-free comparison, so a wrong token leaks no timing signal.
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
// ---- shared reply handling ----
|
||||
|
||||
/// Turns an RPC result into an HTTP response. A `bridge.error` reply from the shard becomes a 4xx;
|
||||
|
||||
Reference in New Issue
Block a user