feat(k1): stabilize LAB bridge and isolate onboard device integration

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 10:31:33 +03:00
parent 020a878915
commit 63ea2bed67
115 changed files with 13700 additions and 988 deletions
@@ -0,0 +1,323 @@
package node
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
type EnrollmentCommand struct {
ID string `json:"operation_id"`
NodeID string `json:"node_id"`
RuntimeID string `json:"runtime_id"`
Action string `json:"action"`
Deadline string `json:"deadline_at"`
Parameters map[string]any `json:"parameters"`
}
// This is the complete journal schema. No command parameters or credentials.
type EnrollmentOperation struct {
ID string `json:"operation_id"`
NodeID string `json:"node_id"`
RuntimeID string `json:"runtime_id"`
Action string `json:"action"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Result map[string]any `json:"result,omitempty"`
Remote bool `json:"remote,omitempty"`
Updated int64 `json:"updated_at"`
}
type DeviceEnrollment struct {
mu sync.Mutex
root string
nodeID string
client *http.Client
operations map[string]*EnrollmentOperation
}
func OpenDeviceEnrollment(root, nodeID string) (*DeviceEnrollment, error) {
dir := filepath.Join(root, "device-enrollment")
if e := os.MkdirAll(dir, 0700); e != nil {
return nil, e
}
d := &DeviceEnrollment{root: dir, nodeID: nodeID, operations: map[string]*EnrollmentOperation{}}
d.client = &http.Client{Timeout: 185 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-k1/driver.sock")
}}}
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
for _, path := range files {
data, e := os.ReadFile(path)
if e != nil {
return nil, e
}
var op EnrollmentOperation
if json.Unmarshal(data, &op) != nil || !operationID.MatchString(op.ID) {
return nil, errors.New("invalid enrollment journal")
}
if op.State == "running" {
op.State = "unknown"
op.Error = "БК перезапущен. Обновите состояние K1 перед новой командой."
}
d.operations[op.ID] = &op
}
return d, nil
}
func (d *DeviceEnrollment) call(ctx context.Context, path string, command any) (map[string]any, error) {
var body io.Reader
method := "GET"
if command != nil {
data, e := json.Marshal(command)
if e != nil {
return nil, e
}
body = bytes.NewReader(data)
method = "POST"
}
request, e := http.NewRequestWithContext(ctx, method, "http://k1"+path, body)
if e != nil {
return nil, e
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Node-Id", d.nodeID)
response, e := d.client.Do(request)
if e != nil {
return nil, errors.New("Служба K1 на БК недоступна.")
}
defer response.Body.Close()
var value map[string]any
if json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&value) != nil || response.StatusCode != 200 {
return nil, errors.New("Действие K1 не подтверждено. Обновите состояние устройства.")
}
return value, nil
}
func (d *DeviceEnrollment) Status() map[string]any {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
value, e := d.call(ctx, "/status", nil)
if e != nil {
value = map[string]any{"available": false}
}
value["node_id"] = d.nodeID
value["name"], _ = os.Hostname()
value["fresh"] = true
return value
}
func (c EnrollmentCommand) valid(nodeID string) bool {
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
if e != nil || !deadline.After(time.Now()) || time.Until(deadline) > 180*time.Second || c.NodeID != nodeID || !operationID.MatchString(c.ID) || c.RuntimeID == "" || len(c.RuntimeID) > 128 {
return false
}
keys := map[string]bool{}
switch c.Action {
case "scan", "networks":
case "connect", "verify":
keys = map[string]bool{"device_id": true, "discovery_generation": true, "mode_revision": true}
id, ok := c.Parameters["device_id"].(string)
if !ok || len(id) < 1 || len(id) > 128 {
return false
}
for _, key := range []string{"discovery_generation", "mode_revision"} {
v, ok := c.Parameters[key].(float64)
if !ok || v < 0 || v != float64(int64(v)) {
return false
}
}
if c.Action == "connect" {
keys["ssid"] = true
keys["password"] = true
for key, limit := range map[string]int{"ssid": 32, "password": 64} {
v, ok := c.Parameters[key].(string)
if !ok || len(v) < 1 || len(v) > limit {
return false
}
}
}
default:
return false
}
if len(keys) != len(c.Parameters) {
return false
}
for key := range c.Parameters {
if !keys[key] {
return false
}
}
return true
}
func copyEnrollment(value *EnrollmentOperation) *EnrollmentOperation {
if value == nil {
return nil
}
data, _ := json.Marshal(value)
var out EnrollmentOperation
_ = json.Unmarshal(data, &out)
return &out
}
func (d *DeviceEnrollment) Submit(c EnrollmentCommand, remote bool) (*EnrollmentOperation, error) {
if !c.valid(d.nodeID) {
return nil, errors.New("Проверьте БК, выбранное устройство и параметры сети.")
}
d.mu.Lock()
if old := d.operations[c.ID]; old != nil {
out := copyEnrollment(old)
d.mu.Unlock()
return out, nil
}
if len(d.operations) >= 2000 {
for id, value := range d.operations {
if value.State != "running" && time.Now().Unix()-value.Updated > 86400 {
if os.Remove(filepath.Join(d.root, id+".json")) == nil {
delete(d.operations, id)
}
}
}
if len(d.operations) >= 2000 {
d.mu.Unlock()
return nil, errors.New("Журнал подключений заполнен. Повторите позже.")
}
}
for _, v := range d.operations {
if v.State == "running" {
d.mu.Unlock()
return nil, errors.New("Дождитесь текущего действия K1.")
}
}
d.mu.Unlock()
if d.Status()["runtime_id"] != c.RuntimeID {
return nil, errors.New("Сеанс K1 изменился. Обновите устройства.")
}
d.mu.Lock()
// Status can yield; repeat admission under the lock before the durable write.
if old := d.operations[c.ID]; old != nil {
out := copyEnrollment(old)
d.mu.Unlock()
return out, nil
}
for _, v := range d.operations {
if v.State == "running" {
d.mu.Unlock()
return nil, errors.New("Дождитесь текущего действия K1.")
}
}
op := &EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: remote, Updated: time.Now().Unix()}
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
d.mu.Unlock()
return nil, e
}
d.operations[c.ID] = op
out := copyEnrollment(op)
d.mu.Unlock()
go d.execute(c)
return out, nil
}
func (d *DeviceEnrollment) execute(c EnrollmentCommand) {
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
result, e := d.call(ctx, "/operation", c)
// Dropping references is best-effort lifetime reduction, not a claim that
// Go can zero every immutable JSON/string copy. No secret is journalled.
delete(c.Parameters, "password")
d.mu.Lock()
defer d.mu.Unlock()
op := d.operations[c.ID]
op.Updated = time.Now().Unix()
op.State = "complete"
op.Result = result
if e != nil {
op.State = "unknown"
op.Error = "Действие K1 не подтверждено. Обновите состояние; повторная команда автоматически не отправляется."
}
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
op.State = "unknown"
op.Error = "Результат не сохранён. Обновите состояние K1."
}
}
func (d *DeviceEnrollment) Get(id string) *EnrollmentOperation {
d.mu.Lock()
defer d.mu.Unlock()
return copyEnrollment(d.operations[id])
}
func (d *DeviceEnrollment) RemoteResults() []*EnrollmentOperation {
d.mu.Lock()
defer d.mu.Unlock()
out := []*EnrollmentOperation{}
for _, op := range d.operations {
if op.Remote {
out = append(out, copyEnrollment(op))
if len(out) == 8 {
break
}
}
}
return out
}
func (d *DeviceEnrollment) Acknowledge(ids []string) {
d.mu.Lock()
defer d.mu.Unlock()
for _, id := range ids {
if op := d.operations[id]; op != nil && op.State != "running" {
op.Remote = false
_ = savePrivateJSON(filepath.Join(d.root, id+".json"), op)
}
}
}
func (d *DeviceEnrollment) Routes(mux *http.ServeMux, server *Server) {
mux.HandleFunc("GET /api/devices/enrollment", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
v := d.Status()
_, name := server.Store.Public()
v["name"] = name
reply(w, 200, v)
})
mux.HandleFunc("POST /api/devices/enrollment/operations", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16384)
var c EnrollmentCommand
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if r.Header.Get("Content-Type") != "application/json" || decoder.Decode(&c) != nil {
reply(w, 400, map[string]string{"error": "Некорректный запрос подключения"})
return
}
op, e := d.Submit(c, false)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 202, op)
})
mux.HandleFunc("GET /api/devices/enrollment/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
op := d.Get(r.PathValue("id"))
if op == nil {
reply(w, 200, map[string]string{"state": "unknown", "error": "Результат недоступен. Обновите состояние K1."})
return
}
reply(w, 200, op)
})
}
@@ -0,0 +1,111 @@
package node
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
type enrollmentHTTP func(*http.Request) (*http.Response, error)
func (f enrollmentHTTP) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func enrollmentRequest() EnrollmentCommand {
return EnrollmentCommand{ID: "op_" + strings.Repeat("a", 32), NodeID: "node-test", RuntimeID: "runtime-test", Action: "connect", Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{"device_id": "test-ble", "mode_revision": float64(0), "discovery_generation": float64(1), "ssid": "test-network", "password": token()}}
}
func TestEnrollmentJournalsNoCredentialAndDispatchesOnce(t *testing.T) {
root := t.TempDir()
d, e := OpenDeviceEnrollment(root, "node-test")
if e != nil {
t.Fatal(e)
}
var posts atomic.Int32
d.client = &http.Client{Transport: enrollmentHTTP(func(r *http.Request) (*http.Response, error) {
body := `{"available":true,"runtime_id":"runtime-test"}`
if r.Method == "POST" {
posts.Add(1)
body = `{"available":true,"connected":true}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})}
c := enrollmentRequest()
secret := c.Parameters["password"].(string)
if _, e = d.Submit(c, true); e != nil {
t.Fatal(e)
}
deadline := time.Now().Add(time.Second)
for d.Get(c.ID).State == "running" && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if d.Get(c.ID).State != "complete" {
t.Fatal("operation did not complete")
}
// A repeated delivery owns no new physical intent, even with a replaced secret.
c.Parameters["password"] = token()
if _, e = d.Submit(c, true); e != nil {
t.Fatal(e)
}
if posts.Load() != 1 {
t.Fatal("duplicate dispatch")
}
journal, e := os.ReadFile(filepath.Join(root, "device-enrollment", c.ID+".json"))
if e != nil {
t.Fatal(e)
}
results, _ := json.Marshal(d.RemoteResults())
if bytes.Contains(journal, []byte(secret)) || bytes.Contains(results, []byte(secret)) || bytes.Contains(journal, []byte("parameters")) {
t.Fatal("credential/parameters entered journal or results")
}
}
func TestEnrollmentRestartDoesNotReplayRunningIntent(t *testing.T) {
root := t.TempDir()
d, e := OpenDeviceEnrollment(root, "node-test")
if e != nil {
t.Fatal(e)
}
c := enrollmentRequest()
op := EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: true}
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
t.Fatal(e)
}
restarted, e := OpenDeviceEnrollment(root, "node-test")
if e != nil {
t.Fatal(e)
}
if restarted.Get(c.ID).State != "unknown" {
t.Fatal("restart did not fence unknown result")
}
restarted.client = &http.Client{Transport: enrollmentHTTP(func(*http.Request) (*http.Response, error) { t.Fatal("restart contacted hardware"); return nil, nil })}
result, e := restarted.Submit(c, true)
if e != nil || result.State != "unknown" {
t.Fatal("repeated intent did not retain unknown outcome")
}
}
func TestEnrollmentRejectsAnotherNodeExpiredAndHostMutation(t *testing.T) {
c := enrollmentRequest()
if !c.valid("node-test") {
t.Fatal("valid intent rejected")
}
if c.valid("another-node") {
t.Fatal("wrong node admitted")
}
c.Parameters["allow_host_wifi_switch"] = true
if c.valid("node-test") {
t.Fatal("host association admitted")
}
delete(c.Parameters, "allow_host_wifi_switch")
c.Deadline = time.Now().Add(-time.Second).Format(time.RFC3339Nano)
if c.valid("node-test") {
t.Fatal("expired operation admitted")
}
}
+15 -14
View File
@@ -39,20 +39,21 @@ type PairState struct {
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
}
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
@@ -282,12 +282,28 @@ func (p *Pairing) channel(ctx context.Context) {
payload["sensor_state"] = inv
payload["sensor_results"] = p.Sensors.RemoteResults()
}
if p.DeviceEnrollment != nil {
payload["device_enrollment"] = p.DeviceEnrollment.Status()
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
p.lastSeen = p.now().Unix()
if p.DeviceEnrollment != nil {
var ack []string
if json.Unmarshal(result["enrollment_acknowledgements"], &ack) == nil {
p.DeviceEnrollment.Acknowledge(ack)
}
var commands []EnrollmentCommand
if json.Unmarshal(result["enrollment_commands"], &commands) == nil {
for _, c := range commands {
_, _ = p.DeviceEnrollment.Submit(c, true)
}
}
}
if p.Sensors != nil {
var ack []string
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
+49 -12
View File
@@ -45,19 +45,20 @@ type SensorOperation struct {
Updated int64 `json:"updated_at"`
}
type Sensors struct {
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
initialized map[string]bool
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
NetworkDevices *DeviceEnrollment
operations map[string]*SensorOperation
names map[string]string
initialized map[string]bool
}
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`)
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
func OpenSensors(root, nodeID string) (*Sensors, error) {
@@ -168,6 +169,32 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
func (s *Sensors) Inventory() map[string]any {
items := []any{}
seen := map[string]bool{}
if s.NetworkDevices != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
result, e := s.NetworkDevices.call(ctx, "/inventory", nil)
cancel()
if e == nil {
if found, ok := result["items"].([]any); ok {
for _, raw := range found {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id, _ := item["id"].(string)
if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) {
continue
}
s.mu.Lock()
if name := s.names[id]; name != "" {
item["name"] = name
}
s.mu.Unlock()
seen[id] = true
items = append(items, item)
}
}
}
}
if result, e := s.driver("/inventory", nil); e == nil {
if found, ok := result["items"].([]any); ok {
for _, v := range found {
@@ -259,6 +286,9 @@ func sensorViewAction(action string) bool {
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") {
return nil, errors.New("Эта операция не поддерживается K1.")
}
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
return nil, errors.New("Некорректная команда устройства.")
}
@@ -344,7 +374,14 @@ func (s *Sensors) execute(c SensorCommand) {
}
} else {
var v map[string]any
v, err = s.driver("/operation", c)
if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil {
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c)
cancel()
} else {
v, err = s.driver("/operation", c)
}
uncertain = err != nil || v["state"] == "unknown"
if err == nil {
if v["state"] == "complete" {
+18 -14
View File
@@ -13,20 +13,21 @@ import (
)
type Server struct {
Store *Store
Pairing *Pairing
Sensors *Sensors
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
Store *Store
Pairing *Pairing
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
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 {
@@ -82,6 +83,9 @@ func (s *Server) Handler() http.Handler {
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
if s.DeviceEnrollment != nil {
s.DeviceEnrollment.Routes(mux, s)
}
if s.Pairing != nil {
s.Pairing.localRoutes(mux, s)
}