liminal_server/config/
validation.rs1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::ServerError;
5
6use super::types::{LoadedSchema, ServerConfig, ServiceProfile};
7
8mod agents;
9mod links;
10mod open_mode;
11mod profiles;
12mod templates;
13mod websocket;
14use agents::validate_agent_places;
15use links::validate_outbound_links;
16use open_mode::validate_open_mode;
17use profiles::validate_services;
18pub(crate) use profiles::worker_front_door_field_errors;
19use templates::validate_template_places;
20use websocket::validate_websocket;
21
22#[cfg(test)]
23mod link_export_tests;
24
25pub fn validate(config: &mut ServerConfig, base_dir: Option<&Path>) -> Result<(), ServerError> {
47 let mut errors = Vec::new();
48
49 validate_listen_address(config, &mut errors);
50 super::extra_listen::collect_errors(config, &mut errors);
51 validate_health_listen_address(config, &mut errors);
52 validate_channels(config, &mut errors);
56 validate_routing_rules(config, &mut errors);
57 validate_persistence_path(config, &mut errors);
58 validate_cluster(config, &mut errors);
59 validate_auth(config, &mut errors);
60 validate_open_mode(config, &mut errors);
61 validate_template_places(config, &mut errors);
62 validate_agent_places(config, &mut errors);
63 if let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) {
64 crate::services::broker::validate_configuration(lys, &mut errors);
65 crate::link_pass::validate_configuration(lys, &mut errors);
66 if let Some(membership) = &lys.membership {
67 if let Err(error) = crate::auth_membership::validate_configuration(membership) {
68 errors.push(error.to_string());
69 }
70 }
71 if let Some(stream) = &lys.grant_stream {
72 stream.collect_errors(&mut errors);
73 }
74 }
75 super::peers::collect_errors(config, &mut errors);
76 validate_outbound_links(config, &mut errors);
77 super::templates::validate_templates(config, &mut errors);
78 validate_services(config, &mut errors);
79 validate_websocket(config, &mut errors);
80 config.limits.collect_errors(&mut errors);
81 validate_participant(config, &mut errors);
82 load_channel_schemas(config, base_dir, &mut errors);
83
84 if errors.is_empty() {
85 Ok(())
86 } else {
87 Err(ServerError::ConfigValidation {
88 message: errors.join("\n"),
89 })
90 }
91}
92
93fn load_channel_schemas(
98 config: &mut ServerConfig,
99 base_dir: Option<&Path>,
100 errors: &mut Vec<String>,
101) {
102 for channel in &mut config.channels {
103 let Some(schema_ref) = channel.schema_ref.as_ref() else {
104 continue;
105 };
106 let path = resolve_schema_path(schema_ref, base_dir);
107 match load_schema_document(&path) {
108 Ok(loaded) => channel.loaded_schema = Some(loaded),
109 Err(reason) => errors.push(format!(
110 "channels.schema_ref '{}': {reason}",
111 schema_ref.display()
112 )),
113 }
114 }
115}
116
117fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
121 base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
124}
125
126fn load_schema_document(path: &Path) -> Result<LoadedSchema, String> {
133 let bytes = std::fs::read(path)
134 .map_err(|error| format!("schema file '{}' is unreadable: {error}", path.display()))?;
135 let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
136 format!(
137 "schema file '{}' is not valid JSON: {error}",
138 path.display()
139 )
140 })?;
141 liminal::channel::Schema::new(document.clone()).map_err(|error| {
142 format!(
143 "schema file '{}' is not a valid JSON Schema: {error}",
144 path.display()
145 )
146 })?;
147 Ok(LoadedSchema { bytes, document })
148}
149
150fn validate_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
151 if config.listen_address.port() == 0 {
152 errors.push("listen_address: port must be non-zero".to_owned());
153 }
154}
155
156fn validate_health_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
157 if config.health_listen_address.port() == 0 {
158 errors.push("health_listen_address: port must be non-zero".to_owned());
159 }
160
161 if config.health_listen_address == config.listen_address {
162 errors.push(
163 "health_listen_address: must differ from listen_address for probe isolation".to_owned(),
164 );
165 } else if config.health_listen_address.port() == config.listen_address.port() {
166 errors.push(
167 "health_listen_address: port must differ from listen_address port for probe isolation"
168 .to_owned(),
169 );
170 }
171}
172
173fn validate_channels(config: &ServerConfig, errors: &mut Vec<String>) {
174 let mut seen = BTreeSet::new();
175 let mut duplicates = BTreeSet::new();
176
177 for channel in &config.channels {
178 let name = channel.name.trim();
179 if name.is_empty() {
180 errors.push("channels.name: channel name must not be empty".to_owned());
181 continue;
182 }
183
184 if !seen.insert(name.to_owned()) {
185 duplicates.insert(name.to_owned());
186 }
187
188 if channel.durable && crate::server::connection::is_console_subject(name) {
191 errors.push(format!(
192 "channels.durable: console channel '{name}' must not be durable"
193 ));
194 }
195 }
196
197 if !duplicates.is_empty() {
198 let names = duplicates.into_iter().collect::<Vec<_>>().join(", ");
199 errors.push(format!("channels.name: duplicate channel names: {names}"));
200 }
201}
202
203fn validate_routing_rules(config: &ServerConfig, errors: &mut Vec<String>) {
204 let channel_names = config
205 .channels
206 .iter()
207 .map(|channel| channel.name.as_str())
208 .collect::<BTreeSet<_>>();
209
210 for (index, rule) in config.routing_rules.iter().enumerate() {
211 let source = rule.source_channel.trim();
212 if source.is_empty() {
213 errors.push(format!(
214 "routing_rules[{index}].source_channel: source channel must not be empty"
215 ));
216 } else if !channel_names.contains(source) {
217 errors.push(format!(
218 "routing_rules[{index}].source_channel: unknown channel '{source}'"
219 ));
220 }
221
222 let target = rule.target_channel.trim();
223 if target.is_empty() {
224 errors.push(format!(
225 "routing_rules[{index}].target_channel: target channel must not be empty"
226 ));
227 } else if !channel_names.contains(target) {
228 errors.push(format!(
229 "routing_rules[{index}].target_channel: unknown channel '{target}'"
230 ));
231 }
232 }
233}
234
235pub(crate) const MISSING_PERSISTENCE_PATH: &str = "persistence_path: full services require an existing directory; set persistence_path or LIMINAL_PERSISTENCE_PATH; no store is opened";
236
237pub(crate) fn required_persistence_path(config: &ServerConfig) -> Result<&Path, ServerError> {
238 config
239 .persistence_path
240 .as_deref()
241 .ok_or_else(|| ServerError::ConfigValidation {
242 message: MISSING_PERSISTENCE_PATH.to_owned(),
243 })
244}
245
246fn validate_persistence_path(config: &ServerConfig, errors: &mut Vec<String>) {
247 let Some(path) = config.persistence_path.as_deref() else {
248 if matches!(config.services.profile(), Ok(ServiceProfile::Full)) {
249 errors.push(MISSING_PERSISTENCE_PATH.to_owned());
250 }
251 return;
252 };
253
254 match std::fs::metadata(path) {
255 Ok(metadata) => {
256 if !metadata.is_dir() {
257 errors.push(format!(
258 "persistence_path '{}': path must be an existing directory",
259 path.display()
260 ));
261 } else if metadata.permissions().readonly() {
262 errors.push(format!(
263 "persistence_path '{}': path is not writable",
264 path.display()
265 ));
266 }
267 }
268 Err(error) => {
269 errors.push(format!(
270 "persistence_path '{}': path is unreachable: {error}",
271 path.display()
272 ));
273 }
274 }
275}
276
277fn validate_cluster(config: &ServerConfig, errors: &mut Vec<String>) {
278 let Some(cluster) = config.cluster.as_ref() else {
279 return;
280 };
281
282 if cluster.node_name.trim().is_empty() {
283 errors.push("cluster.node_name: node name must not be empty".to_owned());
284 }
285
286 if cluster.cookie.is_empty() {
287 errors.push("cluster.cookie: distribution cookie must not be empty".to_owned());
288 } else if cluster.cookie == super::types::DEFAULT_COOKIE {
289 errors.push(
290 "cluster.cookie: secret cookie must be set; the public default is refused".to_owned(),
291 );
292 }
293
294 if cluster.listen_address.port() == 0 {
295 errors.push("cluster.listen_address: distribution port must be non-zero".to_owned());
296 }
297
298 if cluster.listen_address == config.listen_address {
299 errors.push(
300 "cluster.listen_address: distribution port must differ from the client listen_address"
301 .to_owned(),
302 );
303 }
304
305 let mut seed_node_counts = BTreeMap::new();
306 for (index, seed_node) in cluster.seed_nodes.iter().enumerate() {
307 if seed_node.port() == 0 {
308 errors.push(format!(
309 "cluster.seed_nodes[{index}]: seed node port must be non-zero"
310 ));
311 }
312 seed_node_counts
313 .entry(seed_node.to_string())
314 .and_modify(|count| *count += 1)
315 .or_insert(1_usize);
316 }
317
318 let duplicates = seed_node_counts
319 .into_iter()
320 .filter_map(|(seed_node, count)| (count > 1).then_some(seed_node))
321 .collect::<Vec<_>>();
322
323 if !duplicates.is_empty() {
324 errors.push(format!(
325 "cluster.seed_nodes: duplicate seed nodes: {}",
326 duplicates.join(", ")
327 ));
328 }
329}
330
331fn validate_auth(config: &ServerConfig, errors: &mut Vec<String>) {
337 let Some(auth) = config.auth.as_ref() else {
338 return;
339 };
340
341 if auth.token.is_empty() {
342 errors.push("auth.token: authentication token must not be empty".to_owned());
343 }
344 let max = crate::server::connection::pre_admission::MAX_CONNECT_AUTH_TOKEN_BYTES;
347 if auth.token.len() > max {
348 errors.push(format!(
349 "auth.token: authentication token is {} bytes; a Connect can carry at most {max}",
350 auth.token.len()
351 ));
352 }
353 let peer_lys = auth
356 .lys
357 .as_ref()
358 .and_then(|lys| lys.machine.as_ref())
359 .is_some_and(|machine| machine.carrier_choice.peers.is_some());
360 if auth.require_verified_worker && auth.pass.is_none() && !peer_lys {
361 errors.push(
362 "auth.require_verified_worker: requires [auth.pass], or no worker could register"
363 .to_owned(),
364 );
365 }
366}
367
368fn validate_participant(config: &ServerConfig, errors: &mut Vec<String>) {
373 let Some(participant) = config.participant.as_ref() else {
374 return;
375 };
376 participant.collect_errors(errors);
377 if participant.wire_frame_limit != 0
378 && let Err(error) = crate::server::participant::normalize_configured_frame_limit(
379 participant.wire_frame_limit,
380 )
381 {
382 errors.push(format!(
383 "participant.wire_frame_limit: {} is below the protocol's minimum complete \
384 participant frame ({error:?})",
385 participant.wire_frame_limit
386 ));
387 }
388}
389
390#[cfg(test)]
391mod self_stream_tests;
392#[cfg(test)]
393mod tests;