feat: 设备鉴权迁移至客户端 + 插件卸载保护

安全修复(客户端鉴权):
- remotedevice 服务端移除授权状态存储(authorized map/SetAuthorized/handleDeviceAuth)
- DeviceMeta.Authorized 改为设备 hello 自报,服务端仅透传展示
- device_ctl_* 工具移除服务端授权检查,无条件转发,设备端自行决定是否执行
- 共享设备桥库 Bridge 新增本地 authorized 状态,未授权收到 cmd 直接拒绝
- waiter: --device-authorized / device_authorized 配置控制本地授权
- GUI: 授权存 gui-prefs 本地文件;设备页仅本机可切换开关
- webui /device/auth 旧路径返回 410 Gone
- 根因:agent 可经 config_set 篡改服务端授权配置自行授权设备

插件管理强化:
- 内置插件禁止卸载(IsBuiltinPlugin + 409),外部插件卸载即时生效
- 卸载不存在插件返回 404;移除误导性 reload_required 提示
- webui 插件路由:名称白名单校验防路径穿越、保留字路径保护
This commit is contained in:
JianFeeeee
2026-08-24 19:26:11 +08:00
parent 5163ce51a7
commit ba5785036a
21 changed files with 616 additions and 383 deletions

View File

@ -22,25 +22,28 @@ type DataHandler func(reqID, kind, mime string, data []byte)
// Bridge 是设备桥客户端核心结构体。
// 管理 WebSocket 连接、消息路由、心跳保活和命令分发。
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
// 未授权时收到 cmd 直接拒绝执行并回执 error。
type Bridge struct {
mu sync.RWMutex
gateway string
token string
deviceID string
name string
kind string
caps []string
info map[string]interface{}
mu sync.RWMutex
gateway string
token string
deviceID string
name string
kind string
caps []string
info map[string]interface{}
authorized bool // 客户端本地授权状态(用户在设备上手动开启)
ws *wsConn
stopCh chan struct{}
doneCh chan struct{}
started bool
ws *wsConn
stopCh chan struct{}
doneCh chan struct{}
started bool
// 回调
cmdHandler CmdHandler
resultHandler CmdResultHandler
dataHandler DataHandler
cmdHandler CmdHandler
resultHandler CmdResultHandler
dataHandler DataHandler
// 二进制数据聚合(服务端→设备,如 TTS 音频)
speechAccum *speechBuffer
@ -85,19 +88,52 @@ func New(gateway, token, deviceID, name string, caps []string, info map[string]i
}
return &Bridge{
gateway: gateway,
token: token,
deviceID: deviceID,
name: name,
kind: "computer",
caps: caps,
info: info,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
pingInterval: 30 * time.Second,
gateway: gateway,
token: token,
deviceID: deviceID,
name: name,
kind: "computer",
caps: caps,
info: info,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
pingInterval: 30 * time.Second,
}
}
// SetAuthorized 设置客户端本地授权状态(用户在设备上手动开启)。
// 授权后立即重新发送 hello 同步到服务端展示。
func (b *Bridge) SetAuthorized(auth bool) {
b.mu.Lock()
b.authorized = auth
b.mu.Unlock()
// 重新 hello 同步状态
b.mu.RLock()
ws := b.ws
connected := ws != nil && !ws.closed
b.mu.RUnlock()
if connected {
b.sendJSON(map[string]interface{}{
"op": "hello",
"device": map[string]interface{}{
"device_id": b.deviceID,
"name": b.name,
"kind": b.kind,
"caps": b.caps,
"info": b.info,
"authorized": auth,
},
})
}
}
// Authorized 返回当前客户端本地授权状态。
func (b *Bridge) Authorized() bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.authorized
}
// OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。
func (b *Bridge) OnCmd(handler CmdHandler) {
b.mu.Lock()
@ -149,15 +185,19 @@ func (b *Bridge) Start() error {
b.ws = ws
b.mu.Unlock()
// 发送 hello
// 发送 hello(含设备自报的授权状态,服务端仅展示不决策)
b.mu.RLock()
auth := b.authorized
b.mu.RUnlock()
b.sendJSON(map[string]interface{}{
"op": "hello",
"device": map[string]interface{}{
"device_id": b.deviceID,
"name": b.name,
"kind": b.kind,
"caps": b.caps,
"info": b.info,
"device_id": b.deviceID,
"name": b.name,
"kind": b.kind,
"caps": b.caps,
"info": b.info,
"authorized": auth,
},
})
@ -382,12 +422,19 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
if reqID == "" || command == "" {
return
}
// 客户端鉴权:未授权时拒绝执行(服务端不存储授权状态,无法被 agent 篡改)
b.mu.RLock()
auth := b.authorized
handler := b.cmdHandler
b.mu.RUnlock()
if !auth {
log.Printf("[devicebridge] cmd rejected (unauthorized) req=%s cmd=%s", reqID, truncateString(command, 60))
b.SendResult(reqID, "error", "", "设备未授权:请在设备本机开启远程控制授权")
return
}
// 记录日志
log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60))
b.mu.RLock()
handler := b.cmdHandler
b.mu.RUnlock()
if handler != nil {
handler(reqID, command)
}
@ -480,4 +527,4 @@ func truncateString(s string, n int) string {
return s
}
return s[:n] + "..."
}
}