package main import ( "fmt" "html" "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"` // RedirectWithPort:http→https 跳转时是否带上入口端口。 // 默认 false(跳 https://host,适用于前置 frp/443 的场景); // 全新安装且对外端口=入口端口时置 true。 RedirectWithPort bool `yaml:"redirectWithPort,omitempty" json:"redirectWithPort,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", // 本机内部地址,非外部域名 // 登录地址不预置任何域名:未配置时由后台/首装脚本填入(gateway.yml loginUrl) LoginURL: "", 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) } // writeErrorPages 写公共 location。portalFavicon 决定是否提供**门户自己的** favicon: // 只应写在门户页/登录页/默认 404 页这些由门户自身渲染的 server 块里。 // // 踩坑(2026-09-13 实测):这两行曾被无条件写进每个 server 块,而 try_files 是在 // 门户的 root(/app/portal)里找文件 —— 于是**所有被代理域名的 /favicon.ico 都返回门户图标**: // // · 浏览器里每个站点的标签图标都变成门户图标; // · 导航项图标抓取(优先抓 /favicon.ico)把门户图标当成了站点图标, // 实测 pi / ittools / vikunja 三个站点存下了同一张图标。 func writeErrorPages(b *strings.Builder, include404, portalFavicon bool) { b.WriteString(" root /app/portal;\n") // 关键:错误页与它自己的静态资源必须 `auth_request off`。 // 上游故障时 nginx 会内部跳转到 /500.html,而服务端的 auth_request 会被 // 这个内部请求继承 —— 于是"上游 502"最终以 401(认证失败)呈现给用户, // 自定义错误页永远显示不出来。本轮实测踩到(日志里能看到 connect() refused, // 客户端却收到 nginx 的默认 401 页)。 if include404 { b.WriteString(" error_page 404 /404.html;\n") b.WriteString(" location = /404.html { internal; auth_request off; }\n") } b.WriteString(" error_page 500 502 503 504 /500.html;\n") b.WriteString(" location = /500.html { internal; auth_request off; }\n") b.WriteString(" location ^~ /css/ { auth_request off; try_files $uri =404; }\n") if portalFavicon { b.WriteString(" location = /favicon.svg { auth_request off; try_files $uri =404; }\n") b.WriteString(" location = /favicon.ico { auth_request off; 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 { // 备案号来自站点信息(可配置);未配置则不注入,避免把别人的备案号印到你的页面上。 if snip := nginxSnippetSafe(beianSnippet()); snip != "" { fmt.Fprintf(b, " sub_filter '' '
%s
';\n", snip) b.WriteString(" sub_filter_once off;\n") // sub_filter 要生效必须关掉上游压缩,故只在真正注入时才加这行 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) } // 只有"门户自己渲染"的块才给门户图标;代理到上游的块必须让 /favicon.ico 走 location / writeErrorPages(b, routeKind(r) == "portal", strings.TrimSpace(r.Upstream) == "") if routeKind(r) == "portal" { // 门户首页交给 admin-server 渲染(pageInfo 可配置 + 支持数据卷整页覆盖), // 静态资源仍由本容器提供(root /app/portal)。 // 注意:不要在这里再写 root —— writeErrorPages 已经写过一次, // 重复会让 nginx -t 报 "root directive is duplicate"(本轮真实踩到)。 b.WriteString(" location = / { proxy_pass http://127.0.0.1:3005/portal-page; proxy_set_header Host $host; proxy_redirect off; }\n") b.WriteString(" 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) } // favicon 免登录直通上游。 // 理由:① 图标不是敏感资源,未登录时浏览器标签也该显示站点自己的图标; // ② 导航项/历史条目的图标抓取依赖它在未登录时可取。 // 之前这里是"在门户 root 里 try_files",等于把每个被代理站点的图标都换成门户图标 // (实测 pi / ittools / vikunja 存下了同一张门户图标)。 // 注意:auth 类的上游是在上面 writeErrorPages 之后才赋值的,那时它已按"门户自己渲染" // 拿到了门户 favicon —— 这里必须跳过,否则同一个 location 出现两次,nginx -t 直接失败 // (部署脚本回滚过一版,实测踩到)。 if up := strings.TrimSpace(r.Upstream); up != "" && routeKind(r) != "auth" { for _, fa := range []string{"/favicon.ico", "/favicon.svg"} { fmt.Fprintf(b, " location = %s {\n auth_request off;\n proxy_pass %s;\n", fa, up) 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.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://") { b.WriteString(" proxy_ssl_verify off;\n") } b.WriteString(" proxy_redirect off;\n }\n") } } writeProxyLocation(b, r, "", false) b.WriteString("}\n\n") return nil } // beianSnippet 生成注入被代理页面的备案 HTML:来自后台「站点信息」的 ICP/公网安备, // 两者都没配置时返回空串(此时不注入任何东西)。 // 原先这里写死了具体备案号,等于每个部署者都会带上别人的备案信息。 func beianSnippet() string { cfg, err := loadConfig() if err != nil || cfg == nil { return "" } icp := strings.TrimSpace(mapString(cfg.PageInfo, "icp")) gongan := strings.TrimSpace(mapString(cfg.PageInfo, "gongan")) var parts []string if icp != "" { parts = append(parts, ``+html.EscapeString(icp)+``) } if gongan != "" { parts = append(parts, ``+html.EscapeString(gongan)+``) } if len(parts) == 0 { return "" } return strings.Join(parts, " | ") } // nginxSnippetSafe 去掉会破坏 nginx 配置或引发变量展开的字符。 func nginxSnippetSafe(s string) string { return strings.NewReplacer("'", "", `\`, "", "\n", "", "\r", "", "$", "").Replace(s) } // 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 { // 跳转目标是否带上入口端口:由 redirectWithPort 显式决定。 // 不能用"端口 != 443"推导——生产容器监听 3080,但对外经 frp 映射为 443, // 那种情况下带端口反而把公网用户导向 https://host:3080(打不开)。 // 只有在"对外端口就等于入口端口"的部署(如全新安装 13080)才应开启。 redirectHost := "$host" if cfg.RedirectWithPort && cfg.ListenPort != 443 { redirectHost = fmt.Sprintf("$host:%d", cfg.ListenPort) } fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 302 https://%s$request_uri;\n}\n\n", cfg.RedirectPort, redirectHost) } // 默认服务器:保留原统一代理的自定义错误页设计。 b.WriteString("server {\n") writeListeners(&b, cfg, true) b.WriteString(" server_name _;\n") if cfg.ListenHTTPS { writeTLS(&b, cfg) } writeErrorPages(&b, true, 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") }