use serde::{Deserialize, Serialize};
use crate::security::SecurityPosture;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KernelRef {
pub source: String,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ComputeDefaults {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_kernel: Option<KernelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcpus: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_mib: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConsoleSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PostureTighten {
pub oidc_require_audience: Option<bool>,
pub ratelimit_fail_open: Option<bool>,
pub allow_unauthenticated_public_bind: Option<bool>,
pub allow_site_private_upstreams: Option<bool>,
pub allow_site_unix_upstreams: Option<bool>,
pub allow_shared_kernel_compute: Option<bool>,
pub domain_verify_allow_private: Option<bool>,
pub allow_implicit_routing: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DaemonConfig {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_site: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protect_previews: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_upload_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upload_idle_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_uploads: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_rate_limit: Option<bool>,
#[serde(default, skip_serializing_if = "ComputeDefaults::is_empty")]
pub compute: ComputeDefaults,
#[serde(default, skip_serializing_if = "ConsoleSettings::is_empty")]
pub console: ConsoleSettings,
#[serde(default, skip_serializing_if = "McpSettings::is_empty")]
pub mcp: McpSettings,
#[serde(default, skip_serializing_if = "PostureTighten::is_empty")]
pub posture: PostureTighten,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
default_site: None,
protect_previews: None,
max_upload_bytes: None,
upload_idle_timeout_secs: None,
max_concurrent_uploads: None,
cluster_rate_limit: None,
compute: ComputeDefaults::default(),
console: ConsoleSettings::default(),
mcp: McpSettings::default(),
posture: PostureTighten::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigBaseline {
pub default_site: Option<String>,
pub protect_previews: bool,
pub max_upload_bytes: u64,
pub upload_idle_timeout_secs: Option<u64>,
pub max_concurrent_uploads: Option<u64>,
pub cluster_rate_limit: bool,
pub compute_vcpus: u32,
pub compute_mem_mib: u32,
pub console_enabled: bool,
pub console_host: Option<String>,
pub console_path: Option<String>,
pub max_upload_ceiling: u64,
pub max_concurrent_uploads_ceiling: Option<u64>,
pub posture: SecurityPosture,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveConfig {
pub default_site: Option<String>,
pub protect_previews: bool,
pub max_upload_bytes: u64,
pub upload_idle_timeout_secs: Option<u64>,
pub max_concurrent_uploads: Option<u64>,
pub cluster_rate_limit: bool,
pub compute_vcpus: u32,
pub compute_mem_mib: u32,
pub default_kernel: Option<KernelRef>,
pub console_enabled: bool,
pub console_host: Option<String>,
pub console_path: Option<String>,
pub mcp_enabled: bool,
pub posture: SecurityPosture,
}
impl ComputeDefaults {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
impl ConsoleSettings {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct McpSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
impl McpSettings {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
impl PostureTighten {
fn is_empty(&self) -> bool {
*self == Self::default()
}
fn apply(&self, mut p: SecurityPosture) -> SecurityPosture {
if self.oidc_require_audience == Some(true) {
p.oidc_require_audience = true;
}
if self.ratelimit_fail_open == Some(false) {
p.ratelimit_fail_open = false;
}
if self.allow_unauthenticated_public_bind == Some(false) {
p.allow_unauthenticated_public_bind = false;
}
if self.allow_site_private_upstreams == Some(false) {
p.allow_site_private_upstreams = false;
}
if self.allow_site_unix_upstreams == Some(false) {
p.allow_site_unix_upstreams = false;
}
if self.allow_shared_kernel_compute == Some(false) {
p.allow_shared_kernel_compute = false;
}
if self.domain_verify_allow_private == Some(false) {
p.domain_verify_allow_private = false;
}
if self.allow_implicit_routing == Some(false) {
p.allow_implicit_routing = false;
}
p
}
fn loosening(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if self.oidc_require_audience == Some(false) {
out.push("oidc_require_audience");
}
if self.ratelimit_fail_open == Some(true) {
out.push("ratelimit_fail_open");
}
for (v, name) in [
(
self.allow_unauthenticated_public_bind,
"allow_unauthenticated_public_bind",
),
(
self.allow_site_private_upstreams,
"allow_site_private_upstreams",
),
(self.allow_site_unix_upstreams, "allow_site_unix_upstreams"),
(
self.allow_shared_kernel_compute,
"allow_shared_kernel_compute",
),
(
self.domain_verify_allow_private,
"domain_verify_allow_private",
),
(self.allow_implicit_routing, "allow_implicit_routing"),
] {
if v == Some(true) {
out.push(name);
}
}
out
}
}
fn clamp_cap(v: u64, ceiling: u64) -> u64 {
if ceiling == 0 {
v
} else if v == 0 || v > ceiling {
ceiling
} else {
v
}
}
impl DaemonConfig {
pub fn resolve(&self, base: &ConfigBaseline) -> EffectiveConfig {
let max_upload_bytes = clamp_cap(
self.max_upload_bytes.unwrap_or(base.max_upload_bytes),
base.max_upload_ceiling,
);
let max_concurrent_uploads = match (
self.max_concurrent_uploads.or(base.max_concurrent_uploads),
base.max_concurrent_uploads_ceiling,
) {
(Some(v), Some(c)) => Some(v.min(c)),
(v, _) => v,
};
EffectiveConfig {
default_site: self
.default_site
.clone()
.or_else(|| base.default_site.clone()),
protect_previews: self.protect_previews.unwrap_or(base.protect_previews),
max_upload_bytes,
upload_idle_timeout_secs: self
.upload_idle_timeout_secs
.or(base.upload_idle_timeout_secs),
max_concurrent_uploads,
cluster_rate_limit: self.cluster_rate_limit.unwrap_or(base.cluster_rate_limit),
compute_vcpus: self.compute.vcpus.unwrap_or(base.compute_vcpus),
compute_mem_mib: self.compute.mem_mib.unwrap_or(base.compute_mem_mib),
default_kernel: self.compute.default_kernel.clone(),
console_enabled: self.console.enabled.unwrap_or(base.console_enabled),
console_host: self
.console
.host
.clone()
.or_else(|| base.console_host.clone()),
console_path: self
.console
.path
.clone()
.or_else(|| base.console_path.clone()),
mcp_enabled: self.mcp.enabled.unwrap_or(true),
posture: self.posture.apply(base.posture),
}
}
pub fn validate(&self, base: &ConfigBaseline) -> Result<(), String> {
if self.version != crate::SCHEMA_VERSION {
return Err(format!(
"unsupported daemon-config version {} (expected {})",
self.version,
crate::SCHEMA_VERSION
));
}
if let Some(v) = self.max_upload_bytes {
if exceeds_ceiling(v, base.max_upload_ceiling) {
return Err(format!(
"max_upload_bytes {v} exceeds the static ceiling {}",
base.max_upload_ceiling
));
}
}
if let (Some(v), Some(c)) = (
self.max_concurrent_uploads,
base.max_concurrent_uploads_ceiling,
) {
if v > c {
return Err(format!(
"max_concurrent_uploads {v} exceeds the static ceiling {c}"
));
}
}
let loosening = self.posture.loosening();
if !loosening.is_empty() {
return Err(format!(
"posture overrides are tighten-only; these would loosen and must be set \
in boatramp.cfg + restart instead: {}",
loosening.join(", ")
));
}
Ok(())
}
}
fn exceeds_ceiling(v: u64, ceiling: u64) -> bool {
ceiling != 0 && (v == 0 || v > ceiling)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::SecurityProfile;
fn baseline() -> ConfigBaseline {
ConfigBaseline {
default_site: Some("fallback".into()),
protect_previews: false,
max_upload_bytes: 10,
upload_idle_timeout_secs: None,
max_concurrent_uploads: None,
cluster_rate_limit: false,
compute_vcpus: 4,
compute_mem_mib: 1024,
console_enabled: false,
console_host: None,
console_path: None,
max_upload_ceiling: 100,
max_concurrent_uploads_ceiling: Some(8),
posture: SecurityProfile::MultiTenant.preset(),
}
}
#[test]
fn empty_config_defers_to_baseline() {
let eff = DaemonConfig::default().resolve(&baseline());
assert_eq!(eff.default_site.as_deref(), Some("fallback"));
assert_eq!(eff.max_upload_bytes, 10);
assert!(!eff.protect_previews);
assert_eq!(eff.compute_vcpus, 4);
assert!(eff.default_kernel.is_none());
}
#[test]
fn override_within_ceiling_wins() {
let cfg = DaemonConfig {
default_site: Some("blog".into()),
protect_previews: Some(true),
max_upload_bytes: Some(50),
..Default::default()
};
assert!(cfg.validate(&baseline()).is_ok());
let eff = cfg.resolve(&baseline());
assert_eq!(eff.default_site.as_deref(), Some("blog"));
assert!(eff.protect_previews);
assert_eq!(eff.max_upload_bytes, 50);
}
#[test]
fn cap_over_ceiling_is_rejected_and_clamped() {
let cfg = DaemonConfig {
max_upload_bytes: Some(1000),
..Default::default()
};
assert!(cfg.validate(&baseline()).is_err());
assert_eq!(cfg.resolve(&baseline()).max_upload_bytes, 100);
let unlimited = DaemonConfig {
max_upload_bytes: Some(0),
..Default::default()
};
assert!(unlimited.validate(&baseline()).is_err());
assert_eq!(unlimited.resolve(&baseline()).max_upload_bytes, 100);
}
#[test]
fn posture_tighten_only() {
let tighten = DaemonConfig {
posture: PostureTighten {
ratelimit_fail_open: Some(false),
oidc_require_audience: Some(true),
..Default::default()
},
..Default::default()
};
let mut base = baseline();
base.posture = SecurityProfile::Dev.preset();
assert!(base.posture.ratelimit_fail_open);
assert!(!base.posture.oidc_require_audience);
assert!(tighten.validate(&base).is_ok());
let eff = tighten.resolve(&base);
assert!(!eff.posture.ratelimit_fail_open, "tightened to fail-closed");
assert!(
eff.posture.oidc_require_audience,
"tightened to require audience"
);
let loosen = DaemonConfig {
posture: PostureTighten {
allow_shared_kernel_compute: Some(true),
ratelimit_fail_open: Some(true),
..Default::default()
},
..Default::default()
};
let strict = baseline(); assert!(loosen.validate(&strict).is_err());
let eff = loosen.resolve(&strict);
assert!(
!eff.posture.allow_shared_kernel_compute,
"loosening ignored at resolve"
);
assert!(!eff.posture.ratelimit_fail_open);
}
#[test]
fn console_override_toggles_and_defers() {
let eff = DaemonConfig::default().resolve(&baseline());
assert!(!eff.console_enabled);
assert!(eff.console_host.is_none());
let cfg = DaemonConfig {
console: ConsoleSettings {
enabled: Some(true),
host: Some("console.example.com".into()),
path: Some("/admin".into()),
},
..Default::default()
};
assert!(cfg.validate(&baseline()).is_ok());
let eff = cfg.resolve(&baseline());
assert!(eff.console_enabled);
assert_eq!(eff.console_host.as_deref(), Some("console.example.com"));
assert_eq!(eff.console_path.as_deref(), Some("/admin"));
let mut base = baseline();
base.console_enabled = true;
base.console_path = Some("/_console".into());
let off = DaemonConfig {
console: ConsoleSettings {
enabled: Some(false),
..Default::default()
},
..Default::default()
};
let eff = off.resolve(&base);
assert!(!eff.console_enabled);
assert_eq!(eff.console_path.as_deref(), Some("/_console"));
}
#[test]
fn mcp_enabled_defaults_on_and_toggles_off() {
let base = baseline();
assert!(DaemonConfig::default().resolve(&base).mcp_enabled);
let off = DaemonConfig {
mcp: McpSettings {
enabled: Some(false),
},
..Default::default()
};
assert!(!off.resolve(&base).mcp_enabled);
}
#[test]
fn json_round_trips_and_rejects_unknown_fields() {
let cfg = DaemonConfig {
default_site: Some("x".into()),
compute: ComputeDefaults {
default_kernel: Some(KernelRef {
source: "abc".into(),
sha256: "def".into(),
sig: None,
}),
..Default::default()
},
..Default::default()
};
let json = serde_json::to_vec(&cfg).unwrap();
let back: DaemonConfig = serde_json::from_slice(&json).unwrap();
assert_eq!(cfg, back);
assert!(serde_json::from_slice::<DaemonConfig>(br#"{"nope":1}"#).is_err());
}
}