chore(infra): 新增 Gitea+Jenkins CI/CD 部署与凭据整改

- infra/cicd:docker-compose(gitea/jenkins)、JCasC(凭据统一 ${ENV} 注入,无硬编码)、
  备份/恢复、健康检查、凭据轮换与 git 历史清除脚本
- docs/deployment/cicd:安装、高可用备份监控、凭据事故复盘
- .env.example 仅为占位模板;真实 .env 由 .gitignore 排除
This commit was merged in pull request #27.
This commit is contained in:
2026-09-20 10:37:57 +08:00
parent 7df2dde438
commit 4ab2f3cd8e
15 changed files with 2092 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# ============================================================
# 清除 git 历史中的明文凭据
#
# 背景(2026-09-14 实测确认已入库并推送到公网可达远程):
# 泄露位置见 docs/deployment/cicd/03-credential-incident.md 第 2 节
#
# ⚠️ 强制推送会重写远程历史:
# - 所有协作者必须重新 clone(旧本地仓库 push 会把泄露内容带回来)
# - 若 Gitea 上有未合入的 PR / fork,会失效
# - 建议:先轮换凭据(rotate-credentials.sh),再清史,最后强制推送
#
# 前置:pip install git-filter-repo (或 brew install git-filter-repo
#
# ⚠️ 本脚本【不】内置泄露明文。曾踩坑:把泄露值硬编码进本脚本的注释与
# 替换规则里,一旦提交等于把泄露物重新入库一次,清史变成滚雪球。
# 替换规则从仓库外文件读取(默认 ~/.novalon-purge-secrets.txt)。
#
# 用法:
# SECRETS_FILE=~/.novalon-purge-secrets.txt \
# ./purge-git-history.sh --dry-run # 只扫描并列出命中,不改写
# ./purge-git-history.sh # 执行清史(会先做 mirror 备份)
# ./purge-git-history.sh --no-push # 清史但不强推,留自己复核
# ============================================================
set -Eeuo pipefail
DRY_RUN=0
NO_PUSH=0
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=1 ;;
--no-push) NO_PUSH=1 ;;
esac
done
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)}"
BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/.workbuddy/tmp/git-purge-backup}"
REMOTE="${REMOTE:-origin}"
BRANCH="${BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
SECRETS_FILE="${SECRETS_FILE:-$HOME/.novalon-purge-secrets.txt}"
# 需要整体删除的文件(含明文配置,且本身不该入库)
FILES_TO_DROP=(
"scripts/setup-cicd/cicd.config"
)
log() { echo "[purge] $*"; }
die() { echo "[purge] ❌ $*" >&2; exit 1; }
cd "$REPO_DIR" || die "无法进入仓库: $REPO_DIR"
[[ -d .git ]] || die "不是 git 仓库: $REPO_DIR"
# ============================================================
# 读取替换规则(格式: 明文==>***REMOVED*** ,每行一条)
# ============================================================
if [[ ! -s "$SECRETS_FILE" ]]; then
cat >&2 <<EOF
[purge] ❌ 未找到替换规则文件: $SECRETS_FILE
为避免把泄露明文写进仓库,本脚本不内置规则。请在【仓库外】创建(不要入 git):
cat > $SECRETS_FILE <<'EOF'
<泄露明文1>==>***REMOVED***
<泄露明文2>==>***REMOVED***
<泄露明文3>==>***REMOVED***
EOF
chmod 600 $SECRETS_FILE
具体哪些值:见 docs/deployment/cicd/03-credential-incident.md 第 2 节列出的
文件与变量名(cicd.config 的三个值 + setup-registry-auth.sh 的 gto_ secret)。
创建后重新运行本脚本。
EOF
exit 1
fi
# 提取明文值用于扫描命中
mapfile -t SECRET_VALUES < <(sed 's/==>.*$//' "$SECRETS_FILE" | grep -v '^[[:space:]]*$')
if (( ${#SECRET_VALUES[@]} == 0 )); then
die "替换规则文件为空或格式错误: $SECRETS_FILE"
fi
log "已加载 ${#SECRET_VALUES[@]} 条替换规则(值不回显)"
# 构造 git grep 的 -e 参数数组
GREP_ARGS=()
for v in "${SECRET_VALUES[@]}"; do
GREP_ARGS+=( -e "$v" )
done
scan_hits() {
# shellcheck disable=SC2086
git grep -l -F "${GREP_ARGS[@]}" $(git rev-list --all) -- 2>/dev/null | wc -l | tr -d ' '
}
# ---------- 扫描命中 ----------
log "----- 扫描历史中的命中 -----"
git log --all --oneline -- "${FILES_TO_DROP[@]}" scripts/setup-registry-auth.sh 2>/dev/null | head -20 || true
HIT_COUNT=$(scan_hits)
log "历史中含泄露凭据的 blob 数: $HIT_COUNT"
if (( DRY_RUN == 1 )); then
log "dry-run 结束,未做任何改写。"
exit 0
fi
if [[ "$HIT_COUNT" == "0" ]]; then
log "未发现命中,无需清史。"
exit 0
fi
# ---------- 备份 ----------
mkdir -p "$BACKUP_DIR"
MIRROR="$BACKUP_DIR/$(basename "$REPO_DIR")-$(date +%Y%m%d_%H%M%S).git"
log "----- 备份镜像到 $MIRROR -----"
git clone --mirror "$REPO_DIR" "$MIRROR" || die "备份失败,已中止(不冒险改写)"
log "✅ 备份完成"
# ---------- 工具检查 ----------
if command -v git-filter-repo >/dev/null 2>&1; then
TOOL="filter-repo"
elif python3 -c "import git_filter_repo" >/dev/null 2>&1; then
TOOL="python-module"
else
die "未找到 git-filter-repo。安装: pip install git-filter-repo 或 brew install git-filter-repo"
fi
# ---------- 二次确认 ----------
echo
echo "⚠️ 即将重写 git 历史并强制推送到 $REMOTE。"
echo "⚠️ 所有协作者必须重新 clone,未合入的 PR 可能失效。"
read -r -p "输入 'PURGE' 继续: " C
if [[ "$C" != "PURGE" ]]; then
log "未确认,已中止。"
exit 1
fi
# ---------- 执行清史 ----------
log "----- 执行清史(工具: $TOOL -----"
if [[ "$TOOL" == "filter-repo" ]]; then
git filter-repo --replace-text "$SECRETS_FILE" \
$(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
--force
else
python3 -m git_filter_repo --replace-text "$SECRETS_FILE" \
$(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
--force
fi
log "✅ 历史已重写"
# ---------- 本地清理 ----------
log "----- 本地 GC -----"
git reflog expire --expire=now --all
git gc --prune=now --aggressive
log "✅ 本地对象已清理"
# ---------- 校验 ----------
log "----- 校验:重新扫描是否还有残留 -----"
REMAIN=$(scan_hits)
if [[ "$REMAIN" != "0" ]]; then
die "仍有 $REMAIN 处残留!请检查 .git/packed-refs 或其他分支。备份在: $MIRROR"
fi
log "✅ 无残留"
# ---------- 推送 ----------
if (( NO_PUSH == 1 )); then
log "--no-push:已跳过强制推送。复核后手动执行:"
log " git push origin --force --all && git push origin --force --tags"
else
read -r -p "确认强制推送到 $REMOTE ? (yes/N): " P
if [[ "$P" == "yes" ]]; then
git push "$REMOTE" --force --all
git push "$REMOTE" --force --tags
log "✅ 已强制推送"
cat <<EOF
================ 收尾(必做) =================
1. 通知所有协作者:rm -rf 本地仓库后重新 clone(旧仓库 push 会带回泄露内容)
2. 在 Gitea 上执行「仓库设置 → 维护 → 运行垃圾回收」,删除远程的旧对象
(否则旧 blob 仍可通过 commit sha 直接访问)
3. 检查是否有 fork / 镜像 / CI 缓存仍持有旧历史
4. 确认 rotate-credentials.sh 已执行完毕(清史不能替代轮换!)
5. 加 pre-commit 钩子防复发:gitleaks / trufflehog
6. 处置完毕后删除 $SECRETS_FILE
===============================================
EOF
else
log "已跳过推送。备份镜像保留在: $MIRROR"
fi
fi
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# ============================================================
# P0 凭据轮换脚本
#
# 背景(2026-09-14 实测确认):
# 以下真实凭据已随 git 提交并推送到 https://git.f.novalon.cn/novalon/novalon-website.git
# 该远程公网可达,等同于已泄露:
# - scripts/setup-cicd/cicd.config GITEA_PASSWORD, JENKINS_ADMIN_PASSWORD
# - scripts/setup-registry-auth.sh Gitea OAuth2 secret (gto_cc5c...)
# - novalon-cicd/docker-compose.yml 仅落在服务器,未入库(但同样应外置)
#
# 本脚本执行四件事:
# 1. 生成强随机新凭据
# 2. 轮换 Gitea 用户密码 + 吊销旧 OAuth2 应用 + 签发新的只读访问令牌
# 3. 轮换 Jenkins admin 密码(通过 init.groovy.d 在重启时生效,无需已有管理员凭据)
# 4. 写回 .env,并输出"后续必须手工完成"的清单
#
# ⚠️ 本脚本会立即使旧凭据失效 —— 请在无人使用的时间窗执行,
# 且必须在清史(purge-git-history.sh**之后**或**同时**完成,顺序无强制但间隔越短越好。
#
# 用法:
# ./rotate-credentials.sh --dry-run # 只生成并打印计划,不改动任何东西
# ./rotate-credentials.sh # 实际执行
# ============================================================
set -Eeuo pipefail
DRY_RUN=0
for a in "$@"; do [[ "$a" == "--dry-run" ]] && DRY_RUN=1; done
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
ENV_FILE="${ENV_FILE:-$DEPLOY_ROOT/infra/cicd/.env}"
REPO_DIR="${REPO_DIR:-/Users/zhangxiang/Codes/Novalon/novalon-website}"
GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
GITEA_URL="${GITEA_URL:-https://git.f.novalon.cn}"
log() { echo "[rotate] $*"; }
die() { echo "[rotate] ❌ $*" >&2; exit 1; }
gen_pw() { openssl rand -base64 24 | tr -d '/+=' | head -c 24; }
gen_tok() { openssl rand -hex 32; }
log "========== P0 凭据轮换 =========="
log "dry-run=$DRY_RUN"
# ---------- 0. 前置校验 ----------
command -v docker >/dev/null || die "docker 不可用(本脚本需在生产服务器上执行)"
docker ps --format '{{.Names}}' | grep -qx "$GITEA_CONTAINER" || die "Gitea 容器未运行"
docker ps --format '{{.Names}}' | grep -qx "$JENKINS_CONTAINER" || die "Jenkins 容器未运行"
# ---------- 1. 生成新凭据 ----------
NEW_GITEA_PASSWORD="$(gen_pw)"
NEW_JENKINS_PASSWORD="$(gen_pw)"
NEW_DB_PASSWORD="$(gen_pw)"
log "已生成新凭据(下方仅在 dry-run 时明文展示)"
if (( DRY_RUN )); then
echo " Gitea 密码 : $NEW_GITEA_PASSWORD"
echo " Jenkins密码 : $NEW_JENKINS_PASSWORD"
echo " 数据库 密码 : $NEW_DB_PASSWORD"
fi
if (( DRY_RUN == 0 )); then
echo
log "⚠️ 即将轮换生产凭据,旧凭据立即失效。"
read -r -p "输入 'ROTATE' 继续: " C
[[ "$C" == "ROTATE" ]] || die "未确认,已中止"
fi
if (( DRY_RUN == 1 )); then
log "dry-run:以下操作均跳过。"
log "计划:1) Gitea 改密 2) 吊销 OAuth2 应用 3) 签发只读令牌 4) Jenkins 改密 5) 写回 .env"
exit 0
fi
# ---------- 2. 轮换 Gitea 用户密码 ----------
log "----- [1/5] Gitea 用户密码 -----"
# 从 app.ini 取管理员账号(第一个 uid=1 的用户)
GITEA_ADMIN="${GITEA_ADMIN:-zhangxiang@novalon.cn}"
docker exec -u git "$GITEA_CONTAINER" \
gitea admin user change-password --username "$GITEA_ADMIN" --password "$NEW_GITEA_PASSWORD" \
&& log "✅ Gitea 用户 $GITEA_ADMIN 密码已轮换" \
|| log "⚠️ Gitea 改密失败(账号名可能不对,请用 --env GITEA_ADMIN= 指定)"
# ---------- 3. 吊销旧 OAuth2 应用 ----------
log "----- [2/5] 吊销 Gitea OAuth2 应用(含泄露的 gto_cc5c... -----"
docker exec -u git "$GITEA_CONTAINER" \
gitea admin auth list-oauth 2>/dev/null || log " 列出 OAuth2 应用失败(版本差异,可界面处理)"
log "️ 请核对上方列表,对 registry / jenkins 相关的应用执行:"
log " docker exec -u git $GITEA_CONTAINER gitea admin auth delete-oauth --id <ID>"
log " 或在 Gitea → 设置 → 应用 → 授权应用 手动撤销,再重新创建。"
# ---------- 4. 签发新的只读令牌 ----------
log "----- [3/5] 签发新的 Gitea 只读访问令牌 -----"
NEW_TOKEN="$(curl -fsS --noproxy '*' -X POST \
-u "$GITEA_ADMIN:$NEW_GITEA_PASSWORD" \
-H 'Content-Type: application/json' \
-d '{"name":"jenkins-readonly-'$(date +%Y%m%d)'","scopes":["read:repository"]}' \
"$GITEA_URL/api/v1/users/$GITEA_ADMIN/tokens" \
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p' || true)"
if [[ -z "$NEW_TOKEN" ]]; then
log "⚠️ 自动签发失败,请手动:Gitea → 设置 → 应用 → 生成新令牌(仅勾 read:repository"
read -r -p " 手动生成后粘贴令牌(可留空跳过): " NEW_TOKEN
else
log "✅ 新令牌已签发(sha1 前缀 ${NEW_TOKEN:0:8}..."
fi
# ---------- 5. 轮换 Jenkins admin 密码 ----------
log "----- [4/5] Jenkins admin 密码(下次重启生效) -----"
JENKINS_HOME_MOUNT=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/jenkins_home"}}{{.Source}}{{end}}{{end}}' "$JENKINS_CONTAINER")
if [[ -z "$JENKINS_HOME_MOUNT" ]]; then
docker volume inspect jenkins_jenkins_home >/dev/null 2>&1 \
&& JENKINS_HOME_MOUNT="$(docker volume inspect -f '{{.Mountpoint}}' jenkins_jenkins_home)" \
|| die "无法定位 JENKINS_HOME 挂载点"
fi
mkdir -p "$JENKINS_HOME_MOUNT/init.groovy.d"
cat > "$JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy" <<GROOVY
import jenkins.model.*
import hudson.security.*
import com.cloudbees.jenkins.plugins.sshcredentials.impl.*
import org.jenkinsci.plugins.plaincredentials.*
def instance = Jenkins.getInstance()
def realm = instance.getSecurityRealm()
if (realm instanceof HudsonPrivateSecurityRealm) {
def u = realm.getUser("${JENKINS_ADMIN_USER:-zhangxiang}")
if (u != null) {
u.addProperty(new hudson.security.HudsonPrivateSecurityRealm.Details(${hudson.security.HudsonPrivateSecurityRealm.class.name}.fromPlainPassword("${NEW_JENKINS_PASSWORD}")))
u.save()
println "[rotate] admin 密码已更新"
} else {
println "[rotate] 未找到用户,跳过"
}
}
instance.save()
GROOVY
log "✅ 已写入 $JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy"
log "️ 需重启 Jenkins 生效:docker restart $JENKINS_CONTAINER"
log "️ 生效后请删除该 groovy 文件,避免每次启动重复写密码。"
# ---------- 6. 写回 .env ----------
log "----- [5/5] 写回 $ENV_FILE -----"
mkdir -p "$(dirname "$ENV_FILE")"
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
upsert() {
local key="$1" val="$2"
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
sed -i.bak "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
upsert JENKINS_ADMIN_USER "${JENKINS_ADMIN_USER:-zhangxiang}"
upsert JENKINS_ADMIN_PASSWORD "$NEW_JENKINS_PASSWORD"
upsert GITEA_TOKEN "$NEW_TOKEN"
upsert GITEA_DB_PASSWORD "$NEW_DB_PASSWORD"
rm -f "$ENV_FILE.bak"
log "✅ .env 已更新(权限 600"
# ---------- 收尾清单 ----------
cat <<EOF
================ 后续必须手工完成 ================
1. 重启 Jenkins 使新密码生效:
docker restart $JENKINS_CONTAINER
验证能登录后删除:
rm -f $JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy
2. 更新 Jenkins 内凭据 gitea-credentials 为新令牌:
Manage Jenkins → Credentials → gitea-credentials → Update
(或用 casc.yaml + JCasC reload
3. 同步 Gitea 数据库连接密码(若改了 DB 密码):
改 app.ini [database] PASSWD 与 .env 的 GITEA_DB_PASSWORD 一致后重启 Gitea
4. 清理 git 历史中的明文凭据:
cd $REPO_DIR && ./infra/cicd/security/purge-git-history.sh
5. 把 scripts/setup-cicd/cicd.config 从仓库删除,改为 .env 模板引用
6. 重新签发所有依赖旧凭据的地方(本机 git credential、其他协作者、registry 登录)
==================================================
EOF