signalwire-agents 0.1.28__py3-none-any.whl → 0.1.30__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.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/cli/__init__.py +9 -0
- signalwire_agents/cli/config.py +9 -0
- signalwire_agents/cli/core/__init__.py +9 -0
- signalwire_agents/cli/core/agent_loader.py +9 -0
- signalwire_agents/cli/core/argparse_helpers.py +9 -0
- signalwire_agents/cli/core/dynamic_config.py +9 -0
- signalwire_agents/cli/execution/__init__.py +9 -0
- signalwire_agents/cli/execution/datamap_exec.py +9 -0
- signalwire_agents/cli/execution/webhook_exec.py +9 -0
- signalwire_agents/cli/output/__init__.py +9 -0
- signalwire_agents/cli/output/output_formatter.py +9 -0
- signalwire_agents/cli/output/swml_dump.py +9 -0
- signalwire_agents/cli/simulation/__init__.py +9 -0
- signalwire_agents/cli/simulation/data_generation.py +9 -0
- signalwire_agents/cli/simulation/data_overrides.py +9 -0
- signalwire_agents/cli/simulation/mock_env.py +9 -0
- signalwire_agents/cli/test_swaig.py +9 -0
- signalwire_agents/cli/types.py +9 -0
- signalwire_agents/core/agent/deployment/__init__.py +9 -0
- signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
- signalwire_agents/core/agent/routing/__init__.py +9 -0
- signalwire_agents/core/agent/security/__init__.py +9 -0
- signalwire_agents/core/agent/swml/__init__.py +9 -0
- signalwire_agents/core/auth_handler.py +233 -0
- signalwire_agents/core/config_loader.py +259 -0
- signalwire_agents/core/contexts.py +84 -0
- signalwire_agents/core/security_config.py +333 -0
- signalwire_agents/core/swml_service.py +19 -25
- signalwire_agents/search/search_service.py +200 -11
- signalwire_agents/skills/__init__.py +9 -0
- signalwire_agents/skills/api_ninjas_trivia/__init__.py +9 -0
- signalwire_agents/skills/api_ninjas_trivia/skill.py +9 -0
- signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
- signalwire_agents/skills/datetime/__init__.py +9 -0
- signalwire_agents/skills/joke/__init__.py +9 -0
- signalwire_agents/skills/math/__init__.py +9 -0
- signalwire_agents/skills/mcp_gateway/__init__.py +9 -0
- signalwire_agents/skills/native_vector_search/__init__.py +9 -0
- signalwire_agents/skills/play_background_file/__init__.py +9 -0
- signalwire_agents/skills/play_background_file/skill.py +9 -0
- signalwire_agents/skills/spider/__init__.py +9 -0
- signalwire_agents/skills/spider/skill.py +9 -0
- signalwire_agents/skills/swml_transfer/__init__.py +9 -0
- signalwire_agents/skills/weather_api/__init__.py +9 -0
- signalwire_agents/skills/weather_api/skill.py +9 -0
- signalwire_agents/skills/web_search/__init__.py +9 -0
- signalwire_agents/skills/wikipedia_search/__init__.py +9 -0
- signalwire_agents/skills/wikipedia_search/skill.py +9 -0
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/METADATA +1 -1
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/RECORD +55 -52
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/entry_points.txt +0 -0
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.28.dist-info → signalwire_agents-0.1.30.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,259 @@
|
|
1
|
+
"""
|
2
|
+
Copyright (c) 2025 SignalWire
|
3
|
+
|
4
|
+
This file is part of the SignalWire AI Agents SDK.
|
5
|
+
|
6
|
+
Licensed under the MIT License.
|
7
|
+
See LICENSE file in the project root for full license information.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
import re
|
12
|
+
import json
|
13
|
+
from typing import Any, Dict, List, Optional, Union
|
14
|
+
from signalwire_agents.core.logging_config import get_logger
|
15
|
+
|
16
|
+
logger = get_logger("config_loader")
|
17
|
+
|
18
|
+
|
19
|
+
class ConfigLoader:
|
20
|
+
"""
|
21
|
+
Configuration loader with environment variable substitution.
|
22
|
+
|
23
|
+
Supports ${VAR|default} syntax for referencing environment variables
|
24
|
+
within JSON configuration files. This provides a clean pattern for
|
25
|
+
configuration across all SignalWire services.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, config_paths: Optional[List[str]] = None):
|
29
|
+
"""
|
30
|
+
Initialize config loader.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
config_paths: Optional list of config file paths to check.
|
34
|
+
If not provided, uses default search paths.
|
35
|
+
"""
|
36
|
+
self.config_paths = config_paths or self._get_default_paths()
|
37
|
+
self._config = None
|
38
|
+
self._config_file = None
|
39
|
+
self._load_config()
|
40
|
+
|
41
|
+
def _get_default_paths(self) -> List[str]:
|
42
|
+
"""Get default configuration file search paths."""
|
43
|
+
return [
|
44
|
+
"config.json",
|
45
|
+
"agent_config.json",
|
46
|
+
"swml_config.json",
|
47
|
+
".swml/config.json",
|
48
|
+
os.path.expanduser("~/.swml/config.json"),
|
49
|
+
"/etc/swml/config.json"
|
50
|
+
]
|
51
|
+
|
52
|
+
def _load_config(self) -> None:
|
53
|
+
"""Load configuration from the first available config file."""
|
54
|
+
for path in self.config_paths:
|
55
|
+
if os.path.exists(path):
|
56
|
+
try:
|
57
|
+
with open(path, 'r') as f:
|
58
|
+
self._config = json.load(f)
|
59
|
+
self._config_file = path
|
60
|
+
logger.info("config_loaded", path=path)
|
61
|
+
break
|
62
|
+
except Exception as e:
|
63
|
+
logger.error("config_load_error", path=path, error=str(e))
|
64
|
+
|
65
|
+
def has_config(self) -> bool:
|
66
|
+
"""Check if a configuration was loaded."""
|
67
|
+
return self._config is not None
|
68
|
+
|
69
|
+
def get_config_file(self) -> Optional[str]:
|
70
|
+
"""Get the path of the loaded config file."""
|
71
|
+
return self._config_file
|
72
|
+
|
73
|
+
def get_config(self) -> Dict[str, Any]:
|
74
|
+
"""Get the raw configuration (before substitution)."""
|
75
|
+
return self._config or {}
|
76
|
+
|
77
|
+
def substitute_vars(self, value: Any) -> Any:
|
78
|
+
"""
|
79
|
+
Recursively substitute environment variables in configuration values.
|
80
|
+
|
81
|
+
Supports ${VAR|default} syntax where:
|
82
|
+
- VAR is the environment variable name
|
83
|
+
- default is the fallback value if VAR is not set
|
84
|
+
|
85
|
+
Args:
|
86
|
+
value: The value to process (can be string, dict, list, etc.)
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
The value with all environment variables substituted
|
90
|
+
"""
|
91
|
+
if isinstance(value, str):
|
92
|
+
# Pattern to match ${VAR} or ${VAR|default}
|
93
|
+
pattern = r'\$\{([^}|]+)(?:\|([^}]*))?\}'
|
94
|
+
|
95
|
+
def replacer(match):
|
96
|
+
var_name = match.group(1)
|
97
|
+
default = match.group(2) if match.group(2) is not None else ''
|
98
|
+
return os.environ.get(var_name, default)
|
99
|
+
|
100
|
+
# Substitute all variables
|
101
|
+
result = re.sub(pattern, replacer, value)
|
102
|
+
|
103
|
+
# Try to parse as JSON to get proper types
|
104
|
+
if result.lower() in ('true', 'false'):
|
105
|
+
return result.lower() == 'true'
|
106
|
+
elif result.isdigit():
|
107
|
+
return int(result)
|
108
|
+
elif result.replace('.', '', 1).isdigit():
|
109
|
+
return float(result)
|
110
|
+
else:
|
111
|
+
return result
|
112
|
+
|
113
|
+
elif isinstance(value, dict):
|
114
|
+
# Recursively process dictionary
|
115
|
+
return {k: self.substitute_vars(v) for k, v in value.items()}
|
116
|
+
|
117
|
+
elif isinstance(value, list):
|
118
|
+
# Recursively process list
|
119
|
+
return [self.substitute_vars(item) for item in value]
|
120
|
+
|
121
|
+
else:
|
122
|
+
# Return other types as-is
|
123
|
+
return value
|
124
|
+
|
125
|
+
def get(self, key_path: str, default: Any = None) -> Any:
|
126
|
+
"""
|
127
|
+
Get a configuration value by dot-notation path.
|
128
|
+
|
129
|
+
Args:
|
130
|
+
key_path: Dot-separated path (e.g., "security.ssl_enabled")
|
131
|
+
default: Default value if path not found
|
132
|
+
|
133
|
+
Returns:
|
134
|
+
The configuration value with variables substituted
|
135
|
+
"""
|
136
|
+
if not self._config:
|
137
|
+
return default
|
138
|
+
|
139
|
+
# Navigate through the config using the dot path
|
140
|
+
keys = key_path.split('.')
|
141
|
+
value = self._config
|
142
|
+
|
143
|
+
for key in keys:
|
144
|
+
if isinstance(value, dict) and key in value:
|
145
|
+
value = value[key]
|
146
|
+
else:
|
147
|
+
return default
|
148
|
+
|
149
|
+
# Substitute variables before returning
|
150
|
+
return self.substitute_vars(value)
|
151
|
+
|
152
|
+
def get_section(self, section: str) -> Dict[str, Any]:
|
153
|
+
"""
|
154
|
+
Get an entire configuration section.
|
155
|
+
|
156
|
+
Args:
|
157
|
+
section: The section name (e.g., "security", "server")
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
The configuration section with all variables substituted
|
161
|
+
"""
|
162
|
+
if not self._config or section not in self._config:
|
163
|
+
return {}
|
164
|
+
|
165
|
+
return self.substitute_vars(self._config[section])
|
166
|
+
|
167
|
+
def merge_with_env(self, env_prefix: str = "SWML_") -> Dict[str, Any]:
|
168
|
+
"""
|
169
|
+
Merge configuration with environment variables.
|
170
|
+
|
171
|
+
Config file takes precedence over environment variables,
|
172
|
+
but config can reference env vars via substitution.
|
173
|
+
|
174
|
+
Args:
|
175
|
+
env_prefix: Prefix for environment variables to consider
|
176
|
+
|
177
|
+
Returns:
|
178
|
+
Merged configuration dictionary
|
179
|
+
"""
|
180
|
+
# Start with substituted config
|
181
|
+
result = self.substitute_vars(self._config) if self._config else {}
|
182
|
+
|
183
|
+
# Only add env vars that aren't already in config
|
184
|
+
# This preserves config file precedence
|
185
|
+
for key, value in os.environ.items():
|
186
|
+
if key.startswith(env_prefix):
|
187
|
+
# Convert SWML_SSL_ENABLED to ssl_enabled
|
188
|
+
config_key = key[len(env_prefix):].lower()
|
189
|
+
|
190
|
+
# Only set if not already in config
|
191
|
+
if not self._has_nested_key(result, config_key):
|
192
|
+
self._set_nested_key(result, config_key, value)
|
193
|
+
|
194
|
+
return result
|
195
|
+
|
196
|
+
def _has_nested_key(self, data: Dict, key_path: str) -> bool:
|
197
|
+
"""Check if a nested key exists in dictionary."""
|
198
|
+
keys = key_path.split('_')
|
199
|
+
current = data
|
200
|
+
|
201
|
+
for key in keys:
|
202
|
+
if isinstance(current, dict) and key in current:
|
203
|
+
current = current[key]
|
204
|
+
else:
|
205
|
+
return False
|
206
|
+
return True
|
207
|
+
|
208
|
+
def _set_nested_key(self, data: Dict, key_path: str, value: Any) -> None:
|
209
|
+
"""Set a value in dictionary using underscore-separated path."""
|
210
|
+
keys = key_path.split('_')
|
211
|
+
current = data
|
212
|
+
|
213
|
+
for key in keys[:-1]:
|
214
|
+
if key not in current:
|
215
|
+
current[key] = {}
|
216
|
+
current = current[key]
|
217
|
+
|
218
|
+
current[keys[-1]] = value
|
219
|
+
|
220
|
+
@staticmethod
|
221
|
+
def find_config_file(service_name: Optional[str] = None,
|
222
|
+
additional_paths: Optional[List[str]] = None) -> Optional[str]:
|
223
|
+
"""
|
224
|
+
Static method to find a config file for a service.
|
225
|
+
|
226
|
+
Args:
|
227
|
+
service_name: Optional service name for service-specific config
|
228
|
+
additional_paths: Additional paths to check
|
229
|
+
|
230
|
+
Returns:
|
231
|
+
Path to the first config file found, or None
|
232
|
+
"""
|
233
|
+
paths = []
|
234
|
+
|
235
|
+
# Service-specific config
|
236
|
+
if service_name:
|
237
|
+
paths.extend([
|
238
|
+
f"{service_name}_config.json",
|
239
|
+
f".swml/{service_name}_config.json"
|
240
|
+
])
|
241
|
+
|
242
|
+
# Additional paths
|
243
|
+
if additional_paths:
|
244
|
+
paths.extend(additional_paths)
|
245
|
+
|
246
|
+
# Default paths
|
247
|
+
paths.extend([
|
248
|
+
"config.json",
|
249
|
+
"agent_config.json",
|
250
|
+
".swml/config.json",
|
251
|
+
os.path.expanduser("~/.swml/config.json"),
|
252
|
+
"/etc/swml/config.json"
|
253
|
+
])
|
254
|
+
|
255
|
+
for path in paths:
|
256
|
+
if os.path.exists(path):
|
257
|
+
return path
|
258
|
+
|
259
|
+
return None
|
@@ -1,3 +1,12 @@
|
|
1
|
+
"""
|
2
|
+
Copyright (c) 2025 SignalWire
|
3
|
+
|
4
|
+
This file is part of the SignalWire AI Agents SDK.
|
5
|
+
|
6
|
+
Licensed under the MIT License.
|
7
|
+
See LICENSE file in the project root for full license information.
|
8
|
+
"""
|
9
|
+
|
1
10
|
"""
|
2
11
|
Contexts and Steps System for SignalWire Agents
|
3
12
|
|
@@ -259,6 +268,10 @@ class Context:
|
|
259
268
|
# Context prompt (separate from system_prompt)
|
260
269
|
self._prompt_text: Optional[str] = None
|
261
270
|
self._prompt_sections: List[Dict[str, Any]] = []
|
271
|
+
|
272
|
+
# Context fillers
|
273
|
+
self._enter_fillers: Optional[Dict[str, List[str]]] = None
|
274
|
+
self._exit_fillers: Optional[Dict[str, List[str]]] = None
|
262
275
|
|
263
276
|
def add_step(self, name: str) -> Step:
|
264
277
|
"""
|
@@ -450,6 +463,70 @@ class Context:
|
|
450
463
|
self._prompt_sections.append({"title": title, "bullets": bullets})
|
451
464
|
return self
|
452
465
|
|
466
|
+
def set_enter_fillers(self, enter_fillers: Dict[str, List[str]]) -> 'Context':
|
467
|
+
"""
|
468
|
+
Set fillers that the AI says when entering this context
|
469
|
+
|
470
|
+
Args:
|
471
|
+
enter_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
|
472
|
+
Example: {"en-US": ["Welcome...", "Hello..."], "default": ["Entering..."]}
|
473
|
+
|
474
|
+
Returns:
|
475
|
+
Self for method chaining
|
476
|
+
"""
|
477
|
+
if enter_fillers and isinstance(enter_fillers, dict):
|
478
|
+
self._enter_fillers = enter_fillers
|
479
|
+
return self
|
480
|
+
|
481
|
+
def set_exit_fillers(self, exit_fillers: Dict[str, List[str]]) -> 'Context':
|
482
|
+
"""
|
483
|
+
Set fillers that the AI says when exiting this context
|
484
|
+
|
485
|
+
Args:
|
486
|
+
exit_fillers: Dictionary mapping language codes (or "default") to lists of filler phrases
|
487
|
+
Example: {"en-US": ["Goodbye...", "Thank you..."], "default": ["Exiting..."]}
|
488
|
+
|
489
|
+
Returns:
|
490
|
+
Self for method chaining
|
491
|
+
"""
|
492
|
+
if exit_fillers and isinstance(exit_fillers, dict):
|
493
|
+
self._exit_fillers = exit_fillers
|
494
|
+
return self
|
495
|
+
|
496
|
+
def add_enter_filler(self, language_code: str, fillers: List[str]) -> 'Context':
|
497
|
+
"""
|
498
|
+
Add enter fillers for a specific language
|
499
|
+
|
500
|
+
Args:
|
501
|
+
language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
|
502
|
+
fillers: List of filler phrases for entering this context
|
503
|
+
|
504
|
+
Returns:
|
505
|
+
Self for method chaining
|
506
|
+
"""
|
507
|
+
if language_code and fillers and isinstance(fillers, list):
|
508
|
+
if self._enter_fillers is None:
|
509
|
+
self._enter_fillers = {}
|
510
|
+
self._enter_fillers[language_code] = fillers
|
511
|
+
return self
|
512
|
+
|
513
|
+
def add_exit_filler(self, language_code: str, fillers: List[str]) -> 'Context':
|
514
|
+
"""
|
515
|
+
Add exit fillers for a specific language
|
516
|
+
|
517
|
+
Args:
|
518
|
+
language_code: Language code (e.g., "en-US", "es") or "default" for catch-all
|
519
|
+
fillers: List of filler phrases for exiting this context
|
520
|
+
|
521
|
+
Returns:
|
522
|
+
Self for method chaining
|
523
|
+
"""
|
524
|
+
if language_code and fillers and isinstance(fillers, list):
|
525
|
+
if self._exit_fillers is None:
|
526
|
+
self._exit_fillers = {}
|
527
|
+
self._exit_fillers[language_code] = fillers
|
528
|
+
return self
|
529
|
+
|
453
530
|
def _render_prompt(self) -> Optional[str]:
|
454
531
|
"""Render the context's prompt text"""
|
455
532
|
if self._prompt_text is not None:
|
@@ -533,6 +610,13 @@ class Context:
|
|
533
610
|
elif self._prompt_text is not None:
|
534
611
|
# Use string format
|
535
612
|
context_dict["prompt"] = self._prompt_text
|
613
|
+
|
614
|
+
# Add enter and exit fillers if defined
|
615
|
+
if self._enter_fillers is not None:
|
616
|
+
context_dict["enter_fillers"] = self._enter_fillers
|
617
|
+
|
618
|
+
if self._exit_fillers is not None:
|
619
|
+
context_dict["exit_fillers"] = self._exit_fillers
|
536
620
|
|
537
621
|
return context_dict
|
538
622
|
|
@@ -0,0 +1,333 @@
|
|
1
|
+
"""
|
2
|
+
Copyright (c) 2025 SignalWire
|
3
|
+
|
4
|
+
This file is part of the SignalWire AI Agents SDK.
|
5
|
+
|
6
|
+
Licensed under the MIT License.
|
7
|
+
See LICENSE file in the project root for full license information.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
import secrets
|
12
|
+
from typing import Dict, Any, Optional, Tuple, List, Union
|
13
|
+
from signalwire_agents.core.logging_config import get_logger
|
14
|
+
from signalwire_agents.core.config_loader import ConfigLoader
|
15
|
+
|
16
|
+
logger = get_logger("security_config")
|
17
|
+
|
18
|
+
|
19
|
+
class SecurityConfig:
|
20
|
+
"""
|
21
|
+
Unified security configuration for SignalWire services.
|
22
|
+
|
23
|
+
This class provides centralized security settings that can be used by
|
24
|
+
both SWML and Search services, ensuring consistent security behavior.
|
25
|
+
"""
|
26
|
+
|
27
|
+
# Security environment variable names
|
28
|
+
SSL_ENABLED = 'SWML_SSL_ENABLED'
|
29
|
+
SSL_CERT_PATH = 'SWML_SSL_CERT_PATH'
|
30
|
+
SSL_KEY_PATH = 'SWML_SSL_KEY_PATH'
|
31
|
+
SSL_DOMAIN = 'SWML_DOMAIN'
|
32
|
+
SSL_VERIFY_MODE = 'SWML_SSL_VERIFY_MODE'
|
33
|
+
|
34
|
+
# Additional security settings
|
35
|
+
ALLOWED_HOSTS = 'SWML_ALLOWED_HOSTS'
|
36
|
+
CORS_ORIGINS = 'SWML_CORS_ORIGINS'
|
37
|
+
MAX_REQUEST_SIZE = 'SWML_MAX_REQUEST_SIZE'
|
38
|
+
RATE_LIMIT = 'SWML_RATE_LIMIT'
|
39
|
+
REQUEST_TIMEOUT = 'SWML_REQUEST_TIMEOUT'
|
40
|
+
USE_HSTS = 'SWML_USE_HSTS'
|
41
|
+
HSTS_MAX_AGE = 'SWML_HSTS_MAX_AGE'
|
42
|
+
|
43
|
+
# Authentication
|
44
|
+
BASIC_AUTH_USER = 'SWML_BASIC_AUTH_USER'
|
45
|
+
BASIC_AUTH_PASSWORD = 'SWML_BASIC_AUTH_PASSWORD'
|
46
|
+
|
47
|
+
# Defaults (secure by default)
|
48
|
+
DEFAULTS = {
|
49
|
+
SSL_ENABLED: False, # Off by default, but secure when enabled
|
50
|
+
SSL_VERIFY_MODE: 'CERT_REQUIRED',
|
51
|
+
ALLOWED_HOSTS: '*', # Accept all hosts by default for backward compatibility
|
52
|
+
CORS_ORIGINS: '*', # Accept all origins by default for backward compatibility
|
53
|
+
MAX_REQUEST_SIZE: 10 * 1024 * 1024, # 10MB
|
54
|
+
RATE_LIMIT: 60, # Requests per minute
|
55
|
+
REQUEST_TIMEOUT: 30, # Seconds
|
56
|
+
USE_HSTS: True, # Enable HSTS when HTTPS is on
|
57
|
+
HSTS_MAX_AGE: 31536000, # 1 year
|
58
|
+
}
|
59
|
+
|
60
|
+
def __init__(self, config_file: Optional[str] = None, service_name: Optional[str] = None):
|
61
|
+
"""
|
62
|
+
Initialize security configuration.
|
63
|
+
|
64
|
+
Args:
|
65
|
+
config_file: Optional path to config file
|
66
|
+
service_name: Optional service name for finding service-specific config
|
67
|
+
"""
|
68
|
+
# First, set defaults
|
69
|
+
self._set_defaults()
|
70
|
+
|
71
|
+
# Then load from environment variables (backward compatibility)
|
72
|
+
self.load_from_env()
|
73
|
+
|
74
|
+
# Finally, apply config file if available (highest priority)
|
75
|
+
self._load_config_file(config_file, service_name)
|
76
|
+
|
77
|
+
def _set_defaults(self):
|
78
|
+
"""Set default values for all configuration"""
|
79
|
+
# SSL configuration
|
80
|
+
self.ssl_enabled = self.DEFAULTS[self.SSL_ENABLED]
|
81
|
+
self.ssl_cert_path = None
|
82
|
+
self.ssl_key_path = None
|
83
|
+
self.domain = None
|
84
|
+
self.ssl_verify_mode = self.DEFAULTS[self.SSL_VERIFY_MODE]
|
85
|
+
|
86
|
+
# Additional settings
|
87
|
+
self.allowed_hosts = self._parse_list(self.DEFAULTS[self.ALLOWED_HOSTS])
|
88
|
+
self.cors_origins = self._parse_list(self.DEFAULTS[self.CORS_ORIGINS])
|
89
|
+
self.max_request_size = self.DEFAULTS[self.MAX_REQUEST_SIZE]
|
90
|
+
self.rate_limit = self.DEFAULTS[self.RATE_LIMIT]
|
91
|
+
self.request_timeout = self.DEFAULTS[self.REQUEST_TIMEOUT]
|
92
|
+
self.use_hsts = self.DEFAULTS[self.USE_HSTS]
|
93
|
+
self.hsts_max_age = self.DEFAULTS[self.HSTS_MAX_AGE]
|
94
|
+
|
95
|
+
# Authentication
|
96
|
+
self.basic_auth_user = None
|
97
|
+
self.basic_auth_password = None
|
98
|
+
|
99
|
+
def _load_config_file(self, config_file: Optional[str], service_name: Optional[str]):
|
100
|
+
"""Load configuration from config file if available"""
|
101
|
+
# Find config file
|
102
|
+
if not config_file:
|
103
|
+
config_file = ConfigLoader.find_config_file(service_name)
|
104
|
+
|
105
|
+
if not config_file:
|
106
|
+
return
|
107
|
+
|
108
|
+
# Load config
|
109
|
+
config_loader = ConfigLoader([config_file])
|
110
|
+
if not config_loader.has_config():
|
111
|
+
return
|
112
|
+
|
113
|
+
logger.info("loading_config_from_file", file=config_file)
|
114
|
+
|
115
|
+
# Get security section
|
116
|
+
security_config = config_loader.get_section('security')
|
117
|
+
if not security_config:
|
118
|
+
return
|
119
|
+
|
120
|
+
# Apply security settings (config file takes precedence)
|
121
|
+
if 'ssl_enabled' in security_config:
|
122
|
+
self.ssl_enabled = security_config['ssl_enabled']
|
123
|
+
|
124
|
+
if 'ssl_cert_path' in security_config:
|
125
|
+
self.ssl_cert_path = security_config['ssl_cert_path']
|
126
|
+
|
127
|
+
if 'ssl_key_path' in security_config:
|
128
|
+
self.ssl_key_path = security_config['ssl_key_path']
|
129
|
+
|
130
|
+
if 'domain' in security_config:
|
131
|
+
self.domain = security_config['domain']
|
132
|
+
|
133
|
+
if 'ssl_verify_mode' in security_config:
|
134
|
+
self.ssl_verify_mode = security_config['ssl_verify_mode']
|
135
|
+
|
136
|
+
# Additional settings
|
137
|
+
if 'allowed_hosts' in security_config:
|
138
|
+
self.allowed_hosts = self._parse_list(security_config['allowed_hosts'])
|
139
|
+
|
140
|
+
if 'cors_origins' in security_config:
|
141
|
+
self.cors_origins = self._parse_list(security_config['cors_origins'])
|
142
|
+
|
143
|
+
if 'max_request_size' in security_config:
|
144
|
+
self.max_request_size = int(security_config['max_request_size'])
|
145
|
+
|
146
|
+
if 'rate_limit' in security_config:
|
147
|
+
self.rate_limit = int(security_config['rate_limit'])
|
148
|
+
|
149
|
+
if 'request_timeout' in security_config:
|
150
|
+
self.request_timeout = int(security_config['request_timeout'])
|
151
|
+
|
152
|
+
if 'use_hsts' in security_config:
|
153
|
+
self.use_hsts = security_config['use_hsts']
|
154
|
+
|
155
|
+
if 'hsts_max_age' in security_config:
|
156
|
+
self.hsts_max_age = int(security_config['hsts_max_age'])
|
157
|
+
|
158
|
+
# Authentication from config
|
159
|
+
auth_config = security_config.get('auth', {})
|
160
|
+
if isinstance(auth_config, dict):
|
161
|
+
basic_auth = auth_config.get('basic', {})
|
162
|
+
if isinstance(basic_auth, dict):
|
163
|
+
if 'user' in basic_auth:
|
164
|
+
self.basic_auth_user = basic_auth['user']
|
165
|
+
if 'password' in basic_auth:
|
166
|
+
self.basic_auth_password = basic_auth['password']
|
167
|
+
|
168
|
+
def load_from_env(self):
|
169
|
+
"""Load configuration from environment variables"""
|
170
|
+
# SSL configuration
|
171
|
+
ssl_enabled_env = os.environ.get(self.SSL_ENABLED, '').lower()
|
172
|
+
self.ssl_enabled = ssl_enabled_env in ('true', '1', 'yes')
|
173
|
+
self.ssl_cert_path = os.environ.get(self.SSL_CERT_PATH)
|
174
|
+
self.ssl_key_path = os.environ.get(self.SSL_KEY_PATH)
|
175
|
+
self.domain = os.environ.get(self.SSL_DOMAIN)
|
176
|
+
self.ssl_verify_mode = os.environ.get(self.SSL_VERIFY_MODE, self.DEFAULTS[self.SSL_VERIFY_MODE])
|
177
|
+
|
178
|
+
# Additional security settings
|
179
|
+
self.allowed_hosts = self._parse_list(os.environ.get(self.ALLOWED_HOSTS, self.DEFAULTS[self.ALLOWED_HOSTS]))
|
180
|
+
self.cors_origins = self._parse_list(os.environ.get(self.CORS_ORIGINS, self.DEFAULTS[self.CORS_ORIGINS]))
|
181
|
+
self.max_request_size = int(os.environ.get(self.MAX_REQUEST_SIZE, self.DEFAULTS[self.MAX_REQUEST_SIZE]))
|
182
|
+
self.rate_limit = int(os.environ.get(self.RATE_LIMIT, self.DEFAULTS[self.RATE_LIMIT]))
|
183
|
+
self.request_timeout = int(os.environ.get(self.REQUEST_TIMEOUT, self.DEFAULTS[self.REQUEST_TIMEOUT]))
|
184
|
+
|
185
|
+
# HSTS settings
|
186
|
+
use_hsts_env = os.environ.get(self.USE_HSTS, '').lower()
|
187
|
+
self.use_hsts = use_hsts_env != 'false' if use_hsts_env else self.DEFAULTS[self.USE_HSTS]
|
188
|
+
self.hsts_max_age = int(os.environ.get(self.HSTS_MAX_AGE, self.DEFAULTS[self.HSTS_MAX_AGE]))
|
189
|
+
|
190
|
+
# Authentication
|
191
|
+
self.basic_auth_user = os.environ.get(self.BASIC_AUTH_USER)
|
192
|
+
self.basic_auth_password = os.environ.get(self.BASIC_AUTH_PASSWORD)
|
193
|
+
|
194
|
+
def _parse_list(self, value: Union[str, list]) -> list:
|
195
|
+
"""Parse comma-separated list from environment variable or list from config"""
|
196
|
+
if isinstance(value, list):
|
197
|
+
return value
|
198
|
+
if value == '*':
|
199
|
+
return ['*']
|
200
|
+
return [item.strip() for item in value.split(',') if item.strip()]
|
201
|
+
|
202
|
+
def validate_ssl_config(self) -> Tuple[bool, Optional[str]]:
|
203
|
+
"""
|
204
|
+
Validate SSL configuration.
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
Tuple of (is_valid, error_message)
|
208
|
+
"""
|
209
|
+
if not self.ssl_enabled:
|
210
|
+
return True, None
|
211
|
+
|
212
|
+
if not self.ssl_cert_path:
|
213
|
+
return False, "SSL enabled but SWML_SSL_CERT_PATH not set"
|
214
|
+
|
215
|
+
if not self.ssl_key_path:
|
216
|
+
return False, "SSL enabled but SWML_SSL_KEY_PATH not set"
|
217
|
+
|
218
|
+
if not os.path.exists(self.ssl_cert_path):
|
219
|
+
return False, f"SSL certificate file not found: {self.ssl_cert_path}"
|
220
|
+
|
221
|
+
if not os.path.exists(self.ssl_key_path):
|
222
|
+
return False, f"SSL key file not found: {self.ssl_key_path}"
|
223
|
+
|
224
|
+
return True, None
|
225
|
+
|
226
|
+
def get_ssl_context_kwargs(self) -> Dict[str, Any]:
|
227
|
+
"""
|
228
|
+
Get SSL context kwargs for uvicorn.
|
229
|
+
|
230
|
+
Returns:
|
231
|
+
Dictionary of SSL parameters for uvicorn
|
232
|
+
"""
|
233
|
+
if not self.ssl_enabled:
|
234
|
+
return {}
|
235
|
+
|
236
|
+
is_valid, error = self.validate_ssl_config()
|
237
|
+
if not is_valid:
|
238
|
+
logger.error("ssl_validation_failed", error=error)
|
239
|
+
return {}
|
240
|
+
|
241
|
+
return {
|
242
|
+
'ssl_certfile': self.ssl_cert_path,
|
243
|
+
'ssl_keyfile': self.ssl_key_path,
|
244
|
+
# Additional SSL options can be added here
|
245
|
+
}
|
246
|
+
|
247
|
+
def get_basic_auth(self) -> Tuple[str, str]:
|
248
|
+
"""
|
249
|
+
Get basic auth credentials, generating if not set.
|
250
|
+
|
251
|
+
Returns:
|
252
|
+
Tuple of (username, password)
|
253
|
+
"""
|
254
|
+
username = self.basic_auth_user or "signalwire"
|
255
|
+
password = self.basic_auth_password or secrets.token_urlsafe(32)
|
256
|
+
|
257
|
+
return username, password
|
258
|
+
|
259
|
+
def get_security_headers(self, is_https: bool = False) -> Dict[str, str]:
|
260
|
+
"""
|
261
|
+
Get security headers to add to responses.
|
262
|
+
|
263
|
+
Args:
|
264
|
+
is_https: Whether the connection is over HTTPS
|
265
|
+
|
266
|
+
Returns:
|
267
|
+
Dictionary of security headers
|
268
|
+
"""
|
269
|
+
headers = {
|
270
|
+
'X-Content-Type-Options': 'nosniff',
|
271
|
+
'X-Frame-Options': 'DENY',
|
272
|
+
'X-XSS-Protection': '1; mode=block',
|
273
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
274
|
+
}
|
275
|
+
|
276
|
+
# Add HSTS header if HTTPS and enabled
|
277
|
+
if is_https and self.use_hsts:
|
278
|
+
headers['Strict-Transport-Security'] = f'max-age={self.hsts_max_age}; includeSubDomains'
|
279
|
+
|
280
|
+
return headers
|
281
|
+
|
282
|
+
def should_allow_host(self, host: str) -> bool:
|
283
|
+
"""
|
284
|
+
Check if a host is allowed.
|
285
|
+
|
286
|
+
Args:
|
287
|
+
host: The host to check
|
288
|
+
|
289
|
+
Returns:
|
290
|
+
True if the host is allowed
|
291
|
+
"""
|
292
|
+
if '*' in self.allowed_hosts:
|
293
|
+
return True
|
294
|
+
|
295
|
+
return host in self.allowed_hosts
|
296
|
+
|
297
|
+
def get_cors_config(self) -> Dict[str, Any]:
|
298
|
+
"""
|
299
|
+
Get CORS configuration for FastAPI.
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
Dictionary of CORS settings
|
303
|
+
"""
|
304
|
+
return {
|
305
|
+
'allow_origins': self.cors_origins,
|
306
|
+
'allow_credentials': True,
|
307
|
+
'allow_methods': ['*'],
|
308
|
+
'allow_headers': ['*'],
|
309
|
+
}
|
310
|
+
|
311
|
+
def get_url_scheme(self) -> str:
|
312
|
+
"""Get the URL scheme based on SSL configuration"""
|
313
|
+
return 'https' if self.ssl_enabled else 'http'
|
314
|
+
|
315
|
+
def log_config(self, service_name: str):
|
316
|
+
"""Log the current security configuration"""
|
317
|
+
logger.info(
|
318
|
+
"security_config_loaded",
|
319
|
+
service=service_name,
|
320
|
+
ssl_enabled=self.ssl_enabled,
|
321
|
+
domain=self.domain,
|
322
|
+
allowed_hosts=self.allowed_hosts,
|
323
|
+
cors_origins=self.cors_origins,
|
324
|
+
max_request_size=self.max_request_size,
|
325
|
+
rate_limit=self.rate_limit,
|
326
|
+
use_hsts=self.use_hsts,
|
327
|
+
has_basic_auth=bool(self.basic_auth_user and self.basic_auth_password)
|
328
|
+
)
|
329
|
+
|
330
|
+
|
331
|
+
# Global instance for easy access (backward compatibility)
|
332
|
+
# Services can create their own instances with specific config files
|
333
|
+
security_config = SecurityConfig()
|