feat(webui): 通用反向代理 —— 插件声明服务,HomeAgent 按子域反代出去

用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个
webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件
的可声明项;插件 UI 要有可直接点击的入口。

实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') →
fetch('/api/status'))。挂在 /p/<name>/ 这类路径前缀下,这些请求会打到
HomeAgent 自己的 /api/status —— 静默错路由;做 HTML/JS 内容重写对拼进
JS 字符串的绝对路径只是"按概率能用",会产生"页面能开、某个按钮就坏"的
静默故障。子域路由下根路径天然正确,**插件前端零改动**。

且它天然匹配"只穿透一个端口":webui 监听 0.0.0.0:8080 按 Host 分发,
外层 frp 单端口 TCP 隧道**一行都不用改**。

默认基座 localhost:RFC 6761 规定 *.localhost 强制解析到 loopback,
现代浏览器原生支持 ⇒ <标签>.localhost:8080 **零配置可用**,不需要 DNS、
证书、/etc/hosts。远程部署改 base_domain 即可。

- 外部插件 → plugin.json 的 proxies(静态可发现:插件没起来也能报
  "声明了 ui 但目标不可达",而不是静默 404)
- 内置插件 → s.DeclareProxy()(remotedevice 是内置的、没有 plugin.json,
  却最需要被反代出去)

反代层在 webui 侧读清单:webui 已能拿到插件目录(PluginManager.PluginDir),
因此**无需给内核接口加方法**。manifest 解析忽略未知字段,加 proxies 对
"旧内核读新插件"与"新内核读旧插件"都无害。

新增 sdk/ProxyDecl 与配套校验(ValidProxyAuth / ValidProxyHostLabel /
NormalizeProxyHost / ValidateProxyDecl);新增运行期 ProxyDeclarer 通道。
hmapdev 的 writePluginJSON 是**白名单 map 重建**——不同步加字段会让声明
被打包静默丢弃(插件作者本地正常、装上去失效),因此 PlgConfig 与
writePluginJSON 同时加,并在打包前校验声明(插件作者本地就能发现写错)。

auth=homeagent(默认,安全的默认):门户会话 / X-API-Key / ?__token=;
auth=none:信任上游自身鉴权,供设备与嵌入式客户端使用——它们不可能持有
浏览器会话,强制走门户鉴权会把设备链路挡死。remotedevice 声明 none,
因为它自身用 ws_token 强制校验。

未声明时升级请求**明确拒绝**(400 + 原因),而不是静默降级成普通请求
(后者表现为前端不断重连、日志看不出原因)。

1. 不跟随上游 3xx:旧实现用 http.DefaultClient(默认跟最多 10 跳),
   上游 302 到内网地址时反代自己跟过去、失败回 502 并把内网 URL 泄给
   客户端。httputil.ReverseProxy 默认不跟随,3xx 原样透传。
2. 逐帧 flush:旧实现 io.Copy 导致上游流式响应被缓冲到上游关闭才下发
   (实测 3 帧 200ms 间隔的流,客户端在 +600ms 一次性收到全部)。
   设 FlushInterval=-1。

另补齐 X-Forwarded-For/Host/Proto(旧实现完全不注入,上游无法判断真实
来源),并剥掉上游 Set-Cookie 的 Domain(防止插件 cookie 打到主门户域)。

插件页新增「服务入口」卡片:列出全部被反代的插件服务(含被拒条目与
不可达原因),点「打开」直接访问。链接带 ?__token=<api_key>,因为子域
与门户不同源、浏览器不会自动带会话 cookie。

webui +35 条、SDK +4 条、工具链 +4 条。关键几条:
- 根绝对路径必须原样到上游(选 Host 路由的核心理由)
- 上游 302 必须原样透传、且反代不得跟随(旧缺陷)
- 已知 Content-Length 的慢速响应必须逐帧到达(**这条经过变异验证**:
  把 FlushInterval 改回 0 后判据挂死 → FAIL,还原后回绿。
  说明:最初写的 SSE/chunked 版本是假判据——ReverseProxy 对
  text/event-stream 与 ContentLength=-1 会自动立即 flush,与
  FlushInterval 无关,变异抓不到,已改正)
- 子域标签冲突不得静默覆盖(后者保留可见并带原因)
- 非法声明不进路由但必须可见(配置页要能看到原因)
- 未声明 websocket 的升级请求必须 400
- auth 逐条生效:none 放行匿名、homeagent 与默认档 401 且给可操作提示
- 自动发现:显式 host 不得被自动编号覆盖(**测试抓到的真 bug**:
  remotedevice 声明的 "devices" 会被改成 "devices-2" 而静默失效)
- 真实端到端:生产实例 huawei_smarthome 的 UI(9444 字节)与其
  /api/status 经反代正确透传

go build ./... 通过;相关包全量测试通过。
internal/plugin/proc 的 TestStreaming_PublishLatencyFlatAcrossSubscribers
是**预存在的不稳定测试**(同一份代码 10 次跑 9 过 1 败,且本改动完全
未触及该包),非本次引入。
This commit is contained in:
JianFeeeee
2026-09-25 12:13:03 +08:00
parent 4e40597954
commit 1e58af79d3
14 changed files with 2151 additions and 2 deletions

View File

@ -0,0 +1,727 @@
package webui
import (
"context"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
pluginpkg "gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 通用反向代理:插件声明自带 HTTP 服务(plugin.json 的 proxies),
// HomeAgent 按 **Host 子域标签** 把它们从 webui 的同一端口反代出去。
//
// ## 为什么是 Host 路由而不是路径前缀
//
// 插件自带 UI 普遍使用根绝对路径(实测 huawei_smarthome 的前端是
// `api('/api/status')` → `fetch('/api/status')`)。若挂在 `/p/huawei/` 下,
// 这些请求会打到 HomeAgent 自己的 `/api/status`,**静默错路由**。路径前缀方案
// 要么要求所有插件改前端,要么做 HTML/JS 内容重写——后者对拼进 JS 字符串的
// 绝对路径只是"按概率能用",会产生「页面能开、某个按钮就坏」的静默故障。
//
// Host 路由下,插件前端的根路径天然正确,**插件零改动**。且它正好匹配
// 「只穿透一个端口」:webui 监听 0.0.0.0:8080,按 Host 分发;外层 frp/nginx
// 是单端口隧道,**不需要为每个插件加一条映射**。
//
// ## 默认基座:*.localhost(零配置)
//
// RFC 6761 规定 `*.localhost` 必须解析到 loopback,现代浏览器原生支持。
// 于是默认基座是 `<标签>.localhost:<webui端口>`——装完即可用,
// **不需要 DNS、证书、/etc/hosts 或任何配置**。远程访问时配置 `base_domain`
// (如 webui.example.com)即切成 `<标签>.webui.example.com`。
//
// ## 认证
//
// 逐条由插件声明(ProxyAuthHomeAgent / ProxyAuthNone),默认 HomeAgent 统一保护。
// 但 Host 路由下**子域与主门户不同源**,浏览器不会把门户的 homeagent_session
// 发给子域——所以受保护模式下由反代层校验门户会话/API Key,校验通过后放行。
// 详见 authorizeProxy。
// ProxyRoute 是一条**已解析**的反代路由(声明 + 归属插件 + 校验结果)。
type ProxyRoute struct {
Plugin string // 声明该服务的插件名
Name string // 声明内的服务标识(展示用,如 "ui")
Host string // 子域标签(小写,已归一化)
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
WS bool // 是否允许 WebSocket 升级
Auth string // 生效的鉴权模式(已归一化)
Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示)
upstream *url.URL
reverse *httputil.ReverseProxy
}
// proxyTable 是全部反代路由的**不可变快照**。
//
// 用快照 + 原子替换而不是加锁读写 map:反代处于每个请求的热路径上,
// 而声明只在启动/插件重载时变化。读路径无锁,重载时整体换指针。
type proxyTable struct {
routes map[string]*ProxyRoute // key = 小写 host 标签
ordered []*ProxyRoute // 稳定顺序(展示/配置页用)
base string // 基域名("" 表示用 localhost)
}
var (
proxyMu sync.RWMutex
proxySnap *proxyTable
proxyDirty bool // 声明有变更、需要重建快照
proxySeenVer int64 // 上次建表时看到的内置声明版本号
)
// manualProxyRoutes 是「手填」来源:用户在 webui 设置页配置的额外/覆盖条目。
//
// 保持原始文本(每行 `标签 上游地址 [选项]`),解析在 rebuildProxyTable 里做,
// 解析失败不会让设置页爆炸,而是作为一条 Err 条目展示出来。
var manualProxyRoutes string
// SetManualProxyRoutes 注入手填的声明(插件 Start 时从设置读取)。
func SetManualProxyRoutes(raw string) {
proxyMu.Lock()
manualProxyRoutes = raw
proxyDirty = true
proxySnap = nil
proxyMu.Unlock()
}
// InvalidateProxyRoutes 标记声明有变更(插件启停/重载后调用)。
func InvalidateProxyRoutes() {
proxyMu.Lock()
proxyDirty = true
proxySnap = nil
proxyMu.Unlock()
}
// proxyBaseDomain 返回基域名:配置了就用,否则回落到 localhost。
func proxyBaseDomain(settings sdk.SettingsAPI) string {
if settings != nil {
if v, err := settings.Get("base_domain"); err == nil && v != nil {
if s, ok := v.(string); ok {
if d := strings.Trim(strings.ToLower(strings.TrimSpace(s)), "."); d != "" {
return d
}
}
}
}
return "localhost"
}
// proxyHostLabel 从请求 Host 里抽出子域标签。
//
// 处理三种输入:
// - `huawei.localhost:8080` → "huawei"
// - `huawei.webui.example.com`(base_domain=webui.example.com)→ "huawei"
// - `webui.example.com`(基域名本身)→ ""(不是插件路由,交给主站)
//
// 刻意只认**单层**子域(不匹配 `a.b.webui.example.com`):多级标签会让
// 「哪个是插件、哪个是基域」变得含混,且容易被 `..` 类输入绕过判断。
func proxyHostLabel(host, base string) string {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return ""
}
if hp, _, err := net.SplitHostPort(h); err == nil {
h = hp
} else if i := strings.LastIndexByte(h, ':'); i >= 0 {
// 无括号的 IPv6 等异常输入:丢弃端口段
h = h[:i]
}
h = strings.Trim(h, ".")
if base == "" {
base = "localhost"
}
base = strings.ToLower(base)
if h == base {
return ""
}
suffix := "." + base
if !strings.HasSuffix(h, suffix) {
return ""
}
label := strings.TrimSuffix(h, suffix)
// 只认单层标签:出现 `.` 说明是多级子域,不接。
if label == "" || strings.Contains(label, ".") {
return ""
}
return label
}
// buildProxyTable 由「插件声明 + 手填条目」构建路由表。
//
// 冲突与非法条目的处理原则:**宁可报出来,不可静默丢弃**。被拒绝的条目
// 仍会出现在表里(Err 非空),在配置页可见;只是不参与路由。
func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.SettingsAPI) *proxyTable {
base := proxyBaseDomain(settings)
t := &proxyTable{routes: map[string]*ProxyRoute{}, base: base}
add := func(r *ProxyRoute) {
t.ordered = append(t.ordered, r)
if r.Err != "" {
return
}
key := strings.ToLower(r.Host)
if prev, dup := t.routes[key]; dup {
// 冲突:保留先到者,后来者标错。**不做后者覆盖**——那会让先声明者
// 静默消失,用户以为两个插件都挂上了。
r.Err = fmt.Sprintf("子域标签 %q 已被插件 %s 的服务 %s 占用", r.Host, prev.Plugin, prev.Name)
return
}
t.routes[key] = r
}
for _, d := range decls {
r := &ProxyRoute{
Plugin: d.Plugin,
Name: d.Name,
Host: d.Host,
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
} else if r.Name == "" {
r.Name = d.Plugin
}
add(r)
}
for _, d := range parseManualRoutes(manualText) {
r := &ProxyRoute{
Plugin: "manual",
Name: d.Name,
Host: d.Host,
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
}
add(r)
}
// 给合法路由预建 ReverseProxy(每条一个,避免每请求分配)。
for _, r := range t.ordered {
if r.Err != "" {
continue
}
u, err := parseUpstream(r.Target)
if err != nil {
r.Err = "解析上游地址失败: " + err.Error()
delete(t.routes, strings.ToLower(r.Host))
continue
}
r.upstream = u
r.reverse = newReverseProxy(u)
log.Printf("[webui] 反代: %s.%s → %s (plugin=%s ws=%v auth=%s)",
r.Host, base, r.Target, r.Plugin, r.WS, r.Auth)
}
sort.SliceStable(t.ordered, func(i, j int) bool {
if t.ordered[i].Err != t.ordered[j].Err {
return t.ordered[i].Err == ""
}
if t.ordered[i].Host != t.ordered[j].Host {
return t.ordered[i].Host < t.ordered[j].Host
}
return t.ordered[i].Plugin < t.ordered[j].Plugin
})
return t
}
// parseUpstream 把声明里的 Target 解析成 *url.URL。
// 允许省略 scheme(默认 http)与端口(http→80 / https→443)。
func parseUpstream(target string) (*url.URL, error) {
s := strings.TrimSpace(target)
if !strings.Contains(s, "://") {
s = "http://" + s
}
u, err := url.Parse(s)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("只支持 http/https 上游,得到 %q", u.Scheme)
}
if u.Host == "" {
return nil, fmt.Errorf("缺少主机部分")
}
return u, nil
}
// newReverseProxy 构造一个 httputil.ReverseProxy。
//
// 一次性消除手写反代的历史缺陷:
// 1. **逐帧 Flush**:httputil.ReverseProxy 在响应带 FlushInterval 或识别到
// text/event-stream 时会 Flush;这里显式设 -1(立即 flush),否则上游的
// SSE/流式响应会被缓冲到上游关闭才下发(旧实现实测:3 帧 200ms 间隔的
// 流,客户端在 +600ms 一次性收到全部)。
// 2. **不跟随上游 3xx**:旧实现用 http.DefaultClient(默认跟最多 10 跳),
// 上游 302 到内网地址时反代自己跟过去、失败就回 502,并把内网 URL
// 泄给客户端。ReverseProxy 默认不跟随重定向,3xx 原样透传。
// 3. **补齐转发头**:SetXForwarded 注入 X-Forwarded-For/Host/Proto,
// 旧实现完全不注入,上游无法判断真实来源。
func newReverseProxy(u *url.URL) *httputil.ReverseProxy {
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(u)
pr.SetXForwarded()
// 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。
pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host)
// 上游可能自带鉴权,浏览器带来的门户 cookie 不应泄漏给它。
pr.Out.Header.Del("Cookie")
pr.Out.Header.Del("Authorization")
pr.Out.Header.Del("X-API-Key")
},
FlushInterval: -1, // 立即 flush:SSE/长轮询逐帧下发
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[webui] 反代 %s 失败: %v", r.Host, err)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `{"error":"上游不可达: %s"}`, strings.ReplaceAll(err.Error(), `"`, `'`))
},
ModifyResponse: func(resp *http.Response) error {
// 上游 Set-Cookie 的 Path 若为 "/",会因 Host 路由而只作用于该子域,
// 天然隔离,无需重写。但 Domain 若被上游写成裸域会把 cookie 打到
// 主门户域上,属于跨插件越权——剥掉它,交给浏览器按当前 host 收窄。
cookies := resp.Cookies()
if len(cookies) > 0 {
resp.Header.Del("Set-Cookie")
for _, c := range cookies {
c.Domain = ""
resp.Header.Add("Set-Cookie", c.String())
}
}
return nil
},
}
return rp
}
// currentProxyTable 返回当前快照;需要时按声明重建。
//
// declProvider 由插件在 Start 时注入(见 handler.go 的 SetProxyDeclProvider),
// 它负责从插件目录读 manifest 并归一化。这里做成回调而不是直接依赖内部状态,
// 是为了让反代层可单测(测试注入假声明)。
var declProvider func() []proxyDecl
// SetProxyDeclProvider 注入「读取全部插件声明」的回调。
func SetProxyDeclProvider(fn func() []proxyDecl) { declProvider = fn }
func currentProxyTable() *proxyTable {
// 内置插件的声明是运行期登记的(插件 Start 时),版本号一变就重建——
// 比每次请求都重新聚合一遍便宜得多。
if sdk.BuiltinProxyVersion() != proxySeenVer {
proxyMu.Lock()
proxyDirty = true
proxyMu.Unlock()
}
proxyMu.RLock()
if !proxyDirty && proxySnap != nil {
t := proxySnap
proxyMu.RUnlock()
return t
}
manual := manualProxyRoutes
proxyMu.RUnlock()
proxyMu.Lock()
defer proxyMu.Unlock()
if proxySnap != nil && !proxyDirty {
return proxySnap
}
var decls []proxyDecl
if declProvider != nil {
decls = declProvider()
}
// 内置插件的运行期声明(无 plugin.json,扫目录发现不到)。
for plugin, list := range sdk.BuiltinProxyDecls() {
for _, d := range list {
host := d.Host
if host == "" {
host = sdk.NormalizeProxyHost(plugin)
}
name := d.Name
if name == "" {
name = "service"
}
decls = append(decls, proxyDecl{
Plugin: plugin, Name: name, Host: strings.ToLower(host),
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
})
}
}
proxySnap = buildProxyTable(decls, manual, nil)
proxyDirty = false
proxySeenVer = sdk.BuiltinProxyVersion()
return proxySnap
}
// serveProxyHost 是挂在根路由前的 Host 分发入口。
// 返回 true 表示已处理该请求。
func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
base := "localhost"
if h.settings != nil {
base = proxyBaseDomain(h.settings)
}
label := proxyHostLabel(r.Host, base)
if label == "" {
return false
}
t := currentProxyTable()
route, ok := t.routes[label]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("没有插件声明子域 %q(基域名 %s)", label, base),
"hint": "在插件 plugin.json 的 proxies 里声明,或在 webui 设置页手填",
})
return true
}
// WebSocket 升级必须由插件显式声明。未声明时明确拒绝,而不是把升级请求
// 当普通请求透传——后者表现为前端不断重连、日志里看不出原因。
if isWebSocketUpgrade(r) && !route.WS {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", route.Plugin, route.Name),
})
return true
}
if route.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) {
return true // 已写 401
}
if route.upstream == nil || route.reverse == nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "路由未就绪"})
return true
}
route.reverse.ServeHTTP(w, r)
return true
}
// authorizeProxy 校验受保护路由的访问者。
//
// Host 路由下子域与门户不同源,浏览器**不会**自动带上门户 cookie;因此这里
// 接受三种凭证,任一通过即放行:
// 1. 门户会话 cookie(用户同浏览器访问过门户时;
// SameSite=Lax 在同站子域导航下会带上,同站不同源仍算同一 site);
// 2. X-API-Key / Bearer(脚本与非浏览器客户端);
// 3. `?__token=` 查询参数(便于在新标签页里直接打开,见"服务入口")。
//
// 三者都没有时返回 401 并给出**可操作提示**(告诉用户先登录门户),
// 而不是把请求静默透传给上游。
func (h *Handler) authorizeProxy(w http.ResponseWriter, r *http.Request) bool {
if h.validAPIKey(r) || h.validSession(r) {
return true
}
// ?__token= 形式:与 X-API-Key 同一把密钥,用于「点一下直接打开」的入口。
if tok := strings.TrimSpace(r.URL.Query().Get("__token")); tok != "" {
apiKey, _, _, _ := h.getWebUIConfig()
if apiKey != "" && tok == apiKey {
return true
}
}
w.Header().Set("WWW-Authenticate", `Bearer realm="homeagent"`)
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "该服务由 HomeAgent 统一保护,需要先登录门户或用 X-API-Key 访问",
"hint": "浏览器先访问门户登录;脚本用 -H 'X-API-Key: <key>' 或 ?__token=<key>",
})
return false
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
// proxyServiceEntry 是「服务入口」条目:给前端渲染选项卡用。
type proxyServiceEntry struct {
Plugin string `json:"plugin"`
PluginZh string `json:"plugin_name"`
Name string `json:"name"`
Host string `json:"host"`
URL string `json:"url"` // 完整可点 URL(带端口/协议,按当前请求推导)
Auth string `json:"auth"` // homeagent | none
WS bool `json:"websocket"`
Target string `json:"target"`
OK bool `json:"ok"` // false = 声明被拒或上游不可达(见 error)
Error string `json:"error,omitempty"`
}
// listProxyServices 汇总反代服务清单(含被拒条目,供配置页排错)。
// schemePort 由调用方按当前请求推导(本机 http:8080 / 远程 https:443 等)。
func (h *Handler) listProxyServices(scheme, hostPort string) []proxyServiceEntry {
t := currentProxyTable()
metas := map[string]sdk.PluginMeta{}
if h.pluginMgr != nil {
metas = h.pluginMgr.PluginMetas()
}
out := make([]proxyServiceEntry, 0, len(t.ordered))
for _, r := range t.ordered {
e := proxyServiceEntry{
Plugin: r.Plugin,
Name: r.Name,
Host: r.Host,
Auth: r.Auth,
WS: r.WS,
Target: r.Target,
OK: r.Err == "",
Error: r.Err,
}
if m, ok := metas[r.Plugin]; ok {
e.PluginZh = m.NameZh
}
if e.PluginZh == "" {
e.PluginZh = r.Plugin
}
if r.Err == "" {
e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, t.base, hostPort)
}
out = append(out, e)
}
return out
}
// ---- 声明读取(自动发现)----
// proxyDecl 是归一化后的插件声明(与 SDK 的 ProxyDecl 同形,另带插件名)。
type proxyDecl struct {
Plugin string
Name string
Host string
Target string
WebSocket bool
Auth string
}
// readPluginProxyDecls 扫描插件目录里的 plugin.json,聚合 proxies 声明。
//
// 为什么在 webui 侧读而不是问内核:manifest 解析(internal/plugin.ReadManifest)
// 是纯文件读取,webui 已能拿到插件目录(sdk.PluginManager.PluginDir()),
// 这样**无需给内核接口加方法**即可实现自动发现——插件进程没起来也照样发现,
// 便于给出「声明了但不可达」的准确报错。
//
// 内置插件(编译进内核、无独立目录)不参与:它们要暴露服务应走内核自身路由。
func readPluginProxyDecls(pluginDir string) []proxyDecl {
if pluginDir == "" {
return nil
}
entries, err := readDirNames(pluginDir)
if err != nil {
log.Printf("[webui] 读取插件目录失败(反代自动发现跳过): %v", err)
return nil
}
var out []proxyDecl
for _, name := range entries {
m, err := pluginpkg.ReadManifest(filepath.Join(pluginDir, name))
if err != nil {
continue // 非插件目录/无 manifest:静默跳过
}
for i, p := range m.Proxies {
host := strings.TrimSpace(p.Host)
explicitHost := host != ""
if !explicitHost {
host = sdk.NormalizeProxyHost(m.Name)
}
sname := strings.TrimSpace(p.Name)
if sname == "" {
sname = "service"
}
// 只在**未显式指定** host 时加序号:插件写死的 host 是它的
// 刻意选择(如 remotedevice 的 "devices"),被自动编号覆盖会让
// 声明静默失效——用户按文档访问 devices.<base> 就是 404。
if !explicitHost && i > 0 {
host = fmt.Sprintf("%s-%d", host, i+1)
}
out = append(out, proxyDecl{
Plugin: m.Name,
Name: sname,
Host: strings.ToLower(host),
Target: p.Target,
WebSocket: p.WebSocket,
Auth: p.Auth,
})
}
}
return out
}
// parseManualRoutes 解析手填条目。格式(每行一条,空行与 # 注释跳过):
//
// <子域标签> <上游地址> [ws] [auth=none|homeagent]
//
// 例:
//
// grafana 127.0.0.1:3000
// devices 127.0.0.1:9890 ws auth=none
//
// 手填条目与插件声明同表竞争:先声明者占住标签,后来者(无论来源)被标错,
// 便于用户发现"我手填的标签和某插件撞了"。
func parseManualRoutes(text string) []proxyDecl {
var out []proxyDecl
for _, rawLine := range strings.Split(text, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
out = append(out, proxyDecl{
Plugin: "manual", Name: "invalid", Host: "invalid",
Target: "",
Auth: "homeagent",
})
continue
}
d := proxyDecl{Plugin: "manual", Name: fields[0], Host: strings.ToLower(fields[0]), Target: fields[1]}
for _, opt := range fields[2:] {
lo := strings.ToLower(opt)
switch {
case lo == "ws" || lo == "websocket":
d.WebSocket = true
case strings.HasPrefix(lo, "auth="):
d.Auth = strings.TrimPrefix(lo, "auth=")
default:
d.Target = ""
}
}
out = append(out, d)
}
return out
}
// ---- 小工具(避免为两行逻辑引入额外依赖文件)----
func readDirNames(dir string) ([]string, error) {
f, err := os.Open(dir)
if err != nil {
return nil, err
}
defer f.Close()
return f.Readdirnames(-1)
}
// proxyUpstreamReachable 探测上游是否可达(供服务入口列表显示状态)。
func proxyUpstreamReachable(target string) error {
u, err := parseUpstream(target)
if err != nil {
return err
}
host := u.Host
if _, _, err := net.SplitHostPort(host); err != nil {
if u.Scheme == "https" {
host = net.JoinHostPort(host, "443")
} else {
host = net.JoinHostPort(host, "80")
}
}
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
var d net.Dialer
c, err := d.DialContext(ctx, "tcp", host)
if err != nil {
return err
}
return c.Close()
}
// ---- HTTP 接口:服务入口清单 ----
// proxySchemeAndPort 按当前请求推导对外访问的 scheme 与端口。
//
// 为什么不能写死 http:8080:远程访问常经 nginx/frp(https:443),
// 写死会让「服务入口」给出的链接点不开。优先取反代头,回退请求自身。
func (h *Handler) proxySchemeAndPort(r *http.Request) (string, string) {
scheme := "http"
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
scheme = strings.ToLower(strings.TrimSpace(strings.Split(p, ",")[0]))
} else if r.TLS != nil {
scheme = "https"
}
host := r.Host
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = strings.TrimSpace(strings.Split(h, ",")[0])
}
// Host 可能带端口;子域链接要沿用同一个端口(单端口穿透的前提)。
if _, port, err := net.SplitHostPort(host); err == nil {
return scheme, ":" + port
}
if scheme == "https" {
return scheme, "" // 443 省略
}
// 按 Host 头推断不出端口(如反代层剥了),退回到监听地址的端口。
if _, port, err := net.SplitHostPort(r.Host); err == nil {
return scheme, ":" + port
}
if h.hostPort != "" {
return scheme, h.hostPort
}
return scheme, ""
}
// handleProxyServices 返回反代服务清单(含被拒条目与可达性)。
func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
scheme, port := h.proxySchemeAndPort(r)
svcs := h.listProxyServices(scheme, port)
// 可达性探测:并发带超时,避免一个坏上游拖住整个清单。
var wg sync.WaitGroup
for i := range svcs {
if !svcs[i].OK {
continue
}
wg.Add(1)
go func(e *proxyServiceEntry) {
defer wg.Done()
if err := proxyUpstreamReachable(e.Target); err != nil {
e.OK = false
e.Error = "上游不可达: " + err.Error()
}
}(&svcs[i])
}
wg.Wait()
base := "localhost"
if h.settings != nil {
base = proxyBaseDomain(h.settings)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"services": svcs,
"base_domain": base,
"scheme": scheme,
"port": port,
})
}
// handleProxyInfo 返回反代能力总览(给设置页说明用)。
func (h *Handler) handleProxyInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
t := currentProxyTable()
mode := "host"
writeJSON(w, http.StatusOK, map[string]interface{}{
"mode": mode,
"base_domain": t.base,
"count": len(t.routes),
"total": len(t.ordered),
"manual": strings.TrimSpace(manualProxyRoutes) != "",
})
}