ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

history | grep ssh:Linux命令历史与SSH连接记录排查指南

history | grep ssh:Linux命令历史与SSH连接记录排查指南

1. 先搞清楚这条命令到底在解决什么问题

history | grep ssh这行东西,估计每个跟 Linux 打过交道的人都在某个深夜敲过。它干的事情极其朴素:把当前 shell 的命令历史全量倒出来,交给grep过滤,留下所有带ssh字样的行。就三秒钟,你就能翻出上个月连过的那台跳板机地址、那个只连过一次的边缘节点、以及那条被自己改坏又忘了改回去的端口转发参数。关键词拆开看无非三个:history提供数据源,grep提供筛选,ssh只是你要找的特征串。但真到了排障现场,这三个词凑一起能救命。

这篇东西写给谁看?刚上手运维、还在被"那台机器 IP 到底是多少"折磨的同学能直接抄作业;写过几百次这条命令但从没想过HISTSIZE是干嘛的老手,也能在中间几节里找到点之前忽略的细节。我不会只丢一行命令就完事,重点是把"为什么有时候查不到""怎么查得又快又准""怎么让它长期可用"这几件事讲透。毕竟这条命令的价值不在于语法,而在于你对 shell 历史机制的理解深度。

1.1 history、grep、ssh 三个角色各司其职

history是 bash 的内建命令,不是/usr/bin/history那个可执行文件,你在文件系统里ls是找不到它的。它的输出格式固定为"行号 + 两个空格 + 原始命令",比如512 ssh ops@192.0.2.10 -p 2222。这个行号很关键,后面用!512重放命令、用history -d 512删除某条记录,都靠它定位。

grep在这里就是个纯文本过滤器,默认按基本正则匹配,大小写敏感。所以history | grep ssh抓不到SSH、抓不到Ssh,也抓不到scp——虽然scp底层走的也是同一套远程传输协议。这一点新人经常栽跟头,明明记得昨天传过文件,grep ssh死活没结果,其实人家叫scp。

ssh在这个上下文里只是被搜索的字符串,但它的特殊性在于:一条 ssh 命令行里往往压缩了大量上下文信息——用户名、目标主机、端口、密钥路径、跳板配置、本地/远程端口转发规则。这就是为什么查 ssh 历史比查别的命令更有价值,一行记录基本等价于一份浓缩的"我当时在干什么"的证词。

1.2 半夜被叫起来的第一个动作

说个真实场景(细节做了虚构处理)。某天凌晨两点,监控告警说一台业务节点负载飙到 40,值班的 A 同学登上去看,发现有个进程在疯狂写日志,但不知道是谁、什么时候、从哪台机器上去的操作。他做的第一件事就是history | grep ssh,翻出这台机器最近被谁连过、连的时候带了什么参数——因为很多时候问题不是自动化脚本引起的,而是白天某个临时操作留下的后遗症。

还有更常见的版本:接手一台前人维护的机器,配置文件里写着AllowUsers但不知道还有谁在用;或者要下线一台旧机器,得先确认没有业务方还在往里连。这时候~/.bash_history就是最廉价、最直接的"软审计日志",不用装任何采集组件,不用申请任何权限,一条管道就出结果。

这也是它真正的定位:轻量级、即时性的现场取证工具。它不是正式的审计系统,不具备防篡改、不可抵赖的特性,但在 90% 的日常排障里,够用了。

1.3 先把边界划清楚:它查不到什么

这条命令最大的坑不是语法,是预期错位。很多人以为敲完就能看到"这台机器上发生过的所有 ssh 操作",实际上你看到的东西受限于一堆条件:

  • 只覆盖当前用户的历史,别人的~/.bash_history你没权限读(除非有 sudo)。
  • 只覆盖bash,如果对方用的是 zsh、fish、dash,历史文件位置和格式都不一样,zsh 默认在~/.zsh_history且带自己的扩展格式。
  • 只覆盖交互式 shell,通过ssh host 'command'这种非交互方式执行的一次性命令,通常压根不进历史。
  • 只覆盖落盘了的部分,还在内存缓冲里没写文件的命令,可能在另一个终端里根本看不见。
  • 只覆盖没被清理的部分,history -c、手动编辑文件、设置HISTIGNORE都能让它消失。

把这五点先记住,后面所有"为什么查不到"的问题,八成都能归到其中一条。

2. history 的存储机制:为什么有时候 grep 出来是空的

这是全文最值得花时间的一节。绝大多数"命令明明敲过却查不到"的疑问,根源都在 bash 处理历史的两段式设计上。搞懂这一段,你排查历史问题就不需要靠重启终端碰运气了。

2.1 内存缓冲与文件落盘是两回事

bash 的历史分两层:一层是进程内存里的列表,你按上方向键翻到的就是它;另一层是磁盘上的~/.bash_history文件。这两层默认不是实时同步的。

bash 的行为大致是这样:启动时把~/.bash_history读进内存(受HISTSIZE限制,只读最后 N 条);之后你敲的每条命令只往内存里追加;只有在 shell 正常退出时,才把内存内容覆盖写回文件(默认是覆盖,不是追加)。这个"覆盖"动作是无数人踩坑的源头——你开了两个终端,终端 A 敲了 50 条命令,终端 B 敲了 3 条,先退出 A(写入 50 条),再退出 B(用 3 条覆盖掉全部),A 那 50 条历史就蒸发了。

解决办法是在~/.bashrc里加两行:

shopt -s histappend PROMPT_COMMAND="history -a; $PROMPT_COMMAND"

第一行把"退出时覆盖"改成"退出时追加",第二行更狠,让每次显示提示符之前都执行history -a,也就是每条命令执行完立刻追加到文件。加了这两行之后,多终端并存、意外断连、终端被强制关闭,历史都不会丢。

注意:history -a只追加"自上次写入以来新增的部分",不会重复写,所以这个操作是幂等的,不用担心文件被写爆。

2.2 四个环境变量,决定你能查多远

光有文件还不够,能查多久取决于四个参数。这几个值在不同发行版上差异很大,别信网上抄来的默认值,自己echo一下最准。

变量作用常见默认建议值
HISTSIZE内存中保留多少条500 / 100010000
HISTFILESIZE文件里保留多少行500 / 200050000
HISTCONTROL去重 / 忽略空格开头未设置ignoreboth
HISTIGNORE忽略特定模式未设置ls:cd:pwd:exit
HISTTIMEFORMAT是否带时间戳未设置%F %T

HISTSIZE和HISTFILESIZE的关系经常被搞混:前者管内存(你在当前会话能翻到多少),后者管文件(下次开 shell 能加载多少)。如果只调大HISTSIZE不调HISTFILESIZE,那你退出时写文件会被截断到HISTFILESIZE行,等于白调。两个必须一起改。

HISTCONTROL=ignoreboth是ignorespace和ignoredups的合体:前者让以空格开头的命令不被记录,后者让连续重复的命令只记一次。ignorespace这个特性非常实用——你在命令行里临时输入一个带敏感参数的命令,前面加个空格就干净了。

2.3 打开时间戳之后,历史文件长什么样

HISTTIMEFORMAT这个变量值得单独讲。不设它的时候,history输出只有行号和命令,你根本不知道那条ssh ops@192.0.2.10是三小时前还是三个月前的。设了之后:

export HISTTIMEFORMAT='%F %T '

输出就变成512 2024-06-11 02:17:43 ssh ops@192.0.2.10 -p 2222。排障时这个时间戳的价值极高,因为你能把命令和日志时间线对齐——看看这个 ssh 是发生在告警之前还是之后,是"操作导致了故障"还是"故障发生后有人上去修"。

但有个细节必须知道:开启这个选项后,~/.bash_history文件的格式会变。每条命令前面会多插入一行#<Unix时间戳>,比如:

#1718069863 ssh ops@192.0.2.10 -p 2222

这意味着两件事。第一,文件体积会变大,每条命令平均多出约 12 个字节,估算容量时要算进去。第二,如果你的历史文件是在没开时间戳的时期生成的,现在开了,那些老记录前面没有时间戳,history显示它们时时间列会是错的或者是空的——这是正常现象,不是数据损坏。

还有个更隐蔽的问题:history | grep ssh本身是一条命令,它自己也会被记录进历史,而且因为它在管道右侧启动,某些情况下记录的是history | grep ssh完整形式,导致你下次再 grep 的时候会多出一条自引用噪音。这个后面第 3 节会讲怎么排除。

3. grep 的写法演进:从能用到好用

基础版history | grep ssh能解决 60% 的问题,剩下 40% 需要稍微动点脑筋。这一节按实用度排序,从最常用的变形讲起。

3.1 基础变形:大小写、词边界、模糊匹配

先把最常见的三个开关记住:

history | grep -i ssh # 忽略大小写,能抓到 SSH、Ssh history | grep -iw ssh # 整个单词匹配,避免匹配到 sshd_config 之类 history | grep -iE 'ssh|scp|sftp' # 扩展正则,一次抓全远程传输类命令

-w这个参数的价值在一个具体场景里体现得最明显。假设你曾经编辑过/etc/ssh/sshd_config,历史里有vim /etc/ssh/sshd_config;同时也连过几十台机器。只敲grep ssh的时候,vim那行会混在结果里,把真正有用的连接记录冲散。加上-w之后,只匹配"ssh"作为一个完整单词出现的行,sshd_config因为sshd后面跟着_不算词边界,就被过滤掉了。

反过来,如果你就是想找所有跟 ssh 相关的东西(包括配置文件编辑、密钥生成、服务重启),那就不加-w,甚至应该用grep -i ssh加上更宽的模式。

-E加多关键词是我个人最推荐的写法。实际工作里你要找的往往不是 ssh 本身,而是"所有跟远程访问相关的操作",这时候ssh|scp|sftp|rsync一起抓,覆盖面比单抓 ssh 强太多,因为很多人传文件用的是rsync -e ssh,那行里 ssh 可能只是参数的一部分。

3.2 排除噪音:让结果只剩你要看的东西

随着历史记录变多,原始输出会变得很难读。三个降噪技巧:

排除自引用。grep -v 'grep'能过滤掉你自己查历史的那条命令:

history | grep ssh | grep -v 'grep'

排除明显的本地操作。如果你只想看真正发起的连接,可以反向排除一些常见误匹配:

history | grep ssh | grep -vE 'vim|cat|less|man|systemctl'

去重并按频次排序。想知道自己最常连哪台机器,用这个:

history | grep -oE 'ssh [^ ]+' | sort | uniq -c | sort -rn | head -20

这里的-o是关键,它让 grep 只输出匹配到的部分而不是整行,配合后面的sort | uniq -c,就能统计出每个目标主机被连了多少次。我在一次梳理"哪些旧机器可以下线"的工作里就是用这个办法,一分钟内列出了半年内所有被连过的目标,按频次排序,零访问的那批直接进下线清单。

提示:这个统计方式对ssh -p 2222 user@host这种带参数的写法会失真,因为[^ ]+只抓到-p。遇到这种情况,模式改成ssh .*(@[^ ]+)再用awk取最后一段更准。

3.3 多会话与多机器的历史怎么归并

单机单会话是最理想的情况。现实是:你有三台跳板机、五个终端、两台本地开发机,历史散落在各处。归并思路分三层。

第一层:同一台机器上的多个终端。靠 2.1 节说的histappend+history -a解决,前提是配置到位。没有配置的话,你可以在查之前先手动合并一次当前会话到文件:history -a,然后cat ~/.bash_history | grep ssh,这样至少能看到文件里已有的全部内容。

第二层:同一台机器上的多个用户。需要 sudo 权限去读别人的历史文件:

sudo grep -h ssh /home/*/.bash_history | sort -u | tail -50

-h参数的作用是不输出文件名前缀,避免每行前面挂一串路径。sort -u去重,tail -50看最近的。这个命令在"这台机器上到底有几个人在操作"的排查里非常有效,因为不同用户的历史文件是分开的,history命令本身看不到别人的。

第三层:跨机器的历史汇总。这个就没法靠一条命令解决了,需要提前做采集,把各机器的历史文件定期收集到一处。最朴素的做法是用rsync定时拉取,稍微正规一点是配置集中式日志采集。第 4 节会讲具体的落地方案。

4. 落地:把它做成随手可用的工具

一条命令敲三次以上就该封装。这一节讲三个层次的落地,从个人用的别名,到容量规划的估算,再到团队级的做法。

4.1 别名与函数封装

最轻量的方案是在~/.bashrc里加别名:

alias hssh='history | grep -iE "ssh|scp|sftp" | grep -v "grep" | tail -30' alias htime='HISTTIMEFORMAT="%F %T " history | tail -50'

tail -30是刻意的限制。不加的话,一个用了两年的账号历史可能有上万条,全刷出来眼睛都花了,而且真正有用的永远是最近的。想全看的时候,去掉 tail 用| less翻页。

但别名有个局限:不能带参数。想要"查任意关键词"的能力,用函数更合适:

hfind() { if [ -z "$1" ]; then echo "用法: hfind <关键词>" return 1 fi history | grep -i -- "$1" | grep -v "hfind" | tail -40 }

这个函数比我见过的很多版本都管用,原因是它加了--分隔符。如果你搜的关键词恰好以-开头(比如查-p 2222),不加--的话 grep 会把它当成自己的选项,直接报错退出。这种细节平时遇不到,遇到一次就够你懵半天。

另外那个grep -v "hfind"也别省。函数名本身会出现在历史里,不加过滤的话,你每查一次就多一条噪音,查十次能堆出十行无用记录。

4.2 容量与轮转:参数怎么算

调大HISTSIZE之前,先算一下成本。历史文件是纯文本,体积很好估。假设一条命令平均 60 个字符(含用户、主机、端口这些),开启时间戳后每条约 75 字节。

每日命令数单日体积保留 90 天保留 365 天
200 条约 15 KB约 1.3 MB约 5.4 MB
1000 条约 75 KB约 6.6 MB约 27 MB
5000 条约 375 KB约 33 MB约 134 MB

结论很明确:对个人账号来说,这个开销可以忽略不计,HISTFILESIZE=50000完全撑得住。真正的成本不在磁盘,在检索效率——grep扫 5 万行是毫秒级的,扫 50 万行也能接受,所以不用太担心性能。

配置推荐如下,直接抄:

export HISTSIZE=10000 export HISTFILESIZE=50000 export HISTCONTROL=ignoreboth export HISTIGNORE='ls:cd:pwd:exit:clear:history' export HISTTIMEFORMAT='%F %T ' shopt -s histappend PROMPT_COMMAND="history -a; $PROMPT_COMMAND"

顺序有讲究:HISTIGNORE里加了history之后,你敲的history命令本身就不再入档,前面说的自引用噪音问题从源头上就消掉了一半。但注意HISTIGNORE匹配的是完整命令的精确模式(用冒号分隔,支持通配符),所以history | grep ssh这种带管道的不会被history这一项匹配掉,还是得靠grep -v兜底。

改完记得source ~/.bashrc,然后在新开的终端里验证——已存在的 shell 不会重新读取配置,这个坑我见过太多次了。

4.3 从个人历史到团队审计

个人用history | grep ssh没问题。但如果要回答"过去 30 天有哪些机器被谁连过"这种团队级问题,靠翻各人主目录就不现实了。这时候需要把历史变成结构化数据。

一个不依赖任何商业组件的做法是:在统一的 shell 配置里挂一个PROMPT_COMMAND钩子,把每条命令连同用户、主机名、时间、TTY 一起写到本地的统一日志文件,再由日志采集统一收走。

log_cmd() { local cmd cmd=$(history 1 | sed 's/^ *[0-9]* *//') [ -z "$cmd" ] && return printf '%s|%s|%s|%s\n' \ "$(date '+%F %T')" "$USER" "$(hostname -s)" "$cmd" \ >> /var/log/cmd-audit/commands.log }

这段钩子的核心是history 1——它取的是历史列表里的最后一条,也就是刚刚执行完的那条。加上时间、用户、主机三列之后,你就有了一个可以用awk、grep随意切分的扁平文本。

格式统一之后,团队级的查询就变成一句话:

awk -F'|' '$4 ~ /ssh/ {print $1, $2, $3, $4}' /var/log/cmd-audit/commands.log | tail -100

比起在几十台机器上分别history | grep ssh,这种方式的价值在于时间线能对齐。故障发生时,你把各机器的命令日志按时间排序,谁先动的手一目了然。

注意:这类记录会包含命令行里的所有参数,包括可能出现的临时凭据、内网地址、业务标识。落盘前务必评估脱敏方案,至少要把明显的敏感字段用正则替换掉,日志目录权限设成0700且只对特定组可读。这件事不能事后补救,必须在设计阶段就定下来。

5. 常见问题与排查速查表

这一节把我在实际环境里见过的问题整理成表,遇到情况直接对号入座,不用从头推。

5.1 现象与原因对照

现象最可能的原因处理方式
明明敲过,grep完全无结果命令在另一个终端,且未配置histappend,退出时被覆盖加shopt -s histappend和history -a
只能看到最近的几十条HISTSIZE太小,或文件被HISTFILESIZE截断两个变量同时调大
有记录但没有时间未设置HISTTIMEFORMAT就产生了这些历史无法追溯,只能从现在开始设
时间列显示得乱七八糟老记录无时间戳,新记录有,混合显示属正常现象,history -c后重新积累或忽略
搜索关键词以-开头时 grep 报错grep 把关键词当成了自己的选项加--分隔符
一条命令被记了两次多终端各自写文件后合并属正常,sort -u去重即可
别人的操作查不到history只读当前用户用 sudo 读对方~/.bash_history
刚执行的命令在文件里搜不到只grep了文件,没grep内存先history -a落盘再查

5.2 几个真踩过的坑

坑一:su之后历史断了。用su切换用户理论上会读新用户的历史文件,但如果你是用su不带-参数,环境变量大部分被继承下来了,包括HISTFILE指向的还是原来的文件。表现就是切过去之后,history看到的还是上一个用户的内容,或者双方混在一起。解决方案是用su -带减号,或者sudo -i,让环境干净重载。

坑二:HISTIGNORE写太宽,把关键记录吃掉了。有人为了清净,把HISTIGNORE设成了*ssh*,本意是忽略那些无聊的重复连接,结果排障时发现所有 ssh 操作都不见了。这个变量是输入时过滤的,被忽略的命令压根不会进历史,事后完全无法找回,比事后删除危险得多。要清净用HISTCONTROL=ignoredups就够了,别动HISTIGNORE。

坑三:history -c清了之后发现清错终端了。history -c只清当前 shell 的内存列表,不动文件。真正的彻底清空需要history -c && history -w,先清内存再写空回文件。这个动作基本不可逆,敲之前想清楚是哪个终端。我用它的时候习惯先cp ~/.bash_history ~/.bash_history.bak留一手。

坑四:zsh 环境照抄 bash 配置无效。zsh 的历史配置文件是~/.zsh_history,格式是: <时间戳>:<耗时>;<命令>这种扩展形式,HISTSIZE对应的是SAVEHIST和HISTSIZE两个不同变量,HISTTIMEFORMAT压根不存在(zsh 默认就记录时间)。所以history | grep ssh在 zsh 里能跑,但配置项要全部换一套。

6. 历史命令里藏着的信息面

聊到这儿该说点稍微严肃的东西。~/.bash_history这个文件在便利性和风险性上是完全对称的——它记录得越全,泄露面就越大。一个简单的判断标准:如果你不愿意让运维同事看到某条命令,那它就不该以明文躺在历史文件里。

6.1 哪些东西容易无意间留下痕迹

最常见的几类:直接写在命令行里的账号凭据(mysql -u root -pXXXX这种写法,密码会完整进历史);带 token 的接口调用(curl -H "Authorization: Bearer xxx");内网拓扑信息(一堆ssh目标地址,拼起来就是内网地图);临时文件路径里带业务名称的(能反推出业务结构)。

这类痕迹的危险在于它不像配置文件那样有明确的权限边界。配置文件你至少知道要chmod 600,历史文件很多人压根没管过,默认644,同机器的其他用户cat一下就全看见了。

处理方式分两头。能改习惯的改习惯——密码走交互式提示或密钥认证,token 走环境变量或配置文件。改不了习惯的加一道保护:

chmod 600 ~/.bash_history

更彻底一点,在交互式命令前加个空格(配合HISTCONTROL=ignorespace),这条命令完全不进历史。这个小技巧我常用,成本低到可以随手做。

6.2 团队里怎么定这条线

个人习惯之外,团队需要一条明确的规则线,不然每个人的标准都不一样。我见过的比较务实的做法是三条:

第一,收集范围写清楚。明确告知哪些机器的命令历史会被集中采集、保留多久、谁能看。不写清楚就采集,后面一旦出现争议,很难解释。

第二,保留期和业务对齐。财务相关的机器保留期长一些,临时测试机 7 天就够。一刀切定 365 天既浪费存储又放大风险。

第三,查询要留痕。谁在什么时候查了别人的历史,本身就是一条该被记录的记录。这个要求听起来有点重,但真正做起来就是一个包装脚本的事,比事后追责容易得多。

从history | grep ssh这么轻的东西,一路聊到团队规范确实有点远。但实际干过几年就会发现,工具本身从来不复杂,复杂的是人和人之间的边界。


我在这个命令上花的调试时间,差不多可以换算成好几个通宵了。最开始是为了找自己三天前连过的一台机器,后来变成帮同事定位操作记录,再后来是给团队设计采集方案。走过的路里最值钱的经验就一条:别在事后才想起history。等你真正需要它的时候,能查到什么完全取决于你半年前怎么配置的那几行~/.bashrc。所以如果这台机器你还打算用一年以上,现在就花五分钟把那几个HIST变量和histappend加上,比什么都实在。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进