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