platform/config/
control.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
8#[serde(rename_all = "snake_case")]
9pub enum ControlHead {
10 #[default]
11 AdminUi,
12 GrpcApi,
13}
14
15#[derive(Debug, Serialize, Deserialize, Clone)]
17pub struct ControlGrpcApiConfig {
18 #[serde(default)]
20 pub enabled: bool,
21
22 #[serde(default = "default_grpc_node_id")]
24 pub node_id: String,
25
26 #[serde(default = "default_grpc_node_name")]
28 pub node_name: String,
29
30 #[serde(default)]
32 pub shared_secret: String,
33
34 #[serde(default = "default_max_clock_skew_secs")]
36 pub max_clock_skew_secs: u64,
37}
38
39#[derive(Debug, Serialize, Deserialize, Clone)]
41pub struct AdminUiConfig {
42 #[serde(default = "default_admin_ui_enabled")]
44 pub enabled: bool,
45
46 #[serde(default)]
48 pub password: String,
49
50 #[serde(default = "default_session_expiry_secs")]
52 pub session_expiry_secs: u64,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone, Default)]
57pub struct ControlConfig {
58 #[serde(default)]
60 pub head: Option<ControlHead>,
61
62 #[serde(default)]
64 pub grpc_api: ControlGrpcApiConfig,
65
66 #[serde(default)]
68 pub admin_ui: AdminUiConfig,
69}
70
71fn default_grpc_node_id() -> String {
72 "actrix-node".to_string()
73}
74
75fn default_grpc_node_name() -> String {
76 "actrix-node".to_string()
77}
78
79fn default_max_clock_skew_secs() -> u64 {
80 300
81}
82
83fn default_session_expiry_secs() -> u64 {
84 86400
85}
86
87fn default_admin_ui_enabled() -> bool {
88 true
89}
90
91impl Default for AdminUiConfig {
92 fn default() -> Self {
93 Self {
94 enabled: default_admin_ui_enabled(),
95 password: String::new(),
96 session_expiry_secs: default_session_expiry_secs(),
97 }
98 }
99}
100
101impl Default for ControlGrpcApiConfig {
102 fn default() -> Self {
103 Self {
104 enabled: false,
105 node_id: default_grpc_node_id(),
106 node_name: default_grpc_node_name(),
107 shared_secret: String::new(),
108 max_clock_skew_secs: default_max_clock_skew_secs(),
109 }
110 }
111}
112
113impl ControlGrpcApiConfig {
114 pub fn effective_node_name(&self) -> String {
115 let trimmed = self.node_name.trim();
116 if trimmed.is_empty() {
117 self.node_id.trim().to_string()
118 } else {
119 trimmed.to_string()
120 }
121 }
122}
123
124impl ControlConfig {
125 pub fn admin_ui_enabled(&self) -> bool {
126 match self.head {
127 Some(ControlHead::AdminUi) => true,
128 Some(ControlHead::GrpcApi) => false,
129 None => self.admin_ui.enabled,
130 }
131 }
132
133 pub fn grpc_api_enabled(&self) -> bool {
134 match self.head {
135 Some(ControlHead::AdminUi) => false,
136 Some(ControlHead::GrpcApi) => true,
137 None => self.grpc_api.enabled,
138 }
139 }
140
141 pub fn superv_managed(&self) -> bool {
142 self.grpc_api_enabled()
143 }
144
145 pub fn validate(&self) -> Result<(), String> {
146 if !self.admin_ui_enabled() && !self.grpc_api_enabled() {
147 return Err("at least one control endpoint must be enabled".to_string());
148 }
149
150 if self.admin_ui_enabled() {
151 self.validate_admin_ui()?;
152 }
153
154 if self.grpc_api_enabled() {
155 self.validate_grpc_api()?;
156 }
157
158 Ok(())
159 }
160
161 fn validate_admin_ui(&self) -> Result<(), String> {
162 let cfg = &self.admin_ui;
163
164 if cfg.password.is_empty() {
165 return Err(
166 "control.admin_ui.password is required when admin_ui is enabled".to_string(),
167 );
168 }
169
170 if cfg.password.len() < 8 {
171 return Err("control.admin_ui.password must be at least 8 characters".to_string());
172 }
173
174 if cfg.session_expiry_secs == 0 {
175 return Err("control.admin_ui.session_expiry_secs must be greater than 0".to_string());
176 }
177
178 Ok(())
179 }
180
181 fn validate_grpc_api(&self) -> Result<(), String> {
182 let cfg = &self.grpc_api;
183
184 if cfg.node_id.trim().is_empty() {
185 return Err("control.grpc_api.node_id cannot be empty".to_string());
186 }
187
188 if cfg.shared_secret.trim().is_empty() {
189 return Err(
190 "control.grpc_api.shared_secret is required when grpc_api is enabled".to_string(),
191 );
192 }
193
194 if cfg.shared_secret.len() < 64 {
195 return Err(
196 "control.grpc_api.shared_secret must be at least 64 hex characters (32 bytes)"
197 .to_string(),
198 );
199 }
200
201 if hex::decode(&cfg.shared_secret).is_err() {
202 return Err("control.grpc_api.shared_secret must be a valid hex string".to_string());
203 }
204
205 if cfg.max_clock_skew_secs == 0 {
206 return Err("control.grpc_api.max_clock_skew_secs must be greater than 0".to_string());
207 }
208
209 Ok(())
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn default_control_head_is_admin_ui() {
219 let cfg = ControlConfig::default();
220 assert!(cfg.admin_ui_enabled());
221 assert!(!cfg.grpc_api_enabled());
222 }
223
224 #[test]
225 fn admin_ui_requires_password() {
226 let cfg = ControlConfig {
227 head: Some(ControlHead::AdminUi),
228 admin_ui: AdminUiConfig {
229 password: String::new(),
230 ..Default::default()
231 },
232 ..Default::default()
233 };
234
235 assert!(cfg.validate().is_err());
236 }
237
238 #[test]
239 fn admin_ui_rejects_short_password() {
240 let cfg = ControlConfig {
241 head: Some(ControlHead::AdminUi),
242 admin_ui: AdminUiConfig {
243 password: "short".to_string(),
244 ..Default::default()
245 },
246 ..Default::default()
247 };
248
249 assert!(cfg.validate().is_err());
250 }
251
252 #[test]
253 fn admin_ui_accepts_valid_password() {
254 let cfg = ControlConfig {
255 head: Some(ControlHead::AdminUi),
256 admin_ui: AdminUiConfig {
257 password: "changeme123".to_string(),
258 ..Default::default()
259 },
260 ..Default::default()
261 };
262
263 assert!(cfg.validate().is_ok());
264 }
265
266 #[test]
267 fn grpc_api_requires_shared_secret() {
268 let cfg = ControlConfig {
269 head: Some(ControlHead::GrpcApi),
270 grpc_api: ControlGrpcApiConfig {
271 shared_secret: String::new(),
272 ..Default::default()
273 },
274 ..Default::default()
275 };
276
277 assert!(cfg.validate().is_err());
278 }
279
280 #[test]
281 fn grpc_api_accepts_valid_secret() {
282 let cfg = ControlConfig {
283 head: Some(ControlHead::GrpcApi),
284 grpc_api: ControlGrpcApiConfig {
285 shared_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
286 .to_string(),
287 ..Default::default()
288 },
289 ..Default::default()
290 };
291
292 assert!(cfg.validate().is_ok());
293 }
294
295 #[test]
296 fn enabled_flags_allow_admin_ui_and_grpc_together() {
297 let cfg = ControlConfig {
298 head: None,
299 admin_ui: AdminUiConfig {
300 enabled: true,
301 password: "changeme123".to_string(),
302 ..Default::default()
303 },
304 grpc_api: ControlGrpcApiConfig {
305 enabled: true,
306 shared_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
307 .to_string(),
308 ..Default::default()
309 },
310 };
311
312 assert!(cfg.admin_ui_enabled());
313 assert!(cfg.grpc_api_enabled());
314 assert!(cfg.superv_managed());
315 assert!(cfg.validate().is_ok());
316 }
317}