Files
quicproquo/crates/quicproquo-server/src/config.rs
Christian Nennemann 3f5a3a5ac8 feat: add WebTransport (HTTP/3) server endpoint for browser clients
Feature-gated behind --features webtransport. Uses h3, h3-quinn,
and h3-webtransport crates to accept WebTransport sessions over
HTTP/3. Dispatches RPC through the same v2 handler registry as
native QUIC, using identical wire framing.

- webtransport.rs: H3 connection handling, session management,
  bidi stream RPC dispatch with auth handshake
- Config: --webtransport-listen / QPQ_WEBTRANSPORT_LISTEN
- ALPN: "h3" for WebTransport, "capnp" for native QUIC
- Also fixes: add missing save/load_revocation_log to SqlStore
2026-03-04 20:59:59 +01:00

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/qpq.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("qpq-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(".qpq-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(())
}