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:
@@ -20,8 +20,9 @@
|
||||
//! on restart; there is no table.
|
||||
//!
|
||||
//! The client IP is the TCP peer (axum `ConnectInfo<SocketAddr>`), or the last `X-Forwarded-For` entry when the peer
|
||||
//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most
|
||||
//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without
|
||||
//! is an address of `KVITT_TRUSTED_PROXY` (Nginx Proxy Manager, SPEC §8.5). The host name is resolved lazily and
|
||||
//! re-resolved at most once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted
|
||||
//! (D-022), and so does the default `none` ([`NO_TRUSTED_PROXY`]), which is never looked up. Without
|
||||
//! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -39,6 +40,7 @@ use tower::{Layer, Service};
|
||||
|
||||
use crate::audit::BoxFuture;
|
||||
use crate::auth::{bearer_token, is_token_shaped, sha256_hex};
|
||||
use crate::config::NO_TRUSTED_PROXY;
|
||||
use crate::error::AppError;
|
||||
use crate::time::Clock;
|
||||
use crate::{AppState, lock};
|
||||
@@ -214,7 +216,7 @@ impl Limiter {
|
||||
return addrs.clone();
|
||||
}
|
||||
let name = self.trusted_proxy.trim();
|
||||
let addrs: Vec<IpAddr> = if name.is_empty() {
|
||||
let addrs: Vec<IpAddr> = if name.is_empty() || name.eq_ignore_ascii_case(NO_TRUSTED_PROXY) {
|
||||
Vec::new()
|
||||
} else if let Ok(ip) = name.parse::<IpAddr>() {
|
||||
vec![ip]
|
||||
@@ -463,6 +465,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn limiter_trusting(trusted_proxy: &str) -> Limiter {
|
||||
let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap();
|
||||
Limiter::new(Arc::new(FixedClock::new(now)), trusted_proxy.to_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_default_none_trusts_no_peer() {
|
||||
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
|
||||
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
|
||||
for value in [crate::config::DEFAULT_TRUSTED_PROXY, " NONE ", ""] {
|
||||
let limiter = limiter_trusting(value);
|
||||
assert_eq!(
|
||||
limiter.proxy_addrs().await,
|
||||
Vec::<IpAddr>::new(),
|
||||
"{value:?}"
|
||||
);
|
||||
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unresolvable_name_trusts_no_peer() {
|
||||
// `.invalid` never resolves (RFC 6761).
|
||||
let limiter = limiter_trusting("proxy.kvitt.invalid");
|
||||
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
|
||||
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
|
||||
assert_eq!(limiter.client_ip(Some(peer), Some(forwarded)).await, peer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ip_literal_is_trusted() {
|
||||
let limiter = limiter_trusting("192.168.1.20");
|
||||
let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
|
||||
let forwarded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
|
||||
assert_eq!(
|
||||
limiter.client_ip(Some(peer), Some(forwarded)).await,
|
||||
forwarded
|
||||
);
|
||||
}
|
||||
|
||||
fn token_key(i: u32) -> Key {
|
||||
Key::Token {
|
||||
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn};
|
||||
use utoipa::openapi::response::{Response, ResponseBuilder};
|
||||
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
|
||||
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme};
|
||||
use utoipa::openapi::server::Server;
|
||||
use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
|
||||
use utoipa::openapi::{Content, Ref, RefOr, Required};
|
||||
use utoipa::{Modify, OpenApi};
|
||||
|
||||
@@ -35,8 +35,15 @@ const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household
|
||||
timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \
|
||||
strings. Common rules: SPEC §14.3.1.";
|
||||
|
||||
/// The production base URL (SPEC §7).
|
||||
pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443";
|
||||
/// The production base URL (SPEC §7, §14.3): the host is the server variable [`HOST_VARIABLE`], because the Kvitt
|
||||
/// host name is owner-chosen (SPEC §10.5).
|
||||
pub const PUBLIC_SERVER_URL: &str = "https://{host}:8443";
|
||||
/// The server variable in [`PUBLIC_SERVER_URL`].
|
||||
pub const HOST_VARIABLE: &str = "host";
|
||||
/// The default of [`HOST_VARIABLE`]: a reserved name that never resolves (RFC 6761), not a real host.
|
||||
pub const HOST_VARIABLE_DEFAULT: &str = "kvitt.example.invalid";
|
||||
/// The description of [`HOST_VARIABLE`].
|
||||
const HOST_VARIABLE_DESCRIPTION: &str = "the owner-chosen Kvitt host name, SPEC §10.5";
|
||||
|
||||
const JSON: &str = api::JSON_CONTENT_TYPE;
|
||||
const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
|
||||
@@ -191,7 +198,17 @@ impl Modify for CommonRules {
|
||||
openapi.info.description = Some(INFO_DESCRIPTION.to_owned());
|
||||
openapi.info.license = None;
|
||||
openapi.info.contact = None;
|
||||
openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]);
|
||||
openapi.servers = Some(vec![
|
||||
ServerBuilder::new()
|
||||
.url(PUBLIC_SERVER_URL)
|
||||
.parameter(
|
||||
HOST_VARIABLE,
|
||||
ServerVariableBuilder::new()
|
||||
.default_value(HOST_VARIABLE_DEFAULT)
|
||||
.description(Some(HOST_VARIABLE_DESCRIPTION)),
|
||||
)
|
||||
.build(),
|
||||
]);
|
||||
openapi.security = Some(vec![SecurityRequirement::new(
|
||||
"device",
|
||||
Vec::<String>::new(),
|
||||
|
||||
Reference in New Issue
Block a user