Server: no built-in public host, no trusted proxy by default; OpenAPI server variable host (D-123)

KVITT_PUBLIC_URL is required: every command refuses to start without it (compose
always sets it). KVITT_TRUSTED_PROXY defaults to none, which the rate limiter never
resolves. contracts/openapi.yaml and the utoipa document: servers[].url
https://{host}:8443 with variable host (default kvitt.example.invalid). E-7 and E-12
examples on kvitt.example.invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-19 20:11:33 +02:00
parent 4b1ae0c473
commit c9abd24c77
13 changed files with 168 additions and 39 deletions
+56 -19
View File
@@ -9,14 +9,16 @@ use crate::error::AppError;
pub const DEFAULT_DATA_DIR: &str = "/data";
/// Default of `KVITT_BIND`.
pub const DEFAULT_BIND: &str = "0.0.0.0:8080";
/// Default of `KVITT_PUBLIC_URL`.
pub const DEFAULT_PUBLIC_URL: &str = "https://icehack.asuscomm.com:8443";
/// Default of `KVITT_FCM_CREDENTIALS`.
pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json";
/// Default of `KVITT_FCM_PROJECT_ID`.
pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337";
/// Default of `KVITT_TRUSTED_PROXY`.
pub const DEFAULT_TRUSTED_PROXY: &str = "caddy";
/// Default of `KVITT_TRUSTED_PROXY`: no peer is trusted to set `X-Forwarded-For` (the rate limiter never resolves
/// this value). `KVITT_PUBLIC_URL` has no default: the Kvitt host name is owner-chosen (SPEC §10.5), so the server
/// refuses to start without it.
pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY;
/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space).
pub const NO_TRUSTED_PROXY: &str = "none";
/// Default of `RUST_LOG`.
pub const DEFAULT_LOG_FILTER: &str = "info";
@@ -33,14 +35,15 @@ pub struct Config {
pub data_dir: PathBuf,
/// `KVITT_BIND`: listen address.
pub bind: SocketAddr,
/// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Stored without a trailing slash.
/// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Required (no default). Stored without a
/// trailing slash.
pub public_url: String,
/// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing.
pub fcm_credentials: PathBuf,
/// `KVITT_FCM_PROJECT_ID`.
pub fcm_project_id: String,
/// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the
/// rate limiter; an unresolvable name means "no proxy".
/// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy".
pub trusted_proxy: String,
}
@@ -51,13 +54,10 @@ impl Config {
}
/// Reads the variables through `lookup` (testable without touching the process environment). Empty values count
/// as unset.
/// as unset. A missing `KVITT_PUBLIC_URL` is an error.
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AppError> {
let get = |key: &str, default: &str| {
lookup(key)
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| default.to_owned())
};
let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty());
let get = |key: &str, default: &str| set(key).unwrap_or_else(|| default.to_owned());
let bind_text = get("KVITT_BIND", DEFAULT_BIND);
let bind: SocketAddr = bind_text.parse().map_err(|e| {
AppError::new(
@@ -65,12 +65,17 @@ impl Config {
format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"),
)
})?;
let public_url = set("KVITT_PUBLIC_URL").ok_or_else(|| {
AppError::new(
crate::error::ErrorCode::Internal,
"KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \
https://<KVITT_PUBLIC_HOST>:8443 (SPEC §10.4)",
)
})?;
Ok(Self {
data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)),
bind,
public_url: get("KVITT_PUBLIC_URL", DEFAULT_PUBLIC_URL)
.trim_end_matches('/')
.to_owned(),
public_url: public_url.trim().trim_end_matches('/').to_owned(),
fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)),
fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID),
trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY),
@@ -97,18 +102,25 @@ impl Config {
mod tests {
use super::*;
const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443";
#[test]
fn defaults_when_unset_or_empty() {
let config = Config::from_lookup(|k| (k == "KVITT_BIND").then(String::new)).unwrap();
let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" | "KVITT_TRUSTED_PROXY" => Some(String::new()),
_ => None,
})
.unwrap();
assert_eq!(config.data_dir, PathBuf::from("/data"));
assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap());
assert_eq!(config.public_url, "https://icehack.asuscomm.com:8443");
assert_eq!(config.public_url, PUBLIC_URL);
assert_eq!(
config.fcm_credentials,
PathBuf::from("/secrets/fcm-service-account.json")
);
assert_eq!(config.fcm_project_id, "kvitt-1337");
assert_eq!(config.trusted_proxy, "caddy");
assert_eq!(config.trusted_proxy, "none");
assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite"));
}
@@ -118,6 +130,7 @@ mod tests {
"KVITT_DATA_DIR" => Some("/tmp/kvitt".into()),
"KVITT_BIND" => Some("127.0.0.1:9000".into()),
"KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()),
"KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()),
_ => None,
})
.unwrap();
@@ -127,10 +140,34 @@ mod tests {
);
assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap());
assert_eq!(config.public_url, "http://192.168.1.10:8080");
assert_eq!(config.trusted_proxy, "192.168.1.20");
}
#[test]
fn public_url_is_required() {
for value in [None, Some(""), Some(" ")] {
let error = Config::from_lookup(|k| {
(k == "KVITT_PUBLIC_URL")
.then_some(value)
.flatten()
.map(str::to_owned)
})
.unwrap_err();
assert!(
error.message.contains("KVITT_PUBLIC_URL"),
"{}",
error.message
);
}
}
#[test]
fn bad_bind_is_an_error() {
assert!(Config::from_lookup(|k| (k == "KVITT_BIND").then(|| "nope".into())).is_err());
let config = Config::from_lookup(|k| match k {
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()),
"KVITT_BIND" => Some("nope".into()),
_ => None,
});
assert!(config.is_err());
}
}