mcp-proxy-adapter 6.8.2__py3-none-any.whl → 6.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,218 +1,1106 @@
1
1
  """
2
- Configuration validation utilities.
2
+ Configuration Validator for MCP Proxy Adapter
3
+ Validates configuration files and ensures all required settings are present and correct.
3
4
 
4
5
  Author: Vasiliy Zdanovskiy
5
6
  email: vasilyvz@gmail.com
6
-
7
- This module provides strict configuration validation with support for two modes:
8
- - startup (strict): fail-fast with SystemExit on invalid config
9
- - reload (soft): log errors and keep previous config
10
7
  """
11
8
 
12
- from __future__ import annotations
13
-
9
+ import json
14
10
  import os
15
- from dataclasses import dataclass
11
+ import logging
16
12
  from pathlib import Path
17
- from typing import Any, Dict, List, Optional, Tuple
13
+ from typing import Dict, List, Any, Optional, Tuple, Set
14
+ from enum import Enum
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ import ssl
18
+ import socket
18
19
 
19
- from mcp_proxy_adapter.core.logging import logger
20
+ logger = logging.getLogger(__name__)
20
21
 
21
22
 
22
- @dataclass
23
- class ConfigValidationResult:
24
- is_valid: bool
25
- errors: List[str]
26
- warnings: List[str]
23
+ class ValidationLevel(Enum):
24
+ """Validation severity levels."""
25
+ ERROR = "error"
26
+ WARNING = "warning"
27
+ INFO = "info"
27
28
 
28
- @staticmethod
29
- def ok() -> "ConfigValidationResult":
30
- return ConfigValidationResult(True, [], [])
29
+
30
+ # Import ValidationResult from errors to avoid circular imports
31
+ from .errors import ValidationResult
31
32
 
32
33
 
33
34
  class ConfigValidator:
34
35
  """
35
- Unified configuration validator supporting both result-based and boolean APIs.
36
+ Comprehensive configuration validator for MCP Proxy Adapter.
37
+
38
+ Validates:
39
+ - Required sections and keys
40
+ - File existence for referenced files
41
+ - Feature flag dependencies
42
+ - Protocol-specific requirements
43
+ - Security configuration consistency
36
44
  """
37
-
38
- def __init__(self, _config: Optional[Dict[str, Any]] = None) -> None:
39
- # Optional stored config for validate_all() compatibility
40
- self._config: Optional[Dict[str, Any]] = _config
41
- self.errors: List[str] = []
42
- self.warnings: List[str] = []
43
-
44
- def validate(self, config: Optional[Dict[str, Any]] = None) -> ConfigValidationResult:
45
+
46
+ def __init__(self, config_path: Optional[str] = None):
45
47
  """
46
- Validate configuration and return detailed result.
47
-
48
+ Initialize configuration validator.
49
+
48
50
  Args:
49
- config: Configuration dict. If None, uses self._config.
51
+ config_path: Path to configuration file for validation
50
52
  """
51
- cfg = config if config is not None else (self._config or {})
52
- self.errors = []
53
- self.warnings = []
54
-
55
- # Top-level basics
56
- self._validate_uuid(cfg)
57
- self._validate_server(cfg)
58
- self._validate_protocols(cfg)
59
-
60
- # SSL/mTLS
61
- self._validate_ssl(cfg)
62
-
63
- # Security framework sections
64
- self._validate_security_auth(cfg)
65
- self._validate_permissions(cfg)
66
- self._validate_rate_limit(cfg)
67
-
68
- return ConfigValidationResult(
69
- is_valid=len(self.errors) == 0,
70
- errors=list(self.errors),
71
- warnings=list(self.warnings),
72
- )
73
-
74
- # Boolean API for backward compatibility (used in main.py)
75
- def validate_all(self) -> bool:
76
- result = self.validate(self._config)
77
- return result.is_valid
78
-
79
- def get_errors(self) -> List[str]:
80
- return list(self.errors)
81
-
82
- def get_warnings(self) -> List[str]:
83
- return list(self.warnings)
84
-
85
- # ------------- Section validators -------------
86
-
87
- def _validate_uuid(self, config: Dict[str, Any]) -> None:
88
- uuid_value = config.get("uuid")
89
- if not uuid_value or not isinstance(uuid_value, str) or len(uuid_value) < 8:
90
- self.errors.append(
91
- "UUID is required in configuration. Add 'uuid' field with a valid UUID4 value."
92
- )
93
-
94
- def _validate_server(self, config: Dict[str, Any]) -> None:
95
- server = config.get("server", {})
96
- port = server.get("port")
97
- if port is None or not isinstance(port, int) or not (1 <= port <= 65535):
98
- self.errors.append("Server.port must be an integer between 1 and 65535")
99
-
100
- host = server.get("host")
101
- if not host or not isinstance(host, str):
102
- self.errors.append("Server.host must be a non-empty string")
103
-
104
- def _validate_protocols(self, config: Dict[str, Any]) -> None:
105
- protocols = config.get("protocols", {})
106
- if not isinstance(protocols, dict):
107
- self.warnings.append("'protocols' must be a dictionary; ignoring")
53
+ self.config_path = config_path
54
+ self.config_data: Dict[str, Any] = {}
55
+ self.validation_results: List[ValidationResult] = []
56
+
57
+ # Define required sections and their keys
58
+ self.required_sections = {
59
+ "server": {
60
+ "host": str,
61
+ "port": int,
62
+ "protocol": str,
63
+ "debug": bool,
64
+ "log_level": str
65
+ },
66
+ "logging": {
67
+ "level": str,
68
+ "log_dir": str,
69
+ "log_file": str,
70
+ "error_log_file": str,
71
+ "access_log_file": str,
72
+ "max_file_size": (str, int),
73
+ "backup_count": int,
74
+ "format": str,
75
+ "date_format": str,
76
+ "console_output": bool,
77
+ "file_output": bool
78
+ },
79
+ "commands": {
80
+ "auto_discovery": bool,
81
+ "commands_directory": str,
82
+ "catalog_directory": str,
83
+ "plugin_servers": list,
84
+ "auto_install_dependencies": bool,
85
+ "enabled_commands": list,
86
+ "disabled_commands": list,
87
+ "custom_commands_path": str
88
+ },
89
+ "transport": {
90
+ "type": str,
91
+ "port": (int, type(None)),
92
+ "verify_client": bool,
93
+ "chk_hostname": bool
94
+ },
95
+ "proxy_registration": {
96
+ "enabled": bool,
97
+ "proxy_url": str,
98
+ "server_id": str,
99
+ "server_name": str,
100
+ "description": str,
101
+ "version": str,
102
+ "registration_timeout": int,
103
+ "retry_attempts": int,
104
+ "retry_delay": int,
105
+ "auto_register_on_startup": bool,
106
+ "auto_unregister_on_shutdown": bool
107
+ },
108
+ "debug": {
109
+ "enabled": bool,
110
+ "level": str
111
+ },
112
+ "security": {
113
+ "enabled": bool,
114
+ "tokens": dict,
115
+ "roles": dict,
116
+ "roles_file": (str, type(None))
117
+ },
118
+ "roles": {
119
+ "enabled": bool,
120
+ "config_file": (str, type(None)),
121
+ "default_policy": dict,
122
+ "auto_load": bool,
123
+ "validation_enabled": bool
124
+ }
125
+ }
126
+
127
+ # Define feature flags and their dependencies
128
+ self.feature_flags = {
129
+ "security": {
130
+ "enabled_key": "security.enabled",
131
+ "dependencies": ["security.tokens", "security.roles"],
132
+ "required_files": ["security.roles_file"],
133
+ "optional_files": []
134
+ },
135
+ "roles": {
136
+ "enabled_key": "roles.enabled",
137
+ "dependencies": ["roles.config_file"],
138
+ "required_files": ["roles.config_file"],
139
+ "optional_files": []
140
+ },
141
+ "proxy_registration": {
142
+ "enabled_key": "proxy_registration.enabled",
143
+ "dependencies": ["proxy_registration.proxy_url"],
144
+ "required_files": [],
145
+ "optional_files": [
146
+ "proxy_registration.certificate.cert_file",
147
+ "proxy_registration.certificate.key_file"
148
+ ]
149
+ },
150
+ "ssl": {
151
+ "enabled_key": "ssl.enabled",
152
+ "dependencies": ["ssl.cert_file", "ssl.key_file"],
153
+ "required_files": ["ssl.cert_file", "ssl.key_file"],
154
+ "optional_files": ["ssl.ca_cert"]
155
+ },
156
+ "transport_ssl": {
157
+ "enabled_key": "transport.ssl.enabled",
158
+ "dependencies": ["transport.ssl.cert_file", "transport.ssl.key_file"],
159
+ "required_files": ["transport.ssl.cert_file", "transport.ssl.key_file"],
160
+ "optional_files": ["transport.ssl.ca_cert"]
161
+ }
162
+ }
163
+
164
+ # Protocol-specific requirements
165
+ self.protocol_requirements = {
166
+ "http": {
167
+ "ssl_enabled": False,
168
+ "client_verification": False,
169
+ "required_files": []
170
+ },
171
+ "https": {
172
+ "ssl_enabled": True,
173
+ "client_verification": False,
174
+ "required_files": ["ssl.cert_file", "ssl.key_file"]
175
+ },
176
+ "mtls": {
177
+ "ssl_enabled": True,
178
+ "client_verification": True,
179
+ "required_files": ["ssl.cert_file", "ssl.key_file", "ssl.ca_cert"]
180
+ }
181
+ }
182
+
183
+ def load_config(self, config_path: Optional[str] = None) -> None:
184
+ """
185
+ Load configuration from file.
186
+
187
+ Args:
188
+ config_path: Path to configuration file
189
+ """
190
+ if config_path:
191
+ self.config_path = config_path
192
+
193
+ if not self.config_path or not os.path.exists(self.config_path):
194
+ self.validation_results.append(ValidationResult(
195
+ level="error",
196
+ message=f"Configuration file not found: {self.config_path}",
197
+ section="config_file"
198
+ ))
108
199
  return
109
-
110
- enabled = protocols.get("enabled", True)
111
- if not enabled:
112
- return # Disabled => allow all (manager bypassed)
113
-
114
- allowed = protocols.get("allowed_protocols", [])
115
- default_protocol = protocols.get("default_protocol")
116
- if not isinstance(allowed, list) or not all(isinstance(x, str) for x in allowed):
117
- self.errors.append("protocols.allowed_protocols must be a list of strings")
118
- if default_protocol and default_protocol not in allowed:
119
- self.errors.append(
120
- "protocols.default_protocol must be present in allowed_protocols"
121
- )
122
-
123
- def _validate_ssl(self, config: Dict[str, Any]) -> None:
124
- # Prefer security.ssl if enabled; otherwise fallback to root ssl
125
- sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
126
- security_ssl = sec.get("ssl", {}) if isinstance(sec.get("ssl"), dict) else {}
127
- root_ssl = config.get("ssl", {}) if isinstance(config.get("ssl"), dict) else {}
128
-
129
- effective_ssl = security_ssl if security_ssl.get("enabled", False) else root_ssl
130
- if not isinstance(effective_ssl, dict):
200
+
201
+ try:
202
+ with open(self.config_path, 'r', encoding='utf-8') as f:
203
+ self.config_data = json.load(f)
204
+ except json.JSONDecodeError as e:
205
+ self.validation_results.append(ValidationResult(
206
+ level="error",
207
+ message=f"Invalid JSON in configuration file: {e}",
208
+ section="config_file"
209
+ ))
210
+ except Exception as e:
211
+ self.validation_results.append(ValidationResult(
212
+ level="error",
213
+ message=f"Error loading configuration file: {e}",
214
+ section="config_file"
215
+ ))
216
+
217
+ def validate_config(self, config_data: Optional[Dict[str, Any]] = None) -> List[ValidationResult]:
218
+ """
219
+ Validate configuration data.
220
+
221
+ Args:
222
+ config_data: Configuration data to validate. If None, uses loaded config.
223
+
224
+ Returns:
225
+ List of validation results
226
+ """
227
+ if config_data is not None:
228
+ self.config_data = config_data
229
+
230
+ if not self.config_data:
231
+ self.validation_results.append(ValidationResult(
232
+ level="error",
233
+ message="No configuration data to validate",
234
+ section="config_data"
235
+ ))
236
+ return self.validation_results
237
+
238
+ # Clear previous results
239
+ self.validation_results = []
240
+
241
+ # Perform all validation checks
242
+ self._validate_required_sections()
243
+ self._validate_feature_flags()
244
+ self._validate_protocol_requirements()
245
+ self._validate_file_existence()
246
+ self._validate_security_consistency()
247
+ self._validate_ssl_configuration()
248
+ self._validate_proxy_registration()
249
+ self._validate_roles_configuration()
250
+
251
+ return self.validation_results
252
+
253
+ def _validate_required_sections(self) -> None:
254
+ """Validate that all required sections and keys are present for enabled features only."""
255
+ # Always required sections (core functionality)
256
+ always_required = {
257
+ "server": self.required_sections["server"],
258
+ "logging": self.required_sections["logging"],
259
+ "commands": self.required_sections["commands"]
260
+ }
261
+
262
+ # Check always required sections
263
+ for section_name, required_keys in always_required.items():
264
+ if section_name not in self.config_data:
265
+ self.validation_results.append(ValidationResult(
266
+ level="error",
267
+ message=f"Required section '{section_name}' is missing",
268
+ section=section_name
269
+ ))
270
+ continue
271
+
272
+ section_data = self.config_data[section_name]
273
+ for key, expected_type in required_keys.items():
274
+ if key not in section_data:
275
+ self.validation_results.append(ValidationResult(
276
+ level="error",
277
+ message=f"Required key '{key}' is missing in section '{section_name}'",
278
+ section=section_name,
279
+ key=key
280
+ ))
281
+ else:
282
+ # Validate type
283
+ value = section_data[key]
284
+ if isinstance(expected_type, tuple):
285
+ if not isinstance(value, expected_type):
286
+ expected_names = [t.__name__ for t in expected_type]
287
+ self.validation_results.append(ValidationResult(
288
+ level="error",
289
+ message=f"Key '{key}' in section '{section_name}' has wrong type. Expected {' or '.join(expected_names)}, got {type(value).__name__}",
290
+ section=section_name,
291
+ key=key
292
+ ))
293
+ else:
294
+ if not isinstance(value, expected_type):
295
+ self.validation_results.append(ValidationResult(
296
+ level="error",
297
+ message=f"Key '{key}' in section '{section_name}' has wrong type. Expected {expected_type.__name__}, got {type(value).__name__}",
298
+ section=section_name,
299
+ key=key
300
+ ))
301
+
302
+ # Check conditional sections based on feature flags
303
+ for feature_name, feature_config in self.feature_flags.items():
304
+ enabled_key = feature_config["enabled_key"]
305
+ is_enabled = self._get_nested_value(enabled_key, False)
306
+
307
+ if is_enabled and feature_name in self.required_sections:
308
+ section_name = feature_name
309
+ required_keys = self.required_sections[section_name]
310
+
311
+ if section_name not in self.config_data:
312
+ self.validation_results.append(ValidationResult(
313
+ level="error",
314
+ message=f"Required section '{section_name}' is missing for enabled feature",
315
+ section=section_name
316
+ ))
317
+ continue
318
+
319
+ section_data = self.config_data[section_name]
320
+ for key, expected_type in required_keys.items():
321
+ if key not in section_data:
322
+ self.validation_results.append(ValidationResult(
323
+ level="error",
324
+ message=f"Required key '{key}' is missing in section '{section_name}' for enabled feature",
325
+ section=section_name,
326
+ key=key
327
+ ))
328
+ else:
329
+ # Validate type
330
+ value = section_data[key]
331
+ if isinstance(expected_type, tuple):
332
+ if not isinstance(value, expected_type):
333
+ expected_names = [t.__name__ for t in expected_type]
334
+ self.validation_results.append(ValidationResult(
335
+ level="error",
336
+ message=f"Key '{key}' in section '{section_name}' has wrong type. Expected {' or '.join(expected_names)}, got {type(value).__name__}",
337
+ section=section_name,
338
+ key=key
339
+ ))
340
+ else:
341
+ if not isinstance(value, expected_type):
342
+ self.validation_results.append(ValidationResult(
343
+ level="error",
344
+ message=f"Key '{key}' in section '{section_name}' has wrong type. Expected {expected_type.__name__}, got {type(value).__name__}",
345
+ section=section_name,
346
+ key=key
347
+ ))
348
+
349
+ def _validate_feature_flags(self) -> None:
350
+ """Validate feature flags and their dependencies."""
351
+ for feature_name, feature_config in self.feature_flags.items():
352
+ enabled_key = feature_config["enabled_key"]
353
+ is_enabled = self._get_nested_value(enabled_key, False)
354
+
355
+ if is_enabled:
356
+ # Check dependencies
357
+ for dependency in feature_config["dependencies"]:
358
+ if not self._has_nested_key(dependency):
359
+ self.validation_results.append(ValidationResult(
360
+ level="error",
361
+ message=f"Feature '{feature_name}' is enabled but required dependency '{dependency}' is missing",
362
+ section=feature_name,
363
+ key=dependency
364
+ ))
365
+
366
+ # Check required files
367
+ for file_key in feature_config["required_files"]:
368
+ file_path = self._get_nested_value(file_key)
369
+ if file_path and not os.path.exists(file_path):
370
+ self.validation_results.append(ValidationResult(
371
+ level="error",
372
+ message=f"Feature '{feature_name}' is enabled but required file '{file_path}' does not exist",
373
+ section=feature_name,
374
+ key=file_key
375
+ ))
376
+ else:
377
+ # Feature is disabled - check that optional files are not required
378
+ for file_key in feature_config["optional_files"]:
379
+ file_path = self._get_nested_value(file_key)
380
+ if file_path and not os.path.exists(file_path):
381
+ self.validation_results.append(ValidationResult(
382
+ level="warning",
383
+ message=f"Optional file '{file_path}' for disabled feature '{feature_name}' does not exist",
384
+ section=feature_name,
385
+ key=file_key,
386
+ suggestion="This is not an error since the feature is disabled"
387
+ ))
388
+
389
+ def _validate_protocol_requirements(self) -> None:
390
+ """Validate protocol-specific requirements."""
391
+ protocol = self._get_nested_value("server.protocol", "http")
392
+
393
+ if protocol not in self.protocol_requirements:
394
+ self.validation_results.append(ValidationResult(
395
+ level="error",
396
+ message=f"Unsupported protocol: {protocol}",
397
+ section="server",
398
+ key="protocol"
399
+ ))
131
400
  return
132
-
133
- if effective_ssl.get("enabled", False):
134
- cert_file = effective_ssl.get("cert_file")
135
- key_file = effective_ssl.get("key_file")
136
- if not cert_file or not key_file:
137
- self.errors.append(
138
- "SSL enabled but 'cert_file' or 'key_file' is missing"
139
- )
401
+
402
+ requirements = self.protocol_requirements[protocol]
403
+
404
+ # Check SSL requirements
405
+ if requirements["ssl_enabled"]:
406
+ ssl_enabled = self._get_nested_value("ssl.enabled", False)
407
+ if not ssl_enabled:
408
+ self.validation_results.append(ValidationResult(
409
+ level="error",
410
+ message=f"Protocol '{protocol}' requires SSL to be enabled",
411
+ section="ssl",
412
+ key="enabled"
413
+ ))
414
+
415
+ # Check required SSL files
416
+ for file_key in requirements["required_files"]:
417
+ file_path = self._get_nested_value(file_key)
418
+ if not file_path:
419
+ self.validation_results.append(ValidationResult(
420
+ level="error",
421
+ message=f"Protocol '{protocol}' requires {file_key} to be specified",
422
+ section="ssl",
423
+ key=file_key.split(".")[-1]
424
+ ))
425
+ elif not os.path.exists(file_path):
426
+ self.validation_results.append(ValidationResult(
427
+ level="error",
428
+ message=f"Protocol '{protocol}' requires file '{file_path}' to exist",
429
+ section="ssl",
430
+ key=file_key.split(".")[-1]
431
+ ))
432
+
433
+ # Check client verification requirements
434
+ if requirements["client_verification"]:
435
+ verify_client = self._get_nested_value("transport.ssl.verify_client", False)
436
+ if not verify_client:
437
+ self.validation_results.append(ValidationResult(
438
+ level="error",
439
+ message=f"Protocol '{protocol}' requires client certificate verification",
440
+ section="transport.ssl",
441
+ key="verify_client"
442
+ ))
443
+
444
+ def _validate_file_existence(self) -> None:
445
+ """Validate that all referenced files exist."""
446
+ file_keys = [
447
+ "logging.log_dir",
448
+ "commands.commands_directory",
449
+ "commands.catalog_directory",
450
+ "commands.custom_commands_path",
451
+ "security.roles_file",
452
+ "roles.config_file",
453
+ "ssl.cert_file",
454
+ "ssl.key_file",
455
+ "ssl.ca_cert",
456
+ "transport.ssl.cert_file",
457
+ "transport.ssl.key_file",
458
+ "transport.ssl.ca_cert",
459
+ "proxy_registration.certificate.cert_file",
460
+ "proxy_registration.certificate.key_file"
461
+ ]
462
+
463
+ for file_key in file_keys:
464
+ file_path = self._get_nested_value(file_key)
465
+ if file_path and not os.path.exists(file_path):
466
+ # Check if this is a required file based on enabled features
467
+ is_required = self._is_file_required_for_enabled_features(file_key)
468
+ level = "error" if is_required else "warning"
469
+
470
+ self.validation_results.append(ValidationResult(
471
+ level=level,
472
+ message=f"Referenced file '{file_path}' does not exist",
473
+ section=file_key.split(".")[0],
474
+ key=file_key.split(".")[-1],
475
+ suggestion="Create the file or update the configuration" if is_required else "This file is optional"
476
+ ))
477
+
478
+ def _validate_security_consistency(self) -> None:
479
+ """Validate security configuration consistency."""
480
+ security_enabled = self._get_nested_value("security.enabled", False)
481
+
482
+ if security_enabled:
483
+ # Check if authentication is properly configured
484
+ tokens = self._get_nested_value("security.tokens", {})
485
+ roles = self._get_nested_value("security.roles", {})
486
+ roles_file = self._get_nested_value("security.roles_file")
487
+
488
+ has_tokens = bool(tokens and any(tokens.values()))
489
+ has_roles = bool(roles and any(roles.values()))
490
+ has_roles_file = bool(roles_file and os.path.exists(roles_file))
491
+
492
+ if not (has_tokens or has_roles or has_roles_file):
493
+ self.validation_results.append(ValidationResult(
494
+ level="warning",
495
+ message="Security is enabled but no authentication methods are configured",
496
+ section="security",
497
+ suggestion="Configure tokens, roles, or roles_file in the security section"
498
+ ))
499
+
500
+ # Check roles consistency
501
+ if has_roles and has_roles_file:
502
+ self.validation_results.append(ValidationResult(
503
+ level="warning",
504
+ message="Both inline roles and roles_file are configured. roles_file will take precedence",
505
+ section="security",
506
+ suggestion="Remove either inline roles or roles_file configuration"
507
+ ))
508
+
509
+ def _validate_proxy_registration(self) -> None:
510
+ """Validate proxy registration configuration."""
511
+ registration_enabled = self._get_nested_value("proxy_registration.enabled", False)
512
+
513
+ if registration_enabled:
514
+ proxy_url = self._get_nested_value("proxy_registration.proxy_url")
515
+ if not proxy_url:
516
+ self.validation_results.append(ValidationResult(
517
+ level="error",
518
+ message="Proxy registration is enabled but proxy_url is not specified",
519
+ section="proxy_registration",
520
+ key="proxy_url"
521
+ ))
522
+
523
+ # Check authentication method consistency
524
+ auth_method = self._get_nested_value("proxy_registration.auth_method", "none")
525
+ if auth_method != "none":
526
+ if auth_method == "certificate":
527
+ cert_file = self._get_nested_value("proxy_registration.certificate.cert_file")
528
+ key_file = self._get_nested_value("proxy_registration.certificate.key_file")
529
+ if not cert_file or not key_file:
530
+ self.validation_results.append(ValidationResult(
531
+ level="error",
532
+ message="Certificate authentication is enabled but certificate files are not specified",
533
+ section="proxy_registration",
534
+ key="certificate"
535
+ ))
536
+ elif auth_method == "token":
537
+ token = self._get_nested_value("proxy_registration.token.token")
538
+ if not token:
539
+ self.validation_results.append(ValidationResult(
540
+ level="error",
541
+ message="Token authentication is enabled but token is not specified",
542
+ section="proxy_registration",
543
+ key="token"
544
+ ))
545
+
546
+ def _validate_ssl_configuration(self) -> None:
547
+ """Validate SSL configuration with detailed certificate validation."""
548
+ ssl_enabled = self._get_nested_value("ssl.enabled", False)
549
+
550
+ if ssl_enabled:
551
+ cert_file = self._get_nested_value("ssl.cert_file")
552
+ key_file = self._get_nested_value("ssl.key_file")
553
+ ca_cert = self._get_nested_value("ssl.ca_cert")
554
+
555
+ # Check certificate file
556
+ if not cert_file:
557
+ self.validation_results.append(ValidationResult(
558
+ level="error",
559
+ message="SSL is enabled but cert_file is not specified",
560
+ section="ssl",
561
+ key="cert_file"
562
+ ))
563
+ elif not os.path.exists(cert_file):
564
+ self.validation_results.append(ValidationResult(
565
+ level="error",
566
+ message=f"SSL certificate file '{cert_file}' does not exist",
567
+ section="ssl",
568
+ key="cert_file"
569
+ ))
140
570
  else:
141
- if not Path(cert_file).exists():
142
- self.errors.append(f"SSL certificate file not found: {cert_file}")
143
- if not Path(key_file).exists():
144
- self.errors.append(f"SSL private key file not found: {key_file}")
145
-
146
- # mTLS
147
- verify_client = effective_ssl.get("verify_client", False)
148
- ca_candidates = [
149
- effective_ssl.get("ca_cert_file"), # security.ssl
150
- effective_ssl.get("ca_cert"), # legacy root.ssl
151
- ]
152
- ca_cert = next((c for c in ca_candidates if isinstance(c, str) and c), None)
153
-
154
- if verify_client:
155
- if not ca_cert:
156
- self.errors.append(
157
- "mTLS requires a CA certificate (security.ssl.ca_cert_file or ssl.ca_cert)"
571
+ # Validate certificate file
572
+ self._validate_certificate_file(cert_file, "ssl", "cert_file")
573
+
574
+ # Check key file
575
+ if not key_file:
576
+ self.validation_results.append(ValidationResult(
577
+ level="error",
578
+ message="SSL is enabled but key_file is not specified",
579
+ section="ssl",
580
+ key="key_file"
581
+ ))
582
+ elif not os.path.exists(key_file):
583
+ self.validation_results.append(ValidationResult(
584
+ level="error",
585
+ message=f"SSL key file '{key_file}' does not exist",
586
+ section="ssl",
587
+ key="key_file"
588
+ ))
589
+ else:
590
+ # Validate key file
591
+ self._validate_key_file(key_file, "ssl", "key_file")
592
+
593
+ # Check CA certificate if specified
594
+ if ca_cert:
595
+ if not os.path.exists(ca_cert):
596
+ self.validation_results.append(ValidationResult(
597
+ level="error",
598
+ message=f"SSL CA certificate file '{ca_cert}' does not exist",
599
+ section="ssl",
600
+ key="ca_cert"
601
+ ))
602
+ else:
603
+ # Validate CA certificate
604
+ self._validate_ca_certificate_file(ca_cert, "ssl", "ca_cert")
605
+
606
+ # Validate certificate-key pair if both exist
607
+ if cert_file and key_file and os.path.exists(cert_file) and os.path.exists(key_file):
608
+ self._validate_certificate_key_pair(cert_file, key_file, ca_cert, "ssl")
609
+
610
+ def _validate_roles_configuration(self) -> None:
611
+ """Validate roles configuration."""
612
+ roles_enabled = self._get_nested_value("roles.enabled", False)
613
+
614
+ if roles_enabled:
615
+ config_file = self._get_nested_value("roles.config_file")
616
+ if not config_file:
617
+ self.validation_results.append(ValidationResult(
618
+ level="error",
619
+ message="Roles are enabled but config_file is not specified",
620
+ section="roles",
621
+ key="config_file"
622
+ ))
623
+ elif not os.path.exists(config_file):
624
+ self.validation_results.append(ValidationResult(
625
+ level="error",
626
+ message=f"Roles config file '{config_file}' does not exist",
627
+ section="roles",
628
+ key="config_file"
629
+ ))
630
+
631
+ def _get_nested_value(self, key: str, default: Any = None) -> Any:
632
+ """Get value from nested dictionary using dot notation."""
633
+ keys = key.split(".")
634
+ value = self.config_data
635
+
636
+ for k in keys:
637
+ if isinstance(value, dict) and k in value:
638
+ value = value[k]
639
+ else:
640
+ return default
641
+
642
+ return value
643
+
644
+ def _has_nested_key(self, key: str) -> bool:
645
+ """Check if nested key exists in configuration."""
646
+ keys = key.split(".")
647
+ value = self.config_data
648
+
649
+ for k in keys:
650
+ if isinstance(value, dict) and k in value:
651
+ value = value[k]
652
+ else:
653
+ return False
654
+
655
+ return True
656
+
657
+ def _is_file_required_for_enabled_features(self, file_key: str) -> bool:
658
+ """Check if file is required based on enabled features."""
659
+ for feature_name, feature_config in self.feature_flags.items():
660
+ enabled_key = feature_config["enabled_key"]
661
+ is_enabled = self._get_nested_value(enabled_key, False)
662
+
663
+ if is_enabled and file_key in feature_config["required_files"]:
664
+ return True
665
+
666
+ return False
667
+
668
+ def _validate_certificate_file(self, cert_file: str, section: str, key: str) -> None:
669
+ """Validate certificate file format and content."""
670
+ try:
671
+ import cryptography
672
+ from cryptography import x509
673
+ from cryptography.hazmat.primitives import serialization
674
+
675
+ with open(cert_file, 'rb') as f:
676
+ cert_data = f.read()
677
+
678
+ # Try to parse as PEM
679
+ try:
680
+ cert = x509.load_pem_x509_certificate(cert_data)
681
+ except Exception:
682
+ # Try to parse as DER
683
+ try:
684
+ cert = x509.load_der_x509_certificate(cert_data)
685
+ except Exception as e:
686
+ self.validation_results.append(ValidationResult(
687
+ level="error",
688
+ message=f"Certificate file '{cert_file}' is not a valid PEM or DER certificate: {e}",
689
+ section=section,
690
+ key=key
691
+ ))
692
+ return
693
+
694
+ # Check certificate expiration
695
+ now = datetime.now(timezone.utc)
696
+ not_after = cert.not_valid_after.replace(tzinfo=timezone.utc)
697
+
698
+ if now > not_after:
699
+ self.validation_results.append(ValidationResult(
700
+ level="error",
701
+ message=f"Certificate '{cert_file}' has expired on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
702
+ section=section,
703
+ key=key
704
+ ))
705
+ elif (not_after - now).days < 30:
706
+ self.validation_results.append(ValidationResult(
707
+ level="warning",
708
+ message=f"Certificate '{cert_file}' expires in {(not_after - now).days} days on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
709
+ section=section,
710
+ key=key,
711
+ suggestion="Consider renewing the certificate"
712
+ ))
713
+
714
+ # Check if certificate is self-signed
715
+ issuer = cert.issuer
716
+ subject = cert.subject
717
+
718
+ if issuer == subject:
719
+ self.validation_results.append(ValidationResult(
720
+ level="warning",
721
+ message=f"Certificate '{cert_file}' is self-signed",
722
+ section=section,
723
+ key=key,
724
+ suggestion="Consider using a certificate from a trusted CA for production"
725
+ ))
726
+
727
+ # Check certificate key usage
728
+ try:
729
+ key_usage = cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.KEY_USAGE)
730
+ if not key_usage.value.digital_signature:
731
+ self.validation_results.append(ValidationResult(
732
+ level="warning",
733
+ message=f"Certificate '{cert_file}' does not have digital signature key usage",
734
+ section=section,
735
+ key=key,
736
+ suggestion="Ensure the certificate supports digital signature for SSL/TLS"
737
+ ))
738
+ except x509.ExtensionNotFound:
739
+ pass # Key usage extension not present, which is sometimes OK
740
+
741
+ except ImportError:
742
+ # cryptography library not available, do basic validation
743
+ self.validation_results.append(ValidationResult(
744
+ level="warning",
745
+ message=f"Cannot validate certificate '{cert_file}' - cryptography library not available",
746
+ section=section,
747
+ key=key,
748
+ suggestion="Install cryptography library for detailed certificate validation"
749
+ ))
750
+ except Exception as e:
751
+ self.validation_results.append(ValidationResult(
752
+ level="error",
753
+ message=f"Error validating certificate '{cert_file}': {e}",
754
+ section=section,
755
+ key=key
756
+ ))
757
+
758
+ def _validate_key_file(self, key_file: str, section: str, key: str) -> None:
759
+ """Validate private key file format and content."""
760
+ try:
761
+ import cryptography
762
+ from cryptography.hazmat.primitives import serialization
763
+
764
+ with open(key_file, 'rb') as f:
765
+ key_data = f.read()
766
+
767
+ # Try to parse as PEM
768
+ try:
769
+ private_key = serialization.load_pem_private_key(
770
+ key_data,
771
+ password=None
772
+ )
773
+ except Exception:
774
+ # Try to parse as DER
775
+ try:
776
+ private_key = serialization.load_der_private_key(
777
+ key_data,
778
+ password=None
158
779
  )
159
- elif not Path(ca_cert).exists():
160
- self.errors.append(f"CA certificate file not found: {ca_cert}")
161
-
162
- # TLS versions
163
- min_tls = str(effective_ssl.get("min_tls_version", "TLSv1.2"))
164
- valid_min = {"TLSv1.2", "1.2", "TLSv1.3", "1.3"}
165
- if min_tls not in valid_min:
166
- self.warnings.append(
167
- f"Unknown min_tls_version '{min_tls}', expected one of {sorted(valid_min)}"
780
+ except Exception as e:
781
+ self.validation_results.append(ValidationResult(
782
+ level="error",
783
+ message=f"Key file '{key_file}' is not a valid PEM or DER private key: {e}",
784
+ section=section,
785
+ key=key
786
+ ))
787
+ return
788
+
789
+ # Check key size
790
+ if hasattr(private_key, 'key_size'):
791
+ if private_key.key_size < 2048:
792
+ self.validation_results.append(ValidationResult(
793
+ level="warning",
794
+ message=f"Private key '{key_file}' has key size {private_key.key_size} bits, which is below recommended 2048 bits",
795
+ section=section,
796
+ key=key,
797
+ suggestion="Consider using a key with at least 2048 bits for better security"
798
+ ))
799
+
800
+ except ImportError:
801
+ # cryptography library not available, do basic validation
802
+ self.validation_results.append(ValidationResult(
803
+ level="warning",
804
+ message=f"Cannot validate private key '{key_file}' - cryptography library not available",
805
+ section=section,
806
+ key=key,
807
+ suggestion="Install cryptography library for detailed key validation"
808
+ ))
809
+ except Exception as e:
810
+ self.validation_results.append(ValidationResult(
811
+ level="error",
812
+ message=f"Error validating private key '{key_file}': {e}",
813
+ section=section,
814
+ key=key
815
+ ))
816
+
817
+ def _validate_ca_certificate_file(self, ca_cert_file: str, section: str, key: str) -> None:
818
+ """Validate CA certificate file."""
819
+ try:
820
+ import cryptography
821
+ from cryptography import x509
822
+
823
+ with open(ca_cert_file, 'rb') as f:
824
+ ca_data = f.read()
825
+
826
+ # Try to parse as PEM
827
+ try:
828
+ ca_cert = x509.load_pem_x509_certificate(ca_data)
829
+ except Exception:
830
+ # Try to parse as DER
831
+ try:
832
+ ca_cert = x509.load_der_x509_certificate(ca_data)
833
+ except Exception as e:
834
+ self.validation_results.append(ValidationResult(
835
+ level="error",
836
+ message=f"CA certificate file '{ca_cert_file}' is not a valid PEM or DER certificate: {e}",
837
+ section=section,
838
+ key=key
839
+ ))
840
+ return
841
+
842
+ # Check CA certificate expiration
843
+ now = datetime.now(timezone.utc)
844
+ not_after = ca_cert.not_valid_after.replace(tzinfo=timezone.utc)
845
+
846
+ if now > not_after:
847
+ self.validation_results.append(ValidationResult(
848
+ level="error",
849
+ message=f"CA certificate '{ca_cert_file}' has expired on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
850
+ section=section,
851
+ key=key
852
+ ))
853
+ elif (not_after - now).days < 30:
854
+ self.validation_results.append(ValidationResult(
855
+ level="warning",
856
+ message=f"CA certificate '{ca_cert_file}' expires in {(not_after - now).days} days on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
857
+ section=section,
858
+ key=key,
859
+ suggestion="Consider renewing the CA certificate"
860
+ ))
861
+
862
+ # Check if CA certificate has CA basic constraint
863
+ try:
864
+ basic_constraints = ca_cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.BASIC_CONSTRAINTS)
865
+ if not basic_constraints.value.ca:
866
+ self.validation_results.append(ValidationResult(
867
+ level="warning",
868
+ message=f"CA certificate '{ca_cert_file}' does not have CA basic constraint set",
869
+ section=section,
870
+ key=key,
871
+ suggestion="Ensure the certificate is marked as a CA certificate"
872
+ ))
873
+ except x509.ExtensionNotFound:
874
+ self.validation_results.append(ValidationResult(
875
+ level="warning",
876
+ message=f"CA certificate '{ca_cert_file}' does not have basic constraints extension",
877
+ section=section,
878
+ key=key,
879
+ suggestion="Consider using a proper CA certificate with basic constraints"
880
+ ))
881
+
882
+ except ImportError:
883
+ # cryptography library not available
884
+ self.validation_results.append(ValidationResult(
885
+ level="warning",
886
+ message=f"Cannot validate CA certificate '{ca_cert_file}' - cryptography library not available",
887
+ section=section,
888
+ key=key,
889
+ suggestion="Install cryptography library for detailed CA certificate validation"
890
+ ))
891
+ except Exception as e:
892
+ self.validation_results.append(ValidationResult(
893
+ level="error",
894
+ message=f"Error validating CA certificate '{ca_cert_file}': {e}",
895
+ section=section,
896
+ key=key
897
+ ))
898
+
899
+ def _validate_certificate_key_pair(self, cert_file: str, key_file: str, ca_cert_file: Optional[str], section: str) -> None:
900
+ """Validate that certificate and key are a matching pair."""
901
+ try:
902
+ import cryptography
903
+ from cryptography import x509
904
+ from cryptography.hazmat.primitives import serialization, hashes
905
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
906
+
907
+ # Load certificate
908
+ with open(cert_file, 'rb') as f:
909
+ cert_data = f.read()
910
+
911
+ try:
912
+ cert = x509.load_pem_x509_certificate(cert_data)
913
+ except Exception:
914
+ cert = x509.load_der_x509_certificate(cert_data)
915
+
916
+ # Load private key
917
+ with open(key_file, 'rb') as f:
918
+ key_data = f.read()
919
+
920
+ try:
921
+ private_key = serialization.load_pem_private_key(key_data, password=None)
922
+ except Exception:
923
+ private_key = serialization.load_der_private_key(key_data, password=None)
924
+
925
+ # Check if certificate public key matches private key
926
+ cert_public_key = cert.public_key()
927
+
928
+ # For RSA keys, compare modulus
929
+ if isinstance(cert_public_key, rsa.RSAPublicKey) and isinstance(private_key, rsa.RSAPrivateKey):
930
+ if cert_public_key.public_numbers().n != private_key.public_key().public_numbers().n:
931
+ self.validation_results.append(ValidationResult(
932
+ level="error",
933
+ message=f"Certificate '{cert_file}' and private key '{key_file}' do not match",
934
+ section=section,
935
+ key="cert_file",
936
+ suggestion="Ensure the certificate and private key are from the same key pair"
937
+ ))
938
+ return
939
+
940
+ # If CA certificate is provided, validate certificate chain
941
+ if ca_cert_file and os.path.exists(ca_cert_file):
942
+ self._validate_certificate_chain(cert_file, ca_cert_file, section)
943
+
944
+ except ImportError:
945
+ # cryptography library not available
946
+ self.validation_results.append(ValidationResult(
947
+ level="warning",
948
+ message=f"Cannot validate certificate-key pair - cryptography library not available",
949
+ section=section,
950
+ key="cert_file",
951
+ suggestion="Install cryptography library for detailed certificate validation"
952
+ ))
953
+ except Exception as e:
954
+ self.validation_results.append(ValidationResult(
955
+ level="error",
956
+ message=f"Error validating certificate-key pair: {e}",
957
+ section=section,
958
+ key=key
959
+ ))
960
+
961
+ def _validate_certificate_chain(self, cert_file: str, ca_cert_file: str, section: str) -> None:
962
+ """Validate certificate chain against CA certificate."""
963
+ try:
964
+ import cryptography
965
+ from cryptography import x509
966
+ from cryptography.hazmat.primitives import hashes
967
+ from cryptography.hazmat.primitives.asymmetric import padding
968
+
969
+ # Load certificate
970
+ with open(cert_file, 'rb') as f:
971
+ cert_data = f.read()
972
+
973
+ try:
974
+ cert = x509.load_pem_x509_certificate(cert_data)
975
+ except Exception:
976
+ cert = x509.load_der_x509_certificate(cert_data)
977
+
978
+ # Load CA certificate
979
+ with open(ca_cert_file, 'rb') as f:
980
+ ca_data = f.read()
981
+
982
+ try:
983
+ ca_cert = x509.load_pem_x509_certificate(ca_data)
984
+ except Exception:
985
+ ca_cert = x509.load_der_x509_certificate(ca_data)
986
+
987
+ # Verify certificate signature with CA
988
+ try:
989
+ ca_cert.public_key().verify(
990
+ cert.signature,
991
+ cert.tbs_certificate_bytes,
992
+ padding.PKCS1v15(),
993
+ cert.signature_algorithm_oid._name
168
994
  )
995
+ except Exception as e:
996
+ self.validation_results.append(ValidationResult(
997
+ level="error",
998
+ message=f"Certificate '{cert_file}' is not signed by CA certificate '{ca_cert_file}': {e}",
999
+ section=section,
1000
+ key="cert_file",
1001
+ suggestion="Ensure the certificate is properly signed by the CA"
1002
+ ))
1003
+ return
1004
+
1005
+ # Check if certificate issuer matches CA subject
1006
+ if cert.issuer != ca_cert.subject:
1007
+ self.validation_results.append(ValidationResult(
1008
+ level="warning",
1009
+ message=f"Certificate issuer '{cert.issuer}' does not match CA subject '{ca_cert.subject}'",
1010
+ section=section,
1011
+ key="cert_file",
1012
+ suggestion="Verify that the certificate is issued by the correct CA"
1013
+ ))
1014
+
1015
+ except ImportError:
1016
+ # cryptography library not available
1017
+ self.validation_results.append(ValidationResult(
1018
+ level="warning",
1019
+ message=f"Cannot validate certificate chain - cryptography library not available",
1020
+ section=section,
1021
+ key="cert_file",
1022
+ suggestion="Install cryptography library for detailed certificate chain validation"
1023
+ ))
1024
+ except Exception as e:
1025
+ self.validation_results.append(ValidationResult(
1026
+ level="error",
1027
+ message=f"Error validating certificate chain: {e}",
1028
+ section=section,
1029
+ key="cert_file"
1030
+ ))
1031
+
1032
+ def get_validation_summary(self) -> Dict[str, Any]:
1033
+ """Get summary of validation results."""
1034
+ error_count = sum(1 for r in self.validation_results if r.level == "error")
1035
+ warning_count = sum(1 for r in self.validation_results if r.level == "warning")
1036
+ info_count = sum(1 for r in self.validation_results if r.level == "info")
1037
+
1038
+ return {
1039
+ "total_issues": len(self.validation_results),
1040
+ "errors": error_count,
1041
+ "warnings": warning_count,
1042
+ "info": info_count,
1043
+ "is_valid": error_count == 0
1044
+ }
1045
+
1046
+ def print_validation_report(self) -> None:
1047
+ """Print detailed validation report."""
1048
+ summary = self.get_validation_summary()
1049
+
1050
+ print("=" * 60)
1051
+ print("CONFIGURATION VALIDATION REPORT")
1052
+ print("=" * 60)
1053
+ print(f"Total issues: {summary['total_issues']}")
1054
+ print(f"Errors: {summary['errors']}")
1055
+ print(f"Warnings: {summary['warnings']}")
1056
+ print(f"Info: {summary['info']}")
1057
+ print(f"Configuration is valid: {summary['is_valid']}")
1058
+ print("=" * 60)
1059
+
1060
+ if self.validation_results:
1061
+ for result in self.validation_results:
1062
+ level_symbol = {
1063
+ "error": "❌",
1064
+ "warning": "⚠️",
1065
+ "info": "ℹ️"
1066
+ }[result.level]
1067
+
1068
+ location = f"{result.section}.{result.key}" if result.key else result.section
1069
+ print(f"{level_symbol} [{result.level.value.upper()}] {result.message}")
1070
+ if location:
1071
+ print(f" Location: {location}")
1072
+ if result.suggestion:
1073
+ print(f" Suggestion: {result.suggestion}")
1074
+ print()
1075
+ else:
1076
+ print("✅ No issues found in configuration!")
169
1077
 
170
- # Conflict check
171
- if security_ssl.get("enabled") and root_ssl.get("enabled"):
172
- self.warnings.append(
173
- "SSL configured in both security.ssl and root ssl; security.ssl is preferred"
174
- )
175
-
176
- def _validate_security_auth(self, config: Dict[str, Any]) -> None:
177
- sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
178
- auth = sec.get("auth", {}) if isinstance(sec.get("auth"), dict) else {}
179
-
180
- if not sec.get("enabled", False) or not auth.get("enabled", False):
181
- return
182
-
183
- methods = auth.get("methods", [])
184
- if not isinstance(methods, list):
185
- self.errors.append("security.auth.methods must be a list")
186
- return
187
1078
 
188
- if "jwt" in methods and not auth.get("jwt_secret"):
189
- self.warnings.append("JWT method enabled but jwt_secret is empty")
190
-
191
- if "certificate" in methods:
192
- # Ensure SSL is enabled for certificate auth
193
- ssl_enabled = (
194
- (sec.get("ssl", {}) or {}).get("enabled", False)
195
- or (config.get("ssl", {}) or {}).get("enabled", False)
196
- )
197
- if not ssl_enabled:
198
- self.errors.append(
199
- "Certificate auth enabled but SSL is disabled (enable security.ssl or root ssl)"
200
- )
1079
+ def validate_config_file(config_path: str) -> bool:
1080
+ """
1081
+ Validate a configuration file.
1082
+
1083
+ Args:
1084
+ config_path: Path to configuration file
1085
+
1086
+ Returns:
1087
+ True if configuration is valid, False otherwise
1088
+ """
1089
+ validator = ConfigValidator(config_path)
1090
+ validator.load_config()
1091
+ validator.validate_config()
1092
+
1093
+ validator.print_validation_report()
1094
+ return validator.get_validation_summary()["is_valid"]
201
1095
 
202
- def _validate_permissions(self, config: Dict[str, Any]) -> None:
203
- sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
204
- perm = sec.get("permissions", {}) if isinstance(sec.get("permissions"), dict) else {}
205
- roles = perm.get("roles_file")
206
- if perm.get("enabled", False) and roles:
207
- if not Path(roles).exists():
208
- self.errors.append(f"Permissions enabled but roles file not found: {roles}")
209
1096
 
210
- def _validate_rate_limit(self, config: Dict[str, Any]) -> None:
211
- sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
212
- rl = sec.get("rate_limit", {}) if isinstance(sec.get("rate_limit"), dict) else {}
213
- if rl.get("enabled", False):
214
- rpm = rl.get("default_requests_per_minute") or rl.get("requests_per_minute")
215
- if rpm is None or not isinstance(rpm, int) or rpm <= 0:
216
- self.errors.append(
217
- "Rate limit enabled but requests_per_minute is not a positive integer"
218
- )
1097
+ if __name__ == "__main__":
1098
+ import sys
1099
+
1100
+ if len(sys.argv) != 2:
1101
+ print("Usage: python config_validator.py <config_file>")
1102
+ sys.exit(1)
1103
+
1104
+ config_file = sys.argv[1]
1105
+ is_valid = validate_config_file(config_file)
1106
+ sys.exit(0 if is_valid else 1)