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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Просмотр лог-файлов стенда со стороны хаба: список файлов источника логов,
|
|
3
|
+
выбор основного (самого свежего) файла, tail содержимого, открытие папки
|
|
4
|
+
логов в файловом менеджере ОС хоста.
|
|
5
|
+
|
|
6
|
+
ДВА ИСТОЧНИКА логов на стенд (``source``, см. ``resolve_logs_dir``):
|
|
7
|
+
|
|
8
|
+
- ``"stand"`` — логи самого стенда (платформа BPMSoft), пишет их движок,
|
|
9
|
+
каталог ``<stand.stand_dir>/logs`` (папка ``logs`` в корне стенда). Это
|
|
10
|
+
ближе всего к тому, что видно в PS-окне/консоли самого стенда — дефолт
|
|
11
|
+
для панели "Текущее состояние".
|
|
12
|
+
- ``"bpmkit"`` — логи BPMkit-ПРОЕКТА (scaffold, ``project_scaffold``): не
|
|
13
|
+
логи стенда и НЕ ``Stand.extra["logs_path"]`` (та запись указывает на тот
|
|
14
|
+
же каталог, что и ``"stand"``, — путать источники не нужно), а подпапка
|
|
15
|
+
``logs`` внутри папки проекта ``Stand.extra["docs_folder"]``
|
|
16
|
+
(``<docs_folder>/logs``). Туда пишутся логи разработки поверх стенда;
|
|
17
|
+
если ``docs_folder`` в записи стенда не задан (провижининг без
|
|
18
|
+
project_scaffold), источник недоступен целиком.
|
|
19
|
+
|
|
20
|
+
Важно отличать оба источника от ``standkit.lifecycle.log_path``/
|
|
21
|
+
``standkit.logs`` (см. ``standkit_hub.server._api_stand_logs``) — тот лог
|
|
22
|
+
существует только для стендов, ЗАПУЩЕННЫХ САМИМ standkit
|
|
23
|
+
(``transport=local`` через ``lifecycle.start``), это третий, отдельный канал.
|
|
24
|
+
|
|
25
|
+
STDLIB-ONLY: ``pathlib``, ``subprocess``, ``sys``, ``os`` (только ``os.startfile``
|
|
26
|
+
на Windows).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
from standkit.models import Stand
|
|
39
|
+
|
|
40
|
+
# Допустимые значения ``source`` — единственный источник истины для валидации
|
|
41
|
+
# как здесь, так и в standkit_hub.server (query-параметр ``source``).
|
|
42
|
+
LOG_SOURCES = ("stand", "bpmkit")
|
|
43
|
+
|
|
44
|
+
DEFAULT_LOG_SOURCE = "stand"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def raw_logs_path(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional[str]:
|
|
48
|
+
"""
|
|
49
|
+
"Сырой" (не проверенный на существование) путь к каталогу логов для
|
|
50
|
+
выбранного источника — используется только для человекочитаемых сообщений
|
|
51
|
+
("каталог не найден — <путь>"), когда ``resolve_logs_dir`` вернул ``None``.
|
|
52
|
+
|
|
53
|
+
Бросает ``ValueError`` на неизвестный ``source`` — та же дисциплина, что
|
|
54
|
+
и у ``resolve_logs_dir``.
|
|
55
|
+
"""
|
|
56
|
+
if source == "stand":
|
|
57
|
+
return str(Path(stand.stand_dir) / "logs") if stand.stand_dir else None
|
|
58
|
+
if source == "bpmkit":
|
|
59
|
+
docs_folder = stand.extra.get("docs_folder")
|
|
60
|
+
return str(Path(docs_folder) / "logs") if docs_folder else None
|
|
61
|
+
raise ValueError(f"неизвестный источник логов: {source!r}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_logs_dir(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional[Path]:
|
|
65
|
+
"""
|
|
66
|
+
Резолвит каталог логов стенда для выбранного источника:
|
|
67
|
+
|
|
68
|
+
- ``source="stand"`` (по умолчанию) — ``<stand.stand_dir>/logs``;
|
|
69
|
+
- ``source="bpmkit"`` — ``<stand.extra["docs_folder"]>/logs`` (логи
|
|
70
|
+
BPMkit-проекта, папка ``logs`` внутри project-scaffold, НЕ
|
|
71
|
+
``stand.extra["logs_path"]``).
|
|
72
|
+
|
|
73
|
+
Возвращает ``None``, если путь не задан (для "stand" — пуст сам
|
|
74
|
+
``stand_dir``; для "bpmkit" — не задан ``docs_folder``), либо не
|
|
75
|
+
существует, либо указывает не на каталог —
|
|
76
|
+
вызывающая сторона (хаб) обязана отдать понятное сообщение "лог
|
|
77
|
+
недоступен", а не падать с исключением.
|
|
78
|
+
|
|
79
|
+
Бросает ``ValueError`` на неизвестный ``source`` — это ошибка вызывающего
|
|
80
|
+
кода (например, невалидированный query-параметр), а не штатная ситуация
|
|
81
|
+
"лога нет"; HTTP-слой (``standkit_hub.server``) обязан провалидировать
|
|
82
|
+
``source`` ДО вызова и вернуть 400, не давая исключению дойти сюда.
|
|
83
|
+
"""
|
|
84
|
+
raw = raw_logs_path(stand, source)
|
|
85
|
+
if not raw:
|
|
86
|
+
return None
|
|
87
|
+
p = Path(raw)
|
|
88
|
+
if not p.exists() or not p.is_dir():
|
|
89
|
+
return None
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_log_files(logs_dir: Path) -> list[dict]:
|
|
94
|
+
"""
|
|
95
|
+
Список лог-файлов каталога (без рекурсии в подкаталоги): имя, размер в
|
|
96
|
+
байтах, mtime (unix timestamp). Отсортирован по mtime по убыванию (самый
|
|
97
|
+
свежий — первым), чтобы фронтенду не нужно было сортировать самому.
|
|
98
|
+
"""
|
|
99
|
+
entries: list[dict] = []
|
|
100
|
+
for child in logs_dir.iterdir():
|
|
101
|
+
if not child.is_file():
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
st = child.stat()
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
entries.append({"name": child.name, "size": st.st_size, "mtime": st.st_mtime})
|
|
108
|
+
entries.sort(key=lambda e: e["mtime"], reverse=True)
|
|
109
|
+
return entries
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pick_primary_log(logs_dir: Path) -> Optional[Path]:
|
|
113
|
+
"""Выбирает "основной" лог каталога — самый свежий по mtime файл."""
|
|
114
|
+
files = list_log_files(logs_dir)
|
|
115
|
+
if not files:
|
|
116
|
+
return None
|
|
117
|
+
return logs_dir / files[0]["name"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sanitize_log_filename(logs_dir: Path, name: str) -> Optional[Path]:
|
|
121
|
+
"""
|
|
122
|
+
Резолвит имя файла лога СТРОГО внутри ``logs_dir`` (защита от path
|
|
123
|
+
traversal) — тот же принцип, что ``standkit_hub.security.sanitize_static_path``,
|
|
124
|
+
но для произвольного (не фиксированного) каталога логов конкретного стенда.
|
|
125
|
+
|
|
126
|
+
Возвращает ``None``, если путь небезопасен, не существует или указывает
|
|
127
|
+
не на файл.
|
|
128
|
+
"""
|
|
129
|
+
if not name or name.startswith("/") or "\\" in name or ".." in Path(name).parts:
|
|
130
|
+
return None
|
|
131
|
+
logs_dir_resolved = logs_dir.resolve()
|
|
132
|
+
candidate = (logs_dir / name).resolve()
|
|
133
|
+
try:
|
|
134
|
+
candidate.relative_to(logs_dir_resolved)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None
|
|
137
|
+
if not candidate.is_file():
|
|
138
|
+
return None
|
|
139
|
+
return candidate
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class OpenFolderResult:
|
|
144
|
+
"""Результат попытки открыть папку логов в файловом менеджере ОС хоста."""
|
|
145
|
+
|
|
146
|
+
ok: bool
|
|
147
|
+
message: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def open_folder(path: Path) -> OpenFolderResult:
|
|
151
|
+
"""
|
|
152
|
+
Открывает каталог в файловом менеджере ОС хоста: Windows — ``os.startfile``
|
|
153
|
+
(штатный способ ОС попросить проводник открыть путь — надёжнее выводит
|
|
154
|
+
открытое окно на передний план, чем спавн ``explorer`` через subprocess,
|
|
155
|
+
который часто просто сворачивает уже открытое окно той же папки в
|
|
156
|
+
таскбар вместо фокуса); macOS — ``open``, остальное — ``xdg-open`` (оба
|
|
157
|
+
через ``subprocess.Popen``, не блокируясь на ожидании закрытия окна).
|
|
158
|
+
|
|
159
|
+
Никогда не бросает исключение наружу — при отсутствии DISPLAY, нужной
|
|
160
|
+
утилиты в PATH и т.п. возвращает ``ok=False`` с текстом причины, чтобы
|
|
161
|
+
хаб мог показать это пользователю, а не упасть 500-й.
|
|
162
|
+
"""
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return OpenFolderResult(False, f"каталог не существует: {path}")
|
|
165
|
+
try:
|
|
166
|
+
if sys.platform == "win32":
|
|
167
|
+
os.startfile(str(path)) # type: ignore[attr-defined]
|
|
168
|
+
elif sys.platform == "darwin":
|
|
169
|
+
subprocess.Popen(["open", str(path)])
|
|
170
|
+
else:
|
|
171
|
+
subprocess.Popen(["xdg-open", str(path)])
|
|
172
|
+
return OpenFolderResult(True, f"открыто: {path}")
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
return OpenFolderResult(False, f"не удалось открыть проводник: {exc}")
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Минимальный STDLIB-ONLY RESP-клиент для очистки БД Redis стенда (``SELECT`` +
|
|
3
|
+
``FLUSHDB``) — используется кнопкой "Очистить Redis" в таблице стендов.
|
|
4
|
+
|
|
5
|
+
Намеренно НЕ тянет ``redis``/``aioredis`` и т.п. — хаб (как и ядро/агент)
|
|
6
|
+
разворачивается без ``pip install`` чего-либо стороннего. Протокол Redis
|
|
7
|
+
(RESP) в объёме, нужном для двух команд, тривиален: массив bulk-строк на
|
|
8
|
+
запрос, однострочный ``+OK``/``-ERR ...`` на ответ — велосипед оправдан
|
|
9
|
+
отсутствием стороннего клиента в рантайме хаба.
|
|
10
|
+
|
|
11
|
+
Намеренно НЕ разбирает произвольные RESP-ответы (bulk/array/integer) — только
|
|
12
|
+
однострочные (``+``/``-``), этого достаточно для ``SELECT``/``FLUSHDB``.
|
|
13
|
+
|
|
14
|
+
Также здесь живёт ``resolve_redis_from_stand_config`` — BEST-EFFORT резолвер
|
|
15
|
+
параметров подключения к Redis ИЗ КОНФИГА САМОГО СТЕНДА (не реестра), на
|
|
16
|
+
случай, когда ``extra["redis_db"]`` не задан в реестре BPMkit (частый
|
|
17
|
+
случай — реестр вообще не хранит Redis-параметры). Используется как второй
|
|
18
|
+
шаг резолва в ``standkit_hub.server._redis_connect_params``: реестр в
|
|
19
|
+
приоритете, конфиг стенда — фолбэк.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import socket
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
_DEFAULT_TIMEOUT_SEC = 5.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RedisClearError(Exception):
|
|
35
|
+
"""Ошибка обращения к Redis (недоступен, таймаут, протокольная ошибка)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RedisClearResult:
|
|
40
|
+
"""Результат попытки очистки БД Redis — никогда не бросает исключение наружу."""
|
|
41
|
+
|
|
42
|
+
ok: bool
|
|
43
|
+
message: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _encode_command(*args: str) -> bytes:
|
|
47
|
+
"""Кодирует команду в формате RESP (массив bulk-строк) — то, что понимает redis-server."""
|
|
48
|
+
parts = [f"*{len(args)}\r\n".encode("ascii")]
|
|
49
|
+
for arg in args:
|
|
50
|
+
raw = str(arg).encode("utf-8")
|
|
51
|
+
parts.append(f"${len(raw)}\r\n".encode("ascii"))
|
|
52
|
+
parts.append(raw)
|
|
53
|
+
parts.append(b"\r\n")
|
|
54
|
+
return b"".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_line(sock) -> bytes:
|
|
58
|
+
"""
|
|
59
|
+
Читает одну CRLF-терминированную строку ответа Redis (побайтово — простая,
|
|
60
|
+
не самая быстрая реализация, но ответы ``+OK``/``-ERR ...`` короткие,
|
|
61
|
+
производительность не критична для разовой административной операции).
|
|
62
|
+
"""
|
|
63
|
+
buf = b""
|
|
64
|
+
while not buf.endswith(b"\r\n"):
|
|
65
|
+
chunk = sock.recv(1)
|
|
66
|
+
if not chunk:
|
|
67
|
+
raise RedisClearError("соединение с Redis закрыто неожиданно (до получения ответа)")
|
|
68
|
+
buf += chunk
|
|
69
|
+
return buf[:-2]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def flush_db(host: str, port: int, db: int, *, timeout: float = _DEFAULT_TIMEOUT_SEC) -> RedisClearResult:
|
|
73
|
+
"""
|
|
74
|
+
Подключается к ``host:port``, выполняет ``SELECT <db>`` затем ``FLUSHDB``.
|
|
75
|
+
|
|
76
|
+
Никогда не бросает исключение наружу (сетевые/протокольные ошибки
|
|
77
|
+
заворачиваются в ``RedisClearResult(ok=False, ...)``) — вызывающая сторона
|
|
78
|
+
(HTTP-слой хаба) должна показать понятный текст пользователю, а не 500-ю.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
sock = socket.create_connection((host, port), timeout=timeout)
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
return RedisClearResult(False, f"не удалось подключиться к Redis {host}:{port}: {exc}")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
sock.settimeout(timeout)
|
|
87
|
+
|
|
88
|
+
sock.sendall(_encode_command("SELECT", str(db)))
|
|
89
|
+
reply = _read_line(sock)
|
|
90
|
+
if not reply.startswith(b"+"):
|
|
91
|
+
return RedisClearResult(
|
|
92
|
+
False, f"Redis отклонил SELECT {db}: {reply.decode('utf-8', errors='replace')}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
sock.sendall(_encode_command("FLUSHDB"))
|
|
96
|
+
reply = _read_line(sock)
|
|
97
|
+
if not reply.startswith(b"+"):
|
|
98
|
+
return RedisClearResult(
|
|
99
|
+
False, f"Redis отклонил FLUSHDB: {reply.decode('utf-8', errors='replace')}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return RedisClearResult(True, f"Redis db={db} очищен ({host}:{port})")
|
|
103
|
+
except (OSError, RedisClearError) as exc:
|
|
104
|
+
return RedisClearResult(False, f"ошибка обращения к Redis {host}:{port}: {exc}")
|
|
105
|
+
finally:
|
|
106
|
+
try:
|
|
107
|
+
sock.close()
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- best-effort резолвер Redis-параметров из конфига стенда (фолбэк реестра) ---
|
|
113
|
+
#
|
|
114
|
+
# Типичные для BPMSoft/.NET места: <stand_dir>\ConnectionStrings.config
|
|
115
|
+
# (классический .NET connectionStrings-XML), <stand_dir>\appsettings.json
|
|
116
|
+
# (JSON-конфиг ASP.NET Core), либо любой другой *.config/*.json в КОРНЕ
|
|
117
|
+
# каталога стенда (без рекурсии в подпапки — не сканируем весь стенд).
|
|
118
|
+
#
|
|
119
|
+
# Best-effort: любая ошибка чтения/парсинга конкретного файла — файл просто
|
|
120
|
+
# пропускается, никогда не бросаем исключение наружу. Формат Redis-строки у
|
|
121
|
+
# BPMSoft заранее не задокументирован нам достоверно — резолвер ищет
|
|
122
|
+
# правдоподобные пары ключ=значение (host/port/db/$db) в тексте рядом со
|
|
123
|
+
# словом "redis", это эвристика, а не парсер конкретного формата.
|
|
124
|
+
|
|
125
|
+
_REDIS_CONFIG_FILENAMES = ("ConnectionStrings.config", "appsettings.json")
|
|
126
|
+
_REDIS_CONFIG_EXTENSIONS = (".config", ".json")
|
|
127
|
+
# Не читаем большие файлы целиком без нужды — конфиги стенда обычно ↓ 200KB,
|
|
128
|
+
# 2MB — щедрый потолок на случай нетипичного файла.
|
|
129
|
+
_MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024
|
|
130
|
+
|
|
131
|
+
_REDIS_KV_RE = re.compile(r"(?i)(\$db|\bdb\b|\bhost\b|\bport\b)\s*=\s*([^;,\s\"']+)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_redis_connection_string(text: str) -> Optional[dict]:
|
|
135
|
+
"""
|
|
136
|
+
Ищет пары ``host=``/``port=``/``db=``/``$db=`` в произвольном тексте
|
|
137
|
+
(типично — значение атрибута ``connectionString`` или кусок JSON-строки).
|
|
138
|
+
|
|
139
|
+
``db`` обязателен — без него результат бесполезен для очистки (нельзя
|
|
140
|
+
угадывать номер БД), при отсутствии возвращает ``None``. ``host``/``port``
|
|
141
|
+
— опциональны, дефолты ``127.0.0.1``/``6379`` (та же дисциплина, что и у
|
|
142
|
+
резолвера из реестра, см. ``standkit_hub.server._redis_from_registry``).
|
|
143
|
+
"""
|
|
144
|
+
if not text:
|
|
145
|
+
return None
|
|
146
|
+
found: dict[str, Any] = {}
|
|
147
|
+
for m in _REDIS_KV_RE.finditer(text):
|
|
148
|
+
key = m.group(1).lower().lstrip("$")
|
|
149
|
+
val = m.group(2).strip().rstrip(";,")
|
|
150
|
+
if key == "db" and "db" not in found:
|
|
151
|
+
try:
|
|
152
|
+
found["db"] = int(val)
|
|
153
|
+
except ValueError:
|
|
154
|
+
continue
|
|
155
|
+
elif key == "host" and "host" not in found:
|
|
156
|
+
found["host"] = val
|
|
157
|
+
elif key == "port" and "port" not in found:
|
|
158
|
+
try:
|
|
159
|
+
found["port"] = int(val)
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
if "db" not in found:
|
|
163
|
+
return None
|
|
164
|
+
return {"host": found.get("host", "127.0.0.1"), "port": found.get("port", 6379), "db": found["db"]}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_connection_string_from_config_xml(text: str) -> Optional[str]:
|
|
168
|
+
"""
|
|
169
|
+
Ищет ``<add name="..." connectionString="..."/>`` записи, чьё имя/тег
|
|
170
|
+
содержит "redis" (регистронезависимо) — стандартный вид ``<connectionStrings>``
|
|
171
|
+
секции .NET-конфига (``ConnectionStrings.config``/``Web.config``/``App.config``).
|
|
172
|
+
"""
|
|
173
|
+
for m in re.finditer(r"<add\b[^>]*/?>", text, re.IGNORECASE):
|
|
174
|
+
tag = m.group(0)
|
|
175
|
+
if "redis" not in tag.lower():
|
|
176
|
+
continue
|
|
177
|
+
cs_m = re.search(r'connectionString\s*=\s*"([^"]*)"', tag, re.IGNORECASE)
|
|
178
|
+
if cs_m and cs_m.group(1):
|
|
179
|
+
return cs_m.group(1)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _windowed_redis_search(text: str) -> Optional[dict]:
|
|
184
|
+
"""Запасной вариант для произвольного текста: окно ~350 символов вокруг первого упоминания "redis"."""
|
|
185
|
+
lower = text.lower()
|
|
186
|
+
idx = lower.find("redis")
|
|
187
|
+
if idx == -1:
|
|
188
|
+
return None
|
|
189
|
+
window = text[max(0, idx - 50): idx + 300]
|
|
190
|
+
return _parse_redis_connection_string(window)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_host_port_db_from_json_dict(d: dict) -> Optional[dict]:
|
|
194
|
+
"""
|
|
195
|
+
Достаёт host/port/db из JSON-объекта, ПОХОЖЕГО на настройки redis
|
|
196
|
+
(напр. ``{"Host": "127.0.0.1", "Port": 6379, "Db": 2}``), либо, если это
|
|
197
|
+
обёртка над строкой подключения (``{"ConnectionString": "host=..;db=.."}``),
|
|
198
|
+
делегирует в ``_parse_redis_connection_string``.
|
|
199
|
+
"""
|
|
200
|
+
lower = {k.lower(): v for k, v in d.items() if isinstance(k, str)}
|
|
201
|
+
db_val = None
|
|
202
|
+
for key in ("db", "database", "number", "redisdb", "redis_db"):
|
|
203
|
+
if key in lower:
|
|
204
|
+
try:
|
|
205
|
+
db_val = int(lower[key])
|
|
206
|
+
break
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
continue
|
|
209
|
+
if db_val is None:
|
|
210
|
+
for key in ("connectionstring", "connection_string", "value", "url"):
|
|
211
|
+
if key in lower and isinstance(lower[key], str):
|
|
212
|
+
parsed = _parse_redis_connection_string(lower[key])
|
|
213
|
+
if parsed:
|
|
214
|
+
return parsed
|
|
215
|
+
return None
|
|
216
|
+
host = lower.get("host") or lower.get("hostname") or "127.0.0.1"
|
|
217
|
+
port_raw = lower.get("port")
|
|
218
|
+
try:
|
|
219
|
+
port = int(port_raw) if port_raw is not None else 6379
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
port = 6379
|
|
222
|
+
return {"host": str(host), "port": port, "db": db_val}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _find_redis_in_json(data: Any) -> Optional[dict]:
|
|
226
|
+
"""
|
|
227
|
+
Рекурсивно ищет "redis"-ключ (регистронезависимо) в разобранном JSON
|
|
228
|
+
(dict/list произвольной вложенности — ``appsettings.json`` обычно
|
|
229
|
+
группирует секции, поэтому Redis может лежать не в корне) и пытается
|
|
230
|
+
извлечь host/port/db из значения (строка-подключение ИЛИ объект).
|
|
231
|
+
"""
|
|
232
|
+
if isinstance(data, dict):
|
|
233
|
+
for key, value in data.items():
|
|
234
|
+
if isinstance(key, str) and "redis" in key.lower():
|
|
235
|
+
if isinstance(value, str):
|
|
236
|
+
parsed = _parse_redis_connection_string(value)
|
|
237
|
+
if parsed:
|
|
238
|
+
return parsed
|
|
239
|
+
elif isinstance(value, dict):
|
|
240
|
+
parsed = _extract_host_port_db_from_json_dict(value)
|
|
241
|
+
if parsed:
|
|
242
|
+
return parsed
|
|
243
|
+
for value in data.values():
|
|
244
|
+
result = _find_redis_in_json(value)
|
|
245
|
+
if result:
|
|
246
|
+
return result
|
|
247
|
+
elif isinstance(data, list):
|
|
248
|
+
for item in data:
|
|
249
|
+
result = _find_redis_in_json(item)
|
|
250
|
+
if result:
|
|
251
|
+
return result
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _try_extract_redis_from_file(path: Path) -> Optional[dict]:
|
|
256
|
+
"""Best-effort извлечение Redis-параметров из ОДНОГО файла. Никогда не бросает исключение."""
|
|
257
|
+
try:
|
|
258
|
+
if path.stat().st_size > _MAX_CONFIG_FILE_BYTES:
|
|
259
|
+
return None
|
|
260
|
+
text = path.read_text(encoding="utf-8-sig", errors="ignore")
|
|
261
|
+
except OSError:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
if path.suffix.lower() == ".json":
|
|
265
|
+
try:
|
|
266
|
+
data = json.loads(text)
|
|
267
|
+
except (json.JSONDecodeError, ValueError, RecursionError):
|
|
268
|
+
return None
|
|
269
|
+
return _find_redis_in_json(data)
|
|
270
|
+
|
|
271
|
+
# .config и прочее текстовое — сначала штатный вид <add .../>, иначе
|
|
272
|
+
# запасной поиск "redis" по окну текста.
|
|
273
|
+
cs = _extract_connection_string_from_config_xml(text)
|
|
274
|
+
if cs:
|
|
275
|
+
parsed = _parse_redis_connection_string(cs)
|
|
276
|
+
if parsed:
|
|
277
|
+
return parsed
|
|
278
|
+
return _windowed_redis_search(text)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def resolve_redis_from_stand_config(stand_dir: Optional[str]) -> Optional[dict]:
|
|
282
|
+
"""
|
|
283
|
+
Best-effort резолвер параметров подключения к Redis ИЗ КОНФИГА СТЕНДА —
|
|
284
|
+
фолбэк, когда реестр BPMkit не содержит ``extra["redis_db"]`` (частый
|
|
285
|
+
случай, см. docstring модуля). Возвращает ``{"host": str, "port": int,
|
|
286
|
+
"db": int}`` либо ``None``, если ничего похожего на redis-подключение с
|
|
287
|
+
номером БД не нашлось (или ``stand_dir`` пуст/не существует).
|
|
288
|
+
|
|
289
|
+
Порядок поиска в ``stand_dir`` (без рекурсии в подпапки):
|
|
290
|
+
1. ``ConnectionStrings.config``, ``appsettings.json`` (типичные для
|
|
291
|
+
BPMSoft/.NET имена) — в этом порядке;
|
|
292
|
+
2. остальные ``*.config``/``*.json`` файлы в корне каталога стенда.
|
|
293
|
+
|
|
294
|
+
Никогда не бросает исключение — отсутствующий/битый/нечитаемый файл
|
|
295
|
+
просто пропускается (следующий кандидат).
|
|
296
|
+
"""
|
|
297
|
+
if not stand_dir:
|
|
298
|
+
return None
|
|
299
|
+
root = Path(stand_dir)
|
|
300
|
+
if not root.is_dir():
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
candidates: list[Path] = []
|
|
304
|
+
for name in _REDIS_CONFIG_FILENAMES:
|
|
305
|
+
p = root / name
|
|
306
|
+
if p.is_file():
|
|
307
|
+
candidates.append(p)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
entries = sorted(root.iterdir())
|
|
311
|
+
except OSError:
|
|
312
|
+
entries = []
|
|
313
|
+
for p in entries:
|
|
314
|
+
try:
|
|
315
|
+
if not p.is_file() or p in candidates:
|
|
316
|
+
continue
|
|
317
|
+
except OSError:
|
|
318
|
+
continue
|
|
319
|
+
if p.suffix.lower() in _REDIS_CONFIG_EXTENSIONS:
|
|
320
|
+
candidates.append(p)
|
|
321
|
+
|
|
322
|
+
for path in candidates:
|
|
323
|
+
result = _try_extract_redis_from_file(path)
|
|
324
|
+
if result:
|
|
325
|
+
return result
|
|
326
|
+
return None
|
standkit_hub/security.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security-примитивы веб-хаба standkit_hub: сессионный токен, извлечение
|
|
3
|
+
предъявленного токена (cookie/заголовок), anti-CSRF/cross-origin проверка
|
|
4
|
+
мутаций, санитайзинг путей статики.
|
|
5
|
+
|
|
6
|
+
По образцу ``standkit_agent/security.py`` (fail-closed bind-проверка,
|
|
7
|
+
``hmac.compare_digest`` вместо ``==``), но адаптировано под браузерный клиент:
|
|
8
|
+
сессия — HttpOnly-cookie, а мутации (POST/DELETE) дополнительно требуют
|
|
9
|
+
дублирующий заголовок ``X-Standkit-Token`` (double-submit паттерн: сторонний
|
|
10
|
+
сайт может заставить браузер отправить cookie, но не может ни прочитать её,
|
|
11
|
+
ни подделать наш кастомный заголовок для cross-origin запроса) плюс сверку
|
|
12
|
+
``Origin``/``Referer`` с адресом самого хаба.
|
|
13
|
+
|
|
14
|
+
Хаб — та же RCE-поверхность, что и headless-агент (управляет процессами
|
|
15
|
+
через ``standkit.lifecycle``/``standkit_hub.agent_control``), поэтому
|
|
16
|
+
fail-closed bind-проверка (``validate_bind_security``/``InsecureBindError``/
|
|
17
|
+
``is_loopback_host``) переиспользуется напрямую из ``standkit_agent.security``
|
|
18
|
+
— дублировать эту логику здесь было бы источником рассинхронизации.
|
|
19
|
+
|
|
20
|
+
STDLIB-ONLY: ``hmac``, ``re``, ``secrets``, ``urllib.parse``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hmac
|
|
26
|
+
import re
|
|
27
|
+
import secrets as _secrets_mod
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
from urllib.parse import urlparse
|
|
31
|
+
|
|
32
|
+
# Реэкспорт — единственный источник fail-closed bind-логики (не дублируем).
|
|
33
|
+
from standkit_agent.security import ( # noqa: F401
|
|
34
|
+
InsecureBindError,
|
|
35
|
+
is_loopback_host,
|
|
36
|
+
validate_bind_security,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SESSION_COOKIE_NAME = "standkit_session"
|
|
40
|
+
TOKEN_HEADER_NAME = "X-Standkit-Token"
|
|
41
|
+
TOKEN_QUERY_PARAM = "t"
|
|
42
|
+
|
|
43
|
+
DEFAULT_MAX_BODY_BYTES = 256 * 1024 # 256 КБ — лимит тела JSON-запроса хаба
|
|
44
|
+
DEFAULT_MAX_LOGS_N = 10_000
|
|
45
|
+
|
|
46
|
+
_COOKIE_ATTR_RE = re.compile(r"^" + re.escape(SESSION_COOKIE_NAME) + r"=([^;]*)")
|
|
47
|
+
|
|
48
|
+
# То же множество символов, что допускает реестр/агент — не дублируем правило
|
|
49
|
+
# отдельным regex'ом с шансом разъехаться, просто переиспользуем то же имя.
|
|
50
|
+
_STAND_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
|
|
51
|
+
_SECRET_REF_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_session_token() -> str:
|
|
55
|
+
"""Криптостойкий сессионный токен хаба (генерируется один раз при старте процесса)."""
|
|
56
|
+
return _secrets_mod.token_urlsafe(32)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_cookie_token(cookie_header: str) -> Optional[str]:
|
|
60
|
+
"""Достаёт значение cookie ``standkit_session`` из заголовка ``Cookie`` (без внешних зависимостей)."""
|
|
61
|
+
if not cookie_header:
|
|
62
|
+
return None
|
|
63
|
+
for part in cookie_header.split(";"):
|
|
64
|
+
part = part.strip()
|
|
65
|
+
m = _COOKIE_ATTR_RE.match(part)
|
|
66
|
+
if m:
|
|
67
|
+
return m.group(1)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tokens_match(presented: Optional[str], expected: str) -> bool:
|
|
72
|
+
"""Сравнение токенов ТОЛЬКО через ``hmac.compare_digest`` (защита от timing-атак)."""
|
|
73
|
+
if not presented:
|
|
74
|
+
return False
|
|
75
|
+
return hmac.compare_digest(presented, expected)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_local_origin(value: Optional[str], *, expected_port: int) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
True, если ``Origin``/``Referer`` указывает на loopback-хост И порт,
|
|
81
|
+
совпадающий с портом самого хаба.
|
|
82
|
+
|
|
83
|
+
Anti-CSRF проверка на мутациях (в дополнение к double-submit токену) —
|
|
84
|
+
сторонний сайт не может ни подделать заголовок ``Origin`` браузера, ни
|
|
85
|
+
прочитать наш HttpOnly-cookie, чтобы продублировать его в заголовок.
|
|
86
|
+
"""
|
|
87
|
+
if not value:
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
parsed = urlparse(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
if not is_loopback_host(parsed.hostname or ""):
|
|
94
|
+
return False
|
|
95
|
+
return parsed.port == expected_port
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sanitize_static_path(web_dir: Path, rel_path: str) -> Optional[Path]:
|
|
99
|
+
"""
|
|
100
|
+
Резолвит путь внутри ``web_dir`` для статики, отклоняя traversal
|
|
101
|
+
(``..``, абсолютные пути в компонентах). Возвращает ``None``, если путь
|
|
102
|
+
небезопасен, не существует или указывает не на файл.
|
|
103
|
+
"""
|
|
104
|
+
if not rel_path or rel_path.startswith("/") or ".." in Path(rel_path).parts:
|
|
105
|
+
return None
|
|
106
|
+
web_dir_resolved = web_dir.resolve()
|
|
107
|
+
candidate = (web_dir / rel_path).resolve()
|
|
108
|
+
try:
|
|
109
|
+
candidate.relative_to(web_dir_resolved)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return None
|
|
112
|
+
if not candidate.is_file():
|
|
113
|
+
return None
|
|
114
|
+
return candidate
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_stand_name(name: str) -> bool:
|
|
118
|
+
"""Валидация имени стенда из URL — тот же whitelist, что и у агента."""
|
|
119
|
+
return bool(name) and bool(_STAND_NAME_RE.match(name))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_secret_ref(ref: str) -> bool:
|
|
123
|
+
"""Валидация ссылки на секрет из URL (буквы/цифры/``_.:-``, кап длины)."""
|
|
124
|
+
return bool(ref) and bool(_SECRET_REF_RE.match(ref))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clamp_logs_n(raw: str, *, max_n: int = DEFAULT_MAX_LOGS_N) -> int:
|
|
128
|
+
"""Парсит и капит параметр ``n`` запроса логов. Бросает ``ValueError`` на некорректный ввод."""
|
|
129
|
+
n = int(raw)
|
|
130
|
+
if n < 0:
|
|
131
|
+
raise ValueError("n не может быть отрицательным")
|
|
132
|
+
return min(n, max_n)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate_content_length(header_value: Optional[str], *, max_bytes: int = DEFAULT_MAX_BODY_BYTES) -> int:
|
|
136
|
+
"""Разбирает ``Content-Length`` и проверяет лимит тела запроса ДО его фактического чтения из сокета."""
|
|
137
|
+
raw = header_value if header_value not in (None, "") else "0"
|
|
138
|
+
try:
|
|
139
|
+
n = int(raw)
|
|
140
|
+
except (TypeError, ValueError) as exc:
|
|
141
|
+
raise ValueError(f"некорректный Content-Length: {raw!r}") from exc
|
|
142
|
+
if n < 0:
|
|
143
|
+
raise ValueError("Content-Length не может быть отрицательным")
|
|
144
|
+
if n > max_bytes:
|
|
145
|
+
raise ValueError(f"тело запроса превышает лимит {max_bytes} байт")
|
|
146
|
+
return n
|