signalwire-agents 0.1.6__py3-none-any.whl → 1.0.7__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.
Files changed (140) hide show
  1. signalwire_agents/__init__.py +130 -4
  2. signalwire_agents/agent_server.py +438 -32
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +18 -0
  5. signalwire_agents/cli/build_search.py +1367 -0
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/execution/__init__.py +10 -0
  13. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  14. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  15. signalwire_agents/cli/init_project.py +1225 -0
  16. signalwire_agents/cli/output/__init__.py +10 -0
  17. signalwire_agents/cli/output/output_formatter.py +255 -0
  18. signalwire_agents/cli/output/swml_dump.py +186 -0
  19. signalwire_agents/cli/simulation/__init__.py +10 -0
  20. signalwire_agents/cli/simulation/data_generation.py +374 -0
  21. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  22. signalwire_agents/cli/simulation/mock_env.py +282 -0
  23. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  24. signalwire_agents/cli/test_swaig.py +809 -0
  25. signalwire_agents/cli/types.py +81 -0
  26. signalwire_agents/core/__init__.py +2 -2
  27. signalwire_agents/core/agent/__init__.py +12 -0
  28. signalwire_agents/core/agent/config/__init__.py +12 -0
  29. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  30. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  31. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  32. signalwire_agents/core/agent/prompt/manager.py +306 -0
  33. signalwire_agents/core/agent/routing/__init__.py +9 -0
  34. signalwire_agents/core/agent/security/__init__.py +9 -0
  35. signalwire_agents/core/agent/swml/__init__.py +9 -0
  36. signalwire_agents/core/agent/tools/__init__.py +15 -0
  37. signalwire_agents/core/agent/tools/decorator.py +97 -0
  38. signalwire_agents/core/agent/tools/registry.py +210 -0
  39. signalwire_agents/core/agent_base.py +959 -2166
  40. signalwire_agents/core/auth_handler.py +233 -0
  41. signalwire_agents/core/config_loader.py +259 -0
  42. signalwire_agents/core/contexts.py +707 -0
  43. signalwire_agents/core/data_map.py +487 -0
  44. signalwire_agents/core/function_result.py +1150 -1
  45. signalwire_agents/core/logging_config.py +376 -0
  46. signalwire_agents/core/mixins/__init__.py +28 -0
  47. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  48. signalwire_agents/core/mixins/auth_mixin.py +287 -0
  49. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  50. signalwire_agents/core/mixins/serverless_mixin.py +368 -0
  51. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  52. signalwire_agents/core/mixins/state_mixin.py +153 -0
  53. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  54. signalwire_agents/core/mixins/web_mixin.py +1134 -0
  55. signalwire_agents/core/security/session_manager.py +174 -86
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +200 -0
  58. signalwire_agents/core/skill_manager.py +244 -0
  59. signalwire_agents/core/swaig_function.py +33 -9
  60. signalwire_agents/core/swml_builder.py +212 -12
  61. signalwire_agents/core/swml_handler.py +43 -13
  62. signalwire_agents/core/swml_renderer.py +123 -297
  63. signalwire_agents/core/swml_service.py +277 -260
  64. signalwire_agents/prefabs/concierge.py +6 -2
  65. signalwire_agents/prefabs/info_gatherer.py +149 -33
  66. signalwire_agents/prefabs/receptionist.py +14 -22
  67. signalwire_agents/prefabs/survey.py +6 -2
  68. signalwire_agents/schema.json +9218 -5489
  69. signalwire_agents/search/__init__.py +137 -0
  70. signalwire_agents/search/document_processor.py +1223 -0
  71. signalwire_agents/search/index_builder.py +804 -0
  72. signalwire_agents/search/migration.py +418 -0
  73. signalwire_agents/search/models.py +30 -0
  74. signalwire_agents/search/pgvector_backend.py +752 -0
  75. signalwire_agents/search/query_processor.py +502 -0
  76. signalwire_agents/search/search_engine.py +1264 -0
  77. signalwire_agents/search/search_service.py +574 -0
  78. signalwire_agents/skills/README.md +452 -0
  79. signalwire_agents/skills/__init__.py +23 -0
  80. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  81. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  82. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  83. signalwire_agents/skills/datasphere/README.md +210 -0
  84. signalwire_agents/skills/datasphere/__init__.py +12 -0
  85. signalwire_agents/skills/datasphere/skill.py +310 -0
  86. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  87. signalwire_agents/skills/datasphere_serverless/__init__.py +10 -0
  88. signalwire_agents/skills/datasphere_serverless/skill.py +237 -0
  89. signalwire_agents/skills/datetime/README.md +132 -0
  90. signalwire_agents/skills/datetime/__init__.py +10 -0
  91. signalwire_agents/skills/datetime/skill.py +126 -0
  92. signalwire_agents/skills/joke/README.md +149 -0
  93. signalwire_agents/skills/joke/__init__.py +10 -0
  94. signalwire_agents/skills/joke/skill.py +109 -0
  95. signalwire_agents/skills/math/README.md +161 -0
  96. signalwire_agents/skills/math/__init__.py +10 -0
  97. signalwire_agents/skills/math/skill.py +105 -0
  98. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  99. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  100. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  101. signalwire_agents/skills/native_vector_search/README.md +210 -0
  102. signalwire_agents/skills/native_vector_search/__init__.py +10 -0
  103. signalwire_agents/skills/native_vector_search/skill.py +820 -0
  104. signalwire_agents/skills/play_background_file/README.md +218 -0
  105. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  106. signalwire_agents/skills/play_background_file/skill.py +242 -0
  107. signalwire_agents/skills/registry.py +459 -0
  108. signalwire_agents/skills/spider/README.md +236 -0
  109. signalwire_agents/skills/spider/__init__.py +13 -0
  110. signalwire_agents/skills/spider/skill.py +598 -0
  111. signalwire_agents/skills/swml_transfer/README.md +395 -0
  112. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  113. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  114. signalwire_agents/skills/weather_api/README.md +178 -0
  115. signalwire_agents/skills/weather_api/__init__.py +12 -0
  116. signalwire_agents/skills/weather_api/skill.py +191 -0
  117. signalwire_agents/skills/web_search/README.md +163 -0
  118. signalwire_agents/skills/web_search/__init__.py +10 -0
  119. signalwire_agents/skills/web_search/skill.py +739 -0
  120. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  121. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  122. signalwire_agents/skills/wikipedia_search/skill.py +210 -0
  123. signalwire_agents/utils/__init__.py +14 -0
  124. signalwire_agents/utils/schema_utils.py +111 -44
  125. signalwire_agents/web/__init__.py +17 -0
  126. signalwire_agents/web/web_service.py +559 -0
  127. signalwire_agents-1.0.7.data/data/share/man/man1/sw-agent-init.1 +307 -0
  128. signalwire_agents-1.0.7.data/data/share/man/man1/sw-search.1 +483 -0
  129. signalwire_agents-1.0.7.data/data/share/man/man1/swaig-test.1 +308 -0
  130. signalwire_agents-1.0.7.dist-info/METADATA +992 -0
  131. signalwire_agents-1.0.7.dist-info/RECORD +142 -0
  132. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/WHEEL +1 -1
  133. signalwire_agents-1.0.7.dist-info/entry_points.txt +4 -0
  134. signalwire_agents/core/state/file_state_manager.py +0 -219
  135. signalwire_agents/core/state/state_manager.py +0 -101
  136. signalwire_agents-0.1.6.data/data/schema.json +0 -5611
  137. signalwire_agents-0.1.6.dist-info/METADATA +0 -199
  138. signalwire_agents-0.1.6.dist-info/RECORD +0 -34
  139. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/licenses/LICENSE +0 -0
  140. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/top_level.txt +0 -0
@@ -14,73 +14,92 @@ Session manager for handling call sessions and security tokens
14
14
  from typing import Dict, Any, Optional, Tuple
15
15
  import secrets
16
16
  import time
17
- from datetime import datetime
18
-
19
-
20
- class CallSession:
21
- """
22
- Represents a single call session with associated tokens and state
23
- """
24
- def __init__(self, call_id: str):
25
- self.call_id = call_id
26
- self.tokens: Dict[str, str] = {} # function_name -> token
27
- self.state = "pending" # pending, active, expired
28
- self.started_at = datetime.now()
29
- self.metadata: Dict[str, Any] = {} # Custom state for the call
17
+ import hmac
18
+ import hashlib
19
+ import base64
20
+ from datetime import datetime, timedelta
30
21
 
31
22
 
32
23
  class SessionManager:
33
24
  """
34
- Manages call sessions and their associated security tokens
25
+ Manages security tokens for function calls
26
+
27
+ This implementation is completely stateless - it does not track call sessions
28
+ or store any information in memory. All validation is done using cryptographic
29
+ signatures with the tokens containing all necessary information.
35
30
  """
36
- def __init__(self, token_expiry_secs: int = 600):
31
+ def __init__(self, token_expiry_secs: int = 3600, secret_key: Optional[str] = None):
37
32
  """
38
33
  Initialize the session manager
39
34
 
40
35
  Args:
41
- token_expiry_secs: Seconds until tokens expire (default: 10 minutes)
36
+ token_expiry_secs: Seconds until tokens expire (default: 60 minutes)
37
+ secret_key: Secret key for signing tokens (generated if not provided)
42
38
  """
43
- self._active_calls: Dict[str, CallSession] = {}
44
39
  self.token_expiry_secs = token_expiry_secs
40
+ # Use provided secret key or generate a secure one
41
+ self.secret_key = secret_key or secrets.token_hex(32)
45
42
 
46
43
  def create_session(self, call_id: Optional[str] = None) -> str:
47
44
  """
48
- Create a new call session
45
+ Create a new session ID if one isn't provided
49
46
 
50
47
  Args:
51
48
  call_id: Optional call ID, generated if not provided
52
49
 
53
50
  Returns:
54
- The call_id for the new session
51
+ The call_id for the session
55
52
  """
56
53
  # Generate call_id if not provided
57
54
  if not call_id:
58
55
  call_id = secrets.token_urlsafe(16)
59
56
 
60
- # Create new session
61
- self._active_calls[call_id] = CallSession(call_id)
62
57
  return call_id
63
58
 
64
59
  def generate_token(self, function_name: str, call_id: str) -> str:
65
60
  """
66
- Generate a secure token for a function call
61
+ Generate a secure self-contained token for a function call
67
62
 
68
63
  Args:
69
64
  function_name: Name of the function to generate a token for
70
65
  call_id: Call session ID
71
66
 
72
67
  Returns:
73
- A secure random token
74
-
75
- Raises:
76
- ValueError: If the call session does not exist
68
+ A secure token
77
69
  """
78
- if call_id not in self._active_calls:
79
- raise ValueError(f"No active session for call_id: {call_id}")
70
+ # Create token parts
71
+ expiry = int(time.time()) + self.token_expiry_secs
72
+ nonce = secrets.token_hex(4)
73
+
74
+ # Create the message to sign
75
+ message = f"{call_id}:{function_name}:{expiry}:{nonce}"
76
+
77
+ # Sign the message
78
+ signature = hmac.new(
79
+ self.secret_key.encode(),
80
+ message.encode(),
81
+ hashlib.sha256
82
+ ).hexdigest()[:16] # Use first 16 chars of signature for shorter tokens
80
83
 
81
- token = secrets.token_urlsafe(24)
82
- self._active_calls[call_id].tokens[function_name] = token
83
- return token
84
+ # Combine all parts into the token
85
+ token = f"{call_id}.{function_name}.{expiry}.{nonce}.{signature}"
86
+
87
+ # Base64 encode for URL safety
88
+ return base64.urlsafe_b64encode(token.encode()).decode()
89
+
90
+ # Alias for generate_token to maintain backward compatibility
91
+ def create_tool_token(self, function_name: str, call_id: str) -> str:
92
+ """
93
+ Alias for generate_token to maintain backward compatibility
94
+
95
+ Args:
96
+ function_name: Name of the function to generate a token for
97
+ call_id: Call session ID
98
+
99
+ Returns:
100
+ A secure token
101
+ """
102
+ return self.generate_token(function_name, call_id)
84
103
 
85
104
  def validate_token(self, call_id: str, function_name: str, token: str) -> bool:
86
105
  """
@@ -94,86 +113,155 @@ class SessionManager:
94
113
  Returns:
95
114
  True if valid, False otherwise
96
115
  """
97
- session = self._active_calls.get(call_id)
98
- if not session or session.state != "active":
99
- return False
100
-
101
- # Check if token matches and is not expired
102
- expected_token = session.tokens.get(function_name)
103
- if not expected_token or expected_token != token:
104
- return False
116
+ try:
117
+ # Decode the token
118
+ decoded_token = base64.urlsafe_b64decode(token.encode()).decode()
105
119
 
106
- # Check expiry
107
- now = datetime.now()
108
- seconds_elapsed = (now - session.started_at).total_seconds()
109
- if seconds_elapsed > self.token_expiry_secs:
110
- session.state = "expired"
111
- return False
120
+ # Split the token parts
121
+ parts = decoded_token.split('.')
122
+ if len(parts) != 5:
123
+ return False
124
+
125
+ token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
112
126
 
113
- return True
127
+ # Special case: if call_id is None or empty, use the call_id from the token
128
+ # This helps with scenarios where the call_id isn't provided in the request
129
+ if not call_id:
130
+ call_id = token_call_id
131
+
132
+ # Verify the function matches
133
+ if token_function != function_name:
134
+ return False
135
+
136
+ # Check if the token has expired
137
+ expiry = int(token_expiry)
138
+ if expiry < time.time():
139
+ return False
140
+
141
+ # Recreate the message and verify the signature
142
+ message = f"{token_call_id}:{token_function}:{token_expiry}:{token_nonce}"
143
+ expected_signature = hmac.new(
144
+ self.secret_key.encode(),
145
+ message.encode(),
146
+ hashlib.sha256
147
+ ).hexdigest()[:16]
148
+
149
+ if token_signature != expected_signature:
150
+ return False
151
+
152
+ # Finally, verify the call_id matches unless we're in special case
153
+ # This check is done last to ensure the token is otherwise valid
154
+ if token_call_id != call_id:
155
+ return False
156
+
157
+ return True
158
+ except Exception:
159
+ # Any exception during validation means the token is invalid
160
+ return False
114
161
 
115
- def activate_session(self, call_id: str) -> bool:
162
+ # Alias for validate_token to maintain backward compatibility
163
+ def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
116
164
  """
117
- Activate a call session (called by startup_hook)
165
+ Alias for validate_token to maintain backward compatibility
118
166
 
119
167
  Args:
168
+ function_name: Name of the function being called
169
+ token: Token to validate
120
170
  call_id: Call session ID
121
171
 
122
172
  Returns:
123
- True if successful, False otherwise
173
+ True if valid, False otherwise
174
+ """
175
+ # Reorder parameters to match validate_token signature (call_id first, then function_name)
176
+ return self.validate_token(call_id=call_id, function_name=function_name, token=token)
177
+
178
+ # Legacy methods that now don't track state but provide API compatibility
179
+
180
+ def activate_session(self, call_id: str) -> bool:
181
+ """
182
+ Legacy method, does nothing but returns success
124
183
  """
125
- session = self._active_calls.get(call_id)
126
- if not session:
127
- return False
128
-
129
- session.state = "active"
130
184
  return True
131
185
 
132
186
  def end_session(self, call_id: str) -> bool:
133
187
  """
134
- End a call session (called by hangup_hook)
135
-
136
- Args:
137
- call_id: Call session ID
138
-
139
- Returns:
140
- True if successful, False otherwise
188
+ Legacy method, does nothing but returns success
141
189
  """
142
- if call_id in self._active_calls:
143
- del self._active_calls[call_id]
144
- return True
145
- return False
190
+ return True
146
191
 
147
192
  def get_session_metadata(self, call_id: str) -> Optional[Dict[str, Any]]:
148
193
  """
149
- Get custom metadata for a call session
150
-
151
- Args:
152
- call_id: Call session ID
153
-
154
- Returns:
155
- Metadata dict or None if session not found
194
+ Legacy method, always returns empty metadata
156
195
  """
157
- session = self._active_calls.get(call_id)
158
- if not session:
159
- return None
160
- return session.metadata
196
+ return {}
161
197
 
162
198
  def set_session_metadata(self, call_id: str, key: str, value: Any) -> bool:
163
199
  """
164
- Set custom metadata for a call session
200
+ Legacy method, does nothing but returns success
201
+ """
202
+ return True
203
+
204
+ def debug_token(self, token: str) -> Dict[str, Any]:
205
+ """
206
+ Debug a token without validating it
207
+
208
+ This method decodes the token and extracts its components for debugging purposes
209
+ without performing validation.
165
210
 
166
211
  Args:
167
- call_id: Call session ID
168
- key: Metadata key
169
- value: Metadata value
212
+ token: The token to debug
170
213
 
171
214
  Returns:
172
- True if successful, False otherwise
215
+ Dictionary with token components and analysis
173
216
  """
174
- session = self._active_calls.get(call_id)
175
- if not session:
176
- return False
217
+ try:
218
+ # Decode the token
219
+ decoded_token = base64.urlsafe_b64decode(token.encode()).decode()
177
220
 
178
- session.metadata[key] = value
179
- return True
221
+ # Split the token parts
222
+ parts = decoded_token.split('.')
223
+ if len(parts) != 5:
224
+ return {
225
+ "valid_format": False,
226
+ "parts_count": len(parts),
227
+ "decoded": decoded_token
228
+ }
229
+
230
+ token_call_id, token_function, token_expiry, token_nonce, token_signature = parts
231
+
232
+ # Check expiration
233
+ current_time = int(time.time())
234
+ try:
235
+ expiry = int(token_expiry)
236
+ is_expired = expiry < current_time
237
+ expires_in = expiry - current_time if not is_expired else 0
238
+ expiry_date = datetime.fromtimestamp(expiry).isoformat()
239
+ except ValueError:
240
+ expiry = None
241
+ is_expired = None
242
+ expires_in = None
243
+ expiry_date = None
244
+
245
+ return {
246
+ "valid_format": True,
247
+ "components": {
248
+ "call_id": token_call_id,
249
+ "function": token_function,
250
+ "expiry": token_expiry,
251
+ "expiry_date": expiry_date,
252
+ "nonce": token_nonce,
253
+ "signature": token_signature
254
+ },
255
+ "status": {
256
+ "current_time": current_time,
257
+ "is_expired": is_expired,
258
+ "expires_in_seconds": expires_in
259
+ }
260
+ }
261
+ except Exception as e:
262
+ # Any exception during parsing
263
+ return {
264
+ "valid_format": False,
265
+ "error": str(e),
266
+ "token": token
267
+ }
@@ -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()