feat(gateway): 统一入口路由表(后端基础层)——"哪些服务走统一认证"变成一条数据

目标:不再手工维护外部 nginx 配置,把"域名→内网后端、是否走统一登录认证"
变成后台可编辑的数据,由本进程生成 nginx 配置并热加载。

本提交只做基础层(纯 Go、可测、零行为变更):

- gateway.go:Route/H gatewayConfig 数据模型 + ValidateRoute + RenderGatewayConf
  · 认证**按路由**决定:RequireLogin 为真才生成 auth_request 与登录跳转,
    也就是"哪些服务受统一认证保护"完全由路由表决定
  · 支持 websocket 透传、单域名上传上限、HTTPS 段(可同时生成旁路端口)
  · 路由表单独存 gateway.yml(不混进 Dashy 的 conf.yml),写坏了也不影响导航页
- API:GET/POST /admin/api/gateway/routes(保存)、POST /admin/api/gateway/apply
  · apply 的顺序是 生成 → nginx -t 校验 → 通过才 reload;**校验失败把上一份可用
    配置写回去并把 nginx 原始报错回给界面**,避免"点了应用整个入口挂掉"
- nginx.conf 加 include /app/data/gateway/*.conf(生成物在容器自己的卷里,
  不碰宿主机文件,天然避开"单文件 bind mount 不能用 sed -i"的坑)
- 生成物默认监听旁路端口(8081/8443):可以先与正式入口并存验证,再切 80/443

测试:受保护域名必须有 auth_request、未受保护域名必须没有;websocket/上传上限;
空域名/带协议域名/非 http 后端/域名重复/需认证却无认证地址/启用 HTTPS 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
This commit is contained in:
HomeDash Bot
2026-09-11 20:17:57 +08:00
parent a6b5513f88
commit 19d082e70a
4 changed files with 508 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@ -995,6 +996,130 @@ func safeIconName(hint, filename, ext string) string {
return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext)
}
// firstLines 取前 n 行,避免把 nginx 的长输出整段塞进界面。
func firstLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
// ── 统一入口(gateway)路由表 API ──
//
// GET /api/gateway/routes → 当前路由表(含"哪些域名走统一认证")
// POST /api/gateway/routes → 整体替换并保存(不立即生效)
// POST /api/gateway/apply → 生成配置 → nginx -t 校验 → 通过才 reload
//
// 校验失败时保留上一份可用配置,并把 nginx 的原始报错回给界面,
// 避免"点了应用,整个入口挂了"。
func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg)
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req GatewayConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
if req.Routes == nil {
req.Routes = []Route{}
}
for _, rt := range req.Routes {
if err := ValidateRoute(rt); err != nil {
jsonErr(w, 400, err.Error())
return
}
}
if _, err := RenderGatewayConf(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
if req.ListenPort == 0 {
req.ListenPort = 8081
}
if req.ListenTLSPort == 0 {
req.ListenTLSPort = 8443
}
if err := saveGateway(&req); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"status": "saved"})
default:
jsonErr(w, 405, "method not allowed")
}
}
func nginxCommand(args ...string) (string, error) {
cmd := exec.Command("nginx", args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func handleGatewayApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
conf, err := RenderGatewayConf(cfg)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if err := os.MkdirAll(gatewayOutDir, 0o755); err != nil {
jsonErr(w, 500, err.Error())
return
}
target := gatewayFileFor()
prev, hadPrev := []byte{}, false
if b, err := os.ReadFile(target); err == nil {
prev, hadPrev = b, true
}
if err := os.WriteFile(target, []byte(conf), 0o644); err != nil {
jsonErr(w, 500, err.Error())
return
}
// 校验:不通过就把上一份写回去,保证入口始终有一份可用配置
if out, err := nginxCommand("-t"); err != nil {
if hadPrev {
_ = os.WriteFile(target, prev, 0o644)
} else {
_ = os.Remove(target)
}
jsonErr(w, 400, "配置未通过 nginx 校验,已保留上一份可用配置:\n"+firstLines(out, 12))
return
}
if out, err := nginxCommand("-s", "reload"); err != nil {
jsonErr(w, 500, "reload 失败:"+firstLines(out, 6))
return
}
jsonResp(w, map[string]any{"status": "applied", "routes": len(cfg.Routes), "file": target})
}
func main() {
// Ensure conf file exists
if _, err := os.Stat(confFile); os.IsNotExist(err) {
@ -1027,6 +1152,8 @@ func main() {
http.HandleFunc("/api/wallpaper", cors(handleWallpaper))
http.HandleFunc("/api/sync-info", cors(handleSyncInfo))
http.HandleFunc("/api/frameable", cors(handleFrameable))
http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes))
http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply))
// 历史记录管理:列表 / 清空全部 / 删除单条
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {