mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
配套 webui 反代改造:网关现在可由 HomeAgent 反代出去,客户端不能再靠
「门户地址同 host 拼 /api/v1/device/ws」猜地址——基域名与子域标签都是
**服务端配置**,客户端无从得知。
## 服务端:/api/v1/device/gateway 发现端点
客户端问「网关在哪」是唯一不会漂移的做法:子域标签可改(插件声明)、
基域名可改(webui.base_domain)、实例可换形态,客户端都不用跟着改。
⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令;
把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。令牌仍由客户端
自配。已有判据钉住「不得泄漏凭证字段」。
## ★ 实测发现:*.localhost 只有浏览器能解析
这是本轮最重要的发现,直接决定了设计:
| 环境 | devices.localhost 解析 |
|---|---|
| 浏览器 | ✓(RFC 6761 内置) |
| curl | ✓(内置特例) |
| getent / Go / Node | ✗(系统 nsswitch 是 files,dns,无 nss-myhostname) |
设备客户端(waiter / GUI 主进程 / 嵌入式固件)用的正是系统解析器。
实测 waiter 报「lookup devices.localhost on 192.168.2.1:53」。
因此**两处**设计变更:
1. 发现端点同时返回两种形态,并标 preferred:
- url(子域)—— 浏览器用
- url_portal(门户同源,同一 host、同一端口,走路径挂载)—— 非浏览器用,
无任何 DNS 依赖
2. SDK 的 ProxyDecl 新增 **Path**(路径挂载前缀):让同一服务同时挂到
门户自身 host 的路径下。remotedevice 声明 Path="/api/v1/device",
设备客户端因此能沿用**它已硬编码的路径**,不需要知道反代存在。
路径挂载语义:请求路径**原样保留**(不剥前缀),上游按真实路径注册即可。
边界卡在路径分隔符上(/api/v1/device 不匹配 /api/v1/devicefoo)。
## 客户端
- **waiter**:新增 discoverGateway(),仅在用户配了门户地址时尝试,失败回退
自配地址(老版本 HomeAgent 无该端点)。抽出 normalizeGateway() 纯函数,
显式钉住「已带子域/完整端点的地址不得被改写」。
- **GUI**:renderer 新增 loadDiscoveredGateway(),renderDeviceChannel 优先用
发现值、回退旧口径。顺带修掉此前插入函数时 anchor 不匹配导致调用点
找不到定义的问题。
- **鸿蒙**:discoverGateway() + resolveGatewayUrl(),优先 url_portal。
- 三者都**优先 url_portal**(system resolver 的现实约束)。
## 遗留路由鉴权修正
`/api/v1/device/` 的旧路径反代原被 requireAPI 包裹 —— 但其调用方是设备
(带设备令牌而非门户凭证),套上门户鉴权会把它们全挡在 401(**真实实测**:
waiter 经此路径升级握手 401)。去掉这层包装,鉴权交给上游 remotedevice
自己的 requireToken,安全性不降级。
## 判据
webui +6 条、waiter +7 条。
★ 其中一条是**真实回归**:/api/v1/device/gateway 曾被 Path="/api/v1/device"
的路径挂载接走(那服务 auth=none),于是发现请求被转给上游、回 401,
客户端再也发现不到网关。修法是发现端点先于路径挂载判定,并补判据
(走完整生产链,同时确认同前缀的真实设备路径仍归反代)。
## 真实验收(隔离实例,命名 netns + 独立 data + 18080)
真 waiter 客户端 + 真 remotedevice 网关:
device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
device bridge active: waiter-mainserver authorized=true
hello_ack / bind_ack 均经反代往返成功
说明:`bind_ack device=<nil>` 与在线列表为空的现象,**直连 9890 绕开反代
完全一致复现**,属 remotedevice 与 waiter 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
928 lines
32 KiB
Go
928 lines
32 KiB
Go
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 // 子域标签(小写,已归一化)
|
||
Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
|
||
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 标签
|
||
paths map[string]*ProxyRoute // key = 路径挂载前缀(按最长前缀匹配)
|
||
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{}, paths: 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
|
||
if r.Path != "" {
|
||
if prev, dup := t.paths[r.Path]; dup {
|
||
r.Err = fmt.Sprintf("路径前缀 %q 已被插件 %s 的服务 %s 占用", r.Path, prev.Plugin, prev.Name)
|
||
delete(t.routes, key)
|
||
return
|
||
}
|
||
t.paths[r.Path] = r
|
||
}
|
||
}
|
||
|
||
for _, d := range decls {
|
||
r := &ProxyRoute{
|
||
Plugin: d.Plugin,
|
||
Name: d.Name,
|
||
Host: d.Host,
|
||
Path: strings.TrimSpace(d.Path),
|
||
Target: d.Target,
|
||
WS: d.WebSocket,
|
||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||
}
|
||
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
|
||
Name: d.Name, Host: d.Host, Path: d.Path,
|
||
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,
|
||
Path: strings.TrimSpace(d.Path),
|
||
Target: d.Target,
|
||
WS: d.WebSocket,
|
||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||
}
|
||
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
|
||
Name: d.Name, Host: d.Host, Path: d.Path,
|
||
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, r.Auth)
|
||
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, auth string) *httputil.ReverseProxy {
|
||
stripCredentials := auth == sdk.ProxyAuthHomeAgent
|
||
rp := &httputil.ReverseProxy{
|
||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||
pr.SetURL(u)
|
||
pr.SetXForwarded()
|
||
// 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。
|
||
pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host)
|
||
// 凭证头的处理**必须按路由的 auth 分开**:
|
||
//
|
||
// auth=homeagent:凭证是给门户的(会话 cookie / 门户 API Key),
|
||
// 上游不需要也不该看到它们 ⇒ 剥掉,避免把门户凭证泄漏给插件。
|
||
// auth=none:请求就是要原样交给上游的,凭证本来就是给**上游**的
|
||
// (设备网关的接入令牌正是通过 X-API-Key 传的)⇒ 必须保留。
|
||
//
|
||
// 这里踩过一次真实故障:无条件剥 X-API-Key 导致 auth=none 的设备
|
||
// 链路全部 401(直连 9890 是 200,经反代却 401)。单测用的是不校验
|
||
// 凭证的假上游,抓不到;是**真实端到端**(真设备网关 + 真令牌)发现的。
|
||
if stripCredentials {
|
||
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),
|
||
Path: strings.TrimSpace(d.Path),
|
||
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
|
||
})
|
||
}
|
||
}
|
||
proxySnap = buildProxyTable(decls, manual, nil)
|
||
proxyDirty = false
|
||
proxySeenVer = sdk.BuiltinProxyVersion()
|
||
return proxySnap
|
||
}
|
||
|
||
// matchProxyPath 按**最长前缀**匹配路径挂载的服务。
|
||
//
|
||
// 边界要卡在路径分隔符上:/api/v1/device 不能匹配 /api/v1/devicefoo
|
||
// (否则会劫持同前缀的其它路径)。返回剩余部分供上游使用。
|
||
func (t *proxyTable) matchProxyPath(p string) (*ProxyRoute, bool) {
|
||
var best *ProxyRoute
|
||
for prefix, r := range t.paths {
|
||
if p == prefix || strings.HasPrefix(p, prefix+"/") {
|
||
if best == nil || len(prefix) > len(best.Path) {
|
||
best = r
|
||
}
|
||
}
|
||
}
|
||
return best, best != nil
|
||
}
|
||
|
||
// serveProxyHost 是挂在根路由前的 Host 分发入口。
|
||
// 返回 true 表示已处理该请求。
|
||
func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
|
||
base := "localhost"
|
||
if h.settings != nil {
|
||
base = proxyBaseDomain(h.settings)
|
||
}
|
||
// ★ 发现端点必须先于路径挂载判定。
|
||
//
|
||
// 否则它会被 /api/v1/device 这类前缀接走:声明该前缀的服务通常
|
||
// auth=none(凭设备令牌),于是发现请求会被当成设备请求转给上游,
|
||
// 上游对 /api/v1/device/gateway 回 401 —— 客户端再也发现不到网关。
|
||
// (真实实测踩到:waiter 用门户地址发现时拿到 401 unauthorized。)
|
||
if r.URL.Path == "/api/v1/device/gateway" {
|
||
return false // 交给 mux 上的 requireAPI 处理
|
||
}
|
||
|
||
t := currentProxyTable()
|
||
|
||
// 先把「门户自身 host + 声明了 path」的请求交给对应服务。
|
||
//
|
||
// 这条分支让**非浏览器客户端**(设备/固件/CLI,走系统解析器解析不了
|
||
// *.localhost)也能用:它们连门户地址本身即可,不需要知道反代的存在。
|
||
// 路径原样保留 —— 客户端沿用它已有的路径。
|
||
if rt, ok := t.matchProxyPath(r.URL.Path); ok && proxyHostLabel(r.Host, base) == "" {
|
||
if isWebSocketUpgrade(r) && !rt.WS {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||
"error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", rt.Plugin, rt.Name),
|
||
})
|
||
return true
|
||
}
|
||
if rt.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) {
|
||
return true
|
||
}
|
||
if rt.reverse == nil {
|
||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "路由未就绪"})
|
||
return true
|
||
}
|
||
rt.reverse.ServeHTTP(w, r)
|
||
return true
|
||
}
|
||
|
||
label := proxyHostLabel(r.Host, base)
|
||
if label == "" {
|
||
return false
|
||
}
|
||
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"`
|
||
Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
|
||
URL string `json:"url"` // 子域形态(浏览器)
|
||
// URLPortal 是门户同源形态:挂在门户自身 host 的路径下,**无 DNS 依赖**。
|
||
// 非浏览器客户端(设备/固件/CLI)用系统解析器解析不了 *.localhost,用它。
|
||
URLPortal string `json:"url_portal,omitempty"`
|
||
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, portalHost 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,
|
||
Path: r.Path,
|
||
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)
|
||
if r.Path != "" {
|
||
e.URLPortal = fmt.Sprintf("%s://%s%s%s/", scheme, portalHost, hostPort, r.Path)
|
||
}
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- 声明读取(自动发现)----
|
||
|
||
// proxyDecl 是归一化后的插件声明(与 SDK 的 ProxyDecl 同形,另带插件名)。
|
||
type proxyDecl struct {
|
||
Plugin string
|
||
Name string
|
||
Host string
|
||
Path 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),
|
||
Path: strings.TrimSpace(p.Path),
|
||
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)
|
||
portalHost := r.Host
|
||
if hh := r.Header.Get("X-Forwarded-Host"); hh != "" {
|
||
portalHost = strings.TrimSpace(strings.Split(hh, ",")[0])
|
||
}
|
||
// 反代层看到的 Host 可能不含端口(nginx 默认剥掉),此时用监听端口补,
|
||
// 保证服务入口链接点得开。
|
||
if _, _, err := net.SplitHostPort(portalHost); err != nil {
|
||
if p := strings.TrimPrefix(port, ":"); p != "" {
|
||
if portalHost == "" {
|
||
portalHost = "localhost"
|
||
}
|
||
if _, _, e2 := net.SplitHostPort(portalHost + ":" + p); e2 == nil {
|
||
portalHost = portalHost + ":" + p
|
||
}
|
||
}
|
||
}
|
||
svcs := h.listProxyServices(scheme, port, portalHost)
|
||
// 可达性探测:并发带超时,避免一个坏上游拖住整个清单。
|
||
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) != "",
|
||
})
|
||
}
|
||
|
||
// ---- 设备网关发现(客户端自动链接的权威来源)----
|
||
|
||
// deviceGatewayRoute 找出本实例的设备网关反代路由。
|
||
//
|
||
// 为什么按「插件名 + 声明名」而不是按地址猜:地址是插件配置里可改的
|
||
// (remotedevice 的 listen_addr 就能改),按地址匹配会在改配置后静默失配。
|
||
// 声明归属是稳定的契约。
|
||
func deviceGatewayRoute(t *proxyTable) *ProxyRoute {
|
||
for _, r := range t.ordered {
|
||
if r.Err != "" {
|
||
continue
|
||
}
|
||
if r.Plugin == "remotedevice" && r.WS {
|
||
return r
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// handleDeviceGatewayDiscovery 返回设备网关的**可连接地址**,供客户端
|
||
// (GUI / 鸿蒙 / waiter / 设备固件)自动链接。
|
||
//
|
||
// 为什么需要它:改造成子域反代后,网关不再是「门户地址 + /api/v1/device/ws」——
|
||
// 硬拼路径的客户端会连到门户自己的路由上(那里没有 WS 升级处理),
|
||
// 或者根本连不上。而**客户端无从知道基域名与子域标签**(那是服务端配置)。
|
||
// 让服务端回答「网关在哪」是唯一不会漂移的做法:
|
||
// - 子域标签可改(插件声明)→ 客户端不用跟着改;
|
||
// - 基域名可改(webui.base_domain)→ 同上;
|
||
// - 实例可换成路径前缀模式 → 客户端拿到的仍是对的 URL。
|
||
//
|
||
// 返回的 url 用 ws/wss 前缀,可直接喂给 WebSocket 客户端。
|
||
//
|
||
// ⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令
|
||
// (cmdrun 等),把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。
|
||
// 令牌仍由客户端自己的配置提供(见部署说明)。
|
||
func (h *Handler) handleDeviceGatewayDiscovery(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)
|
||
wsScheme := "ws"
|
||
if scheme == "https" {
|
||
wsScheme = "wss"
|
||
}
|
||
t := currentProxyTable()
|
||
route := deviceGatewayRoute(t)
|
||
|
||
out := map[string]interface{}{
|
||
"base_domain": t.base,
|
||
"scheme": scheme,
|
||
"port": port,
|
||
// available=false 时,客户端应回退到自己配置的网关地址
|
||
// (老部署、或设备网关被显式关闭的实例)。
|
||
"available": route != nil,
|
||
}
|
||
if route == nil {
|
||
out["reason"] = "本实例没有声明设备网关反代(remotedevice 未加载,或未声明 websocket)"
|
||
out["hint"] = "在 webui 设置页手填,或在插件声明里加 proxies(host=devices, websocket=true)"
|
||
writeJSON(w, http.StatusOK, out)
|
||
return
|
||
}
|
||
// 两种形态都要给,因为**能解析 *.localhost 的只有浏览器**:
|
||
//
|
||
// 实测:浏览器 ✓ / curl ✓(各自内置 RFC 6761 特例),
|
||
// 但 getent 与 Go/Node 的解析器 ✗(系统 nsswitch 是 files,dns,
|
||
// 没有 nss-myhostname,也没有通配条目)。设备客户端(waiter / GUI
|
||
// 主进程 / 嵌入式固件)用的正是系统解析器。
|
||
//
|
||
// 所以:
|
||
// url —— 子域形态。浏览器用;基域名配成真实通配域名时通用。
|
||
// url_portal —— **门户同源形态**(同一 host、同一端口,走路径挂载)。
|
||
// 无任何 DNS 依赖,永远可解析 ⇒ 设备客户端的正确选择。
|
||
//
|
||
// 两者都是「同一个端口」,单端口穿透的前提不受影响。
|
||
out["host"] = route.Host + "." + t.base
|
||
out["url"] = wsScheme + "://" + route.Host + "." + t.base + port + "/api/v1/device/ws"
|
||
out["http_url"] = scheme + "://" + route.Host + "." + t.base + port
|
||
// 门户同源形态:用**客户端实际访问用的 host**,保证它一定能解析。
|
||
portalHost := r.Host
|
||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||
portalHost = strings.TrimSpace(strings.Split(h, ",")[0])
|
||
}
|
||
if route.Path != "" {
|
||
// 声明了路径挂载 ⇒ 门户同源形态就是它(无 DNS 依赖,设备客户端首选)
|
||
out["url_portal"] = wsScheme + "://" + portalHost + route.Path + "/ws"
|
||
out["path"] = route.Path
|
||
} else {
|
||
// 未声明 path:门户同源形态只能退回旧口径(门户自己的设备路由)
|
||
out["url_portal"] = wsScheme + "://" + portalHost + "/api/v1/device/ws"
|
||
}
|
||
out["preferred"] = "url_portal" // 对非浏览器客户端更稳(无 DNS 依赖)
|
||
out["auth"] = route.Auth
|
||
out["target"] = route.Target
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|