Files
HomeDash/admin-server/gateway.go
HomeDash Bot 5f6fca5c59 fix(gateway): 未知域名必须被兜底拒绝,不能落进某条路由的后端
实测发现:入口端口上若没有匹配 server_name 的请求,nginx 会交给该端口排序
第一的 server 块,于是一个打错的域名会访问到别的服务(若第一条恰好是开放
路由,等于把该服务暴露在错误域名下)。现在为 HTTP/HTTPS 各生成一个
default_server 返回 404,并在单测中断言其存在。
2026-09-11 20:34:20 +08:00

253 lines
10 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")
port := cfg.ListenPort
if port == 0 {
port = 8081
}
// 兜底 server:没有匹配到任何 server_name 的请求必须当场拒绝。
// 不加这一段,nginx 会把未知域名交给该端口上排序第一的 server 块——
// 也就是"打错域名会访问到别的服务"。404 而不是 444,便于排查。
fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 404 \"unknown host\\n\";\n}\n\n", port)
for _, r := range routes {
host := strings.TrimSpace(r.Host)
up := strings.TrimSpace(r.Upstream)
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
}
fmt.Fprintf(&b, "server {\n listen %d ssl default_server;\n server_name _;\n", tlsPort)
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 return 404 \"unknown host\\n\";\n}\n\n")
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")
}