feat: 门户页卡片可配置 + 升级前域名校验 + 清理明文门户口令
按用户选择实施三件事:
1) 门户页服务卡片可配置(不再写死真实域名)
- portal/index.html 卡片区加 <!--NAVI_CARDS_START/END--> 标记,内置卡片改为通用示例
- renderPortalPage 支持数据卷 <data>/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 可见 = 泄露面)
- 升级后写入强随机口令并通过接口生效,部署日志只打印一次;失败不阻断升级并提示后台可改
This commit is contained in:
25
README.md
25
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 不影响登录。想自己设口令:后台「统一登录账号」。
|
||||
|
||||
### 门户页服务卡片(可配置,不写死域名)
|
||||
|
||||
门户页的卡片区带 `<!--NAVI_CARDS_START-->` / `<!--NAVI_CARDS_END-->` 标记,两种改法:
|
||||
|
||||
1. **数据卷片段**(推荐,改完刷新即生效,无需重建镜像):把卡片 HTML 写到
|
||||
`<数据卷>/portal/cards.html`,内容会替换标记之间的部分;清空该文件即回到内置卡片。
|
||||
2. **接口**:`GET/POST /api/portal-cards`,body `{"html":"…"}`(POST 需后台令牌,空串表示重置)。
|
||||
|
||||
整页替换(含版式)仍可用 `<数据卷>/portal/index.html`。仓库内置的卡片是通用示例,不含任何真实域名。
|
||||
|
||||
### 跨子域单点登录需要的两个环境变量
|
||||
|
||||
登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -64,10 +65,78 @@ func renderPortalPage() []byte {
|
||||
"<!--FOOTER-->", 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 = "<!--NAVI_CARDS_START-->"
|
||||
cardsEndMarker = "<!--NAVI_CARDS_END-->"
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
@ -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("<a href=\"https://mine.example.org\">我的服务</a>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := "<div>" + cardsStartMarker + "<a href=\"https://old.example.com\">旧</a>" + cardsEndMarker + "</div>"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82"/></svg>
|
||||
JianF Git
|
||||
</a>
|
||||
<!--NAVI_CARDS_START-->
|
||||
<a href="https://gitea.example.com">Gitea</a>
|
||||
<a href="https://blog.example.com">博客</a>
|
||||
<div class="spacer"></div>
|
||||
@ -74,6 +75,7 @@
|
||||
<span class="card-arrow"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z"/></svg></span>
|
||||
</a>
|
||||
<a href="https://dashy.example.com" class="service-card service-card-accent">
|
||||
<!--NAVI_CARDS_END-->
|
||||
<div class="service-icon icon-grad"><svg viewBox="0 0 24 24" width="25" height="25" fill="currentColor" aria-hidden="true"><path d="M13 3v6h8V3m-8 18h8V11h-8M3 21h8v-6H3m0-2h8V3H3z"/></svg></div>
|
||||
<div class="service-info">
|
||||
<div class="service-name">门户中心
|
||||
|
||||
Reference in New Issue
Block a user