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") }