293 lines
16 KiB
Python
293 lines
16 KiB
Python
#!/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()
|