feat(node): configure system environment through the desktop workflow
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1.
|
||||
[Service]
|
||||
RestrictAddressFamilies=AF_NETLINK
|
||||
@@ -13,7 +13,7 @@ import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.3.2"
|
||||
VERSION = "0.4.0"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
@@ -65,10 +65,9 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, openssh-server, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)]
|
||||
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
|
||||
@@ -86,8 +85,13 @@ Description: Mission Core onboard computer configuration
|
||||
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
|
||||
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
|
||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
||||
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
|
||||
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||
]:
|
||||
files.append((path, (p / source).read_bytes(), mode))
|
||||
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py start
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/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):
|
||||
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 через интерфейс системы.")
|
||||
|
||||
|
||||
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()
|
||||
@@ -63,6 +63,8 @@ class NodeApplication(Gtk.Application):
|
||||
self.pending = False
|
||||
self.initial_login = False
|
||||
self.cancelled_downloads = set()
|
||||
self.environment_timer = None
|
||||
self.environment_previous = None
|
||||
|
||||
def do_activate(self):
|
||||
if self.window:
|
||||
@@ -84,7 +86,7 @@ class NodeApplication(Gtk.Application):
|
||||
self.view.connect("web-process-terminated", self.process_failed)
|
||||
manager = self.view.get_user_content_manager()
|
||||
manager.add_script(WebKit2.UserScript.new(
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true})});",
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true, environmentSetup: true})});",
|
||||
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
|
||||
))
|
||||
manager.register_script_message_handler("node")
|
||||
@@ -108,9 +110,71 @@ class NodeApplication(Gtk.Application):
|
||||
action = result.get_js_value().to_string()
|
||||
if action == "authorize":
|
||||
self.login()
|
||||
elif action == "configure-system":
|
||||
self.configure_environment()
|
||||
elif action in ("install-tailscale", "connect-tailscale"):
|
||||
self.network_action(action)
|
||||
|
||||
def environment_record(self):
|
||||
try:
|
||||
path = Path("/var/lib/mission-core-node-environment/last-run.json")
|
||||
if path.stat().st_size > 32768:
|
||||
return None
|
||||
value = json.loads(path.read_text())
|
||||
if value.get("schema") == "missioncore.node.environment/v1":
|
||||
return value
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def environment_progress(self):
|
||||
if not self.window or not self.pending:
|
||||
self.environment_timer = None
|
||||
return False
|
||||
record = self.environment_record()
|
||||
if record and record.get("run_id") != self.environment_previous:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-progress', {detail: " + json.dumps(record) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return True
|
||||
|
||||
def configure_environment(self):
|
||||
if self.pending:
|
||||
return
|
||||
self.pending = True
|
||||
previous = self.environment_record()
|
||||
self.environment_previous = previous.get("run_id") if previous else None
|
||||
self.environment_timer = GLib.timeout_add(1000, self.environment_progress)
|
||||
def work():
|
||||
value = {"ok": False}
|
||||
try:
|
||||
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/configure-system"], capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
|
||||
else:
|
||||
value = json.loads(process.stdout)
|
||||
if type(value.get("ok")) is not bool:
|
||||
raise ValueError("Unexpected environment result")
|
||||
if value.get("login_uri") and not LOGIN.fullmatch(value["login_uri"]):
|
||||
raise ValueError("Unexpected local login")
|
||||
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
value = {"ok": False, "error": "Не удалось завершить настройку окружения. Повторите действие."}
|
||||
GLib.idle_add(self.environment_finished, value)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def environment_finished(self, value):
|
||||
self.pending = False
|
||||
if self.environment_timer:
|
||||
GLib.source_remove(self.environment_timer)
|
||||
self.environment_timer = None
|
||||
uri = value.pop("login_uri", None)
|
||||
value["reloading"] = bool(uri)
|
||||
if self.window:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
if uri:
|
||||
self.view.load_uri(uri)
|
||||
return False
|
||||
|
||||
def network_action(self, action):
|
||||
if self.pending:
|
||||
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
|
||||
@@ -156,7 +220,7 @@ class NodeApplication(Gtk.Application):
|
||||
value["browser_opened"] = True
|
||||
except GLib.Error:
|
||||
value["ok"] = False
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход."
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход."
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return False
|
||||
@@ -170,7 +234,7 @@ class NodeApplication(Gtk.Application):
|
||||
uri = authorize(self.development_socket)
|
||||
GLib.idle_add(self.login_ready, uri)
|
||||
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос Ubuntu.")
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.")
|
||||
finally:
|
||||
GLib.idle_add(self.login_finished)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
@@ -258,5 +322,5 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
|
||||
arguments = parser.parse_args()
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit("Run the desktop application as your normal Ubuntu user")
|
||||
raise SystemExit("Run the desktop application as your normal system user")
|
||||
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node explicit environment configuration
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py run
|
||||
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=DEBIAN_FRONTEND=noninteractive
|
||||
UMask=0077
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
# A package transaction must finish even if the operator closes the UI.
|
||||
TimeoutStartSec=0
|
||||
@@ -23,9 +23,8 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
# Go reads interface/address inventory through route netlink on Linux.
|
||||
# CAP_NET_ADMIN stays absent; this does not grant network reconfiguration.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK
|
||||
# The explicit environment workflow admits read-only route netlink metadata.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
LimitNOFILE=1024
|
||||
|
||||
@@ -53,7 +53,7 @@ def checked(command):
|
||||
# bounded network/lock waits; the fixed root process completes independently.
|
||||
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других установок Ubuntu, затем повторите.")
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других системных установок, затем повторите.")
|
||||
|
||||
|
||||
def control_transport():
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>NODE.DC</vendor>
|
||||
<action id="org.nodedc.mission-core-node.configure-system">
|
||||
<description>Configure the Mission Core Node environment</description>
|
||||
<description xml:lang="ru">Настроить окружение Mission Core Node</description>
|
||||
<message>Install required packages, configure Node and SSH services, collect system inventory and install Tailscale.</message>
|
||||
<message xml:lang="ru">Установить зависимости, настроить службы БК и SSH, проверить сеть и USB, установить Tailscale. Вход и ключи подтверждаются отдельно.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/configure-system</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.open">
|
||||
<description>Open Mission Core Node</description>
|
||||
<description xml:lang="ru">Открыть Mission Core Node</description>
|
||||
|
||||
@@ -5,28 +5,10 @@ case "$1" in
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
mc_node_ssh_template=/usr/share/mission-core-node/60-mission-core-node.conf
|
||||
if [ -L "$mc_node_ssh_snippet" ] || { [ -e "$mc_node_ssh_snippet" ] && ! cmp -s "$mc_node_ssh_template" "$mc_node_ssh_snippet"; }; then
|
||||
echo "Mission Core Node: existing custom SSH snippet preserved; configuration conflict." >&2
|
||||
exit 1
|
||||
fi
|
||||
install -D -m 0644 "$mc_node_ssh_template" "$mc_node_ssh_snippet"
|
||||
# Only bootstrap required to open the GUI. Operational configuration is a
|
||||
# versioned job started by «Настройка окружения → Сконфигурировать».
|
||||
if [ -d /run/systemd/system ]; then
|
||||
install -d -m 0755 /run/sshd
|
||||
/usr/sbin/sshd -t
|
||||
mc_node_ssh_config=$(/usr/sbin/sshd -T)
|
||||
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u' >/dev/null; then
|
||||
echo "Mission Core Node: another AuthorizedKeysCommand overrides Node SSH access. Existing configuration was preserved; resolve this conflict before accepting setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommanduser mission-core-node' >/dev/null; then
|
||||
echo "Mission Core Node: conflicting AuthorizedKeysCommandUser; existing configuration was preserved." >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ssh.service
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
fi
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||
echo "Mission Core Node: this package requires Ubuntu 24.04 LTS Desktop amd64." >&2
|
||||
echo "Mission Core Node: this package does not support the installed system." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
@@ -11,9 +20,15 @@ case "$1" in
|
||||
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
|
||||
fi
|
||||
fi
|
||||
mc_node_environment_snippet=/etc/systemd/system/mission-core-node.service.d/60-environment.conf
|
||||
if [ ! -L "$mc_node_environment_snippet" ] && cmp -s /usr/share/mission-core-node/60-environment.conf "$mc_node_environment_snippet"; then
|
||||
rm "$mc_node_environment_snippet"
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
if [ -x /usr/sbin/sshd ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
fi
|
||||
systemctl stop mission-core-node.service
|
||||
systemctl disable mission-core-node.service
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Workflow acceptance boundaries without touching the host OS or credentials."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import environment_helper as helper
|
||||
|
||||
PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-profile.json").read_text())
|
||||
|
||||
|
||||
class EnvironmentWorkflowTests(unittest.TestCase):
|
||||
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
|
||||
saved = []
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = helper.SetupError('Package lock held')
|
||||
first = helper.run_steps(PROFILE, operations, lambda data: saved.append(copy.deepcopy(data)))
|
||||
states = {item['id']: item['state'] for item in first['steps']}
|
||||
self.assertEqual(first['state'], 'error')
|
||||
self.assertEqual(states['packages'], 'error')
|
||||
self.assertEqual(states['ssh-service'], 'blocked')
|
||||
self.assertEqual(states['tailscale-install'], 'blocked')
|
||||
self.assertEqual(states['network-inventory'], 'complete')
|
||||
self.assertEqual(states['usb-inventory'], 'complete')
|
||||
operations['ssh-service'].assert_not_called()
|
||||
operations['tailscale-install'].assert_not_called()
|
||||
self.assertTrue(any(row['state'] == 'running' for state in saved for row in state['steps']))
|
||||
operations['packages'].side_effect = None
|
||||
second = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(second['state'], 'complete')
|
||||
self.assertNotEqual(first['run_id'], second['run_id'])
|
||||
self.assertEqual(operations['network-inventory'].call_count, 2)
|
||||
operations['ssh-service'].assert_called_once()
|
||||
|
||||
def test_incompatible_platform_prevents_all_system_mutations(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['platform'].side_effect = helper.SetupError('Unsupported system')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
for name, operation in operations.items():
|
||||
if name != 'platform':
|
||||
operation.assert_not_called()
|
||||
|
||||
def test_subprocess_exception_does_not_leak_output_or_report_success(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = subprocess.CalledProcessError(1, ['private-command'], output='private-output')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
encoded = json.dumps(result)
|
||||
self.assertNotIn('private-command', encoded)
|
||||
self.assertNotIn('private-output', encoded)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
|
||||
def test_existing_packages_skip_apt_and_no_service_command_is_hidden_here(self):
|
||||
with patch.object(helper.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'installed', '')) as run:
|
||||
helper.packages()
|
||||
self.assertTrue(run.call_args_list)
|
||||
self.assertTrue(all(call.args[0][0] == '/usr/bin/dpkg-query' for call in run.call_args_list))
|
||||
|
||||
def test_missing_package_installed_without_removal_and_without_transaction_timeout(self):
|
||||
def respond(argv, **kwargs):
|
||||
if argv[0] == '/usr/bin/dpkg-query':
|
||||
return subprocess.CompletedProcess(argv, 0, 'installed' if argv[-1] == 'ca-certificates' or installed[0] else 'not-installed', '')
|
||||
if 'install' in argv:
|
||||
installed[0] = True
|
||||
self.assertIn('--no-remove', argv)
|
||||
self.assertNotIn('timeout', kwargs)
|
||||
return subprocess.CompletedProcess(argv, 0, '', '')
|
||||
installed = [False]
|
||||
with patch.object(helper.subprocess, 'run', side_effect=respond) as run:
|
||||
helper.packages()
|
||||
installs = [call.args[0] for call in run.call_args_list if 'install' in call.args[0]]
|
||||
self.assertEqual(len(installs), 1)
|
||||
self.assertEqual(installs[0][-1], 'openssh-server')
|
||||
|
||||
def test_inventory_verifies_board_service_not_root_access(self):
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': False, 'networks': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': [{'addresses_readable': False}]}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': []}):
|
||||
self.assertIn('0', helper.network_inventory())
|
||||
with patch.object(helper, 'probe_node', return_value={'usb_readable': False, 'usb': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.usb_inventory()
|
||||
|
||||
def test_foreign_config_and_symlink_are_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(helper, 'trusted_directory'):
|
||||
template, target = Path(directory)/'template', Path(directory)/'config'
|
||||
template.write_bytes(b'owned')
|
||||
target.write_bytes(b'foreign')
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertEqual(target.read_bytes(), b'foreign')
|
||||
target.unlink()
|
||||
target.symlink_to(template)
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertTrue(target.is_symlink())
|
||||
self.assertEqual(template.read_bytes(), b'owned')
|
||||
|
||||
def test_delayed_service_start_retries_only_readiness(self):
|
||||
with patch.object(helper, 'probe_node', side_effect=[OSError('not listening'), {}]) as read, patch.object(helper.time, 'sleep'), patch.object(helper, 'command') as mutation:
|
||||
helper.wait_for_node()
|
||||
self.assertEqual(read.call_count, 2)
|
||||
mutation.assert_not_called()
|
||||
|
||||
def test_failed_service_rights_never_starts_or_restarts_it(self):
|
||||
def command(argv):
|
||||
if '--property=User' in argv: return 'root'
|
||||
return ''
|
||||
with patch.object(helper, 'owned_config', return_value=False), patch.object(helper, 'command', side_effect=command) as run:
|
||||
with self.assertRaises(helper.SetupError): helper.node_service()
|
||||
self.assertFalse(any('restart' in call.args[0] or 'enable' in call.args[0] for call in run.call_args_list))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in New Issue
Block a user