kanade_shared/config.rs
1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
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}
53
54/// Non-secret SMTP connection settings. Lives here (rather than in `wire`)
55/// because it's the shape `mail::Mailer::from_config` builds from, but it's
56/// carried operator-editably in the `server_settings` KV bucket
57/// (`wire::ServerSettings::mail` / SPA), **not** in `backend.toml` (#884).
58/// `Serialize` is derived for the KV / API path; the SMTP password is never
59/// a field here — it comes from the `MailPassword` registry secret (or
60/// `$KANADE_MAIL_PASSWORD`), keeping secrets out of the KV.
61#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
62pub struct MailSection {
63 /// SMTP relay host (e.g. an internal mail relay).
64 pub host: String,
65 /// SMTP port — 587 (STARTTLS), 465 (implicit TLS), or 25 (plain).
66 pub port: u16,
67 #[serde(default)]
68 pub encryption: MailEncryption,
69 /// Envelope/`From` address every kanade email is sent as.
70 pub from: String,
71 /// SMTP AUTH username. Omit for an unauthenticated internal relay;
72 /// when set, pair it with the `MailPassword` secret.
73 #[serde(default)]
74 pub username: Option<String>,
75}
76
77/// Transport security for the SMTP connection.
78#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
79#[serde(rename_all = "lowercase")]
80pub enum MailEncryption {
81 /// Upgrade a plaintext connection via STARTTLS (port 587). Default.
82 #[default]
83 Starttls,
84 /// Implicit TLS from the first byte (port 465).
85 Tls,
86 /// No transport security (port 25 on a trusted internal segment).
87 None,
88}
89
90#[derive(Deserialize, Debug, Clone, Default)]
91pub struct WebauthnSection {
92 /// Relying party ID — a bare host name (e.g. `kanade.example.com` or
93 /// `localhost`). No scheme, port, path or credentials. Must be
94 /// lowercase ASCII; use punycode for IDN. Must be a domain name, not
95 /// an IP address. Subdomains of this RP ID may be listed in
96 /// `rp_origins`.
97 #[serde(default)]
98 pub rp_id: String,
99 /// Allowed origin(s) for WebAuthn ceremonies. Each must be a bare
100 /// origin (`https://host[:port]`) whose host is either the RP ID or a
101 /// subdomain of it. `http` is only accepted for `localhost`. At least
102 /// one origin is required. No wildcards, paths, queries or fragments.
103 #[serde(default)]
104 pub rp_origins: Vec<String>,
105 /// Human-readable relying party name. Defaults to "Kanade".
106 #[serde(default)]
107 pub rp_name: Option<String>,
108}
109
110#[derive(Deserialize, Debug, Clone)]
111pub struct ServerSection {
112 pub bind: String,
113 /// Externally-reachable base URL of the SPA (e.g.
114 /// `https://kanade.example.com`), used to build absolute links in
115 /// emails (password setup / reset). Optional: when unset the backend
116 /// derives the base from each request's `Host` header (+
117 /// `X-Forwarded-Proto`), which is correct for a direct LAN deploy.
118 /// Set this when behind a reverse proxy / TLS terminator, or to harden
119 /// the public forgot-password path against `Host`-header poisoning
120 /// (`bind` can't be used — it's a wildcard like `0.0.0.0:8080` and
121 /// carries no scheme/hostname).
122 #[serde(default)]
123 pub public_url: Option<String>,
124 /// WebAuthn (passkey) relying party configuration. When present and
125 /// valid, passkey sign-in, enrolment and management are enabled for
126 /// the Web UI. Optional: omit or leave empty to disable the feature.
127 #[serde(default)]
128 pub webauthn: WebauthnSection,
129}
130
131#[derive(Deserialize, Debug, Clone)]
132pub struct NatsSection {
133 pub url: String,
134 /// #1270: base URL of the broker's HTTP monitoring endpoint (the
135 /// `http_port` in `nats-server.conf`, 8222 by default). The backend
136 /// polls `/connz` there to learn which NATS user each agent's live
137 /// connection authenticated as.
138 ///
139 /// Optional: when unset it is derived from [`Self::url`] by swapping
140 /// the scheme for `http` and the port for 8222, which is right for the
141 /// standard single-broker deploy. Set it when monitoring listens
142 /// elsewhere. Unreachable / disabled monitoring is not fatal — the
143 /// projection simply stays unpopulated.
144 #[serde(default)]
145 pub monitor_url: Option<String>,
146}
147
148/// Port `nats-server` serves its monitoring endpoints on by default, and
149/// the `http_port` shipped in `configs/nats-server.conf`.
150const DEFAULT_MONITOR_PORT: u16 = 8222;
151
152impl NatsSection {
153 /// The monitoring base URL to poll — configured, or derived from
154 /// [`Self::url`].
155 ///
156 /// Derivation deliberately keeps only the **host**: the client URL's
157 /// port is the client port (4222), its scheme may be `nats`/`tls`/`ws`
158 /// (none of which the monitoring endpoint speaks), and it may carry
159 /// inline credentials that must not be re-sent over plain HTTP.
160 pub fn resolved_monitor_url(&self) -> String {
161 if let Some(u) = self.monitor_url.as_deref().map(str::trim)
162 && !u.is_empty()
163 {
164 let u = u.trim_end_matches('/');
165 // A scheme-less value (`10.0.0.9:8222`) would otherwise be
166 // parsed as a URI whose *scheme* is the hostname, failing every
167 // poll behind a "monitoring endpoint unreadable" line that names
168 // the symptom rather than the typo.
169 return if u.contains("://") {
170 u.to_string()
171 } else {
172 format!("http://{u}")
173 };
174 }
175 let host = monitor_host_from_client_url(&self.url);
176 format!("http://{host}:{DEFAULT_MONITOR_PORT}")
177 }
178}
179
180/// Extract the host from a NATS client URL, dropping scheme, credentials,
181/// port and path. Returns the input unchanged when it is already a bare
182/// host, and falls back to loopback for input with no host at all — a
183/// wrong-but-harmless target beats a panic in a background poller.
184fn monitor_host_from_client_url(url: &str) -> String {
185 let after_scheme = url.split_once("://").map_or(url, |(_scheme, rest)| rest);
186 // `user:pass@host:port` — credentials are before the LAST '@' so a
187 // password containing '@' does not truncate the host.
188 let authority = match after_scheme.rsplit_once('@') {
189 Some((_creds, host)) => host,
190 None => after_scheme,
191 };
192 // Strip any path / query the URL carried.
193 let authority = authority
194 .split(['/', '?', '#'])
195 .next()
196 .unwrap_or(authority)
197 .trim();
198 // IPv6 literals are bracketed (`[::1]:4222`) and their colons are not
199 // port separators — keep the brackets, which is also the form an HTTP
200 // URL needs.
201 let host = if let Some(end) = authority.find(']') {
202 &authority[..=end]
203 } else {
204 authority.split(':').next().unwrap_or(authority)
205 };
206 if host.is_empty() {
207 "127.0.0.1".to_string()
208 } else {
209 host.to_string()
210 }
211}
212
213#[derive(Deserialize, Debug, Clone)]
214pub struct DbSection {
215 pub sqlite_path: String,
216}
217
218// ─── Loader ──────────────────────────────────────────────────────────
219
220fn load_typed<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
221 let mut engine = teravars::Engine::new();
222 let ctx = teravars::system_context();
223 let paths: Vec<PathBuf> = vec![path.to_path_buf()];
224 let merged = teravars::load_merged(&paths, &mut engine, &ctx)
225 .with_context(|| format!("teravars load_merged: {path:?}"))?;
226 let cfg: T = toml::Value::Table(merged.config)
227 .try_into()
228 .with_context(|| format!("decode config from {path:?}"))?;
229 Ok(cfg)
230}
231
232pub fn load_agent_config(path: &Path) -> Result<AgentConfig> {
233 load_typed(path)
234}
235
236pub fn load_backend_config(path: &Path) -> Result<BackendConfig> {
237 load_typed(path)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn nats(url: &str, monitor: Option<&str>) -> NatsSection {
245 NatsSection {
246 url: url.to_string(),
247 monitor_url: monitor.map(str::to_string),
248 }
249 }
250
251 /// #1270: the derived monitoring URL must keep the host and drop
252 /// everything else. The client port is not the monitoring port, and an
253 /// inline credential must not be replayed over plain HTTP.
254 #[test]
255 fn the_monitor_url_derives_from_the_client_url_host_only() {
256 for (client, want) in [
257 ("nats://127.0.0.1:4222", "http://127.0.0.1:8222"),
258 (
259 "nats://nats.example.com:4222",
260 "http://nats.example.com:8222",
261 ),
262 // No scheme, no port — a bare host is still a host.
263 ("broker-01", "http://broker-01:8222"),
264 // Inline credentials: the host is after the last '@'.
265 ("nats://user:p@ss@10.0.0.5:4222", "http://10.0.0.5:8222"),
266 // wss deploys terminate elsewhere, but the host still answers.
267 (
268 "wss://kanade.example.com:443/nats",
269 "http://kanade.example.com:8222",
270 ),
271 // IPv6 literals keep their brackets in both URL forms.
272 ("nats://[::1]:4222", "http://[::1]:8222"),
273 ] {
274 assert_eq!(
275 nats(client, None).resolved_monitor_url(),
276 want,
277 "deriving from {client}",
278 );
279 }
280 }
281
282 #[test]
283 fn an_explicit_monitor_url_wins_and_is_taken_verbatim() {
284 assert_eq!(
285 nats("nats://127.0.0.1:4222", Some("http://10.0.0.9:9999")).resolved_monitor_url(),
286 "http://10.0.0.9:9999",
287 );
288 // A trailing slash would produce `//connz` when joined.
289 assert_eq!(
290 nats("nats://127.0.0.1:4222", Some("http://10.0.0.9:9999/")).resolved_monitor_url(),
291 "http://10.0.0.9:9999",
292 );
293 // Blank / whitespace-only is not a configured value — fall back to
294 // derivation rather than polling the empty string.
295 assert_eq!(
296 nats("nats://127.0.0.1:4222", Some(" ")).resolved_monitor_url(),
297 "http://127.0.0.1:8222",
298 );
299 // A scheme-less value is the likely typo, and it parses as a URI
300 // whose scheme is the hostname — which fails on every poll behind an
301 // error that names the symptom, not the cause.
302 assert_eq!(
303 nats("nats://127.0.0.1:4222", Some("10.0.0.9:8222")).resolved_monitor_url(),
304 "http://10.0.0.9:8222",
305 );
306 // ...but an explicit scheme is never rewritten, including https.
307 assert_eq!(
308 nats("nats://127.0.0.1:4222", Some("https://mon.example.com")).resolved_monitor_url(),
309 "https://mon.example.com",
310 );
311 }
312
313 /// Smoke test the dev-fleet flow against `agent.dev.toml`:
314 /// 1. When `KANADE_DEV_AGENT_ID` is set, the teravars template
315 /// resolves `vars.pc_id` to that value and propagates it
316 /// into `agent.id` + `log.path`. Also exercises a `[vars]`
317 /// self-reference (`pc_id` falls back to `vars.hostname`),
318 /// which `load_merged` resolves via its internal
319 /// fixed-point pass.
320 /// 2. Without the env, the template falls back to `system.host`
321 /// so vanilla `cargo make agent-dev` still works.
322 ///
323 /// Both halves live in a single `#[test]` so they execute
324 /// sequentially within the cargo test runtime — splitting them
325 /// across two tests races on `KANADE_DEV_AGENT_ID` (macOS CI
326 /// turned the race up enough to fail consistently).
327 #[test]
328 fn agent_dev_toml_renders_pc_id_from_env_or_system_host() {
329 // The dev config lives at the workspace root; CARGO_MANIFEST_DIR
330 // resolves to crates/kanade-shared/, so hop up two.
331 let cfg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
332 .join("..")
333 .join("..")
334 .join("configs")
335 .join("agent.dev.toml");
336
337 // (1) env set → pc_id == env value
338 // SAFETY: env mutation is process-global; this single test
339 // body owns set + remove so no sibling test can race us.
340 unsafe {
341 std::env::set_var("KANADE_DEV_AGENT_ID", "dev-pc-render-test");
342 }
343 let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env set)");
344 assert_eq!(cfg.agent.id, "dev-pc-render-test");
345 assert!(
346 cfg.log.path.contains("dev-pc-render-test"),
347 "log path should embed pc_id, got {}",
348 cfg.log.path,
349 );
350
351 // (2) env removed → pc_id falls back to vars.hostname
352 // = system.host. The host string varies by box; just assert
353 // it's non-empty and not the literal template that would mean
354 // teravars failed to render.
355 unsafe {
356 std::env::remove_var("KANADE_DEV_AGENT_ID");
357 }
358 let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env unset)");
359 assert!(
360 !cfg.agent.id.is_empty(),
361 "pc_id should fall back to system.host"
362 );
363 assert_ne!(
364 cfg.agent.id, "{{ system.host }}",
365 "template should render, not leak"
366 );
367 }
368}