neuronum 8.4.0__py3-none-any.whl → 10.0.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.
Potentially problematic release.
This version of neuronum might be problematic. Click here for more details.
- cli/main.py +229 -942
- neuronum/__init__.py +1 -1
- neuronum/neuronum.py +671 -264
- neuronum-10.0.0.dist-info/METADATA +157 -0
- neuronum-10.0.0.dist-info/RECORD +10 -0
- neuronum-8.4.0.dist-info/METADATA +0 -124
- neuronum-8.4.0.dist-info/RECORD +0 -10
- {neuronum-8.4.0.dist-info → neuronum-10.0.0.dist-info}/WHEEL +0 -0
- {neuronum-8.4.0.dist-info → neuronum-10.0.0.dist-info}/entry_points.txt +0 -0
- {neuronum-8.4.0.dist-info → neuronum-10.0.0.dist-info}/licenses/LICENSE.md +0 -0
- {neuronum-8.4.0.dist-info → neuronum-10.0.0.dist-info}/top_level.txt +0 -0
neuronum/neuronum.py
CHANGED
|
@@ -1,338 +1,745 @@
|
|
|
1
1
|
import aiohttp
|
|
2
|
-
|
|
2
|
+
import aiofiles
|
|
3
|
+
from typing import AsyncGenerator, Optional, Dict, Any, List
|
|
3
4
|
import websockets
|
|
4
5
|
import json
|
|
5
6
|
import asyncio
|
|
6
7
|
import base64
|
|
8
|
+
import ssl
|
|
7
9
|
import os
|
|
10
|
+
import time
|
|
11
|
+
import logging
|
|
8
12
|
from pathlib import Path
|
|
9
|
-
from
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from websockets.exceptions import ConnectionClosed, WebSocketException
|
|
10
15
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
11
16
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
12
17
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
13
18
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
14
19
|
from cryptography.hazmat.backends import default_backend
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
15
21
|
|
|
22
|
+
# Configure logging
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
16
28
|
|
|
17
|
-
class Node:
|
|
18
|
-
def __init__(self, id: str, private_key: str, public_key: str):
|
|
19
|
-
self.node_id = id
|
|
20
|
-
self.private_key_path = private_key
|
|
21
|
-
self.public_key_path = public_key
|
|
22
|
-
self.queue = asyncio.Queue()
|
|
23
|
-
self.host = self._load_host()
|
|
24
|
-
self.network = self._load_network()
|
|
25
|
-
self.synapse = self._load_synapse()
|
|
26
|
-
self.password = self._load_password()
|
|
27
|
-
self._private_key = self._load_private_key()
|
|
28
|
-
self._public_key = self._load_public_key()
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"password": self.password,
|
|
35
|
-
"synapse": self.synapse
|
|
36
|
-
}
|
|
30
|
+
# Custom Exceptions
|
|
31
|
+
class NeuronumError(Exception):
|
|
32
|
+
"""Base exception for Neuronum errors"""
|
|
33
|
+
pass
|
|
37
34
|
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
private_key = serialization.load_pem_private_key(
|
|
43
|
-
f.read(),
|
|
44
|
-
password=None,
|
|
45
|
-
backend=default_backend()
|
|
46
|
-
)
|
|
47
|
-
return private_key
|
|
48
|
-
except FileNotFoundError:
|
|
49
|
-
print(f"Private key file not found at {self.private_key_path}.")
|
|
50
|
-
return None
|
|
51
|
-
|
|
36
|
+
class AuthenticationError(NeuronumError):
|
|
37
|
+
"""Raised when authentication fails"""
|
|
38
|
+
pass
|
|
52
39
|
|
|
53
|
-
def _load_host(self):
|
|
54
|
-
credentials_folder_path = Path.home() / ".neuronum"
|
|
55
|
-
env_path = credentials_folder_path / ".env"
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
class EncryptionError(NeuronumError):
|
|
42
|
+
"""Raised when encryption/decryption fails"""
|
|
43
|
+
pass
|
|
58
44
|
|
|
59
|
-
try:
|
|
60
|
-
with open(env_path, "r") as f:
|
|
61
|
-
for line in f:
|
|
62
|
-
key, value = line.strip().split("=")
|
|
63
|
-
env_data[key] = value
|
|
64
45
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
print(f"Cell Host not found")
|
|
69
|
-
return None
|
|
70
|
-
|
|
46
|
+
class CellNotFoundError(NeuronumError):
|
|
47
|
+
"""Raised when a cell cannot be found"""
|
|
48
|
+
pass
|
|
71
49
|
|
|
72
|
-
def _load_password(self):
|
|
73
|
-
credentials_folder_path = Path.home() / ".neuronum"
|
|
74
|
-
env_path = credentials_folder_path / ".env"
|
|
75
50
|
|
|
76
|
-
|
|
51
|
+
class NetworkError(NeuronumError):
|
|
52
|
+
"""Raised when network operations fail"""
|
|
53
|
+
pass
|
|
77
54
|
|
|
78
|
-
try:
|
|
79
|
-
with open(env_path, "r") as f:
|
|
80
|
-
for line in f:
|
|
81
|
-
key, value = line.strip().split("=")
|
|
82
|
-
env_data[key] = value
|
|
83
55
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
56
|
+
# Configuration
|
|
57
|
+
@dataclass
|
|
58
|
+
class ClientConfig:
|
|
59
|
+
"""Client configuration settings"""
|
|
60
|
+
network: str = "neuronum.net"
|
|
61
|
+
cache_expiry: int = 3600
|
|
62
|
+
credentials_path: Path = Path.home() / ".neuronum"
|
|
63
|
+
timeout: int = 30
|
|
64
|
+
max_retries: int = 3
|
|
65
|
+
retry_delay: float = 1.0
|
|
66
|
+
max_retry_delay: float = 60.0
|
|
93
67
|
|
|
94
|
-
env_data = {}
|
|
95
68
|
|
|
69
|
+
class CryptoManager:
|
|
70
|
+
"""Handles all cryptographic operations"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, private_key: Optional[ec.EllipticCurvePrivateKey] = None):
|
|
73
|
+
self._private_key = private_key
|
|
74
|
+
self._public_key = private_key.public_key() if private_key else None
|
|
75
|
+
|
|
76
|
+
def sign_message(self, message: bytes) -> str:
|
|
77
|
+
"""Sign a message with the private key"""
|
|
78
|
+
if not self._private_key:
|
|
79
|
+
raise EncryptionError("Private key not available for signing")
|
|
80
|
+
|
|
96
81
|
try:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
signature = self._private_key.sign(message, ec.ECDSA(hashes.SHA256()))
|
|
83
|
+
return base64.b64encode(signature).decode()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error("Failed to sign message", exc_info=True)
|
|
86
|
+
raise EncryptionError(f"Message signing failed: {e}")
|
|
87
|
+
|
|
88
|
+
def get_public_key_pem(self) -> str:
|
|
89
|
+
"""Get public key in PEM format"""
|
|
90
|
+
if not self._public_key:
|
|
91
|
+
raise EncryptionError("Public key not available")
|
|
107
92
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
93
|
+
pem_bytes = self._public_key.public_bytes(
|
|
94
|
+
encoding=serialization.Encoding.PEM,
|
|
95
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
96
|
+
)
|
|
97
|
+
return pem_bytes.decode('utf-8')
|
|
98
|
+
|
|
99
|
+
def load_public_key_from_pem(self, pem_string: str) -> ec.EllipticCurvePublicKey:
|
|
100
|
+
"""Load a public key from PEM format"""
|
|
114
101
|
try:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
102
|
+
return serialization.load_pem_public_key(
|
|
103
|
+
pem_string.encode(),
|
|
104
|
+
backend=default_backend()
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error("Failed to load public key from PEM", exc_info=True)
|
|
108
|
+
raise EncryptionError(f"Failed to load public key: {e}")
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def safe_b64decode(data: str) -> bytes:
|
|
112
|
+
"""Safely decode base64 with proper padding"""
|
|
113
|
+
padding = 4 - (len(data) % 4)
|
|
114
|
+
if padding != 4:
|
|
115
|
+
data += '=' * padding
|
|
116
|
+
return base64.urlsafe_b64decode(data)
|
|
117
|
+
|
|
118
|
+
def encrypt_with_ecdh_aesgcm(
|
|
119
|
+
self,
|
|
120
|
+
public_key: ec.EllipticCurvePublicKey,
|
|
121
|
+
plaintext_dict: Dict[str, Any]
|
|
122
|
+
) -> Dict[str, str]:
|
|
123
|
+
"""Encrypt data using ECDH + AES-GCM"""
|
|
128
124
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
"crv": "P-256",
|
|
157
|
-
"x": base64.urlsafe_b64encode(x_bytes).rstrip(b'=').decode('utf-8'),
|
|
158
|
-
"y": base64.urlsafe_b64encode(y_bytes).rstrip(b'=').decode('utf-8')
|
|
159
|
-
}
|
|
125
|
+
ephemeral_private = ec.generate_private_key(ec.SECP256R1())
|
|
126
|
+
shared_secret = ephemeral_private.exchange(ec.ECDH(), public_key)
|
|
127
|
+
derived_key = HKDF(
|
|
128
|
+
algorithm=hashes.SHA256(),
|
|
129
|
+
length=32,
|
|
130
|
+
salt=None,
|
|
131
|
+
info=b'handshake data'
|
|
132
|
+
).derive(shared_secret)
|
|
133
|
+
|
|
134
|
+
aesgcm = AESGCM(derived_key)
|
|
135
|
+
nonce = os.urandom(12)
|
|
136
|
+
plaintext_bytes = json.dumps(plaintext_dict).encode()
|
|
137
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
|
|
138
|
+
|
|
139
|
+
ephemeral_public_bytes = ephemeral_private.public_key().public_bytes(
|
|
140
|
+
serialization.Encoding.X962,
|
|
141
|
+
serialization.PublicFormat.UncompressedPoint
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
'ciphertext': base64.urlsafe_b64encode(ciphertext).rstrip(b'=').decode(),
|
|
146
|
+
'nonce': base64.urlsafe_b64encode(nonce).rstrip(b'=').decode(),
|
|
147
|
+
'ephemeralPublicKey': base64.urlsafe_b64encode(ephemeral_public_bytes).rstrip(b'=').decode()
|
|
148
|
+
}
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.error("Encryption failed", exc_info=True)
|
|
151
|
+
raise EncryptionError(f"Encryption failed: {e}")
|
|
160
152
|
|
|
161
|
-
|
|
162
|
-
|
|
153
|
+
def decrypt_with_ecdh_aesgcm(
|
|
154
|
+
self,
|
|
155
|
+
ephemeral_public_key_bytes: bytes,
|
|
156
|
+
nonce: bytes,
|
|
157
|
+
ciphertext: bytes
|
|
158
|
+
) -> Dict[str, Any]:
|
|
159
|
+
"""Decrypt data using ECDH + AES-GCM"""
|
|
160
|
+
if not self._private_key:
|
|
161
|
+
raise EncryptionError("Private key not available for decryption")
|
|
162
|
+
|
|
163
163
|
try:
|
|
164
164
|
ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
165
165
|
ec.SECP256R1(), ephemeral_public_key_bytes
|
|
166
166
|
)
|
|
167
|
-
|
|
168
167
|
shared_secret = self._private_key.exchange(ec.ECDH(), ephemeral_public_key)
|
|
169
|
-
|
|
170
168
|
derived_key = HKDF(
|
|
171
|
-
algorithm=hashes.SHA256(),
|
|
172
|
-
length=32,
|
|
173
|
-
salt=None,
|
|
169
|
+
algorithm=hashes.SHA256(),
|
|
170
|
+
length=32,
|
|
171
|
+
salt=None,
|
|
174
172
|
info=b'handshake data'
|
|
175
173
|
).derive(shared_secret)
|
|
176
|
-
|
|
174
|
+
|
|
177
175
|
aesgcm = AESGCM(derived_key)
|
|
178
176
|
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
|
|
179
177
|
return json.loads(plaintext_bytes.decode())
|
|
180
|
-
|
|
181
178
|
except Exception as e:
|
|
182
|
-
|
|
179
|
+
logger.error("Decryption failed", exc_info=True)
|
|
180
|
+
raise EncryptionError(f"Decryption failed: {e}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CacheManager:
|
|
184
|
+
"""Manages cell cache with async file operations"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, config: ClientConfig):
|
|
187
|
+
self.config = config
|
|
188
|
+
self.cache_file = config.credentials_path / "cells.json"
|
|
189
|
+
self._lock = asyncio.Lock()
|
|
190
|
+
self._memory_cache: Optional[List[Dict[str, Any]]] = None
|
|
191
|
+
self._cache_time: Optional[float] = None
|
|
192
|
+
|
|
193
|
+
async def get_cells(self) -> List[Dict[str, Any]]:
|
|
194
|
+
"""Get cached cells if valid, otherwise fetch new"""
|
|
195
|
+
async with self._lock:
|
|
196
|
+
# Check memory cache first
|
|
197
|
+
if self._is_memory_cache_valid():
|
|
198
|
+
logger.debug("Using in-memory cache")
|
|
199
|
+
return self._memory_cache
|
|
200
|
+
|
|
201
|
+
# Check file cache
|
|
202
|
+
if await self._is_file_cache_valid():
|
|
203
|
+
logger.debug("Using file cache")
|
|
204
|
+
cells = await self._load_from_file()
|
|
205
|
+
self._update_memory_cache(cells)
|
|
206
|
+
return cells
|
|
207
|
+
|
|
183
208
|
return None
|
|
209
|
+
|
|
210
|
+
async def update_cells(self, cells: List[Dict[str, Any]]) -> None:
|
|
211
|
+
"""Update cache with new cell data"""
|
|
212
|
+
async with self._lock:
|
|
213
|
+
self._update_memory_cache(cells)
|
|
214
|
+
await self._save_to_file(cells)
|
|
215
|
+
|
|
216
|
+
def _is_memory_cache_valid(self) -> bool:
|
|
217
|
+
"""Check if memory cache is still valid"""
|
|
218
|
+
if not self._memory_cache or not self._cache_time:
|
|
219
|
+
return False
|
|
220
|
+
return (time.time() - self._cache_time) < self.config.cache_expiry
|
|
221
|
+
|
|
222
|
+
async def _is_file_cache_valid(self) -> bool:
|
|
223
|
+
"""Check if file cache is still valid"""
|
|
224
|
+
if not self.cache_file.exists():
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
file_mtime = os.path.getmtime(self.cache_file)
|
|
229
|
+
return (time.time() - file_mtime) < self.config.cache_expiry
|
|
230
|
+
except OSError:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
async def _load_from_file(self) -> List[Dict[str, Any]]:
|
|
234
|
+
"""Load cells from cache file"""
|
|
235
|
+
try:
|
|
236
|
+
async with aiofiles.open(self.cache_file, 'r') as f:
|
|
237
|
+
content = await f.read()
|
|
238
|
+
return json.loads(content)
|
|
239
|
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
240
|
+
logger.warning(f"Failed to load cache file: {e}")
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
async def _save_to_file(self, cells: List[Dict[str, Any]]) -> None:
|
|
244
|
+
"""Save cells to cache file"""
|
|
245
|
+
try:
|
|
246
|
+
self.config.credentials_path.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
async with aiofiles.open(self.cache_file, 'w') as f:
|
|
248
|
+
await f.write(json.dumps(cells, indent=4))
|
|
249
|
+
logger.debug("Cache file updated")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.error(f"Failed to save cache file: {e}")
|
|
252
|
+
|
|
253
|
+
def _update_memory_cache(self, cells: List[Dict[str, Any]]) -> None:
|
|
254
|
+
"""Update in-memory cache"""
|
|
255
|
+
self._memory_cache = cells
|
|
256
|
+
self._cache_time = time.time()
|
|
184
257
|
|
|
185
258
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
259
|
+
class NetworkClient:
|
|
260
|
+
"""Handles all network operations with retry logic"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, config: ClientConfig):
|
|
263
|
+
self.config = config
|
|
264
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
265
|
+
|
|
266
|
+
async def __aenter__(self):
|
|
267
|
+
self._session = aiohttp.ClientSession(
|
|
268
|
+
timeout=aiohttp.ClientTimeout(total=self.config.timeout)
|
|
269
|
+
)
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
async def __aexit__(self, *args):
|
|
273
|
+
if self._session:
|
|
274
|
+
await self._session.close()
|
|
275
|
+
|
|
276
|
+
async def post_request(
|
|
277
|
+
self,
|
|
278
|
+
url: str,
|
|
279
|
+
payload: Dict[str, Any],
|
|
280
|
+
retry_count: int = 0
|
|
281
|
+
) -> Optional[Dict[str, Any]]:
|
|
282
|
+
"""Make POST request with retry logic"""
|
|
283
|
+
if not self._session:
|
|
284
|
+
self._session = aiohttp.ClientSession(
|
|
285
|
+
timeout=aiohttp.ClientTimeout(total=self.config.timeout)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
async with self._session.post(url, json=payload) as response:
|
|
290
|
+
response.raise_for_status()
|
|
291
|
+
return await response.json()
|
|
292
|
+
except aiohttp.ClientResponseError as e:
|
|
293
|
+
logger.error(f"HTTP error {e.status} for URL: {url}")
|
|
294
|
+
if retry_count < self.config.max_retries and e.status >= 500:
|
|
295
|
+
return await self._retry_request(url, payload, retry_count)
|
|
296
|
+
raise NetworkError(f"HTTP {e.status} error")
|
|
297
|
+
except aiohttp.ClientError as e:
|
|
298
|
+
logger.error(f"Client error for URL {url}: {e}")
|
|
299
|
+
if retry_count < self.config.max_retries:
|
|
300
|
+
return await self._retry_request(url, payload, retry_count)
|
|
301
|
+
raise NetworkError(f"Client error: {e}")
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"Unexpected error for URL {url}: {e}")
|
|
304
|
+
raise NetworkError(f"Unexpected error: {e}")
|
|
305
|
+
|
|
306
|
+
async def _retry_request(
|
|
307
|
+
self,
|
|
308
|
+
url: str,
|
|
309
|
+
payload: Dict[str, Any],
|
|
310
|
+
retry_count: int
|
|
311
|
+
) -> Optional[Dict[str, Any]]:
|
|
312
|
+
"""Retry request with exponential backoff"""
|
|
313
|
+
delay = min(
|
|
314
|
+
self.config.retry_delay * (2 ** retry_count),
|
|
315
|
+
self.config.max_retry_delay
|
|
316
|
+
)
|
|
317
|
+
logger.info(f"Retrying request in {delay}s (attempt {retry_count + 1})")
|
|
318
|
+
await asyncio.sleep(delay)
|
|
319
|
+
return await self.post_request(url, payload, retry_count + 1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class BaseClient(ABC):
|
|
323
|
+
"""Base client with common functionality"""
|
|
324
|
+
|
|
325
|
+
def __init__(self, config: Optional[ClientConfig] = None):
|
|
326
|
+
self.config = config or ClientConfig()
|
|
327
|
+
self.env: Dict[str, str] = {}
|
|
328
|
+
self._crypto: Optional[CryptoManager] = None
|
|
329
|
+
self._cache_manager = CacheManager(self.config)
|
|
330
|
+
self._network_client = NetworkClient(self.config)
|
|
331
|
+
self.host = ""
|
|
332
|
+
self.network = self.config.network
|
|
333
|
+
|
|
334
|
+
@abstractmethod
|
|
335
|
+
def _load_private_key(self) -> Optional[ec.EllipticCurvePrivateKey]:
|
|
336
|
+
"""Load private key - to be implemented by subclasses"""
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
def _init_crypto(self, private_key: Optional[ec.EllipticCurvePrivateKey]) -> None:
|
|
340
|
+
"""Initialize crypto manager with private key"""
|
|
341
|
+
self._crypto = CryptoManager(private_key)
|
|
342
|
+
|
|
343
|
+
def to_dict(self) -> Dict[str, str]:
|
|
344
|
+
"""Create authentication payload"""
|
|
345
|
+
if not self._crypto:
|
|
346
|
+
logger.warning("Crypto manager not initialized")
|
|
347
|
+
timestamp = str(int(time.time()))
|
|
348
|
+
return {
|
|
349
|
+
"host": self.host,
|
|
350
|
+
"signed_message": "",
|
|
351
|
+
"message": f"host={self.host};timestamp={timestamp}"
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
timestamp = str(int(time.time()))
|
|
355
|
+
message = f"host={self.host};timestamp={timestamp}"
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
signed_message = self._crypto.sign_message(message.encode())
|
|
359
|
+
except EncryptionError:
|
|
360
|
+
logger.error("Failed to sign authentication message")
|
|
361
|
+
signed_message = ""
|
|
362
|
+
|
|
363
|
+
return {
|
|
189
364
|
"host": self.host,
|
|
190
|
-
"
|
|
191
|
-
"
|
|
365
|
+
"signed_message": signed_message,
|
|
366
|
+
"message": message
|
|
192
367
|
}
|
|
368
|
+
|
|
369
|
+
async def _get_target_cell_public_key(self, cell_id: str) -> str:
|
|
370
|
+
"""Get public key for target cell"""
|
|
371
|
+
# Try cached cells first
|
|
372
|
+
cells = await self.list_cells(update=False)
|
|
373
|
+
|
|
374
|
+
for cell in cells:
|
|
375
|
+
if cell.get('cell_id') == cell_id:
|
|
376
|
+
public_key = cell.get('public_key', {})
|
|
377
|
+
if public_key:
|
|
378
|
+
return public_key
|
|
379
|
+
|
|
380
|
+
# Refresh cache and try again
|
|
381
|
+
logger.info(f"Cell {cell_id} not in cache, refreshing")
|
|
382
|
+
cells = await self.list_cells(update=True)
|
|
383
|
+
|
|
384
|
+
for cell in cells:
|
|
385
|
+
if cell.get('cell_id') == cell_id:
|
|
386
|
+
public_key = cell.get('public_key', {})
|
|
387
|
+
if public_key:
|
|
388
|
+
return public_key
|
|
389
|
+
|
|
390
|
+
raise CellNotFoundError(f"Cell not found: {cell_id}")
|
|
391
|
+
|
|
392
|
+
async def list_cells(self, update: bool = False) -> List[Dict[str, Any]]:
|
|
393
|
+
"""
|
|
394
|
+
List all available cells.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
update: Force cache refresh if True
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
List of cell information dictionaries
|
|
401
|
+
"""
|
|
402
|
+
if not update:
|
|
403
|
+
cached_cells = await self._cache_manager.get_cells()
|
|
404
|
+
if cached_cells is not None:
|
|
405
|
+
return cached_cells
|
|
406
|
+
|
|
407
|
+
# Fetch from API
|
|
408
|
+
full_url = f"https://{self.network}/api/list_cells"
|
|
409
|
+
payload = {"cell": self.to_dict()}
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
data = await self._network_client.post_request(full_url, payload)
|
|
413
|
+
cells = data.get("Cells", []) if data else []
|
|
414
|
+
await self._cache_manager.update_cells(cells)
|
|
415
|
+
return cells
|
|
416
|
+
except NetworkError as e:
|
|
417
|
+
logger.error(f"Failed to fetch cells: {e}")
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
async def tx_response(
|
|
421
|
+
self,
|
|
422
|
+
transmitter_id: str,
|
|
423
|
+
data: Dict[str, Any],
|
|
424
|
+
client_public_key_str: str
|
|
425
|
+
) -> None:
|
|
426
|
+
"""
|
|
427
|
+
Send an encrypted response to a transmitter.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
transmitter_id: ID of the transmitter
|
|
431
|
+
data: Response data to encrypt
|
|
432
|
+
client_public_key_str: Client's public key in PEM format
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
EncryptionError: If encryption fails
|
|
436
|
+
NetworkError: If network request fails
|
|
437
|
+
"""
|
|
438
|
+
if not self._crypto:
|
|
439
|
+
raise EncryptionError("Crypto manager not initialized")
|
|
440
|
+
|
|
441
|
+
if not client_public_key_str:
|
|
442
|
+
raise ValueError("client_public_key_str is required")
|
|
443
|
+
|
|
444
|
+
url = f"https://{self.network}/api/tx_response/{transmitter_id}"
|
|
445
|
+
|
|
446
|
+
public_key = self._crypto.load_public_key_from_pem(client_public_key_str)
|
|
447
|
+
encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(public_key, data)
|
|
448
|
+
payload = {"data": encrypted_payload, "cell": self.to_dict()}
|
|
449
|
+
|
|
450
|
+
await self._network_client.post_request(url, payload)
|
|
451
|
+
logger.info(f"Response sent to transmitter {transmitter_id}")
|
|
452
|
+
|
|
453
|
+
async def activate_tx(
|
|
454
|
+
self,
|
|
455
|
+
cell_id: str,
|
|
456
|
+
data: Dict[str, Any]
|
|
457
|
+
) -> Optional[Dict[str, Any]]:
|
|
458
|
+
"""
|
|
459
|
+
Activate a transaction with the specified cell.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
cell_id: Target cell identifier
|
|
463
|
+
data: Transaction data to encrypt and send
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Decrypted response dict or None on failure
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
CellNotFoundError: If cell_id is not found
|
|
470
|
+
EncryptionError: If encryption/decryption fails
|
|
471
|
+
NetworkError: If network request fails
|
|
472
|
+
"""
|
|
473
|
+
if not self._crypto:
|
|
474
|
+
raise EncryptionError("Crypto manager not initialized")
|
|
475
|
+
|
|
476
|
+
url = f"https://{self.network}/api/activate_tx/{cell_id}"
|
|
477
|
+
payload = {"cell": self.to_dict()}
|
|
478
|
+
|
|
479
|
+
# Get target cell's public key
|
|
480
|
+
public_key_pem_str = await self._get_target_cell_public_key(cell_id)
|
|
481
|
+
public_key_object = self._crypto.load_public_key_from_pem(public_key_pem_str)
|
|
482
|
+
|
|
483
|
+
# Prepare and encrypt data
|
|
484
|
+
data_to_encrypt = data.copy()
|
|
485
|
+
data_to_encrypt["public_key"] = self._crypto.get_public_key_pem()
|
|
486
|
+
encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(
|
|
487
|
+
public_key_object,
|
|
488
|
+
data_to_encrypt
|
|
489
|
+
)
|
|
490
|
+
payload["data"] = {"encrypted": encrypted_payload}
|
|
491
|
+
|
|
492
|
+
response_data = await self._network_client.post_request(url, payload)
|
|
493
|
+
|
|
494
|
+
if not response_data or "response" not in response_data:
|
|
495
|
+
logger.warning("Unexpected or missing response")
|
|
496
|
+
return response_data
|
|
497
|
+
|
|
498
|
+
inner_response = response_data["response"]
|
|
499
|
+
|
|
500
|
+
# Decrypt response if encrypted
|
|
501
|
+
if "ciphertext" in inner_response:
|
|
502
|
+
try:
|
|
503
|
+
ephemeral_public_key_bytes = CryptoManager.safe_b64decode(
|
|
504
|
+
inner_response["ephemeralPublicKey"]
|
|
505
|
+
)
|
|
506
|
+
nonce = CryptoManager.safe_b64decode(inner_response["nonce"])
|
|
507
|
+
ciphertext = CryptoManager.safe_b64decode(inner_response["ciphertext"])
|
|
508
|
+
|
|
509
|
+
return self._crypto.decrypt_with_ecdh_aesgcm(
|
|
510
|
+
ephemeral_public_key_bytes, nonce, ciphertext
|
|
511
|
+
)
|
|
512
|
+
except EncryptionError:
|
|
513
|
+
logger.error("Failed to decrypt response")
|
|
514
|
+
return None
|
|
515
|
+
else:
|
|
516
|
+
logger.debug("Received unencrypted response")
|
|
517
|
+
return inner_response
|
|
518
|
+
|
|
519
|
+
async def sync(self) -> AsyncGenerator[Dict[str, Any], None]:
|
|
520
|
+
"""
|
|
521
|
+
Sync with the network and yield operations as they arrive.
|
|
522
|
+
|
|
523
|
+
Yields:
|
|
524
|
+
Operation dictionaries with decrypted data
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
ValueError: If not called from Cell instance or host not set
|
|
528
|
+
"""
|
|
529
|
+
if not isinstance(self, Cell):
|
|
530
|
+
raise ValueError("sync must be called from a Cell instance")
|
|
531
|
+
|
|
532
|
+
cell = getattr(self, 'host', None)
|
|
533
|
+
if not cell:
|
|
534
|
+
raise ValueError("host is required for Cell sync")
|
|
535
|
+
|
|
536
|
+
full_url = f"wss://{self.network}/sync/{cell}"
|
|
537
|
+
auth_payload = self.to_dict()
|
|
538
|
+
|
|
539
|
+
logger.info(f"Starting sync with {cell}")
|
|
540
|
+
|
|
541
|
+
retry_count = 0
|
|
193
542
|
while True:
|
|
194
543
|
try:
|
|
195
|
-
|
|
544
|
+
ssl_context = ssl.create_default_context()
|
|
545
|
+
|
|
546
|
+
async with websockets.connect(full_url, ssl=ssl_context) as ws:
|
|
196
547
|
await ws.send(json.dumps(auth_payload))
|
|
197
|
-
|
|
548
|
+
logger.info(f"Connected and authenticated to {cell}")
|
|
549
|
+
retry_count = 0 # Reset on successful connection
|
|
550
|
+
|
|
198
551
|
while True:
|
|
199
552
|
try:
|
|
200
|
-
raw_operation = await
|
|
553
|
+
raw_operation = await asyncio.wait_for(
|
|
554
|
+
ws.recv(),
|
|
555
|
+
timeout=self.config.timeout
|
|
556
|
+
)
|
|
201
557
|
operation = json.loads(raw_operation)
|
|
202
558
|
|
|
203
559
|
if "encrypted" in operation.get("data", {}):
|
|
204
560
|
encrypted_data = operation["data"]["encrypted"]
|
|
205
561
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if decrypted_data:
|
|
562
|
+
try:
|
|
563
|
+
ephemeral_public_key_bytes = CryptoManager.safe_b64decode(
|
|
564
|
+
encrypted_data["ephemeralPublicKey"]
|
|
565
|
+
)
|
|
566
|
+
nonce = CryptoManager.safe_b64decode(
|
|
567
|
+
encrypted_data["nonce"]
|
|
568
|
+
)
|
|
569
|
+
ciphertext = CryptoManager.safe_b64decode(
|
|
570
|
+
encrypted_data["ciphertext"]
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
decrypted_data = self._crypto.decrypt_with_ecdh_aesgcm(
|
|
574
|
+
ephemeral_public_key_bytes, nonce, ciphertext
|
|
575
|
+
)
|
|
576
|
+
|
|
223
577
|
operation["data"].update(decrypted_data)
|
|
224
578
|
operation["data"].pop("encrypted")
|
|
225
579
|
yield operation
|
|
226
|
-
|
|
227
|
-
|
|
580
|
+
except EncryptionError:
|
|
581
|
+
logger.error("Failed to decrypt operation")
|
|
228
582
|
else:
|
|
229
|
-
|
|
583
|
+
logger.warning("Received unencrypted data")
|
|
584
|
+
|
|
230
585
|
except asyncio.TimeoutError:
|
|
231
|
-
|
|
586
|
+
await ws.ping()
|
|
587
|
+
continue
|
|
232
588
|
except ConnectionClosed as e:
|
|
233
|
-
|
|
234
|
-
print(f"WebSocket closed cleanly (code 1000). Reconnecting...")
|
|
235
|
-
else:
|
|
236
|
-
print(f"Connection closed with error code {e.code}: {e.reason}. Reconnecting...")
|
|
589
|
+
logger.warning(f"Connection closed: {e.code} - {e.reason}")
|
|
237
590
|
break
|
|
238
591
|
except Exception as e:
|
|
239
|
-
|
|
592
|
+
logger.error(f"Error in receive loop: {e}")
|
|
240
593
|
break
|
|
241
|
-
|
|
242
|
-
|
|
594
|
+
|
|
595
|
+
except WebSocketException as e:
|
|
596
|
+
logger.error(f"WebSocket error: {e}")
|
|
243
597
|
except Exception as e:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
598
|
+
logger.error(f"General error in sync: {e}")
|
|
599
|
+
|
|
600
|
+
# Exponential backoff for reconnection
|
|
601
|
+
retry_count += 1
|
|
602
|
+
delay = min(
|
|
603
|
+
self.config.retry_delay * (2 ** retry_count),
|
|
604
|
+
self.config.max_retry_delay
|
|
605
|
+
)
|
|
606
|
+
logger.info(f"Reconnecting in {delay}s")
|
|
607
|
+
await asyncio.sleep(delay)
|
|
608
|
+
|
|
609
|
+
async def stream(self, cell_id: str, data: Dict[str, Any]) -> bool:
|
|
610
|
+
"""
|
|
611
|
+
Stream data to a target cell.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
cell_id: Target cell identifier
|
|
615
|
+
data: Data to encrypt and stream
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
True if successful, False otherwise
|
|
619
|
+
|
|
620
|
+
Raises:
|
|
621
|
+
ValueError: If not called from Cell instance or host not set
|
|
622
|
+
CellNotFoundError: If cell_id is not found
|
|
623
|
+
EncryptionError: If encryption fails
|
|
624
|
+
"""
|
|
625
|
+
if not isinstance(self, Cell):
|
|
626
|
+
raise ValueError("stream must be called from a Cell instance")
|
|
627
|
+
|
|
628
|
+
if not getattr(self, 'host', None):
|
|
629
|
+
raise ValueError("host is required for Cell stream")
|
|
630
|
+
|
|
631
|
+
if not self._crypto:
|
|
632
|
+
raise EncryptionError("Crypto manager not initialized")
|
|
633
|
+
|
|
634
|
+
# Get target cell's public key
|
|
635
|
+
public_key_pem_str = await self._get_target_cell_public_key(cell_id)
|
|
636
|
+
public_key_object = self._crypto.load_public_key_from_pem(public_key_pem_str)
|
|
637
|
+
|
|
638
|
+
# Prepare and encrypt data
|
|
639
|
+
data_to_encrypt = data.copy()
|
|
640
|
+
data_to_encrypt["public_key"] = self._crypto.get_public_key_pem()
|
|
641
|
+
encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(
|
|
642
|
+
public_key_object,
|
|
643
|
+
data_to_encrypt
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
auth_payload = self.to_dict()
|
|
647
|
+
data_payload = {"data": {"encrypted": encrypted_payload}}
|
|
648
|
+
send_payload = {**auth_payload, **data_payload}
|
|
649
|
+
|
|
650
|
+
full_url = f"wss://{self.network}/stream/{cell_id}"
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
ssl_context = ssl.create_default_context()
|
|
654
|
+
async with websockets.connect(full_url, ssl=ssl_context) as ws:
|
|
655
|
+
await ws.send(json.dumps(send_payload))
|
|
656
|
+
logger.info(f"Data streamed to {cell_id}")
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
ack = await asyncio.wait_for(ws.recv(), timeout=2)
|
|
660
|
+
logger.debug(f"Server acknowledgment: {ack}")
|
|
661
|
+
except asyncio.TimeoutError:
|
|
662
|
+
logger.debug("No immediate acknowledgment (data sent)")
|
|
663
|
+
except Exception as e:
|
|
664
|
+
logger.warning(f"Error reading acknowledgment: {e}")
|
|
665
|
+
|
|
666
|
+
return True
|
|
667
|
+
except WebSocketException as e:
|
|
668
|
+
logger.error(f"WebSocket error during stream: {e}")
|
|
669
|
+
return False
|
|
670
|
+
except Exception as e:
|
|
671
|
+
logger.error(f"Error during stream: {e}")
|
|
672
|
+
return False
|
|
247
673
|
|
|
248
|
-
async def list_nodes(self):
|
|
249
|
-
full_url = f"https://{self.network}/api/list_nodes"
|
|
250
|
-
list_nodes_payload = {
|
|
251
|
-
"cell": self.to_dict()
|
|
252
|
-
}
|
|
253
|
-
async with aiohttp.ClientSession() as session:
|
|
254
|
-
try:
|
|
255
|
-
async with session.post(full_url, json=list_nodes_payload) as response:
|
|
256
|
-
response.raise_for_status()
|
|
257
|
-
data = await response.json()
|
|
258
|
-
return data.get("Nodes", [])
|
|
259
|
-
except aiohttp.ClientError as e:
|
|
260
|
-
print(f"Error sending request: {e}")
|
|
261
|
-
except Exception as e:
|
|
262
|
-
print(f"Unexpected error: {e}")
|
|
263
|
-
|
|
264
674
|
|
|
265
|
-
|
|
675
|
+
class Cell(BaseClient):
|
|
676
|
+
"""Cell client implementation"""
|
|
677
|
+
|
|
678
|
+
def __init__(self, config: Optional[ClientConfig] = None):
|
|
679
|
+
super().__init__(config)
|
|
680
|
+
self.env = self._load_env()
|
|
681
|
+
private_key = self._load_private_key()
|
|
682
|
+
self._init_crypto(private_key)
|
|
683
|
+
|
|
684
|
+
self.host = self.env.get("HOST", "")
|
|
685
|
+
if not self.host:
|
|
686
|
+
logger.warning("HOST not set in environment")
|
|
687
|
+
|
|
688
|
+
def _load_private_key(self) -> Optional[ec.EllipticCurvePrivateKey]:
|
|
689
|
+
"""Load private key from credentials folder"""
|
|
690
|
+
credentials_path = self.config.credentials_path
|
|
691
|
+
credentials_path.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
|
|
693
|
+
key_path = credentials_path / "private_key.pem"
|
|
694
|
+
|
|
266
695
|
try:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
696
|
+
with open(key_path, "rb") as f:
|
|
697
|
+
private_key = serialization.load_pem_private_key(
|
|
698
|
+
f.read(),
|
|
699
|
+
password=None,
|
|
700
|
+
backend=default_backend()
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Check file permissions (should be 0600)
|
|
704
|
+
stat = os.stat(key_path)
|
|
705
|
+
if stat.st_mode & 0o177:
|
|
706
|
+
logger.warning(
|
|
707
|
+
f"Private key file has insecure permissions: {oct(stat.st_mode)}"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
logger.info("Private key loaded successfully")
|
|
711
|
+
return private_key
|
|
712
|
+
except FileNotFoundError:
|
|
713
|
+
logger.error(f"Private key not found at {key_path}")
|
|
714
|
+
return None
|
|
715
|
+
except Exception as e:
|
|
716
|
+
logger.error(f"Error loading private key: {e}")
|
|
278
717
|
return None
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def _encrypt_with_ecdh_aesgcm(self, public_key, plaintext_dict):
|
|
282
|
-
ephemeral_private = ec.generate_private_key(ec.SECP256R1())
|
|
283
|
-
ephemeral_public = ephemeral_private.public_key()
|
|
284
|
-
shared_secret = ephemeral_private.exchange(ec.ECDH(), public_key)
|
|
285
|
-
derived_key = HKDF(
|
|
286
|
-
algorithm=hashes.SHA256(),
|
|
287
|
-
length=32,
|
|
288
|
-
salt=None,
|
|
289
|
-
info=b'handshake data'
|
|
290
|
-
).derive(shared_secret)
|
|
291
|
-
aesgcm = AESGCM(derived_key)
|
|
292
|
-
nonce = os.urandom(12)
|
|
293
|
-
plaintext_bytes = json.dumps(plaintext_dict).encode()
|
|
294
|
-
ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
|
|
295
|
-
ephemeral_public_bytes = ephemeral_public.public_bytes(
|
|
296
|
-
serialization.Encoding.X962,
|
|
297
|
-
serialization.PublicFormat.UncompressedPoint
|
|
298
|
-
)
|
|
299
|
-
return {
|
|
300
|
-
'ciphertext': base64.b64encode(ciphertext).decode(),
|
|
301
|
-
'nonce': base64.b64encode(nonce).decode(),
|
|
302
|
-
'ephemeralPublicKey': base64.b64encode(ephemeral_public_bytes).decode()
|
|
303
|
-
}
|
|
304
718
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
response.raise_for_status()
|
|
333
|
-
data = await response.json()
|
|
334
|
-
print(data["message"])
|
|
335
|
-
except aiohttp.ClientError as e:
|
|
336
|
-
print(f"Error sending request: {e}")
|
|
337
|
-
except Exception as e:
|
|
338
|
-
print(f"Unexpected error: {e}")
|
|
719
|
+
def _load_env(self) -> Dict[str, str]:
|
|
720
|
+
"""Load environment variables from .env file"""
|
|
721
|
+
env_path = self.config.credentials_path / ".env"
|
|
722
|
+
env_data = {}
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
with open(env_path, "r") as f:
|
|
726
|
+
for line in f:
|
|
727
|
+
line = line.strip()
|
|
728
|
+
if line and not line.startswith('#') and '=' in line:
|
|
729
|
+
key, value = line.split('=', 1)
|
|
730
|
+
env_data[key.strip()] = value.strip()
|
|
731
|
+
logger.info("Environment loaded successfully")
|
|
732
|
+
return env_data
|
|
733
|
+
except FileNotFoundError:
|
|
734
|
+
logger.error(f"Environment file not found at {env_path}")
|
|
735
|
+
return {}
|
|
736
|
+
except Exception as e:
|
|
737
|
+
logger.error(f"Error loading environment: {e}")
|
|
738
|
+
return {}
|
|
739
|
+
|
|
740
|
+
async def __aenter__(self):
|
|
741
|
+
await self._network_client.__aenter__()
|
|
742
|
+
return self
|
|
743
|
+
|
|
744
|
+
async def __aexit__(self, *args):
|
|
745
|
+
await self._network_client.__aexit__(*args)
|