diff --git a/README.md b/README.md index ff8d055..ac18f22 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,22 @@ NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50 - 「从现有 nginx 配置导入」可把旧 portal-nginx 的配置整段贴进来,解析出全部域名→后端初稿 (含认证状态、免认证路径、SSE、备案注入、自签证书、auth/portal 类型);**解析只读,确认后才保存** +### 跨子域单点登录需要的两个环境变量 + +登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。 +要让 `login.example.com` 登录后能访问 `dashy.example.com` 等子域,必须设置: + +| 变量 | 值 | 说明 | +| --- | --- | --- | +| `PORTAL_COOKIE_DOMAIN` | `.example.com` | cookie 作用域(前导点表示含所有子域) | +| `PORTAL_HOME_URL` | `https://example.com/` | 登录成功后跳回的门户地址(默认 `/`,相对跳转) | + +首装脚本在给了 `NAVI_DOMAIN` 时会自动带上这两个变量;**升级已有部署时请确认容器 env 里已设置** +(早期版本靠代码里的写死默认值工作,现在已移除,不设会导致跨子域登录失效)。 + +同理,`gateway.yml` 的 `loginUrl` 也不再预置域名:受保护路由要求配置认证与登录地址, +留空会在生成配置时报错并说明原因(不会静默生成一个跳不出去的登录页)。 + ### 统一登录账号 管理后台「统一登录账号」写入数据卷 `/portal-auth.json`(**只存加盐哈希,不存明文**)。 @@ -493,7 +509,7 @@ host 网络启动容器 → 应用配置 + 自检。**自检不全绿就退出** | `NAVI_SYNC_TOKEN` / `NAVI_ADMIN_TOKEN` | 空→自动生成 | 历史同步令牌 / 后台 API 令牌 | | `NAVI_BUILD` / `NAVI_TAR` | `1` / 空 | `0` 表示用 `docker load -i $NAVI_TAR` 导入现成镜像 | | `NAVI_SSH_PASS`(zd-host.sh) | 空 | ssh 密码;用密钥则留空 | -| `NAVI_HOST`(zd-host.sh) | `admin@10.0.0.10` | 开发机侧默认值,换机器需覆盖 | +| `NAVI_HOST`(zd-host.sh / zd.sh) | **必填、无默认** | 目标机 ssh 地址 `user@host`(仓库不预置任何真实地址);缺失会直接以退出码 2 报错 | ### 证书与初始网关配置 diff --git a/admin-server/auth.go b/admin-server/auth.go index fd90f82..f0d22dc 100644 --- a/admin-server/auth.go +++ b/admin-server/auth.go @@ -22,10 +22,13 @@ import ( const portalCookieName = "portal_session" var ( - portalUsername = envOr("PORTAL_USERNAME", "admin") - portalPassword = envOr("PORTAL_PASSWORD", "changeme") - portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", ".example.com") - portalHomeURL = envOr("PORTAL_HOME_URL", "https://dashy.example.com") + portalUsername = envOr("PORTAL_USERNAME", "admin") + portalPassword = envOr("PORTAL_PASSWORD", "changeme") + // 均不预置真实域名:cookie 域为空时下发 host-only cookie(同一站点可用, + // 跨子域单点登录请设置 PORTAL_COOKIE_DOMAIN=.example.com); + // 门户首页为空时用相对跳转 "/"。 + portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", "") + portalHomeURL = envOr("PORTAL_HOME_URL", "/") portalSessionTTL = 24 * time.Hour portalSecretOnce sync.Once portalSecret []byte diff --git a/admin-server/gateway.go b/admin-server/gateway.go index 3668da1..4794430 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "html" "os" "path/filepath" "sort" @@ -58,9 +59,10 @@ 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{}, + AuthURL: "http://127.0.0.1:3005/auth/verify", // 本机内部地址,非外部域名 + // 登录地址不预置任何域名:未配置时由后台/首装脚本填入(gateway.yml loginUrl) + LoginURL: "", + Routes: []Route{}, } } @@ -240,9 +242,13 @@ func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) b.WriteString(" proxy_ssl_verify off;\n") } if r.InjectBeian { - b.WriteString(" sub_filter '' '
京ICP备00000000号-1 | 京公网安备00000000000000号
';\n") - b.WriteString(" sub_filter_once off;\n") - b.WriteString(" proxy_set_header Accept-Encoding \"\";\n") + // 备案号来自站点信息(可配置);未配置则不注入,避免把别人的备案号印到你的页面上。 + 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") } @@ -293,8 +299,37 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error { 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("配置为空") diff --git a/admin-server/gateway_import_test.go b/admin-server/gateway_import_test.go index 4722ced..bbd47ae 100644 --- a/admin-server/gateway_import_test.go +++ b/admin-server/gateway_import_test.go @@ -12,7 +12,7 @@ const sampleConf = `map $host $backend { gitea.example.com http://127.0.0.1:3000; login.example.com http://127.0.0.1:4001; dashy.example.com http://127.0.0.1:3003; - modulerouter.example.com http://10.0.0.11:8081; + modulerouter.example.com http://10.0.0.5:8081; } server { listen 3080 ssl; diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go index 5a6dcc1..b0321ca 100644 --- a/admin-server/gateway_test.go +++ b/admin-server/gateway_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "path/filepath" "strings" "testing" ) @@ -438,3 +439,57 @@ func TestRedirectDefaultsNoPortOnNonStandardPort(t *testing.T) { t.Fatalf("默认不应带端口:\n%s", out) } } + +// 注进被代理页面的备案号必须来自站点信息(可配置),不能再写死。 +func TestBeianInjectionUsesPageInfo(t *testing.T) { + dir := t.TempDir() + old := confFile + defer func() { confFile = old }() + confFile = filepath.Join(dir, "conf.yml") + if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: 京ICP备12345678号-1\n gongan: 京公网安备11000000000001号\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if !strings.Contains(out, "京ICP备12345678号-1") || !strings.Contains(out, "京公网安备11000000000001号") { + t.Fatalf("未使用配置的备案号:\n%s", out) + } +} + +// 未配置备案号时不注入(不得残留任何硬编码备案号)。 +func TestBeianInjectionSkippedWhenUnset(t *testing.T) { + dir := t.TempDir() + old := confFile + defer func() { confFile = old }() + confFile = filepath.Join(dir, "conf.yml") + if err := os.WriteFile(confFile, []byte("pageInfo: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if strings.Contains(out, "navi-proxy-beian") { + t.Fatalf("未配置备案号却注入了:\n%s", out) + } +} + +// 备案号里的引号/美元符不能破坏 nginx 配置。 +func TestBeianInjectionSanitized(t *testing.T) { + dir := t.TempDir() + old := confFile + defer func() { confFile = old }() + confFile = filepath.Join(dir, "conf.yml") + if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: \"a';b$c\"\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if strings.Contains(out, "';b") || strings.Contains(out, "b$c") { + t.Fatalf("危险字符未清理:\n%s", out) + } +} diff --git a/admin/index.html b/admin/index.html index 29c75c5..25b084f 100644 --- a/admin/index.html +++ b/admin/index.html @@ -289,7 +289,7 @@ html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }
添加转发
-
+