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
backend/target
dist
dist-next
dist-previous
.env
.env.local
.env.backup-*
.DS_Store
npm-debug.log*
vite-dev*.log
+3 -2
View File
@@ -41,8 +41,9 @@ Run the Rust backend separately:
npm run dev:backend
```
The backend listens on <http://localhost:6000> by default and reads the SQLite
databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`.
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`. Keep it bound to
`127.0.0.1` in production and let `tssbot-web` proxy public API requests.
## Production with PM2
+3 -1
View File
@@ -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 <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
+89 -57
View File
@@ -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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
.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<Vec<PeriodHisto
)
.map_err(db_error)?;
stmt.query_map(params![team_id], |row| {
let period: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
Ok(PeriodHistory {
period,
battles,
wins,
losses,
win_rate: percent(wins, battles),
let rows = stmt
.query_map(params![team_id], |row| {
let period: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
Ok(PeriodHistory {
period,
battles,
wins,
losses,
win_rate: percent(wins, battles),
})
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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)?;
stmt.query_map(params![team_id], |row| {
Ok(RatingPoint {
timestamp: row.get(0)?,
rating: row.get(1)?,
let rows = stmt
.query_map(params![team_id], |row| {
Ok(RatingPoint {
timestamp: row.get(0)?,
rating: row.get(1)?,
})
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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)?;
stmt.query_map(params![team_id], |row| {
let session_id: String = row.get(0)?;
let timestamp: i64 = row.get(1)?;
let mission_mode: Option<String> = 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<String> = 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::<Result<Vec<_>, _>>()
.map_err(db_error)
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.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<u16> {
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 {
let raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string());
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 = {
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',
},
+2
View File
@@ -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