很多团队改 /etc/ssh/sshd_config 的方式还是「登上去 vim 一下,然后 systemctl reload sshd」。这种做法的风险不在于改错一两行,而在于改完之后没人知道这台机器上到底改过什么:三年前有人把 PermitRootLogin 改成 yes,两年半前另一个人把 PasswordAuthentication 复制了两行、一行 yes 一行 no,sshd 取第一个匹配值还是最后一个取决于上下文,最终行为只有重启之后才知道。等到某次巡检或者合规审计要求「列出所有机器的 SSH 配置基线」,你才发现配置已经漂移到无法自动收敛。
Ansible 的 lineinfile 模块是处理这类「单文件 + 行级键值对」配置最趁手的工具,它天然幂等:值已经对了就什么都不做,值不对就替换,行不存在就新增。但它也有两个高频坑:一是同一个 key 在文件里出现多次时,默认只改最后一行,前面的脏行会留下;二是 sshd_config 里存在 Match 块,块内的同名指令不能和全局指令一起用同一套逻辑处理。再叠加「改完必须 reload 才生效」「reload 有语法错误会直接失败」这两点,一个不谨慎的 playbook 足以让一批机器同时失联。
下面这套做法要解决的问题是:把 sshd_config 的期望状态写进 playbook,执行前用 --check --diff 拿到逐行差异并人工确认,执行后先 sshd -t 验证语法,通过才 reload,且整个过程可以重复执行任意次结果不变。
先看清 sshd_config 的三个特殊之处
在动手写任务之前,必须先把目标文件的特性摸清楚,否则 lineinfile 的默认行为会给你制造麻烦。
键值对里键名大小写不敏感
sshd 的内置指令名是大小写不敏感的,PermitRootLogin、permitrootlogin、Permitrootlogin 是同一个东西。但 lineinfile 的 regexp 默认走 Python 正则,是区分大小写的。如果你只写 ^PermitRootLogin,那么文件里一行小写的 permitrootlogin yes 会被判定为「不存在该行」,于是模块在文件末尾追加一行新的,最终机器上两份配置并存。所以正则必须写成 ^(?i)PermitRootLogin,或者用 (?i) 内联标志,或者把常见大小写变体都覆盖进去。
首次匹配生效,不是最后匹配
这一点和大多数配置文件相反,也和 lineinfile 的默认行为相反。sshd_config 里同一个关键字出现多次时,sshd 取第一次出现的值(Match 块内除外,块内取块内第一次)。而 lineinfile 默认 lastmatch 思维——不带参数时它替换最后一行匹配。这就意味着:如果文件里原本有 PermitRootLogin yes 在第 45 行、PermitRootLogin no 在第 120 行,你让 lineinfile 去把匹配行改成 no,它改的是第 120 行,第 45 行的 yes 依然生效,你以为改成功了,实际上 sshd 读的是 yes。这是最隐蔽的一类事故。
正确姿势是:先用参数 firstmatch=yes 让模块从第一行开始匹配,再配合 backrefs 或者干脆用 regexp 把整行锚定替换。但 lineinfile 一次只处理一行,重复的脏行需要另一次任务清理。
存在 Match 块
典型文件末尾会有:
Match User deploy
PasswordAuthentication no
X11Forwarding no
Match Address 10.0.0.0/8
PermitRootLogin no
块内的 PasswordAuthentication 和全局的 PasswordAuthentication 是两个语义不同的东西。如果你用 regexp: '^(?i)PasswordAuthentication\s+' 全局替换,块内那行也会被改成全局期望值,等于悄悄改掉了 deploy 用户的免密策略。稳妥的做法是把全局基线集中写在文件头部一段由 Ansible 托管的区块里,Match 块保持不动,这样正则替换就限定在可预期的范围内。
用 blockinfile 打标记,把托管区域和人工区域隔开
比逐行 lineinfile 更可控的结构是:在文件开头插入一个带标记的托管区块,所有全局基线写在里面,后续再补 lineinfile 处理个别必须原位的项。标记形如:
# BEGIN ANSIBLE MANAGED BLOCK sshd-baseline
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
MaxAuthTries 4
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
UseDNS no
# END ANSIBLE MANAGED BLOCK sshd-baseline
放在文件最前面是有讲究的:前面说过 sshd 取第一次出现的值,托管区块置顶以后,它就必然压过文件后面那些历史遗留行,即使那些行还在,也不再生效。这一步相当于用「顺序」而不是「删除」来消除漂移,风险最低,也最容易回滚。
对应的任务:
- name: 托管 sshd 全局基线区块(置于文件头部)
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config
marker: "# {mark} ANSIBLE MANAGED BLOCK sshd-baseline"
insertbefore: BOF
block: |
PermitRootLogin {{ sshd_permit_root_login }}
PasswordAuthentication {{ sshd_password_auth }}
PermitEmptyPasswords no
MaxAuthTries {{ sshd_max_auth_tries }}
ClientAliveInterval {{ sshd_client_alive_interval }}
ClientAliveCountMax {{ sshd_client_alive_count_max }}
X11Forwarding no
UseDNS no
validate: /usr/sbin/sshd -t -f %s
backup: yes
notify: reload sshd
几个参数值得一个一个说清:
insertbefore: BOF:BOF是模块内置的虚拟标记,表示「文件最开头」。区块已存在时该参数不生效,只做内容比对更新,所以幂等。validate: /usr/sbin/sshd -t -f %s:这是整个方案的安全阀。blockinfile在写入前会把修改后的完整文件复制到临时路径,用%s占位传给它,执行sshd -t -f 临时文件做语法校验。校验失败则任务 fail、文件保持原样、不触发 handler。注意必须用绝对路径/usr/sbin/sshd,因为 Ansible 任务默认 PATH 里可能没有 sbin。backup: yes:每次实际发生修改时在path同级留一个带时间戳的备份,出问题可以直接cp回去。只在有变更时产生,不会撑爆磁盘。notify: reload sshd:只有任务状态为 changed 时才触发 handler。这保证了「没改动就不 reload」,避免无谓的中断。
一个常见报错是 validate 报 sshd: no hostkeys available -- exiting.。原因是 sshd -t 在校验时也会尝试加载主机密钥,如果目标机 /etc/ssh/ssh_host_*_key 权限不对或者尚未生成,校验就失败。这不是配置写错了,先修好 host key 权限(一般 600、属主 root)再跑。另一个报错是 Bad configuration option: xxx,那是 validate 真实拦下了一个错,看 diff 就知道是哪一行。
用 lineinfile 处理重复行与分散配置
托管区块解决的是全局基线,但实际环境里还有两类配置分散在文件中,需要 lineinfile 单独处理。
场景一:清理历史重复行,只保留一行
文件里可能存在多行 PasswordAuthentication。既然托管区块已经置顶生效,理论上可以不管它们,但从可维护性和审计角度看,最好收敛到只剩一行。做法是先用 lineinfile 的 state: absent 把「所有匹配行」删掉,再独立插入一行——不过注意,删掉之后如果还有人手工加回来,下次执行又会删,这本身就是期望行为。真正要小心的是一删一插必须原子化顺序执行。
- name: 移除文件中已有的 PasswordAuthentication 行(含大小写变体)
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^(?i)\s*PasswordAuthentication\s+'
state: absent
register: pam_removed
- name: 在托管区块之后补一行期望值
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^(?i)\s*PasswordAuthentication\s+'
line: "PasswordAuthentication {{ sshd_password_auth }}"
insertafter: '^# END ANSIBLE MANAGED BLOCK sshd-baseline'
validate: /usr/sbin/sshd -t -f %s
when: pam_removed.changed
这里 when: pam_removed.changed 是个小优化:只有真的删过行才需要补回来,否则跳过。但更常见的写法是不加 when,因为 lineinfile 本身带 regexp 且 line 与现有内容一致时是幂等的——只是要注意,如果文件里本来就没有这一行,它会插入;如果已经有且值正确,它不动。
场景二:保留 Match 块内的独立策略
假如某个 Match User deploy 块需要单独设置 PasswordAuthentication yes(允许部署账号用密码,其余全部密钥),不能简单用全局 regexp,否则会把块内行一起改掉。可靠的方式是先把整个 Match 块用 blockinfile 单独托管:
- name: 托管 deploy 用户的 Match 块
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config
marker: "# {mark} ANSIBLE MANAGED BLOCK match-deploy"
block: |
Match User deploy
PasswordAuthentication yes
X11Forwarding no
validate: /usr/sbin/sshd -t -f %s
notify: reload sshd
注意 Match 块有一个硬约束:它必须是文件里该位置之后所有指令的作用域起点,一旦 Match 出现,后面就不能再回到全局配置(除非再写一个 Match all)。所以这个块必须放在文件末尾,且不能再用 insertbefore: BOF。如果你同时用了托管区块置顶 + Match 块置尾,顺序天然正确。
用 –check –diff 做预检,把变更打在屏幕上
这是整套流程里最值得强调的一步。Ansible 的 --check 走 dry-run 模式,大多数模块会计算「如果要改会改成什么」但不动文件;--diff 则把改动以 unified diff 形式打印出来。两者组合:
ansible-playbook -i inventory/prod.ini sshd_baseline.yml --check --diff --limit 'sshd_targets'
输出类似:
TASK [托管 sshd 全局基线区块(置于文件头部)] ***********************
--- before: /etc/ssh/sshd_config
+++ after: /etc/ssh/sshd_config
@@ -1,4 +1,14 @@
+# BEGIN ANSIBLE MANAGED BLOCK sshd-baseline
+PermitRootLogin no
+PasswordAuthentication no
+PermitEmptyPasswords no
+MaxAuthTries 4
+ClientAliveInterval 300
+ClientAliveCountMax 2
+X11Forwarding no
+UseDNS no
+# END ANSIBLE MANAGED BLOCK sshd-baseline
# $OpenBSD: sshd_config,v 1.100 2016/08/15 12:32:04 naddy Exp $
changed: [web01.prod]
运维评审时,把这段 diff 贴到变更单里,比任何文字描述都有说服力。有几个实践细节:
--check对validate的影响:check 模式下blockinfile依旧会生成临时文件跑 validate,所以语法错误在预检阶段就能暴露,不用等到真改。这一点很多人不知道,白白浪费了一次预检机会。- handler 在 check 模式下不会真正执行,但会显示为「Would notify」。如果你的 reload handler 里有额外逻辑(比如先
sshd -t再 reload),要确认它在 check 下不会被误触发。 - 配合
--limit先在一台灰度机上跑 diff,确认无意外后再全量。改 sshd 直接影响登录通道,永远不要在没看过 diff 的情况下对全量执行。 - 如果 diff 太长,可以只针对单台机器:
ansible-playbook ... --check --diff --limit host01,输出更聚焦。
还需要提醒一个 --diff 的局限:当文件很大时,diff 会截断显示(Ansible 默认有 diff 的上下文限制),想看完整差异可以在 ansible.cfg 里配置 stdout_callback = yaml 或用 -v 提升详细度。但截断只影响显示,不影响模块实际写入的内容。
handler、语法规避与回滚
真正的 last mile 在变更生效阶段。sshd 的 reload 不能简单粗暴地 systemctl reload sshd,因为一旦配置有语义错误(语法对但参数非法,比如 MaxAuthTries 0),reload 可能失败甚至让新连接被拒,而老连接还在,你会以为自己没事。
- name: reload sshd
ansible.builtin.systemd:
name: sshd
state: reloaded
listen: reload sshd
- name: 变更后自检:sshd 语法与监听
ansible.builtin.shell: |
/usr/sbin/sshd -t && ss -lntp | grep -q ':22 '
changed_when: false
listen: reload sshd
handler 里用 systemd 的 state: reloaded 而不是 command: systemctl reload sshd,是为了让 Ansible 拿到正确的返回码和幂等语义。如果目标机是 RHEL/CentOS 且服务名是 sshd、Debian/Ubuntu 也是 sshd(新版)或 ssh(老版),服务名要用变量:
- name: reload sshd
ansible.builtin.systemd:
name: "{{ sshd_service_name | default('sshd') }}"
state: reloaded
回滚方案要提前写好,而不是出事才想。前面 backup: yes 每次变更都会在 /etc/ssh/ 下留 sshd_config.20240415xxxxxx 之类的文件,回滚就是:
ls -lt /etc/ssh/sshd_config.* | head
cp /etc/ssh/sshd_config.20240415103012 /etc/ssh/sshd_config
/usr/sbin/sshd -t && systemctl reload sshd
更稳的做法是把配置纳入 Git:playbook 仓库就是唯一事实源,机器上的是派生物,回滚就是回滚 commit 再重跑 playbook。这也是为什么托管区块要带 marker——人工改动一眼可见,评审时对着 git diff 就能判断。
还有一个必须提前预防的问题:不要在同一批里同时改 Port 和依赖当前端口的防火墙规则。如果你把 Port 22 改成 Port 2222,而 SELinux 没放行 2222、或者防火墙没开,reload 之后新连接直接进不来,而你的 Ansible 恰恰是通过 22 连过去的。正确顺序是:先加端口(ssh 支持多个 Port 指令)、放行防火墙与 SELinux(semanage port -a -t ssh_port_t -p tcp 2222)、验证新端口可连、最后再删旧端口。这个顺序任何一步都不能省。
一个可直接运行的完整 playbook
把上面的片段拼成一份可执行文件,变量集中在 group_vars 或 play 的 vars 里:
---
- name: 维护 sshd_config 基线
hosts: all
become: true
vars:
sshd_service_name: sshd
sshd_permit_root_login: "no"
sshd_password_auth: "no"
sshd_max_auth_tries: 4
sshd_client_alive_interval: 300
sshd_client_alive_count_max: 2
tasks:
- name: 托管全局基线区块
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config
marker: "# {mark} ANSIBLE MANAGED BLOCK sshd-baseline"
insertbefore: BOF
block: |
PermitRootLogin {{ sshd_permit_root_login }}
PasswordAuthentication {{ sshd_password_auth }}
PermitEmptyPasswords no
MaxAuthTries {{ sshd_max_auth_tries }}
ClientAliveInterval {{ sshd_client_alive_interval }}
ClientAliveCountMax {{ sshd_client_alive_count_max }}
X11Forwarding no
UseDNS no
validate: /usr/sbin/sshd -t -f %s
backup: true
notify: reload sshd
- name: 清理重复的 PasswordAuthentication 行
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^(?i)\s*PasswordAuthentication\s+'
state: absent
notify: reload sshd
- name: 校验最终文件语法
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
register: sshd_syntax
failed_when: sshd_syntax.rc != 0
handlers:
- name: reload sshd
ansible.builtin.systemd:
name: "{{ sshd_service_name }}"
state: reloaded
- name: 变更后确认 22 端口仍在监听
ansible.builtin.shell: ss -lnt | grep -q ':22 '
changed_when: false
注意第二个任务「清理重复行」会删掉所有 PasswordAuthentication 行,而托管区块里的那一行位于 BOF 区域,也匹配 ^(?i)\s*PasswordAuthentication\s+。这里要小心——lineinfile 会把它一起删掉!这是实际踩过的坑。解决办法有两个,任选其一:把清理任务放到托管区块任务之前执行(这样区块在清理之后才插入,不会被删),或者把清理正则改成更严格的、排除掉托管区块的写法。最简洁的还是调顺序:先清理,后建区块。调整后 handler 依然只在最后统一触发一次(ansible handler 去重),语义正确。
再补充一个不易发现的问题:lineinfile 的 state: absent 加 regexp 时,如果正则匹配到了多个位置,它会把所有匹配行都删掉,这正好符合「清理重复」的需求。但如果你只想删一处,就要配合 firstmatch 或者用带锚点的唯一正则以限定范围,否则会误删。
小结
这套方案的核心思路可以压缩成三条:
- 用顺序压过漂移。 把全局基线放进置顶的托管区块,利用 sshd「首次匹配生效」的特性,让历史脏行自动失效,而不是冒险去删它们。
- 用 validate 做写入闸门。 每次写文件前让
sshd -t校验,语法错的变更根本落不了盘,从机制上避免「改完才发现配错」。 - 用 check + diff 做人工闸门。 变更前先看真实 diff,灰度一台,确认无误再全量。这一步比任何自动化都重要,因为它把「不确定」变成了「可审阅」。
配合 backup、handler 去重、Git 版本管理,你就得到一个可以每天跑、每次结果都相同、且出问题时十秒内能回滚的 sshd 配置维护流程。
FAQ
Q1:执行时报 “Destination /etc/ssh/sshd_config does not exist”,但我确定文件存在?
多数情况是 become_user 或者 ansible_remote_user 对 /etc/ssh 目录没有执行权限,模块检查时被拦下。加 become: true,或者确认目标用户能 ls -l /etc/ssh。另外如果 path 写成了带引号的变量且变量为空,也会指向根目录,检查 vars 是否生效。
Q2:为什么 lineinfile 明明执行了 changed,但 sshd -T 看到的还是旧值?
几乎必然是重复行 + 首次匹配导致的。lineinfile 默认替换最后一行,而 sshd 读第一次出现的那行。用 sshd -T | grep -i permitrootlogin 看生效值,再用 grep -n -i permitrootlogin /etc/ssh/sshd_config 看全部行,答案就出来了。按本文方案把托管区块置顶即可根治。
Q3:validate 报 “Missing privilege separation directory: /run/sshd” 怎么办?
这是 Debian/Ubuntu 上 sshd -t 的常见报错,原因是 /run/sshd 目录不存在(重启后 /run 被清空,而 sshd 服务还没启动)。这不是配置错误。解决:先 install -d -m 0755 /run/sshd,或者在 playbook 里加一个 ansible.builtin.file 任务提前建目录,然后再做配置变更。
Q4:reload 之后新连接被拒绝,但旧 session 还在,怎么紧急恢复?
不要关掉现有 session。从旧 session 直接 cp /etc/ssh/sshd_config.<时间戳> /etc/ssh/sshd_config,然后 /usr/sbin/sshd -t 确认,再 systemctl reload sshd。如果连 reload 都失败,sshd 主进程通常还在用旧配置服务(reload 是 SIGHUP,失败行为取决于版本),别再二次折腾,走带外(IPMI/云控制台)进去修。这也是为什么每次变更都要 backup: yes。
Q5:能不能用 template 模块一次性渲染整个 sshd_config?
可以,而且对全新机器更干净。但对接管存量机器的场景,整文件渲染会把机器上其他团队维护的 Match 块、HostKey、Subsystem 路径全部覆盖掉,风险远大于区块 + 行级修改。折中方案是只对自建镜像用 template,对存量机用本文的增量方式。两者不要混用在同一台机器上。
Q6:blockinfile 的 marker 能自定义吗?会不会冲突?
可以,marker 支持 {mark} 占位,会替换成 BEGIN/END。关键是 marker 字符串在当前文件里必须唯一,否则模块会把两个区块搞混。建议统一加上业务前缀,比如 # {mark} ANSIBLE MANAGED BLOCK sshd-baseline-prod,避免不同 playbook 打架。