KeyisBVMHost 0.0.0.0.2__tar.gz

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.
@@ -0,0 +1,40 @@
1
+ """
2
+ Copyright (C) 2024 KeyisB. All rights reserved.
3
+
4
+ Permission is hereby granted, free of charge, to any person
5
+ obtaining a copy of this software and associated documentation
6
+ files (the "Software"), to use the Software exclusively for
7
+ projects related to the GN or GW systems, including personal,
8
+ educational, and commercial purposes, subject to the following
9
+ conditions:
10
+
11
+ 1. Copying, modification, merging, publishing, distribution,
12
+ sublicensing, and/or selling copies of the Software are
13
+ strictly prohibited.
14
+ 2. The licensee may use the Software only in its original,
15
+ unmodified form.
16
+ 3. All copies or substantial portions of the Software must
17
+ remain unaltered and include this copyright notice and these terms of use.
18
+ 4. Use of the Software for projects not related to GN or
19
+ GW systems is strictly prohibited.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
22
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
23
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR
24
+ A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT
25
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
26
+ CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
27
+ OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
28
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
29
+ DEALINGS IN THE SOFTWARE.
30
+ """
31
+
32
+ from ._app import App
33
+ from KeyisBClient.gn import GNRequest, GNResponse, CORSObject, FileObject, TemplateObject
34
+
35
+
36
+
37
+
38
+
39
+
40
+ from ._client import AsyncClient
@@ -0,0 +1,390 @@
1
+ from GNServer import App as _App, GNRequest, GNResponse, AsyncClient
2
+ from typing import Optional
3
+ import datetime
4
+ import os
5
+ import re
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from typing import Iterable, Set, Tuple
11
+ import os
12
+ from cryptography.hazmat.primitives.ciphers import Cipher,algorithms
13
+ import subprocess
14
+ import sys
15
+ from KeyisBClient import Url
16
+ import asyncio
17
+
18
+
19
+ def _kill_process_by_port(port: int):
20
+
21
+ def _run(cmd: list[str]) -> Tuple[int, str, str]:
22
+ try:
23
+ p = subprocess.run(cmd, capture_output=True, text=True, check=False)
24
+ return p.returncode, p.stdout.strip(), p.stderr.strip()
25
+ except FileNotFoundError:
26
+ return 127, "", f"{cmd[0]} not found"
27
+
28
+ def pids_from_fuser(port: int, proto: str) -> Set[int]:
29
+ # fuser понимает 59367/udp и 59367/tcp (оба стека)
30
+ rc, out, _ = _run(["fuser", f"{port}/{proto}"])
31
+ if rc != 0:
32
+ return set()
33
+ return {int(x) for x in re.findall(r"\b(\d+)\b", out)}
34
+
35
+ def pids_from_lsof(port: int, proto: str) -> Set[int]:
36
+ # lsof -ti UDP:59367 / lsof -ti TCP:59367
37
+ rc, out, _ = _run(["lsof", "-ti", f"{proto.upper()}:{port}"])
38
+ if rc != 0 or not out:
39
+ return set()
40
+ return {int(x) for x in out.splitlines() if x.isdigit()}
41
+
42
+ def pids_from_ss(port: int, proto: str) -> Set[int]:
43
+ # ss -H -uapn 'sport = :59367' (UDP) / ss -H -tapn ... (TCP)
44
+ flag = "-uapn" if proto == "udp" else "-tapn"
45
+ rc, out, _ = _run(["ss", "-H", flag, f"sport = :{port}"])
46
+ if rc != 0 or not out:
47
+ return set()
48
+ pids = set()
49
+ for line in out.splitlines():
50
+ # ... users:(("python3",pid=1234,fd=55))
51
+ for m in re.finditer(r"pid=(\d+)", line):
52
+ pids.add(int(m.group(1)))
53
+ return pids
54
+
55
+ def find_pids(port: int, proto: str | None) -> Set[int]:
56
+ protos: Iterable[str] = [proto] if proto in ("udp","tcp") else ("udp","tcp")
57
+ found: Set[int] = set()
58
+ for pr in protos:
59
+ # Порядок: fuser -> ss -> lsof (достаточно любого)
60
+ found |= pids_from_fuser(port, pr)
61
+ found |= pids_from_ss(port, pr)
62
+ found |= pids_from_lsof(port, pr)
63
+ # не убивать себя
64
+ found.discard(os.getpid())
65
+ return found
66
+
67
+ def kill_pids(pids: Set[int]) -> None:
68
+ if not pids:
69
+ return
70
+ me = os.getpid()
71
+ for sig in (signal.SIGTERM, signal.SIGKILL):
72
+ still = set()
73
+ for pid in pids:
74
+ if pid == me:
75
+ continue
76
+ try:
77
+ os.kill(pid, sig)
78
+ except ProcessLookupError:
79
+ continue
80
+ except PermissionError:
81
+ print(f"[WARN] No permission to signal {pid}")
82
+ still.add(pid)
83
+ continue
84
+ still.add(pid)
85
+ if not still:
86
+ return
87
+ # подождём чуть-чуть
88
+ for _ in range(10):
89
+ live = set()
90
+ for pid in still:
91
+ try:
92
+ os.kill(pid, 0)
93
+ live.add(pid)
94
+ except ProcessLookupError:
95
+ pass
96
+ still = live
97
+ if not still:
98
+ return
99
+ time.sleep(0.1)
100
+
101
+ def wait_port_free(port: int, proto: str | None, timeout: float = 3.0) -> bool:
102
+ t0 = time.time()
103
+ while time.time() - t0 < timeout:
104
+ if not find_pids(port, proto):
105
+ return True
106
+ time.sleep(0.1)
107
+ return not find_pids(port, proto)
108
+
109
+ for proto in ("udp", "tcp"):
110
+ pids = find_pids(port, proto)
111
+
112
+
113
+ print(f"Гашу процессы на порту {port}: {sorted(pids)}")
114
+ kill_pids(pids)
115
+
116
+ if wait_port_free(port, proto):
117
+ print(f"Порт {port} освобождён.")
118
+ else:
119
+ print(f"[ERROR] Не удалось освободить порт {port}. Возможно, другой netns/служба перезапускает процесс.")
120
+
121
+
122
+
123
+
124
+ def _sign(k:bytes)->bytes:nonce=os.urandom(16);m=b"keyisb-vm-host-"+os.urandom(32);return nonce+Cipher(algorithms.ChaCha20(k[:32],nonce),None).encryptor().update(m)
125
+ def _verify(k:bytes,s:bytes)->bool:nonce,ct=s[:16],s[16:];return Cipher(algorithms.ChaCha20(k[:32],nonce),None).decryptor().update(ct).startswith(b"keyisb-vm-host-")
126
+
127
+ class App():
128
+ def __init__(self):
129
+ self._app = _App()
130
+
131
+ self._servers_start_files = {}
132
+
133
+ self._access_key: Optional[str] = None
134
+
135
+ self._default_venv_path = None
136
+
137
+ self._client = AsyncClient()
138
+
139
+ self.__add_routes()
140
+
141
+
142
+
143
+ def setAccessKey(self, key: str):
144
+ self._access_key = key
145
+
146
+ def setVenvPath(self, venv_path: str):
147
+ self._default_venv_path = venv_path
148
+
149
+ def addServerStartFile(self, name: str, file_path: str, port: Optional[int] = None, start_when_run: bool = False, venv_path: Optional[str] = None):
150
+ self._servers_start_files[name] = {"name": name, "path": file_path, "port": port, "start_when_run": start_when_run, "venv_path": venv_path if venv_path is not None else self._default_venv_path}
151
+
152
+ async def startLikeRun(self):
153
+ for server in self._servers_start_files:
154
+ if self._servers_start_files[server]["start_when_run"]:
155
+ asyncio.create_task(self.startServer(server))
156
+
157
+ async def startServer(self, name: str, timeout: float = 10.0) -> bool:
158
+ if name in self._servers_start_files:
159
+ server = self._servers_start_files[name]
160
+ path = server["path"]
161
+ venv_path = server["venv_path"]
162
+ if not os.path.isfile(path):
163
+ raise ValueError(f"Server start file not found: {path}")
164
+
165
+ if path.endswith('.py'):
166
+ if venv_path is not None:
167
+ if not os.path.isdir(venv_path):
168
+ raise ValueError(f"Virtual environment path not found: {venv_path}")
169
+ python_executable = os.path.join(venv_path, 'bin', 'python')
170
+ if not os.path.isfile(python_executable):
171
+ raise ValueError(f"Python executable not found in virtual environment: {python_executable}")
172
+ else:
173
+ python_executable = sys.executable
174
+
175
+ subprocess.Popen([python_executable, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
176
+
177
+ else:
178
+ subprocess.Popen([path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
179
+
180
+ return await self.checkServerHealth(name, timeout=timeout)
181
+ else:
182
+ raise ValueError(f"No server start file found with name: {name}")
183
+
184
+
185
+
186
+ async def _send_message_to_server(self, name: str, path: str, payload: Optional[dict] = None, timeout: float = 1.0) -> Optional[GNResponse]:
187
+ port = self._servers_start_files[name].get("port")
188
+ if port is None:
189
+ raise ValueError(f"No port specified for server: {name}")
190
+
191
+ if path.startswith('/'):
192
+ path = path[1:]
193
+
194
+ c = self._client.request(GNRequest('POST', Url(f'gn://127.0.0.1:{port}/!gn-vm-host/{path}'), payload=payload))
195
+
196
+ try:
197
+ result = await asyncio.wait_for(c, timeout=timeout)
198
+ except asyncio.TimeoutError:
199
+ result = None
200
+
201
+ return result
202
+
203
+ async def checkServerHealth(self, name: str, timeout: float = 3.0, interval=0.5):
204
+ end = asyncio.get_event_loop().time() + timeout
205
+ while asyncio.get_event_loop().time() < end:
206
+ result = await self._send_message_to_server(name, '/ping', timeout=timeout)
207
+ if result is not None:
208
+ return True
209
+ else:
210
+ await asyncio.sleep(interval)
211
+ return False
212
+
213
+
214
+
215
+ def stopServer(self, name: str):
216
+ if name in self._servers_start_files:
217
+ server = self._servers_start_files[name]
218
+ port = server["port"]
219
+ if port is not None:
220
+ _kill_process_by_port(port)
221
+ else:
222
+ raise ValueError(f"No port specified for server: {name}")
223
+ else:
224
+ raise ValueError(f"No server start file found with name: {name}")
225
+
226
+ async def reloadServer(self, name: str, timeout: float = 1):
227
+ if name in self._servers_start_files:
228
+ self.stopServer(name)
229
+ await asyncio.sleep(timeout)
230
+ return await self.startServer(name)
231
+ else:
232
+ raise ValueError(f"No server start file found with name: {name}")
233
+
234
+ def run(self,
235
+ host,
236
+ port,
237
+ cert_path: str,
238
+ key_path: str,
239
+ *,
240
+ idle_timeout: float = 20.0,
241
+ wait: bool = True
242
+ ):
243
+
244
+
245
+ asyncio.create_task(self.startLikeRun())
246
+
247
+ self._app.run(
248
+ host=host,
249
+ port=port,
250
+ cert_path=cert_path,
251
+ key_path=key_path,
252
+ idle_timeout=idle_timeout,
253
+ wait=wait
254
+ )
255
+
256
+
257
+ def __resolve_access_key(self, request: GNRequest) -> bool:
258
+ if self._access_key is None:
259
+ raise ValueError("Access key is not set.")
260
+
261
+ sign = request.cookies.get('vm-host-sign')
262
+
263
+ if sign is None:
264
+ return False
265
+
266
+ return _verify(self._access_key.encode(), sign)
267
+
268
+ def __add_routes(self):
269
+ @self._app.route('POST', '/ping')
270
+ async def ping_handler(request: GNRequest, name: Optional[str] = None, timeout: float = 3.0):
271
+ if not self.__resolve_access_key(request):
272
+ return None
273
+
274
+ if not name:
275
+ return GNResponse('ok', {'time': datetime.datetime.now(datetime.timezone.utc).isoformat()})
276
+ else:
277
+ try:
278
+ result = await self.checkServerHealth(name, timeout=timeout)
279
+ if result:
280
+ return GNResponse('ok', {'message': f'Server {name} is alive.'})
281
+ else:
282
+ return GNResponse('error', {'error': f'Server {name} is not responding.'})
283
+ except ValueError as e:
284
+ return GNResponse('error', {'error': str(e)})
285
+
286
+ @self._app.route('GET', '/servers')
287
+ async def list_servers_handler(request: GNRequest):
288
+ if not self.__resolve_access_key(request):
289
+ return None
290
+
291
+ servers_info = []
292
+ for server in self._servers_start_files.values():
293
+ servers_info.append({
294
+ 'name': server['name'],
295
+ 'port': server['port'],
296
+ 'start_when_run': server['start_when_run']
297
+ })
298
+ return GNResponse('ok', {'servers': servers_info})
299
+
300
+
301
+
302
+
303
+
304
+ @self._app.route('POST', '/start-server')
305
+ async def start_server_handler(request: GNRequest, name: str = ''):
306
+ if not self.__resolve_access_key(request):
307
+ return None
308
+
309
+ if not name:
310
+ return GNResponse('error', {'error': 'Server name is required.'})
311
+ try:
312
+ result = await self.startServer(name)
313
+ if result:
314
+ return GNResponse('ok', {'message': f'Server {name} started.'})
315
+ else:
316
+ return GNResponse('error', {'error': f'Server {name} failed to start within the timeout period.'})
317
+ except ValueError as e:
318
+ return GNResponse('error', {'error': str(e)})
319
+
320
+
321
+
322
+ @self._app.route('POST', '/reload-server')
323
+ async def reload_server_handler(request: GNRequest, name: str = '', timeout: float = 0.5):
324
+ if not self.__resolve_access_key(request):
325
+ return None
326
+
327
+ if not name:
328
+ return GNResponse('error', {'error': 'Server name is required.'})
329
+
330
+ try:
331
+ result = await self.reloadServer(name, timeout)
332
+ if result:
333
+ return GNResponse('ok', {'message': f'Server {name} reloaded.'})
334
+ else:
335
+ return GNResponse('error', {'error': f'Server {name} failed to reload within the timeout period.'})
336
+ except ValueError as e:
337
+ return GNResponse('error', {'error': str(e)})
338
+
339
+ @self._app.route('POST', '/stop-server')
340
+ async def stop_server_handler(request: GNRequest, name: str = ''):
341
+ if not self.__resolve_access_key(request):
342
+ return None
343
+
344
+ if not name:
345
+ return GNResponse('error', {'error': 'Server name is required.'})
346
+
347
+ try:
348
+ self.stopServer(name)
349
+ return GNResponse('ok', {'message': f'Server {name} stopped.'})
350
+ except ValueError as e:
351
+ return GNResponse('error', {'error': str(e)})
352
+
353
+ @self._app.route('POST', '/start-all-servers')
354
+ async def start_all_servers_handler(request: GNRequest):
355
+ if not self.__resolve_access_key(request):
356
+ return None
357
+
358
+ for server in self._servers_start_files:
359
+ try:
360
+ result = await self.startServer(server)
361
+ if not result:
362
+ return GNResponse('error', {'error': f'Server {server} failed to start within the timeout period.'})
363
+ except ValueError as e:
364
+ return GNResponse('error', {'error': str(e)})
365
+
366
+ @self._app.route('POST', '/stop-all-servers')
367
+ async def stop_all_servers_handler(request: GNRequest):
368
+ if not self.__resolve_access_key(request):
369
+ return None
370
+
371
+ for server in self._servers_start_files:
372
+ try:
373
+ self.stopServer(server)
374
+ except ValueError as e:
375
+ return GNResponse('error', {'error': str(e)})
376
+
377
+ return GNResponse('ok', {'message': 'All servers stopped.'})
378
+
379
+ @self._app.route('POST', '/reload-all-servers')
380
+ async def reload_all_servers_handler(request: GNRequest, timeout: float = 0.5):
381
+ if not self.__resolve_access_key(request):
382
+ return None
383
+
384
+ for server in self._servers_start_files:
385
+ try:
386
+ result = await self.reloadServer(server, timeout)
387
+ if not result:
388
+ return GNResponse('error', {'error': f'Server {server} failed to reload within the timeout period.'})
389
+ except ValueError as e:
390
+ return GNResponse('error', {'error': str(e)})
@@ -0,0 +1,600 @@
1
+
2
+ import os
3
+ import httpx
4
+ import asyncio
5
+ import typing as _typing
6
+ import logging as logging2
7
+ from typing import Union, List, Dict, Tuple, Optional
8
+ import datetime
9
+ logging2.basicConfig(level=logging2.INFO)
10
+
11
+ from KeyisBLogging import logging
12
+ from typing import Dict, List, Tuple, Optional, cast, AsyncGenerator, Callable
13
+ from itertools import count
14
+ from aioquic.asyncio.client import connect
15
+ from aioquic.asyncio.protocol import QuicConnectionProtocol
16
+ from aioquic.h3.connection import H3_ALPN, H3Connection
17
+ from aioquic.h3.events import DataReceived, DatagramReceived, H3Event, HeadersReceived
18
+ from aioquic.quic.configuration import QuicConfiguration
19
+ from aioquic.quic.events import QuicEvent
20
+
21
+
22
+ import time
23
+ import json, ssl, asyncio, struct, base64, hashlib
24
+ from typing import Any, Dict, Optional
25
+ import websockets
26
+
27
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
28
+ import os
29
+ import msgpack
30
+ import logging
31
+ from httpx import Request, Headers, URL
32
+ logging.basicConfig(level=logging.DEBUG)
33
+ logging.getLogger("websockets").setLevel(logging.DEBUG)
34
+
35
+ import KeyisBClient
36
+ from KeyisBClient import Url
37
+ httpxAsyncClient = httpx.AsyncClient(verify=KeyisBClient.ssl_gw_crt_path, timeout=200)
38
+
39
+ class GNExceptions:
40
+ class ConnectionError:
41
+ class openconnector():
42
+ """Ошибка подключения к серверу openconnector.gn"""
43
+
44
+ class connection(Exception):
45
+ def __init__(self, message="Ошибка подключения к серверу openconnector.gn. Сервер не найден."):
46
+ super().__init__(message)
47
+
48
+ class timeout(Exception):
49
+ def __init__(self, message="Ошибка подключения к серверу openconnector.gn. Проблема с сетью или сервер перегружен."):
50
+ super().__init__(message)
51
+
52
+ class data(Exception):
53
+ def __init__(self, message="Ошибка подключения к серверу openconnector.gn. Сервер не подтвердил подключение."):
54
+ super().__init__(message)
55
+
56
+ class dns_core():
57
+ """Ошибка подключения к серверу dns.core"""
58
+ class connection(Exception):
59
+ def __init__(self, message="Ошибка подключения к серверу dns.core Сервер не найден."):
60
+ super().__init__(message)
61
+
62
+ class timeout(Exception):
63
+ def __init__(self, message="Ошибка подключения к серверу dns.core Проблема с сетью или сервер перегружен"):
64
+ super().__init__(message)
65
+
66
+ class data(Exception):
67
+ def __init__(self, message="Ошибка подключения к серверу dns.core Сервер не подтвердил подключение."):
68
+ super().__init__(message)
69
+
70
+
71
+
72
+ class connector():
73
+ """Ошибка подключения к серверу <?>~connector.gn"""
74
+
75
+ class connection(Exception):
76
+ def __init__(self, message="Ошибка подключения к серверу <?>~connector.gn. Сервер не найден."):
77
+ super().__init__(message)
78
+
79
+ class timeout(Exception):
80
+ def __init__(self, message="Ошибка подключения к серверу <?>~connector.gn. Проблема с сетью или сервер перегружен"):
81
+ super().__init__(message)
82
+
83
+ class data(Exception):
84
+ def __init__(self, message="Ошибка подключения к серверу <?>~connector.gn. Сервер не подтвердил подключение."):
85
+ super().__init__(message)
86
+
87
+
88
+
89
+ from KeyisBClient.gn import GNRequest, GNResponse, GNProtocol
90
+
91
+
92
+
93
+
94
+ class AsyncClient:
95
+ def __init__(self):
96
+ self.__dns_core__ipv4 = '51.250.85.38:52943'
97
+ self.__dns_gn__ipv4 = None
98
+
99
+ self.__user = {}
100
+ self.__current_session = {}
101
+ self.__request_callbacks = {}
102
+ self.__response_callbacks = {}
103
+
104
+ self._client: QuicClient = QuicClient()
105
+
106
+ self._active_connections: Dict[str, Any] = {}
107
+
108
+ async def _getCoreDNS(self, domain: str):
109
+ try:
110
+ if self.__dns_gn__ipv4 is None:
111
+ r1 = await httpxAsyncClient.request('GET', f'https://{self.__dns_core__ipv4}/gn/getIp?d=dns.gn')
112
+ if r1.status_code != 200:
113
+ raise GNExceptions.ConnectionError.dns_core.data
114
+ r1_data = r1.json()
115
+ self.__dns_gn__ipv4 = r1_data['ip'] + ':' + str(r1_data['port'])
116
+
117
+
118
+ r2 = await httpxAsyncClient.request('GET', f'https://{self.__dns_gn__ipv4}/gn/getIp?d={domain}')
119
+ except httpx.TimeoutException:
120
+ raise GNExceptions.ConnectionError.dns_core.timeout
121
+ except:
122
+ raise GNExceptions.ConnectionError.dns_core.connection
123
+
124
+ if r2.status_code != 200:
125
+ raise GNExceptions.ConnectionError.dns_core.data
126
+
127
+ r2_data = r2.json()
128
+
129
+ return r2_data
130
+
131
+ def addRequestCallback(self, callback: Callable, name: str):
132
+ self.__request_callbacks[name] = callback
133
+
134
+ def addResponseCallback(self, callback: Callable, name: str):
135
+ self.__response_callbacks[name] = callback
136
+
137
+
138
+ async def connect(self, domain: str):
139
+ if domain in self._active_connections:
140
+ return
141
+
142
+ data = await self._getCoreDNS(domain)
143
+ # подключаемся к серверу gn-proxy
144
+ await self._client.connect(data['ip'], data['port'])
145
+ self._active_connections[domain] = 'active'
146
+
147
+ async def disconnect(self):
148
+ await self._client.disconnect()
149
+
150
+
151
+ def _return_token(self, bigToken: str, s: bool = True) -> str:
152
+ return bigToken[:128] if s else bigToken[128:]
153
+
154
+
155
+ async def request(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]) -> GNResponse:
156
+ """
157
+ Build and send a async request.
158
+
159
+ ```python
160
+ gnAsyncClient = KeyisBClient.AsyncClient()
161
+ async def func():
162
+ response = await gnAsyncClient.request(GNRequest('GET', Url('gn://example.com/example')))
163
+ command = response.command()
164
+ data = response.payload()
165
+ ```
166
+ """
167
+
168
+
169
+
170
+
171
+
172
+
173
+ if isinstance(request, GNRequest):
174
+
175
+
176
+ if request.url.hostname not in self._active_connections:
177
+ await self.connect(request.url.hostname)
178
+
179
+
180
+
181
+ for f in self.__request_callbacks.values():
182
+ asyncio.create_task(f(request))
183
+
184
+ r = await self._client.asyncRequest(request)
185
+
186
+ for f in self.__response_callbacks.values():
187
+ asyncio.create_task(f(r))
188
+
189
+ return r
190
+
191
+ # else:
192
+ # async def wrapped(request) -> AsyncGenerator[GNRequest, None]:
193
+ # async for req in request:
194
+ # if req.gn_protocol is None:
195
+ # req.setGNProtocol(self.__current_session['protocols'][0])
196
+ # req._stream = True
197
+
198
+ # for f in self.__request_callbacks.values():
199
+ # asyncio.create_task(f(req))
200
+
201
+ # yield req
202
+ # r = await self.client.asyncRequest(wrapped(request))
203
+
204
+ # for f in self.__response_callbacks.values():
205
+ # asyncio.create_task(f(r))
206
+
207
+ # return r
208
+
209
+ async def requestStream(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]) -> AsyncGenerator[GNResponse, None]:
210
+ """
211
+ Build and send a async request.
212
+ """
213
+ if isinstance(request, GNRequest):
214
+ if request.gn_protocol is None:
215
+ request.setGNProtocol(self.__current_session['protocols'][0])
216
+
217
+ for f in self.__request_callbacks.values():
218
+ asyncio.create_task(f(request))
219
+
220
+ async for response in self.client.asyncRequestStream(request):
221
+
222
+ for f in self.__response_callbacks.values():
223
+ asyncio.create_task(f(response))
224
+
225
+ yield response
226
+ else:
227
+ async def wrapped(request) -> AsyncGenerator[GNRequest, None]:
228
+ async for req in request:
229
+ if req.gn_protocol is None:
230
+ req.setGNProtocol(self.__current_session['protocols'][0])
231
+
232
+ for f in self.__request_callbacks.values():
233
+ asyncio.create_task(f(req))
234
+
235
+ req._stream = True
236
+ yield req
237
+ async for response in self.client.asyncRequestStream(wrapped(request)):
238
+
239
+ for f in self.__response_callbacks.values():
240
+ asyncio.create_task(f(response))
241
+
242
+ yield response
243
+
244
+
245
+
246
+
247
+
248
+
249
+
250
+
251
+
252
+ # gn:quik
253
+
254
+ from aioquic.asyncio.protocol import QuicConnectionProtocol
255
+ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
256
+ from aioquic.quic.connection import END_STATES
257
+ import asyncio
258
+ from collections import deque
259
+ from typing import Dict, Deque, Tuple, Optional, List
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Raw QUIC client with a dedicated SYS‑stream that consumes ~90 % CWND
263
+ # ---------------------------------------------------------------------------
264
+ # Основная идея:
265
+ # • Один постоянный bidirectional stream (sys_stream_id) используется для
266
+ # служебных сообщений.
267
+ # • Остальные запросы открываются в обычных потоках (user streams).
268
+ # • Отправка данных идёт через собственный scheduler: берём 9 «квантов» из
269
+ # SYS‑очереди и 1 квант из USER‑очереди, пока есть SYS‑данные.
270
+ # • Таким образом SYS‑канал получает ~90 % пропускной способности.
271
+ # ---------------------------------------------------------------------------
272
+
273
+ import asyncio
274
+ import time
275
+ from collections import deque
276
+ from dataclasses import dataclass
277
+ from itertools import count
278
+ from typing import Deque, Dict, Optional, Tuple, Union
279
+
280
+ from aioquic.quic.configuration import QuicConfiguration
281
+ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
282
+
283
+
284
+ class RawQuicClient(QuicConnectionProtocol):
285
+ """Чистый‑QUIC клиент с приоритизированным SYS‑каналом + стриминг."""
286
+
287
+ SYS_RATIO_NUM = 9 # SYS 9/10
288
+ SYS_RATIO_DEN = 10
289
+ KEEPALIVE_INTERVAL = 10 # сек
290
+ KEEPALIVE_IDLE_TRIGGER = 30 # сек
291
+
292
+ # ────────────────────────────────────────────────────────────────── init ─┐
293
+ def __init__(self, *args, **kwargs):
294
+ super().__init__(*args, **kwargs)
295
+
296
+ self._sys_stream_id: Optional[int] = None
297
+ self._queue_sys: Deque[Tuple[int, bytes, bool]] = deque()
298
+ self._queue_user: Deque[Tuple[int, bytes, bool]] = deque()
299
+
300
+ # <‑‑ Future | Queue[bytes | None]
301
+ self._inflight: Dict[int, Union[asyncio.Future, asyncio.Queue[Optional[GNResponse]]]] = {}
302
+ self._inflight_streams: Dict[int, bytearray] = {}
303
+ self._sys_inflight: Dict[int, asyncio.Future] = {}
304
+ self._buffer: Dict[Union[int, str], bytearray] = {}
305
+
306
+ self._sys_budget = self.SYS_RATIO_NUM
307
+ self._sys_id_gen = count(1) # int64 message‑id generator
308
+
309
+ self._last_activity = time.time()
310
+ self._running = True
311
+ self._ping_id_gen = count(1) # int64 ping‑id generator
312
+ asyncio.create_task(self._keepalive_loop())
313
+
314
+ # ───────────────────────────────────────── private helpers ─┤
315
+ def _activity(self):
316
+ self._last_activity = time.time()
317
+
318
+ async def _keepalive_loop(self):
319
+ while self._running:
320
+ await asyncio.sleep(self.KEEPALIVE_INTERVAL)
321
+ idle_time = time.time() - self._last_activity
322
+ if idle_time > self.KEEPALIVE_IDLE_TRIGGER:
323
+ self._quic.send_ping(next(self._ping_id_gen))
324
+ self.transmit()
325
+ self._last_activity = time.time()
326
+
327
+ def stop(self):
328
+ self._running = False
329
+
330
+ # ───────────────────────────────────────────── events ─┤
331
+ def quic_event_received(self, event: QuicEvent) -> None: # noqa: C901
332
+ # ─── DATA ───────────────────────────────────────────
333
+ if isinstance(event, StreamDataReceived):
334
+ #print(event)
335
+ # SYS поток
336
+ if event.stream_id == self._sys_stream_id:
337
+ buf = self._buffer.setdefault("sys", bytearray())
338
+ buf.extend(event.data)
339
+ while True:
340
+ if len(buf) < 12:
341
+ break
342
+ msg_id = int.from_bytes(buf[:8], "little")
343
+ size = int.from_bytes(buf[8:12], "little")
344
+ if len(buf) < 12 + size:
345
+ break
346
+ payload = bytes(buf[12 : 12 + size])
347
+ del buf[: 12 + size]
348
+ fut = self._sys_inflight.pop(msg_id, None) if msg_id else None
349
+ if fut and not fut.done():
350
+ fut.set_result(payload)
351
+ # USER поток
352
+ else:
353
+ handler = self._inflight.get(event.stream_id)
354
+ if handler is None:
355
+ return
356
+
357
+ # Чтение в зависимости от режима
358
+ if isinstance(handler, asyncio.Queue): # стрим от сервера
359
+ # получаем байты
360
+
361
+ buf = self._buffer.setdefault(event.stream_id, bytearray())
362
+ buf.extend(event.data)
363
+
364
+ if len(buf) < 8: # не дошел даже frame пакета
365
+ return
366
+
367
+ # получаем длинну пакета
368
+ mode, stream, lenght = GNResponse.type(buf)
369
+
370
+ if mode != 4: # не наш пакет
371
+ self._buffer.pop(event.stream_id)
372
+ return
373
+
374
+ if not stream: # клиент просил стрим, а сервер прислал один пакет
375
+ self._buffer.pop(event.stream_id)
376
+ return
377
+
378
+ # читаем пакет
379
+ if len(buf) < lenght: # если пакет не весь пришел, пропускаем
380
+ return
381
+
382
+ # пакет пришел весь
383
+
384
+ # берем пакет
385
+ data = buf[:lenght]
386
+
387
+ # удаляем его из буфера
388
+ del buf[:lenght]
389
+
390
+
391
+ r = GNResponse.deserialize(data, 2)
392
+ handler.put_nowait(r)
393
+ if event.end_stream:
394
+ handler.put_nowait(None)
395
+ self._buffer.pop(event.stream_id)
396
+ self._inflight.pop(event.stream_id, None)
397
+
398
+
399
+
400
+ else: # Future
401
+ buf = self._buffer.setdefault(event.stream_id, bytearray())
402
+ buf.extend(event.data)
403
+ if event.end_stream:
404
+ self._inflight.pop(event.stream_id, None)
405
+ data = bytes(self._buffer.pop(event.stream_id, b""))
406
+ if not handler.done():
407
+ handler.set_result(data)
408
+
409
+ # ─── RESET ──────────────────────────────────────────
410
+ elif isinstance(event, StreamReset):
411
+ handler = self._inflight.pop(event.stream_id, None) or self._sys_inflight.pop(
412
+ event.stream_id, None
413
+ )
414
+ if handler is None:
415
+ return
416
+ if isinstance(handler, asyncio.Queue):
417
+ handler.put_nowait(None)
418
+ else:
419
+ if not handler.done():
420
+ handler.set_exception(RuntimeError("stream reset"))
421
+
422
+ # ─────────────────────────────────────────── scheduler ─┤
423
+ def _enqueue(self, sid: int, blob: bytes, end_stream: bool, is_sys: bool):
424
+ (self._queue_sys if is_sys else self._queue_user).append((sid, blob, end_stream))
425
+
426
+ def _schedule_flush(self):
427
+ while (self._queue_sys or self._queue_user) and self._quic._close_event is None:
428
+ q = None
429
+ if self._queue_sys and (self._sys_budget > 0 or not self._queue_user):
430
+ q = self._queue_sys
431
+ self._sys_budget -= 1
432
+ elif self._queue_user:
433
+ q = self._queue_user
434
+ self._sys_budget = self.SYS_RATIO_NUM
435
+ if q is None:
436
+ break
437
+ sid, blob, end_stream = q.popleft()
438
+ self._quic.send_stream_data(sid, blob, end_stream=end_stream)
439
+ self.transmit()
440
+ self._activity()
441
+
442
+ # ─────────────────────────────────────────── public API ─┤
443
+ async def ensure_sys_stream(self):
444
+ if self._sys_stream_id is None:
445
+ self._sys_stream_id = self._quic.get_next_available_stream_id()
446
+ self._enqueue(self._sys_stream_id, b"", False, True) # dummy
447
+ self._schedule_flush()
448
+
449
+ async def send_sys(self, request: GNRequest, response: bool = False) -> Optional[bytes]:
450
+ await self.ensure_sys_stream()
451
+ if response:
452
+ msg_id = next(self._sys_id_gen)
453
+ blob = request.serialize(2)
454
+ payload = (
455
+ msg_id.to_bytes(8, "little") + len(blob).to_bytes(4, "little") + blob
456
+ )
457
+ fut = asyncio.get_running_loop().create_future()
458
+ self._sys_inflight[msg_id] = fut
459
+ self._enqueue(self._sys_stream_id, payload, False, True)
460
+ self._schedule_flush()
461
+ return await fut
462
+ payload = (0).to_bytes(8, "little") + request.serialize(2)
463
+ self._enqueue(self._sys_stream_id, payload, False, True)
464
+ self._schedule_flush()
465
+ return None
466
+
467
+ async def request(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]):
468
+ if isinstance(request, GNRequest):
469
+ blob = request.serialize(2)
470
+ sid = self._quic.get_next_available_stream_id()
471
+ self._enqueue(sid, blob, True, False)
472
+ self._schedule_flush()
473
+
474
+
475
+ fut = asyncio.get_running_loop().create_future()
476
+ self._inflight[sid] = fut
477
+ return await fut
478
+
479
+ else:
480
+ sid = self._quic.get_next_available_stream_id()
481
+ #if sid in self._quic._streams and not self._quic._streams[sid].is_finished:
482
+
483
+ async def _stream_sender(sid, request: AsyncGenerator[GNRequest, Any]):
484
+ _last = None
485
+ async for req in request:
486
+ _last = req
487
+ blob = req.serialize(2)
488
+ self._enqueue(sid, blob, False, False)
489
+
490
+
491
+ self._schedule_flush()
492
+
493
+ print(f'Отправлен stream запрос {req}')
494
+
495
+
496
+ _last.setPayload(None)
497
+ _last.setMethod('gn:end-stream')
498
+ blob = _last.serialize(2)
499
+ self._enqueue(sid, blob, True, False)
500
+ self._schedule_flush()
501
+
502
+ asyncio.create_task(_stream_sender(sid, request))
503
+
504
+
505
+ fut = asyncio.get_running_loop().create_future()
506
+ self._inflight[sid] = fut
507
+ return await fut
508
+
509
+ async def requestStream(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]) -> asyncio.Queue[GNResponse]:
510
+ if isinstance(request, GNRequest):
511
+ blob = request.serialize(2)
512
+ sid = self._quic.get_next_available_stream_id()
513
+ self._enqueue(sid, blob, False, False)
514
+ self._schedule_flush()
515
+
516
+
517
+ q = asyncio.Queue()
518
+ self._inflight[sid] = q
519
+ return q
520
+
521
+ else:
522
+ sid = self._quic.get_next_available_stream_id()
523
+
524
+ async def _stream_sender(sid, request: AsyncGenerator[GNRequest, Any]):
525
+ _last = None
526
+ async for req in request:
527
+ _last = req
528
+ blob = req.serialize(2)
529
+ self._enqueue(sid, blob, False, False)
530
+
531
+
532
+ self._schedule_flush()
533
+
534
+ print(f'Отправлен stream запрос {req}')
535
+
536
+
537
+ _last.setPayload(None)
538
+ _last.setMethod('gn:end-stream')
539
+ blob = _last.serialize(2)
540
+ self._enqueue(sid, blob, True, False)
541
+ self._schedule_flush()
542
+
543
+ asyncio.create_task(_stream_sender(sid, request))
544
+
545
+
546
+ q = asyncio.Queue()
547
+ self._inflight[sid] = q
548
+ return q
549
+
550
+
551
+
552
+ class QuicClient:
553
+ """Обёртка‑фасад над RawQuicClient."""
554
+
555
+ def __init__(self):
556
+ self._quik_core: Optional[RawQuicClient] = None
557
+ self._client_cm = None
558
+
559
+ async def connect(self, ip: str, port: int):
560
+ cfg = QuicConfiguration(is_client=True, alpn_protocols=["gn:backend"])
561
+ cfg.load_verify_locations(KeyisBClient.ssl_gw_crt_path)
562
+
563
+ self._client_cm = connect(
564
+ ip,
565
+ port,
566
+ configuration=cfg,
567
+ create_protocol=RawQuicClient,
568
+ wait_connected=True,
569
+ )
570
+ self._quik_core = await self._client_cm.__aenter__()
571
+
572
+ async def disconnect(self):
573
+ self._quik_core.close()
574
+ await self._quik_core.wait_closed()
575
+ self._quik_core = None
576
+
577
+ def syncRequest(self, request: GNRequest):
578
+ return asyncio.get_event_loop().run_until_complete(self.asyncRequest(request))
579
+
580
+ async def asyncRequest(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]) -> GNResponse:
581
+ if self._quik_core is None:
582
+ raise RuntimeError("Not connected")
583
+
584
+ resp = await self._quik_core.request(request)
585
+ return GNResponse.deserialize(resp, 2)
586
+
587
+ async def asyncRequestStream(self, request: Union[GNRequest, AsyncGenerator[GNRequest, Any]]) -> AsyncGenerator[GNResponse, None]:
588
+
589
+ if self._quik_core is None:
590
+ raise RuntimeError("Not connected")
591
+
592
+ queue = await self._quik_core.requestStream(request)
593
+
594
+ while True:
595
+ chunk = await queue.get()
596
+ if chunk is None or chunk.command == 'gn:end-stream':
597
+ break
598
+ yield chunk
599
+
600
+
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: KeyisBVMHost
3
+ Version: 0.0.0.0.2
4
+ Summary: KeyisBVMHost
5
+ Home-page: https://github.com/KeyisB/libs/tree/main/KeyisBVMHost
6
+ Author: KeyisB
7
+ Author-email: keyisb.pip@gmail.com
8
+ License: MMB License v1.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/plain
13
+ License-File: LICENSE
14
+ Requires-Dist: aioquic
15
+ Requires-Dist: anyio
16
+ Requires-Dist: KeyisBClient
17
+ Requires-Dist: aiofiles
18
+ Requires-Dist: uvloop
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: license
26
+ Dynamic: license-file
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
30
+
31
+ GW and MMB Project libraries
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ setup.py
4
+ KeyisBVMHost/LICENSE
5
+ KeyisBVMHost/mmbConfig.json
6
+ KeyisBVMHost/KeyisBVMHost/__init__.py
7
+ KeyisBVMHost/KeyisBVMHost/_app.py
8
+ KeyisBVMHost/KeyisBVMHost/_client.py
9
+ KeyisBVMHost/KeyisBVMHost.egg-info/PKG-INFO
10
+ KeyisBVMHost/KeyisBVMHost.egg-info/SOURCES.txt
11
+ KeyisBVMHost/KeyisBVMHost.egg-info/dependency_links.txt
12
+ KeyisBVMHost/KeyisBVMHost.egg-info/requires.txt
13
+ KeyisBVMHost/KeyisBVMHost.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ aioquic
2
+ anyio
3
+ KeyisBClient
4
+ aiofiles
5
+ uvloop
@@ -0,0 +1,28 @@
1
+ Copyright (C) 2024 KeyisB. All rights reserved.
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated
5
+ 1. Copying, modification, merging, publishing, distribution,
6
+ sublicensing, and/or selling copies of the Software are
7
+ strictly prohibited.documentation
8
+ files (the "Software"), to use the Software exclusively for
9
+ projects related to the GN or GW systems, including personal,
10
+ educational, and commercial purposes, subject to the following
11
+ conditions:
12
+
13
+ 2. The licensee may use the Software only in its original,
14
+ unmodified form.
15
+ 3. All copies or substantial portions of the Software must
16
+ remain unaltered and include this copyright notice and these terms of use.
17
+ 4. Use of the Software for projects not related to GN or
18
+ GW systems is strictly prohibited.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
21
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
22
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR
23
+ A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT
24
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
25
+ CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
26
+ OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
27
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
28
+ DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,9 @@
1
+ {
2
+ "requirements": [
3
+ "aioquic",
4
+ "anyio",
5
+ "KeyisBClient",
6
+ "aiofiles",
7
+ "uvloop"
8
+ ]
9
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 KeyisB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ graft KeyisBVMHost
2
+ global-exclude __pycache__ *.py[cod] .DS_Store
3
+ include LICENSE
4
+ include README.md
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: KeyisBVMHost
3
+ Version: 0.0.0.0.2
4
+ Summary: KeyisBVMHost
5
+ Home-page: https://github.com/KeyisB/libs/tree/main/KeyisBVMHost
6
+ Author: KeyisB
7
+ Author-email: keyisb.pip@gmail.com
8
+ License: MMB License v1.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/plain
13
+ License-File: LICENSE
14
+ Requires-Dist: aioquic
15
+ Requires-Dist: anyio
16
+ Requires-Dist: KeyisBClient
17
+ Requires-Dist: aiofiles
18
+ Requires-Dist: uvloop
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: license
26
+ Dynamic: license-file
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
30
+
31
+ GW and MMB Project libraries
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from setuptools import setup
2
+
3
+ name = 'KeyisBVMHost'
4
+ filesName = 'KeyisBVMHost'
5
+
6
+ setup(
7
+ name=name,
8
+ version='0.0.0.0.2',
9
+ author="KeyisB",
10
+ author_email="keyisb.pip@gmail.com",
11
+ description=name,
12
+ long_description = 'GW and MMB Project libraries',
13
+ long_description_content_type= 'text/plain',
14
+ url=f"https://github.com/KeyisB/libs/tree/main/{name}",
15
+ include_package_data=True,
16
+ package_data = {}, # type: ignore
17
+ package_dir={'': f'{filesName}'.replace('-','_')},
18
+ classifiers=[
19
+ "Programming Language :: Python :: 3",
20
+ "Operating System :: OS Independent",
21
+ ],
22
+ python_requires='>=3.12',
23
+ license="MMB License v1.0",
24
+ install_requires = ['aioquic', 'anyio', 'KeyisBClient', 'aiofiles', 'uvloop'],
25
+ )