Files
HomeAgent/internal/plugins/webui/plugin.go
JianFeeeee 5da0f8f9fb feat(webui): 外部入口 base_url 配置 —— 穿透场景下链接不再靠猜
用户指出 webui 实际是经 https://homeagent.jianfgit.xyz/ 穿透出去的,
应当支持配置 base URL。实测确认了这个诉求的正当性。

## 实测发现的约束(决定方案)

1. **子域形态在外部不可用**:`*.homeagent.jianfgit.xyz` 泛解析存在,
   但外层只给 `*.jianfgit.xyz` 通配证书 —— 该证书**不匹配三级子域**,
   实测 `huawei-smarthome.homeagent.jianfgit.xyz` 外部握手失败(HTTP 000)。
   外层只放行 `homeagent.jianfgit.xyz` 这一个 Host。
2. **路径挂载形态外部可用**:实测
   `https://homeagent.jianfgit.xyz/api/v1/device/online` → 200。
   所以「一个外部 Host + 路径挂载」是这条链路的正解,且已经工作。
3. 外层 nginx/WAF 会带 `X-Forwarded-Proto: https` 与 `X-Forwarded-Host`,
   因此即使不配置也能推出正确链接;配 base_url 则是显式兜底。

## 新增设置项 base_url

三级优先解析「对外入口」(resolveEntry):

  1. **配置项 base_url** —— 外部入口是部署事实,不该靠请求猜。
     经多层网关时请求可能带内网 Host,按它推导会拼出用户点不开的链接。
  2. **X-Forwarded-Proto / X-Forwarded-Host** —— 反代层给权威信息时可靠。
  3. **请求自身** —— 直连时的正确来源。

base_url 的主机名同时用作**子域反代的基域名**:入口是 homeagent.example.com
时,插件服务自然是 <标签>.homeagent.example.com。

服务清单另增 entry_url 字段,直接给出「外部入口是什么」,便于前端与排错。

## 生效点

- `/api/v1/proxy/services`:url / url_portal / base_domain / entry_url
- `/api/v1/device/gateway`:url / url_portal / http_url / host
- 两处原先各自推导 portalHost,现统一走 resolveEntry,避免再次漂移

## 部署后实测(生产,经真实外部入口)

  entry_url:   https://homeagent.jianfgit.xyz
  base_domain: homeagent.jianfgit.xyz
  remotedevice    | https://devices.homeagent.jianfgit.xyz
                  | https://homeagent.jianfgit.xyz/api/v1/device/   ← 外部可点
  huawei_smarthome| https://huawei-smarthome.homeagent.jianfgit.xyz

发现端点(外部视角):
  url_portal = wss://homeagent.jianfgit.xyz/api/v1/device/ws   ← 外部可连

## 判据

+3 条:TestBaseURLOverridesRequestDerived(内网 Host 场景下必须用 base_url)、
TestEntryPrefersForwardedHeaders(XFF 优先于请求自身)、
TestBaseURLTolerant(尾斜杠/空格容错 —— 手填配置最常见的两种手误)。

另更新一条旧判据的期望值:外部入口是 portal.example.com 时,子域基名应取
**实际入口**而非本机配置的 localhost(后者对远程用户无意义)。
2026-09-26 13:49:59 +08:00

336 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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。
// 外部入口 base URL:经 frp/nginx 穿透时,请求 Host 往往是内网地址或
// 缺少协议信息,而生成给用户的链接必须是**外部可点的**。填这里即覆盖。
// 例:https://homeagent.example.com —— 生成的链接一律用它做协议+主机。
s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_url", Default: "", Type: "string", DisplayName: "外部入口 Base URL", Description: "经反代/穿透暴露给外部的完整入口地址(含协议),如 https://homeagent.example.com。留空则按请求推导(直连时正确;经多层网关时可能拼错)。填了它,所有生成的外部链接都用它", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_domain", Default: "localhost", Type: "string", DisplayName: "反代基域名", Description: "插件服务按子域反代时用的基域名(如 webui.example.com 则 <插件标签>.webui.example.com)。默认 localhost 只对本机浏览器有效。若外层未放行子域,请改用手填反代条目或路径挂载", 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
}