feat(node): configure system environment through the desktop workflow

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 19:46:52 +03:00
parent 15bb793e5e
commit 59e14c5bc0
29 changed files with 929 additions and 81 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
func (a *AccessStore) Add(user, label, key string) error {
if !a.allowed(user) {
return errors.New("Выберите существующую учётную запись администратора Ubuntu")
return errors.New("Выберите существующую учётную запись администратора системы")
}
label = strings.TrimSpace(label)
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
@@ -0,0 +1,13 @@
{
"schema": "missioncore.node.environment/v1",
"revision": "ubuntu-24.04-amd64/1",
"steps": [
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
{"id":"tailscale-install","label":"Установка Tailscale","description":"Проверенный пакет и системная служба частной сети","requires":["packages"]}
]
}
@@ -0,0 +1,100 @@
package node
import (
"context"
_ "embed"
"encoding/json"
"io"
"os"
"os/exec"
"time"
)
//go:embed environment-profile.json
var environmentProfile []byte
type EnvironmentRun struct {
Schema string `json:"schema"`
ProfileRevision string `json:"profile_revision"`
RunID string `json:"run_id"`
State string `json:"state"`
StartedAt float64 `json:"started_at"`
UpdatedAt float64 `json:"updated_at"`
Steps []EnvironmentStep `json:"steps"`
}
type EnvironmentStep struct {
ID string `json:"id"`
State string `json:"state"`
Detail string `json:"detail"`
}
type EnvironmentStatus struct {
Profile json.RawMessage `json:"profile"`
Run *EnvironmentRun `json:"run"`
Available bool `json:"available"`
}
func ReadEnvironment() EnvironmentStatus {
result := readEnvironmentFile("/var/lib/mission-core-node-environment/last-run.json")
if result.Run != nil && result.Run.State == "running" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
state, err := exec.CommandContext(ctx, "/usr/bin/systemctl", "show", "--property=ActiveState", "--value", "mission-core-node-environment.service").Output()
if err != nil || (string(state) != "activating\n" && string(state) != "active\n") {
// A stopped job/reboot cannot leave yesterday's spinner running.
result.Run.State = "interrupted"
}
}
return result
}
func readEnvironmentFile(path string) EnvironmentStatus {
result := EnvironmentStatus{Profile: json.RawMessage(environmentProfile), Available: true}
f, err := os.Open(path)
if os.IsNotExist(err) {
return result
}
if err != nil {
result.Available = false
return result
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() > 32768 {
result.Available = false
return result
}
var run EnvironmentRun
decoder := json.NewDecoder(io.LimitReader(f, 32769))
if decoder.Decode(&run) != nil || decoder.Decode(new(any)) != io.EOF || !validEnvironmentRun(run) {
result.Available = false
return result
}
result.Run = &run
return result
}
// Invalid or inconsistent progress cannot turn a failed setup into green UI.
func validEnvironmentRun(run EnvironmentRun) bool {
if run.Schema != "missioncore.node.environment/v1" || run.RunID == "" || len(run.RunID) > 64 || len(run.Steps) == 0 || len(run.Steps) > 32 || run.ProfileRevision == "" {
return false
}
if run.State != "running" && run.State != "complete" && run.State != "error" {
return false
}
seen := map[string]bool{}
for _, step := range run.Steps {
if step.ID == "" || len(step.ID) > 64 || seen[step.ID] || len(step.Detail) > 4096 {
return false
}
seen[step.ID] = true
switch step.State {
case "pending", "running", "complete", "error", "blocked":
default:
return false
}
if run.State == "complete" && step.State != "complete" {
return false
}
}
return true
}
@@ -0,0 +1,51 @@
package node
import (
"os"
"path/filepath"
"testing"
)
func TestEnvironmentStatusIsAuthorizedReadOnly(t *testing.T) {
s := newTestServer(t)
s.Environment = func() EnvironmentStatus { return EnvironmentStatus{Available: true, Profile: environmentProfile} }
if got := call(s, "GET", "/api/environment", "", nil); got.Code != 401 {
t.Fatal(got.Code)
}
cookie := login(t, s)
if got := call(s, "GET", "/api/environment", "", cookie); got.Code != 200 {
t.Fatal(got.Code, got.Body.String())
}
if got := call(s, "POST", "/api/environment", "{}", cookie); got.Code == 200 {
t.Fatal("HTTP can mutate system")
}
}
func TestMissingCorruptAndPartialEnvironmentReports(t *testing.T) {
path := filepath.Join(t.TempDir(), "last-run.json")
result := readEnvironmentFile(path)
if !result.Available || result.Run != nil {
t.Fatal("new install not admitted")
}
samples := []string{
`{broken`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"network-inventory","state":"error"}]}`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"},{"id":"x","state":"complete"}]}`,
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"}]} {}`,
}
for _, sample := range samples {
if err := os.WriteFile(path, []byte(sample), 0600); err != nil {
t.Fatal(err)
}
if got := readEnvironmentFile(path); got.Available || got.Run != nil {
t.Fatal("invalid report admitted", sample)
}
}
if err := os.WriteFile(path, []byte(`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"error","steps":[{"id":"packages","state":"error"},{"id":"ssh-service","state":"blocked"}]}`), 0600); err != nil {
t.Fatal(err)
}
result = readEnvironmentFile(path)
if !result.Available || result.Run == nil || result.Run.State != "error" || result.Run.Steps[1].State != "blocked" {
t.Fatal("failure lost")
}
}
+24 -13
View File
@@ -13,17 +13,18 @@ import (
)
type Server struct {
Store *Store
Assets fs.FS
Origin string
Version string
Inventory func() Inventory
Access *AccessStore
Tailscale func() TailscaleStatus
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
Now func() time.Time
Store *Store
Assets fs.FS
Origin string
Version string
Inventory func() Inventory
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
Now func() time.Time
}
func token() string {
@@ -80,6 +81,16 @@ func (s *Server) Handler() http.Handler {
s.accessRoutes(mux)
}
mux.HandleFunc("POST /api/session", s.login)
mux.HandleFunc("GET /api/environment", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
read := s.Environment
if read == nil {
read = ReadEnvironment
}
reply(w, 200, read())
})
mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
@@ -194,7 +205,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
_, ok := s.logins[body.Token]
delete(s.logins, body.Token)
if !ok {
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню Ubuntu"})
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"})
return
}
if s.sessions == nil {
@@ -224,7 +235,7 @@ func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
defer s.mu.Unlock()
prune(s.sessions, s.now())
if _, ok := s.sessions[c.Value]; !ok {
reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню Ubuntu"})
reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню приложений"})
return false
}
return true