standkit 0.3.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.
@@ -0,0 +1,386 @@
1
+ """
2
+ HTTP/RPC-сервер агента — STDLIB-ONLY (http.server, ssl), никаких сторонних
3
+ веб-фреймворков намеренно (агент должен разворачиваться на голом хосте
4
+ стенда без pip install чего-либо, кроме самого standkit).
5
+
6
+ Эндпоинты (JSON):
7
+ GET /stands — список стендов реестра агента (read)
8
+ GET /stand/{name}/status — StandStatus.to_dict() (read)
9
+ POST /stand/{name}/start — запустить стенд (transport=local) (control)
10
+ POST /stand/{name}/stop — остановить стенд (control)
11
+ POST /stand/{name}/restart — перезапустить стенд (control)
12
+ GET /stand/{name}/logs?n=100 — последние n строк лога (read)
13
+
14
+ СЕКЬЮРИТИ-МОДЕЛЬ (см. также standkit_agent/security.py, standkit_agent/audit.py):
15
+ - Транспорт: опциональный TLS (ssl.SSLContext, минимум TLS 1.2), опциональный
16
+ mTLS (verify_mode=CERT_REQUIRED против --tls-client-ca). Управляется
17
+ вызывающей стороной run_server(); fail-closed bind-проверка — ДО открытия
18
+ сокета, см. security.validate_bind_security().
19
+ - Аутентификация: заголовок ``Authorization: Bearer <token>``, сравнение —
20
+ ТОЛЬКО через hmac.compare_digest (security.Authenticator). Два скоупа:
21
+ control (start/stop/restart + всё read) и readonly (только read).
22
+ - Rate limiting/lockout: security.LockoutTracker — N неудачных
23
+ аутентификаций подряд с одного IP в окне → 429 до окончания окна.
24
+ - Аудит: каждый запрос (успех/отказ/ошибка) пишется в append-only
25
+ JSON-lines лог (audit.audit_event) — без токенов и секретов.
26
+ - Input-hardening: лимит тела запроса, кап на n логов, таймаут сокета,
27
+ валидация имени стенда, 400 на некорректный ввод (не 500/креш).
28
+
29
+ TODO(следующая итерация):
30
+ - CORS-заголовки, если GUI когда-либо будет ходить из браузера, а не из
31
+ десктоп-приложения.
32
+ - Полноценный роутинг (сейчас — простое сопоставление префиксов пути).
33
+ - CN→scope маппинг для mTLS (сейчас скоуп всегда определяется токеном,
34
+ CN клиентского сертификата используется только для идентичности в аудите).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ import ssl
42
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
43
+ from pathlib import Path
44
+ from typing import Optional
45
+ from urllib.parse import urlparse, parse_qs
46
+
47
+ from standkit import health, lifecycle
48
+ from standkit.registry import Registry, RegistryError
49
+ from standkit_agent import audit as _audit
50
+ from standkit_agent import security as _security
51
+ from standkit_agent.security import (
52
+ Authenticator,
53
+ LockoutTracker,
54
+ DEFAULT_MAX_BODY_BYTES,
55
+ DEFAULT_MAX_LOGS_N,
56
+ DEFAULT_SOCKET_TIMEOUT,
57
+ )
58
+
59
+ _STAND_ACTION_RE = re.compile(r"^/stand/(?P<name>[^/]+)/(?P<action>start|stop|restart|status|logs)$")
60
+
61
+
62
+ class AgentAuthError(Exception):
63
+ """Ошибка аутентификации запроса к агенту."""
64
+
65
+
66
+ def make_handler(
67
+ registry: Registry,
68
+ authenticator: Authenticator,
69
+ *,
70
+ run_dir: Optional[Path] = None,
71
+ log_dir: Optional[Path] = None,
72
+ lockout: Optional[LockoutTracker] = None,
73
+ audit_logger=None,
74
+ max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
75
+ max_logs_n: int = DEFAULT_MAX_LOGS_N,
76
+ ) -> type:
77
+ """
78
+ Фабрика класса-обработчика запросов с "захваченными" зависимостями
79
+ (реестр, аутентификатор, lockout, аудит-логгер) — BaseHTTPRequestHandler
80
+ не поддерживает конструктор с доп. аргументами напрямую, поэтому
81
+ вложенный класс.
82
+ """
83
+ lockout = lockout or LockoutTracker()
84
+
85
+ class Handler(BaseHTTPRequestHandler):
86
+ server_version = "standkit-agent/0.2"
87
+ # Таймаут на соединение (DoS-hardening) — socketserver.StreamRequestHandler
88
+ # применяет его к сокету в setup(), если атрибут не None.
89
+ timeout = DEFAULT_SOCKET_TIMEOUT
90
+
91
+ # --- вспомогательные ---
92
+
93
+ def _client_ip(self) -> str:
94
+ return self.client_address[0] if self.client_address else "-"
95
+
96
+ def _peer_cn(self) -> Optional[str]:
97
+ """CN клиентского сертификата, если соединение по mTLS (для аудита)."""
98
+ conn = getattr(self, "connection", None)
99
+ if conn is None or not isinstance(conn, ssl.SSLSocket):
100
+ return None
101
+ try:
102
+ return _security.peer_identity_from_cert(conn.getpeercert())
103
+ except Exception:
104
+ return None
105
+
106
+ def _audit(self, *, identity: str, action: str, result: str, code: int) -> None:
107
+ if audit_logger is None:
108
+ return
109
+ _audit.audit_event(
110
+ audit_logger,
111
+ src_ip=self._client_ip(),
112
+ identity=identity or "-",
113
+ method=self.command,
114
+ path=self.path,
115
+ action=action,
116
+ result=result,
117
+ code=code,
118
+ )
119
+
120
+ def _bearer_token(self) -> Optional[str]:
121
+ auth = self.headers.get("Authorization", "")
122
+ if not auth.startswith("Bearer "):
123
+ return None
124
+ return auth[len("Bearer "):].strip() or None
125
+
126
+ def _authenticate(self, action: str) -> Optional[str]:
127
+ """
128
+ Полный цикл: lockout-проверка → извлечение токена → скоуп → проверка
129
+ прав на действие → аудит отказов/локов → успех регистрируется
130
+ (сброс счётчика неудач для этого IP).
131
+
132
+ Возвращает скоуп ("control"/"readonly") при успехе, либо ``None``
133
+ (и уже отправляет ответ клиенту — 429/401/403 — и пишет аудит) при
134
+ отказе.
135
+ """
136
+ ip = self._client_ip()
137
+ cn = self._peer_cn()
138
+
139
+ if lockout.is_locked(ip):
140
+ self._send_json(429, {"error": "too many failed attempts, try later"})
141
+ self._audit(identity=cn or "-", action=action, result="denied", code=429)
142
+ return None
143
+
144
+ token = self._bearer_token()
145
+ scope = authenticator.check(token)
146
+ identity = scope or cn or "-"
147
+
148
+ if scope is None:
149
+ lockout.record_failure(ip)
150
+ self._send_json(401, {"error": "unauthorized"})
151
+ self._audit(identity=identity, action=action, result="denied", code=401)
152
+ return None
153
+
154
+ if not Authenticator.scope_allows(scope, action):
155
+ # Валидный токен, но недостаточно прав — НЕ засчитывается как
156
+ # неудача аутентификации (это не brute-force ситуация), но в
157
+ # аудит попадает как отказ.
158
+ lockout.record_success(ip)
159
+ self._send_json(403, {"error": "forbidden: insufficient scope"})
160
+ self._audit(identity=identity, action=action, result="denied", code=403)
161
+ return None
162
+
163
+ lockout.record_success(ip)
164
+ return scope
165
+
166
+ def _send_json(self, code: int, payload: dict) -> None:
167
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
168
+ self.send_response(code)
169
+ self.send_header("Content-Type", "application/json; charset=utf-8")
170
+ self.send_header("Content-Length", str(len(body)))
171
+ self.end_headers()
172
+ try:
173
+ self.wfile.write(body)
174
+ except (BrokenPipeError, ConnectionResetError):
175
+ pass
176
+
177
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003 - сигнатура BaseHTTPRequestHandler
178
+ # Приглушаем стандартный access-лог http.server в stderr — у
179
+ # агента есть собственный структурный аудит-лог (см. audit.py).
180
+ pass
181
+
182
+ def _method_not_allowed(self) -> None:
183
+ self._send_json(405, {"error": "method not allowed"})
184
+ self._audit(identity="-", action="unknown", result="denied", code=405)
185
+
186
+ do_PUT = _method_not_allowed
187
+ do_DELETE = _method_not_allowed
188
+ do_PATCH = _method_not_allowed
189
+
190
+ # --- маршрутизация ---
191
+
192
+ def do_GET(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
193
+ parsed = urlparse(self.path)
194
+ path = parsed.path
195
+
196
+ if path == "/stands":
197
+ scope = self._authenticate("stands")
198
+ if scope is None:
199
+ return
200
+ self._send_json(200, {"stands": [s.name for s in registry.list()]})
201
+ self._audit(identity=scope, action="stands", result="ok", code=200)
202
+ return
203
+
204
+ m = _STAND_ACTION_RE.match(path)
205
+ if m and m.group("action") == "status":
206
+ name = m.group("name")
207
+ scope = self._authenticate("status")
208
+ if scope is None:
209
+ return
210
+ if not _security.validate_stand_name(name):
211
+ self._send_json(400, {"error": "invalid stand name"})
212
+ self._audit(identity=scope, action="status", result="error", code=400)
213
+ return
214
+ self._handle_status(name, scope)
215
+ return
216
+ if m and m.group("action") == "logs":
217
+ name = m.group("name")
218
+ scope = self._authenticate("logs")
219
+ if scope is None:
220
+ return
221
+ if not _security.validate_stand_name(name):
222
+ self._send_json(400, {"error": "invalid stand name"})
223
+ self._audit(identity=scope, action="logs", result="error", code=400)
224
+ return
225
+ qs = parse_qs(parsed.query)
226
+ raw_n = qs.get("n", ["100"])[0]
227
+ try:
228
+ n = _security.clamp_logs_n(raw_n, max_n=max_logs_n)
229
+ except (ValueError, TypeError):
230
+ self._send_json(400, {"error": "invalid n"})
231
+ self._audit(identity=scope, action="logs", result="error", code=400)
232
+ return
233
+ self._handle_logs(name, n, scope)
234
+ return
235
+
236
+ self._send_json(404, {"error": "not found"})
237
+
238
+ def do_POST(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
239
+ m = _STAND_ACTION_RE.match(self.path)
240
+ if not m or m.group("action") not in ("start", "stop", "restart"):
241
+ self._send_json(404, {"error": "not found"})
242
+ return
243
+
244
+ # Лимит тела запроса — ДО чтения (проверяем заявленный Content-Length),
245
+ # чтобы не читать в память произвольно большое тело.
246
+ try:
247
+ content_length = _security.validate_content_length(
248
+ self.headers.get("Content-Length"), max_bytes=max_body_bytes
249
+ )
250
+ except ValueError as exc:
251
+ self._send_json(400, {"error": str(exc)})
252
+ return
253
+ if content_length:
254
+ # Тело текущим API не используется, но должно быть вычитано
255
+ # из сокета до отправки ответа (иначе keep-alive соединение
256
+ # десинхронизируется). Лимит выше уже гарантирует, что это
257
+ # не более max_body_bytes.
258
+ try:
259
+ self.rfile.read(content_length)
260
+ except Exception:
261
+ self._send_json(400, {"error": "failed to read request body"})
262
+ return
263
+
264
+ name = m.group("name")
265
+ action = m.group("action")
266
+
267
+ scope = self._authenticate(action)
268
+ if scope is None:
269
+ return
270
+
271
+ if not _security.validate_stand_name(name):
272
+ self._send_json(400, {"error": "invalid stand name"})
273
+ self._audit(identity=scope, action=action, result="error", code=400)
274
+ return
275
+
276
+ try:
277
+ stand = registry.get(name)
278
+ except RegistryError as exc:
279
+ self._send_json(404, {"error": str(exc)})
280
+ self._audit(identity=scope, action=action, result="error", code=404)
281
+ return
282
+
283
+ try:
284
+ if action == "start":
285
+ pid = lifecycle.start(stand, run_dir=run_dir, log_dir=log_dir)
286
+ self._send_json(200, {"ok": True, "pid": pid})
287
+ elif action == "stop":
288
+ ok = lifecycle.stop(stand, run_dir=run_dir)
289
+ self._send_json(200, {"ok": ok})
290
+ elif action == "restart":
291
+ pid = lifecycle.restart(stand, run_dir=run_dir, log_dir=log_dir)
292
+ self._send_json(200, {"ok": True, "pid": pid})
293
+ self._audit(identity=scope, action=action, result="ok", code=200)
294
+ except Exception as exc: # noqa: BLE001 - агент не должен падать на ошибке одного стенда
295
+ self._send_json(500, {"error": str(exc)})
296
+ self._audit(identity=scope, action=action, result="error", code=500)
297
+
298
+ def _handle_status(self, name: str, scope: str) -> None:
299
+ try:
300
+ stand = registry.get(name)
301
+ except RegistryError as exc:
302
+ self._send_json(404, {"error": str(exc)})
303
+ self._audit(identity=scope, action="status", result="error", code=404)
304
+ return
305
+ pf = lifecycle.pidfile_path(stand, run_dir)
306
+ status = health.check_stand(stand, pidfile=pf)
307
+ self._send_json(200, status.to_dict())
308
+ self._audit(identity=scope, action="status", result="ok", code=200)
309
+
310
+ def _handle_logs(self, name: str, n: int, scope: str) -> None:
311
+ from standkit import logs as _logs
312
+
313
+ try:
314
+ stand = registry.get(name)
315
+ except RegistryError as exc:
316
+ self._send_json(404, {"error": str(exc)})
317
+ self._audit(identity=scope, action="logs", result="error", code=404)
318
+ return
319
+ lp = lifecycle.log_path(stand, log_dir)
320
+ self._send_json(200, {"lines": _logs.tail(lp, n)})
321
+ self._audit(identity=scope, action="logs", result="ok", code=200)
322
+
323
+ return Handler
324
+
325
+
326
+ def run_server(
327
+ registry: Registry,
328
+ authenticator: Authenticator,
329
+ *,
330
+ host: str = "127.0.0.1",
331
+ port: int = 8765,
332
+ run_dir: Optional[Path] = None,
333
+ log_dir: Optional[Path] = None,
334
+ tls_cert: Optional[str] = None,
335
+ tls_key: Optional[str] = None,
336
+ tls_client_ca: Optional[str] = None,
337
+ insecure: bool = False,
338
+ lockout: Optional[LockoutTracker] = None,
339
+ audit_log_path: Optional[Path] = None,
340
+ max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
341
+ max_logs_n: int = DEFAULT_MAX_LOGS_N,
342
+ ) -> None:
343
+ """
344
+ Запускает HTTP(S)-сервер агента (блокирующий вызов — рассчитан на запуск
345
+ в основном потоке процесса-демона, см. standkit_agent.__main__).
346
+
347
+ Secure-defaults (fail-closed): ``host`` по умолчанию loopback; если
348
+ вызывающая сторона передаёт non-loopback host без TLS и без
349
+ ``insecure=True`` — старт прерывается ``InsecureBindError`` ДО открытия
350
+ сокета (см. security.validate_bind_security).
351
+ """
352
+ tls_enabled = bool(tls_cert and tls_key)
353
+ _security.validate_bind_security(host, tls_enabled=tls_enabled, insecure=insecure)
354
+
355
+ if insecure and not tls_enabled and not _security.is_loopback_host(host):
356
+ import sys
357
+
358
+ print(
359
+ "[standkit-agent] !!! ВНИМАНИЕ: --insecure — агент слушает "
360
+ f"{host}:{port} ОТКРЫТЫМ HTTP без TLS на non-loopback адресе. "
361
+ "Это RCE-поверхность (start/stop/restart процессов стенда) без "
362
+ "шифрования и без аутентификации транспорта. НЕ использовать в "
363
+ "проде/недоверенной сети. Только dev/тест за изолированным "
364
+ "периметром.",
365
+ file=sys.stderr,
366
+ )
367
+
368
+ audit_logger = _audit.build_audit_logger(audit_log_path)
369
+ handler_cls = make_handler(
370
+ registry,
371
+ authenticator,
372
+ run_dir=run_dir,
373
+ log_dir=log_dir,
374
+ lockout=lockout,
375
+ audit_logger=audit_logger,
376
+ max_body_bytes=max_body_bytes,
377
+ max_logs_n=max_logs_n,
378
+ )
379
+ httpd = ThreadingHTTPServer((host, port), handler_cls)
380
+ if tls_enabled:
381
+ ctx = _security.build_ssl_context(tls_cert, tls_key, tls_client_ca)
382
+ httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
383
+ try:
384
+ httpd.serve_forever()
385
+ finally:
386
+ httpd.server_close()
@@ -0,0 +1,13 @@
1
+ """
2
+ standkit_hub — локальный веб-дашборд диспетчера стендов standkit (вариант A).
3
+
4
+ Отдаёт статический фронтенд (vanilla JS/CSS, без CDN и сборки) и JSON API
5
+ (``/api/*``) поверх stdlib ``http.server``. Опциональная десктопная оболочка —
6
+ через ``pywebview`` (extra ``standkit[desktop]``), см. ``standkit_hub.__main__``.
7
+
8
+ Заменяет собой прежний Qt-слой ``standkit_gui`` (удалён) — та же роль
9
+ ("диспетчер стендов, который можно поставить на рабочий стол"), но без
10
+ PySide6: браузер универсален и не тянет тяжёлую GUI-зависимость.
11
+ """
12
+
13
+ from __future__ import annotations
@@ -0,0 +1,131 @@
1
+ """
2
+ Точка входа веб-дашборда: ``python -m standkit_hub`` (или консольный скрипт
3
+ ``standkit-gui``/``standkit-hub`` после установки пакета).
4
+
5
+ По умолчанию хаб слушает ``127.0.0.1`` на эфемерном порту, печатает URL с
6
+ одноразовым сессионным токеном и открывает системный браузер. Флаг
7
+ ``--desktop`` — опциональная нативная оболочка через ``pywebview`` (extra
8
+ ``standkit[desktop]``); при отсутствии пакета хаб печатает понятное
9
+ сообщение и падает обратно в браузер, а не роняется исключением импорта.
10
+
11
+ БЕЗОПАСНОСТЬ: см. standkit_hub/security.py и standkit_hub/server.py —
12
+ хаб управляет процессами стендов (RCE-поверхность), поэтому secure-defaults
13
+ идентичны headless-агенту: loopback-only, fail-closed на non-loopback без
14
+ ``--insecure``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ import threading
22
+ import webbrowser
23
+ from pathlib import Path
24
+
25
+ from standkit_hub.config import HubConfig
26
+ from standkit_hub.security import InsecureBindError, generate_session_token
27
+ from standkit_hub.server import create_hub_server
28
+ from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_shortcut
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = argparse.ArgumentParser(
33
+ prog="standkit-gui",
34
+ description="Локальный веб-дашборд standkit — диспетчер стендов BPMSoft (вариант A: браузер/pywebview)",
35
+ )
36
+ parser.add_argument(
37
+ "--host",
38
+ default="127.0.0.1",
39
+ help="адрес, на котором слушать (по умолчанию 127.0.0.1 — loopback-only, secure default)",
40
+ )
41
+ parser.add_argument(
42
+ "--port",
43
+ type=int,
44
+ default=0,
45
+ help="порт (по умолчанию 0 — эфемерный свободный порт, выбирается ОС)",
46
+ )
47
+ parser.add_argument(
48
+ "--config",
49
+ default=None,
50
+ help="путь к конфигу хаба (по умолчанию — %%APPDATA%%\\BPMkit\\standkit-hub.json / ~/.config/BPMkit/standkit-hub.json)",
51
+ )
52
+ parser.add_argument("--no-browser", action="store_true", help="не открывать системный браузер автоматически")
53
+ parser.add_argument(
54
+ "--desktop",
55
+ action="store_true",
56
+ help="открыть дашборд в нативном окне pywebview вместо браузера (требует extra standkit[desktop])",
57
+ )
58
+ parser.add_argument(
59
+ "--insecure",
60
+ action="store_true",
61
+ help="ОСОЗНАННЫЙ обход fail-closed проверки bind (non-loopback host без TLS) — только dev/тест",
62
+ )
63
+ parser.add_argument(
64
+ "--install-shortcut",
65
+ action="store_true",
66
+ help="создать ярлык дашборда на рабочем столе и выйти (без запуска сервера)",
67
+ )
68
+ parser.add_argument(
69
+ "--uninstall-shortcut",
70
+ action="store_true",
71
+ help="удалить ранее созданный ярлык дашборда и выйти (без запуска сервера)",
72
+ )
73
+ args = parser.parse_args(argv)
74
+
75
+ if args.install_shortcut or args.uninstall_shortcut:
76
+ result = install_desktop_shortcut() if args.install_shortcut else uninstall_desktop_shortcut()
77
+ print(f"[standkit-hub] {result.message}")
78
+ return 0 if result.ok else 1
79
+
80
+ config_path = Path(args.config) if args.config else HubConfig.config_path()
81
+ session_token = generate_session_token()
82
+
83
+ try:
84
+ httpd = create_hub_server(
85
+ args.host,
86
+ args.port,
87
+ config_path=config_path,
88
+ session_token=session_token,
89
+ insecure=args.insecure,
90
+ )
91
+ except InsecureBindError as exc:
92
+ print(f"[standkit-hub] {exc}", file=sys.stderr)
93
+ return 1
94
+
95
+ actual_port = httpd.server_address[1]
96
+ url = f"http://{args.host}:{actual_port}/?t={session_token}"
97
+ print(f"[standkit-hub] дашборд слушает {args.host}:{actual_port}")
98
+ print(f"[standkit-hub] откройте: {url}")
99
+
100
+ if args.desktop:
101
+ try:
102
+ import webview # type: ignore
103
+ except ImportError:
104
+ print(
105
+ "[standkit-hub] pywebview не установлен (pip install standkit[desktop]) — открываю в системном браузере",
106
+ file=sys.stderr,
107
+ )
108
+ if not args.no_browser:
109
+ webbrowser.open(url)
110
+ else:
111
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
112
+ thread.start()
113
+ try:
114
+ webview.create_window("BPMkit Дашборд", url)
115
+ webview.start()
116
+ finally:
117
+ httpd.shutdown()
118
+ httpd.server_close()
119
+ return 0
120
+ elif not args.no_browser:
121
+ webbrowser.open(url)
122
+
123
+ try:
124
+ httpd.serve_forever()
125
+ finally:
126
+ httpd.server_close()
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ raise SystemExit(main())