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.
standkit_hub/server.py ADDED
@@ -0,0 +1,881 @@
1
+ """
2
+ HTTP-сервер веб-дашборда standkit — STDLIB-ONLY (http.server), никаких
3
+ сторонних веб-фреймворков намеренно (тот же принцип, что и у
4
+ standkit_agent.server: хаб должен разворачиваться без pip install чего-либо,
5
+ кроме самого standkit/standkit_agent).
6
+
7
+ Отдаёт:
8
+ - статический фронтенд (vanilla JS/CSS, web/index.html + /static/*);
9
+ - JSON API под ``/api/*`` (агрегированный статус стендов, start/stop/
10
+ restart, настройки хаба, секреты, локальный агент, ярлык).
11
+
12
+ СЕКЬЮРИТИ-МОДЕЛЬ (см. standkit_hub/security.py, docstring там подробнее):
13
+ - Bind ТОЛЬКО на loopback по умолчанию (fail-closed, см.
14
+ standkit_agent.security.validate_bind_security — переиспользуется
15
+ напрямую, не дублируется).
16
+ - Сессионный токен генерируется один раз при старте процесса
17
+ (``secrets.token_urlsafe(32)``). Первый переход по ``/?t=<token>``
18
+ ставит HttpOnly+SameSite=Strict cookie и редиректит на ``/`` без токена
19
+ в URL. Далее ``GET /api/*`` требует совпадения токена (cookie ИЛИ
20
+ заголовок ``X-Standkit-Token``) — иначе 401.
21
+ - Мутации (``POST``/``DELETE`` под ``/api/*``) ДОПОЛНИТЕЛЬНО требуют явный
22
+ заголовок ``X-Standkit-Token`` (double-submit — сторонний сайт не может
23
+ ни прочитать HttpOnly-cookie, ни продублировать его в заголовок) И
24
+ совпадающий по loopback-хосту и порту ``Origin``/``Referer`` — иначе 403.
25
+ - Никакого CORS (same-origin по дизайну).
26
+ - Статика (``/``, ``/static/*``) отдаётся БЕЗ авторизации — это только
27
+ HTML/JS/CSS-оболочка без данных стенда, авторизация нужна исключительно
28
+ для ``/api/*``.
29
+ - Input-hardening: лимит тела запроса, кап на ``n`` логов, таймаут сокета,
30
+ валидация имени стенда/ссылки на секрет, санитайзинг статических путей
31
+ (защита от traversal), 400/404 без стектрейсов.
32
+ - Секреты (``POST /api/secret/{ref}``) никогда не логируются и не попадают
33
+ в аудит/ответ — только статус ``has_secret``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import mimetypes
40
+ import re
41
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
42
+ from pathlib import Path
43
+ from typing import Optional
44
+ from urllib.parse import parse_qs, urlparse
45
+
46
+ from standkit import __version__ as _standkit_version
47
+ from standkit import logs as _logs
48
+ from standkit.lifecycle import LifecycleError
49
+ from standkit.models import Stand
50
+ from standkit.registry import Registry, RegistryError, default_registry_path
51
+ from standkit.secrets import SecretError, delete_secret, has_secret, set_secret
52
+ from standkit_hub import logs_browser
53
+ from standkit_hub import redis_min
54
+ from standkit_hub import security as _security
55
+ from standkit_hub.agent_control import AgentControlError, AgentController
56
+ from standkit_hub.client import FederatedClient, RemoteCallError
57
+ from standkit_hub.config import HubConfig
58
+ from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_shortcut
59
+
60
+ _STAND_ACTION_RE = re.compile(
61
+ r"^/api/stand/(?P<name>[^/]+)/(?P<action>status|logs|start|stop|restart|state|redis-clear)$"
62
+ )
63
+ # Единственный оставшийся суб-путь "логов" — открытие папки логов в
64
+ # проводнике ОС (POST). Просмотр отдельных файлов лога из UI убран (см.
65
+ # CLAUDE.md фидбэк: панель "Текущее состояние" показывает только консоль
66
+ # выбранного стенда, без выбора файла) — соответствующие эндпоинты
67
+ # /logs/list и /logs/file удалены вместе с фронтом, который их использовал.
68
+ _STAND_LOGS_SUB_RE = re.compile(r"^/api/stand/(?P<name>[^/]+)/logs/(?P<sub>open-folder)$")
69
+ _SECRET_RE = re.compile(r"^/api/secret/(?P<ref>[^/]+)$")
70
+
71
+ # Человекочитаемые подписи источника логов для сообщений "лог недоступен".
72
+ _LOG_SOURCE_LABELS = {"stand": "Стенд", "bpmkit": "BPMkit"}
73
+
74
+ _DEFAULT_WEB_DIR = Path(__file__).parent / "web"
75
+
76
+
77
+ def _redis_from_registry(stand: Stand) -> Optional[dict]:
78
+ """
79
+ Резолвит ``{"host", "port", "db"}`` ТОЛЬКО из реестра/``extra`` (без
80
+ чтения конфига стенда) — первый шаг резолва, см. ``_redis_connect_params``.
81
+
82
+ ``db`` ищется по нескольким правдоподобным ключам (плоские
83
+ ``extra["redis_db"]``/``extra["redis_number"]``, либо вложенный
84
+ ``extra["redis"]["db"/"number"/"redis_db"]``) — реестр BPMkit исторически
85
+ не имеет единой строгой схемы для Redis-параметров. Возвращает ``None``,
86
+ если ``db`` в реестре не найден (это ожидаемо в большинстве случаев —
87
+ реестр обычно вообще не хранит Redis-параметры, они лежат в конфиге
88
+ самого стенда, см. ``standkit_hub.redis_min.resolve_redis_from_stand_config``).
89
+ """
90
+ nested = stand.extra.get("redis")
91
+ nested = nested if isinstance(nested, dict) else {}
92
+
93
+ db: Optional[int] = None
94
+ for key in ("redis_db", "redis_number"):
95
+ val = stand.extra.get(key)
96
+ if val is not None:
97
+ try:
98
+ db = int(val)
99
+ break
100
+ except (TypeError, ValueError):
101
+ continue
102
+ if db is None:
103
+ for key in ("db", "number", "redis_db"):
104
+ val = nested.get(key)
105
+ if val is not None:
106
+ try:
107
+ db = int(val)
108
+ break
109
+ except (TypeError, ValueError):
110
+ continue
111
+ if db is None:
112
+ return None
113
+
114
+ host = stand.extra.get("redis_host") or nested.get("host") or "127.0.0.1"
115
+ port_raw = stand.extra.get("redis_port")
116
+ if port_raw is None:
117
+ port_raw = nested.get("port")
118
+ try:
119
+ port = int(port_raw) if port_raw is not None else 6379
120
+ except (TypeError, ValueError):
121
+ port = 6379
122
+
123
+ return {"host": host, "port": port, "db": db}
124
+
125
+
126
+ _REDIS_MISSING_DB_MESSAGE = (
127
+ "redis не настроен у стенда — не найден ни redis_db в реестре, ни "
128
+ "redis-подключение в конфиге стенда"
129
+ )
130
+
131
+
132
+ def _redis_connect_params(stand: Stand) -> tuple[str, int, Optional[int]]:
133
+ """
134
+ Резолвит параметры подключения к Redis стенда для кнопки "Очистить Redis":
135
+ ``host`` (дефолт ``127.0.0.1``), ``port`` (дефолт ``6379``), ``db``.
136
+
137
+ Порядок резолва (``db`` — ОБЯЗАТЕЛЕН для очистки, см.
138
+ ``_api_stand_redis_clear``; номер БД НИКОГДА не угадывается):
139
+ 1. реестр/``extra`` (см. ``_redis_from_registry``);
140
+ 2. best-effort резолвер по конфигу самого стенда (см.
141
+ ``standkit_hub.redis_min.resolve_redis_from_stand_config`` —
142
+ ``ConnectionStrings.config``/``appsettings.json``/прочие
143
+ ``*.config``/``*.json`` в корне ``stand_dir``);
144
+ 3. ``None`` — вызывающая сторона обязана отдать 400 с понятным текстом.
145
+ """
146
+ from_registry = _redis_from_registry(stand)
147
+ if from_registry is not None:
148
+ return from_registry["host"], from_registry["port"], from_registry["db"]
149
+
150
+ from_config = redis_min.resolve_redis_from_stand_config(stand.stand_dir)
151
+ if from_config is not None:
152
+ return from_config["host"], from_config["port"], from_config["db"]
153
+
154
+ return "127.0.0.1", 6379, None
155
+
156
+
157
+ def _redis_number(stand: Stand) -> Optional[int]:
158
+ """
159
+ Номер БД Redis стенда — реестр в приоритете, иначе best-effort резолв из
160
+ конфига стенда (см. ``_redis_connect_params``). ``None``, если не найден
161
+ нигде — используется UI (``/api/stands``), чтобы дизейблить кнопку
162
+ "Очистить Redis" только когда db реально нигде не найден.
163
+ """
164
+ _, _, db = _redis_connect_params(stand)
165
+ return db
166
+
167
+
168
+ def _load_config(config_path: Path) -> HubConfig:
169
+ return HubConfig.load(config_path)
170
+
171
+
172
+ def _load_registry(config: HubConfig) -> Registry:
173
+ reg_path = Path(config.registry_path) if config.registry_path else default_registry_path()
174
+ return Registry.load(reg_path)
175
+
176
+
177
+ def make_handler(
178
+ *,
179
+ config_path: Path,
180
+ session_token: str,
181
+ web_dir: Optional[Path] = None,
182
+ max_body_bytes: int = _security.DEFAULT_MAX_BODY_BYTES,
183
+ max_logs_n: int = _security.DEFAULT_MAX_LOGS_N,
184
+ ) -> type:
185
+ """
186
+ Фабрика класса-обработчика запросов хаба с "захваченными" зависимостями
187
+ (путь конфига, сессионный токен, каталог статики) — по тому же принципу,
188
+ что ``standkit_agent.server.make_handler``.
189
+ """
190
+ web_dir = web_dir or _DEFAULT_WEB_DIR
191
+
192
+ class Handler(BaseHTTPRequestHandler):
193
+ server_version = "standkit-hub/0.1"
194
+ timeout = 30.0
195
+
196
+ # --- вспомогательные ---
197
+
198
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003 - сигнатура BaseHTTPRequestHandler
199
+ # У хаба нет отдельного аудит-лога (в отличие от агента) — но
200
+ # стандартный access-лог http.server в stderr всё равно приглушаем,
201
+ # чтобы не шуметь секретными путями (/api/secret/<ref>) в консоли.
202
+ pass
203
+
204
+ def _send_json(self, code: int, payload: dict) -> None:
205
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
206
+ self.send_response(code)
207
+ self.send_header("Content-Type", "application/json; charset=utf-8")
208
+ self.send_header("Content-Length", str(len(body)))
209
+ self.end_headers()
210
+ try:
211
+ self.wfile.write(body)
212
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
213
+ pass
214
+
215
+ def _hub_port(self) -> int:
216
+ return int(self.server.server_address[1])
217
+
218
+ def _presented_token(self) -> Optional[str]:
219
+ header = self.headers.get(_security.TOKEN_HEADER_NAME)
220
+ if header:
221
+ return header
222
+ return _security.extract_cookie_token(self.headers.get("Cookie", ""))
223
+
224
+ def _authorize_read(self) -> bool:
225
+ """GET /api/* — токен из cookie ИЛИ заголовка. 401 при несовпадении."""
226
+ if not _security.tokens_match(self._presented_token(), session_token):
227
+ self._send_json(401, {"error": "unauthorized"})
228
+ return False
229
+ return True
230
+
231
+ def _authorize_mutation(self) -> bool:
232
+ """POST/DELETE /api/* — явный заголовок + локальный Origin/Referer. 403 при несовпадении."""
233
+ header_token = self.headers.get(_security.TOKEN_HEADER_NAME)
234
+ if not _security.tokens_match(header_token, session_token):
235
+ self._send_json(403, {"error": "forbidden: missing or invalid X-Standkit-Token header"})
236
+ return False
237
+ origin = self.headers.get("Origin") or self.headers.get("Referer")
238
+ if not _security.is_local_origin(origin, expected_port=self._hub_port()):
239
+ self._send_json(403, {"error": "forbidden: origin/referer is not the local hub"})
240
+ return False
241
+ return True
242
+
243
+ def _log_source_from_qs(self, parsed) -> Optional[str]:
244
+ """
245
+ Разбирает query-параметр ``source`` (какой источник логов стенда
246
+ использовать — "stand" — логи самого стенда, "bpmkit" — логи,
247
+ которые пишет BPMkit MCP). Дефолт — "stand" (см.
248
+ ``logs_browser.DEFAULT_LOG_SOURCE``).
249
+
250
+ При некорректном значении сразу отправляет 400 и возвращает
251
+ ``None`` — вызывающая сторона обязана прервать обработку запроса.
252
+ """
253
+ qs = parse_qs(parsed.query)
254
+ raw = (qs.get("source") or [logs_browser.DEFAULT_LOG_SOURCE])[0]
255
+ if raw not in logs_browser.LOG_SOURCES:
256
+ self._send_json(
257
+ 400,
258
+ {"error": f"invalid source: {raw!r} (ожидается 'stand' или 'bpmkit')"},
259
+ )
260
+ return None
261
+ return raw
262
+
263
+ def _read_json_body(self, *, max_bytes: int) -> Optional[dict]:
264
+ try:
265
+ length = _security.validate_content_length(
266
+ self.headers.get("Content-Length"), max_bytes=max_bytes
267
+ )
268
+ except ValueError as exc:
269
+ self._send_json(400, {"error": str(exc)})
270
+ return None
271
+ raw = self.rfile.read(length) if length else b""
272
+ if not raw:
273
+ return {}
274
+ try:
275
+ data = json.loads(raw.decode("utf-8"))
276
+ except (UnicodeDecodeError, json.JSONDecodeError):
277
+ self._send_json(400, {"error": "invalid JSON body"})
278
+ return None
279
+ if not isinstance(data, dict):
280
+ self._send_json(400, {"error": "JSON body must be an object"})
281
+ return None
282
+ return data
283
+
284
+ # --- статика ---
285
+
286
+ def _serve_index(self, inject_token: Optional[str] = None, set_cookie: bool = False) -> None:
287
+ index_path = web_dir / "index.html"
288
+ if not index_path.is_file():
289
+ self._send_json(500, {"error": "index.html не найден в пакете хаба"})
290
+ return
291
+ # Токен инжектим в <meta> ТОЛЬКО аутентифицированному запросу (см.
292
+ # _handle_root). Неаутентифицированному — плейсхолдер очищается в пустоту,
293
+ # токен не утекает.
294
+ text = index_path.read_text(encoding="utf-8")
295
+ text = text.replace("__STANDKIT_TOKEN__", inject_token or "")
296
+ body = text.encode("utf-8")
297
+ self.send_response(200)
298
+ self.send_header("Content-Type", "text/html; charset=utf-8")
299
+ if set_cookie:
300
+ self.send_header(
301
+ "Set-Cookie",
302
+ f"{_security.SESSION_COOKIE_NAME}={session_token}; HttpOnly; SameSite=Strict; Path=/",
303
+ )
304
+ self.send_header("Content-Length", str(len(body)))
305
+ self.end_headers()
306
+ try:
307
+ self.wfile.write(body)
308
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
309
+ pass
310
+
311
+ def _serve_static(self, rel_path: str) -> None:
312
+ target = _security.sanitize_static_path(web_dir, rel_path)
313
+ if target is None:
314
+ self._send_json(404, {"error": "not found"})
315
+ return
316
+ content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
317
+ body = target.read_bytes()
318
+ self.send_response(200)
319
+ self.send_header("Content-Type", content_type)
320
+ self.send_header("Content-Length", str(len(body)))
321
+ self.end_headers()
322
+ try:
323
+ self.wfile.write(body)
324
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
325
+ pass
326
+
327
+ def _handle_root(self, parsed) -> None:
328
+ qs = parse_qs(parsed.query)
329
+ token = (qs.get(_security.TOKEN_QUERY_PARAM) or [None])[0]
330
+ authed_query = bool(token and _security.tokens_match(token, session_token))
331
+ cookie_tok = _security.extract_cookie_token(self.headers.get("Cookie", ""))
332
+ authed_cookie = _security.tokens_match(cookie_tok, session_token)
333
+ if authed_query or authed_cookie:
334
+ # Аутентифицированный запрос: отдаём index с токеном в <meta>, чтобы
335
+ # JS мог класть X-Standkit-Token в мутации (cookie HttpOnly, JS её не
336
+ # читает). Cookie ставим, если пришли по ссылке ?t=. Без редиректа —
337
+ # иначе токен теряется до загрузки JS (был баг 403 на мутациях).
338
+ self._serve_index(inject_token=session_token, set_cookie=authed_query)
339
+ else:
340
+ self._serve_index()
341
+
342
+ # --- API: стенды ---
343
+
344
+ def _api_stands(self) -> None:
345
+ config = _load_config(config_path)
346
+ try:
347
+ registry = _load_registry(config)
348
+ except RegistryError as exc:
349
+ self._send_json(500, {"error": str(exc)})
350
+ return
351
+ client = FederatedClient(registry)
352
+ statuses = client.status_all()
353
+ stands = []
354
+ for name in registry.names():
355
+ stand = registry.get(name)
356
+ status = statuses.get(name)
357
+ status_dict = status.to_dict() if status else None
358
+ http_state = status.http.value if status else "unknown"
359
+ db_state = status.db.value if status else "unknown"
360
+ redis_state = status.redis.value if status else "unknown"
361
+ process_state = status.process.value if status else "unknown"
362
+ # Таблица стендов показывает каталог логов BPMkit-ПРОЕКТА
363
+ # (<extra["docs_folder"]>/logs, scaffold, НЕ extra["logs_path"]
364
+ # — тот указывает на каталог логов самого стенда) — источник
365
+ # "stand" здесь не запрашивается ни query-параметром, ни
366
+ # выбором пользователя (тот выбор — только у панели "Текущее
367
+ # состояние"/сплит-меню ниже).
368
+ logs_dir = logs_browser.resolve_logs_dir(stand, source="bpmkit")
369
+ logs_path = str(logs_dir) if logs_dir else (logs_browser.raw_logs_path(stand, "bpmkit") or None)
370
+ # Флаг для UI: доступен ли источник логов "Логи BPMkit-проекта"
371
+ # у ЭТОГО стенда — задан extra["docs_folder"] И каталог
372
+ # <docs_folder>/logs реально существует (см.
373
+ # logs_browser.resolve_logs_dir). Используется, чтобы
374
+ # дизейблить соответствующий пункт сплит-меню "Открыть папку
375
+ # логов" вместо того, чтобы позволять открывать несуществующий
376
+ # источник (см. CLAUDE.md фидбэк по кнопкам логов).
377
+ bpmkit_logs_available = logs_dir is not None
378
+ http_url = (
379
+ f"http://{stand.stand_host}:{stand.stand_port}"
380
+ if stand.stand_host and stand.stand_port
381
+ else None
382
+ )
383
+ stands.append(
384
+ {
385
+ "name": name,
386
+ "transport": stand.transport.value,
387
+ "status": status_dict,
388
+ "http": {"url": http_url, "state": http_state},
389
+ "db": {"name": stand.db_name or None, "state": db_state},
390
+ "redis": {"number": _redis_number(stand), "state": redis_state},
391
+ "process": {
392
+ "state": process_state,
393
+ "transport": stand.transport.value,
394
+ "logs_path": logs_path,
395
+ },
396
+ "logs": {"bpmkit_available": bpmkit_logs_available},
397
+ }
398
+ )
399
+ self._send_json(200, {"stands": stands, "default": registry.default})
400
+
401
+ def _api_stand_status(self, name: str) -> None:
402
+ config = _load_config(config_path)
403
+ registry = _load_registry(config)
404
+ if name not in registry:
405
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
406
+ return
407
+ client = FederatedClient(registry)
408
+ try:
409
+ status = client.status(name)
410
+ except (RemoteCallError, SecretError) as exc:
411
+ self._send_json(502, {"error": str(exc)})
412
+ return
413
+ except NotImplementedError as exc:
414
+ self._send_json(400, {"error": str(exc)})
415
+ return
416
+ self._send_json(200, status.to_dict())
417
+
418
+ def _api_stand_logs(self, name: str, parsed) -> None:
419
+ config = _load_config(config_path)
420
+ registry = _load_registry(config)
421
+ if name not in registry:
422
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
423
+ return
424
+ qs = parse_qs(parsed.query)
425
+ raw_n = (qs.get("n") or ["100"])[0]
426
+ try:
427
+ n = _security.clamp_logs_n(raw_n, max_n=max_logs_n)
428
+ except (ValueError, TypeError):
429
+ self._send_json(400, {"error": "invalid n"})
430
+ return
431
+ client = FederatedClient(registry)
432
+ try:
433
+ lines = client.logs(name, n)
434
+ except (RemoteCallError, SecretError) as exc:
435
+ self._send_json(502, {"error": str(exc)})
436
+ return
437
+ except NotImplementedError as exc:
438
+ self._send_json(400, {"error": str(exc)})
439
+ return
440
+ self._send_json(200, {"lines": lines})
441
+
442
+ def _api_stand_state(self, name: str, parsed) -> None:
443
+ """
444
+ Текущее состояние стенда (то, что видно в консоли/PS-окне стенда) —
445
+ tail основного лог-файла из выбранного источника (``source``,
446
+ дефолт "stand" — см. ``logs_browser``). Не путать с /logs
447
+ (standkit-managed лог для transport=local через lifecycle) — это
448
+ отдельный источник, специфичный для того, как реально запущен
449
+ стенд (зачастую — вне standkit).
450
+ """
451
+ config = _load_config(config_path)
452
+ registry = _load_registry(config)
453
+ if name not in registry:
454
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
455
+ return
456
+ source = self._log_source_from_qs(parsed)
457
+ if source is None:
458
+ return
459
+ stand = registry.get(name)
460
+ label = _LOG_SOURCE_LABELS[source]
461
+ logs_dir = logs_browser.resolve_logs_dir(stand, source=source)
462
+ if logs_dir is None:
463
+ raw = logs_browser.raw_logs_path(stand, source)
464
+ detail = "путь не задан" if not raw else f"каталог не найден — {raw}"
465
+ self._send_json(
466
+ 200,
467
+ {
468
+ "available": False,
469
+ "text": f"лог недоступен (источник «{label}»: {detail})",
470
+ "file": None,
471
+ "source": source,
472
+ },
473
+ )
474
+ return
475
+ primary = logs_browser.pick_primary_log(logs_dir)
476
+ if primary is None:
477
+ self._send_json(
478
+ 200,
479
+ {
480
+ "available": False,
481
+ "text": f"лог недоступен (источник «{label}»: в каталоге {logs_dir} нет файлов)",
482
+ "file": None,
483
+ "source": source,
484
+ },
485
+ )
486
+ return
487
+ # Хвост берём щедрым (4000 строк), чтобы гарантированно захватить
488
+ # ВСЮ последнюю сессию (от "=== START pid="/"Application starting"
489
+ # до конца файла), даже если она сама по себе длинная — затем
490
+ # extract_current_session() отрезает всё, что относится к прошлым
491
+ # запускам, и уже результат капается до разумного размера для UI.
492
+ raw_lines = _logs.tail(primary, 4000)
493
+ raw_text = "\n".join(raw_lines)
494
+ session_text = _logs.extract_current_session(raw_text) if raw_text else ""
495
+ if session_text:
496
+ session_lines = session_text.split("\n")
497
+ if len(session_lines) > 1000:
498
+ session_lines = session_lines[-1000:]
499
+ session_text = "\n".join(session_lines)
500
+ self._send_json(
501
+ 200,
502
+ {
503
+ "available": True,
504
+ "text": session_text if session_text else "(лог пуст)",
505
+ "file": primary.name,
506
+ "source": source,
507
+ },
508
+ )
509
+
510
+ def _api_stand_logs_open_folder(self, name: str, parsed) -> None:
511
+ config = _load_config(config_path)
512
+ registry = _load_registry(config)
513
+ if name not in registry:
514
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
515
+ return
516
+ source = self._log_source_from_qs(parsed)
517
+ if source is None:
518
+ return
519
+ stand = registry.get(name)
520
+ logs_dir = logs_browser.resolve_logs_dir(stand, source=source)
521
+ if logs_dir is None:
522
+ self._send_json(400, {"error": "источник логов не задан или недоступен"})
523
+ return
524
+ result = logs_browser.open_folder(logs_dir)
525
+ self._send_json(
526
+ 200 if result.ok else 400,
527
+ {"ok": result.ok, "message": result.message, "source": source},
528
+ )
529
+
530
+ def _api_stand_action(self, name: str, action: str) -> None:
531
+ config = _load_config(config_path)
532
+ registry = _load_registry(config)
533
+ if name not in registry:
534
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
535
+ return
536
+ client = FederatedClient(registry)
537
+ try:
538
+ result = getattr(client, action)(name)
539
+ except (RemoteCallError, SecretError) as exc:
540
+ self._send_json(502, {"error": str(exc)})
541
+ return
542
+ except LifecycleError as exc:
543
+ # Понятная причина отказа (dotnet не найден в PATH, процесс
544
+ # умер сразу после старта и т.п., см. standkit.lifecycle.start)
545
+ # — фронт обязан показать текст пользователю, а не просто "ошибка".
546
+ self._send_json(400, {"error": str(exc)})
547
+ return
548
+ except NotImplementedError as exc:
549
+ self._send_json(400, {"error": str(exc)})
550
+ return
551
+ payload: dict = {"ok": True}
552
+ if action in ("start", "restart") and isinstance(result, int):
553
+ payload["pid"] = result
554
+ self._send_json(200, payload)
555
+
556
+ def _api_stand_redis_clear(self, name: str) -> None:
557
+ """
558
+ Очищает БД Redis стенда (``SELECT <db>`` + ``FLUSHDB``, см.
559
+ ``standkit_hub.redis_min``) — кнопка "Очистить Redis" в таблице
560
+ стендов. Требует явно заданный ``redis_db`` в реестре/``extra``
561
+ (см. ``_redis_connect_params``) — номер БД НИКОГДА не угадывается.
562
+ """
563
+ config = _load_config(config_path)
564
+ registry = _load_registry(config)
565
+ if name not in registry:
566
+ self._send_json(404, {"error": f"стенд '{name}' не найден"})
567
+ return
568
+ stand = registry.get(name)
569
+ host, port, db = _redis_connect_params(stand)
570
+ if db is None:
571
+ self._send_json(400, {"error": _REDIS_MISSING_DB_MESSAGE})
572
+ return
573
+ result = redis_min.flush_db(host, port, db)
574
+ if result.ok:
575
+ self._send_json(200, {"ok": True, "message": result.message})
576
+ else:
577
+ # "error" (не только "message") — чтобы фронт (handleResponse
578
+ # в app.js, которая читает data.error на не-2xx-ответах) показал
579
+ # содержательный текст, а не голое "HTTP 502".
580
+ self._send_json(502, {"ok": False, "error": result.message})
581
+
582
+ # --- API: версия ---
583
+
584
+ def _api_version(self) -> None:
585
+ """
586
+ Версия ядра ``standkit`` (для модалки «О программе» на фронте) —
587
+ read-only, тот же ``_authorize_read``, что у прочих ``GET /api/*``.
588
+ """
589
+ self._send_json(200, {"version": _standkit_version, "name": "BPMkitStand"})
590
+
591
+ # --- API: настройки ---
592
+
593
+ def _api_settings_get(self) -> None:
594
+ config = _load_config(config_path)
595
+ self._send_json(200, config.to_dict())
596
+
597
+ def _api_settings_post(self) -> None:
598
+ body = self._read_json_body(max_bytes=max_body_bytes)
599
+ if body is None:
600
+ return
601
+ current = _load_config(config_path)
602
+ data = current.to_dict()
603
+ data.update(body)
604
+ new_config = HubConfig.from_dict(data)
605
+ new_config.save(config_path)
606
+ self._send_json(200, new_config.to_dict())
607
+
608
+ # --- API: секреты ---
609
+
610
+ def _api_secret_get(self, ref: str) -> None:
611
+ if not _security.validate_secret_ref(ref):
612
+ self._send_json(400, {"error": "invalid secret ref"})
613
+ return
614
+ self._send_json(200, {"ref": ref, "has_secret": has_secret(ref)})
615
+
616
+ def _api_secret_post(self, ref: str) -> None:
617
+ if not _security.validate_secret_ref(ref):
618
+ self._send_json(400, {"error": "invalid secret ref"})
619
+ return
620
+ body = self._read_json_body(max_bytes=max_body_bytes)
621
+ if body is None:
622
+ return
623
+ value = body.get("value")
624
+ if not isinstance(value, str) or not value:
625
+ self._send_json(400, {"error": "поле 'value' обязательно и должно быть непустой строкой"})
626
+ return
627
+ try:
628
+ set_secret(ref, value)
629
+ except SecretError as exc:
630
+ self._send_json(400, {"error": str(exc)})
631
+ return
632
+ self._send_json(200, {"ok": True, "ref": ref})
633
+
634
+ def _api_secret_delete(self, ref: str) -> None:
635
+ if not _security.validate_secret_ref(ref):
636
+ self._send_json(400, {"error": "invalid secret ref"})
637
+ return
638
+ try:
639
+ delete_secret(ref)
640
+ except SecretError as exc:
641
+ self._send_json(400, {"error": str(exc)})
642
+ return
643
+ self._send_json(200, {"ok": True, "ref": ref})
644
+
645
+ # --- API: локальный агент ---
646
+
647
+ def _api_agent_status(self) -> None:
648
+ config = _load_config(config_path)
649
+ controller = AgentController(config)
650
+ self._send_json(200, {"running": controller.is_running()})
651
+
652
+ def _api_agent_start(self) -> None:
653
+ config = _load_config(config_path)
654
+ controller = AgentController(config)
655
+ try:
656
+ result = controller.start()
657
+ except AgentControlError as exc:
658
+ self._send_json(400, {"error": str(exc)})
659
+ return
660
+ self._send_json(200, {"ok": True, "pid": result.pid, "log_path": result.log_path})
661
+
662
+ def _api_agent_stop(self) -> None:
663
+ config = _load_config(config_path)
664
+ controller = AgentController(config)
665
+ try:
666
+ stopped = controller.stop()
667
+ except AgentControlError as exc:
668
+ self._send_json(400, {"error": str(exc)})
669
+ return
670
+ self._send_json(200, {"ok": stopped})
671
+
672
+ # --- API: ярлык ---
673
+
674
+ def _api_shortcut_install(self) -> None:
675
+ result = install_desktop_shortcut()
676
+ self._send_json(200 if result.ok else 400, {"ok": result.ok, "path": result.path, "message": result.message})
677
+
678
+ def _api_shortcut_uninstall(self) -> None:
679
+ result = uninstall_desktop_shortcut()
680
+ self._send_json(200 if result.ok else 400, {"ok": result.ok, "path": result.path, "message": result.message})
681
+
682
+ # --- маршрутизация ---
683
+
684
+ def do_GET(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
685
+ parsed = urlparse(self.path)
686
+ path = parsed.path
687
+
688
+ if path == "/":
689
+ self._handle_root(parsed)
690
+ return
691
+
692
+ if path.startswith("/static/"):
693
+ self._serve_static(path[len("/static/"):])
694
+ return
695
+
696
+ if not path.startswith("/api/"):
697
+ self._send_json(404, {"error": "not found"})
698
+ return
699
+
700
+ if path == "/api/stands":
701
+ if not self._authorize_read():
702
+ return
703
+ self._api_stands()
704
+ return
705
+
706
+ if path == "/api/settings":
707
+ if not self._authorize_read():
708
+ return
709
+ self._api_settings_get()
710
+ return
711
+
712
+ if path == "/api/version":
713
+ if not self._authorize_read():
714
+ return
715
+ self._api_version()
716
+ return
717
+
718
+ if path == "/api/agent/status":
719
+ if not self._authorize_read():
720
+ return
721
+ self._api_agent_status()
722
+ return
723
+
724
+ m = _SECRET_RE.match(path)
725
+ if m:
726
+ if not self._authorize_read():
727
+ return
728
+ self._api_secret_get(m.group("ref"))
729
+ return
730
+
731
+ m = _STAND_LOGS_SUB_RE.match(path)
732
+ if m:
733
+ # Единственный суб-путь — "open-folder", он только POST
734
+ # (мутация: запускает процесс на хосте). GET сюда — 404.
735
+ self._send_json(404, {"error": "not found"})
736
+ return
737
+
738
+ m = _STAND_ACTION_RE.match(path)
739
+ if m and m.group("action") == "status":
740
+ if not self._authorize_read():
741
+ return
742
+ if not _security.validate_stand_name(m.group("name")):
743
+ self._send_json(400, {"error": "invalid stand name"})
744
+ return
745
+ self._api_stand_status(m.group("name"))
746
+ return
747
+ if m and m.group("action") == "logs":
748
+ if not self._authorize_read():
749
+ return
750
+ if not _security.validate_stand_name(m.group("name")):
751
+ self._send_json(400, {"error": "invalid stand name"})
752
+ return
753
+ self._api_stand_logs(m.group("name"), parsed)
754
+ return
755
+ if m and m.group("action") == "state":
756
+ if not self._authorize_read():
757
+ return
758
+ if not _security.validate_stand_name(m.group("name")):
759
+ self._send_json(400, {"error": "invalid stand name"})
760
+ return
761
+ self._api_stand_state(m.group("name"), parsed)
762
+ return
763
+
764
+ self._send_json(404, {"error": "not found"})
765
+
766
+ def do_POST(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
767
+ parsed = urlparse(self.path)
768
+ path = parsed.path
769
+
770
+ if not path.startswith("/api/"):
771
+ self._send_json(404, {"error": "not found"})
772
+ return
773
+
774
+ if path == "/api/settings":
775
+ if not self._authorize_mutation():
776
+ return
777
+ self._api_settings_post()
778
+ return
779
+
780
+ if path == "/api/agent/start":
781
+ if not self._authorize_mutation():
782
+ return
783
+ self._api_agent_start()
784
+ return
785
+ if path == "/api/agent/stop":
786
+ if not self._authorize_mutation():
787
+ return
788
+ self._api_agent_stop()
789
+ return
790
+
791
+ if path == "/api/shortcut/install":
792
+ if not self._authorize_mutation():
793
+ return
794
+ self._api_shortcut_install()
795
+ return
796
+ if path == "/api/shortcut/uninstall":
797
+ if not self._authorize_mutation():
798
+ return
799
+ self._api_shortcut_uninstall()
800
+ return
801
+
802
+ m = _SECRET_RE.match(path)
803
+ if m:
804
+ if not self._authorize_mutation():
805
+ return
806
+ self._api_secret_post(m.group("ref"))
807
+ return
808
+
809
+ m = _STAND_LOGS_SUB_RE.match(path)
810
+ if m and m.group("sub") == "open-folder":
811
+ if not self._authorize_mutation():
812
+ return
813
+ if not _security.validate_stand_name(m.group("name")):
814
+ self._send_json(400, {"error": "invalid stand name"})
815
+ return
816
+ self._api_stand_logs_open_folder(m.group("name"), parsed)
817
+ return
818
+
819
+ m = _STAND_ACTION_RE.match(path)
820
+ if m and m.group("action") in ("start", "stop", "restart"):
821
+ if not self._authorize_mutation():
822
+ return
823
+ if not _security.validate_stand_name(m.group("name")):
824
+ self._send_json(400, {"error": "invalid stand name"})
825
+ return
826
+ self._api_stand_action(m.group("name"), m.group("action"))
827
+ return
828
+ if m and m.group("action") == "redis-clear":
829
+ if not self._authorize_mutation():
830
+ return
831
+ if not _security.validate_stand_name(m.group("name")):
832
+ self._send_json(400, {"error": "invalid stand name"})
833
+ return
834
+ self._api_stand_redis_clear(m.group("name"))
835
+ return
836
+
837
+ self._send_json(404, {"error": "not found"})
838
+
839
+ def do_DELETE(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
840
+ parsed = urlparse(self.path)
841
+ path = parsed.path
842
+
843
+ m = _SECRET_RE.match(path)
844
+ if m:
845
+ if not self._authorize_mutation():
846
+ return
847
+ self._api_secret_delete(m.group("ref"))
848
+ return
849
+
850
+ self._send_json(404, {"error": "not found"})
851
+
852
+ def do_PUT(self) -> None: # noqa: N802 - сигнатура BaseHTTPRequestHandler
853
+ self._send_json(405, {"error": "method not allowed"})
854
+
855
+ do_PATCH = do_PUT
856
+
857
+ return Handler
858
+
859
+
860
+ def create_hub_server(
861
+ host: str,
862
+ port: int,
863
+ *,
864
+ config_path: Path,
865
+ session_token: str,
866
+ web_dir: Optional[Path] = None,
867
+ insecure: bool = False,
868
+ ) -> ThreadingHTTPServer:
869
+ """
870
+ Биндит и возвращает готовый ``ThreadingHTTPServer`` (БЕЗ ``serve_forever``)
871
+ — вынесено отдельно от блокирующего запуска, чтобы вызывающая сторона
872
+ (``standkit_hub.__main__``) могла узнать реальный порт (важно при
873
+ ``port=0`` — эфемерный порт) ДО того, как открыть браузер, и чтобы тесты
874
+ могли поднимать сервер в отдельном потоке без дублирования bind-логики.
875
+
876
+ Fail-closed bind-проверка (см. standkit_hub.security.validate_bind_security)
877
+ выполняется ДО открытия сокета — как и у headless-агента.
878
+ """
879
+ _security.validate_bind_security(host, tls_enabled=False, insecure=insecure)
880
+ handler_cls = make_handler(config_path=config_path, session_token=session_token, web_dir=web_dir)
881
+ return ThreadingHTTPServer((host, port), handler_cls)