Fix onboard WebKit preview and archive board telemetry locally
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local WebKit requires completed bounded HTTP responses. The same plugin
|
||||
// delivery owns RRD/fMP4 framing, backpressure, resume and acquisition isolation.
|
||||
func (s *Server) localPreviewRoutes(mux *http.ServeMux) {
|
||||
capacity := make(chan struct{}, 2)
|
||||
for _, route := range []string{"/api/devices/preview", "/api/devices/preview/read"} {
|
||||
mux.HandleFunc("POST "+route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var value json.RawMessage
|
||||
if !decode(w, r, &value) {
|
||||
return
|
||||
}
|
||||
if s.DeviceEnrollment == nil {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
path := "/local-preview/read"
|
||||
if r.URL.Path == "/api/devices/preview" {
|
||||
var command SensorCommand
|
||||
if json.Unmarshal(value, &command) != nil || command.Action != "offer" || !sensorID.MatchString(command.Session.DeviceID) || !strings.HasPrefix(command.Session.DeviceID, "k1_") {
|
||||
http.Error(w, "Invalid preview", 400)
|
||||
return
|
||||
}
|
||||
path = "/local-preview"
|
||||
}
|
||||
select {
|
||||
case capacity <- struct{}{}:
|
||||
defer func() { <-capacity }()
|
||||
default:
|
||||
http.Error(w, "Close another viewer", 429)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), "POST", "http://k1"+path, bytes.NewReader(value))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid preview", 400)
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Node-Id", s.DeviceEnrollment.nodeID)
|
||||
client := &http.Client{Transport: s.DeviceEnrollment.client.Transport, Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
http.Error(w, "Preview unavailable", response.StatusCode)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, 196609))
|
||||
if err != nil || len(data) > 196608 {
|
||||
http.Error(w, "Preview unavailable", 503)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.missioncore.preview")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The collector owns sampling/storage. This bounded cache keeps monitoring I/O
|
||||
// out of the paired control heartbeat and out of acquisition lifecycles.
|
||||
type Monitor struct {
|
||||
mu sync.Mutex
|
||||
client *http.Client
|
||||
source string
|
||||
after int64
|
||||
sent int64
|
||||
value map[string]any
|
||||
}
|
||||
|
||||
func NewMonitor() *Monitor {
|
||||
return &Monitor{client: &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-monitor/monitor.sock")
|
||||
}}}}
|
||||
}
|
||||
func (m *Monitor) read(ctx context.Context, path string, body []byte) (map[string]any, error) {
|
||||
method := "GET"
|
||||
if body != nil {
|
||||
method = "POST"
|
||||
}
|
||||
r, e := http.NewRequestWithContext(ctx, method, "http://monitor"+path, bytes.NewReader(body))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
response, e := m.client.Do(r)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errors.New("monitor unavailable")
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(response.Body, 262145))
|
||||
if e != nil || len(data) > 262144 {
|
||||
return nil, errors.New("monitor response budget")
|
||||
}
|
||||
var value map[string]any
|
||||
e = json.Unmarshal(data, &value)
|
||||
return value, e
|
||||
}
|
||||
func (m *Monitor) Run(ctx context.Context) {
|
||||
defer m.client.CloseIdleConnections()
|
||||
timer := time.NewTicker(2 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
status, e := m.read(ctx, "/status", nil)
|
||||
if e == nil {
|
||||
source, _ := status["source_id"].(string)
|
||||
m.mu.Lock()
|
||||
if source != m.source {
|
||||
m.source = source
|
||||
m.after = 0
|
||||
m.sent = 0
|
||||
}
|
||||
after := m.after
|
||||
m.mu.Unlock()
|
||||
batch, be := m.read(ctx, "/batch?after="+strconv.FormatInt(after, 10), nil)
|
||||
if be == nil && batch["source_id"] == source {
|
||||
status["batch"] = batch
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.value = status
|
||||
m.mu.Unlock()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
func (m *Monitor) Snapshot() map[string]any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.value == nil {
|
||||
return map[string]any{"storage": "unavailable"}
|
||||
}
|
||||
if batch, ok := m.value["batch"].(map[string]any); ok {
|
||||
if rows, ok := batch["samples"].([]any); ok {
|
||||
for _, raw := range rows {
|
||||
if row, ok := raw.(map[string]any); ok {
|
||||
if seq, ok := row["seq"].(float64); ok && int64(seq) > m.sent {
|
||||
m.sent = int64(seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.value
|
||||
}
|
||||
func (m *Monitor) Acknowledge(raw json.RawMessage) {
|
||||
var ack struct {
|
||||
Source string `json:"source_id"`
|
||||
After int64 `json:"after"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ack) != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// A receiver may already have a later cursor from before the Node restarted.
|
||||
// Its authenticated ACK is bounded by the collector's durable latest record.
|
||||
latest := m.sent
|
||||
if row, ok := m.value["latest"].(map[string]any); ok {
|
||||
if seq, ok := row["seq"].(float64); ok {
|
||||
latest = int64(seq)
|
||||
}
|
||||
}
|
||||
if ack.Source == m.source && ack.After >= m.after && ack.After <= latest {
|
||||
m.after = ack.After
|
||||
}
|
||||
}
|
||||
func (s *Server) monitorRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/monitor", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
if s.Monitor == nil {
|
||||
reply(w, 503, map[string]string{"error": "Мониторинг недоступен"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, s.Monitor.Snapshot())
|
||||
})
|
||||
mux.HandleFunc("POST /api/monitor/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var value json.RawMessage
|
||||
if !decode(w, r, &value) {
|
||||
return
|
||||
}
|
||||
if len(value) > 2048 || s.Monitor == nil {
|
||||
http.Error(w, "Monitoring unavailable", 503)
|
||||
return
|
||||
}
|
||||
if _, e := s.Monitor.read(r.Context(), "/events", value); e != nil {
|
||||
http.Error(w, "Monitoring unavailable", 503)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonitorAcknowledgementsRequireSameArchiveAndDurableBounds(t *testing.T) {
|
||||
m := NewMonitor()
|
||||
m.source = "archive-one"
|
||||
m.value = map[string]any{"latest": map[string]any{"seq": float64(100)}}
|
||||
for _, raw := range []string{`{"source_id":"another","after":50}`, `{"source_id":"archive-one","after":101}`, `{"source_id":"archive-one","after":-1}`} {
|
||||
m.Acknowledge(json.RawMessage(raw))
|
||||
if m.after != 0 {
|
||||
t.Fatal("accepted invalid cursor")
|
||||
}
|
||||
}
|
||||
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":80}`))
|
||||
if m.after != 80 {
|
||||
t.Fatal("did not restore committed receiver cursor after restart")
|
||||
}
|
||||
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":10}`))
|
||||
if m.after != 80 {
|
||||
t.Fatal("regressed cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAndPreviewCannotBypassNodeSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
for _, path := range []string{"/api/devices/preview", "/api/devices/preview/read", "/api/monitor/events"} {
|
||||
if got := call(s, "POST", path, `{}`, nil).Code; got != 401 {
|
||||
t.Fatalf("%s: %d", path, got)
|
||||
}
|
||||
}
|
||||
if got := call(s, "GET", "/api/monitor", "", nil).Code; got != 401 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
cookie := login(t, s)
|
||||
if got := call(s, "POST", "/api/devices/preview", `{}`, cookie).Code; got != 503 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
s.Monitor = NewMonitor()
|
||||
if got := call(s, "GET", "/api/monitor", "", cookie).Code; got != 200 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
// An unavailable collector remains independent from the authenticated UI.
|
||||
if got := call(s, "GET", "/api/status", "", cookie).Code; got != 200 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type PairState struct {
|
||||
Revocations []CoreBinding `json:"revocations,omitempty"`
|
||||
}
|
||||
type Pairing struct {
|
||||
Monitor *Monitor
|
||||
Sensors *Sensors
|
||||
DeviceEnrollment *DeviceEnrollment
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -286,10 +286,16 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
|
||||
}
|
||||
if p.Monitor != nil {
|
||||
payload["monitor"] = p.Monitor.Snapshot()
|
||||
}
|
||||
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 {
|
||||
if p.Monitor != nil {
|
||||
p.Monitor.Acknowledge(result["monitor_ack"])
|
||||
}
|
||||
p.connection = "online"
|
||||
p.lastSeen = p.now().Unix()
|
||||
if p.DeviceEnrollment != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Monitor *Monitor
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
@@ -81,6 +82,8 @@ func reply(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.localPreviewRoutes(mux)
|
||||
s.monitorRoutes(mux)
|
||||
if s.Presentation != nil {
|
||||
s.presentationRoutes(mux)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user