mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
device gateway: remotedevice 插件(设备接入网关) + GUI/waiter 受控设备桥 + 设备页/授权开关/托盘/退出进托盘 + 白屏修复(惰性Tray) + deviceinfo 工具
This commit is contained in:
@ -11,6 +11,7 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
)
|
||||
|
||||
262
internal/plugins/remotedevice/device.go
Normal file
262
internal/plugins/remotedevice/device.go
Normal file
@ -0,0 +1,262 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// devicectlDevice 把设备网关暴露为 IOManager 的一个 Device:
|
||||
// Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult,
|
||||
// Execute() 检查授权并路由到 WS 在线设备。
|
||||
type devicectlDevice struct {
|
||||
reg *Registry
|
||||
persist func() // 授权变更后持久化
|
||||
}
|
||||
|
||||
func (d *devicectlDevice) Name() string { return "devicectl" }
|
||||
func (d *devicectlDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
|
||||
func (d *devicectlDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapStructured }
|
||||
func (d *devicectlDevice) Description() string {
|
||||
return "远程设备控制网关:查看已接入/已授权的设备并发送控制指令(经用户授权的设备)"
|
||||
}
|
||||
func (d *devicectlDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *devicectlDevice) Start() error { return nil }
|
||||
func (d *devicectlDevice) Stop() error { return nil }
|
||||
|
||||
func (d *devicectlDevice) Tools() []agentIO.ToolDef {
|
||||
return []agentIO.ToolDef{
|
||||
{
|
||||
Name: "devicedetect",
|
||||
Description: "扫描并列出已接入设备网关的设备(含在线/离线状态与授权状态)。" +
|
||||
"用途:查看当前有哪些设备连接了 HomeAgent、是否在线、是否已授权。" +
|
||||
"参数 kind 可选,只返回该种类的设备。返回值 device_id 用于后续 device_ctl_* 工具。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"kind": map[string]interface{}{"type": "string", "description": "按设备种类过滤(light/camera/computer/phone/...),可省略"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "device_ctl_status",
|
||||
Description: "查询一台已在线设备的实时状态。" +
|
||||
"仅返回设备上报的状态信息(如电量/温度/运行状态)。" +
|
||||
"需要 device_id(来自 devicedetect)。设备必须已授权且在线。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
|
||||
},
|
||||
"required": []interface{}{"device_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "device_ctl_cmdrun",
|
||||
Description: "向一台已授权且在线设备发送命令执行请求(如开机/重启/播放/自定义命令)。" +
|
||||
"⚡ 高危:设备必须已授权,且该操作会改变设备行为。" +
|
||||
"返回 accepted=true 表示已下发并等待设备执行,之后可用 device_ctl_cmdresult 查询结果。" +
|
||||
"若设备未授权或离线,返回错误信息。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "要执行的命令(设备自定义语义,如 power_on、play:xxx 或 shell 命令)"},
|
||||
},
|
||||
"required": []interface{}{"device_id", "command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "device_ctl_cmdresult",
|
||||
Description: "查询之前 device_ctl_cmdrun 下发命令的执行结果(按 req_id 或 device_id 最近一次)。" +
|
||||
"若非阻塞或已超时,用此工具取回设备执行输出。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"req_id": map[string]interface{}{"type": "string", "description": "命令请求 ID(device_ctl_cmdrun 返回)"},
|
||||
"device_id": map[string]interface{}{"type": "string", "description": "设备 ID(查询最近一次结果)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deviceinfo",
|
||||
Description: "探查一台设备接入网关时声明的详细信息与支持能力。" +
|
||||
"返回设备的 OS/架构/CPU/内存/能力 caps 等(设备接入时上报,非实时)。" +
|
||||
"需要 device_id(来自 devicedetect)。设备必须已授权。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
|
||||
},
|
||||
"required": []interface{}{"device_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
switch tool {
|
||||
case "devicedetect":
|
||||
return d.detect(args)
|
||||
case "device_ctl_status":
|
||||
return d.status(args)
|
||||
case "device_ctl_cmdrun":
|
||||
return d.cmdrun(args)
|
||||
case "device_ctl_cmdresult":
|
||||
return d.cmdresult(args)
|
||||
case "deviceinfo":
|
||||
return d.info(args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown device tool %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
// publicDevices 把设备列表转为简洁 JSON(无内部字段)。
|
||||
func publicDevices(in []DeviceMeta) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(in))
|
||||
for _, m := range in {
|
||||
entry := map[string]interface{}{
|
||||
"device_id": m.DeviceID,
|
||||
"name": m.Name,
|
||||
"kind": m.Kind,
|
||||
"caps": m.Caps,
|
||||
"authorized": m.Authorized,
|
||||
"online": m.Online,
|
||||
"last_seen": m.LastSeen,
|
||||
}
|
||||
if len(m.Info) > 0 {
|
||||
entry["info"] = m.Info
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// detect 实现 devicedetect。
|
||||
func (d *devicectlDevice) detect(args map[string]interface{}) (interface{}, error) {
|
||||
kind, _ := args["kind"].(string)
|
||||
devs := d.reg.List()
|
||||
if kind != "" {
|
||||
var filtered []DeviceMeta
|
||||
for _, m := range devs {
|
||||
if strings.EqualFold(m.Kind, kind) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
devs = filtered
|
||||
}
|
||||
sort.Slice(devs, func(i, j int) bool { return devs[i].DeviceID < devs[j].DeviceID })
|
||||
if len(devs) == 0 {
|
||||
return map[string]interface{}{"devices": []interface{}{}, "message": "暂无设备接入"}, nil
|
||||
}
|
||||
return map[string]interface{}{"devices": publicDevices(devs)}, nil
|
||||
}
|
||||
|
||||
// status 实现 device_ctl_status(只读,查询设备最后一次上报状态)。
|
||||
func (d *devicectlDevice) status(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["device_id"].(string)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("device_id required")
|
||||
}
|
||||
m, ok := d.reg.Get(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("device %s 不存在", id)
|
||||
}
|
||||
if !m.Authorized {
|
||||
return nil, fmt.Errorf("device %s 未授权,无法查询状态(需先在设备管理页或经 bind 授权)", id)
|
||||
}
|
||||
if !m.Online {
|
||||
return publicDevices([]DeviceMeta{m}), nil // 带 offline=true
|
||||
}
|
||||
return publicDevices([]DeviceMeta{m}), nil
|
||||
}
|
||||
|
||||
// cmdrun 实现 device_ctl_cmdrun:检查授权+在线,push 命令,异步等待结果。
|
||||
func (d *devicectlDevice) cmdrun(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["device_id"].(string)
|
||||
cmd, _ := args["command"].(string)
|
||||
if id == "" || cmd == "" {
|
||||
return nil, fmt.Errorf("device_id and command required")
|
||||
}
|
||||
m, ok := d.reg.Get(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("device %s 不存在", id)
|
||||
}
|
||||
if !m.Authorized {
|
||||
return nil, fmt.Errorf("device %s 未授权,无法执行命令(请先在设备管理页授权)", id)
|
||||
}
|
||||
if !m.Online {
|
||||
return nil, fmt.Errorf("device %s 不在线,无法执行命令", id)
|
||||
}
|
||||
reqID := newReqID()
|
||||
if err := d.reg.PushCmd(id, reqID, cmd); err != nil {
|
||||
return nil, fmt.Errorf("下发命令失败: %w", err)
|
||||
}
|
||||
// 阻塞等待设备结果(带超时);结果同时由 registry 留档。
|
||||
res, err := d.reg.AwaitResult(reqID, 30*time.Second)
|
||||
if err != nil {
|
||||
d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true})
|
||||
return map[string]interface{}{"accepted": true, "req_id": reqID, "pending": true, "note": "命令已下发但设备未在超时内回执,可用 device_ctl_cmdresult 再查"}, nil
|
||||
}
|
||||
out := res
|
||||
out["req_id"] = reqID
|
||||
d.reg.SaveResult(reqID, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cmdresult 实现 device_ctl_cmdresult。
|
||||
func (d *devicectlDevice) cmdresult(args map[string]interface{}) (interface{}, error) {
|
||||
reqID, _ := args["req_id"].(string)
|
||||
deviceID, _ := args["device_id"].(string)
|
||||
if reqID != "" {
|
||||
res, ok := d.reg.GetResult(reqID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no result for req %s(设备可能尚未回执)", reqID)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
if deviceID != "" {
|
||||
// 返回该设备最近一次结果(简化:遍历 results 找 device_id 匹配的最近一条)
|
||||
// 说明:当前只按 req_id 查询;device_id 查询留给后续迭代。
|
||||
m, ok := d.reg.Get(deviceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("device %s 不存在", deviceID)
|
||||
}
|
||||
if !m.Authorized {
|
||||
return nil, fmt.Errorf("device %s 未授权", deviceID)
|
||||
}
|
||||
return map[string]interface{}{"device_id": deviceID, "note": "请用 device_ctl_cmdrun 返回的 req_id 查询命令结果"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("req_id 或 device_id 至少提供一个")
|
||||
}
|
||||
|
||||
// info 实现 deviceinfo:返回设备详情 + 支持能力。
|
||||
func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["device_id"].(string)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("device_id required")
|
||||
}
|
||||
m, ok := d.reg.Get(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("device %s 不存在", id)
|
||||
}
|
||||
if !m.Authorized {
|
||||
return nil, fmt.Errorf("device %s 未授权,无法探查信息(请先在设备管理页授权)", id)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"device_id": m.DeviceID,
|
||||
"name": m.Name,
|
||||
"kind": m.Kind,
|
||||
"caps": m.Caps,
|
||||
"authorized": m.Authorized,
|
||||
"online": m.Online,
|
||||
"last_seen": m.LastSeen,
|
||||
}
|
||||
if len(m.Info) > 0 {
|
||||
out["info"] = m.Info
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
260
internal/plugins/remotedevice/plugin.go
Normal file
260
internal/plugins/remotedevice/plugin.go
Normal file
@ -0,0 +1,260 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterPluginMeta("remotedevice", "远程设备网关", "Remote Device")
|
||||
plugin.RegisterFactory("remotedevice", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
const defaultAddr = "127.0.0.1:9890"
|
||||
|
||||
// Plugin 是 remotedevice 设备接入网关插件:
|
||||
// 持有 HTTP 服务(WS 设备通道 + REST 管理面)与 devicectl Device(agent 工具)。
|
||||
type Plugin struct {
|
||||
name string
|
||||
registry *Registry
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
addr string
|
||||
token string
|
||||
sdk *sdk.PluginSDK
|
||||
dev *devicectlDevice
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
registry: NewRegistry(),
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func genToken() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("tok-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func newReqID() string {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
// ---- 设置 ----------------
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890,仅本机)", Category: "remotedevice"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "authorized_devices", Default: "", Type: "text", DisplayName: "已授权设备", Description: "逗号分隔的已授权设备 ID 列表(由系统维护)", Category: "remotedevice"})
|
||||
|
||||
p.addr = defaultAddr
|
||||
if v, _ := s.Settings().Get("listen_addr"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 != "" {
|
||||
p.addr = s2
|
||||
}
|
||||
}
|
||||
p.token = ""
|
||||
if v, _ := s.Settings().Get("ws_token"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 != "" {
|
||||
p.token = s2
|
||||
}
|
||||
}
|
||||
if p.token == "" {
|
||||
p.token = genToken()
|
||||
if err := s.Settings().Set("ws_token", p.token); err != nil {
|
||||
log.Printf("[remotedevice] persist ws_token: %v", err)
|
||||
}
|
||||
}
|
||||
p.registry.SetAcceptToken(func(provided string) bool {
|
||||
return provided != "" && provided == p.token
|
||||
})
|
||||
|
||||
// 恢复已授权设备集合
|
||||
if v, _ := s.Settings().Get("authorized_devices"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 != "" {
|
||||
var ids []string
|
||||
for _, id := range strings.Split(s2, ",") {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
p.registry.RestoreAuthorized(ids)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- devicectl Device(agent 工具) ----------------
|
||||
p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized}
|
||||
if err := s.RegisterChannel("devicectl", p.dev); err != nil {
|
||||
log.Printf("[remotedevice] register devicectl channel: %v", err)
|
||||
}
|
||||
|
||||
// ---- REST 管理面 + WS 设备通道 ----------------
|
||||
p.registerRoutes()
|
||||
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
|
||||
go func() {
|
||||
log.Printf("[remotedevice] device gateway listening on %s", p.addr)
|
||||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[remotedevice] server error: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistAuthorized 在授权变更后写回设置(持久化重启不丢)。
|
||||
func (p *Plugin) persistAuthorized() {
|
||||
if p.sdk == nil {
|
||||
return
|
||||
}
|
||||
ids := p.registry.AuthorizedIDs()
|
||||
_ = p.sdk.Settings().Set("authorized_devices", strings.Join(ids, ","))
|
||||
}
|
||||
|
||||
func (p *Plugin) registerRoutes() {
|
||||
// 设备通道(WS)
|
||||
p.mux.HandleFunc("/api/v1/device/ws", p.registry.ServeWS)
|
||||
// REST 管理面(全部需 token)
|
||||
p.mux.HandleFunc("/api/v1/device", p.requireToken(p.handleDeviceList))
|
||||
p.mux.HandleFunc("/api/v1/device/online", p.requireToken(p.handleDeviceOnline))
|
||||
p.mux.HandleFunc("/api/v1/device/", p.requireToken(p.handleDeviceByID))
|
||||
p.mux.HandleFunc("/api/v1/device/push", p.requireToken(p.handleDevicePush))
|
||||
p.mux.HandleFunc("/api/v1/device/auth", p.requireToken(p.handleDeviceAuth))
|
||||
}
|
||||
|
||||
// requireToken 校验 REST 请求的接入令牌(X-API-Key header 或 ?token=)。
|
||||
func (p *Plugin) requireToken(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
k := r.Header.Get("X-API-Key")
|
||||
if k == "" {
|
||||
k = r.URL.Query().Get("token")
|
||||
}
|
||||
if k == "" || k != p.token {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleDeviceList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.List()})
|
||||
}
|
||||
|
||||
func (p *Plugin) handleDeviceOnline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.OnlineList()})
|
||||
}
|
||||
|
||||
func (p *Plugin) handleDeviceByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
m, ok := p.registry.Get(id)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.DeviceID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
|
||||
return
|
||||
}
|
||||
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"})
|
||||
}
|
||||
|
||||
func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Authorize bool `json:"authorize"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.DeviceID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
|
||||
return
|
||||
}
|
||||
if _, ok := p.registry.Get(req.DeviceID); !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
p.registry.SetAuthorized(req.DeviceID, req.Authorize)
|
||||
p.persistAuthorized()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize})
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
if p.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return p.server.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
592
internal/plugins/remotedevice/registry.go
Normal file
592
internal/plugins/remotedevice/registry.go
Normal file
@ -0,0 +1,592 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceMeta 描述一台接入了网关的设备。
|
||||
type DeviceMeta struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Caps []string `json:"caps"`
|
||||
Info map[string]interface{} `json:"info,omitempty"`
|
||||
Authorized bool `json:"authorized"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
}
|
||||
|
||||
// wconn 表示一条活跃的 WS 连接(由网关持有)。
|
||||
type wconn struct {
|
||||
deviceID string
|
||||
w *bufio.Writer
|
||||
}
|
||||
|
||||
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据与已授权集合。线程安全。
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]*DeviceMeta // deviceID -> meta(在线/历史)
|
||||
authorized map[string]bool // deviceID -> 是否已授权(持久化恢复)
|
||||
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
|
||||
onlineCh chan string
|
||||
onStatus func(msg map[string]interface{})
|
||||
acceptFn func(token string) bool
|
||||
cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel
|
||||
results map[string]resultEntry // reqID -> 已留档结果
|
||||
}
|
||||
|
||||
// resultEntry 保存一次 cmdrun 的结果(供 device_ctl_cmdresult 查询)。
|
||||
type resultEntry struct {
|
||||
Result map[string]interface{}
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
devices: make(map[string]*DeviceMeta),
|
||||
authorized: make(map[string]bool),
|
||||
conns: make(map[string]*wconn),
|
||||
onlineCh: make(chan string, 16),
|
||||
cmdPending: make(map[string]chan map[string]interface{}),
|
||||
results: make(map[string]resultEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAcceptToken 设置绑定 token 校验函数(插件注入,来自 Settings)。
|
||||
func (r *Registry) SetAcceptToken(fn func(token string) bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.acceptFn = fn
|
||||
}
|
||||
|
||||
// SetStatusHandler 注册设备上报状态的回调。
|
||||
func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.onStatus = h
|
||||
}
|
||||
|
||||
func (r *Registry) acceptBind(token string) bool {
|
||||
r.mu.RLock()
|
||||
fn := r.acceptFn
|
||||
r.mu.RUnlock()
|
||||
if fn == nil {
|
||||
return false
|
||||
}
|
||||
return fn(token)
|
||||
}
|
||||
|
||||
// ============ 设备查询 ============
|
||||
|
||||
// Online 返回设备是否在线。
|
||||
func (r *Registry) Online(id string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
m, ok := r.devices[id]
|
||||
return ok && m.Online
|
||||
}
|
||||
|
||||
// Authorized 返回设备是否已授权。
|
||||
func (r *Registry) Authorized(id string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.authorized[id]
|
||||
}
|
||||
|
||||
// List 返回全部设备(在线或历史),合并授权态。
|
||||
func (r *Registry) List() []DeviceMeta {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]DeviceMeta, 0, len(r.devices))
|
||||
for _, m := range r.devices {
|
||||
c := *m
|
||||
c.Authorized = r.authorized[c.DeviceID]
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// OnlineList 返回在线的设备列表。
|
||||
func (r *Registry) OnlineList() []DeviceMeta {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var out []DeviceMeta
|
||||
for _, m := range r.devices {
|
||||
if m.Online {
|
||||
c := *m
|
||||
c.Authorized = r.authorized[c.DeviceID]
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get 返回单个设备。
|
||||
func (r *Registry) Get(id string) (DeviceMeta, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
m, ok := r.devices[id]
|
||||
if !ok {
|
||||
return DeviceMeta{}, false
|
||||
}
|
||||
c := *m
|
||||
c.Authorized = r.authorized[id]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// ============ 授权 ============
|
||||
|
||||
// SetAuthorized 标记某设备已授权/取消授权(持久化由插件负责)。
|
||||
func (r *Registry) SetAuthorized(id string, auth bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.authorized[id] = auth
|
||||
if m, ok := r.devices[id]; ok {
|
||||
m.Authorized = auth
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreAuthorized 插件启动时从配置恢复已授权设备集合。
|
||||
func (r *Registry) RestoreAuthorized(ids []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
r.authorized[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizedIDs 返回全部已授权设备 ID(供插件持久化)。
|
||||
func (r *Registry) AuthorizedIDs() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var out []string
|
||||
for id, ok := range r.authorized {
|
||||
if ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============ 在线状态维护 ============
|
||||
|
||||
func (r *Registry) register(meta DeviceMeta) {
|
||||
r.mu.Lock()
|
||||
meta.Online = true
|
||||
meta.LastSeen = time.Now().Unix()
|
||||
meta.Authorized = r.authorized[meta.DeviceID]
|
||||
r.devices[meta.DeviceID] = &meta
|
||||
r.mu.Unlock()
|
||||
r.notifyChange(meta.DeviceID)
|
||||
}
|
||||
|
||||
func (r *Registry) markOffline(id string) {
|
||||
r.mu.Lock()
|
||||
if m, ok := r.devices[id]; ok {
|
||||
m.Online = false
|
||||
}
|
||||
delete(r.conns, id)
|
||||
r.mu.Unlock()
|
||||
r.notifyChange(id)
|
||||
}
|
||||
|
||||
func (r *Registry) notifyChange(id string) {
|
||||
select {
|
||||
case r.onlineCh <- id:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeChan 返回设备上下线变更通知。
|
||||
func (r *Registry) ChangeChan() <-chan string { return r.onlineCh }
|
||||
|
||||
// SaveResult 保存一次命令执行结果(供 cmdresult 查询)。
|
||||
func (r *Registry) SaveResult(reqID string, res map[string]interface{}) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.results == nil {
|
||||
r.results = make(map[string]resultEntry)
|
||||
}
|
||||
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
|
||||
}
|
||||
|
||||
// GetResult 返回某次命令执行的结果。
|
||||
func (r *Registry) GetResult(reqID string) (map[string]interface{}, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
e, ok := r.results[reqID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return e.Result, true
|
||||
}
|
||||
|
||||
// ============ Push(agent -> 设备) ============
|
||||
|
||||
// PushJSON 向在线设备推送一条 JSON 消息。
|
||||
func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) error {
|
||||
r.mu.RLock()
|
||||
c, ok := r.conns[deviceID]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
return writeText(c.w, mustJSON(payload))
|
||||
}
|
||||
|
||||
// PushCmd 向设备发送命令执行请求。
|
||||
func (r *Registry) PushCmd(deviceID, reqID, command string) error {
|
||||
return r.PushJSON(deviceID, map[string]interface{}{
|
||||
"op": "cmd",
|
||||
"req_id": reqID,
|
||||
"command": command,
|
||||
})
|
||||
}
|
||||
|
||||
// AwaitResult 等待某请求的结果(带超时)。
|
||||
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
|
||||
ch := make(chan map[string]interface{}, 1)
|
||||
r.mu.Lock()
|
||||
r.cmdPending[reqID] = ch
|
||||
r.mu.Unlock()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.cmdPending, reqID)
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for device result")
|
||||
}
|
||||
}
|
||||
|
||||
// deliverResult 设备回执结果时由 handleWS 调用。
|
||||
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
|
||||
r.mu.RLock()
|
||||
ch, ok := r.cmdPending[reqID]
|
||||
r.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- res:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ WS 网关(标准库 Hijacker,RFC6455 子集) ============
|
||||
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
func wsAccept(key string) string {
|
||||
h := sha256.Sum256([]byte(key + wsGUID))
|
||||
return base64.StdEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func headerListHas(h, sub string) bool {
|
||||
for _, part := range strings.Split(h, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func httpUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, *bufio.ReadWriter, error) {
|
||||
if !headerListHas(r.Header.Get("Upgrade"), "websocket") ||
|
||||
!headerListHas(r.Header.Get("Connection"), "upgrade") {
|
||||
return nil, nil, fmt.Errorf("not a websocket upgrade request")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
return nil, nil, fmt.Errorf("missing Sec-WebSocket-Key")
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("hijack not supported")
|
||||
}
|
||||
conn, rw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
accept := wsAccept(key)
|
||||
resp := "HTTP/1.1 101 Switching Protocols" + "\x0d\x0a" +
|
||||
"Upgrade: websocket\x0d\x0aConnection: Upgrade\x0d\x0a" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\x0d\x0a\x0d\x0a"
|
||||
if _, err := rw.WriteString(resp); err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, rw, nil
|
||||
}
|
||||
|
||||
func readFrame(r *bufio.Reader) ([]byte, bool, error) {
|
||||
b0, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
opcode := b0 & 0x0f
|
||||
b1, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
masked := b1&0x80 != 0
|
||||
length := uint64(b1 & 0x7f)
|
||||
if length == 126 {
|
||||
var ext [2]byte
|
||||
if _, err := io.ReadFull(r, ext[:]); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
length = uint64(binary.BigEndian.Uint16(ext[:]))
|
||||
} else if length == 127 {
|
||||
var ext [8]byte
|
||||
if _, err := io.ReadFull(r, ext[:]); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
length = binary.BigEndian.Uint64(ext[:])
|
||||
}
|
||||
if length > 1<<20 {
|
||||
return nil, true, fmt.Errorf("frame too large")
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
}
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
switch opcode {
|
||||
case 0x1:
|
||||
return payload, false, nil
|
||||
case 0x8:
|
||||
return nil, true, nil
|
||||
case 0xa:
|
||||
return nil, false, nil
|
||||
case 0x9:
|
||||
return nil, false, errPing
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unsupported opcode %x", opcode)
|
||||
}
|
||||
}
|
||||
|
||||
var errPing = fmt.Errorf("ping")
|
||||
|
||||
func writeText(w *bufio.Writer, payload []byte) error {
|
||||
if err := writeFrameHeader(w, 0x1, len(payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func writePong(w *bufio.Writer) error {
|
||||
return writeFrameHeader(w, 0xa, 0)
|
||||
}
|
||||
|
||||
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {
|
||||
if err := w.WriteByte(0x80 | opcode); err != nil {
|
||||
return err
|
||||
}
|
||||
if length < 126 {
|
||||
if err := w.WriteByte(byte(length)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if length <= 0xffff {
|
||||
if err := w.WriteByte(126); err != nil {
|
||||
return err
|
||||
}
|
||||
var ext [2]byte
|
||||
binary.BigEndian.PutUint16(ext[:], uint16(length))
|
||||
if _, err := w.Write(ext[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := w.WriteByte(127); err != nil {
|
||||
return err
|
||||
}
|
||||
var ext [8]byte
|
||||
binary.BigEndian.PutUint64(ext[:], uint64(length))
|
||||
if _, err := w.Write(ext[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return []byte("{}")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ServeWS 是 WS 端点的 HTTP handler:认证 token(query 或 Sec-WebSocket-Protocol),
|
||||
// 升级后进入 handleWS。未带 token 也允许 hello(登记设备),bind 时校验。
|
||||
func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token := req.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
|
||||
if strings.HasPrefix(p, "homeagent.") {
|
||||
token = strings.TrimPrefix(p, "homeagent.")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if token != "" && !r.acceptBind(token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, rw, err := httpUpgrade(w, req)
|
||||
if err != nil {
|
||||
http.Error(w, "upgrade failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
|
||||
go r.handleWS(conn, rw)
|
||||
}
|
||||
|
||||
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
defer conn.Close()
|
||||
var curID string
|
||||
defer func() {
|
||||
if curID != "" {
|
||||
r.markOffline(curID)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
payload, isClose, err := readFrame(rw.Reader)
|
||||
if err != nil {
|
||||
if err == errPing {
|
||||
if werr := writePong(rw.Writer); werr != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
if isClose {
|
||||
return
|
||||
}
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
op, _ := msg["op"].(string)
|
||||
switch op {
|
||||
case "hello":
|
||||
meta := metaFromMsg(msg)
|
||||
if meta.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
meta.RemoteAddr = conn.RemoteAddr().String()
|
||||
curID = meta.DeviceID
|
||||
r.register(meta)
|
||||
r.mu.Lock()
|
||||
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
|
||||
r.mu.Unlock()
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
})); err != nil {
|
||||
return
|
||||
}
|
||||
case "bind":
|
||||
token, _ := msg["token"].(string)
|
||||
if r.acceptBind(token) {
|
||||
id, _ := msg["device_id"].(string)
|
||||
if id != "" {
|
||||
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动
|
||||
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。
|
||||
}
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
case "status":
|
||||
id, _ := msg["device_id"].(string)
|
||||
r.mu.Lock()
|
||||
if m, ok := r.devices[id]; ok {
|
||||
m.LastSeen = time.Now().Unix()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.mu.RLock()
|
||||
h := r.onStatus
|
||||
r.mu.RUnlock()
|
||||
if h != nil {
|
||||
h(msg)
|
||||
}
|
||||
case "cmd_result":
|
||||
reqID, _ := msg["req_id"].(string)
|
||||
if reqID != "" {
|
||||
r.deliverResult(reqID, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metaFromMsg(msg map[string]interface{}) DeviceMeta {
|
||||
var meta DeviceMeta
|
||||
if d, ok := msg["device"].(map[string]interface{}); ok {
|
||||
if v, ok := d["device_id"].(string); ok {
|
||||
meta.DeviceID = v
|
||||
}
|
||||
if v, ok := d["name"].(string); ok {
|
||||
meta.Name = v
|
||||
}
|
||||
if v, ok := d["kind"].(string); ok {
|
||||
meta.Kind = v
|
||||
}
|
||||
if caps, ok := d["caps"].([]interface{}); ok {
|
||||
for _, c := range caps {
|
||||
if s, ok := c.(string); ok {
|
||||
meta.Caps = append(meta.Caps, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if info, ok := d["info"].(map[string]interface{}); ok {
|
||||
if len(info) > 0 {
|
||||
meta.Info = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@ -611,6 +611,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
|
||||
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
|
||||
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
|
||||
// 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404)
|
||||
mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy))
|
||||
mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions))
|
||||
mux.HandleFunc("/", h.requireWeb(h.handleStatic))
|
||||
}
|
||||
@ -1663,6 +1665,55 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ========
|
||||
|
||||
// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。
|
||||
// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到
|
||||
// remotedevice 插件(self-contained),避免与 remotedevice 耦合。
|
||||
var (
|
||||
deviceGatewayEnabled bool
|
||||
deviceGatewayAddr string
|
||||
deviceGatewayToken string
|
||||
)
|
||||
|
||||
// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。
|
||||
// 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。
|
||||
func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if !deviceGatewayEnabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
addr := deviceGatewayAddr
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:9890"
|
||||
}
|
||||
path := r.URL.Path // 保留 /api/v1/device/... 全路径
|
||||
url := "http://" + addr + path
|
||||
if r.URL.RawQuery != "" {
|
||||
url += "?" + r.URL.RawQuery
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Header = r.Header.Clone()
|
||||
if deviceGatewayToken != "" {
|
||||
req.Header.Set("X-API-Key", deviceGatewayToken)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ======== Plugin Management (proxied to pluginmgr HTTP API) ========
|
||||
|
||||
func (h *Handler) pluginmgrAddr() string {
|
||||
|
||||
@ -704,7 +704,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
|
||||
|
||||
type echoProvider struct{ name string }
|
||||
|
||||
func (p *echoProvider) Name() string { return p.name }
|
||||
func (p *echoProvider) Name() string { return p.name }
|
||||
func (p *echoProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
content := "echo: " + lastUserContent(req.Messages)
|
||||
|
||||
@ -95,7 +95,26 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_addr", Default: "127.0.0.1:9890", Type: "string", DisplayName: "设备网关地址", Description: "remotedevice 插件的内部监听地址", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_token", Default: "", Type: "password", DisplayName: "设备网关令牌", Description: "访问 remotedevice 的 token(与 remotedevice 的 ws_token 一致)", Category: "webui"})
|
||||
p.ensureAuthBootstrap(s)
|
||||
// 注入设备网关反代配置(默认禁用;仅当用户开启时才挂载路由)
|
||||
if v, _ := s.Settings().Get("device_gateway_enabled"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 == "true" {
|
||||
deviceGatewayEnabled = true
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("device_gateway_addr"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 != "" {
|
||||
deviceGatewayAddr = s2
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("device_gateway_token"); v != nil {
|
||||
if s2, ok := v.(string); ok && s2 != "" {
|
||||
deviceGatewayToken = s2
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
|
||||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}})
|
||||
|
||||
Reference in New Issue
Block a user