第11章 扩展实例: 合并用户数据库

本章背景:真实系统管理场景,两台服务器要开启NFS文件共享。NFS依靠UID识别文件归属,如果两台机器同名用户UID不一样,或者同UID不同用户名,访问文件就会权限错乱。 目标:合并两台机器 /etc/passwd 用户密码文件,保证全局UID唯一,并且修改磁盘上所有文件的属主。 用到工具:sortawk(自定义函数、关联数组)、while readgetoptsfindxargstsort

11.1 问题描述

/etc/passwd 密码文件格式

一行7个字段,冒号:分隔

1
tolstoy:x:2076:10:Leo Tolstoy:/home/tolstoy:/bin/bash
字段 含义
$1 用户名username
$2 加密密码占位(x)
$3 UID 用户ID
$4 GID 组ID
$5 用户全名注释
$6 家目录home
$7 登录shell

重要背景:UNIX识别文件权限看UID,不看用户名。 两台主机做NFS共享:同样UID必须对应同一个人,否则文件权限完全错乱。

两台机器样例密码文件

u1.passwd(机器1)

1
2
3
4
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
jhancock:x:200:10:John Hancock:/home/jhancock:/bin/bash
ben:x:201:10:Ben Franklin:/home/ben:/bin/bash

u2.passwd(机器2)

1
2
3
4
5
6
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
jhancock:x:300:10:John Hancock:/home/jhancock:/bin/bash
ben:x:301:10:Ben Franklin:/home/ben:/bin/bash
abe:x:105:10:Honest Abe Lincoln:/home/abe:/bin/bash
tj:x:105:10:Thomas Jefferson:/home/tj:/bin/bash

四类冲突场景

  1. 用户名、UID全部相同:系统账号root/bin,无需处理
  2. 账号只存在其中一台机器:直接保留
  3. 用户名相同,UID不一样:比如jhancock,u1是200,u2是300 → dupusers
  4. UID相同,用户名不一样:abe(105)、tj(105) → dupids

本章任务:

  1. 合并passwd,消除UID冲突,产出最终密码文件
  2. 生成命令,修改全磁盘所有文件属主UID

11.2 密码文件

见上面样例。

11.3 合并密码文件

整体7大步

  1. 物理合并两个passwd,按用户名排序
  2. awk脚本分类输出3个文件:unique1无冲突;dupusers同名不同UID;dupids同UID不同名
  3. 提取全部已经占用UID列表
  4. 脚本生成未被占用的空闲UID
  5. 处理冲突,生成映射表:用户名 旧UID 新UID,同时生成新passwd片段unique2 unique3
  6. 合并unique1/2/3排序得到最终final.password
  7. 批量生成修改文件属主的shell脚本

11.3.1 根据管理性切分用户

步骤1:合并+排序

1
sort u1.passwd u2.passwd | tee merge1
  • sort:同时读取两个文件,把所有行全部放到一起,按整行字典序排序(从行首字符开始比,passwd 行首就是用户名,等价按用户名排序)。sort 默认不会去重,完全一模一样的行会保留两份。
  • tee 做两件事:① 把内容写入磁盘文件 merge1;② 原样输出到标准输出(屏幕)

输入结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
abe:x:105:10:Honest Abe Lincoln:/home/abe:/bin/bash
ben:x:201:10:Ben Franklin:/home/ben:/bin/bash
ben:x:301:10:Ben Franklin:/home/ben:/bin/bash
betsy:x:1110:10:Betsy Ross:/home/betsy:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
bin:x:1:1:bin:/bin:/sbin/nologin
dorothy:x:110:10:Dorothy Gale:/home/dorothy:/bin/bash
george:x:1100:10:George Washington:/home/george:/bin/bash
jhancock:x:200:10:John Hancock:/home/jhancock:/bin/bash
jhancock:x:300:10:John Hancock:/home/jhancock:/bin/bash
root:x:0:0:root:/root:/bin/bash
root:x:0:0:root:/root:/bin/bash
tj:x:105:10:Thomas Jefferson:/home/tj:/bin/bash
toto:x:110:10:Toto Gale:/home/toto:/bin/bash

步骤2 splitout.awk 分类脚本

作用:读取merge1,把记录分到3个输出文件

  • unique:无冲突账号
  • dupusers:用户名相同UID不同
  • dupids:UID相同用户名不同

逻辑核心:

  1. FS=":" 冒号做字段分隔
  2. 两个关联数组
    • name[$1]:key=用户名,value=整行passwd记录
    • uid[$3]:key=UID,value=整行passwd记录
  3. 遍历每一行:
    • ①用户名已经在name数组:说明用户名重复;如果UID不一样,两条记录写入dupusers,清理数组旧记录
    • ②UID已经在uid数组:说明UID重复;两条记录写入dupids,清理数组旧记录
    • 否则:第一次见到该用户,存入两个数组
  4. END块:数组剩余没有冲突的记录写入unique
  5. 两个自定义函数remove_uid_by_name / remove_name_by_uid:遍历数组删除旧条目

splitout.awk

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#! /bin/awk -f
BEGIN {FS = ":"}
{
    if ($1 in name) {
        if ($3 in uid)
            ; # 名称与uid完全一致,啥事不做
        else {
            print name[$1] > "dupusers"
            print $0 > "dupusers"
            delete name[$1]
            remove_uid_by_name($1)
        }
    } else if ($3 in uid) {
        print uid[$3] > "dupids"
        print $0 > "dupids"
        delete uid[$3]
        remove_name_by_uid($3)
    } else
        name[$1] = uid[$3] = $0
}
END {
    for (i in name)
        print name[i] > "unique1"
    close("unique1")
    close("dupusers")
    close("dupids")
}
function remove_uid_by_name(n, i, f)
{
    for (i in uid) {
        split(uid[i], f, ":")
        if (f[1] == n) {
            delete uid[i]
            break
        }
    }
}
function remove_name_by_uid(id, i, f)
{
    for (i in name) {
        split(name[i], f, ":")
        if (f[3] == id) {
            delete name[i]
            break
        }
    }
}

运行:

1
awk -f splitout.awk merge1

运行后磁盘生成3个文件

  • unique1:无冲突账号
  • dupusers:同名不同UID
  • dupids:同UID不同用户名

11.3.2 管理UID

提取所有已经占用UID,去重、数字排序

1
awk -F: '{ print $3 }' merge1 | sort -n -u > unique‑ids
  • -n数字排序;-u去重。

newuids.sh:获取空闲未使用UID

作用:读取已占用UID列表,找出中间空缺、没被占用的UID;支持‑c N取N个空闲UID。

原理:

  1. awk把全部已占用UID读到数组uidlist[]
  2. 循环对比相邻两个UID,如果两者不是连续数字,中间数字就是空闲UID。
  3. 输出指定count个空闲ID就直接exit。

newuids.sh

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#! /bin/sh -
# newuids.sh [-c N] id‑file
count=1
while getopts "c:" opt
do
    case $opt in
    c) count=$OPTARG ;;
    esac
done
shift $(($OPTIND - 1))
IDFILE=$1
awk -v count=$count '
BEGIN {
    for (i = 1; getline id > 0; i++)
        uidlist[i] = id
    totalids = i
    for (i = 2; i <= totalids; i++) {
        if (uidlist[i-1] != uidlist[i]) {
            for (j = uidlist[i-1]+1; j < uidlist[i]; j++) {
                print j
                if (--count == 0)
                    exit
            }
        }
    }
}' $IDFILE
newuids.sh 逐段讲解

脚本作用:读取已经占用UID的文件,输出空闲未被使用的UID 调用示例:

1
2
3
4
5
# 输出1个空闲UID(默认count=1)
sh newuids.sh unique‑ids

# 输出4个空闲UID
sh newuids.sh -c 4 unique‑ids

第一部分 shell 部分:getopts 解析命令行参数

  1. getopts 是什么

getoptsshell内置命令,专门用来解析短选项,例如 -c 4-a-h

语法格式:

1
2
3
4
5
6
while getopts "c:" opt
do
    case $opt in
        c) count=$OPTARG ;;
    esac
done
  • "c:":选项字符串。c代表识别 -c冒号:代表 -c后面必须跟参数(值),比如 -c 4
  • opt 变量:循环每次拿到选项名,这里拿到字符串 c
  • $OPTARGshell内置特殊变量。如果选项需要参数,$OPTARG 就保存这个参数的值。>

例子:命令行 -c 4opt="c"OPTARG="4"

代码片段拆解

1
2
3
4
5
6
7
count=1                     # 默认输出1个空闲UID
while getopts "c:" opt
do
case $opt in
c) count=$OPTARG ;;         # 把 -c 后面数字赋值给count变量
esac
done

场景:执行脚本:newuids.sh -c 4 unique‑ids

  1. getopts读到 -copt=cOPTARG=4
  2. case匹配c分支,执行 count=4

如果不写 -ccount 保持初始值 1

shift $(($OPTIND - 1))

$OPTIND:也是shell内置变量。记录处理完的参数位置下标

示例:newuids.sh -c 4 unique‑ids

  • 参数列表:$1=-c$2=4$3=unique‑ids
  • getopts处理完 -c 4 之后,OPTIND=3
  • $OPTIND‑1 = 2shift 2:把前2个参数踢掉。 shift之后,原来的$3移动变成新的$1
1
IDFILE=$1   # 此时 $1 就是文件名 unique‑ids

目的:把选项 -c 4 消耗掉,剩下**位置参数(文件名)**交给 $1

第二部分 awk 脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
awk -v count=$count '
BEGIN {
    # getline 读取文件每一行
    for (i = 1; getline id > 0; i++)
        uidlist[i] = id
    totalids = i

    # 遍历排序好的UID数组,找空隙
    for (i = 2; i <= totalids; i++) {
        if (uidlist[i-1] != uidlist[i]) {
            # 两个UID之间的数字,就是空闲UID
            for (j = uidlist[i-1]+1; j < uidlist[i]; j++) {
                print j
                if (--count == 0)
                    exit
            }
        }
    }
}' $IDFILE

awk -v count=$count

‑v:awk用来把shell变量传入awk内部变量

shell变量count(比如4)赋值给awk里面的变量count

getline id >0

getline:awk读取文件的一行,存入变量id;读取成功返回>0;读到文件末尾返回0,循环结束。

假设 unique‑ids 文件内容(已经数字排序、去重)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
0
1
2
3
105
110
112
200
201
300
301
1100
1110
2076

循环:

1
2
for (i = 1; getline id > 0; i++)
    uidlist[i] = id

把全部UID读到数组:

1
2
3
4
5
6
7
uidlist[1]=0
uidlist[2]=1
uidlist[3]=2
uidlist[4]=3
uidlist[5]=105
……
totalids = i  # 一共多少条UID

找空闲UID核心逻辑

1
2
3
4
5
6
7
8
9
for (i = 2; i <= totalids; i++) {
    if (uidlist[i-1] != uidlist[i]) {
        for (j = uidlist[i-1]+1; j < uidlist[i]; j++) {
            print j
            if (--count == 0)
                exit
        }
    }
}

拿相邻两个UID对比:

  • uidlist[4]=3,下一个uidlist[5]=105
  • 3 !=105,中间全部数字 4,5,6 ... 104 都是空闲UID。
  • j从4开始输出。
  • --count ==0:每输出一个,count自减;count到0,直接exit,不再输出。

如果调用 -c 4,就输出 4 5 6 7,然后退出。正好给dupids冲突用户做新UID。

完整示例跑一遍

unique‑ids里面已占用UID:0,1,2,3,105,110...

1
sh newuids.sh -c 4 unique‑ids

输出:

1
2
3
4
4
5
6
7

知识点总结表格

变量 类型 含义
getopts shell内置命令 解析短选项 -c -a
opt 自定义变量 保存当前解析到的选项名字,如c
OPTARG shell内置变量 保存选项后面跟的参数,‑c 4OPTARG=4
OPTIND shell内置变量 下一个待解析参数的下标,配合shift去掉已经解析的选项
shift N shell内置 丢弃前N个位置参数,后面参数往前移动
awk -v var=xxx awk参数 把shell变量传递进awk脚本内部
getline awk内置函数 读取文件一行存入变量,成功返回大于0
getopts "c:" opt 解析
1
getopts "c:" opt

"c:" 不是命令行要敲 c:,它是getopts 的配置字符串(元描述),告诉 getopts:

我程序支持 -c 这个选项,并且 -c 后面必须附带一个参数值。 冒号 : 是给 getopts 看的标记符号,不要写到命令行

语法规则拆解

配置串字符 含义
c 识别命令行的 -c 选项
:紧跟在字母后面 代表:该选项需要带参数(必须跟一个值)

命令行正确写法(减号不能丢)

1
sh newuids.sh -c 4 unique‑ids
  • -c:命令行选项(要有减号 -
  • 4:就是 -c 的参数,存入内置变量 $OPTARG

错误写法

1
2
sh newuids.sh c: 4 unique‑ids   # 错!不要写冒号,也不能丢掉减号
sh newuids.sh c 4 unique‑ids    # 错!少减号,c被当成普通位置参数,不是选项

举多选项例子加深理解

1
2
3
4
5
6
7
8
while getopts "ab:c:" opt
do
    case $opt in
        a) echo "拿到 -a" ;;
        b) echo "拿到 -b,参数=$OPTARG" ;;
        c) echo "拿到 -c,参数=$OPTARG" ;;
    esac
done

配置串 "ab:c:" 含义:

  1. -a:不需要参数(后面没有冒号)
  2. -b必须带参数(b后面紧跟:
  3. -c必须带参数(c后面紧跟:

正确调用:

1
sh test.sh -a -b hello -c world file.txt

记住口诀:
配置字符串里的冒号是给 getopts 的说明书标记,只写在脚本内部,命令行不写冒号。
命令行写 -字母,冒号不出现在命令行。

回到 newuids.sh 的执行流程:sh newuids.sh -c 4 unique‑ids

  1. getopts读取命令行参数,看到 -c,匹配配置串的c;因为c后面有:,它就把下一个参数4抓出来放进$OPTARG
  2. opt变量赋值为字符串c,进入case c)分支:count=$OPTARGcount=4
  3. 处理完,OPTIND 变成3。执行 shift $((OPTIND‑1)),把 -c4 这两个参数移走。
  4. 剩下的 unique‑ids 落到 $1

思考题

如果脚本写 getopts "c" opt(c后面没有冒号),执行:

1
sh newuids.sh -c 4 unique‑ids

会发生什么?

答案 配置串没有冒号,代表 -c 不接收参数。 $OPTARG 为空,4 会被当成普通位置参数,而不是 -c 的值。

小结

  1. 脚本内:getopts "c:" optc是选项名,:是标记,代表该选项需要参数。
  2. 命令行:-c,不要写冒号,后面跟数字。
  3. $OPTARG:只有选项配置带:时,才会保存选项后面的值。
  4. 命令行的选项必须带减号‑c,不能直接写c
$OPTIND 详细讲清楚

$OPTIND:shell内置变量,下一次 getopts 要去处理的参数的下标,不是已经处理完的最后一个下标。

示例命令:

1
sh newuids.sh -c 4 unique‑ids

脚本启动时,位置参数:

下标
$1 -c
$2 4
$3 unique‑ids

shell数组下标从1开始。 脚本刚启动,还没跑getopts的时候:OPTIND=1,代表:下一次getopts要去拿第1号参数处理

跟着while循环走一遍

1
2
3
4
5
6
while getopts "c:" opt
do
    case $opt in
        c) count=$OPTARG ;;
    esac
done
  1. 第一次执行getopts "c:" opt
  • 当前OPTIND=1,读取$1也就是‑c
  • -c在配置串里带:,说明这个选项需要参数,于是把下一个参数$2(值4)拿进$OPTARG
  • 处理完‑c以及它的参数4。 getopts把OPTIND设置为 3

OPTIND=3含义:下一轮循环,我准备去读取第3号参数

  1. 回到while,再次调用getopts
    现在OPTIND=3,读取$3的值是unique‑ids,它不以减号-开头,不是选项
    getopts知道没有更多选项可以解析,返回false,while循环直接结束

循环结束之后:OPTIND=3

不是“已经处理完第3个参数”; 含义:下一个待处理的参数位置是3号。 而3号这个unique‑ids根本没有被getopts处理过,它是留给我们脚本自己用的文件名。

为什么写 shift $((OPTIND - 1))

现在 OPTIND=3
OPTIND‑1 = 2shift 2

shift N:丢掉最前面 N 个位置参数,后面参数向前移动。

  • shift 2 扔掉 $1(-c)$2(4)
  • 原来的$3(unique‑ids)往前挪,变成新的$1
1
IDFILE=$1   # 拿到文件名

形象记忆

  • OPTIND 像数组的指针、光标,指向下一个要读取的位置。
  • 每处理完一个选项(连同它附带的参数),光标OPTIND就往后移动。

再测试另外一种场景:不带 -c

1
sh newuids.sh unique‑ids

参数列表:

  • $1 = unique‑ids
  1. 脚本启动 OPTIND=1
  2. 第一次调用getopts,读取$1,内容unique‑ids没有-,不是选项,getopts返回false,while一次都不进入直接结束。
  3. 此时OPTIND=1
  4. shift $((1‑1))shift 0

shift 0 什么都不干,参数保持原样。$1依旧是unique‑ids。完美。

这就是为什么不管加不加‑c,这一行代码都可以通用。

易错点澄清你的疑问

问题:参数一共3个,OPTIND是不是就等于3? 不是。OPTIND是光标位置,不是总参数个数。

  • 处理完‑c 4,光标指向3,OPTIND=3;但第3号参数根本没被getopts碰过。

问题:optind是处理到哪个参数,optind就是几? 不完全准确: OPTIND = 下一次要处理的参数下标,不是刚刚处理完的那个下标。

举个小例子,简单脚本测试,可以复制运行看输出:

1
2
3
4
5
6
7
8
#!/bin/sh
while getopts "c:" opt
do
    echo "opt=$opt, OPTARG=$OPTARG, OPTIND=$OPTIND"
done
echo "循环结束 OPTIND=$OPTIND"
shift $((OPTIND-1))
echo "shift之后 \$1=$1"

调用:

1
sh test.sh -c 4 myfile.txt

输出:

1
2
3
opt=c, OPTARG=4, OPTIND=3
循环结束 OPTIND=3
shift之后 $1=myfile.txt

总结

  1. OPTIND 是光标,代表下一个getopts将要读取的参数位置(下标),初始值永远等于1。
  2. getopts每消耗一个选项+它的参数,OPTIND就向后跳。
  3. 遇到不是开头的普通参数,getopts停止解析,while退出;OPTIND停留在这个普通参数的下标。
  4. shift $((OPTIND‑1)):把已经解析完毕的选项全部shift扔掉,剩下的普通位置参数,就落到$1 $2...供脚本自己使用。
  5. 如果没有任何选项,OPTIND=1 → shift 0,不改动参数,兼容无选项调用。

重点:getopts只管解析-x`这种带减号的选项;不带减号的文件名一类参数不归它处理,留给shift之后用$1获取。

示例:取4个空闲UID

1
newuids.sh -c 4 unique‑ids

11.3.3 用户‑旧UID‑新UID映射表

处理dupusers(用户名相同UID不同)

文件 dupusers 的内容特点:冲突用户是两行一组挨在一起存储

1
2
3
4
ben:x:201:10:Ben Franklin:/home/ben:/bin/bash     #组内第1行
ben:x:301:10:Ben Franklin:/home/ben:/bin/bash     #组内第2行
jhancock:x:200:10:John Hancock:/home/jhancock:/bin/bash  #下一组第1行
jhancock:x:300:10:John Hancock:/home/jhancock:/bin/bash  #下一组第2行

关键点:

  • 修改IFS=:,让read直接按冒号分割passwd字段
  • dupusers文件是两行一组:同一个用户两条不同UID记录
  • 循环一次read读取两行;输出新passwd记录到unique2;输出映射关系 用户名 旧UID 新UID 追加写入old‑new‑list
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
rm -f old-new-list          # 先删除旧的映射文件,避免旧数据追加
old_ifs=$IFS                # 备份shell原始分隔符
IFS=:                       # 设置shell分隔符为冒号,read按冒号切割passwd字段

# 从dupusers文件读取,循环输出定向到 unique2
while read user passwd uid gid fullname homedir Shell
do
    # 重点:继续从同一个dupusers文件,再读下一行!
    if read user2 passwd2 uid2 gid2 fullname2 homedir2 Shell2
    then
        # 读到了第二行,判断两行用户名必须完全一样
        if [ $user = $user2 ]
        then
            # 输出:用户名 旧UID 新UID >>追加写入映射表 old‑new‑list
            printf "%s\t%s\t%s\n" $user $uid $uid2 >> old-new-list
            # 输出组装好的新passwd记录,输出重定向到 unique2
            echo "$user:$passwd:$uid2:$gid:$fullname:$homedir:$Shell"
        else
            # 两行用户名不一样,文件错乱,报错到标准错误,退出脚本
            echo $0: out of sync: $user and $user2 >&2
            exit 1
        fi
    else
        # 想读第二行,但是读到文件末尾读不到数据了,说明dupusers文件格式损坏(只有孤零零一行,没有配对)
        echo $0: no duplicate for $user >&2
        exit 1
    fi
done < dupusers > unique2

IFS=$old_ifs    # 恢复原来的IFS,还原shell环境

unique2内容:

1
2
ben:x:301:10:Ben Franklin:/home/ben:/bin/bash
jhancock:x:300:10:John Hancock:/home/jhancock:/bin/bash

处理dupids(UID相同用户名不同)

  1. 统计dupids行数,就是需要分配新UID的账号数量
  2. set -- $(newuids.sh -c $count unique‑ids):把所有空闲UID放到shell位置参数 $1 $2 $3 …
  3. while循环读取dupids每一行;每次shift取出下一个空闲UID,分配给用户
  4. 输出新passwd记录unique3;追加映射到old‑new‑list
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
count=$(wc -l < dupids)

# set -- arg1 arg2 arg3 …
# 直接改写 shell 的位置参数 $1 $2 $3 $4 …,把原来的位置参数全部替换成后面给出的值。
# set -- 4 5 6 7
# $(...) 命令替换,老式使用反引号 `cmd`,现代推荐写法:$(命令),执行括号内部的 shell 命令,捕获这个命令打印到标准输出的全部内容且换行被shell换成空格,得到字符串:"4 5 6 7",把输出结果替换到脚本当前位置。
set -- $(newuids.sh -c $count unique-ids)
IFS=:
while read user passwd uid gid fullname homedir Shell
do
    newuid=$1
    shift
    echo "$user:$passwd:$newuid:$gid:$fullname:$homedir:$Shell"
    printf "%s\t%s\t%s\n" $user $uid $newuid >> old-new-list
done < dupids > unique3
IFS=$old_ifs

此时产出:

  • unique2:同名冲突用户新passwd记录
  • unique3:同UID冲突用户新passwd记录
  • old‑new‑list:映射表,格式:用户名 旧UID 新UID

unique3 文件

1
2
3
4
abe:x:4:10:Honest Abe Lincoln:/home/abe:/bin/bash
tj:x:5:10:Thomas Jefferson:/home/tj:/bin/bash
dorothy:x:6:10:Dorothy Gale:/home/dorothy:/bin/bash
toto:x:7:10:Toto Gale:/home/toto:/bin/bash

生成最终完整passwd文件

按第3字段(UID)数字排序,合并unique1 unique2 unique3

1
sort -k 3 -t : -n unique[123] > final.password

-t :分隔符冒号;‑k 3按第3列;‑n数字排序。

11.4 改变文件所有权

坑点1:用户文件不只是在家目录,还散落在 /tmp、项目目录等任意位置。只改家目录会漏掉大量文件。
坑点2:UID替换顺序风险:A要改成UID=10,B原来UID=10要改成别的;如果先把A改成10,原来B的文件会被错误归属给A。
本章规避方案:给冲突用户全部分配全新从来没有使用过的UID,不存在顺序问题。

正确思路

扫描整个根文件系统,找到所有属于该旧用户的全部文件,修改UID。 find + xargs,不要find -exec(会大量fork进程,性能差)

普通版本

1
find / -user $user -print | xargs chown $newuid

GNU安全版本(兼容带换行/空格文件名)

1
find / -user $user -print0 | xargs -0 chown $newuid

生成批量脚本,不直接执行(安全!先人工检查)

读取映射表old‑new‑list,把find+xargs命令写入脚本文件chown‑files

1
2
3
4
5
while read user old new
do
    echo "find / -user $user -print | xargs chown $new"
done < old-new-list > chown-files
chmod +x chown-files

重点:先生成脚本文件,人看确认没问题再执行,不直接现场执行高危操作

重要前提:chown‑files脚本必须分别在u1、u2两台机器本地执行。 u1上不存在的用户,find -user直接报错,不会误处理;u2同理。避免跨机器混淆。

11.5 其他真实世界议题

  1. /etc/group组文件也要合并

    • GID也要全局唯一;同一个组的成员列表要合并。
    • 处理逻辑几乎和passwd一致。
  2. 孤儿文件:文件的UID/GID在passwd/group找不到任何账号

1
find / -nouser -o -nogroup -ls

需要人工确认,要么分配给现有用户,要么新建账号。

  1. ⚠操作环境要求:系统进入单用户模式。防止业务运行过程中新产生文件,造成漏改。

  2. 性能问题:上面写法每变更一个用户就要全盘find一遍。上千用户会很慢。 优化方案:只全盘find一次,输出所有文件,awk读取新旧UID映射表,一次性批量生成全部chown命令。

11.6 小结

本章学到的shell实战知识点

  1. /etc/passwd字段结构,UID决定文件权限,NFS共享的UID唯一性要求。
  2. sort合并排序文本;awk关联数组做数据分类,awk自定义函数。
  3. shell修改IFS变量,让read按冒号分割密码文件字段;用完恢复旧IFS。
  4. getopts解析脚本命令行选项。
  5. set --把命令输出放到shell位置参数;shift逐个取出参数。
  6. 工程安全思想:不直接执行高危操作;先输出脚本文件,人工审核再运行
  7. find+xargs批量处理文件;理解‑print0 / ‑0安全处理特殊文件名。
  8. 真实运维的边界条件:孤儿文件、单用户模式、性能优化。

完整工作流回顾

合并passwd → awk分类冲突账号 → 提取已占用UID → 脚本生成空闲UID → 构建旧UID→新UID映射 → 生成最终passwd → 生成chown脚本,人工审核后执行。

整套脚本全部由UNIX小工具组合完成,170行以内,替代复杂C程序。