#!/usr/bin/python3 """Fixed, versioned environment workflow. Called only by the installed UI. The privileged dispatcher starts a durable systemd job. It accepts no command, path, package name, address, key or other configuration from JavaScript. """ import fcntl import http.client import json import os from pathlib import Path import re import stat import subprocess import sys import tempfile import time import uuid ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"} PROFILE = Path("/usr/share/mission-core-node/environment-profile.json") STATE = Path("/var/lib/mission-core-node-environment") UNIT = "mission-core-node-environment.service" NODE_UNIT = "mission-core-node.service" ORIGIN = "http://127.0.0.1:8780" LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=([A-Za-z0-9_-]{43})") class SetupError(Exception): pass def command(argv, *, timeout=15): result = subprocess.run(argv, env=ENV, capture_output=True, text=True, timeout=timeout) if result.returncode: raise SetupError("Системное действие не завершено. Повторите настройку; если ошибка сохранится, откройте диагностику.") return result.stdout.strip() def trusted_directory(path, mode=0o755): created = not path.exists() and not path.is_symlink() path.mkdir(mode=mode, parents=True, exist_ok=True) info = path.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: raise SetupError("Каталог настройки имеет неподходящие права. Переустановите пакет Node через интерфейс системы.") # umask 0077 must protect working files, but this nonsensitive report and # newly created configuration directories must be traversable by readers. # The only existing directory repaired here is our dedicated report store. if created or path == STATE: path.chmod(mode) def publish(path, data): trusted_directory(path.parent) if path.is_symlink(): raise SetupError("Конфликт системного файла: существующая ссылка сохранена.") with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".node-env-", delete=False) as output: temporary = Path(output.name) try: output.write(data) output.flush() os.fchmod(output.fileno(), 0o644) os.fsync(output.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def owned_config(template, destination): expected = template.read_bytes() trusted_directory(destination.parent) if destination.is_symlink(): raise SetupError("Конфликт с существующей настройкой. Она сохранена без изменений.") if destination.exists(): info = destination.stat() if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or destination.read_bytes() != expected: raise SetupError("Конфликт с существующей настройкой. Она сохранена; проверьте конфигурацию перед повтором.") return False publish(destination, expected) return True def authorize(): uri = command(["/usr/lib/mission-core-node/node-agent", "authorize"]) if not LOGIN.fullmatch(uri): raise SetupError("Не удалось проверить локальную службу БК.") return uri def probe_node(): # Validate the actual sandboxed service, not the root helper's own access. token = LOGIN.fullmatch(authorize()).group(1) connection = http.client.HTTPConnection("127.0.0.1", 8780, timeout=10) cookie = None try: connection.request("POST", "/api/session", json.dumps({"token": token}), {"Origin": ORIGIN, "Content-Type": "application/json"}) response = connection.getresponse() if response.status != 200: raise SetupError("Служба БК не подтвердила доступ для проверки.") cookie = response.getheader("Set-Cookie", "").split(";", 1)[0] response.read() connection.request("GET", "/api/status", headers={"Cookie": cookie}) response = connection.getresponse() if response.status != 200: raise SetupError("Не удалось получить сведения из службы БК.") data = response.read(2 * 1024 * 1024) return json.loads(data)["host"] finally: if cookie: try: connection.request("POST", "/api/logout", "{}", {"Cookie": cookie, "Origin": ORIGIN, "Content-Type": "application/json"}) connection.getresponse().read() except (OSError, http.client.HTTPException): pass connection.close() def platform(): release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines() if "=" in line) if release.get("ID", "").strip('"') != "ubuntu" or release.get("VERSION_ID", "").strip('"') != "24.04" or command(["/usr/bin/dpkg", "--print-architecture"]) != "amd64": raise SetupError("Этот профиль не поддерживает установленную систему или архитектуру. Сведения о системе доступны в обзоре БК.") return "Система и архитектура соответствуют профилю." def packages(): missing = [] for name in ["openssh-server", "ca-certificates"]: result = subprocess.run(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name], env=ENV, capture_output=True, text=True, timeout=10) if result.returncode or result.stdout.strip() != "installed": missing.append(name) if missing: options = ["-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"] # Never kill APT/dpkg in the middle of a transaction or delete its lock. for argv in [["/usr/bin/apt-get", *options, "update"], ["/usr/bin/apt-get", *options, "--no-remove", "--no-install-recommends", "install", "-y", *missing]]: result = subprocess.run(argv, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode: raise SetupError("Не удалось установить пакеты. Проверьте интернет, закройте другие системные установщики и повторите настройку.") for name in ["openssh-server", "ca-certificates"]: if command(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name]) != "installed": raise SetupError("Проверка установленных пакетов не пройдена.") return "OpenSSH Server и системные зависимости установлены." def node_service(): changed = owned_config(Path("/usr/share/mission-core-node/60-environment.conf"), Path("/etc/systemd/system/mission-core-node.service.d/60-environment.conf")) command(["/usr/bin/systemctl", "daemon-reload"]) if command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=User", "--value"]) != "mission-core-node" or command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=CapabilityBoundingSet", "--value"]): raise SetupError("Права службы отличаются от профиля. Настройка остановлена без изменения чужих разрешений.") command(["/usr/bin/systemctl", "enable", "--now", NODE_UNIT]) needs_restart = changed if not needs_restart: try: needs_restart = not probe_node().get("networks_readable") except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError): needs_restart = True if needs_restart: command(["/usr/bin/systemctl", "restart", NODE_UNIT]) families = command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=RestrictAddressFamilies", "--value"]) if "AF_NETLINK" not in families.split(): raise SetupError("Существующая настройка службы запрещает получение сетевых данных. Она сохранена; требуется устранить конфликт профиля.") command(["/usr/bin/systemctl", "is-active", NODE_UNIT]) wait_for_node() return "Служба БК запущена; автозапуск и системный профиль проверены." def wait_for_node(): # Type=simple starts before the local socket/listener is ready. Retry only # read-only readiness, never package/service changes or user actions. deadline = time.monotonic() + 10 while True: try: probe_node() return except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError): if time.monotonic() >= deadline: raise SetupError("Служба БК не подтвердила готовность после запуска. Повторите настройку.") time.sleep(0.25) def network_inventory(): host = probe_node() if not host.get("networks_readable") or any(not item.get("addresses_readable") for item in host["networks"]): raise SetupError("Служба БК не смогла получить интерфейсы или адреса. Проверьте этап настройки службы и повторите.") return f"Получено сетевых интерфейсов: {len(host['networks'])}." def usb_inventory(): host = probe_node() if not host.get("usb_readable"): raise SetupError("Служба БК не смогла получить USB-устройства. Повторите настройку.") return f"Получено USB-устройств: {len(host['usb'])}. Это системное обнаружение." def ssh_service(): owned_config(Path("/usr/share/mission-core-node/60-mission-core-node.conf"), Path("/etc/ssh/sshd_config.d/60-mission-core-node.conf")) trusted_directory(Path("/run/sshd")) command(["/usr/sbin/sshd", "-t"]) config = command(["/usr/sbin/sshd", "-T"]).splitlines() if "authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u" not in config or "authorizedkeyscommanduser mission-core-node" not in config: raise SetupError("Другая конфигурация SSH переопределяет доступ Node. Она сохранена; устраните конфликт и повторите.") command(["/usr/bin/systemctl", "enable", "--now", "ssh.service"]) command(["/usr/bin/systemctl", "try-reload-or-restart", "ssh.service"]) import socket with socket.create_connection(("127.0.0.1", 22), timeout=3) as connection: if not connection.recv(256).startswith(b"SSH-2.0-"): raise SetupError("SSH запущен, но не подтвердил локальную готовность.") return "SSH отвечает локально; реестр доверенных ключей подключён." def tailscale_install(): # Reuse the existing pinned provider installer, checksum and operation lock. result = subprocess.run(["/usr/lib/mission-core-node/install-tailscale"], env=ENV, capture_output=True, text=True) if result.returncode: raise SetupError("Не удалось запустить установку Tailscale.") value = json.loads(result.stdout) if value.get("ok") is not True: raise SetupError(str(value.get("error", "Установка Tailscale не завершена."))[:1024]) command(["/usr/bin/systemctl", "is-active", "tailscaled.service"]) return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно." OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install} def run_steps(profile, operations, save): record = {"schema": profile["schema"], "profile_revision": profile["revision"], "run_id": str(uuid.uuid4()), "state": "running", "started_at": time.time(), "steps": [{"id": step["id"], "state": "pending", "detail": ""} for step in profile["steps"]]} def update(): record["updated_at"] = time.time() save(record) update() for specification, step in zip(profile["steps"], record["steps"]): states = {item["id"]: item["state"] for item in record["steps"]} if any(states.get(dependency) != "complete" for dependency in specification["requires"]): step.update(state="blocked", detail="Сначала завершите предыдущие необходимые этапы.") update() continue step.update(state="running", detail="") update() try: step.update(state="complete", detail=operations[step["id"]]()) except SetupError as error: step.update(state="error", detail=str(error)) except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, subprocess.SubprocessError): step.update(state="error", detail="Не удалось завершить этап. Повторите настройку; сведения о проблеме сохранены в этом списке.") update() record["state"] = "complete" if all(step["state"] == "complete" for step in record["steps"]) else "error" update() return record def start(): trusted_directory(STATE) fd = os.open(STATE / "dispatch.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, "w") as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise SetupError("Настройка уже выполняется. Дождитесь её завершения.") result = subprocess.run(["/usr/bin/systemctl", "start", UNIT], env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode: raise SetupError("Задание настройки не завершилось. Посмотрите этапы и повторите действие.") record = json.loads((STATE / "last-run.json").read_text()) # Renew the ordinary local UI session after a service restart. The # capability is returned only to the native launcher, never to reports. return {"ok": record["state"] == "complete", "login_uri": authorize()} def main(): if os.geteuid() != 0 or sys.argv[1:] not in (["start"], ["run"]): raise SystemExit("Use the installed application's environment setup") os.environ.clear() os.environ.update(ENV) os.umask(0o077) try: if sys.argv[1] == "run": profile = json.loads(PROFILE.read_text()) if {step["id"] for step in profile["steps"]} != set(OPERATIONS): raise SetupError("Профиль окружения не соответствует установленной версии.") run_steps(profile, OPERATIONS, lambda record: publish(STATE / "last-run.json", (json.dumps(record) + "\n").encode())) return result = start() except SetupError as error: result = {"ok": False, "error": str(error)} except (OSError, ValueError, KeyError, subprocess.SubprocessError): result = {"ok": False, "error": "Не удалось выполнить настройку окружения. Повторите действие."} if sys.argv[1] == "run": raise SystemExit(1) print(json.dumps(result)) if __name__ == "__main__": main()