feat(node): configure system environment through the desktop workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1.
|
||||
[Service]
|
||||
RestrictAddressFamilies=AF_NETLINK
|
||||
@@ -13,7 +13,7 @@ import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.3.2"
|
||||
VERSION = "0.4.0"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
@@ -65,10 +65,9 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, openssh-server, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)]
|
||||
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
|
||||
@@ -86,8 +85,13 @@ Description: Mission Core onboard computer configuration
|
||||
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
|
||||
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
|
||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
||||
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
|
||||
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||
]:
|
||||
files.append((path, (p / source).read_bytes(), mode))
|
||||
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py start
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/python3
|
||||
"""Fixed, versioned environment workflow. Called only by the installed UI.
|
||||
|
||||
The privileged dispatcher starts a durable systemd job. It accepts no command,
|
||||
path, package name, address, key or other configuration from JavaScript.
|
||||
"""
|
||||
import fcntl
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
|
||||
PROFILE = Path("/usr/share/mission-core-node/environment-profile.json")
|
||||
STATE = Path("/var/lib/mission-core-node-environment")
|
||||
UNIT = "mission-core-node-environment.service"
|
||||
NODE_UNIT = "mission-core-node.service"
|
||||
ORIGIN = "http://127.0.0.1:8780"
|
||||
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=([A-Za-z0-9_-]{43})")
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def command(argv, *, timeout=15):
|
||||
result = subprocess.run(argv, env=ENV, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise SetupError("Системное действие не завершено. Повторите настройку; если ошибка сохранится, откройте диагностику.")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def trusted_directory(path, mode=0o755):
|
||||
path.mkdir(mode=mode, parents=True, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SetupError("Каталог настройки имеет неподходящие права. Переустановите пакет Node через интерфейс системы.")
|
||||
|
||||
|
||||
def publish(path, data):
|
||||
trusted_directory(path.parent)
|
||||
if path.is_symlink():
|
||||
raise SetupError("Конфликт системного файла: существующая ссылка сохранена.")
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".node-env-", delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
try:
|
||||
output.write(data)
|
||||
output.flush()
|
||||
os.fchmod(output.fileno(), 0o644)
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def owned_config(template, destination):
|
||||
expected = template.read_bytes()
|
||||
trusted_directory(destination.parent)
|
||||
if destination.is_symlink():
|
||||
raise SetupError("Конфликт с существующей настройкой. Она сохранена без изменений.")
|
||||
if destination.exists():
|
||||
info = destination.stat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or destination.read_bytes() != expected:
|
||||
raise SetupError("Конфликт с существующей настройкой. Она сохранена; проверьте конфигурацию перед повтором.")
|
||||
return False
|
||||
publish(destination, expected)
|
||||
return True
|
||||
|
||||
|
||||
def authorize():
|
||||
uri = command(["/usr/lib/mission-core-node/node-agent", "authorize"])
|
||||
if not LOGIN.fullmatch(uri):
|
||||
raise SetupError("Не удалось проверить локальную службу БК.")
|
||||
return uri
|
||||
|
||||
|
||||
def probe_node():
|
||||
# Validate the actual sandboxed service, not the root helper's own access.
|
||||
token = LOGIN.fullmatch(authorize()).group(1)
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8780, timeout=10)
|
||||
cookie = None
|
||||
try:
|
||||
connection.request("POST", "/api/session", json.dumps({"token": token}), {"Origin": ORIGIN, "Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise SetupError("Служба БК не подтвердила доступ для проверки.")
|
||||
cookie = response.getheader("Set-Cookie", "").split(";", 1)[0]
|
||||
response.read()
|
||||
connection.request("GET", "/api/status", headers={"Cookie": cookie})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise SetupError("Не удалось получить сведения из службы БК.")
|
||||
data = response.read(2 * 1024 * 1024)
|
||||
return json.loads(data)["host"]
|
||||
finally:
|
||||
if cookie:
|
||||
try:
|
||||
connection.request("POST", "/api/logout", "{}", {"Cookie": cookie, "Origin": ORIGIN, "Content-Type": "application/json"})
|
||||
connection.getresponse().read()
|
||||
except (OSError, http.client.HTTPException):
|
||||
pass
|
||||
connection.close()
|
||||
|
||||
|
||||
def platform():
|
||||
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines() if "=" in line)
|
||||
if release.get("ID", "").strip('"') != "ubuntu" or release.get("VERSION_ID", "").strip('"') != "24.04" or command(["/usr/bin/dpkg", "--print-architecture"]) != "amd64":
|
||||
raise SetupError("Этот профиль не поддерживает установленную систему или архитектуру. Сведения о системе доступны в обзоре БК.")
|
||||
return "Система и архитектура соответствуют профилю."
|
||||
|
||||
|
||||
def packages():
|
||||
missing = []
|
||||
for name in ["openssh-server", "ca-certificates"]:
|
||||
result = subprocess.run(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name], env=ENV, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or result.stdout.strip() != "installed":
|
||||
missing.append(name)
|
||||
if missing:
|
||||
options = ["-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
|
||||
# Never kill APT/dpkg in the middle of a transaction or delete its lock.
|
||||
for argv in [["/usr/bin/apt-get", *options, "update"], ["/usr/bin/apt-get", *options, "--no-remove", "--no-install-recommends", "install", "-y", *missing]]:
|
||||
result = subprocess.run(argv, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Не удалось установить пакеты. Проверьте интернет, закройте другие системные установщики и повторите настройку.")
|
||||
for name in ["openssh-server", "ca-certificates"]:
|
||||
if command(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name]) != "installed":
|
||||
raise SetupError("Проверка установленных пакетов не пройдена.")
|
||||
return "OpenSSH Server и системные зависимости установлены."
|
||||
|
||||
|
||||
def node_service():
|
||||
changed = owned_config(Path("/usr/share/mission-core-node/60-environment.conf"), Path("/etc/systemd/system/mission-core-node.service.d/60-environment.conf"))
|
||||
command(["/usr/bin/systemctl", "daemon-reload"])
|
||||
if command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=User", "--value"]) != "mission-core-node" or command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=CapabilityBoundingSet", "--value"]):
|
||||
raise SetupError("Права службы отличаются от профиля. Настройка остановлена без изменения чужих разрешений.")
|
||||
command(["/usr/bin/systemctl", "enable", "--now", NODE_UNIT])
|
||||
needs_restart = changed
|
||||
if not needs_restart:
|
||||
try:
|
||||
needs_restart = not probe_node().get("networks_readable")
|
||||
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
needs_restart = True
|
||||
if needs_restart:
|
||||
command(["/usr/bin/systemctl", "restart", NODE_UNIT])
|
||||
families = command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=RestrictAddressFamilies", "--value"])
|
||||
if "AF_NETLINK" not in families.split():
|
||||
raise SetupError("Существующая настройка службы запрещает получение сетевых данных. Она сохранена; требуется устранить конфликт профиля.")
|
||||
command(["/usr/bin/systemctl", "is-active", NODE_UNIT])
|
||||
wait_for_node()
|
||||
return "Служба БК запущена; автозапуск и системный профиль проверены."
|
||||
|
||||
|
||||
def wait_for_node():
|
||||
# Type=simple starts before the local socket/listener is ready. Retry only
|
||||
# read-only readiness, never package/service changes or user actions.
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
probe_node()
|
||||
return
|
||||
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise SetupError("Служба БК не подтвердила готовность после запуска. Повторите настройку.")
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def network_inventory():
|
||||
host = probe_node()
|
||||
if not host.get("networks_readable") or any(not item.get("addresses_readable") for item in host["networks"]):
|
||||
raise SetupError("Служба БК не смогла получить интерфейсы или адреса. Проверьте этап настройки службы и повторите.")
|
||||
return f"Получено сетевых интерфейсов: {len(host['networks'])}."
|
||||
|
||||
|
||||
def usb_inventory():
|
||||
host = probe_node()
|
||||
if not host.get("usb_readable"):
|
||||
raise SetupError("Служба БК не смогла получить USB-устройства. Повторите настройку.")
|
||||
return f"Получено USB-устройств: {len(host['usb'])}. Это системное обнаружение."
|
||||
|
||||
|
||||
def ssh_service():
|
||||
owned_config(Path("/usr/share/mission-core-node/60-mission-core-node.conf"), Path("/etc/ssh/sshd_config.d/60-mission-core-node.conf"))
|
||||
trusted_directory(Path("/run/sshd"))
|
||||
command(["/usr/sbin/sshd", "-t"])
|
||||
config = command(["/usr/sbin/sshd", "-T"]).splitlines()
|
||||
if "authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u" not in config or "authorizedkeyscommanduser mission-core-node" not in config:
|
||||
raise SetupError("Другая конфигурация SSH переопределяет доступ Node. Она сохранена; устраните конфликт и повторите.")
|
||||
command(["/usr/bin/systemctl", "enable", "--now", "ssh.service"])
|
||||
command(["/usr/bin/systemctl", "try-reload-or-restart", "ssh.service"])
|
||||
import socket
|
||||
with socket.create_connection(("127.0.0.1", 22), timeout=3) as connection:
|
||||
if not connection.recv(256).startswith(b"SSH-2.0-"):
|
||||
raise SetupError("SSH запущен, но не подтвердил локальную готовность.")
|
||||
return "SSH отвечает локально; реестр доверенных ключей подключён."
|
||||
|
||||
|
||||
def tailscale_install():
|
||||
# Reuse the existing pinned provider installer, checksum and operation lock.
|
||||
result = subprocess.run(["/usr/lib/mission-core-node/install-tailscale"], env=ENV, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise SetupError("Не удалось запустить установку Tailscale.")
|
||||
value = json.loads(result.stdout)
|
||||
if value.get("ok") is not True:
|
||||
raise SetupError(str(value.get("error", "Установка Tailscale не завершена."))[:1024])
|
||||
command(["/usr/bin/systemctl", "is-active", "tailscaled.service"])
|
||||
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
|
||||
|
||||
|
||||
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
|
||||
|
||||
|
||||
def run_steps(profile, operations, save):
|
||||
record = {"schema": profile["schema"], "profile_revision": profile["revision"], "run_id": str(uuid.uuid4()), "state": "running", "started_at": time.time(), "steps": [{"id": step["id"], "state": "pending", "detail": ""} for step in profile["steps"]]}
|
||||
def update():
|
||||
record["updated_at"] = time.time()
|
||||
save(record)
|
||||
update()
|
||||
for specification, step in zip(profile["steps"], record["steps"]):
|
||||
states = {item["id"]: item["state"] for item in record["steps"]}
|
||||
if any(states.get(dependency) != "complete" for dependency in specification["requires"]):
|
||||
step.update(state="blocked", detail="Сначала завершите предыдущие необходимые этапы.")
|
||||
update()
|
||||
continue
|
||||
step.update(state="running", detail="")
|
||||
update()
|
||||
try:
|
||||
step.update(state="complete", detail=operations[step["id"]]())
|
||||
except SetupError as error:
|
||||
step.update(state="error", detail=str(error))
|
||||
except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
step.update(state="error", detail="Не удалось завершить этап. Повторите настройку; сведения о проблеме сохранены в этом списке.")
|
||||
update()
|
||||
record["state"] = "complete" if all(step["state"] == "complete" for step in record["steps"]) else "error"
|
||||
update()
|
||||
return record
|
||||
|
||||
|
||||
def start():
|
||||
trusted_directory(STATE)
|
||||
fd = os.open(STATE / "dispatch.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise SetupError("Настройка уже выполняется. Дождитесь её завершения.")
|
||||
result = subprocess.run(["/usr/bin/systemctl", "start", UNIT], env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Задание настройки не завершилось. Посмотрите этапы и повторите действие.")
|
||||
record = json.loads((STATE / "last-run.json").read_text())
|
||||
# Renew the ordinary local UI session after a service restart. The
|
||||
# capability is returned only to the native launcher, never to reports.
|
||||
return {"ok": record["state"] == "complete", "login_uri": authorize()}
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or sys.argv[1:] not in (["start"], ["run"]):
|
||||
raise SystemExit("Use the installed application's environment setup")
|
||||
os.environ.clear()
|
||||
os.environ.update(ENV)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
if sys.argv[1] == "run":
|
||||
profile = json.loads(PROFILE.read_text())
|
||||
if {step["id"] for step in profile["steps"]} != set(OPERATIONS):
|
||||
raise SetupError("Профиль окружения не соответствует установленной версии.")
|
||||
run_steps(profile, OPERATIONS, lambda record: publish(STATE / "last-run.json", (json.dumps(record) + "\n").encode()))
|
||||
return
|
||||
result = start()
|
||||
except SetupError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
result = {"ok": False, "error": "Не удалось выполнить настройку окружения. Повторите действие."}
|
||||
if sys.argv[1] == "run":
|
||||
raise SystemExit(1)
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -63,6 +63,8 @@ class NodeApplication(Gtk.Application):
|
||||
self.pending = False
|
||||
self.initial_login = False
|
||||
self.cancelled_downloads = set()
|
||||
self.environment_timer = None
|
||||
self.environment_previous = None
|
||||
|
||||
def do_activate(self):
|
||||
if self.window:
|
||||
@@ -84,7 +86,7 @@ class NodeApplication(Gtk.Application):
|
||||
self.view.connect("web-process-terminated", self.process_failed)
|
||||
manager = self.view.get_user_content_manager()
|
||||
manager.add_script(WebKit2.UserScript.new(
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true})});",
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true, environmentSetup: true})});",
|
||||
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
|
||||
))
|
||||
manager.register_script_message_handler("node")
|
||||
@@ -108,9 +110,71 @@ class NodeApplication(Gtk.Application):
|
||||
action = result.get_js_value().to_string()
|
||||
if action == "authorize":
|
||||
self.login()
|
||||
elif action == "configure-system":
|
||||
self.configure_environment()
|
||||
elif action in ("install-tailscale", "connect-tailscale"):
|
||||
self.network_action(action)
|
||||
|
||||
def environment_record(self):
|
||||
try:
|
||||
path = Path("/var/lib/mission-core-node-environment/last-run.json")
|
||||
if path.stat().st_size > 32768:
|
||||
return None
|
||||
value = json.loads(path.read_text())
|
||||
if value.get("schema") == "missioncore.node.environment/v1":
|
||||
return value
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def environment_progress(self):
|
||||
if not self.window or not self.pending:
|
||||
self.environment_timer = None
|
||||
return False
|
||||
record = self.environment_record()
|
||||
if record and record.get("run_id") != self.environment_previous:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-progress', {detail: " + json.dumps(record) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return True
|
||||
|
||||
def configure_environment(self):
|
||||
if self.pending:
|
||||
return
|
||||
self.pending = True
|
||||
previous = self.environment_record()
|
||||
self.environment_previous = previous.get("run_id") if previous else None
|
||||
self.environment_timer = GLib.timeout_add(1000, self.environment_progress)
|
||||
def work():
|
||||
value = {"ok": False}
|
||||
try:
|
||||
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/configure-system"], capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
|
||||
else:
|
||||
value = json.loads(process.stdout)
|
||||
if type(value.get("ok")) is not bool:
|
||||
raise ValueError("Unexpected environment result")
|
||||
if value.get("login_uri") and not LOGIN.fullmatch(value["login_uri"]):
|
||||
raise ValueError("Unexpected local login")
|
||||
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
value = {"ok": False, "error": "Не удалось завершить настройку окружения. Повторите действие."}
|
||||
GLib.idle_add(self.environment_finished, value)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def environment_finished(self, value):
|
||||
self.pending = False
|
||||
if self.environment_timer:
|
||||
GLib.source_remove(self.environment_timer)
|
||||
self.environment_timer = None
|
||||
uri = value.pop("login_uri", None)
|
||||
value["reloading"] = bool(uri)
|
||||
if self.window:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
if uri:
|
||||
self.view.load_uri(uri)
|
||||
return False
|
||||
|
||||
def network_action(self, action):
|
||||
if self.pending:
|
||||
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
|
||||
@@ -156,7 +220,7 @@ class NodeApplication(Gtk.Application):
|
||||
value["browser_opened"] = True
|
||||
except GLib.Error:
|
||||
value["ok"] = False
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход."
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход."
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return False
|
||||
@@ -170,7 +234,7 @@ class NodeApplication(Gtk.Application):
|
||||
uri = authorize(self.development_socket)
|
||||
GLib.idle_add(self.login_ready, uri)
|
||||
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос Ubuntu.")
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.")
|
||||
finally:
|
||||
GLib.idle_add(self.login_finished)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
@@ -258,5 +322,5 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
|
||||
arguments = parser.parse_args()
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit("Run the desktop application as your normal Ubuntu user")
|
||||
raise SystemExit("Run the desktop application as your normal system user")
|
||||
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node explicit environment configuration
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py run
|
||||
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=DEBIAN_FRONTEND=noninteractive
|
||||
UMask=0077
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
# A package transaction must finish even if the operator closes the UI.
|
||||
TimeoutStartSec=0
|
||||
@@ -23,9 +23,8 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
# Go reads interface/address inventory through route netlink on Linux.
|
||||
# CAP_NET_ADMIN stays absent; this does not grant network reconfiguration.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK
|
||||
# The explicit environment workflow admits read-only route netlink metadata.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
LimitNOFILE=1024
|
||||
|
||||
@@ -53,7 +53,7 @@ def checked(command):
|
||||
# bounded network/lock waits; the fixed root process completes independently.
|
||||
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других установок Ubuntu, затем повторите.")
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других системных установок, затем повторите.")
|
||||
|
||||
|
||||
def control_transport():
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>NODE.DC</vendor>
|
||||
<action id="org.nodedc.mission-core-node.configure-system">
|
||||
<description>Configure the Mission Core Node environment</description>
|
||||
<description xml:lang="ru">Настроить окружение Mission Core Node</description>
|
||||
<message>Install required packages, configure Node and SSH services, collect system inventory and install Tailscale.</message>
|
||||
<message xml:lang="ru">Установить зависимости, настроить службы БК и SSH, проверить сеть и USB, установить Tailscale. Вход и ключи подтверждаются отдельно.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/configure-system</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.open">
|
||||
<description>Open Mission Core Node</description>
|
||||
<description xml:lang="ru">Открыть Mission Core Node</description>
|
||||
|
||||
@@ -5,28 +5,10 @@ case "$1" in
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
mc_node_ssh_template=/usr/share/mission-core-node/60-mission-core-node.conf
|
||||
if [ -L "$mc_node_ssh_snippet" ] || { [ -e "$mc_node_ssh_snippet" ] && ! cmp -s "$mc_node_ssh_template" "$mc_node_ssh_snippet"; }; then
|
||||
echo "Mission Core Node: existing custom SSH snippet preserved; configuration conflict." >&2
|
||||
exit 1
|
||||
fi
|
||||
install -D -m 0644 "$mc_node_ssh_template" "$mc_node_ssh_snippet"
|
||||
# Only bootstrap required to open the GUI. Operational configuration is a
|
||||
# versioned job started by «Настройка окружения → Сконфигурировать».
|
||||
if [ -d /run/systemd/system ]; then
|
||||
install -d -m 0755 /run/sshd
|
||||
/usr/sbin/sshd -t
|
||||
mc_node_ssh_config=$(/usr/sbin/sshd -T)
|
||||
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u' >/dev/null; then
|
||||
echo "Mission Core Node: another AuthorizedKeysCommand overrides Node SSH access. Existing configuration was preserved; resolve this conflict before accepting setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommanduser mission-core-node' >/dev/null; then
|
||||
echo "Mission Core Node: conflicting AuthorizedKeysCommandUser; existing configuration was preserved." >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ssh.service
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
fi
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||
echo "Mission Core Node: this package requires Ubuntu 24.04 LTS Desktop amd64." >&2
|
||||
echo "Mission Core Node: this package does not support the installed system." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
@@ -11,9 +20,15 @@ case "$1" in
|
||||
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
|
||||
fi
|
||||
fi
|
||||
mc_node_environment_snippet=/etc/systemd/system/mission-core-node.service.d/60-environment.conf
|
||||
if [ ! -L "$mc_node_environment_snippet" ] && cmp -s /usr/share/mission-core-node/60-environment.conf "$mc_node_environment_snippet"; then
|
||||
rm "$mc_node_environment_snippet"
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
if [ -x /usr/sbin/sshd ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
fi
|
||||
systemctl stop mission-core-node.service
|
||||
systemctl disable mission-core-node.service
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Workflow acceptance boundaries without touching the host OS or credentials."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import environment_helper as helper
|
||||
|
||||
PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-profile.json").read_text())
|
||||
|
||||
|
||||
class EnvironmentWorkflowTests(unittest.TestCase):
|
||||
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
|
||||
saved = []
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = helper.SetupError('Package lock held')
|
||||
first = helper.run_steps(PROFILE, operations, lambda data: saved.append(copy.deepcopy(data)))
|
||||
states = {item['id']: item['state'] for item in first['steps']}
|
||||
self.assertEqual(first['state'], 'error')
|
||||
self.assertEqual(states['packages'], 'error')
|
||||
self.assertEqual(states['ssh-service'], 'blocked')
|
||||
self.assertEqual(states['tailscale-install'], 'blocked')
|
||||
self.assertEqual(states['network-inventory'], 'complete')
|
||||
self.assertEqual(states['usb-inventory'], 'complete')
|
||||
operations['ssh-service'].assert_not_called()
|
||||
operations['tailscale-install'].assert_not_called()
|
||||
self.assertTrue(any(row['state'] == 'running' for state in saved for row in state['steps']))
|
||||
operations['packages'].side_effect = None
|
||||
second = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(second['state'], 'complete')
|
||||
self.assertNotEqual(first['run_id'], second['run_id'])
|
||||
self.assertEqual(operations['network-inventory'].call_count, 2)
|
||||
operations['ssh-service'].assert_called_once()
|
||||
|
||||
def test_incompatible_platform_prevents_all_system_mutations(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['platform'].side_effect = helper.SetupError('Unsupported system')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
for name, operation in operations.items():
|
||||
if name != 'platform':
|
||||
operation.assert_not_called()
|
||||
|
||||
def test_subprocess_exception_does_not_leak_output_or_report_success(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = subprocess.CalledProcessError(1, ['private-command'], output='private-output')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
encoded = json.dumps(result)
|
||||
self.assertNotIn('private-command', encoded)
|
||||
self.assertNotIn('private-output', encoded)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
|
||||
def test_existing_packages_skip_apt_and_no_service_command_is_hidden_here(self):
|
||||
with patch.object(helper.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'installed', '')) as run:
|
||||
helper.packages()
|
||||
self.assertTrue(run.call_args_list)
|
||||
self.assertTrue(all(call.args[0][0] == '/usr/bin/dpkg-query' for call in run.call_args_list))
|
||||
|
||||
def test_missing_package_installed_without_removal_and_without_transaction_timeout(self):
|
||||
def respond(argv, **kwargs):
|
||||
if argv[0] == '/usr/bin/dpkg-query':
|
||||
return subprocess.CompletedProcess(argv, 0, 'installed' if argv[-1] == 'ca-certificates' or installed[0] else 'not-installed', '')
|
||||
if 'install' in argv:
|
||||
installed[0] = True
|
||||
self.assertIn('--no-remove', argv)
|
||||
self.assertNotIn('timeout', kwargs)
|
||||
return subprocess.CompletedProcess(argv, 0, '', '')
|
||||
installed = [False]
|
||||
with patch.object(helper.subprocess, 'run', side_effect=respond) as run:
|
||||
helper.packages()
|
||||
installs = [call.args[0] for call in run.call_args_list if 'install' in call.args[0]]
|
||||
self.assertEqual(len(installs), 1)
|
||||
self.assertEqual(installs[0][-1], 'openssh-server')
|
||||
|
||||
def test_inventory_verifies_board_service_not_root_access(self):
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': False, 'networks': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': [{'addresses_readable': False}]}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': []}):
|
||||
self.assertIn('0', helper.network_inventory())
|
||||
with patch.object(helper, 'probe_node', return_value={'usb_readable': False, 'usb': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.usb_inventory()
|
||||
|
||||
def test_foreign_config_and_symlink_are_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(helper, 'trusted_directory'):
|
||||
template, target = Path(directory)/'template', Path(directory)/'config'
|
||||
template.write_bytes(b'owned')
|
||||
target.write_bytes(b'foreign')
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertEqual(target.read_bytes(), b'foreign')
|
||||
target.unlink()
|
||||
target.symlink_to(template)
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertTrue(target.is_symlink())
|
||||
self.assertEqual(template.read_bytes(), b'owned')
|
||||
|
||||
def test_delayed_service_start_retries_only_readiness(self):
|
||||
with patch.object(helper, 'probe_node', side_effect=[OSError('not listening'), {}]) as read, patch.object(helper.time, 'sleep'), patch.object(helper, 'command') as mutation:
|
||||
helper.wait_for_node()
|
||||
self.assertEqual(read.call_count, 2)
|
||||
mutation.assert_not_called()
|
||||
|
||||
def test_failed_service_rights_never_starts_or_restarts_it(self):
|
||||
def command(argv):
|
||||
if '--property=User' in argv: return 'root'
|
||||
return ''
|
||||
with patch.object(helper, 'owned_config', return_value=False), patch.object(helper, 'command', side_effect=command) as run:
|
||||
with self.assertRaises(helper.SetupError): helper.node_service()
|
||||
self.assertFalse(any('restart' in call.args[0] or 'enable' in call.args[0] for call in run.call_args_list))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 <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>
|
||||
{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>}
|
||||
<p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа Ubuntu сохраняются.</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={`Пользователь системы: ${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; открытые сеансы и отдельно настроенные способы входа в систему сохраняются.</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>}>
|
||||
{!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" />
|
||||
<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} />
|
||||
<p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p>
|
||||
</form>}
|
||||
</Window>
|
||||
<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>
|
||||
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
|
||||
if (!remove) return;
|
||||
|
||||
@@ -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 <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}>
|
||||
<p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</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>}
|
||||
{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>}
|
||||
</>}
|
||||
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню Ubuntu. После обновления пакета требуется перезапуск окна.</p>}
|
||||
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне Ubuntu. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне Ubuntu. Проверяем подключение…"} />}
|
||||
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню приложений. После обновления пакета требуется перезапуск окна.</p>}
|
||||
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне. Проверяем подключение…"} />}
|
||||
{notice && <p className="node-note" role="status">{notice}</p>}
|
||||
</SettingsCard></div>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RootId>("system");
|
||||
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" });
|
||||
const workspace = useApplicationWorkspace<ViewId>({ 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" ? <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 === "usb" ? <USBView 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;
|
||||
return <>
|
||||
<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} /></>}
|
||||
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>} />}
|
||||
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: 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}
|
||||
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") }] : []}
|
||||
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}
|
||||
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}>
|
||||
{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" : "Нода недоступна"}>
|
||||
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне Ubuntu." : "Не удалось связаться со службой. Повторите подключение."}</p>}
|
||||
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
|
||||
{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 ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
|
||||
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
|
||||
</SettingsCard>}
|
||||
</div>} />
|
||||
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user