From 7c2bed2f2dfa230c3218f8e3fc7c9e0bbf0e4ec1 Mon Sep 17 00:00:00 2001 From: HomeDash Bot Date: Sat, 12 Sep 2026 10:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=97=A8=E6=88=B7=E9=A1=B5=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=8F=AF=E9=85=8D=E7=BD=AE=20+=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=89=8D=E5=9F=9F=E5=90=8D=E6=A0=A1=E9=AA=8C=20+=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=98=8E=E6=96=87=E9=97=A8=E6=88=B7=E5=8F=A3=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户选择实施三件事: 1) 门户页服务卡片可配置(不再写死真实域名) - portal/index.html 卡片区加 标记,内置卡片改为通用示例 - renderPortalPage 支持数据卷 /portal/cards.html 覆盖标记区间(清空即回落内置) - 新增 GET/POST /api/portal-cards 读写该片段 - 测试:覆盖替换成功、空白片段回落内置 - 仓库内真实域名由此清零 2) 升级前必须提供跨子域单点登录变量(防止"去掉写死默认值"造成 SSO 静默失效) - zd-host.sh 在切换前检查 PORTAL_COOKIE_DOMAIN:缺失且未传 NAVI_COOKIE_DOMAIN 时直接拒绝升级(退出码 2)并给出正确命令;PORTAL_HOME_URL 可用 NAVI_HOME_URL 补 - README 新增「升级已有部署前必须确认的两个变量」含失败表现与正确命令 3) 清理明文门户口令 - 迁移旧容器 env 时丢弃 PORTAL_PASSWORD(docker inspect 可见 = 泄露面) - 升级后写入强随机口令并通过接口生效,部署日志只打印一次;失败不阻断升级并提示后台可改 --- README.md | 25 ++++++++++++++ admin-server/main.go | 2 ++ admin-server/pages.go | 69 ++++++++++++++++++++++++++++++++++++++ admin-server/pages_test.go | 26 ++++++++++++++ deploy/zd-host.sh | 34 +++++++++++++++++++ portal/index.html | 2 ++ 6 files changed, 158 insertions(+) diff --git a/README.md b/README.md index ac18f22..c34cf79 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,31 @@ NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50 - 「从现有 nginx 配置导入」可把旧 portal-nginx 的配置整段贴进来,解析出全部域名→后端初稿 (含认证状态、免认证路径、SSE、备案注入、自签证书、auth/portal 类型);**解析只读,确认后才保存** +### 升级已有部署前必须确认的两个变量 + +`deploy/zd-host.sh` 会在切换前检查:旧容器 env 里没有 `PORTAL_COOKIE_DOMAIN` 且你没传 +`NAVI_COOKIE_DOMAIN` 时**直接拒绝升级**(退出码 2),因为新版本已移除代码里的写死默认值, +直接升级会让会话 cookie 退化为 host-only —— 表现为"在登录页登录成功,跳到别的子域却又是未登录"。 + +```bash +NAVI_COOKIE_DOMAIN=.example.com NAVI_HOME_URL=https://example.com/ \ +NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54 +``` + +同一脚本还会**丢弃旧容器 env 里的明文 `PORTAL_PASSWORD`**(`docker inspect` 可见,属泄露面), +并在升级后写入一个强随机门户口令、在部署日志里**只打印一次**;实际生效的本来就是数据卷里的 +加盐哈希,因此丢掉 env 不影响登录。想自己设口令:后台「统一登录账号」。 + +### 门户页服务卡片(可配置,不写死域名) + +门户页的卡片区带 `` / `` 标记,两种改法: + +1. **数据卷片段**(推荐,改完刷新即生效,无需重建镜像):把卡片 HTML 写到 + `<数据卷>/portal/cards.html`,内容会替换标记之间的部分;清空该文件即回到内置卡片。 +2. **接口**:`GET/POST /api/portal-cards`,body `{"html":"…"}`(POST 需后台令牌,空串表示重置)。 + +整页替换(含版式)仍可用 `<数据卷>/portal/index.html`。仓库内置的卡片是通用示例,不含任何真实域名。 + ### 跨子域单点登录需要的两个环境变量 登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。 diff --git a/admin-server/main.go b/admin-server/main.go index 46e9be4..98e26d4 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -1240,6 +1240,8 @@ func main() { http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings)) // 门户首页渲染(可配置文案 + 数据卷整页覆盖),供 gateway 的 kind=portal 反代 http.HandleFunc("/portal-page", handlePortalPage) + // 门户页服务卡片片段(数据卷 portal/cards.html),改链接不必重建镜像 + http.HandleFunc("/api/portal-cards", cors(handlePortalCards)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/admin-server/pages.go b/admin-server/pages.go index 2d309a2..c391247 100644 --- a/admin-server/pages.go +++ b/admin-server/pages.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "html" "net/http" "os" @@ -64,10 +65,78 @@ func renderPortalPage() []byte { "", beianFooter(mapString(info, "icp"), mapString(info, "gongan")), ) out := rep.Replace(string(builtin)) + // 服务卡片区可覆盖:数据卷 portal/cards.html 存在时替换标记之间的内容, + // 这样改门户页的服务链接不必重新构建镜像(仓库也不再预置任何真实域名)。 + out = applyCardsOverride(out) // 未做占位替换的旧模板(例如自备的 index.html)原样返回 return []byte(out) } +// cardsOverridePath 数据卷里的服务卡片片段。 +func cardsOverridePath() string { + return filepath.Join(filepath.Dir(confFile), "portal", "cards.html") +} + +const ( + cardsStartMarker = "" + cardsEndMarker = "" +) + +// applyCardsOverride 用数据卷片段替换标记之间的卡片内容;无片段或标记缺失则原样返回。 +func applyCardsOverride(page string) string { + raw, err := os.ReadFile(cardsOverridePath()) + if err != nil || len(strings.TrimSpace(string(raw))) == 0 { + return page + } + i := strings.Index(page, cardsStartMarker) + j := strings.Index(page, cardsEndMarker) + if i < 0 || j < i { + return page + } + return page[:i+len(cardsStartMarker)] + "\n" + string(raw) + "\n" + page[j:] +} + +// handlePortalCards 读写服务卡片片段(后台「站点与外观」用)。 +func handlePortalCards(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + raw, err := os.ReadFile(cardsOverridePath()) + if err != nil { + jsonResp(w, map[string]any{"html": ""}) + return + } + jsonResp(w, map[string]any{"html": string(raw)}) + case http.MethodPost: + if !requireAdminToken(w, r) { + return + } + var body struct { + HTML string `json:"html"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonErr(w, 400, err.Error()) + return + } + path := cardsOverridePath() + if strings.TrimSpace(body.HTML) == "" { + _ = os.Remove(path) // 清空 = 回到内置卡片 + jsonResp(w, map[string]any{"status": "reset"}) + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + jsonErr(w, 500, err.Error()) + return + } + if err := os.WriteFile(path, []byte(body.HTML), 0o644); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, map[string]any{"status": "saved"}) + default: + jsonErr(w, 405, "方法不允许") + } +} + // handlePortalPage 供 nginx 的 kind=portal 路由反代使用。 func handlePortalPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/admin-server/pages_test.go b/admin-server/pages_test.go index 2883e31..ff549ab 100644 --- a/admin-server/pages_test.go +++ b/admin-server/pages_test.go @@ -65,3 +65,29 @@ func TestPortalOverrideWins(t *testing.T) { t.Fatalf("空白覆盖文件应被忽略") } } + +// 数据卷里的卡片片段应替换标记之间的内容(改门户链接无需重建镜像)。 +func TestCardsOverrideReplacesMarkedBlock(t *testing.T) { + dir := t.TempDir() + old := confFile + defer func() { confFile = old }() + confFile = filepath.Join(dir, "conf.yml") + if err := os.MkdirAll(filepath.Join(dir, "portal"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte("我的服务"), 0o644); err != nil { + t.Fatal(err) + } + page := "
" + cardsStartMarker + "旧" + cardsEndMarker + "
" + got := applyCardsOverride(page) + if !strings.Contains(got, "mine.example.org") || strings.Contains(got, "old.example.com") { + t.Fatalf("覆盖未生效: %s", got) + } + // 清空后回落内置卡片 + if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + if got := applyCardsOverride(page); !strings.Contains(got, "old.example.com") { + t.Fatalf("空白片段应回落内置卡片: %s", got) + } +} diff --git a/deploy/zd-host.sh b/deploy/zd-host.sh index 258c64d..52778c8 100644 --- a/deploy/zd-host.sh +++ b/deploy/zd-host.sh @@ -94,6 +94,30 @@ OLD_ID=$(docker inspect navi --format '{{.Id}}') docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \ | grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND)' > "/tmp/navi-host-${STAMP}.env" || true chmod 600 "/tmp/navi-host-${STAMP}.env" + +# ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。 +# 实际生效的是数据卷里的加盐哈希,所以丢掉 env 不影响登录; +# 稍后会在新容器里写入一个强度更高的随机口令并打印一次。 +CLEAN_PORTAL_PW=0 +if grep -q '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env"; then + grep -v '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.new" && mv "/tmp/navi-host-${STAMP}.env.new" "/tmp/navi-host-${STAMP}.env" + CLEAN_PORTAL_PW=1 +fi + +# ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值, +# 不显式提供的话登录 cookie 会退化成 host-only(登录后换子域就掉线)。 +if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "/tmp/navi-host-${STAMP}.env"; then + if [ -n "${NAVI_COOKIE_DOMAIN:-}" ]; then + echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "/tmp/navi-host-${STAMP}.env" + else + echo "!! 缺少 PORTAL_COOKIE_DOMAIN:新版已删除代码默认值,直接升级会让跨子域单点登录失效。" >&2 + echo " 执行方式:NAVI_COOKIE_DOMAIN=.你的域名 NAVI_HOME_URL=https://你的门户/ NAVI_SSH_PASS=... bash deploy/zd-host.sh $VER" >&2 + exit 2 + fi +fi +if ! grep -q '^PORTAL_HOME_URL=' "/tmp/navi-host-${STAMP}.env" && [ -n "${NAVI_HOME_URL:-}" ]; then + echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "/tmp/navi-host-${STAMP}.env" +fi OLD_IMAGE=$(docker inspect navi --format '{{.Config.Image}}') PREV="navi-prev-${STAMP}" @@ -145,6 +169,16 @@ if [ -n "$ADMIN_TOK" ]; then APPLY=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" http://127.0.0.1:3005/api/gateway/apply || true) echo " 已重新生成网关配置: ${APPLY}" case "$APPLY" in *applied*) ;; *) echo "!! 网关配置重新生成失败" >&2; false ;; esac + +# ③ 旧容器用过明文 PORTAL_PASSWORD 时,换成强随机口令并只打印一次 +# (失败不影响升级:可在后台「统一登录账号」自行设置) +if [ "${CLEAN_PORTAL_PW:-0}" = "1" ]; then + NEWPW="$( (openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64) | tr -d '/+=' | cut -c1-20)" + RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" -H 'Content-Type: application/json' \ + -d "{\"username\":\"admin\",\"password\":\"$NEWPW\"}" http://127.0.0.1:3005/api/portal-auth || true) + case "$RESP" in *error*|*Error*) echo " ! 门户口令重置失败(可在后台改):$RESP" >&2 ;; + *) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录)" ;; esac +fi fi HIST=$(curl -s "http://127.0.0.1:18080/admin/api/history" | grep -o '"url"' | wc -l) diff --git a/portal/index.html b/portal/index.html index ad9184d..3bc6b19 100644 --- a/portal/index.html +++ b/portal/index.html @@ -21,6 +21,7 @@ JianF Git + Gitea 博客
@@ -74,6 +75,7 @@ +
门户中心