mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
两处都是**隔离实例上跑真实端到端**才暴露的,单测(用不校验凭证的假上游、
直接调 serveProxyHost)全绿却线上出错。记录在此以免重蹈。
## 故障 1:auth=none 路由的凭证被无条件剥掉 ⇒ 设备链路全 401
Rewrite 里原本无条件 Del("Cookie"/"Authorization"/"X-API-Key")。但
auth=none 的语义是"请求原样交给上游",凭证本来就是给**上游**的——
remotedevice 的接入令牌正是走 X-API-Key 传的。
实测症状:带设备令牌经反代访问 /api/v1/device/online → 401;
直连 127.0.0.1:9890 → 200。差异极难定位,因为两侧状态码语义相同。
修法:按路由 auth 分流。
- auth=homeagent:凭证是门户的,剥掉(避免泄漏给插件)
- auth=none:保留(上游要用)
复验:经反代与直连**逐字节一致**(HTTP 200 / 17B,cmp 相同)。
## 故障 2:插件子域被门户路由截走 ⇒ 401 且响应体是门户的 JSON
原实现把 Host 分发放在 mux 的 "/" 兜底里。但 stdlib ServeMux 是**最长前缀
优先**:任何更具体的模式都先命中。插件子域上的 /api/v1/device/online 被门户
为「旧路径反代」注册的 /api/v1/device/ 接走(requireAPI 包裹)→ 401。
判据(响应体格式)是定位关键:
webui requireAPI → {"error":"unauthorized"} 25B ← 实际拿到
remotedevice requireToken → "unauthorized" text/plain 13B
看响应体格式就能区分是谁拒的,比看状态码有效。
修法:Host 分发提为**最外层中间件**,包在整个 mux 之外,先于任何路径匹配。
## 顺带:消除判据与生产接线错位的可能
新增 Handler.Handler() 返回生产用的完整链(Host 分发 → 日志 → mux),
plugin.go 与测试共用同一条。本次踩过:测试自己组装 mux、中间件却挂在
plugin.go,判据全绿而线上 401;共享同一条链可结构性避免。
(写这条判据时还发现测试里 h.mux 为 nil 导致 panic——也正是这种错位的表现。)
## 新增判据 2 条
- TestProxyCredentialHeadersDependOnAuth:auth=none 必须转发上游令牌、
auth=homeagent 必须剥掉门户凭证(两个方向都钉)
- TestProxyHostTakesPrecedenceOverPortalRoutes:走**完整生产链**,确认
插件子域上的 /api/v1/device/online、/api/v1/status、/api/v1/plugins/ 都
归反代;同时确认门户自身的 /api/v1/status 仍返回门户 JSON(没被反代吞掉)
## 真实验收(隔离实例:命名 netns + 独立 data + 端口 18080)
- 设备网关:令牌经反代 200,与直连逐字节一致;WS 升级 101
- 未声明子域:404 且错误信息含具体标签
- huawei_smarthome(用新 hmapdev 重打包、真装载):
匿名 401 + 可操作提示;带门户 key 拿到真实 UI(9444B,
<title>华为智慧生活管家);页面内根绝对路径 /api/status 正确透传
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package webui
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
func init() {
|
||
plugin.RegisterPluginMeta("webui", "Web 控制台", "WebUI")
|
||
plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return New(name), nil
|
||
})
|
||
}
|
||
|
||
type Plugin struct {
|
||
name string
|
||
handler *Handler
|
||
server *http.Server
|
||
mux *http.ServeMux
|
||
}
|
||
|
||
func New(name string) *Plugin {
|
||
return &Plugin{
|
||
name: name,
|
||
mux: http.NewServeMux(),
|
||
}
|
||
}
|
||
|
||
func randomSecret(n int) string {
|
||
buf := make([]byte, n)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return ""
|
||
}
|
||
return hex.EncodeToString(buf)
|
||
}
|
||
|
||
// webFilesDir 是 agent 向 webui 发送文件时的中转目录(<data>/webui_files)。
|
||
// 由插件 Start 时从 daemon.data_dir 推导注入。
|
||
var webFilesDir string
|
||
|
||
// webDataDir 是 <data> 根目录(同上推导),供聊天记录等路径解析使用。
|
||
var webDataDir string
|
||
|
||
// uploadsDir 是用户经 webui 上传文件的存储目录(<data>/uploads)。
|
||
// handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。
|
||
var uploadsDir string
|
||
|
||
// listenOverride 是内核在插件加载前给出的监听地址覆盖(CLI --webui,
|
||
// 或核心配置 webui.listen_addr 被显式改成非默认值)。
|
||
//
|
||
// 为什么需要这个旁路:内核曾在插件加载前写 settings["addr"],但那时
|
||
// config_<name> 表还没建,PluginSettings.Set 的 INSERT 会失败且错误被忽略;
|
||
// 随后 plugin Start 里 RegisterDef 才建表并写入默认值 :8080。结果是
|
||
// CLI --webui 与 webui.listen_addr **一直是死配置**。这里改为插件自己
|
||
// 接受一个显式覆盖值,优先级高于 settings["addr"](后者是 Web 设置页的持久值)。
|
||
var listenOverride string
|
||
|
||
// SetListenOverride 设置监听地址覆盖(空值表示不覆盖)。
|
||
// 由 cmd/homed 在插件加载前调用,见 resolveWebUIOverride。
|
||
func SetListenOverride(addr string) {
|
||
listenOverride = strings.TrimSpace(addr)
|
||
}
|
||
|
||
// resolveListenAddr 决定最终监听地址:覆盖值 > 插件设置 > 内置默认。
|
||
// 抽成纯函数是为了能被单测直接钉住优先级。
|
||
func resolveListenAddr(setting string) string {
|
||
addr := ":8080"
|
||
if setting != "" {
|
||
addr = setting
|
||
}
|
||
if listenOverride != "" {
|
||
addr = listenOverride
|
||
}
|
||
return addr
|
||
}
|
||
|
||
// stageWebFile 把 agent 要发送的本地文件拷贝到 webui_files 中转目录,
|
||
// 返回可下载 URL 路径与字节数。image/file 的 payload 支持本地路径或 http(s) URL
|
||
// (URL 直接透传给前端,不落盘)。文件名用随机 UUID 防路径猜测,扩展名保留自源文件。
|
||
func stageWebFile(payload string, isImage bool) (url string, size int64, err error) {
|
||
if strings.HasPrefix(payload, "http://") || strings.HasPrefix(payload, "https://") {
|
||
return payload, 0, nil // 远程 URL 直接透传
|
||
}
|
||
if webFilesDir == "" {
|
||
return "", 0, fmt.Errorf("webui files dir not initialized")
|
||
}
|
||
src := payload
|
||
if _, err := os.Stat(src); err != nil {
|
||
return "", 0, fmt.Errorf("文件不存在: %s", src)
|
||
}
|
||
if err := os.MkdirAll(webFilesDir, 0755); err != nil {
|
||
return "", 0, fmt.Errorf("create webui_files: %w", err)
|
||
}
|
||
buf := make([]byte, 8)
|
||
rand.Read(buf)
|
||
ext := strings.ToLower(filepath.Ext(src))
|
||
if extBad(ext) {
|
||
ext = ".bin"
|
||
}
|
||
name := hex.EncodeToString(buf) + ext
|
||
dst := filepath.Join(webFilesDir, name)
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("open source: %w", err)
|
||
}
|
||
defer in.Close()
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("create dest: %w", err)
|
||
}
|
||
defer out.Close()
|
||
n, err := io.Copy(out, in)
|
||
if err != nil {
|
||
os.Remove(dst)
|
||
return "", 0, fmt.Errorf("copy: %w", err)
|
||
}
|
||
return "/files/" + name, n, nil
|
||
}
|
||
|
||
// extBad 过滤危险/无意义扩展名(防双扩展名绕过 Content-Type)。
|
||
func extBad(ext string) bool {
|
||
switch ext {
|
||
case "", ".html", ".htm", ".svg", ".js", ".exe", ".sh", ".bat", ".cmd", ".ps1":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) {
|
||
sett := s.Settings()
|
||
if sett == nil {
|
||
return
|
||
}
|
||
if v, _ := sett.Get("username"); v == nil || fmt.Sprint(v) == "" {
|
||
_ = sett.Set("username", "admin")
|
||
}
|
||
if v, _ := sett.Get("password"); v == nil || fmt.Sprint(v) == "" {
|
||
pw := randomSecret(12)
|
||
_ = sett.Set("password", pw)
|
||
log.Printf("[webui] bootstrap password generated for user admin: %s", pw)
|
||
}
|
||
if v, _ := sett.Get("api_key"); v == nil || fmt.Sprint(v) == "" {
|
||
key := randomSecret(16)
|
||
_ = sett.Set("api_key", key)
|
||
log.Printf("[webui] bootstrap api_key generated: %s", key)
|
||
}
|
||
if v, _ := sett.Get("session_ttl_hours"); v == nil || fmt.Sprint(v) == "" {
|
||
_ = sett.Set("session_ttl_hours", "24")
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
|
||
// 中转目录:<data>/webui_files,agent 发送 image/file 时拷贝至此
|
||
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||
if s2, ok := dd.(string); ok && s2 != "" {
|
||
webDataDir = s2
|
||
webFilesDir = filepath.Join(s2, "webui_files")
|
||
uploadsDir = filepath.Join(s2, "uploads")
|
||
}
|
||
}
|
||
|
||
addr := ":8080"
|
||
if v, _ := s.Settings().Get("addr"); v != nil {
|
||
if s2, ok := v.(string); ok && s2 != "" {
|
||
addr = s2
|
||
}
|
||
}
|
||
addr = resolveListenAddr(addr)
|
||
|
||
// 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片
|
||
// 入站通道:webui(控制台对话)与 http(外部 HTTP 注入),都由本插件注入输入。
|
||
// http 通道还声明 NoMemory:外部抓来的内容不进记忆计算(见 handler 里的 NoMemory 注入)。
|
||
_ = s.RegisterInputChannel("webui", sdk.ChannelDef{})
|
||
_ = s.RegisterInputChannel("http", sdk.ChannelDef{NoMemory: true})
|
||
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||
payload, _ := args["payload"].(string)
|
||
rawType, _ := args["type"].(string)
|
||
if payload == "" {
|
||
return nil, fmt.Errorf("payload 不能为空")
|
||
}
|
||
// 能力位:CapText|CapFile|CapImage = 1|2|4 = 7(旧值 1 仅文本)。
|
||
// image/file 时 payload 为本地路径(或 http URL),拷贝到 webui_files
|
||
// 并经 /files/ 带鉴权下发;前端按 kind 渲染图片预览/文件下载卡片。
|
||
if rawType == "image" || rawType == "file" {
|
||
url, size, err := stageWebFile(payload, rawType == "image")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.Publish(&sdk.Event{
|
||
Type: sdk.EventAgentOutput,
|
||
Payload: map[string]interface{}{
|
||
"content": payload,
|
||
"channel": "webui",
|
||
"kind": "channel_output",
|
||
"output_type": rawType,
|
||
"url": url,
|
||
"size": size,
|
||
},
|
||
})
|
||
return map[string]interface{}{"status": "ok", "url": url, "size": size}, nil
|
||
}
|
||
s.Publish(&sdk.Event{
|
||
Type: sdk.EventAgentOutput,
|
||
Payload: map[string]interface{}{
|
||
"content": payload,
|
||
"channel": "webui",
|
||
"kind": "channel_output",
|
||
},
|
||
})
|
||
return map[string]interface{}{"status": "ok"}, nil
|
||
})
|
||
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "Web 控制台监听地址", Category: "webui"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "history_file", Default: "", Type: "string", DisplayName: "聊天记录文件", Description: "聊天记录存放路径。留空 = <data>/webui_chat_history.json;相对路径按 data 目录解析(可指向独立挂载盘)", Category: "webui"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"})
|
||
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"})
|
||
// ---- 通用反代(HomeAgent 自带能力)----
|
||
// 基域名:默认 localhost ⇒ <标签>.localhost:<端口> 开箱即用(RFC 6761 强制
|
||
// 解析到 loopback,无需 DNS/证书/hosts)。远程访问时改成本机可达的域名,
|
||
// 如 webui.example.com ⇒ <标签>.webui.example.com。
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_domain", Default: "localhost", Type: "string", DisplayName: "反代基域名", Description: "插件服务反代的基域名。默认 localhost,此时 <插件标签>.localhost:<端口> 直接可用;远程访问填如 webui.example.com", Category: "webui"})
|
||
// 手填反代条目(自动发现之外的补充):每行 `<标签> <上游地址> [ws] [auth=none]`
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "proxy_routes", Default: "", Type: "text", DisplayName: "手填反代条目", Description: "每行一条:<子域标签> <上游地址> [ws] [auth=none|homeagent]。插件的 proxies 声明会自动发现,这里只用于补充未声明/第三方服务。例:grafana 127.0.0.1:3000", 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"}})
|
||
return nil
|
||
})
|
||
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||
tool := ""
|
||
if len(ctx.ToolCalls) > 0 {
|
||
tool = ctx.ToolCalls[0].Name
|
||
}
|
||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "before_toolcall", "tool": tool, "message": "tool:" + tool}})
|
||
return nil
|
||
})
|
||
s.RegisterStage(sdk.StageBeforeOutput, func(ctx *sdk.StageContext) error {
|
||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "before_output", "message": "output"}})
|
||
return nil
|
||
})
|
||
|
||
// 反代接线:手填条目 + 自动发现回调。
|
||
// 自动发现读插件目录里的 plugin.json(webui 已能拿到该目录),因此
|
||
// **无需给内核接口加方法**即可发现声明,插件进程没起来也照样可见。
|
||
if v, _ := s.Settings().Get("proxy_routes"); v != nil {
|
||
if raw, ok := v.(string); ok {
|
||
SetManualProxyRoutes(raw)
|
||
}
|
||
}
|
||
if pm := s.PluginMgr(); pm != nil {
|
||
dir := pm.PluginDir()
|
||
SetProxyDeclProvider(func() []proxyDecl { return readPluginProxyDecls(dir) })
|
||
}
|
||
InvalidateProxyRoutes()
|
||
|
||
p.handler = NewHandler(s)
|
||
p.handler.RegisterRoutes(p.mux)
|
||
|
||
// 最外层套 logged 中间件:记录每个请求的来源 IP / 方法 / 路径 / 认证方式 / 状态码。
|
||
// 用于排查“谁调用了什么接口”(如插件禁用等变更操作)。
|
||
//
|
||
// 同步 Listen:端口被占时必须**在这里**失败并把错误交回加载器,
|
||
// 而不是“后台 goroutine 里报一行日志、插件仍被当成加载成功”。
|
||
// 修复前 Start 总是返回 nil,于是 :8080 被占时 WebUI 静默死亡,
|
||
// 调用方看不到任何失败信号。
|
||
ln, err := net.Listen("tcp", addr)
|
||
if err != nil {
|
||
return fmt.Errorf("webui: 监听 %s 失败: %w", addr, err)
|
||
}
|
||
// 记录实际监听端口:「服务入口」链接必须带同一端口(单端口穿透的前提)。
|
||
if _, port, err := net.SplitHostPort(ln.Addr().String()); err == nil {
|
||
p.handler.hostPort = ":" + port
|
||
}
|
||
p.server = &http.Server{Handler: p.handler.Handler()}
|
||
go func() {
|
||
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||
log.Printf("[webui] server error: %v", err)
|
||
}
|
||
}()
|
||
log.Printf("[webui] HTTP server listening on %s", ln.Addr())
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
// 先停聊天记录写盘协程并落最后一次,再关服务器:
|
||
// 写盘是节流的(chatSaveThrottle),不显式关会丢掉最后一轮对话。
|
||
if p.handler != nil {
|
||
p.handler.Close()
|
||
}
|
||
if p.server != nil {
|
||
return p.server.Close()
|
||
}
|
||
return nil
|
||
}
|