Files
HomeDash/admin-server/gateway.go
JianFeeeee 5562d584d3 fix(gateway): https 上游不再无条件关闭证书校验
审计第 2 条:writeProxyLocation(主 location / bypass 段)与 writeRouteServer
的 favicon 段原本都是

    if r.InsecureTLS || strings.HasPrefix(strings.ToLower(upstream), "https://") {
        proxy_ssl_verify off;
    }

即「只要上游是 https 就关掉上游证书校验」,对所有 https 上游一律不验签 ——
中间人可冒充任意 https 后端,用户提交的凭据与 cookie 全部可截。

现在条件收敛为 `r.InsecureTLS && isHTTPSUpstream(upstream)`:
「上游是 https」不再是关闭校验的理由,只有**显式勾选 insecureTls** 的路由
才会输出 proxy_ssl_verify off。两处(writeProxyLocation 用 r.Upstream、
favicon 段用局部变量 up)都改到了,并抽出 isHTTPSUpstream 消除重复判断。

⚠️ 运维需知的行为变化(有意为之,不要偷偷改回去):
- 普通 https 上游现在走 nginx 默认:**校验上游证书**,使用系统 CA 库。
  公共 CA(Let's Encrypt 等)签发的上游不受影响,无需任何配置。
- 仓库内没有 proxy_ssl_trusted_certificate 的配置,因此**自签证书的上游现在会 502**。
  这是预期的 fail-closed 结果:要接自签上游,请在该路由上显式勾选 insecureTls
  (等价于确认「我知道这条链路不验签」)。
- http 上游行为不变(本来就不会有 TLS 握手);本次也顺带保证不会给它写这行噪声配置。

测试(admin-server/gateway_tls_verify_test.go,沿用 gateway_test.go 的
RenderGatewayConf + section 取值风格):
1. https 上游 + InsecureTLS=false → 主 location 不含 proxy_ssl_verify off;
2. https 上游 + InsecureTLS=true  → 含;
3. http 上游(两种情况)→ 都不含;
4. favicon 段(location = /favicon.ico 与 /favicon.svg)逐条断言 1/2 两种行为,
   防止只改主 location 漏掉 favicon 段;
5. 计数断言:整份配置里 proxy_ssl_verify off 的出现次数 == 勾了 insecureTls 的
   https 路由贡献的 location 数(主/bypass + 2 个 favicon),兜住别处同类短路;
6. 源码护栏:不得再出现两种旧写法,且注释必须保留「自签默认 502 是预期」的说明。

反向验证:把两处判断改回 `|| https://` 后,1/3/4/5/6 全部报红(详见
`go test -run 'TestHTTPSUpstream|TestHTTPUpstream|TestFaviconPassthrough|TestSSLVerifyOff|TestInsecureTLSBehaviour'`),
计数用例给出 13 次 vs 期望 7 次;恢复后全绿。
2026-10-22 23:14:32 +08:00

502 lines
21 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"
"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"`
// AdminGuard 为该 server 块加上「管理接口需登录」防护:
// 只放行 adminPublicPaths(登录/登出/校验等),其余 /api/、/admin/ 都过 auth_request。
//
// 2026-10-22 渗透测试:login 域(kind=auth)全站 requireLogin=false 是合理的
// (它就是登录服务),但它同时把 /api/ 与 /admin/ 原样反代到 3005,而 admin-server
// 当时默认不校验 token,导致管理 API 可被匿名调用。此开关把该域收紧。
AdminGuard bool `yaml:"adminGuard,omitempty" json:"adminGuard,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 isHTTPSUpstream(upstream string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstream)), "https://")
}
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")
// 只在**显式勾选** insecureTls 的 https 上游上关闭证书校验。
//
// 审计第 2 条:这里原本是 `r.InsecureTLS || 上游是 https://`,等于所有 https 上游
// 都不校验证书 —— 中间人可冒充任意 https 后端,用户输入与 cookie 全部可截。
// 现在普通 https 上游走 nginx 默认(校验上游证书、走系统 CA);自签上游会 502,
// 这是**预期行为**,要自签就勾 insecureTls。别改回去。
//
// 另外仍然带上「必须是 https 上游」这半个条件(而不是完全去掉):http 上游上没有
// TLS 握手,这行是纯噪声且容易让人误以为已关掉什么安全机制;结果与旧行为一致
// (http 上游从来不需要它),只是不再把「是 https」当成关闭校验的理由。
if r.InsecureTLS && isHTTPSUpstream(r.Upstream) {
b.WriteString(" proxy_ssl_verify off;\n")
}
if r.InjectBeian {
// 备案号来自站点信息(可配置);未配置则不注入,避免把别人的备案号印到你的页面上。
if snip := nginxSnippetSafe(beianSnippet()); snip != "" {
fmt.Fprintf(b, " sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\">%s</div>';\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)
}
// 管理 API 的兜底防护(安全纵深)。
//
// 2026-10-22 渗透测试:login 域(kind=auth)本身是登录服务,所以全站
// requireLogin=false 是**合理**的,但它同时也把 /api/、/admin/ 原样
// 反代到 127.0.0.1:3005,而 admin-server 当年默认不校验 token ⇒ 管理 API
// 可匿名改路由表、拆登录墙、改门户口令。
//
// admin-server 现在已改「默认拒绝」,这里再加一层:只放行真正需要匿名的
// 登录端点,其余 /api/、/admin/ 一律要求先拿到管理员会话。
if r.AdminGuard {
// server 级 auth_request 必须先声明:/api/、/admin/ 的 location 不带
// auth_request off,正是为了继承它。
if strings.TrimSpace(cfg.AuthURL) != "" {
b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n")
}
for _, p := range adminPublicPaths {
writeProxyLocation(b, r, p, true)
}
for _, p := range adminProtectedPaths {
writeProxyLocation(b, r, p, false)
}
if strings.TrimSpace(cfg.AuthURL) != "" {
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")
}
if strings.TrimSpace(cfg.LoginURL) != "" {
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 && isHTTPSUpstream(up) {
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, `<a href="https://beian.miit.gov.cn" target="_blank">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank">`+html.EscapeString(gongan)+`</a>`)
}
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")
}
// adminPublicPaths 是登录域上**必须匿名可达**的端点。
//
// 这些是登录流程本身:没有它们就没人能登录(拿到会话),
// 属于「先有鸡还是先有蛋」的白名单。除此之外的 /api/、/admin/ 一律要求已登录。
var adminPublicPaths = []string{
"/_portal/login", // 提交账号口令
"/_portal/logout", // 退出
"/_portal/verify", // 会话校验(登录页/网关内部调用)
"/login", // 登录页本身
"/portal-page", // 门户首页渲染
"/favicon.ico",
"/favicon.svg",
}
// adminProtectedPaths 是需要「已登录」才能访问的管理面。
//
// 这几条正是渗透测试里被匿名利用的入口:改路由表、改门口气令、
// 写门户 HTML、读浏览历史、SSRF 探测。
var adminProtectedPaths = []string{
"/api/",
"/admin/",
"/admin/api/",
}