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