From d696842f5d361cd57fbb8deb19caff3ba4f6f638 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 5 Sep 2026 17:27:59 +0300 Subject: [PATCH] feat(node): package Ubuntu desktop setup and trusted access --- apps/node-agent/.gitignore | 4 + apps/node-agent/README.md | 166 +++ apps/node-agent/_qa/main.go | 39 + apps/node-agent/cmd/node-agent/main.go | 144 ++ apps/node-agent/go.mod | 3 + apps/node-agent/internal/node/access.go | 265 ++++ apps/node-agent/internal/node/inventory.go | 107 ++ apps/node-agent/internal/node/node_test.go | 255 ++++ apps/node-agent/internal/node/server.go | 231 +++ apps/node-agent/internal/node/state.go | 122 ++ apps/node-agent/internal/node/tailscale.go | 76 + .../internal/node/tailscale_test.go | 60 + .../packaging/60-mission-core-node.conf | 5 + apps/node-agent/packaging/authorize | 2 + apps/node-agent/packaging/build.py | 60 + apps/node-agent/packaging/build_deb.py | 106 ++ apps/node-agent/packaging/connect-tailscale | 2 + apps/node-agent/packaging/install-tailscale | 2 + apps/node-agent/packaging/launcher.py | 262 ++++ .../packaging/mission-core-node.desktop | 10 + .../packaging/mission-core-node.service | 34 + apps/node-agent/packaging/network_helper.py | 219 +++ .../org.nodedc.mission-core-node.policy | 29 + apps/node-agent/packaging/postinst | 34 + apps/node-agent/packaging/postrm | 7 + apps/node-agent/packaging/preinst | 9 + apps/node-agent/packaging/prerm | 21 + .../packaging/tailscale-release.json | 6 + .../packaging/test_network_helper.py | 102 ++ apps/node-agent/toolchain.json | 9 + apps/node-agent/ui/index.html | 2 + apps/node-agent/ui/package-lock.json | 1244 +++++++++++++++++ apps/node-agent/ui/package.json | 24 + apps/node-agent/ui/public/nodedc-logo.svg | 8 + apps/node-agent/ui/public/nodedc-mark.svg | 4 + apps/node-agent/ui/src/InventoryViews.tsx | 18 + apps/node-agent/ui/src/NodeOverview.tsx | 27 + apps/node-agent/ui/src/SetupView.tsx | 14 + apps/node-agent/ui/src/SystemAccess.tsx | 43 + apps/node-agent/ui/src/TailnetAccess.tsx | 72 + apps/node-agent/ui/src/api.ts | 41 + apps/node-agent/ui/src/main.tsx | 56 + apps/node-agent/ui/src/node.css | 14 + apps/node-agent/ui/src/nodeModel.ts | 17 + apps/node-agent/ui/src/useAccess.ts | 16 + apps/node-agent/ui/src/useNode.ts | 28 + apps/node-agent/ui/test/boundary.test.mjs | 11 + apps/node-agent/ui/tsconfig.json | 8 + apps/node-agent/ui/vite.config.js | 7 + apps/node-agent/web/assets.go | 6 + .../01_BOOTSTRAP_SURFACE_AND_ACCEPTANCE.md | 300 ++++ docs/node/02_NODE_DESKTOP_SURFACE.md | 106 ++ 52 files changed, 4457 insertions(+) create mode 100644 apps/node-agent/.gitignore create mode 100644 apps/node-agent/README.md create mode 100644 apps/node-agent/_qa/main.go create mode 100644 apps/node-agent/cmd/node-agent/main.go create mode 100644 apps/node-agent/go.mod create mode 100644 apps/node-agent/internal/node/access.go create mode 100644 apps/node-agent/internal/node/inventory.go create mode 100644 apps/node-agent/internal/node/node_test.go create mode 100644 apps/node-agent/internal/node/server.go create mode 100644 apps/node-agent/internal/node/state.go create mode 100644 apps/node-agent/internal/node/tailscale.go create mode 100644 apps/node-agent/internal/node/tailscale_test.go create mode 100644 apps/node-agent/packaging/60-mission-core-node.conf create mode 100644 apps/node-agent/packaging/authorize create mode 100644 apps/node-agent/packaging/build.py create mode 100644 apps/node-agent/packaging/build_deb.py create mode 100644 apps/node-agent/packaging/connect-tailscale create mode 100644 apps/node-agent/packaging/install-tailscale create mode 100644 apps/node-agent/packaging/launcher.py create mode 100644 apps/node-agent/packaging/mission-core-node.desktop create mode 100644 apps/node-agent/packaging/mission-core-node.service create mode 100644 apps/node-agent/packaging/network_helper.py create mode 100644 apps/node-agent/packaging/org.nodedc.mission-core-node.policy create mode 100644 apps/node-agent/packaging/postinst create mode 100644 apps/node-agent/packaging/postrm create mode 100644 apps/node-agent/packaging/preinst create mode 100644 apps/node-agent/packaging/prerm create mode 100644 apps/node-agent/packaging/tailscale-release.json create mode 100644 apps/node-agent/packaging/test_network_helper.py create mode 100644 apps/node-agent/toolchain.json create mode 100644 apps/node-agent/ui/index.html create mode 100644 apps/node-agent/ui/package-lock.json create mode 100644 apps/node-agent/ui/package.json create mode 100644 apps/node-agent/ui/public/nodedc-logo.svg create mode 100644 apps/node-agent/ui/public/nodedc-mark.svg create mode 100644 apps/node-agent/ui/src/InventoryViews.tsx create mode 100644 apps/node-agent/ui/src/NodeOverview.tsx create mode 100644 apps/node-agent/ui/src/SetupView.tsx create mode 100644 apps/node-agent/ui/src/SystemAccess.tsx create mode 100644 apps/node-agent/ui/src/TailnetAccess.tsx create mode 100644 apps/node-agent/ui/src/api.ts create mode 100644 apps/node-agent/ui/src/main.tsx create mode 100644 apps/node-agent/ui/src/node.css create mode 100644 apps/node-agent/ui/src/nodeModel.ts create mode 100644 apps/node-agent/ui/src/useAccess.ts create mode 100644 apps/node-agent/ui/src/useNode.ts create mode 100644 apps/node-agent/ui/test/boundary.test.mjs create mode 100644 apps/node-agent/ui/tsconfig.json create mode 100644 apps/node-agent/ui/vite.config.js create mode 100644 apps/node-agent/web/assets.go create mode 100644 docs/node/01_BOOTSTRAP_SURFACE_AND_ACCEPTANCE.md create mode 100644 docs/node/02_NODE_DESKTOP_SURFACE.md diff --git a/apps/node-agent/.gitignore b/apps/node-agent/.gitignore new file mode 100644 index 0000000..6f18efa --- /dev/null +++ b/apps/node-agent/.gitignore @@ -0,0 +1,4 @@ +/build/ +/ui/node_modules/ +/ui/dist/ +/web/dist/ diff --git a/apps/node-agent/README.md b/apps/node-agent/README.md new file mode 100644 index 0000000..3f76d56 --- /dev/null +++ b/apps/node-agent/README.md @@ -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. diff --git a/apps/node-agent/_qa/main.go b/apps/node-agent/_qa/main.go new file mode 100644 index 0000000..aa20fab --- /dev/null +++ b/apps/node-agent/_qa/main.go @@ -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(""),[]byte(``),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)) +} diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go new file mode 100644 index 0000000..30b9248 --- /dev/null +++ b/apps/node-agent/cmd/node-agent/main.go @@ -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 +} diff --git a/apps/node-agent/go.mod b/apps/node-agent/go.mod new file mode 100644 index 0000000..654a04d --- /dev/null +++ b/apps/node-agent/go.mod @@ -0,0 +1,3 @@ +module nodedc.local/mission-core/node-agent + +go 1.26.0 diff --git a/apps/node-agent/internal/node/access.go b/apps/node-agent/internal/node/access.go new file mode 100644 index 0000000..bb3ac3b --- /dev/null +++ b/apps/node-agent/internal/node/access.go @@ -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 +} diff --git a/apps/node-agent/internal/node/inventory.go b/apps/node-agent/internal/node/inventory.go new file mode 100644 index 0000000..e8a5cd7 --- /dev/null +++ b/apps/node-agent/internal/node/inventory.go @@ -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 +} diff --git a/apps/node-agent/internal/node/node_test.go b/apps/node-agent/internal/node/node_test.go new file mode 100644 index 0000000..cfeb349 --- /dev/null +++ b/apps/node-agent/internal/node/node_test.go @@ -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") + } +} diff --git a/apps/node-agent/internal/node/server.go b/apps/node-agent/internal/node/server.go new file mode 100644 index 0000000..7bda3d2 --- /dev/null +++ b/apps/node-agent/internal/node/server.go @@ -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 +} diff --git a/apps/node-agent/internal/node/state.go b/apps/node-agent/internal/node/state.go new file mode 100644 index 0000000..688f8ca --- /dev/null +++ b/apps/node-agent/internal/node/state.go @@ -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 +} diff --git a/apps/node-agent/internal/node/tailscale.go b/apps/node-agent/internal/node/tailscale.go new file mode 100644 index 0000000..60eec64 --- /dev/null +++ b/apps/node-agent/internal/node/tailscale.go @@ -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 +} diff --git a/apps/node-agent/internal/node/tailscale_test.go b/apps/node-agent/internal/node/tailscale_test.go new file mode 100644 index 0000000..cac45be --- /dev/null +++ b/apps/node-agent/internal/node/tailscale_test.go @@ -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") + } +} diff --git a/apps/node-agent/packaging/60-mission-core-node.conf b/apps/node-agent/packaging/60-mission-core-node.conf new file mode 100644 index 0000000..ae2c724 --- /dev/null +++ b/apps/node-agent/packaging/60-mission-core-node.conf @@ -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 diff --git a/apps/node-agent/packaging/authorize b/apps/node-agent/packaging/authorize new file mode 100644 index 0000000..fc18529 --- /dev/null +++ b/apps/node-agent/packaging/authorize @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/lib/mission-core-node/node-agent authorize diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py new file mode 100644 index 0000000..814b5e6 --- /dev/null +++ b/apps/node-agent/packaging/build.py @@ -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") diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py new file mode 100644 index 0000000..eee50c1 --- /dev/null +++ b/apps/node-agent/packaging/build_deb.py @@ -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'\n' + + brand + b'\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 +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"!\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) diff --git a/apps/node-agent/packaging/connect-tailscale b/apps/node-agent/packaging/connect-tailscale new file mode 100644 index 0000000..aa205bd --- /dev/null +++ b/apps/node-agent/packaging/connect-tailscale @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect diff --git a/apps/node-agent/packaging/install-tailscale b/apps/node-agent/packaging/install-tailscale new file mode 100644 index 0000000..9a92459 --- /dev/null +++ b/apps/node-agent/packaging/install-tailscale @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install diff --git a/apps/node-agent/packaging/launcher.py b/apps/node-agent/packaging/launcher.py new file mode 100644 index 0000000..bc047e9 --- /dev/null +++ b/apps/node-agent/packaging/launcher.py @@ -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([])) diff --git a/apps/node-agent/packaging/mission-core-node.desktop b/apps/node-agent/packaging/mission-core-node.desktop new file mode 100644 index 0000000..7927ac5 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node.desktop @@ -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 diff --git a/apps/node-agent/packaging/mission-core-node.service b/apps/node-agent/packaging/mission-core-node.service new file mode 100644 index 0000000..ab374d2 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node.service @@ -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 diff --git a/apps/node-agent/packaging/network_helper.py b/apps/node-agent/packaging/network_helper.py new file mode 100644 index 0000000..0617972 --- /dev/null +++ b/apps/node-agent/packaging/network_helper.py @@ -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() diff --git a/apps/node-agent/packaging/org.nodedc.mission-core-node.policy b/apps/node-agent/packaging/org.nodedc.mission-core-node.policy new file mode 100644 index 0000000..03c4d7e --- /dev/null +++ b/apps/node-agent/packaging/org.nodedc.mission-core-node.policy @@ -0,0 +1,29 @@ + + + + NODE.DC + + Open Mission Core Node + Открыть Mission Core Node + Authenticate to manage this onboard computer. + Подтвердите доступ к управлению этим бортовым компьютером. + nonoauth_admin + /usr/lib/mission-core-node/authorize + + + Install Tailscale for Mission Core Node + Установить Tailscale для Mission Core Node + Install the verified Tailscale package and enable its system service. + Установить проверенный пакет Tailscale и включить его системную службу. + nonoauth_admin + /usr/lib/mission-core-node/install-tailscale + + + Connect this computer to Tailscale + Подключить борт к Tailscale + Enable Tailscale and open its sign-in page if authentication is required. + Включить Tailscale и открыть страницу входа, если требуется авторизация. + nonoauth_admin + /usr/lib/mission-core-node/connect-tailscale + + diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst new file mode 100644 index 0000000..e65c909 --- /dev/null +++ b/apps/node-agent/packaging/postinst @@ -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 diff --git a/apps/node-agent/packaging/postrm b/apps/node-agent/packaging/postrm new file mode 100644 index 0000000..de311b3 --- /dev/null +++ b/apps/node-agent/packaging/postrm @@ -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. diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst new file mode 100644 index 0000000..eb7ea11 --- /dev/null +++ b/apps/node-agent/packaging/preinst @@ -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 diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm new file mode 100644 index 0000000..bb6d013 --- /dev/null +++ b/apps/node-agent/packaging/prerm @@ -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 diff --git a/apps/node-agent/packaging/tailscale-release.json b/apps/node-agent/packaging/tailscale-release.json new file mode 100644 index 0000000..0c2e65c --- /dev/null +++ b/apps/node-agent/packaging/tailscale-release.json @@ -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" +} diff --git a/apps/node-agent/packaging/test_network_helper.py b/apps/node-agent/packaging/test_network_helper.py new file mode 100644 index 0000000..affa9bc --- /dev/null +++ b/apps/node-agent/packaging/test_network_helper.py @@ -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() diff --git a/apps/node-agent/toolchain.json b/apps/node-agent/toolchain.json new file mode 100644 index 0000000..a7a0e19 --- /dev/null +++ b/apps/node-agent/toolchain.json @@ -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" +} \ No newline at end of file diff --git a/apps/node-agent/ui/index.html b/apps/node-agent/ui/index.html new file mode 100644 index 0000000..1daac2f --- /dev/null +++ b/apps/node-agent/ui/index.html @@ -0,0 +1,2 @@ + +Mission Core Node
diff --git a/apps/node-agent/ui/package-lock.json b/apps/node-agent/ui/package-lock.json new file mode 100644 index 0000000..6c318f5 --- /dev/null +++ b/apps/node-agent/ui/package-lock.json @@ -0,0 +1,1244 @@ +{ + "name": "@nodedc/mission-core-node-ui", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nodedc/mission-core-node-ui", + "version": "0.2.0", + "dependencies": { + "@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "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" + } + }, + "../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": { + "name": "@nodedc/tokens", + "version": "0.6.0" + }, + "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": { + "name": "@nodedc/ui-core", + "version": "0.7.0", + "dependencies": { + "@nodedc/tokens": "0.6.0" + } + }, + "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": { + "name": "@nodedc/ui-react", + "version": "0.7.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@nodedc/ui-core": "0.7.0", + "lucide-react": "^0.468.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodedc/tokens": { + "resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", + "link": true + }, + "node_modules/@nodedc/ui-core": { + "resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", + "link": true + }, + "node_modules/@nodedc/ui-react": { + "resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "link": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/apps/node-agent/ui/package.json b/apps/node-agent/ui/package.json new file mode 100644 index 0000000..1c77b70 --- /dev/null +++ b/apps/node-agent/ui/package.json @@ -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" + } +} diff --git a/apps/node-agent/ui/public/nodedc-logo.svg b/apps/node-agent/ui/public/nodedc-logo.svg new file mode 100644 index 0000000..8a68666 --- /dev/null +++ b/apps/node-agent/ui/public/nodedc-logo.svg @@ -0,0 +1,8 @@ + diff --git a/apps/node-agent/ui/public/nodedc-mark.svg b/apps/node-agent/ui/public/nodedc-mark.svg new file mode 100644 index 0000000..836bf0e --- /dev/null +++ b/apps/node-agent/ui/public/nodedc-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/node-agent/ui/src/InventoryViews.tsx b/apps/node-agent/ui/src/InventoryViews.tsx new file mode 100644 index 0000000..27c0df5 --- /dev/null +++ b/apps/node-agent/ui/src/InventoryViews.tsx @@ -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
+ {value.host.networks.length === 0 ?

Сетевые интерфейсы не обнаружены.

: {value.host.networks.map(network =>
  • } title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={{network.up ? "Включён" : "Выключен"}} />
  • )}
    } +

    Наличие адреса не подтверждает доступность другого компьютера или устройства.

    ; +} +export function DevicesView({ value }: { value: Status }) { + return
    {value.host.usb.length}}> + {!value.host.usb_readable ?

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

    : value.host.usb.length === 0 ?

    Подключите устройство к USB, затем обновите список.

    : {value.host.usb.map(device =>
  • } 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={Обнаружено} />
  • )}
    } +

    Обнаружение USB ещё не означает готовность к съёмке.

    ; +} +export function DiagnosticsView({ value }: { value: Status }) { + return
    + +

    Сведения получены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.

    +
    {value.host.warnings.length ? value.host.warnings.map(warning =>

    {warning}

    ) :

    При последнем сборе сведений замечаний нет.

    }
    ; +} diff --git a/apps/node-agent/ui/src/NodeOverview.tsx b/apps/node-agent/ui/src/NodeOverview.tsx new file mode 100644 index 0000000..2d92a77 --- /dev/null +++ b/apps/node-agent/ui/src/NodeOverview.tsx @@ -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; 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
    + Node работает}> +
    Операционная система
    {value.host.os}
    Архитектура
    {value.host.architecture}
    Логических процессоров
    {value.host.cpus}
    Оперативная память
    {memory(value.host.memory_kib)}
    Доступно памяти
    {memory(value.host.available_kib)}
    Mission Core Node
    {value.version}
    +
    + +
    + setName(event.target.value)} autoComplete="off" /> + + +
    ID ноды
    {value.node_id}
    +
    +

    Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.

    +
    ; +} diff --git a/apps/node-agent/ui/src/SetupView.tsx b/apps/node-agent/ui/src/SetupView.tsx new file mode 100644 index 0000000..9473b49 --- /dev/null +++ b/apps/node-agent/ui/src/SetupView.tsx @@ -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
    +

    Компоненты для работы с этим компьютером и его обслуживания.

    + +
  • } title="Mission Core Node" description="Локальная служба и приложение" status={Работает} />
  • +
  • : } title="OpenSSH Server" description="Доступ для обслуживания компьютера" status={{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}} actions={} />
  • +
  • } title="Tailscale" description="Подключение к частной сети" actions={} />
  • +
    +

    OpenSSH устанавливается вместе с Node. Ответ локального сервера не подтверждает подключение с другого компьютера.

    +
    ; +} diff --git a/apps/node-agent/ui/src/SystemAccess.tsx b/apps/node-agent/ui/src/SystemAccess.tsx new file mode 100644 index 0000000..e6dfe49 --- /dev/null +++ b/apps/node-agent/ui/src/SystemAccess.tsx @@ -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(null); + const [detail, setDetail] = useState(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
    +

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

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

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

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

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

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

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

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

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

    : access.users.length === 0 ?

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

    :
    + setLabel(event.target.value)} autoComplete="off" /> +