Skip to main content

platform/config/
control.rs

1use serde::{Deserialize, Serialize};
2
3/// Control 头类型。
4///
5/// - `admin_ui`: 提供本地管理 UI(HTTP)。
6/// - `grpc_api`: 提供给集群 supervisor 的 gRPC API(复用主 HTTP 端口)。
7#[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/// gRPC 控制面配置。
16#[derive(Debug, Serialize, Deserialize, Clone)]
17pub struct ControlGrpcApiConfig {
18    /// 是否启用 NodeAdminService gRPC API。
19    #[serde(default)]
20    pub enabled: bool,
21
22    /// 节点 ID(用于认证载荷)
23    #[serde(default = "default_grpc_node_id")]
24    pub node_id: String,
25
26    /// 节点展示名(为空时回退到 node_id)
27    #[serde(default = "default_grpc_node_name")]
28    pub node_name: String,
29
30    /// nonce-auth 共享密钥(hex, 至少 64 个字符)
31    #[serde(default)]
32    pub shared_secret: String,
33
34    /// 允许的最大时钟偏差(秒)
35    #[serde(default = "default_max_clock_skew_secs")]
36    pub max_clock_skew_secs: u64,
37}
38
39/// Admin UI 配置。
40#[derive(Debug, Serialize, Deserialize, Clone)]
41pub struct AdminUiConfig {
42    /// 是否启用本地 Admin UI。
43    #[serde(default = "default_admin_ui_enabled")]
44    pub enabled: bool,
45
46    /// 登录密码(admin_ui 模式必填,≥8 字符)
47    #[serde(default)]
48    pub password: String,
49
50    /// JWT 会话过期时间(秒),默认 86400(24 小时)
51    #[serde(default = "default_session_expiry_secs")]
52    pub session_expiry_secs: u64,
53}
54
55/// Control 常驻配置。
56#[derive(Debug, Serialize, Deserialize, Clone, Default)]
57pub struct ControlConfig {
58    /// 旧版二选一头模式。新配置优先使用 admin_ui.enabled/grpc_api.enabled。
59    #[serde(default)]
60    pub head: Option<ControlHead>,
61
62    /// gRPC 头参数(仅 grpc_api 模式使用)
63    #[serde(default)]
64    pub grpc_api: ControlGrpcApiConfig,
65
66    /// Admin UI 参数(仅 admin_ui 模式使用)
67    #[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}