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