"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).""" import base64 import hmac import json import logging import re import threading import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse from . import backup, db, files, http, projects, queue, registry, relay, release, settings from .errors import CommandError, HttpMapped from .http import ApiError, Raw, int_group, int_param, mapped, not_found from .paths import backups_dir as default_backups_dir from .paths import secure_dir from .queue import ClaimedError log = logging.getLogger(__name__) MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт WEB_APP = Path(__file__).resolve().parent.parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) NOT_BUILT = ("SDLC Hub

Веб-приложение не собрано: выполните " "make web в каталоге sdlc-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») def _no_update() -> dict: return {"update_state": None, "launcher_version": None, "reported": False} class Hub: def __init__(self, db_path, backups_dir=None, release_root=None, web_dir=None, files_dir=None): self.db_path = Path(db_path) self.web_dir = Path(web_dir) if web_dir else WEB_APP self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_root = Path(release_root) if release_root else self.db_path.parent / "release" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь) # последнее из claim по каждому агенту; в памяти (R-3). reported — был ли claim с запуска хаба: до него # launcher_version неизвестен, а не null (hub-hygiene-d, решение 7) self.agent_update: dict[str, dict] = {m.name: _no_update() for m in registry.agent_modules()} self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ secure_dir(self.release_root) # release/ — 0700 при каждом старте release.migrate_layout(self.release_root) for m in registry.agent_modules(): if (self.release_root / m.name).is_dir(): secure_dir(self.release_root / m.name) # release/agent.py → release/jira/ (раскладка по модулям) conn = self.conn() settings.ensure_defaults(conn) conn.close() def conn(self): return db.connect(self.db_path) def tick(self): """Один шаг фоновых работ: уборка файлов ядра (не чаще раза в сутки), затем tick модулей. Сбой каждой работы логируется и не мешает ни другой, ни следующему тику; соединение открывается при первой работе.""" works = [("уборка файлов: сбой", self.sweep_files)] for m in registry.modules(): for fn in m.tick: label = getattr(fn, "label", getattr(fn, "__name__", m.name)) works.append((f"{label}: сбой тика", lambda conn, fn=fn: fn(self, conn))) conn = None try: for message, work in works: try: if conn is None: conn = self.conn() work(conn) except Exception: # noqa: BLE001 log.exception(message) finally: if conn is not None: conn.close() def sweep_files(self, conn, now=None): """Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано.""" now = time.time() if now is None else now if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY: return None self.last_sweep = now removed = files.sweep(conn, self.files_dir, now) if removed: log.info("уборка файлов: удалено %d", len(removed)) return removed def note_agent_update(self, agent, body): """update_state и launcher_version из claim агента: хранятся в памяти, агент шлёт их в каждом claim (R-3). Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" new = dict(self.agent_update.get(agent) or _no_update()) new["reported"] = True us = body.get("update_state") if us is None or isinstance(us, dict): if us is not None and isinstance(us.get("error"), str): us = {**us, "error": us["error"][:500]} new["update_state"] = us lv = body.get("launcher_version") if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): new["launcher_version"] = lv self.agent_update[agent] = new def make_backup(self, label): conn = self.conn() try: backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30)) finally: conn.close() # --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- def h_claim(hub, conn, m, q, body): agent = http.agent_of(q) hub.note_agent_update(agent, body) deadline = time.monotonic() + hub.claim_wait while True: cmd = queue.claim(conn, agent, body.get("agent_version"), body.get("settings_version"), body.get("last_call_at", body.get("last_jira_call_at")), backup=hub.make_backup, files_dir=hub.files_dir) if cmd or time.monotonic() >= deadline: break time.sleep(1) try: # команда уже выдана: публикация не должна превращать ответ в 500 (I-1) rel = release.current(hub.release_root / agent) except Exception: # noqa: BLE001 log.exception("release: чтение публикации при claim") rel = None return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} def h_agent_release(hub, conn, m, q, body): rel = release.load(hub.release_root / http.agent_of(q)) if rel is None: raise ApiError(404, "no_release", "версия агента не опубликована") return 200, rel def h_agent_file(hub, conn, m, q, body): """Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex нижнего регистра — тот же 404 no_file (решение 16).""" data = files.read(hub.files_dir, m.group(1)) if data is None: raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба") return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} def h_agent_settings(hub, conn, m, q, body): return 200, settings.for_agent(conn, http.agent_of(q)) def _own_command(conn, cmd_id, agent): """Команда по id, но только своего модуля: чужой тип — тот же 404, что и несуществующая команда (I-1 финального ревью).""" cmd = not_found(queue.get_command(conn, cmd_id), "command") if cmd["type"] not in queue._own_types(agent): raise ApiError(404, "not_found", "command") return cmd def h_agent_command(hub, conn, m, q, body): """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у команд с файлом).""" cmd = _own_command(conn, int_group(m), http.agent_of(q)) return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): _own_command(conn, int_group(m), http.agent_of(q)) try: cmd = queue.report_result(conn, int_group(m), body.get("status"), body.get("result"), body.get("error"), body.get("jira_calls", 0), body.get("rate_limited", 0), files_dir=hub.files_dir) except KeyError: raise ApiError(404, "not_found", "command") except queue.NotClaimedError as e: raise ApiError(409, "not_claimed", str(e)) except ValueError as e: raise ApiError(422, "bad_status", str(e)) return 200, cmd def h_app(hub, conn, m, q, body): """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" index = hub.web_dir / "index.html" if not index.is_file(): return 503, Raw("text/html; charset=utf-8", NOT_BUILT) rest = unquote(m.group(1) or "") if rest.startswith("assets/"): if "\x00" in rest: # T1.1: нулевой байт — 404, а не 500 из resolve() raise ApiError(404, "not_found", "/app/" + rest.replace("\x00", "%00")) assets = (hub.web_dir / "assets").resolve() try: f = (hub.web_dir / rest).resolve() except (OSError, ValueError): raise ApiError(404, "not_found", "/app/" + rest) from None if not f.is_relative_to(assets) or not f.is_file(): raise ApiError(404, "not_found", "/app/" + rest) return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE) return 200, Raw("text/html; charset=utf-8", index.read_bytes()) def h_root(hub, conn, m, q, body): """Старые адреса доски (/, /links) — на страницу доски веб-приложения.""" return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/", "Cache-Control": "no-cache"}) def h_batches_post(hub, conn, m, q, body): if not body.get("title") or not isinstance(body["title"], str): raise ApiError(422, "bad_batch", "title — непустая строка") if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_batch", "author — строка") if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") if body.get("project") is not None and not isinstance(body["project"], str): raise ApiError(422, "bad_batch", "project — строка") if body.get("project") == "": raise ApiError(422, "bad_batch", "project: пустая строка") try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir, project=body.get("project")) except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2) raise mapped(e) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_batches_get(hub, conn, m, q, body): module = q.get("module") or None if module is not None and registry.module(module) is None: raise ApiError(404, "not_found", f"module {module}") return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None, module=module)} def h_batch_get(hub, conn, m, q, body): return 200, not_found(queue.batch_view(conn, int_group(m)), "batch") def h_batch_cancel(hub, conn, m, q, body): try: return 200, queue.cancel_batch(conn, int_group(m)) except KeyError: raise ApiError(404, "not_found", "batch") def _put_file(hub, body, allow_empty=False): """Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) отбрасываются до строгой проверки (решение 14).""" content = body.get("content") if not isinstance(content, str): raise ApiError(400, "bad_request", "content — строка base64") try: data = base64.b64decode("".join(content.split()), validate=True) except ValueError: # binascii.Error — подкласс ValueError raise ApiError(400, "bad_request", "content — не base64") from None try: sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty) except files.EmptyFile: raise ApiError(422, "empty_file", "пустой файл") from None except files.TooLarge: raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None return 200, {"file": sha, "size": size} def h_files_post(hub, conn, m, q, body): return _put_file(hub, body) def h_agent_files_post(hub, conn, m, q, body): """Файл от агента (ответ на запрос файла, решение 5): как POST /files, но пустой файл принимается (решение 13).""" return _put_file(hub, body, allow_empty=True) def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") if body.get("project") is not None and not isinstance(body["project"], str): raise ApiError(422, "bad_retry", "project — строка") if body.get("project") == "": raise ApiError(422, "bad_retry", "project: пустая строка") try: return 201, queue.retry_command(conn, int_group(m), body.get("author") or "api", files_dir=hub.files_dir, project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2) raise mapped(e) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_command_cancel(hub, conn, m, q, body): try: return 200, queue.cancel_command(conn, int_group(m), bool(body.get("force"))) except KeyError: raise ApiError(404, "not_found", "command") except ClaimedError as e: raise ApiError(409, "claimed", e.claimed_at) def _agent_view(hub, conn, name) -> dict: view = queue.agent_view(conn, name) rel = release.current(hub.release_root / name) au = hub.agent_update.get(name) or _no_update() us, lv, reported = au["update_state"], au["launcher_version"], au["reported"] view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported, update_hint=release.hint(rel, view, us, lv, reported=reported)) return view def h_agent_get(hub, conn, m, q, body): """Статус агента модуля: ?module=<имя>, по умолчанию jira; неизвестный или без агента — 404.""" name = q.get("module") or registry.DEFAULT_AGENT if name not in [a.name for a in registry.agent_modules()]: raise ApiError(404, "not_found", f"module {name}") return 200, _agent_view(hub, conn, name) def h_agents_get(hub, conn, m, q, body): return 200, {"agents": [_agent_view(hub, conn, a.name) for a in registry.agent_modules()]} def _settings_view(conn) -> dict: """Настройки ядра и модулей плюс вычисляемые поля модулей (settings_view).""" out = settings.get_all(conn) for mod in registry.modules(): if mod.settings_view: out.update(mod.settings_view(conn)) return out def h_settings_get(hub, conn, m, q, body): return 200, _settings_view(conn) def h_settings_put(hub, conn, m, q, body): """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у GET /settings (hub-hygiene-d, решение 9).""" bad = [k for k in body if k in settings.PROTECTED] if bad: raise ApiError(422, "protected", ", ".join(bad)) for key, value in body.items(): try: settings.validate(key, value) except ValueError as e: raise ApiError(422, "bad_setting", str(e)) with db.transaction(conn): for key, value in body.items(): settings.set_(conn, key, value) return 200, _settings_view(conn) def project_rows(conn, labels=None) -> list[dict]: """Проекты плоскими объектами: поля ядра и поля всех модулей (project_view); labels — только эти метки.""" rows = projects.list_projects(conn) if labels is not None: rows = [r for r in rows if r["label"] in labels] found = [r["label"] for r in rows] for mod in registry.modules(): view = mod.project_view(conn, found) if mod.project_view else {} for r in rows: r.update(view.get(r["label"], {})) return rows def h_projects_get(hub, conn, m, q, body): return 200, {"projects": project_rows(conn)} def _author(body) -> str: author = body.get("author") if author is None or author == "": return "api" if not isinstance(author, str): raise ApiError(422, "bad_project", "author — строка") return author def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object, dict]]: """Поля модулей из тела: модуль → (модуль, {поле: проверенное значение}). Неизвестное поле — 422; при editable_only неизменяемое поле (в том числе ядра) — 422 «после создания не меняется».""" known = registry.project_fields() out: dict[str, tuple[object, dict]] = {} for key, value in body.items(): if key == "author": # автор пакетов, не поле проекта: читает _author continue if key in skip: if editable_only: raise ApiError(422, "bad_project", f"{key} после создания не меняется") continue if key not in known: raise ApiError(422, "bad_project", f"{key}: неизвестное поле") mod, fld = known[key] if editable_only and not fld.editable: raise ApiError(422, "bad_project", f"{key} после создания не меняется") try: value = (fld.validate_edit if editable_only and fld.validate_edit else fld.validate)(value) except ValueError as e: raise ApiError(422, "bad_project", f"{key}: {e}") out.setdefault(mod.name, (mod, {}))[1][key] = value return out def h_projects_post(hub, conn, m, q, body): """Новый проект: проект, поля и пакеты модулей (on_project_created; у jira — «Новый проект <метка>») — в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»). Ответ: project и batch_id — пакет первого модуля, вернувшего пакет.""" author = _author(body) try: label, name = projects.validate(body.get("label"), body.get("name")) except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) by_module = _module_fields(body, {"label", "name"}, editable_only=False) batches = [] try: with db.transaction(conn): project = projects.create(conn, label, name) for mod in registry.modules(): batch = mod.on_project_created(conn, project, by_module.get(mod.name, (mod, {}))[1], author) \ if mod.on_project_created else None if batch is not None: batches.append({"module": mod.name, "id": batch["id"]}) row = project_rows(conn, [label])[0] except projects.ProjectExists as e: raise ApiError(409, "project_exists", str(e)) except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) return 201, {"project": row, "batch_id": batches[0]["id"] if batches else None, "batches": batches} def h_project_patch(hub, conn, m, q, body): """Правка изменяемых полей модулей; пакеты модулей ставятся в той же транзакции.""" label = unquote(m.group(1)) project = not_found(projects.get(conn, label), f"project {label}") author = _author(body) by_module = _module_fields(body, {"label", "name", "created_at"}, editable_only=True) batches = [] try: with db.transaction(conn): for mod, fields in by_module.values(): if mod.on_project_updated: batches += [{"module": mod.name, "id": b["id"]} for b in mod.on_project_updated(conn, project, fields, author)] row = project_rows(conn, [label])[0] except CommandError as e: raise ApiError(422, "bad_batch", str(e)) return 200, {"project": row, "batch_ids": [b["id"] for b in batches], "batches": batches} CORE_ROUTES = [ ("GET", r"^/(links)?$", h_root, None), ("GET", r"^/app(?:/(.*))?$", h_app, None), ("POST", r"^/agent/claim$", h_claim, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), ("POST", r"^/agent/files$", h_agent_files_post, "agent"), ("POST", r"^/batches$", h_batches_post, "client"), ("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/files$", h_files_post, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("GET", r"^/agent$", h_agent_get, "client"), ("GET", r"^/agents$", h_agents_get, "client"), ("GET", r"^/settings$", h_settings_get, "client"), ("PUT", r"^/settings$", h_settings_put, "client"), ("GET", r"^/projects$", h_projects_get, "client"), ("POST", r"^/projects$", h_projects_post, "client"), ("PATCH", r"^/projects/([^/]+)$", h_project_patch, "client"), ] # маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS) ROUTES = CORE_ROUTES + [r for mod in registry.modules() for r in mod.routes] # (метод, regex, обработчик, scope, модуль-владелец маршрута; None — маршрут ядра) _COMPILED = [(m, re.compile(p), fn, scope, None) for m, p, fn, scope in CORE_ROUTES] + \ [(m, re.compile(p), fn, scope, mod.name) for mod in registry.modules() for m, p, fn, scope in mod.routes] class Handler(BaseHTTPRequestHandler): timeout = SOCKET_TIMEOUT hub: Hub = None # подставляется в make_server def do_GET(self): self._dispatch("GET") def do_POST(self): self._dispatch("POST") def do_PUT(self): self._dispatch("PUT") def do_PATCH(self): self._dispatch("PATCH") def log_message(self, fmt, *args): # тише стандартного: без адреса клиента print(f"[{self.log_date_time_string()}] {fmt % args}") def _auth(self, scope, owner=None) -> str | None: """Проверить токен для scope маршрута. Для agent возвращает модуль агента по токену; маршрут модуля пускает только агента этого модуля. Для client возвращает None.""" token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip() conn = self.hub.conn() try: tokens = settings.tokens(conn) finally: conn.close() raw = token.encode("utf-8") if scope != "agent": if not token or not hmac.compare_digest(raw, tokens["client"].encode("utf-8")): raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") return None found = None for name, expected in tokens["agents"].items(): # сравниваем со всеми: время не зависит от совпавшего if token and hmac.compare_digest(raw, expected.encode("utf-8")): found = name if found is None or (owner is not None and owner != found): raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") return found def _dispatch(self, method): url = urlparse(self.path) try: query = {k: v[0] for k, v in parse_qs(url.query).items()} raw_len = (self.headers.get("Content-Length") or "0").strip() if not raw_len.isascii() or not raw_len.isdigit(): raise ApiError(400, "bad_content_length", "Content-Length должен быть неотрицательным целым") length = int(raw_len) if length > MAX_BODY: raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт") raw = self.rfile.read(length) if length else b"" for route_method, rx, fn, scope, owner in _COMPILED: if route_method != method: continue match = rx.match(url.path) if not match: continue if scope is not None: agent = self._auth(scope, owner) if agent is not None: query[http.AGENT] = agent # перезаписывает значение из адреса try: body = json.loads(raw.decode("utf-8")) if raw else {} except ValueError as e: # битый JSON или не UTF-8 raise ApiError(400, "bad_json", str(e)) if not isinstance(body, dict): raise ApiError(400, "bad_json", "ожидается объект") conn = self.hub.conn() try: status, payload = fn(self.hub, conn, match, query, body) finally: conn.close() return self._send(status, payload) raise ApiError(404, "not_found", url.path) except ApiError as e: self._send(e.status, {"error": e.error, "detail": e.detail}) except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе traceback.print_exc() self._send(500, {"error": "internal", "detail": str(e)}) def _send(self, status, payload): if isinstance(payload, Raw): ctype, data = payload.content_type, payload.data extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"} else: ctype, extra = "application/json; charset=utf-8", {} data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) for name, value in extra.items(): self.send_header(name, value) self.end_headers() self.wfile.write(data) def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: handler = type("HubHandler", (Handler,), {"hub": hub}) return ThreadingHTTPServer((host, port), handler) def start(hub: Hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, threading.Event]: """Создать HTTP-сервер и запустить в фоне тикер автообновления и реле общей папки. Обслуживание HTTP (serve_forever) запускает вызывающий; stop останавливает тикер и реле.""" srv = make_server(hub, host, port) stop = threading.Event() def ticker(): while not stop.wait(60): hub.tick() threading.Thread(target=ticker, daemon=True).start() target = "127.0.0.1" if host in ("", "0.0.0.0") else host threading.Thread(target=relay.Relay(hub, srv.server_port, host=target).run, args=(stop,), daemon=True).start() return srv, stop def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: srv, stop = start(hub, host, port) print(f"sdlc-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: pass finally: stop.set() srv.server_close()