From 7100f9c8e811b2e8e8db446a9939728c53350468 Mon Sep 17 00:00:00 2001 From: Heidi Date: Fri, 29 May 2026 21:56:12 +0100 Subject: [PATCH] Harden Rust backend deployment --- .gitignore | 2 + README.md | 5 +- backend/README.md | 4 +- backend/src/main.rs | 146 ++++++++++++++++++++++++++----------------- ecosystem.config.cjs | 34 ++++++++++ example.env | 2 + 6 files changed, 133 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index 99df0d8..eadc0df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ node_modules +backend/target dist dist-next dist-previous .env .env.local +.env.backup-* .DS_Store npm-debug.log* vite-dev*.log diff --git a/README.md b/README.md index dc78abb..db013f7 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ Run the Rust backend separately: npm run dev:backend ``` -The backend listens on by default and reads the SQLite -databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`. +The backend listens on by default and reads the SQLite +databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`. Keep it bound to +`127.0.0.1` in production and let `tssbot-web` proxy public API requests. ## Production with PM2 diff --git a/backend/README.md b/backend/README.md index 94bf14f..b4e7295 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,6 +6,8 @@ It reads two SQLite databases: - `TSS_BATTLES_DB` for `tss_battles.db` - `TSS_TEAMS_DB` for `tss_teams.db` +- `BACKEND_HOST` bind host, default `127.0.0.1` +- `BACKEND_ALLOWED_ORIGINS` comma-separated browser origins allowed by CORS Both paths can be absolute or relative to the repo root when run through the root scripts/PM2. @@ -25,7 +27,7 @@ It currently exposes: npm run dev:backend ``` -The backend listens on by default. Override with `BACKEND_PORT`. +The backend listens on by default. Override with `BACKEND_PORT` and `BACKEND_HOST`. ## Production build diff --git a/backend/src/main.rs b/backend/src/main.rs index 4e2ab30..ecaf589 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, Query, State}, - http::{header, Method, StatusCode}, + http::{header, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, @@ -11,13 +11,13 @@ use serde_json::json; use std::{ collections::BTreeMap, env, fs, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, sync::Arc, }; use tokio::net::TcpListener; use tower_http::{ - cors::{Any, CorsLayer}, + cors::{AllowOrigin, CorsLayer}, trace::TraceLayer, }; @@ -273,6 +273,7 @@ async fn main() -> Result<(), Box> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); + let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); let port = env_u16("BACKEND_PORT") .or_else(|| env_u16("PORT")) .unwrap_or(6000); @@ -292,13 +293,13 @@ async fn main() -> Result<(), Box> { .layer( CorsLayer::new() .allow_methods([Method::GET]) - .allow_origin(Any) + .allow_origin(allowed_origins()) .allow_headers([header::ACCEPT, header::CONTENT_TYPE]), ) .layer(TraceLayer::new_for_http()) .with_state(state); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::from((host, port)); let listener = TcpListener::bind(addr).await?; tracing::info!("tssbot backend listening on http://{}", addr); axum::serve(listener, app) @@ -727,22 +728,24 @@ fn period_history_for(conn: &Connection, team_id: i64) -> Result, _>>() - .map_err(db_error) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) } fn rating_history_for(conn: &Connection, team_id: i64) -> Result, ApiError> { @@ -755,15 +758,17 @@ fn rating_history_for(conn: &Connection, team_id: i64) -> Result, _>>() - .map_err(db_error) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) } fn games_for(conn: &Connection, team_id: i64) -> Result, ApiError> { @@ -798,36 +803,38 @@ fn games_for(conn: &Connection, team_id: i64) -> Result, ApiError> ) .map_err(db_error)?; - stmt.query_map(params![team_id], |row| { - let session_id: String = row.get(0)?; - let timestamp: i64 = row.get(1)?; - let mission_mode: Option = row.get(2)?; - Ok(GameRow { - session_id, - timestamp, - endtime_unix: timestamp, - map_name: mission_mode.clone(), - mission_mode, - result: row.get(3)?, - player_count: row.get(4)?, - winning_team: row.get(14)?, - losing_team: row.get(15)?, - stats: GameStats { - ground_kills: row.get(5)?, - air_kills: row.get(6)?, - assists: row.get(7)?, - captures: row.get(8)?, - deaths: row.get(9)?, - score: row.get(10)?, - missile_evades: row.get(11)?, - shell_interceptions: row.get(12)?, - team_kills_stat: row.get(13)?, - }, + let rows = stmt + .query_map(params![team_id], |row| { + let session_id: String = row.get(0)?; + let timestamp: i64 = row.get(1)?; + let mission_mode: Option = row.get(2)?; + Ok(GameRow { + session_id, + timestamp, + endtime_unix: timestamp, + map_name: mission_mode.clone(), + mission_mode, + result: row.get(3)?, + player_count: row.get(4)?, + winning_team: row.get(14)?, + losing_team: row.get(15)?, + stats: GameStats { + ground_kills: row.get(5)?, + air_kills: row.get(6)?, + assists: row.get(7)?, + captures: row.get(8)?, + deaths: row.get(9)?, + score: row.get(10)?, + missile_evades: row.get(11)?, + shell_interceptions: row.get(12)?, + team_kills_stat: row.get(13)?, + }, + }) }) - }) - .map_err(db_error)? - .collect::, _>>() - .map_err(db_error) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) } fn validate_team_name(name: &str) -> Result<&str, ApiError> { @@ -875,6 +882,31 @@ fn env_u16(key: &str) -> Option { env::var(key).ok()?.parse().ok() } +fn env_ip(key: &str) -> Option { + env::var(key).ok()?.parse().ok() +} + +fn allowed_origins() -> AllowOrigin { + let origins = env::var("BACKEND_ALLOWED_ORIGINS") + .or_else(|_| env::var("PUBLIC_ORIGIN")) + .unwrap_or_default() + .split(',') + .filter_map(|origin| { + let origin = origin.trim(); + if origin.is_empty() { + return None; + } + HeaderValue::from_str(origin).ok() + }) + .collect::>(); + + if origins.is_empty() { + AllowOrigin::list(Vec::::new()) + } else { + AllowOrigin::list(origins) + } +} + fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { let raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string()); let expanded = expand_home(&raw); diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 0a42fdc..df920b3 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,3 +1,35 @@ +const fs = require('node:fs') +const path = require('node:path') + +function loadEnvFile() { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) return + + const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex === -1) continue + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (key && (!process.env[key] || process.env[key] === '')) { + process.env[key] = value + } + } +} + +loadEnvFile() + module.exports = { apps: [ { @@ -52,6 +84,8 @@ module.exports = { env: { NODE_ENV: 'production', BACKEND_PORT: process.env.BACKEND_PORT || 6000, + BACKEND_HOST: process.env.BACKEND_HOST || '127.0.0.1', + BACKEND_ALLOWED_ORIGINS: process.env.BACKEND_ALLOWED_ORIGINS || process.env.PUBLIC_ORIGIN || '', TSS_BATTLES_DB: process.env.TSS_BATTLES_DB || 'tss_battles.db', TSS_TEAMS_DB: process.env.TSS_TEAMS_DB || 'tss_teams.db', }, diff --git a/example.env b/example.env index fedee9a..6fc456c 100644 --- a/example.env +++ b/example.env @@ -6,6 +6,8 @@ API_UPSTREAM=http://127.0.0.1:6000 PUBLIC_ORIGIN=https://example.com BACKEND_PORT=6000 +BACKEND_HOST=127.0.0.1 +BACKEND_ALLOWED_ORIGINS=https://example.com TSS_BATTLES_DB=./tss_battles.db TSS_TEAMS_DB=./tss_teams.db