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