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.
351 lines
12 KiB
Rust
351 lines
12 KiB
Rust
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(())
|
|
}
|