mcp-proxy-adapter 6.8.1__py3-none-any.whl → 6.9.0__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,1041 @@
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,
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."""
255
+ for section_name, required_keys in self.required_sections.items():
256
+ if section_name not in self.config_data:
257
+ self.validation_results.append(ValidationResult(
258
+ level="error",
259
+ message=f"Required section '{section_name}' is missing",
260
+ section=section_name
261
+ ))
262
+ continue
263
+
264
+ section_data = self.config_data[section_name]
265
+ for key, expected_type in required_keys.items():
266
+ if key not in section_data:
267
+ self.validation_results.append(ValidationResult(
268
+ level="error",
269
+ message=f"Required key '{key}' is missing in section '{section_name}'",
270
+ section=section_name,
271
+ key=key
272
+ ))
273
+ else:
274
+ # Validate type
275
+ value = section_data[key]
276
+ if not isinstance(value, expected_type):
277
+ self.validation_results.append(ValidationResult(
278
+ level="error",
279
+ message=f"Key '{key}' in section '{section_name}' has wrong type. Expected {expected_type.__name__}, got {type(value).__name__}",
280
+ section=section_name,
281
+ key=key
282
+ ))
283
+
284
+ def _validate_feature_flags(self) -> None:
285
+ """Validate feature flags and their dependencies."""
286
+ for feature_name, feature_config in self.feature_flags.items():
287
+ enabled_key = feature_config["enabled_key"]
288
+ is_enabled = self._get_nested_value(enabled_key, False)
289
+
290
+ if is_enabled:
291
+ # Check dependencies
292
+ for dependency in feature_config["dependencies"]:
293
+ if not self._has_nested_key(dependency):
294
+ self.validation_results.append(ValidationResult(
295
+ level="error",
296
+ message=f"Feature '{feature_name}' is enabled but required dependency '{dependency}' is missing",
297
+ section=feature_name,
298
+ key=dependency
299
+ ))
300
+
301
+ # Check required files
302
+ for file_key in feature_config["required_files"]:
303
+ file_path = self._get_nested_value(file_key)
304
+ if file_path and not os.path.exists(file_path):
305
+ self.validation_results.append(ValidationResult(
306
+ level="error",
307
+ message=f"Feature '{feature_name}' is enabled but required file '{file_path}' does not exist",
308
+ section=feature_name,
309
+ key=file_key
310
+ ))
311
+ else:
312
+ # Feature is disabled - check that optional files are not required
313
+ for file_key in feature_config["optional_files"]:
314
+ file_path = self._get_nested_value(file_key)
315
+ if file_path and not os.path.exists(file_path):
316
+ self.validation_results.append(ValidationResult(
317
+ level="warning",
318
+ message=f"Optional file '{file_path}' for disabled feature '{feature_name}' does not exist",
319
+ section=feature_name,
320
+ key=file_key,
321
+ suggestion="This is not an error since the feature is disabled"
322
+ ))
323
+
324
+ def _validate_protocol_requirements(self) -> None:
325
+ """Validate protocol-specific requirements."""
326
+ protocol = self._get_nested_value("server.protocol", "http")
327
+
328
+ if protocol not in self.protocol_requirements:
329
+ self.validation_results.append(ValidationResult(
330
+ level="error",
331
+ message=f"Unsupported protocol: {protocol}",
332
+ section="server",
333
+ key="protocol"
334
+ ))
131
335
  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
- )
336
+
337
+ requirements = self.protocol_requirements[protocol]
338
+
339
+ # Check SSL requirements
340
+ if requirements["ssl_enabled"]:
341
+ ssl_enabled = self._get_nested_value("ssl.enabled", False)
342
+ if not ssl_enabled:
343
+ self.validation_results.append(ValidationResult(
344
+ level="error",
345
+ message=f"Protocol '{protocol}' requires SSL to be enabled",
346
+ section="ssl",
347
+ key="enabled"
348
+ ))
349
+
350
+ # Check required SSL files
351
+ for file_key in requirements["required_files"]:
352
+ file_path = self._get_nested_value(file_key)
353
+ if not file_path:
354
+ self.validation_results.append(ValidationResult(
355
+ level="error",
356
+ message=f"Protocol '{protocol}' requires {file_key} to be specified",
357
+ section="ssl",
358
+ key=file_key.split(".")[-1]
359
+ ))
360
+ elif not os.path.exists(file_path):
361
+ self.validation_results.append(ValidationResult(
362
+ level="error",
363
+ message=f"Protocol '{protocol}' requires file '{file_path}' to exist",
364
+ section="ssl",
365
+ key=file_key.split(".")[-1]
366
+ ))
367
+
368
+ # Check client verification requirements
369
+ if requirements["client_verification"]:
370
+ verify_client = self._get_nested_value("transport.ssl.verify_client", False)
371
+ if not verify_client:
372
+ self.validation_results.append(ValidationResult(
373
+ level="error",
374
+ message=f"Protocol '{protocol}' requires client certificate verification",
375
+ section="transport.ssl",
376
+ key="verify_client"
377
+ ))
378
+
379
+ def _validate_file_existence(self) -> None:
380
+ """Validate that all referenced files exist."""
381
+ file_keys = [
382
+ "logging.log_dir",
383
+ "commands.commands_directory",
384
+ "commands.catalog_directory",
385
+ "commands.custom_commands_path",
386
+ "security.roles_file",
387
+ "roles.config_file",
388
+ "ssl.cert_file",
389
+ "ssl.key_file",
390
+ "ssl.ca_cert",
391
+ "transport.ssl.cert_file",
392
+ "transport.ssl.key_file",
393
+ "transport.ssl.ca_cert",
394
+ "proxy_registration.certificate.cert_file",
395
+ "proxy_registration.certificate.key_file"
396
+ ]
397
+
398
+ for file_key in file_keys:
399
+ file_path = self._get_nested_value(file_key)
400
+ if file_path and not os.path.exists(file_path):
401
+ # Check if this is a required file based on enabled features
402
+ is_required = self._is_file_required_for_enabled_features(file_key)
403
+ level = "error" if is_required else "warning"
404
+
405
+ self.validation_results.append(ValidationResult(
406
+ level=level,
407
+ message=f"Referenced file '{file_path}' does not exist",
408
+ section=file_key.split(".")[0],
409
+ key=file_key.split(".")[-1],
410
+ suggestion="Create the file or update the configuration" if is_required else "This file is optional"
411
+ ))
412
+
413
+ def _validate_security_consistency(self) -> None:
414
+ """Validate security configuration consistency."""
415
+ security_enabled = self._get_nested_value("security.enabled", False)
416
+
417
+ if security_enabled:
418
+ # Check if authentication is properly configured
419
+ tokens = self._get_nested_value("security.tokens", {})
420
+ roles = self._get_nested_value("security.roles", {})
421
+ roles_file = self._get_nested_value("security.roles_file")
422
+
423
+ has_tokens = bool(tokens and any(tokens.values()))
424
+ has_roles = bool(roles and any(roles.values()))
425
+ has_roles_file = bool(roles_file and os.path.exists(roles_file))
426
+
427
+ if not (has_tokens or has_roles or has_roles_file):
428
+ self.validation_results.append(ValidationResult(
429
+ level="warning",
430
+ message="Security is enabled but no authentication methods are configured",
431
+ section="security",
432
+ suggestion="Configure tokens, roles, or roles_file in the security section"
433
+ ))
434
+
435
+ # Check roles consistency
436
+ if has_roles and has_roles_file:
437
+ self.validation_results.append(ValidationResult(
438
+ level="warning",
439
+ message="Both inline roles and roles_file are configured. roles_file will take precedence",
440
+ section="security",
441
+ suggestion="Remove either inline roles or roles_file configuration"
442
+ ))
443
+
444
+ def _validate_proxy_registration(self) -> None:
445
+ """Validate proxy registration configuration."""
446
+ registration_enabled = self._get_nested_value("proxy_registration.enabled", False)
447
+
448
+ if registration_enabled:
449
+ proxy_url = self._get_nested_value("proxy_registration.proxy_url")
450
+ if not proxy_url:
451
+ self.validation_results.append(ValidationResult(
452
+ level="error",
453
+ message="Proxy registration is enabled but proxy_url is not specified",
454
+ section="proxy_registration",
455
+ key="proxy_url"
456
+ ))
457
+
458
+ # Check authentication method consistency
459
+ auth_method = self._get_nested_value("proxy_registration.auth_method", "none")
460
+ if auth_method != "none":
461
+ if auth_method == "certificate":
462
+ cert_file = self._get_nested_value("proxy_registration.certificate.cert_file")
463
+ key_file = self._get_nested_value("proxy_registration.certificate.key_file")
464
+ if not cert_file or not key_file:
465
+ self.validation_results.append(ValidationResult(
466
+ level="error",
467
+ message="Certificate authentication is enabled but certificate files are not specified",
468
+ section="proxy_registration",
469
+ key="certificate"
470
+ ))
471
+ elif auth_method == "token":
472
+ token = self._get_nested_value("proxy_registration.token.token")
473
+ if not token:
474
+ self.validation_results.append(ValidationResult(
475
+ level="error",
476
+ message="Token authentication is enabled but token is not specified",
477
+ section="proxy_registration",
478
+ key="token"
479
+ ))
480
+
481
+ def _validate_ssl_configuration(self) -> None:
482
+ """Validate SSL configuration with detailed certificate validation."""
483
+ ssl_enabled = self._get_nested_value("ssl.enabled", False)
484
+
485
+ if ssl_enabled:
486
+ cert_file = self._get_nested_value("ssl.cert_file")
487
+ key_file = self._get_nested_value("ssl.key_file")
488
+ ca_cert = self._get_nested_value("ssl.ca_cert")
489
+
490
+ # Check certificate file
491
+ if not cert_file:
492
+ self.validation_results.append(ValidationResult(
493
+ level="error",
494
+ message="SSL is enabled but cert_file is not specified",
495
+ section="ssl",
496
+ key="cert_file"
497
+ ))
498
+ elif not os.path.exists(cert_file):
499
+ self.validation_results.append(ValidationResult(
500
+ level="error",
501
+ message=f"SSL certificate file '{cert_file}' does not exist",
502
+ section="ssl",
503
+ key="cert_file"
504
+ ))
140
505
  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)"
506
+ # Validate certificate file
507
+ self._validate_certificate_file(cert_file, "ssl", "cert_file")
508
+
509
+ # Check key file
510
+ if not key_file:
511
+ self.validation_results.append(ValidationResult(
512
+ level="error",
513
+ message="SSL is enabled but key_file is not specified",
514
+ section="ssl",
515
+ key="key_file"
516
+ ))
517
+ elif not os.path.exists(key_file):
518
+ self.validation_results.append(ValidationResult(
519
+ level="error",
520
+ message=f"SSL key file '{key_file}' does not exist",
521
+ section="ssl",
522
+ key="key_file"
523
+ ))
524
+ else:
525
+ # Validate key file
526
+ self._validate_key_file(key_file, "ssl", "key_file")
527
+
528
+ # Check CA certificate if specified
529
+ if ca_cert:
530
+ if not os.path.exists(ca_cert):
531
+ self.validation_results.append(ValidationResult(
532
+ level="error",
533
+ message=f"SSL CA certificate file '{ca_cert}' does not exist",
534
+ section="ssl",
535
+ key="ca_cert"
536
+ ))
537
+ else:
538
+ # Validate CA certificate
539
+ self._validate_ca_certificate_file(ca_cert, "ssl", "ca_cert")
540
+
541
+ # Validate certificate-key pair if both exist
542
+ if cert_file and key_file and os.path.exists(cert_file) and os.path.exists(key_file):
543
+ self._validate_certificate_key_pair(cert_file, key_file, ca_cert, "ssl")
544
+
545
+ def _validate_roles_configuration(self) -> None:
546
+ """Validate roles configuration."""
547
+ roles_enabled = self._get_nested_value("roles.enabled", False)
548
+
549
+ if roles_enabled:
550
+ config_file = self._get_nested_value("roles.config_file")
551
+ if not config_file:
552
+ self.validation_results.append(ValidationResult(
553
+ level="error",
554
+ message="Roles are enabled but config_file is not specified",
555
+ section="roles",
556
+ key="config_file"
557
+ ))
558
+ elif not os.path.exists(config_file):
559
+ self.validation_results.append(ValidationResult(
560
+ level="error",
561
+ message=f"Roles config file '{config_file}' does not exist",
562
+ section="roles",
563
+ key="config_file"
564
+ ))
565
+
566
+ def _get_nested_value(self, key: str, default: Any = None) -> Any:
567
+ """Get value from nested dictionary using dot notation."""
568
+ keys = key.split(".")
569
+ value = self.config_data
570
+
571
+ for k in keys:
572
+ if isinstance(value, dict) and k in value:
573
+ value = value[k]
574
+ else:
575
+ return default
576
+
577
+ return value
578
+
579
+ def _has_nested_key(self, key: str) -> bool:
580
+ """Check if nested key exists in configuration."""
581
+ keys = key.split(".")
582
+ value = self.config_data
583
+
584
+ for k in keys:
585
+ if isinstance(value, dict) and k in value:
586
+ value = value[k]
587
+ else:
588
+ return False
589
+
590
+ return True
591
+
592
+ def _is_file_required_for_enabled_features(self, file_key: str) -> bool:
593
+ """Check if file is required based on enabled features."""
594
+ for feature_name, feature_config in self.feature_flags.items():
595
+ enabled_key = feature_config["enabled_key"]
596
+ is_enabled = self._get_nested_value(enabled_key, False)
597
+
598
+ if is_enabled and file_key in feature_config["required_files"]:
599
+ return True
600
+
601
+ return False
602
+
603
+ def _validate_certificate_file(self, cert_file: str, section: str, key: str) -> None:
604
+ """Validate certificate file format and content."""
605
+ try:
606
+ import cryptography
607
+ from cryptography import x509
608
+ from cryptography.hazmat.primitives import serialization
609
+
610
+ with open(cert_file, 'rb') as f:
611
+ cert_data = f.read()
612
+
613
+ # Try to parse as PEM
614
+ try:
615
+ cert = x509.load_pem_x509_certificate(cert_data)
616
+ except Exception:
617
+ # Try to parse as DER
618
+ try:
619
+ cert = x509.load_der_x509_certificate(cert_data)
620
+ except Exception as e:
621
+ self.validation_results.append(ValidationResult(
622
+ level="error",
623
+ message=f"Certificate file '{cert_file}' is not a valid PEM or DER certificate: {e}",
624
+ section=section,
625
+ key=key
626
+ ))
627
+ return
628
+
629
+ # Check certificate expiration
630
+ now = datetime.now(timezone.utc)
631
+ not_after = cert.not_valid_after.replace(tzinfo=timezone.utc)
632
+
633
+ if now > not_after:
634
+ self.validation_results.append(ValidationResult(
635
+ level="error",
636
+ message=f"Certificate '{cert_file}' has expired on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
637
+ section=section,
638
+ key=key
639
+ ))
640
+ elif (not_after - now).days < 30:
641
+ self.validation_results.append(ValidationResult(
642
+ level="warning",
643
+ message=f"Certificate '{cert_file}' expires in {(not_after - now).days} days on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
644
+ section=section,
645
+ key=key,
646
+ suggestion="Consider renewing the certificate"
647
+ ))
648
+
649
+ # Check if certificate is self-signed
650
+ issuer = cert.issuer
651
+ subject = cert.subject
652
+
653
+ if issuer == subject:
654
+ self.validation_results.append(ValidationResult(
655
+ level="warning",
656
+ message=f"Certificate '{cert_file}' is self-signed",
657
+ section=section,
658
+ key=key,
659
+ suggestion="Consider using a certificate from a trusted CA for production"
660
+ ))
661
+
662
+ # Check certificate key usage
663
+ try:
664
+ key_usage = cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.KEY_USAGE)
665
+ if not key_usage.value.digital_signature:
666
+ self.validation_results.append(ValidationResult(
667
+ level="warning",
668
+ message=f"Certificate '{cert_file}' does not have digital signature key usage",
669
+ section=section,
670
+ key=key,
671
+ suggestion="Ensure the certificate supports digital signature for SSL/TLS"
672
+ ))
673
+ except x509.ExtensionNotFound:
674
+ pass # Key usage extension not present, which is sometimes OK
675
+
676
+ except ImportError:
677
+ # cryptography library not available, do basic validation
678
+ self.validation_results.append(ValidationResult(
679
+ level="warning",
680
+ message=f"Cannot validate certificate '{cert_file}' - cryptography library not available",
681
+ section=section,
682
+ key=key,
683
+ suggestion="Install cryptography library for detailed certificate validation"
684
+ ))
685
+ except Exception as e:
686
+ self.validation_results.append(ValidationResult(
687
+ level="error",
688
+ message=f"Error validating certificate '{cert_file}': {e}",
689
+ section=section,
690
+ key=key
691
+ ))
692
+
693
+ def _validate_key_file(self, key_file: str, section: str, key: str) -> None:
694
+ """Validate private key file format and content."""
695
+ try:
696
+ import cryptography
697
+ from cryptography.hazmat.primitives import serialization
698
+
699
+ with open(key_file, 'rb') as f:
700
+ key_data = f.read()
701
+
702
+ # Try to parse as PEM
703
+ try:
704
+ private_key = serialization.load_pem_private_key(
705
+ key_data,
706
+ password=None
707
+ )
708
+ except Exception:
709
+ # Try to parse as DER
710
+ try:
711
+ private_key = serialization.load_der_private_key(
712
+ key_data,
713
+ password=None
158
714
  )
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)}"
715
+ except Exception as e:
716
+ self.validation_results.append(ValidationResult(
717
+ level="error",
718
+ message=f"Key file '{key_file}' is not a valid PEM or DER private key: {e}",
719
+ section=section,
720
+ key=key
721
+ ))
722
+ return
723
+
724
+ # Check key size
725
+ if hasattr(private_key, 'key_size'):
726
+ if private_key.key_size < 2048:
727
+ self.validation_results.append(ValidationResult(
728
+ level="warning",
729
+ message=f"Private key '{key_file}' has key size {private_key.key_size} bits, which is below recommended 2048 bits",
730
+ section=section,
731
+ key=key,
732
+ suggestion="Consider using a key with at least 2048 bits for better security"
733
+ ))
734
+
735
+ except ImportError:
736
+ # cryptography library not available, do basic validation
737
+ self.validation_results.append(ValidationResult(
738
+ level="warning",
739
+ message=f"Cannot validate private key '{key_file}' - cryptography library not available",
740
+ section=section,
741
+ key=key,
742
+ suggestion="Install cryptography library for detailed key validation"
743
+ ))
744
+ except Exception as e:
745
+ self.validation_results.append(ValidationResult(
746
+ level="error",
747
+ message=f"Error validating private key '{key_file}': {e}",
748
+ section=section,
749
+ key=key
750
+ ))
751
+
752
+ def _validate_ca_certificate_file(self, ca_cert_file: str, section: str, key: str) -> None:
753
+ """Validate CA certificate file."""
754
+ try:
755
+ import cryptography
756
+ from cryptography import x509
757
+
758
+ with open(ca_cert_file, 'rb') as f:
759
+ ca_data = f.read()
760
+
761
+ # Try to parse as PEM
762
+ try:
763
+ ca_cert = x509.load_pem_x509_certificate(ca_data)
764
+ except Exception:
765
+ # Try to parse as DER
766
+ try:
767
+ ca_cert = x509.load_der_x509_certificate(ca_data)
768
+ except Exception as e:
769
+ self.validation_results.append(ValidationResult(
770
+ level="error",
771
+ message=f"CA certificate file '{ca_cert_file}' is not a valid PEM or DER certificate: {e}",
772
+ section=section,
773
+ key=key
774
+ ))
775
+ return
776
+
777
+ # Check CA certificate expiration
778
+ now = datetime.now(timezone.utc)
779
+ not_after = ca_cert.not_valid_after.replace(tzinfo=timezone.utc)
780
+
781
+ if now > not_after:
782
+ self.validation_results.append(ValidationResult(
783
+ level="error",
784
+ message=f"CA certificate '{ca_cert_file}' has expired on {not_after.strftime('%Y-%m-%d %H:%M:%S UTC')}",
785
+ section=section,
786
+ key=key
787
+ ))
788
+ elif (not_after - now).days < 30:
789
+ self.validation_results.append(ValidationResult(
790
+ level="warning",
791
+ 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')}",
792
+ section=section,
793
+ key=key,
794
+ suggestion="Consider renewing the CA certificate"
795
+ ))
796
+
797
+ # Check if CA certificate has CA basic constraint
798
+ try:
799
+ basic_constraints = ca_cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.BASIC_CONSTRAINTS)
800
+ if not basic_constraints.value.ca:
801
+ self.validation_results.append(ValidationResult(
802
+ level="warning",
803
+ message=f"CA certificate '{ca_cert_file}' does not have CA basic constraint set",
804
+ section=section,
805
+ key=key,
806
+ suggestion="Ensure the certificate is marked as a CA certificate"
807
+ ))
808
+ except x509.ExtensionNotFound:
809
+ self.validation_results.append(ValidationResult(
810
+ level="warning",
811
+ message=f"CA certificate '{ca_cert_file}' does not have basic constraints extension",
812
+ section=section,
813
+ key=key,
814
+ suggestion="Consider using a proper CA certificate with basic constraints"
815
+ ))
816
+
817
+ except ImportError:
818
+ # cryptography library not available
819
+ self.validation_results.append(ValidationResult(
820
+ level="warning",
821
+ message=f"Cannot validate CA certificate '{ca_cert_file}' - cryptography library not available",
822
+ section=section,
823
+ key=key,
824
+ suggestion="Install cryptography library for detailed CA certificate validation"
825
+ ))
826
+ except Exception as e:
827
+ self.validation_results.append(ValidationResult(
828
+ level="error",
829
+ message=f"Error validating CA certificate '{ca_cert_file}': {e}",
830
+ section=section,
831
+ key=key
832
+ ))
833
+
834
+ def _validate_certificate_key_pair(self, cert_file: str, key_file: str, ca_cert_file: Optional[str], section: str) -> None:
835
+ """Validate that certificate and key are a matching pair."""
836
+ try:
837
+ import cryptography
838
+ from cryptography import x509
839
+ from cryptography.hazmat.primitives import serialization, hashes
840
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
841
+
842
+ # Load certificate
843
+ with open(cert_file, 'rb') as f:
844
+ cert_data = f.read()
845
+
846
+ try:
847
+ cert = x509.load_pem_x509_certificate(cert_data)
848
+ except Exception:
849
+ cert = x509.load_der_x509_certificate(cert_data)
850
+
851
+ # Load private key
852
+ with open(key_file, 'rb') as f:
853
+ key_data = f.read()
854
+
855
+ try:
856
+ private_key = serialization.load_pem_private_key(key_data, password=None)
857
+ except Exception:
858
+ private_key = serialization.load_der_private_key(key_data, password=None)
859
+
860
+ # Check if certificate public key matches private key
861
+ cert_public_key = cert.public_key()
862
+
863
+ # For RSA keys, compare modulus
864
+ if isinstance(cert_public_key, rsa.RSAPublicKey) and isinstance(private_key, rsa.RSAPrivateKey):
865
+ if cert_public_key.public_numbers().n != private_key.public_key().public_numbers().n:
866
+ self.validation_results.append(ValidationResult(
867
+ level="error",
868
+ message=f"Certificate '{cert_file}' and private key '{key_file}' do not match",
869
+ section=section,
870
+ key="cert_file",
871
+ suggestion="Ensure the certificate and private key are from the same key pair"
872
+ ))
873
+ return
874
+
875
+ # If CA certificate is provided, validate certificate chain
876
+ if ca_cert_file and os.path.exists(ca_cert_file):
877
+ self._validate_certificate_chain(cert_file, ca_cert_file, section)
878
+
879
+ except ImportError:
880
+ # cryptography library not available
881
+ self.validation_results.append(ValidationResult(
882
+ level="warning",
883
+ message=f"Cannot validate certificate-key pair - cryptography library not available",
884
+ section=section,
885
+ key="cert_file",
886
+ suggestion="Install cryptography library for detailed certificate validation"
887
+ ))
888
+ except Exception as e:
889
+ self.validation_results.append(ValidationResult(
890
+ level="error",
891
+ message=f"Error validating certificate-key pair: {e}",
892
+ section=section,
893
+ key=key
894
+ ))
895
+
896
+ def _validate_certificate_chain(self, cert_file: str, ca_cert_file: str, section: str) -> None:
897
+ """Validate certificate chain against CA certificate."""
898
+ try:
899
+ import cryptography
900
+ from cryptography import x509
901
+ from cryptography.hazmat.primitives import hashes
902
+ from cryptography.hazmat.primitives.asymmetric import padding
903
+
904
+ # Load certificate
905
+ with open(cert_file, 'rb') as f:
906
+ cert_data = f.read()
907
+
908
+ try:
909
+ cert = x509.load_pem_x509_certificate(cert_data)
910
+ except Exception:
911
+ cert = x509.load_der_x509_certificate(cert_data)
912
+
913
+ # Load CA certificate
914
+ with open(ca_cert_file, 'rb') as f:
915
+ ca_data = f.read()
916
+
917
+ try:
918
+ ca_cert = x509.load_pem_x509_certificate(ca_data)
919
+ except Exception:
920
+ ca_cert = x509.load_der_x509_certificate(ca_data)
921
+
922
+ # Verify certificate signature with CA
923
+ try:
924
+ ca_cert.public_key().verify(
925
+ cert.signature,
926
+ cert.tbs_certificate_bytes,
927
+ padding.PKCS1v15(),
928
+ cert.signature_algorithm_oid._name
168
929
  )
930
+ except Exception as e:
931
+ self.validation_results.append(ValidationResult(
932
+ level="error",
933
+ message=f"Certificate '{cert_file}' is not signed by CA certificate '{ca_cert_file}': {e}",
934
+ section=section,
935
+ key="cert_file",
936
+ suggestion="Ensure the certificate is properly signed by the CA"
937
+ ))
938
+ return
939
+
940
+ # Check if certificate issuer matches CA subject
941
+ if cert.issuer != ca_cert.subject:
942
+ self.validation_results.append(ValidationResult(
943
+ level="warning",
944
+ message=f"Certificate issuer '{cert.issuer}' does not match CA subject '{ca_cert.subject}'",
945
+ section=section,
946
+ key="cert_file",
947
+ suggestion="Verify that the certificate is issued by the correct CA"
948
+ ))
949
+
950
+ except ImportError:
951
+ # cryptography library not available
952
+ self.validation_results.append(ValidationResult(
953
+ level="warning",
954
+ message=f"Cannot validate certificate chain - cryptography library not available",
955
+ section=section,
956
+ key="cert_file",
957
+ suggestion="Install cryptography library for detailed certificate chain validation"
958
+ ))
959
+ except Exception as e:
960
+ self.validation_results.append(ValidationResult(
961
+ level="error",
962
+ message=f"Error validating certificate chain: {e}",
963
+ section=section,
964
+ key="cert_file"
965
+ ))
966
+
967
+ def get_validation_summary(self) -> Dict[str, Any]:
968
+ """Get summary of validation results."""
969
+ error_count = sum(1 for r in self.validation_results if r.level == "error")
970
+ warning_count = sum(1 for r in self.validation_results if r.level == "warning")
971
+ info_count = sum(1 for r in self.validation_results if r.level == "info")
972
+
973
+ return {
974
+ "total_issues": len(self.validation_results),
975
+ "errors": error_count,
976
+ "warnings": warning_count,
977
+ "info": info_count,
978
+ "is_valid": error_count == 0
979
+ }
980
+
981
+ def print_validation_report(self) -> None:
982
+ """Print detailed validation report."""
983
+ summary = self.get_validation_summary()
984
+
985
+ print("=" * 60)
986
+ print("CONFIGURATION VALIDATION REPORT")
987
+ print("=" * 60)
988
+ print(f"Total issues: {summary['total_issues']}")
989
+ print(f"Errors: {summary['errors']}")
990
+ print(f"Warnings: {summary['warnings']}")
991
+ print(f"Info: {summary['info']}")
992
+ print(f"Configuration is valid: {summary['is_valid']}")
993
+ print("=" * 60)
994
+
995
+ if self.validation_results:
996
+ for result in self.validation_results:
997
+ level_symbol = {
998
+ "error": "❌",
999
+ "warning": "⚠️",
1000
+ "info": "ℹ️"
1001
+ }[result.level]
1002
+
1003
+ location = f"{result.section}.{result.key}" if result.key else result.section
1004
+ print(f"{level_symbol} [{result.level.value.upper()}] {result.message}")
1005
+ if location:
1006
+ print(f" Location: {location}")
1007
+ if result.suggestion:
1008
+ print(f" Suggestion: {result.suggestion}")
1009
+ print()
1010
+ else:
1011
+ print("✅ No issues found in configuration!")
169
1012
 
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
1013
 
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
- )
1014
+ def validate_config_file(config_path: str) -> bool:
1015
+ """
1016
+ Validate a configuration file.
1017
+
1018
+ Args:
1019
+ config_path: Path to configuration file
1020
+
1021
+ Returns:
1022
+ True if configuration is valid, False otherwise
1023
+ """
1024
+ validator = ConfigValidator(config_path)
1025
+ validator.load_config()
1026
+ validator.validate_config()
1027
+
1028
+ validator.print_validation_report()
1029
+ return validator.get_validation_summary()["is_valid"]
201
1030
 
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
1031
 
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
- )
1032
+ if __name__ == "__main__":
1033
+ import sys
1034
+
1035
+ if len(sys.argv) != 2:
1036
+ print("Usage: python config_validator.py <config_file>")
1037
+ sys.exit(1)
1038
+
1039
+ config_file = sys.argv[1]
1040
+ is_valid = validate_config_file(config_file)
1041
+ sys.exit(0 if is_valid else 1)