chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
350
crates/quicprochat-server/src/config.rs
Normal file
350
crates/quicprochat-server/src/config.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const DEFAULT_LISTEN: &str = "0.0.0.0:7000";
|
||||
pub const DEFAULT_DATA_DIR: &str = "data";
|
||||
pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der";
|
||||
pub const DEFAULT_TLS_KEY: &str = "data/server-key.der";
|
||||
pub const DEFAULT_STORE_BACKEND: &str = "file";
|
||||
pub const DEFAULT_DB_PATH: &str = "data/qpc.db";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct FileConfig {
|
||||
pub listen: Option<String>,
|
||||
pub data_dir: Option<String>,
|
||||
pub tls_cert: Option<PathBuf>,
|
||||
pub tls_key: Option<PathBuf>,
|
||||
pub auth_token: Option<String>,
|
||||
pub allow_insecure_auth: Option<bool>,
|
||||
/// When true, enqueue does not require an identity-bound session: only a valid token is required.
|
||||
/// The server does not associate the request with a specific sender (Sealed Sender).
|
||||
#[serde(default)]
|
||||
pub sealed_sender: Option<bool>,
|
||||
pub store_backend: Option<String>,
|
||||
pub db_path: Option<PathBuf>,
|
||||
pub db_key: Option<String>,
|
||||
/// Metrics HTTP listen address (e.g. "0.0.0.0:9090"). If set, /metrics is served there.
|
||||
pub metrics_listen: Option<String>,
|
||||
/// When true and metrics_listen is set, start the metrics server.
|
||||
#[serde(default)]
|
||||
pub metrics_enabled: Option<bool>,
|
||||
pub federation: Option<FederationFileConfig>,
|
||||
/// Directory containing plugin `.so` / `.dylib` files to load at startup.
|
||||
pub plugin_dir: Option<PathBuf>,
|
||||
/// When true, audit logs hash identity key prefixes and omit payload sizes.
|
||||
#[serde(default)]
|
||||
pub redact_logs: Option<bool>,
|
||||
/// WebSocket JSON-RPC bridge listen address (e.g. "0.0.0.0:9000").
|
||||
pub ws_listen: Option<String>,
|
||||
/// WebTransport (HTTP/3) listen address for browser clients (e.g. "0.0.0.0:7443").
|
||||
pub webtransport_listen: Option<String>,
|
||||
/// Graceful shutdown drain timeout in seconds.
|
||||
pub drain_timeout_secs: Option<u64>,
|
||||
/// Default per-RPC timeout in seconds.
|
||||
pub rpc_timeout_secs: Option<u64>,
|
||||
/// Storage/database operation timeout in seconds.
|
||||
pub storage_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EffectiveConfig {
|
||||
pub listen: String,
|
||||
pub data_dir: String,
|
||||
pub tls_cert: PathBuf,
|
||||
pub tls_key: PathBuf,
|
||||
pub auth_token: Option<String>,
|
||||
pub allow_insecure_auth: bool,
|
||||
/// When true, enqueue does not require identity; valid token only (Sealed Sender).
|
||||
pub sealed_sender: bool,
|
||||
pub store_backend: String,
|
||||
pub db_path: PathBuf,
|
||||
pub db_key: String,
|
||||
/// If Some(addr), metrics server listens here (e.g. "0.0.0.0:9090").
|
||||
pub metrics_listen: Option<String>,
|
||||
/// Start metrics server only when true and metrics_listen is set.
|
||||
pub metrics_enabled: bool,
|
||||
pub federation: Option<EffectiveFederationConfig>,
|
||||
/// Directory to scan for plugin `.so` / `.dylib` files at startup. None = no plugins.
|
||||
pub plugin_dir: Option<PathBuf>,
|
||||
/// When true, audit logs hash identity key prefixes and omit payload sizes.
|
||||
pub redact_logs: bool,
|
||||
/// WebSocket JSON-RPC bridge listen address. If set, the bridge is started.
|
||||
pub ws_listen: Option<String>,
|
||||
/// WebTransport (HTTP/3) listen address. If set, the WebTransport endpoint is started.
|
||||
pub webtransport_listen: Option<String>,
|
||||
/// Graceful shutdown drain timeout in seconds.
|
||||
pub drain_timeout_secs: u64,
|
||||
/// Default per-RPC timeout in seconds.
|
||||
pub rpc_timeout_secs: u64,
|
||||
/// Storage/database operation timeout in seconds.
|
||||
pub storage_timeout_secs: u64,
|
||||
}
|
||||
|
||||
pub const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 30;
|
||||
pub const DEFAULT_RPC_TIMEOUT_SECS: u64 = 30;
|
||||
pub const DEFAULT_STORAGE_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct FederationFileConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub domain: Option<String>,
|
||||
pub listen: Option<String>,
|
||||
pub federation_cert: Option<PathBuf>,
|
||||
pub federation_key: Option<PathBuf>,
|
||||
pub federation_ca: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub peers: Vec<FederationPeerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FederationPeerConfig {
|
||||
pub domain: String,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // federation not yet wired up
|
||||
pub struct EffectiveFederationConfig {
|
||||
pub enabled: bool,
|
||||
pub domain: String,
|
||||
pub listen: String,
|
||||
pub federation_cert: PathBuf,
|
||||
pub federation_key: PathBuf,
|
||||
pub federation_ca: PathBuf,
|
||||
pub peers: Vec<FederationPeerConfig>,
|
||||
}
|
||||
|
||||
pub fn load_config(path: Option<&Path>) -> anyhow::Result<FileConfig> {
|
||||
let path = match path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => PathBuf::from("qpc-server.toml"),
|
||||
};
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(FileConfig::default());
|
||||
}
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("read config file {path:?}"))?;
|
||||
let cfg: FileConfig =
|
||||
toml::from_str(&contents).with_context(|| format!("parse config file {path:?}"))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig {
|
||||
let listen = if args.listen == DEFAULT_LISTEN {
|
||||
file.listen
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_LISTEN.to_string())
|
||||
} else {
|
||||
args.listen.clone()
|
||||
};
|
||||
|
||||
let data_dir = if args.data_dir == DEFAULT_DATA_DIR {
|
||||
file.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_DATA_DIR.to_string())
|
||||
} else {
|
||||
args.data_dir.clone()
|
||||
};
|
||||
|
||||
let tls_cert = if args.tls_cert == Path::new(DEFAULT_TLS_CERT) {
|
||||
file.tls_cert
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_CERT))
|
||||
} else {
|
||||
args.tls_cert.clone()
|
||||
};
|
||||
|
||||
let tls_key = if args.tls_key == Path::new(DEFAULT_TLS_KEY) {
|
||||
file.tls_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_KEY))
|
||||
} else {
|
||||
args.tls_key.clone()
|
||||
};
|
||||
|
||||
let auth_token = if args.auth_token.is_some() {
|
||||
args.auth_token.clone()
|
||||
} else {
|
||||
file.auth_token.clone()
|
||||
};
|
||||
|
||||
let allow_insecure_auth = if args.allow_insecure_auth {
|
||||
true
|
||||
} else {
|
||||
file.allow_insecure_auth.unwrap_or(false)
|
||||
};
|
||||
|
||||
let sealed_sender = args.sealed_sender || file.sealed_sender.unwrap_or(false);
|
||||
|
||||
let store_backend = if args.store_backend == DEFAULT_STORE_BACKEND {
|
||||
file.store_backend
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_STORE_BACKEND.to_string())
|
||||
} else {
|
||||
args.store_backend.clone()
|
||||
};
|
||||
|
||||
let db_path = if args.db_path == Path::new(DEFAULT_DB_PATH) {
|
||||
file.db_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH))
|
||||
} else {
|
||||
args.db_path.clone()
|
||||
};
|
||||
|
||||
let db_key = if args.db_key.is_empty() {
|
||||
file.db_key.clone().unwrap_or_else(|| args.db_key.clone())
|
||||
} else {
|
||||
args.db_key.clone()
|
||||
};
|
||||
|
||||
let metrics_listen = args
|
||||
.metrics_listen
|
||||
.clone()
|
||||
.or_else(|| file.metrics_listen.clone());
|
||||
let metrics_enabled = args
|
||||
.metrics_enabled
|
||||
.or(file.metrics_enabled)
|
||||
.unwrap_or(metrics_listen.is_some());
|
||||
|
||||
let federation = {
|
||||
let file_fed = file.federation.as_ref();
|
||||
let enabled = args.federation_enabled
|
||||
|| file_fed.and_then(|f| f.enabled).unwrap_or(false);
|
||||
|
||||
if enabled {
|
||||
let domain = args.federation_domain.clone()
|
||||
.or_else(|| file_fed.and_then(|f| f.domain.clone()))
|
||||
.unwrap_or_default();
|
||||
let listen_fed = args.federation_listen.clone()
|
||||
.or_else(|| file_fed.and_then(|f| f.listen.clone()))
|
||||
.unwrap_or_else(|| "0.0.0.0:7001".to_string());
|
||||
let federation_cert = file_fed.and_then(|f| f.federation_cert.clone())
|
||||
.unwrap_or_else(|| PathBuf::from("data/federation-cert.der"));
|
||||
let federation_key = file_fed.and_then(|f| f.federation_key.clone())
|
||||
.unwrap_or_else(|| PathBuf::from("data/federation-key.der"));
|
||||
let federation_ca = file_fed.and_then(|f| f.federation_ca.clone())
|
||||
.unwrap_or_else(|| PathBuf::from("data/federation-ca.der"));
|
||||
let peers = file_fed
|
||||
.map(|f| f.peers.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(EffectiveFederationConfig {
|
||||
enabled,
|
||||
domain,
|
||||
listen: listen_fed,
|
||||
federation_cert,
|
||||
federation_key,
|
||||
federation_ca,
|
||||
peers,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let plugin_dir = args.plugin_dir.clone().or_else(|| file.plugin_dir.clone());
|
||||
let redact_logs = args.redact_logs || file.redact_logs.unwrap_or(false);
|
||||
let ws_listen = args
|
||||
.ws_listen
|
||||
.clone()
|
||||
.or_else(|| file.ws_listen.clone());
|
||||
|
||||
let webtransport_listen = args
|
||||
.webtransport_listen
|
||||
.clone()
|
||||
.or_else(|| file.webtransport_listen.clone());
|
||||
|
||||
let drain_timeout_secs = if args.drain_timeout == DEFAULT_DRAIN_TIMEOUT_SECS {
|
||||
file.drain_timeout_secs.unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS)
|
||||
} else {
|
||||
args.drain_timeout
|
||||
};
|
||||
let rpc_timeout_secs = if args.rpc_timeout == DEFAULT_RPC_TIMEOUT_SECS {
|
||||
file.rpc_timeout_secs.unwrap_or(DEFAULT_RPC_TIMEOUT_SECS)
|
||||
} else {
|
||||
args.rpc_timeout
|
||||
};
|
||||
let storage_timeout_secs = if args.storage_timeout == DEFAULT_STORAGE_TIMEOUT_SECS {
|
||||
file.storage_timeout_secs.unwrap_or(DEFAULT_STORAGE_TIMEOUT_SECS)
|
||||
} else {
|
||||
args.storage_timeout
|
||||
};
|
||||
|
||||
EffectiveConfig {
|
||||
listen,
|
||||
data_dir,
|
||||
tls_cert,
|
||||
tls_key,
|
||||
auth_token,
|
||||
allow_insecure_auth,
|
||||
sealed_sender,
|
||||
store_backend,
|
||||
db_path,
|
||||
db_key,
|
||||
metrics_listen,
|
||||
metrics_enabled,
|
||||
federation,
|
||||
plugin_dir,
|
||||
redact_logs,
|
||||
ws_listen,
|
||||
webtransport_listen,
|
||||
drain_timeout_secs,
|
||||
rpc_timeout_secs,
|
||||
storage_timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result<()> {
|
||||
if effective.allow_insecure_auth {
|
||||
anyhow::bail!("production forbids --allow-insecure-auth");
|
||||
}
|
||||
let token = effective
|
||||
.auth_token
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("production requires QPQ_AUTH_TOKEN (non-empty)")
|
||||
})?;
|
||||
if token == "devtoken" {
|
||||
anyhow::bail!(
|
||||
"production forbids auth_token 'devtoken'; set a strong QPQ_AUTH_TOKEN"
|
||||
);
|
||||
}
|
||||
if token.len() < 16 {
|
||||
anyhow::bail!(
|
||||
"production requires QPQ_AUTH_TOKEN of at least 16 characters (got {})",
|
||||
token.len()
|
||||
);
|
||||
}
|
||||
if effective.store_backend == "sql" && effective.db_key.is_empty() {
|
||||
anyhow::bail!("production with store_backend=sql requires non-empty QPQ_DB_KEY");
|
||||
}
|
||||
if effective.store_backend == "sql" {
|
||||
let db_dir = effective
|
||||
.db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
// Verify the directory exists and is writable by creating+removing a probe file.
|
||||
let probe = db_dir.join(".qpc-write-probe");
|
||||
std::fs::write(&probe, b"probe")
|
||||
.with_context(|| format!("DB path parent {:?} is not writable", db_dir))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
}
|
||||
if effective.store_backend != "sql" {
|
||||
tracing::warn!(
|
||||
"production is using file-backed storage; \
|
||||
consider store_backend=sql with QPQ_DB_KEY for encryption at rest"
|
||||
);
|
||||
}
|
||||
if !effective.tls_cert.exists() || !effective.tls_key.exists() {
|
||||
anyhow::bail!(
|
||||
"production requires existing TLS cert and key (no auto-generation); provide QPQ_TLS_CERT and QPQ_TLS_KEY"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user