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

244
admin-server/gateway.go Normal file
View File

@ -0,0 +1,244 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ── 统一入口(gateway)路由表 ──
//
// 目标:把"哪个域名反代到哪个内网服务、要不要走统一登录认证"变成**后台可编辑的
// 数据**,由本进程生成 nginx 配置并热加载,从而不再需要手工维护一份外部 nginx
// 配置(也就避开了"单文件 bind mount 不能用 sed -i"那类坑)。
//
// 设计要点:
// - 路由表单独存一个文件(不是 conf.yml):即使路由写坏了也不会影响导航页本身,
// 而且 conf.yml 是 Dashy 的配置,语义上不该混进来。
// - 生成物写在**容器自己的数据卷**里(include 目录),不碰宿主机文件。
// - 应用配置的顺序永远是:生成 → nginx -t 校验 → 通过才替换并 reload;
// 校验失败保留上一份可用配置并把 nginx 的报错回给调用方。
// Route 一条域名→后端映射。
type Route struct {
Host string `yaml:"host" json:"host"` // 例如 dashy.example.com
Upstream string `yaml:"upstream" json:"upstream"` // 例如 http://10.0.0.11:8080
RequireLogin bool `yaml:"requireLogin" json:"requireLogin"` // true=走统一认证;false=直接放行
Websocket bool `yaml:"websocket" json:"websocket"` // 是否透传 Upgrade(面板/终端类)
MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"` // 上传体积上限,0=不限
Note string `yaml:"note,omitempty" json:"note,omitempty"` // 备注,仅界面展示
}
// GatewayConfig 路由表 + 入口参数。
type GatewayConfig struct {
// ListenHTTPS 是否由本容器终结 TLS(证书挂进来时开启;否则只监听 HTTP)
ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
// CertFile/KeyFile 在 ListenHTTPS 为真时使用
CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
// AuthURL 统一认证的校验地址(auth_request)。例如 http://127.0.0.1:4001/_auth
AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
// LoginURL 未通过认证时的跳转地址
LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
// ListenHTTP/ListenHTTPSPort 监听端口,便于旁路验证(先跑 8081 再切 80)
ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"`
ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"`
Routes []Route `yaml:"routes" json:"routes"`
}
func defaultGatewayConfig() *GatewayConfig {
return &GatewayConfig{
ListenPort: 8081,
ListenTLSPort: 8443,
AuthURL: "http://127.0.0.1:4001/_auth",
LoginURL: "https://login.example.com/login",
Routes: []Route{},
}
}
var (
gatewayFile = envOr("NAVI_GATEWAY_FILE", filepath.Join(filepath.Dir(confFile), "gateway.yml"))
gatewayOutDir = envOr("NAVI_GATEWAY_OUT", filepath.Join(filepath.Dir(confFile), "gateway"))
)
func loadGateway() (*GatewayConfig, error) {
cfg := defaultGatewayConfig()
data, err := os.ReadFile(gatewayFile)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("路由表解析失败: %w", err)
}
if cfg.ListenPort == 0 {
cfg.ListenPort = 8081
}
if cfg.ListenTLSPort == 0 {
cfg.ListenTLSPort = 8443
}
if cfg.Routes == nil {
cfg.Routes = []Route{}
}
return cfg, nil
}
func saveGateway(cfg *GatewayConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(gatewayFile), 0o755); err != nil {
return err
}
tmp := gatewayFile + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, gatewayFile)
}
// ValidateRoute 只做语法层面的检查;连通性不在这里判断。
func ValidateRoute(r Route) error {
host := strings.TrimSpace(r.Host)
if host == "" {
return fmt.Errorf("域名不能为空")
}
if strings.ContainsAny(host, " \t/\\?#") || strings.Contains(host, "://") {
return fmt.Errorf("域名格式不对: %q", r.Host)
}
up := strings.TrimSpace(r.Upstream)
if up == "" {
return fmt.Errorf("%s: 后端地址不能为空", host)
}
if !strings.HasPrefix(up, "http://") && !strings.HasPrefix(up, "https://") {
return fmt.Errorf("%s: 后端必须是 http:// 或 https:// 开头", host)
}
if strings.ContainsAny(up, " \t\"'") {
return fmt.Errorf("%s: 后端地址含非法字符", host)
}
if r.MaxBodyMB < 0 {
return fmt.Errorf("%s: 上传上限不能为负", host)
}
return nil
}
// nginxQuote 把值安全地放进单引号里(host/upstream 都已拒绝引号,这里是兜底)。
func nginxQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "") + "'"
}
// RenderGatewayConf 生成 include 片段的全部 server 块。
//
// 认证是按**每条路由**决定的:RequireLogin 为真才生成 auth_request,
// 也就是说"哪些服务受统一认证保护"完全由后台的路由表决定。
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("配置为空")
}
hosts := make(map[string]bool, len(cfg.Routes))
for _, r := range cfg.Routes {
if err := ValidateRoute(r); err != nil {
return "", err
}
h := strings.TrimSpace(r.Host)
if hosts[h] {
return "", fmt.Errorf("域名重复: %s", h)
}
hosts[h] = true
}
// 先按域名排序,生成结果稳定,便于 diff 与人工核对
routes := append([]Route(nil), cfg.Routes...)
sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
var b strings.Builder
b.WriteString("# 由 HomeDash 后台生成,请勿手改:文件每次应用都会被整体覆盖。\n")
b.WriteString("# 源: " + gatewayFile + "\n\n")
for _, r := range routes {
host := strings.TrimSpace(r.Host)
up := strings.TrimSpace(r.Upstream)
port := cfg.ListenPort
if port == 0 {
port = 8081
}
fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n", port, host)
if r.MaxBodyMB > 0 {
fmt.Fprintf(&b, " client_max_body_size %dm;\n", r.MaxBodyMB)
}
if r.RequireLogin {
if cfg.AuthURL == "" {
return "", fmt.Errorf("%s: 需要认证但未配置认证地址", host)
}
fmt.Fprintf(&b, " # 该域名受统一认证保护\n")
fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
b.WriteString(" auth_request /_auth;\n")
b.WriteString(" error_page 401 = @login;\n")
fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
} else {
b.WriteString(" # 该域名不走统一认证\n")
}
b.WriteString(" location / {\n")
fmt.Fprintf(&b, " proxy_pass %s;\n", up)
b.WriteString(" proxy_http_version 1.1;\n")
b.WriteString(" proxy_set_header Host $host;\n")
b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
if r.Websocket {
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
b.WriteString(" proxy_read_timeout 3600s;\n")
}
b.WriteString(" proxy_redirect off;\n")
b.WriteString(" }\n}\n\n")
}
if cfg.ListenHTTPS {
if cfg.CertFile == "" || cfg.KeyFile == "" {
return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
}
tlsPort := cfg.ListenTLSPort
if tlsPort == 0 {
tlsPort = 8443
}
for _, r := range routes {
host := strings.TrimSpace(r.Host)
fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", tlsPort, host)
fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n",
nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile))
b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n")
// TLS 段复用同一条路由的认证与反代设置
if r.RequireLogin {
fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n")
fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
}
b.WriteString(" location / {\n")
fmt.Fprintf(&b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
b.WriteString(" proxy_http_version 1.1;\n proxy_set_header Host $host;\n")
b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
if r.Websocket {
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n")
}
b.WriteString(" proxy_redirect off;\n }\n}\n\n")
}
}
return b.String(), nil
}
// gatewayFileFor 生成物落地路径(include 目录里的固定文件名)。
func gatewayFileFor() string {
return filepath.Join(gatewayOutDir, "gateway.conf")
}

View File

@ -0,0 +1,132 @@
package main
import "testing"
func TestRenderGatewayConfAuthPerRoute(t *testing.T) {
cfg := &GatewayConfig{
ListenPort: 8081,
AuthURL: "http://127.0.0.1:4001/_auth",
LoginURL: "https://login.example.com/login",
Routes: []Route{
{Host: "protected.example.com", Upstream: "http://10.0.0.1:3000", RequireLogin: true, Websocket: true, MaxBodyMB: 64},
{Host: "open.example.com", Upstream: "http://10.0.0.2:8000"},
},
}
out, err := RenderGatewayConf(cfg)
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
// 受保护的域名要有 auth_request 与登录跳转
if !contains(out, "server_name protected.example.com;") {
t.Fatalf("缺少受保护域名的 server 块:\n%s", out)
}
if !contains(out, "auth_request /_auth;") {
t.Fatalf("受保护域名缺少 auth_request:\n%s", out)
}
if !contains(out, "location @login") {
t.Fatalf("缺少登录跳转")
}
if !contains(out, "proxy_set_header Upgrade $http_upgrade;") {
t.Fatalf("缺少 websocket 透传")
}
if !contains(out, "client_max_body_size 64m;") {
t.Fatalf("缺少上传上限")
}
// 不受保护的域名不得出现 auth_request("哪些服务受保护"是按路由决定的)
open := section(out, "server_name open.example.com;")
if open == "" {
t.Fatalf("缺少开放域名的 server 块")
}
if contains(open, "auth_request") {
t.Fatalf("未受保护的域名不应有 auth_request:\n%s", open)
}
if contains(open, "client_max_body_size") {
t.Fatalf("未设置上限时不应生成 client_max_body_size:\n%s", open)
}
}
func TestRenderGatewayConfRejectsBadInput(t *testing.T) {
base := func(r Route) *GatewayConfig {
return &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{r}}
}
cases := []struct {
name string
cfg *GatewayConfig
}{
{"空域名", base(Route{Host: "", Upstream: "http://a"})},
{"域名带协议", base(Route{Host: "http://a.com", Upstream: "http://a"})},
{"后端非 http", base(Route{Host: "a.com", Upstream: "10.0.0.1:80"})},
{"域名重复", &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{
{Host: "a.com", Upstream: "http://a"}, {Host: "a.com", Upstream: "http://b"},
}}},
{"需认证但无认证地址", &GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}}},
{"启用HTTPS但无证书", &GatewayConfig{ListenPort: 8081, ListenHTTPS: true, Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}},
}
for _, c := range cases {
if _, err := RenderGatewayConf(c.cfg); err == nil {
t.Errorf("%s: 应当报错但没有", c.name)
}
}
}
func TestGatewayRoundTrip(t *testing.T) {
dir := t.TempDir()
oldFile, oldOut := gatewayFile, gatewayOutDir
gatewayFile = dir + "/gateway.yml"
gatewayOutDir = dir + "/gateway"
defer func() { gatewayFile, gatewayOutDir = oldFile, oldOut }()
// 文件不存在时应给出默认配置而不是报错
cfg, err := loadGateway()
if err != nil {
t.Fatalf("缺文件应回落默认配置: %v", err)
}
if cfg.ListenPort != 8081 || len(cfg.Routes) != 0 {
t.Fatalf("默认配置不对: %+v", cfg)
}
cfg.Routes = []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}
if err := saveGateway(cfg); err != nil {
t.Fatalf("保存失败: %v", err)
}
got, err := loadGateway()
if err != nil || len(got.Routes) != 1 || !got.Routes[0].RequireLogin {
t.Fatalf("回读不一致: %+v err=%v", got, err)
}
}
func contains(hay, needle string) bool {
return len(hay) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}
// section 取出 server_name 所属的那个 server 块(到下一个 server{ 之前)
func section(all, marker string) string {
i := indexOf(all, marker)
if i < 0 {
return ""
}
start := i
for start > 0 && all[start-1] != '\n' {
start--
}
rest := all[i:]
if j := indexOf(rest, "\nserver {"); j >= 0 {
return rest[:j]
}
return rest
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
return -1
}

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 {