kanade_shared/config.rs
1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::Deserialize;
5
6// ─── Agent config ────────────────────────────────────────────────────
7
8#[derive(Deserialize, Debug, Clone)]
9pub struct AgentConfig {
10 pub agent: AgentSection,
11 pub log: LogSection,
12}
13
14#[derive(Deserialize, Debug, Clone)]
15pub struct AgentSection {
16 pub id: String,
17 pub nats_url: String,
18 /// DEPRECATED in Sprint 5: group membership is now server-managed
19 /// via the `agent_groups` KV bucket. Use
20 /// `kanade agent groups set <pc_id> <group> [<group> ...]` to
21 /// declare membership. Still parsed for back-compat; the value
22 /// is logged-and-ignored at startup. Field removal is scheduled
23 /// for v0.4.0.
24 #[serde(default)]
25 pub groups: Vec<String>,
26}
27
28#[derive(Deserialize, Debug, Clone)]
29pub struct LogSection {
30 pub path: String,
31 pub level: String,
32 /// Number of rotated daily files (incl. today's) to retain.
33 /// Defaults to 14 — covers two weeks of incidents without
34 /// blowing up disk. Set to 0 to disable on-disk logging
35 /// (stdout only).
36 #[serde(default = "default_keep_days")]
37 pub keep_days: usize,
38}
39
40fn default_keep_days() -> usize {
41 14
42}
43
44// ─── Backend config ──────────────────────────────────────────────────
45
46#[derive(Deserialize, Debug, Clone)]
47pub struct BackendConfig {
48 pub server: ServerSection,
49 pub nats: NatsSection,
50 pub db: DbSection,
51 pub log: LogSection,
52 /// Outbound SMTP relay for compliance-alert + generic email.
53 /// Absent ⇒ email features are no-ops (the in-app/NATS notification
54 /// path is unaffected), so an existing deploy keeps working with no
55 /// config change. Holds only the *non-secret* connection settings —
56 /// the SMTP password is NOT here, it comes from the `MailPassword`
57 /// registry secret (or `$KANADE_MAIL_PASSWORD`), the same way
58 /// `JwtSecret` / `StaticToken` are resolved (kanade keeps secrets out
59 /// of the un-ACL'd `backend.toml`).
60 #[serde(default)]
61 pub mail: Option<MailSection>,
62}
63
64#[derive(Deserialize, Debug, Clone)]
65pub struct MailSection {
66 /// SMTP relay host (e.g. an internal mail relay).
67 pub host: String,
68 /// SMTP port — 587 (STARTTLS), 465 (implicit TLS), or 25 (plain).
69 pub port: u16,
70 #[serde(default)]
71 pub encryption: MailEncryption,
72 /// Envelope/`From` address every kanade email is sent as.
73 pub from: String,
74 /// SMTP AUTH username. Omit for an unauthenticated internal relay;
75 /// when set, pair it with the `MailPassword` secret.
76 #[serde(default)]
77 pub username: Option<String>,
78}
79
80/// Transport security for the SMTP connection.
81#[derive(Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
82#[serde(rename_all = "lowercase")]
83pub enum MailEncryption {
84 /// Upgrade a plaintext connection via STARTTLS (port 587). Default.
85 #[default]
86 Starttls,
87 /// Implicit TLS from the first byte (port 465).
88 Tls,
89 /// No transport security (port 25 on a trusted internal segment).
90 None,
91}
92
93#[derive(Deserialize, Debug, Clone)]
94pub struct ServerSection {
95 pub bind: String,
96 /// Externally-reachable base URL of the SPA (e.g.
97 /// `https://kanade.example.com`), used to build absolute links in
98 /// emails (password setup / reset). Optional: when unset the backend
99 /// derives the base from each request's `Host` header (+
100 /// `X-Forwarded-Proto`), which is correct for a direct LAN deploy.
101 /// Set this when behind a reverse proxy / TLS terminator, or to harden
102 /// the public forgot-password path against `Host`-header poisoning
103 /// (`bind` can't be used — it's a wildcard like `0.0.0.0:8080` and
104 /// carries no scheme/hostname).
105 #[serde(default)]
106 pub public_url: Option<String>,
107}
108
109#[derive(Deserialize, Debug, Clone)]
110pub struct NatsSection {
111 pub url: String,
112}
113
114#[derive(Deserialize, Debug, Clone)]
115pub struct DbSection {
116 pub sqlite_path: String,
117}
118
119// ─── Loader ──────────────────────────────────────────────────────────
120
121fn load_typed<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
122 let mut engine = teravars::Engine::new();
123 let ctx = teravars::system_context();
124 let paths: Vec<PathBuf> = vec![path.to_path_buf()];
125 let merged = teravars::load_merged(&paths, &mut engine, &ctx)
126 .with_context(|| format!("teravars load_merged: {path:?}"))?;
127 let cfg: T = toml::Value::Table(merged.config)
128 .try_into()
129 .with_context(|| format!("decode config from {path:?}"))?;
130 Ok(cfg)
131}
132
133pub fn load_agent_config(path: &Path) -> Result<AgentConfig> {
134 load_typed(path)
135}
136
137pub fn load_backend_config(path: &Path) -> Result<BackendConfig> {
138 load_typed(path)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 /// Smoke test the dev-fleet flow against `agent.dev.toml`:
146 /// 1. When `KANADE_DEV_AGENT_ID` is set, the teravars template
147 /// resolves `vars.pc_id` to that value and propagates it
148 /// into `agent.id` + `log.path`. Also exercises a `[vars]`
149 /// self-reference (`pc_id` falls back to `vars.hostname`),
150 /// which `load_merged` resolves via its internal
151 /// fixed-point pass.
152 /// 2. Without the env, the template falls back to `system.host`
153 /// so vanilla `cargo make agent-dev` still works.
154 ///
155 /// Both halves live in a single `#[test]` so they execute
156 /// sequentially within the cargo test runtime — splitting them
157 /// across two tests races on `KANADE_DEV_AGENT_ID` (macOS CI
158 /// turned the race up enough to fail consistently).
159 #[test]
160 fn agent_dev_toml_renders_pc_id_from_env_or_system_host() {
161 // The dev config lives at the workspace root; CARGO_MANIFEST_DIR
162 // resolves to crates/kanade-shared/, so hop up two.
163 let cfg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
164 .join("..")
165 .join("..")
166 .join("configs")
167 .join("agent.dev.toml");
168
169 // (1) env set → pc_id == env value
170 // SAFETY: env mutation is process-global; this single test
171 // body owns set + remove so no sibling test can race us.
172 unsafe {
173 std::env::set_var("KANADE_DEV_AGENT_ID", "dev-pc-render-test");
174 }
175 let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env set)");
176 assert_eq!(cfg.agent.id, "dev-pc-render-test");
177 assert!(
178 cfg.log.path.contains("dev-pc-render-test"),
179 "log path should embed pc_id, got {}",
180 cfg.log.path,
181 );
182
183 // (2) env removed → pc_id falls back to vars.hostname
184 // = system.host. The host string varies by box; just assert
185 // it's non-empty and not the literal template that would mean
186 // teravars failed to render.
187 unsafe {
188 std::env::remove_var("KANADE_DEV_AGENT_ID");
189 }
190 let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env unset)");
191 assert!(
192 !cfg.agent.id.is_empty(),
193 "pc_id should fall back to system.host"
194 );
195 assert_ne!(
196 cfg.agent.id, "{{ system.host }}",
197 "template should render, not leak"
198 );
199 }
200}