Files
HomeDash/admin-server/gateway.go
HomeDash Bot 1844586feb fix(gateway): TLS 段的 http2 必须写成 listen 参数
容器的 nginx 是 1.22,独立的 `http2 on;` 会报 unknown directive
(该指令 1.25.1 才引入;原 portal-nginx 用 nginx:latest 所以没暴露)。
改为 `listen PORT ssl http2;`,1.22 与 1.25+ 都可用,并加单测锁住。

本轮由旁路端口 TLS 验证发现;apply 的校验-保留机制正确拦下了这次坏配置。
2026-09-11 22:09:57 +08:00

334 lines
12 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"
)
// Route 同时描述一条反向代理及其在 Dashy 中的导航信息。
// Kind: proxy(普通反代)、portal(内置门户静态页)、auth(内置登录服务)。
type Route struct {
Host string `yaml:"host" json:"host"`
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"`
Upstream string `yaml:"upstream,omitempty" json:"upstream,omitempty"`
RequireLogin bool `yaml:"requireLogin" json:"requireLogin"`
Websocket bool `yaml:"websocket" json:"websocket"`
Streaming bool `yaml:"streaming,omitempty" json:"streaming,omitempty"`
InsecureTLS bool `yaml:"insecureTls,omitempty" json:"insecureTls,omitempty"`
MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"`
BypassPaths []string `yaml:"bypassPaths,omitempty" json:"bypassPaths,omitempty"`
InjectBeian bool `yaml:"injectBeian,omitempty" json:"injectBeian,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
NavEnabled bool `yaml:"navEnabled,omitempty" json:"navEnabled,omitempty"`
NavSection string `yaml:"navSection,omitempty" json:"navSection,omitempty"`
NavName string `yaml:"navName,omitempty" json:"navName,omitempty"`
NavDescription string `yaml:"navDescription,omitempty" json:"navDescription,omitempty"`
NavURL string `yaml:"navUrl,omitempty" json:"navUrl,omitempty"`
NavIcon string `yaml:"navIcon,omitempty" json:"navIcon,omitempty"`
NavAutoIcon bool `yaml:"navAutoIcon,omitempty" json:"navAutoIcon,omitempty"`
}
// GatewayConfig 是统一入口的完整配置。ListenHTTPS=false 时 ListenPort 是普通
// HTTP 旁路端口;开启后 ListenPort/ListenTLSPort 都是 TLS 端口,RedirectPort
// 是 HTTP→HTTPS 入口。这样可复刻原 portal-nginx 的 3080/8443 + 3081 设计。
type GatewayConfig struct {
ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"`
ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"`
RedirectPort int `yaml:"redirectPort,omitempty" json:"redirectPort,omitempty"`
Routes []Route `yaml:"routes" json:"routes"`
}
func defaultGatewayConfig() *GatewayConfig {
return &GatewayConfig{
ListenPort: 8081,
ListenTLSPort: 8443,
AuthURL: "http://127.0.0.1:3005/auth/verify",
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)
}
func routeKind(r Route) string {
k := strings.ToLower(strings.TrimSpace(r.Kind))
if k == "" {
return "proxy"
}
return k
}
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)
}
kind := routeKind(r)
if kind != "proxy" && kind != "portal" && kind != "auth" {
return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind)
}
if kind == "proxy" {
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)
}
for _, p := range r.BypassPaths {
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, " \t\"';{}") {
return fmt.Errorf("%s: 免认证路径格式不对: %q", host, p)
}
}
if r.NavEnabled {
if strings.TrimSpace(r.NavName) == "" {
return fmt.Errorf("%s: 已加入导航但名称为空", host)
}
if strings.TrimSpace(r.NavSection) == "" {
return fmt.Errorf("%s: 已加入导航但未选择分组", host)
}
if isHistorySectionName(r.NavSection) {
return fmt.Errorf("%s: 历史访问是系统分组,不能放手工导航", host)
}
}
return nil
}
func isHistorySectionName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "历史访问" || n == "history" || n == "recent"
}
func nginxQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "") + "'"
}
func writeTLS(b *strings.Builder, cfg *GatewayConfig) {
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")
}
func writeListeners(b *strings.Builder, cfg *GatewayConfig, defaultServer bool) {
suffix := ""
if defaultServer {
suffix = " default_server"
}
if cfg.ListenHTTPS {
// 注意:http2 只能作为 listen 的参数写。容器里是 nginx 1.22,
// 独立的 `http2 on;` 会直接报 unknown directive(本轮实测踩到)。
fmt.Fprintf(b, " listen %d ssl http2%s;\n", cfg.ListenPort, suffix)
if cfg.ListenTLSPort > 0 && cfg.ListenTLSPort != cfg.ListenPort {
fmt.Fprintf(b, " listen %d ssl http2%s;\n", cfg.ListenTLSPort, suffix)
}
return
}
fmt.Fprintf(b, " listen %d%s;\n", cfg.ListenPort, suffix)
}
func writeErrorPages(b *strings.Builder, include404 bool) {
b.WriteString(" root /app/portal;\n")
if include404 {
b.WriteString(" error_page 404 /404.html;\n location = /404.html { internal; }\n")
}
b.WriteString(" error_page 500 502 503 504 /500.html;\n location = /500.html { internal; }\n")
b.WriteString(" location ^~ /css/ { try_files $uri =404; }\n")
b.WriteString(" location = /favicon.svg { try_files $uri =404; }\n")
b.WriteString(" location = /favicon.ico { try_files $uri =404; }\n")
}
func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) {
if path == "" {
b.WriteString(" location / {\n")
} else {
fmt.Fprintf(b, " location ^~ %s {\n", path)
}
if authOff {
b.WriteString(" auth_request off;\n")
}
fmt.Fprintf(b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
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")
}
if r.Streaming {
b.WriteString(" postpone_output 0;\n tcp_nopush off;\n tcp_nodelay on;\n")
b.WriteString(" sendfile off;\n proxy_buffering off;\n proxy_request_buffering off;\n")
b.WriteString(" gzip off;\n chunked_transfer_encoding on;\n")
}
b.WriteString(" proxy_read_timeout 86400s;\n proxy_send_timeout 86400s;\n")
if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://") {
b.WriteString(" proxy_ssl_verify off;\n")
}
if r.InjectBeian {
b.WriteString(" sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\"><a href=\"https://beian.miit.gov.cn\" target=\"_blank\">京ICP备00000000号-1</a> | <a href=\"https://beian.mps.gov.cn\" target=\"_blank\">京公网安备00000000000000号</a></div>';\n")
b.WriteString(" sub_filter_once off;\n")
b.WriteString(" proxy_set_header Accept-Encoding \"\";\n")
}
b.WriteString(" proxy_redirect off;\n }\n")
}
func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
host := strings.TrimSpace(r.Host)
b.WriteString("server {\n")
writeListeners(b, cfg, false)
fmt.Fprintf(b, " server_name %s;\n", host)
if cfg.ListenHTTPS {
writeTLS(b, cfg)
}
if r.MaxBodyMB > 0 {
fmt.Fprintf(b, " client_max_body_size %dm;\n", r.MaxBodyMB)
}
writeErrorPages(b, routeKind(r) == "portal")
if routeKind(r) == "portal" {
b.WriteString(" index index.html;\n location / { try_files $uri $uri/ /404.html; }\n}\n\n")
return nil
}
if routeKind(r) == "auth" {
// 登录域名由容器内自带的登录服务直接响应,替代原先的 portal-auth 容器
r.Upstream = "http://127.0.0.1:3005"
r.RequireLogin = false
}
if r.RequireLogin {
if strings.TrimSpace(cfg.AuthURL) == "" || strings.TrimSpace(cfg.LoginURL) == "" {
return fmt.Errorf("%s: 需要认证但未配置认证/登录地址", host)
}
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")
b.WriteString(" proxy_set_header Cookie $http_cookie;\n")
b.WriteString(" 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)
}
for _, p := range r.BypassPaths {
writeProxyLocation(b, r, p, true)
}
writeProxyLocation(b, r, "", false)
b.WriteString("}\n\n")
return nil
}
// RenderGatewayConf 生成完整统一入口配置。未知域名使用内置自定义 404 页面,
// 反向代理故障使用自定义 500 页面;不会再返回一行纯文本。
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("配置为空")
}
if cfg.ListenPort == 0 {
cfg.ListenPort = 8081
}
if cfg.ListenHTTPS && (cfg.CertFile == "" || cfg.KeyFile == "") {
return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
}
hosts := make(map[string]bool, len(cfg.Routes))
for _, r := range cfg.Routes {
if err := ValidateRoute(r); err != nil {
return "", err
}
h := strings.ToLower(strings.TrimSpace(r.Host))
if hosts[h] {
return "", fmt.Errorf("域名重复: %s", h)
}
hosts[h] = true
}
routes := append([]Route(nil), cfg.Routes...)
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
var b strings.Builder
b.WriteString("# 由 HomeDash 后台生成,请勿手改。源: " + gatewayFile + "\n\n")
if cfg.ListenHTTPS && cfg.RedirectPort > 0 {
fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 302 https://$host$request_uri;\n}\n\n", cfg.RedirectPort)
}
// 默认服务器:保留原统一代理的自定义错误页设计。
b.WriteString("server {\n")
writeListeners(&b, cfg, true)
b.WriteString(" server_name _;\n")
if cfg.ListenHTTPS {
writeTLS(&b, cfg)
}
writeErrorPages(&b, true)
b.WriteString(" location / { return 404; }\n}\n\n")
for _, r := range routes {
if err := writeRouteServer(&b, cfg, r); err != nil {
return "", err
}
}
return b.String(), nil
}
func gatewayFileFor() string {
return filepath.Join(gatewayOutDir, "gateway.conf")
}