Files
HomeDash/admin-server/gateway.go
HomeDash Bot 19d082e70a 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 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
2026-09-11 20:17:57 +08:00

245 lines
9.5 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 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")
}