502 lines
14 KiB
Go
502 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"rk3588sys/agent/internal/config"
|
|
"rk3588sys/agent/internal/files"
|
|
"rk3588sys/agent/internal/mediaserver"
|
|
"rk3588sys/agent/internal/modelstore"
|
|
"rk3588sys/agent/internal/procctl"
|
|
"rk3588sys/agent/internal/sysinfo"
|
|
)
|
|
|
|
type Server struct {
|
|
agentCfg config.AgentConfig
|
|
ms *mediaserver.Client
|
|
store *modelstore.Store
|
|
proc *procctl.Controller
|
|
deviceID string
|
|
hostname string
|
|
agentPort int
|
|
mediaPort int
|
|
version string
|
|
gitSHA string
|
|
}
|
|
|
|
type InfoResponse struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceName string `json:"device_name"`
|
|
Hostname string `json:"hostname"`
|
|
IP string `json:"ip"`
|
|
AgentPort int `json:"agent_port"`
|
|
MediaPort int `json:"media_port"`
|
|
Version string `json:"version"`
|
|
GitSHA string `json:"git_sha"`
|
|
ConfigPath string `json:"config_path"`
|
|
LastGoodPath string `json:"last_good_path"`
|
|
UptimeSec int64 `json:"uptime_sec"`
|
|
}
|
|
|
|
func New(agentCfg config.AgentConfig, ms *mediaserver.Client, store *modelstore.Store, deviceID string, agentPort int, mediaPort int, version, gitSHA string) http.Handler {
|
|
var pc *procctl.Controller
|
|
if agentCfg.MediaServerProcess.Enable {
|
|
pc = procctl.New(agentCfg)
|
|
}
|
|
s := &Server{
|
|
agentCfg: agentCfg,
|
|
ms: ms,
|
|
store: store,
|
|
proc: pc,
|
|
deviceID: deviceID,
|
|
hostname: sysinfo.Hostname(),
|
|
agentPort: agentPort,
|
|
mediaPort: mediaPort,
|
|
version: version,
|
|
gitSHA: gitSHA,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/info", s.handleInfo)
|
|
mux.HandleFunc("/v1/config", s.handleConfig)
|
|
mux.HandleFunc("/v1/models", s.handleModelsList)
|
|
mux.HandleFunc("/v1/models/", s.handleModelUpload)
|
|
mux.HandleFunc("/v1/media-server/reload", s.handleMediaReload)
|
|
mux.HandleFunc("/v1/media-server/rollback", s.handleMediaRollback)
|
|
mux.HandleFunc("/v1/media-server/start", s.handleMediaStart)
|
|
mux.HandleFunc("/v1/media-server/restart", s.handleMediaRestart)
|
|
mux.HandleFunc("/v1/media-server/stop", s.handleMediaStop)
|
|
mux.HandleFunc("/v1/graphs", s.handleGraphs)
|
|
mux.HandleFunc("/v1/graphs/", s.handleGraphDetail)
|
|
mux.HandleFunc("/v1/logs/recent", s.handleLogsRecent)
|
|
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, false) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
ip := sysinfo.PrimaryIPv4()
|
|
resp := InfoResponse{
|
|
DeviceID: s.deviceID,
|
|
DeviceName: s.agentCfg.DeviceName,
|
|
Hostname: s.hostname,
|
|
IP: ip,
|
|
AgentPort: s.agentPort,
|
|
MediaPort: s.mediaPort,
|
|
Version: s.version,
|
|
GitSHA: s.gitSHA,
|
|
ConfigPath: s.agentCfg.ConfigPath,
|
|
LastGoodPath: s.agentCfg.ConfigPath + ".last_good.json",
|
|
UptimeSec: sysinfo.UptimeSec(),
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/json")
|
|
return
|
|
}
|
|
|
|
const maxConfigBytes = int64(20 << 20)
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxConfigBytes)
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "request body too large") {
|
|
errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large")
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: empty body")
|
|
return
|
|
}
|
|
var tmp any
|
|
if err := json.Unmarshal(body, &tmp); err != nil {
|
|
errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if err := files.WriteFileAtomic(s.agentCfg.ConfigPath, append(body, '\n'), 0o644); err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: write config failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
if err := s.ms.Reload(ctx); err != nil {
|
|
rerr := err
|
|
rbErr := s.ms.Rollback(ctx)
|
|
if rbErr != nil {
|
|
errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: reload failed: %v; rollback failed: %v", rerr, rbErr))
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: reload failed: %v; rollback ok", rerr))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
var modelNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|
|
|
func (s *Server) handleModelUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/octet-stream" {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/octet-stream")
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, "/v1/models/")
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: invalid name")
|
|
return
|
|
}
|
|
if !modelNameRE.MatchString(name) {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: invalid name")
|
|
return
|
|
}
|
|
if r.ContentLength <= 0 {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: missing Content-Length")
|
|
return
|
|
}
|
|
|
|
expected := strings.TrimSpace(r.Header.Get("X-Model-Sha256"))
|
|
if expected != "" {
|
|
if len(expected) != 64 {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: invalid X-Model-Sha256")
|
|
return
|
|
}
|
|
if _, err := hex.DecodeString(expected); err != nil {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: invalid X-Model-Sha256")
|
|
return
|
|
}
|
|
}
|
|
|
|
item, err := s.store.Upload(name, r.Body, r.ContentLength, expected)
|
|
if err != nil {
|
|
if errors.Is(err, modelstore.ErrPayloadTooLarge) {
|
|
errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large")
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "sha256 mismatch") {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: sha256 mismatch")
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"name": item.Name,
|
|
"sha256": item.Sha256,
|
|
"path": item.Path,
|
|
"size": item.Size,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModelsList(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, false) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
m, err := s.store.List()
|
|
if err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
func (s *Server) handleMediaReload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if err := s.ms.Reload(r.Context()); err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleMediaRollback(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if err := s.ms.Rollback(r.Context()); err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
type mediaProcReq struct {
|
|
Config string `json:"config"`
|
|
}
|
|
|
|
func (s *Server) handleMediaStart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if s.proc == nil || !s.proc.Enabled() {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
|
|
req, err := readOptionalJSON[mediaProcReq](w, r, 1<<20)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
st, err := s.proc.Start(req.Config)
|
|
if err != nil {
|
|
if errors.Is(err, procctl.ErrConflict) {
|
|
errorJSON(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, procctl.ErrInvalidConfig) || errors.Is(err, procctl.ErrConfigNotFound) {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, procctl.ErrNotSupported) {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "running": st.Running, "pid": st.Pid, "config_path": st.ConfigPath})
|
|
}
|
|
|
|
func (s *Server) handleMediaRestart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if s.proc == nil || !s.proc.Enabled() {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
|
|
req, err := readOptionalJSON[mediaProcReq](w, r, 1<<20)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
st, err := s.proc.Restart(req.Config)
|
|
if err != nil {
|
|
if errors.Is(err, procctl.ErrInvalidConfig) || errors.Is(err, procctl.ErrConfigNotFound) {
|
|
errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, procctl.ErrNotSupported) {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "running": st.Running, "pid": st.Pid, "config_path": st.ConfigPath})
|
|
}
|
|
|
|
func (s *Server) handleMediaStop(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, true) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if s.proc == nil || !s.proc.Enabled() {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
st, err := s.proc.Stop()
|
|
if err != nil {
|
|
if errors.Is(err, procctl.ErrNotSupported) {
|
|
errorJSON(w, http.StatusNotImplemented, "not supported")
|
|
return
|
|
}
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "running": st.Running, "pid": st.Pid, "config_path": st.ConfigPath})
|
|
}
|
|
|
|
func readOptionalJSON[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, error) {
|
|
var zero T
|
|
if r.Body == nil {
|
|
return zero, nil
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "request body too large") {
|
|
return zero, errors.New("payload too large")
|
|
}
|
|
return zero, fmt.Errorf("invalid json: %v", err)
|
|
}
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
return zero, nil
|
|
}
|
|
|
|
if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" {
|
|
return zero, errors.New("validation failed: Content-Type must be application/json")
|
|
}
|
|
|
|
var v T
|
|
if err := json.Unmarshal(body, &v); err != nil {
|
|
return zero, fmt.Errorf("invalid json: %v", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s *Server) handleGraphs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, false) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
st, b, err := s.ms.GetGraphs(r.Context())
|
|
if err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeRawJSON(w, st, b)
|
|
}
|
|
|
|
func (s *Server) handleGraphDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, false) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, "/v1/graphs/")
|
|
if name == "" {
|
|
errorJSON(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
st, b, err := s.ms.GetGraph(r.Context(), name)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeRawJSON(w, st, b)
|
|
}
|
|
|
|
func (s *Server) handleLogsRecent(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
if !s.authorize(r, false) {
|
|
errorJSON(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
limit := 200
|
|
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
st, b, err := s.ms.GetLogsRecent(r.Context(), limit)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
|
|
return
|
|
}
|
|
writeRawJSON(w, st, b)
|
|
}
|
|
|
|
func (s *Server) authorize(r *http.Request, write bool) bool {
|
|
need := write || s.agentCfg.RequireTokenForRead
|
|
if !need {
|
|
return true
|
|
}
|
|
tok := r.Header.Get("X-RK-Token")
|
|
return tok != "" && tok == s.agentCfg.Token
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeRawJSON(w http.ResponseWriter, status int, raw []byte) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(raw)
|
|
}
|
|
|
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]any{"error": msg})
|
|
}
|