feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ use crank_config::{
|
||||
parse_migrator, parse_process,
|
||||
};
|
||||
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
fn required_admin() -> BTreeMap<String, String> {
|
||||
[
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
@@ -19,7 +21,7 @@ fn required_admin() -> BTreeMap<String, String> {
|
||||
}
|
||||
|
||||
fn required_mcp() -> BTreeMap<String, String> {
|
||||
[("CRANK_MASTER_KEY", "master")]
|
||||
[("CRANK_MASTER_KEY", TEST_MASTER_KEY)]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect()
|
||||
@@ -106,9 +108,9 @@ fn source_for(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_covers_exactly_the_57_observed_runtime_names() {
|
||||
fn registry_covers_exactly_the_59_observed_runtime_names() {
|
||||
let registry = field_registry();
|
||||
assert_eq!(registry.len(), 57);
|
||||
assert_eq!(registry.len(), 59);
|
||||
let unique = registry
|
||||
.iter()
|
||||
.map(|field| field.env_name)
|
||||
@@ -148,9 +150,10 @@ fn defaults_are_preserved_and_invalid_values_never_fall_back() {
|
||||
|
||||
for (name, value) in [
|
||||
("POSTGRES_PORT", "bad"),
|
||||
("CRANK_MASTER_KEY", "too-short"),
|
||||
("CRANK_SESSION_TTL_HOURS", "bad"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "tru"),
|
||||
("CRANK_TRUSTED_PROXY_IPS", "not-an-ip"),
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.to_owned(), value.to_owned());
|
||||
@@ -309,6 +312,24 @@ fn process_specific_fields_and_zero_ports_fail_closed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_admin_non_loopback_requires_https_base_url() {
|
||||
let mut vars = required_admin();
|
||||
vars.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
||||
vars.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
||||
vars.insert("CRANK_BASE_URL".into(), "http://crank.example.test".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(error.diagnostics().iter().any(|item| {
|
||||
item.code == DiagnosticCode::UnsafeCombination && item.field == "admin.exposure.tls"
|
||||
}));
|
||||
|
||||
let mut safe = required_admin();
|
||||
safe.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
||||
safe.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
||||
safe.insert("CRANK_BASE_URL".into(), "https://crank.example.test".into());
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(safe)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
|
||||
for name in [
|
||||
@@ -355,6 +376,7 @@ fn cross_field_and_typed_boundaries_fail_closed() {
|
||||
("POSTGRES_MAX_CONNECTIONS", "1025"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
|
||||
("CRANK_OUTBOUND_MAX_REQUEST_BYTES", "67108865"),
|
||||
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
|
||||
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
|
||||
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
|
||||
@@ -406,11 +428,12 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
||||
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
|
||||
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
|
||||
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
|
||||
("CRANK_OUTBOUND_MAX_REQUEST_BYTES".into(), "67108864".into()),
|
||||
(
|
||||
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
|
||||
"67108864".into(),
|
||||
),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()),
|
||||
("CRANK_TRUSTED_PROXY_IPS".into(), "127.0.0.1,::1".into()),
|
||||
("CRANK_DEMO_SEED".into(), "off".into()),
|
||||
]);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
@@ -419,7 +442,7 @@ fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
||||
assert_eq!(admin.database.pool.max_connections, 1024);
|
||||
assert_eq!(admin.database.pool.min_connections, 0);
|
||||
assert_eq!(admin.runtime.max_concurrent_unary, 1);
|
||||
assert!(admin.trust_forwarded_headers);
|
||||
assert_eq!(admin.trusted_proxy_ips.len(), 2);
|
||||
assert!(!admin.demo_seed);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ fn generated_reference_distinguishes_required_and_optional_fields() {
|
||||
let reference = render::reference_section();
|
||||
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |"
|
||||
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` | `>=32` |"
|
||||
));
|
||||
assert!(
|
||||
reference
|
||||
|
||||
@@ -2,6 +2,10 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crank_config::{ConfigSource, ProcessKind, parse_process};
|
||||
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
const CANARY_ONE_MASTER_KEY: &str = "CANARY_ONE-000000000000000000000000000000000";
|
||||
const CANARY_TWO_MASTER_KEY: &str = "CANARY_TWO-000000000000000000000000000000000";
|
||||
|
||||
fn config(secret: &str) -> crank_config::EffectiveConfig {
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", secret),
|
||||
@@ -18,8 +22,8 @@ fn config(secret: &str) -> crank_config::EffectiveConfig {
|
||||
|
||||
#[test]
|
||||
fn secrets_are_absent_from_debug_display_and_fingerprint() {
|
||||
let first = config("CANARY_ONE");
|
||||
let second = config("CANARY_TWO");
|
||||
let first = config(CANARY_ONE_MASTER_KEY);
|
||||
let second = config(CANARY_TWO_MASTER_KEY);
|
||||
let rendered = format!("{first:?}");
|
||||
assert!(!rendered.contains("CANARY_ONE"));
|
||||
assert_eq!(first.fingerprint(), second.fingerprint());
|
||||
@@ -35,18 +39,18 @@ fn secrets_are_absent_from_debug_display_and_fingerprint() {
|
||||
#[test]
|
||||
fn effective_semantics_not_input_spelling_drive_fingerprint() {
|
||||
let mut canonical = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "true"),
|
||||
("CRANK_DEMO_SEED", "true"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut compatibility = canonical.clone();
|
||||
compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into());
|
||||
compatibility.insert("CRANK_DEMO_SEED".into(), "yes".into());
|
||||
|
||||
let canonical_config = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
@@ -71,7 +75,7 @@ fn effective_semantics_not_input_spelling_drive_fingerprint() {
|
||||
|
||||
#[test]
|
||||
fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
|
||||
let canary = "CANARY_SECRET_VALUE";
|
||||
let canary = "CANARY_SECRET_VALUE-0000000000000000000000";
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", canary),
|
||||
("CRANK_SESSION_SECRET", canary),
|
||||
@@ -111,7 +115,7 @@ fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
|
||||
#[test]
|
||||
fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
|
||||
let mut vars = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"),
|
||||
@@ -135,7 +139,7 @@ fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
|
||||
#[test]
|
||||
fn normalized_database_and_admin_default_urls_drive_fingerprint() {
|
||||
let base = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
@@ -160,7 +164,7 @@ fn normalized_database_and_admin_default_urls_drive_fingerprint() {
|
||||
assert_eq!(implicit.fingerprint(), url.fingerprint());
|
||||
|
||||
let tls = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
|
||||
Reference in New Issue
Block a user