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