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