feat(node): configure system environment through the desktop workflow

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 19:46:52 +03:00
parent 15bb793e5e
commit 59e14c5bc0
29 changed files with 929 additions and 81 deletions
@@ -0,0 +1,3 @@
# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1.
[Service]
RestrictAddressFamilies=AF_NETLINK
+7 -3
View File
@@ -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()
+68 -4
View File
@@ -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
+1 -1
View File
@@ -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>
+2 -20
View File
@@ -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
+10 -1
View File
@@ -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
+17 -2
View File
@@ -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()