Files
HomeAgent/internal/plugins/webui/plugin.go
JianFeeeee 3273c507b3 fix(webui): Server 读侧超时(防 Slowloris)+ 反向判据守住流式不被腰斩
http.Server 原本**一个超时都没设**(只有 Handler)。后果是 Slowloris:
攻击者只占连接不发完整请求头,每个连接挂几 KB。MaxHeaderBytes 限的是
头部**大小**,「慢慢发」不占大小、不受它约束,几百个连接就能耗尽 fd。

## 为什么不是「把超时都设上」

webui 有一条长连接 SSE(/api/v1/chat/events)与可跑 300s 的流式
/v1/chat/completions。WriteTimeout 是「从请求开始到响应写完」的**总预算**,
会把它们腰斩 —— 表现为 SSE 每隔一段时间断一次、前端疯狂重连。而这类
回归在功能测试里很难立刻发现。

所以只设读侧三项,各管一段:

  ReadHeaderTimeout 20s —— 请求头必须按时发完,Slowloris 的正解
  ReadTimeout       60s —— 读完整请求(含 body)的预算,防慢速上传
  IdleTimeout      120s —— keep-alive 空闲连接(另两项都管不到)
  WriteTimeout        0 —— **刻意不设**(见上)

## 判据(3 条,含一条反向判据)

- TestServerHasReadSideTimeouts:三个读侧超时都必须 > 0
- TestServerHasNoWriteTimeout:**反向**钉住 WriteTimeout 必须保持 0,
  防止将来有人「顺手补全超时」把 SSE 弄坏
- TestSSEConnectionSurvivesBeyondReadTimeout:SSE 连接确实活过读侧窗口

反向判据看着琐碎,但它守的正是「这次没做的那件事」——
不加 WriteTimeout 是个**决定**,不是疏漏,所以要用判据把决定固定下来。

变异验证:补上 WriteTimeout:30s → 反向判据判红;
去掉 ReadHeaderTimeout → 前向判据判红。

测试脚手架注意:newServerForTest 绑 127.0.0.1:0(内核分配空闲端口),
绝不用 :8080 —— 那是生产端口(见 a752ae1)。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00

369 lines
17 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"
"time"
"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。
//
// 测试也用它在加载内置插件前把地址指到 127.0.0.1:0:
// 否则测试会绑生产端口 :8080,与线上实例互相抢(见 integration_test.go)。
func SetListenOverride(addr string) {
listenOverride = strings.TrimSpace(addr)
}
// ListenOverride 返回当前的覆盖值(空串表示未覆盖)。
// 供调用方「保存-还原」用,避免测试互相污染。
func ListenOverride() string { return listenOverride }
// 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: "trusted_proxies", Default: "", Type: "string", DisplayName: "受信反代网段", Description: "逗号分隔的 CIDR 或裸 IP(如 127.0.0.1,10.0.0.0/8)。只有来自这些网段的请求,其 X-Forwarded-For 才被采信用于登录限流计数。**经 frp/nginx 穿透到公网时必须配置**(反代通常就在本机 127.0.0.1),否则所有外部访问被视为同一来源,限流会误伤所有人。留空 = 不采信任何 XFF(保守默认)", 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)
// 受信反代网段:限流要按真实客户端隔离,就得以可信方式拿到客户端 IP。
// 穿透部署下反代通常就在本机 127.0.0.1(外部请求的 RemoteAddr 全是它),
// 不配置的话所有人共用一个桶,限流会误伤所有人 —— 生产上踩过。
p.handler.trustedProxies = parseTrustedProxies(settingString(s.Settings(), "trusted_proxies"))
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
}
// 超时配置。为什么不"全设上"(读侧全设、写侧全不设):
//
// 读侧必须有超时,否则 Slowloris —— 攻击者只占连接不发完整请求头,
// 每个连接挂几 KB。MaxHeaderBytes 限的是头部**大小**,"慢慢发"不占大小,
// 因此不受它约束;几百个连接就能耗尽 fd。这里三个读侧超时分别覆盖:
// ReadHeaderTimeout —— 请求头必须在此时间内发完(Slowloris 的正解)
// ReadTimeout —— 读完整请求(含 body)的预算,防慢速上传
// IdleTimeout —— keep-alive 空闲连接(另外两个都管不到)
//
// 写侧**刻意不设**:webui 有长连接 SSE(/api/v1/chat/events)与可跑
// 300s 的流式 /v1/chat/completions。WriteTimeout 是"从请求开始到响应
// 写完"的**总预算**,会把它们腰斩(表现为 SSE 每隔一段时间断一次、
// 前端疯狂重连)—— 这类回归很难在功能测试里立刻发现,所以有专门
// 的反向判据钉住它必须保持为 0。
p.server = &http.Server{
Handler: p.handler.Handler(),
ReadHeaderTimeout: 20 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
// WriteTimeout 保持 0(见上方说明)
}
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
}