Harden Rust backend deployment

This commit is contained in:
2026-05-29 21:56:12 +01:00
parent aef2113198
commit 7100f9c8e8
6 changed files with 133 additions and 60 deletions
+2
View File
@@ -1,9 +1,11 @@
node_modules node_modules
backend/target
dist dist
dist-next dist-next
dist-previous dist-previous
.env .env
.env.local .env.local
.env.backup-*
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
vite-dev*.log vite-dev*.log
+3 -2
View File
@@ -41,8 +41,9 @@ Run the Rust backend separately:
npm run dev:backend npm run dev:backend
``` ```
The backend listens on <http://localhost:6000> by default and reads the SQLite The backend listens on <http://127.0.0.1:6000> by default and reads the SQLite
databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`. 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 ## Production with PM2
+3 -1
View File
@@ -6,6 +6,8 @@ It reads two SQLite databases:
- `TSS_BATTLES_DB` for `tss_battles.db` - `TSS_BATTLES_DB` for `tss_battles.db`
- `TSS_TEAMS_DB` for `tss_teams.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. 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 npm run dev:backend
``` ```
The backend listens on <http://localhost:6000> by default. Override with `BACKEND_PORT`. The backend listens on <http://127.0.0.1:6000> by default. Override with `BACKEND_PORT` and `BACKEND_HOST`.
## Production build ## Production build
+89 -57
View File
@@ -1,6 +1,6 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, Method, StatusCode}, http::{header, HeaderValue, Method, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
Json, Router, Json, Router,
@@ -11,13 +11,13 @@ use serde_json::json;
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
env, fs, env, fs,
net::SocketAddr, net::{IpAddr, Ipv4Addr, SocketAddr},
path::{Path as FsPath, PathBuf}, path::{Path as FsPath, PathBuf},
sync::Arc, sync::Arc,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::{ use tower_http::{
cors::{Any, CorsLayer}, cors::{AllowOrigin, CorsLayer},
trace::TraceLayer, trace::TraceLayer,
}; };
@@ -273,6 +273,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init(); .init();
let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let port = env_u16("BACKEND_PORT") let port = env_u16("BACKEND_PORT")
.or_else(|| env_u16("PORT")) .or_else(|| env_u16("PORT"))
.unwrap_or(6000); .unwrap_or(6000);
@@ -292,13 +293,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.layer( .layer(
CorsLayer::new() CorsLayer::new()
.allow_methods([Method::GET]) .allow_methods([Method::GET])
.allow_origin(Any) .allow_origin(allowed_origins())
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]), .allow_headers([header::ACCEPT, header::CONTENT_TYPE]),
) )
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from((host, port));
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
tracing::info!("tssbot backend listening on http://{}", addr); tracing::info!("tssbot backend listening on http://{}", addr);
axum::serve(listener, app) axum::serve(listener, app)
@@ -727,22 +728,24 @@ fn period_history_for(conn: &Connection, team_id: i64) -> Result<Vec<PeriodHisto
) )
.map_err(db_error)?; .map_err(db_error)?;
stmt.query_map(params![team_id], |row| { let rows = stmt
let period: String = row.get(0)?; .query_map(params![team_id], |row| {
let battles: i64 = row.get(1)?; let period: String = row.get(0)?;
let wins: i64 = row.get(2)?; let battles: i64 = row.get(1)?;
let losses: i64 = row.get(3)?; let wins: i64 = row.get(2)?;
Ok(PeriodHistory { let losses: i64 = row.get(3)?;
period, Ok(PeriodHistory {
battles, period,
wins, battles,
losses, wins,
win_rate: percent(wins, battles), losses,
win_rate: percent(wins, battles),
})
}) })
}) .map_err(db_error)?
.map_err(db_error)? .collect::<Result<Vec<_>, _>>()
.collect::<Result<Vec<_>, _>>() .map_err(db_error)?;
.map_err(db_error) Ok(rows)
} }
fn rating_history_for(conn: &Connection, team_id: i64) -> Result<Vec<RatingPoint>, ApiError> { fn rating_history_for(conn: &Connection, team_id: i64) -> Result<Vec<RatingPoint>, ApiError> {
@@ -755,15 +758,17 @@ fn rating_history_for(conn: &Connection, team_id: i64) -> Result<Vec<RatingPoint
) )
.map_err(db_error)?; .map_err(db_error)?;
stmt.query_map(params![team_id], |row| { let rows = stmt
Ok(RatingPoint { .query_map(params![team_id], |row| {
timestamp: row.get(0)?, Ok(RatingPoint {
rating: row.get(1)?, timestamp: row.get(0)?,
rating: row.get(1)?,
})
}) })
}) .map_err(db_error)?
.map_err(db_error)? .collect::<Result<Vec<_>, _>>()
.collect::<Result<Vec<_>, _>>() .map_err(db_error)?;
.map_err(db_error) Ok(rows)
} }
fn games_for(conn: &Connection, team_id: i64) -> Result<Vec<GameRow>, ApiError> { fn games_for(conn: &Connection, team_id: i64) -> Result<Vec<GameRow>, ApiError> {
@@ -798,36 +803,38 @@ fn games_for(conn: &Connection, team_id: i64) -> Result<Vec<GameRow>, ApiError>
) )
.map_err(db_error)?; .map_err(db_error)?;
stmt.query_map(params![team_id], |row| { let rows = stmt
let session_id: String = row.get(0)?; .query_map(params![team_id], |row| {
let timestamp: i64 = row.get(1)?; let session_id: String = row.get(0)?;
let mission_mode: Option<String> = row.get(2)?; let timestamp: i64 = row.get(1)?;
Ok(GameRow { let mission_mode: Option<String> = row.get(2)?;
session_id, Ok(GameRow {
timestamp, session_id,
endtime_unix: timestamp, timestamp,
map_name: mission_mode.clone(), endtime_unix: timestamp,
mission_mode, map_name: mission_mode.clone(),
result: row.get(3)?, mission_mode,
player_count: row.get(4)?, result: row.get(3)?,
winning_team: row.get(14)?, player_count: row.get(4)?,
losing_team: row.get(15)?, winning_team: row.get(14)?,
stats: GameStats { losing_team: row.get(15)?,
ground_kills: row.get(5)?, stats: GameStats {
air_kills: row.get(6)?, ground_kills: row.get(5)?,
assists: row.get(7)?, air_kills: row.get(6)?,
captures: row.get(8)?, assists: row.get(7)?,
deaths: row.get(9)?, captures: row.get(8)?,
score: row.get(10)?, deaths: row.get(9)?,
missile_evades: row.get(11)?, score: row.get(10)?,
shell_interceptions: row.get(12)?, missile_evades: row.get(11)?,
team_kills_stat: row.get(13)?, shell_interceptions: row.get(12)?,
}, team_kills_stat: row.get(13)?,
},
})
}) })
}) .map_err(db_error)?
.map_err(db_error)? .collect::<Result<Vec<_>, _>>()
.collect::<Result<Vec<_>, _>>() .map_err(db_error)?;
.map_err(db_error) Ok(rows)
} }
fn validate_team_name(name: &str) -> Result<&str, ApiError> { fn validate_team_name(name: &str) -> Result<&str, ApiError> {
@@ -875,6 +882,31 @@ fn env_u16(key: &str) -> Option<u16> {
env::var(key).ok()?.parse().ok() env::var(key).ok()?.parse().ok()
} }
fn env_ip(key: &str) -> Option<IpAddr> {
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::<Vec<_>>();
if origins.is_empty() {
AllowOrigin::list(Vec::<HeaderValue>::new())
} else {
AllowOrigin::list(origins)
}
}
fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { 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 raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string());
let expanded = expand_home(&raw); let expanded = expand_home(&raw);
+34
View File
@@ -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 = { module.exports = {
apps: [ apps: [
{ {
@@ -52,6 +84,8 @@ module.exports = {
env: { env: {
NODE_ENV: 'production', NODE_ENV: 'production',
BACKEND_PORT: process.env.BACKEND_PORT || 6000, 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_BATTLES_DB: process.env.TSS_BATTLES_DB || 'tss_battles.db',
TSS_TEAMS_DB: process.env.TSS_TEAMS_DB || 'tss_teams.db', TSS_TEAMS_DB: process.env.TSS_TEAMS_DB || 'tss_teams.db',
}, },
+2
View File
@@ -6,6 +6,8 @@ API_UPSTREAM=http://127.0.0.1:6000
PUBLIC_ORIGIN=https://example.com PUBLIC_ORIGIN=https://example.com
BACKEND_PORT=6000 BACKEND_PORT=6000
BACKEND_HOST=127.0.0.1
BACKEND_ALLOWED_ORIGINS=https://example.com
TSS_BATTLES_DB=./tss_battles.db TSS_BATTLES_DB=./tss_battles.db
TSS_TEAMS_DB=./tss_teams.db TSS_TEAMS_DB=./tss_teams.db