feat(node): package Ubuntu desktop setup and trusted access
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/build/
|
||||
/ui/node_modules/
|
||||
/ui/dist/
|
||||
/web/dist/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
// Engineering-only UI fixture: uses the production API and isolated storage.
|
||||
// No fixture is linked into cmd/node-agent or installed by the Debian package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"nodedc.local/mission-core/node-agent/internal/node"
|
||||
"nodedc.local/mission-core/node-agent/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := "/private/tmp/mc-node-ui-030-qa"
|
||||
store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
|
||||
assets, _ := fs.Sub(web.Assets, "dist")
|
||||
memory, available := uint64(8388608), uint64(5242880)
|
||||
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.0-qa", Inventory: func() node.Inventory {
|
||||
return node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available,
|
||||
Networks: []node.Network{{Name:"ethernet-qa", Up:true, Addresses:[]string{"192.0.2.10/24"}}},
|
||||
USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}}
|
||||
}, Access: &node.AccessStore{Path:filepath.Join(dir,"ssh-keys.json"), Users:func()[]string{return []string{"operator"}}}, Tailscale:func()node.TailscaleStatus {
|
||||
if _, err := os.Stat(filepath.Join(dir,"offline")); err == nil { return node.TailscaleStatus{Installed:true, State:"unavailable", Addresses:[]string{}} }
|
||||
return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}}
|
||||
}}
|
||||
if err := os.WriteFile(filepath.Join(dir,"login-url"),[]byte(app.IssueLogin()),0600); err != nil {log.Fatal(err)}
|
||||
handler := app.Handler()
|
||||
http.HandleFunc("/",func(w http.ResponseWriter,r *http.Request){
|
||||
if r.URL.Path == "/qa-bridge.js" { w.Header().Set("Content-Type","application/javascript"); _,_ = w.Write([]byte(`window.missionCoreDesktop={networkSetup:true};`)); return }
|
||||
if r.URL.Path == "/" { b,_:=fs.ReadFile(assets,"index.html"); b=bytes.Replace(b,[]byte("</head>"),[]byte(`<script src="/qa-bridge.js"></script></head>`),1); w.Header().Set("Content-Type","text/html"); _,_=w.Write(b); return }
|
||||
handler.ServeHTTP(w,r)
|
||||
})
|
||||
log.Print("isolated Node UI QA: 127.0.0.1:8780")
|
||||
log.Fatal(http.ListenAndServe("127.0.0.1:8780",nil))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module nodedc.local/mission-core/node-agent
|
||||
|
||||
go 1.26.0
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Version int `json:"version"`
|
||||
PrivateKey []byte `json:"private_key"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
state State
|
||||
}
|
||||
|
||||
func OpenStore(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{path: filepath.Join(dir, "identity.json")}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(b, &s.state); err != nil {
|
||||
return nil, errors.New("invalid identity; recovery required")
|
||||
}
|
||||
if s.state.Version != 1 || len(s.state.PrivateKey) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("unsupported identity; recovery required")
|
||||
}
|
||||
derived := ed25519.NewKeyFromSeed(s.state.PrivateKey[:ed25519.SeedSize])
|
||||
if !equalKey(derived, s.state.PrivateKey) {
|
||||
return nil, errors.New("corrupt identity; recovery required")
|
||||
}
|
||||
if info, e := os.Stat(s.path); e != nil || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("identity permissions must be private")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.state = State{Version: 1, PrivateKey: key, Name: "Моя нода"}
|
||||
if err := s.write(s.state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func equalKey(a, b []byte) bool { return string(a) == string(b) }
|
||||
|
||||
func (s *Store) write(state State) error {
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err = f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(f.Name(), s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := os.Open(filepath.Dir(s.path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
func (s *Store) Public() (string, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pub := ed25519.PrivateKey(s.state.PrivateKey).Public().(ed25519.PublicKey)
|
||||
hash := sha256.Sum256(pub)
|
||||
return "node_" + hex.EncodeToString(hash[:]), s.state.Name
|
||||
}
|
||||
|
||||
func (s *Store) Rename(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 64 || strings.ContainsFunc(name, unicode.IsControl) {
|
||||
return errors.New("Название должно содержать от 1 до 64 символов без управляющих знаков")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := s.state
|
||||
next.Name = name
|
||||
if err := s.write(next); err != nil {
|
||||
return errors.New("Не удалось сохранить название")
|
||||
}
|
||||
s.state = next
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TailscaleStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
State string `json:"state"`
|
||||
Online bool `json:"online"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
type boundedProviderOutput struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedProviderOutput) Write(data []byte) (int, error) {
|
||||
if b.Len()+len(data) > 1024*1024 {
|
||||
return 0, errors.New("provider status too large")
|
||||
}
|
||||
return b.Buffer.Write(data)
|
||||
}
|
||||
|
||||
func ReadTailscale() TailscaleStatus {
|
||||
value := TailscaleStatus{State: "not_installed", Addresses: []string{}}
|
||||
if _, err := os.Stat("/usr/bin/tailscale"); err != nil {
|
||||
return value
|
||||
}
|
||||
value.Installed = true
|
||||
value.State = "unavailable"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/tailscale", "status", "--json", "--peers=false")
|
||||
// Do not request peer inventory or expose auth URLs, user identities, keys,
|
||||
// provider diagnostics or profile objects in the product API.
|
||||
var output boundedProviderOutput
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return value
|
||||
}
|
||||
return parseTailscale(output.Bytes())
|
||||
}
|
||||
|
||||
func parseTailscale(data []byte) TailscaleStatus {
|
||||
value := TailscaleStatus{Installed: true, State: "unavailable", Addresses: []string{}}
|
||||
if len(data) > 1024*1024 {
|
||||
return value
|
||||
}
|
||||
var raw struct {
|
||||
BackendState string
|
||||
TailscaleIPs []string
|
||||
Self *struct{ Online bool }
|
||||
}
|
||||
if json.Unmarshal(data, &raw) != nil {
|
||||
return value
|
||||
}
|
||||
switch raw.BackendState {
|
||||
case "Running", "Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState":
|
||||
value.State = raw.BackendState
|
||||
default:
|
||||
return value
|
||||
}
|
||||
value.Online = raw.BackendState == "Running" && raw.Self != nil && raw.Self.Online
|
||||
for _, address := range raw.TailscaleIPs {
|
||||
if ip, err := netip.ParseAddr(address); err == nil {
|
||||
value.Addresses = append(value.Addresses, ip.String())
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailscaleDoesNotExposeProviderCredentialsOrPeers(t *testing.T) {
|
||||
status := parseTailscale([]byte(`{"BackendState":"Running","TailscaleIPs":["100.64.0.10","invalid"],"Self":{"Online":true,"PublicKey":"synthetic-key"},"AuthURL":"https://login.tailscale.com/a/synthetic","User":{"1":{"LoginName":"synthetic@example.test"}},"Peer":{"synthetic":{"HostName":"another-computer"}}}`))
|
||||
if !status.Online || status.State != "Running" || len(status.Addresses) != 1 {
|
||||
t.Fatalf("wrong connection status: %+v", status)
|
||||
}
|
||||
encoded, _ := json.Marshal(status)
|
||||
for _, forbidden := range []string{"synthetic", "AuthURL", "User", "Peer", "PublicKey"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("provider data leaked: %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailscaleDoesNotClaimUnknownOrOfflineConnection(t *testing.T) {
|
||||
for _, input := range []string{`{`, `null`, `{}`, `{"BackendState":"FutureState","Self":{"Online":true}}`} {
|
||||
got := parseTailscale([]byte(input))
|
||||
if got.Online || got.State != "unavailable" {
|
||||
t.Fatalf("unknown state was accepted: %+v", got)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{"Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState"} {
|
||||
got := parseTailscale([]byte(`{"BackendState":"` + state + `","Self":{"Online":true}}`))
|
||||
if got.Online || got.State != state {
|
||||
t.Fatalf("not connected: %+v", got)
|
||||
}
|
||||
}
|
||||
if parseTailscale([]byte(`{"BackendState":"Running","Self":{"Online":false}}`)).Online {
|
||||
t.Fatal("offline peer shown as connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderOutputIsBounded(t *testing.T) {
|
||||
var buffer boundedProviderOutput
|
||||
if _, err := buffer.Write(make([]byte, 1024*1024+1)); err == nil || buffer.Len() != 0 {
|
||||
t.Fatal("oversized provider output accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailscaleStatusRequiresLocalLoginBeforeProbe(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
probes := 0
|
||||
s.Tailscale = func() TailscaleStatus {
|
||||
probes++
|
||||
return TailscaleStatus{State: "not_installed", Addresses: []string{}}
|
||||
}
|
||||
if call(s, "GET", "/api/network/tailscale", "", nil).Code != 401 || probes != 0 {
|
||||
t.Fatal("unauthenticated provider probe")
|
||||
}
|
||||
if call(s, "GET", "/api/network/tailscale", "", login(t, s)).Code != 200 || probes != 1 {
|
||||
t.Fatal("authenticated status unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Node-managed public keys supplement existing per-user authorized_keys.
|
||||
# The command can only return GUI-enrolled Ed25519 keys for local sudo users.
|
||||
AuthorizedKeysCommand /usr/lib/mission-core-node/node-agent ssh-keys %u
|
||||
AuthorizedKeysCommandUser mission-core-node
|
||||
PermitEmptyPasswords no
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/lib/mission-core-node/node-agent authorize
|
||||
@@ -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")
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic Debian package on macOS/Linux from reviewed artifacts.
|
||||
|
||||
No install operation, sudo, container, package-manager mutation or network I/O.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.3.0"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
def desktop_icon(brand):
|
||||
"""Give desktop loaders a square canvas without distorting the brand mark.
|
||||
|
||||
The canonical SVG remains an unchanged nested document. Its default
|
||||
xMidYMid meet preserves the mark's aspect ratio inside this square viewport.
|
||||
Explicit intrinsic dimensions also keep GTK's pixbuf square.
|
||||
"""
|
||||
if hashlib.sha256(brand).hexdigest() != BRAND_SHA256:
|
||||
raise ValueError("Brand mark differs from the admitted Design Guideline asset")
|
||||
return (b'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" '
|
||||
b'viewBox="0 0 256 256" preserveAspectRatio="xMidYMid meet">\n'
|
||||
+ brand + b'</svg>\n')
|
||||
|
||||
|
||||
def tarball(files):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive:
|
||||
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
|
||||
for name in sorted(directories):
|
||||
item = tarfile.TarInfo(name + "/")
|
||||
item.type, item.mode = tarfile.DIRTYPE, 0o755
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item)
|
||||
for name, data, mode in sorted(files):
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item, io.BytesIO(data))
|
||||
return gzip.compress(stream.getvalue(), mtime=0)
|
||||
|
||||
|
||||
def ar_member(name, data):
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
|
||||
assert len(header) == 60
|
||||
return header + data + (b"\n" if len(data) % 2 else b"")
|
||||
|
||||
|
||||
def build(binary, destination):
|
||||
payload = binary.read_bytes()
|
||||
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
|
||||
raise ValueError("Expected a Linux amd64 ELF binary")
|
||||
p = ROOT / "packaging"
|
||||
control = f"""Package: mission-core-node
|
||||
Version: {VERSION}
|
||||
Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, openssh-server, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)]
|
||||
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
|
||||
files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)]
|
||||
brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes()
|
||||
files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644))
|
||||
for source, path, mode in [
|
||||
("launcher.py", "usr/bin/mission-core-node", 0o755),
|
||||
("authorize", "usr/lib/mission-core-node/authorize", 0o755),
|
||||
("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644),
|
||||
("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644),
|
||||
("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644),
|
||||
("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644),
|
||||
("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644),
|
||||
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
|
||||
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
|
||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||
]:
|
||||
files.append((path, (p / source).read_bytes(), mode))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
digest = hashlib.sha256(archive).hexdigest()
|
||||
destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n")
|
||||
print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--binary", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build(args.binary, args.output)
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/python3
|
||||
"""Standalone GTK application. Only the fixed polkit helper runs as root."""
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("WebKit2", "4.1")
|
||||
from gi.repository import Gio, GLib, Gtk, WebKit2
|
||||
|
||||
ORIGIN = "http://127.0.0.1:8780"
|
||||
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=[A-Za-z0-9_-]{43}")
|
||||
|
||||
|
||||
def local_url(uri):
|
||||
try:
|
||||
u = urlsplit(uri)
|
||||
return (u.scheme, u.hostname, u.port) == ("http", "127.0.0.1", 8780) and not u.username and not u.password
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def authorize(development_socket=None):
|
||||
if development_socket:
|
||||
# Engineering-only, unprivileged service. Cannot read the deployed
|
||||
# service's protected Unix socket and never grants OS privileges.
|
||||
connection = http.client.HTTPConnection("local", timeout=5)
|
||||
connection.sock = socket.socket(socket.AF_UNIX)
|
||||
connection.sock.settimeout(5)
|
||||
try:
|
||||
connection.sock.connect(development_socket)
|
||||
connection.request("POST", "/login", headers={"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise ValueError("Local authorization failed")
|
||||
uri = json.loads(response.read(1024))["url"]
|
||||
finally:
|
||||
connection.close()
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/pkexec", "/usr/lib/mission-core-node/authorize"],
|
||||
check=True, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
uri = result.stdout.strip()
|
||||
if not LOGIN.fullmatch(uri):
|
||||
raise ValueError("Unexpected launcher response")
|
||||
return uri
|
||||
|
||||
|
||||
class NodeApplication(Gtk.Application):
|
||||
def __init__(self, development_socket=None):
|
||||
super().__init__(application_id="org.nodedc.MissionCoreNode", flags=Gio.ApplicationFlags.FLAGS_NONE)
|
||||
self.development_socket = development_socket
|
||||
self.window = None
|
||||
self.pending = False
|
||||
self.initial_login = False
|
||||
self.cancelled_downloads = set()
|
||||
|
||||
def do_activate(self):
|
||||
if self.window:
|
||||
self.window.present()
|
||||
return
|
||||
self.window = Gtk.ApplicationWindow(application=self)
|
||||
self.window.set_title("Mission Core Node")
|
||||
self.window.set_default_size(1100, 780)
|
||||
self.window.set_icon_name("org.nodedc.MissionCoreNode")
|
||||
context = WebKit2.WebContext.new_ephemeral()
|
||||
context.connect("download-started", self.download_started)
|
||||
self.view = WebKit2.WebView.new_with_context(context)
|
||||
self.view.get_settings().set_enable_developer_extras(False)
|
||||
self.view.connect("context-menu", lambda *_: True)
|
||||
self.view.connect("decide-policy", self.decide_policy)
|
||||
self.view.connect("permission-request", self.deny_permission)
|
||||
self.view.connect("load-failed", self.load_failed)
|
||||
self.view.connect("load-changed", self.loaded)
|
||||
self.view.connect("web-process-terminated", self.process_failed)
|
||||
manager = self.view.get_user_content_manager()
|
||||
manager.add_script(WebKit2.UserScript.new(
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true})});",
|
||||
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
|
||||
))
|
||||
manager.register_script_message_handler("node")
|
||||
manager.connect("script-message-received::node", self.message)
|
||||
self.window.add(self.view)
|
||||
self.window.connect("destroy", self.destroyed)
|
||||
self.window.show_all()
|
||||
self.view.load_uri(ORIGIN)
|
||||
|
||||
def loaded(self, _view, event):
|
||||
if event == WebKit2.LoadEvent.FINISHED and not self.initial_login:
|
||||
self.initial_login = True
|
||||
self.login()
|
||||
|
||||
def destroyed(self, *_):
|
||||
self.window = None
|
||||
|
||||
def message(self, _manager, result):
|
||||
if not local_url(self.view.get_uri() or ""):
|
||||
return
|
||||
action = result.get_js_value().to_string()
|
||||
if action == "authorize":
|
||||
self.login()
|
||||
elif action in ("install-tailscale", "connect-tailscale"):
|
||||
self.network_action(action)
|
||||
|
||||
def network_action(self, action):
|
||||
if self.pending:
|
||||
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
|
||||
return
|
||||
self.pending = True
|
||||
def work():
|
||||
value = {"action": action, "ok": False}
|
||||
try:
|
||||
# The action is selected from the allowlist above. No command,
|
||||
# URL, credential, path or network option is accepted from JS.
|
||||
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/" + action],
|
||||
capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
|
||||
else:
|
||||
result = json.loads(process.stdout)
|
||||
if not isinstance(result, dict) or type(result.get("ok")) is not bool:
|
||||
raise ValueError("Unexpected helper response")
|
||||
value["ok"] = result["ok"]
|
||||
if result.get("url"):
|
||||
uri = result["url"]
|
||||
if not re.fullmatch(r"https://login\.tailscale\.com/a/[A-Za-z0-9_-]{1,256}", uri):
|
||||
raise ValueError("Unexpected login destination")
|
||||
# Keep the provider credential inside the native process;
|
||||
# no auth URL is persisted or returned to the web API/JS.
|
||||
value["login_uri"] = uri
|
||||
if not value["ok"]:
|
||||
value["error"] = str(result.get("error", "Настройка Tailscale не завершена."))[:1024]
|
||||
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
value = {"action": action, "ok": False, "error": "Не удалось выполнить настройку Tailscale. Повторите действие."}
|
||||
GLib.idle_add(self.network_result, value)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def network_result(self, value, completed=True):
|
||||
if completed:
|
||||
self.pending = False
|
||||
uri = value.pop("login_uri", None)
|
||||
if not self.window:
|
||||
return False
|
||||
if uri:
|
||||
try:
|
||||
Gio.AppInfo.launch_default_for_uri(uri, None)
|
||||
value["browser_opened"] = True
|
||||
except GLib.Error:
|
||||
value["ok"] = False
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход."
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return False
|
||||
|
||||
def login(self):
|
||||
if self.pending:
|
||||
return
|
||||
self.pending = True
|
||||
def work():
|
||||
try:
|
||||
uri = authorize(self.development_socket)
|
||||
GLib.idle_add(self.login_ready, uri)
|
||||
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос Ubuntu.")
|
||||
finally:
|
||||
GLib.idle_add(self.login_finished)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def login_ready(self, uri):
|
||||
if self.window:
|
||||
self.view.load_uri(uri)
|
||||
return False
|
||||
|
||||
def login_finished(self):
|
||||
self.pending = False
|
||||
return False
|
||||
|
||||
def problem(self, message):
|
||||
if not self.window:
|
||||
return False
|
||||
dialog = Gtk.MessageDialog(transient_for=self.window, modal=True,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.CLOSE,
|
||||
text="Mission Core Node")
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.connect("response", lambda d, _: d.destroy())
|
||||
dialog.show()
|
||||
return False
|
||||
|
||||
def load_failed(self, _view, _event, uri, _error):
|
||||
if local_url(uri):
|
||||
self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.")
|
||||
return True
|
||||
|
||||
def process_failed(self, *_):
|
||||
self.problem("Окно приложения остановилось. Закройте и повторно откройте Mission Core Node. Служба борта продолжает работать отдельно.")
|
||||
|
||||
def deny_permission(self, _view, permission):
|
||||
permission.deny()
|
||||
return True
|
||||
|
||||
def decide_policy(self, _view, decision, kind):
|
||||
if kind in (WebKit2.PolicyDecisionType.NAVIGATION_ACTION, WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION):
|
||||
uri = decision.get_navigation_action().get_request().get_uri()
|
||||
if kind == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or not local_url(uri):
|
||||
decision.ignore()
|
||||
return True
|
||||
elif kind == WebKit2.PolicyDecisionType.RESPONSE:
|
||||
uri = decision.get_request().get_uri()
|
||||
if not local_url(uri):
|
||||
decision.ignore()
|
||||
return True
|
||||
if urlsplit(uri).path == "/api/report" and decision.get_response().get_status_code() == 200:
|
||||
decision.download()
|
||||
return True
|
||||
return False
|
||||
|
||||
def download_started(self, _context, download):
|
||||
uri = download.get_request().get_uri()
|
||||
if not local_url(uri) or urlsplit(uri).path != "/api/report":
|
||||
download.cancel()
|
||||
return
|
||||
download.connect("decide-destination", self.download_destination)
|
||||
download.connect("failed", self.download_failed)
|
||||
|
||||
def download_failed(self, download, _error):
|
||||
if download in self.cancelled_downloads:
|
||||
self.cancelled_downloads.discard(download)
|
||||
return False
|
||||
return self.problem("Не удалось сохранить отчёт.")
|
||||
|
||||
def download_destination(self, download, _suggested):
|
||||
chooser = Gtk.FileChooserNative.new("Сохранить отчёт", self.window,
|
||||
Gtk.FileChooserAction.SAVE, "Сохранить", "Отмена")
|
||||
chooser.set_current_name("mission-core-node-report.json")
|
||||
chooser.set_do_overwrite_confirmation(True)
|
||||
if chooser.run() == Gtk.ResponseType.ACCEPT:
|
||||
download.set_allow_overwrite(True)
|
||||
download.set_destination(Path(chooser.get_filename()).as_uri())
|
||||
else:
|
||||
self.cancelled_downloads.add(download)
|
||||
download.cancel()
|
||||
chooser.destroy()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
|
||||
arguments = parser.parse_args()
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit("Run the desktop application as your normal Ubuntu user")
|
||||
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Mission Core Node
|
||||
Comment=Настройка и диагностика бортового компьютера
|
||||
Exec=/usr/bin/mission-core-node
|
||||
Icon=org.nodedc.MissionCoreNode
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node local device host
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-node
|
||||
Group=mission-core-node
|
||||
ExecStart=/usr/lib/mission-core-node/node-agent
|
||||
StateDirectory=mission-core-node
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=mission-core-node
|
||||
RuntimeDirectoryMode=0700
|
||||
UMask=0077
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
LimitNOFILE=1024
|
||||
TasksMax=64
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/python3
|
||||
"""Fixed polkit operations for the optional Tailscale provider. Never a shell API."""
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import stat
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
TAILSCALE = "/usr/bin/tailscale"
|
||||
RELEASE = Path("/usr/share/mission-core-node/tailscale-release.json")
|
||||
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
|
||||
TRANSPORT_DIRECTORY = Path("/etc/systemd/system/tailscaled.service.d")
|
||||
TRANSPORT_NAME = "60-mission-core-node-https.conf"
|
||||
TRANSPORT_CONFIG = b"# Mission Core Node: provider control transport; preserve on Node removal.\n[Service]\nEnvironment=TS_FORCE_NOISE_443=true\n"
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def login_url(value):
|
||||
if not isinstance(value, str) or len(value) > 512:
|
||||
return False
|
||||
try:
|
||||
u = urlsplit(value)
|
||||
return (u.scheme == "https" and u.netloc == "login.tailscale.com"
|
||||
and not u.query and not u.fragment
|
||||
and re.fullmatch(r"/a/[A-Za-z0-9_-]+", u.path) is not None)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def status():
|
||||
result = subprocess.run([TAILSCALE, "status", "--json", "--peers=false"],
|
||||
env=ENV, capture_output=True, text=True, timeout=8)
|
||||
if result.returncode or len(result.stdout) > 1024 * 1024:
|
||||
raise SetupError("Служба Tailscale пока не отвечает. Подождите и повторите подключение.")
|
||||
value = json.loads(result.stdout)
|
||||
if not isinstance(value, dict):
|
||||
raise SetupError("Не удалось прочитать состояние Tailscale.")
|
||||
return value
|
||||
|
||||
|
||||
def checked(command):
|
||||
# Do not kill dpkg mid-transaction if the desktop window is closed. APT has
|
||||
# bounded network/lock waits; the fixed root process completes independently.
|
||||
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других установок Ubuntu, затем повторите.")
|
||||
|
||||
|
||||
def control_transport():
|
||||
"""Use the pinned provider's HTTPS underlay on networks that stall port 80.
|
||||
|
||||
Only a Node-owned systemd drop-in is written; keys, profiles, DNS, routes
|
||||
and other provider settings are never edited. An active connected provider
|
||||
is left untouched by callers. Never replace a custom file at our path.
|
||||
"""
|
||||
TRANSPORT_DIRECTORY.mkdir(mode=0o755, exist_ok=True)
|
||||
info = TRANSPORT_DIRECTORY.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SetupError("Небезопасные права каталога службы Tailscale. Требуется проверить настройку системы.")
|
||||
destination = TRANSPORT_DIRECTORY / TRANSPORT_NAME
|
||||
if destination.is_symlink():
|
||||
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
|
||||
if destination.exists():
|
||||
info = destination.stat()
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022
|
||||
or destination.read_bytes() != TRANSPORT_CONFIG):
|
||||
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
|
||||
return
|
||||
# Root-only operation lock serializes our own setup. Publish a complete
|
||||
# file atomically; systemd must never see a half-written configuration.
|
||||
with tempfile.NamedTemporaryFile(dir=TRANSPORT_DIRECTORY, prefix=".node-https-", delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
try:
|
||||
output.write(TRANSPORT_CONFIG)
|
||||
output.flush()
|
||||
os.fchmod(output.fileno(), 0o644)
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
checked(["/usr/bin/systemctl", "daemon-reload"])
|
||||
|
||||
|
||||
def https_transport_active():
|
||||
result = subprocess.run(["/usr/bin/systemctl", "show", "--property=MainPID", "--value", "tailscaled.service"],
|
||||
env=ENV, capture_output=True, text=True, timeout=5)
|
||||
pid = result.stdout.strip()
|
||||
if result.returncode or not re.fullmatch(r"[1-9][0-9]{0,9}", pid):
|
||||
return False
|
||||
try:
|
||||
# Read only to check this one nonsensitive flag; never emit the process
|
||||
# environment (which can contain unrelated administrator credentials).
|
||||
return b"TS_FORCE_NOISE_443=true" in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def install():
|
||||
if not Path(TAILSCALE).exists():
|
||||
release = json.loads(RELEASE.read_text())
|
||||
expected = release["sha256"]
|
||||
url = release["url"]
|
||||
if (not isinstance(expected, str) or not isinstance(url, str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", expected)
|
||||
or not re.fullmatch(r"https://pkgs\.tailscale\.com/stable/tailscale_[0-9.]+_amd64\.deb", url)):
|
||||
raise SetupError("Повреждены сведения об установочном пакете Tailscale.")
|
||||
with tempfile.TemporaryDirectory(prefix="mission-core-tailscale-", dir="/var/tmp") as directory:
|
||||
package = Path(directory) / "tailscale.deb"
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with urllib.request.urlopen(url, timeout=30) as response, package.open("xb") as output:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > 64 * 1024 * 1024:
|
||||
raise SetupError("Размер пакета Tailscale не соответствует ожидаемому.")
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
raise SetupError("Контрольная сумма Tailscale не совпала. Пакет не установлен; повторите загрузку.")
|
||||
checked(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1",
|
||||
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30",
|
||||
"--no-remove", "--no-install-recommends", "install", "-y", str(package)])
|
||||
control_transport()
|
||||
# The vendor package may already have started its daemon during APT.
|
||||
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
|
||||
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
if not Path(TAILSCALE).is_file():
|
||||
raise SetupError("Установщик завершился, но Tailscale не найден.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def connect_command(state):
|
||||
if state == "Stopped":
|
||||
# Up with absolutely no flags is the upstream preserve-all-preferences
|
||||
# resume operation. Even --json counts as a flag in the pinned CLI.
|
||||
return [TAILSCALE, "up"]
|
||||
if state == "NeedsLogin":
|
||||
# Fresh onboard setup keeps the current LAN DNS/routes. No exit node,
|
||||
# route advertisement, Tailscale SSH, reset, or forced reauthentication.
|
||||
return [TAILSCALE, "up", "--json", "--timeout=12s", "--accept-dns=false", "--accept-routes=false"]
|
||||
raise SetupError("Tailscale ещё запускается. Подождите и повторите подключение.")
|
||||
|
||||
|
||||
def connect():
|
||||
if not Path(TAILSCALE).is_file():
|
||||
raise SetupError("Сначала установите Tailscale через приложение.")
|
||||
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
current = status()
|
||||
state = current.get("BackendState")
|
||||
if state in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
control_transport()
|
||||
if not https_transport_active():
|
||||
checked(["/usr/bin/systemctl", "daemon-reload"])
|
||||
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
|
||||
if not https_transport_active():
|
||||
raise SetupError("Другие настройки службы мешают восстановить соединение Tailscale. Они сохранены; требуется проверить конфигурацию системы.")
|
||||
current = status()
|
||||
state = current.get("BackendState")
|
||||
if state in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
# A pending provider login is reused; never force a second authentication.
|
||||
if state == "NeedsLogin" and login_url(current.get("AuthURL")):
|
||||
return {"ok": True, "url": current["AuthURL"]}
|
||||
try:
|
||||
result = subprocess.run(connect_command(state), env=ENV, capture_output=True, timeout=18)
|
||||
success = result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
success = False
|
||||
# Read the daemon's actual outcome, not the CLI's progress text. AuthURL and
|
||||
# any other provider credentials are never written to disk or the journal.
|
||||
current = status()
|
||||
if current.get("BackendState") in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
if login_url(current.get("AuthURL")):
|
||||
return {"ok": True, "url": current["AuthURL"]}
|
||||
if success:
|
||||
return {"ok": True}
|
||||
raise SetupError("Tailscale не завершил подключение. Проверьте интернет и повторите; существующие нестандартные настройки требуют отдельной проверки.")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or sys.argv[1:] not in (["install"], ["connect"]):
|
||||
raise SystemExit("Use the installed application and its system authorization dialog")
|
||||
os.environ.clear()
|
||||
os.environ.update(ENV)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
directory = Path("/run/mission-core-node-system")
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
info = directory.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
|
||||
raise SetupError("Небезопасные права системного каталога Node. Требуется восстановить установку.")
|
||||
fd = os.open(directory / "tailscale.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise SetupError("Операция с Tailscale уже выполняется. Подождите и обновите состояние.")
|
||||
result = install() if sys.argv[1] == "install" else connect()
|
||||
except SetupError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
result = {"ok": False, "error": "Не удалось завершить настройку Tailscale. Проверьте подключение к интернету и повторите."}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>NODE.DC</vendor>
|
||||
<action id="org.nodedc.mission-core-node.open">
|
||||
<description>Open Mission Core Node</description>
|
||||
<description xml:lang="ru">Открыть Mission Core Node</description>
|
||||
<message>Authenticate to manage this onboard computer.</message>
|
||||
<message xml:lang="ru">Подтвердите доступ к управлению этим бортовым компьютером.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/authorize</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.install-tailscale">
|
||||
<description>Install Tailscale for Mission Core Node</description>
|
||||
<description xml:lang="ru">Установить Tailscale для Mission Core Node</description>
|
||||
<message>Install the verified Tailscale package and enable its system service.</message>
|
||||
<message xml:lang="ru">Установить проверенный пакет Tailscale и включить его системную службу.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/install-tailscale</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.connect-tailscale">
|
||||
<description>Connect this computer to Tailscale</description>
|
||||
<description xml:lang="ru">Подключить борт к Tailscale</description>
|
||||
<message>Enable Tailscale and open its sign-in page if authentication is required.</message>
|
||||
<message xml:lang="ru">Включить Tailscale и открыть страницу входа, если требуется авторизация.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/connect-tailscale</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
rm "$mc_node_ssh_snippet"
|
||||
else
|
||||
mc_node_saved_snippet=$(mktemp /etc/ssh/sshd_config.d/mission-core-node-removed.XXXXXX)
|
||||
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
|
||||
fi
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
systemctl stop mission-core-node.service
|
||||
systemctl disable mission-core-node.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1.102.3",
|
||||
"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
|
||||
"sha256": "88e1b0319da94a52ea409a1a5935e4e7215065a25cd99bc509b6dcbb73737fae",
|
||||
"checksum_source": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb.sha256"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Security/continuity checks. No installation, OS mutation, or real login."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import network_helper as helper
|
||||
|
||||
|
||||
class NetworkHelperTests(unittest.TestCase):
|
||||
def test_only_provider_login_destinations_are_accepted(self):
|
||||
self.assertTrue(helper.login_url("https://login.tailscale.com/a/synthetic-login"))
|
||||
for value in [None, "http://login.tailscale.com/a/x", "https://login.tailscale.com.evil.test/a/x",
|
||||
"https://login.tailscale.com@evil.test/a/x", "https://login.tailscale.com/a/x?q=x",
|
||||
"https://login.tailscale.com/a/../admin", "https://login.tailscale.com/a/x#fragment",
|
||||
"https://login.tailscale.com:443/a/x", "file:///tmp/x"]:
|
||||
self.assertFalse(helper.login_url(value), value)
|
||||
|
||||
def test_resume_keeps_existing_preferences_and_new_login_keeps_lan(self):
|
||||
self.assertEqual(helper.connect_command("Stopped"), [helper.TAILSCALE, "up"])
|
||||
fresh = helper.connect_command("NeedsLogin")
|
||||
self.assertIn("--accept-dns=false", fresh)
|
||||
self.assertIn("--accept-routes=false", fresh)
|
||||
for flag in ("--reset", "--force-reauth", "--ssh", "--advertise-routes", "--exit-node"):
|
||||
self.assertFalse(any(arg.startswith(flag) for arg in fresh))
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.connect_command("Unknown")
|
||||
|
||||
def test_checksum_failure_never_reaches_apt_or_service_mutation(self):
|
||||
original_tempdir = tempfile.TemporaryDirectory
|
||||
with original_tempdir() as directory:
|
||||
release = Path(directory) / "release.json"
|
||||
release.write_text(json.dumps({"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
|
||||
"sha256": hashlib.sha256(b"expected").hexdigest()}))
|
||||
with patch.object(helper, "TAILSCALE", str(Path(directory) / "missing")), \
|
||||
patch.object(helper, "RELEASE", release), \
|
||||
patch.object(helper.tempfile, "TemporaryDirectory", side_effect=lambda **kwargs: original_tempdir(dir=directory)), \
|
||||
patch.object(helper.urllib.request, "urlopen", return_value=io.BytesIO(b"tampered")), \
|
||||
patch.object(helper, "checked") as mutation:
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.install()
|
||||
mutation.assert_not_called()
|
||||
|
||||
def test_existing_provider_is_not_reinstalled(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper.urllib.request, "urlopen") as download, \
|
||||
patch.object(helper, "checked") as mutation:
|
||||
self.assertTrue(helper.install()["ok"])
|
||||
download.assert_not_called()
|
||||
mutation.assert_called_once_with(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
|
||||
def test_cli_timeout_uses_daemon_outcome_and_does_not_retry_login(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", return_value=True), \
|
||||
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "NeedsLogin", "AuthURL": "https://login.tailscale.com/a/synthetic"}]), \
|
||||
patch.object(helper.subprocess, "run", side_effect=helper.subprocess.TimeoutExpired("tailscale", 18)) as run:
|
||||
self.assertEqual(helper.connect(), {"ok": True, "url": "https://login.tailscale.com/a/synthetic"})
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_connected_provider_is_never_reconfigured(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "status", return_value={"BackendState": "Running"}), \
|
||||
patch.object(helper, "control_transport") as transport:
|
||||
self.assertEqual(helper.connect(), {"ok": True})
|
||||
transport.assert_not_called()
|
||||
|
||||
def test_transport_recovery_uses_saved_profile_without_login_when_possible(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked") as system, \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", side_effect=[False, True]), \
|
||||
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "Running"}]), \
|
||||
patch.object(helper.subprocess, "run") as cli:
|
||||
self.assertEqual(helper.connect(), {"ok": True})
|
||||
self.assertIn(unittest.mock.call(["/usr/bin/systemctl", "restart", "tailscaled.service"]), system.call_args_list)
|
||||
cli.assert_not_called()
|
||||
|
||||
def test_transport_conflict_does_not_start_another_login(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", return_value=False), \
|
||||
patch.object(helper, "status", return_value={"BackendState": "NeedsLogin"}), \
|
||||
patch.object(helper.subprocess, "run") as cli:
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.connect()
|
||||
cli.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Mission Core Node</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+1244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@nodedc/mission-core-node-ui",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,18 @@
|
||||
import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import type { Status } from "./api";
|
||||
export function NetworkView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса этого компьютера.">
|
||||
{value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
|
||||
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
|
||||
}
|
||||
export function DevicesView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Подключённые по USB" description="Устройства, которые обнаружила операционная система." actions={<StatusBadge>{value.host.usb.length}</StatusBadge>}>
|
||||
{!value.host.usb_readable ? <p className="node-note" role="status">Не удалось получить список устройств. Повторите обновление.</p> : value.host.usb.length === 0 ? <p className="node-note">Подключите устройство к USB, затем обновите список.</p> : <ResourceList aria-label="USB-устройства">{value.host.usb.map(device => <li key={device.port}><ResourceRow icon={<Icon name="camera" />} title={device.product || `USB ${device.vendor}:${device.product_id}`} description={`Порт ${device.port} · ${device.speed_mbps ? `${device.speed_mbps} Мбит/с` : "Скорость недоступна"}`} metadata={`${device.vendor}:${device.product_id}`} status={<StatusBadge>Обнаружено</StatusBadge>} /></li>)}</ResourceList>}
|
||||
</SettingsCard><p className="node-note">Обнаружение USB ещё не означает готовность к съёмке.</p></div>;
|
||||
}
|
||||
export function DiagnosticsView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Диагностический отчёт" description="Сведения о системе и доступности компонентов без имени компьютера, сетевых адресов и ID ноды.">
|
||||
<Button icon={<Icon name="download" />} onClick={() => location.assign("/api/report")}>Скачать отчёт</Button>
|
||||
<p className="node-note">Сведения получены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
|
||||
</SettingsCard><SettingsCard title="Замечания системы">{value.host.warnings.length ? value.host.warnings.map(warning => <p className="node-note" key={warning}><StatusBadge tone="warning">{warning}</StatusBadge></p>) : <p className="node-note">При последнем сборе сведений замечаний нет.</p>}</SettingsCard></div>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
|
||||
import { request, type Status } from "./api";
|
||||
|
||||
const memory = (value: number | null) => value === null ? "Недоступно" : `${(value / 1048576).toFixed(1)} ГиБ`;
|
||||
export function NodeOverview({ value, refresh, failure }: { value: Status; refresh: () => Promise<void>; failure: (error: unknown) => void }) {
|
||||
const [name, setName] = useState(value.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => setName(value.name), [value.name]);
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault(); if (saving) return; setSaving(true);
|
||||
try { await request("/api/name", "PUT", { name }); await refresh(); } catch (error) { failure(error); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="node-content">
|
||||
<SettingsCard title={value.name} eyebrow="БОРТОВОЙ КОМПЬЮТЕР" description={value.host.hostname} actions={<StatusBadge tone="success">Node работает</StatusBadge>}>
|
||||
<dl className="node-facts"><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Логических процессоров</dt><dd>{value.host.cpus}</dd></div><div><dt>Оперативная память</dt><dd>{memory(value.host.memory_kib)}</dd></div><div><dt>Доступно памяти</dt><dd>{memory(value.host.available_kib)}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Название компьютера" description="Название, по которому вы узнаёте этот борт в Node.">
|
||||
<form onSubmit={save} className="node-form" aria-busy={saving}>
|
||||
<TextField label="Название ноды" value={name} maxLength={64} disabled={saving} onChange={event => setName(event.target.value)} autoComplete="off" />
|
||||
<Button type="submit" disabled={saving || !name.trim() || name.trim() === value.name}>{saving ? "Сохраняем…" : "Сохранить"}</Button>
|
||||
</form>
|
||||
<dl className="node-facts"><div><dt>ID ноды</dt><dd>{value.node_id}</dd></div></dl>
|
||||
</SettingsCard>
|
||||
<p className="node-note">Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, StatusBadge } from "@nodedc/ui-react";
|
||||
import { useAccess } from "./useAccess";
|
||||
export function SetupView({ revision, failure, openSSH, openTailnet }: { revision: string; failure: (error: unknown) => void; openSSH: () => void; openTailnet: () => void }) {
|
||||
const { access, loading } = useAccess(revision, failure);
|
||||
return <div className="node-content">
|
||||
<p className="node-note">Компоненты для работы с этим компьютером и его обслуживания.</p>
|
||||
<ResourceList aria-label="Компоненты системы">
|
||||
<li><ResourceRow icon={<Icon name="activity" />} title="Mission Core Node" description="Локальная служба и приложение" status={<StatusBadge tone="success">Работает</StatusBadge>} /></li>
|
||||
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="OpenSSH Server" description="Доступ для обслуживания компьютера" status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={openSSH}>Настроить доступ</Button>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="globe" />} title="Tailscale" description="Подключение к частной сети" actions={<Button onClick={openTailnet}>Открыть</Button>} /></li>
|
||||
</ResourceList>
|
||||
<p className="node-note">OpenSSH устанавливается вместе с Node. Ответ локального сервера не подтверждает подключение с другого компьютера.</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { request } from "./api";
|
||||
import { useAccess, type AccessKey } from "./useAccess";
|
||||
|
||||
export function SystemAccess({ revision, failure, success, adding, closeAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void }) {
|
||||
const { access, loading, refresh } = useAccess(revision, failure);
|
||||
const [user, setUser] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [remove, setRemove] = useState<AccessKey | null>(null);
|
||||
const [detail, setDetail] = useState<AccessKey | null>(null);
|
||||
useEffect(() => { if (access) setUser(current => access.users.includes(current) ? current : access.users[0] ?? ""); }, [access]);
|
||||
useEffect(() => { if (!adding) { setLabel(""); setKey(""); } }, [adding]);
|
||||
async function add(event: React.FormEvent) {
|
||||
event.preventDefault(); if (pending) return; setPending(true);
|
||||
try { await request("/api/access", "POST", { user, label, key: key.trim() }); await refresh(); closeAdd(); success("Доступ устройства добавлен"); }
|
||||
catch (error) { failure(error); } finally { setPending(false); }
|
||||
}
|
||||
return <div className="node-content">
|
||||
<div className="node-section-heading"><p className="node-note">Компьютеры, которым разрешён вход по SSH через Node.</p><StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}</StatusBadge></div>
|
||||
{loading && !access ? <ActivityIndicator label="Получение доверенных устройств" /> : !access ? <p className="node-note">Не удалось загрузить список. Повторите обновление.</p> : <>
|
||||
{access.keys.length === 0 ? <p className="node-note">Доверенных устройств пока нет. Нажмите плюс в шапке, чтобы добавить компьютер.</p> : <ResourceList aria-label="Доверенные SSH-устройства">{access.keys.map(item => <li key={`${item.user}:${item.id}`}><ResourceRow icon={<Icon name="key" />} title={item.label} description={`Пользователь Ubuntu: ${item.user}`} metadata={<span title={item.id}>{item.id}</span>} status={<StatusBadge>Доступ разрешён</StatusBadge>} actions={<><IconButton label={`Сведения: ${item.label}`} onClick={() => setDetail(item)}><Icon name="eye" /></IconButton><IconButton label={`Отозвать доступ: ${item.label}`} onClick={() => setRemove(item)}><Icon name="trash" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа Ubuntu сохраняются.</p>
|
||||
</>}
|
||||
<Window open={adding} title="Добавить доверенное устройство" subtitle="Разрешить компьютеру подключаться к этому борту по SSH" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={() => { if (!pending) closeAdd(); }} footer={<WindowFooterActions><Button disabled={pending} onClick={closeAdd}>Отмена</Button><Button type="submit" form="node-add-ssh" disabled={pending || !access || !key.trim() || !label.trim() || !user}>{pending ? "Добавляем…" : "Разрешить доступ"}</Button></WindowFooterActions>}>
|
||||
{!access ? <p className="node-note">{loading ? "Получаем пользователей Ubuntu…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы Ubuntu. Добавьте пользователя в настройках системы.</p> : <form id="node-add-ssh" className="node-form" onSubmit={add} aria-busy={pending}>
|
||||
<TextField label="Название устройства" placeholder="Например, ноутбук оператора" value={label} maxLength={64} disabled={pending} onChange={event => setLabel(event.target.value)} autoComplete="off" />
|
||||
<Select label="Пользователь Ubuntu" value={user} options={access.users.map(value => ({ value, label: value }))} onChange={setUser} disabled={pending} />
|
||||
<TextAreaField label="Публичный SSH-ключ Ed25519" placeholder="ssh-ed25519 …" value={key} rows={4} disabled={pending} onChange={event => setKey(event.target.value)} autoComplete="off" spellCheck={false} />
|
||||
<p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p>
|
||||
</form>}
|
||||
</Window>
|
||||
<Window open={detail !== null} title={detail?.label ?? "Доверенное устройство"} subtitle="SSH-доступ к этому борту" onClose={() => setDetail(null)}>
|
||||
{detail && <dl className="node-facts"><div><dt>Пользователь Ubuntu</dt><dd>{detail.user}</dd></div><div><dt>Отпечаток</dt><dd>{detail.id}</dd></div><div><dt>Публичный ключ</dt><dd>{detail.public_key}</dd></div></dl>}
|
||||
</Window>
|
||||
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
|
||||
if (!remove) return;
|
||||
try { await request("/api/access", "DELETE", { user: remove.user, id: remove.id }); await refresh(); setRemove(null); success("Доступ устройства отозван"); } catch (error) { failure(error); throw error; }
|
||||
}} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Button, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import { APIError, desktopAction, networkSetupAvailable, request } from "./api";
|
||||
|
||||
interface NetworkStatus { installed: boolean; state: string; online: boolean; addresses: string[] }
|
||||
interface NetworkResult { action: string; ok: boolean; error?: string; browser_opened?: boolean }
|
||||
|
||||
const states: Record<string, string> = {
|
||||
not_installed: "Не установлен", unavailable: "Служба недоступна", NeedsLogin: "Требуется вход",
|
||||
NeedsMachineAuth: "Ожидаем разрешения администратора сети", Stopped: "Отключён", Starting: "Подключаемся", NoState: "Запускается",
|
||||
};
|
||||
|
||||
export function TailnetAccess({ failure, revision: hostRevision }: { failure: (error: unknown) => void; revision: string }) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [value, setValue] = useState<NetworkStatus | null>(null);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function update() {
|
||||
try {
|
||||
const next = await request<NetworkStatus>("/api/network/tailscale");
|
||||
if (active) { setValue(next); if (next.online) setNotice(""); }
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setValue(null);
|
||||
// Service upgrades invalidate local sessions. Use the existing
|
||||
// application login surface instead of hiding 401 as provider failure.
|
||||
if (error instanceof APIError && error.status === 401) failure(error);
|
||||
}
|
||||
} finally { if (active) { setChecked(true); timer = setTimeout(() => void update(), 5000); } }
|
||||
}
|
||||
void update();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
}, [revision, hostRevision, failure]);
|
||||
useEffect(() => {
|
||||
function completed(event: Event) {
|
||||
const result = (event as CustomEvent<NetworkResult>).detail;
|
||||
if (!result || !["install-tailscale", "connect-tailscale"].includes(result.action)) return;
|
||||
setPending(null);
|
||||
if (!result.ok) failure(new Error(result.error ?? "Настройка не завершена. Повторите действие."));
|
||||
setNotice(result.browser_opened ? "Завершите вход в открывшемся браузере. Состояние здесь обновится автоматически." : "");
|
||||
setRevision(value => value + 1);
|
||||
}
|
||||
window.addEventListener("mission-core-network-result", completed);
|
||||
return () => window.removeEventListener("mission-core-network-result", completed);
|
||||
}, [failure]);
|
||||
function perform(action: "install-tailscale" | "connect-tailscale") {
|
||||
if (pending) return;
|
||||
setNotice(""); setPending(action);
|
||||
if (!desktopAction(action)) {
|
||||
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu."));
|
||||
}
|
||||
}
|
||||
const label = !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
|
||||
const canConnect = value?.installed && ["NeedsLogin", "Stopped", "unavailable"].includes(value.state);
|
||||
const supported = networkSetupAvailable();
|
||||
return <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}>
|
||||
<p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</p>
|
||||
{!checked ? <ActivityIndicator label="Проверяем подключение Tailscale" /> : !value ? <p className="node-note">Не удалось получить состояние. Повторная проверка выполняется автоматически.</p> : <>
|
||||
{!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение Ubuntu.</p><Button disabled={pending !== null || !supported} onClick={() => perform("install-tailscale")}>Установить Tailscale</Button></>}
|
||||
{canConnect && <Button disabled={pending !== null || !supported} onClick={() => perform("connect-tailscale")}>{value.state === "NeedsLogin" ? "Войти в Tailscale" : "Подключить Tailscale"}</Button>}
|
||||
{value.state === "NeedsMachineAuth" && <p className="node-note">Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.</p>}
|
||||
{value.addresses.length > 0 && <dl className="node-facts"><div><dt>Адреса в Tailscale</dt><dd>{value.addresses.join(" · ")}</dd></div></dl>}
|
||||
</>}
|
||||
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню Ubuntu. После обновления пакета требуется перезапуск окна.</p>}
|
||||
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне Ubuntu. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне Ubuntu. Проверяем подключение…"} />}
|
||||
{notice && <p className="node-note" role="status">{notice}</p>}
|
||||
</SettingsCard></div>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Status {
|
||||
version: string; node_id: string; name: string;
|
||||
host: {
|
||||
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
|
||||
memory_kib: number | null; available_kib: number | null;
|
||||
networks: { name: string; up: boolean; addresses: string[] }[];
|
||||
usb: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[];
|
||||
usb_readable: boolean; warnings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } }
|
||||
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale";
|
||||
export function networkSetupAvailable(): boolean {
|
||||
return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true;
|
||||
}
|
||||
export function desktopAction(action: DesktopAction): boolean {
|
||||
if (action !== "authorize" && !networkSetupAvailable()) return false;
|
||||
const host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } };
|
||||
const channel = host.webkit?.messageHandlers?.node;
|
||||
if (!channel) return false;
|
||||
channel.postMessage(action);
|
||||
return true;
|
||||
}
|
||||
export function desktopLogin(): boolean { return desktopAction("authorize"); }
|
||||
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function loginFromLaunch(): Promise<void> {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
const token = params.get("login");
|
||||
// Remove the one-use credential from history before any asynchronous work.
|
||||
if (location.hash) history.replaceState(null, "", location.pathname);
|
||||
if (token) await request("/api/session", "POST", { token });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, HeaderWorkspace, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
|
||||
import "@nodedc/tokens/tokens.css";
|
||||
import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
import { desktopLogin } from "./api";
|
||||
import { useNode } from "./useNode";
|
||||
import { roots, views, type RootId, type ViewId } from "./nodeModel";
|
||||
import { NodeOverview } from "./NodeOverview";
|
||||
import { DevicesView, DiagnosticsView, NetworkView } from "./InventoryViews";
|
||||
import { SystemAccess } from "./SystemAccess";
|
||||
import { SetupView } from "./SetupView";
|
||||
import { TailnetAccess } from "./TailnetAccess";
|
||||
import "./node.css";
|
||||
|
||||
function App() {
|
||||
const node = useNode();
|
||||
const { value, pending, locked, refresh, failure } = node;
|
||||
const [root, setRoot] = useState<RootId>("system");
|
||||
const workspace = useApplicationWorkspace<ViewId>({ activeView: "overview" });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
|
||||
// Theme applies to body portals as well as the application shell.
|
||||
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
|
||||
const currentRoot = roots.find(item => item.id === root)!;
|
||||
const currentView = views.find(item => item.id === workspace.activeView);
|
||||
function openView(id: ViewId) { setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
|
||||
function selectRoot(id: RootId) { openView(roots.find(item => item.id === id)!.first); }
|
||||
const content = !value ? null : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} />
|
||||
: workspace.activeView === "network" ? <NetworkView value={value} />
|
||||
: workspace.activeView === "usb" ? <DevicesView value={value} />
|
||||
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
|
||||
: workspace.activeView === "setup" ? <SetupView revision={value.host.collected_at} failure={failure} openSSH={() => openView("ssh")} openTailnet={() => openView("tailscale")} />
|
||||
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
|
||||
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
|
||||
return <>
|
||||
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
|
||||
center={<><HeaderWorkspace monochrome kind="mark" label={value?.name ?? "Mission Core Node"} imageUrl="/nodedc-mark.svg" /><HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value }))} onChange={selectRoot} /></>}
|
||||
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: () => void refresh() }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
|
||||
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
|
||||
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
|
||||
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
|
||||
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
|
||||
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
|
||||
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending, onClick: () => void refresh() }]}>{content}</ApplicationPanel>}
|
||||
stage={<div className="node-stage" aria-busy={pending}>
|
||||
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`${value.host.os} · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
|
||||
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне Ubuntu." : "Не удалось связаться со службой. Повторите подключение."}</p>}
|
||||
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню Ubuntu.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
|
||||
</SettingsCard>}
|
||||
</div>} />
|
||||
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
|
||||
</>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,14 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-primary); font-family: var(--nodedc-font-family); font-size: var(--nodedc-font-size-md); }
|
||||
.node-stage { height: 100%; overflow: auto; padding: var(--nodedc-space-5); }
|
||||
.node-content { display: grid; align-content: start; gap: var(--nodedc-space-5); min-width: 0; }
|
||||
.node-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--nodedc-space-5); margin: 0; font-size: var(--nodedc-font-size-sm); }
|
||||
.node-facts > div { min-width: 0; }
|
||||
.node-facts dt { color: var(--nodedc-text-muted); margin-bottom: var(--nodedc-space-2); }
|
||||
.node-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.node-section-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--nodedc-space-3); justify-content: space-between; }
|
||||
.node-form { display: grid; gap: var(--nodedc-space-4); }
|
||||
.node-form > button { justify-self: start; }
|
||||
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
|
||||
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IconName } from "@nodedc/ui-react";
|
||||
export type RootId = "system" | "devices" | "remote";
|
||||
export type ViewId = "overview" | "network" | "setup" | "diagnostics" | "usb" | "tailscale" | "ssh";
|
||||
export const roots: { id: RootId; label: string; first: ViewId }[] = [
|
||||
{ id: "system", label: "Состояние системы", first: "overview" },
|
||||
{ id: "devices", label: "Устройства", first: "usb" },
|
||||
{ id: "remote", label: "Удалённый контроль", first: "tailscale" },
|
||||
];
|
||||
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
|
||||
{ id: "overview", root: "system", label: "Обзор компьютера", icon: "activity" },
|
||||
{ id: "network", root: "system", label: "Сеть", icon: "network" },
|
||||
{ id: "setup", root: "system", label: "Конфигурация системы", icon: "settings" },
|
||||
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },
|
||||
{ id: "usb", root: "devices", label: "USB-устройства", icon: "camera" },
|
||||
{ id: "tailscale", root: "remote", label: "Tailscale", icon: "globe" },
|
||||
{ id: "ssh", root: "remote", label: "SSH · доверенные устройства", icon: "key" },
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { request } from "./api";
|
||||
export interface AccessKey { id: string; user: string; label: string; public_key: string }
|
||||
export interface Access { users: string[]; keys: AccessKey[]; ssh_ready: boolean }
|
||||
export function useAccess(revision: string, failure: (error: unknown) => void) {
|
||||
const [access, setAccess] = useState<Access | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setAccess(await request<Access>("/api/access")); }
|
||||
catch (error) { setAccess(null); failure(error); throw error; }
|
||||
finally { setLoading(false); }
|
||||
}, [failure]);
|
||||
useEffect(() => { void refresh().catch(() => {}); }, [revision, refresh]);
|
||||
return { access, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type ToastItem } from "@nodedc/ui-react";
|
||||
import { APIError, loginFromLaunch, request, type Status } from "./api";
|
||||
|
||||
export function useNode() {
|
||||
const [value, setValue] = useState<Status | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const failure = useCallback((error: unknown) => {
|
||||
if (error instanceof APIError && error.status === 401) { setValue(null); setLocked(true); }
|
||||
setToasts([{ id: "request", tone: "error", title: error instanceof Error ? error.message : "Нода недоступна", durationMs: null }]);
|
||||
}, []);
|
||||
const refresh = useCallback(async () => {
|
||||
setPending(true);
|
||||
try { const result = await request<Status>("/api/status"); setValue(result); setLocked(false); setToasts([]); }
|
||||
catch (error) { failure(error); }
|
||||
finally { setPending(false); }
|
||||
}, [failure]);
|
||||
useEffect(() => {
|
||||
const launch = () => { void loginFromLaunch().then(refresh).catch(error => { failure(error); setPending(false); }); };
|
||||
launch(); window.addEventListener("hashchange", launch);
|
||||
return () => window.removeEventListener("hashchange", launch);
|
||||
}, [refresh, failure]);
|
||||
const success = useCallback((title: string) => setToasts([{ id: "request", tone: "success", title }]), []);
|
||||
return { value, pending, locked, refresh, failure, success, toasts,
|
||||
dismiss: (id: string) => setToasts(items => items.filter(item => item.id !== id)) };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
test("Node UI uses canonical controls and never exposes vendor commands", () => {
|
||||
for (const file of readdirSync(new URL("../src/", import.meta.url)).filter(name => name.endsWith(".tsx"))) {
|
||||
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /<(button|input|select|textarea)\b/, file);
|
||||
assert.doesNotMatch(source, /mqtt|Quick Connect|BLE_UUID|openapi_key|192\.168\.68\.50|dcsudo/, file);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext",
|
||||
"moduleResolution": "bundler", "jsx": "react-jsx", "strict": true,
|
||||
"skipLibCheck": true, "noEmit": true, "esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
// Design Guideline packages are linked during development. Their own React
|
||||
// must never become a second hook dispatcher in the portable production bundle.
|
||||
resolve: { dedupe: ["react", "react-dom"] },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist
|
||||
var Assets embed.FS
|
||||
@@ -0,0 +1,300 @@
|
||||
# Mission Core Node: первый запуск борта
|
||||
|
||||
Основание: MISSIONCOR-76 и согласованные комментарии UI-FIRST / BRIDGE-ONLY.
|
||||
Документ описывает реализацию первого приращения; тело архитектурной карточки
|
||||
остаётся исходной точкой и не изменяется этим документом.
|
||||
|
||||
Ниже сохранена история bootstrap 0.1–0.2.3. Текущая композиция, результаты
|
||||
проверки 0.3.0 и актуальные ограничения описаны в
|
||||
[02_NODE_DESKTOP_SURFACE.md](02_NODE_DESKTOP_SURFACE.md).
|
||||
|
||||
## Пользовательская задача и размещение
|
||||
|
||||
Оператор устанавливает Node на Ubuntu и открывает приложение, чтобы убедиться,
|
||||
что служба запущена, назвать борт и проверить фактические сетевые/USB подключения.
|
||||
Первичная сущность — этот бортовой компьютер; его ID переживает смену имени,
|
||||
IP и перезагрузку. Сетевой интерфейс и обнаруженный USB не означают готовность
|
||||
сенсора к съёмке. Обновление списка ничего не отправляет устройствам.
|
||||
|
||||
Выбрана самостоятельная локальная поверхность Node, уже запрошенная владельцем:
|
||||
ApplicationShell с предметным обзором в stage. Альтернатива — только инспектор
|
||||
в разделе «Парк» Control Station — не решает первый запуск без сопряжённого Core.
|
||||
Навигация Core и его существующие рабочие пространства не меняются. Это доменная
|
||||
композиция из существующих ApplicationShell, AppHeader, HeaderWorkspace,
|
||||
GlassSurface, TextField, Button, StatusBadge, ActivityIndicator и ToastStack;
|
||||
новые общие компоненты или визуальные примитивы не вводятся. Лабораторная
|
||||
композиция не используется. Отдельных окон/expanded modes у этого обзора нет.
|
||||
|
||||
Действия: запуск через меню ОС, подтверждение штатного диалога администратора,
|
||||
чтение состояния, переименование с сохранением, обновление, выгрузка очищенного
|
||||
отчёта, ввод и отзыв публичного SSH-ключа для локального администратора.
|
||||
Состояния: ожидание, доступ разрешён, сеанс отсутствует/истёк, служба
|
||||
недоступна, частично недоступная инвентаризация, сохранение, ошибка записи.
|
||||
Ошибки запросов принадлежат ToastStack; фиктивной готовности и кнопок будущих
|
||||
подключений нет. Обновление не показывает устаревшие данные как новые.
|
||||
|
||||
## Граница первого пакета
|
||||
|
||||
`apps/node-agent` — отдельное собираемое приложение внутри текущего монорепозитория.
|
||||
Go-служба работает от непривилегированного системного пользователя. systemd
|
||||
запускает её после перезагрузки; состояние в `/var/lib/mission-core-node`.
|
||||
Установленное приложение открывается в собственном окне GTK со встроенным
|
||||
WebKit, без внешней вкладки браузера и адресной строки. Общие React-компоненты
|
||||
дизайн-системы отображаются внутри этого окна. Внутренний UI-служебный канал
|
||||
доступен только через `127.0.0.1:8780` на борту. Это локальный UI
|
||||
Node, а не второй экземпляр Control Station на 8000 и не endpoint сопряжения.
|
||||
Адреса удалённых Node не принимаются этим локальным API.
|
||||
|
||||
Установка — `.deb` через графический установщик пакетов Ubuntu. При сборке
|
||||
в бинарник включены JS/CSS, включая зависимости дизайн-системы; на борту не
|
||||
нужны Node.js, npm, Go, исходники Core или соседний checkout дизайн-системы.
|
||||
Системный установщик разрешает зависимости и запрашивает права через штатный UI.
|
||||
По уточнению владельца «Конфигурация системы» включает установку OpenSSH Server
|
||||
как обязательной зависимости, проверку `sshd -t`, включение `ssh.service` и
|
||||
автозапуска. Интерфейс проверяет ответ SSH-2.0 на loopback: это проверка процесса,
|
||||
не доказательство входа с другого компьютера. Публичные ключи Ed25519 вводятся
|
||||
через TextAreaField, пользователь выбирается через Select, отзыв подтверждается
|
||||
ConfirmationModal. Эти существующие общие компоненты дополняют список выше.
|
||||
|
||||
Ключи Node дополняют обычные Ubuntu authorized_keys через фиксированный,
|
||||
принадлежащий root AuthorizedKeysCommand. Он читает только реестр публичных
|
||||
ключей; отдельные процессы запускаются от пользователя службы, не root.
|
||||
Регистрация ограничена существующими локальными пользователями группы sudo,
|
||||
root исключён. Строки authorized_keys с command/options и приватные ключи не
|
||||
принимаются. Выданные строки ограничены частными LAN/Tailscale адресами через
|
||||
`from=`. Отзыв в Node не закрывает существующие SSH-сессии и не удаляет отдельные
|
||||
ключи/методы входа Ubuntu. SSH не становится транспортом управления сенсорами.
|
||||
Владелец открыл файл `.deb` версии 0.1.1, установил его через GUI и нашёл Node
|
||||
в меню приложений. На Mini подтверждены статус installed, запуск окна из
|
||||
`/usr/bin/mission-core-node`, активные и включённые службы Node/SSH. Это ещё не
|
||||
полная приёмка на чистой Ubuntu: SSH ранее подготовлен для инженерного доступа,
|
||||
проверка перезагрузки и отзыва отдельного SSH-ключа через Node ещё не пройдена.
|
||||
|
||||
Ярлык запускает непривилегированное окно приложения. Оно вызывает фиксированный
|
||||
polkit helper, который только запрашивает одноразовый минутный допуск у службы
|
||||
по Unix-сокету с правами 0600. URL с допуском загружается внутри окна приложения.
|
||||
WebKit использует временный профиль; переходы на другие адреса и дополнительные
|
||||
окна запрещены. Сохранение отчёта открывает системный диалог выбора файла.
|
||||
Закрытие окна не завершает службу борта. Токен передаётся во fragment и
|
||||
удаляется из истории до запроса. API обменивает его один раз на HttpOnly,
|
||||
SameSite=Strict cookie на 8 часов. После перезапуска сеансы прекращаются.
|
||||
Host/Origin/fetch-metadata проверки препятствуют управлению через чужой сайт.
|
||||
Никаких ручных ключей, паролей в конфигурации или SSH для оператора.
|
||||
|
||||
Ed25519 identity создаётся при первом запуске и хранится с правами 0600. При
|
||||
повреждённой identity запуск останавливается, другая identity молча не создаётся.
|
||||
ID — полный SHA-256 публичного ключа. Это идентичность Node, не новый контракт
|
||||
идентичности устройств: Plugin SDK v0alpha2 остаётся источником device contract.
|
||||
Настройка имени не меняет ключ. Экспорт отчёта не содержит ключей, сессий, ID ноды,
|
||||
hostname и сетевых адресов. Инвентаризация не читает USB serial.
|
||||
|
||||
## Что остаётся реализовать по базовой карточке
|
||||
|
||||
Этот пакет не является завершённым Node v1. Отдельными вертикальными сценариями
|
||||
идут сопряжение Core с одноразовым приглашением и mTLS, Plugin SDK transport, D455, Linux K1 Bridge, управление
|
||||
секретами устройств через UI, запись/импорт, WebRTC и восстановление.
|
||||
Не добавлять их как пустые маршруты, готовые галочки или mock-устройства.
|
||||
K1 Quick остаётся в прежнем лабораторном Mac-пути, не в Node.
|
||||
K1 подключается только по Wi-Fi в режиме Bridge к общей локальной сети;
|
||||
проводное подключение K1 не входит в план. D455 подключается к борту по USB.
|
||||
|
||||
## Инженерный доступ к текущему Mini
|
||||
|
||||
Владелец разрешил ручной SSH bootstrap для текущей разработки и размещение
|
||||
полного репозитория в `Загрузки/NDC/MISSION_CORE` на Mini. Этот путь относится
|
||||
к рабочему checkout, а не к обязательным путям устанавливаемого продукта.
|
||||
Передача исходников и пакетов идёт по SSH/SFTP. Приватный ключ оператора остаётся
|
||||
на его компьютере. Доступ обычного пользователя по SSH и административное
|
||||
повышение прав проверяются отдельно: наличие группы sudo не означает, что
|
||||
`sudo` доступен без подтверждения. Инженерный launcher может использовать
|
||||
приватный сокет пользовательской службы; это не проверка установки с нуля.
|
||||
|
||||
Первый вход установил фактические Ubuntu 24.04.4 LTS amd64 и ядро
|
||||
7.0.0-31-generic. Совместимость D455 следует проверять на этом ядре, не выводить
|
||||
её из номера LTS. На Mini подтверждена D455 (8086:0b5c), подключение 5000 Мбит/с,
|
||||
интерфейсы uvcvideo и usbhid. Владелец подтвердил отдельное окно приложения и
|
||||
RealSense в его списке USB. Это приёмка отображения инвентаризации, не SDK/потоков.
|
||||
После установки ярлык `org.nodedc.MissionCoreNode.desktop` совпадает с GTK
|
||||
application ID для корректной регистрации окна в меню и панели Ubuntu.
|
||||
Инженерный запуск из checkout не считается системной установкой приложения.
|
||||
|
||||
## Приращение 0.2.0: частная сеть и значок приложения
|
||||
|
||||
Владелец запросил чистый фирменный знак для значка Ubuntu. Пакет потребляет
|
||||
`nodedc-mark.svg` из закреплённого Design Guideline; значок, desktop
|
||||
entry и GTK application ID согласованы. Новая визуальная сущность не вводится.
|
||||
|
||||
В существующий обзор добавлена предметная секция «Удалённый доступ · Tailscale»
|
||||
из GlassSurface, StatusBadge, Button и ActivityIndicator; ошибки операций
|
||||
передаются в общий ToastStack. Новый экран или навигационный раздел не создаётся.
|
||||
Оператор устанавливает компонент, подтверждает системное действие Ubuntu и
|
||||
входит в свою сеть на странице провайдера в браузере. Состояние возвращается
|
||||
в окно Node автоматически. Вход в Tailscale не означает сопряжения с Core.
|
||||
|
||||
Служба Node имеет только GET адаптер состояния Tailscale: без peer inventory,
|
||||
учётных записей, AuthURL, ключей и необработанных сообщений провайдера. Изменения
|
||||
выполняются двумя фиксированными root-owned polkit helpers, вызываемыми только
|
||||
нативным мостом окна: установить и подключить. Команды, пути, сетевые параметры
|
||||
и секреты из JavaScript не принимаются. Root helper запускает Python в isolated
|
||||
mode, использует фиксированные команды и отдельную блокировку операций в
|
||||
root-owned каталоге. Установка APT не прерывается закрытием окна посреди транзакции.
|
||||
|
||||
Для нового борта загружается официальный Tailscale 1.102.3 amd64 с закреплённой
|
||||
SHA-256; несовпадение блокирует запуск APT. Пакет устанавливается без удаления
|
||||
других пакетов, служба включается. Существующий установленный Tailscale не
|
||||
переустанавливается; новый APT-репозиторий не добавляется. Первое подключение
|
||||
не принимает DNS и subnet routes из tailnet; выход через exit node, публикация
|
||||
подсетей, Tailscale SSH и принудительная повторная авторизация не включаются.
|
||||
Остановленная существующая сеть возобновляется без флагов изменения настроек.
|
||||
Нестандартная конфликтующая конфигурация не сбрасывается автоматически.
|
||||
|
||||
Допущен только URL входа HTTPS на login.tailscale.com с формой /a/…; он живёт
|
||||
в памяти нативного процесса и открывается обычным браузером пользователя.
|
||||
Node не собирает пароль провайдера и не журналирует его URL входа. API состояния
|
||||
показывает только локальные адреса, состояние провайдера и Online. При закрытии
|
||||
или удалении Node отдельная служба Tailscale сохраняется. Замена провайдера
|
||||
сети не должна менять Node identity, Plugin SDK или локальную жизнь устройств.
|
||||
|
||||
При обновлении 0.1.1 → 0.2.0 требуется повторное открытие окна. Возможность
|
||||
сетевой настройки объявляется нативной оболочкой; старое окно после обновления
|
||||
показывает понятную подсказку вместо неподдерживаемых активных кнопок.
|
||||
|
||||
Приёмка этого приращения пока ожидается на Mini: установить новый файл .deb
|
||||
через GUI, запустить из меню и проверить логотип, выполнить установку
|
||||
Tailscale кнопкой, пройти вход в браузере и увидеть фактическое «В сети» в Node.
|
||||
Отдельно проверить отмену OS-auth, повторное открытие окна и сохранность доступа
|
||||
к прежней LAN. Дальше — перезагрузка и UI-сценарий SSH enrollment/revoke.
|
||||
|
||||
## Выявленные ограничения установки 0.2.0 (2026-09-05)
|
||||
|
||||
Владелец проверил обновление поверх 0.1.1: установленный на Mini App Center
|
||||
revision 1270 показал «установлено» и не предложил обновление локальным файлом.
|
||||
Предыдущая рекомендация просто открыть новый .deb для обновления не прошла
|
||||
пользовательскую проверку. Этот путь нельзя считать готовым; требуется отдельно
|
||||
довести и принять обновление через интерфейс без ручного удаления приложения.
|
||||
|
||||
После успешного удаления через Synaptic повторная установка 0.2.0 останавливалась
|
||||
до распаковки. В журнале App Center зафиксирован PackageKit.cannotGetLock:
|
||||
`/var/lib/dpkg/lock-frontend` занят оставшимся открытым Synaptic. Каждая попытка
|
||||
завершалась отказом примерно через 10 секунд, интерфейс возвращал «Установить».
|
||||
Проверка хеша файла успешна; APT simulation планирует только установку Node 0.2.0,
|
||||
без удаления других пакетов. Это диагностика, не доказательство установки.
|
||||
|
||||
Временная процедура повторного прогона: выйти из Synaptic через «Файл → Выход»,
|
||||
повторить «Установить» в App Center, затем проверить пакет, службу и запуск окна.
|
||||
Не удалять lock-файлы и не прерывать выполняющуюся пакетную транзакцию.
|
||||
В требования пользовательской приёмки входят понятное сообщение о занятом
|
||||
менеджере пакетов, повтор после освобождения блокировки, обновление и удаление
|
||||
через UI. Удаление Node сохраняет identity; переустановка не равна чистой Ubuntu.
|
||||
|
||||
Повтор после закрытия Synaptic успешен: владелец подтвердил установку,
|
||||
PackageKit завершил install-files успешно, dpkg сообщает install ok installed
|
||||
0.2.0, служба Node active. Пакет не требовал пересборки для устранения блокировки.
|
||||
|
||||
## Исправление значка 0.2.1
|
||||
|
||||
Владелец сообщил об искажённых пропорциях иконки после установки 0.2.0.
|
||||
Штатный Gtk.IconTheme на Mini загружал исходный SVG как прямоугольный pixbuf
|
||||
64×38 даже с FORCE_SIZE. В 0.2.1 build_deb.py оборачивает неизменённый брендовый
|
||||
SVG квадратным прозрачным SVG 256×256 с сохранением пропорций. SHA-256 исходного
|
||||
знака по-прежнему проверяется; его геометрия и цвет не меняются.
|
||||
|
||||
Проверка тем же GTK на Mini даёт квадратные pixbuf 32, 48, 64, 128 и 256 пикселей;
|
||||
видимая область на 256×256 — 256×150, что соответствует исходному отношению
|
||||
93.22:54.55 с округлением растеризации. PNG 256×256 визуально проверен.
|
||||
Встроенный UI переиспользуется из проверенной сборки 0.2.0. Бинарник Go собирается
|
||||
с номером текущего пакета через linker flag, чтобы версия в интерфейсе не отставала.
|
||||
Обновление через системное окно авторизации в инженерном
|
||||
сеансе не заменяет ещё не принятую установку/обновление одним файлом для оператора.
|
||||
|
||||
## Исправление транспорта Tailscale 0.2.2
|
||||
|
||||
Две авторизации через браузер регистрировали Mini на стороне Tailscale, но
|
||||
локальная служба оставалась NeedsLogin без адресов и сохранённого профиля.
|
||||
После успешной регистрации machineAuthorized=true следовали повторные тайм-ауты
|
||||
PollNetMap. TCP 80 показывал 1713 байт в Send-Q, повторные передачи и отсутствие
|
||||
подтверждений. Это фактическое незавершённое подключение, а не устаревший статус
|
||||
Node. UDP/IPv4 netcheck успешен. Причина потери пакетов внутри внешней сети
|
||||
не установлена; утверждать конкретного провайдера/фильтр по этим данным нельзя.
|
||||
|
||||
Встроенная диагностика Tailscale debug ts2021 с TS_FORCE_NOISE_443=true успешно
|
||||
прошла TLS, Noise handshake и whoami через TCP 443. Возможность проверена по
|
||||
исходникам установленного Tailscale v1.102.3 control/controlhttp/client.go.
|
||||
|
||||
0.2.2 объединяет квадратный значок и применение HTTPS-транспорта провайдера
|
||||
через /etc/systemd/system/tailscaled.service.d/60-mission-core-node-https.conf.
|
||||
Новая установка компонента применяет настройку перед входом. Явное действие
|
||||
подключения восстанавливает ещё не подключённую службу с этим транспортом;
|
||||
уже работающая или ожидающая одобрения служба не перенастраивается. Реальное
|
||||
окружение daemon проверяется только на нужный несекретный флаг, без вывода
|
||||
окружения. Чужой файл/симлинк/небезопасные права приводят к отказу с сохранением
|
||||
существующих настроек. Удаление Node сохраняет самостоятельную службу Tailscale
|
||||
и её транспорт; ключи, профили, DNS, exit node и маршруты helper не редактирует.
|
||||
|
||||
8 Python-проверок прошли, включая сохранение работающего провайдера,
|
||||
восстановление без нового входа при наличии профиля и отказ при конфликте
|
||||
эффективных настроек. Владелец затем подтвердил реальный вход через UI:
|
||||
«В сети» и адрес Tailscale. Независимый status на Mini — Running, Online=true,
|
||||
адреса назначены; TSMP ping до компьютера оператора идёт напрямую по LAN за 5 мс.
|
||||
Это ещё не приёмка TCP/SSH через tailnet: отдельная попытка SSH не завершилась.
|
||||
Промежуточный пакет 0.2.1 со значком заменён этим кандидатом до установки.
|
||||
|
||||
## Восстановление UI-сеанса 0.2.3 и настройка владельца
|
||||
|
||||
При обновлении службы ранее открытое окно сохраняло старый сеанс. Polling в
|
||||
TailnetAccess поглощал HTTP 401 как общее «Состояние недоступно», хотя причина
|
||||
относилась к доступу в Node. Теперь 401 передаётся существующему обработчику
|
||||
приложения: сведения убираются, показывается штатная поверхность входа.
|
||||
Это не изменение сетевого статуса Tailscale и не обход авторизации.
|
||||
|
||||
Проверка через интерфейс: временный Node 0.2.3-qa на Mac, вход, перезапуск только
|
||||
тестовой службы с сохранением identity и новым набором сеансов, автоматическое
|
||||
появление поверхности входа на очередном polling. Тестовый процесс и вкладка
|
||||
закрыты. TypeScript, Node UI boundary, 4 архитектурные проверки и production build
|
||||
успешны. Пакет 0.2.3 передан на Mini; его подтверждение после обновления и
|
||||
переоткрытия окна фиксируется отдельно от лабораторной проверки.
|
||||
|
||||
Владелец отдельно и явно выбрал отключение запросов административного пароля
|
||||
для своего активного локального сеанса во всей Ubuntu. Подготовлено персональное
|
||||
правило Polkit: конкретный пользователь владельца, local=true, active=true,
|
||||
членство sudo, результат YES. Оно относится к графическим действиям через Polkit;
|
||||
sudoers, SSH-аутентификация, вход в ОС и блокировка экрана не изменяются.
|
||||
Это персональная настройка текущего Mini, не значение по умолчанию пакета Node
|
||||
для остальных операторов. Правило расширяет права приложений локального сеанса;
|
||||
владелец выбрал этот режим после уточнения области действия.
|
||||
|
||||
Сценарий применения находится в Загрузки/NDC/.setup/mc-node-owner-setup.py на Mini:
|
||||
проверяет SHA-256 конкретного .deb, обновляет Node до 0.2.3, проверяет установленную
|
||||
версию и только после этого атомарно публикует root-owned правило Polkit.
|
||||
Чужой файл в целевом пути сохраняется с отказом. После ввода пароля в системном
|
||||
окне и переоткрытия Node владелец 2026-09-05 явно подтвердил через интерфейс:
|
||||
«0.2.3, в сети, без пароля». Это подтверждает версию окна, статус Tailscale и
|
||||
открытие Node без повторного запроса пароля. Все остальные административные
|
||||
действия Ubuntu этим сценарием не проверены. Независимая окончательная проверка
|
||||
установленного пакета и файла правила ещё не выполнена: SSH к Mini обрывается
|
||||
до авторизации. Восстановление инженерного SSH остаётся открытым пунктом.
|
||||
|
||||
## Приёмка с нуля
|
||||
|
||||
Инженерные тесты проверяют защиту API, сохранность identity и очистку отчёта.
|
||||
Они не заменяют следующую пользовательскую приёмку на реальной Ubuntu:
|
||||
|
||||
1. На чистой согласованной Ubuntu без подготовленных SSH/ключей открыть пакет.
|
||||
2. Установить через GUI и найти Mission Core Node в меню приложений.
|
||||
3. Открыть, подтвердить системный запрос, получить фактические данные именно Mini.
|
||||
4. Изменить название; закрыть окно приложения, открыть снова, проверить сохранение и ID.
|
||||
5. Перезагрузить Mini; повторить запуск, проверить автозапуск и неизменный ID.
|
||||
6. Подключить/отключить D455 через USB и обновить список; не выдавать enumeration
|
||||
за рабочий поток и не считать USB 2 достаточным для D455.
|
||||
7. Сохранить и проверить очищенный отчёт через системный диалог; проверить
|
||||
закрытие/повторное открытие окна, повторную авторизацию и истечение сеанса.
|
||||
Отдельно проверить отсутствие доступа к внутреннему адресу через браузер
|
||||
без авторизации и невозможность входа повторным URL.
|
||||
8. Проверить клавиатуру, отмену системного запроса, ошибку службы и повторный запуск.
|
||||
9. Проверить SSH на чистой Ubuntu: установка зависимости, перезагрузка, живой
|
||||
ответ, ввод публичного ключа через UI, реальный вход с частного адреса,
|
||||
отзыв через UI и отказ в новом соединении. Отдельно проверить несовместимую
|
||||
существующую конфигурацию sshd: не подменять её молча и не заявлять доступ
|
||||
только на основании сохранённого ключа.
|
||||
|
||||
До выполнения этих пунктов на Mini первый пакет остаётся кандидатом для проверки.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Node 0.3 — структура настольного приложения
|
||||
|
||||
Решение владельца от 2026-09-05: привести доступные функции Node к визуальной
|
||||
системе действующего Mission Core, повторно использовать шапку, навигацию и
|
||||
длинные строки AI Inference. Это согласование состава вкладок и композиции.
|
||||
Базовые архитектурные требования карточки MISSIONCOR-76 не изменяются.
|
||||
|
||||
## Задача оператора
|
||||
|
||||
На бортовом компьютере проверить систему и оборудование, подключить частную
|
||||
сеть и разрешить обслуживание с нескольких доверенных компьютеров. Сценарий
|
||||
начинается с запуска установленного приложения, используется при первичной
|
||||
настройке и обслуживании. Объекты: этот борт, его интерфейсы/USB и список
|
||||
разрешённых публичных SSH-ключей. Управление съёмкой не добавляется.
|
||||
|
||||
## Размещение
|
||||
|
||||
Выбрана предложенная владельцем структура:
|
||||
|
||||
- Состояние системы: обзор компьютера, сеть, конфигурация системы, диагностика.
|
||||
- Устройства: существующее обнаружение USB.
|
||||
- Удалённый контроль: Tailscale и SSH / доверенные устройства.
|
||||
|
||||
Верхние вкладки — HeaderNavigation. Подразделы — AdminNavigationPanel.
|
||||
Контент — ApplicationPanel в ApplicationShell. Открытие, закрытие, разворот
|
||||
и узкий экран принадлежат useApplicationWorkspace. Общая сетка всех функций
|
||||
отклонена: смешивала инвентаризацию, настройку транспорта и выдачу доступа.
|
||||
Лабораторный отчёт как шаблон Node также отклонён: здесь изменяемые объекты и
|
||||
действия оператора. Из LAB используется только уже существующий список.
|
||||
VPN-профили не появляются пустым пунктом: их провайдер пока не реализован.
|
||||
|
||||
## Повторное использование
|
||||
|
||||
Node уже использовал React и пакеты Design Guideline. Теперь подключён полный
|
||||
канонический shell без конвертации изображения или копии CSS шапки. GTK/WebKit
|
||||
остаётся установленным настольным контейнером; React-сборка входит в .deb.
|
||||
Сервер слушает только loopback. Браузерная QA не заменяет настольную приёмку.
|
||||
|
||||
Компоненты: AppHeader, HeaderWorkspace, HeaderNavigation, HeaderProfile,
|
||||
UserProfileMenu, ApplicationShell, ApplicationPanel, AdminNavigationPanel,
|
||||
SettingsCard, Icon/IconButton/Button, Window/ConfirmationModal, Select,
|
||||
TextField/TextAreaField, ActivityIndicator, StatusBadge, ToastStack.
|
||||
Семантические иконки взяты из registry/icons.json.
|
||||
|
||||
ResourceRow/ResourceList сначала выделены в Design Guideline из существующей
|
||||
геометрии observatory-evidence-card. Добавлены export, registry, документация
|
||||
и пример каталога. Состояния и действия остаются у потребителя. Старые строки
|
||||
AI Inference не меняются этим приращением; их миграция на общий export —
|
||||
отдельное изменение Core. Точные исходники и dist DG хэшируются в provenance,
|
||||
поскольку локальный общий компонент ещё не является опубликованным релизом.
|
||||
|
||||
## Действия и состояния
|
||||
|
||||
SSH: плюс в шапке открывает добавление, выбор существующего администратора,
|
||||
название устройства и вставка публичного Ed25519. Разрешено несколько ключей;
|
||||
приватный ключ остаётся на компьютере оператора. Подробности показывают полный
|
||||
отпечаток и публичный ключ. Отзыв требует ConfirmationModal и запрещает только
|
||||
новые подключения через Node. Статус ключа не означает online компьютера.
|
||||
Локальный ответ sshd не означает успешный удалённый вход.
|
||||
|
||||
Обзор сохраняет имя и показывает системные сведения с временем получения.
|
||||
USB показывает обнаружение, без обещания готовности к съёмке. Tailscale
|
||||
сохраняет действующий поллинг и обработку истёкшего сеанса. Все списки имеют
|
||||
loading, empty, unavailable и recovery через обновление; ошибки — ToastStack.
|
||||
Добавление и удаление блокируются на время запроса. Нет консольных команд и
|
||||
отладочных механизмов в операторском интерфейсе.
|
||||
|
||||
## Приёмка
|
||||
|
||||
Проверить собранный React-интерфейс через UI: каждую вкладку и подраздел,
|
||||
разворот/возврат, добавление/подробности/отмену/отзыв SSH на изолированной QA-ноде,
|
||||
rename, ошибки и повторное открытие, Tailscale online/expired session.
|
||||
Проверить темы и узкий экран; затем новый .deb на Mini, сохранение identity,
|
||||
списка доверенных ключей и текущего входа Tailscale. Реальный удалённый SSH и
|
||||
чистая Ubuntu — самостоятельные пункты приёмки.
|
||||
|
||||
## Результат проверки 2026-09-05
|
||||
|
||||
Пройдены UI-сценарии на изолированной QA-ноде с настоящим API Node и отдельным
|
||||
хранилищем: обзор/сеть/конфигурация/диагностика/USB/Tailscale/SSH, сохранение имени,
|
||||
неверный ключ с сохранением draft, добавление синтетического публичного ключа,
|
||||
полные сведения, Escape с возвратом фокуса, отмена и подтверждение отзыва.
|
||||
Успешная операция заменяет предыдущее сообщение об ошибке. Проверены dark/light,
|
||||
узкое окно 720×800, сворачивание и разворот. После рестарта тестовой службы
|
||||
поллинг Tailscale переводит приложение на вход; ошибочный сетевой статус не
|
||||
подставляется. Первый запрос Tailscale теперь имеет отдельное состояние загрузки.
|
||||
|
||||
При QA исправлены два общих дефекта Design Guideline: на узком экране скрывается
|
||||
expand по типу действия, а не первая кнопка шапки; одноцветные светлые знаки
|
||||
могут явно включить контраст для light theme. Цветные изображения по умолчанию
|
||||
не меняются. Геометрия ResourceRow проверена в обоих режимах.
|
||||
|
||||
Typecheck всех пакетов DG и каталога, registry validation, production build
|
||||
каталога и Node, 4 архитектурных теста Core и Node UI boundary прошли.
|
||||
Готовый .deb установлен на Mini до 0.3.0. После обновления независимо проверены
|
||||
работающий Tailscale, D455 USB3 и запуск нового настольного процесса. Пользовательская
|
||||
визуальная приёмка именно окна Ubuntu запрошена отдельно. Через временный
|
||||
loopback SSH-туннель дополнительно проверен UI именно установленного Mini:
|
||||
версия 0.3.0, реальные сведения о системе, Tailscale «В сети», SSH отвечает
|
||||
локально, реальная D455 со скоростью 5000 Мбит/с. Туннель и QA-процессы закрыты. Все синтетические
|
||||
SSH-ключи находятся только в QA-хранилище и не дают доступа к настоящему Mini.
|
||||
|
||||
Инженерный SSH к Mini снова работает по Tailscale с проверкой прежнего ключа
|
||||
хоста. Прямой LAN-путь не квалифицирован; причина прежнего обрыва не доказана.
|
||||
Чистая установка Ubuntu, перезагрузка борта, GUI updater/uninstaller и реальное
|
||||
зачисление/отзыв ключа оператора остаются отдельными пунктами общей приёмки.
|
||||
Reference in New Issue
Block a user