Files
Skald-Circle/crates/skald-relay-server/src/config.rs
T
2026-07-10 15:02:09 +01:00

167 lines
6.4 KiB
Rust

//! Runtime configuration, read from the environment (relay.md §7). Sensible
//! defaults so the relay boots with zero config in local dev.
//!
//! | Env var | Meaning | Default |
//! |------------------|------------------------------------------------------|-------------------------------|
//! | `RELAY_BIND` | full `ip:port` to listen on | `0.0.0.0:8080` |
//! | `PORT` | port only (used if no RELAY_BIND) | — |
//! | `RELAY_DB` | SQLite file path | `data/relay.db` |
//! | `APNS_KEY_PATH` | (push-live) JSON file with team/key/PEM | `./config/apns-key.json` |
//! | `APNS_BUNDLE_ID` | (push-live) iOS bundle id (used as `apns-topic`) | — (required when push-live) |
//! | `APNS_SANDBOX` | (push-live) `1`/`true` → api.sandbox.push.apple.com | `0` (production) |
use std::net::SocketAddr;
use crate::limits::{
PIPE_IDLE_TIMEOUT_SECS, PIPE_MAX_BPS_DEFAULT, PIPE_MAX_FRAME_BYTES, PIPE_MAX_PER_NS,
PIPE_PENDING_TTL_SECS,
};
/// Tunables for the `/v1/pipe` data plane (docs/relay/pipe.md §2.3). Defaults in
/// [`crate::limits`]; each is overridable via the matching `RELAY_PIPE_*` env var.
#[derive(Debug, Clone)]
pub struct PipeConfig {
/// Per-connection, per-direction bandwidth cap in bytes/sec. `0` = unlimited.
pub max_bps: u64,
/// Max concurrent pipes (pending + matched) per namespace.
pub max_per_ns: usize,
/// Half-open pending TTL (seconds).
pub pending_ttl_secs: u64,
/// Idle (no-bytes) timeout on a matched pipe (seconds).
pub idle_timeout_secs: u64,
/// Max data-plane WS frame size (bytes).
pub max_frame_bytes: usize,
}
impl Default for PipeConfig {
fn default() -> Self {
Self {
max_bps: PIPE_MAX_BPS_DEFAULT,
max_per_ns: PIPE_MAX_PER_NS,
pending_ttl_secs: PIPE_PENDING_TTL_SECS,
idle_timeout_secs: PIPE_IDLE_TIMEOUT_SECS,
max_frame_bytes: PIPE_MAX_FRAME_BYTES,
}
}
}
impl PipeConfig {
fn from_env() -> PipeConfig {
fn env_parse<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key).ok().and_then(|s| s.parse().ok()).unwrap_or(default)
}
let d = PipeConfig::default();
PipeConfig {
max_bps: env_parse("RELAY_PIPE_MAX_BPS", d.max_bps),
max_per_ns: env_parse("RELAY_PIPE_MAX_PER_NS", d.max_per_ns),
pending_ttl_secs: env_parse("RELAY_PIPE_PENDING_TTL_SECS", d.pending_ttl_secs),
idle_timeout_secs: env_parse("RELAY_PIPE_IDLE_TIMEOUT_SECS", d.idle_timeout_secs),
max_frame_bytes: env_parse("RELAY_PIPE_MAX_FRAME_BYTES", d.max_frame_bytes),
}
}
}
/// APNs configuration, populated from `config/apns-key.json` and env vars when
/// the `push-live` cargo feature is on. The PEM is already newline-decoded by
/// `serde_json` so it can be passed straight to `jsonwebtoken`.
#[cfg(feature = "push-live")]
#[derive(Debug, Clone)]
pub struct ApnsConfig {
pub team_id: String,
pub key_id: String,
/// PEM-encoded PKCS#8 EC private key (P-256), with real newlines.
pub private_key_pem: String,
/// Bundle ID for the iOS app (used as `apns-topic`).
pub bundle_id: String,
/// If true, send to api.sandbox.push.apple.com.
pub sandbox: bool,
}
#[derive(Debug, Clone)]
pub struct Config {
pub bind: SocketAddr,
pub db_path: String,
/// Pipe data-plane tunables (docs/relay/pipe.md §2.3).
pub pipe: PipeConfig,
/// `None` ⇒ the relay falls back to [`LogPusher`] (relay still boots).
#[cfg(feature = "push-live")]
pub apns: Option<ApnsConfig>,
}
impl Config {
pub fn from_env() -> Config {
let default_bind: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let bind = std::env::var("RELAY_BIND")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| {
std::env::var("PORT")
.ok()
.and_then(|p| format!("0.0.0.0:{p}").parse().ok())
})
.unwrap_or(default_bind);
let db_path = std::env::var("RELAY_DB").unwrap_or_else(|_| "data/relay.db".into());
Config {
bind,
db_path,
pipe: PipeConfig::from_env(),
#[cfg(feature = "push-live")]
apns: ApnsConfig::load_from_env(),
}
}
}
#[cfg(feature = "push-live")]
impl ApnsConfig {
/// Read `APNS_KEY_PATH` (default `./config/apns-key.json`), `APNS_BUNDLE_ID`,
/// and `APNS_SANDBOX`. Returns `None` if anything required is missing — the
/// caller in `AppState::build` logs a generic warning and falls back to
/// `LogPusher`.
pub fn load_from_env() -> Option<ApnsConfig> {
let path = std::env::var("APNS_KEY_PATH")
.unwrap_or_else(|_| "./config/apns-key.json".into());
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return None, // silent: caller will warn at fallback point
};
#[derive(serde::Deserialize)]
struct KeyFile {
team_id: String,
key_id: String,
private_key: String,
}
let parsed: KeyFile = match serde_json::from_str(&raw) {
Ok(k) => k,
Err(e) => {
tracing::warn!(
target: "relay::push",
path = %path,
error = %e,
"apns key file is not valid JSON; APNs disabled"
);
return None;
}
};
let bundle_id = match std::env::var("APNS_BUNDLE_ID") {
Ok(b) if !b.is_empty() => b,
_ => {
tracing::warn!(
target: "relay::push",
"APNS_BUNDLE_ID not set; APNs disabled"
);
return None;
}
};
let sandbox = matches!(
std::env::var("APNS_SANDBOX").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE")
);
Some(ApnsConfig {
team_id: parsed.team_id,
key_id: parsed.key_id,
private_key_pem: parsed.private_key,
bundle_id,
sandbox,
})
}
}