diff --git a/apps/node-agent/README.md b/apps/node-agent/README.md index 974a42d..1830c9b 100644 --- a/apps/node-agent/README.md +++ b/apps/node-agent/README.md @@ -1,4 +1,12 @@ -# Mission Core Node — Ubuntu system configuration candidate +# Mission Core Node — system configuration candidate + +0.4.0 makes «Настройка окружения» the first system view. A fixed privileged +helper starts a durable, versioned systemd workflow for packages, the board +service, network/USB inventory, SSH and Tailscale. Operator-controlled SSH keys +and Tailscale login are available in the same section. The package bootstraps +the GUI/service; operational configuration is performed by its UI button. +Operator copy is OS-neutral; actual OS/version appears only in «Обзор БК». +Support remains Ubuntu 24.04 LTS Desktop amd64. 0.3.2 also repairs Linux interface inventory: the unprivileged service admits AF_NETLINK for OS metadata reads while retaining an empty capability set. @@ -41,8 +49,8 @@ Version 0.2.0 contains local host/USB/network inventory, persistent Ed25519 identity, GUI naming, OS-authenticated local launch, redacted report export, OpenSSH installation/autostart, and GUI enrollment/revocation of Ed25519 public keys for local Ubuntu administrators. The agent and desktop window run -unprivileged; the polkit helper can only issue a temporary local login. SSH configuration is -owned by the installer, with conflict detection and cleanup on removal. +unprivileged; fixed polkit helpers admit only local login and the shipped system/network actions. SSH configuration is +owned by the versioned environment workflow, with conflict detection and cleanup on removal. Core pairing/mTLS, sensor plugins, capture, media and recovery are subsequent vertical increments, not implemented capabilities of diff --git a/apps/node-agent/_qa/main.go b/apps/node-agent/_qa/main.go index 9a7ac03..3e5b626 100644 --- a/apps/node-agent/_qa/main.go +++ b/apps/node-agent/_qa/main.go @@ -15,11 +15,11 @@ import ( ) func main() { - dir := "/private/tmp/mc-node-ui-032-qa" + dir := "/private/tmp/mc-node-ui-040-qa" store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) } assets, _ := fs.Sub(web.Assets, "dist") memory, available := uint64(8388608), uint64(5242880) - app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.2-qa", Inventory: func() node.Inventory { + app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.4.0-qa", Inventory: func() node.Inventory { inventory := node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available, NetworksReadable:true, Networks: []node.Network{{Name:"ethernet-qa", Up:true, AddressesReadable:true, Addresses:[]string{"192.0.2.10/24"}}}, USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}} diff --git a/apps/node-agent/internal/node/access.go b/apps/node-agent/internal/node/access.go index bb3ac3b..8d8c4da 100644 --- a/apps/node-agent/internal/node/access.go +++ b/apps/node-agent/internal/node/access.go @@ -152,7 +152,7 @@ func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error { func (a *AccessStore) Add(user, label, key string) error { if !a.allowed(user) { - return errors.New("Выберите существующую учётную запись администратора Ubuntu") + return errors.New("Выберите существующую учётную запись администратора системы") } label = strings.TrimSpace(label) if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) { diff --git a/apps/node-agent/internal/node/environment-profile.json b/apps/node-agent/internal/node/environment-profile.json new file mode 100644 index 0000000..578addb --- /dev/null +++ b/apps/node-agent/internal/node/environment-profile.json @@ -0,0 +1,13 @@ +{ + "schema": "missioncore.node.environment/v1", + "revision": "ubuntu-24.04-amd64/1", + "steps": [ + {"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]}, + {"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]}, + {"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]}, + {"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]}, + {"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]}, + {"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]}, + {"id":"tailscale-install","label":"Установка Tailscale","description":"Проверенный пакет и системная служба частной сети","requires":["packages"]} + ] +} diff --git a/apps/node-agent/internal/node/environment.go b/apps/node-agent/internal/node/environment.go new file mode 100644 index 0000000..9e28588 --- /dev/null +++ b/apps/node-agent/internal/node/environment.go @@ -0,0 +1,100 @@ +package node + +import ( + "context" + _ "embed" + "encoding/json" + "io" + "os" + "os/exec" + "time" +) + +//go:embed environment-profile.json +var environmentProfile []byte + +type EnvironmentRun struct { + Schema string `json:"schema"` + ProfileRevision string `json:"profile_revision"` + RunID string `json:"run_id"` + State string `json:"state"` + StartedAt float64 `json:"started_at"` + UpdatedAt float64 `json:"updated_at"` + Steps []EnvironmentStep `json:"steps"` +} +type EnvironmentStep struct { + ID string `json:"id"` + State string `json:"state"` + Detail string `json:"detail"` +} +type EnvironmentStatus struct { + Profile json.RawMessage `json:"profile"` + Run *EnvironmentRun `json:"run"` + Available bool `json:"available"` +} + +func ReadEnvironment() EnvironmentStatus { + result := readEnvironmentFile("/var/lib/mission-core-node-environment/last-run.json") + if result.Run != nil && result.Run.State == "running" { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + state, err := exec.CommandContext(ctx, "/usr/bin/systemctl", "show", "--property=ActiveState", "--value", "mission-core-node-environment.service").Output() + if err != nil || (string(state) != "activating\n" && string(state) != "active\n") { + // A stopped job/reboot cannot leave yesterday's spinner running. + result.Run.State = "interrupted" + } + } + return result +} + +func readEnvironmentFile(path string) EnvironmentStatus { + result := EnvironmentStatus{Profile: json.RawMessage(environmentProfile), Available: true} + f, err := os.Open(path) + if os.IsNotExist(err) { + return result + } + if err != nil { + result.Available = false + return result + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > 32768 { + result.Available = false + return result + } + var run EnvironmentRun + decoder := json.NewDecoder(io.LimitReader(f, 32769)) + if decoder.Decode(&run) != nil || decoder.Decode(new(any)) != io.EOF || !validEnvironmentRun(run) { + result.Available = false + return result + } + result.Run = &run + return result +} + +// Invalid or inconsistent progress cannot turn a failed setup into green UI. +func validEnvironmentRun(run EnvironmentRun) bool { + if run.Schema != "missioncore.node.environment/v1" || run.RunID == "" || len(run.RunID) > 64 || len(run.Steps) == 0 || len(run.Steps) > 32 || run.ProfileRevision == "" { + return false + } + if run.State != "running" && run.State != "complete" && run.State != "error" { + return false + } + seen := map[string]bool{} + for _, step := range run.Steps { + if step.ID == "" || len(step.ID) > 64 || seen[step.ID] || len(step.Detail) > 4096 { + return false + } + seen[step.ID] = true + switch step.State { + case "pending", "running", "complete", "error", "blocked": + default: + return false + } + if run.State == "complete" && step.State != "complete" { + return false + } + } + return true +} diff --git a/apps/node-agent/internal/node/environment_test.go b/apps/node-agent/internal/node/environment_test.go new file mode 100644 index 0000000..a209463 --- /dev/null +++ b/apps/node-agent/internal/node/environment_test.go @@ -0,0 +1,51 @@ +package node + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnvironmentStatusIsAuthorizedReadOnly(t *testing.T) { + s := newTestServer(t) + s.Environment = func() EnvironmentStatus { return EnvironmentStatus{Available: true, Profile: environmentProfile} } + if got := call(s, "GET", "/api/environment", "", nil); got.Code != 401 { + t.Fatal(got.Code) + } + cookie := login(t, s) + if got := call(s, "GET", "/api/environment", "", cookie); got.Code != 200 { + t.Fatal(got.Code, got.Body.String()) + } + if got := call(s, "POST", "/api/environment", "{}", cookie); got.Code == 200 { + t.Fatal("HTTP can mutate system") + } +} + +func TestMissingCorruptAndPartialEnvironmentReports(t *testing.T) { + path := filepath.Join(t.TempDir(), "last-run.json") + result := readEnvironmentFile(path) + if !result.Available || result.Run != nil { + t.Fatal("new install not admitted") + } + samples := []string{ + `{broken`, + `{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"network-inventory","state":"error"}]}`, + `{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"},{"id":"x","state":"complete"}]}`, + `{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"}]} {}`, + } + for _, sample := range samples { + if err := os.WriteFile(path, []byte(sample), 0600); err != nil { + t.Fatal(err) + } + if got := readEnvironmentFile(path); got.Available || got.Run != nil { + t.Fatal("invalid report admitted", sample) + } + } + if err := os.WriteFile(path, []byte(`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"error","steps":[{"id":"packages","state":"error"},{"id":"ssh-service","state":"blocked"}]}`), 0600); err != nil { + t.Fatal(err) + } + result = readEnvironmentFile(path) + if !result.Available || result.Run == nil || result.Run.State != "error" || result.Run.Steps[1].State != "blocked" { + t.Fatal("failure lost") + } +} diff --git a/apps/node-agent/internal/node/server.go b/apps/node-agent/internal/node/server.go index 7bda3d2..75a492f 100644 --- a/apps/node-agent/internal/node/server.go +++ b/apps/node-agent/internal/node/server.go @@ -13,17 +13,18 @@ import ( ) type Server struct { - Store *Store - Assets fs.FS - Origin string - Version string - Inventory func() Inventory - Access *AccessStore - Tailscale func() TailscaleStatus - mu sync.Mutex - logins map[string]time.Time - sessions map[string]time.Time - Now func() time.Time + Store *Store + Assets fs.FS + Origin string + Version string + Inventory func() Inventory + Access *AccessStore + Tailscale func() TailscaleStatus + Environment func() EnvironmentStatus + mu sync.Mutex + logins map[string]time.Time + sessions map[string]time.Time + Now func() time.Time } func token() string { @@ -80,6 +81,16 @@ func (s *Server) Handler() http.Handler { s.accessRoutes(mux) } mux.HandleFunc("POST /api/session", s.login) + mux.HandleFunc("GET /api/environment", func(w http.ResponseWriter, r *http.Request) { + if !s.authorized(w, r) { + return + } + read := s.Environment + if read == nil { + read = ReadEnvironment + } + reply(w, 200, read()) + }) mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { return @@ -194,7 +205,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { _, ok := s.logins[body.Token] delete(s.logins, body.Token) if !ok { - reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню Ubuntu"}) + reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"}) return } if s.sessions == nil { @@ -224,7 +235,7 @@ func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool { defer s.mu.Unlock() prune(s.sessions, s.now()) if _, ok := s.sessions[c.Value]; !ok { - reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню Ubuntu"}) + reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню приложений"}) return false } return true diff --git a/apps/node-agent/packaging/60-environment.conf b/apps/node-agent/packaging/60-environment.conf new file mode 100644 index 0000000..bd0d535 --- /dev/null +++ b/apps/node-agent/packaging/60-environment.conf @@ -0,0 +1,3 @@ +# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1. +[Service] +RestrictAddressFamilies=AF_NETLINK diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 759c4b1..e3fe8b0 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -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 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"!\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files)) diff --git a/apps/node-agent/packaging/configure-system b/apps/node-agent/packaging/configure-system new file mode 100644 index 0000000..97f1ba8 --- /dev/null +++ b/apps/node-agent/packaging/configure-system @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py start diff --git a/apps/node-agent/packaging/environment_helper.py b/apps/node-agent/packaging/environment_helper.py new file mode 100644 index 0000000..85df8fd --- /dev/null +++ b/apps/node-agent/packaging/environment_helper.py @@ -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() diff --git a/apps/node-agent/packaging/launcher.py b/apps/node-agent/packaging/launcher.py index bc047e9..42f532a 100644 --- a/apps/node-agent/packaging/launcher.py +++ b/apps/node-agent/packaging/launcher.py @@ -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([])) diff --git a/apps/node-agent/packaging/mission-core-node-environment.service b/apps/node-agent/packaging/mission-core-node-environment.service new file mode 100644 index 0000000..5db84bb --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-environment.service @@ -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 diff --git a/apps/node-agent/packaging/mission-core-node.service b/apps/node-agent/packaging/mission-core-node.service index cbf979a..0180123 100644 --- a/apps/node-agent/packaging/mission-core-node.service +++ b/apps/node-agent/packaging/mission-core-node.service @@ -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 diff --git a/apps/node-agent/packaging/network_helper.py b/apps/node-agent/packaging/network_helper.py index 0617972..7673190 100644 --- a/apps/node-agent/packaging/network_helper.py +++ b/apps/node-agent/packaging/network_helper.py @@ -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(): diff --git a/apps/node-agent/packaging/org.nodedc.mission-core-node.policy b/apps/node-agent/packaging/org.nodedc.mission-core-node.policy index 03c4d7e..02d63ee 100644 --- a/apps/node-agent/packaging/org.nodedc.mission-core-node.policy +++ b/apps/node-agent/packaging/org.nodedc.mission-core-node.policy @@ -2,6 +2,14 @@ NODE.DC + + Configure the Mission Core Node environment + Настроить окружение Mission Core Node + Install required packages, configure Node and SSH services, collect system inventory and install Tailscale. + Установить зависимости, настроить службы БК и SSH, проверить сеть и USB, установить Tailscale. Вход и ключи подтверждаются отдельно. + nonoauth_admin + /usr/lib/mission-core-node/configure-system + Open Mission Core Node Открыть Mission Core Node diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index e65c909..c0a05a6 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -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 diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index eb7ea11..481729f 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -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 diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index bb6d013..95ea73d 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -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 diff --git a/apps/node-agent/packaging/test_environment_helper.py b/apps/node-agent/packaging/test_environment_helper.py new file mode 100644 index 0000000..92dcfcb --- /dev/null +++ b/apps/node-agent/packaging/test_environment_helper.py @@ -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() diff --git a/apps/node-agent/ui/src/EnvironmentView.tsx b/apps/node-agent/ui/src/EnvironmentView.tsx new file mode 100644 index 0000000..78e34ea --- /dev/null +++ b/apps/node-agent/ui/src/EnvironmentView.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react"; +import { environmentSetupAvailable } from "./api"; +import type { useEnvironment } from "./useEnvironment"; +import { TailnetAccess } from "./TailnetAccess"; +import { SystemAccess } from "./SystemAccess"; + +const labels:Record={pending:"Ожидает",running:"Выполняется",complete:"Готово",error:"Ошибка",blocked:"Не выполнено"}; +export function EnvironmentView({environment,failure,success}: {environment:ReturnType;failure:(error:unknown)=>void;success:(text:string)=>void}) { + const {value,pending,running,loading,start}=environment; + const [adding,setAdding]=useState(false); + const revision=String(value?.run?.updated_at??0); + const complete=value?.run?.state==="complete"&&value.run.profile_revision===value.profile.revision; + return
+ +

Сконфигурируйте окружение при первом запуске или повторите проверку после изменений. Приложение установит нужные пакеты, настроит службы и получит системные сведения. Вход в частную сеть и доверенный SSH-ключ подтверждаются ниже.

+ + {!environmentSetupAvailable()&&

Настройка запускается из установленного приложения на БК.

} + {pending&&(!value?.run||value.run.state!=="running")&&} + {!value?loading?:

Сведения о настройке недоступны. Обновите страницу.

:<> + {!value.available&&

Не удалось прочитать результат предыдущей настройки. Запустите настройку, чтобы заново проверить окружение.

} + {value.profile.steps.map(specification=>{ + const step=value.run?.steps.find(item=>item.id===specification.id); + const state=step?.state??"pending"; + return
  • :} title={specification.label} description={specification.description} metadata={step?.detail} status={{labels[state]??"Неизвестно"}} />
  • ; + })}
    + {complete&&!running&&

    Системные этапы завершены. Ниже проверьте вход в Tailscale и доверенный ключ для SSH.

    } + {value.run?.state==="error"&&!running&&

    Часть этапов не завершена. Исправьте указанные причины и повторите настройку.

    } + {value.run?.state==="interrupted"&&

    Предыдущая настройка прервана. Повторный запуск проверит уже выполненные этапы.

    } + } +
    + {!running&&<> + setAdding(false)} startAdd={()=>setAdding(true)} /> + + } +
    ; +} diff --git a/apps/node-agent/ui/src/SystemAccess.tsx b/apps/node-agent/ui/src/SystemAccess.tsx index e6dfe49..36627ab 100644 --- a/apps/node-agent/ui/src/SystemAccess.tsx +++ b/apps/node-agent/ui/src/SystemAccess.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, Resourc import { request } from "./api"; import { useAccess, type AccessKey } from "./useAccess"; -export function SystemAccess({ revision, failure, success, adding, closeAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void }) { +export function SystemAccess({ revision, failure, success, adding, closeAdd, startAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void; startAdd?: () => void }) { const { access, loading, refresh } = useAccess(revision, failure); const [user, setUser] = useState(""); const [label, setLabel] = useState(""); @@ -21,19 +21,20 @@ export function SystemAccess({ revision, failure, success, adding, closeAdd }: { return

    Компьютеры, которым разрешён вход по SSH через Node.

    {loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}
    {loading && !access ? : !access ?

    Не удалось загрузить список. Повторите обновление.

    : <> - {access.keys.length === 0 ?

    Доверенных устройств пока нет. Нажмите плюс в шапке, чтобы добавить компьютер.

    : {access.keys.map(item =>
  • } title={item.label} description={`Пользователь Ubuntu: ${item.user}`} metadata={{item.id}} status={Доступ разрешён} actions={<> setDetail(item)}> setRemove(item)}>} />
  • )}
    } -

    Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа Ubuntu сохраняются.

    + {access.keys.length === 0 ?

    Доверенных устройств пока нет. Добавьте компьютер, которому нужен доступ.

    : {access.keys.map(item =>
  • } title={item.label} description={`Пользователь системы: ${item.user}`} metadata={{item.id}} status={Доступ разрешён} actions={<> setDetail(item)}> setRemove(item)}>} />
  • )}
    } +

    Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа в систему сохраняются.

    } + {startAdd && } { if (!pending) closeAdd(); }} footer={}> - {!access ?

    {loading ? "Получаем пользователей Ubuntu…" : "Не удалось получить пользователей. Закройте окно и обновите список."}

    : access.users.length === 0 ?

    Не найдены администраторы Ubuntu. Добавьте пользователя в настройках системы.

    :
    + {!access ?

    {loading ? "Получаем пользователей системы…" : "Не удалось получить пользователей. Закройте окно и обновите список."}

    : access.users.length === 0 ?

    Не найдены администраторы системы. Добавьте пользователя в настройках системы.

    : setLabel(event.target.value)} autoComplete="off" /> - ({ value, label: value }))} onChange={setUser} disabled={pending} /> setKey(event.target.value)} autoComplete="off" spellCheck={false} />

    Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.

    }
    setDetail(null)}> - {detail &&
    Пользователь Ubuntu
    {detail.user}
    Отпечаток
    {detail.id}
    Публичный ключ
    {detail.public_key}
    } + {detail &&
    Пользователь системы
    {detail.user}
    Отпечаток
    {detail.id}
    Публичный ключ
    {detail.public_key}
    }
    setRemove(null)} onConfirm={async () => { if (!remove) return; diff --git a/apps/node-agent/ui/src/TailnetAccess.tsx b/apps/node-agent/ui/src/TailnetAccess.tsx index f04dfb2..e4f6a20 100644 --- a/apps/node-agent/ui/src/TailnetAccess.tsx +++ b/apps/node-agent/ui/src/TailnetAccess.tsx @@ -27,7 +27,7 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e if (pending) return; setNotice(""); setPending(action); if (!desktopAction(action)) { - setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu.")); + setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } } const label = tailscaleLabel(value, checked); @@ -36,13 +36,13 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e return
    {label}}>

    Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.

    {!checked ? : !value ?

    Не удалось получить состояние. Повторная проверка выполняется автоматически.

    : <> - {!value.installed && <>

    Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение Ubuntu.

    } + {!value.installed && <>

    Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение.

    } {canConnect && } {value.state === "NeedsMachineAuth" &&

    Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.

    } {value.addresses.length > 0 &&
    Адреса в Tailscale
    {value.addresses.join(" · ")}
    } } - {!supported &&

    Для настройки сети закройте окно и заново откройте установленное приложение из меню Ubuntu. После обновления пакета требуется перезапуск окна.

    } - {pending && } + {!supported &&

    Для настройки сети закройте окно и заново откройте установленное приложение из меню приложений. После обновления пакета требуется перезапуск окна.

    } + {pending && } {notice &&

    {notice}

    }
    ; } diff --git a/apps/node-agent/ui/src/api.ts b/apps/node-agent/ui/src/api.ts index 45c3571..9a05ac8 100644 --- a/apps/node-agent/ui/src/api.ts +++ b/apps/node-agent/ui/src/api.ts @@ -11,12 +11,15 @@ export interface Status { } export class APIError extends Error { constructor(message: string, public status: number) { super(message); } } -export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale"; +export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale" | "configure-system"; +export function environmentSetupAvailable(): boolean { + return (window as Window & { missionCoreDesktop?: { environmentSetup?: boolean } }).missionCoreDesktop?.environmentSetup === true; +} export function networkSetupAvailable(): boolean { return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true; } export function desktopAction(action: DesktopAction): boolean { - if (action !== "authorize" && !networkSetupAvailable()) return false; + if (action === "configure-system" ? !environmentSetupAvailable() : action !== "authorize" && !networkSetupAvailable()) return false; const host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } }; const channel = host.webkit?.messageHandlers?.node; if (!channel) return false; diff --git a/apps/node-agent/ui/src/main.tsx b/apps/node-agent/ui/src/main.tsx index c9f26ed..ff9664b 100644 --- a/apps/node-agent/ui/src/main.tsx +++ b/apps/node-agent/ui/src/main.tsx @@ -11,22 +11,26 @@ import { NodeOverview } from "./NodeOverview"; import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews"; import { SystemAccess } from "./SystemAccess"; import { TailnetAccess } from "./TailnetAccess"; +import { useEnvironment } from "./useEnvironment"; +import { EnvironmentView } from "./EnvironmentView"; import "./node.css"; function App() { const node = useNode(); const { value, pending, locked, refresh, failure } = node; + const environment = useEnvironment(!!value, failure); + const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}}; const [root, setRoot] = useState("system"); - const workspace = useApplicationWorkspace({ activeView: "overview" }); + const workspace = useApplicationWorkspace({ activeView: "environment" }); const [adding, setAdding] = useState(false); const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark"); // Theme applies to body portals as well as the application shell. useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]); const currentRoot = roots.find(item => item.id === root)!; const currentView = views.find(item => item.id === workspace.activeView); - function openView(id: ViewId) { setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); } + function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); } function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); } - const content = !value ? null : workspace.activeView === "overview" ? + const content = !value ? null : workspace.activeView === "environment" ? : workspace.activeView === "overview" ? : workspace.activeView === "network" ? : workspace.activeView === "usb" ? : workspace.activeView === "diagnostics" ? @@ -34,18 +38,18 @@ function App() { : workspace.activeView === "ssh" ? setAdding(false)} /> : null; return <> } brandLabel="Mission Core Node" - center={<> ({ value: item.id, label: item.label, disabled: !value || !item.first }))} onChange={selectRoot} />} - right={ void refresh() }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} />} />} + center={<> ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />} + right={ { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} />} />} navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded} navigation={, onSelect: () => openView("overview") }] : []} items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={Mission Core Node · {value?.version}} />} content={currentView && setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending, onClick: () => void refresh() }]}>{content}} + utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{content}} stage={
    - {value ?
    {roots.map(item => )}
    : - {pending ? :

    {locked ? "Подтвердите доступ в системном окне Ubuntu." : "Не удалось связаться со службой. Повторите подключение."}

    } - + {value ?
    {roots.map(item => )}
    : + {pending ? :

    {locked ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}

    } +
    }
    } /> diff --git a/apps/node-agent/ui/src/nodeModel.ts b/apps/node-agent/ui/src/nodeModel.ts index 1332546..166d508 100644 --- a/apps/node-agent/ui/src/nodeModel.ts +++ b/apps/node-agent/ui/src/nodeModel.ts @@ -1,11 +1,12 @@ import type { IconName } from "@nodedc/ui-react"; export type RootId = "system" | "devices"; -export type ViewId = "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh"; +export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh"; export const roots: { id: RootId; label: string; first: ViewId | null }[] = [ - { id: "system", label: "Система", first: "overview" }, + { id: "system", label: "Система", first: "environment" }, { id: "devices", label: "Устройства", first: null }, ]; export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [ + { id: "environment", root: "system", label: "Настройка окружения", icon: "settings" }, { id: "overview", root: "system", label: "Обзор БК", icon: "activity" }, { id: "network", root: "system", label: "Сеть", icon: "network" }, { id: "usb", root: "system", label: "USB-устройства", icon: "camera" }, diff --git a/apps/node-agent/ui/src/useEnvironment.ts b/apps/node-agent/ui/src/useEnvironment.ts new file mode 100644 index 0000000..65ea284 --- /dev/null +++ b/apps/node-agent/ui/src/useEnvironment.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; +import { desktopAction, request } from "./api"; + +export interface EnvironmentStep { id: string; state: string; detail: string } +export interface EnvironmentRun { schema: string; profile_revision: string; run_id: string; state: string; updated_at: number; steps: EnvironmentStep[] } +export interface EnvironmentStatus { available: boolean; profile: {revision: string; steps:{id:string;label:string;description:string}[]}; run: EnvironmentRun | null } + +export function useEnvironment(authorized: boolean, failure: (error: unknown) => void) { + const [value, setValue] = useState(null); + const [pending, setPending] = useState(false); + const [loading, setLoading] = useState(false); + const refresh = useCallback(async () => { + setLoading(true); + try {setValue(await request("/api/environment"));} + catch(error) {failure(error);} + finally {setLoading(false);} + },[failure]); + // Native progress survives a service restart and never depends on a stale + // HTTP session. Reopening an existing run uses the read-only status API. + useEffect(() => { + function progress(event:Event) { + const run=(event as CustomEvent).detail; + if(run?.schema==="missioncore.node.environment/v1") setValue(current=>current?{...current,available:true,run}:current); + } + function complete(event:Event) { + const result=(event as CustomEvent<{ok:boolean;error?:string;reloading?:boolean}>).detail; + if(!result?.reloading) setPending(false); + if(result?.error) failure(new Error(result.error)); + // The native launcher renews login after service changes. No immediate + // request with the expired cookie is made here. + } + const sessionReady=()=>setPending(false); + window.addEventListener("mission-core-session-ready",sessionReady); + window.addEventListener("mission-core-environment-progress",progress); + window.addEventListener("mission-core-environment-result",complete); + return()=>{window.removeEventListener("mission-core-session-ready",sessionReady);window.removeEventListener("mission-core-environment-progress",progress);window.removeEventListener("mission-core-environment-result",complete);}; + },[failure]); + useEffect(()=>{if(authorized&&!pending) void refresh();},[authorized,pending,refresh]); + useEffect(()=>{ + if(!authorized||pending||value?.run?.state!=="running") return; + const timer=setTimeout(()=>void refresh(),2000);return()=>clearTimeout(timer); + },[authorized,pending,value,refresh]); + function start() { + if(pending||value?.run?.state==="running") return; + if(!desktopAction("configure-system")) {failure(new Error("Откройте установленное приложение Node для настройки окружения."));return;} + setValue(current=>current?{...current,run:null}:current); + setPending(true); + } + return {value,pending,loading,refresh,start,running:pending||value?.run?.state==="running"}; +} diff --git a/apps/node-agent/ui/src/useNode.ts b/apps/node-agent/ui/src/useNode.ts index 87908a8..dcba138 100644 --- a/apps/node-agent/ui/src/useNode.ts +++ b/apps/node-agent/ui/src/useNode.ts @@ -18,7 +18,7 @@ export function useNode() { finally { setPending(false); } }, [failure]); useEffect(() => { - const launch = () => { void loginFromLaunch().then(refresh).catch(error => { failure(error); setPending(false); }); }; + const launch = () => { void loginFromLaunch().then(refresh).then(()=>window.dispatchEvent(new Event("mission-core-session-ready"))).catch(error => { failure(error); setPending(false); }); }; launch(); window.addEventListener("hashchange", launch); return () => window.removeEventListener("hashchange", launch); }, [refresh, failure]); diff --git a/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md b/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md index e572494..44e0f8f 100644 --- a/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md +++ b/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md @@ -21,6 +21,8 @@ capabilities или готовностью к съёмке. Устройства ## Node: согласованное размещение - Верхний раздел «Система» вместо «Состояние системы». +- Первый пункт слева — «Настройка окружения»: кнопка «Сконфигурировать» + запускает общий версионный профиль системных действий с проверяемыми этапами. - «Обзор БК»: ОС, архитектура, CPU/RAM, имя, сводка фактических подключений. Название редактируется в блоке «Название БК». Самоподтверждающий зелёный статус «Node работает» удаляется. Сведения имеют время получения. @@ -32,8 +34,9 @@ capabilities или готовностью к съёмке. Устройства привязки к Core. Подключение Core отделено от настройки сетевого транспорта. - «Диагностика»: существующий очищенный отчёт и замечания инвентаризации. Это ограниченная диагностика, не полный support bundle всех будущих модулей. -- Отдельная «Конфигурация системы» удаляется. Состояния SSH/Tailscale и - оборудования входят в сводку; операции остаются в своих подразделах. +- Прежняя пассивная «Конфигурация системы» заменена рабочей «Настройкой + окружения» по последующему уточнению владельца. Состояния входят в обзор; + полный сценарий подготовки доступен в первом пункте «Системы». - Верхние «Устройства» предназначены для драйверных K1, D455 и следующих сенсоров. Системный USB-список там не дублируется. В этом приращении вкладка недоступна до появления рабочего подключения через драйвер; @@ -142,7 +145,7 @@ adapter и K1 Bridge в общей с БК локальной сети. Близ ## Порядок реализации и приёмка 1. Текущее приращение: перенос существующих страниц в «Систему», обзор БК, - переименование полей, устранение отдельной конфигурации и self-status. + переименование полей, устранение self-status и единая настройка окружения. Сборка .deb и GUI-проверка на физическом Mini. 2. Закрыть оставшиеся системные проверки P1 по предыдущему отчёту: чистая установка, GUI upgrade/repair, reboot и SSH lifecycle без консоли. @@ -160,7 +163,7 @@ Select/TextField/TextAreaField, Window/ConfirmationModal, StatusBadge, ActivityIndicator, ToastStack. Новых визуальных примитивов и копий DG нет. Для текущей приёмки: USB доступен из «Системы»; прежних remote/setup маршрутов -нет; имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым +нет (новая настройка — environment); имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым драйвером; Tailscale/SSH открываются из сводки; плюс SSH работает после переноса; темы/узкое окно и recovery сеанса не нарушены. Для будущего сопряжения: добавленный аппарат сохраняется после reload/restart и открывает именно @@ -192,3 +195,66 @@ Go race tests проверяют ошибку чтения отдельно от адресов без вымышленных данных. Через UI изолированной ноды подтверждены «Нет сведений» в сводке, сообщение об ошибке на странице сети и появление интерфейса после восстановления и нажатия «Обновить сведения». + + +## Уточнение владельца: единая настройка окружения (0.4.0) + +Эта поправка заменяет раннее решение об удалении страницы конфигурации. +Оператор получает чистую поддерживаемую систему и .deb. Пакет устанавливает +минимум для открытия приложения и непривилегированной службы. После запуска +первым слева открывается «Настройка окружения». Кнопка «Сконфигурировать» +выполняет весь текущий системный профиль; ручные действия инженера по SSH +не считаются продуктовой реализацией или приёмкой. + +Единый источник этапов — `internal/node/environment-profile.json`. Он входит +и в API/UI, и в установленный пакет. Текущие автоматические этапы: + +1. Проверка совместимости системы и архитектуры. +2. Установка недостающих OpenSSH Server и системных зависимостей. +3. Настройка службы БК и автозапуска, включая разрешение чтения netlink. +4. Получение интерфейсов и адресов именно из непривилегированной службы БК. +5. Получение USB-инвентаризации из той же службы. +6. Подключение реестра доверенных ключей, проверка конфигурации и запуск SSH. +7. Установка Tailscale и проверка его системной службы. + +В этом же разделе доступны реестр доверенных SSH-устройств с формой добавления +публичного ключа и блок фактического входа в Tailscale. Установленная служба +не означает зарегистрированный ключ, удалённый SSH-вход или авторизованную +частную сеть. Эти состояния показываются отдельно по наблюдаемому результату. +Ключ и вход требуют действия оператора через UI; помощник не подставляет +инженерные ключи и не использует общую учётную запись из пакета. + +Каждая строка проходит ожидание → выполнение → готово/ошибка. Зависимые от +неуспешного этапа строки блокируются, независимые проверки продолжаются. +Повторный запуск заново проверяет результат, сохраняет существующие identity, +ключи и сетевые предпочтения; завершённая ранее строка не даёт автоматическую +галочку новому запуску. Закрытие окна не прерывает системное задание или dpkg. +Последний результат сохраняется атомарно; повторное открытие показывает его, +прерванное заданием/перезагрузкой выполнение не выдаётся за успех. + +Фиксированный polkit-helper запускает отдельное root-owned systemd-задание. +Из UI не принимаются команды, пути, имена пакетов или произвольные аргументы. +Web API читает состояние, но не запускает привилегированные действия. +После изменения службы native launcher обновляет локальный сеанс. Повторная +установка/удаление пакета во время задания отклоняется с понятной причиной; +блокировки APT не удаляются и активная транзакция не завершается принудительно. + +Исправление netlink из 0.3.2 теперь принадлежит этому сценарию: managed drop-in +устанавливается кнопкой настройки. Проверяется фактический доступ службы к +сети без CAP_NET_ADMIN. На этом этапе пустые/недоступные сведения различаются. +Чужие конфигурации в управляемых путях сохраняются и дают конфликт, который +нельзя скрывать зелёной галочкой или ручным исправлением только на Mini. +Любое следующее необходимое изменение ОС должно расширять версионный профиль, +его скрипт, проверку результата и сценарий GUI-приёмки на чистой системе. + +Во всех текстах операторского интерфейса используется «система». Название и +версия ОС выводятся один раз в «Обзор БК» из фактической инвентаризации. +Название дистрибутива не является частью навигации, форм SSH, системных +подтверждений или ошибок. Это не расширяет совместимость: профиль пакета +по-прежнему допускает только Ubuntu 24.04 LTS Desktop amd64. + +Приёмка этого приращения требует запуска именно штатной кнопкой на Mini, +наблюдения этапов, повторной настройки без потери доступа/identity, проверки +SSH-формы и состояния Tailscale. Чистая установка, перезагрузка и полный +GUI upgrade/remove остаются отдельными проверками P1; отладочный вызов +helper по SSH не заменяет ни одну из них.