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:
Executable
+189
@@ -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
|
||||
Executable
+188
@@ -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
|
||||
Reference in New Issue
Block a user