Format the sidecar source with `cargo fmt` so the new `cargo fmt --check` CI gate passes on the first run. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
223 lines
6.5 KiB
Rust
223 lines
6.5 KiB
Rust
//! Runtime configuration, loaded from an external file — nothing here is compiled into the binary.
|
|
//!
|
|
//! Precedence: environment variables override the file, the file overrides built-in defaults. On
|
|
//! first run, if the file is absent, a default one is written with a freshly generated auth token,
|
|
//! so the sidecar is secured out of the box and the operator just copies the token to the website.
|
|
//!
|
|
//! File path: `$UOLINK_CONFIG`, else `sidecar.toml` in the working directory.
|
|
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use serde::Deserialize;
|
|
use tracing::info;
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct Config {
|
|
#[serde(default)]
|
|
pub shard: ShardCfg,
|
|
#[serde(default)]
|
|
pub web: WebCfg,
|
|
#[serde(default)]
|
|
pub store: StoreCfg,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ShardCfg {
|
|
#[serde(default = "default_shard_bind")]
|
|
pub bind: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct WebCfg {
|
|
#[serde(default = "default_web_bind")]
|
|
pub bind: String,
|
|
/// Shared secret the website must present. Empty means the web surface is unauthenticated —
|
|
/// only acceptable when `bind` is loopback; refused otherwise (see `Config::validate`).
|
|
#[serde(default)]
|
|
pub auth_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct StoreCfg {
|
|
#[serde(default = "default_db_path")]
|
|
pub path: String,
|
|
}
|
|
|
|
fn default_shard_bind() -> String {
|
|
"127.0.0.1:7788".into()
|
|
}
|
|
fn default_web_bind() -> String {
|
|
"127.0.0.1:8080".into()
|
|
}
|
|
fn default_db_path() -> String {
|
|
"uo-link.db".into()
|
|
}
|
|
|
|
impl Default for ShardCfg {
|
|
fn default() -> Self {
|
|
Self {
|
|
bind: default_shard_bind(),
|
|
}
|
|
}
|
|
}
|
|
impl Default for WebCfg {
|
|
fn default() -> Self {
|
|
Self {
|
|
bind: default_web_bind(),
|
|
auth_token: String::new(),
|
|
}
|
|
}
|
|
}
|
|
impl Default for StoreCfg {
|
|
fn default() -> Self {
|
|
Self {
|
|
path: default_db_path(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load() -> anyhow::Result<Self> {
|
|
let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into());
|
|
let existed = Path::new(&path).exists();
|
|
|
|
let mut cfg: Config = if existed {
|
|
let text = fs::read_to_string(&path)?;
|
|
toml::from_str(&text)?
|
|
} else {
|
|
Config::default()
|
|
};
|
|
|
|
cfg.apply_env();
|
|
|
|
// Authentication is always on. A blank token is never allowed — if none is set (fresh
|
|
// install, or someone cleared it), generate one, save it, and continue. This keeps setup
|
|
// effortless while making it impossible to accidentally run with auth off.
|
|
if cfg.web.auth_token.trim().is_empty() {
|
|
let token = generate_token();
|
|
|
|
if existed {
|
|
persist_token(&path, &token)?;
|
|
} else {
|
|
fs::write(&path, default_file(&token))?;
|
|
}
|
|
|
|
cfg.web.auth_token = token.clone();
|
|
|
|
info!("No auth token configured.");
|
|
info!("Generated new token: {}", token);
|
|
info!("Saved to {}. Authentication is on.", path);
|
|
}
|
|
|
|
Ok(cfg)
|
|
}
|
|
|
|
/// Environment overrides, so a deployment can set secrets without editing the file.
|
|
fn apply_env(&mut self) {
|
|
if let Ok(v) = env::var("UOLINK_SHARD_BIND") {
|
|
self.shard.bind = v;
|
|
}
|
|
if let Ok(v) = env::var("UOLINK_WEB_BIND") {
|
|
self.web.bind = v;
|
|
}
|
|
if let Ok(v) = env::var("UOLINK_WEB_TOKEN") {
|
|
self.web.auth_token = v;
|
|
}
|
|
if let Ok(v) = env::var("UOLINK_DB_PATH") {
|
|
self.store.path = v;
|
|
}
|
|
}
|
|
|
|
pub fn auth_required(&self) -> bool {
|
|
// Always true now — load() guarantees a non-empty token.
|
|
!self.web.auth_token.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls
|
|
/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent.
|
|
fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
|
let text = fs::read_to_string(path)?;
|
|
let line = format!("auth_token = \"{token}\"");
|
|
|
|
if text
|
|
.lines()
|
|
.any(|l| l.trim_start().starts_with("auth_token"))
|
|
{
|
|
let out: String = text
|
|
.lines()
|
|
.map(|l| {
|
|
if l.trim_start().starts_with("auth_token") {
|
|
line.clone()
|
|
} else {
|
|
l.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
fs::write(path, out + "\n")?;
|
|
} else if text.lines().any(|l| l.trim() == "[web]") {
|
|
let out: String = text
|
|
.lines()
|
|
.flat_map(|l| {
|
|
if l.trim() == "[web]" {
|
|
vec![l.to_string(), line.clone()]
|
|
} else {
|
|
vec![l.to_string()]
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
fs::write(path, out + "\n")?;
|
|
} else {
|
|
fs::write(path, format!("{text}\n[web]\n{line}\n"))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_token() -> String {
|
|
let mut buf = [0u8; 24];
|
|
// OS randomness; falls back to a time-seeded token only if the OS RNG is unavailable.
|
|
if getrandom::getrandom(&mut buf).is_err() {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
return format!("insecure-fallback-{nanos:x}");
|
|
}
|
|
buf.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
fn default_file(token: &str) -> String {
|
|
format!(
|
|
r#"# uo-link sidecar configuration.
|
|
# Read at startup. Nothing here is compiled into the binary. Environment variables
|
|
# (UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN, UOLINK_DB_PATH) override these.
|
|
|
|
[shard]
|
|
# Loopback address the shard dials out to. Keep this on localhost — the game must
|
|
# not be reachable from anywhere else.
|
|
bind = "127.0.0.1:7788"
|
|
|
|
[web]
|
|
# Address the website connects to (WebSocket + REST).
|
|
# 127.0.0.1:8080 -> same host only
|
|
# 0.0.0.0:8080 -> accept remote clients (then auth_token is mandatory)
|
|
bind = "127.0.0.1:8080"
|
|
|
|
# Shared secret the website must present on every request:
|
|
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
|
|
# WebSocket: add ?token=<token> to the connect URL
|
|
# Authentication is always on: if this is left blank, the sidecar generates a new
|
|
# token here on startup. Rotate by changing this value and restarting.
|
|
auth_token = "{token}"
|
|
|
|
[store]
|
|
path = "uo-link.db"
|
|
"#
|
|
)
|
|
}
|