feat(node): configure system environment through the desktop workflow
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user