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