feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+5 -1
View File
@@ -27,6 +27,7 @@ impl fmt::Debug for OutboundSettings {
f.debug_struct("OutboundSettings")
.field("allowed_host_count", &self.allowed_hosts.len())
.field("denied_host_count", &self.denied_hosts.len())
.field("max_request_bytes", &self.max_request_bytes)
.field("max_response_bytes", &self.max_response_bytes)
.finish()
}
@@ -96,7 +97,10 @@ impl fmt::Debug for AdminProcessConfig {
.field("storage_root", &"configured")
.field("session_secret", &self.session_secret)
.field("password_pepper", &self.password_pepper)
.field("bootstrap_password", &self.bootstrap_password)
.field(
"bootstrap_password",
&self.bootstrap_password.as_ref().map(|_| "configured"),
)
.finish_non_exhaustive()
}
}
+40 -60
View File
@@ -1,7 +1,3 @@
use std::{collections::BTreeMap, fmt, net::SocketAddr, path::PathBuf};
use url::Url;
use crate::{
ConfigError, ConfigSource, Diagnostic, DiagnosticCode, ProcessScope, SecretString,
deployment_field_registry, field_registry,
@@ -9,14 +5,20 @@ use crate::{
schema::semantic_path_for,
validation::{valid_database_host, valid_database_identifier, valid_percent_encoding},
};
use std::{
collections::BTreeMap,
fmt,
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use url::Url;
mod list_parsers;
const MAX_ENV_VALUE_BYTES: usize = 8_192;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessKind {
AdminApi,
McpServer,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeprecationRecord {
pub field: &'static str,
@@ -24,7 +26,6 @@ pub struct DeprecationRecord {
pub replacement: &'static str,
pub removal_window: &'static str,
}
impl ProcessKind {
pub const fn as_str(self) -> &'static str {
match self {
@@ -49,7 +50,6 @@ pub struct PoolSettings {
pub idle_timeout_ms: u64,
pub max_lifetime_ms: u64,
}
#[derive(Clone)]
pub struct DatabaseSettings {
pub url: Option<SecretString>,
@@ -60,27 +60,24 @@ pub struct DatabaseSettings {
pub password: SecretString,
pub pool: PoolSettings,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CacheBackend {
Memory,
Valkey,
Redis,
}
#[derive(Clone)]
pub struct CacheSettings {
pub backend: CacheBackend,
pub url: Option<SecretString>,
}
#[derive(Clone)]
pub struct OutboundSettings {
pub allowed_hosts: Vec<String>,
pub denied_hosts: Vec<String>,
pub max_request_bytes: usize,
pub max_response_bytes: usize,
}
#[derive(Clone)]
pub struct RuntimeSettings {
pub master_key: SecretString,
@@ -90,20 +87,17 @@ pub struct RuntimeSettings {
pub cache: CacheSettings,
pub outbound: OutboundSettings,
}
#[derive(Clone, Debug)]
pub struct RateLimitSettings {
pub requests_per_second: u32,
pub burst: u32,
}
#[derive(Clone)]
pub struct MetricsSettings {
pub enabled: bool,
pub bind_addr: SocketAddr,
pub bearer_token: Option<SecretString>,
}
#[derive(Clone, Default)]
pub struct OtlpSettings {
pub endpoint: Option<String>,
@@ -141,9 +135,9 @@ pub struct AdminProcessConfig {
pub session_secret: SecretString,
pub password_pepper: SecretString,
pub session_ttl_hours: i64,
pub trust_forwarded_headers: bool,
pub trusted_proxy_ips: Vec<IpAddr>,
pub bootstrap_email: String,
pub bootstrap_password: SecretString,
pub bootstrap_password: Option<SecretString>,
pub bootstrap_display_name: String,
pub demo_seed: bool,
}
@@ -313,7 +307,15 @@ impl<'a> Parser<'a> {
}
fn secret(&mut self, name: &'static str, default: Option<&str>) -> SecretString {
SecretString::new(self.string(name, default))
let value = self.string(name, default);
if let Some(spec) = field_registry().iter().find(|field| field.env_name == name)
&& let Some(minimum) = spec.minimum
&& !value.is_empty()
&& value.len() < minimum as usize
{
self.push(DiagnosticCode::OutOfRange, spec.semantic_path);
}
SecretString::new(value)
}
fn optional_secret(&mut self, name: &'static str) -> Option<SecretString> {
@@ -501,41 +503,6 @@ impl<'a> Parser<'a> {
Some(SecretString::new(raw))
}
}
fn host_list(&mut self, name: &'static str) -> Vec<String> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
if raw.len() > 16_384 {
self.push(DiagnosticCode::OutOfRange, name);
return Vec::new();
}
let mut items = Vec::new();
for item in raw.split(',') {
let item = item.trim().to_ascii_lowercase();
let base = item.strip_prefix("*.").unwrap_or(&item);
let valid_ip = !item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok();
if item.is_empty()
|| item.len() > 253
|| item.contains('/')
|| item.contains(char::is_whitespace)
|| base.is_empty()
|| (!valid_ip && base.contains(':'))
|| (item.starts_with("*.") && base.parse::<std::net::IpAddr>().is_ok())
{
self.push(DiagnosticCode::InvalidType, name);
continue;
}
if !items.contains(&item) {
items.push(item);
}
}
if items.len() > 256 {
self.push(DiagnosticCode::OutOfRange, name);
items.truncate(256);
}
items
}
}
fn parse_database(parser: &mut Parser<'_>) -> DatabaseSettings {
@@ -634,7 +601,6 @@ pub(crate) fn parse_database_source(
}
Ok((database, parser.deprecations))
}
pub fn parse_process(
kind: ProcessKind,
source: ConfigSource,
@@ -642,7 +608,6 @@ pub fn parse_process(
let values = source.values();
let mut parser = Parser::new(kind, values);
parser.check_source();
let database = parse_database(&mut parser);
let cache_backend = match parser
@@ -709,6 +674,7 @@ pub fn parse_process(
outbound: OutboundSettings {
allowed_hosts: parser.host_list("CRANK_OUTBOUND_ALLOWED_HOSTS"),
denied_hosts: parser.host_list("CRANK_OUTBOUND_DENIED_HOSTS"),
max_request_bytes: parser.number("CRANK_OUTBOUND_MAX_REQUEST_BYTES") as usize,
max_response_bytes: parser.number("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") as usize,
},
};
@@ -811,14 +777,24 @@ pub fn parse_process(
ProcessKind::AdminApi => {
let rps = parser.number("CRANK_ADMIN_RATE_LIMIT_RPS") as u32;
let burst = parser.number("CRANK_ADMIN_RATE_LIMIT_BURST") as u32;
let bind_addr = parser.socket("CRANK_ADMIN_BIND");
if burst < rps {
parser.push(DiagnosticCode::UnsafeCombination, "admin.rate_limit.burst");
}
if observability.environment == "production"
&& !bind_addr.ip().is_loopback()
&& !runtime
.base_url
.as_deref()
.is_some_and(|url| url.starts_with("https://"))
{
parser.push(DiagnosticCode::UnsafeCombination, "admin.exposure.tls");
}
Projection::Admin(AdminProcessConfig {
database,
runtime,
observability,
bind_addr: parser.socket("CRANK_ADMIN_BIND"),
bind_addr,
storage_root: parser.absolute_path("CRANK_STORAGE_ROOT", "/var/lib/crank/storage"),
rate_limit: RateLimitSettings {
requests_per_second: rps,
@@ -829,9 +805,9 @@ pub fn parse_process(
session_secret: parser.secret("CRANK_SESSION_SECRET", None),
password_pepper: parser.secret("CRANK_PASSWORD_PEPPER", None),
session_ttl_hours: parser.number("CRANK_SESSION_TTL_HOURS") as i64,
trust_forwarded_headers: parser.boolean("CRANK_TRUST_FORWARDED_HEADERS"),
trusted_proxy_ips: parser.ip_list("CRANK_TRUSTED_PROXY_IPS"),
bootstrap_email: parser.string("CRANK_BOOTSTRAP_ADMIN_EMAIL", None),
bootstrap_password: parser.secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD", None),
bootstrap_password: parser.optional_secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD"),
bootstrap_display_name: parser
.string("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", Some("Crank Owner")),
demo_seed: parser.boolean("CRANK_DEMO_SEED"),
@@ -889,14 +865,17 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
),
format!("retention={}", config.invocation_log_retention_days),
format!("session_ttl={}", config.session_ttl_hours),
format!("trusted={}", config.trust_forwarded_headers),
format!("trusted_proxies={:?}", config.trusted_proxy_ips),
format!("demo={}", config.demo_seed),
"storage=path-configured".to_owned(),
format!("session_secret={}", config.session_secret.is_configured()),
format!("pepper={}", config.password_pepper.is_configured()),
format!(
"bootstrap_password={}",
config.bootstrap_password.is_configured()
config
.bootstrap_password
.as_ref()
.is_some_and(SecretString::is_configured)
),
],
),
@@ -970,6 +949,7 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
),
format!("allowed={}", allowed.join(",")),
format!("denied={}", denied.join(",")),
format!("max_request={}", runtime.outbound.max_request_bytes),
format!("max_response={}", runtime.outbound.max_response_bytes),
format!("environment={}", observability.environment),
format!("log_filter={}", observability.log_filter),
@@ -0,0 +1,36 @@
use std::net::IpAddr;
use crate::{
DiagnosticCode,
validation::{parse_host_list, parse_ip_list},
};
impl super::Parser<'_> {
pub(super) fn host_list(&mut self, name: &'static str) -> Vec<String> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
let parsed = parse_host_list(&raw);
if parsed.invalid {
self.push(DiagnosticCode::InvalidType, name);
}
if parsed.out_of_range {
self.push(DiagnosticCode::OutOfRange, name);
}
parsed.items
}
pub(super) fn ip_list(&mut self, name: &'static str) -> Vec<IpAddr> {
let Some(raw) = self.optional(name) else {
return Vec::new();
};
let parsed = parse_ip_list(&raw);
if parsed.invalid {
self.push(DiagnosticCode::InvalidType, name);
}
if parsed.out_of_range {
self.push(DiagnosticCode::OutOfRange, name);
}
parsed.items
}
}
+5 -2
View File
@@ -63,6 +63,8 @@ pub fn reference_section() -> String {
};
let bounds = match (field.minimum, field.maximum) {
(Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"),
(Some(minimum), None) => format!(">={minimum}"),
(None, Some(maximum)) => format!("<={maximum}"),
_ => "-".to_owned(),
};
output.push_str(&format!(
@@ -102,9 +104,10 @@ fn example_value(field: &FieldSpec, production: bool) -> String {
}
match (field.env_name, production) {
("CRANK_ENVIRONMENT", true) => "production".to_owned(),
("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(),
("CRANK_BASE_URL", true) => "https://crank.example.local".to_owned(),
("CRANK_BASE_URL", false) => "http://localhost:3000".to_owned(),
("POSTGRES_HOST", true) => "postgres".to_owned(),
("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(),
("CRANK_TRUSTED_PROXY_IPS", true) => "127.0.0.1".to_owned(),
_ => field.default.unwrap_or("").to_owned(),
}
}
+34 -5
View File
@@ -78,7 +78,7 @@ macro_rules! f {
};
}
static FIELDS: [FieldSpec; 57] = [
static FIELDS: [FieldSpec; 59] = [
FieldSpec {
compatibility: Some("legacy URL form"),
rules: &[
@@ -222,7 +222,7 @@ static FIELDS: [FieldSpec; 57] = [
"secret",
None,
None,
None,
Some(32),
None,
Secret
)
@@ -317,6 +317,17 @@ static FIELDS: [FieldSpec; 57] = [
Internal
)
},
f!(
"outbound.max_request_bytes",
"CRANK_OUTBOUND_MAX_REQUEST_BYTES",
Shared,
"u64",
Some("bytes"),
Some("4194304"),
Some(1),
Some(67108864),
Public
),
f!(
"outbound.max_response_bytes",
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES",
@@ -636,19 +647,37 @@ static FIELDS: [FieldSpec; 57] = [
Public
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
mode: FieldMode::DeprecatedNoEffect,
compatibility: Some("deprecated boolean proxy trust; use CRANK_TRUSTED_PROXY_IPS"),
..f!(
"admin.trust_forwarded_headers",
"CRANK_TRUST_FORWARDED_HEADERS",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
None,
Public
)
},
FieldSpec {
rules: &[
"only listed immediate peer IPs may supply X-Real-IP/X-Forwarded-For client identity",
"empty value disables forwarded-header trust",
],
..f!(
"admin.trusted_proxy_ips",
"CRANK_TRUSTED_PROXY_IPS",
AdminApi,
"ip_list",
None,
Some(""),
None,
None,
Internal
)
},
FieldSpec {
required: true,
..f!(
@@ -664,7 +693,7 @@ static FIELDS: [FieldSpec; 57] = [
)
},
FieldSpec {
required: true,
compatibility: Some("deprecated startup-bootstrap password; use local bootstrap contract"),
..f!(
"admin.bootstrap.password",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
+69
View File
@@ -1,3 +1,11 @@
use std::net::IpAddr;
pub(crate) struct ParsedList<T> {
pub(crate) items: Vec<T>,
pub(crate) invalid: bool,
pub(crate) out_of_range: bool,
}
pub(crate) fn valid_percent_encoding(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
@@ -40,3 +48,64 @@ pub(crate) fn valid_database_identifier(value: &str) -> bool {
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
pub(crate) fn parse_host_list(raw: &str) -> ParsedList<String> {
let mut result = ParsedList {
items: Vec::new(),
invalid: false,
out_of_range: raw.len() > 16_384,
};
if result.out_of_range {
return result;
}
for item in raw.split(',') {
let item = item.trim().to_ascii_lowercase();
let base = item.strip_prefix("*.").unwrap_or(&item);
let valid_ip = !item.starts_with("*.") && base.parse::<IpAddr>().is_ok();
if item.is_empty()
|| item.len() > 253
|| item.contains('/')
|| item.contains(char::is_whitespace)
|| base.is_empty()
|| (!valid_ip && base.contains(':'))
|| (item.starts_with("*.") && base.parse::<IpAddr>().is_ok())
{
result.invalid = true;
continue;
}
if !result.items.contains(&item) {
result.items.push(item);
}
}
if result.items.len() > 256 {
result.out_of_range = true;
result.items.truncate(256);
}
result
}
pub(crate) fn parse_ip_list(raw: &str) -> ParsedList<IpAddr> {
let mut result = ParsedList {
items: Vec::new(),
invalid: false,
out_of_range: raw.len() > 4096,
};
if result.out_of_range {
return result;
}
for item in raw.split(',').map(str::trim) {
match item.parse::<IpAddr>() {
Ok(value) if !result.items.contains(&value) => result.items.push(value),
Ok(_) => {}
Err(_) => result.invalid = true,
}
}
if result.items.is_empty() {
result.invalid = true;
}
if result.items.len() > 64 {
result.out_of_range = true;
result.items.truncate(64);
}
result
}