feat(node): package Ubuntu desktop setup and trusted access

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 17:27:59 +03:00
parent 57f2537af3
commit d696842f5d
52 changed files with 4457 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/build/
/ui/node_modules/
/ui/dist/
/web/dist/
+166
View File
@@ -0,0 +1,166 @@
# Mission Core Node — Ubuntu system configuration candidate
0.3.0 adopts the canonical Mission Core shell, navigation, system views and a
GUI list of trusted SSH devices. ResourceRow is shared from Design Guideline;
see `docs/node/02_NODE_DESKTOP_SURFACE.md` for composition and acceptance.
The Debian package still contains the GTK/WebKit desktop launcher and bundled
React UI. Real Mini installation and read-only UI checks passed; physical
reboot, bare-Ubuntu installation and full GUI upgrade/removal remain open.
**Qualification in progress (2026-09-05):** the owner installed 0.2.0 through
App Center after closing Synaptic, which had blocked package installation.
PackageKit completed successfully and the packaged service is active.
The owner subsequently confirmed the corrected icon and Tailscale “online” with
the board address under 0.2.2. 0.2.2 corrects the desktop icon's
canvas and configures the pinned provider's HTTPS control transport. The earlier
0.2.1 icon-only candidate was superseded before installation.
Manual SSH bootstrap is authorized for engineering access only.
0.2.3 also routes expired-session errors from Tailscale polling to the existing
application login surface. Browser acceptance confirmed that restarting the
temporary test service now shows login instead of a misleading unavailable
provider. This does not preserve authentication across a service restart.
Source of truth: MISSIONCOR-76 and its UI-FIRST / BRIDGE-ONLY / system
configuration comments. Product surface and physical acceptance procedure:
`docs/node/01_BOOTSTRAP_SURFACE_AND_ACCEPTANCE.md` at the repository root.
This is an independently built application in the Mission Core monorepo.
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.
Core pairing/mTLS, sensor plugins, capture, media and
recovery are subsequent vertical increments, not implemented capabilities of
this package. This is not a completed Node v1 or a hardware-qualified release.
## Operator workflow
Open the `.deb` in Ubuntu's graphical package installer, install it, then launch
Mission Core Node from the applications menu and approve the normal OS dialog.
The application opens in its own GTK window with embedded WebKit rendering and
native system dialogs for authorization and report saving. No external browser
is opened. Closing the window leaves the independent board service running.
The system installer resolves dependencies from Ubuntu repositories; internet
access is needed when those dependencies are absent. There are no shared
credentials in the package. SSH public keys are enrolled explicitly in the UI.
No shell, Go, Python environment setup, npm, or source checkout is required from
the operator. The included Python launcher uses the system Python dependency.
The desktop icon contains the unchanged canonical NODE.DC mark from the admitted
Design Guideline revision inside a transparent square SVG canvas. This gives
desktop loaders square intrinsic dimensions without stretching the mark. After
upgrading the package, close and reopen the
application window so its native helpers and embedded UI have matching features.
**Observed installer limitation (2026-09-05):** App Center revision 1270 on the
qualification board showed “installed” instead of offering the 0.1.1 → 0.2.0
local-file upgrade. Do not claim that update path is accepted. The owner's
subsequent GUI removal succeeded; reinstall attempts then failed before dpkg
because Synaptic remained open and held `/var/lib/dpkg/lock-frontend`.
Exit Synaptic through File → Quit before retrying the `.deb` in App Center.
Do not delete package-manager locks or terminate a running transaction. The
0.2.0 SHA-256 still matches, and APT simulation resolves its dependencies;
neither check alone establishes actual installation. The following GUI retry
completed successfully and the installed package is 0.2.0. A complete product installer
must still qualify GUI upgrade, removal and actionable lock/error handling.
## Private network setup
The optional Tailscale panel has real install, login, waiting-for-approval,
stopped, starting, unavailable and connected states. Installation and connection
use two fixed root-owned polkit helpers from the desktop window. The web API
can only read a reduced local status; it cannot run commands or change network
settings. The generic Node identity and capture lifecycle do not depend on
Tailscale. Pairing to Mission Core is still a separate, unimplemented operation.
On a new machine, the helper downloads the official amd64 `.deb` pinned in
`packaging/tailscale-release.json`, verifies SHA-256 before invoking APT, installs
without removing other packages, and enables `tailscaled`. It does not add an
APT repository or upgrade an existing Tailscale installation. An existing
stopped authenticated configuration is resumed with a bare `tailscale up`;
fresh login explicitly disables accepting remote DNS and subnet routes. No
exit node, advertised subnet, Tailscale SSH, forced reauthentication or reset
is configured. Incompatible existing preferences fail instead of being reset.
For a new provider install, and when explicitly reconnecting a disconnected
provider, a root-owned systemd drop-in selects `TS_FORCE_NOISE_443=true`.
The board's port-80 control connection stalled after registration with queued
unacknowledged data; the upstream `debug ts2021` handshake succeeded over 443.
The helper checks the daemon's effective flag and restarts it only when needed.
An already Running/NeedsMachineAuth provider is left untouched. Conflicting
custom drop-ins are preserved and reported. Keys, DNS and route preferences
are not changed. This drop-in remains with the independent provider on Node
removal. The transport setting is specific to pinned Tailscale, not Node identity.
The provider's validated `https://login.tailscale.com/a/...` URL opens in the
user's normal browser only after the explicit login action. Node never collects
the account password or exports the login URL to JS, its status API or reports.
The status probe requests no peers and returns only installation/state, local
Tailscale addresses and the provider's online flag. Closing Node or uninstalling
it does not disconnect or remove the independently installed Tailscale service.
Source contracts: [Tailscale stable packages](https://pkgs.tailscale.com/stable/),
[pinned up implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/up.go),
[pinned status implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/status.go).
HTTPS underlay: [pinned control dialer](https://github.com/tailscale/tailscale/blob/v1.102.3/control/controlhttp/client.go).
JSON contracts are version-sensitive; review the adapter when updating the pin.
Only Ubuntu 24.04 LTS Desktop amd64 is admitted by this first package. No blind
upgrade of OS, firmware, network profiles, router settings or camera SDK occurs.
Existing Ubuntu SSH authentication is retained. Keys enrolled in Node are
limited to private source addresses. Removing Node removes its SSH integration,
but leaves the SSH server and persistent Node state available for reinstall.
## Engineering build (not the operator installation procedure)
Install UI dependencies with `npm ci --ignore-scripts` in `ui/`. The build
requires the sibling Design Guideline repository used by the monorepo, at
`8a79dfe84d895c9f1d42b8d285bc6670114f939f`. In that checkout, run
`npm ci --ignore-scripts` and `npm run build:packages` before building Node.
Its dependencies are bundled into the binary; the board never references that
sibling path. The pinned commit includes ResourceRow and the shared shell fixes;
source and generated export hashes are also retained in package provenance.
Use the Go release pinned in `toolchain.json`; download it from the official
Go distribution and verify its SHA-256. No global Go install is needed.
```sh
python3 packaging/build.py --go /path/to/verified/go/bin/go
```
This runs the production UI build, replaces generated embedded assets, builds
a static Linux amd64 Go binary, records source/build provenance, and packages
the `.deb` without executing any installer scripts. `build/` is ignored.
Validation is sequential: `go test -race ./...`, the Control Station application
architecture boundary test, Node UI typecheck/unit tests/build, then desktop GUI QA.
Package script syntax/archive checks and a macOS browser run cannot establish
Ubuntu systemd/polkit/SSH or clean-install acceptance. Those require the GUI
procedure on the actual board. The temporary native QA build must be stopped
after inspection; the canonical Mission Core on port 8000 stays running.
For this board, the owner's engineering checkout is under
`Загрузки/NDC/MISSION_CORE` in the operator's home directory. Keep complete Git history separately
from generated artifacts; do not copy another worktree's `.git` pointer. The
launcher accepts `--development-socket` for an unprivileged development service
on the board. This does not grant OS privileges and is not installer acceptance.
K1 uses wireless Bridge in the common LAN only. D455 is attached by USB; check
its actual negotiated speed and SDK operation separately from enumeration.
## Local authority
The node service binds only `127.0.0.1:8780`. This is not the remote Node/Core
control plane. Its private Unix socket is `0600` in a `0700` directory. The
root-owned launcher helper has a fixed executable and socket; no user command,
path, URL or environment is executed with elevated privileges. One-use login
tokens expire after one minute, authenticated cookies after eight hours, and
all sessions expire on service restart. Identity corruption fails closed.
Raw device serials and MAC addresses are not collected. The report omits node
ID, hostname and network addresses; credentials and private keys never enter
the public status or report. Real evidence and credentials stay outside Git.
+39
View File
@@ -0,0 +1,39 @@
// Engineering-only UI fixture: uses the production API and isolated storage.
// No fixture is linked into cmd/node-agent or installed by the Debian package.
package main
import (
"bytes"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"time"
"nodedc.local/mission-core/node-agent/internal/node"
"nodedc.local/mission-core/node-agent/web"
)
func main() {
dir := "/private/tmp/mc-node-ui-030-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.0-qa", Inventory: func() node.Inventory {
return 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,
Networks: []node.Network{{Name:"ethernet-qa", Up: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{}}
}, Access: &node.AccessStore{Path:filepath.Join(dir,"ssh-keys.json"), Users:func()[]string{return []string{"operator"}}}, Tailscale:func()node.TailscaleStatus {
if _, err := os.Stat(filepath.Join(dir,"offline")); err == nil { return node.TailscaleStatus{Installed:true, State:"unavailable", Addresses:[]string{}} }
return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}}
}}
if err := os.WriteFile(filepath.Join(dir,"login-url"),[]byte(app.IssueLogin()),0600); err != nil {log.Fatal(err)}
handler := app.Handler()
http.HandleFunc("/",func(w http.ResponseWriter,r *http.Request){
if r.URL.Path == "/qa-bridge.js" { w.Header().Set("Content-Type","application/javascript"); _,_ = w.Write([]byte(`window.missionCoreDesktop={networkSetup:true};`)); return }
if r.URL.Path == "/" { b,_:=fs.ReadFile(assets,"index.html"); b=bytes.Replace(b,[]byte("</head>"),[]byte(`<script src="/qa-bridge.js"></script></head>`),1); w.Header().Set("Content-Type","text/html"); _,_=w.Write(b); return }
handler.ServeHTTP(w,r)
})
log.Print("isolated Node UI QA: 127.0.0.1:8780")
log.Fatal(http.ListenAndServe("127.0.0.1:8780",nil))
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"nodedc.local/mission-core/node-agent/internal/node"
"nodedc.local/mission-core/node-agent/web"
)
var version = "0.2.0"
const defaultSocket = "/run/mission-core-node/admin.sock"
func main() {
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) > 1 && os.Args[1] == "authorize" {
return authorize(defaultSocket)
}
if len(os.Args) == 3 && os.Args[1] == "ssh-keys" {
value, err := node.AuthorizedKeys("/var/lib/mission-core-node/ssh-keys.json", os.Args[2])
if err == nil {
fmt.Print(value)
}
return err
}
flags := flag.NewFlagSet("node-agent", flag.ContinueOnError)
dir := flags.String("state", "/var/lib/mission-core-node", "private state directory")
socket := flags.String("socket", defaultSocket, "private launcher socket")
listen := flags.String("listen", "127.0.0.1:8780", "loopback UI address")
if err := flags.Parse(os.Args[1:]); err != nil {
return err
}
host, _, err := net.SplitHostPort(*listen)
if err != nil || host != "127.0.0.1" {
return errors.New("local UI must bind 127.0.0.1")
}
// Bind before touching state/socket; a second instance cannot replace identity or launcher authority.
tcp, err := net.Listen("tcp", *listen)
if err != nil {
return err
}
defer tcp.Close()
store, err := node.OpenStore(*dir)
if err != nil {
return err
}
assets, err := fs.Sub(web.Assets, "dist")
if err != nil {
return err
}
app := &node.Server{Store: store, Assets: assets, Origin: "http://" + *listen, Version: version, Inventory: func() node.Inventory { return node.Host("/") }}
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
return err
}
if info, e := os.Lstat(*socket); e == nil {
if info.Mode()&os.ModeSocket == 0 {
return errors.New("launcher path is not a socket")
}
if err := os.Remove(*socket); err != nil {
return err
}
} else if !os.IsNotExist(e) {
return e
}
unix, err := net.Listen("unix", *socket)
if err != nil {
return err
}
defer unix.Close()
defer os.Remove(*socket)
if err := os.Chmod(*socket, 0600); err != nil {
return err
}
admin := http.NewServeMux()
admin.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": app.IssueLogin()})
})
public := &http.Server{Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192}
private := &http.Server{Handler: admin, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second, MaxHeaderBytes: 8192}
errs := make(chan error, 2)
go func() { errs <- public.Serve(tcp) }()
go func() { errs <- private.Serve(unix) }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Print("Mission Core Node " + version + " listening on loopback")
select {
case err = <-errs:
case <-ctx.Done():
}
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
public.Shutdown(shutdown)
private.Shutdown(shutdown)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func authorize(socket string) error {
// Called by a fixed, root-owned polkit helper. No user-supplied URL, command,
// path, or environment is interpreted by the privileged operation.
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
}}}
res, err := client.Post("http://local/login", "application/json", nil)
if err != nil {
return errors.New("Node is unavailable")
}
defer res.Body.Close()
if res.StatusCode != 200 {
return errors.New("Node rejected local authorization")
}
var value struct {
URL string `json:"url"`
}
if err := json.NewDecoder(io.LimitReader(res.Body, 1024)).Decode(&value); err != nil {
return err
}
fmt.Print(value.URL)
return nil
}
+3
View File
@@ -0,0 +1,3 @@
module nodedc.local/mission-core/node-agent
go 1.26.0
+265
View File
@@ -0,0 +1,265 @@
package node
import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
type AccessKey struct {
ID string `json:"id"`
User string `json:"user"`
Label string `json:"label"`
PublicKey string `json:"public_key"`
}
type AccessStore struct {
mu sync.Mutex
Path string
Users func() []string
}
var usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
// Only existing local administrative accounts are eligible. Never root,
// a supplied home directory, an arbitrary NSS principal, or a generated user.
func LocalAdmins(root string) []string {
group, _ := os.ReadFile(filepath.Join(root, "etc/group"))
admins := map[string]bool{}
for _, line := range strings.Split(string(group), "\n") {
p := strings.Split(line, ":")
if len(p) == 4 && p[0] == "sudo" {
for _, u := range strings.Split(p[3], ",") {
admins[u] = true
}
}
}
passwd, _ := os.ReadFile(filepath.Join(root, "etc/passwd"))
result := []string{}
for _, line := range strings.Split(string(passwd), "\n") {
p := strings.Split(line, ":")
if len(p) != 7 {
continue
}
uid, e := strconv.Atoi(p[2])
if e == nil && uid >= 1000 && uid < 65534 && admins[p[0]] && usernamePattern.MatchString(p[0]) && !strings.HasSuffix(p[6], "nologin") && !strings.HasSuffix(p[6], "false") {
result = append(result, p[0])
}
}
return result
}
func canonicalKey(key string) (string, string, error) {
parts := strings.Fields(strings.TrimSpace(key))
bad := errors.New("Нужен публичный ключ Ed25519, начинающийся с ssh-ed25519; приватный ключ вводить нельзя")
if len(parts) < 2 || parts[0] != "ssh-ed25519" || strings.ContainsAny(key, "\r\n") {
return "", "", bad
}
b, e := base64.StdEncoding.DecodeString(parts[1])
if e != nil || len(b) != 51 {
return "", "", bad
}
if binary.BigEndian.Uint32(b[:4]) != 11 || string(b[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(b[15:19]) != 32 {
return "", "", bad
}
h := sha256.Sum256(b)
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b), "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]), nil
}
func ReadAccess(path string) ([]AccessKey, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return []AccessKey{}, nil
}
if err != nil {
return nil, err
}
var keys []AccessKey
if err = json.Unmarshal(b, &keys); err != nil {
return nil, err
}
if len(keys) > 64 {
return nil, errors.New("too many access keys")
}
for _, k := range keys {
key, id, err := canonicalKey(k.PublicKey)
if err != nil || key != k.PublicKey || id != k.ID || !usernamePattern.MatchString(k.User) {
return nil, errors.New("invalid access store")
}
}
return keys, nil
}
func (a *AccessStore) List() ([]AccessKey, error) {
a.mu.Lock()
defer a.mu.Unlock()
return ReadAccess(a.Path)
}
func (a *AccessStore) allowed(user string) bool {
for _, u := range a.Users() {
if user == u {
return true
}
}
return false
}
func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
a.mu.Lock()
defer a.mu.Unlock()
keys, err := ReadAccess(a.Path)
if err != nil {
return errors.New("Хранилище SSH недоступно")
}
keys, err = fn(keys)
if err != nil {
return err
}
b, err := json.Marshal(keys)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(a.Path), ".ssh-keys-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), a.Path)
}
func (a *AccessStore) Add(user, label, key string) error {
if !a.allowed(user) {
return errors.New("Выберите существующую учётную запись администратора Ubuntu")
}
label = strings.TrimSpace(label)
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
return errors.New("Название ключа должно содержать от 1 до 64 символов")
}
key, id, err := canonicalKey(key)
if err != nil {
return err
}
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
for _, k := range keys {
if k.ID == id && k.User == user {
return keys, nil
}
}
if len(keys) >= 64 {
return nil, errors.New("Достигнут предел 64 ключа")
}
return append(keys, AccessKey{ID: id, User: user, Label: label, PublicKey: key}), nil
})
}
func (a *AccessStore) Remove(user, id string) error {
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
next := []AccessKey{}
for _, k := range keys {
if k.User != user || k.ID != id {
next = append(next, k)
}
}
return next, nil
})
}
func SSHReady() bool {
c, err := net.DialTimeout("tcp", "127.0.0.1:22", 400*time.Millisecond)
if err != nil {
return false
}
defer c.Close()
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
s := bufio.NewScanner(c)
return s.Scan() && strings.HasPrefix(s.Text(), "SSH-2.0-")
}
func (s *Server) accessRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
keys, err := s.Access.List()
if err != nil {
reply(w, 503, map[string]string{"error": "Хранилище SSH недоступно"})
return
}
reply(w, 200, map[string]any{"users": s.Access.Users(), "keys": keys, "ssh_ready": SSHReady()})
})
mux.HandleFunc("POST /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
Label string `json:"label"`
Key string `json:"key"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Add(b.User, b.Label, b.Key); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("DELETE /api/access", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var b struct {
User string `json:"user"`
ID string `json:"id"`
}
if !decode(w, r, &b) {
return
}
if err := s.Access.Remove(b.User, b.ID); err != nil {
reply(w, 503, map[string]string{"error": "Не удалось удалить ключ"})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
func AuthorizedKeys(path, user string) (string, error) {
a := &AccessStore{Path: path, Users: func() []string { return LocalAdmins("/") }}
if !a.allowed(user) {
return "", nil
}
keys, err := a.List()
if err != nil {
return "", err
}
var out strings.Builder
for _, k := range keys {
if k.User == user {
out.WriteString(`from="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,127.0.0.0/8,::1,fc00::/7,fe80::/10" ` + k.PublicKey + "\n")
}
}
return out.String(), nil
}
+107
View File
@@ -0,0 +1,107 @@
package node
import (
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
type Network struct {
Name string `json:"name"`
Up bool `json:"up"`
Addresses []string `json:"addresses"`
}
type USB struct {
Port string `json:"port"`
Vendor string `json:"vendor"`
ProductID string `json:"product_id"`
Product string `json:"product"`
Speed string `json:"speed_mbps"`
}
type Inventory struct {
CollectedAt string `json:"collected_at"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Architecture string `json:"architecture"`
CPUs int `json:"cpus"`
MemoryKiB *uint64 `json:"memory_kib"`
AvailableKiB *uint64 `json:"available_kib"`
Networks []Network `json:"networks"`
USB []USB `json:"usb"`
USBReadable bool `json:"usb_readable"`
Warnings []string `json:"warnings"`
}
// Host reads only local kernel/OS metadata. It never probes network devices,
// opens camera streams, reads device serials, or changes a network interface.
func Host(root string) Inventory {
read := func(p string) string {
b, _ := os.ReadFile(filepath.Join(root, p))
return strings.TrimSpace(string(b))
}
host, _ := os.Hostname()
v := Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname: host, OS: runtime.GOOS, Architecture: runtime.GOARCH, CPUs: runtime.NumCPU(), Networks: []Network{}, USB: []USB{}, Warnings: []string{}}
for _, line := range strings.Split(read("etc/os-release"), "\n") {
if x, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
v.OS = strings.Trim(x, "\"")
}
}
for _, line := range strings.Split(read("proc/meminfo"), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
n, e := strconv.ParseUint(fields[1], 10, 64)
if e != nil {
continue
}
if fields[0] == "MemTotal:" {
v.MemoryKiB = &n
}
if fields[0] == "MemAvailable:" {
v.AvailableKiB = &n
}
}
if v.MemoryKiB == nil {
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
}
interfaces, err := net.Interfaces()
if err != nil {
v.Warnings = append(v.Warnings, "Не удалось прочитать сетевые интерфейсы")
}
for _, it := range interfaces {
if it.Flags&net.FlagLoopback != 0 {
continue
}
n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}}
addresses, e := it.Addrs()
if e != nil {
v.Warnings = append(v.Warnings, "Адреса интерфейса "+it.Name+" недоступны")
}
for _, a := range addresses {
n.Addresses = append(n.Addresses, a.String())
}
sort.Strings(n.Addresses)
v.Networks = append(v.Networks, n)
}
sort.Slice(v.Networks, func(i, j int) bool { return v.Networks[i].Name < v.Networks[j].Name })
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
v.USBReadable = err == nil
if err != nil {
v.Warnings = append(v.Warnings, "Сведения об USB недоступны")
}
for _, e := range entries {
prefix := filepath.Join("sys/bus/usb/devices", e.Name())
vendor := read(filepath.Join(prefix, "idVendor"))
if vendor == "" {
continue
}
v.USB = append(v.USB, USB{Port: e.Name(), Vendor: vendor, ProductID: read(filepath.Join(prefix, "idProduct")), Product: read(filepath.Join(prefix, "product")), Speed: read(filepath.Join(prefix, "speed"))})
}
return v
}
+255
View File
@@ -0,0 +1,255 @@
package node
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
state, e := OpenStore(t.TempDir())
if e != nil {
t.Fatal(e)
}
return &Server{Store: state, Origin: "http://127.0.0.1:8780", Assets: fstest.MapFS{"index.html": {Data: []byte("test-only asset")}}, Inventory: func() Inventory {
return Inventory{Hostname: "private-host", Networks: []Network{{Name: "eth0", Addresses: []string{"192.168.10.4/24"}}}}
}}
}
func call(s *Server, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, s.Origin+path, strings.NewReader(body))
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func login(t *testing.T, s *Server) *http.Cookie {
t.Helper()
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
w := call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
return w.Result().Cookies()[0]
}
func TestIdentitySurvivesRenameAndReopen(t *testing.T) {
dir := t.TempDir()
s, e := OpenStore(dir)
if e != nil {
t.Fatal(e)
}
id, _ := s.Public()
if e = s.Rename("Борт 1"); e != nil {
t.Fatal(e)
}
s, e = OpenStore(dir)
if e != nil {
t.Fatal(e)
}
next, name := s.Public()
if next != id || name != "Борт 1" {
t.Fatal(next, name)
}
info, _ := os.Stat(filepath.Join(dir, "identity.json"))
if info.Mode().Perm() != 0600 {
t.Fatal(info.Mode())
}
if e = s.Rename("bad\nname"); e == nil {
t.Fatal("accepted control character")
}
}
func TestCorruptIdentityNeverReplaced(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "identity.json")
bad := []byte(`{"version":1,"private_key":"bad"}`)
os.WriteFile(p, bad, 0600)
if _, e := OpenStore(dir); e == nil {
t.Fatal("corrupt state accepted")
}
got, _ := os.ReadFile(p)
if !bytes.Equal(got, bad) {
t.Fatal("identity replaced")
}
}
func TestLoginOneUseConcurrentAndExpires(t *testing.T) {
s := newTestServer(t)
now := time.Now()
s.Now = func() time.Time { return now }
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
var wg sync.WaitGroup
codes := make(chan int, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); codes <- call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code }()
}
wg.Wait()
close(codes)
success := 0
for c := range codes {
if c == 200 {
success++
} else if c != 401 {
t.Fatal(c)
}
}
if success != 1 {
t.Fatal(success)
}
v = strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
now = now.Add(time.Minute)
if call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code != 401 {
t.Fatal("expired launch accepted")
}
c := login(t, s)
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode {
t.Fatal(c)
}
now = now.Add(8 * time.Hour)
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("expired session accepted")
}
}
func TestUnauthenticatedAndCrossSiteRequestsDenied(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, path := range []string{"/api/status", "/api/report"} {
if call(s, "GET", path, "", nil).Code != 401 {
t.Fatal(path)
}
}
for _, kind := range []string{"origin", "host", "metadata", "missing-origin"} {
r := httptest.NewRequest("PUT", s.Origin+"/api/name", strings.NewReader(`{"name":"attacker"}`))
r.AddCookie(c)
r.Header.Set("Origin", s.Origin)
r.Header.Set("Content-Type", "application/json")
switch kind {
case "origin":
r.Header.Set("Origin", "https://evil.example")
case "host":
r.Host = "evil.example"
case "metadata":
r.Header.Set("Sec-Fetch-Site", "cross-site")
case "missing-origin":
r.Header.Del("Origin")
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(kind, w.Code)
}
}
_, name := s.Store.Public()
if name == "attacker" {
t.Fatal("cross-site state changed")
}
}
func TestReportDoesNotLeakPrivateState(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
w := call(s, "GET", "/api/report", "", c)
if w.Code != 200 {
t.Fatal(w.Code)
}
id, _ := s.Store.Public()
for _, secret := range []string{"private-host", "192.168.10.4", id, "private_key", c.Value} {
if strings.Contains(w.Body.String(), secret) {
t.Fatal("report leaked", secret)
}
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "attachment") {
t.Fatal("not downloadable")
}
}
func TestLogoutAndStrictJSON(t *testing.T) {
s := newTestServer(t)
c := login(t, s)
for _, body := range []string{`{"name":"x"} {}`, `{"name":"x","other":1}`} {
if call(s, "PUT", "/api/name", body, c).Code != 400 {
t.Fatal("accepted invalid document")
}
}
if call(s, "POST", "/api/logout", `{}`, c).Code != 200 {
t.Fatal("logout failed")
}
if call(s, "GET", "/api/status", "", c).Code != 401 {
t.Fatal("session survived logout")
}
}
func syntheticKey() string {
b := make([]byte, 51)
binary.BigEndian.PutUint32(b[:4], 11)
copy(b[4:15], "ssh-ed25519")
binary.BigEndian.PutUint32(b[15:19], 32)
for i := 19; i < len(b); i++ {
b[i] = byte(i)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b)
}
func TestSSHKeyEnrollmentRejectsCommandsAndRoot(t *testing.T) {
a := &AccessStore{Path: filepath.Join(t.TempDir(), "ssh-keys.json"), Users: func() []string { return []string{"operator"} }}
key := syntheticKey()
for _, bad := range []string{"command=\"sh\" " + key, key + "\n" + key, "-----BEGIN PRIVATE KEY-----", "ssh-ed25519 YQ=="} {
if e := a.Add("operator", "laptop", bad); e == nil {
t.Fatal("unsafe key accepted")
}
}
if e := a.Add("root", "laptop", key); e == nil {
t.Fatal("root accepted")
}
if e := a.Add("operator", "laptop", key+" private-comment"); e != nil {
t.Fatal(e)
}
if e := a.Add("operator", "laptop", key); e != nil {
t.Fatal(e)
}
keys, e := a.List()
if e != nil || len(keys) != 1 || keys[0].PublicKey != key {
t.Fatal(keys, e)
}
if e := a.Remove("operator", keys[0].ID); e != nil {
t.Fatal(e)
}
keys, _ = a.List()
if len(keys) != 0 {
t.Fatal("revocation failed")
}
}
func TestLinuxInventoryUsesActualMetadataWithoutSerial(t *testing.T) {
dir := t.TempDir()
for p, v := range map[string]string{"etc/os-release": "PRETTY_NAME=\"Synthetic Linux\"", "proc/meminfo": "MemTotal: 8388608 kB\nMemAvailable: 4000000 kB", "sys/bus/usb/devices/1-2/idVendor": "8086", "sys/bus/usb/devices/1-2/idProduct": "0b5c", "sys/bus/usb/devices/1-2/product": "Synthetic camera", "sys/bus/usb/devices/1-2/speed": "5000", "sys/bus/usb/devices/1-2/serial": "do-not-read"} {
target := filepath.Join(dir, p)
os.MkdirAll(filepath.Dir(target), 0700)
os.WriteFile(target, []byte(v), 0600)
}
v := Host(dir)
if v.OS != "Synthetic Linux" || *v.MemoryKiB != 8388608 || !v.USBReadable || len(v.USB) != 1 || v.USB[0].Speed != "5000" {
t.Fatal(v)
}
b, _ := json.Marshal(v)
if bytes.Contains(b, []byte("do-not-read")) {
t.Fatal("serial leaked")
}
}
+231
View File
@@ -0,0 +1,231 @@
package node
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"sync"
"time"
)
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
}
func token() string {
b := make([]byte, 32)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func prune(m map[string]time.Time, now time.Time) {
for k, v := range m {
if !v.After(now) {
delete(m, k)
}
}
}
// IssueLogin is reachable through the private Unix socket, never the web API.
// OS authentication belongs to the fixed polkit launcher, not a web password.
func (s *Server) IssueLogin() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.logins == nil {
s.logins = make(map[string]time.Time)
}
prune(s.logins, s.now())
// Cap abandoned desktop launches; newest launches supersede the oldest.
if len(s.logins) >= 16 {
for k := range s.logins {
delete(s.logins, k)
break
}
}
t := token()
s.logins[t] = s.now().Add(time.Minute)
return s.Origin + "/#login=" + t
}
func reply(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Access != nil {
s.accessRoutes(mux)
}
mux.HandleFunc("POST /api/session", s.login)
mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
probe := s.Tailscale
if probe == nil {
probe = ReadTailscale
}
reply(w, 200, probe())
})
mux.HandleFunc("POST /api/logout", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
c, _ := r.Cookie("mc_node")
s.mu.Lock()
delete(s.sessions, c.Value)
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
id, name := s.Store.Public()
reply(w, 200, map[string]any{"version": s.Version, "node_id": id, "name": name, "host": s.Inventory()})
})
mux.HandleFunc("PUT /api/name", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var body struct {
Name string `json:"name"`
}
if !decode(w, r, &body) {
return
}
if err := s.Store.Rename(body.Name); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
mux.HandleFunc("GET /api/report", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
v := s.Inventory()
// Export is deliberately redacted even though the authenticated UI shows LAN addresses.
v.Hostname = "[redacted]"
for i := range v.Networks {
v.Networks[i].Addresses = []string{}
}
w.Header().Set("Content-Disposition", `attachment; filename="mission-core-node-report.json"`)
reply(w, 200, map[string]any{"schema": "missioncore.node.inventory-report/v1", "version": s.Version, "host": v})
})
mux.Handle("GET /", http.FileServerFS(s.Assets))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
if "http://"+r.Host != s.Origin {
http.Error(w, "Invalid host", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" && origin != s.Origin {
http.Error(w, "Invalid origin", http.StatusForbidden)
return
}
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
http.Error(w, "Cross-site request denied", http.StatusForbidden)
return
}
if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("Origin") != s.Origin {
http.Error(w, "Origin required", http.StatusForbidden)
return
}
mux.ServeHTTP(w, r)
})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return false
}
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
return false
}
if err := d.Decode(new(any)); err != io.EOF {
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
return false
}
return true
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
}
if !decode(w, r, &body) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
prune(s.logins, s.now())
_, ok := s.logins[body.Token]
delete(s.logins, body.Token)
if !ok {
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню Ubuntu"})
return
}
if s.sessions == nil {
s.sessions = make(map[string]time.Time)
}
prune(s.sessions, s.now())
if len(s.sessions) >= 32 {
for k := range s.sessions {
delete(s.sessions, k)
break
}
}
t := token()
s.sessions[t] = s.now().Add(8 * time.Hour)
// Loopback HTTP is intentionally local-only; never expose this cookie on LAN.
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: t, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 28800})
reply(w, 200, map[string]bool{"ok": true})
}
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
c, err := r.Cookie("mc_node")
if err != nil || strings.TrimSpace(c.Value) == "" {
reply(w, 401, map[string]string{"error": "Откройте Mission Core Node через меню приложений"})
return false
}
s.mu.Lock()
defer s.mu.Unlock()
prune(s.sessions, s.now())
if _, ok := s.sessions[c.Value]; !ok {
reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню Ubuntu"})
return false
}
return true
}
+122
View File
@@ -0,0 +1,122 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
type State struct {
Version int `json:"version"`
PrivateKey []byte `json:"private_key"`
Name string `json:"name"`
}
type Store struct {
mu sync.Mutex
path string
state State
}
func OpenStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
s := &Store{path: filepath.Join(dir, "identity.json")}
b, err := os.ReadFile(s.path)
if err == nil {
if err = json.Unmarshal(b, &s.state); err != nil {
return nil, errors.New("invalid identity; recovery required")
}
if s.state.Version != 1 || len(s.state.PrivateKey) != ed25519.PrivateKeySize {
return nil, errors.New("unsupported identity; recovery required")
}
derived := ed25519.NewKeyFromSeed(s.state.PrivateKey[:ed25519.SeedSize])
if !equalKey(derived, s.state.PrivateKey) {
return nil, errors.New("corrupt identity; recovery required")
}
if info, e := os.Stat(s.path); e != nil || info.Mode().Perm()&0077 != 0 {
return nil, errors.New("identity permissions must be private")
}
return s, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
s.state = State{Version: 1, PrivateKey: key, Name: "Моя нода"}
if err := s.write(s.state); err != nil {
return nil, err
}
return s, nil
}
func equalKey(a, b []byte) bool { return string(a) == string(b) }
func (s *Store) write(state State) error {
b, err := json.Marshal(state)
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(f.Name(), s.path); err != nil {
return err
}
d, err := os.Open(filepath.Dir(s.path))
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
func (s *Store) Public() (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
pub := ed25519.PrivateKey(s.state.PrivateKey).Public().(ed25519.PublicKey)
hash := sha256.Sum256(pub)
return "node_" + hex.EncodeToString(hash[:]), s.state.Name
}
func (s *Store) Rename(name string) error {
name = strings.TrimSpace(name)
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 64 || strings.ContainsFunc(name, unicode.IsControl) {
return errors.New("Название должно содержать от 1 до 64 символов без управляющих знаков")
}
s.mu.Lock()
defer s.mu.Unlock()
next := s.state
next.Name = name
if err := s.write(next); err != nil {
return errors.New("Не удалось сохранить название")
}
s.state = next
return nil
}
@@ -0,0 +1,76 @@
package node
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/netip"
"os"
"os/exec"
"time"
)
type TailscaleStatus struct {
Installed bool `json:"installed"`
State string `json:"state"`
Online bool `json:"online"`
Addresses []string `json:"addresses"`
}
type boundedProviderOutput struct{ bytes.Buffer }
func (b *boundedProviderOutput) Write(data []byte) (int, error) {
if b.Len()+len(data) > 1024*1024 {
return 0, errors.New("provider status too large")
}
return b.Buffer.Write(data)
}
func ReadTailscale() TailscaleStatus {
value := TailscaleStatus{State: "not_installed", Addresses: []string{}}
if _, err := os.Stat("/usr/bin/tailscale"); err != nil {
return value
}
value.Installed = true
value.State = "unavailable"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/tailscale", "status", "--json", "--peers=false")
// Do not request peer inventory or expose auth URLs, user identities, keys,
// provider diagnostics or profile objects in the product API.
var output boundedProviderOutput
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return value
}
return parseTailscale(output.Bytes())
}
func parseTailscale(data []byte) TailscaleStatus {
value := TailscaleStatus{Installed: true, State: "unavailable", Addresses: []string{}}
if len(data) > 1024*1024 {
return value
}
var raw struct {
BackendState string
TailscaleIPs []string
Self *struct{ Online bool }
}
if json.Unmarshal(data, &raw) != nil {
return value
}
switch raw.BackendState {
case "Running", "Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState":
value.State = raw.BackendState
default:
return value
}
value.Online = raw.BackendState == "Running" && raw.Self != nil && raw.Self.Online
for _, address := range raw.TailscaleIPs {
if ip, err := netip.ParseAddr(address); err == nil {
value.Addresses = append(value.Addresses, ip.String())
}
}
return value
}
@@ -0,0 +1,60 @@
package node
import (
"encoding/json"
"strings"
"testing"
)
func TestTailscaleDoesNotExposeProviderCredentialsOrPeers(t *testing.T) {
status := parseTailscale([]byte(`{"BackendState":"Running","TailscaleIPs":["100.64.0.10","invalid"],"Self":{"Online":true,"PublicKey":"synthetic-key"},"AuthURL":"https://login.tailscale.com/a/synthetic","User":{"1":{"LoginName":"synthetic@example.test"}},"Peer":{"synthetic":{"HostName":"another-computer"}}}`))
if !status.Online || status.State != "Running" || len(status.Addresses) != 1 {
t.Fatalf("wrong connection status: %+v", status)
}
encoded, _ := json.Marshal(status)
for _, forbidden := range []string{"synthetic", "AuthURL", "User", "Peer", "PublicKey"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider data leaked: %s", forbidden)
}
}
}
func TestTailscaleDoesNotClaimUnknownOrOfflineConnection(t *testing.T) {
for _, input := range []string{`{`, `null`, `{}`, `{"BackendState":"FutureState","Self":{"Online":true}}`} {
got := parseTailscale([]byte(input))
if got.Online || got.State != "unavailable" {
t.Fatalf("unknown state was accepted: %+v", got)
}
}
for _, state := range []string{"Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState"} {
got := parseTailscale([]byte(`{"BackendState":"` + state + `","Self":{"Online":true}}`))
if got.Online || got.State != state {
t.Fatalf("not connected: %+v", got)
}
}
if parseTailscale([]byte(`{"BackendState":"Running","Self":{"Online":false}}`)).Online {
t.Fatal("offline peer shown as connected")
}
}
func TestProviderOutputIsBounded(t *testing.T) {
var buffer boundedProviderOutput
if _, err := buffer.Write(make([]byte, 1024*1024+1)); err == nil || buffer.Len() != 0 {
t.Fatal("oversized provider output accepted")
}
}
func TestTailscaleStatusRequiresLocalLoginBeforeProbe(t *testing.T) {
s := newTestServer(t)
probes := 0
s.Tailscale = func() TailscaleStatus {
probes++
return TailscaleStatus{State: "not_installed", Addresses: []string{}}
}
if call(s, "GET", "/api/network/tailscale", "", nil).Code != 401 || probes != 0 {
t.Fatal("unauthenticated provider probe")
}
if call(s, "GET", "/api/network/tailscale", "", login(t, s)).Code != 200 || probes != 1 {
t.Fatal("authenticated status unavailable")
}
}
@@ -0,0 +1,5 @@
# Node-managed public keys supplement existing per-user authorized_keys.
# The command can only return GUI-enrolled Ed25519 keys for local sudo users.
AuthorizedKeysCommand /usr/lib/mission-core-node/node-agent ssh-keys %u
AuthorizedKeysCommandUser mission-core-node
PermitEmptyPasswords no
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/mission-core-node/node-agent authorize
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Engineering-only, sequential build. Never run by an Ubuntu operator."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "8a79dfe84d895c9f1d42b8d285bc6670114f939f"
def guideline_sources():
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
paths = list((dg / "packages/ui-react/src").glob("*"))
paths += list((dg / "packages/ui-react/dist").glob("*"))
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(paths) if p.is_file()}
def provenance():
files = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(ROOT.rglob("*")) if p.is_file()
and not any(x in p.relative_to(ROOT).parts for x in ("node_modules", "build", "__pycache__"))}
return {"package": "mission-core-node", "version": VERSION,
"brand_mark_sha256": BRAND_SHA256,
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"design_guideline_commit": DG_COMMIT,
"design_guideline_files": guideline_sources(),
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--go", type=Path, required=True)
args = parser.parse_args()
go = args.go.resolve()
expected = json.loads((ROOT / "toolchain.json").read_text())["version"]
if subprocess.check_output([str(go), "version"], text=True).split()[2] != expected:
sys.exit("Go version does not match toolchain.json")
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
if subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=dg, text=True).strip() != DG_COMMIT:
sys.exit("Design Guideline revision does not match the admitted build")
subprocess.run(["npm", "run", "build"], cwd=ROOT / "ui", check=True)
assets = ROOT / "web/dist"
if assets.exists():
shutil.rmtree(assets)
shutil.copytree(ROOT / "ui/dist", assets)
output = ROOT / "build"
output.mkdir(exist_ok=True)
env = dict(os.environ, GOMAXPROCS="2", CGO_ENABLED="0", GOOS="linux", GOARCH="amd64")
subprocess.run([str(go), "build", "-trimpath", f"-ldflags=-s -w -X main.version={VERSION}", "-o",
str(output / "node-agent-linux-amd64"), "./cmd/node-agent"], cwd=ROOT, env=env, check=True)
(output / "provenance.json").write_text(json.dumps(provenance(), indent=2) + "\n")
build(output / "node-agent-linux-amd64", output / f"mission-core-node_{VERSION}_amd64.deb")
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Build a deterministic Debian package on macOS/Linux from reviewed artifacts.
No install operation, sudo, container, package-manager mutation or network I/O.
"""
import argparse
import gzip
import hashlib
import io
import json
from pathlib import Path
import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.3.0"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
def desktop_icon(brand):
"""Give desktop loaders a square canvas without distorting the brand mark.
The canonical SVG remains an unchanged nested document. Its default
xMidYMid meet preserves the mark's aspect ratio inside this square viewport.
Explicit intrinsic dimensions also keep GTK's pixbuf square.
"""
if hashlib.sha256(brand).hexdigest() != BRAND_SHA256:
raise ValueError("Brand mark differs from the admitted Design Guideline asset")
return (b'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" '
b'viewBox="0 0 256 256" preserveAspectRatio="xMidYMid meet">\n'
+ brand + b'</svg>\n')
def tarball(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive:
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
for name in sorted(directories):
item = tarfile.TarInfo(name + "/")
item.type, item.mode = tarfile.DIRTYPE, 0o755
item.uname = item.gname = "root"
archive.addfile(item)
for name, data, mode in sorted(files):
item = tarfile.TarInfo(name)
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
item.uname = item.gname = "root"
archive.addfile(item, io.BytesIO(data))
return gzip.compress(stream.getvalue(), mtime=0)
def ar_member(name, data):
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
assert len(header) == 60
return header + data + (b"\n" if len(data) % 2 else b"")
def build(binary, destination):
payload = binary.read_bytes()
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
raise ValueError("Expected a Linux amd64 ELF binary")
p = ROOT / "packaging"
control = f"""Package: mission-core-node
Version: {VERSION}
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
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"]]
files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)]
brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes()
files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644))
for source, path, mode in [
("launcher.py", "usr/bin/mission-core-node", 0o755),
("authorize", "usr/lib/mission-core-node/authorize", 0o755),
("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644),
("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644),
("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644),
("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644),
("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644),
("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),
]:
files.append((path, (p / source).read_bytes(), mode))
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))
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive)
digest = hashlib.sha256(archive).hexdigest()
destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n")
print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest}))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--binary", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
build(args.binary, args.output)
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/python3
"""Standalone GTK application. Only the fixed polkit helper runs as root."""
import argparse
import http.client
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import threading
from urllib.parse import urlsplit
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("WebKit2", "4.1")
from gi.repository import Gio, GLib, Gtk, WebKit2
ORIGIN = "http://127.0.0.1:8780"
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=[A-Za-z0-9_-]{43}")
def local_url(uri):
try:
u = urlsplit(uri)
return (u.scheme, u.hostname, u.port) == ("http", "127.0.0.1", 8780) and not u.username and not u.password
except ValueError:
return False
def authorize(development_socket=None):
if development_socket:
# Engineering-only, unprivileged service. Cannot read the deployed
# service's protected Unix socket and never grants OS privileges.
connection = http.client.HTTPConnection("local", timeout=5)
connection.sock = socket.socket(socket.AF_UNIX)
connection.sock.settimeout(5)
try:
connection.sock.connect(development_socket)
connection.request("POST", "/login", headers={"Content-Type": "application/json"})
response = connection.getresponse()
if response.status != 200:
raise ValueError("Local authorization failed")
uri = json.loads(response.read(1024))["url"]
finally:
connection.close()
else:
result = subprocess.run(
["/usr/bin/pkexec", "/usr/lib/mission-core-node/authorize"],
check=True, capture_output=True, text=True, timeout=180,
)
uri = result.stdout.strip()
if not LOGIN.fullmatch(uri):
raise ValueError("Unexpected launcher response")
return uri
class NodeApplication(Gtk.Application):
def __init__(self, development_socket=None):
super().__init__(application_id="org.nodedc.MissionCoreNode", flags=Gio.ApplicationFlags.FLAGS_NONE)
self.development_socket = development_socket
self.window = None
self.pending = False
self.initial_login = False
self.cancelled_downloads = set()
def do_activate(self):
if self.window:
self.window.present()
return
self.window = Gtk.ApplicationWindow(application=self)
self.window.set_title("Mission Core Node")
self.window.set_default_size(1100, 780)
self.window.set_icon_name("org.nodedc.MissionCoreNode")
context = WebKit2.WebContext.new_ephemeral()
context.connect("download-started", self.download_started)
self.view = WebKit2.WebView.new_with_context(context)
self.view.get_settings().set_enable_developer_extras(False)
self.view.connect("context-menu", lambda *_: True)
self.view.connect("decide-policy", self.decide_policy)
self.view.connect("permission-request", self.deny_permission)
self.view.connect("load-failed", self.load_failed)
self.view.connect("load-changed", self.loaded)
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})});",
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
))
manager.register_script_message_handler("node")
manager.connect("script-message-received::node", self.message)
self.window.add(self.view)
self.window.connect("destroy", self.destroyed)
self.window.show_all()
self.view.load_uri(ORIGIN)
def loaded(self, _view, event):
if event == WebKit2.LoadEvent.FINISHED and not self.initial_login:
self.initial_login = True
self.login()
def destroyed(self, *_):
self.window = None
def message(self, _manager, result):
if not local_url(self.view.get_uri() or ""):
return
action = result.get_js_value().to_string()
if action == "authorize":
self.login()
elif action in ("install-tailscale", "connect-tailscale"):
self.network_action(action)
def network_action(self, action):
if self.pending:
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
return
self.pending = True
def work():
value = {"action": action, "ok": False}
try:
# The action is selected from the allowlist above. No command,
# URL, credential, path or network option is accepted from JS.
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/" + action],
capture_output=True, text=True)
if process.returncode:
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
else:
result = json.loads(process.stdout)
if not isinstance(result, dict) or type(result.get("ok")) is not bool:
raise ValueError("Unexpected helper response")
value["ok"] = result["ok"]
if result.get("url"):
uri = result["url"]
if not re.fullmatch(r"https://login\.tailscale\.com/a/[A-Za-z0-9_-]{1,256}", uri):
raise ValueError("Unexpected login destination")
# Keep the provider credential inside the native process;
# no auth URL is persisted or returned to the web API/JS.
value["login_uri"] = uri
if not value["ok"]:
value["error"] = str(result.get("error", "Настройка Tailscale не завершена."))[:1024]
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
value = {"action": action, "ok": False, "error": "Не удалось выполнить настройку Tailscale. Повторите действие."}
GLib.idle_add(self.network_result, value)
threading.Thread(target=work, daemon=True).start()
def network_result(self, value, completed=True):
if completed:
self.pending = False
uri = value.pop("login_uri", None)
if not self.window:
return False
if uri:
try:
Gio.AppInfo.launch_default_for_uri(uri, None)
value["browser_opened"] = True
except GLib.Error:
value["ok"] = False
value["error"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход."
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
def login(self):
if self.pending:
return
self.pending = True
def work():
try:
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.")
finally:
GLib.idle_add(self.login_finished)
threading.Thread(target=work, daemon=True).start()
def login_ready(self, uri):
if self.window:
self.view.load_uri(uri)
return False
def login_finished(self):
self.pending = False
return False
def problem(self, message):
if not self.window:
return False
dialog = Gtk.MessageDialog(transient_for=self.window, modal=True,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.CLOSE,
text="Mission Core Node")
dialog.format_secondary_text(message)
dialog.connect("response", lambda d, _: d.destroy())
dialog.show()
return False
def load_failed(self, _view, _event, uri, _error):
if local_url(uri):
self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.")
return True
def process_failed(self, *_):
self.problem("Окно приложения остановилось. Закройте и повторно откройте Mission Core Node. Служба борта продолжает работать отдельно.")
def deny_permission(self, _view, permission):
permission.deny()
return True
def decide_policy(self, _view, decision, kind):
if kind in (WebKit2.PolicyDecisionType.NAVIGATION_ACTION, WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION):
uri = decision.get_navigation_action().get_request().get_uri()
if kind == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or not local_url(uri):
decision.ignore()
return True
elif kind == WebKit2.PolicyDecisionType.RESPONSE:
uri = decision.get_request().get_uri()
if not local_url(uri):
decision.ignore()
return True
if urlsplit(uri).path == "/api/report" and decision.get_response().get_status_code() == 200:
decision.download()
return True
return False
def download_started(self, _context, download):
uri = download.get_request().get_uri()
if not local_url(uri) or urlsplit(uri).path != "/api/report":
download.cancel()
return
download.connect("decide-destination", self.download_destination)
download.connect("failed", self.download_failed)
def download_failed(self, download, _error):
if download in self.cancelled_downloads:
self.cancelled_downloads.discard(download)
return False
return self.problem("Не удалось сохранить отчёт.")
def download_destination(self, download, _suggested):
chooser = Gtk.FileChooserNative.new("Сохранить отчёт", self.window,
Gtk.FileChooserAction.SAVE, "Сохранить", "Отмена")
chooser.set_current_name("mission-core-node-report.json")
chooser.set_do_overwrite_confirmation(True)
if chooser.run() == Gtk.ResponseType.ACCEPT:
download.set_allow_overwrite(True)
download.set_destination(Path(chooser.get_filename()).as_uri())
else:
self.cancelled_downloads.add(download)
download.cancel()
chooser.destroy()
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser()
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(NodeApplication(arguments.development_socket).run([]))
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Mission Core Node
Comment=Настройка и диагностика бортового компьютера
Exec=/usr/bin/mission-core-node
Icon=org.nodedc.MissionCoreNode
Terminal=false
Categories=System;
StartupNotify=true
@@ -0,0 +1,34 @@
[Unit]
Description=Mission Core Node local device host
After=network.target
[Service]
Type=simple
User=mission-core-node
Group=mission-core-node
ExecStart=/usr/lib/mission-core-node/node-agent
StateDirectory=mission-core-node
StateDirectoryMode=0700
RuntimeDirectory=mission-core-node
RuntimeDirectoryMode=0700
UMask=0077
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_INET
CapabilityBoundingSet=
LockPersonality=yes
LimitNOFILE=1024
TasksMax=64
MemoryMax=256M
[Install]
WantedBy=multi-user.target
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/python3
"""Fixed polkit operations for the optional Tailscale provider. Never a shell API."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import stat
import urllib.request
from urllib.parse import urlsplit
TAILSCALE = "/usr/bin/tailscale"
RELEASE = Path("/usr/share/mission-core-node/tailscale-release.json")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
TRANSPORT_DIRECTORY = Path("/etc/systemd/system/tailscaled.service.d")
TRANSPORT_NAME = "60-mission-core-node-https.conf"
TRANSPORT_CONFIG = b"# Mission Core Node: provider control transport; preserve on Node removal.\n[Service]\nEnvironment=TS_FORCE_NOISE_443=true\n"
class SetupError(Exception):
pass
def login_url(value):
if not isinstance(value, str) or len(value) > 512:
return False
try:
u = urlsplit(value)
return (u.scheme == "https" and u.netloc == "login.tailscale.com"
and not u.query and not u.fragment
and re.fullmatch(r"/a/[A-Za-z0-9_-]+", u.path) is not None)
except ValueError:
return False
def status():
result = subprocess.run([TAILSCALE, "status", "--json", "--peers=false"],
env=ENV, capture_output=True, text=True, timeout=8)
if result.returncode or len(result.stdout) > 1024 * 1024:
raise SetupError("Служба Tailscale пока не отвечает. Подождите и повторите подключение.")
value = json.loads(result.stdout)
if not isinstance(value, dict):
raise SetupError("Не удалось прочитать состояние Tailscale.")
return value
def checked(command):
# Do not kill dpkg mid-transaction if the desktop window is closed. APT has
# 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, затем повторите.")
def control_transport():
"""Use the pinned provider's HTTPS underlay on networks that stall port 80.
Only a Node-owned systemd drop-in is written; keys, profiles, DNS, routes
and other provider settings are never edited. An active connected provider
is left untouched by callers. Never replace a custom file at our path.
"""
TRANSPORT_DIRECTORY.mkdir(mode=0o755, exist_ok=True)
info = TRANSPORT_DIRECTORY.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SetupError("Небезопасные права каталога службы Tailscale. Требуется проверить настройку системы.")
destination = TRANSPORT_DIRECTORY / TRANSPORT_NAME
if destination.is_symlink():
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
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() != TRANSPORT_CONFIG):
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
return
# Root-only operation lock serializes our own setup. Publish a complete
# file atomically; systemd must never see a half-written configuration.
with tempfile.NamedTemporaryFile(dir=TRANSPORT_DIRECTORY, prefix=".node-https-", delete=False) as output:
temporary = Path(output.name)
try:
output.write(TRANSPORT_CONFIG)
output.flush()
os.fchmod(output.fileno(), 0o644)
os.fsync(output.fileno())
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
checked(["/usr/bin/systemctl", "daemon-reload"])
def https_transport_active():
result = subprocess.run(["/usr/bin/systemctl", "show", "--property=MainPID", "--value", "tailscaled.service"],
env=ENV, capture_output=True, text=True, timeout=5)
pid = result.stdout.strip()
if result.returncode or not re.fullmatch(r"[1-9][0-9]{0,9}", pid):
return False
try:
# Read only to check this one nonsensitive flag; never emit the process
# environment (which can contain unrelated administrator credentials).
return b"TS_FORCE_NOISE_443=true" in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
except OSError:
return False
def install():
if not Path(TAILSCALE).exists():
release = json.loads(RELEASE.read_text())
expected = release["sha256"]
url = release["url"]
if (not isinstance(expected, str) or not isinstance(url, str)
or not re.fullmatch(r"[a-f0-9]{64}", expected)
or not re.fullmatch(r"https://pkgs\.tailscale\.com/stable/tailscale_[0-9.]+_amd64\.deb", url)):
raise SetupError("Повреждены сведения об установочном пакете Tailscale.")
with tempfile.TemporaryDirectory(prefix="mission-core-tailscale-", dir="/var/tmp") as directory:
package = Path(directory) / "tailscale.deb"
digest = hashlib.sha256()
size = 0
with urllib.request.urlopen(url, timeout=30) as response, package.open("xb") as output:
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > 64 * 1024 * 1024:
raise SetupError("Размер пакета Tailscale не соответствует ожидаемому.")
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
raise SetupError("Контрольная сумма Tailscale не совпала. Пакет не установлен; повторите загрузку.")
checked(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1",
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30",
"--no-remove", "--no-install-recommends", "install", "-y", str(package)])
control_transport()
# The vendor package may already have started its daemon during APT.
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
if not Path(TAILSCALE).is_file():
raise SetupError("Установщик завершился, но Tailscale не найден.")
return {"ok": True}
def connect_command(state):
if state == "Stopped":
# Up with absolutely no flags is the upstream preserve-all-preferences
# resume operation. Even --json counts as a flag in the pinned CLI.
return [TAILSCALE, "up"]
if state == "NeedsLogin":
# Fresh onboard setup keeps the current LAN DNS/routes. No exit node,
# route advertisement, Tailscale SSH, reset, or forced reauthentication.
return [TAILSCALE, "up", "--json", "--timeout=12s", "--accept-dns=false", "--accept-routes=false"]
raise SetupError("Tailscale ещё запускается. Подождите и повторите подключение.")
def connect():
if not Path(TAILSCALE).is_file():
raise SetupError("Сначала установите Tailscale через приложение.")
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
current = status()
state = current.get("BackendState")
if state in ("Running", "NeedsMachineAuth"):
return {"ok": True}
control_transport()
if not https_transport_active():
checked(["/usr/bin/systemctl", "daemon-reload"])
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
if not https_transport_active():
raise SetupError("Другие настройки службы мешают восстановить соединение Tailscale. Они сохранены; требуется проверить конфигурацию системы.")
current = status()
state = current.get("BackendState")
if state in ("Running", "NeedsMachineAuth"):
return {"ok": True}
# A pending provider login is reused; never force a second authentication.
if state == "NeedsLogin" and login_url(current.get("AuthURL")):
return {"ok": True, "url": current["AuthURL"]}
try:
result = subprocess.run(connect_command(state), env=ENV, capture_output=True, timeout=18)
success = result.returncode == 0
except subprocess.TimeoutExpired:
success = False
# Read the daemon's actual outcome, not the CLI's progress text. AuthURL and
# any other provider credentials are never written to disk or the journal.
current = status()
if current.get("BackendState") in ("Running", "NeedsMachineAuth"):
return {"ok": True}
if login_url(current.get("AuthURL")):
return {"ok": True, "url": current["AuthURL"]}
if success:
return {"ok": True}
raise SetupError("Tailscale не завершил подключение. Проверьте интернет и повторите; существующие нестандартные настройки требуют отдельной проверки.")
def main():
if os.geteuid() != 0 or sys.argv[1:] not in (["install"], ["connect"]):
raise SystemExit("Use the installed application and its system authorization dialog")
os.environ.clear()
os.environ.update(ENV)
os.umask(0o077)
try:
directory = Path("/run/mission-core-node-system")
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise SetupError("Небезопасные права системного каталога Node. Требуется восстановить установку.")
fd = os.open(directory / "tailscale.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("Операция с Tailscale уже выполняется. Подождите и обновите состояние.")
result = install() if sys.argv[1] == "install" else connect()
except SetupError as error:
result = {"ok": False, "error": str(error)}
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
result = {"ok": False, "error": "Не удалось завершить настройку Tailscale. Проверьте подключение к интернету и повторите."}
print(json.dumps(result))
if __name__ == "__main__":
main()
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!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.open">
<description>Open Mission Core Node</description>
<description xml:lang="ru">Открыть Mission Core Node</description>
<message>Authenticate to manage this onboard computer.</message>
<message xml:lang="ru">Подтвердите доступ к управлению этим бортовым компьютером.</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/authorize</annotate>
</action>
<action id="org.nodedc.mission-core-node.install-tailscale">
<description>Install Tailscale for Mission Core Node</description>
<description xml:lang="ru">Установить Tailscale для Mission Core Node</description>
<message>Install the verified Tailscale package and enable its system service.</message>
<message xml:lang="ru">Установить проверенный пакет 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/install-tailscale</annotate>
</action>
<action id="org.nodedc.mission-core-node.connect-tailscale">
<description>Connect this computer to Tailscale</description>
<description xml:lang="ru">Подключить борт к Tailscale</description>
<message>Enable Tailscale and open its sign-in page if authentication is required.</message>
<message xml:lang="ru">Включить 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/connect-tailscale</annotate>
</action>
</policyconfig>
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
case "$1" in
configure)
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"
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
;;
esac
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
# Preserve identity and ownership on remove/purge. A future explicit UI factory
# reset must distinguish local deletion from revoking remote Core authorization.
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then
. /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
exit 1
fi
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
case "$1" in
remove|deconfigure)
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
if [ -e "$mc_node_ssh_snippet" ]; then
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
rm "$mc_node_ssh_snippet"
else
mc_node_saved_snippet=$(mktemp /etc/ssh/sshd_config.d/mission-core-node-removed.XXXXXX)
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
fi
fi
if [ -d /run/systemd/system ]; then
/usr/sbin/sshd -t
systemctl try-reload-or-restart ssh.service
systemctl stop mission-core-node.service
systemctl disable mission-core-node.service
fi
;;
esac
@@ -0,0 +1,6 @@
{
"version": "1.102.3",
"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
"sha256": "88e1b0319da94a52ea409a1a5935e4e7215065a25cd99bc509b6dcbb73737fae",
"checksum_source": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb.sha256"
}
@@ -0,0 +1,102 @@
"""Security/continuity checks. No installation, OS mutation, or real login."""
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import network_helper as helper
class NetworkHelperTests(unittest.TestCase):
def test_only_provider_login_destinations_are_accepted(self):
self.assertTrue(helper.login_url("https://login.tailscale.com/a/synthetic-login"))
for value in [None, "http://login.tailscale.com/a/x", "https://login.tailscale.com.evil.test/a/x",
"https://login.tailscale.com@evil.test/a/x", "https://login.tailscale.com/a/x?q=x",
"https://login.tailscale.com/a/../admin", "https://login.tailscale.com/a/x#fragment",
"https://login.tailscale.com:443/a/x", "file:///tmp/x"]:
self.assertFalse(helper.login_url(value), value)
def test_resume_keeps_existing_preferences_and_new_login_keeps_lan(self):
self.assertEqual(helper.connect_command("Stopped"), [helper.TAILSCALE, "up"])
fresh = helper.connect_command("NeedsLogin")
self.assertIn("--accept-dns=false", fresh)
self.assertIn("--accept-routes=false", fresh)
for flag in ("--reset", "--force-reauth", "--ssh", "--advertise-routes", "--exit-node"):
self.assertFalse(any(arg.startswith(flag) for arg in fresh))
with self.assertRaises(helper.SetupError):
helper.connect_command("Unknown")
def test_checksum_failure_never_reaches_apt_or_service_mutation(self):
original_tempdir = tempfile.TemporaryDirectory
with original_tempdir() as directory:
release = Path(directory) / "release.json"
release.write_text(json.dumps({"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
"sha256": hashlib.sha256(b"expected").hexdigest()}))
with patch.object(helper, "TAILSCALE", str(Path(directory) / "missing")), \
patch.object(helper, "RELEASE", release), \
patch.object(helper.tempfile, "TemporaryDirectory", side_effect=lambda **kwargs: original_tempdir(dir=directory)), \
patch.object(helper.urllib.request, "urlopen", return_value=io.BytesIO(b"tampered")), \
patch.object(helper, "checked") as mutation:
with self.assertRaises(helper.SetupError):
helper.install()
mutation.assert_not_called()
def test_existing_provider_is_not_reinstalled(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper.urllib.request, "urlopen") as download, \
patch.object(helper, "checked") as mutation:
self.assertTrue(helper.install()["ok"])
download.assert_not_called()
mutation.assert_called_once_with(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
def test_cli_timeout_uses_daemon_outcome_and_does_not_retry_login(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", return_value=True), \
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "NeedsLogin", "AuthURL": "https://login.tailscale.com/a/synthetic"}]), \
patch.object(helper.subprocess, "run", side_effect=helper.subprocess.TimeoutExpired("tailscale", 18)) as run:
self.assertEqual(helper.connect(), {"ok": True, "url": "https://login.tailscale.com/a/synthetic"})
self.assertEqual(run.call_count, 1)
def test_connected_provider_is_never_reconfigured(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "status", return_value={"BackendState": "Running"}), \
patch.object(helper, "control_transport") as transport:
self.assertEqual(helper.connect(), {"ok": True})
transport.assert_not_called()
def test_transport_recovery_uses_saved_profile_without_login_when_possible(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked") as system, \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", side_effect=[False, True]), \
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "Running"}]), \
patch.object(helper.subprocess, "run") as cli:
self.assertEqual(helper.connect(), {"ok": True})
self.assertIn(unittest.mock.call(["/usr/bin/systemctl", "restart", "tailscaled.service"]), system.call_args_list)
cli.assert_not_called()
def test_transport_conflict_does_not_start_another_login(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", return_value=False), \
patch.object(helper, "status", return_value={"BackendState": "NeedsLogin"}), \
patch.object(helper.subprocess, "run") as cli:
with self.assertRaises(helper.SetupError):
helper.connect()
cli.assert_not_called()
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -0,0 +1,9 @@
{
"filename": "go1.26.8.darwin-arm64.tar.gz",
"os": "darwin",
"arch": "arm64",
"version": "go1.26.8",
"sha256": "a012b25b571bd0138a03dcd25375ceba866fe5ca822f426d2c66a4de56fd3f4b",
"size": 64626620,
"kind": "archive"
}
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Mission Core Node</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@nodedc/mission-core-node-ui",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test test/*.test.mjs",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}
@@ -0,0 +1,8 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
<defs>
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
</defs>
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,4 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+18
View File
@@ -0,0 +1,18 @@
import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import type { Status } from "./api";
export function NetworkView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса этого компьютера.">
{value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
}
export function DevicesView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Подключённые по USB" description="Устройства, которые обнаружила операционная система." actions={<StatusBadge>{value.host.usb.length}</StatusBadge>}>
{!value.host.usb_readable ? <p className="node-note" role="status">Не удалось получить список устройств. Повторите обновление.</p> : value.host.usb.length === 0 ? <p className="node-note">Подключите устройство к USB, затем обновите список.</p> : <ResourceList aria-label="USB-устройства">{value.host.usb.map(device => <li key={device.port}><ResourceRow icon={<Icon name="camera" />} title={device.product || `USB ${device.vendor}:${device.product_id}`} description={`Порт ${device.port} · ${device.speed_mbps ? `${device.speed_mbps} Мбит/с` : "Скорость недоступна"}`} metadata={`${device.vendor}:${device.product_id}`} status={<StatusBadge>Обнаружено</StatusBadge>} /></li>)}</ResourceList>}
</SettingsCard><p className="node-note">Обнаружение USB ещё не означает готовность к съёмке.</p></div>;
}
export function DiagnosticsView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Диагностический отчёт" description="Сведения о системе и доступности компонентов без имени компьютера, сетевых адресов и ID ноды.">
<Button icon={<Icon name="download" />} onClick={() => location.assign("/api/report")}>Скачать отчёт</Button>
<p className="node-note">Сведения получены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
</SettingsCard><SettingsCard title="Замечания системы">{value.host.warnings.length ? value.host.warnings.map(warning => <p className="node-note" key={warning}><StatusBadge tone="warning">{warning}</StatusBadge></p>) : <p className="node-note">При последнем сборе сведений замечаний нет.</p>}</SettingsCard></div>;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { Button, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
import { request, type Status } from "./api";
const memory = (value: number | null) => value === null ? "Недоступно" : `${(value / 1048576).toFixed(1)} ГиБ`;
export function NodeOverview({ value, refresh, failure }: { value: Status; refresh: () => Promise<void>; failure: (error: unknown) => void }) {
const [name, setName] = useState(value.name);
const [saving, setSaving] = useState(false);
useEffect(() => setName(value.name), [value.name]);
async function save(event: React.FormEvent) {
event.preventDefault(); if (saving) return; setSaving(true);
try { await request("/api/name", "PUT", { name }); await refresh(); } catch (error) { failure(error); } finally { setSaving(false); }
}
return <div className="node-content">
<SettingsCard title={value.name} eyebrow="БОРТОВОЙ КОМПЬЮТЕР" description={value.host.hostname} actions={<StatusBadge tone="success">Node работает</StatusBadge>}>
<dl className="node-facts"><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Логических процессоров</dt><dd>{value.host.cpus}</dd></div><div><dt>Оперативная память</dt><dd>{memory(value.host.memory_kib)}</dd></div><div><dt>Доступно памяти</dt><dd>{memory(value.host.available_kib)}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl>
</SettingsCard>
<SettingsCard title="Название компьютера" description="Название, по которому вы узнаёте этот борт в Node.">
<form onSubmit={save} className="node-form" aria-busy={saving}>
<TextField label="Название ноды" value={name} maxLength={64} disabled={saving} onChange={event => setName(event.target.value)} autoComplete="off" />
<Button type="submit" disabled={saving || !name.trim() || name.trim() === value.name}>{saving ? "Сохраняем…" : "Сохранить"}</Button>
</form>
<dl className="node-facts"><div><dt>ID ноды</dt><dd>{value.node_id}</dd></div></dl>
</SettingsCard>
<p className="node-note">Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
</div>;
}
+14
View File
@@ -0,0 +1,14 @@
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, StatusBadge } from "@nodedc/ui-react";
import { useAccess } from "./useAccess";
export function SetupView({ revision, failure, openSSH, openTailnet }: { revision: string; failure: (error: unknown) => void; openSSH: () => void; openTailnet: () => void }) {
const { access, loading } = useAccess(revision, failure);
return <div className="node-content">
<p className="node-note">Компоненты для работы с этим компьютером и его обслуживания.</p>
<ResourceList aria-label="Компоненты системы">
<li><ResourceRow icon={<Icon name="activity" />} title="Mission Core Node" description="Локальная служба и приложение" status={<StatusBadge tone="success">Работает</StatusBadge>} /></li>
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="OpenSSH Server" description="Доступ для обслуживания компьютера" status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={openSSH}>Настроить доступ</Button>} /></li>
<li><ResourceRow icon={<Icon name="globe" />} title="Tailscale" description="Подключение к частной сети" actions={<Button onClick={openTailnet}>Открыть</Button>} /></li>
</ResourceList>
<p className="node-note">OpenSSH устанавливается вместе с Node. Ответ локального сервера не подтверждает подключение с другого компьютера.</p>
</div>;
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
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 }) {
const { access, loading, refresh } = useAccess(revision, failure);
const [user, setUser] = useState("");
const [label, setLabel] = useState("");
const [key, setKey] = useState("");
const [pending, setPending] = useState(false);
const [remove, setRemove] = useState<AccessKey | null>(null);
const [detail, setDetail] = useState<AccessKey | null>(null);
useEffect(() => { if (access) setUser(current => access.users.includes(current) ? current : access.users[0] ?? ""); }, [access]);
useEffect(() => { if (!adding) { setLabel(""); setKey(""); } }, [adding]);
async function add(event: React.FormEvent) {
event.preventDefault(); if (pending) return; setPending(true);
try { await request("/api/access", "POST", { user, label, key: key.trim() }); await refresh(); closeAdd(); success("Доступ устройства добавлен"); }
catch (error) { failure(error); } finally { setPending(false); }
}
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>
</>}
<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}>
<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} />
<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>}
</Window>
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
if (!remove) return;
try { await request("/api/access", "DELETE", { user: remove.user, id: remove.id }); await refresh(); setRemove(null); success("Доступ устройства отозван"); } catch (error) { failure(error); throw error; }
}} />
</div>;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { ActivityIndicator, Button, SettingsCard, StatusBadge } from "@nodedc/ui-react";
import { APIError, desktopAction, networkSetupAvailable, request } from "./api";
interface NetworkStatus { installed: boolean; state: string; online: boolean; addresses: string[] }
interface NetworkResult { action: string; ok: boolean; error?: string; browser_opened?: boolean }
const states: Record<string, string> = {
not_installed: "Не установлен", unavailable: "Служба недоступна", NeedsLogin: "Требуется вход",
NeedsMachineAuth: "Ожидаем разрешения администратора сети", Stopped: "Отключён", Starting: "Подключаемся", NoState: "Запускается",
};
export function TailnetAccess({ failure, revision: hostRevision }: { failure: (error: unknown) => void; revision: string }) {
const [checked, setChecked] = useState(false);
const [value, setValue] = useState<NetworkStatus | null>(null);
const [pending, setPending] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function update() {
try {
const next = await request<NetworkStatus>("/api/network/tailscale");
if (active) { setValue(next); if (next.online) setNotice(""); }
} catch (error) {
if (active) {
setValue(null);
// Service upgrades invalidate local sessions. Use the existing
// application login surface instead of hiding 401 as provider failure.
if (error instanceof APIError && error.status === 401) failure(error);
}
} finally { if (active) { setChecked(true); timer = setTimeout(() => void update(), 5000); } }
}
void update();
return () => { active = false; clearTimeout(timer); };
}, [revision, hostRevision, failure]);
useEffect(() => {
function completed(event: Event) {
const result = (event as CustomEvent<NetworkResult>).detail;
if (!result || !["install-tailscale", "connect-tailscale"].includes(result.action)) return;
setPending(null);
if (!result.ok) failure(new Error(result.error ?? "Настройка не завершена. Повторите действие."));
setNotice(result.browser_opened ? "Завершите вход в открывшемся браузере. Состояние здесь обновится автоматически." : "");
setRevision(value => value + 1);
}
window.addEventListener("mission-core-network-result", completed);
return () => window.removeEventListener("mission-core-network-result", completed);
}, [failure]);
function perform(action: "install-tailscale" | "connect-tailscale") {
if (pending) return;
setNotice(""); setPending(action);
if (!desktopAction(action)) {
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu."));
}
}
const label = !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
const canConnect = value?.installed && ["NeedsLogin", "Stopped", "unavailable"].includes(value.state);
const supported = networkSetupAvailable();
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></>}
{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. Проверяем подключение…"} />}
{notice && <p className="node-note" role="status">{notice}</p>}
</SettingsCard></div>;
}
+41
View File
@@ -0,0 +1,41 @@
export interface Status {
version: string; node_id: string; name: string;
host: {
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; available_kib: number | null;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[];
usb_readable: boolean; warnings: string[];
};
}
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } }
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale";
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;
const host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } };
const channel = host.webkit?.messageHandlers?.node;
if (!channel) return false;
channel.postMessage(action);
return true;
}
export function desktopLogin(): boolean { return desktopAction("authorize"); }
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) });
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
}
return res.json();
}
export async function loginFromLaunch(): Promise<void> {
const params = new URLSearchParams(location.hash.slice(1));
const token = params.get("login");
// Remove the one-use credential from history before any asynchronous work.
if (location.hash) history.replaceState(null, "", location.pathname);
if (token) await request("/api/session", "POST", { token });
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, HeaderWorkspace, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
import "@nodedc/tokens/tokens.css";
import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css";
import { desktopLogin } from "./api";
import { useNode } from "./useNode";
import { roots, views, type RootId, type ViewId } from "./nodeModel";
import { NodeOverview } from "./NodeOverview";
import { DevicesView, DiagnosticsView, NetworkView } from "./InventoryViews";
import { SystemAccess } from "./SystemAccess";
import { SetupView } from "./SetupView";
import { TailnetAccess } from "./TailnetAccess";
import "./node.css";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" });
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 selectRoot(id: RootId) { openView(roots.find(item => item.id === id)!.first); }
const content = !value ? null : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <DevicesView value={value} />
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
: workspace.activeView === "setup" ? <SetupView revision={value.host.collected_at} failure={failure} openSSH={() => openView("ssh")} openTailnet={() => openView("tailscale")} />
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
: 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 }))} 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>} />}
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>}
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} 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>
</SettingsCard>}
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
</>;
}
createRoot(document.getElementById("root")!).render(<App />);
+14
View File
@@ -0,0 +1,14 @@
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-primary); font-family: var(--nodedc-font-family); font-size: var(--nodedc-font-size-md); }
.node-stage { height: 100%; overflow: auto; padding: var(--nodedc-space-5); }
.node-content { display: grid; align-content: start; gap: var(--nodedc-space-5); min-width: 0; }
.node-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--nodedc-space-5); margin: 0; font-size: var(--nodedc-font-size-sm); }
.node-facts > div { min-width: 0; }
.node-facts dt { color: var(--nodedc-text-muted); margin-bottom: var(--nodedc-space-2); }
.node-facts dd { margin: 0; overflow-wrap: anywhere; }
.node-section-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--nodedc-space-3); justify-content: space-between; }
.node-form { display: grid; gap: var(--nodedc-space-4); }
.node-form > button { justify-self: start; }
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
+17
View File
@@ -0,0 +1,17 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices" | "remote";
export type ViewId = "overview" | "network" | "setup" | "diagnostics" | "usb" | "tailscale" | "ssh";
export const roots: { id: RootId; label: string; first: ViewId }[] = [
{ id: "system", label: "Состояние системы", first: "overview" },
{ id: "devices", label: "Устройства", first: "usb" },
{ id: "remote", label: "Удалённый контроль", first: "tailscale" },
];
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
{ id: "overview", root: "system", label: "Обзор компьютера", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" },
{ id: "setup", root: "system", label: "Конфигурация системы", icon: "settings" },
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },
{ id: "usb", root: "devices", label: "USB-устройства", icon: "camera" },
{ id: "tailscale", root: "remote", label: "Tailscale", icon: "globe" },
{ id: "ssh", root: "remote", label: "SSH · доверенные устройства", icon: "key" },
];
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { request } from "./api";
export interface AccessKey { id: string; user: string; label: string; public_key: string }
export interface Access { users: string[]; keys: AccessKey[]; ssh_ready: boolean }
export function useAccess(revision: string, failure: (error: unknown) => void) {
const [access, setAccess] = useState<Access | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try { setAccess(await request<Access>("/api/access")); }
catch (error) { setAccess(null); failure(error); throw error; }
finally { setLoading(false); }
}, [failure]);
useEffect(() => { void refresh().catch(() => {}); }, [revision, refresh]);
return { access, loading, refresh };
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from "react";
import { type ToastItem } from "@nodedc/ui-react";
import { APIError, loginFromLaunch, request, type Status } from "./api";
export function useNode() {
const [value, setValue] = useState<Status | null>(null);
const [pending, setPending] = useState(true);
const [locked, setLocked] = useState(false);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const failure = useCallback((error: unknown) => {
if (error instanceof APIError && error.status === 401) { setValue(null); setLocked(true); }
setToasts([{ id: "request", tone: "error", title: error instanceof Error ? error.message : "Нода недоступна", durationMs: null }]);
}, []);
const refresh = useCallback(async () => {
setPending(true);
try { const result = await request<Status>("/api/status"); setValue(result); setLocked(false); setToasts([]); }
catch (error) { failure(error); }
finally { setPending(false); }
}, [failure]);
useEffect(() => {
const launch = () => { void loginFromLaunch().then(refresh).catch(error => { failure(error); setPending(false); }); };
launch(); window.addEventListener("hashchange", launch);
return () => window.removeEventListener("hashchange", launch);
}, [refresh, failure]);
const success = useCallback((title: string) => setToasts([{ id: "request", tone: "success", title }]), []);
return { value, pending, locked, refresh, failure, success, toasts,
dismiss: (id: string) => setToasts(items => items.filter(item => item.id !== id)) };
}
+11
View File
@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
test("Node UI uses canonical controls and never exposes vendor commands", () => {
for (const file of readdirSync(new URL("../src/", import.meta.url)).filter(name => name.endsWith(".tsx"))) {
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8");
assert.doesNotMatch(source, /<(button|input|select|textarea)\b/, file);
assert.doesNotMatch(source, /mqtt|Quick Connect|BLE_UUID|openapi_key|192\.168\.68\.50|dcsudo/, file);
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext",
"moduleResolution": "bundler", "jsx": "react-jsx", "strict": true,
"skipLibCheck": true, "noEmit": true, "esModuleInterop": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
export default defineConfig({
// Design Guideline packages are linked during development. Their own React
// must never become a second hook dispatcher in the portable production bundle.
resolve: { dedupe: ["react", "react-dom"] },
});
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist
var Assets embed.FS