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
+11 -3
View File
@@ -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 0.3.2 also repairs Linux interface inventory: the unprivileged service admits
AF_NETLINK for OS metadata reads while retaining an empty capability set. 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, identity, GUI naming, OS-authenticated local launch, redacted report export,
OpenSSH installation/autostart, and GUI enrollment/revocation of Ed25519 public OpenSSH installation/autostart, and GUI enrollment/revocation of Ed25519 public
keys for local Ubuntu administrators. The agent and desktop window run 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 unprivileged; fixed polkit helpers admit only local login and the shipped system/network actions. SSH configuration is
owned by the installer, with conflict detection and cleanup on removal. owned by the versioned environment workflow, with conflict detection and cleanup on removal.
Core pairing/mTLS, sensor plugins, capture, media and Core pairing/mTLS, sensor plugins, capture, media and
recovery are subsequent vertical increments, not implemented capabilities of recovery are subsequent vertical increments, not implemented capabilities of
+2 -2
View File
@@ -15,11 +15,11 @@ import (
) )
func main() { 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) } store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
assets, _ := fs.Sub(web.Assets, "dist") assets, _ := fs.Sub(web.Assets, "dist")
memory, available := uint64(8388608), uint64(5242880) 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, 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"}}}, 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{}} USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}}
+1 -1
View File
@@ -152,7 +152,7 @@ func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
func (a *AccessStore) Add(user, label, key string) error { func (a *AccessStore) Add(user, label, key string) error {
if !a.allowed(user) { if !a.allowed(user) {
return errors.New("Выберите существующую учётную запись администратора Ubuntu") return errors.New("Выберите существующую учётную запись администратора системы")
} }
label = strings.TrimSpace(label) label = strings.TrimSpace(label)
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) { if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
@@ -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"]}
]
}
@@ -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
}
@@ -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")
}
}
+24 -13
View File
@@ -13,17 +13,18 @@ import (
) )
type Server struct { type Server struct {
Store *Store Store *Store
Assets fs.FS Assets fs.FS
Origin string Origin string
Version string Version string
Inventory func() Inventory Inventory func() Inventory
Access *AccessStore Access *AccessStore
Tailscale func() TailscaleStatus Tailscale func() TailscaleStatus
mu sync.Mutex Environment func() EnvironmentStatus
logins map[string]time.Time mu sync.Mutex
sessions map[string]time.Time logins map[string]time.Time
Now func() time.Time sessions map[string]time.Time
Now func() time.Time
} }
func token() string { func token() string {
@@ -80,6 +81,16 @@ func (s *Server) Handler() http.Handler {
s.accessRoutes(mux) s.accessRoutes(mux)
} }
mux.HandleFunc("POST /api/session", s.login) 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) { mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) { if !s.authorized(w, r) {
return return
@@ -194,7 +205,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
_, ok := s.logins[body.Token] _, ok := s.logins[body.Token]
delete(s.logins, body.Token) delete(s.logins, body.Token)
if !ok { if !ok {
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню Ubuntu"}) reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"})
return return
} }
if s.sessions == nil { if s.sessions == nil {
@@ -224,7 +235,7 @@ func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
defer s.mu.Unlock() defer s.mu.Unlock()
prune(s.sessions, s.now()) prune(s.sessions, s.now())
if _, ok := s.sessions[c.Value]; !ok { 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 false
} }
return true return true
@@ -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] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.3.2" VERSION = "0.4.0"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -65,10 +65,9 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid> Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin Section: admin
Priority: optional 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 Description: Mission Core onboard computer configuration
Local graphical setup, host inventory, SSH access and persistent node identity. Local graphical setup, host inventory, SSH access and persistent node identity.
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
""".encode() """.encode()
controls = [("control", control, 0o644)] controls = [("control", control, 0o644)]
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]] 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), ("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755), ("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644), ("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((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(): if (ROOT / "build/provenance.json").exists():
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644)) 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)) 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.pending = False
self.initial_login = False self.initial_login = False
self.cancelled_downloads = set() self.cancelled_downloads = set()
self.environment_timer = None
self.environment_previous = None
def do_activate(self): def do_activate(self):
if self.window: if self.window:
@@ -84,7 +86,7 @@ class NodeApplication(Gtk.Application):
self.view.connect("web-process-terminated", self.process_failed) self.view.connect("web-process-terminated", self.process_failed)
manager = self.view.get_user_content_manager() manager = self.view.get_user_content_manager()
manager.add_script(WebKit2.UserScript.new( 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, WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
)) ))
manager.register_script_message_handler("node") manager.register_script_message_handler("node")
@@ -108,9 +110,71 @@ class NodeApplication(Gtk.Application):
action = result.get_js_value().to_string() action = result.get_js_value().to_string()
if action == "authorize": if action == "authorize":
self.login() self.login()
elif action == "configure-system":
self.configure_environment()
elif action in ("install-tailscale", "connect-tailscale"): elif action in ("install-tailscale", "connect-tailscale"):
self.network_action(action) 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): def network_action(self, action):
if self.pending: if self.pending:
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False) self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
@@ -156,7 +220,7 @@ class NodeApplication(Gtk.Application):
value["browser_opened"] = True value["browser_opened"] = True
except GLib.Error: except GLib.Error:
value["ok"] = False value["ok"] = False
value["error"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход." value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход."
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));" script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
self.view.evaluate_javascript(script, -1, None, None, None, None, None) self.view.evaluate_javascript(script, -1, None, None, None, None, None)
return False return False
@@ -170,7 +234,7 @@ class NodeApplication(Gtk.Application):
uri = authorize(self.development_socket) uri = authorize(self.development_socket)
GLib.idle_add(self.login_ready, uri) GLib.idle_add(self.login_ready, uri)
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError): except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос Ubuntu.") GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.")
finally: finally:
GLib.idle_add(self.login_finished) GLib.idle_add(self.login_finished)
threading.Thread(target=work, daemon=True).start() 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") parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
arguments = parser.parse_args() arguments = parser.parse_args()
if os.geteuid() == 0: 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([])) 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 ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
RestrictSUIDSGID=yes RestrictSUIDSGID=yes
# Go reads interface/address inventory through route netlink on Linux. # The explicit environment workflow admits read-only route netlink metadata.
# CAP_NET_ADMIN stays absent; this does not grant network reconfiguration. RestrictAddressFamilies=AF_UNIX AF_INET
RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK
CapabilityBoundingSet= CapabilityBoundingSet=
LockPersonality=yes LockPersonality=yes
LimitNOFILE=1024 LimitNOFILE=1024
+1 -1
View File
@@ -53,7 +53,7 @@ def checked(command):
# bounded network/lock waits; the fixed root process completes independently. # bounded network/lock waits; the fixed root process completes independently.
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode: if result.returncode:
raise SetupError("Установка не завершена. Проверьте интернет и завершение других установок Ubuntu, затем повторите.") raise SetupError("Установка не завершена. Проверьте интернет и завершение других системных установок, затем повторите.")
def control_transport(): 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"> <!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig> <policyconfig>
<vendor>NODE.DC</vendor> <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"> <action id="org.nodedc.mission-core-node.open">
<description>Open Mission Core Node</description> <description>Open Mission Core Node</description>
<description xml:lang="ru">Открыть 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 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 adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
fi fi
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf # Only bootstrap required to open the GUI. Operational configuration is a
mc_node_ssh_template=/usr/share/mission-core-node/60-mission-core-node.conf # versioned job started by «Настройка окружения → Сконфигурировать».
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"
if [ -d /run/systemd/system ]; then 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 daemon-reload
systemctl enable --now ssh.service
systemctl try-reload-or-restart ssh.service
systemctl enable mission-core-node.service systemctl enable mission-core-node.service
systemctl restart mission-core-node.service systemctl restart mission-core-node.service
fi fi
+10 -1
View File
@@ -1,9 +1,18 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then 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 . /etc/os-release
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then 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 exit 1
fi fi
fi fi
+17 -2
View File
@@ -1,5 +1,14 @@
#!/bin/sh #!/bin/sh
set -eu 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 case "$1" in
remove|deconfigure) remove|deconfigure)
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf 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" mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
fi fi
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 if [ -d /run/systemd/system ]; then
/usr/sbin/sshd -t if [ -x /usr/sbin/sshd ]; then
systemctl try-reload-or-restart ssh.service /usr/sbin/sshd -t
systemctl try-reload-or-restart ssh.service
fi
systemctl stop mission-core-node.service systemctl stop mission-core-node.service
systemctl disable mission-core-node.service systemctl disable mission-core-node.service
fi 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()
@@ -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<string,string>={pending:"Ожидает",running:"Выполняется",complete:"Готово",error:"Ошибка",blocked:"Не выполнено"};
export function EnvironmentView({environment,failure,success}: {environment:ReturnType<typeof useEnvironment>;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 <div className="node-content">
<SettingsCard title="Настройка окружения" description="Установка и проверка компонентов для работы с бортовым компьютером.">
<p className="node-note">Сконфигурируйте окружение при первом запуске или повторите проверку после изменений. Приложение установит нужные пакеты, настроит службы и получит системные сведения. Вход в частную сеть и доверенный SSH-ключ подтверждаются ниже.</p>
<Button onClick={start} disabled={running||!value||!environmentSetupAvailable()}>{running?"Настраиваем…":value?.run?"Проверить и сконфигурировать":"Сконфигурировать"}</Button>
{!environmentSetupAvailable()&&<p className="node-note">Настройка запускается из установленного приложения на БК.</p>}
{pending&&(!value?.run||value.run.state!=="running")&&<ActivityIndicator label="Подтвердите действие в системном окне. Ожидаем начало настройки…" />}
{!value?loading?<ActivityIndicator label="Получаем этапы настройки" />:<p className="node-note">Сведения о настройке недоступны. Обновите страницу.</p>:<>
{!value.available&&<p className="node-note">Не удалось прочитать результат предыдущей настройки. Запустите настройку, чтобы заново проверить окружение.</p>}
<ResourceList aria-label="Этапы настройки окружения">{value.profile.steps.map(specification=>{
const step=value.run?.steps.find(item=>item.id===specification.id);
const state=step?.state??"pending";
return <li key={specification.id}><ResourceRow icon={state==="running"?<ActivityIndicator size="compact" />:<Icon name={state==="complete"?"check":state==="error"?"alert":"circle"} />} title={specification.label} description={specification.description} metadata={step?.detail} status={<StatusBadge tone={state==="complete"?"success":state==="error"?"warning":"neutral"}>{labels[state]??"Неизвестно"}</StatusBadge>} /></li>;
})}</ResourceList>
{complete&&!running&&<p className="node-note">Системные этапы завершены. Ниже проверьте вход в Tailscale и доверенный ключ для SSH.</p>}
{value.run?.state==="error"&&!running&&<p className="node-note">Часть этапов не завершена. Исправьте указанные причины и повторите настройку.</p>}
{value.run?.state==="interrupted"&&<p className="node-note">Предыдущая настройка прервана. Повторный запуск проверит уже выполненные этапы.</p>}
</>}
</SettingsCard>
{!running&&<>
<SettingsCard title="Доверенное устройство для SSH"><SystemAccess revision={revision} failure={failure} success={success} adding={adding} closeAdd={()=>setAdding(false)} startAdd={()=>setAdding(true)} /></SettingsCard>
<TailnetAccess failure={failure} revision={revision} />
</>}
</div>;
}
+7 -6
View File
@@ -3,7 +3,7 @@ import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, Resourc
import { request } from "./api"; import { request } from "./api";
import { useAccess, type AccessKey } from "./useAccess"; 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 { access, loading, refresh } = useAccess(revision, failure);
const [user, setUser] = useState(""); const [user, setUser] = useState("");
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
@@ -21,19 +21,20 @@ export function SystemAccess({ revision, failure, success, adding, closeAdd }: {
return <div className="node-content"> return <div className="node-content">
<div className="node-section-heading"><p className="node-note">Компьютеры, которым разрешён вход по SSH через Node.</p><StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}</StatusBadge></div> <div className="node-section-heading"><p className="node-note">Компьютеры, которым разрешён вход по SSH через Node.</p><StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}</StatusBadge></div>
{loading && !access ? <ActivityIndicator label="Получение доверенных устройств" /> : !access ? <p className="node-note">Не удалось загрузить список. Повторите обновление.</p> : <> {loading && !access ? <ActivityIndicator label="Получение доверенных устройств" /> : !access ? <p className="node-note">Не удалось загрузить список. Повторите обновление.</p> : <>
{access.keys.length === 0 ? <p className="node-note">Доверенных устройств пока нет. Нажмите плюс в шапке, чтобы добавить компьютер.</p> : <ResourceList aria-label="Доверенные SSH-устройства">{access.keys.map(item => <li key={`${item.user}:${item.id}`}><ResourceRow icon={<Icon name="key" />} title={item.label} description={`Пользователь Ubuntu: ${item.user}`} metadata={<span title={item.id}>{item.id}</span>} status={<StatusBadge>Доступ разрешён</StatusBadge>} actions={<><IconButton label={`Сведения: ${item.label}`} onClick={() => setDetail(item)}><Icon name="eye" /></IconButton><IconButton label={`Отозвать доступ: ${item.label}`} onClick={() => setRemove(item)}><Icon name="trash" /></IconButton></>} /></li>)}</ResourceList>} {access.keys.length === 0 ? <p className="node-note">Доверенных устройств пока нет. Добавьте компьютер, которому нужен доступ.</p> : <ResourceList aria-label="Доверенные SSH-устройства">{access.keys.map(item => <li key={`${item.user}:${item.id}`}><ResourceRow icon={<Icon name="key" />} title={item.label} description={`Пользователь системы: ${item.user}`} metadata={<span title={item.id}>{item.id}</span>} status={<StatusBadge>Доступ разрешён</StatusBadge>} actions={<><IconButton label={`Сведения: ${item.label}`} onClick={() => setDetail(item)}><Icon name="eye" /></IconButton><IconButton label={`Отозвать доступ: ${item.label}`} onClick={() => setRemove(item)}><Icon name="trash" /></IconButton></>} /></li>)}</ResourceList>}
<p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа Ubuntu сохраняются.</p> <p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа в систему сохраняются.</p>
</>} </>}
{startAdd && <Button onClick={startAdd} disabled={!access}>Добавить доверенное устройство</Button>}
<Window open={adding} title="Добавить доверенное устройство" subtitle="Разрешить компьютеру подключаться к этому борту по SSH" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={() => { if (!pending) closeAdd(); }} footer={<WindowFooterActions><Button disabled={pending} onClick={closeAdd}>Отмена</Button><Button type="submit" form="node-add-ssh" disabled={pending || !access || !key.trim() || !label.trim() || !user}>{pending ? "Добавляем…" : "Разрешить доступ"}</Button></WindowFooterActions>}> <Window open={adding} title="Добавить доверенное устройство" subtitle="Разрешить компьютеру подключаться к этому борту по SSH" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={() => { if (!pending) closeAdd(); }} footer={<WindowFooterActions><Button disabled={pending} onClick={closeAdd}>Отмена</Button><Button type="submit" form="node-add-ssh" disabled={pending || !access || !key.trim() || !label.trim() || !user}>{pending ? "Добавляем…" : "Разрешить доступ"}</Button></WindowFooterActions>}>
{!access ? <p className="node-note">{loading ? "Получаем пользователей Ubuntu…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы Ubuntu. Добавьте пользователя в настройках системы.</p> : <form id="node-add-ssh" className="node-form" onSubmit={add} aria-busy={pending}> {!access ? <p className="node-note">{loading ? "Получаем пользователей системы…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы системы. Добавьте пользователя в настройках системы.</p> : <form id="node-add-ssh" className="node-form" onSubmit={add} aria-busy={pending}>
<TextField label="Название устройства" placeholder="Например, ноутбук оператора" value={label} maxLength={64} disabled={pending} onChange={event => setLabel(event.target.value)} autoComplete="off" /> <TextField label="Название устройства" placeholder="Например, ноутбук оператора" value={label} maxLength={64} disabled={pending} onChange={event => setLabel(event.target.value)} autoComplete="off" />
<Select label="Пользователь Ubuntu" value={user} options={access.users.map(value => ({ value, label: value }))} onChange={setUser} disabled={pending} /> <Select label="Пользователь системы" value={user} options={access.users.map(value => ({ value, label: value }))} onChange={setUser} disabled={pending} />
<TextAreaField label="Публичный SSH-ключ Ed25519" placeholder="ssh-ed25519 …" value={key} rows={4} disabled={pending} onChange={event => setKey(event.target.value)} autoComplete="off" spellCheck={false} /> <TextAreaField label="Публичный SSH-ключ Ed25519" placeholder="ssh-ed25519 …" value={key} rows={4} disabled={pending} onChange={event => setKey(event.target.value)} autoComplete="off" spellCheck={false} />
<p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p> <p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p>
</form>} </form>}
</Window> </Window>
<Window open={detail !== null} title={detail?.label ?? "Доверенное устройство"} subtitle="SSH-доступ к этому борту" onClose={() => setDetail(null)}> <Window open={detail !== null} title={detail?.label ?? "Доверенное устройство"} subtitle="SSH-доступ к этому борту" onClose={() => setDetail(null)}>
{detail && <dl className="node-facts"><div><dt>Пользователь Ubuntu</dt><dd>{detail.user}</dd></div><div><dt>Отпечаток</dt><dd>{detail.id}</dd></div><div><dt>Публичный ключ</dt><dd>{detail.public_key}</dd></div></dl>} {detail && <dl className="node-facts"><div><dt>Пользователь системы</dt><dd>{detail.user}</dd></div><div><dt>Отпечаток</dt><dd>{detail.id}</dd></div><div><dt>Публичный ключ</dt><dd>{detail.public_key}</dd></div></dl>}
</Window> </Window>
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => { <ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
if (!remove) return; if (!remove) return;
+4 -4
View File
@@ -27,7 +27,7 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e
if (pending) return; if (pending) return;
setNotice(""); setPending(action); setNotice(""); setPending(action);
if (!desktopAction(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); const label = tailscaleLabel(value, checked);
@@ -36,13 +36,13 @@ export function TailnetAccess({ failure, revision: hostRevision }: { failure: (e
return <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}> return <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}>
<p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</p> <p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</p>
{!checked ? <ActivityIndicator label="Проверяем подключение Tailscale" /> : !value ? <p className="node-note">Не удалось получить состояние. Повторная проверка выполняется автоматически.</p> : <> {!checked ? <ActivityIndicator label="Проверяем подключение Tailscale" /> : !value ? <p className="node-note">Не удалось получить состояние. Повторная проверка выполняется автоматически.</p> : <>
{!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение Ubuntu.</p><Button disabled={pending !== null || !supported} onClick={() => perform("install-tailscale")}>Установить Tailscale</Button></>} {!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение.</p><Button disabled={pending !== null || !supported} onClick={() => perform("install-tailscale")}>Установить Tailscale</Button></>}
{canConnect && <Button disabled={pending !== null || !supported} onClick={() => perform("connect-tailscale")}>{value.state === "NeedsLogin" ? "Войти в Tailscale" : "Подключить Tailscale"}</Button>} {canConnect && <Button disabled={pending !== null || !supported} onClick={() => perform("connect-tailscale")}>{value.state === "NeedsLogin" ? "Войти в Tailscale" : "Подключить Tailscale"}</Button>}
{value.state === "NeedsMachineAuth" && <p className="node-note">Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.</p>} {value.state === "NeedsMachineAuth" && <p className="node-note">Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.</p>}
{value.addresses.length > 0 && <dl className="node-facts"><div><dt>Адреса в Tailscale</dt><dd>{value.addresses.join(" · ")}</dd></div></dl>} {value.addresses.length > 0 && <dl className="node-facts"><div><dt>Адреса в Tailscale</dt><dd>{value.addresses.join(" · ")}</dd></div></dl>}
</>} </>}
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню Ubuntu. После обновления пакета требуется перезапуск окна.</p>} {!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню приложений. После обновления пакета требуется перезапуск окна.</p>}
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне Ubuntu. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне Ubuntu. Проверяем подключение…"} />} {pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне. Проверяем подключение…"} />}
{notice && <p className="node-note" role="status">{notice}</p>} {notice && <p className="node-note" role="status">{notice}</p>}
</SettingsCard></div>; </SettingsCard></div>;
} }
+5 -2
View File
@@ -11,12 +11,15 @@ export interface Status {
} }
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } } 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 { export function networkSetupAvailable(): boolean {
return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true; return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true;
} }
export function desktopAction(action: DesktopAction): boolean { 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 host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } };
const channel = host.webkit?.messageHandlers?.node; const channel = host.webkit?.messageHandlers?.node;
if (!channel) return false; if (!channel) return false;
+13 -9
View File
@@ -11,22 +11,26 @@ import { NodeOverview } from "./NodeOverview";
import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews"; import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews";
import { SystemAccess } from "./SystemAccess"; import { SystemAccess } from "./SystemAccess";
import { TailnetAccess } from "./TailnetAccess"; import { TailnetAccess } from "./TailnetAccess";
import { useEnvironment } from "./useEnvironment";
import { EnvironmentView } from "./EnvironmentView";
import "./node.css"; import "./node.css";
function App() { function App() {
const node = useNode(); const node = useNode();
const { value, pending, locked, refresh, failure } = node; 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<RootId>("system"); const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" }); const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark"); const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
// Theme applies to body portals as well as the application shell. // Theme applies to body portals as well as the application shell.
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]); useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
const currentRoot = roots.find(item => item.id === root)!; const currentRoot = roots.find(item => item.id === root)!;
const currentView = views.find(item => item.id === workspace.activeView); 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); } function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
const content = !value ? null : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} /> const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "network" ? <NetworkView value={value} /> : workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} /> : workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} /> : workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
@@ -34,18 +38,18 @@ function App() {
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null; : workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
return <> return <>
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node" <ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first }))} onChange={selectRoot} /></>} center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} /></>}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: () => void refresh() }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />} right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded} navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки" navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []} contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />} items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView} content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending, onClick: () => void refresh() }]}>{content}</ApplicationPanel>} utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{content}</ApplicationPanel>}
stage={<div className="node-stage" aria-busy={pending}> stage={<div className="node-stage" aria-busy={pending}>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`${value.host.os} · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}> {value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`Бортовой компьютер · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне Ubuntu." : "Не удалось связаться со службой. Повторите подключение."}</p>} {pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button> <Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
</SettingsCard>} </SettingsCard>}
</div>} /> </div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} /> <ToastStack items={node.toasts} onDismiss={node.dismiss} />
+3 -2
View File
@@ -1,11 +1,12 @@
import type { IconName } from "@nodedc/ui-react"; import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices"; 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 }[] = [ 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 }, { id: "devices", label: "Устройства", first: null },
]; ];
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [ 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: "overview", root: "system", label: "Обзор БК", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" }, { id: "network", root: "system", label: "Сеть", icon: "network" },
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" }, { id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
+50
View File
@@ -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<EnvironmentStatus | null>(null);
const [pending, setPending] = useState(false);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {setValue(await request<EnvironmentStatus>("/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<EnvironmentRun>).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"};
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function useNode() {
finally { setPending(false); } finally { setPending(false); }
}, [failure]); }, [failure]);
useEffect(() => { 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); launch(); window.addEventListener("hashchange", launch);
return () => window.removeEventListener("hashchange", launch); return () => window.removeEventListener("hashchange", launch);
}, [refresh, failure]); }, [refresh, failure]);
@@ -21,6 +21,8 @@ capabilities или готовностью к съёмке. Устройства
## Node: согласованное размещение ## Node: согласованное размещение
- Верхний раздел «Система» вместо «Состояние системы». - Верхний раздел «Система» вместо «Состояние системы».
- Первый пункт слева — «Настройка окружения»: кнопка «Сконфигурировать»
запускает общий версионный профиль системных действий с проверяемыми этапами.
- «Обзор БК»: ОС, архитектура, CPU/RAM, имя, сводка фактических подключений. - «Обзор БК»: ОС, архитектура, CPU/RAM, имя, сводка фактических подключений.
Название редактируется в блоке «Название БК». Самоподтверждающий зелёный Название редактируется в блоке «Название БК». Самоподтверждающий зелёный
статус «Node работает» удаляется. Сведения имеют время получения. статус «Node работает» удаляется. Сведения имеют время получения.
@@ -32,8 +34,9 @@ capabilities или готовностью к съёмке. Устройства
привязки к Core. Подключение Core отделено от настройки сетевого транспорта. привязки к Core. Подключение Core отделено от настройки сетевого транспорта.
- «Диагностика»: существующий очищенный отчёт и замечания инвентаризации. - «Диагностика»: существующий очищенный отчёт и замечания инвентаризации.
Это ограниченная диагностика, не полный support bundle всех будущих модулей. Это ограниченная диагностика, не полный support bundle всех будущих модулей.
- Отдельная «Конфигурация системы» удаляется. Состояния SSH/Tailscale и - Прежняя пассивная «Конфигурация системы» заменена рабочей «Настройкой
оборудования входят в сводку; операции остаются в своих подразделах. окружения» по последующему уточнению владельца. Состояния входят в обзор;
полный сценарий подготовки доступен в первом пункте «Системы».
- Верхние «Устройства» предназначены для драйверных K1, D455 и следующих - Верхние «Устройства» предназначены для драйверных K1, D455 и следующих
сенсоров. Системный USB-список там не дублируется. В этом приращении сенсоров. Системный USB-список там не дублируется. В этом приращении
вкладка недоступна до появления рабочего подключения через драйвер; вкладка недоступна до появления рабочего подключения через драйвер;
@@ -142,7 +145,7 @@ adapter и K1 Bridge в общей с БК локальной сети. Близ
## Порядок реализации и приёмка ## Порядок реализации и приёмка
1. Текущее приращение: перенос существующих страниц в «Систему», обзор БК, 1. Текущее приращение: перенос существующих страниц в «Систему», обзор БК,
переименование полей, устранение отдельной конфигурации и self-status. переименование полей, устранение self-status и единая настройка окружения.
Сборка .deb и GUI-проверка на физическом Mini. Сборка .deb и GUI-проверка на физическом Mini.
2. Закрыть оставшиеся системные проверки P1 по предыдущему отчёту: 2. Закрыть оставшиеся системные проверки P1 по предыдущему отчёту:
чистая установка, GUI upgrade/repair, reboot и SSH lifecycle без консоли. чистая установка, GUI upgrade/repair, reboot и SSH lifecycle без консоли.
@@ -160,7 +163,7 @@ Select/TextField/TextAreaField, Window/ConfirmationModal, StatusBadge,
ActivityIndicator, ToastStack. Новых визуальных примитивов и копий DG нет. ActivityIndicator, ToastStack. Новых визуальных примитивов и копий DG нет.
Для текущей приёмки: USB доступен из «Системы»; прежних remote/setup маршрутов Для текущей приёмки: USB доступен из «Системы»; прежних remote/setup маршрутов
нет; имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым нет (новая настройка — environment); имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым
драйвером; Tailscale/SSH открываются из сводки; плюс SSH работает после переноса; драйвером; Tailscale/SSH открываются из сводки; плюс SSH работает после переноса;
темы/узкое окно и recovery сеанса не нарушены. Для будущего сопряжения: темы/узкое окно и recovery сеанса не нарушены. Для будущего сопряжения:
добавленный аппарат сохраняется после reload/restart и открывает именно добавленный аппарат сохраняется после reload/restart и открывает именно
@@ -192,3 +195,66 @@ Go race tests проверяют ошибку чтения отдельно от
адресов без вымышленных данных. Через UI изолированной ноды подтверждены адресов без вымышленных данных. Через 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 не заменяет ни одну из них.