standkit 0.6.0__tar.gz → 0.6.1__tar.gz
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-0.6.0 → standkit-0.6.1}/LICENSE +21 -21
- {standkit-0.6.0/standkit.egg-info → standkit-0.6.1}/PKG-INFO +1 -1
- {standkit-0.6.0 → standkit-0.6.1}/docs/BACKLOG.md +2 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/CHANGELOG.md +22 -0
- {standkit-0.6.0 → standkit-0.6.1}/pyproject.toml +1 -1
- {standkit-0.6.0 → standkit-0.6.1}/standkit/__init__.py +1 -1
- {standkit-0.6.0 → standkit-0.6.1/standkit.egg-info}/PKG-INFO +1 -1
- {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/SOURCES.txt +1 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/__init__.py +9 -9
- {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/audit.py +93 -93
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/__main__.py +21 -2
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/server.py +105 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/app.js +67 -21
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/index.html +9 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/style.css +25 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_agent_security.py +303 -303
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_agent_server_integration.py +192 -192
- standkit-0.6.1/tests/test_hub_single_instance.py +200 -0
- {standkit-0.6.0 → standkit-0.6.1}/MANIFEST.in +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/README.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/SECURITY.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/ARCHITECTURE.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/HOSTING.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/REMOTE_STANDS.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/ROADMAP.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0001-hosting-backends.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0002-k8s-backend.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0003-remote-log-browsing.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0004-iis-site-scoped-lifecycle.md +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/projects.sample.json +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/setup.cfg +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/adopt.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/health.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/hosting.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/lifecycle.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/logs.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/models.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/platform.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/registry.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit/secrets.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/dependency_links.txt +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/entry_points.txt +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/requires.txt +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/top_level.txt +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/__main__.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/security.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/server.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/__init__.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/agent_control.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/assets/bpmkit-icon.ico +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/assets/icon.png +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/client.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/config.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/logs_browser.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/poller.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/redis_min.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/security.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/shortcut.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/bpmkit-logo.svg +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/favicon.png +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/favicon.svg +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/manifest.webmanifest +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_adopt.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_health.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hosting.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_agent_control.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_config.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_fast_paint.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_logs_browser.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_pwa_and_compact.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_redis_min.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_register.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_server.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_shortcut.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_lifecycle.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_logs.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_models.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_registry.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_registry_resolver.py +0 -0
- {standkit-0.6.0 → standkit-0.6.1}/tests/test_secrets.py +0 -0
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 standkit contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 standkit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standkit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
|
|
5
5
|
Author: standkit contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,6 +61,8 @@ TCP-порту** — их достаточно для дашборда.
|
|
|
61
61
|
| Ярлык на рабочем столе для **macOS** | осознанно не реализовано — возвращается понятный статус | `standkit_hub/shortcut.py` (докстринг) |
|
|
62
62
|
| Нативный трей (иконка со сводным состоянием, баллоны при падении) | не реализовано — потребовало бы `pystray`/`Pillow` против инварианта stdlib-only. Вместо него PWA-установка и компактный режим `?view=compact` | `standkit_hub/web/manifest.webmanifest` |
|
|
63
63
|
| Автозапуск хаба при входе в систему | не реализовано; механика ярлыка есть | `standkit_hub/shortcut.py` |
|
|
64
|
+
| Остановка хаба, когда дашборд никто не смотрит (idle-shutdown / heartbeat от фронта) | не реализовано — запущенный ярлыком процесс живёт под `pythonw` без окна до перезагрузки или ручного снятия. Дубли при повторном клике по ярлыку закрыты single-instance проверкой, но сам «вечный» процесс остаётся | `standkit_hub/__main__.py` (`serve_forever` без таймаута), `standkit_hub/server.py::probe_hub_instance` |
|
|
65
|
+
| Остановка хаба из интерфейса (кнопка «Завершить работу диспетчера») | не реализовано — единственный штатный способ Ctrl+C недоступен при запуске ярлыком (консоли нет) | `standkit_hub/__main__.py` (обработка `KeyboardInterrupt`) |
|
|
64
66
|
|
|
65
67
|
## Секреты
|
|
66
68
|
|
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версии —
|
|
4
4
|
[SemVer](https://semver.org/lang/ru/). Даты — 2026 год.
|
|
5
5
|
|
|
6
|
+
## [0.6.1] — 2026-07-27
|
|
7
|
+
|
|
8
|
+
### Исправлено
|
|
9
|
+
- **Повторный запуск по ярлыку больше не поднимает второй диспетчер.** Ярлык
|
|
10
|
+
стартует хаб через `pythonw.exe`, окна нет, а закрытие браузера процесс не
|
|
11
|
+
останавливает — idle-shutdown у хаба нет. Поэтому второй клик по ярлыку
|
|
12
|
+
запускал ещё один экземпляр: два фоновых поллера дёргали health-пробы и
|
|
13
|
+
внешние команды бэкендов хостинга над одним `projects.json`, а окно
|
|
14
|
+
открывалось на другом origin со своей копией localStorage (сбрасывалась
|
|
15
|
+
тема). На Windows было хуже, чем предполагалось: из-за `SO_REUSEADDR` bind
|
|
16
|
+
на уже слушаемый 8770 проходил успешно, и два хаба молча делили один порт.
|
|
17
|
+
Теперь перед стартом проверяется, не работает ли диспетчер уже — если да,
|
|
18
|
+
просто открывается браузер на нём. Откат на свободный порт сохранён для
|
|
19
|
+
случая, когда 8770 занял чужой сервис.
|
|
20
|
+
- **Дашборд честно сообщает, что диспетчер остановлен.** Раньше вкладка с
|
|
21
|
+
умершим хабом продолжала показывать последний снапшот как живой, а
|
|
22
|
+
единственными признаками были красная точка в углу и браузерное
|
|
23
|
+
`Failed to fetch` в тостах. Теперь показывается баннер с объяснением и тем,
|
|
24
|
+
что делать, устаревшая таблица гасится, а сетевые ошибки и 401 («сессия не
|
|
25
|
+
подтверждена» после перезапуска диспетчера) переведены на понятный язык.
|
|
26
|
+
|
|
6
27
|
## [0.6.0] — 2026-07-27
|
|
7
28
|
|
|
8
29
|
### Добавлено
|
|
@@ -104,6 +125,7 @@
|
|
|
104
125
|
- Первый публичный релиз ядра `standkit` + агента `standkit_agent` + веб-дашборда
|
|
105
126
|
`standkit_hub` на PyPI; фронт-README, `docs/REMOTE_STANDS.md`, скриншоты.
|
|
106
127
|
|
|
128
|
+
[0.6.1]: https://pypi.org/project/standkit/0.6.1/
|
|
107
129
|
[0.6.0]: https://pypi.org/project/standkit/0.6.0/
|
|
108
130
|
[0.5.2]: https://pypi.org/project/standkit/0.5.2/
|
|
109
131
|
[0.5.1]: https://pypi.org/project/standkit/0.5.1/
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "standkit"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standkit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
|
|
5
5
|
Author: standkit contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""
|
|
2
|
-
standkit_agent — лёгкий headless-демон на хосте стенда: ядро standkit +
|
|
3
|
-
крошечный HTTP/RPC сервер (stdlib-only: http.server, subprocess, socket,
|
|
4
|
-
urllib). Кроссплатформенный (Windows/Linux). Без Qt и без сторонних
|
|
5
|
-
зависимостей — специально, чтобы агент можно было развернуть на "голом"
|
|
6
|
-
хосте стенда без сборки колёс под конкретную ОС/архитектуру.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
__all__: list[str] = []
|
|
1
|
+
"""
|
|
2
|
+
standkit_agent — лёгкий headless-демон на хосте стенда: ядро standkit +
|
|
3
|
+
крошечный HTTP/RPC сервер (stdlib-only: http.server, subprocess, socket,
|
|
4
|
+
urllib). Кроссплатформенный (Windows/Linux). Без Qt и без сторонних
|
|
5
|
+
зависимостей — специально, чтобы агент можно было развернуть на "голом"
|
|
6
|
+
хосте стенда без сборки колёс под конкретную ОС/архитектуру.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
|
|
3
|
-
DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
|
|
4
|
-
стенда.
|
|
5
|
-
|
|
6
|
-
Формат одной строки (JSON-объект, без вложенных переводов строк):
|
|
7
|
-
{
|
|
8
|
-
"ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
|
|
9
|
-
"src_ip": "10.0.0.5",
|
|
10
|
-
"identity": "control", # control|readonly|<CN>|"-"
|
|
11
|
-
"method": "POST",
|
|
12
|
-
"path": "/stand/demo/restart",
|
|
13
|
-
"action": "restart",
|
|
14
|
-
"result": "ok", # ok|denied|error
|
|
15
|
-
"code": 200
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
|
|
19
|
-
уровня "какой скоуп/CN использован", не сам токен.
|
|
20
|
-
|
|
21
|
-
STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
|
|
22
|
-
файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
|
|
23
|
-
конфигурацией логирования встраивающего процесса).
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
import json
|
|
29
|
-
import logging
|
|
30
|
-
from datetime import datetime, timezone
|
|
31
|
-
from pathlib import Path
|
|
32
|
-
from typing import Optional
|
|
33
|
-
|
|
34
|
-
DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
|
|
35
|
-
|
|
36
|
-
_LOGGER_NAME = "standkit_agent.audit"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
|
|
40
|
-
"""
|
|
41
|
-
Возвращает (создавая при необходимости) файловый логгер аудита.
|
|
42
|
-
|
|
43
|
-
Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
|
|
44
|
-
хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
|
|
45
|
-
вызываться многократно в одном процессе).
|
|
46
|
-
"""
|
|
47
|
-
p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
|
|
48
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
|
|
50
|
-
logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
|
|
51
|
-
logger = logging.getLogger(logger_name)
|
|
52
|
-
logger.setLevel(logging.INFO)
|
|
53
|
-
logger.propagate = False # не утекает в root-логгер встраивающего процесса
|
|
54
|
-
|
|
55
|
-
already_attached = any(
|
|
56
|
-
isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
|
|
57
|
-
for h in logger.handlers
|
|
58
|
-
)
|
|
59
|
-
if not already_attached:
|
|
60
|
-
handler = logging.FileHandler(p, encoding="utf-8")
|
|
61
|
-
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
62
|
-
logger.addHandler(handler)
|
|
63
|
-
return logger
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def audit_event(
|
|
67
|
-
logger: logging.Logger,
|
|
68
|
-
*,
|
|
69
|
-
src_ip: str,
|
|
70
|
-
identity: str,
|
|
71
|
-
method: str,
|
|
72
|
-
path: str,
|
|
73
|
-
action: str,
|
|
74
|
-
result: str,
|
|
75
|
-
code: int,
|
|
76
|
-
) -> None:
|
|
77
|
-
"""Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
|
|
78
|
-
entry = {
|
|
79
|
-
"ts": datetime.now(timezone.utc).isoformat(),
|
|
80
|
-
"src_ip": src_ip,
|
|
81
|
-
"identity": identity or "-",
|
|
82
|
-
"method": method,
|
|
83
|
-
"path": path,
|
|
84
|
-
"action": action,
|
|
85
|
-
"result": result,
|
|
86
|
-
"code": code,
|
|
87
|
-
}
|
|
88
|
-
try:
|
|
89
|
-
logger.info(json.dumps(entry, ensure_ascii=False))
|
|
90
|
-
except Exception:
|
|
91
|
-
# Аудит-лог не должен ронять обработку запроса ни при каких
|
|
92
|
-
# обстоятельствах (диск полон, права на файл и т.п.).
|
|
93
|
-
pass
|
|
1
|
+
"""
|
|
2
|
+
Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
|
|
3
|
+
DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
|
|
4
|
+
стенда.
|
|
5
|
+
|
|
6
|
+
Формат одной строки (JSON-объект, без вложенных переводов строк):
|
|
7
|
+
{
|
|
8
|
+
"ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
|
|
9
|
+
"src_ip": "10.0.0.5",
|
|
10
|
+
"identity": "control", # control|readonly|<CN>|"-"
|
|
11
|
+
"method": "POST",
|
|
12
|
+
"path": "/stand/demo/restart",
|
|
13
|
+
"action": "restart",
|
|
14
|
+
"result": "ok", # ok|denied|error
|
|
15
|
+
"code": 200
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
|
|
19
|
+
уровня "какой скоуп/CN использован", не сам токен.
|
|
20
|
+
|
|
21
|
+
STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
|
|
22
|
+
файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
|
|
23
|
+
конфигурацией логирования встраивающего процесса).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
|
|
35
|
+
|
|
36
|
+
_LOGGER_NAME = "standkit_agent.audit"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
|
|
40
|
+
"""
|
|
41
|
+
Возвращает (создавая при необходимости) файловый логгер аудита.
|
|
42
|
+
|
|
43
|
+
Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
|
|
44
|
+
хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
|
|
45
|
+
вызываться многократно в одном процессе).
|
|
46
|
+
"""
|
|
47
|
+
p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
|
|
48
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
|
|
51
|
+
logger = logging.getLogger(logger_name)
|
|
52
|
+
logger.setLevel(logging.INFO)
|
|
53
|
+
logger.propagate = False # не утекает в root-логгер встраивающего процесса
|
|
54
|
+
|
|
55
|
+
already_attached = any(
|
|
56
|
+
isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
|
|
57
|
+
for h in logger.handlers
|
|
58
|
+
)
|
|
59
|
+
if not already_attached:
|
|
60
|
+
handler = logging.FileHandler(p, encoding="utf-8")
|
|
61
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
62
|
+
logger.addHandler(handler)
|
|
63
|
+
return logger
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def audit_event(
|
|
67
|
+
logger: logging.Logger,
|
|
68
|
+
*,
|
|
69
|
+
src_ip: str,
|
|
70
|
+
identity: str,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
action: str,
|
|
74
|
+
result: str,
|
|
75
|
+
code: int,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
|
|
78
|
+
entry = {
|
|
79
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
"src_ip": src_ip,
|
|
81
|
+
"identity": identity or "-",
|
|
82
|
+
"method": method,
|
|
83
|
+
"path": path,
|
|
84
|
+
"action": action,
|
|
85
|
+
"result": result,
|
|
86
|
+
"code": code,
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
logger.info(json.dumps(entry, ensure_ascii=False))
|
|
90
|
+
except Exception:
|
|
91
|
+
# Аудит-лог не должен ронять обработку запроса ни при каких
|
|
92
|
+
# обстоятельствах (диск полон, права на файл и т.п.).
|
|
93
|
+
pass
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
(схема+хост+ПОРТ) является ключом браузерного localStorage и HTTP-кэша, и на
|
|
9
9
|
прежнем эфемерном порту каждый запуск давал новый origin — тема не
|
|
10
10
|
запоминалась, кэш статики был вечно холодным, закладка протухала. Если порт
|
|
11
|
-
занят, хаб не
|
|
11
|
+
занят, хаб не падает: сначала проверяет, не занял ли его НАШ ЖЕ работающий
|
|
12
|
+
экземпляр (single-instance, см. ``server.probe_hub_instance``) — тогда просто
|
|
13
|
+
открывает браузер на нём и выходит, не плодя второй фоновый поллер; если порт
|
|
14
|
+
занял чужой сервис — откатывается на эфемерный и честно об этом пишет
|
|
12
15
|
(``--port 0`` — явная просьба эфемерного порта). Флаг
|
|
13
16
|
``--desktop`` — опциональная нативная оболочка через ``pywebview`` (extra
|
|
14
17
|
``standkit[desktop]``); при отсутствии пакета хаб печатает понятное
|
|
@@ -30,7 +33,7 @@ from pathlib import Path
|
|
|
30
33
|
|
|
31
34
|
from standkit_hub.config import HubConfig
|
|
32
35
|
from standkit_hub.security import InsecureBindError, generate_session_token
|
|
33
|
-
from standkit_hub.server import DEFAULT_HUB_PORT, bind_hub_server
|
|
36
|
+
from standkit_hub.server import DEFAULT_HUB_PORT, HubAlreadyRunning, bind_hub_server
|
|
34
37
|
from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_shortcut
|
|
35
38
|
|
|
36
39
|
|
|
@@ -114,6 +117,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
114
117
|
insecure=args.insecure,
|
|
115
118
|
on_fallback=_report_port_busy,
|
|
116
119
|
)
|
|
120
|
+
except HubAlreadyRunning as exc:
|
|
121
|
+
# Второй запуск по ярлыку (обычный сценарий: окно браузера закрыли, а
|
|
122
|
+
# процесс под pythonw остался жить — idle-shutdown у хаба нет). Второй
|
|
123
|
+
# сервер здесь не нужен и вреден: два фоновых поллера над одним
|
|
124
|
+
# реестром плюс разъехавшийся localStorage на другом origin. Просто
|
|
125
|
+
# открываем браузер на уже работающем экземпляре.
|
|
126
|
+
#
|
|
127
|
+
# Токен в URL НЕ подставляем — у нас его нет (он сгенерирован в чужом
|
|
128
|
+
# процессе). Работающий экземпляр узнает браузер по сессионной cookie,
|
|
129
|
+
# выданной при первом открытии; если cookie не пережила полное
|
|
130
|
+
# закрытие браузера, дашборд честно скажет об этом (см. app.js,
|
|
131
|
+
# разбор 401), и достаточно перезапустить диспетчер.
|
|
132
|
+
print(f"[standkit-hub] диспетчер уже работает на {exc.host}:{exc.port} — открываю его, второй не запускаю")
|
|
133
|
+
if not args.no_browser:
|
|
134
|
+
webbrowser.open(exc.url)
|
|
135
|
+
return 0
|
|
117
136
|
except InsecureBindError as exc:
|
|
118
137
|
print(f"[standkit-hub] {exc}", file=sys.stderr)
|
|
119
138
|
return 1
|
|
@@ -86,6 +86,10 @@ from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_sh
|
|
|
86
86
|
# см. ``bind_hub_server``: откат на эфемерный с явным сообщением, а не падение.
|
|
87
87
|
DEFAULT_HUB_PORT = 8770
|
|
88
88
|
|
|
89
|
+
# Заголовок-опознаватель хаба, который ставится на КАЖДЫЙ ответ (см.
|
|
90
|
+
# ``Handler.end_headers``) и читается ``probe_hub_instance``.
|
|
91
|
+
HUB_IDENTITY_HEADER = "X-Standkit-Hub"
|
|
92
|
+
|
|
89
93
|
_STAND_ACTION_RE = re.compile(
|
|
90
94
|
r"^/api/stand/(?P<name>[^/]+)/(?P<action>status|logs|start|stop|restart|adopt|state|redis-clear)$"
|
|
91
95
|
)
|
|
@@ -553,6 +557,17 @@ def make_handler(
|
|
|
553
557
|
# чтобы не шуметь секретными путями (/api/secret/<ref>) в консоли.
|
|
554
558
|
pass
|
|
555
559
|
|
|
560
|
+
def end_headers(self) -> None:
|
|
561
|
+
# Метка «здесь живой standkit-hub» на КАЖДОМ ответе, включая 401 и
|
|
562
|
+
# 404. Нужна single-instance проверке (см. probe_hub_instance):
|
|
563
|
+
# второй запуск по ярлыку должен отличить свой уже работающий
|
|
564
|
+
# экземпляр от чужого сервиса, занявшего порт, НЕ предъявляя
|
|
565
|
+
# токена — а значит, судить можно только по неаутентифицированному
|
|
566
|
+
# ответу. Версия в значении — диагностика, не контракт: сравнивать
|
|
567
|
+
# её не нужно, важен сам факт заголовка.
|
|
568
|
+
self.send_header(HUB_IDENTITY_HEADER, _standkit_version)
|
|
569
|
+
super().end_headers()
|
|
570
|
+
|
|
556
571
|
def _send_json(self, code: int, payload: dict) -> None:
|
|
557
572
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
558
573
|
self.send_response(code)
|
|
@@ -1713,6 +1728,73 @@ def create_hub_server(
|
|
|
1713
1728
|
_PORT_BUSY_ERRNOS = frozenset({errno.EADDRINUSE, errno.EACCES})
|
|
1714
1729
|
|
|
1715
1730
|
|
|
1731
|
+
def probe_hub_instance(host: str, port: int, *, timeout: float = 1.5) -> Optional[str]:
|
|
1732
|
+
"""
|
|
1733
|
+
Отвечает ли на ``host:port`` уже работающий standkit-hub?
|
|
1734
|
+
|
|
1735
|
+
Возвращает строку версии из заголовка ``X-Standkit-Hub`` либо ``None``,
|
|
1736
|
+
если порт занят чем-то другим (или не отвечает вовремя).
|
|
1737
|
+
|
|
1738
|
+
ЗАЧЕМ. Ярлык на рабочем столе запускает хаб через ``pythonw.exe`` — без
|
|
1739
|
+
консоли. Закрытие окна браузера НЕ останавливает процесс (сервер живёт в
|
|
1740
|
+
``serve_forever``, idle-shutdown нет), поэтому повторный клик по ярлыку
|
|
1741
|
+
раньше поднимал ВТОРОЙ экземпляр: порт 8770 занят → откат на эфемерный →
|
|
1742
|
+
два фоновых поллера дёргают health-пробы и ``subprocess`` бэкендов
|
|
1743
|
+
хостинга над одним ``projects.json``, а origin у второго окна другой, то
|
|
1744
|
+
есть своя копия localStorage. Сообщение об откате уходило в ``stderr``,
|
|
1745
|
+
которого при ``pythonw`` никто не видит.
|
|
1746
|
+
|
|
1747
|
+
Запрос НАМЕРЕННО неаутентифицированный: сессионного токена работающего
|
|
1748
|
+
экземпляра у нас нет и быть не может. Поэтому опознание идёт по
|
|
1749
|
+
заголовку, который хаб ставит на любой ответ, включая 401 — отдавать
|
|
1750
|
+
токен или иную чувствительную информацию в обмен на «постучались с
|
|
1751
|
+
loopback» нельзя, это был бы обход аутентификации.
|
|
1752
|
+
|
|
1753
|
+
Сетевые ошибки здесь не исключение, а нормальный ответ «не хаб»: чужой
|
|
1754
|
+
сервис может рвать соединение, отвечать мусором или молчать. Любой сбой
|
|
1755
|
+
трактуем консервативно — ``None``, то есть «не наш», и вызывающий уходит
|
|
1756
|
+
в прежний откат на эфемерный порт.
|
|
1757
|
+
"""
|
|
1758
|
+
import http.client
|
|
1759
|
+
|
|
1760
|
+
conn = None
|
|
1761
|
+
try:
|
|
1762
|
+
conn = http.client.HTTPConnection(host, port, timeout=timeout)
|
|
1763
|
+
# HEAD, а не GET: опознавательный заголовок есть и здесь, а тело
|
|
1764
|
+
# (index.html целиком) читать незачем.
|
|
1765
|
+
conn.request("HEAD", "/")
|
|
1766
|
+
resp = conn.getresponse()
|
|
1767
|
+
value = resp.getheader(HUB_IDENTITY_HEADER)
|
|
1768
|
+
return value or None
|
|
1769
|
+
except Exception:
|
|
1770
|
+
return None
|
|
1771
|
+
finally:
|
|
1772
|
+
if conn is not None:
|
|
1773
|
+
try:
|
|
1774
|
+
conn.close()
|
|
1775
|
+
except Exception:
|
|
1776
|
+
pass
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
class HubAlreadyRunning(RuntimeError):
|
|
1780
|
+
"""
|
|
1781
|
+
На запрошенном порту уже работает standkit-hub.
|
|
1782
|
+
|
|
1783
|
+
Не ошибка в привычном смысле, а сигнал вызывающему (CLI): второй процесс
|
|
1784
|
+
поднимать не нужно, надо открыть браузер на уже работающем экземпляре.
|
|
1785
|
+
"""
|
|
1786
|
+
|
|
1787
|
+
def __init__(self, host: str, port: int, version: Optional[str] = None) -> None:
|
|
1788
|
+
self.host = host
|
|
1789
|
+
self.port = port
|
|
1790
|
+
self.version = version
|
|
1791
|
+
super().__init__(f"диспетчер уже работает на {host}:{port}")
|
|
1792
|
+
|
|
1793
|
+
@property
|
|
1794
|
+
def url(self) -> str:
|
|
1795
|
+
return f"http://{self.host}:{self.port}/"
|
|
1796
|
+
|
|
1797
|
+
|
|
1716
1798
|
def bind_hub_server(
|
|
1717
1799
|
host: str,
|
|
1718
1800
|
port: int,
|
|
@@ -1723,6 +1805,7 @@ def bind_hub_server(
|
|
|
1723
1805
|
insecure: bool = False,
|
|
1724
1806
|
poll: bool = True,
|
|
1725
1807
|
on_fallback: Optional[Callable[[int, OSError], None]] = None,
|
|
1808
|
+
single_instance: bool = True,
|
|
1726
1809
|
) -> HubHTTPServer:
|
|
1727
1810
|
"""
|
|
1728
1811
|
``create_hub_server`` + откат на эфемерный порт, если запрошенный занят.
|
|
@@ -1737,7 +1820,29 @@ def bind_hub_server(
|
|
|
1737
1820
|
``on_fallback(requested_port, exc)`` вызывается ПЕРЕД повторным bind'ом,
|
|
1738
1821
|
чтобы вызывающий (CLI) мог сообщить пользователю причину. Ошибки, не
|
|
1739
1822
|
связанные с занятостью порта, пробрасываются как есть — «честный отказ».
|
|
1823
|
+
|
|
1824
|
+
``single_instance=True`` (по умолчанию): перед bind'ом проверяем, не занял
|
|
1825
|
+
ли порт НАШ ЖЕ работающий хаб (см. ``probe_hub_instance``) — если да,
|
|
1826
|
+
второй экземпляр не поднимается, бросается ``HubAlreadyRunning``, и CLI
|
|
1827
|
+
просто открывает браузер на уже работающем. Откат на эфемерный остаётся
|
|
1828
|
+
для случая «порт занял чужой сервис»: тогда второй хаб действительно нужен.
|
|
1829
|
+
``single_instance=False`` возвращает прежнее безусловное поведение и
|
|
1830
|
+
нужен тестам, которым надо поднять два хаба подряд.
|
|
1740
1831
|
"""
|
|
1832
|
+
# Проверка ДО bind'а, а не в обработчике OSError — и это принципиально.
|
|
1833
|
+
# ``ThreadingHTTPServer.allow_reuse_address = 1`` (SO_REUSEADDR), а на
|
|
1834
|
+
# Windows SO_REUSEADDR разрешает второму процессу занять УЖЕ слушаемый
|
|
1835
|
+
# порт: bind проходит успешно, EADDRINUSE не возникает, и два хаба
|
|
1836
|
+
# оказываются на одном 8770 — входящие соединения распределяются между
|
|
1837
|
+
# ними непредсказуемо. То есть на Windows отката на эфемерный порт даже не
|
|
1838
|
+
# случалось, было тихое раздвоение (обнаружено тестом
|
|
1839
|
+
# tests/test_hub_single_instance.py). Единственный переносимый способ
|
|
1840
|
+
# узнать правду — постучаться и посмотреть, кто ответит.
|
|
1841
|
+
if single_instance and port != 0:
|
|
1842
|
+
running = probe_hub_instance(host, port)
|
|
1843
|
+
if running is not None:
|
|
1844
|
+
raise HubAlreadyRunning(host, port, running)
|
|
1845
|
+
|
|
1741
1846
|
try:
|
|
1742
1847
|
return create_hub_server(
|
|
1743
1848
|
host,
|