Git 共享凭据配置 — 实战教程与开发计划

Git 共享凭据批量配置工具 v2 — 教程与开发计划

面向对象:有基础 Linux 和 Git 操作经验的新人(约1个月经验)
版本: v2.0 | 运行环境: 跳板机 | 支持并发


一、背景:我们要解决什么问题?

团队有大量开发服务器(1500+),每台服务器上都有 git 仓库。每次有新同事加入或换机器工作,都要重新输入代码仓库平台的用户名和密码。

目标: 批量在所有服务器上设置共享凭据存储,让所有用户在 git 操作时自动使用保存好的账号密码,无需任何用户额外操作。

约束条件:

  • 没有 sudo/root 权限
  • 无法修改系统级配置文件
  • 通过跳板机统一管理(脚本直接在跳板机上执行,单跳 SSH 到目标机)
  • git 版本 2.36.0+
  • 需要支持高并发处理大量服务器

二、预备知识

2.1 Git 的三层配置

system  → git安装目录/etc/gitconfig(影响所有用户)
global  → ~/.gitconfig(只影响当前用户)
local   → 仓库/.git/config(只影响当前仓库)

优先级:local > global > system。高优先级覆盖低优先级。

2.2 Git Credential Helper

credential.helper 帮你记住 git 密码。store 方式将凭据明文存到文件:

git config --global credential.helper store
# 下次输密码后保存到 ~/.git-credentials
# 文件格式: https://用户名:密码@服务器地址

2.3 credential.helper 的多值行为

  • 读取凭据时: 按顺序尝试每个 helper,第一个返回凭据的胜出
  • 存储凭据时: 所有 helper 都会被调用

三、方案探索过程

3.1 阶段1:系统级配置(失败)

目标: 让所有用户都使用同一个 credential helper。

最直觉的想法: 修改系统级配置,就像管理员设置公司电脑的统一规则一样。

操作步骤:

$ git config --system --add credential.helper 'store --file /repo/shared/git-credentials'

解释 --system 参数:

  • git config 有三个级别:--system(所有用户)、--global(当前用户)、--local(当前仓库)
  • --system 会修改一个全局配置文件,类似于 Windows 注册表中的"所有用户"设置
  • 这个文件的位置在 git 编译安装时就固定了

报错:

error: could not lock config file /app/vbuild/.../etc/gitconfig: No such file or directory

报错含义:

  • git 想写入 /app/vbuild/.../etc/gitconfig 这个文件
  • "No such file or directory" — 这个文件(甚至目录)根本不存在
  • "could not lock" — git 写文件前要先创建一个 .lock 临时文件防止并发写入,但连目录都没有自然创建不了

排查过程:

# 确认 git 认为系统配置文件在哪
$ git config --system --list --show-origin
fatal: unable to read config file '/app/vbuild/RHEL8-x86_64/git/2.36.0/etc/gitconfig': No such file or directory

# 检查那个目录是否存在
$ ls /app/vbuild/RHEL8-x86_64/git/2.36.0/etc
ls: cannot access '...': No such file or directory

# 能不能自己创建?检查父目录权限
$ ls -ld /app/vbuild/RHEL8-x86_64/git/2.36.0/
drwxrwsr-x 8 build-admin build-group 2048 May 6 2022 ...
# 解读: owner 是 build-admin, group 是 build-group
# rwxrwsr-x 表示: owner 可读写执行, group 可读写执行, 其他人只能读和执行

# 我是不是 build-group 的成员?
$ id
uid=xxx(shared-user) gid=xxx(devgroup) groups=xxx(devgroup)...
# 不是!所以我对这个目录只有 r-x(读和执行),没有 w(写),无法创建子目录

什么是文件权限?(新人必读)

Linux 每个文件/目录有三组权限:owner(拥有者)、group(所属组)、other(其他人)。

drwxrwsr-x
│├─┤├─┤├─┤
│ │  │  └─ other: r-x(读+执行,不能写)
│ │  └──── group: rws(读+写+执行,s 是特殊位)
│ └─────── owner: rwx(读+写+执行)
└────────── d = 目录

你要写入一个目录(创建文件/子目录),需要对该目录有 w(写)权限。

尝试的其他方案:

方案 执行的命令 结果 原因
创建系统配置目录 mkdir -p /app/.../etc Permission denied 父目录无写权限
写入标准系统配置 git config -f /etc/gitconfig ... Permission denied /etc 属于 root
设置环境变量 export GIT_CONFIG_SYSTEM=/repo/shared/gitconfig 技术上可行 但需要每个用户在自己的 .bashrc 中添加这一行,不算"零操作"
GIT_CONFIG_COUNT GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper ... 技术上可行 同上,需要每个用户设置环境变量

结论: 没有 sudo/root 权限时,Linux 的安全模型不允许普通用户影响其他用户的全局配置。这是操作系统层面的限制,不是 git 的 bug。


3.2 阶段2:发现 local 配置的妙用

思路转变: 既然不能改"全局规则",能不能改"仓库内部的规则"?

关键洞察: 仓库的 .git/config 文件是所有用户共享的!

# 举例:/repo/user-a/project/.git/config 这个文件
# 无论是 user-a 还是 user-b 在这个仓库里执行 git 命令,
# 都会读取同一个 .git/config 文件

类比:system 配置像公司的规章制度(需要管理层批准),local 配置像项目组的内部约定(项目组成员可以自行修改)。

操作步骤:

$ cd /repo/user-a/project
$ git config --local credential.helper 'store --file /repo/.git-credentials/credentials'

# 验证:看看配置写到哪了
$ cat .git/config
[credential]
    helper = store --file /repo/.git-credentials/credentials

新问题浮现:

假设用户 user-b 的 ~/.gitconfig(global 配置)中有:

[credential]
    helper = store

当 user-b 在这个仓库执行 git pull 时,git 会按顺序收集所有级别的 credential.helper:

  1. system 级(如果有的话)
  2. global 级:store(存到 ~/.git-credentials
  3. local 级:store --file /repo/.git-credentials/credentials

git 从第一个 helper 开始尝试读取凭据。如果 user-b 的 ~/.git-credentials 里已经有密码了,git 在第 2 步就拿到凭据了,根本不会走到第 3 步。我们的共享配置形同虚设。

关键发现 — 空字符串重置机制:

翻阅 git 官方文档发现:

在多值配置项中(如 credential.helper),如果某个值是空字符串 "",它会清除该点之前所有层级继承来的值。只有空字符串之后的配置才生效。

这就像在一叠纸中间插入一张白纸说"前面的全部作废,从这里重新开始"。

操作步骤:

# 第一步:写入空字符串(清除所有继承的 helper)
$ git config --local credential.helper ""

# 第二步:追加实际的 helper(--add 表示追加,不覆盖上面的空字符串)
$ git config --local --add credential.helper "store --file /repo/.git-credentials/credentials"

效果:

$ git config --show-origin --show-scope --get-all credential.helper
global  file:~/.gitconfig    store              ← 还在,但被下面的空字符串清除
local   file:.git/config                        ← 空字符串(重置点:前面全部作废)
local   file:.git/config     store --file ...   ← 唯一实际生效的 helper

此时 .git/config 文件内容:

[credential]
    helper =
    helper = store --file /repo/.git-credentials/credentials

第一行 helper =(空值)起到"断路器"作用。无论用户在自己的 global/system 中配了什么 credential.helper,到了 local 这里全部被清零重置。

为什么 --add 很重要?

# 不加 --add:会覆盖前面设置的空字符串
$ git config --local credential.helper "store --file ..."
# .git/config 结果:
#   helper = store --file ...    ← 只有一行,空字符串被覆盖了!

# 加 --add:追加一个新值
$ git config --local --add credential.helper "store --file ..."
# .git/config 结果:
#   helper =                     ← 空字符串保留
#   helper = store --file ...    ← 追加在后面

3.3 阶段3:批量设置与 safe.directory 问题

新挑战: 我们有几十个仓库,不可能一个一个手动设置。需要写脚本批量操作。

查找所有仓库:

# find 命令:在目录树中搜索文件/目录
$ find /repo -maxdepth 4 -type d -name ".git"
/repo/user-a/project-1/.git
/repo/user-a/project-2/.git
/repo/user-a/group/project-3/.git
...

解释每个参数:

  • /repo — 从这个目录开始搜索
  • -maxdepth 4 — 最多往下搜索 4 层子目录
  • -type d — 只找目录(d=directory),不找文件
  • -name ".git" — 目录名必须是 .git

为什么限制 maxdepth?

# 不限制深度:会进入 node_modules、.git/objects 等大量无关目录
$ time find /repo -type d -name ".git" | wc -l
37 个结果, 耗时 2.139 秒

# 限制 4 层:结果一样但快 100 倍
$ time find /repo -maxdepth 4 -type d -name ".git" | wc -l
37 个结果, 耗时 0.022 秒

仓库最深的路径是 /repo/用户/分组/项目/.git(4 层),所以 maxdepth 4 刚好覆盖所有情况。

遇到问题 — safe.directory 安全检查:

# 尝试配置别人的仓库
$ git -C /repo/other-user/project config --local credential.helper ""
fatal: unsafe repository ('/repo/other-user/project' is owned by someone else)
To add an exception for this directory, call:
    git config --global --add safe.directory /repo/other-user/project

什么是 safe.directory?

这是 git 2.35.2 引入的安全机制。背景:2022 年发现一个安全漏洞——攻击者可以在共享目录中放置恶意的 .git/config,当其他用户在该目录中执行 git 命令时会被利用。

所以 git 增加了一个检查:如果仓库目录的 owner 不是当前用户,拒绝操作

# 仓库目录 owner 是 other-user
$ ls -ld /repo/other-user/project
drwxrwxr-x 8 other-user devgroup 307 ...

# 当前用户是 shared-user ≠ other-user → 被拒绝

为什么 git -c safe.directory='*' 不好用?

# 尝试用 -c 参数绕过
$ git -c safe.directory='*' -C /repo/other-user/project config --local ...
# 在 git 2.36.0 中不稳定,有时仍然报错

-c 参数是在 git 命令执行之后注入配置,但 safe.directory 检查发生在解析仓库的早期阶段,可能在 -c 生效之前就已经拒绝了。

解决方案 — git config -f

# git config -f <文件路径> 直接操作指定文件
$ git config -f /repo/other-user/project/.git/config credential.helper ""
# 成功!没有报错

为什么 -f 能绕过?两种方式的本质区别:

git -C <目录> config --local

1. 进入目录
2. 识别这是一个 git 仓库
3. 检查仓库 owner 是否是当前用户 ← safe.directory 检查
4. 检查通过后才读写 .git/config

git config -f <文件>

1. 打开指定文件
2. 当作普通 ini 格式文件读写
3. 完成

-f 模式下,git 不知道这个文件跟 git 仓库有什么关系,它只是在编辑一个普通文本文件。所以不会触发任何仓库级别的安全检查。唯一的限制是操作系统的文件权限——你需要对该文件有写权限。

类比: git -C 相当于"去图书馆借书,需要出示借阅证";git config -f 相当于"直接在书上做笔记"——你不是通过图书馆系统操作,所以不需要借阅证。


3.4 阶段4:凭据文件的权限难题

凭据文件存在哪?所有用户要能读写它。这里我们踩了两个大坑。

坑1 — sticky bit 阻止 rename

最初想直接把文件放在 /repo/.git-credentials

$ touch /repo/.git-credentials
$ chmod 666 /repo/.git-credentials
# 看起来所有人都能读写了?

然后某个用户执行 git pull,输入密码后:

fatal: unable to write credential store: Operation not permitted

什么是 sticky bit?

查看 /repo 目录权限:

$ ls -ld /repo
drwxrwxrwt 6 root root 99 ...
#        ^ 注意这个 t

权限末尾的 t 就是 sticky bit。最常见的例子是 /tmp 目录。

正常情况下,如果一个目录权限是 rwxrwxrwx(所有人可读写),那任何人都可以删除目录里的任何文件——即使文件不属于自己。这在共享目录中很危险。

sticky bit 的作用是:即使目录对所有人开放写权限,也只有文件的 owner(或 root)才能删除/重命名该目录中的文件

# 举例:/tmp 目录有 sticky bit
$ ls -ld /tmp
drwxrwxrwt 20 root root 4096 ...

# user-a 在 /tmp 创建了一个文件
$ touch /tmp/user-a-file

# user-b 尝试删除它
$ rm /tmp/user-a-file
rm: cannot remove '/tmp/user-a-file': Operation not permitted
# 被 sticky bit 阻止了!

为什么影响到 git credential store?

git credential store 写入文件的内部流程:

1. 创建临时文件: /repo/.git-credentials.XXXXXX
2. 把新的凭据内容写入临时文件
3. rename() 临时文件 → 覆盖 /repo/.git-credentials

第 3 步的 rename() 本质上是"删除旧文件 + 把临时文件改名"。如果 /repo/.git-credentials 是 user-a 创建的,user-b 的 rename 操作会被 sticky bit 阻止。

为什么 git 不直接写入而用 rename?

这是一种原子写入(atomic write)模式,防止写入过程中断电导致文件损坏:

  • 直接写入:如果写到一半断电,文件只有半截,损坏了
  • rename 方式:临时文件写完后才 rename,如果中途断电,旧文件还在,不会损坏

解决方案:用子目录绕过 sticky bit

$ mkdir -p /repo/.git-credentials     # 创建子目录
$ chmod 777 /repo/.git-credentials    # 所有人可读写执行

关键:子目录本身没有 sticky bit(权限是 rwxrwxrwx 不是 rwxrwxrwt)。在子目录中,rename 操作不受限制。

$ ls -ld /repo/.git-credentials
drwxrwxrwx 2 shared-user devgroup 25 ...
#        ^ x 不是 t!

坑2 — umask 导致新建文件权限不对

创建好子目录和凭据文件:

$ touch /repo/.git-credentials/credentials
$ chmod 666 /repo/.git-credentials/credentials
$ ls -la /repo/.git-credentials/credentials
-rw-rw-rw- 1 shared-user devgroup 0 credentials   # ✅ 权限正确

然后 user-b 执行 git pull 输入密码,git 存入凭据后:

$ ls -la /repo/.git-credentials/credentials
-rw------- 1 user-b devgroup 58 credentials   # ❌ 权限变成 600!

发生了什么?

还记得 git 的原子写入吗?它不是"修改原文件",而是"删除原文件 → 创建新文件"。新创建的文件权限由以下公式决定:

新文件权限 = 0666 & ~umask

什么是 umask?

umask 是每个用户的"权限掩码",决定新建文件时去掉哪些权限

$ umask
0077
# 含义:新建文件时,去掉 group 和 other 的所有权限
# 0666 & ~0077 = 0600 = rw------- (只有 owner 能读写)

所以 user-b 创建的新文件权限是 600,其他用户(包括 user-a 和 shared-user)都无法读写。

为什么 chmod 没用?

我们之前 chmod 666 设置的是旧文件的权限。git 写入时删除了旧文件,创建了新文件——新文件是全新的,不继承旧文件的权限

解决方案 — ACL 默认权限

ACL(Access Control List,访问控制列表)是 Linux 权限系统的扩展,比传统的 rwx 更灵活。

其中有一个功能叫默认 ACL(default ACL):可以给目录设置规则,规定"在这个目录下新建的文件自动获得什么权限",不受 umask 影响

# 设置默认 ACL
$ setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/

参数解释:

  • setfacl — 设置 ACL 的命令
  • -d — 设置的是默认(default)ACL,只影响未来新建的文件
  • -m — 修改(modify)
  • u::rw — user(owner)权限:读+写
  • g::rw — group 权限:读+写
  • o::rw — other 权限:读+写

设置后验证:

$ getfacl /repo/.git-credentials/
# file: repo/.git-credentials/
# owner: shared-user
# group: devgroup
user::rwx
group::rwx
other::rwx
default:user::rw-      ← 新建文件 owner 权限
default:group::rw-     ← 新建文件 group 权限
default:other::rw-     ← 新建文件 other 权限

现在 user-b 执行 git pull 后:

$ ls -la /repo/.git-credentials/credentials
-rw-rw-rw- 1 user-b devgroup 58 credentials   # ✅ 权限正确!

虽然 user-b 的 umask 是 0077,但默认 ACL 覆盖了 umask 的限制。

补充 — 文件 owner 为什么变了?

注意上面文件 owner 从 shared-user 变成了 user-b。这是因为 git 的原子写入流程:

1. 删除旧文件(owner=shared-user)
2. 创建新的临时文件(owner=user-b,因为是 user-b 创建的)
3. rename 为 credentials

新文件是 user-b 创建的,所以 owner 是 user-b。这是正常行为,不影响使用——ACL 保证了无论 owner 是谁,所有人都有 rw 权限。

总结这两个坑:

根因 解决
sticky bit /repot 位,非 owner 不能 rename 凭据放子目录(无 t 位)
umask git 创建新文件时受 umask 限制 ACL 默认权限覆盖 umask

坑3 — 仓库目录本身无 group 写权限(部署后发现)

解决了凭据文件的问题后,批量部署时发现大量服务器被标记为"无可写仓库":

[server-01/user-x] SKIP (无可写 git 仓库)

手动检查发现仓库目录确实存在,但权限是 drwxr-xr-x(755),.git/config 文件权限是 -rw-r--r--(644):

$ ls -ld /repo/user-x/project/
drwxr-xr-x  9 user-x devgroup 183 ... project/
$ ls -l /repo/user-x/project/.git/config
-rw-r--r--  1 user-x devgroup 298 ... config    ← group 只有 r(读),没有 w(写)

共享用户 shared-user 属于 devgroup 组,有 r(能读)但没有 w(不能写)。脚本中 [ ! -w "$cf" ] && continue 把这些仓库全部跳过了。

为什么有的服务器可以、有的不行?

对比两台服务器:

# 服务器 A(成功)— user-a 在 tcsh 中 clone 的(umask 002)
$ ls -ld /repo/user-a/project/
drwxrwxr-x  ...   ← 775, group 可写 ✅

# 服务器 B(失败)— user-x 通过 SSH login bash clone 的(umask 022)
$ ls -ld /repo/user-x/project/
drwxr-xr-x  ...   ← 755, group 不可写 ❌

用户 clone 仓库时所处 shell 的 umask 不同,导致创建出来的目录权限不同。

为什么 ACL 解决不了这个问题?

ACL 能解决凭据文件的问题,是因为凭据目录是我们自己创建的(我们是 owner,能设置 ACL)。但仓库目录是其他用户创建的,我们不是 owner,setfacl 会报 Operation not permitted

这个问题的本质:

我们能控制的:          我们不能控制的:
├─ 凭据目录权限 ✅      ├─ 别人仓库的权限 ❌
├─ 脚本逻辑 ✅          ├─ 别人的 umask ❌
└─ 状态标记 ✅          └─ 系统全局配置 ❌(无 root)

最终处理: 脚本自动检测、标记为 no_repos 跳过。需要修复只能由 owner 本人执行 chmod -R g+w /repo/$(whoami)/

详细的 umask 原理、为什么同一用户在不同 shell 中 umask 不同、以及预防措施,参见 第八章 8.4 节


3.5 阶段5:远程批量执行与并发

问题: 有 1500+ 台服务器需要配置,不可能一台台手动登录。

3.5.1 远程执行的原理

SSH 可以直接在远程机器上执行命令:

# 最简单的远程执行
$ ssh user@server hostname
dev-server-01.example.com

但我们需要执行的不是一条命令,而是一段脚本(几十行)。方法是用 bash -s

# bash -s 表示"从 stdin 读取脚本执行"
# <<'EOF' ... EOF 是 heredoc,把中间的内容作为 stdin 传给 bash -s
$ ssh user@server bash -s <<'EOF'
echo "我在远程机器上执行"
echo "当前主机: $(hostname)"
echo "当前用户: $(whoami)"
EOF

为什么用单引号 'EOF' 因为单引号告诉 bash "不要在本地展开变量"。如果写 <<EOF(无引号),$(hostname) 会在本地先被替换成跳板机的 hostname,而不是远程机器的。

3.5.2 获取远程执行的输出

$() 捕获远程命令的所有 stdout 输出:

# result 变量会包含远程脚本打印的所有内容
result=$(ssh user@server bash -s <<'EOF'
echo "第一行"
echo "第二行"
EOF
)
echo "$result"
# 输出:
# 第一行
# 第二行

3.5.3 重试机制

SSH 连接可能偶尔失败,所以需要重试:

run_remote() {
  local server=$1
  local script=$2
  local attempt=0

  while [ $attempt -lt 3 ]; do        # 最多重试 3 次
    ((attempt++))
    output=$(ssh -o ConnectTimeout=10 user@"$server" bash -s <<EOF
$script
EOF
    )
    if [ $? -eq 0 ]; then             # $? 是上一条命令的退出码,0=成功
      echo "$output"                   # 成功:输出结果
      return 0
    else
      echo "第${attempt}次失败,5秒后重试..." >&2   # 失败:输出到 stderr
      sleep 5
    fi
  done
  return 1                             # 3 次都失败
}

关键: 重试信息输出到 >&2(stderr),不会混入 $() 捕获的返回值(只捕获 stdout)。

# 调用示例
result=$(run_remote "dev-server-01.example.com" 'echo "hello"')
# result = "hello"(不会包含重试信息)

3.5.4 为什么需要并发?

串行执行 1500 台,每台约 5-10 秒:

1500 × 7秒 = 10500秒 ≈ 3小时

20 并发:

1500 ÷ 20 × 7秒 = 525秒 ≈ 9分钟

3.5.5 并发的实现:xargs -P

xargs 是 Linux 自带的命令,-P 参数指定并发数:

# 串行:一个一个执行
echo -e "server1\nserver2\nserver3" | xargs -I {} echo "处理 {}"
# 输出(按顺序):
# 处理 server1
# 处理 server2
# 处理 server3

# 并发:同时执行 3 个
echo -e "server1\nserver2\nserver3" | xargs -P 3 -I {} echo "处理 {}"
# 输出(顺序不确定,因为同时执行):
# 处理 server2
# 处理 server1
# 处理 server3

在我们的脚本中:

# 把所有待处理的服务器名输出,每行一个
# xargs -P 20 同时启动最多 20 个子进程
# 每个子进程调用 process_server 函数处理一台服务器
printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}

拆解这行命令:

  • printf '%s\n' "${SERVERS[@]}" — 把服务器数组逐行输出
  • | — 管道,传给 xargs
  • xargs -P 20 — 最多 20 个并发进程
  • -I {} — 用 {} 代表每一行输入(即服务器名)
  • bash -c 'process_server "$@"' _ {} — 对每个服务器启动一个 bash 调用 process_server

为什么是 bash -c '...' _ {}

  • bash -c 'cmd "$@"' — 在新 bash 中执行命令
  • _ — 占位符,对应 $0(脚本名),不使用
  • {} — xargs 替换为实际的服务器名,传入作为 $1(即 "$@" 的内容)

3.5.6 并发时的数据安全问题

什么是数据安全问题?

想象一个生活中的例子:有一本签到簿(相当于 servers.json),20 个人(子进程)同时想在上面写名字。如果所有人同时拿起笔写,就会出现:

  • 两个人写在同一行,字迹重叠看不清
  • 有人还没写完就被别人翻页了

这就是"并发写入冲突"。

我们的场景中哪些地方有风险?

子进程1: 处理 server-A,想把状态写成 "configured"
子进程2: 处理 server-B,想把状态写成 "no_repos"
子进程3: 处理 server-C,想把状态写成 "configured"
     ↓ 如果三个同时改 servers.json ↓

JSON 文件不支持"只改一行",必须读取整个文件 → 修改 → 覆盖写回。如果三个进程同时这样做:

时间线:
  子进程1: 读取 JSON(A=pending, B=pending, C=pending)
  子进程2: 读取 JSON(A=pending, B=pending, C=pending)  ← 读到的还是旧数据!
  子进程1: 写回 JSON(A=configured, B=pending, C=pending)
  子进程2: 写回 JSON(A=pending, B=no_repos, C=pending) ← 子进程1的修改被覆盖了!

结果:子进程1 把 server-A 改成了 configured,但被子进程2 的写入覆盖回了 pending。数据丢失了。

我们的解决方案:不让子进程直接改 JSON

思路类似于"大家不要直接写签到簿,每人发一张便利贴写上名字,最后由一个人统一贴上去"。

# ❌ 错误方式:每个子进程直接改 servers.json
process_server() {
  # ... 处理服务器 ...
  update_status "$server" "configured"  # 多个进程同时改同一个文件 → 冲突!
}

# ✅ 正确方式:子进程写便利贴(临时文件),主进程最后统一处理
process_server() {
  # ... 处理服务器 ...
  # 只是追加一行到临时文件(相当于贴便利贴)
  echo "$server:configured" >> "$SCRIPT_DIR/.status_updates"
}

# 主进程等所有子进程结束后,统一更新 JSON
while IFS=: read -r host new_status backup_path; do
  update_status "$host" "$new_status" "$backup_path"  # 只有一个进程在改,没有冲突
done < "$SCRIPT_DIR/.status_updates"

为什么追加(>>)不会冲突?

>> 是追加写入(append),不是覆盖。类比:

  • > 覆盖 = 擦掉整页重写 → 多人同时写会互相覆盖
  • >> 追加 = 在末尾加一行 → 多人同时加各自的行

Linux 内核保证:当写入内容小于 PIPE_BUF(通常 4096 字节),单次 write() 系统调用是原子的。我们每行只有几十个字符(如 server-01.example.com:configured),远小于 4096,所以不同进程追加的行不会互相交错。

用实际例子说明完整流程:

# 假设有 3 台服务器待处理,并发数 3
SERVERS=(server-A server-B server-C)

# 步骤1:清空临时文件
> .status_updates                  # 文件内容为空

# 步骤2:启动 3 个并发子进程
# 子进程1(处理 server-A):
#   SSH 到 server-A → 配置成功
#   追加: echo "server-A:configured" >> .status_updates
#
# 子进程2(处理 server-B):
#   SSH 到 server-B → 无仓库
#   追加: echo "server-B:no_repos" >> .status_updates
#
# 子进程3(处理 server-C):
#   SSH 到 server-C → 连接失败
#   追加: echo "server-C:failed" >> .status_updates

# 步骤3:所有子进程结束后,.status_updates 内容:
#   server-A:configured
#   server-B:no_repos
#   server-C:failed
# (顺序可能不同,但每行内容完整)

# 步骤4:主进程逐行读取,更新 servers.json
#   读第1行 → jq 更新 server-A 为 configured
#   读第2行 → jq 更新 server-B 为 no_repos
#   读第3行 → jq 更新 server-C 为 failed
# 没有冲突,每次只有主进程一个在操作 JSON

备份文件为什么不会冲突?

每个子进程备份时写入不同的文件:

# 子进程1 备份到: git-credentials-backup/server-01_user-a_credentials
# 子进程2 备份到: git-credentials-backup/server-02_user-b_credentials
# 子进程3 备份到: git-credentials-backup/server-03_user-d_credentials

文件名包含 服务器短名_用户名,每台服务器的组合是唯一的,所以 20 个子进程同时写 20 个不同的文件,互不干扰。

总结:并发安全的三条原则

原则 我们的做法
不共享可变状态 每个子进程操作不同的远程服务器
写入用追加而非覆盖 状态更新写入 .status_updates>>
汇总由单一进程完成 主进程最后统一更新 servers.json

3.5.7 并发时的备份文件命名

多个子进程同时备份,文件名必须不能冲突:

# 用 服务器名+用户名 组合,确保唯一
backup_file="git-credentials-backup/${short_server}_${username}_credentials"
# 例如: server-01_user-a_credentials
#       server-02_user-b_credentials

每台服务器的 server_name 和 username 组合是唯一的,所以不会冲突。

3.5.8 完整的并发执行流程图

主进程
  │
  ├─ 读取 servers.json,获取待处理列表
  ├─ 清空 .status_updates 临时文件
  │
  ├─ xargs -P 20 启动并发 ──┬── 子进程1: process_server(server-A)
  │                          ├── 子进程2: process_server(server-B)
  │                          ├── 子进程3: process_server(server-C)
  │                          ├── ...
  │                          └── 子进程20: process_server(server-T)
  │
  │  (每个子进程独立 SSH 到目标机执行,结果追加到 .status_updates)
  │
  ├─ 所有子进程结束(xargs 自动等待)
  ├─ 读取 .status_updates,批量更新 servers.json
  └─ 打印汇总统计

3.5.9 架构对比(v1 vs v2)

v1(从开发机执行,双跳):
  开发机 ──SSH──→ 跳板机 ──SSH──→ 目标机
  - 每次请求经过两次 SSH 握手
  - 延迟高(~3-5秒/台)
  - 跳板机容易限流
  - 只能串行

v2(从跳板机执行,单跳):
  跳板机 ──SSH──→ 目标机
  - 只有一次 SSH 握手
  - 延迟低(~1-2秒/台)
  - 内网直连,稳定
  - 支持 20+ 并发

性能实测对比:

方式 1500 台耗时
v1 双跳串行 4-7 小时
v2 单跳串行 2-3 小时
v2 单跳 10 并发 15-20 分钟
v2 单跳 20 并发 8-10 分钟

四、最终方案

4.1 文件结构

/path/to/shared-credentials-tools-v2/
├── lib/common.sh                     # 公共函数库
├── servers.json                      # 服务器配置 + 状态管理
├── check-credentials-remote.sh       # 主脚本:远程批量执行(支持并发)
├── setup-shared-git-credentials.sh   # 本地设置(可 scp 到目标机单独执行)
├── cleanup-shared-git-credentials.sh # 本地清除(回滚用)
├── git-credentials-backup/           # 凭据备份目录
└── README.md                         # 本文档

4.2 servers.json 配置

{
  "ssh_user": "shared-user",
  "cred_dir": "/repo/.git-credentials",
  "cred_file": "/repo/.git-credentials/credentials",
  "max_retries": 3,
  "retry_interval": 5,
  "server_interval": 1,
  "servers": [
    {
      "host": "dev-server-01.example.com",
      "username": "user-a",
      "status": "backed_up_deleted",
      "backup_path": "git-credentials-backup/dev-server-01_user-a_credentials",
      "processed_at": "2026-06-22T12:18:00+02:00"
    },
    {
      "host": "dev-server-02.example.com",
      "username": "user-b",
      "status": "pending"
    }
  ]
}

全局配置字段:

字段 说明
ssh_user SSH 登录用户名
cred_dir 共享凭据目录路径
cred_file 共享凭据文件路径
max_retries SSH 连接最大重试次数
retry_interval 重试间隔(秒)
server_interval 串行时服务器之间的等待时间(秒)

4.3 状态流转

pending → configured → backed_up_deleted (终态)
   ↑                        ↑
   └── failed ──┘     backed_up ──┘ (删除失败时中间状态)
   
   pending → invalid_user (终态)
   pending → not_target (终态)
   pending → no_repos (终态)
状态 含义 脚本执行时的操作
pending 初始状态 验证用户 → 扫描仓库 → 配置 → configured
configured 已设置,等待凭据写入 检测凭据文件有内容 → 备份+清除+删除 → backed_up_deleted
backed_up 备份成功但删除失败 重试清除配置+删除 → backed_up_deleted
backed_up_deleted 完成(终态) 跳过
failed 连接失败 下次自动重试
invalid_user /home 下无此用户(终态) 跳过
not_target /repo 下无此用户目录(终态) 跳过
no_repos 无可写 git 仓库(终态) 跳过

典型使用流程:

  1. 第一次执行:pendingconfigured(设置完成)
  2. 用户在远程服务器执行 git pull 输入密码(凭据写入文件)
  3. 第二次执行:configuredbacked_up_deleted(备份+清除+删除完成)

4.4 使用方式

# 处理所有非终态服务器(串行)
bash check-credentials-remote.sh

# 20 并发处理所有 pending
bash check-credentials-remote.sh --status pending --parallel 20

# 只处理 configured 状态(检测凭据并备份)
bash check-credentials-remote.sh --status configured --parallel 10

# 重试失败的
bash check-credentials-remote.sh --status failed --parallel 10

# 预览模式(不修改任何文件)
bash check-credentials-remote.sh --dry-run

# 查看状态统计
bash check-credentials-remote.sh --check

# 显示帮助
bash check-credentials-remote.sh --help

4.5 pending 状态详细流程

对每台 pending 服务器,脚本通过一次 SSH 完成:

  1. 验证用户

    • 检查 /home/{username} 是否存在 → 否则 invalid_user
    • 检查 /repo/{username} 是否存在 → 否则 not_target
  2. 扫描仓库

    • find /repo -maxdepth 4 -type d -name '.git'
    • 0 个结果 → no_repos
  3. 配置仓库

    • 对每个可写的 .git/config:清除旧配置 → 写入空字符串 → 写入共享 helper
    • 配置成功 0 个 → no_repos(有仓库但无写权限)
  4. 创建凭据目录(仅在步骤 3 成功后)

    • mkdir -p /repo/.git-credentials + chmod 777 + setfacl
    • touch credentials + chmod 666
  5. 更新状态configured

4.6 configured 状态详细流程

  1. 检测凭据文件[ -s /repo/.git-credentials/credentials ]
  2. 为空 → 打印提示,保持 configured 不变
  3. 有内容 → 通过 SSH 读取文件内容,保存到本地备份目录
  4. 清除配置 — 远程所有仓库 .git/config 中的 credential.helper
  5. 删除文件和目录rm -f credentials + rmdir .git-credentials/
  6. 更新状态 → 成功 backed_up_deleted / 删除失败 backed_up

4.7 本地脚本(辅助)

当网络不通或需要在单台服务器上操作时:

# 在目标服务器上直接执行设置
bash setup-shared-git-credentials.sh
bash setup-shared-git-credentials.sh --dry-run
bash setup-shared-git-credentials.sh --check

# 在目标服务器上回滚
bash cleanup-shared-git-credentials.sh
bash cleanup-shared-git-credentials.sh --dry-run

五、系统架构

5.1 整体工作流程

先从宏观理解:这套工具做什么事、怎么做、涉及哪些角色。

角色:

  • 跳板机(你坐在这里操作)— 执行脚本的地方
  • 目标服务器(1500+ 台)— 要被配置的远程机器
  • servers.json — "花名册",记录每台服务器的信息和当前状态

工作流程(类比快递分拣):

你(跳板机)就像快递分拣中心的操作员:
1. 打开花名册(servers.json),找到待处理的包裹(pending 状态的服务器)
2. 同时派出 20 个快递员(并发子进程)
3. 每个快递员去一个地址(SSH 到目标服务器)
4. 到了之后检查情况、完成操作
5. 快递员回来汇报(写入临时文件)
6. 你统一在花名册上更新状态

用一张图表示数据流向:

                    ┌─────────────┐
                    │ servers.json │ ← 配置+状态(输入/输出)
                    └──────┬──────┘
                           │ 读取
                           ▼
┌──────────────────────────────────────────────────┐
│              check-credentials-remote.sh          │
│                                                    │
│  1. 读取 JSON,过滤待处理的服务器                 │
│  2. 启动 N 个并发子进程                           │
│  3. 等待所有子进程完成                            │
│  4. 读取临时文件,统一更新 JSON                   │
└──────────────────┬───────────────────────────────┘
                   │ SSH(并发)
          ┌────────┼────────┐
          ▼        ▼        ▼
     ┌────────┐ ┌────────┐ ┌────────┐
     │服务器 A│ │服务器 B│ │服务器 C│ ...(最多 N 个同时)
     └────┬───┘ └────┬───┘ └────┬───┘
          │          │          │
          ▼          ▼          ▼
     验证用户     验证用户     验证用户
     扫描仓库     扫描仓库     扫描仓库
     配置/备份    配置/备份    配置/备份
          │          │          │
          ▼          ▼          ▼
     ┌──────────────────────────────┐
     │     .status_updates          │ ← 临时文件(子进程追加结果)
     └──────────────┬───────────────┘
                    │ 主进程读取
                    ▼
              更新 servers.json
              输出到 git-credentials-backup/

5.2 模块划分(文件对应功能)

每个文件就像一个"工具",各有各的用途:

文件 角色 类比
check-credentials-remote.sh 主脚本,指挥所有操作 总调度(决定做什么、调谁去做)
lib/common.sh 公共函数库,被其他脚本引用 工具箱(锤子、螺丝刀都在里面)
servers.json 配置和状态数据 花名册 + 任务清单
setup-shared-git-credentials.sh 本地设置脚本 单兵装备(单台服务器上直接使用)
cleanup-shared-git-credentials.sh 本地清除脚本 急救包(出问题时回滚用)
git-credentials-backup/ 备份目录 档案柜(保存收集到的凭据)

它们之间的依赖关系:

check-credentials-remote.sh ──引用──→ lib/common.sh ──读取──→ servers.json
setup-shared-git-credentials.sh ──引用──→ lib/common.sh ──读取──→ servers.json
cleanup-shared-git-credentials.sh ──引用──→ lib/common.sh ──读取──→ servers.json

所有脚本都依赖 lib/common.shservers.json。修改公共库时要注意三个脚本都会受影响。

5.3 公共函数库(lib/common.sh)里有什么

┌─────────────────────────────────────────────────────────┐
│                    lib/common.sh                          │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  ┌─ 入口函数 ──────────────────────────────────────┐    │
│  │ parse_args()     解析命令行参数                  │    │
│  │ show_usage()     显示帮助信息                    │    │
│  │ preflight_check() 检查依赖(jq/git/setfacl)    │    │
│  │ load_config()    从 JSON 读取配置到全局变量      │    │
│  └─────────────────────────────────────────────────┘    │
│                                                          │
│  ┌─ 核心操作 ──────────────────────────────────────┐    │
│  │ run_remote()     SSH 到目标机执行脚本(带重试)  │    │
│  │ update_status()  更新 servers.json 中的状态      │    │
│  └─────────────────────────────────────────────────┘    │
│                                                          │
│  ┌─ 全局变量 ──────────────────────────────────────┐    │
│  │ CRED_DIR, CRED_FILE, HELPER                     │    │
│  │ SSH_USER, MAX_RETRIES, RETRY_INTERVAL           │    │
│  │ DRY_RUN, CHECK_MODE, FILTER_STATUS, PARALLEL    │    │
│  └─────────────────────────────────────────────────┘    │
│                                                          │
└─────────────────────────────────────────────────────────┘

为什么要提取公共库?

假设没有公共库,三个脚本各自写自己的 SSH 连接函数、JSON 读取函数……如果某天需要改 SSH 超时时间:

  • 没有公共库 → 要改三个文件,容易遗漏
  • 有公共库 → 只改 common.sh 一处,所有脚本自动生效

这就是编程中的 DRY 原则(Don't Repeat Yourself,不要重复自己)。

5.4 脚本如何引用公共库

每个脚本开头都有这三行"启动仪式":

#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"   # 获取脚本所在目录的绝对路径
source "$SCRIPT_DIR/lib/common.sh"             # 加载公共库(相当于 import)

parse_args "$@"                                # 解析用户传入的参数
load_config "$SCRIPT_DIR/servers.json"         # 读取配置
preflight_check                                # 检查依赖是否满足

逐行解释:

  • SCRIPT_DIR=... — 无论从哪个目录执行脚本,都能正确找到同目录下的其他文件
  • source — 把另一个文件的内容"导入"到当前脚本,之后就能直接调用里面定义的函数
  • parse_args "$@"$@ 是用户传给脚本的所有参数(如 --parallel 20 --status pending
  • load_config — 读取 JSON,把配置值存入全局变量供后续使用
  • preflight_check — 如果缺少 jq 或 git,在这里就报错退出,避免执行到一半才失败

5.5 servers.json 的结构设计

{
  "ssh_user": "shared-user",        ← 全局配置(所有服务器共用)
  "cred_dir": "/repo/.git-credentials",
  "cred_file": "/repo/.git-credentials/credentials",
  "max_retries": 3,
  "retry_interval": 5,
  "server_interval": 1,
  "servers": [                      ← 服务器列表(每台独立)
    {
      "host": "dev-server-01.example.com",   ← 服务器地址
      "username": "user-a",                   ← 该服务器的主要用户
      "status": "configured",                 ← 当前状态
      "processed_at": "2026-06-24T10:00:00"   ← 上次处理时间
    },
    ...
  ]
}

为什么用 JSON 而不用简单的文本文件?

需求 文本文件 JSON
存储主机名 ✅ 每行一个
存储状态 ❌ 得用注释或特殊格式 ✅ 直接是字段
存储用户名 ❌ 得用分隔符 ✅ 直接是字段
存储处理时间 ❌ 很难 ✅ 直接是字段
按状态过滤 ❌ 需要复杂的 grep jq 一行搞定
被程序修改 ❌ sed 容易出错 jq 安全修改

5.6 并发机制详解

串行模式(默认,--parallel 1):

时间线: ═══[服务器A 7秒]═══[服务器B 7秒]═══[服务器C 7秒]═══
总耗时: 7 × 3 = 21 秒
for server in "${SERVERS[@]}"; do
  process_server "$server"    # 阻塞等待完成
done

并发模式(--parallel 3):

时间线: ═══[服务器A 7秒]═══
        ═══[服务器B 7秒]═══
        ═══[服务器C 7秒]═══
总耗时: 7 秒(三个同时进行)
printf '%s\n' "${SERVERS[@]}" | xargs -P 3 -I {} bash -c 'process_server "$@"' _ {}

并发安全保证:

多个子进程同时运行时,必须确保它们不会互相干扰:

资源 是否有冲突风险 为什么安全
远程服务器的 /repo ❌ 无风险 每个子进程操作不同的服务器
本地备份文件 ❌ 无风险 文件名含 server名+username,全局唯一
.status_updates 临时文件 ⚠️ 需要注意 用追加(>>)而非覆盖(>),Linux 保证短行追加原子性
servers.json ⚠️ 有风险 不让子进程直接改,主进程最后统一更新
终端输出(stdout) ⚠️ 会交错 每行加 [server/user] 前缀,方便区分来源

并发输出交错示例:

# 20 并发时,输出可能是这样的(各台服务器的结果混在一起):
[server-01/user-a] OK → configured (37 个仓库)
[server-03/user-c] SKIP (非目标开发人员)
[server-02/user-b] OK → configured (12 个仓库)
[server-05/user-e] OK → configured (8 个仓库)
[server-04/user-d] FAILED (连接失败)

这是正常的——并发执行时谁先完成谁先输出,顺序不可预测。通过前缀 [server/user] 可以区分每一行属于哪台服务器。


六、代码详解

本章按执行顺序逐段讲解主脚本 check-credentials-remote.sh 的代码逻辑。

6.1 启动阶段:初始化

#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"

parse_args "$@"
load_config "$SCRIPT_DIR/servers.json"
preflight_check

LOCAL_BACKUP_DIR="$SCRIPT_DIR/git-credentials-backup"
mkdir -p "$LOCAL_BACKUP_DIR"

逐行解释:

作用
#!/bin/bash 告诉系统用 bash 执行此脚本
SCRIPT_DIR=... 获取脚本所在目录的绝对路径(无论你从哪个目录运行它)
source .../common.sh 导入公共函数库,之后可以直接调用 run_remoteupdate_status 等函数
parse_args "$@" 解析你传入的参数(如 --parallel 20 --status pending
load_config 读取 servers.json,将配置存入全局变量
preflight_check 检查 jq/git 是否存在、JSON 格式是否正确,不满足则报错退出
mkdir -p 确保备份目录存在(-p 表示已存在不报错)

执行到这里时,全局变量已就绪:

SSH_USER = "shared-user"
CRED_DIR = "/repo/.git-credentials"
CRED_FILE = "/repo/.git-credentials/credentials"
HELPER = "store --file /repo/.git-credentials/credentials"
PARALLEL = 20(如果传了 --parallel 20)
FILTER_STATUS = "pending"(如果传了 --status pending)

6.2 模式分支:--check / --dry-run / 正常执行

脚本根据参数决定走哪条路:

# 路径1:--check 模式(只看状态,不做任何操作)
if [ "$CHECK_MODE" = true ]; then
  jq ... 输出统计
  exit 0
fi

# 路径2:获取待处理服务器列表
if [ -n "$FILTER_STATUS" ]; then
  # 只获取指定状态的服务器
  mapfile -t SERVERS < <(jq ... select(.status == $s) ...)
else
  # 获取所有非终态的服务器
  mapfile -t SERVERS < <(jq ... select(.status != "backed_up_deleted" and ...) ...)
fi

# 路径3:--dry-run 模式(显示将要做什么,但不执行)
if [ "$DRY_RUN" = true ]; then
  # 列出每台服务器将要执行的操作
  exit 0
fi

mapfile -t SERVERS < <(...) 是什么意思?

拆开看:

  • <(jq ...) — 进程替换,把 jq 命令的输出当作一个"虚拟文件"
  • < <(...) — 把这个"虚拟文件"的内容作为输入
  • mapfile -t SERVERS — 逐行读入,存到 SERVERS 数组中(-t 去掉每行末尾换行符)

结果:SERVERS 是一个数组,每个元素是一个服务器主机名。


6.3 核心函数:process_server

这是最重要的函数——处理单台服务器的所有逻辑。根据服务器当前状态,执行不同操作。

process_server() {
  local server=$1    # 第一个参数:服务器主机名
  
  # 从 JSON 中读取该服务器的状态和用户名
  local current_status=$(jq -r --arg h "$server" '.servers[] | select(.host == $h) | .status' "$CONFIG_FILE")
  local server_name=$(jq -r --arg h "$server" '.servers[] | select(.host == $h) | .username' "$CONFIG_FILE")
  local short_server=$(echo "$server" | cut -d. -f1)   # 取主机名第一段作为缩写
  local log_prefix="[$short_server/$server_name]"       # 日志前缀,便于并发时区分

接下来是一个大的 if/elif/elif 结构,根据 current_status 决定做什么。


6.4 状态处理:pending → configured

当服务器处于 pending(或 failed 重试)状态时:

if [[ "$current_status" == "pending" ]] || [[ "$current_status" == "failed" ]]; then

  local result=$(ssh ... bash -s <<REMOTE
    # ===== 以下全部在远程服务器上执行 =====
    
    # 第一步:验证用户
    if [ ! -d "/home/${server_name}" ]; then
      echo "STATUS:invalid_user"
      exit 0
    elif [ ! -d "/repo/${server_name}" ]; then
      echo "STATUS:not_target"
      exit 0
    fi
    
    # 第二步:扫描仓库
    repo_count=$(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | wc -l)
    if [ "$repo_count" -eq 0 ]; then
      echo "STATUS:no_repos"
      exit 0
    fi
    
    # 第三步:配置每个仓库
    success=0
    while read gitdir; do
      cf="$gitdir/config"
      [ ! -f "$cf" ] || [ ! -w "$cf" ] && continue     # 不存在或不可写则跳过
      git config -f "$cf" --unset-all credential.helper 2>/dev/null
      git config -f "$cf" credential.helper "" && \
      git config -f "$cf" --add credential.helper "$HELPER" && ((success++))
    done < <(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | sort -u)
    
    if [ "$success" -eq 0 ]; then
      echo "STATUS:no_repos"       # 有仓库但全部不可写
      exit 0
    fi
    
    # 第四步:创建凭据目录和文件(只在配置成功后才执行)
    mkdir -p "$CRED_DIR"
    chmod 777 "$CRED_DIR"
    setfacl -d -m u::rw,g::rw,o::rw "$CRED_DIR" 2>/dev/null
    touch "$CRED_FILE"
    chmod 666 "$CRED_FILE"
    
    echo "STATUS:configured:${success}"
    # ===== 远程执行结束 =====
REMOTE
  )

远程脚本通过 echo "STATUS:xxx" 传递结果。 回到本地后解析:

  if [ $? -ne 0 ]; then
    # SSH 连接失败
    echo "$log_prefix FAILED (连接失败)"
    echo "$server:failed" >> "$SCRIPT_DIR/.status_updates"
    return
  fi

  # 从远程输出中提取 STATUS 行
  local new_status=$(echo "$result" | grep "^STATUS:" | tail -1 | cut -d: -f2)
  local repo_count=$(echo "$result" | grep "^STATUS:" | tail -1 | cut -d: -f3)

  # 根据远程返回的状态打印日志
  case "$new_status" in
    invalid_user) echo "$log_prefix SKIP (非有效用户)" ;;
    not_target)   echo "$log_prefix SKIP (非目标开发人员)" ;;
    no_repos)     echo "$log_prefix SKIP (无 git 仓库)" ;;
    configured)   echo "$log_prefix OK → configured (${repo_count} 个仓库)" ;;
    *)            echo "$log_prefix FAILED (未知响应)"; new_status="failed" ;;
  esac
  
  # 把结果追加到临时文件(供主进程最后统一更新 JSON)
  echo "$server:$new_status" >> "$SCRIPT_DIR/.status_updates"

流程图:

SSH到远程 → 验证用户 → 扫描仓库 → 配置仓库 → 创建目录 → 返回 STATUS
    ↓ 失败                ↓ 无仓库      ↓ 不可写      ↓ 成功
  failed            no_repos       no_repos     configured

6.5 状态处理:configured → backed_up_deleted

当服务器已设置好(configured),检测凭据文件是否有内容:

elif [[ "$current_status" == "configured" ]]; then

  # 第一次 SSH:检测凭据文件
  local result=$(ssh ... bash -s <<REMOTE
    if [ -s "${CRED_FILE}" ]; then      # -s 判断文件存在且大小>0
      echo "HAS_CONTENT"
      cat "${CRED_FILE}"                 # 输出文件内容
      echo "__EOF__"                     # 结束标记
    else
      echo "EMPTY"
    fi
REMOTE
  )

如果有内容(用户已输入过密码):

  if echo "$result" | head -1 | grep -q "HAS_CONTENT"; then
    
    # 提取凭据内容,保存到本地备份文件
    local backup_file="$LOCAL_BACKUP_DIR/${short_server}_${server_name}_credentials"
    echo "$result" | sed '1d' | sed '/__EOF__/d' > "$backup_file"
    #                   ↑ 去掉第一行(HAS_CONTENT)  ↑ 去掉结束标记

sed '1d' — 删除第一行(HAS_CONTENT
sed '/__EOF__/d' — 删除包含 __EOF__ 的行

剩下的就是纯粹的凭据内容,写入备份文件。

    if [ -s "$backup_file" ]; then      # 备份文件非空 = 备份成功
      
      # 第二次 SSH:清除配置 + 删除文件和目录
      local del_result=$(ssh ... bash -s <<REMOTE
        # 清除所有仓库的 credential.helper
        while read gitdir; do
          cf="$gitdir/config"
          [ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
          git config -f "$cf" --get credential.helper >/dev/null 2>&1 || continue
          git config -f "$cf" --unset-all credential.helper
        done < <(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | sort -u)
        
        # 删除凭据文件和目录
        rm -f "${CRED_FILE}" 2>/dev/null
        rmdir "${CRED_DIR}" 2>/dev/null
        [ ! -d "${CRED_DIR}" ] && echo OK || echo FAIL
REMOTE
      )
      
      # 根据删除结果决定最终状态
      if echo "$del_result" | grep -q "OK"; then
        echo "$server:backed_up_deleted:..." >> .status_updates
      else
        echo "$server:backed_up:..." >> .status_updates
      fi
    fi
  else
    echo "$log_prefix WAIT (凭据文件为空)"
    # 不更新状态,保持 configured
  fi

流程图:

SSH检测文件 → 有内容? → 是 → 备份到本地 → SSH清除+删除 → 成功? → backed_up_deleted
                  ↓ 否                                       ↓ 失败
             保持 configured                            backed_up

为什么 configured 状态需要两次 SSH?

因为第一次 SSH 需要把文件内容传回本地保存。如果合成一次,凭据内容和清除操作的输出会混在一起,很难解析。分成两步:

  1. 第一次:读取+传回(只有数据输出)
  2. 第二次:清除+删除(只关心成功/失败)

6.6 状态处理:backed_up → backed_up_deleted

上一步如果删除失败(比如权限问题),服务器留在 backed_up 状态。下次执行时重试:

elif [[ "$current_status" == "backed_up" ]]; then
  
  # 重试:清除配置 + 删除文件和目录(逻辑与 6.5 中第二次 SSH 完全相同)
  local del_result=$(ssh ... "清除+删除脚本")
  
  if 成功; then
    echo "$server:backed_up_deleted" >> .status_updates
  else
    echo "$log_prefix RETRY (删除失败)"
    # 不写入 .status_updates,状态不变
  fi
fi

6.7 执行阶段:串行或并发

所有准备工作完成后,开始实际执行:

# 清空临时文件(防止上次残留数据干扰)
> "$SCRIPT_DIR/.status_updates"

if [ "$PARALLEL" -le 1 ]; then
  # 串行:简单循环
  for server in "${SERVERS[@]}"; do
    process_server "$server"
  done
else
  # 并发:用 xargs 启动多个子进程
  printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}
fi

串行 vs 并发的可视化对比:

串行(--parallel 1):
  process_server(A) → process_server(B) → process_server(C) → ...
  [====7秒====]       [====7秒====]       [====7秒====]

并发(--parallel 3):
  process_server(A) → 
  process_server(B) → (同时进行)
  process_server(C) → 
  [====7秒====]        ← 总耗时只有 7 秒

6.8 收尾阶段:统一更新状态

# 检查临时文件是否有内容(有内容说明有子进程完成了操作)
if [ -s "$SCRIPT_DIR/.status_updates" ]; then
  echo "更新 servers.json..."
  
  # 逐行读取临时文件,格式: host:status[:backup_path]
  while IFS=: read -r host new_status backup_path; do
    update_status "$host" "$new_status" "$backup_path"
  done < "$SCRIPT_DIR/.status_updates"
  
  rm -f "$SCRIPT_DIR/.status_updates"   # 清理临时文件
fi

IFS=: read -r host new_status backup_path 是什么?

  • IFS=: — 设置分隔符为冒号 :
  • read -r — 读取一行(-r 不处理反斜杠转义)
  • host new_status backup_path — 按冒号分割后,依次赋值给这三个变量

例如输入行是 server-01.example.com:configured:git-credentials-backup/xxx

  • host = server-01.example.com
  • new_status = configured
  • backup_path = git-credentials-backup/xxx

最后调用 update_status 用 jq 更新 JSON 文件。


6.9 完整执行时间线(一台 pending 服务器)

时间    跳板机(本地)                    目标服务器(远程)
──────  ────────────────────              ────────────────────
0s      从 JSON 读取 server 信息
0.1s    建立 SSH 连接 ─────────────────→  接受连接
0.5s                                      检查 /home/username → 存在
0.6s                                      检查 /repo/username → 存在
0.8s                                      find .git 目录 → 找到 20 个
1.0s                                      配置第 1 个仓库
1.1s                                      配置第 2 个仓库
...                                       ...
3.0s                                      配置第 20 个仓库 → success=20
3.2s                                      mkdir + chmod + setfacl
3.5s                                      echo "STATUS:configured:20"
3.5s    收到远程输出 ←────────────────── 连接关闭
3.6s    解析 STATUS → configured
3.7s    追加到 .status_updates
3.7s    函数返回,处理下一台

七、开发计划

7.1 阶段划分

整体开发分为三个阶段,每个阶段可独立交付使用:

Phase 1: MVP               Phase 2: 工程化              Phase 3: 运维增强
──────────────             ────────────────             ─────────────────────
本地设置脚本               公共函数库提取               定时自动扫描新仓库
本地清除脚本               --dry-run 预览模式           配置漂移检测
远程批量执行               --check 状态检查             凭据轮转通知
JSON 配置管理              --status 状态过滤            操作日志审计
状态流转机制               --parallel 并发执行
凭据备份                   错误处理增强
                           用户验证

7.2 Phase 1: MVP

目标: 核心功能可用,能在单台或多台服务器上完成凭据配置的全流程。

交付物:

  • setup-shared-git-credentials.sh — 本地设置脚本
  • cleanup-shared-git-credentials.sh — 本地清除脚本
  • check-credentials-remote.sh — 远程批量执行脚本
  • servers.json — 服务器配置与状态管理
  • 状态流转机制(pending → configured → backed_up_deleted)
  • 凭据自动备份到本地目录

核心代码 — 本地设置(单台服务器):

#!/bin/bash
CRED_DIR="/repo/.git-credentials"
CRED_FILE="$CRED_DIR/credentials"
HELPER="store --file ${CRED_FILE}"

# 扫描并配置所有可写仓库
success=0
while read gitdir; do
  cf="$gitdir/config"
  [ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
  git config -f "$cf" --unset-all credential.helper 2>/dev/null
  git config -f "$cf" credential.helper ""
  git config -f "$cf" --add credential.helper "$HELPER" && ((success++))
done < <(find /repo -maxdepth 4 -type d -name ".git" 2>/dev/null | sort -u)

# 有成功才创建凭据目录
if [ $success -gt 0 ]; then
  mkdir -p "$CRED_DIR" && chmod 777 "$CRED_DIR"
  setfacl -d -m u::rw,g::rw,o::rw "$CRED_DIR"
  touch "$CRED_FILE" && chmod 666 "$CRED_FILE"
fi

核心代码 — 远程批量执行(通过 SSH):

#!/bin/bash
# 读取 servers.json 中 pending 状态的服务器
mapfile -t SERVERS < <(jq -r '.servers[] | select(.status == "pending") | .host' servers.json)

for server in "${SERVERS[@]}"; do
  # 通过 SSH 远程执行设置脚本
  result=$(ssh user@jump-server "ssh user@$server bash -s" <<'REMOTE'
    # 验证 → 扫描 → 配置 → 创建目录
    # ... 返回 STATUS:configured:N
REMOTE
  )
  # 解析结果,更新 servers.json
done

核心代码 — 状态管理(servers.json):

{
  "ssh_user": "shared-user",
  "cred_dir": "/repo/.git-credentials",
  "cred_file": "/repo/.git-credentials/credentials",
  "servers": [
    {"host": "server-01.example.com", "username": "user-a", "status": "pending"}
  ]
}
# 用 jq 更新状态
jq --arg h "$host" --arg s "configured" \
  '(.servers[] | select(.host == $h)).status = $s' servers.json > tmp && mv tmp servers.json

验收标准:

  • 执行设置后,用户 git pull 不再提示密码
  • 另一用户在同一仓库也不提示密码
  • 执行清除后恢复原状
  • 远程批量执行覆盖多台服务器
  • 凭据成功备份到本地

7.3 Phase 2: 工程化

目标: 提升代码可维护性、操作安全性和执行效率。

任务1:提取公共函数库

创建 lib/common.sh,将重复逻辑统一:

#!/bin/bash
# lib/common.sh

# 全局变量
CRED_DIR=""; CRED_FILE=""; HELPER=""
SSH_USER=""; JUMP_SERVER=""
DRY_RUN=false; CHECK_MODE=false; FILTER_STATUS=""; PARALLEL=1

load_config() {
  CONFIG_FILE=$1
  CRED_DIR=$(jq -r '.cred_dir' "$CONFIG_FILE")
  CRED_FILE=$(jq -r '.cred_file' "$CONFIG_FILE")
  HELPER="store --file ${CRED_FILE}"
  SSH_USER=$(jq -r '.ssh_user' "$CONFIG_FILE")
  # ...
}

parse_args() {
  while [[ "$1" == --* ]]; do
    case "$1" in
      --dry-run) DRY_RUN=true; shift ;;
      --check) CHECK_MODE=true; shift ;;
      --status) FILTER_STATUS="$2"; shift 2 ;;
      --parallel) PARALLEL="$2"; shift 2 ;;
      --help|-h) show_usage; exit 0 ;;
    esac
  done
}

preflight_check() {
  command -v jq &>/dev/null || { echo "[ERROR] 缺少 jq" >&2; exit 1; }
  command -v git &>/dev/null || { echo "[ERROR] 缺少 git" >&2; exit 1; }
  jq empty "$CONFIG_FILE" 2>/dev/null || { echo "[ERROR] JSON 格式错误" >&2; exit 1; }
}

run_remote() {
  local server=$1 script=$2 attempt=0
  while [ $attempt -lt $MAX_RETRIES ]; do
    ((attempt++))
    output=$(ssh -o ConnectTimeout=10 user@jump "ssh user@$server bash -s" <<< "$script" 2>/dev/null)
    [ $? -eq 0 ] && { echo "$output"; return 0; }
    echo "  [重试] 第${attempt}次失败..." >&2
    sleep "$RETRY_INTERVAL"
  done
  return 1
}

update_status() {
  local host=$1 status=$2 backup_path=$3
  jq --arg h "$host" --arg s "$status" \
    '(.servers[] | select(.host == $h)).status = $s' "$CONFIG_FILE" > tmp && mv tmp "$CONFIG_FILE"
}

三个脚本统一引用:

#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
parse_args "$@"
load_config "$SCRIPT_DIR/servers.json"
preflight_check

任务2:--dry-run 预览模式

在公共函数中根据 $DRY_RUN 标志决定是否执行:

setup_repo() {
  local config_file=$1
  local repo=$(dirname "$(dirname "$config_file")")
  if [ "$DRY_RUN" = true ]; then
    echo "[DRY-RUN] 将配置: $repo"
    return 0
  fi
  # 实际执行...
}

ensure_cred_dir() {
  if [ "$DRY_RUN" = true ]; then
    echo "[DRY-RUN] 将创建目录: $CRED_DIR (777 + ACL)"
    echo "[DRY-RUN] 将创建文件: $CRED_FILE (666)"
    return 0
  fi
  mkdir -p "$CRED_DIR" && chmod 777 "$CRED_DIR"
  # ...
}

输出示例:

$ bash check-credentials-remote.sh --dry-run
=== DRY-RUN 模式 ===
  [设置] server-01.example.com (user-a)
  [备份] server-02.example.com (user-b)
  [删除] server-03.example.com (user-c)

任务3:--check 状态检查

if [ "$CHECK_MODE" = true ]; then
  echo "=== 服务器状态统计 ==="
  jq -r '[.servers[].status] | group_by(.) | map({status: .[0], count: length}) | .[] | "  \(.status): \(.count)"' "$CONFIG_FILE"
  echo ""
  echo "总计: $(jq '.servers | length' "$CONFIG_FILE") 台"
  exit 0
fi

任务4:--status 状态过滤

if [ -n "$FILTER_STATUS" ]; then
  mapfile -t SERVERS < <(jq -r --arg s "$FILTER_STATUS" \
    '.servers[] | select(.status == $s) | .host' "$CONFIG_FILE")
else
  mapfile -t SERVERS < <(jq -r '.servers[] | select(
    .status != "backed_up_deleted" and .status != "invalid_user" and
    .status != "not_target" and .status != "no_repos") | .host' "$CONFIG_FILE")
fi

任务5:--parallel 并发执行

if [ "$PARALLEL" -le 1 ]; then
  for server in "${SERVERS[@]}"; do
    process_server "$server"
  done
else
  # 并发:xargs -P 启动多个子进程
  printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}
fi

# 并发安全:子进程写临时文件,主进程最后统一更新 JSON
> .status_updates    # 清空
# ... 并发执行 ...
# 主进程统一更新:
while IFS=: read -r host status path; do
  update_status "$host" "$status" "$path"
done < .status_updates

任务6:错误处理增强

preflight_check() {
  local errors=0
  command -v jq &>/dev/null || { echo "[ERROR] 缺少 jq (yum install jq)" >&2; ((errors++)); }
  command -v git &>/dev/null || { echo "[ERROR] 缺少 git" >&2; ((errors++)); }
  command -v setfacl &>/dev/null || echo "[WARN] 缺少 setfacl" >&2
  jq empty "$CONFIG_FILE" 2>/dev/null || { echo "[ERROR] JSON 格式错误" >&2; ((errors++)); }
  [ $errors -gt 0 ] && exit 1
}

# SSH 失败时的诊断建议
run_remote() {
  # ... 重试失败后 ...
  echo "  [诊断建议]" >&2
  echo "    1. ssh ${SSH_USER}@${JUMP_SERVER} hostname" >&2
  echo "    2. 先登录跳板机,再 ssh ${SSH_USER}@${server} hostname" >&2
  echo "    3. 增大 servers.json 中的 retry_interval" >&2
}

# git config 失败时的原因提示
if ! git config -f "$cf" credential.helper ""; then
  echo "[ERROR] 无法写入 $cf" >&2
  echo "  可能原因: 文件系统只读 / 磁盘满 / 文件被锁" >&2
fi

任务7:用户验证

# 在远程脚本中,配置前先验证
if [ ! -d "/home/${username}" ]; then
  echo "STATUS:invalid_user"; exit 0
elif ! find /repo -maxdepth 1 -user "${username}" -type d 2>/dev/null | grep -q .; then
  echo "STATUS:not_target"; exit 0
fi

# 配置成功 0 个仓库也视为无效
if [ "$success" -eq 0 ]; then
  echo "STATUS:no_repos"; exit 0
fi

7.4 Phase 3: 运维增强

目标: 长期运行维护所需的自动化和监控能力。

任务8:定时自动扫描新仓库

目标: 新 clone 的仓库自动被配置,无需手动重跑脚本。

实施方案: 创建 auto-scan.sh,只扫描 configured 状态的服务器,对未配置的仓库增量设置:

#!/bin/bash
source lib/common.sh
load_config servers.json

jq -r '.servers[] | select(.status == "configured") | .host' "$CONFIG_FILE" | while read server; do
  ssh ${SSH_USER}@"$server" bash -s <<'EOF'
CRED_FILE="/repo/.git-credentials/credentials"
HELPER="store --file $CRED_FILE"
find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | while read gitdir; do
  cf="$gitdir/config"
  [ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
  git config -f "$cf" --get credential.helper 2>/dev/null | grep -q "$CRED_FILE" && continue
  git config -f "$cf" --unset-all credential.helper 2>/dev/null
  git config -f "$cf" credential.helper ""
  git config -f "$cf" --add credential.helper "$HELPER"
  echo "[新增] $(dirname $gitdir)"
done
EOF
done

加入 crontab(每天凌晨 2 点执行):

0 2 * * * /path/to/auto-scan.sh >> /path/to/logs/auto-scan.log 2>&1

任务9:配置漂移检测

目标: 发现被手动修改或丢失的配置。

实施方案: 创建 drift-check.sh,并发检测所有 configured 服务器:

#!/bin/bash
source lib/common.sh
load_config servers.json

jq -r '.servers[] | select(.status == "configured") | .host' "$CONFIG_FILE" | \
  xargs -P 10 -I {} ssh -o ConnectTimeout=5 ${SSH_USER}@{} bash -s <<'EOF'
CRED_FILE="/repo/.git-credentials/credentials"
total=$(find /repo -maxdepth 4 -type d -name '.git' -exec test -w '{}/config' \; -print 2>/dev/null | wc -l)
configured=$(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | while read g; do
  git config -f "$g/config" --get credential.helper 2>/dev/null | grep -q "$CRED_FILE" && echo 1
done | wc -l)
[ "$total" -ne "$configured" ] && echo "DRIFT:$(hostname):${configured}/${total}"
EOF

加入 crontab(每天早上 8 点检测):

0 8 * * * /path/to/drift-check.sh >> /path/to/logs/drift-check.log 2>&1

任务10:凭据轮转通知

目标: 凭据过期前提醒维护者更新。

实施方案:servers.json 中添加字段:

{
  "credential_updated_at": "2026-06-22T12:00:00+02:00",
  "credential_max_age_days": 90
}

创建 credential-age-check.sh

#!/bin/bash
source lib/common.sh
load_config servers.json
updated=$(jq -r '.credential_updated_at' "$CONFIG_FILE")
max_age=$(jq -r '.credential_max_age_days' "$CONFIG_FILE")
age_days=$(( ($(date +%s) - $(date -d "$updated" +%s)) / 86400 ))
remaining=$((max_age - age_days))
if [ $remaining -le 7 ]; then
  echo "[警告] 凭据将在 ${remaining} 天后过期!"
  echo "  上次更新: $updated"
  echo "  轮转步骤:"
  echo "    1. 在代码平台生成新 HTTP Token"
  echo "    2. 删除凭据文件: rm /repo/.git-credentials/credentials"
  echo "    3. 执行 git pull 输入新 Token"
  echo "    4. 更新 servers.json 中的 credential_updated_at"
fi

加入 crontab:

0 9 * * * /path/to/credential-age-check.sh

7.5 关键路径与并发策略

Phase 1(串行):
  本地设置脚本 → 本地清除脚本 → 远程执行脚本 → JSON 状态机 → 测试验证

Phase 2(可并发):
  开发者1: [公共库提取] → [三个脚本引用公共库] → [集成测试]
  开发者2: [--dry-run] → [--check] → [--status]
  开发者3: [--parallel 并发] → [错误处理] → [用户验证]

Phase 3(完全独立):
  auto-scan.sh ←── 独立开发
  drift-check.sh ←── 独立开发
  credential-age-check.sh ←── 独立开发

八、核心知识点

本项目涉及的 10 个关键技术点,每个都可能在其他场景复用。

8.1 Git 多值配置中空字符串的重置作用

场景: 需要在 local 配置中覆盖 global 配置的行为。

原理: git 的 credential.helper 是多值配置项(可以有多个值)。当 git 读取到空字符串 "" 时,会清除该点之前从 system/global 层继承来的所有值。

记忆口诀: 空字符串 = "前面全部作废"。

# 最终 .git/config 中的内容:
[credential]
    helper =                          # "前面全部作废"
    helper = store --file /path/to    # 这是唯一生效的

8.2 git config -f 绕过安全检查

场景: 需要修改别人拥有的仓库的配置。

原理: git -C <目录> 会把目录当作仓库来操作,触发 safe.directory 检查。而 git config -f <文件> 只把文件当作普通文本来编辑,不触发任何仓库级检查。

适用条件: 对目标文件有操作系统级别的写权限(group 权限 w)。

8.3 Sticky Bit 对文件操作的限制

场景: 共享目录中多用户需要创建/删除/重命名文件。

原理: 目录权限末尾的 t 位(如 drwxrwxrwt)限制了"只有文件 owner 或 root 才能删除/重命名该目录中的文件",即使目录本身对所有人开放写权限。

绕过方式: 把文件放到子目录中,子目录不设 sticky bit。在子目录内操作不受限制。

识别方法: ls -ld /目录 看权限末尾是 t 还是 x

8.4 Umask、文件权限与 ACL — 完整指南

本项目中有两个地方受 umask 影响:

  1. 凭据文件(git credential store 创建新文件时)
  2. 仓库目录(用户 git clone 时创建的目录和文件)

两个问题的解决方案不同,但根因相同。


8.4.1 什么是 umask?

umask 是每个进程(shell session)自带的"权限掩码"。创建文件/目录时,去掉 umask 指定的权限位:

新目录权限 = 0777 & ~umask
新文件权限 = 0666 & ~umask

计算示例:

umask = 022 (二进制: 000 010 010)
~022 = 755 (二进制: 111 101 101)

新目录: 0777 & ~022 = 0755 → drwxr-xr-x  (group 不可写)
新文件: 0666 & ~022 = 0644 → -rw-r--r--   (group 不可写)
umask = 002 (二进制: 000 000 010)
~002 = 775 (二进制: 111 111 101)

新目录: 0777 & ~002 = 0775 → drwxrwxr-x  (group 可写 ✅)
新文件: 0666 & ~002 = 0664 → -rw-rw-r--   (group 可写 ✅)

8.4.2 umask 从哪里设置的?

系统中有多个地方可以设置 umask,按 shell 类型和启动方式不同,加载顺序不同:

Bash login shell(SSH 直接登录):

/etc/profile → /etc/profile.d/*.sh → ~/.bash_profile → ~/.bashrc → /etc/bashrc → 企业环境脚本(arc.bashrc 等)

Bash non-login shell(在 tcsh 中输入 bash):

继承父进程的 umask → ~/.bashrc → /etc/bashrc

Tcsh(终端直接打开):

/etc/csh.cshrc → ~/.cshrc → ~/.tcshrc

关键:non-login shell 不会执行 /etc/profile,而是继承父进程的 umask。


8.4.3 为什么同一用户 umask 会不同?

典型条件判断逻辑(/etc/profile/etc/bashrc/etc/csh.cshrc 中都有类似的):

if [ $UID -gt 199 ] && [ "$(id -gn)" = "$(id -un)" ]; then
    umask 002    # 用户名 == 主组名时
else
    umask 022    # 用户名 ≠ 主组名时(大多数情况)
fi

大多数用户的主组是 devgroup(不等于用户名),所以这个条件走 else022

但实际使用中不同 session 结果不同:

进入方式 shell 类型 umask 原因
打开终端 tcsh (login) 002 tcsh 的 /etc/csh.cshrc 条件可能走了不同分支
终端中输入 bash bash (non-login) 002 继承了 tcsh 的 umask,不执行 /etc/profile
SSH 直接登录 bash (login) 022 完整执行 /etc/profile + 企业脚本中的 umask 022
终端中输入 bash --login bash (login) 022 强制走 login 流程

核心规律: 子进程继承父进程的 umask。如果你在 tcsh(002) 中启动 bash,bash 继承 002 并且不会主动改回 022(因为 non-login shell 不加载 /etc/profile)。


8.4.4 对仓库目录的影响

共享用户操作别人的仓库时:

$ ls -ld /repo/some-user/project/
drwxrwxr-x  →  umask 002 时创建  →  group 有写权限  →  共享用户能写 ✅
drwxr-xr-x  →  umask 022 时创建  →  group 无写权限  →  共享用户不能写 ❌

同一个用户在同一台机器上 git clone,根据他当时在哪种 session:

  • tcsh 或 non-login bash → umask 002 → clone 出来的仓库 能被共享用户操作
  • SSH login bash → umask 022 → clone 出来的仓库 不能被共享用户操作

共享用户(如 shared-user)能修复吗? 不能。chmodsetfacl 都要求是文件 owner 或 root。

谁能修复:

# 方法1:目录 owner 本人执行(一次性)
$ chmod -R g+w /repo/$(whoami)/

# 方法2:owner 修改 umask 后重新 clone
$ umask 002
$ git clone ...

# 方法3:owner 永久修改 umask
$ echo "umask 002" >> ~/.bashrc    # bash 用户
$ echo "umask 002" >> ~/.cshrc     # tcsh 用户

# 方法4:管理员全局强制
$ echo "umask 002" > /etc/profile.d/umask-fix.sh   # 需要 root

如果无法联系 owner: 该服务器标记为 no_repos 跳过,这是 Linux 权限的硬限制。


8.4.5 对凭据文件的影响

不同于仓库目录(我们无法控制别人的 umask),凭据文件的目录是我们自己创建的,可以用 ACL 解决:

问题: git credential store 写入凭据时会删除旧文件、创建新文件。新文件权限受当时用户的 umask 影响:

# 用户 A(umask 022)执行 git pull 写入凭据后:
-rw------- 1 user-a devgroup 58 credentials   # 600!其他人读不了

为什么 chmod 无效: 我们可以 chmod 666,但下次有人 git pull 时文件被删除重建,权限又变回去。

解决方案 — ACL 默认权限:

$ setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/
  • setfacl — 设置 ACL
  • -d — 设置默认(default)ACL,影响未来新建的文件
  • -m u::rw,g::rw,o::rw — owner/group/others 都有读写权限

效果: 无论哪个用户创建文件、无论他的 umask 是什么,该目录下新文件自动继承 rw-rw-rw- 权限。

验证:

$ getfacl /repo/.git-credentials/
default:user::rw-      ← 新文件 owner 权限
default:group::rw-     ← 新文件 group 权限
default:other::rw-     ← 新文件 other 权限

为什么凭据文件能用 ACL 解决,而仓库目录不能?

凭据文件目录 仓库目录
谁创建的 我们(shared-user) 其他用户
我们是 owner? ✅ 是 ❌ 不是
能设置 ACL? ✅ 能(我们是 owner) ❌ 不能(不是 owner)

8.4.5.1 setfacl 与 umask 的关系详解

新人常问:"既然 umask 控制新文件权限,setfacl 也控制新文件权限,它们什么关系?谁听谁的?"

正常情况(没有 default ACL):umask 说了算

用户创建文件 → 权限 = 0666 & ~umask

就像一个过滤器:系统给你 0666 的基础权限,umask 把不想要的位去掉。

例:umask=022
0666 = rw-rw-rw-
~022 = ---w--w--  (要去掉的)
结果 = rw-r--r--  (644)

有 default ACL 时:ACL 说了算,umask 被忽略

用户在设了 default ACL 的目录中创建文件 → 权限 = default ACL 定义的
                                          umask? 不管它!

用一个实验说明:

# 准备:设置 default ACL
$ mkdir /tmp/test-acl
$ setfacl -d -m u::rw,g::rw,o::rw /tmp/test-acl/

# 实验:用最严格的 umask 创建文件
$ umask 077          # 077 = 只有 owner 能读写
$ touch /tmp/test-acl/file1
$ ls -l /tmp/test-acl/file1
-rw-rw-rw-  ...  file1     ← ACL 赢了!umask 077 被无视

# 对比:在没有 ACL 的目录创建文件
$ touch /tmp/file2
$ ls -l /tmp/file2
-rw-------  ...  file2     ← umask 077 生效,只有 owner 能读写

类比理解:

想象你有一栋楼(目录):

  • umask = 每个住户(用户)自己带的门锁设置("我的门默认只开给自己")
  • default ACL = 物业在大楼设计时安装的门禁规则("这栋楼所有新房间必须所有人都能进")
  • 物业的规则优先于住户自带的门锁

它们的对比:

umask setfacl -d (default ACL)
谁控制 每个用户自己的 shell 设置 目录 owner 设置在目录上
影响范围 当前 shell 中所有地方创建的文件 仅限该目录内创建的文件
谁能设置 任何用户(只影响自己) 目录 owner 或 root
持久性 session 结束就没了 永久保存在文件系统中
优先级 低(被 default ACL 覆盖) (覆盖 umask)

在本项目中的运用:

# 我们是 /repo/.git-credentials/ 的 owner
# 设置 default ACL 后:
setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/

# 不管之后是谁来写入凭据:
#   user-a (umask 002) git pull → credentials 权限 rw-rw-rw- ✅
#   user-b (umask 022) git pull → credentials 权限 rw-rw-rw- ✅
#   user-c (umask 077) git pull → credentials 权限 rw-rw-rw- ✅
# 全部被 ACL 兜底,不怕任何 umask

为什么不能用 setfacl 解决仓库目录的问题?

# 尝试对别人的目录设置 ACL
$ setfacl -d -m g::rw /repo/other-user/project/
setfacl: /repo/other-user/project/: Operation not permitted

# 原因:setfacl 本身也受 Linux 权限控制
# 只有目录 owner 或 root 才能修改该目录的 ACL
# 我们不是 owner → 无权操作

这就像你想去别人的楼里改门禁规则——物业不认你,拒绝执行。


8.4.6 总结

问题 根因 解决方案 谁能操作
凭据文件权限变 600 git 重建文件受 umask 影响 ACL 默认权限 我们自己 ✅
仓库 .git/config 不可写 owner clone 时 umask 022 chmod g+w 或改 umask 只有 owner/root

8.5 SSH Heredoc 远程执行

场景: 需要在远程机器上执行一段完整脚本,而不是单条命令。

语法:

ssh user@host bash -s <<'EOF'
# 多行脚本内容
# 单引号 EOF 确保变量不在本地展开
EOF

关键细节:

  • bash -s — 从 stdin 读取脚本执行
  • <<'EOF'(单引号)— 内容原样传到远程,$VAR 在远程展开
  • <<EOF(无引号)— 内容在本地先展开,$VAR 变成本地的值

8.6 Stdout vs Stderr 在函数返回值中的区别

场景:$() 捕获函数输出作为返回值时,不希望日志信息混入。

原理:

  • $(command) 只捕获 stdout(文件描述符 1)
  • stderr(文件描述符 2)直接输出到终端,不被捕获

实践:

echo "这会被 $() 捕获"        # stdout
echo "这不会被捕获" >&2        # stderr,直接显示在终端

在本项目中的应用: run_remote 函数的重试提示输出到 stderr,确保 result=$(run_remote ...)result 只包含远程命令的实际输出。

8.7 Git Credential Store 的原子写入机制

场景: 理解为什么凭据文件的 owner 会变化。

流程:

① 创建临时文件 credentials.XXXXXX(owner=当前用户)
② 写入凭据内容到临时文件
③ rename(临时文件, credentials)  — 原子替换

后果: 每次写入后 credentials 文件的 owner 变为执行操作的用户。这是特性不是 bug——rename 是原子操作,保证不会出现"写到一半"的损坏文件。

8.8 jq 操作 JSON

场景: Shell 脚本中需要读取、修改结构化配置。

常用操作:

# 读取字段
jq -r '.ssh_user' servers.json

# 过滤数组
jq -r '.servers[] | select(.status == "pending") | .host' servers.json

# 修改字段
jq --arg h "server-01" --arg s "configured" \
  '(.servers[] | select(.host == $h)) |= . + {status: $s}' servers.json

# 统计
jq '[.servers[].status] | group_by(.) | map({status: .[0], count: length})' servers.json

为什么不用 grep/sed 处理 JSON? 因为 JSON 可能有换行、嵌套、转义字符,用文本工具处理非常脆弱。jq 理解 JSON 结构,操作安全可靠。

8.9 xargs -P 并发

场景: 需要对一批输入并行执行同一操作。

核心参数:

  • -P N — 最多 N 个并发进程
  • -I {} — 用 {} 代替每行输入
  • 输入从 stdin 按行读取

与其他并发方案对比:

方案 优点 缺点
xargs -P(本项目) 系统自带,无需安装 错误处理不够灵活
GNU parallel 功能强大,支持进度条 需要安装
bash & + wait 最简单 无法控制并发数量
Python multiprocessing 完全控制 需要 Python 环境

九、决策记录(ADR)

ADR(Architecture Decision Records)记录每个重要技术决策的背景、理由和权衡。当后来者问"为什么这么做而不那么做"时,这里有答案。

9.1 配置层级选择:local 而非 system/global

  • 问题: 需要让所有用户在共享仓库中自动使用统一凭据
  • 尝试过的方案:
    • git config --system → 系统配置路径不存在,无 sudo 无法创建
    • GIT_CONFIG_COUNT 环境变量 → 需要每个用户自己设置,违背"零操作"目标
    • GIT_CONFIG_SYSTEM 指向自定义文件 → 同上
  • 最终决策: 使用仓库 local 配置(.git/config),所有用户共享同一个文件
  • 关键技巧: 空字符串 "" 清除 global/system 继承的 helper,确保只有 local 配置生效
  • 局限: 新 clone 的仓库需要重新配置

9.2 操作 .git/config 的方式:-f 而非 -C

  • 问题: 共享用户修改其他用户 owner 的仓库时,被 safe.directory 拒绝
  • 尝试过的方案:
    • git -c safe.directory='*' → git 2.36.0 中不稳定,有时仍报错
    • git config --global --add safe.directory → 需要修改每台机器的全局配置
  • 最终决策: git config -f <文件路径> 直接操作文件,绕过仓库上下文
  • 原理: -f 模式下 git 不进入仓库,不触发安全检查,只当作普通文件编辑
  • 前提: 文件系统级别对该文件有写权限(group w 位)

9.3 凭据文件存放位置:子目录而非根目录

  • 问题: /repo 目录有 sticky bit,git credential store 的 rename 操作被阻止
  • 原理: sticky bit 使得只有文件 owner 才能 rename/delete 该目录中的文件
  • 尝试过的方案:
    • 直接放 /repo/.git-credentialsOperation not permitted(rename 被 sticky bit 阻止)
  • 最终决策: 创建子目录 /repo/.git-credentials/(chmod 777,无 sticky bit),凭据文件放在其中
  • 效果: 子目录内 rename 不受限制,所有用户都能写入

9.4 凭据文件权限保障:ACL 而非 chmod

  • 问题: git credential store 每次写入都会删除旧文件、创建新文件,新文件权限由 umask 决定
  • 尝试过的方案:
    • chmod 666 → 下次 git 写入时文件被重建,权限恢复为 umask 决定的值(可能 600)
  • 最终决策: setfacl -d -m u::rw,g::rw,o::rw 设置目录默认 ACL
  • 原理: ACL 默认权限覆盖 umask,无论哪个用户创建文件都自动 666
  • 前提: 文件系统支持 ACL(ext4/xfs 默认支持),且我们是目录 owner
  • 附带发现: 文件 owner 会随写入者变化(原子写入机制),但 ACL 保证权限不变

9.5 不可写仓库的处理:标记跳过而非报错

  • 问题: 部分服务器上仓库的 .git/config 对共享用户没有写权限(umask 022 创建的目录是 755)
  • 根因分析: 用户 clone 仓库时所处 shell 的 umask 不同导致:
    • login bash(SSH 登录)→ umask 022 → 目录 755 → 共享用户不可写
    • tcsh / non-login bash → umask 002 → 目录 775 → 共享用户可写
  • 能否解决: 不能。共享用户无法 chmod/setfacl 别人的文件(只有 owner 或 root 可以)
  • 最终决策: 配置成功 0 个仓库时标记为 no_repos 终态,不再重试
  • 额外措施: 先配置再创建凭据目录,避免在不可写的服务器上留下无用目录

9.6 服务器配置管理:JSON 而非文本文件

  • 问题: 需要为每台服务器存储多个属性并按状态过滤
  • 尝试过的方案:
    • servers.txt 每行一个主机名 + # 注释标记已处理 → 无法可靠存储状态/用户名/时间
    • sed 替换注释 → 格式脆弱,多字段时维护困难
  • 最终决策: servers.json + jq 工具
  • 好处: 结构化存储、按字段过滤(jq select)、原子更新(写临时文件再 mv)
  • 依赖: 需要 jq(所有目标机器已验证可用)

9.7 用户验证:find -user 而非检查同名目录

  • 问题: 有些服务器上用户的仓库不在 /repo/{username}/ 下,而是直接在 /repo/
  • 尝试过的方案:
    • [ -d "/repo/${username}" ] → 漏掉了仓库直接在 /repo 根目录的情况
  • 最终决策: find /repo -maxdepth 1 -user "${username}" -type d 检查是否有该用户 owner 的目录
  • 效果: 无论目录名是否与用户名相同,只要是该用户创建的就通过验证

9.8 执行架构:跳板机直连(v2)与本机双跳(v1)并存

  • 问题: 1500+ 台服务器,双跳串行耗时过长
  • 决策: 提供两个版本
    • v1:本机 → 跳板机 → 目标机(双跳,适合少量服务器或无法在跳板机部署脚本时)
    • v2:跳板机 → 目标机(单跳,适合大规模批量操作)
  • 性能对比: v2 比 v1 快约 30%,且并发上限更高(v1 建议 5-10,v2 建议 10-20)
  • 选择依据: 50 台以下用 v1,500 台以上用 v2

9.9 并发实现:xargs -P + 临时文件

  • 问题: 需要并发处理但不能并发写 JSON
  • 尝试过的方案:
    • 子进程直接更新 JSON → 并发写入导致数据覆盖丢失
    • flock 文件锁 → 增加复杂度,子进程排队等锁影响性能
  • 最终决策: 子进程只追加结果到 .status_updates 临时文件,主进程最后统一更新 JSON
  • 并发安全保证: 短行追加(>>)在 Linux 下是原子的(< PIPE_BUF 4096 字节)
  • 代价: 中途中断时已完成的状态未保存,重跑会重复处理(但幂等,无副作用)

9.10 状态机设计:多步推进而非一步到位

  • 问题: 配置 → 等待用户写入凭据 → 备份 → 清除,无法在一次执行中完成
  • 决策: 设计多状态流转,脚本每次执行推进一步:
    pending → configured → backed_up_deleted
    
  • 原因: configuredbacked_up 之间需要人工介入(用户执行 git pull 写入凭据)
  • 额外状态:
    • failed → 连接失败,下次自动重试
    • backed_up → 备份成功但删除失败的中间态
    • invalid_user / not_target / no_repos → 各种跳过原因的终态
  • 好处: 脚本可多次安全执行,每次处理一批,中断无损失

9.11 备份后清除配置 + 删除文件和目录

  • 问题: 备份完凭据后,远程服务器上的配置和文件是否需要保留?
  • 决策: 全部清除(清除仓库 credential.helper + 删除凭据文件 + 删除凭据目录)
  • 原因: 这是一个"收集凭据"的操作,最终目标是拿到密码备份到本地,远程不需要保留任何痕迹
  • 如果需要重新配置: 将状态改回 pending 重新执行即可

9.12 umask 022 导致的不可写问题:记录而非解决

  • 问题: 约 30% 的服务器因 umask 022 导致仓库目录不可写
  • 能否自动解决: 不能。只有文件 owner 或 root 能修改权限
  • 决策: 记录为已知限制,标记为 no_repos 跳过
  • 根因: 不同 shell(bash login vs tcsh non-login)的 umask 配置来源不同,企业环境 arc.bashrc 中硬编码了 umask 022
  • 长期方案建议: 推动管理员在 /etc/profile.d/ 统一设置 umask 002,或由仓库 owner 自行 chmod -R g+w

十、风险管理

10.1 风险矩阵

# 风险 概率 影响 等级 应对措施
1 目标机 SSH 不通 ⚠️ 重试 3 次 + failed 状态自动重试
2 凭据文件被恶意读取 🔴 使用 HTTP Token 替代密码 + 定期轮转
3 .git/config 被用户手动修改 ⚠️ Phase 3 漂移检测 + --check 模式
4 用户目录无 group 写权限 ⚠️ 自动标记 no_repos 跳过
5 并发过高 SSH 被限流 ℹ️ 降低 --parallel 值
6 新仓库未被自动配置 ℹ️ Phase 3 定时扫描
7 脚本中途被强制终止 ℹ️ 幂等设计,重跑即可
8 servers.json 被误删或损坏 🔴 定期备份 + git 版本管理

10.2 各风险详细分析

风险 1:SSH 连接失败

发生条件:网络抖动、目标机重启、sshd 服务异常

已有应对:

  • 每台服务器最多重试 3 次,间隔 5 秒
  • 失败标记为 failed,下次执行时自动重试
  • --status failed 可以只重试失败的服务器

建议额外措施:

  • 如果某台服务器连续多次 failed,人工检查是否已下线

风险 2:凭据泄露

发生条件:任何能登录目标服务器的人都可以读取凭据文件(权限 666)

已有应对:

  • 凭据文件在备份后被删除(backed_up_deleted 状态)
  • 备份保存在跳板机上,不在目标机保留

建议额外措施:

  • 使用 HTTP Token 而非真实密码 — Token 只能操作 git,不能登录 Web 界面
  • 定期轮转 — 每 90 天更换 Token
  • 限制 Token 权限 — 只授予必要的仓库 read/write 权限

风险 3:配置漂移

发生条件:用户在仓库中执行了 git config --local --unset credential.helper(比如调试时)

影响:该仓库恢复为使用用户自己的 global 配置,共享凭据失效

已有应对:

  • --check 模式可以检测哪些仓库配置不正确

建议额外措施:

  • Phase 3 的定时漂移检测脚本
  • 团队内部沟通,告知不要手动修改 credential.helper

风险 4:无 group 写权限

发生条件:用户创建目录时 umask 为 022,导致 group 权限只有 r-x

影响:共享 SSH 用户无法写入 .git/config,无法配置

已有应对:

  • 脚本自动检测 [ ! -w "$cf" ],跳过不可写的仓库
  • 配置成功 0 个时标记为 no_repos

无法完全解决:这需要仓库 owner 修改自己目录的权限(chmod g+w),或由管理员统一调整 umask 策略。


风险 8:servers.json 丢失

发生条件:误操作 rm、磁盘故障

影响:丢失所有服务器的状态信息,需要重新开始

应对建议:

  • servers.json 纳入 git 版本管理
  • 或定期复制一份到其他位置:cp servers.json servers.json.bak
  • 脚本执行前自动备份:cp servers.json servers.json.$(date +%Y%m%d)

十一、FAQ

基本操作

Q: 脚本可以多次执行吗?
A: 可以,而且就应该多次执行。脚本每次只推进一步状态(pending → configured → backed_up_deleted),幂等安全,终态的服务器会被自动跳过。

Q: 想重新处理某台服务器怎么办?
A: 在 servers.json 中将该服务器的 status 改回 "pending" 即可。下次执行时会重新走完整流程。

Q: 如何添加新服务器?
A: 在 servers.jsonservers 数组中追加:

{"host": "new-server.example.com", "username": "new-user", "status": "pending"}

Q: 如何查看当前所有服务器状态?
A: bash check-credentials-remote.sh --check,会输出按状态分组的统计。


凭据管理

Q: 密码改了怎么办?
A: 凭据文件内容需要更新。步骤:

  1. 将目标服务器状态改回 pending
  2. 重新执行脚本(会重新创建凭据目录)
  3. 在该服务器上执行 git pull 输入新密码
  4. 再次执行脚本备份新凭据

Q: 新 clone 的仓库没有自动配置怎么办?
A: 两种方式:

  • 手动:在该服务器上执行 bash setup-shared-git-credentials.sh
  • 自动:等 Phase 3 的定时扫描脚本(如已部署)

Q: 凭据文件安全吗?
A: 凭据以明文存储(git credential store 的设计限制)。建议:

  • 使用 HTTP Token 而非真实密码
  • Token 只授予必要的仓库权限
  • 备份完成后远程文件会被删除(backed_up_deleted 状态)
  • 定期轮转 Token

权限问题

Q: 为什么有些服务器显示"无可写 git 仓库"但实际有仓库?
A: 仓库存在但 .git/config 文件对共享用户没有写权限。根因是仓库 owner 创建目录时 umask 为 022(目录权限 755,group 无写权限)。只有仓库 owner 自己能修复:chmod -R g+w /repo/$(whoami)/

Q: 为什么同一个用户的 umask 有时是 002 有时是 022?
A: 取决于进入 shell 的方式:

  • 终端打开 tcsh → 输入 bash:继承 tcsh 的 umask 002
  • SSH 直接登录:走 login shell 流程,企业环境脚本设置 umask 022
  • 建议用户 clone 仓库前执行 umask 002

Q: 能否用 setfacl 修复别人仓库的权限?
A: 不能。setfaclchmod 一样需要是文件 owner 或 root。共享用户无法修改别人文件的权限。

Q: 凭据文件的 owner 为什么会变化?
A: git credential store 使用原子写入(删除旧文件 → 创建新文件 → rename)。新文件的 owner 是创建者(最后一个执行 git pull 的用户)。ACL 默认权限保证无论 owner 是谁,文件权限始终是 666。


并发与稳定性

Q: 并发设多少合适?
A:

  • v1(双跳):建议 5-10,跳板机承受双倍连接压力
  • v2(单跳):建议 10-20
  • 如果频繁出现 FAILED (连接失败),降低并发数并增大 server_interval

Q: 并发执行中途 Ctrl+C 强制停止了怎么办?
A: 不会造成数据损坏:

  • 已完成的子进程结果在 .status_updates 临时文件中,但 servers.json 还没更新
  • 下次执行时那些服务器会被重复处理一次(幂等,无副作用)
  • .status_updates 残留文件无需手动清理,脚本启动时自动清空

Q: FAILED (未知响应) 是什么意思?
A: SSH 连接成功了但远程脚本返回了不在预期中的 STATUS 值。可能原因:

  • 远程机器的 bash 版本过旧不支持某些语法
  • 远程脚本执行中途被 kill
  • 解决:手动 SSH 到该机器调试

Q: FAILED (连接失败) 怎么排查?
A: 按脚本输出的诊断建议操作:

  1. 测试跳板机是否可达:ssh shared-user@jump-server hostname
  2. 测试目标机是否可达:先登录跳板机,再 ssh shared-user@target hostname
  3. 如果频繁失败,增大 servers.json 中的 retry_intervalserver_interval

版本差异

Q: v1 和 v2 有什么区别?
A:

  • v1:在本机执行,双跳经过跳板机。适合少量服务器或不方便在跳板机部署时
  • v2:在跳板机上执行,单跳直连。适合大规模批量操作
  • 两者功能完全一致,只是执行位置和 SSH 路径不同

Q: 什么时候用 setup/cleanup 脚本而不是主脚本?
A: 当你直接登录到目标服务器上操作时(不走远程批量流程):

  • setup-shared-git-credentials.sh:在单台机器上配置
  • cleanup-shared-git-credentials.sh:在单台机器上回滚
  • 适用于调试、网络不通、或只需操作单台的场景
posted @ 2026-06-23 15:43  枫奇丶宛南  阅读(24)  评论(0)    收藏  举报