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/__init__.py +11 -0
- standkit/health.py +163 -0
- standkit/lifecycle.py +191 -0
- standkit/logs.py +221 -0
- standkit/models.py +211 -0
- standkit/platform.py +174 -0
- standkit/registry.py +223 -0
- standkit/secrets.py +144 -0
- standkit-0.3.7.dist-info/METADATA +155 -0
- standkit-0.3.7.dist-info/RECORD +38 -0
- standkit-0.3.7.dist-info/WHEEL +5 -0
- standkit-0.3.7.dist-info/entry_points.txt +4 -0
- standkit-0.3.7.dist-info/licenses/LICENSE +21 -0
- standkit-0.3.7.dist-info/top_level.txt +3 -0
- standkit_agent/__init__.py +9 -0
- standkit_agent/__main__.py +192 -0
- standkit_agent/audit.py +93 -0
- standkit_agent/security.py +300 -0
- standkit_agent/server.py +386 -0
- standkit_hub/__init__.py +13 -0
- standkit_hub/__main__.py +131 -0
- standkit_hub/agent_control.py +216 -0
- standkit_hub/assets/bpmkit-icon.ico +0 -0
- standkit_hub/assets/icon.png +0 -0
- standkit_hub/client.py +159 -0
- standkit_hub/config.py +156 -0
- standkit_hub/logs_browser.py +174 -0
- standkit_hub/redis_min.py +326 -0
- standkit_hub/security.py +146 -0
- standkit_hub/server.py +881 -0
- standkit_hub/shortcut.py +264 -0
- standkit_hub/web/app.js +803 -0
- standkit_hub/web/bpmkit-logo-dark.svg +5 -0
- standkit_hub/web/bpmkit-logo.svg +5 -0
- standkit_hub/web/favicon.png +0 -0
- standkit_hub/web/favicon.svg +4 -0
- standkit_hub/web/index.html +267 -0
- standkit_hub/web/style.css +843 -0
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)
|