精通-Bash-全-

精通 Bash(全)

原文:annas-archive.org/md5/e0c2fd28e94b45417eb419297b4224b2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Bash 是一个常见的日常任务工具,几乎每个 Linux 用户都依赖它。无论你想做什么,都需要登录到 shell,大多数时候,使用的就是 Bash。本书旨在解释如何使用这个工具,充分发挥它的作用,无论是编写插件、网络客户端,还是简单地解释为什么双点表示它的意思,我们都将比平时更深入探讨,帮助你完全掌握 shell。从基础开始,但换个角度看待,我们将一步一步向上攀升,专注于我们环境中的编程部分,探索如何防止在设置重复任务时出现问题,并确保一切顺利运行。做到一次,花点时间,调试、改进,然后“一劳永逸”;正如老一套 Linux 格言所说,“如果它能工作,为什么要改变?”既然我们谈到了格言,那么我们还可以遵循其他两个基石:“KISS:保持简单,傻瓜” 和 “只做一件事,但做得好。” 这三个原则是 Linux 的核心:做某件事,而不是所有事,做到简单、可靠,并花时间让它运行得好,这样你就不需要频繁修改它。当某件事专注且简单时,它就容易理解,易于维护,且安全。这就是我们的理念,因为 Bash 不仅是一个工具,还是我们花费大量时间的环境,所以理解它、充分利用它、保持一切干净整洁应该是我们的日常目标。

本书内容简介

第一章,让我们开始编程,是我们首次接触 Bash 的魔力。我们将使用基本的 shell 编程语法编写简单代码,预见到更高级脚本带来的所有好处。

第二章,操作符,是在这一章中我们执行一些简单的操作,例如检查某个数值是否大于、等于或小于另一个数值,以及如何加、减、处理数字。这是对脚本中处理事件时施加条件的第一步。

第三章,测试,解释了检查某些内容是否符合边界和特定条件是否满足的基本原则,这对于让我们的脚本能够根据系统或其他程序的实时指示做出反应并决定该做什么至关重要。

第四章,引用与转义,告诉你 shell 有自己的保留字,这些字在不完全了解它们的作用时不能使用。此外,变量保存的值必须在操作过程中保持不变。在这一章中,我们将学会如何小心地处理我们要编写的内容。

第五章,菜单、数组与函数,探讨了如何让脚本与用户进行交互,例如,给用户机会回答一些问题并处理突出显示的选项。这涉及到为程序本身创建命令行界面的能力,以及如何存储数据,使其容易提取。这正是数组的用途所在

第六章,迭代,解释了迭代在遍历数据、根据某些条件提取和处理数据时的重要性。例如,当数据存在时,或者我们使用某些值作为计数器时。我们将学习如何使用 while 和 for 循环。

第七章,连接到真实世界,介绍了最著名的开源监控系统之一,Nagios,它完全依赖于插件。你可以用任何语言编写复杂的程序,对你的站点和应用程序进行所需的检查。但我使用过的一些最棘手的插件是用 Bash 编写的,除此之外没有任何其他工具。

第八章,我们想聊一聊,是关于 Slack 的,Slack 目前是最广泛使用的消息系统之一。为什么不编写一小段代码,通过 Slack 渠道发送我们的想法,也许可以做成一个通讯插件,使其他脚本能够通过该消息系统发送消息呢?

第九章,子 shell、信号与作业控制,讨论了有时单个进程不足以满足需求的情况。我们的脚本必须同时做很多事,使用一种原始的并行处理方式来达到预期的结果。那么,是时候看看我们如何在 shell 中创建子进程,如何控制作业,以及如何发送信号了。

第十章,让我们创建一个进程聊天,探讨了进程之间如何相互交流,互相传递数据并共享数据处理的负担。管道、重定向、进程替换和一些 netcat——这可能会打开新的场景,我们将看看如何实现。

第十一章,作为守护进程存在,解释了有时仅仅将脚本发送到后台是不够的。它不能长时间存活,但你可以使用一些技巧,如双重派生、setsid 和 disown 来让它变得有点狡猾,并在进程终止之前存活下来。将它做成守护进程,让它等待你的命令。

第十二章,通过 SSH 进行远程连接,介绍了脚本如何可以在本地运行,但它们也可以为你做更多的事。脚本可以通过安全通道远程登录,并代表你发出命令,无需你输入进一步的指令。一切都存储在一个密钥中,解锁了大量的新可能性。

第十三章,该为定时器设定时间了,讨论了如何完全自动化常规任务。我们必须有一种方法来根据某些条件运行脚本。最常见的基于时间,例如每小时、每天、每周或每月的重复。只需想象一个简单的日志轮换,它在特定条件下触发,最常见的是在每天的安排中。

第十四章,安全时刻,解释了为什么安全在工作环境中至关重要。脚本通常意味着访问远程服务器并与之交互,因此,学习一些技巧来提高服务器的安全性,将帮助你防止入侵,确保你的工作免受外界的干扰。

本书所需内容

本书假设读者具有较高水平的 Linux 操作系统经验,并且对 Bash shell 有一定的中级知识,同时,由于某些章节涉及 Nagios 监控和 Slack 消息传递,因此需要具备基本的网络概念。

需要一个简单的 Linux 安装,规格要求非常低,因为即使是 Nagios 插件也可以在无需实际安装监控系统的情况下进行测试。所以,这是最低的配置要求:

  • CPU:单核

  • 内存:2 GB

  • 硬盘空间:20 GB

本书需要以下软件:

  • Linux 操作系统:Debian 8

  • Nagios Core 3.5.1

  • OpenSSH 6.7p1

  • rssh 2.3.4

需要互联网连接以安装必要的服务包,并尝试一些示例。

本书适合的读者

本书面向从事复杂日常任务的高级用户。书中从基础开始,旨在作为一本参考手册,帮助读者找到便捷的解决方案和建议,使他们的脚本更加灵活和强大。

约定

在本书中,你将看到许多不同的文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名如下所示:“这里有趣的是,real的值在两个命令之间略有不同。”

一块代码区块如下所示:

#!/bin/bash 
set -x
echo "The total disk allocation for this system is: "
echo -e "\n" 
df -h 
echo -e "\n" 
set +x 
df -h | grep /dm-0 | awk '{print "Space left on root partition: " $4}'

任何命令行输入或输出如下所示:

gzarrelli:~$ time echo $0
/bin/bash
real 0m0.000s
user 0m0.000s
sys 0m0.000s gzarrelli:~$

新术语重要词汇以粗体显示。

警告或重要说明会以框的形式显示,如下所示。

提示和技巧如下所示。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们非常重要,它帮助我们开发出你真正能受益的书籍。

若要向我们提供一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。

如果你在某个领域具有专业知识,并且有兴趣撰写或参与编写书籍,请参考我们的作者指南:www.packtpub.com/authors

客户支持

现在,你是一本 Packt 书籍的骄傲拥有者,我们为你提供了许多资源,帮助你充分利用这次购买。

下载示例代码

你可以从你的账户中下载本书的示例代码文件,网址为www.packtpub.com。如果你是在其他地方购买的本书,可以访问www.packtpub.com/support,注册后将示例文件通过电子邮件直接发送给你。

你可以按照以下步骤下载代码文件:

  1. 使用你的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  3. 点击代码下载和勘误。

  4. 在搜索框中输入书名。

  5. 选择你想下载代码文件的书籍。

  6. 从下拉菜单中选择你购买此书的地方。

  7. 点击代码下载。

文件下载完成后,请确保使用最新版本的工具解压或提取文件夹:

  • WinRAR / 7-Zip 适用于 Windows

  • Zipeg / iZip / UnRarX 适用于 Mac

  • 7-Zip / PeaZip 适用于 Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Bash。我们还提供了其他书籍和视频的代码包,您可以在github.com/PacktPublishing/找到。快去看看吧!

下载本书的彩色图像

我们还为你提供了一个 PDF 文件,包含本书中使用的截图/图表的彩色图像。这些彩色图像将帮助你更好地理解输出结果的变化。你可以从www.packtpub.com/sites/default/files/downloads/MasteringBash_ColorImages.pdf下载此文件。

勘误

尽管我们已尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书籍中发现错误——可能是文本或代码中的错误——我们将非常感激您向我们报告。通过这样做,您可以帮助其他读者避免困扰,并帮助我们改进后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata进行报告,选择您的书籍,点击勘误提交表格链接,并填写勘误详情。一旦您的勘误经过验证,您的提交将被接受,勘误将上传到我们的网站,或添加到该书籍的勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将显示在勘误部分。

盗版

互联网上的版权盗版问题在所有媒体中普遍存在。我们在 Packt 公司非常重视保护我们的版权和许可证。如果您在网上发现任何形式的非法复制我们的作品,请立即提供相关地址或网站名称,以便我们采取相应的措施。

请通过copyright@packtpub.com联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们的作者及我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有疑问,可以通过questions@packtpub.com联系我们,我们将尽力解决问题。

第一章:开始编程吧

精通 Bash 就是掌握如何利用你的环境,最大化其潜力。这不仅仅是处理可以自动化的枯燥日常任务。它是将你的工作空间打造得更加高效,以实现你的目标。因此,尽管 Bash 脚本的表达能力不如 Python 或 JavaScript 等其他复杂语言,但它足够简单,可以在短时间内掌握,而且灵活到足以满足你大多数日常任务的需求,甚至是最棘手的任务。

但 Bash 就这么简单易用吗?让我们看看在 Bash 中的第一行代码。我们从简单的开始:

gzarrelli:~$ time echo $0
/bin/bash
real 0m0.000s
user 0m0.000s
sys 0m0.000s
gzarrelli:~$ 

现在,让我们以稍微不同的方式再做一次:

gzarrelli:~$ time /bin/echo $0/bin/bash
real 0m0.001s
user 0m0.000s
sys 0m0.000s  

这里有趣的是,real 的值在两个命令之间稍有不同。好的,但为什么呢?让我们通过以下命令深入探讨:

gzarrelli:~$ type echo
echo is a shell builtin
gzarrelli:~$ type /bin/echo
/bin/echo is /bin/echo  

有趣的是,第一个似乎是一个 shell builtin,第二个只是一个系统程序,一个外部工具,差异就出在这里。builtin 是内置于 shell 中的命令,而系统程序则由 shell 调用。内置命令与外部命令正好相反。

要理解导致如此不同的执行时机的内外部 shell 命令之间的区别,我们必须了解外部程序是如何被 shell 调用的。当外部程序要执行时,Bash 会创建一个它自己的副本,使用父 shell 相同的环境,并产生一个具有不同进程 ID 的新进程。可以说,我们刚刚看到了如何进行分叉。在新的地址空间中,调用系统的 exec 来加载新进程数据。

对于 builtin 命令,情况有所不同,Bash 在不进行任何分叉的情况下执行它们,这会带来以下几个有趣的结果:

  • builtin 执行更快,因为没有副本,也没有执行文件被调用。值得注意的是,这一优势在短时间运行的程序中更为明显,因为开销是在任何可执行文件被调用之前:一旦外部程序被调用,builtin 命令和程序之间的纯执行时间差异可以忽略不计。

  • 作为 Bash 的内置命令,builtin 命令可以影响其内部状态,而外部程序无法做到这一点。让我们考虑一个经典的例子,使用 builtincd。如果 cd 是一个外部程序,一旦从 shell 中调用,如下所示:

cd /this_dir  

  • 第一个操作是我们的 shell 为 cd 分叉一个进程,后者会改变它自己的进程中的当前目录,而不是我们所在的那个进程,该进程是为了启动 cd 进程而被分叉的。父 shell 不会受到影响。因此,我们将无法前往任何地方。

想知道有哪些 builtin 可用吗?你有几个选项,可以执行以下 builtin

compgen -b  

或者这个其他的 builtin 命令:

enable -a | awk '{ print $2 }'  

为了更好地理解为何builtin和外部程序的执行有差异,我们需要看看当我们调用命令时发生了什么。

  • 首先,记住 Shell 是从左到右工作的,它会处理所有的变量赋值和重定向,并将它们保存起来以供后续处理。

  • 如果没有其他内容,Shell 将把命令行中的第一个单词作为命令名称,而其余部分将被视为参数。

  • 下一步是处理所需的输入和输出重定向。

  • 最后,在赋值给变量之前,所有在=符号后的文本都将进行波浪线扩展、参数扩展、命令替换、算术扩展和引号移除。

  • 如果最后的操作没有产生命令名称,变量就可以影响环境。如果赋值失败,则会引发错误,并且调用的命令会以非零状态退出。

  • 如果操作结果中没有命令名称,所有重定向将被应用,但与变量不同,它们不会影响当前环境。同样,如果发生任何错误,返回的退出状态为非零。

一旦前面的操作完成,命令就会被执行,并根据是否有一个或多个扩展包含命令替换来决定退出状态。总体退出状态将是最后一个命令替换的状态,如果没有执行命令替换,退出状态将为零。

此时,我们最终只剩下一个命令名称和一些可选的参数。此时,builtins和外部程序的路径开始分歧。

  • 一开始,Shell 查看命令名称,如果没有斜杠,它会搜索命令的路径。

  • 如果没有斜杠,Shell 会尝试查找是否存在一个与该名称相同的函数并执行它。

  • 如果没有找到函数,Shell 会尝试执行builtin,如果有该名称的builtin,它会被执行。

好的,现在如果有任何builtin,它已经被调用。那么外部程序呢?

  • 如果在命令行上找不到名为builtins的内容,我们的 Bash 将继续执行,并且有三次机会:

    • 执行命令的完整路径已经包含在其内部哈希表中,哈希表是用来加速查找的结构。

    • 如果完整路径不在哈希表中,Shell 会在环境变量PATH的内容中查找,如果找到,它会被添加到哈希表中。

    • 如果PATH变量中没有完整路径,Shell 会返回退出状态 127。

哈希甚至可以像这样被调用:

gzarrelli:~$ hash
hits command
1  /usr/bin/which
1  /usr/bin/ld
24  /bin/sh
1  /bin/ps
1  /usr/bin/who
1  /usr/bin/man
1  /bin/ls
1  /usr/bin/top  

第二列将告诉你,不仅哪些命令已经被哈希,还会告诉你每个命令在当前会话中执行的次数(命中次数)。

假设搜索找到了我们想要执行的命令的完整路径;现在我们有了完整路径,情况就像 Bash 在命令名称中发现一个或多个斜杠一样。在这两种情况下,shell 都认为它有一个有效的路径来调用命令,并在一个分叉的环境中执行该命令。

这是我们运气好的时候,但也可能发生调用的文件不是可执行文件的情况,这时,由于我们的路径指向的是一个目录而非文件,Bash 会做出一个合理的猜测,认为需要运行一个 shell 脚本。在这种情况下,脚本会在一个子 shell 中执行,而这个子 shell 是一个全新的环境,它继承了父 shell 的哈希表内容。

在做任何其他事情之前,shell 会查看脚本的第一行,寻找一个可选的 sha-bang(我们稍后会看到这是什么)- 在 sha-bang 后面是用于管理脚本的解释器路径和一些可选参数。

此时,只有在这个时刻,如果你的外部命令是脚本,它才会被执行。如果它是可执行文件,它会在稍早一些时候被调用,但仍然在任何builtin命令之后执行。

在前面的段落中,我们已经看到了一些应该对你来说熟悉的命令和概念。本章的接下来的段落将快速讲解一些 Bash 的基本元素,如变量、扩展和重定向。如果你已经熟悉它们,那么在工作脚本时,你可以将接下来的页面作为参考。如果相反,你对这些概念不太熟悉,那么请继续阅读接下来的内容,因为你所阅读的所有内容将对理解你可以在 shell 中做什么以及如何操作非常重要。

输入输出重定向

正如我们在前面的页面所看到的,重定向是 Bash 在解析并准备执行命令的命令行时所执行的最后操作之一。那么什么是重定向呢?你可以从日常经验中轻松猜到。它意味着将一个流从一个点引导到另一个点,然后让它去别的地方,就像改变一条河流的流向让它流向别的地方一样。在 Linux 和 Unix 中,情况也差不多,只需要记住以下两个原则:

  • 在 Unix 中,除了守护进程外,每个进程都应该连接到标准输入、标准输出和标准错误设备

  • Unix 中的每个设备都通过一个文件来表示

你也可以将这些设备视为流:

  • 标准输入,名为 stdin,是进程接收输入数据的流

  • 标准输出,名为 stdout,是进程写入其输出数据的外发流

  • 标准错误,名为 stderr,是进程写入其错误信息的流

这些流也由标准 POSIX 文件描述符来标识,文件描述符是一个整数,内核用它作为处理程序来引用这些流,如下表所示:

设备 模式 文件描述符
stdin 0
stdout 1
stderr 写入 2

所以,操作文件描述符来处理三种主要流意味着我们可以在stdinstdout之间重定向流,也可以在stderr之间重定向流,甚至可以让一个进程与另一个进程进行通信,这实际上是一种进程间通信(IPC)的形式,我们将在本书后续章节更详细地讨论这一点。

我们如何将输入/输出I/O)从一个进程重定向到另一个进程?我们可以利用一些特殊字符来实现这一目标:

>  

我们先声明,进程的默认输出通常是stdout。无论它返回什么,都会返回到stdout,而stdout通常是显示器或终端。通过使用>字符,我们可以将这个流重定向到文件。如果文件不存在,它将被创建,如果文件已存在,它将被覆盖并用进程的输出流替换原有内容。

一个简单的例子可以澄清如何将输出重定向到文件:

gzarrelli:~$ echo "This is some content"
This is some content  

我们使用了命令echo来打印信息到stdout,因此我们可以看到信息被写入到通常与 Shell 连接的文本终端中:

gzarrelli:~$ ls -lah
total 0
drwxr-xr-x   2 zarrelli  gzarrelli    68B 20 Jan 07:43 .
drwxr-xr-x+ 47 zarrelli  gzarrelli   1.6K 20 Jan 07:43 ..  

文件系统中没有任何内容,所以输出直接显示在终端上,但底层目录没有受到影响。现在,是时候进行重定向了:

gzarrelli:~$ echo "This is some content" > output_file.txt  

好吧,屏幕上没有显示任何内容;没有任何输出:

gzarrelli:~$ ls -lah
total 8
drwxr-xr-x   3 gzarrelli  gzarrelli   102B 20 Jan 07:44 .
drwxr-xr-x+ 47 gzarrelli  gzarrelli   1.6K 20 Jan 07:43 ..
-rw-r--r--   1 gzarrelli  gzarrelli    21B 20 Jan 07:44 
output_file.txt  

事实上,正如你所看到的,输出没有消失;它只是被重定向到当前目录下的一个新文件中,并被创建和填充:

gzarrelli:~$ cat output_file.txt
This is some content  

这里有些有趣的内容。cat命令获取output_file.txt的内容并将其发送到stdout。我们可以看到的是,前一个命令的输出被重定向到终端并写入到文件中。

>>  

这个双重标记解决了我们经常面临的一个问题:我们如何在不覆盖任何内容的情况下,将更多来自进程的内容添加到文件中? 使用这个双重字符,表示文件尚不存在时会创建一个新文件;如果文件已经存在,则直接将新数据追加到文件末尾。我们来看一下之前的文件并向其添加一些内容:

gzarrelli:~$ echo "This is some other content" >> output_file.txt
gzarrelli:~$ cat output_file.txt
This is some content
This is some other content  

太好了,文件没有被覆盖,echo命令的新内容被添加到了旧内容中。现在,我们知道如何写入文件,那么从stdin以外的其他地方读取数据呢?

<  

如果文本终端是stdin,键盘则是进程的标准输入,进程从中获取数据。我们同样可以重定向数据流或读取流,并让进程从文件中读取数据。以我们的例子为例,我们首先创建一个包含一组无序数字的文件:

gzarrelli:~$ echo -e '5\n9\n4\n1\n0\n6\n2' > to_sort  

然后我们验证它的内容,方法如下:

gzarrelli:~$ cat to_sort
5
9
4
1
0
6
2  

现在我们可以让sort命令将这个文件读取到它的stdin中,方法如下:

gzarrelli:~$ sort < to_sort
0
1
2
4
5
6
9  

不错,我们的数字现在已经按顺序排列,但我们可以做些更有趣的事情:

gzarrelli:~$ sort < to_sort > sorted  

我们做了什么?我们只是将文件to_sort传递给sort命令的标准输入,同时连接了第二个重定向,使得sort的输出被写入文件sorted

gzarrelli:~$ cat sorted
0
1
2
4
5
6
9   

因此,我们可以连接多个重定向并获得一些有趣的结果,但我们还可以做一些更复杂的事情,即将输入和输出链式连接,操作的对象不再是文件,而是进程,正如我们接下来所看到的。

|  

管道字符正如其名称所示,管道将一个进程的流(可能是stdoutstderr)传递给另一个进程,创建一个简单的进程间通信机制:

gzarrelli:~$ ps aux | awk '{print $2, $3, $4}' | grep -v [A-Z] | sort -r -k 2 
-g | head -n 3
95 0.0 0.0
94 0.0 0.0
93 0.0 0.0  

在这个示例中,我们玩得很开心,首先获取了一个进程列表,然后将输出通过管道传给awk工具,awk只打印了第一个、第十一和第十二列,分别是进程 ID、CPU 百分比和内存百分比。然后,我们去掉了标题PID %CPU %MEM,将awk的输出通过管道传给grepgrep对所有包含字符而非数字的字符串进行了反向模式匹配。接下来,我们将输出传给了sort命令,按第二列的值对数据进行了倒序排序。最后,我们只想要前三行,于是我们得到了依赖于 CPU 占用的前三个最重进程的PID

重定向也可以用来做一些有趣或有用的事情,正如你在下面的截图中所看到的:

如你所见,机器上有两个用户在不同的终端上,记住每个用户都必须连接到一个终端。要能够向任何用户的终端写入内容,你必须是 root 用户,或者像本例中一样,是同一用户在两个不同的终端上。通过who命令,我们可以识别出用户连接到的终端(ttys),也就是从中读取,然后我们只需将echo命令的输出重定向到他的终端。因为他的会话已连接到该终端,所以他会读取我们发送到其终端设备stdin的数据(因此是/dev/ttysxxx)。

Unix 中的一切都通过文件表示,无论是设备、终端,还是我们需要访问的任何内容。我们还有一些特殊的文件,比如/dev/null,它是一个“黑洞”——你发送到它的任何东西都会丢失:

gzarrelli:~$ echo "Hello" > /dev/null
gzarrelli:~$  

另外,看看以下示例:

root:~$ ls
output_file.txtsortedto_sort
root:~$ mv output_file.txt /dev/null
root:~$ ls
to_sort  

很好,有足够的内容来玩耍,但这只是个开始。还有很多工作要做,涉及文件描述符。

玩弄 stdin、stdout 和 stderr

好吧,如果我们稍微调整一下文件描述符和特殊字符,我们可以得到一些不错的、真的很不错的结果;让我们看看能做些什么。

  • x < filename:这会以读取模式打开一个文件,并为名为a的描述符分配一个值,该值在39之间。我们可以选择任何名字,通过这个名字,我们可以轻松地通过stdin访问文件内容。

  • 1 > 文件名:这会将标准输出重定向到文件名。如果文件不存在,它会被创建;如果文件已存在,原有数据将被覆盖。

  • 1 >> 文件名:这会将标准输出重定向到文件名。如果文件不存在,它会被创建;如果文件已存在,内容将被追加到原有数据之后。

  • 2 > 文件名:这会将标准错误重定向到文件名。如果文件不存在,它会被创建;如果文件已存在,原有数据将被覆盖。

  • 2 >> 文件名:这会将标准错误重定向到文件名。如果文件不存在,它会被创建;如果文件已存在,内容将被追加到原有数据之后。

  • &> 文件名:这会将 stdoutstderr 都重定向到文件名。如果文件不存在,它会被创建;如果文件已存在,则会覆盖原有数据。

  • 2>&1:这会将 stderr 重定向到 stdout。如果你在程序中使用此命令,它的错误信息将被重定向到 stdout,即通常的显示器上。

  • y>&x:这会将描述符 y 的文件重定向到描述符 x,这样描述符 y 指向的文件的输出就会重定向到描述符 x 指向的文件。

  • >&x:这会将与 stdout 关联的文件描述符 1 重定向到描述符 x 指向的文件中,这样任何输出到标准输出的内容都将写入描述符 x 指向的文件。

  • x<> 文件名:这会以读写模式打开文件,并将描述符 x 分配给它。如果文件不存在,则会创建该文件;如果没有指定描述符,则默认为 0,即 stdin

  • x<&-:这会关闭以读模式打开并与描述符 x 关联的文件。

  • 0<&- 或 <&-:这会关闭以读模式打开并与描述符 0(即 stdin)关联的文件,然后关闭该文件。

  • x>&-:这会关闭以写模式打开并与描述符 x 关联的文件。

  • 1>&- 或 >&-:这会关闭以写模式打开并与描述符 1(即 stdout)关联的文件,然后关闭该文件。

如果你想查看哪些文件描述符与进程相关,你可以探索 /proc 目录,并指向以下路径:

/proc/pid/fd  

在该路径下,替换 PID 为你想要探索的进程的 ID;你将找到与该进程相关的所有文件描述符,如以下示例所示:

gzarrelli:~$ ls -lah /proc/15820/fd
total 0
dr-x------ 2 postgres postgres  0 Jan 20 17:59 .
dr-xr-xr-x 9 postgres postgres  0 Jan 20 09:59 ..
lr-x------ 1 postgres postgres 64 Jan 20 17:59 0 -> /dev/null 
(deleted)
l-wx------ 1 postgres postgres 64 Jan 20 17:59 1 -> /var/log/postgresql/postgresql-9.4-main.log
lrwx------ 1 postgres postgres 64 Jan 20 17:59 10 -> /var/lib/postgresql/9.4/main/base/16385/16587
lrwx------ 1 postgres postgres 64 Jan 20 17:59 11 -> socket:[13135]
lrwx------ 1 postgres postgres 64 Jan 20 17:59 12 -> socket:[1502010]
lrwx------ 1 postgres postgres 64 Jan 20 17:59 13 -> /var/lib/postgresql/9.4/main/base/16385/16591
lrwx------ 1 postgres postgres 64 Jan 20 17:59 14 -> /var/lib/postgresql/9.4/main/base/16385/16593
lrwx------ 1 postgres postgres 64 Jan 20 17:59 15 -> /var/lib/postgresql/9.4/main/base/16385/16634
lrwx------ 1 postgres postgres 64 Jan 20 17:59 16 -> /var/lib/postgresql/9.4/main/base/16385/16399
lrwx------ 1 postgres postgres 64 Jan 20 17:59 17 -> /var/lib/postgresql/9.4/main/base/16385/16406
lrwx------ 1 postgres postgres 64 Jan 20 17:59 18 -> /var/lib/postgresql/9.4/main/base/16385/16408
l-wx------ 1 postgres postgres 64 Jan 20 17:59 2 -> /var/log/postgresql/postgresql-9.4-main.log
lr-x------ 1 postgres postgres 64 Jan 20 17:59 3 -> /dev/urandom
l-wx------ 1 postgres postgres 64 Jan 20 17:59 4 -> /dev/null 
(deleted)
l-wx------ 1 postgres postgres 64 Jan 20 17:59 5 -> /dev/null 
(deleted)
lr-x------ 1 postgres postgres 64 Jan 20 17:59 6 -> pipe:[1502013]
l-wx------ 1 postgres postgres 64 Jan 20 17:59 7 -> pipe:[1502013]
lrwx------ 1 postgres postgres 64 Jan 20 17:59 8 -> /var/lib/postgresql/9.4/main/base/16385/11943
lr-x------ 1 postgres postgres 64 Jan 20 17:59 9 -> pipe:[13125]

不错,是吧?那么,让我们做一些绝对有趣的事情:

首先,让我们以读写模式打开一个虚拟机的网络服务器的套接字,并将描述符 9 分配给它:

gzarrelli:~$ exec 9<> /dev/tcp/172.16.210.128/80 || exit 1  

然后,让我们往它写点东西;没有什么复杂的:

gzarrelli:~$ printf 'GET /index2.html HTTP/1.1\nHost: 172.16.210.128\nConnection: close\n\n' >&9

我们只是请求了一个为此示例创建的简单 HTML 文件。

现在,让我们读取文件描述符 9

gzarrelli:~$ cat <&9
HTTP/1.1 200 OK
Date: Sat, 21 Jan 2017 17:57:33 GMT
Server: Apache/2.4.10 (Debian)
Last-Modified: Sat, 21 Jan 2017 17:57:12 GMT
ETag: "f3-5469e7ef9e35f"
Accept-Ranges: bytes
Content-Length: 243
Vary: Accept-Encoding
Connection: close
Content-Type: text/html
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
 "http://www.w3.org/TR/html4/strict.dtd">
<HTML>
 <HEAD>
 <TITLE>This is a test file</TITLE>
 </HEAD>
 <BODY>
 <P>And we grabbed it through our descriptor!
 </BODY>
</HTML>  

就是这样!我们通过套接字将文件描述符连接到远程服务器,我们可以写入并读取响应,同时将数据流通过网络进行重定向。

仅仅处理命令行,我们到目前为止已经做了很多,但是如果我们想更进一步,就必须看看如何将所有这些命令编写成脚本,并充分利用它们。是时候写我们的第一个脚本了!

是时候了解解释器了:sha-bang

当任务变得更复杂时,单纯的命令行连接可能不足以完成我们要完成的任务。单行中的太多内容显得杂乱无章,缺乏清晰性,因此最好将我们的命令或 builtins 存储在文件中,并让它执行。

当脚本执行时,系统加载程序解析第一行,寻找被称为 sha-bang 或 shebang 的字符序列。

#!  

这将强制加载程序将后续字符视为解释器的路径及其可选参数,用于进一步解析脚本,脚本将作为另一个参数传递给解释器。因此,最终,解释器将解析脚本,这次我们将忽略 sha-bang,因为它的第一个字符是井号,通常表示脚本中的注释,而注释是不会被执行的。更进一步说,sha-bang 是我们称之为 2 位魔术数字的东西,它是一个常数数字或文本值序列,在 Unix 中用于标识文件或协议类型。所以,0x23 0x21 实际上是 #! 的 ASCII 表示。

那么,让我们做个小实验,创建一个简单的一行脚本:

gzarrelli:~$ echo "echo \"This should go under the sha-bang\"" > test.sh  

只有一行。我们来看看:

gzarrelli:~$ cat test.sh 
echo "This should go under the sha-bang"  

很好,一切都如我们预期的那样。Linux 对我们的脚本有什么看法吗?让我们问问看:

gzarrelli:~$ file test.sh 
test.sh: ASCII text  

好吧,文件工具说它是一个普通文件,事实上它确实是一个简单的文本文件。是时候来个小把戏:

gzarrelli:~$ sed -i '1s/^/#!\/bin\/sh\n/' test.sh  

没什么特别的;我们只是添加了一个指向 /bin/shsha-bang

gzarrelli:~$ cat test.sh 
#!/bin/sh
echo "This should go under the sha-bang"  

正如预期的那样,sha-bang 出现在我们文件的开头:

gzarrelli:~$ file test.sh 
test.sh: POSIX shell script, ASCII text executable  

不敢相信,现在它变成了一个脚本!文件工具进行三种不同的测试来识别它正在处理的文件类型。依次是:文件系统测试、魔术数字测试和语言测试。在我们的案例中,它识别出了表示 sha-bang 的魔术数字,从而确定这是一个脚本,结果就是这样告诉我们的:它是一个脚本。

接下来,几个最终的备注,之后我们将继续前进。

  • 如果你的脚本不使用 Shell builtins 或 Shell 内部命令,可以省略 sha-bang

    • 注意 /bin/sh,并不是所有看起来像无害可执行文件的东西都真的是它看起来的样子:
gzarrelli:~$ ls -lah /bin/sh
lrwxrwxrwx 1 root root 4 Nov  8  2014 /bin/sh -> dash  

在某些系统中,/bin/sh 是指向不同类型解释器的符号链接,如果你使用的是 Bash 的一些内部命令或 builtins,你的脚本可能会产生不想要的或意外的结果。

调用你的脚本

好的,我们有了一个两行的脚本;是时候看看它是否真的按我们想要的方式执行了:

gzarrelli:~$ ./test.sh
-bash: ./test.sh: Permission denied  

不行!它没有执行,从错误信息来看,似乎与文件权限有关:

gzarrelli:~$ ls -lah test.sh 
-rw-r--r-- 1 gzarrelli gzarrelli 41 Jan 21 18:56 test.sh  

有趣。让我们回顾一下文件权限是什么。正如你所看到的,描述文件属性的那一行以一系列字母和符号开头。

类型 用户 其他
- rw- r-- r--

对于文件类型,我们可以有两个主要值,d - 实际上是一个目录,或 -,表示这是一个常规文件。接着,我们可以看到为文件所有者、所属组和其他所有用户设置的权限。如你所猜,r 代表读取权限;w 代表写入权限;x 代表执行权限;- 表示没有权限。这些权限是按顺序排列的,先是 r,然后是 w,再是 x。所以,无论你看到 - 替代 rwx,就意味着该权限没有被授予。

同样的道理适用于目录权限,只是 x 代表你可以遍历该目录;r 代表你可以列出目录内容;w 代表你可以修改目录的属性并删除其中的条目。

指示符 文件类型
- 常规文件
b 块文件(磁盘或分区)
c 字符文件,例如 /dev 下的终端
d 目录
l 符号链接
p 命名管道 (FIFO)
s 套接字

所以,回到我们的文件,我们没有看到设置任何执行位。为什么?这里,shell builtin 可以帮助我们:

gzarrelli:~$ umask
0022  

这对你有意义吗?嗯,一旦我们看到如何用数字形式表示文件的权限,它就应该有意义了。把权限看作是与文件相关的元数据的位,每个权限对应一位;没有权限时是0

r-- = 100
-w- = 010
--x = 001  

现在,让我们从二进制转换为十进制:

权限 二进制 十进制
r 100 4
w 010 2
x 001 1

现在,只需将这些十进制值结合起来,得到最终的权限,但记住,你需要按三元组计算读、写和执行权限——一组用于文件所有者,一组用于所属组,另一组用于其他人。

回到我们的文件,我们可以通过几种方式改变其权限。假设我们希望文件对用户可读、可写、可执行;对组可读、可写;对其他人仅可读。我们可以使用 chmod 命令来实现这个目标:

chmod u+rwx filename
chmod g+wfilename  

所以,+- 用来添加或删除文件或目录的权限,ugo 用来定义我们指的是哪三组属性。

但我们可以使用数字值加快速度:

User - rwx: 4+2+1 =7
Group - rw: 4+2 = 6
Other - r = 4  

所以,下面的命令应该能一行完成这项工作:

chmod  764 test.sh  

是时候验证一下了:

gzarrelli:~$ ls -lah test.sh 
-rwxrw-r-- 1 gzarrelli gzarrelli 41 Jan 21 18:56 test.sh  

我们到了。现在,我们只需要看看我们的用户是否能执行文件,因为授予的权限表明了这一点:

gzarrelli:~$ ./test.sh  

这应该放在 sha-bang 下面。

很好,它工作了。虽然脚本并不复杂,但它满足了我们的目的。不过,我们留下了一个问题:为什么文件会以那组权限创建?作为初步解释,我运行了 umask 命令,结果是 0022,但没有进一步探讨。

计算umask中的数字,以及chmod中的数字模式。四位与三位相对。那前导数字是什么意思呢?我们需要引入一些特殊的权限模式,以启用一些有趣的功能:

  • 粘滞位。将其视为对文件或目录的用户权限声明。如果一个目录设置了粘滞位,目录中的文件只能由文件所有者、文件所在目录的所有者或 root 删除或重命名。在共享目录中非常有用,可以防止一个用户删除或重命名其他用户的文件。粘滞位通过权限列表末尾的t字母或八进制数字 1 在开头来表示。让我们看看它是如何工作的:
gzarrelli:~$ chmod +t test.sh
gzarrelli:~$ ls -lah test.sh
-rwxrw-r-T 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh  

  • 有趣的是,t 是大写的,而不是小写的,正如我们之前提到的。也许这一串命令能让一切更清楚:
gzarrelli:~$ chmod +t test.sh 
gzarrelli:~$ ls -lah test.sh 
-rwxrw-r-T 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh 
gzarrelli:~$ chmod o+x test.sh 
gzarrelli:~$ ls -lah test.sh 
-rwxrw-r-t 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh 

  • 你大概明白了:当文件或目录上的执行位(x)未设置给其他用户(o)时,t 属性会变为大写。

  • 现在,回到最初的情况:

gzarrelli:~$ chmod 0764 test.sh 
gzarrelli:~$ ls -lah test.sh 
-rwxrw-r-- 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh 

  • 我们使用了四位数字表示法,前导的0清除了表示粘滞位的1。显然,我们也可以使用chmod -t来实现相同的目标。最后一点,如果粘滞位和 GUID 发生冲突,粘滞位会优先授予权限。

    • 设置 UIDSUID(执行时设置用户 ID)标记一个可执行文件,使其在运行时以文件所有者的身份执行,拥有他的权限,而不是执行它的用户身份。另一个棘手的用法是,如果将其分配给目录,所有创建或移动到该目录的文件都会将文件所有权更改为目录所有者,而不是实际执行操作的用户。从视觉上看,它表示为用户执行权限位置上的 s。与之相关的八进制数字是 4:
gzarrelli:~$ chmod u+s test.sh
gzarrelli:~$ ls -lah test.sh
-rwsrw-r-- 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh      

    • 设置 GIDSGID(执行时设置组 ID)标记一个可执行文件,使其在运行时以文件所属组的身份执行,而不是以执行它的用户身份。如果应用于目录,则每个创建或移动到该目录的文件都会将其组设置为拥有目录的组,而不是执行操作的用户所属的组。从视觉上看,它表示为组执行权限位置上的s。与之相关的八进制数字是 2。
  • 让我们重置test文件上的权限:

gzarrelli:~$ chmod 0764 test.sh
gzarrelli:~$ ls -lah test.sh
-rwxrw-r-- 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh    

  • 现在,我们使用表示 SGID 的八进制数字来应用它:
gzarrelli:~$ chmod 2764 test.sh
gzarrelli:~$ ls -lah test.sh
-rwxrwSr-- 1 gzarrelli gzarrelli 41 Jan 22 09:05 test.sh  

在这个例子中,s 是大写的,因为我们没有在组上授予执行权限;SUID 也是如此。

所以,现在我们可以再次回到我们的 umask,此时你可能已经知道四位数字表示法的含义了。它是一个在文件创建时修改权限的命令,拒绝设置权限位。以我们目录的默认创建掩码为例:

0777  

我们可以将umask0022理解为:

0777 -
0022
------ 
0755  

不要关注第一个0;它是粘滞位,仅从目录的默认授予遮罩rwx(用户、组和其他)的值中减去umask的值。剩下的值就是文件创建的当前权限遮罩。如果你不习惯数字表示法,可以使用以下命令以熟悉的rwx表示法查看umask值:

gzarrelli:~$ umask -S
u=rwx,g=rx,o=rx  

对于文件,默认的遮罩是666,所以:

0666 -
0022
--------
0644  

实际上比这稍微复杂一点,但这个经验法则可以让你快速计算出遮罩。让我们尝试创建一个新的umask。首先,重置umask值:

gzarrelli:~$ umask
0000
gzarrelli:~$ umask -S
u=rwx,g=rwx,o=rwx  

如我们所见,什么也没有被减去:

zarrelli:~$ touch test-file
gzarrelli:~$ mkdir test-dir
gzarrelli:~$ ls -lah test-*
-rw-rw-rw- 1 gzarrelli gzarrelli    0 Jan 22 18:01 test-file

test-dir:
total 8.0K
drwxrwxrwx 2 gzarrelli gzarrelli 4.0K Jan 22 18:01 .
drwxr-xr-x 4 gzarrelli gzarrelli 4.0K Jan 22 18:01 ..  

test文件的访问权限是666,目录是777。这其实是过多了:

zarrelli:~$ umask o-rwx,g-w
gzarrelli:~$ umask -S
u=rwx,g=rx,o=

gzarrelli:~$ touch 2-test-file
gzarrelli:~$ mkdir 2-test-dir
gzarrelli:~$ ls -lah 2-test-*
-rw-r----- 1 gzarrelli gzarrelli    0 Jan 22 18:03 2-test-file

2-test-dir:
total 8.0K
drwxr-x--- 2 gzarrelli gzarrelli 4.0K Jan 22 18:03 .
drwxr-xr-x 5 gzarrelli gzarrelli 4.0K Jan 22 18:03 ..  

如你所见,目录的权限是 750,文件的权限是 640。稍微做点数学就能理解:

0777 -
0750
--------
0027  

你可以从umask命令获得相同的结果:

gzarrelli:~$ umask
0027  

所有这些设置会在你登录会话期间生效,所以如果你想使它们永久生效,只需将适当参数的umask命令添加到/etc/bash.bashrc中,或者为了系统范围的效果,可以添加到/etc/profile中,或者对于单个用户的遮罩,可以将其添加到用户主目录中的.bashrc文件里。

出现了问题,让我们追踪一下

所以,我们有一个新的小脚本,名为disk.sh

gzarrelli:~$ cat disk.sh
#!/bin/bash    
echo "The total disk allocation for this system is: "    
echo -e "\n"    
df -h    
echo -e "\n    
df -h | grep /$ | awk '{print "Space left on root partition: " $4}'  

没什么特别的,只有一个 shebang,在新的一行上添加几个 echo 来进行垂直间隔,输出df -h命令,以及同样的命令通过awk解析,以便给出有意义的信息。让我们运行一下:

zarrelli:~$ ./disk.sh  

该系统的总磁盘分配为:

Filesystem      Size  Used Avail Use% Mounted on
/dev/dm-0        19G   15G  3.0G  84% /
udev             10M     0   10M   0% /dev
tmpfs            99M  9.1M   90M  10% /run
tmpfs           248M   80K  248M   1% /dev/shm
tmpfs           5.0M  4.0K  5.0M   1% /run/lock
tmpfs           248M     0  248M   0% /sys/fs/cgroup
/dev/sda1       236M   33M  191M  15% /boot
tmpfs            50M   12K   50M   1% /run/user/1000
tmpfs            50M     0   50M   0% /run/user/0
Space left on root partition: 3.0G  

没什么复杂的,都是一些简单的命令,如果失败会在标准输出上打印错误信息。然而,让我们想象一下,假设我们有一个更灵活的脚本,更多的行,某些变量赋值、循环和其他结构,并且出现了问题,但输出什么也没告诉我们。在这种情况下,如果能够看到实际在我们脚本内部运行的方法,那就方便多了,这样我们就可以看到命令的输出、变量赋值等等。在 Bash 中,这是可能的;感谢set命令和-x参数的组合,它会在命令展开后、实际调用之前将所有的命令和参数打印到stdout。通过-x参数运行子 shell 也能获得相同的行为。让我们看看如果在我们的脚本中使用它会发生什么:

gzarrelli:~$ bash -x disk.sh
+ echo 'The total disk allocation for this system is: '
The total disk allocation for this system is:
+ echo -e '\n'    
+ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/dm-0        19G   15G  3.0G  84% /
udev             10M     0   10M   0% /dev
tmpfs            99M  9.1M   90M  10% /run
tmpfs           248M   80K  248M   1% /dev/shm
tmpfs           5.0M  4.0K  5.0M   1% /run/lock
tmpfs           248M     0  248M   0% /sys/fs/cgroup
/dev/sda1       236M   33M  191M  15% /boot
tmpfs            50M   12K   50M   1% /run/user/1000
tmpfs            50M     0   50M   0% /run/user/0
+ echo -e '\n'    
+ awk '{print "Space left on root partition: " $4}'
+ grep /dm-0
+ df -h
Space left on root partition: 3.0G  

现在非常容易理解数据流是如何在脚本中流动的:所有以+号开头的行是命令,接下来的行是输出。

让我们想一想,我们有更长的脚本;对于大多数部分,我们确信事情进行得很顺利。对于某些行,我们并不完全确定结果。调试所有这些将会是嘈杂的。在这种情况下,我们可以使用set-x仅为需要检查的那些行启用日志记录,在不再需要时使用set+x关闭它。是时候修改脚本了,如下所示:

#!/bin/bash  
set -x 
echo "The total disk allocation for this system is: "  
echo -e "\n"  
df -h  
echo -e "\n"  
set +x  
df -h | grep /dm-0 | awk '{print "Space left on root partition: " $4}' 

现在,是时候再次运行它了,如下所示:

gzarrelli:~$ ./disk.sh
+ echo 'The total disk allocation for this system is: '
The total disk allocation for this system is:
+ echo -e '\n'    
+ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/dm-0        19G   15G  3.0G  84% /
udev             10M     0   10M   0% /dev
tmpfs            99M  9.1M   90M  10% /run
tmpfs           248M   80K  248M   1% /dev/shm
tmpfs           5.0M  4.0K  5.0M   1% /run/lock
tmpfs           248M     0  248M   0% /sys/fs/cgroup
/dev/sda1       236M   33M  191M  15% /boot
tmpfs            50M   12K   50M   1% /run/user/1000
tmpfs            50M     0   50M   0% /run/user/0
+ echo -e '\n'    
+ set +x
Space left on root partition: 3.0G  

如你所见,我们在由set-x标记的块中看到了给出的指令,我们还看到了给出的set+x指令,但随后,带有awk的行消失了,我们只看到了它的输出,过滤掉了对我们来说不太有趣的部分,只留下了我们想要关注的部分。

这不是更复杂的编程语言典型的强大调试系统,但在数百行的脚本中,它确实非常有用,那些脚本可能会失去对于评估、循环或变量分配等复杂结构的追踪,这使得脚本更加表达但也更难掌握和掌控。因此,现在我们清楚了如何调试文件,需要哪些权限来安全地使其可执行,以及如何解析命令行,我们准备好看看如何使用变量为我们手工制作的工具增添更多的灵活性。

变量

什么是变量?我们可以回答说它不是常数;这个笑话很好,但对我们帮助不大。最好将其视为一个桶,我们可以在其中存储一些信息以供稍后处理:在脚本的某一点上,你获取一个值,一个信息片段,你不想在那一刻处理它,因此你将它放入一个变量中,稍后在脚本中调用它。这在直观上就是变量的使用方式,一种分配系统内存的方式来保存你的数据。

到目前为止,我们已经看到我们的脚本可以从系统中检索一些信息,并且必须立即处理它们,因为如果没有使用变量,我们没有办法进一步处理信息,除了将输出连接或重定向到另一个程序。这迫使我们进行线性执行,没有灵活性,没有复杂性:一旦获取了一些数据,就立即处理它们,将文件描述符依次重定向到链中的另一个。

变量并不是什么新鲜事物;许多编程语言都使用它们来存储不同类型的数据,整数、浮点数、字符串,你可以看到许多与它们相关的不同类型的变量,它们持有不同类型的数据。所以,你可能听说过变量的类型转换,大致意思是改变它的类型:你得到一个数字字符串的值,你想把它用作整数,所以你将它转换为int,然后使用一些数学函数处理它。

我们的 Shell 不太复杂,它只有一种类型的变量,或者更准确地说,它没有变量类型:你存储在其中的任何内容稍后都可以不经类型转换地进行处理。这可能很方便,因为你不需要关心所持数据的类型;你得到一个作为字符串的数字,可以直接作为整数处理。简单又轻松,但我们必须记住,限制不仅仅是为了防止我们做某事,也是在帮助我们避免做一些对代码不健康的事情,这正是拥有扁平化变量的风险——编写一些根本无法工作的代码,不能工作的代码。

赋值一个变量

正如我们刚才看到的,变量是一种存储值的方式:我们获取一个值,将其赋给一个变量,然后通过后者来访问前者。检索变量内容的操作叫做变量替换。有点像,如果你想象描述符,使用它们来访问文件。赋值变量的方式相当简单:

LABEL=value  

LABEL可以是任何字符串,可以包含大写和小写字母,开始或包含数字和下划线,并且区分大小写。

赋值是通过=字符来进行的,注意,它与等于==符号不同;它们是两回事,并且用于不同的上下文。最后,无论你在赋值符号右边放什么,它就会成为变量的值。那么,让我们给第一个变量赋值:

gzarrelli:~$ FIRST_VARIABLE=amazing  

现在我们可以尝试通过对变量本身执行操作来访问值:

gzarrelli:~$ echo FIRST_VARIABLE
FIRST_VARIABLE  

并不是我们期望的结果。我们想要的是内容,而不是变量名。看看这个:

gzarrelli:~$ echo $FIRST_VARIABLE
amazing  

这样更好。将$字符放在变量名的开头,使其被识别为变量而不是普通字符串,这样我们就可以访问其中的内容。这意味着,从现在开始,我们可以直接使用变量与任何命令,而不需要引用整个内容。所以,让我们再试一次:

gzarrelli:~$ echo $first_variable    
gzarrelli:~$  

输出是 null,而不是 0;稍后我们会看到,零与 null 并不相同,因为 null 代表没有值,而 0 确实是一个值,一个整数。前面的输出是什么意思?简单来说,就是我们的标签区分大小写,只要改变一个字符的大小写,你就会得到一个新变量,由于没有给它赋值,它不包含任何值,因此你在尝试访问它时会得到 null。

保持变量名的安全

我们刚刚看到$label是我们引用变量内容的方式,但是如果你查看一些脚本,你会发现另一种获取变量内容的方法:

${label}  

引用变量内容的两种方式都是有效的,你可以在任何情况下使用第一种更紧凑的方式,除了在将变量名与任何字符连接时,这可能会改变变量名本身。在这种情况下,必须使用扩展版的变量替代方法,正如以下示例将明确说明的那样。

让我们再次打印我们的变量:

gzarrelli:~$ echo $FIRST_VARIABLE
amazing  

现在,让我们使用扩展版的替代法再做一遍:

gzarrelli:~$ echo ${FIRST_VARIABLE}
amazing  

完全相同的输出,因为正如我们所说的,这两种方法是等效的。现在,让我们给变量名添加一个字符串:

gzarrelli:~$ echo $FIRST_VARIABLEngly    
gzarrelli:~$  

什么也没有,我们能理解为什么变量名改变了;所以我们没有内容可以访问。但现在,让我们尝试扩展方式:

gzarrelli:~$ echo ${FIRST_VARIABLE}ly
amazingly  

成功了!变量的名字被保留下来,以便 shell 能够引用它的值,然后将其与我们添加到名字中的 ly 字符串连接。

记住这个区别,因为图形将成为连接字符串与变量的一种便捷方式,可以为你的脚本增色。而且作为一个好习惯,建议使用图形引用变量。这样可以帮助你避免不必要的障碍。

变量的作用域有限

正如我们之前所说,变量在 shell 中没有类型,这使得它们在某种程度上容易使用,但我们必须注意它们使用的一些限制。

  • 首先,变量的内容只有在赋值后才可以访问。

  • 一个例子会让一切变得更清楚:

gzarrelli:~$ cat disk-space.sh 
#!/bin/bash    
echo -e "\n"    
echo "The space left is ${disk_space}"
disk_space=`df -h | grep /$ | awk '{print $4}'`    
echo "The space left is ${disk_space}  

我们使用变量 disk_space 来存储 df 命令的结果,并尝试在前后行引用它的值。让我们以调试模式运行它:

gzarrelli:~$ sh -x disk-space.sh 
+ echo -e \n
-e     
+ echo The space left is 
The space left is 
+ awk {print $4}
+ grep /dm-0
+ df -h
+ disk_space=3.0G
+ echo The space left is 3.0G
The space left is 3.0G  

正如我们所见,执行流程是顺序的:只有在变量实例化之后,你才能访问它的值,而不是在之前。还要记住,第一行实际上打印了某些内容:一个空值。那么,现在让我们在命令行打印变量:

gzarrelli:~$ echo ${disk_space}    
gzarrelli:~$  

变量在脚本内实例化,它被限制在那里面,存在于启动命令时生成的 shell 内,并且没有任何内容传递到我们的主 shell。

我们可以对一个变量施加一些限制,正如我们在下一个例子中将看到的那样。在这个新例子中,我们将引入函数的使用,这是本书后续部分会详细探讨的内容,以及关键字 local

gzarrelli:~$ cat disk-space-function.sh
#!/bin/bash    
echo -e "\n"    
echo "The space left is ${disk_space}"    
disk_space=`df -h | grep /dm-0 | awk '{print $4}'`    
print () {    
echo "The space left inside the function is ${disk_space}"    
local available=yes
last=yes
echo "Is the available variable available inside the function? ${available}"    
}
echo "Is the last variable available outside the function before it is invoked? ${last}"
print
echo "The space left outside is ${disk_space}"
echo "Is the available variable available outside the function? ${available}"
echo "Is the last variable available outside the function after it is invoked? ${last}"  

现在让我们运行它:

gzarrelli:~$ cat di./pace-function.sh
The space left is
Is the last variable available outside the function before it is invoked?
The space left inside the function is 3.0G
Is the available variable available inside the function? yes
The space left outside is 3.0G
Is the available variable available outside the function?
Is the last variable available outside the function after it is invoked? yes  

我们能看到什么?

变量 disk_space 的内容在变量本身实例化之前不可用。我们已经知道这一点。

变量在函数内部实例化后的内容,在函数定义时不可用,只有当函数本身被调用时才能访问。

local 关键字标记并在函数内部定义的变量,仅在函数内部且函数被调用时可用。在函数本身定义的代码块外,局部变量对脚本的其余部分不可见。因此,使用局部变量对编写递归代码很有帮助,尽管不推荐使用。

所以,我们刚才看到了几种方法,可以将一个变量限制在特定的作用域内,我们还注意到它的内容在其实例化的脚本之外无法访问。是不是很希望能有一些作用域更广的变量,能够影响每一个脚本的执行,类似于环境级别的东西?是的,接下来我们将探索环境变量。

环境变量

正如我们之前讨论的,shell 带有一个环境,决定了它能做什么和不能做什么,所以下面我们就用 env 命令来看看这些变量到底是什么:

zarrelli:~$ env    
...
LANG=en_GB.utf8
...
DISPLAY=:0.0
...
USER=zarrelli
...
DESKTOP_SESSION=xfce
...
PWD=/home/zarrelli/Documents
...
HOME=/home/zarrelli
...
SHELL=/bin/bash
...
LANGUAGE=en_GB:en
...
GDMSESSION=xfce
...
LOGNAME=zarrelli
...
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
_=/usr/bin/env  

为了清晰起见,一些变量被省略了,否则输出会太长,但我们仍然可以看到一些有趣的东西。我们可以查看 PATH 变量的内容,它决定了 shell 会在哪些地方查找要执行的程序或脚本。我们还可以看到当前正在使用的是哪个 shell,哪个用户在使用它,当前目录是什么,以及上一个目录是什么。

但是环境变量不仅可以被读取,还可以通过 export 命令来实例化:

zarrelli:~$ export TEST_VAR=awesome  

现在,让我们读取它:

zarrelli:~/$ echo ${TEST_VAR}
awesome  

就是这样,但由于这只是一个测试,最好取消设置变量,以免在 shell 环境中留下不必要的值:

zarrelli:~$ unset TEST_VAR  

现在,让我们试着获取变量的内容:

zarrelli:~/$ echo ${TEST_VAR}
zarrelli:~/$  

不可能!变量的内容不见了,正如你现在会看到的,一旦 shell 结束,它的环境变量就会消失。让我们看看下面的脚本:

zarrelli:~$ cat setting.sh 
#!/bin/bash    
export MYTEST=NOWAY    
env | grep MYTEST    
echo ${MYTEST}  

我们简单地实例化一个新变量,在环境中 grep 它,然后将其内容打印到 stdout。调用后会发生什么?

zarrelli@:~$ ./setting.sh ; echo ${MYTEST}
MYTEST=NOWAY
NOWAY    
zarrelli:~$   

我们可以轻松地看到变量已经在 env 输出中被 grep,所以这意味着该变量实际上是以环境级别实例化的,我们可以访问它的内容并打印出来。但接下来我们再次执行了 MYTEST 的内容回显,但却只是打印了一个空行。如果你还记得,当我们执行脚本时,shell 会派生一个新的 shell,并将其完整的环境传递给它,因此程序内的命令可以操作该环境。但一旦程序终止,相关的 shell 也会终止,其环境变量也会丢失;子 shell 会继承父 shell 的环境,父 shell 并不会继承子 shell 的环境。

现在,让我们回到我们的 shell,看看如何利用环境来为我们所用。如果你还记得,当 shell 需要调用一个程序或脚本时,它会查看 PATH 环境变量的内容,看看能不能在列出的路径中找到它。如果找不到,无法仅凭名字调用可执行文件或脚本,必须传入完整路径。但看看这个脚本能做什么:

#!/bin/bash    
echo "We are into the directory"
pwd  

我们打印当前用户目录:

echo "What is our PATH?"
echo ${PATH}  

现在我们打印环境中 PATH 变量的内容:

echo "Now we expand the path for all the shell"
export PATH=${PATH}:~/tmp  

这有点棘手。使用图表,我们保留了变量的内容,并添加了a,它是PATH列表中每个路径的分隔符,加上~/tmp,这字面意思是当前用户home目录下的tmp目录:

echo "And now our PATH is..."
echo ${PATH}
echo "We are looking for the setting.sh script!"
which setting.sh
echo "Found it!"  

我们真的找到了。好吧,你也可以添加一些评估来让echo变成条件语句,但我们稍后会看到这种用法。接下来是一些有趣的内容:

echo "Time for magic!"
echo  "We are looking for the setting.sh script!"
env PATH=/usr/bin which setting.sh
echo "BOOOO, nothing!"  

注意以env开头的那行;这个命令能够覆盖PATH环境变量,并传递它自己的变量及相关值。使用export代替env也可以获得相同的行为:

echo "Second try..."
env PATH=/usr/sbin which setting.sh    
echo "No way..."  

最后的尝试甚至更糟。我们修改了$PATH变量的内容,它现在指向一个找不到脚本的目录。所以,脚本不在$PATH中,仅凭名字无法调用:

zarrelli:~$ ./setenv.sh   

我们在目录中:

/home/zarrelli/Documents/My books/Mastering bash/Chapter 1/Scripts  

我们的PATH是什么?

/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games  

现在我们为所有的 shell 扩展了路径。

现在我们的PATH是:

/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home
/zarrelli/tmp  

我们正在寻找setting.sh脚本!

/home/zarrelli/tmp/setting.sh  

找到了!

魔法时刻!

我们正在寻找setting.sh脚本!

BOOOO,什么都没有!

第二次尝试…

env:'which':没有此类文件或目录

不可能…

环境变量 用途
BASH_VERSION 当前 Bash 会话的版本
HOME 当前用户的主目录
HOSTNAME 主机名
LANG 用于管理数据的地区设置
PATH shell 的搜索路径
PS1 提示符配置
PWD 当前目录的路径
USER 当前登录用户的名字
LOGNAME user

我们还可以使用带有-i参数的env命令来剥离所有环境变量,只传递给进程我们想要的,就像我们在以下示例中看到的那样。让我们从简单的开始:

zarrelli:~$ cat env-test.sh 
#!/bin/bash
env PATH=HELLO /usr/bin/env | grep -A1 -B1 ^PATH  

没什么难的,我们修改了PATH变量,传递了一个无用的值,因为HELLO不是一个可搜索的路径,然后我们必须使用完整路径来调用env,因为PATH变得无效。最后,我们将所有内容传递给grep的输入,它会选择所有以字符串PATH开头的行(^),并打印出该行及其前后各一行:

zarrelli:~$ ./env-test.sh     
2705-XDG_CONFIG_DIRS=/etc/xdg
2730:PATH=HELLO
2741-SESSION_MANAGER=local/moveaway:@/tmp/.ICE-unix/888,unix/moveaway:/tmp/.ICE-unix/888  

现在,让我们修改脚本,给第一个env添加-i

zarrelli:~$ cat env-test.sh 
#!/bin/bash    
env -i PATH=HELLO /usr/bin/env | grep -A1 -B1 ^PATH  

现在让我们运行它:

zarrelli:~/$ ./env-test.sh 
PATH=HELLO
zarrelli:~/$   

你能猜到发生了什么吗?再做一个更改,会让一切变得更加清晰:

env -i PATH=HELLO /usr/bin/env   

没有grep;我们能够看到第二个env命令的完整输出:

zarrelli:~$ env -i PATH=HELLO /usr/bin/env
PATH=HELLO
zarrelli:~$   

仅仅是将PATH=HELLO env作为参数传递给第二个env进程,这是一个简化的环境,仅包含命令行中指定的变量:

zarrelli:~$ env -i PATH=HELLO LOGNAME=whoami/usr/bin/env
PATH=HELLO
LOGNAME=whoami/usr/bin/env
zarrelli:~$  

因为我们正在进行简化,让我们看看如何使用著名的unset -f命令让一个函数消失:

#!/bin/bash    
echo -e "\n"    
echo "The space left is ${disk_space}"    
disk_space=`df -h | grep vg-root | awk '{print $4}'`    
print () {    
echo "The space left inside the function is ${disk_space}"    
local available=yes
last=yes    
echo "Is the available variable available inside the function? ${available}"   
}    
echo "Is the last variable available outside the function before it is invoked? ${last}"
print
echo "The space left outside is ${disk_space}"
echo "Is the available variable available outside the function? ${available}"
echo "Is the last variable available outside the function after it is invoked? ${last}"    
echo "What happens if we unset a variable, like last?"
unset last
echo "Has last a referrable value ${last}"
echo "And what happens if I try to unset a while print functions using  unset -f" t
print    
unset -f print
echo "Unset done, now let us invoke the function"
print  

该是验证unset命令效果的时候了:

zarrelli:~$ ./disk-space-function-unavailable.sh   

剩余的空间是:

Is the last variable available outside the function before it is invoked? 
The space left inside the function is 202G
Is the available variable available inside the function? yes
The space left outside is 202G
Is the available variable available outside the function? 
Is the last variable available outside the function after it is invoked? yes
What happens if we unset a variable, like last?
Has last a referrable value 
And what happens if I try to unset a while print functions using  
unset -f
The space left inside the function is 202G
Is the available variable available inside the function? yes  

完成取消设置,现在让我们调用函数:

zarrelli:~$   

print 函数表现正常,如预期那样,直到我们取消设置它,此时变量内容不再可用。说到变量,我们实际上可以在同一行上取消设置一些变量,方法如下:

unset -v variable1 variable2 variablen  

我们看到如何修改一个环境变量,但如果我们想使它成为只读,以防止其内容被意外修改呢?

zarrelli:~$ cat readonly.sh 
#!/bin/bash    
echo "What is our PATH?"
echo ${PATH}    
echo "Now we make it readonly"
readonly PATH
echo "Now  we expand the path for all the shell"
export PATH=${PATH}:~/tmp  

看看这一行 readonlyPATH,现在让我们看看执行这个脚本会带我们走向何处:

zarrelli:~$ ./readonly.sh 
What is our PATH?
/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
Now we make it readonly
Now  we expand the path for all the shell
./readonly.sh: line 10: PATH: readonly variable
zarrelli:~$  

发生的事情是,我们的脚本尝试修改 PATH 变量,而这个变量在几行前刚刚被设置为 readonly,因此失败了。这次失败导致我们退出屏幕,显示错误,这可以通过打印 $? 变量的值来确认,$? 变量保存了上一个命令的退出状态:

zarrelli:~$ echo $?
1
zarrelli:~$ echo $?
0  

我们稍后会看到这种类型的变量的使用,但现在我们关心的是理解那个 01 的含义:第一次运行 echo 命令时,在调用脚本之后,它返回了退出代码 1,表示失败,这很有道理,因为脚本因错误而中断退出。第二次运行 echo 时,它显示了 0,表示上一个命令成功执行,之前的 echo 没有任何错误。

变量扩展

变量扩展是我们访问并实际更改变量或参数内容的方法。访问或引用变量值的最简单方式如下所示:

x=1 ; echo $x    
zarrelli:~$ x=1 ; echo $x
1  

所以,我们给变量 x 赋了一个值,然后在变量名前加上美元符号 $ 来引用该值。于是,echo$x 打印了 x 的内容,即 1,到标准输出。但我们还可以做得更微妙一些:

zarrelli:~$ x=1 ; y=$x; echo "x is $x" ; echo "y is $y"
x is 1
y is 1  

所以,我们给变量 x 赋了一个值,然后通过引用变量 x 的内容实例化了变量 y。因此,y 通过 $ 符号引用 x 的值,而不是直接使用数字赋值。到目前为止,我们看到两种不同的引用变量的方式:

$x
${x}  

第一个方法简洁,但最好还是使用第二种方式,因为它保留了变量的名称,并且正如我们在前几页所见,它允许我们在不丢失引用变量的可能性的情况下,将字符串与变量连接起来。

我们刚刚看到的是不同方式中最简单的操作变量值的方法。接下来,我们将看到如何操作变量,使其具有默认值和消息,从而让我们与变量的交互更加灵活。在继续之前,请记住,我们可以使用两种符号表示法,它们是等价的:

${variable-default}
${variable:-default}  

所以,在脚本中你可能会看到这两种方式,它们都是正确的:

${variable:-default} ${variable-default}  

简单地说,如果一个变量没有设置,则返回默认值,正如我们在下面的示例中看到的:

#!/bin/bash
echo "Setting the variable x"
x=10
echo "Printing the value of x using a default fallback value"
echo "${x:-20}"
echo "Unsetting x"
unset -v x
echo "Printing the value of x using a default fallback value"
echo "${x:-20}"
echo "Setting the value of x to null"
x=
echo "Printing the value of x with x to null"
echo "${x:-30}  

现在,让我们执行一下:

zarrelli:~$ ./variables.sh 
Setting the variable x
Printing the value of x using a default fallback value
10
Unsetting x
Printing the value of x using a default fallback value
20
Setting the value of x to null
Printing the value of x with x to null
30  

如前所述,带冒号或不带冒号的两种符号是非常相似的。让我们看看如果在前面的脚本中将${x:-somenumber}替换为${x-somenumber}会发生什么。

让我们运行修改后的脚本:

Setting the variable x
Printing the value of x using a default fallback value
10
Unsetting x
Printing the value of x using a default fallback value
20
Setting the value of x to null
Printing the value of x with x to null    
zarrelli:$   

一切都正常,但最后一行。那么,这里起作用的区别是什么呢?很简单:

  • *${x-30}: 带冒号的符号强制检查变量的值是否存在,而这个值可能为 null。如果有值,它会打印变量的值,忽略回退值。

    • unset -f x: 它取消设置变量,因此它没有值,我们会得到一个回退值。

    • x=: 它给x赋值为 null;因此回退机制不会起作用,我们得到变量的值,例如,null。

  • ${x:-30}: 如果变量的值为 null 或不存在,这强制使用回退值。

    • unset -f x: 它取消设置变量,因此它没有值,我们会得到一个回退值。

    • x=: 它给x赋值为 null,但回退机制起作用,我们得到一个默认值。

默认值在编写需要输入的脚本时非常有用,尤其是当客户没有提供值时:如果客户没有提供值,我们可以使用回退默认值,并使变量实例化为有意义的内容:

#!/bin/bash        
echo "Hello user, please give me a number: "
read user_input        
echo "The number is: ${user_input:-99}"      

我们要求用户提供输入。如果他给我们一个值,我们打印它;否则,我们将变量的回退值设置为99并打印它:

zarrelli:~$ ./userinput.sh 
Hello user, please give me a number: 
10
The number is: 10
zarrelli:~/$    
zarrelli$ ./userinput.sh 
Hello user, please give me a number:     
The number is: 99
zarrelli:~/$
${variable:=default} ${variable=default}  

如果变量有值,则返回该值;否则,变量将被分配一个默认值。在前一个案例中,如果变量没有值,我们得到一个返回的值;或者是 null,这里变量实际上被赋予了一个值。最好看一个例子:

#!/bin/bash    
#!/bin/bash
echo "Setting the variable x"
x=10
echo "Printing the value of x"
echo ${x}
echo "Unsetting x"
unset -v x
echo "Printing the value of x using a default fallback value"
echo "${x:-20}"
echo "Printing the value of x"
echo ${x}
echo "Setting the variable x with assignement"
echo "${x:=30}"
echo "Printing the value of x again"
echo ${x}  

我们设置一个变量并打印其值。然后,我们取消设置它并打印其值,但因为它被取消设置,所以我们会得到一个默认值。然后,我们尝试打印x的值,但由于前面的操作中得到的数字不是通过赋值获得的,x仍然没有设置。最后,我们使用echo "${x:=30}",并将值30赋给变量x,确实,当我们打印变量的值时,我们得到了一个值。让我们看看脚本的执行效果:

Setting the variable x
Printing the value of x
10
Unsetting x
Printing the value of x using a default fallback value
20
Printing the value of x    
Setting the variable x with assignement
30
Printing the value of x again
30  

注意输出中的空白行:我们刚刚从前面的操作中得到一个值,而不是一个真正的变量赋值:

${variable:+default} ${variable+default}  

强制检查变量是否有非 null 值。如果有值,返回默认值;否则返回 null:

#!/bin/bash    
#!/bin/bash
echo "Setting the variable x"
x=10
echo "Printing the value of x"
echo ${x}
echo "Printing the value of x with a default value on 
assigned value"
echo "${x:+100}"
echo "Printing the value of x after default"
echo ${x}
echo "Unsetting x"
unset -v x
echo "Printing the value of x using a default fallback value"
echo "${x:+20}"
echo "Printing the value of x"
echo ${x}
echo "Setting the variable x with assignement"
echo "${x:+30}"
echo "Printing the value of x again"
echo ${x}  

现在,让我们运行它并检查,如下所示:

Setting the variable x
Printing the value of x
10
Printing the value of x with a default value on assigned value
100
Printing the value of x after default
10
Unsetting x
Printing the value of x using a default fallback value    
Printing the value of x    
Setting the variable x with assignement    
Printing the value of x again    
zarrelli:~$   

如你所见,当变量正确实例化时,它不会返回其值,而是返回一个默认的100,并且在后面的行中我们打印x的值,它仍然是10:我们看到的100并不是赋值,而只是作为默认值返回,而不是实际的值。

${variable:?message} ${variable?message}
#!/bin/bash
x=10
y=
unset -v z
echo ${x:?"Should work"}
echo ${y:?"No way"}
echo ${y:?"Well"}  

结果是相当直接的:

zarrelli:~$ ./set-message.sh 
10
./set-message.sh: line 8: y: No way  

当我们尝试访问一个 void 变量时,由于未设置,情况应该是一样的,脚本因错误退出,并打印了我们从变量扩展中得到的消息。第一行没问题,x 有值并且我们打印了它,但如你所见,我们无法到达第三行,第三行保持未解析状态:脚本在打印默认消息后突然退出。

很棒,对吧?好吧,还有很多内容,我们需要继续探索模式匹配与变量的关系。

对变量进行模式匹配

我们有几种方式可以操作变量,其中一些在脚本中有非常有趣的用途,稍后在本书中我们会看到。让我们简要回顾一下我们可以对变量做什么以及如何做,但记住我们处理的是返回的值,而不是回赋给变量:

${#variable)  

它为我们提供了变量的长度,或者如果是数组,则为数组第一个元素的长度。这里有一个例子:

zarrelli:~$ my_variable=thisisaverylongvalue
zarrelli:~$ echo ${#my_variable}
20  

确实,thisisaverylongvalue 是由 20 个字符组成的。现在,让我们来看一个关于数组的例子:

zarrelli:~$ fruit=(apple pear banana)  

在这里,我们实例化了一个包含三个元素的数组:applepearbanana。稍后我们将在本书中看到如何详细处理数组:

zarrelli@moveaway:~$ echo ${fruit[2]}
banana  

我们打印了数组的第三个元素。数组的索引从 0 开始,所以第三个元素在索引 2 处,它是 banana,一个长度为 6 个字符的单词:

zarrelli@moveaway:~$ echo ${fruit[1]}
pear  

我们打印了数组中的第二个元素:pear,一个长度为 4 个字符的单词:

zarrelli@moveaway:~$ echo ${fruit[0]}
apple  

现在,第一个元素,即 apple 是 5 个字符长。现在,如果我们看到的例子是正确的,下面的命令应该返回 5

zarrelli:~$ echo ${#fruit}
5  

实际上,单词 apple 的长度是 5 个字符:

${variable#pattern) 

如果你需要从变量中提取一部分,可以使用模式并去除模式在变量开头的最短出现,然后返回结果值。这不是变量赋值,不是那么容易理解,但通过一个例子会更清楚:

zarrelli:~$ shortest=1010201010
zarrelli:~$ echo ${shortest#10}
10201010
zarrelli:~$ echo ${shortest}
1010201010
${variable##pattern)  

这个形式与前一个类似,但有一个小差异,模式用于移除变量中最大的一次出现:

zarrelli:~$ my_variable=10102010103  

我们用一系列重复的数字实例化了变量:

zarrelli:~$ echo ${my_variable#1*1}
02010103  

然后,我们尝试匹配一个模式,即在前后都为 1 的情况下,最短的出现形式。结果是提取出了 10102010103:

zarrelli:~$ echo ${my_variablet##1*1}
03  

现在,我们去除模式的最宽广出现,因此 10102010103,结果返回一个微不足道的 03 作为返回值:

${variable%pattern)  

在这里,我们从变量值的末尾去除了模式的最短出现:

zarrelli:~$ ending=10102010103
zarrelli:~$ echo ${ending%1*3}
10102010  

因此,从文件末尾开始计算,1*3 模式的最短出现是 10102010103,所以我们返回 10102010

${variable%%pattern)  

类似于前面的示例,使用 ## 时,在这种情况下,我们从变量值的末尾去除模式的最长出现:

zarrelli:~$ ending=10102010103
zarrelli:~$ echo ${ending}
10102010103
zarrelli:~$ echo ${ending%1*3}
10102010
zarrelli:~$ echo ${ending%%1*3}
zarrelli:~$   

相当清楚,对吧?最长的出现 1*310102010103,所以我们去除了所有内容,什么也不返回,就像这个使用 -z(是否为空)评估的例子所展示的那样:

zarrelli:~$ my_var=${ending%1*3}
zarrelli:~$ [[ -z "$my_var" ]] && echo "Empty" || echo "Not empty"
Not empty
zarrelli:~$ my_var=${ending%%1*3}
zarrelli:~$ [[ -z "$my_var" ]] && echo "Empty" || echo "Not empty"
Empty
${variable/pattern/substitution}  

熟悉正则表达式的读者可能已经了解结果是什么:将变量中的模式的第一次出现替换为替换内容。如果替换不存在,则删除变量中模式的第一次出现:

zarrelli:~$ my_var="Give me a banana"
zarrelli:~$ echo ${my_var}
Give me a banana
zarrelli:~$ echo ${my_var/banana/pear}
Give me a pear
zarrelli:~$ fruit=${my_var/banana/pear}
zarrelli:~$ echo ${fruit}
Give me a pear  

并不那么讨厌,我们能够使用我们的查找和替换的输出实例化一个变量:

${variable//pattern/substitution}  

与前面的情况类似,在这种情况下,我们将替换变量中模式的出现:

zarrelli@moveaway:~$ fruit="A pear is a pear and is not a banana"
zarrelli@moveaway:~$ echo ${fruit//pear/watermelon}
A watermelon is a watermelon and is not a banana  

与前面的示例类似,如果省略替换,则从变量中删除模式:

${variable/#pattern/substitution}  

如果变量的前缀匹配,则用替换替换变量中的模式,因此这与前面的类似,但仅在变量开头匹配:

zarrelli:~$ fruit="a pear is a pear and is not a banana"
zarrelli:~$ echo ${fruit/#"a pear"/}
is a pear and is not a banana
zarrelli:~$ echo ${fruit/#"a pear"/"an apple"}
an apple is a pear and is not a banana  

通常情况下,省略意味着从变量中删除模式的出现。

${variable/%pattern/substitution}  

再次,一个位置的替换,这次是在变量值的末尾:

zarrelli:~$ fruit="a pear is not a banana even tough I would 
like to eat a banana"
zarrelli:~$ echo ${fruit/%"a banana"/"an apple"}
a pear is not a banana even though I would like to eat an apple  

很多废话,但是有意义:

${!prefix_variable*}
${!prefix_variable@}  

匹配以突出显示的前缀开头的变量名:

zarrelli:~$ firstvariable=1
zarrelli:~$ secondvariable=${!first*}
zarrelli@:~$ echo ${secondvariable}
firstvariable
zarrelli:~$ thirdvariable=${secondvariable}
zarrelli:~$ echo ${thirdvariable}
firstvariable
${variable:position}  

我们可以决定从哪个位置开始扩展变量,从而确定我们想要从其值中获取回来的部分:

zarrelli:~$ picnic="Either I eat an apple or I eat a raspberry"
zarrelli:~$ echo ${picnic:25}
I eat a raspberry  

因此,我们只取了变量的一部分,并确定了起始点,但我们也可以定义挑选的持续时间:

${variable:position:offset}
zarrelli:~$ wheretogo="I start here, I go there, no further"
zarrelli:~$ echo ${wheretogo:14:10}
I go there  

因此,我们不再继续,从一个位置开始并停在偏移处;这样,我们可以从变量值中提取任何连续的字符/数字。

到目前为止,我们已经看到了许多不同的方式来访问和修改变量或者至少是从变量中获取的内容。还有一类非常特殊的变量需要查看,当编写脚本时,这些变量将非常方便。

特殊变量

现在让我们看看一些具有一些特殊用途的变量,我们可以从中受益:

${1}, ${n}

我们要探索的第一个有趣的变量在我们的脚本中有特殊作用,因为它们将允许我们在第一次命令行执行中捕获多个参数。看看这些行的一堆:

!/bin/bash    
fistvariable=${1}
secondvariable=${2}
thirdvariable=${3}    
echo "The value of the first variable is ${1}, the second 
is ${2}, the third is ${3}" 

注意$1$2$3

zarrelli:~$ ./positional.sh 
The value of the first variable is , the second is , the third is   

第一次尝试,在命令行上没有参数,我们看不到打印变量的内容:

zarrelli:~$ ./positional.sh 1 2 3
The value of the first variable is 1, the second is 2, 
the third is 3  

第二次尝试,我们调用脚本并添加由空格分隔的三个数字,实际上,我们可以看到它们被打印出来。命令行上的第一个对应于$1,第二个对应于$2,第三个对应于$3

zarrelli:~$ ./positional.sh Green Yellow Red  

第一个变量的值是Green;第二个是Yellow;第三个是Red

第三次尝试,我们使用具有相同结果的单词。但请注意这里:

zarrelli:~$ ./positional.sh "One sentence" "Another one" 
A third one
The value of the first variable is One sentence, the second 
is Another one, the third is A  

我们使用双引号来防止一个句子和另一个之间的空格被解释为命令行位的分隔符,事实上,第一和第二句被添加为变量的完整字符串,但第三句只有一个 A,因为后续未引用的空格被视为分隔符,并且接下来的位被视为$4$5$n。请注意,我们也可以混合分配顺序,如下所示:

thirdvariable=${3}
fistvariable=${1}
secondvariable=${2}  

结果将是一样的。重要的是,我们声明的变量的位置并不重要,而是我们将其与哪个位置关联。

如你所见,我们使用了两种不同的方法来表示一个位置变量:

${1}
$1  

它们是一样的吗?差不多。看这里:

#!/bin/bash    
fistvariable=${1}
secondvariable=${2}
thirdvariable=${3}
eleventhvariable=$11    
echo "The value of the first variable is ${fistvariable}, 
the second is ${secondvriable}, the third is ${thirdvariable}, 
the eleventh is ${eleventhvariable}"   

现在,让我们执行脚本:

zarrelli:~$ ./positional.sh "One sentence" "Another one" A 
third one
The value of the first variable is One sentence, the second 
is Another one, the third is A, the eleventh is One sentence1  

有趣的是,eleventhvariable被当作位置参数$1解释,并添加了一个1。奇怪,我们来按下面的方式重写 echo:

eleventhvariable=${11}  

然后再次运行脚本:

zarrelli$ ./positional.sh "One sentence" "Another one" A third one
The value of the first variable is One sentence, the second is 
Another one, the third is A, the eleventh is   

现在我们是正确的了。我们没有在命令行上传递第十一项位置参数,因此eleventhvariable没有被实例化,我们也没有看到任何输出到屏幕上的内容。小心,始终使用${};它会在复杂脚本中保留变量的值,当你需要掌握每一个细节时,这会变得非常重要:

${0}  

这个展开为脚本的完整路径;它为你提供了在脚本中处理它的方法。所以,我们来在脚本末尾添加以下一行并执行它:

echo "The full path to the script is $0"
zarrelli:~$ ./positional.sh 1 2 3
The value of the first variable is 1, the second is 2, the 
third is 3, the eleventh is 
The full path to the script is ./positional.sh 

在我们这个例子中,路径是本地的,因为我们是从包含脚本的目录中调用的脚本:

${#}  

这个展开为传递给脚本的参数数量,显示了命令行上传递给脚本的参数个数。所以,让我们在脚本中添加以下一行,看看会输出什么:

echo "We passed ${#} arguments to the script"    
zarrelli:~$ ./positional.sh 1 2 3 4 5 6 7 
The value of the first variable is 1, the second is 2, the 
third is 3, the eleventh is 
The full path to the script is ./positional.sh
We passed 7 arguments to the script    
${@}
${*}  

它给我们返回了传递给脚本的命令行参数列表,有一个不同点:${@}保留了空格,而第二种方法则没有:

#!/bin/bash
fistvariable=${1}
secondvariable=${2}
thirdvariable=${3}
eleventhvariable=${11}
export IFS=*    
echo "The value of the first variable is ${fistvariable}, 
the second is ${secondvariable}, the third is ${thirdvariable}, 
the eleventh is ${eleventhvariable}"
echo "The full path to the script is $0"
echo "We passed ${#} arguments to the script"    
echo "This is the list of the arguments ${@}"
echo "This too is the list of the arguments ${*}"
IFS=
echo "This too is the list of the arguments ${*}"  

我们更改了 shell 使用的字符作为分隔符来识别单个词。现在,让我们执行脚本:

zarrelli:~$ ./positional.sh 1 2 3
The value of the first variable is 1, the second is 2, 
the third is 3, the eleventh is 
The full path to the script is ./positional.sh
We passed 3 arguments to the script
This is the list of the arguments 1 2 3
This too is the list of the arguments 1*2*3
This too is the list of the arguments 123  

这里,你可以看到差异的体现:

  • *:这个展开为位置参数,从第一个开始,当展开发生在双引号内时,它会展开为单一的词,并使用 IFS 的第一个字符分隔每个位置参数。如果 IFS 为空,则使用空格;如果 IFS 为 null,则词语会被连接在一起,没有分隔符。

  • @:这个展开为位置参数,从第一个开始,如果展开发生在双引号内,每个位置参数会被展开为独立的词:

${?}  

这个特殊变量展开为最后执行的命令的退出值,正如我们之前看到的那样:

zarrelli:~$ /bin/ls disk.sh ; echo ${?} ; tt ; echo ${?}
disk.sh
0
bash: tt: command not found
127  

第一个命令执行成功,因此退出代码是0;第二个命令报错127command not found,因为tt命令不存在。

${$}展开为当前 shell 的进程号,对于脚本来说,就是它运行的 shell。我们来给positional.sh脚本添加以下这一行:

echo "The process id of this script is ${$}"  

然后让我们运行它:

zarrelli:~$ ./positional.sh 1 2 3
The value of the first variable is 1, the second is 2, the 
third is 3, the eleventh is 
The full path to the script is ./positional.sh
We passed 3 arguments to the script
This is the list of the arguments 1 2 3
This too is the list of the arguments 1*2*3
This too is the list of the arguments 123
The process id of this script is 13081  

一步步地,脚本告诉我们越来越多的信息:

${!}  

这个有点棘手;它展开为最后一个后台命令的进程号。是时候在脚本中添加一些其他行了:

echo "The background process id of this script is ${!}"
echo "Executing a ps in background"
nohup ps &
echo "The background process id of this script is ${!}"  

然后执行它:

zarrelli:~$ ./positional.sh 1 2 3
The value of the first variable is 1, the second is 2, 
the third is 3, the eleventh is 
The full path to the script is ./positional.sh
We passed 3 arguments to the script
This is the list of the arguments 1 2 3
This too is the list of the arguments 1*2*3
This too is the list of the arguments 123
The process id of this script is 13129
The background process id of this script is 
Executing a ps in background
The background process id of this script is 13130
nohup: appending output to 'nohup.out'  

我们使用了nohup ps &ps发送到后台(&),并将其从当前终端分离(nohup)。稍后我们会更详细地讨论后台命令的使用;现在只需要了解,在将进程发送到后台之前,${!}没有任何值可以打印;它仅在我们将ps发送到后台后才被实例化。

你看到那个了吗?

nohup: appending output to 'nohup.out'  

对我们来说,它没有任何意义,那我们怎么在脚本执行过程中重定向这个无用的输出并将其去除呢?你知道吗?这是一个小练习,在你开始阅读下一章之前,自己做一下吧,那一章会涉及操作符以及更多有趣的内容。

总结

在本章中,我们讨论了一些 Shell 的基础知识,比如你应该如何正确处理的内容。例如,不正确地保存变量名可能会导致我们得到不想要的结果;而另一方面,了解如何访问环境变量将有助于我们为日常任务创建更好的环境。正如我们所说,这些是 Bash 大师应该牢记的基本但重要的内容,因为解除屏蔽、文件描述符和操作变量是让你玩出高阶技巧的关键,也是成为高级用户的构建模块。所以,不要忽视它们,它们会帮助你。

第二章:操作符

到目前为止,我们所做的是处理来自变量扩展和描述符的值,并以巧妙的方式使用它们。因此,这是一个不错的操作,但我们还不能做太多,因为我们没有办法真正关联值、进行比较,甚至按我们的意图修改它们。

这是操作符发挥作用的地方,我们将看到如何修改变量的值,以便它能持有一个值,并随着时间推移逐步修改并收集新的信息。所以,让我们从简单的开始,先从基础数学入手,然后逐步过渡到更复杂的内容。

在继续之前,我们必须记住的一件事是,操作符遵循一个优先级顺序:

  • 复合逻辑操作符 -a-o&& 的优先级较低

  • 算术操作符的优先级如下:

  • 具有相同优先级的操作符按从左到右的顺序进行求值

算术操作符

算术操作符做的就是你想的那样,也就是加、减、除等。即使没有特定的编程知识,我们也很熟悉这些操作。让我们来看一下它们每一个是如何用来操作变量的值的。

在继续之前,请记住,对于 shell 脚本,数字默认是十进制,除非在前面加上 0 表示八进制,0x 表示十六进制,或者使用 base#number 来表示基数为 base 的数值。

+ 操作符

这就像我们在小学时学到的内容;这个操作符允许我们将一个整数加到变量的值上,如下面的示例所示:

#!/bin/bash
echo "Hello user, please give me a number: "    
read user_input    
echo "And now another one, please: "    
read adding    
addition=$((user_input+adding))    
echo "The number is: ${user_input:-99}"
echo "The number added of ${adding} is: ${addition}"  

现在是时候调用脚本了:

zarrelli:$ ./useraddition.sh 
Hello user, please give me a number: 
120
And now another one, please: 
30
The number is: 120
The number added of 30 is: 150  

正如你可能已经注意到的,我们使用了双括号结构 $(( )) 来执行这种算术扩展和求值:简而言之,就是扩展并求值,然后返回值。这是二进制操作符中常见的符号,它还允许我们像用双引号括起来一样引用特殊字符,因此我们不必强制进行转义。唯一的例外是双引号,它仍然需要进行转义。别担心,我们稍后会进一步了解特殊字符以及如何引用它们。

现在,尝试运行脚本并且不提供数字:

The number is: 99
The number added of is: 0  

数字 99 不是赋值,它只是我们在变量没有有效值的情况下返回的默认值,但第一个和第二个变量没有赋值,因此将一个值加到另一个值上会导致 0

- 操作符

好了,在这种情况下,我们将从变量的值中减去一些东西,有一个小的警告:这是一个左结合操作,从左到右进行求值,这意味着我们正在将减号右侧的值从左侧的值中减去:

zarrelli:~$ a=20 ; b=5 ; c=$((a-b)) ; echo ${c}
15  

* 操作符

对于乘法,我们不需要关注顺序;一个值与另一个值相乘,无论我们采取哪个方向:

zarrelli:~$ a=20 ; b=5 ; c=$((${a}*${b})) ; echo ${c}
100 

/ 操作符

除法是另一个左关联的运算,因此我们将除号左边的数字除以右边的数字:

zarrelli:~$ a=20 ; b=5 ; c=$((a/b)) ; echo ${c}
4   

% 运算符

模除运算符给我们提供了两个整数相除的余数:

zarrelli:~$ a=29 ; b=5 ; c=$((a/b)) ; echo ${c}
5
zarrelli:~$ a=29 ; b=5 ; c=$((a%b)) ; echo ${c}
4  

** 运算符

正如我们在学校看到的那样,指数运算是一个数乘以它自己若干次,次数由指数决定:

zarrelli:~$ a=4 ; b=5 ; c=$((a**b)) ; echo ${c}
1024    
zarrelli:~$ a=4 ; c=$((a*a*a*a*a)) ; echo ${c}
1024  

在这种情况下,我们面对的是一个左关联操作,顺序很重要。请注意,在任何情况下,变量会在评估之前展开,所有我们到目前为止看到的运算符都会被应用。我们使用了$a${a},是为了让你习惯在实际生活中,查看你在互联网上会遇到的脚本。

赋值运算符

到目前为止,我们已经看到如何操作赋值给变量的值和整数,然后将这个值重新赋给另一个变量或相同的变量。但为什么要使用两个操作,而不是同时使用赋值运算符来修改变量的值并重新赋值呢?

+= 运算符

这个运算符将一个数量加到变量的值上,并将结果赋值给变量本身,但为了澄清它的用法,让我们重写我们之前见过的一个例子:

#!/bin/bash    
echo "Hello user, please give me a number: "    
read user_input    
echo "And now another one, please: "  

加法

echo "The user_input variable value is: ${user_input}"
echo "The adding variable value is: ${adding}"
echo "${user_input} added of ${adding} is: $((user_input+=adding))"
echo "And the user_input variable has now  the value of 
${user_input}"
echo"But the adding variable has still the value of ${adding}"  

现在,让我们按如下方式运行它:

zarrelli:~$ ./userreassign.sh 
Hello user, please give me a number: 
150
And now another one, please: 
50
The user_input variable value is: 150
The adding variable value is: 50
150 added of 50 is: 200
And the user_input variable has now the value of 200
But the adding variable has still the value of 50  

很简单!我们不需要显式地重新赋值 200。

-= 运算符

事实上,这与前一个运算符非常相似,只是在这种情况下,我们做了减法并重新赋值。让我们用运算符重写我们的最后一个脚本,看看会发生什么:

zarrelli:~$ ./userreassign-subtract.sh 
Hello user, please give me a number: 
200
And now another one, please: 
50
The user_input variable value is: 200
The adding variable value is: 50
200 subtracted of 50 is: 150
And the user_input variable has now  the value of 150
But the adding variable has still the value of 50  

*= 运算符

在这种情况下,我们将变量的值乘以给定的数字,并重新赋值:

zarrelli:~$ ./userreassign-multiply.sh 
Hello user, please give me a number: 
-1
And now another one, please: 
9223372036854775808
The user_input variable value is: -1
The adding variable value is: 9223372036854775808
-1 multiplied for  9223372036854775808 is: 
-9223372036854775808
And the user_input variable has now the value of 
-9223372036854775808
But the adding variable has still the value of 
9223372036854775808  

太好了! 事实上,我们已经达到了现代 Bash 的一个边界:过去,变量保存的值可以用一个 32 位带符号长整型表示,但从 2.05b 版本开始,它切换到一个 64 位带符号整数,范围如下:

−9,223,372,036,854,775,808 to 9,223,372,036,854,775,807  

记住,包含逗号的值会被解释为字符字符串。

/= 运算符

在这种情况下,我们将变量的值除以给定的数字,并重新赋值新值:

zarrelli@:~$ ./userreassign-division.sh 
Hello user, please give me a number: 
10
And now another one, please: 
2
The user_input variable value is: 10
The adding variable value is: 2
10 divided for 2 is: 5
And the user_input variable has now the value of 5
But the adding variable has still the value of 2  

%= 运算符

使用模赋值,我们将变量的值除以给定的数字,并重新赋值余数。我们只需修改我们脚本中的几行:

echo "The value of ${user_input} divided by ${adding} is: $((user_input/=adding))"
echo "The remainder of ${user_input} divided by ${adding} is: $((user_input%=adding))"

然后执行它:

zarrelli@:~$ ./userreassign-modulo.sh 
Hello user, please give me a number: 
324
And now another one, please: 
12
The user_input variable value is: 324
The adding variable value is: 12
The value of 324 divided by 12 is: 27
The remainder of 27 divided by 12 is: 3
And the user_input variable has now  the value of 3
But the adding variable has still the value of 12  

++ 或 -- 运算符

这是一个一元运算符++(或--),它允许我们将变量的值增加/减少1并重新赋值,但要小心,运算符的位置很重要:

zarrelli:~$ a=15 ; echo $((a++)) ; echo ${a}
15
16
zarrelli:~$ a=15 ; echo $((++a)) ; echo ${a}
16
16  

你能弄清楚发生了什么吗?简单来说,在第一个例子中,第一个运算符返回了值,然后才加了 1;在第二个例子中,它首先加了 1 到值,然后返回它。请注意这个运算符,因为它在循环内部广泛使用,用于计数循环次数并最终跳出循环:

zarrelli:~$ cat loop.sh 
#!/bin/bash   
counter=10    
while [ $counter -gt 0 ]; 
do
echo"Loop number: $((counter--))"
done  

我们实例化一个名为counter的变量,初始值为10,然后定义一个循环,当counter的值大于0时,打印counter的值并将其减少,每次减少1。每个循环周期中,变量的值被打印,然后减小。当counter达到0时,while条件不再有效,循环停止:

zarrelli:~$ ./loop.sh 
Loop number: 10
Loop number: 9
Loop number: 8
Loop number: 7
Loop number: 6
Loop number: 5
Loop number: 4
Loop number: 3
Loop number: 2
Loop number: 1  

位运算符

位运算符在处理位掩码时非常有用,但在日常实践中,它们并不那么容易使用,因此你不太可能经常遇到它们。然而,由于它们在 Bash 中可用,我们将通过一些示例来查看它们。

左移 (<<)

位运算左移运算符每移位一次就将值乘以2;以下示例将使一切变得更清楚:

zarrelli:~$ x=10 ; echo $((x<<1))
20
zarrelli:~$ x=10 ; echo $((x<<2))
40
zarrelli:~$ x=10 ; echo $((x<<3))
80
zarrelli:~$ x=10 ; echo $((x<<4))
160  

发生了什么?正如我们之前所说,位运算符作用于位掩码,那么我们就从将整数10转换为其 16 位二进制表示开始,并使用一个2的幂表来检查其值。

在这种情况下,表示十进制数的二进制形式的简单方法是使用二的幂表示法,从将整数分解为幂和数的和开始。在我们的示例中,适合10的最大二的幂是,即8,加上,即2。因此,在二的幂中,我们只选择2321,并可以像下面的表格那样表示:

2⁷ 2⁶ 2⁵ 2⁴ 2⁰
128 64 32 16 8 4 2 1
0 0 0 0 1 0 1 0
8 2

如果我们用1标记2的幂,表示我们用来表示数字10的部分,用0标记未使用的部分,结果将是1010,或者如果使用 8 位表示数字,则为00001010

现在我们要做的是将所有数字向左移动一个位置,但这会给我们带来一个问题,即右边的数字没有右边的数字可以替代它的位置。所以,对于最后一个右边的槽,我们将使用一个0

10100  

128 64 32 16 8 4 2 1
0 0 0 1 0 1 0 0
16 4

而这个数字转换为十进制后是20。所以现在,让我们看看echo$((x<<2))会变成什么:

128 64 32 16 8 4 2 1
0 0 1 0 1 0 0 0
32 8

所以,我们得到了40。现在是时候进行$((x<<3)),将第一个1移出左侧,添加一个尾随的0并在开头添加一个符号位:

1010000  

128 64 32 16 8 4 2 1
0 1 0 1 0 0 0 0
64 16

我们达到了80;现在,让我们进行$((x<<4)),使用相同的步骤:

10100000  

128 64 32 16 8 4 2 1
1 0 1 0 0 0 0 0
128 32

好的,160 就是结果,正如你能想象的那样。所以现在我们知道了一种非常快速的方式来将一个数字乘以二的幂,但是如果我们想要除法呢?

右移 (>>)

按位右移是将数字除以 2 的一种很好的方法,每次右移一位,即通过二的幂进行除法。请注意,这个操作会在左边填充最高有效位,也就是符号位,所以所有的位都会填充为 1,但下面的例子会让一切变得更简单:

zarrelli:~$ x=160 ; echo $((x>>1))
80
zarrelli:~$ x=160 ; echo $((x>>2))
40
zarrelli:~$ x=160 ; echo $((x>>3))
20
zarrelli:~$ x=160 ; echo $((x>>4))
10  

那么,我们从 160 开始:

10100000  

128 64 32 16 8 4 2 1
1 0 1 0 0 0 0 0
128 32

接下来我们做的是将其右移一位:

1010000  

128 64 32 16 8 4 2 1
0 1 0 1 0 0 0 0
64 1

现在我们得到 80,但再次,左移一位:

128 64 32 16 8 4 2 1
0 0 1 0 1 0 0 0
32 8

现在,作为练习,自己计算剩下的值。

按位与

这是按位与运算符,它类似于逻辑与,但它在整数的二进制表示的位掩码上进行操作。二进制位从左到右读取,并在两个数字之间进行比较:如果在每个数字的相同位置上找到一个 1,结果将是 1,否则将是 0

zarrelli:~$ x=50 ; y=20; echo $((x&y))
16  

我们是如何得到这个值的?让我们创建一个包含 5020 二进制值的矩阵:

128 64 32 16 8 4 2 1
50 0 0 1 1 0 0 1 0
20 0 0 0 1 0 1 0 0
& 0 0 0 1 0 0 0 0

结果是二进制 00010000,十进制是 16

按位或(|)

按位或运算符类似于包含性或,它通过二进制表示检查两个整数:如果在相同位置上每个数字都有一个 1,结果将是 1

zarrelli:~$ x=50 ; y=20; echo $((x|y))
54 

正如我们从下面的表格中看到的:

128 64 32 16 8 4 2 1
50 0 0 1 1 0 0 1 0
20 0 0 0 1 0 1 0 0
| 0 0 1 1 0 1 1 0

按位异或 (^)

这就是我们所谓的异或(XOR):只有在同一位置上只有一个 1 时,结果才为 1,如果有两个 1,结果将是 0

zarrelli:~$ x=50 ; y=20; echo $((x^y))
38  

所以,让我们再次检查我们的矩阵:

128 64 32 16 8 4 2 1
50 0 0 1 1 0 0 1 0
20 0 0 0 1 0 1 0 0
^ 0 0 1 0 0 1 1 0

而结果就是 36

按位非(~)

按位非是一个一元运算符,这意味着它只与一个操作符一起使用,翻转用于表示整数的二进制位:

zarrelli:~$ x=50 ; echo $((~x))
-51  

看一下下面的表格:

128 64 32 16 8 4 2 1
50 0 0 1 1 0 0 1 0
~ 1 1 0 0 1 1 0 1
-128 64 8 4 1

我们必须记住,对于一个带符号的整数(尽管我们没有写+50,因为 Bash 以 64 位符号长整型表示整数),最左边的最高位表示符号值。因此,翻转第一个有效位实际上反转了该值。经验法则是,按位取反的结果等于该数的二进制补码减去 1:

zarrelli:~$ x=30 ; echo $((~x))
-31  

128 64 32 16 8 4 2 1
30 0 0 0 1 1 1 1 0
~ 1 1 1 0 0 0 0 1
-128 64 32 1

然而,我们也可以通过不首先将整数转换为二进制,直接取反位并在结果中加上一个1来计算按位运算,这就是所谓的二进制补码:

50 0 0 1 1 0 0 1 0
反转 1 1 0 0 1 1 0 1
加 1 0 0 0 0 0 0 0 1
-51 1 1 0 0 1 1 0 1

所以,正如你所看到的,在二进制补码中,最左边的位为1的二进制数是负数,而以0开头的是正整数。

逻辑运算符

这里,我们进入了一些对我们的脚本非常有用的内容,一些操作符将使我们能够执行测试并做出响应。因此,我们将能够让脚本对某些变化或用户输入作出反应,变得更加灵活。让我们看看有哪些操作符可用。

逻辑非(!)

非运算符用于测试表达式是否为真,当表达式为假时它为真:

[! expression ]  

让我们回到之前的一个脚本,并让它变得更加用户友好:

#!/bin/bash    
echo "Hello user, please give me a number between 10 and 12: "    
read user_input    
if [ ! ${user_input} -eq11 ]
then
echo "The number ${user_input} is not what we are looking for..."
else
echo "Great! The number ${user_input} is what we were looking for!"
fi  

我们在这里做的事情是要求用户输入一个介于1012之间的数字。我们从用户的输入中读取值并进行评估:如果用户输入的值不等于11,那么我们输出一个“错误”语句;否则,我们找到了我们的数字。别担心,我们将在本书后面讨论if...then...else,现在只需将if理解为一个简单的条件语句。让我们运行脚本,看看当我们输入正确答案和错误答案时会发生什么:

zarrelli:~$ ./userinput-not.sh
Hello user, please give me a number between 10 and 12: 
11
Great! The number 11 is what we were looking for!    
zarrelli:~$ ./userinput-not.sh
Hello user, please give me a number between 10 and 12: 
12
The number 12 is not what we are looking for...  

太好了!脚本变得非常互动,根据我们施加的条件和给出的输入,它的输出发生了变化。

逻辑与

与运算符测试两个或多个表达式的成功性,当所有条件都为真时,它为真。这个操作符非常有用,可以让我们的脚本变得更加复杂,这样我们必须满足至少几个条件才能触发某些操作:

#!/bin/bash    
echo "Hello user, please give me a number between 10 and 20: "    
read user_input    
if [ ${user_input} -ge 10 ] && [ ${user_input} -le 20 ]
then
echo "Great! The number ${user_input} is what we were looking for!"
else
echo "The number ${user_input} is not what we are looking for..."
fi  

在这种情况下,我们要求几个条件必须同时成立:用户输入的数字必须大于等于10并且小于等于20。让我们看看:

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
9
The number 9 is not what we are looking for...   
The number 9 is not a valid value: it is less than 20 but 
not bigger than 10.    
zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
10
Great! The number 10 is what we were looking for!  

是的,10是有效的,因为它等于10且小于20。这两个条件同时为真。我们有如下代码:

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
11
Great! The number 11 is what we were looking for!  

对于11,我们是 OK 的,因为它大于10且小于20

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
19
Great! The number 19 is what we were looking for!  

仍然是一个有效的答案,因为它大于10且小于20

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
20
Great! The number 20 is what we were looking for!  

这应该是最后一个有效的答案。值大于10并等于20,但是20是我们的上限,再多一个就超出了我们的边界:

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20: 
21
The number 21 is not what we are looking for..

这里是:大于10但也大于20,而且只有当值小于等于20时,第二个条件才成立。所以,只有一个条件成立,另一个不成立,21不是我们要找的数字。

逻辑“或”运算符(||)

OR 运算符测试两个或多个表达式的成功,并且如果至少一个条件成立,它就成立:

#!/bin/bash    
echo "Hello user, please give me a number between 10 and 20 
or between 50 and 10: "    
read user_input  
if [[ ${user_input} -ge 10 && ${user_input} -le 20 ]] || 
[[ ${user_input} -ge 50 && ${user_input} -le 100 ]]
then
echo "Great! The number ${user_input} is what we were looking 
for!"
else
echo "The number ${user_input} is not what we are looking for..."
fi  

我们在这里看到了一些有趣的事情。首先,我们使用了复合条件测试,现在可以在四个不同的条件之间进行检查,按 2 分组。如果用户给我们的数字大于等于10且小于等于20,或者他输入一个大于等于50且小于等于100的数字,我们的测试就成立。

请注意,我们不得不使用带双括号[[的测试命令;这是 Bash 对单括号的改进,应该优先使用。实际上,[ 是一个实际的二进制命令,你可以在操作系统中找到,而 [[ 只是 Bash、ZshKorn shell 中可用的关键字。

双括号有一些非常有趣的改进。例如,它不会受到词分割或通配符扩展的影响,因此能更好地处理空格和空字符串,且你不需要给变量加引号。其他优点包括你不必在双括号内转义任何括号,并且你可以在其中使用!&&||来组合不同的表达式。在我们的示例中使用了[]测试运算符,只是为了熟悉它们,但你会发现,在大多数脚本中,你通常会遇到括号用于文件或字符串测试,而测试数字时,你更喜欢使用算术操作$(()),因为前者在算术操作中已被弃用。

逗号运算符(,)

另一个实际上不属于任何其他类别的运算符是逗号运算符,它用于将算术操作连接起来。所有操作都会被评估,但只有最后一个操作的值会被返回:

zarrelli:~$ echo $((x=1, 7-2))
5
zarrelli@moveaway:~$ echo ${x}
1  

运算符评估顺序和优先级,按降序排列

运算符的评估顺序是非常精确的,在使用它们时我们必须牢记这一点。记住哪个先评估哪个后评估并不容易,所以下面的表格将帮助我们记住运算符的顺序和优先级:

运算符 评估顺序
++ -- 用于递增/递减的单目运算符,从左到右评估
+- !~ 单目加号和减号,从右到左评估
* / % 乘法、除法、取余,从左到右进行计算,并且在之后进行计算
+ - 加法和减法从左到右进行计算
<<>> 位移运算符从左到右进行计算
<= =><> 比较运算符,从左到右
== != 等式运算符,从左到右
& 位运算 AND,从左到右
^ 位运算 XOR,从左到右
| 位运算 OR,从左到右
&& 逻辑 AND,从左到右
|| 逻辑 OR,从左到右
= += -+ */ /= %= &= ^= <<= =>> }= 赋值运算符,从左到右

退出代码

我们已经看到,当程序遇到问题时,它会退出,通常会带有错误信息。退出意味着什么呢?简单来说,就是代码执行终止,程序或脚本返回一个退出代码,通知系统发生了什么。这对我们非常有用,因为我们可以捕获程序的退出代码,根据其值决定接下来要做什么。

0 成功
1 失败
2 内建命令滥用
126 命令不可执行
127 命令未找到
128 无效参数
128+x 致命错误,退出时信号 x
130 执行被 Ctrl + C 终止
255 退出状态超出边界(0-255)

所以,或许你已经猜到,每次执行都会以退出代码结束,无论成功与否,是否带有错误信息或静默退出:

zarrelli:~$ date ; echo $?
Thu  2 Feb 19:17:48 GMT 2017
0  

如你所见,退出代码是 0,因为命令没有出现问题地执行完毕。现在,让我们尝试这个:

zarrelli:~$ asrw ; echo $?
bash: asrw: command not found
127  

它是一个command not found,因为我们刚刚输入了一串无意义的字符。现在,除非我们按下 Ctrl + C,否则 while 循环不会终止。

while true ; do echo 1 ; done  

你将看到屏幕上充满了一列无限的 1。按下 Ctrl + C,你会看到:

^C  

现在,让我们检查退出代码:

zarrelli:~$ echo $?
130  

现在,让我们创建一个永无止境的脚本:

#!/bin/bash    
while true; do    
echo ${$}    
done  

虽然 true 是一个永无止境的循环,因为条件始终为真,它将打印当前 shell 的 PID 到 stdout,脚本就在这个 shell 中运行。让我们打开第二个终端,并从第一个终端启动脚本;你将看到相同的 PID 无限重复:

1764
1764
1764
1764
1764  

现在,从第二个终端,使用相同的用户或 root 用户,执行:

zarrelli:~$ kill -9 1764  

返回第一个终端,你将看到你的脚本已终止:

1764
1764
1764
1764
Killed  

是时候检查脚本的退出状态了:

zarrelli:~$ echo $?
137  

这就是 128 + 99 是我们用来杀死进程的信号。让我们再次运行脚本:

1778
1778
1778
1778  

现在通过第二个终端使用以下命令结束它:

zarrelli:~$ kill -15 1778    
1778
1778
1778
1778 Terminated  

回到第一个终端,检查脚本的退出代码:

zarrelli:~$ echo $?
143 

143 正好是 128 + 15,如我们预期的那样。

退出脚本

到目前为止,我们已经看到脚本如何根据最后一个命令的退出状态来终止,以及$?如何让我们读取退出值。这是可能的,因为每个命令都会返回一个退出代码,无论是在命令行中发出的命令,还是在脚本内部发出的命令,甚至我们可以将其视为多个命令的组合的函数也会返回一个值。现在,我们将看到脚本如何根据其自身的情况返回一个退出码,而不依赖于最后一个命令的结果:

#!/bin/bash    
counter=10    
while [ $counter -gt 0 ]; 
do
echo "Loop number: $((counter--))"
done
exit 20  

我们拿取了之前的一个脚本并加上了这个:

exit 20  

如你所见,我们使用了exit命令,并跟随一个正整数来给出退出码。记住,你可以使用任何介于以下范围内的代码:

0-255  

除去上一章中我们看到的保留值后,现在让我们运行脚本:

zarrelli:~$ ./loop-exit.sh ; echo $?
Loop number: 10
Loop number: 9
Loop number: 8
Loop number: 7
Loop number: 6
Loop number: 5
Loop number: 4
Loop number: 3
Loop number: 2
Loop number: 1
20   

就是这样。由于最后的指令成功,脚本退出值是0,但因为exit命令的存在,我们得到了20作为退出值。

让我们稍微修改一下脚本,使得退出命令位于我们的echo命令之前:

#!/bin/bash    
counter=10
exit_at=5    
while (( counter > 0 ));
do
echo "Loop number: $((counter--))"
if (( $counter <exit_at )); then
exit 18
fi
done  

注意以下几点:

  • 我们使用了$(())(())。第一个是算术扩展,会给我们一个数字,第二个是一个命令,它会返回一个退出状态,以便我们可以读取如果它为真(0),即计数器的值小于exit_at的值。

  • 我们使用了一个条件来跳出这个无限循环。一旦计数器的值小于exit_at的值,我们就使用18的退出码退出整个脚本,而不管最后一个命令,即if条件的评估是失败的(值为 1)。

现在,执行以下脚本:

zarrelli:~$ /loop-premature-exit.sh ; echo $?
Loop number: 10
Loop number: 9
Loop number: 8
Loop number: 7
Loop number: 6
Loop number: 5
18  

就是这样。脚本一旦超过5的边界就退出了,所以剩下的五个echo命令根本没有被执行,我们得到了18作为退出值。所以,现在你有了一个方便的循环,可以用它遍历项目,并在满足某个条件时停止。

我们曾说过,exit命令会阻止脚本的进一步执行,之前的示例让我们对这一点有所了解,但现在让我们修改之前的循环脚本,把exit 20命令移动到前面几行:

#!/bin/bash    
counter=10    
while [ $counter -gt 0 ];
do
exit 20
echo"Loop number: $((counter--))"
done  

现在,让我们执行它:

zarrelli:$ ./loop-upper-exit.sh ; echo $?
20  

好的,没有输出,退出值为20。脚本没有时间到达echo这一行,它被迫在之前就退出了。现在,让我们看看exit命令是如何掩盖一个“命令未找到”的退出码的:

#!/bin/bash    
counter=10    
while [ $counter -gt 0 ]; 
do
echo "Loop number: $((counter--))"
fsaapoiwe
done
exit 20  

看看echo下方的那一行,也就是一堆没有任何意义的字符,它肯定会引发一个错误:

zarrelli:~$ ./loop-error-exit.sh ; echo $?
Loop number: 10
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 9
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 8
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 7
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 6
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 5
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 4
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 3
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 2
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
Loop number: 1
./loop-error-exit.sh: line 8: fsaapoiwe: command not found
20  

它确实有用,在每个循环中我们都有错误,但总体的退出码仍然是20,因为我们通过exit命令强制了这一点。

使用exit命令我们能获得哪些好处?嗯,我们刚刚看到了一个简单易用的计数器,它可以在遍历数字、项目、数组、列表时非常有用,但从更广泛的角度来看,我们可以利用退出码检查我们创建的函数的结果,检查我们调用的命令的结果,并根据得到的值做出相应的反应。不过,要做到这一点,我们需要一种方法来验证并做出反应,检查一个条件是否满足,然后根据结果执行某些操作或其他操作。为了做到这一点,我们需要更深入地了解if...else语句以及测试运算符。

总结

在上一章,我们学习了变量;现在我们刚刚了解了如何关联它们。为变量赋值并能够对其进行一些数学或逻辑运算为我们提供了更多的灵活性,因为我们不仅仅是收集某些东西,而是将其转化为不同且全新的东西。我们还了解到,退出码有时可能成为陷阱,迷惑我们并引导我们误入歧途,这告诉我们一个重要的事情:永远不要把任何事情视为理所当然,始终仔细检查你在代码中写的内容,并始终尝试捕捉所有可能的结果和例外情况。这个看似显而易见,但由于它是如此常识,我们往往忽视了这种简单却有效的编码风格建议。

第三章:测试

到目前为止,我们强调了给脚本提供结构的重要性,使它们更具灵活性,并让它们响应一些条件和情况,以便帮助我们自动化某些日常任务,做出决策并代表我们执行操作。我们在前几章中所看到的使我们能够赋值变量,以不同的方式更改它们的值,并且还能够保存它们;但从展示的例子来看,我们需要更多的功能,而这正是本章的主题。我们将学习如何进行测试,做比较,并根据结果做出响应,我们将为脚本赋予第一个结构,当某些事情发生时做出决策。

如果...否则

让我们以之前的一个例子为例,详细分析:

#!/bin/bash    
echo "Hello user, please give me a number between 10 and 20: "
read user_input
if [ ${user_input} -ge 10 ] && [ ${user_input} -le 20 ]
then
echo "Great! The number ${user_input} is what we were looking for!"
else
echo "The number ${user_input} is not what we are looking for..."
fi  

作为一个练习,帮助理解,让我们尝试将其写成自然语言:

  1. 打印一个问候语,要求输入一个 10 到 20 之间的数字

  2. 读取用户输入并将其保存在 user_input 变量中

  3. 如果 user_input 的值大于或等于 10user_input 的值小于或等于 2,则打印一条 OK 消息给用户

  4. 否则(else),如果条件不满足,打印一条不 OK 的消息

  5. Fi 条件结束

这些是条件语句的基础,它让你根据条件进行探索:如果成功,执行一条指令;如果失败,则调用另一块指令。我们还可以使它更灵活,介绍一个备用条件,在第一个条件失败时进行检查(elif):

  • if 测试条件的退出代码是 0

  • 然后

  • 执行某个操作

  • elif 这个其他测试条件的退出代码是 0

  • 执行某个操作

  • else if 之前的任何条件返回 0

  • 执行某个操作

  • fi 我们退出条件语句

所以在这种更复杂的形式下,条件提供了更多的灵活性,记住,你可以有尽可能多的 elif 块,甚至将 if 嵌套在 if 中,尽管为了清晰起见不推荐这么做。现在,让我们通过返回代码来举一个实际生活中的例子,从创建三个 test 文件开始:

zarrelli:~$ touch test1 test2 test3  

现在,让我们创建一个小脚本,检查这三个文件是否存在:

#!/bin/sh    
echo "We are going to test for files test1 test2 test3"    
if ls test1
then
echo "File test1 exists so the ls test1 execution returns $?"
elif ls test2
then
echo "File test2 exists so the ls test2 execution returns $?"
elif ls test3
then
echo "File tes3 exists so the ls test3 execution returns $?"
else
echo "Neither or test1 or test2 or test3 exist so the the exit 
code is $?"
fi
echo "End of the script"  

现在,让我们运行它并看看发生了什么:

zarrelli:~$ ./test-files.sh 
We are going to test for files test1 test2 test3
test1
File test1 exists so the ls test1 execution returns 0
End of the script 

发生了什么?第一次对 test1 执行 ls 返回了 0,所以它成功了,条件语句没有继续测试其他选项,而是退出了语句,执行了条件外的下一条指令,这就是:

echo "End of the script"  

现在是时候看看如果遇到第一个条件失败时会发生什么,因此我们将删除 test1 文件:

rm test1  

然后再次执行脚本:

zarrelli:~$ ./test-files.sh 
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
test2
File test2 exists so the ls test2 execution retuns 0
End of the script  

还是发生了什么?第一条指令ls test1失败,因为没有test1文件可以用ls显示,因此该指令返回1。脚本然后进入条件语句的第二个条件,执行ls test2。在这种情况下,由于file2存在,命令返回0,脚本跳出了条件语句,执行了条件语句外的第一条指令:

echo "End of the script"  

让我们继续,删除test2

rm test1  

然后调用脚本:

zarrelli:~$ ./test-files.sh 
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
ls: cannot access 'test2': No such file or directory
test3
File test3 exists so the ls test3 execution retuns 0
End of the script  

由于test1test2不存在,前两个ls失败,因此前两个条件也失败,但第三个ls没有失败,因为test3仍然存在。第三个ls成功返回0,脚本退出条件语句,重新执行了条件语句外的第一条指令:

echo "End of the script"  

最后一次测试,是时候删除test3了:

rm test3  

并执行脚本:

zarrelli:$ ./test-files.sh 
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
ls: cannot access 'test2': No such file or directory
ls: cannot access 'test3': No such file or directory
Neither or test1 or test2 or test3 exist so the the exit code is 2
End of the script  

现在应该清楚发生了什么。所有的if...then条件都失败了,因此最后的手段是else部分,它报告lstest3的退出代码。完成后,脚本退出条件语句,执行了条件语句外的第一条指令,重复如下:

echo "End of the script"  

请注意,条件语句的总体退出状态是最后执行的指令的退出状态,而脚本的总体退出代码是脚本本身执行的最后一条指令的退出状态:

zarrelli:~$ ./test-files.sh ; echo $?
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
ls: cannot access 'test2': No such file or directory
ls: cannot access 'test3': No such file or directory
Neither or test1 or test2 or test3 exist so the the exit code is 2
End of the script
0  

我们看到这里脚本返回了0,这是正确的,因为最后执行的指令echo End of the script成功了。现在让我们将脚本的最后一条指令更改为:

else
:  

冒号实际上意味着什么也不做,让我们看看:

zarrelli$ ./test-files.sh ; echo $?
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
ls: cannot access 'test2': No such file or directory
ls: cannot access 'test3': No such file or directory
0  

还是0。现在,让我们进行一次逆向检查,修改第三个条件,添加一个!

elif !ls test3
then
echo "File test3 exists so the ls test3 execution retuns $?"  

所以,如果ls test3返回1,则检查成功:

zarrelli:~./test-files-not.sh ; echo $?
We are going to test for files test1 test2 test3
ls: cannot access 'test1': No such file or directory
ls: cannot access 'test2': No such file or directory
ls: cannot access 'test3': No such file or directory
File test3 exists so the ls test3 execution retuns 0
0  

好吧,打印的消息其实是一个迷惑性信息,因为ls test3的执行不成功,不能返回0

zarrelli:~$ ls test3 ; echo $?
ls: cannot access 'test3': No such file or directory
2  

实际上返回0的是我们对反向条件所做的检查:

elif !ls file3  

它可以理解为,只有当ls file3没有通过时,if条件才会被验证。因此,对于我们来说,验证是成功的,成功由返回值0表示,条件只有在if ls file3未通过时才会验证(-ne 0)。因此,在使用这样的条件时要小心,因为你可能会遇到一些意想不到的结果。

我们刚刚看到如何逐一检查条件,但我们可以在单个检查中结合运算符,以便获得更有趣的结果。看看下面的脚本:

#!/bin/bash    
echo "Hello user, please give me a number between 10 and 20, 
it must be even: "
read user_input
if [[ ${user_input} -ge 10 && ${user_input} -le 20 && 
$(( $user_input % 2 )) -eq 0 ]]
then
echo "Great! The number ${user_input} is what we were looking for!"
else
echo "The number ${user_input} is not what we are looking for..."
fi  

我们在这里做的是同时测试三个不同的条件,因此只有当用户输入一个1020之间的数字且必须是偶数时,if才会被验证。换句话说,它必须能被 2 整除,我们通过检查该值的模数是否为0来测试它。让我们尝试一些值:

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20, it 
must be even: 
8
The number 8 is not what we are looking for...  

这个数字满足第三个和第二个条件,因为它是偶数且小于 20,但是不满足第一个条件,因为它不等于或大于 10。因此,if 条件不成立,触发了 else 操作:

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20, it 
must be even: 
9
The number 9 is not what we are looking for...  

现在,这个数字不满足第一个和第三个条件,但满足第二个条件,它不等于或大于 10,它不是偶数,但小于 20,因此触发了 else 块的操作。

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20, it 
must be even: 
10
Great! The number 10 is what we were looking for!  

数字 10 是合适的。它满足第一个和第二个条件,因为它等于 10 且小于 20,并且满足第三个条件,因为它是偶数,因此触发了 then 块的操作。

zarrelli:~$ ./userinput-and.sh 
Hello user, please give me a number between 10 and 20, it 
must be even: 
15
The number 15 is not what we are looking for...  

在这种情况下,第一个和第二个条件被验证,但第三个条件没有。15 不是偶数,因此触发了 else 块的操作:

zarrelli:~$ ./userinput-and.sh
Hello user, please give me a number between 10 and 20, it 
must be even: 
20 Great! The number 20 is what we were looking for!  

20 是合适的,它大于 10,等于 20,并且可以被 2 整除,因此所有三个条件都被验证,触发了 if 块的操作:

zarrelli:~$ ./userinput-and.sh
Hello user, please give me a number between 10 and 20, it 
must be even: 
21
The number 21 is not what we are looking for...  

这个数字满足第一个条件,即大于 10,但不满足其他两个条件,因为它不是等于或小于 20,也不是偶数。因此触发了 else 块的操作:

zarrelli:~$ ./userinput-and.sh
Hello user, please give me a number between 10 and 20, it 
must be even: 
22
The number 22 is not what we are looking for...  

甚至这个数字也不合适。它满足第一个条件,即大于 10,但不满足第二个条件,因为它不是等于或小于 20。第三个条件满足,但这还不够。所以触发了 else 块的操作。

如我们所见,当处理多个条件时,我们必须非常小心我们写的内容并考虑结果,因为有时我们可能会得到我们实际上并不想要的结果。一个经验法则是,尽量保持条件的简单,或者花时间彻底检查它们。我们说简单吗?看看这个:

#!/bin/bash
echo "Hello user, please give me an even number: "
read user_input
if ! (( $user_input % 2 ))
then
echo "Great! The number ${user_input} is what we were looking for!"
fi  

我们正在使用一个算术评估复合命令,并对其进行否定以检查一个数字是否为偶数:如果模运算不失败,那么条件就成立。但看, 我们没有 else 块,我们只是评估了 if 条件并退出了条件语句,因为我们不关心对其他情况作出反应。这是典型的例子,比如计数器退出条件,就像我们之前看到的:我们希望当计数器达到特定值时退出循环,否则就让循环继续运行。让我们看看结果:

zarrelli:~$ ./userinput-and-simple.sh
Hello user, please give me an even number: 
20
Great! The number 2 is what we were looking for!
The modulo of 20 by 2 is 0:
zarrelli:~$ a=$((20 % 2 )) ; echo ${a}
0  

模运算没有给出结果。看看这个操作的返回代码:

zarrelli:~/$ ((20 % 2 )) ; echo $? 1 

结果就是这样,返回代码是 1,这意味着对我们来说不合适。所以,如果一个数字通过模运算并得到 0,返回代码就是失败,这意味着将该数字除以二没有余数。这一切意味着,如果一个数字能被 2 整除;例如,它不会给我们除法的余数,而且它是偶数。现在,让我们试试一个奇数:

zarrelli:~$ ./userinput-and-simple.shsimple.sh
Hello user, please give me an even number: 
25  

没有触发 then 块的操作,因为这是一个奇数。让我们验证一下:

zarrelli:~$ ((25 % 2 )) ; echo $?
0  

操作成功,所以我们必须得到一些余数。再检查一下:

zarrelli:~$ a=25 ; b=2 ; c=$((a/b)) ; echo ${c}
12  

我们有 12 作为 25 除以 2 的余数。所以现在我们在之前的脚本中看到的条件更加清晰了:

if ! (( $user_input % 2 ))  

if 语句会在模除二的算术操作失败时被满足。所以,如果一个数不能被 2 整除,它就是偶数,简单明了。现在是时候看看我们如何测试我们的条件了。

测试命令回顾

正如我们在之前的一些例子中所看到的,我们使用了 shell 的 built-in 测试来对变量和文件进行一些检查,并结合条件语句 if...then 使脚本能对条件作出反应:如果测试成功,它返回 0,如果失败,返回 1,这些值触发了我们到目前为止的反应。

我们可以使用几种不同的符号来执行测试,我们已经看过这些:

[expression]  

或者

[[expression]]  

我们已经讨论了这两者之间的区别,但在继续之前,让我们快速回顾一下:

  • 单括号实现了标准的符合 POSIX 的 test 命令,并且在所有 POSIX shell 中都可用。[ 实际上是一个命令,它的参数是 ],这会防止单括号接收更多的参数。

    • 一些 Linux 版本仍然有 /bin/[ 命令,但 built-in 版本在执行时具有优先权。
  • 双括号只在 Bash、zshkorn shell 中可用。

  • 双括号是一个关键字,不是一个程序,从 Bash 的 2.02 版本开始提供,并且具有一些很棒的功能,如下所示:

    • 用于正则表达式匹配的 =~ 操作符

    • === 可以用于模式匹配。

    • 你可以使用 <> 而无需转义 \> \<

    • 你可以使用 && 替代 -a,使用 || 替代 -o

    • 你不需要转义括号 \( \) 来分组表达式。

    • 通配符扩展,所以 * 可以扩展为任何内容,这在模式匹配中非常有用。

    • 你不必引用变量以确保变量内部的空格安全。

所以,看起来双括号给我们比旧命令多了一些灵活性,但在广泛使用之前,请考虑脚本的受众。如果你希望它们在不同的 shell 之间共享,并且在同一个 Bash 中在不同版本之间共享,尽量避免使用只有部分版本可用的命令或内建命令。遵循 POSIX 标准会让你的脚本更加可共享,但作为缺点,它们缺乏一些关键字(如双括号)所提供的高级功能。因此,要明智地平衡你的写作风格,并采用最适合你目标的策略。我们在可能的情况下,会使用单括号符号,以确保尽可能的兼容性。

测试文件

关于测试有很多要说的,其中最常见的任务之一是检查文件系统中的文件或目录是否可用或具有某些权限。因此,想象一下一个需要在目录中写入一些数据的脚本:首先,我们应该检查目录是否存在,然后是否可以向其写入,最后检查我们将要打开以写入的文件与已存在的文件之间是否存在名称冲突。让我们看看我们用来执行一些文件和设备上测试的操作符,并记住它们在条件满足时返回 true

  • -e: 如果文件存在,则返回 true:
zarrelli:~$ ls test-files.sh 
test-files.sh  

我们刚刚验证了文件 test-files.sh 的存在,因为 ls 显示了它:

zarrelli:~$ if [ -e test-files.sh ] ; then 
echo "Yes, this is a file!" ; fi
Yes, this is a file device!  

我们的测试确认了这一点,并显示了一个良好的消息。

现在让我们验证一下,我们当前目录中没有名为 aaaaa 的文件:

zarrelli:~$ lsaaaaa
ls: cannot access 'aaaaa': No such file or directory 

好的,没有这样名称的文件;让我们进行测试:

zarrelli:~$ if [ -e aaaaa ] ; then echo "Yes, this is a file!"; 
else 
echo "There is not such a file!" ; fi
There is not such a file!  

好吧,正如您所见,我们使用分号来分隔语句的不同部分。在脚本中,我们会看到以下内容:

#!/bin/bash
if [ -e aaaaa ]
then
echo "Yes, this is a file!"
else
echo "There is not such a file!"
fi  

每个单独的命令必须适当地以新行或 ; 终止。在 ; 分隔的代码块之前执行每个命令,而无需新行。

  • -a: 具有与 -e 相同的目的,但已弃用。

  • -b: 这检查文件是否实际上是块设备,例如磁盘、CD-ROM 或磁带设备:

zarrelli:~$ if [ -b /dev/nvme0n1p1 ] ; 
then 
echo "Yes, this is a block device!" ; fi
Yes, this is a block device!  

  • -d: 这检查文件是否实际上是一个目录:
zarrelli:~$ if [ -d test ] ; 
then echo "Yes, this is a directory!" ; 
else echo "There is not such a directory!" ; fi
Yes, this is a directory!  

  • -f: 检查文件是否为常规文件,而不是类似字符设备、目录或块设备的东西:
zarrelli:~$ if [ -f /dev/tty7 ] ; 
then echo "Yes, this is a regular file!" ; 
else echo "There is not a regular file!" ; fi
There is not a regular file!  

好的,这是一个代表终端的文件,所以显然不是像 test.file 可能是的常规文件:

zarrelli:~$ touch test.file    
zarrelli:~$ if [ -f test.file ] ; 
then echo "Yes, this is a regular file!" ; 
else echo "There is not a regular file!" ; fi
Yes, this is a regular file!  

  • -c: 测试参数是否为字符文件:
zarrelli:~$ if [ -c /dev/tty7 ] ; 
then echo "Yes, this is a character file!" ; 
else echo "There is not a character file!" ; fi
Yes, this is a character file!  

  • -s: 如果文件不为 0 大小,则为 true:
zarrelli:~$ if [ -s test.file ] ; 
then echo "Yes, the size of this file is not 0!" ; 
else echo "The size of this file is 0!" ; fi
The size of this file is 0!  

好吧,我们刚刚 touch 了这个文件,所以创建了一个大小为 0 字节的文件。让我们用一个字符填充它:

zarrelli:~$ echo 1 >>test.file 

现在,让我们重复测试:

zarrelli:~$ if [ -s test.file ] ; 
then echo "Yes, the size of this file is not 0!" ; 
else echo "The size of this file is 0!" ; fi
Yes, the size of this file is not 0!  

  • -g: 如果目录设置了 sgid 标志,则为 true。正如我们所见,设置组 ID 在目录上强制新创建的文件归属于拥有该目录的组:
zarrelli:~$ if [ -g test ] ; 
then echo "Yes, this dir has a sgid bit" ; 
else echo "No sgid bit on this dir" ; fi
No sgid bit on this dir  

现在:

zarrelli:~$ chmodg+s test
zarrelli:~$ if [ -g test ] ; 
then echo "Yes, this dir has a sgid bit" ; 
else echo "No sgid bit on this dir" ; fi
Yes, this dir has a sgid bit  

  • -G: 如果组 ID 与您的相同,则为 true。先在文件上进行测试:
zarrelli:~$ if [ -G test.file ] ; 
then echo "Yes, this file has your same group owner" ; 
else echo "No the group owner is not the same of yours" ; fi
Yes, this file has your same group owner  

现在针对目录:

zarrelli:~$ if [ -G test ] ; 
then echo "Yes, this file has your same group owner" ; 
else echo "No the group owner is not the same of yours" ; fi
Yes, this file has your same group owner  

让我们再次验证更改 test.file 的组所有者:

zarrelli:~$ su
Password: 
root:# chgrp root test.file
root:# ls -lahtest.file
-rw-r--r-- 1 zarrelli root 2 Feb  6 18:23 test.file
root:# exit
exit
zarrelli:~$ if [ -G test.file ] ; 
then echo "Yes, this file has your same group owner" ; 
else echo "No the group owner is not the same of yours" ; fi
No the group owner is not the same of yours  

  • -O: 如果您是所有者,则为 true:
zarrelli$ if [ -O test.file ] ; 
then echo "Yes, you are the owner" ; 
else echo "No you are not the owner" ; fi
Yes, you are the owner
zarrelli:~$ ls -lahtest.file
-rw-r--r-- 1 zarrellizarrelli 2 Feb  6 18:23 test.file  

  • -N: 如果文件自上次读取以来已修改,则为 true。当您想要备份文件或仅查看是否添加了新信息时,这可能会非常有用。典型的场景可能是一个由进程或服务馈送的日志文件或数据文件:如果在一定的时间内文件未被修改,则可能意味着该进程未运行或未正常工作,因此我们可以做一些像重新启动它的操作。让我们看看我们之前的某个脚本:
zarrelli:~$ if [ -N userinput-or.sh ] ; 
then echo "Yes, it has been modified since last read" ; 
else echo "No modifications since last read " ; fi
No modifications since last read   

好的,看起来文件似乎最近没有修改过,所以现在是修改它的时候了:

zarrelli:~$ echo 1 >> userinput-or.sh 
zarrelli:~$ if [ -N userinput-or.sh ] ; 
then echo "Yes, it has been modified since last read" ; 
else echo "No modifications since last read " ; fi
Yes, it has been modified since last read  

就是这样。请记住,在所有测试中,当我们说条件验证时测试为真时,我们暗示了第二个条件,即文件必须存在。所以在这种情况下,我们会说,如果文件存在且自上次读取后没有被修改,则验证通过。另外,请记住,在 Unix 中一切皆文件,包括目录。

  • -u:如果设置了suid位,则为真。这种测试可以非常有用,原因有很多,尤其是因为当你运行一个可执行文件时,它通常会以调用它的用户的权限运行。而如果设置了suid位,该可执行文件将以该文件所有者的权限运行,而不是以调用者的权限运行。因此,带有suid位的 root 拥有的程序可能会对系统安全构成严重威胁,因为调用它的任何人都将获得 root 权限。另一方面,一些程序,尤其是那些必须拥有 root 权限才能访问设备的程序,必须设置suid位,因为这可以允许普通用户像 root 一样访问设备,而无需访问完整的 root 环境:
zarrelli:$ su
Password: 
root:# chown root test.file
root:# ls -lahtest.file
-rw-r--r-- 1 root zarrelli 2 Feb  6 18:23 test.file
root:# chmod +s test.file
root:# ls -lahtest.file
-rwSr-Sr-- 1 root zarrelli 2 Feb  6 18:23 test.file
root:# exit
exit
zarrelli:~$ if [ -u test.file ] ; 
then echo "Yes, it has the suid bit flagged" ; 
else echo "No suid bit found" ; fi  

  • -k:如果设置了sticky位,则为真。这种权限非常有趣,因为如果它应用于文件,它会使文件保持在内存中,从而加速访问;但如果应用于目录,则限制用户权限:只有目录所有者或目录内文件的所有者,才能删除该文件。这在协作环境中非常有用,因为多个用户可以将工作文件放在同一目录中,并且对该目录应用 sticky 位后,只有文件所有者才能删除他们自己的文件:
zarrelli:~$ chmod +t test
zarrelli:~$ if [ -k test ] ; 
then echo "Yes, it has the sticky bit set" ; 
else echo "No sticky bit set" ; fi
Yes, it has the sticky bit set  

  • -r:如果为执行测试的用户设置了读取权限,则为真:
zarrelli:~$ if [ -r test.file ] ; 
then echo "Yes, this user can read the file" ; 
else echo "No this user cannot read the file" ; fi
Yes, this user can read the file  

所以,用户可以读取文件,让我们检查接下来的内容:

zarrelli$ ls -lahtest.file
-rwSr-Sr-- 1 root zarrelli 2 Feb  6 18:23 test.file  

哦,文件由 root 拥有,并且 root 有读取权限,那么为什么测试在 root 有读取权限的情况下成功呢?很简单:

zarrelli:~$ su
Password: 
root:# chmodog-r test.file
root:# exit
exit
zarrelli:~$ if [ -r test.file ] ; 
then echo "Yes, this user can read the file" ; 
else echo "No this user cannot read the file" ; fi
No this user cannot read the file  

发生了什么?第一次我们尝试测试时,所有者是 root,但用户zarrelli仍然通过组权限和其他权限能够读取文件。因此,清除这些位使得文件只能由 root 用户读取,其他人无法访问。

  • -w:如果写入位被设置,则为真:
zarrelli:~$ if [ -w test.file ] ; 
then echo "Yes, this user can write to the file" ; 
else echo "No this user cannot write to the file" ; fi
No this user cannot write to the file  

有趣,让我们看看文件:

zarrelli:~$ ls -lahtest.file
-rw---S--- 1 root zarrelli 2 Feb  6 18:23 test.file  

确实,只有 root 用户可以写入它。你想尝试修复问题然后再次运行测试吗?

  • -x:如果执行位被设置,则为真:
zarrelli:~$ if [ -x test.file ] ; 
then echo "Yes, this user can execute it" ; 
else echo "No this user cannot execute it" ; fi
No this user cannot execute it  

让我们看看文件的访问权限:

zarrelli$ ls -lah test.file
-rw---S--- 1 root zarrelli 2 Feb  6 18:23 test.file  

所以没有给用户执行位。现在,我们来测试一个目录:

zarrelli:~$ if [ -x test ] ; 
then echo "Yes, this user can execute it" ; 
else echo "No this user cannot execute it" ; fi
Yes, this user can execute it  

有趣,是时候看看目录的权限了:

zarrelli:~$ ls -lah test
total 8.0K
drwxr-sr-t 2 zarrellizarrelli 4.0K Feb  6 18:12 .
drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 08:12 ..  

那些点和双点是什么?让我们仔细看看:

zarrelli$ ls -lai
total 8
5900830 drwxr-sr-t 2 zarrellizarrelli 4096 Feb  6 18:12 .
5899440 drwxr-xr-x 3 zarrellizarrelli 4096 Feb  7 08:12 ..  

请记住第一列。这些是与 ...相关的inode编号:

zarrelli@:~$ cd ..
zarrelli@:~$ ls -lai
total 36
5899440drwxr-xr-x 3 zarrellizarrelli 4096 Feb  7 08:12 .
5899435 drwxr-xr-x 3 zarrellizarrelli 4096 Feb  7 20:47 ..
5900830 drwxr-sr-t 2 zarrellizarrelli 4096 Feb  6 18:12 
test
5899311 -rw---S--- 1 root     zarrelli    2 Feb  6 18:23 
test.file
5899447 -rwxr--r-- 1 zarrellizarrelli  319 Feb  5 11:42 
test-files-not.sh
5899450 -rwxr--r-- 1 zarrellizarrelli  317 Feb  5 11:21 
test-files.sh
5898796 -rw-r--r-- 1 zarrellizarrelli    0 Feb  7 08:00 
test.modified
5899448 -rwxrwxr-x 1 zarrellizarrelli  352 Feb  5 12:31 
userinput-and.sh
5899449 -rwxr-xr-x 1 zarrellizarrelli  190 Feb  5 12:41 
userinput-and-simple.sh
5899444 -rwxrwxr-x 1 zarrellizarrelli  305 Feb  7 08:07 
userinput-or.sh  

test 目录中的 . 的 inode 值是 5900830,现在我们向上移动一层目录,我们可以看到 test 目录的 inode 值是 5900830。所以我们可以安全地说,. 指向我们所在的目录。那么 .. 呢?看看父目录中 . 的值,它是 5899440。现在看看 test 目录中 .. 的值,它是 5899440,因此我们可以安全地说,.. 指向父目录,因为两者都指向相同的 inode。

简单来说,要理解 inode 与文件之间的关系,我们可以说,在 Unix 风格的文件系统中,inode 是一个元数据结构,描述文件和目录的属性,例如类型、时间戳、大小、访问权限、链接计数,以及指向磁盘块的指针,这些磁盘块存储构成文件或目录内容的数据。每个 inode 编号实际上是一个索引,允许内核访问文件或目录及其内容和属性,就像数组中的索引一样。实际上,如果你知道如何查看文件,你可以了解它的很多信息:

zarrelli:~$ stat test
 File: test
 Size: 4096      Blocks: 8          IO Block: 4096   
  directory
Device: fd01h/25025aInode: 5900830     Links: 2
Access: (3755/drwxr-sr-t)  Uid: ( 1200/zarrelli)   Gid: ( 1200/zarrelli)
Access: 2017-02-06 18:12:53.376827639 +0000
Modify: 2017-02-06 18:12:53.376827639 +0000
Change: 2017-02-07 19:26:15.936253432 +0000
 Birth: -  

所以,我们可以说,指向相同 inode 编号的东西,指向的是相同的数据结构、相同的文件或目录,这就是将 ... 链接到父目录和当前目录表示的原因,就像我们通过跟踪 inode 编号所证明的那样。

  • -h -L:如果文件是链接,则此选项为真。链接是指向另一个文件的指针,您可以拥有软链接或硬链接:

    • 符号链接是指向文件的引用,可以跨越不同的文件系统。它是一种特殊类型的文件,保存对另一个文件的引用,因此当操作系统试图访问该链接时,它会将其识别为链接,并将所有操作重定向到实际的目标文件。如果目标文件被删除,链接仍然存在,但指向空值。符号链接的主要限制是它在操作中会产生额外的开销;操作系统必须将所有操作从符号链接重定向到目标文件。

    • 硬链接是指向相同 inode 的另一个文件,由于 inode 是文件系统的元结构,硬链接无法跨越文件系统。一旦原始文件被删除,硬链接不受影响,因为它指向一个有效的 inode,而该 inode 即使文件被删除后仍然存在于文件系统中。一个限制是它不能指向目录。

所以,为了简单起见,我们可以说符号链接是指向文件的名称指针,而硬链接是指向 inode 的指针。我们从一个软链接开始,看看一些实际差异:

zarrelli:~$ ln -s test.filenew.test.file
zarrelli@:~$ ls -lah | grep new
lrwxrwxrwx 1 zarrellizarrelli    9 Feb  7 21:48 new.test.file ->test.file  

让我们 cat 链接:

zarrelli:~$ su
Password: 
root:# chmoda+rtest.file
root:# exit
exit
zarrelli:~$ cat new.test.file
1  

现在,让我们删除原始文件:

zarrelli:~$ rmtest.file

并验证我们是否可以通过链接访问内容:

zarrelli:~$ cat new.test.file
cat: new.test.file: No such file or directory  

使用 ls 可以清楚地看到问题:

zarrelli:~$ ls -lah
total 32K
drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 22:06 .
drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 22:02 ..
lrwxrwxrwx 1 zarrellizarrelli    9 Feb  7 21:48 
new.test.file ->test.file
drwxr-sr-t 2 zarrellizarrelli 4.0K Feb  6 18:12 
test
-rwxr--r-- 1 zarrellizarrelli  319 Feb  5 11:42 
test-files-not.sh
-rwxr--r-- 1 zarrellizarrelli  317 Feb  5 11:21 
test-files.sh
-rw-r--r-- 1 zarrellizarrelli    0 Feb  7 08:00 
test.modified
-rwxrwxr-x 1 zarrellizarrelli  352 Feb  5 12:31 
userinput-and.sh
-rwxr-xr-x 1 zarrellizarrelli  190 Feb  5 12:41 
userinput-and-simple.sh
-rwxrwxr-x 1 zarrellizarrelli  305 Feb  7 08:07 
userinput-or.sh  

链接仍然存在,但原始文件已经消失,因此当操作系统尝试访问它时,它会失败。让我们重新创建原始文件和链接:

zarrelli:~$ rmnew.test.file
zarrelli:~$ rmnew.test.file
zarrelli:~$ echo 1 >test.file
zarrelli:~$ ln -s test.filenew.test.file  

现在,让我们跨越 /boot 文件系统创建链接。首先,让我们检查 /boot 是否挂载在自己的分区和文件系统上:

zarrelli:~$ mount  | grep boot
/dev/nvme0n1p1 on /boot type ext2 (rw,relatime,block_validity,barrier,user_xattr,acl)  

好的,它确实是另一个文件系统,所以让我们使用软链接:

zarrelli:~$ su
Password:     
root:# ln -s test.file /boot/boot.test.file  

然后访问该链接:

root:# cat /boot/boot.test.file
cat: /boot/boot.test.file: No such file or directory  

嗯,似乎出了点问题。发生了什么?用 ls -lah 查看一下:

root:# ls -lah /boot/boot.test.file
lrwxrwxrwx 1 root root 9 Feb  7 22:19 /boot/boot.test.file 
->test.file  

嗯,尽管我们从另一个目录进行了链接,但链接指向 /boot 中的 test.file。我们需要一个绝对引用:

root:# ln -s /home/zarrelli/test.file /boot/new.test.file
root:# cat /boot/new.test.file
1  

现在,它可以工作了。最后,让我们看看如果尝试链接一个目录会发生什么:

zarrelli:~$ ln -s test new.test  

我们将 new.test 链接到 test 目录,现在只需检查 test 目录是否为空:

zarrelli:~$ ls -lah test
total 8.0K
drwxr-sr-t 2 zarrellizarrelli 4.0K Feb  7 22:29 .
drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 22:26 ..  

现在,让我们使用链接创建一个文件:

zarrelli:~$ echo 2 >new.test/testing
zarrelli:~$ cat test/testing 
2  

然后让我们看看 test 中有什么:

zarrelli:~$ cat test/testing 
2  

这里,我们可以通过 new.test 软链接访问 test 中的数据。

现在是时候跨文件系统使用硬链接了:

zarrelli:~$ su
Password: 
root:# ln  /home/zarrelli/test.file /boot/hard.test.file
ln: failed to create hard link '/boot/hard.test.file' =>'/home/zarrelli/test.file': Invalid cross-device link  

不行!我们无法跨文件系统使用硬链接,因为 inode 限制阻止了我们这么做。

现在,让我们尝试链接一个目录:

zarrelli:~$ ln test hard.test
ln: test: hard link not allowed for directory  

再次,无法继续。现在,让我们尝试在同一个文件系统中进行硬链接:

zarrelli:~$ lntest.filehard.test.file  

没问题,但让我们看看 inode 情况:

zarrelli:~$ ls -lai
total 40
5899440 drwxr-xr-x 3 zarrellizarrelli 4096 Feb  7 22:35 .
5899435 drwxr-xr-x 3 zarrellizarrelli 4096 Feb  7 22:33 ..
5899465 -rw-r--r-- 2 zarrellizarrelli    2 Feb  7 22:11 
hard.test.file
5899355 lrwxrwxrwx 1 zarrellizarrelli    4 Feb  7 22:26 
new.test -> test
5900839 lrwxrwxrwx 1 zarrellizarrelli    9 Feb  7 22:12 
new.test.file ->test.file
5900830 drwxr-sr-t 2 zarrellizarrelli 4096 Feb  7 22:31 
test
5899465 -rw-r--r-- 2 zarrellizarrelli    2 Feb  7 22:11 
test.file
5899447 -rwxr--r-- 1 zarrellizarrelli  319 Feb  5 11:42 
test-files-not.sh
5899450 -rwxr--r-- 1 zarrellizarrelli  317 Feb  5 11:21 
test-files.sh
5898796 -rw-r--r-- 1 zarrellizarrelli    0 Feb  7 08:00 
test.modified
5899448 -rwxrwxr-x 1 zarrellizarrelli  352 Feb  5 12:31 
userinput-and.sh
5899449 -rwxr-xr-x 1 zarrellizarrelli  190 Feb  5 12:41 
userinput-and-simple.sh
5899444 -rwxrwxr-x 1 zarrellizarrelli  305 Feb  7 08:07 
userinput-or.sh  

硬链接指向原始文件的相同 inode,软链接则不指向。现在再次删除原始文件并尝试 cat 硬链接和软链接:

zarrelli:~$ rmtest.file
zarrelli:~$ cat new.test.file
cat: new.test.file: No such file or directory
zarrelli:~$ cat hard.test.file
1  

正如我们预期的那样,软链接失败了,因为没有文件名可以指向,而硬链接成功了,因为 inode 仍然存在:

zarrelli$ ls -lahi
total 36K
5899440 drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 22:40 .
5899435 drwxr-xr-x 3 zarrellizarrelli 4.0K Feb  7 22:33 ..
5899465 -rw-r--r-- 1 zarrellizarrelli    2 Feb  7 22:11 
hard.test.file
5899355 lrwxrwxrwx 1 zarrellizarrelli    4 Feb  7 22:26 
new.test -> test
5900839 lrwxrwxrwx 1 zarrellizarrelli    9 Feb  7 22:12 
new.test.file ->test.file
5900830 drwxr-sr-t 2 zarrellizarrelli 4.0K Feb  7 22:31 
test
5899447 -rwxr--r-- 1 zarrellizarrelli  319 Feb  5 11:42 
test-files-not.sh
5899450 -rwxr--r-- 1 zarrellizarrelli  317 Feb  5 11:21 
test-files.sh
5898796 -rw-r--r-- 1 zarrellizarrelli    0 Feb  7 08:00 
test.modified
5899448 -rwxrwxr-x 1 zarrellizarrelli  352 Feb  5 12:31 
userinput-and.sh
5899449 -rwxr-xr-x 1 zarrellizarrelli  190 Feb  5 12:41 
userinput-and-simple.sh
5899444 -rwxrwxr-x 1 zarrellizarrelli  305 Feb  7 08:07 
userinput-or.sh  

一个简单的经验法则是,不要使用软链接来引用频繁访问的文件,如网页。例如,可以将它们用于 config 文件,因为这些文件通常只在启动时读取。软链接比较慢。硬链接适合引用文件,并且能够保留其内容,无论原文件发生什么:

  • -p:如果文件是管道,则为真。回想一下我们在第一章中关于管道和命名管道的内容;它们是让不同进程之间进行通信的一种方式。如果一个进程是由父进程生成的,这并不算什么大事,它们共享相同的环境和文件描述符。在父进程的打开描述符中,子进程将能够按写入顺序读取数据,使用缓冲区内核来保存等待读取的位。如果进程之间没有共享相同的环境,我们可以使用管道,它利用一个文件,进程可以依附在该文件上:该文件通常会比使用它的进程存在得更久,通常直到重启时才会被删除或重定向。这类进程间通信结构被称为 命名管道先进先出 (FIFO) 管道,基于数据处理的顺序。让我们做个示例,创建一个管道,可以使用 mkfifomknode
zarrelli:~$ mkfifomyfifo  

现在,让我们打开第二个终端,并在相同目录下执行以下命令:

zarrelli:~$ cat myfifo  

该进程将挂起,等待读取某些内容。现在,让我们回到第一个终端并向命名管道写入一些内容:

zarrelli:~$ echo hello >myfifo  

现在回到第二个终端查看发生了什么:

zarrelli:~$ cat myfifo
hello  

我们将在接下来的章节中进一步了解命名管道,现在我们只需检查 myfifo 是否真的是一个命名管道:

zarrelli:~$ if [ -p myfifo ] ; 
then echo "Yes, it is a named pipe" ; 
else echo "No it is not a pipe" ; fi
Yes, it is a named pipe  

  • -S:如果它是一个套接字,则返回为真。正如我们之前看到的,套接字是同一系统内或跨网络的两个设备之间的端点,它允许设备交换数据,正如我们看到的管道和命名管道一样。正如你可以轻易猜到的,Linux 系统有很多套接字:
    zarrelli:~$ if [ -S /tmp/OSL_PIPE_1000_SingleOfficeIPC_39e ] ; then echo "Yes, it is a named pipe" ; else echo "No it is not a pipe" ; fi
Yes, it is a named pipe

我们可以通过查看ls列出的文件属性来进行双重检查:

zarrelli:~$ ls -lah /tmp/OSL_PIPE_1000_SingleOfficeIPC_39e 
srwxr-xr-x 1 zarrellizarrelli 0 Feb 12 09:02 /tmp/OSL_PIPE_1000_SingleOfficeIPC_39e

我们可以看到权限列表的开头有一个s字符,这表明这个文件正如我们预期的那样,是一个套接字。

  • -t:如果文件链接到终端,则返回为真。几乎所有指南中都会有一个经典的用途:检查脚本中的stdinstderr是否链接到终端。让我们检查一下我们的 shell 的stdout
zarrelli:~$ if [ -t 1 ] ; 
then echo "Yes, it is associated to a terminal" ; 
else echo "No it is not associated to a terminal" ; fi
Yes, it is associated to a terminal  

需要再双重检查一下吗?很简单,因为我们在当前 shell 中执行的命令的输出已经打印到连接到终端的显示器上。

  • file -ntother_file:如果文件比其他文件更新(通过修改日期进行比较),返回为真。让我们看一下:
zarrelli:~$ touch other_file ; 
for i in {1..10}; do : ; done ; touch file; 
if [ file -ntother_file ] ; 
then echo "Yes, file is newer then other_file" ; 
else echo "No file is not newer than other_file" ; fi
Yes, file is newer then other_file  

那么,我们做了什么?我们简单地串联了一些命令,并且由于other_file一定是较旧的文件,我们从创建它开始。然后,我们设置了一个简单的"for"循环,迭代从110,什么都不做,正如双冒号所暗示的那样,因为我们需要让一些时间过去,然后才创建较新的文件。经过10次迭代后,我们创建了一个新文件并进行比较。这个例子中有一点值得注意的是:

{1..10}  

这是一个自 Bash 3.0 版本以来就有的构造。让我们快速设置一个范围,写出起始数字和结束数字。从 Bash 4.0 版本开始,我们还可以定义一个带有增量的范围,形式如下:

{start..end..step}  

就像下面的例子一样:

zarrelli:~$ touch other_file ; 
for i in {1..120..2}; do if [ $(($i%5)) -eq 0 ] ; 
then echo "Waiting...cycle $i" ; fi ; sleep 1 ; done ; 
touch file; if [ file -ntother_file ] ; 
then echo "Yes, file is newer then other_file" ; 
else echo "No file is not newer than other_file" ; fi
Waiting...cycle 5
Waiting...cycle 15
Waiting...cycle 25
Waiting...cycle 35
Waiting...cycle 45
Waiting...cycle 55
Waiting...cycle 65
Waiting...cycle 75
Waiting...cycle 85
Waiting...cycle 95
Waiting...cycle 105
Waiting...cycle 115
Yes, file is newer then other_file  

好吧,这里加了一些东西:循环现在从1120,步长为 2,只有在循环数字能被 5 整除时才打印消息,这样就不会使屏幕太乱。最后,我们使用sleep命令让每个循环等待 1 秒,以确保在循环结束时,我们在创建最后一个文件并进行比较之前,已经花费了 60 秒的时间。

我们还可以使用特定的日期修改文件的时间,即使是回到过去,使用touch命令和格式-t YYMMDDHHmm

zarrelli:~$ ls -lahtest.file

-rw-r--r-- 3 zarrellizarrelli 0 Feb 12 14:56 test.file

touch -t 8504251328 test.file

zarrelli:~$ ls -lahtest.file

-rw-r--r-- 3 zarrellizarrelli 0 Apr 25 1985 test.file

  • file -otother_file:如果文件比other_file旧,返回为真:
zarrelli:~/$ if [ userinput-or.sh -otother_file ] ; 
then echo "Yes, the first file is older than the second" ; 
else echo "No the first file is not older than the second" ; fi  

确实如此:

zarrelli:~/$ ls -laht userinput-or.sh other_file
-rw-r--r-- 1 zarrellizarrelli   0 Feb 12 14:16 other_file
-rwxrwxr-x 1 zarrellizarrelli 305 Feb  7 08:07 userinput-or.sh  

现在只需触摸userinput-or.sh

zarrelli:~$ if [ userinput-or.sh -otother_file ] ; 
then echo "Yes, the first file is older than the second" ; 
else echo "No the first file is not older than the second" ; fi
No the first file is not older than the second  

  • file -efother_file:如果文件和其他文件共享相同的 inode 号和设备,则返回为真。你是否已经听说过具有相同 inode 号的文件,并且它们不能位于不同设备上?是的,我们称之为硬链接:
zarrelli$ rmtest.file
zarrelli:~$ rmnew.test.file
zarrelli:~$ touch test.file
zarrelli$ lntest.filenew.test.file
zarrelli$ lntest.fileother.new.test.file
zarrelli:~$ if [ new.test.file -efother.new.test.file ] ; 
then echo "Yes, the files share the same inode number and device" ; else echo "No the files do not share the same inode number and device" ; fi
Yes, the files share the same inode number and device  

现在让我们再做一次双重检查:

zarrelli:~$ lls -laihtest.fileother.new.test.fileother
.new.test.file
5900839 -rw-r--r-- 3 zarrellizarrelli 0 Feb 12 14:56 other.new.test.file
5900839 -rw-r--r-- 3 zarrellizarrelli 0 Feb 12 14:56 other.new.test.file
5900839 -rw-r--r-- 3 zarrellizarrelli 0 Feb 12 14:56 
test.file  

这三个文件共享相同的 inode,因为 inode 是文件系统的元结构,所以它们都在同一个文件系统中。

我们已经完成了文件比较,但还有一些其他内容需要了解,例如如何测试整数,因为它们在我们看到的所有脚本中都是相当常见的。

测试整数

正如我们在文件比较中看到的,我们可以使用一些二进制操作符对整数执行类似的操作。这在我们想根据变量的值做出决策时非常有用,正如我们在之前的示例中所看到的,它是处理脚本时常见的一种操作:

  • -eq:当第一个整数等于第二个整数时为真:
#!/bin/bash    
echo "Hello user, please type in one integer and press enter:"
read user_input1 
echo "Now type in the number again and press enter:"
read user_input2 
if [ ${user_input1} -eq ${user_input2} ]
then
echo "Great! The integer ${user_input1} is equal to ${user_input2}"
else
echo "The integer ${user_input1} is not equal to ${user_input2}..."
fi  

代码相当简单,它没有对输入进行任何检查,但即使如此,它的简洁性也足以满足我们的需求:

zarrelli:~$ ./eq.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
10
Great! The integer 10 is equal to 10
zarrelli:~$ ./eq.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
20
The integer 10 is not equal to 20...  

  • -ne:当第一个整数不等于第二个整数时为真。让我们稍微修改一下之前的脚本:
if [ ${user_input1} -ne ${user_input2} ]
then
echo "Great! The integer ${user_input1} is not equal to 
${user_input2}"
else
echo "The integer ${user_input1} is equal to ${user_input2}..."
fi  

现在,让我们执行这段新的代码:

zarrelli:~$ ./ne.sh 
Hello user, please type in one integer and press enter:
100
Now type in the number again and press enter:
50
Great! The integers 100 is not equal to 50  

  • -gt:当第一个整数大于第二个整数时为真。再一次,修改几行代码:
if [ ${user_input1} -gt ${user_input2} ]
then
echo "Great! The integer ${user_input1} is greater than 
${user_input2}"
else
echo "The integer ${user_input1} is not greater than 
${user_input2}..."
fi  

现在,让我们试试:

zarrelli:~$ ./gt.sh 
Hello user, please type in one integer and press enter:
999
Now type in the number again and press enter:
333
Great! The integer 999 is greater than 333
zarrelli:~$ ./gt.sh 
Hello user, please type in one integer and press enter:
222
Now type in the number again and press enter:
888
The integer 222 is not greater than 888...  

  • -ge:当第一个整数大于或等于第二个整数时为真。以下是一些修改:
if [ ${user_input1} -ge ${user_input2} ]
then
echo "Great! The integer ${user_input1} is greater than or 
equal to ${user_input2}"
else
echo "The integer ${user_input1} is not greater than or 
equal to ${user_input2}..."
fi  

现在,这里有一些测试:

zarrelli:~$ ./ge.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
5
Great! The integer 10 is greater than or equal to 5
zarrelli@moveaway:~/Documents/My books/Mastering bash/Chapter 3/Scripts$ ./ge.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
10
Great! The integer 10 is greater than or equal to 10
zarrelli@moveaway:~/Documents/My books/Mastering bash/Chapter 3/Scripts$ ./ge.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
11
The integer 10 is not greater than or equal to 11...  

  • -lt:当第一个整数小于第二个整数时为真。所以,做一下小修改如下:
if [ ${user_input1} -lt ${user_input2} ]
then
echo "Great! The integer ${user_input1} is less than 
${user_input2}"
else
echo "The integer ${user_input1} is not less than 
${user_input2}..."
fi  

现在来做几个测试:

zarrelli:~$ ./lt.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
5
The integer 10 is not less than 5...
zarrelli:~$ ./lt.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
20
Great! The integer 10 is less than 20  

  • -le:当第一个整数小于或等于第二个整数时为真:
if [ ${user_input1} -le ${user_input2} ]
then
echo "Great! The integer ${user_input1} is less than or 
equal to ${user_input2}"
else
echo "The integer ${user_input1} is not less than or 
equal to ${user_input2}..."
fi  

现在,常规测试如下:

zarrelli:~$ ./le.sh 
Hello user, please type in one integer and press enter:
30
Now type in the number again and press enter:
20
The integer 30 is not less than or equal to 20...
zarrelli:~$ ./le.sh 
Hello user, please type in one integer and press enter:
60
Now type in the number again and press enter:
70
Great! The integer 60 is less than or equal to 70
zarrelli:~$ ./le.sh 
Hello user, please type in one integer and press enter:
70
Now type in the number again and press enter:
70
Great! The integer 70 is less than or equal to 70 

我们还有一组可以与双括号符号一起使用的操作符,它会执行算术扩展和计算:

  • <:当第一个整数小于第二个整数时为真。这是修改后的代码:
#!/bin/bash    
echo "Hello user, please type in one integer and press enter:"
read user_input1
echo "Now type in the number again and press enter:"
read user_input2
if (($user_input1 < $user_input2))
then
echo "Great! The integer $user_input1 is less than $user_input2"
else
echo "The integer $user_input1 is not less than $user_input2..."
fi  

现在,让我们测试一下:

zarrelli:~$ ./minor.sh 
Hello user, please type in one integer and press enter:
100
Now type in the number again and press enter:
50
The integer 100 is not less than 50...
zarrelli:~$ ./minor.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
100
Great! The integer 10 is less than 100  

  • <=:当第一个整数小于或等于第二个整数时为真。对之前的代码做一个小改动:
if (($user_input1 <= $user_input2))
then
echo "Great! The integer $user_input1 is less than or equal to $user_input2"
else
echo "The integer $user_input1 is not less than or equal to $user_input2..."
fi  

以及常规测试:

zarrelli:~$ ./minequal.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
20
Great! The integer 10 is less than or equal to 20
zarrelli:~$ ./minequal.sh 
Hello user, please type in one integer and press enter:
20
Now type in the number again and press enter:
10
The integer 20 is not less than or equal to 10...
zarrelli:~$ ./minequal.sh 
Hello user, please type in one integer and press enter:
20
Now type in the number again and press enter:
20
Great! The integer 20 is less than or equal to 20  

  • >:当第一个整数大于第二个整数时为真。稍作修改:
if (($user_input1 > $user_input2))
then
echo "Great! The integer $user_input1 is greater than 
$user_input2"
else
echo "The integer $user_input1 is not greater than 
$user_input2..."
fi  

以及常规测试,因为我们正在测试我们所写的所有内容:

    zarrelli:~$ ./greater.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
20
The integer 10 is not greater than 20...
zarrelli:~$ ./greater.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
5
Great! The integer 10 is greater than 5

  • >=:当第一个整数大于或等于第二个整数时为真:
if (($user_input1 >= $user_input2))
then
echo "Great! The integer ${user_input1} is greater than or equal to ${user_input2}"
else
echo "The integer ${user_input1} is not greater than or equal to ${user_input2}..."
fi  

现在,让我们运行一些检查:

zarrelli:~$ ./greaqual.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
10
Great! The integer 10 is greater than or equal to 10
zarrelli:~$ ./greaqual.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
5
Great! The integer 10 is greater than or equal to 5
zarrelli:~$ ./greaqual.sh 
Hello user, please type in one integer and press enter:
10
Now type in the number again and press enter:
20
The integer 10 is not greater than or equal to 20...  

测试字符串

我们刚才看到了一些有趣的比较,但现在事情变得更加有趣,因为我们将引入一些字符串测试,这将让我们在使脚本更加反应灵敏方面迈出一步:

  • =:当第一个字符串等于第二个字符串时为真:
#!/bin/bash 
echo "Hello user, please type a string and press enter:"
read user_input1 
echo "Now type another string and press enter:"
read user_input2 
if [ ${user_input1} = ${user_input2} ]
then
echo "Great! The string ${user_input1} is equal to ${user_input2}"
else
echo "The string ${user_input1} is not equal to ${user_input2}..."
fi

现在做一些测试:

zarrelli:~$ ./equal.sh 
Hello user, please type a string and press enter:
hello
Now type another string and press enter:
hello
Great! The string hello is equal to hello
zarrelli:~$ ./equal.sh 
Hello user, please type a string and press enter:
hello
Now type another string and press enter:
Hello
The string hello is not equal to Hello...  

现在,看看这个:

if [ ${user_input1}=${user_input2} ]
then
echo "Great! The string ${user_input1} is equal to ${user_input2}"
else
echo "The string ${user_input1} is not equal to ${user_input2}..."
fi  

现在做一些检查:

zarrelli:~$ ./equal-wrong.sh 
Hello user, please type a string and press enter:
hello
Now type another string and press enter:
hello
Great! The string hello is equal to hello
zarrelli:~$ ./equal-wrong.sh 
Hello user, please type a string and press enter:
hello
Now type another string and press enter:
Hello
Great! The string hello is equal to Hello  

有点奇怪,不是吗?我们刚刚去除了等号周围的空格,现在它不再起作用了:

zarrelli:~$ ./equal-wrong.sh 
Hello user, please type a string and press enter:
hello
Now type another string and press enter:
cat
Great! The string hello is equal to cat  

这些空格其实比你想象的更重要。我们的比较变成了赋值!所以,务必小心!

  • ==:当第一个字符串等于第二个字符串时为真,但在双括号中使用时,它的行为与=不同:
#!/bin/bash    
echo "Hello user, please type a string and press enter:"
read user_input1 
echo "Now type another string and press enter:"
read user_input2 
if [[ ${user_input1} == ${user_input2}* ]]
then
echo "Great! The string ${user_input1} is equal to ${user_input2}"
else
echo "The string ${user_input1} is not equal to ${user_input2}..."
fi  

只要第一个字符串以第二个字符串输入的字符开始,*将匹配第二个字符串中输入字符后面的所有字符:

zarrelli:~$ ./equalequal.sh 
Hello user, please type a string and press enter:
match
Now type another string and press enter:
mi
The string match is not equal to mi...  

现在我们匹配带空格的情况:

zarrelli:~$ ./equalequal.sh 
Hello user, please type a string and press enter:
this should match
Now type another string and press enter:
this
Great! The string this should match is equal to this  

小心,在使用单括号时,由于文件模式匹配和单词拆分生效,这个操作符的行为会发生变化。

  • !=: 如果第一个字符串不等于第二个字符串,则为真,但请记住,存在模式匹配;因此,我们将稍微修改前面的脚本:
if [[ ${user_input1} != ${user_input2}* ]]
then
echo "Great! The string ${user_input1} is not equal 
to ${user_input2}"
else
echo "The string ${user_input1} is equal 
to ${user_input2}..."
fi  

现在测试如下:

zarrelli:~/Documents/My books/Mastering bash/Chapter 3/Scripts$ ./equalnot.sh 
Hello user, please type a string and press enter:
this should match
Now type another string and press enter:
this
The string this should match is equal to this...
zarrelli:~$ ./equalnot.sh 
Hello user, please type a string and press enter:
this should match
Now type another string and press enter:
not this
Great! The string this should match is not equal to not this  

  • <: 如果第一个字符串在 ASCII 字母顺序中小于第二个字符串,则为真。那么,让我们修改脚本:
if [[ ${user_input1} < ${user_input2} ]]
then
echo "Great! The string ${user_input1} is less than ${user_input2}"
else
echo "The string ${user_input1} is not less than ${user_input2}..."
fi  

现在进行一些测试:

zarrelli:~$ ./less.sh 
Hello user, please type a string and press enter:
abcd
Now type another string and press enter:
bcde
Great! The string abcd is less than bcde
zarrelli:~$ ./less.sh 
Hello user, please type a string and press enter:
zcf
Now type another string and press enter:
rst
The string zcf is not less than rst...  

小心,在使用单括号时,<符号必须转义,因此条件变为:

if [ ${user_input1} \< ${user_input2} ]

  • >: 如果第一个字符串在 ASCII 字母顺序中大于第二个字符串,则为真。那么,让我们修改脚本:
if [[ ${user_input1} > ${user_input2} ]]
then
echo "Great! The string ${user_input1} is greater 
than ${user_input2}"
else
echo "The string ${user_input1} is not greater 
than ${user_input2}..."
fi  

现在进行一些测试:

zarrelli:~$ ./greater.sh 
Hello user, please type a string and press enter:
@
Now type another string and press enter:
A
The string @ is not greater than A...  

实际上,@符号在 ASCII 表中的值为40,而A的值为41,所以A大于@

小心,在使用单括号时,>符号必须转义,因此条件变为:

if [ ${user_input1} \> ${user_input2} ]

  • -z: 如果字符串为空,则为真。这里有一堆行来验证这个条件:
#!/bin/bash  
echo "Hello user, please type a string and press enter:" 
read user_input1  
if [[ -z ${user_input1} ]] 
then 
echo "Great! The string ${user_input1} is null" 
else 
echo "The string ${user_input1} is not null..." 
fi 

现在进行几个检查:

zarrelli:~$ ./null.sh 
Hello user, please type a string and press enter:    
Great! The string  is null
zarrelli:~$ ./null.sh 
Hello user, please type a string and press enter:
Hello
The string Hello is not null...  

正如你所看到的,在null值的情况下,当我们echo变量值时,屏幕上不会显示任何内容。

  • -n: 如果字符串不为空,则为真。对之前代码的一个小修改:
if [[ -n ${user_input1} ]]
then
echo "Great! The string ${user_input1} is not null"
else
echo "The string ${user_input1} is null..."
fi

现在进行一些测试

zarrelli:~$ ./notnull.sh 
Hello user, please type a string and press enter:    
The string  is null...
zarrelli:~$ ./notnull.sh 
Hello user, please type a string and press enter:
Hello
Great! The string Hello is not null  

每次测试变量时,请用引号引起来!未加引号的变量可能会导致奇怪的结果,特别是在处理空值时。

看看在单括号和未引用变量的测试中结果如何变化。请记住,user_input2未实例化,因此其值为null

#!/bin/bash
echo "Hello user, please type a string and press enter:"
read user_input1 
if [ -n ${user_input1} ]
then
echo "Great! The string ${user_input1} is not null"
else
echo "The string ${user_input1} is null..."
fi
echo "But look at this..."    
if [ -n ${user_input2} ]
then
echo "Great! The string ${user_input2} is not null"
else
echo "The string ${user_input2} is null..."
fi    
echo "And now at this..."    
if [ -n "${user_input2}" ]
then
echo "Great! The string ${user_input2} is not null"
else
echo "The string ${user_input2} is null..."
fi  

现在,运行它:

zarrelli:~$ ./nullornot.sh 
Hello user, please type a string and press enter:    
Great! The string  is not null
But look at this...
Great! The string  is not null
And now at this...
The string  is null...  

好吧,正如你所看到的,引号确实很重要!

更多关于测试的内容

我们刚才看到了一些每次检查一个条件的测试,但我们也有&&||的等效操作符,因此我们可以进行复合检查并一次测试更多条件,如下所示:

  • -a: 这是逻辑与,当两个条件都为真时,它为真。我们将这个操作符与测试命令一起使用,或者在单括号中使用。让我们重写之前的一个脚本:
#!/bin/bash    
echo "Hello user, please give me a number between 10 and 20, 
it must be even: "
read user_input
if [ ${user_input} -ge 10 -a ${user_input} -le 20 -a 
$(( $user_input % 2 )) -eq 0 ]
then
echo "Great! The number ${user_input} is what we were looking for!"
else
echo "The number ${user_input} is not what we are looking for..."
fi  

现在进行一些测试:

zarrelli@moveaway:~/Documents/My books/Mastering bash
/Chapter 3/Scripts$ ./userinput-a.sh 
Hello user, please give me a number between 10 and 20, 
it must be even: 
10
Great! The number 10 is what we were looking for!
zarrelli@moveaway:~/Documents/My books/Mastering bash
/Chapter 3/Scripts$ ./userinput-a.sh 
Hello user, please give me a number between 10 and 20, 
it must be even: 
13
The number 13 is not what we are looking for...  

  • -o: 这是逻辑或,如果其中一个条件为真,则为真。我们将这个操作符与测试命令一起使用,或者在单括号中使用。让我们重写前一个示例中的一些行:
if [ ${user_input} -ge 10 -a ${user_input} -le 20 -o 
$(( $user_input % 2 )) -eq 0 ]  

现在进行一些检查:

zarrelli:~$ ./userinput-o.sh 
Hello user, please give me a number between 10 and 20 OR even: 
15
Great! The number 15 is what we were looking for!
zarrelli:~$ ./userinput-o.sh
Hello user, please give me a number between 10 and 20 OR even: 
70
Great! The number 70 is what we were looking for!  

因此,现在如果数字在1020之间或是偶数,则是可接受的:

zarrelli:~$ ./userinput-o.sh
Hello user, please give me a number between 10 and 20 OR even: 
23
The number 23 is not what we are looking for...
zarrelli:~$ ./userinput-o.sh
Hello user, please give me a number between 10 and 20 OR even: 
24
Great! The number 24 is what we were looking for!  

总结

我们终于进入了一个有趣的主题。并不是说前面的章节不重要,而是通过测试,我们看到现在可以创建一些条件语句,让脚本对不同的情况做出反应。这不仅仅是变量的回声,我们可以获取它的值,对其进行处理,决定接下来要做什么,并据此采取行动。在这一章中,我们简要了解了脚本中的灵活性意味着什么:它必须是一个工具,这也是编写脚本的主要目标之一:它必须是一个能够代表我们做出决策并根据我们提前设定的条件作出反应的工具。

第四章:引用和转义

不是所有的东西看起来都像它的样子。我们必须记住,当处理操作符和变量时,有时我们会根据使用它们的方式得到意外的结果。一个小例子可以让这个建议更加清楚:

zarrelli:~$ ls

目录没有内容,所以它是我们的起始点:

zarrelli:~$ touch *

我们刚刚创建了一个名为 star 的文件:

zarrelli:~$ ls *
*

当我们执行 ls * 时,我们实际上会看到它:

zarrelli:~$ ls
*

即使我们没有提供任何参数,简单地执行 ls 时,我们仍然能看到这一点:

zarrelli:~$ touch 1 2 3

现在,我们创建了三个空文件:

zarrelli:~$ ls *
* 1 2 3

好吧,我们试图列出只有星号的文件,但我们看到了所有的文件。如何只显示以星号命名的文件?我们可以这样做:

zarrelli:~$ ls "*"
*

所以,现在我们已经引用了星号符号,我们可以看到以它命名的文件。为什么会这样呢?嗯,正如我所说,有一些字符对 Shell 来说有特殊意义,比如星号,它会被 Shell 扩展为所有字符。这就是为什么当我们执行 ls * 时,我们会看到目录中的所有文件,因为我们请求显示任何文件名由任意字符或数字组成(除了以点号开头的文件名)。引用星号符号可以防止 Shell 解释这个特殊字符,因此将其视为字面意义上的字符,我们只想看到名为 * 的文件。

特殊字符

我们在前几章已经使用过一些特殊字符,给出了它们的含义提示。现在,我们将仔细查看每一个字符,并研究它们对 Shell 的特殊价值,以及如何在脚本中使用它们。

井号字符(#)

这表示一个注释。每行以 # 开头的内容都会被视为注释,Shell 不会对其进行解释。让我们看看以下脚本:

#!/bin/bash
# I am a comment at the beginning of a line'
ls # I am a comment after a command
#I am a comment preceeding a command and so it is not interpreted ps

第一个井号(#)符号实际上不是一个注释,而是与随后的感叹号关联,被解释为sha-bang。第二行显示了典型的注释行,第三行是命令后的注释,第四行仍然是注释,ps 命令没有被解释和执行。让我们运行它:

zarrelli:~$ ./comment.sh
* 1 2 3 comment.sh

我们看到了 ls 命令的输出,但没有看到预期的 ps 输出。同时注意到注释没有被打印到 stdout。因为我们在代码中使用它作为注释,它不是运行时需要显示的内容。让我们再添加几行:

echo # I am a comment but you cannot see me
echo \# I am a comment but you can see me

再次运行脚本:

zarrelli:~$ ./comment.sh
* 1 2 3 comment.sh
# I am a comment but you can see me
zarrelli:~$

如你所见,第一个 echo 命令创建了一个空行,并且注释没有被考虑进去;所以,就好像我们没有给 echo 命令传递任何参数。第二个注释则更有意思。我们通过在它前面加反斜杠来转义了井号。因此,转义后的井号只是一个井号后跟一串字符,所有这些字符作为 echo 命令的参数一起被打印到 stdout。所以,要小心,因为你会发现井号有不同的含义,就像我们在涉及参数替换和变量模式匹配的段落中看到的那样。

分号字符(;)

分号是命令分隔符,允许我们将一个命令链式执行到下一个命令,就像我们在 if 构造中所做的那样。例如,看看这个:

zarrelli:~$ echo TEST > test.txt ; cat test.txt
TEST

我们创建了一个test.txt文件,并紧接着使用 cat 命令查看其内容。使用 find -exec 命令时要小心,因为分号必须进行转义。-exec 选项允许我们对 find 提供的文件执行命令:

zarrelli:~$ echo TEST > test_1.txt ; ls test_1.txt ; find -name test_1.txt 
-exec rm {} 
\; ; ls
test_1.txt
* 1 2 3 comment.sh test.txt

find -exec 对象中,分号是命令序列终止符,而不是命令分隔符,因此必须进行转义,以避免 shell 将其解释为特殊字符。在第一个转义分号之后,我们再添加第二个分号,以分隔 find 命令和随后的 ls 命令。请注意,命令中的 {} 会被 find 替换为找到的文件的完整路径。

双分号字符(;;

双分号是案例构造选项的终止符。我们将在本书后续部分看到案例构造器,但现在,您可以将其视为一种 if/then/else 结构,广泛应用于创建用户菜单:

#!/bin/sh
clear
echo "Please choose between these options:"
echo "ls for listing files"
echo "procs for listing processes"
echo "x for exit"
read input
case "$input" in
 "ls" | "LS" )
   echo "Listing files:"
   ls
   exit 0
   ;;
"procs" | "PROCS")
   echo "Listing processes:"
   ps
   exit 0
   ;;
   "x" | "X" )
   echo "exiting"
   exit 0
   ;;
   * )
   # Default catchall option
   echo "exiting"
   exit 0
   ;;
 esac

如您所见,案例构造从 case 关键字开始,并由其反转 esac 结束。它的作用是验证我们定义的不同选项中的每一个条件。每个选项之间由双冒号分隔。最后一个选项通常是星号,表示无论你输入什么,它是一个兜底选项,以防其他选项未能捕捉到用户输入的内容。让我们看一下:

Please choose between these options:
ls for listing files
procs for listing processes
x for exit
ls
Listing files:
* 1 2 3 comment.sh menu.sh test.txt

用于默认选项:

Please choose between these options:
ls for listing files
procs for listing processes
x for exit
aahadie
exiting

你是否注意到屏幕上的内容已经被清空了?这要归功于我们在文件开头写的 clear 命令,它清空了屏幕,这样你向客户写的任何内容都会出现在屏幕顶部,不会有其他内容分散用户的注意力。

案例终止符(;;&)和(;&)

这些也是增强型的案例终止符,但它们仅在 Bash 4.0 及更高版本中可用。以下是三种操作符之间的区别:

  • ;;:如果条件匹配,则不会再测试其他选项

  • ;&:这使得执行会继续到下一个条件关联的命令

  • ;;&:这使得 shell 检查选项,并在条件匹配时执行关联的命令

如果没有找到匹配项,则退出状态为0;否则,退出状态为最后执行的命令的状态。我们将在下一章中进一步了解这些终止符和案例构造器。

点字符(.

点命令是一个 Shell 内建命令,其功能与 source 相同;当在 shell 中执行时,它会执行一个文件。如果在脚本中使用,它会加载引用文件的内容:

zarrelli:~$ comment.sh
bash: comment.sh: command not found
zarrelli:~$ . comment.sh
* 1 2 3 comment.sh menu.sh test.txt # I am a comment but you can see me

从这个例子可以看到,第一次尝试失败了,因为 comment.sh 不在搜索路径中;但第二次成功了,因为点命令执行了脚本。现在,让我们看看如何将外部文件中的一些代码包含到脚本中。我们从以下代码开始编写外部文件,并从中加载:

zarrelli:~$ cat external-data
var1="Hello"
var2="Nice to see you"

现在,我们需要编写将从此文件加载的脚本:

#!/bin/bash
echo "We now source external-data file and get the variables content"
echo ". external-data"
 . external-data
echo "Now that we sourced the external file, we have access to variables content"
echo "The content of var1 is: ${var1}"
echo "The content of var2 is: ${var2}"

现在,只需看看发生了什么:

zarrelli:~$ ./sourcing.sh We now source external-data file and get the variables content
. external-data
Now that we sourced the external file, we have access to variables content
The content of var1 is: Hello
The content of var2 is: Nice to see you

如我们所见,我们实际上从外部数据文件加载了变量的内容。但还有更多,因为如果我们加载了一个外部脚本,它的代码会被执行并且还能返回值给主脚本:

#!/bin/bash
echo "Hello from the inner script, can you give me a number?"
read number
case "$number" in
        [[:digit:]] )
          echo "$number is a digit!"
          exit 0
          ;;
          * )
          # Default catchall option
          echo "Sorry, $number is not a digit"
          exit 1
          ;;
esac

这是一个简单的菜单,询问用户一个数字并进行检查;不用担心,我们稍后将展示如何匹配数字和字符:

zarrelli:~$ ./external-script.sh
Hello from the inner script, can you give me a number?
1
1 is a digit!
zarrelli:~$ ./external-script.sh
Hello from the inner script, can you give me a number?
d
Sorry, d is not a digit

有一种快速的方法可以通过使用 POSIX 字符类来匹配字符:

  • [:alnum:] 匹配字母和数字字符

  • [:alpha:] 只匹配字母字符

  • [:blank:] 只匹配制表符或空格

  • [:cntrl:] 匹配任何控制字符

  • [:digit:] 只匹配 0 到 9 之间的数字

  • [:graph:] 匹配 ASCII 表中值介于 33 到 126 之间的任何字符

  • [:lower:] 匹配小写字母字符

  • [:print:] 匹配图形字符,同时也匹配从 32 到 126 的范围,包括空格字符

  • [:space:] 匹配空格和水平制表符

  • [:upper:] 匹配大写字母字符

  • [:xdigit:] 匹配数字,但采用十六进制表示

现在,让我们做些更有趣的事情,按以下方式修改之前的外部脚本:

#!/bin/bash
echo "Hello from the inner script, can you give me an integer between 0 and 9?"
read number
case "$number" in
        [[:digit:]] )
          return 0
          ;;
          * )
          # Default catchall option
          return 1
          ;;
esac

我们没有退出,而是将一个值返回给调用脚本,这样子脚本执行结束后,主脚本的执行将继续。现在有了一个新的主脚本:

#!/bin/bash
echo "We now source external-script-return.sh file and ask the customer for a digit between 0 and 9"
echo ". external-script-return.sh"
. external-script-return.sh
return=$?
if [ "$return" -eq 0 ]
        then
        echo "The value returned is a digit between 0 and 9, the exit code was $return"
        else
        echo "The value returned is not a digit between 0 and 9, the exit code was $return"
fi

现在让我们看几个测试例子:

zarrelli:~$ ./sourcing-return.sh

我们现在将加载 external-script-return.sh 文件,并要求客户输入一个 09 之间的数字:

. external-script-return.sh
 Hello from the inner script, can you give me an integer between 0 and 9?
6
The value returned is a digit between 0 and 9, the exit code was 0
zarrelli:~$ ./sourcing-return.sh

我们现在将加载 external-script-return.sh 文件,并要求客户输入一个 09 之间的数字:

. external-script-return.sh
Hello from the inner script, can you give me an integer between 0 and 9?
25
The value returned is not a digit between 0 and 9, the exit code was 1
zarrelli:~$ ./sourcing-return.sh

我们现在将加载 external-script-return.sh 文件,并要求客户输入一个 09 之间的数字:

. external-script-return.sh
Hello from the inner script, can you give me an integer between 0 and 9?
asry
The value returned is not a digit between 0 and 9, the exit code was 1

我们使用了内建命令 return 来停止内部脚本的执行,一旦满足条件,就会返回一个退出状态到父脚本。通常,我们可以不带任何退出代码使用 return,它会返回上一个执行命令的退出状态,或者我们也可以使用一个介于 0255 之间的整数。难道不希望 return 返回一些不同于简单数字的东西吗?好吧,你不能。不过,你可以用一个小技巧来实现。让我们修改之前的子脚本:

#!/bin/bash
echo "Hello from the inner script, can you give me an integer between 0 and 9?"
read number
case "$number" in
        [[:digit:]] )
          echo "This is the integer the user gave us: $number"
          exit
          ;;
          * )
          # Default catchall option
          echo "The user did not give us an integer between 0 and 9 but this: $number"
          exit
          ;;
esac

现在让我们调用这个脚本:

#!/bin/bash
echo "We now source external-script-return-whatever.sh file and ask the customer for a digit between 0 and 9"
echo ". external-script-return-whatever.sh"
returning=$(. external-script-return-whatever.sh)
echo "The value of returning is: $returning"

我们做了什么?我们去掉了return并将消息输出到标准输出中。然后,在调用脚本中,我们使用了命令替换。这是做什么的?它简单地重新分配了命令的输出;在我们的例子中,我们将被调用脚本的输出重新分配给了返回变量。你可以使用...进行命令替换:经典的反引号,虽然它已经被$(...)所取代。所以,使用后者,因为它是当前的形式,并且允许你嵌套多个命令替换。让我们试一下:

zarrelli:~$ ./sourcing-return-whatever.sh
We now source external-script-return-whatever.sh file and ask the customer for a digit between 0 and 9
. external-script-return-whatever.sh
3
The value of returning is: Hello from the inner script, can you give me an integer between 0 and 9?
This is the integer the user gave us: 3

哦,好吧,不错!它给我们回传了来自第一个选项的回显字符串,但不幸的是,我们也捕获了要求用户输入整数的消息。那么,如何去除它呢?你应该已经知道了;只需记住,我们获取的是打印到stdout的所有内容,因此我们需要对内部脚本做一个小小的修改:

>&2 echo "Hello from the inner script, can you give me an integer between 0 and 9?"
read -s number

我们需要对调用脚本进行一次修改,以便最后一行会是这样的:

echo "$returning"

是时候测试我们的修改了:

zarrelli:~$ ./sourcing-return-whatever.sh
We now source external-script-return-whatever.sh file and ask the customer for a digit between 0 and 9
. external-script-return-whatever.sh
Hello from the inner script, can you give me an integer between 0 and 9?
This is the integer the user gave us: 4
zarrelli:~$ ./sourcing-return-whatever.sh
We now source external-script-return-whatever.sh file and ask the customer for a digit between 0 and 9
. external-script-return-whatever.sh
Hello from the inner script, can you give me an integer between 0 and 9?
The user did not give us an integer between 0 and 9 but this: dirthe

我们做了什么?首先,我们将stdout重定向到stderr;两者都与终端相连接,因此用户仍然可以看到脚本提出的问题,但该句子不会被命令替换捕获,因为命令替换只对stdout有效。然后,我们使read内建命令静默,以免它将值回显到stdout和客户输入的值;最后,我们只打印返回值,而没有任何注释。这给了我们一个整洁的输出。当你需要从函数返回一个值到主脚本体中时,这个技巧非常有用,而且不受返回内建命令 0-255 值限制的影响。一个文件可以通过以下语法被调用并传递位置参数:

. file arg1 arg2 argn

被调用的脚本将使用$1$2$n来访问参数的值。从第 10 个参数开始,必须通过${15}变量来访问值。在描述点字符之前,我们应该回顾一下上一章所说的内容:单个点是指向当前目录的链接。它在文件名中也被广泛使用,用来专门定义文件扩展名。最后,在正则表达式中,点表示匹配除换行符之外的任何单个字符。

双引号("...")

双引号也称为部分引号或弱引号,它避免了外壳对大多数特殊字符的解释。我们将在下一节中进一步探讨它们。

单引号('...)

单引号,也称为全引号或强引号,避免了外壳对所有特殊字符的解释。更多信息将在下一节中提到。

逗号字符(,)

逗号运算符将算术运算或字符串连接在一起:

zarrelli:~$ x=$((5 + 10)) ; echo $x
15
zarrelli:~$ x=$((5 + 10, 6-1)) ; echo $x
5
zarrelli@moveaway:~$ x=$((y=25, 6-1)) ; echo "The value of x is $x and the value of y is $y"
The value of x is 5 and the value of y is 25

我们在这里看到的是,即使操作被串联起来,只有最后一个操作的值会被返回。所以,x被实例化为仅6-1的值。但正如我之前提到的,我们可以使用逗号字符来连接字符串:

zarrelli@moveaway:~$ for i in {,1}1 ; do echo $i ; done
1
11
zarrelli@moveaway:~$ for i in {,1}2 ; do echo $i ; done
2
12
zarrelli@moveaway:~$ for i in {,1,}2 ; do echo $i ; done
2
12
2
zarrelli@moveaway:~$ for i in {,1,,}2 ; do echo $i ; done
2
12
2
2
zarrelli@moveaway:~$ for i in {,1,3,}2 ; do echo $i ; done
2
12
32
2

正如你所看到的,你可以将一系列值连接起来,创建新的字符串,并在此过程中做一些有趣的事情:

zarrelli:~$ for i in {"Hello ","Maybe hello ","Ok, I decided, hello "}world ; do echo $i ; done
Hello world
Maybe hello world
Ok, I decided, hello world

这很有趣,但使用它的方式由你决定。你可以在 for 语句中循环并构建一个路径列表进行检查;例如,字符串连接的应用完全取决于你的创造力。

,, 和 , () 大小写修饰符

这是 Bash 4.0 新增的功能,它强制在参数替换中进行小写转换。

^^ 和 ^ () 大小写修饰符

这是 Bash 4.0 新增的功能,它强制在参数替换中进行大写转换:

zarrelli:~$ cat parm-sub.sh
 #!/bin/bash echo "Hello, can you give me a string of characters?"
read my_string
if [[ "$my_string" =~ ^[[:alpha:]]*$ ]]
 then
 echo "Printing the variable \$my_string as \${my_string}: ${my_string}" "| No modifications"
 echo "Printing the variable \$my_string as \${my_string^}: ${my_string^}" "| The first char is uppercase"
 echo "Printing the variable \$my_string as \${my_string^^}: ${my_string^^}" "| All chars are uppercase"
 echo "Printing the variable \$my_string as \${my_string,}: ${my_string,}" "| The first char is lowercase"
 echo "Printing the variable \$my_string as \${my_string,,}: ${my_string,,}" "| All chars are lowercase"
 else
 echo "Please, input characters only"
fi

现在,我们将做几个测试,从小写字符串开始:

zarrelli:~$ ./parm-sub.sh
Hello, can you give me a string of characters?
sdoijweoi
Printing the variable $my_string as ${my_string}: sdoijweoi | No modifications
Printing the variable $my_string as ${my_string^}: Sdoijweoi | The first char is uppercase
Printing the variable $my_string as ${my_string^^}: SDOIJWEOI | All chars are uppercase
Printing the variable $my_string as ${my_string,}: sdoijweoi | The first char is lowercase
Printing the variable $my_string as ${my_string,,}: sdoijweoi | All chars are lowercase

现在,我们将测试一个大写字符串:

zarrelli:~$ ./parm-sub.sh
Hello, can you give me a string of characters?
CSEPTKAS
Printing the variable $my_string as ${my_string}: CSEPTKAS | No modifications
Printing the variable $my_string as ${my_string^}: CSEPTKAS | The first char is uppercase
Printing the variable $my_string as ${my_string^^}: CSEPTKAS | All chars are uppercase
Printing the variable $my_string as ${my_string,}: cSEPTKAS | The first char is lowercase
Printing the variable $my_string as ${my_string,,}: cseptkas | All chars are lowercase

当你想要规范化你从前一个操作或用户输入中获取的字符串时,这非常有用。

反斜杠 ()

这个转义字符用于防止特殊字符被 shell 解释。我们在前面的代码段中看到过它的使用,我们转义了\${my_input},使得 echo 能够将其字面打印出来,而不是试图输出其值。使用\具有与将变量用单引号括起来相同的效果,因此这是一个强引用,对于字面打印"'字符非常有用,这些字符通常被解释为引号字符。

斜杠 (/)

斜杠有两种不同的用途:

  • 它是路径中的文件名分隔符,如我们在/usr/lib/dbus-1.0/dbus-daemon-launch-helper示例中看到的那样。每个斜杠之间的部分都是一个目录,直到最后的文件名。

  • 它是除法的算术运算符。

'...'

这是命令替换,我们在前几页刚刚使用过;它将stdout命令赋值给一个变量。

冒号字符 (😃

冒号实际上什么也不做,除了扩展参数并执行重定向。在循环和测试中,它可以在满足条件时什么也不做。看到它如何用于评估一系列变量的参数替换也很有意思:

#!/bin/bash
x=32
y=5
: ${x?} ${y?} ${z?}
And now let's execute it:
zarrelli:~$ ./test-variable.sh
./test-variable.sh: line 5: z: parameter null or not set

这里是重定向的一个小例子:

zarrelli:~$ echo "012345679" > test.colon.2
zarrelli:~$ ls -lah test.colon.2
-rw-r--r-- 1 zarrelli zarrelli 10 Feb 19 14:18 test.colon.2
zarrelli:~$ : > test.colon.2
zarrelli:~$ ls -lah test.colon.2
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 19 14:18 test.colon.2

所以,我们在这里看到的是,冒号与>的组合为我们提供了一种快速截断普通文件的方法,而不改变其权限。如果文件不存在,它将被创建;但是,如果我们使用>>,它只会创建一个文件,而不会截断一个已存在的文件。你会发现冒号有更多奇怪的用途,比如在/etc/passwd文件中作为字段分隔符,或者作为合法的函数名称。

惊叹号 (!)

惊叹号是一个关键字,用来否定或反转一个测试或退出状态。例如,看看这个:

zarrelli:~$ ls -lah test.colon.2 ; echo $?
-rw-r--r-- 1 zarrelli zarrelli 10 Feb 19 14:22 test.colon.2
0

ls的退出码是0,是的,因为它成功执行。但我们来反转它:

zarrelli:~$ ! ls -lah test.colon.2 ; echo $?
-rw-r--r-- 1 zarrelli zarrelli 10 Feb 19 14:22 test.colon.2
1

关键字

关键字 是一个保留字,在 shell 中具有特殊意义并且是硬编码的,比如内建命令;但不同于后者,关键字不是完整的命令,而是命令构造的一部分,你只需输入以下内容即可列出这些关键字:

  • compgen -k

  • if

  • then

  • else

  • elif

  • fi

  • case

  • esac

  • for

  • select

  • while

  • until

  • do

  • done

  • in

  • function

  • time

  • {

  • }

  • !

  • [[

  • ]]

从命令行执行,但不是从脚本中执行时,感叹号会触发 bash 历史记录。

星号 (*)

星号,也被称为通配符,在文件名扩展中使用时,匹配目录中的所有文件名。文件名扩展也叫做 Globbing。它考虑了一些特殊字符,如 *,它会扩展为所有内容,?,它会扩展为任何单个字符,并且还有一些字符集,使用括号表示:

zarrelli:~$ ls -lah [es]*
-rw-r--r-- 1 zarrelli zarrelli 36 Feb 17 07:50 external-data
-rwxr--r-- 1 zarrelli zarrelli 211 Feb 17 17:40 external-script-return.sh
-rwxr--r-- 1 zarrelli zarrelli 373 Feb 18 11:58 external-script-return-whatever.sh
-rwxr--r-- 1 zarrelli zarrelli 257 Feb 17 08:22 external-script.sh
-rwxr--r-- 1 zarrelli zarrelli 391 Feb 17 17:37 sourcing-return.sh
-rwxr--r-- 1 zarrelli zarrelli 235 Feb 19 10:40 sourcing-return-whatever.sh
-rwxr--r-- 1 zarrelli zarrelli 284 Feb 17 07:53 sourcing.sh

正如我们在示例中看到的,我们列出了所有文件名以 es 开头的 * 文件。但它也将 ^ 字符解释为否定:

zarrelli@moveaway:~/Documents/My books/Mastering bash/Chapter 4/Scripts$ ls -lah [^es]*
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 16 08:35 *
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 16 08:35 1
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 16 08:35 2
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 16 08:35 3
-rwxr--r-- 1 zarrelli zarrelli 249 Feb 16 13:24 comment.sh
-rwxr-xr-x 1 zarrelli zarrelli 409 Feb 16 14:59 menu.sh
-rwxr--r-- 1 zarrelli zarrelli 700 Feb 19 11:22 parm-sub.sh
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 19 14:16 test.colon
-rw-r--r-- 1 zarrelli zarrelli 10 Feb 19 14:22 test.colon.2
-rw-r--r-- 1 zarrelli zarrelli 5 Feb 16 14:07 test.txt
-rwxr-xr-x 1 zarrelli zarrelli 42 Feb 19 14:14 test-variable.sh

在这种情况下,我们列出了当前目录下所有文件名不以 es 开头的文件。要小心,文件通配符中的 * 不会匹配以点开头的文件名:

zarrelli:~$ mkdir test
zarrelli:~$ cd test/
zarrelli:~$ touch file
zarrelli:~$ touch .another_file
zarrelli:~$ ls -l *
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 19 15:08 file

显然缺少一些内容,我们再试试这个:

zarrelli:~$ ls -lah
total 8.0K
drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 19 15:08 .
drwxr-xr-x 3 zarrelli zarrelli 4.0K Feb 19 15:08 ..
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 19 15:08 .another_file
-rw-r--r-- 1 zarrelli zarrelli 0 Feb 19 15:08 file

你也可以尝试这个,效果不错:

ls -l .*

最后的备注,你会发现星号在正则表达式中也被用作通配符,具有相同的含义,同时在算术运算中也用作乘法运算符。

双星号(**)

双星号在两种不同的上下文中使用:

  • 它在算术上下文中用作指数运算符

  • 它作为一个扩展的文件匹配通配符运算符从 Bash 4 开始使用,意味着它递归匹配文件名和目录。

所以,我们有了这个:

zarrelli:~$ for i in * ; do echo "$i" ; done
file
test2

这与以下内容不同:

zarrelli:~$ for i in ** ; do echo "$i" ; done
file
test2
test2/file2

双星号匹配所有文件和目录。如果双星号对你不起作用,可以通过 zarrelli:~$ shopt -s globstar ; for i in ** ; do echo "$i" ; done 来启用 globstar shell 选项。globstar 值改变了 shell 对双星号的解释方式,在文件名扩展中,匹配所有文件和任何子目录。如果模式后面跟着 /,则只会匹配目录和子目录:

zarrelli:~$ for i in **/ ; do echo "$i" ; done
test2/

测试运算符(?)

测试运算符可以用于几种不同的场景。我们已经在参数替换中看到,它用于检查变量是否有值。在算术运算中,它可以用来实现 C 风格的三元运算符:

#!/bin/bash
x=20
y=30
w=40
z=50
k=100
echo 'Usually you would write a control loop in the following way:'
echo 'if [[ $x -gt $y ]]'
echo '     then'
echo '       z="$w"'
echo '       echo "The value for z is: $z"'
echo '     else'
echo '       z="$k"'
echo ' echo "The value for z is: $z"'
echo 'fi'
if [[ $x -gt $y ]]
   then
      z="$w"
      echo "The value for z is: $z"
   else
      z="$k"
      echo "The value for z is: $z"
fi
echo 'But you can also use the C-style trinary operator to achieve the same result:'
echo '(( z = x>y?w:k ))'
echo 'echo "The value for z is: $z"'
(( z = x>y?w:k ))
echo "The value for z is: $z"

如你所见,C 风格的表示法更简洁,尽管它不如标准的循环表示法易读。让我们在这里尝试一下:

zarrelli:~$ ./c-style.sh

通常,你会以以下方式编写控制循环:

if [[ $x -gt $y ]]
   then
      z="$w"
      echo "The value for z is: "$z""
   else
      z="$k"
      echo "The value for z is: "$z""
fi
The value for z is: 100

但你也可以使用 C 风格的三元运算符来实现相同的结果:

(( z = x>y?w:k ))
echo "The value for z is: $z"
The value for z is: 100

如你所见,我们得到了相同的结果,但代码更加简洁。实质上,我们给出了一个以?字符结尾的条件,然后,替代结果跟随其后,使用:字符分隔。

我们看到,C 风格在循环中被广泛使用,可以定义为在循环中用于评估数学表达式的复合命令,正如在前面的例子中所看到的,它还可以用于赋值一个变量。它由三个块组成:第一个在第一次迭代前初始化变量,第二个检查退出循环的条件,第三个修改初始条件。听起来很奇怪?看这个,它会显得非常熟悉:

#!/bin/bash
for ((i = 0 ; i < 3 ; i++)); do
  echo "Counting loop number $i"
done

现在,让我们执行它:

zarrelli:~$ ./c-style-counter.sh
Counting loop number 0
Counting loop number 1
Counting loop number 2

最后,你可以在文件名扩展的通配符中找到用于文件名扩展的引号,它作为一个匹配任意单个字符的通配符;在正则表达式中,它用作单个字符匹配。

替换($)

我们已经知道这个并且使用过它,用于变量替换,使我们能够访问变量的内容:

zarrelli:~$ x=10 ; echo $x
10

它也被用于正则表达式中,以匹配行尾:

ls | grep [[:digit:]]$
1
2
3
test.colon.2

在这个例子中,ls 的输出被过滤为名称以单个整数结尾的文件。

参数替换(${})

这给我们带来了一个参数替换,我们之前在本书中已经看到过。

引号字符串扩展($'...')

这是一个引号字符串扩展,用于扩展 Unicode 或 ASCII 中的转义八进制或十六进制值:

zarrelli:~$ x=$'\110\145\154\154\157' ; echo "$x"
Hello

我们刚刚连接了一些转义的八进制值,以便得到一个漂亮且友好的 ASCII 字符串,并将其分配给变量x

位置参数($* 和 $")

第一个($*)表示所有位置参数作为一个单独的字符串,第二个($")表示所有位置参数,如下所示:

#!/bin/bash
counter=0
echo "First trying the \$*"
for i in "$*"
do
(( counter+=1 ))
echo $counter
done
counter=0
echo "And now \$@"
for i in "$@"
do
(( counter+=1 ))
echo $counter
done

现在,让我们测试它:

zarrelli$ ./positional-single.sh 1 2 3 4 5
First trying the $*
1
And now $@
1
2
3
4
5

正如我们在第一个案例中看到的,参数作为单个单词传递,但要小心,$* 必须加引号,以避免扩展时出现奇怪的副作用。$@ 将每个参数作为带引号的字符串传递,不做任何解释。

退出状态($?)

我们已经看到它表示命令、函数和脚本的退出状态:

zarrelli:~$ ls 2&>1 ; echo $?
0

进程 ID($$)

这个持有进程 IDPID),它在以下内容中出现:

#!/bin/bash
echo $$

让我们执行它:

zarrelli:~$ ./pid.sh
4772

将命令分组(command1 ; command2 ; commandn)

将命令括在括号中会让它们在子 shell 中执行,这具有一个微妙但显著的含义:在子 shell 中所做的任何事情都无法从调用的 shell 中访问,因此,如果你从一个脚本执行一个包含命令的子 shell,那么在子 shell 中所做的事情将无法传递到调用脚本:

#!/bin/bash
x=10
echo "The initial value of x is: $x"
(x=$(( x*x )) ; echo "The value of x is: $x")
echo "But outside the subshell the value of x is untouched: $x" 

现在,让我们执行代码:

zarrelli:~$ ./subshell.sh The initial value of x is: 10 The value of x is: 100 But outside the subshell the value of x is untouched: 10

如果我们回顾一下我们在第一章中读到的内容,这就变得很简单了:子 shell 继承了来自脚本的调用 shell 的环境,因此它可以访问变量x的值,但不能将任何内容注入回去。因此,在将x的值乘以自身并重新分配给变量后,我们可以在子 shell 中打印结果100。但一旦退出,我们将保留原始值x10。变量x在主脚本中从未更改;它仅在子 shell 中更改。正如我们将在本书的后面看到的那样,()也用于初始化数组:

array= (element1 element2 elementn) {a,b,c}

大括号扩展可以方便地处理多个项目:

zarrelli:~$ ls *.{[[:digit:]],txt} test.colon.2 test.txt

在这种情况下,我们展开了以任意字符开头并以点结束,后面跟着单个整数或txt后缀的文件的*。但实际上,我们可以对列表中的文件应用命令,使其生效:

zarrelli:~$ wc -l {test.txt,test.colon.2,*-data} 1 test.txt 1 test.colon.2 2 external-data 4 total 

或者简单地,我们可以这样做:

zarrelli:~$ echo {1,2,3} 1 2 3

但要小心,在大括号内不要使用空格,除非您转义或引用它们;否则您可能会遇到奇怪的问题:

{element1..elementn}

扩展的大括号扩展,从 Bash 3 开始,是创建迭代器的简便方法:

zarrelli:~$ for i in {0..5} ; do echo $i ; done 0 1 2 3 4 5

或者创建更复杂的东西,如下所示:

zarrelli:~$ for i in {1..3} ; do for k in {a..c} ; do echo $i,$k ; done ; done 1,a 1,b 1,c 2,a 2,b 2,c 3,a 3,b 3,c { command1 ; command2 ; commandn ;}

大括号广泛用于创建所谓的匿名函数,这些函数具有一个有趣的属性:这些函数内部的代码对脚本的其余部分可见。还有一种组合命令的方式,但有一些有趣的区别:

  • 命令在同一个 shell 中执行,不会生成子 shell

  • 由于这个原因,所有在括号内实例化的变量都可以从调用 shell 中访问,也就是从调用脚本中。

  • 大括号是保留字,必须用空格或元字符与括号内的元素分开

  • 命令列表末尾需要换行符或;

这是如何使用大括号的示例:

#!/bin/bash
x=10
echo "The initial value of x is: $x"
multiplier () {
local y=$(( x*x ))
echo "The value of y in the function is: $y"
}
echo "We now trigger the function..."
multiplier
echo "The value of y right after the function execution is: $y"
{ z=$(( x*x )) ;
echo "The value of z in the function is: $z" ; }
echo "The value of z right after the function execution is: $z"

现在,让我们来运行它:

zarrelli:~$ ./curly.sh The initial value of x is: 10 We now trigger the function... The value of y in the function is: 100 The value of y right after the function execution is: The value of z in the function is: 100 The value of z right after the function execution is: 100

正如您所见,我们无需调用匿名函数来执行它与普通函数不同。我们没有使用本地作用域来存储变量,因为这是不允许的,这将引发错误。我们稍后将详细了解函数和作用域。

大括号支持代码的 I/O 重定向。

大括号({})

再次说明,大括号有另一种含义:与xargs -i(替换字符串)一起使用大括号可以作为名称的占位符:

zarrelli:~$ ls *.{[[:digit:]],txt} | xargs -i echo "Found file: {}" Found file: test.colon.2 Found file: test.txt

完整路径({} ;)

find -exec一起使用,这会保存 find 定位的文件的完整路径。它不是一个 shell 内建命令,并且必须转义命令序列末尾的分号,以避免 shell 解释:

find . -name *.txt -exec cp {} copy.txt \; zarrelli:~$ ls -lah test.txt copy.txt -rw-r--r-- 1 zarrelli zarrelli 5 Feb 21 16:12 copy.txt -rw-r--r-- 1 zarrelli zarrelli 5 Feb 16 14:07 test.txt

表达式([])

这测试方括号之间的表达式。这是 shell 内建的测试,而不是称为/usr/bin/[的命令。

[[ -f copy.txt ]] && echo "文件找到

表达式([[]])

再次测试方括号之间的表达式,但以更灵活的方式。我们在前几章中已经看到了这一点。

数组索引([])

这指向数组中位于指定索引位置的对象:

fruit=(apple banana lemon) ; echo ${fruit[1]} banana

我们稍后会看到数组是什么,现在只需记住数组索引从0开始,因此1是数组中的第二个元素。

字符范围([])

这定义了正则表达式中匹配字符的范围:

zarrelli:~$ ls | grep ^[c-m] comment.sh copy.txt c-style-counter.sh c-style.sh curly.sh external-data external-script-return.sh external-script-return-whatever.sh external-script.sh menu.sh

在这种情况下,我们匹配了所有以cm范围内的字符开头的文件名。

整数扩展($[…])

这是一个整数扩展,已被弃用,并被((…))替代。

整数扩展((((..))))

这是整数扩展。我们在前几章已经看到过如何使用它。

演示

我们在本书开头已经看过这个,它们用于重定向。你将在提供的示例中找到更多如何使用它的细节。

here 文档(<<)

here 文档是一种重定向形式,它强制 Shell 从随后的字符块中读取输入,直到用户定义的分隔符,然后将这一串字符作为命令或文件描述符的标准输入。如我们在下一个例子中所见,cat命令的参数既没有在命令行提供,也没有询问用户,而是写入脚本中,位于两个DELIMITER词之间:

#!/bin/bash
cat << DELIMITER
This is a string
followed by another
date $(date +%Y.%m.%d)
until the 
DELIMITER

现在,让我们运行它:

zarrelli:~$ ./here-date.sh This is a string followed by another date 2017.02.21 until the 

<<右侧的分隔符可以是你想要的任何字符串,只要它与最后一行匹配:在两个分隔符之间的所有内容将作为标准输入传递给我们的例子中的cat命令。请注意,分隔符不受任何命令替换、算术运算或路径名扩展的影响。但是如果分隔符没有被引用,则分隔符之间的行将受到算术和文件扩展以及命令替换的影响。

一个不错的做法是在<<的右侧添加,这样可以去掉输入中的尾随制表符,这使得创建带缩进的 here 文档成为可能:

#!/bin/bash
cat << DELIMITER
This is a string
        followed by an indented one
            with an indented date: $(date +%Y.%m.%d)
until the
DELIMITER

你现在会得到这个:

zarrelli:~$ ./here-date-indented.sh This is a string
 followed by an indented one with an indented date: 2017.02.21 until the 

如果你将<<换成<,你将得到没有缩进的输入:

zarrelli:~$ ./here-date-indented.sh This is a string followed by an indented one with an indented date: 2017.02.21 until the 

这里字符串(<<<)

here字符串是here文档的简化版本,它由一行组成,分隔符扩展后将命令传入:

#!/bin/bash
today=$(date +%Y.%m.%d)
cat <<< $today

输出在这里:

zarrelli:~$ ./here-date-string.sh 2017.02.21

ASCII 比较运算符 (<) 和 (>)

这是对字符串的 ASCII 比较。我们在前一章已经看到了如何使用这些运算符。

分隔符(< 和 >)

分隔符用于在正则表达式中标识一个单词。让我们创建一个文件:

zarrelli:~$ echo "barnaby went to the bar to see the barnum musical" > text.file 

现在,我们将使用单词分隔符设置为bar,并使用-o选项进行grep,这将只输出匹配的片段,而不是包含它的所有行:

grep -o '\<bar\>' text.file bar

这是正确的。只有一个名为bar的单词,其他的是复合单词,我们可以再次确认这一点:

zarrelli:~$ grep -o bar text.file bar bar bar

如果我们不查找名为bar的单词,而只查找三个字符的字符串 bar 的匹配项,我们会发现它在文件中匹配了三次。

管道符号(|)

管道是进程间通信的经典示例:它将一个进程的stdout传递给另一个进程的stdin

zarrelli:~$ ls -lah | wc -l 35

在这个例子中,我们仅列出了当前目录的内容,并将输出作为stdin传递给wc工具,后者统计了输入数据的行数。管道后的命令在子 Shell 中运行,因此无法将任何修改后的值返回给父进程;如果管道中的某个命令因故中止,这就会导致所谓的破损管道,管道执行停止。

强制重定向 (>|)

即使为 Shell 设置了noclobber选项,这也强制执行重定向。Clobbering 是指覆盖文件内容的行为,这点我们在重定向时已经看到了,不过让我们看一下这个例子:

zarrelli:~$ echo "123" > override.txt zarrelli:~$ cat override.txt 123 zarrelli:~$ echo "456" > override.txt zarrelli:~$ cat override.txt 456

一切都按预期进行。我们重定向了echo的输出,并且在第二次运行时,覆盖了文件的内容。但现在让我们为 Shell 设置noclobber选项:

zarrelli:~$ set -o noclobber

我们将尝试覆盖文件的内容:

zarrelli:~$ echo "789" > override.txt bash: override.txt: cannot overwrite existing file

没办法,我们被noclobber选项所阻止,防止意外覆盖文件:

zarrelli:~$ cat override.txt 456

实际上,文件的内容仍然相同,但现在是这样的:

zarrelli:~$ echo "789" >| override.txt zarrelli:~/$ cat override.txt 789

我们强制了重定向,现在文件的内容已经改变:

zarrelli:~$ set +o noclobber

让我们恢复noclobber选项。

查看一下man bash,它列出了很多有趣的选项,你可以使用set -o选项来改变 Bash 的行为。例如,看看这个:

  • +B禁用花括号扩展

  • -f禁用文件名扩展,也叫做通配符匹配。

  • -i以交互模式运行脚本

  • -n读取脚本中的命令但不执行它们;它是经典的干跑模式,用于语法检查

  • -o使得一切符合 POSIX 规范

  • -p脚本以 SUID 身份运行

  • -r脚本在受限的 Shell 中运行

  • -s命令从stdin读取

  • -v命令在执行前会打印到stdout

  • -x类似于-v,但命令会展开

逻辑或运算符(||)

我们已经理解了它们,所以可以查看前面的页面。

演示

这会将一个进程发送到后台。看这个:

zarrelli:~$ echo "Hello, see you in 5 seconds" ; sleep 5 Hello, see you in 5 seconds

如果你将 sleep 放在后台,它只会暂停 5 秒钟,之后返回提示符:

zarrelli:~$ echo "Hello, see you in 5 seconds" ; sleep 5 & Hello, see you in 5 seconds [1] 8163

Shell 会立即把控制权交还给你,因为 sleep 进程不会再从终端获取输入,命令行对用户保持可用。这样做的好处之一是,虽然你每次只能运行一个前台进程,但在执行期间,你无法输入其他命令与后台进程交互,因此,你可以根据系统资源的限制,启动任意数量的后台进程。

你可以通过按下Ctrl + Z,然后输入fg,手动将前台进程切换到后台:

zarrelli:~$ echo "Hello, see you in 50 seconds" ; sleep 50 Hello, see you in 50 seconds ^Z [6]+ Stopped sleep 50 zarrelli:~$ fg sleep 50

逻辑与运算符

这是逻辑与运算符,它会在测试中返回true,前提是两个条件都为真。

破折号字符(-)

破折号是选项字符,通常表示命令行中的可选参数:

ps -ax

它还用作参数替换中的前缀,用于默认参数,并且也用于从 stdin/stdout 重定向:

zarrelli:~$ cat - 1 1 2 2 3 3 ^C 

或者,它可以执行如下操作:

zarrelli:~$ tar cvzf - $(ls text.file) > zipped.tgz zarrelli:~$ tar -tf zipped.tgz text.file

我们所做的操作是通过命令替换获取文件名并输出到stdout,通过-stdin读取,然后将tar操作的结果重定向到stdout以创建压缩文件。

我们还可以使用破折号返回到之前的目录,这个目录存储在环境变量$OLDPWD中:

zarrelli:~$ mkdir -p dir1/dir2 zarrelli:~$ cd dir1/ zarrelli:~$ cd dir2/ zarrelli:~$ cd - zarrelli:~$ pwd dir1

最后,在算术运算上下文中,破折号表示减法,因此我们可以从一个数字中减去另一个数字。

双破折号(--)

双破折号通常代表命令的长选项。例如,在下一个示例中,我们使用名为-a的短选项和名为--all的长选项来启用相同的行为;长选项通常是短选项的更易读形式。短选项以单破折号开始,而长选项以双破折号开始:

zarrelli:~$ ls -a . .. dir2 zarrelli:~$ ls --all . .. dir2

它也用于我们在几页前看到的set命令,以设置 Bash 选项。

操作符 =

它可以是赋值运算符:

zarrelli:~$ x=10 ; echo $x 10

它也可以是字符串比较运算符:

zarrelli:~$ x=10 ; y=10 ; if [[ "$x" = "$y" ]] ; then echo "Success" ; fi Success 

操作符 +

在算术运算上下文中,它可以用作操作符,将一个数字加到另一个数字上。在正则表达式场景中,它匹配一个或多个之前的正则表达式:

zarrelli:~$ echo "Hello" | grep 'Hel\+o' Hello

加号字符也被一些内建命令用于在参数替换上下文中启用某些选项,以标记变量扩展的替代值:

zarrelli:~$ x=10 ; y=${x+20} ; echo $y 20

如果设置了x,它将使用值20,否则使用空字符串。

取模运算符(%)

这是取模的算术运算符,表示除法的余数。取模运算符也用作参数替换上下文中的模式匹配运算符:

zarrelli:~$ x="highland" ; y=${x%land} ; echo $y high

操作符 ~

这表示环境变量$HOME的相同值:

zarrelli:~$ echo ~ /home/zarrelli zarrelli:~$ ls /home/zarrelli/ Desktop Documents Downloads Music Pictures Public Templates test.file test.sh tmp Videos zarrelli:~$ ls ~/tmp/ setting.sh zarrelli:~$ cd ~ zarrelli:~$ pwd /home/zarrelli

操作符 ~+

这是当前工作目录,其值由名为$PWD的环境变量保存。

操作符 ~-

这是之前的工作目录,其值由名为$OLDPWD的环境变量保存。

操作符 ~=

这是双括号内正则表达式的匹配操作符。它是在 Bash 3 中引入的。

操作符 ^

在正则表达式中,它匹配从行首开始的给定模式。我们在前面的页面中看到了一些例子。

控制字符(^ 和 ^^)

在 Bash 4 中引入的,它们用于在参数替换上下文中进行大写转换。

除了我们目前看到的特殊字符外,还有一个键组合,通常称为控制字符。它们并不直接在脚本中使用,而是便于你与终端的交互。它们是由两个字符组合而成,通常是Ctrl和另一个字符一起按下;但它们也可以以转义的十六进制或八进制表示:

  • Ctrl+A:此操作将光标移动到命令行字符串的开头。

  • Ctrl+B:这是退格键,但不会删除任何内容。

  • Ctrl+C:此操作会中断前台作业,并终止它。

  • Ctrl+D:此操作会退出 shell。如果用户在终端窗口中并且正在输入,它会删除光标所在的字符。如果窗口为空,它将关闭窗口。

  • Ctrl+E:此操作将光标移动到命令行中字符串的末尾。

  • Ctrl+F:此操作将光标向前移动一个位置。

  • Ctrl+G:在终端窗口中,这可能会发出一个蜂鸣声。

  • Ctrl+H:这是退格键,会删除光标下的字符,称为rubout

  • Ctrl+I:这是水平制表符。

  • Ctrl+J:这是换行符,它也可以表示为\012八进制和\x0a十六进制。

  • Ctrl+K:这是一个垂直制表符。在终端窗口中,它会删除光标下方到当前行末的所有字符。

  • Ctrl+L:这是换页符,它会清除屏幕上的所有内容。

  • Ctrl+M:这是回车符。

  • Ctrl+N:此操作会从命令行历史中删除一行。

  • Ctrl+O:在命令行中输入时,它会带你到新的一行,并执行当前的命令。

  • Ctrl+P:此操作会从命令行历史中恢复上一个命令。

  • Ctrl+Q:这是在终端窗口中恢复stdin的快捷键。

  • Ctrl+R:此操作会在历史记录中向后搜索文本。

  • Ctrl+S:此操作会暂停终端窗口中的stdin,通过Ctrl+Q恢复。

  • Ctrl+T:此操作会交换光标下的字符与前一个字符的位置。

  • Ctrl+U:此操作会删除光标位置与命令行开头之间的所有字符。在某些配置中,它会删除命令行上的所有字符。

  • Ctrl+V:此操作主要在编辑器中使用,它允许在插入文本时输入控制字符。

  • Ctrl+W:在终端或 X 终端中,它会删除从光标位置向后直到第一个空格的所有字符;在某些配置中,它会删除到第一个非字母数字字符为止。

  • Ctrl+X:你会在许多文字处理软件中找到它,它用于将文本从编辑器剪切并粘贴到剪贴板。

  • Ctrl+Y:此操作会将用Ctrl+UCtrl+W删除的文本粘贴回来。

  • Ctrl+Z:此操作会暂停正在前台运行的任务。

  • 空格:通常用作字段分隔符。在某些情况下,它是必须的,而在其他情况下则是禁止的,就像我们在前几章的示例中看到的那样。

引用和转义

我们已经看到,在 Bash 中,引用和转义有多么重要,这归因于某些字符不仅仅是它们看起来的样子,而是对 shell 来说具有特殊意义,shell 会在遇到这些字符时进行解释。但有时候,我们希望这些字符仅仅是它们本来的样子;我们希望在字符串中保留空格,而不是将其分割成单词,或者我们只是想查看是否存在一个 * 文件名。或者我们希望 echo 一个双引号而不是开始一个引用。因此,我们通过引用和转义来保持我们所看到的内容,避免 shell 对其进行解释。

反斜杠 (\)

反斜杠是我们用来转义所有其他字符的字符。太好了。这意味着什么呢?简而言之,反斜杠前缀的每个字符都保持其字面值或含义。反斜杠并不是作用于整个字符串,而只是作用于其后面的字符,它被广泛用于转义文件名中的空格:

zarrelli:~$ mkdir this is a directory with spaces in the name total 44K drwxr-xr-x 11 zarrelli zarrelli 4.0K Feb 23 09:50 . drwxr-xr-x 3 zarrelli zarrelli 4.0K Feb 23 09:44 .. drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 a drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 directory drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 in drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 is drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 name drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 spaces drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 the drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 this drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 with

嗯,结果并不是我们想要的,但它是我们应该预期的:空白字符被 shell 解释为分隔符,因此 mkdir 创建了与作为参数传入的单词数量相同的目录。我们需要让 mkdir 解析一个包含空格的完整字符串,而不是一系列由空格分割的单词:

zarrelli:~$ mkdir this\ is\ a\ directory\ with\ spaces\ in\ the\ name zarrelli:~$ ls -lah total 48K drwxr-xr-x 12 zarrelli zarrelli 4.0K Feb 23 09:52 . drwxr-xr-x 3 zarrelli zarrelli 4.0K Feb 23 09:44 .. drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 a drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 directory drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 in drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 is drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 name drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 spaces drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 the drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 this drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:52 this is a directory with spaces in the name drwxr-xr-x 2 zarrelli zarrelli 4.0K Feb 23 09:48 with

好的,我们终于创建了一个名字中包含空格的目录。

你也可以在脚本或命令行中看到反斜杠的使用,当你的指令变得有些过长时。你会看到它们在你想换行而不触发回车时使用,这样就不会执行不完整的命令行:

zarrelli@moveaway:~$ var="This can become a bit too long \ > so better to go on a new line and print on the next" ;\ > echo \ > $var This can become a bit too long so better to go on a new line and print on the next

双引号 ("")

双引号 被称为弱引用,因为它们防止 shell 解释所有元字符,除了 $"'\。这意味着一些事情;最重要的是,你可以引用变量值,即使它已经被引用:

zarrelli:~$ x=10 ; echo "$x" 10

这也意味着你可以使用反斜杠来转义 $,以打印字面量的 $a 字符串:

zarrelli:~$ x=10 ; echo "\$x" $x

因此,要使用前面解释过的字符之一,你必须用反斜杠对其进行转义。

此外,双引号保留空格,所以我们可以重新编写之前看到的命令:

mkdir this\ is\ a\ directory\ with\ spaces\ in\ the\ name 相当于 mkdir "this is a directory with spaces in the name"

那么,如果你想完全防止任何解释呢?你必须依赖强引用。

单引号 (')

单引号使得引用变得强力,这样我们之前看到的所有元字符,如 $"'\ 都不会被解释;而以这种方式引用的所有内容都按字面意思处理,除了单引号本身,它仍然保持作为元字符的功能。这意味着你根本无法使用它,因为通常我们通过这种方式获取字符的字面值。使用反斜杠是无效的,因为在单引号内,反斜杠本身失去了其元字符效果。强引用的一个显著效果是,你不能再引用变量的值:

zarrelli:~$ x=10 ; echo '$x' $x

尽管如此,即使在被转义后,我们仍然有一些字符带有特殊含义;以下是其中的一些:

  • \a 是警告

  • \b 是退格

  • \n 是换行符

  • \r 是回车

  • \t 是制表符

  • \v 是垂直制表符

摘要

现在我们已经了解了如何安全地处理变量和特殊字符,接下来是时候朝着我们日常编程中更有用的内容迈进。在下一章,我们将详细探讨案例结构、数组和函数;这些将帮助我们创建第一个完整的命令行解析器。

第五章:菜单、数组和函数

编写脚本通常意味着需要处理用户交互。你需要知道用户对脚本的期望,并让用户知道他们可以选择的选项。因此,我们提供一些选择给用户,他们提供答案,我们根据一些预设值来评估它们,并决定下一步要做什么。这就意味着要有一种方法,能够将一些数据展示给用户,收集他们的答案,循环选择项,并相应地做出反应。有多种方法可以实现这一点,我们将看到如何使用一些标准结构来完成这个任务。在本章结束时,我们将能够高效地提供、收集、存储和处理数据。

case 语句

当你有更多的选择时,可以通过一系列的 if else 语句来处理它们:

if [condition];
then
command
else
command
fi

if子句可以根据需要进行嵌套,但从长远来看,选择过多会使代码混乱,降低可读性。编程的基本原则之一就是保持代码可读性,使其优雅,因为优雅在这里不仅仅意味着美丽,还意味着在时间上保持一致性。始终保持有意义的缩进,使子句突出。尽量用尽可能少的代码,始终采用相同的符号表示法,使代码紧凑高效。因此,拥有一连串的if/then/else/fi,并且有大量缩进,可能并不是你的脚本的最佳选择,但有一种替代方法已经被广泛采用,那就是使用case语句,形式如下:

case expression in condition_1) command_1 command_n ;; condition_2) command_1 command_n ;; condition_n | z) command_1 command_n ;; esac

这个表达式实际上是一个条件,必须匹配condition x)中给定的模式。一旦匹配为真,对应的命令块就会被执行:

condition_x)
command_1
command_n
;;

每一块命令被称为子句,并以;;双分号结束。所有的案例语句都包含在caseesac中,每个条件可以表示为condition_1)condition_1 | condition_2 | condition_n)

每个条件都可以作为触发子句内部命令执行的替代匹配项。

让我们看两个例子,展示如何使用if/then/else/ficase/esac结构处理相同的选项:

#!/bin/bash
echo "Please, give me some input"
read input
if [[ $input =~ ^[[:digit:]]+$ ]]; 
then
echo "These are digits"
exit 0
elif [[ $input =~ ^[[:alpha:]]+$ ]]; 
then
echo "These are chars"
exit 0
else
echo "Dunno…"
exit 1
fi

现在,让我们看一些测试:

zarrelli:~$ ./if-statement.sh Please, give me some input 123 These are digits zarrelli:~$ ./if-statement.sh Please, give me some input abc These are chars zarrelli:~$ ./if-statement.sh Please, give me some input 12a Dunno... zarrelli:~$ ./if-statement.sh Please, give me some input !der Dunno...

这不是复杂的代码。我们请求一些输入,然后检查几个条件,看输入的文本是否完全由数字或字符组成;如果不是,则使用dunno作为默认答案。我们使用了稍微复杂一点的if/then/else/fi版本。我们采用了elif来检查另一个条件是否符合要求。我们本可以继续使用一系列elif来检查用户是否输入了字母数字或其他类型的字符,但从这个小示例中可以看出,代码开始变得有些难以阅读;它不那么清晰了。现在,让我们试试使用case语句,稍微做点不同的尝试:

#!/bin/bash
echo "Please, give me some input"
read input
case ${input//[[:alpha:]]} in
"") 
echo "There were alphabetic chars only" 
exit 0
;;
*[[:alnum:]]*) 
echo "There were digits in the string"
exit 0
;;
*) 
echo "There were non alphanumeric chars" 
exit 1
;;
esac

我们在这里做的是创建一个条件,以便从输入变量值中去除所有字母字符。剩下的部分会与""进行比较。这意味着什么?它简单地表示,如果在去除字符串中的所有字母字符后,剩下的是一个空字符串。如果条件不满足,我们将检查第二个条件:如果剩下的原始字符串中还有一些字母数字字符,意味着原始字符串中包含了数字字符。如果第二个条件也满足,则意味着在去除字母和数字字符后,剩下的字符串中包含其他字符。

你之前见过或使用过case语句吗?是的,可能比你想象的更常见。现在让我们做点有趣的事情;稍后我们会看到为什么它如此有趣。前往 Linux 发行版的/et/init.d/目录,仍然是SystemV兼容的,并查看你在那里找到的任何脚本。这些是处理系统服务启动/关闭的脚本,例如 cron 或 dbus,以及可以提供的其他服务,如 ssh、Apache 等。查看这些脚本时,有一件事立刻引起了注意,它们具有以下结构:

#!/bin/bash
case "$1" in
start)
:
exit 0
;;
stop)
:
exit 0
;;
status)
:
exit 0
;;
restart)
:
exit 0
;;
condrestart)
if $condition
then
exit 0
fi
exit 1
;;
*)
echo $"Usage: $0 {start|stop|restart|condrestart|status}"
exit 1
;;
esac
exit 0

这可以作为我们交互式脚本的基础,因为它提供了一个基本框架来处理用户交互。如你所见,每个条件后面都跟着一个:,作为基础脚本,没有任何操作会被执行;对于每个条件,我们都会优雅地退出并返回成功代码,除了条件重启和默认选项。条件重启实际上是可选的,但它允许你基于你设置的条件重新启动服务,所以是否保留或删除这个部分由你决定。正如我们在本书前面看到的,case结构有点类似于if/then/else/fi结构,后者用来匹配给定的不同字符串选项。该结构被caseesac标记包围;注意到esaccase的倒写。考虑以下条件:

string1 | string2 | stringn) do_something do_somethingn ;;

它以一个或多个需要匹配的字符串开始;每个可能的匹配项由|分隔,并以;;结束。如果有多个匹配项为真,只有第一个会被考虑。最后一个选项通常是一个星号,通常可以视为一个“通配符”,因为*会匹配用户输入的任何字符串。所以,如果前面的匹配项没有触发任何条件,这最后一个选项仍然会被匹配,通常用于编写帮助信息或一些命令行使用说明,因为如果用户没有输入正确的选项,它将始终显示。

要匹配的模式字符串可以选择性地以(开头,例如,(string1 | string2 | stringn)。记住,在esac之前的最后一个;;可以省略,不会导致任何问题。

每个开始子句的字符串是一个可选的匹配项,用于case condition中的匹配。通常,这是一个必须检查是否与每个开始子句的选项字符串匹配的文本字符串。如果条件是一个变量,它将通过参数扩展、变量扩展(波浪符)、命令替换、进程替换和去除引号来展开,但不会执行路径名扩展、大括号扩展或单词分割。鉴于此,你不需要对变量进行引号处理以确保安全处理。

从 Bash 4 开始,出现了一些子句终止符,我们已经在上一章中见过它们:

  • ;&使得执行继续进行,并与下一个条件关联的命令被执行

  • ;;&使得 Shell 检查该选项,并在条件匹配时执行关联的命令

如果未找到匹配项,则退出状态为 0,否则退出状态为最后执行的命令的状态。

让我们看看它们是如何表述的,并继续修改基础脚本:

#!/bin/bash
case "$1" in
start)
echo "We are starting..."
exit 0
;;
stop)
echo "We are stopping..."
exit 0
;;
status)
echo "We are checking the status..."
exit 0
;;
restart)
echo "We are restarting..."
exit 0
;;
*)
echo $"Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0

现在,让我们试试:

zarrelli:~$ ./terminators.sh Usage: ./terminators.sh {start|stop|restart|status}

如果没有任何参数,给定选项上无法找到匹配项,因此会触发全匹配星号,并执行echo打印使用信息到stdout

zarrelli:~$ ./terminators.sh start We are starting... zarrelli:~$ ./terminators.sh stop We are stopping... zarrelli:~$ ./terminators.sh restart We are restarting... zarrelli:~$ ./terminators.sh status We are checking the status...

所有其他选项都很简单;我们删除了condrestart选项只是为了使脚本更简洁、更易读。

现在,让我们在最后一个子句上使用;&终止符:

restart) echo "We are checking the status..." exit 0 ;&

现在,使用状态作为参数执行脚本:

zarrelli:$ ./terminators.sh status We are checking the status...

嗯,尴尬,什么都没变。为什么?仔细看看子句:;&终止符前面是exit 0,所以在达到终止符之前,脚本的执行就停止了。好吧,让我们删除exit 0并再次使用状态参数调用脚本:

zarrelli:~$ ./terminators-last.sh status We are checking the status... We are restarting...

有趣,不是吗?我们从一个块级跳转到另一个块,因此我们在执行重启块命令后,立即执行了状态块命令。这正是我们期望;&运算符的行为,因为执行需要继续到下一个块,一旦满足第一个条件。但是现在,让我们做点别的,修改重启子句:

restart) echo "We are restarting..." ;;&

让我们执行脚本:

zarrelli:~$ ./terminators-last.sh status We are checking the status... We are restarting... Usage: ./terminators-last.sh {start|stop|restart|status}

发生了什么?简单来说,一旦匹配了status字符串,第一个echo命令被执行,并且;&运算符使得与重启子句关联的命令被调用,而没有进行其他字符串检查。在重启子句中,;;&运算符导致对下一个子句进行字符串检查,但由于这是与通配符的字符串匹配,它无论如何都匹配,因此执行了usage字符串的echo命令。但如果我们将状态子句和重启子句之间的运算符颠倒,会发生什么呢?

zarrelli:~$ ./terminators-last.sh status We are checking the status... Usage: ./terminators-last.sh {start|stop|restart|status}

我们进入了status条件,执行了代码,然后继续执行restart代码。这里命令并没有执行,因为;;&只有在字符串匹配时才会触发命令执行,但我们的status参数并不匹配restart选项。在下一行,我们使用了;&,这会将我们级联到下一个条件,且无论是否匹配,那个条件的代码都会被执行。如果你想强制执行restart条件下的命令,可以修改匹配选项:

restart | status) echo "We are restarting..." ;&

现在,让我们试试看:

zarrelli:~$ ./terminators-last.sh status We are checking the status... We are restarting... Usage: ./terminators-last.sh {start|stop|restart|status}

在这种情况下,我们提供了两个可能的重启条件匹配项,restartstatus。第一个失败了,但第二个匹配上了,于是命令执行了,然后下一个条件命令也被执行了。

我们在一个启动脚本中看到过case结构,该结构与用户的交互较少,但现在,让我们开始使用这个结构,做出一些更有趣的事情:

#!/bin/bash
clear
echo -n "May I create an archive out of the current directory files? [yes or no]: "
read input
case $input in
[yY] | [yY][eE][sS] )
echo -e
echo "Yes, of course...I am proceeding"
echo -e "Archiving the following files\n"
now=$(date +%Y.%m.%d.%H.%M.%S)
filename=${PWD##*/}
tar cvzf ${now}.${filename}.tgz *
echo -e
echo "Archive $now.${filename}.tgz created!"
;;
[nN] | [nN][oO] )
echo -e
echo "No, so have a lovely day".;
echo -e
exit 1
;;
*)
echo -e
echo "Please just answer yes or no, y, n, in lower or capital."
echo -e
;;
esac

这个简单的脚本可以用来归档一个目录的内容。它会询问用户内容,并将用户的回答与小写和大写的yyesnno进行比对。这里没有什么难的,我们只是在将之前章节中学到的内容组合起来。我们首先清除屏幕上的所有旧内容,然后通过echo -n询问用户输入yesno。这样我们就不会输出新的一行,用户的回答将出现在双冒号后面的同一行。接下来的步骤是与字符列表进行比对。

[yY] | [yY][Ee][Ss] )将匹配小写和大写的y,还会匹配yesYES以及这些字符大小写混合的所有情况。如果匹配成功,我们会告知用户我们将继续归档文件。注意我们使用了echo -e,它允许解析反斜杠转义序列,因此我们可以使用\n来插入新的一行并跳到终端的下一行。接下来的指令是命令替换,我们将date命令的输出赋值给变量now。我们正在创建将来拼接成唯一归档文件名的部分。在这个例子中,我们得到了一个由year.month.day.hour.minute.second组成的日期。我们将使用这个字符串作为归档文件的前缀,这样每秒都会生成一个唯一的文件名。但这也是一个限制,因为如果我们在同一时刻创建两个归档,后者将覆盖前者,两个文件的名字是一样的。

时刻牢记你的目标和限制,并坚持它们。在创建某些变量或条件时,你必须从你工作的范围出发,而不是过度思考你正在做的事情。这里的一个例子是归档名称的前缀。给我们一个在秒级时间范围内唯一的名称,允许我们每秒都有一个新的文件名,但如果相同的脚本在相同的时间被两次调用,例如从两个不同的终端调用,就有可能发生归档被覆盖的风险。为了避免这种情况,我们可以创建一个函数来伪造随机字符串作为前缀,从而避免这个问题,或者至少大大减少名称冲突的可能性。本书稍后会介绍如何创建一个随机字符串,但现在有必要吗?我们创建一个示例来展示如何使用case语句处理用户输入并创建归档,因此这个脚本不太可能在同一时间被执行两次。捕捉这种情况可能很好,但由于它不在这个项目的范围内,我们不会这样做,因为所花费的时间并不足以通过结果来证明其价值,也不太可能防止这种事件的发生。相反,在编写脚本时,要花时间明确自己脚本的功能,可能的陷阱,可能发生的错误,以及可以在脚本中编写的解决方案。

专业编程不仅仅是编码,它还包括规划,尝试理解可能发生的事情、你想要什么,以及如何达成目标。首先,问问自己你想要实现什么目标,如何实现,自己是否有足够的知识、资源、时间等来实现它。然后,做出相应的规划和开发。这不仅适用于你,也适用于你的客户,因为大多数时候最难的部分是理解客户真正想要的是什么,不管他是否意识到这一点,编码需要多少时间和资源,客户是否愿意给你足够的时间和资源。最后,问问自己,给定这些前提条件,你是否能够完成任务。假设你被要求用汇编语言编写一个简单的计算器,经过一些学习、练习和多次尝试,你可能可以完成。但如果你只有三天时间,从零开始,你能完成吗?所以,定义目标、目标的限制和资源,计划执行过程,考虑到你的代码可能遇到的陷阱,然后,考虑一个合理的应急预案:你的电脑可能会坏,或者你可能感冒了,任何事情都有可能发生,因此需要留出一定的应急时间,因为客户有一个交付日期,你必须按时交付代码,尽管可能有感冒、流感、电脑故障等情况。最后,保持规律。假设你每天有四小时的编码时间,你知道在这个时间内,你可以编写 50 行代码;但如果付出额外的努力,你最多可以写 65 行代码。不要把 65 行作为目标,要坚持一个平均值。你应该对这个进度有信心,因为你会每天进行编码,而且不能每天都冲刺。根据你知道自己能持续一段时间的努力来制定规律,以避免自己和客户面临不愉快的局面。

所以,在这段插曲之后,让我们继续检查 yes 条件中的最后几个有趣的命令:

filename=${PWD##*/} tar cvzf ${now}.${filename}.tgz *

第一行帮助我们使用参数扩展找到当前目录的名称:我们获取 $PWD 环境变量的内容,并删除与模式匹配的最长部分,在我们的例子中是路径直到最后一个斜杠,并将结果赋值给名为 filename 的变量。第二条指令从本地目录中所有文件创建一个归档文件,文件名为 *,并将之前准备好的不同部分合并成归档名。注意 ${},它允许我们在拼接时保留变量的值。那么,现在是执行脚本的时候了:

May I create an archive out of the current directory files? [yes or no]: yEs Yes, of course...I am proceeding Archiving the following files... base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh Archive 2017.02.26.12.24.55.Scripts.tgz created! And check if the archive has been really created: zarrelli:~$ tar -tf 2017.02.26.12.24.55.Scripts.tgz base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh

我们能够列出归档中的文件,简单的 ls 命令将再次确认结果:

zarrelli:~$ ls -A1 2017.02.26.12.24.55.Scripts.tgz base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh

所有文件都在正确的位置,我们也可以看到新创建的归档文件。但是我们能确保一切都正常吗?让我们创建一个 test 目录并将所有文件复制进去:

zarrelli:~$ mkdir test zarrelli:~$ cp * test cp: -r not specified; omitting directory 'test' zarrelli:~$ chmod -R 0550 test

我们将文件复制到 test 目录,并设置目录权限,以使任何人都无法写入。现在,让我们进入该目录并运行脚本:

May I create an archive out of the current directory files? [yes or no]: yes Yes, of course...I am proceeding Archiving the following files... base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh tar (child): 2017.02.27.08.40.03.test.tgz: Cannot open: Permission denied tar (child): Error is not recoverable: exiting now tar: Child returned status 2 tar: Error is not recoverable: exiting now Archive 2017.02.27.08.40.03.test.tgz created!

很有趣,我们看到了一些错误消息,但脚本仍然说我们有一个归档文件,来检查一下:

zarrelli:~$ ls -A1 base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh

不,我们没有新的归档文件,这正是我们预期的,因为我们的用户无法在测试目录中写入任何内容。因此,这种情况是有可能发生的;有时候,我们的脚本会遇到问题,比如它无法从目录或某些文件中读取或写入,这就是我们现在必须规划的事情:一个应急方法来应对这个潜在问题。我们可以做的是检查 tar 命令的退出代码:如果它与 0 不同,表示归档创建失败,否则一切正常。所以,让我们重写 yes 条件,并在 tar 命令后添加一个测试:

tar cvzf $now.${filename}.tgz * if [ $? -ne 0 ] then echo "Sorry there was an issue creating the archive..." exit 1 else echo -e echo "Archive ${now}.${filename}.tgz created!" exit 0 fi ;;

小心不要在 tartest 之间写任何命令,因为 $? 捕获的是最后执行命令的退出代码。现在,让我们检查一下结果:

May I create an archive out of the current directory files? [yes or no]: yes Yes, of course...I am proceeding Archiving the following files... tar (child): 2017.02.27.09.20.17.test.tgz: Cannot open: Permission denied tar (child): Error is not recoverable: exiting now base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh tar: 2017.02.27.09.20.17.test.tgz: Cannot write: Broken pipe tar: Child returned status 2 tar: Error is not recoverable: exiting now Sorry there was an issue creating the archive…

不错!现在,我们的脚本告诉我们发生了问题,并且它停止显示归档成功创建的消息,尽管 tar 命令失败了。无论如何,输出有点混乱。我们已经从错误消息中得知存在错误,所以让我们通过修改 tar cvzf $now.${filename}.tgz * 2>/dev/null 来清理输出。

我们刚刚将标准错误重定向到 /dev/null,所以错误信息不会显示到 stdout

大多数时候,最好是屏蔽系统或应用程序的错误,给客户提供由你设计的、更有意义的错误消息。请记住,并不是所有用户都是系统管理员或程序员,也不熟悉操作系统、应用程序错误消息或代码。

看一下输出:

May I create an archive out of the current directory files? [yes or no]: yes Yes, of course...I am proceeding Archiving the following files... base.sh case-statement.sh if-statement.sh terminators-last.sh terminators.sh user-case.sh Sorry there was an issue creating the archive.

这样其实更简洁,甚至可以进一步优化,去掉文件列表。你知道怎么做吗?提示:stdout

但是,再看看以下语句:

[yY] | [yY][eE][sS] ) echo -e echo "Yes, of course...I am proceeding" echo -e "Archiving the following files...\n" now=$(date +%Y.%m.%d.%H.%M.%S) filename=${PWD##*/} if tar cvzf $now.${filename}.tgz * 2>/dev/null then echo -e echo "Archive ${now}.${filename}.tgz created!" exit 0 else echo "Sorry there was an issue creating the archive..." exit 1 fi ;;

结果是一样的,但我们使用 if 语句的方式更符合习惯,因为它的目的是测试条件是否为真,或者在本例中,命令是否成功或失败。不过,你有很多方法可以实现同样的结果;看看这里:

zarrelli:~$ rm base.sh && echo "File deleted" || echo "File not deleted" rm: cannot remove 'base.sh': Permission denied File not deleted

在测试目录中,remove 命令因权限不足而失败,但我们可以向上进入一个目录并创建一个测试文件:

zarrelli:~$ touch test1 zarrelli:~$ rm test1 && echo "File deleted" || echo "File not deleted" File deleted

我使用了逻辑与/或运算符来利用我通常称之为短路的特性。请阅读以下语法的前述示例:

如果 [command1] 为真,则我们也会评估命令 2 [但如果第一个条件不为真,则执行命令 3]

使用逻辑与(AND),rm test1echo "file deleted" 两个命令都必须为真,整体表达式才会成立(在 OR(||)的左侧)。如果第一个命令未能返回真,第二个命令将不会被考虑(短路)。

如果第一个部分left_command && right_command的结果为假,OR 操作会触发最后一个命令的执行。但如果在||之前的第一个部分为真,则第二部分不会被触发。因为对于整体表达式left_command || right_command来说,只要其中一个为真即可,若第一个为真,则第二个命令甚至不会被评估(短路)。

这种错误处理方式在出现问题时不会导致脚本退出,这在大多数情况下是可取的行为,但有时我们也可以使用一个技巧,在出错时让我们退出:

#!/bin/bash -e

如果任何命令在子 shell 或大括号中退出时返回非零代码,这将导致脚本退出。如果失败的命令是紧跟在whileuntil命令后的命令列表的一部分,或者是fi/elif测试的一部分,或是在&&||后执行的命令的一部分,则不适用此规则。

我们稍后会看到更多关于如何使用 case 构造的例子,目前我们要看一些有趣的内容,它将影响你收集、存储和处理数据的方式。所以,准备好学习数组吧。

数组

将数组看作一种可以容纳多个对象的结构,类似于一个具有一个或多个值的变量。想象一下你有几个朋友,你想把他们的名字写下来:

friend_1=Anthony friend_2=Mike friend_3=Noel friend_4=Tarek friend_5=Dionysios

一旦你实例化了变量,你就可以进行解引用,解引用是指检索一个值的操作。这是可以的,但它在某种程度上将你限制在一些局限性中,例如你必须调用准确的变量名才能访问它的值,不能轻松地在它们之间循环,不能快速判断值的数量,等等。针对这些操作,有一种合适的结构,它为我们提供了方便,使我们可以将这些值作为一个整体进行处理——这就是数组:

friends=(Anthony Mike Noel Tarek Dionysios)

数组中的元素是有索引的,位置是在赋值时确定的,所以Anthony将位于第一个位置,Dionysios位于第五个位置。但一旦声明并实例化,我们可以在特定位置向数组添加元素:

friends[6]=Claudia

你怎么检查目前为止所说的是否正确?一个好方法是访问不同的元素,并打印出不同位置的值:

zarrelli:~$ friends=(Anthony Mike Noel Tarek Dionysios) ; echo ${friends[0]} ; echo ${friends[4]} ; echo ${friends[5]} ; friends[5]=Claudia ; echo ${friends[5]} Anthony Dionysios Claudia

从之前的例子中,我们可以看到一些有趣的事情:

  • 数组的第一个位置的索引是 0。

  • 通过${array_name[index]}的形式访问一个值。

  • 如果没有赋值,一个位置不会持有任何值。

  • 我们可以使用索引给任何位置赋值。

现在我们来给列表添加另一个人:

friends[-2]=Ilaria

现在,如果能有一种方法一次性打印出整个数组的内容就好了,因为元素数量在增长,回显所有索引值需要一些时间。所以,我们可以使用array_name[@]或者array_name[*]来访问数组的全部内容:

zarrelli:~$ echo ${friends[@]} Anthony Mike Noel Tarek Ilaria Claudia

这里有趣的是Ilaria在数组中的位置。我们将这个名字插入到位置-2,因此使用负索引提供了 Bash 4.2 中引入的新功能,允许我们从数组的末尾开始定位位置。所以,-2 意味着从数组末尾开始两个插槽。但现在,让我们回到数组声明。我们刚刚看到了一种创建数组的方式:

array_name=(element_1 element_2 element_n)

还有其他方式可以创建数组:

array_name[index]

在这种情况下,索引必须是正整数,因为我们没有任何插槽可以向后计数:

zarrelli:~$ test[2]="Here I am!" ; for i in {0..5} ; do echo $i ${test[$i]} ; done 0 1 2 Here I am! 3 4 5 declare -a array_name

这里不需要索引,即使给出,也会被忽略。这种声明数组的方式在你还不知道将存储哪些值时非常有用:

#!/bin/bash
declare -a friends
clear
echo -n "Can you please tell me the name of some of your friends: "
read -a friends
echo "So, your friends are: ${friends[@]}"

即使你使用另一种形式实例化数组,在实例化之前放置declare -a array_name也能加速后续对数组本身的操作。

我们刚刚声明了一个名为friends的数组,并使用了内置的 read 命令,但这次我们给了-a选项,强制 read 从用户那里获取任何单词,并按顺序分配给命名数组的索引。请注意,-a会在第一次赋值前强制取消数组的设置。现在,让我们试试这个小脚本:

Can you please tell me the name of some of your friends: Ilaria Max Ron So, your friends are: Ilaria Max Ron

从 Bash 4 开始,有一种新的数组类型,称为关联数组。它们与我们之前看到的索引数组稍有不同:可以把它想象成一组两个关联的数组:

#!/bin/bash
declare -A friends
clear
echo -n "Can you please tell me the name of one of your friends: "
read name
echo -n "And now his email address: "
read address
friends[$name]=${address}
echo -e "So, your friend name is: ${!friends[@]}\nHis email address is: ${friends[@]}"

我们刚刚声明了一个名为friends的关联数组,并请求用户提供两个值,一个是名称,另一个是电子邮件,但我们将它们存储在两个不同的变量中,而不是直接插入数组。插入数组是接下来的操作。使用名称值作为索引,地址值作为关联内容:

Can you please tell me the name of one of your friends: Giorgio Zarrelli And now his email address: giorgio@whatever.net So, your friend name is: Giorgio Zarrelli His email address is: giorgio@whatever.net

为了演示目的,我们没有检查输入,但请看这个:

Can you please tell me the name of one of your friends: And now his email address: ./declare-array-associative.sh: line 9: friends[$name]: bad array subscript So, your friend name is: His email address is: 

关联数组的索引不能完全为空,因此我们可以修改之前的脚本,添加对名称值的检查:

read name if [[ -z "$name" ]] then echo "The name value cannot be blank" exit 1 fi

我们刚刚检查了name变量是否未设置或为空,这为我们省去了很多麻烦。

命令行上的标准参数分隔符是空格,但你可以使用 IFS 环境变量改变脚本读取单个参数的方式:

#!/bin/bash
IFS=","
declare friends
clear
echo -n "Can you please tell me the name of some of your friends: "
read -a friends
echo "So, your friends are: "
for i in ${!friends[*]}
do 
echo "$i - ${friends[$i]}"
done

现在,让我们执行它并提供参数Anthony Mike

Can you please tell me the name of some of your friends: Anthony Mike So, your friends are: 0 - Anthony Mike

两个名称位于相同的索引位置,因此它们不会被视为两个不同的朋友。那么,让我们现在用逗号来分隔这些名称:

Can you please tell me the name of some of your friends: Noel,Tarek So, your friends are: 0 - Noel 1 – Tarek

在这里,Noel位于索引 0,而Tarek位于索引 1,因此它们实际上是不同的名称,存储在数组的不同位置。但是,如果用户没有及时回答呢?嗯,另一个环境变量可以帮助我们:

#!/bin/bash
IFS=","
TMOUT=3
declare friends
clear
echo -n "Can you please tell me the name of some of your friends: "
read -a friends
if [ ${#friends[@]} -eq 0 ]
then
echo "You did not provide me with any names"
exit 1
else
echo "So, your friends are: "
for i in ${!friends[*]}
do 
echo "$i - ${friends[$i]}"
done
fi
exit 0

我们刚刚将三秒的值赋给了TMOUT环境变量,该变量定义了 shell 和read内建命令的标准超时周期。用于交互式 shell 时,如果在超时到期之前终端没有输入,shell 本身会退出。与read内建命令一起使用时,它定义了超时周期,如果没有输入,命令将在超时后终止。在我们的案例中,当超时发生时,我们会检查存入数组中的元素数量:如果为 0,我们会打印警告信息并以 1 退出:

Can you please tell me the name of some of your friends: You did not provide me with any names

如果我们在数组中找到某个元素,我们将循环并打印所有值和相关索引。

Can you please tell me the name of some of your friends: Anthony,Mike,Tarek So, your friends are: 0 - Anthony 1 - Mike 2 - Tarek

Bash 4 引入了一个新的内建命令mapfile,用于从标准输入(如果提供了-u选项,则是文件描述符)读取行,并将其加载到索引数组中。这个可以用来做什么呢?来看一下这个示例——我们首先创建一个名为file.txt的文件,里面列出了我们的朋友:

zarrelli:~$ cat friends.txt Anthony Dionysios Ilaria Mike Noel Tarek

现在,让我们创建一个脚本,利用mapfile内建命令:

#!/bin/bash
declare -a friends
echo -e
echo -e "Reading friends list from friends.txt file..."
mapfile friends < friends.txt
echo -e "File content loaded!"
echo -e "So, your friends are: \n${friends[@]}"

最后,让我们运行脚本:

zarrelli:~$ ./mapfile-array.sh Reading friends list from friends.txt file... File content loaded! So, your friends are: Anthony Dionysios Ilaria Mike Noel Tarek

很容易看出为什么mapfile很有用:我们加载了文件中的所有行,而不需要使用任何循环或处理每一行。事实上,如果使用内建的read -a,它只会将文件的第一行加载到数组中,之后我们还得用某种循环处理文件的其余部分。使用mapfile时,你只需要加载所有内容,就这么简单。

那么,让我们回顾一下存储值到数组中的不同方法:

array_name[i]=value

这非常直接。通过索引选择数组中的位置并赋值。它可以是任何算术表达式中的整数。如果是负数,那么数组中可以访问从最后一个值开始的i个位置:

zarrelli:~$ my_array[$((3*2))]=my_value ; echo ${my_array[6]} my_value

我们还可以省略索引,在这种情况下,值将被分配到索引 0 位置:

my_array=my_other_value ; for i in {0..6} ; do echo $i ${my_array[$i]} ; done 0 my_other_value 1 2 3 4 5 6 my_value

对于关联数组来说也是如此:

zarrelli:~$ my_associative=my_value ; for i in {0..5} ; do echo $i ${my_associative[$i]} ; done 0 my_value 1 2 3 4 5

在这种情况下,0实际上是作为字符串使用的,正如它在关联数组中应该是的。

存储数据到数组中的另一种方法是之前看到的复合赋值,但它只适用于索引数组:

zarrelli:~$ friends=(Anthony Mike Noel Tarek Dionysios) ; echo ${friends[0]} ; echo ${friends[4]} ; echo ${friends[5]} ; friends[5]=Claudia ; echo ${friends[5]} Anthony Dionysios

使用这种方法时,我们需要小心,因为数组在赋值之前会被取消设置,因此所有之前的值都会丢失:

zarrelli:~/$ friends=(Anthony Mike Noel Tarek Dionysios) ; echo -n "Old array values: ${friends[@]}" ; friends=(Ilaria) ; echo -e ; echo -n "New array values: ${friends[@]}" ; echo -e Old array values: Anthony Mike Noel Tarek Dionysios New array values: Ilaria

我们可以使用+=操作符保留数组中的旧内容:

zarrelli:~$ friends=(Anthony Mike Noel Tarek Dionysios) ; echo -n "Old array values:${friends[@]}" ; friends+=(Ilaria) ; echo -e ; echo -n "New array values: ${friends[@]}" ; echo -e Old array values:Anthony Mike Noel Tarek Dionysios New array values: echo Anthony Mike Noel Tarek Dionysios Ilaria

然后,我们使用键进行复合赋值:

zarrelli:~$ my_array=([2]=first_value [4]=second_value) ; for i in {0..5} ; do echo $i ${my_array[$i]} ; done 0 1 2 first_value 3 4 second_value 5

关联数组也是如此:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
for i in Mike Anthony
do 
echo "$i - ${friends[$i]}"
done
And now let's try it:
zarrelli:~$ ./associative.sh 
Mike - is a friend
Anthony - is another friend

请注意,关联数组并不意味着键的顺序;正如从前面的示例中看到的,它们是无序的。

最后,我们看到mapfile方法:

zarrelli:~$ mapfile < friends.txt ; echo ${MAPFILE[@]} Anthony Dionisios Ilaria Mike Noel Tarek

我们使用了稍微更紧凑的mapfile命令形式,因为我们没有指定数组的名称来读取文件内容。在这种情况下,当没有提供数组时,mapfile会将数据存储到默认的MAPFILE数组中。

现在我们已经看到了不同的存储值的方法,是时候以各种方式检索它们了:

${my_array[i]} 

i 可以是算术表达式中的任何整数。如果它是负数,那么数组中距离最后一个值 i 个位置的值将可用:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[-3]}" third value

我们可以注意到这里有几个有趣的点。

-2 索引指向数组中的最后一个位置,该位置被 "fifth value" 填充,并减去两个槽,因此我们从后往前数,直到到达 5-2 的槽。数组索引中的第三个位置(索引从 0 开始)包含字符串 "third value"

第二,我们使用了带有空格的字符串,得益于双引号将它们保留了下来。为了确保输出正确,我们在回显时也引用了检索到的值。

类似地,我们可以通过使用名为$my_associative[string]的形式,将元素的值检索到关联数组中。

其中字符串是存储在数组中的与我们想要检索的值相关的键之一:

my_associative=([George]=first_value [Anthony]=second_value) ; echo ${my_associative[Anthony]} second_value

我们还可以一次性检索所有存储的值,使用以下方式:

${my_array[@]} ${my_array[*]} ${my_associative[@]} ${my_associative[*]}

正如我们从以下示例中看到的:

zarrelli:~$ echo ${my_array[@]} first value second value third value fourth value fifth value zarrelli:~$ echo ${my_array[*]} first value second value third value fourth value fifth value zarrelli:~$ echo ${my_associative[@]} my_value second_value first_value zarrelli:~$ echo ${my_associative[*]} my_value second_value first_value

但如果你不想要所有的值,我们可以通过以下语法以 切片 的方式获取它们:

${my_array[@]:S:O} ${my_array[*]:S:O}

其中 S 为我们开始的索引值,O 为读取值的偏移量:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[@]:3:2}" fourth value fifth value zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[*]:3:2}" fourth value fifth value

在这两个示例中,我们从索引位置 3 开始读取,实际上读取了接下来的两个值。如果我们省略其中一个值,剩余的值将作为从位置 0 开始的偏移量来考虑:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[*]:2}" third value fourth value fifth value

我们还可以使用本书中前面提到的子字符串删除操作符:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[@]%%fou*}" first value second value third value fifth value

或者:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[@]#s?cond}" first value value third value fourth value fifth value

或者:

zarrelli:~$ my_array=("first value" "second value" "third value" "fourth value" "fifth value") ; echo "${my_array[@]/third/forth-1}" first value second value forth-1 value fourth value fifth value

以此类推。

请注意,${array_name[@]}${array_name[*]}在参数扩展时遵循与$@$*相同的规则,第一个表示将所有参数视为一个单一字符串,后者则将参数视为单独的单词并加以引用。

现在我们知道如何从数组中存储和检索数据,接下来要看看如何删除它们。我们可以使用以下命令:

  • unset array_name

  • unset array_name[@]

  • unset array_name[*]

请看以下示例:

zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; unset my_array ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; unset my_array[@] ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; unset my_array[*] ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: 

我们可以在定义的索引位置取消设置单个值:

zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; unset my_array[2] ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: one two four five

对于关联数组也是如此:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
unset friends[Mike]
for i in Mike Anthony
do 
echo "$i - ${friends[$i]}"
done

如您从以下输出中所看到的:

zarrelli:~$ ./associative-remove.sh Mike - Anthony - is another friend

你也可以将空值分配给数组和单个值,无论是对于索引数组还是关联数组:

zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; my_array[2]="" ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: one two four five

或者:

zarrelli:~$ my_array=(one two three four five) ; echo "The content of the array is: ${my_array[@]}" ; my_array=() ; echo "Now the content of the array is: ${my_array[@]}" The content of the array is: one two three four five Now the content of the array is: 

对于关联数组,只需在前面的脚本中修改这一行:

friends=([Mike]="is a friend" [Anthony]="is another friend") friends[Mike]="" for i in Mike Anthony

现在,运行它:

zarrelli:~$ ./associative-remove.sh Mike - Anthony - is another friend

否则,再次修改这些行:

friends=([Mike]="is a friend" [Anthony]="is another friend") friends=() for i in Mike Anthony

现在,运行脚本:

zarrelli:~$ ./associative-remove.sh Mike - Anthony - 

有一些最终说明,涉及一些我们可以用来处理数组的有趣符号:

${#array_name[index]}

以下代码解释了在索引位置指向的数组值的长度:

my_array=(one two three four five) ; echo "The length of ${my_array[4]} is of ${#my_array[4]} characters" The length of five is of 4 characters

或者:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
echo "The lenght of \"${friends[Anthony]}"\ is ${#friends[Anthony]}"
And executing it gives us:
zarrelli:~$ ./associative-count.sh 
The lenght of "is another friend" is 17

数组的另一个有趣扩展是:

{#array_name[*]} 或 #

这可以扩展为数组中的元素数量:

zarrelli:~$ my_array=(one two three four five) ; echo "We have ${#my_array[*]} elements in the array" We have 5 elements in the array

或者:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
echo "We have ${#friends[@]} elements in the array"

这给我们带来了以下结果:

zarrelli:~$ ./associative-elements.sh We have 2 elements in the array

现在我们拥有了所有元素,可以看看如何遍历数组的内容。我们可以从已经看到的简单例子开始:

#!/bin/bash
declare -a my_array
my_array=("one" "two" "three" "four" "five")
for (( i=0 ; i<${#my_array[*]} ; i++ ));
do 
echo "${my_array[i]}" 
done

现在,让我们执行它:

zarrelli:~$ ./loop1.sh one two three four five

这是一种简单的方法,但有一些限制:索引从 0 开始,并且预期进展是顺序的。但我们可以做一些事情来克服这些限制:

#!/bin/bash
declare -a my_array
my_array=("one" "two" "three" "four" "five")
for i in ${my_array[*]} ; 
do 
echo "$i" 
done

我们修改了for语句,现在i将实例化为从$(my_array[*]}扩展中获得的每个元素:

zarrelli:~$ ./loop2.sh one two three four five

到目前为止,一切顺利。我们可以访问值,但索引呢?请记住,${!array_name[@]}${!array_name[*]} 展开为数组的索引列表。只需注意,在引号中使用@会将每个键展开为一个单独的单词。因此,了解这一点后,我们可以同时检索值和索引:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
for i in ${!friends[*]}
do 
echo "$i - ${friends[$i]}"
done

这将给我们以下结果:

zarrelli:~$ ./loop3.sh Mike - is a friend Anthony - is another friend

最后,稍微复杂一点的内容:

#!/bin/bash
declare -A friends
friends=([Mike]="is a friend" [Anthony]="is another friend")
indexes=(${!friends[*]})
for ((i=0 ; i<${#friends[*]} ; i++));
do 
echo "${indexes[i]} - ${friends[${indexes[i]}]}"
done

我们将朋友数组的所有索引存储在另一个名为indexes的数组中,然后使用它来从前一个数组中获取内容:

zarrelli:~$ ./loop4.sh Mike - is a friend Anthony - is another friend

我们很快会看到更多关于迭代的内容,但接下来我们要关注的是如何通过利用 Bash 提供的另一个构造——函数——来使我们的代码简洁、整洁并且可重用。

函数

在本书的这一部分,我们已经足够了解如何编写自己的代码,处理变量,与用户和环境交互,做很多事情,因此我们已经准备好制造混乱了。我们知道如何写一堆代码行,但我们仍然不知道如何保持代码整洁、简洁,更重要的是,如何使代码可重用。正如我们从到目前为止的示例中轻松猜到的那样,脚本或命令行是单向处理的代码流;构成我们命令的字符是从左到右、从上到下读取的。因此,当你传递一个构造或赋值时,它就完成了,如果你想按照之前的方式处理某些内容,你必须再次编写执行该过程的代码。所以,如果你编写的不仅仅是一个小脚本,你很可能会陷入大量重复代码、凌乱布局和低效的困境;但 Bash 和其他任何编程语言一样,为我们提供了一种方法来克服这些问题。我们说的就是函数。什么是函数?一个例子比千言万语更能说明函数的作用。让我们创建一个小的代码片段:

#!/bin/bash
if (("$1" < "$2"))
then
echo "Great! The integer $1 is less than $2"
else
echo "The integer $1 is not less than $2..."
fi

它接受两个位置参数作为输入,以检查第一个参数是否小于第二个参数,假设输入的是整数:

zarrelli:~$ ./minor-no-function.sh 1 2 Great! The integer 1 is less than 2 Now, let's move part of the code into a function: #!/bin/bash minor() { if (("$1" < "$2")) then echo "Great! The integer $1 is less than $2" else echo "The integer $1 is not less than $2..." fi } minor "$1" "$2"

现在是时候尝试我们全新的函数了:

zarrelli:~$ ./test.sh 1 2 Great! The integer 1 is less than 2 What did we do? First, we see that a function declaration has the following structure: function_name() { instruction_1 … instruction_n }

但它也可以有如下结构:

function _name() { instruction_1 … instruction_n }

它甚至可以使用function关键字来声明,如下所示:

function function_name { instruction_1 … instruction_n }

我们也可以有一个单行定义:

zarrelli:~$ print_me() { echo "This is your input:"; echo "$1"; } ; print_me 1 This is your input: 1

注意最后一个命令后的;。我们在第四章*, 引用和转义中也看到了匿名函数的使用。

无论你想使用什么样的声明,函数只需通过调用它的名称并接受位置参数来触发,如下所示:

function_name arg1 argn

正如我们在前一章所看到的,函数可以返回一个值,因为请记住,函数内部处理的值只有在函数被触发后才可用:

#!/bin/bash
minor()
{
if (("$1" < "$2"))
then
echo "Great! The integer $1 is less than $2"
echo "Assigning \$1 to the variable \"var\"" 
var="$1"
echo "The value of var inside the function is: $var"
else
echo "The integer $1 is not less than $2..."
fi
}
echo "The value of var outside the function before it is triggered is: $var"
minor "$1" "$2"
echo "The value of var outside the function after it is triggered is: $var"

在这个示例中,我们将第一个位置变量的值赋给了名为var的变量,然后在函数内外打印这个值,直到它被触发之前以及触发之后:

zarrelli:~$ ./minor-function.sh 1 2 The value of var outside the function before it is triggered is: Great! The integer 1 is less than 2 Assigning S1 to the variable "var" The value of var inside the function is: 1 The value of var outside the function after it is triggered is: 1

我们注意到了一些有趣的事情。

如果我们尝试在函数触发之前打印 var 的值,我们什么也得不到。这是因为尽管函数的代码在命令之前被读取,但 echo "The value of var outside the function before it is triggered is: $var" 这条语句在函数触发之前执行,因而没有机会操作变量并给 var 赋值。

其次,echo "The value of var outside the function before it is triggered is: $var" 这条语句虽然在函数之前执行,但它实际上是第一个在终端上打印的消息。

在函数内部赋值或创建的内容在函数外部是可用的,因为函数在与脚本相同的 Shell 上下文中运行,因此它们共享相同的环境和变量。但如果我希望创建只在函数内部可用的变量呢?我们可以通过在变量前添加 local 内建命令来修改赋值指令:

local var= "$1"

我们再次运行脚本:

zarrelli:~$ ./minor-function.sh 1 2 The value of var outside the function before it is triggered is: Great! The integer 1 is less than 2 Assigning S1 to the variable "var" The value of var inside the function is: 1 The value of var outside the function after it is triggered is: 

我们可以将变量从脚本的主体中隐藏,但我们也可以让函数返回一些值:

#!/bin/bash
OK=10
NOT_OK=50
minor()
{
if (("$1" < "$2"))
then
echo "Returning the value of OK"
return "$OK"
else
echo "Returning the value of NOT_OK"
return "$NOT_OK"
fi
}
print_return()
{
if (("$3" == "$OK")) ; then
echo "Great! The integer $1 is less than $2"
exit 0
elif (("$3" == "$NOT_OK")) ; then
echo "The integer $1 is not less than $2..."
exit 1
else
echo "Something gone wild..."
echo "The first integer has the value of $1 and the second of $2..."
exit 1
fi 
}
minor "$1" "$2"
print_return "$1" "$2" "$?"

在这个示例中,我们玩了一下创建一个新函数来打印实际的消息,并且我们看到使用函数的一个初步好处:代码中的整数比较现在变得更简洁,行数更少,可读性更强。另一方面,打印返回值时,输入的是前两个位置参数,第三个参数是 minor 函数的返回代码($?)。引入一个专注于打印消息的函数的另一个好处是,它帮助我们实现了分层:print_return 函数负责呈现层,minor 函数负责处理层。因此,每次我们想修改显示信息的方式时,就不需要修改核心函数,从而避免了引入任何错误。另一方面,如果我们想修改核心函数,我们可以随意更改,而不需要修改呈现层,只要核心输出保持不变。

如果你有一些认为可以在多个脚本中使用的函数,将它们写在一个文件里,然后在脚本中引用该文件并从中使用这些函数是个不错的主意。这样,你就拥有了一个自己的函数库,在需要时可以重复使用,避免每次都要编写相同的代码。

但是我们能否将引用其他变量的变量传递给函数呢?让我们试试这个:

zarrelli:$ cat inference.sh #!/bin/bash FIRST_VALUE=SECOND_VALUE SECOND_VALUE=20 print_value() { echo "The value of \$1 is: $1" } print_value "${FIRST_VALUE}" exit 0

所以,FIRST_VALUE 引用了 SECOND_VALUE,而 SECOND_VALUE 的值是 20,因此我们期待在尝试打印 $FIRST_VALUE 时,看到 20

zarrelli:~$ ./inference.sh The value of $1 is: SECOND_VALUE

这并不是我们预期的结果,对吧?之所以会这样,是因为 Bash 将变量名 SECOND_VALUE 视为一串字符。它只是一个按字面值取用的字符串,而不是指向值为(20)的指针。不过我们依然可以解决这个问题;只需在之前的脚本中添加 print_value "${!FIRST_VALUE}",并放在 exit 0 之前,然后再运行它:

zarrelli:~$ ./inference.sh The value of $1 is: SECOND_VALUE The value of $1 is: 20

我们使用了所谓的间接引用来实际引用一个值的值。这种语法 $``{!variable_name} 是在 Bash 2 中引入的,它使得间接引用不再那么难以书写,但有时你可能会遇到旧版本:

zarrelli:~$ a=b ; b=c ; echo $a ; eval a=\$$a ; echo $a b c

我们看到的 $$a 实际上是值的值,然后我们通过 eval 对其进行转义,强制评估并将其赋值给 a

那么,在将变量传递给函数后,如何进行解引用呢?以下是一些可以尝试的代码行:

#!/bin/bash
a10=20
print_value()
{
echo -e
echo -e "The name of the variable passed as \$1 to the function is: $1\n"
b20=\$"$1"
echo -e "b20 holds the reference to the content of the variable passed on the command line: $b20\n"
c30=${b20//[[:punct:]][[:alpha:]]}
echo -e "But playing with parameter substitution we got an untyped value out of it: $c30\n"
eval d40=\$$1
e50=$(($d40+$c30))
echo "And we used it as in integer to add to the original value we received"
echo -e "as input so the integer extracted from the name of the variable added to the variable value is: $e50\n" 
eval $1=$e50
echo -e "Thanks to eval we assign the new value to the original input\n" 
echo -e "The value of \$1 now is: $e50\n"
}
echo -e
echo "The value of a10 before triggering the function is: $a10"
print_value a10
echo -e "The value of a10 after triggering the function is: $a10\n"
exit 0

所以我们从一个叫做 a10 的变量开始,它的值是 10。然后,我们在触发函数之前打印了它的值,接着调用函数并传递了该变量的名称。在 print_value 函数的第一步中,打印了传递给该函数的第一个位置参数的值。现在,你拥有了足够的知识来理解这段代码及其操作。我们稍微玩了一下间接引用、解引用和参数替换,所以脚本的简单输出应该能让一切变得清晰:

zarrelli:~$ ./dereference.sh The value of a10 before triggering the function is: 20 The name of the variable passed as $1 to the function is: a10 b20 holds the reference to the content of the variable passed on the command line: $a10 But playing with parameter substitution we got an untyped value out of it: 10 And we used it as in integer to add to the original value we received as input so the integer extracted from the name of the variable added to the variable value is: 30 Thanks to eval we assign the new value to the original input The value of $1 now is: 30 The value of a10 after triggering the function is: 30

现在我们明白了:a10 的值从原来的 20 变成了新的 30,而且现在我们知道了原因和过程。在离开函数章节之前,再提几点:我们已经讨论过匿名函数:

zarrelli:~$ x=10 ; y=5 ; { z=$(($x*$y)) ; echo "Value of z inside the function: $z" ; } ; echo "Value of z outside the function: $z" Value of z inside the function: 50 Value of z outside the function: 50

只要记住在结束大括号之前的最后一个分号,并查看返回的值:

zarrelli:~$ cat minor-function-return-message.sh #!/bin/bash OK=10 NOT_OK=50 minor() { if (("$1" < "$2")) then echo "Returning the value of OK" return "$OK" else echo "Returning the value of NOT_OK" return "$NOT_OK" fi } message=$(minor "$1" "$2") echo "$message"

一旦调用,脚本会输出一个有意义的错误信息,克服了内置 return 的限制,它只能返回整数:

zarrelli@moveaway:~/Documents/My books/Mastering bash/Chapter 5/Scripts$ ./minor-function-return-message.sh 1 2

之前的代码返回了 OK 的值,并且作为代码块,函数 stdinstdout 可以很容易地重定向:

zarrelli:~$ cat redirect.sh #!/bin/bash file=friends.txt parse() { while read lineofile do echo $lineofile done }<$file parse

这将给我们以下输出:

zarrelli:~$ ./redirect.sh Anthony Dionisios Ilaria Mike Noel Tarek

现在就这些,接下来是时候为我们的脚本加入一些趣味了。

总结

你已经学会了如何与用户交互、读取他们的输入并将其存储在合适的结构中,循环遍历值,并利用函数让我们的代码更加整洁和可重用。现在是时候探索一些我们已经使用过的结构了:我们在谈论的是迭代。

第六章:迭代

到目前为止,我们所看到的使我们能够与用户互动,处理输入,并根据我们设定的条件提供输出。所有这些都很好;如果用户带着一些参数调用我们的脚本,我们可以将它们存储在数组中并进行处理,前提是我们知道他们传递给命令行的选项数量。我们必须事先知道用户将提供多少项,否则我们会丢失多余的项。这时,迭代结构就派上用场了。因为我们已经看过一些示例,它可以枚举数组的内容,并让我们在不知道存储了多少项的情况下处理这些内容。在本章中,我们将看看如何使用 for 循环和 while/until 循环来充分利用用户提供的数据。

for 循环

for 循环是 Bash 脚本中最常用的结构之一,它使我们能够对列表中的每个单独项执行一个或多个操作。它的基本结构可以概括如下:

for placeholder in list_of_items do
 action_1 $placeholder action_2 $placeholder action_n $placeholderdone

所以,我们使用了一个占位符,它将在循环的每一轮中获取列表项中的一个值,然后在 do 部分进行处理。当列表被扫描完后,循环结束,我们退出它。让我们从一个简单且漂亮的例子开始:

#!/bin/bash for i in 1 2 3 4 5 do
 echo "$i"done

现在让我们执行它:

zarrelli:~$ ./counter-simple.sh 1 2 3 4 5

其实,这非常简单,但要注意列表可以是任何操作的结果:

#!/bin/bash for i in {10..1..2} do
 echo "$i"done

在这种情况下,我们使用了大括号扩展来得到一个步长为 2 的倒计时:

zarrelli:~$ ./counter-brace.sh 10 8 6 4 2

我们也可以将 for 循环写在一行中:

zarrelli:~$ for i in *; do echo "Found the following file: $i"; done Found the following file: counter-brace.sh Found the following file: counter-simple.sh

漂亮,不是吗?现在让我们做一些更复杂的事情。假设我们要写出以下列表:

Belfast is in UK Redwood is in USA Milan is in ITALY Paris is in FRANCE

我们该怎么做呢?让我们尝试用一个简单的循环,看看会发生什么:

#!/bin/bash for cities in Belfast UK Redwood USA Milan ITALY Paris FRANCE do
 echo "$cities is in $cities" done exit 0

现在让我们运行它:

zarrelli:$ ./for-pair.sh Belfast is in Belfast UK is in UK Redwood is in Redwood USA is in USA Milan is in Milan ITALY is in ITALY Paris is in Paris FRANCE is in FRANCE

并不是我们想要的结果。嗯,完全不是,因为脚本不知道如何区分城市、国家以及它们之间的关系。我们必须找到一种方法来标定这些项;我们可以通过它们的位置来做到这一点。这里我们使用了内置的 set 命令,它使我们能够将变量的内容分配给位置参数。我们将以一种有趣的方式使用它:

#!/bin/bash for cities in "Belfast UK" "Redwood USA" "Milan ITALY" "Paris FRANCE" do
 set -- $cities echo "$1 is in $2" done exit 0

现在让我们运行脚本:

zarrelli:~$ ./for-pair-set.sh 
  Belfast is in UK
 Redwood is in USA Milan is in ITALY Paris is in FRANCE

这要好得多,正是我们想要的结果;但是我们是怎么实现的呢?第一步是将相关项分组并加上双引号,比如,BelfastUK 一起。棘手的部分是使用 set 内置命令与 --,它强制将后续的值分配给位置参数,即使它们以短横线开头,如果没有提供参数,位置参数将被清空。因此,由于我们有两个一组的项:城市和国家,我们有 $1$2;一个表示城市,另一个表示国家。从那时起,剩下的就是打印这些位置参数的事了。我们甚至可以不指定列表来继续:

zarrelli:~$ cat for-pair-input.sh #!/bin/bash i=0 for cities do
 echo "City $((i++)) is: $cities" done exit 0

然后,我们可以在命令行上提供参数;脚本将从 $@ 获取输入:

zarrelli:~$ ./for-pair-input.sh 
Belfast Redwood Milan Paris City 0 is: Belfast City 1 is: Redwood City 2 is: Milan City 3 is: Paris

如前所述,列表可以是任何东西:一个变量,一个大括号表达式,固定的值,命令替换的结果,任何通过迭代的值创建列表的东西:

zarrelli:~/$ cat counter-function.sh #!/bin/bash counter() {
 echo {10..0..2} } for i in $(counter) do
 echo "$i" done

在这个示例中,列表是由计数器函数提供的,该函数输出大括号展开的结果。然后,我们通过echo命令获取函数返回的值,并将其作为列表进行迭代:

zarrelli:~$ ./counter-function.sh 10 8 6 4 2 0

不要忘记 C 风格:

zarrelli:~$ cat c-for.sh #!/bin/bash for ((i=20;i > 0;i--)) { if (( i % 2 == 0 )) then
 echo "$i is divisible by 2"fi } exit 0

所以,我们有一个递减的计数器:

zarrelli:~$ ./c-for.sh 20 is divisible by 2 18 is divisible by 2 16 is divisible by 2 14 is divisible by 2 12 is divisible by 2 10 is divisible by 2 8 is divisible by 2 6 is divisible by 2 4 is divisible by 2 2 is divisible by 2

到目前为止,我们所看到的内容让我们能够遍历数据结构并对其进行操作,只要我们有一些项目需要处理,但我们还不知道如何在某个条件成立或不成立之前进行操作,所以下一段将讨论如何保持脚本在某些事情发生之前保持运行。

让我们做一些事情,直到……

for 循环是一个很好的选择,用来枚举用户提供的内容,但当需要处理一组预先无法知道数量的选项时,它就不太方便了。在这种情况下,我们会发现一些更有趣的循环结构,它们允许我们在满足某个条件之前或在某种情况持续存在时进行循环,例如,用户输入某些内容时,或者直到达到某个阈值为止。所以,让我们看看哪些结构可以帮助我们:

while condition do
 command_1 command_2 command_n done

一开始,whilefor 循环的区别很明显:后者基于一个占位符,每次从列表中获取一个值并对该值进行操作,而前者在条件满足时触发。让我们通过一个从 for 循环开始的示例来说明:

#!/bin/bash for i in 1 2 3 4 5 do
 echo "$i" done

这是一个简单的计数器,从 1 到 5,我们已经看过它:

zarrelli:~$ ./counter-simple.sh 1 2 3 4 5

现在,让我们使用 while 循环重写它:

#!/bin/bash i=1 while (( i <= 5)) do
 echo "$i"((i++)) done

让我们看看输出是否相同:

zarrelli:~$ ./while-simple.sh 1 2 3 4 5 

好吧,输出是完全相同的。接下来,我们将讨论另一种循环结构,until 循环,它在条件满足之前对列表进行循环。其结构如下:

until condition do
 command_1 command_2 command_n done

结构与 while 循环类似,只是条件不同:while 在条件成立时持续运行,until 在条件成立时停止运行。为了更好地理解区别,让我们将示例重写为 until 形式:

#!/bin/bash i=1 until (( i > 5)) do echo "$i"((i++))done

从代码中可以看到,直到 i 的值大于 5 之前,我们打印它的值并增加它:

zarrelli:~$ ./until-simple.sh 12345

看起来很熟悉,不是吗?那么我们可以总结出三种类型的循环,条件如下:

  • for 在从列表中获取的值上进行迭代

  • while 在条件为 false 时执行循环

  • until 在条件为 false 时执行循环

使用 break 和 continue 退出循环

这给了我们一些不错的机会,比如无限循环:

while true ; do echo "Hello" ; done

由于true始终计算为真,因此条件始终会被验证,从而导致do/done语句块的无限执行;按Ctrl+C退出循环。无限循环看起来像是一个麻烦的事情,但它为我们的脚本打开了一个新的场景,因为我们可以让它们运行或等待某个事件,直到我们希望的时间。实际上,如果我们不使用一些循环控制命令:break将退出循环,continue将重新启动循环,跳过剩余的命令。让我们看看创建一个假设的备份程序菜单的示例:

#!/bin/bash while true do
 clear cat <<MENU BACKUP UTIL v 1.0 ------------------ 1\. Backup a file/directory 2\. Restore a file/directory 0\. Quit ------------------ MENU
 read -p "Please select an option, 0 or Q to exit: " option case $option in 1 | [Bb]) echo "You chose the first option, Backup" sleep 3 ;; 2 | [Rr])  echo "You chose the second option, Restore"
 sleep 3 ;; 0 | [Qq]) echo "You chose the third options, Quit, so we quit!" break ;; *) echo "Not a valid choice, please select an option..." sleep 3 ;; esac done 

来看看我们做了什么。我们打开了一个while true循环,所以其中的内容会一遍又一遍地执行。然后,我们使用了here文档来展示一个漂亮的菜单给用户,并使用read选项让用户选择并评估输入。除非用户选择quit,否则任何选择都会什么也不做,仅显示一条消息并等待 3 秒,之后循环重新开始,清空屏幕并再次显示菜单(这就是sleep命令的作用)。唯一的例外是如果客户选择了0Qq:在这种情况下,会显示一条消息并退出循环:

zarrelli:~$ ./menu.sh 
  BACKUP UTIL v 1.0
 ------------------ 1\. Backup a file/directory 2\. Restore a file/directory 0\. Quit ------------------ Please select an option, 0 or Q to exit: 0 You chose the third options, Quit, so we quit!

请注意,我们退出的是循环,而不一定是整个脚本。这是一个经典的老式菜单,它相较于图形菜单有一些优势:更容易编写、更容易维护、消耗的资源更少,但最重要的是,它不需要图形显示器:它在字符显示器和串行连接上运行良好。对于continue指令,操作流程非常不同,因为它会恢复主forwhileuntilselect循环的迭代。当用于for循环时,变量将取列表中条件的下一个元素的值:

zarrelli:~$ cat for-continue.sh #!/bin/bash for i in {0..10} do
 if (( i == 4 )) then continue else echo $i fi done exit 0

我们的代码将从 0 枚举到 10 并打印出遇到的值,除了当它遇到数字 4 时:在这种情况下,continue将迫使for循环跳过该值并从 5 继续:

zarrelli:~$ ./for-continue.sh 0 1 2 3 5 6 7 8 9 10

现在是时候给我们的客户端一个菜单了

在本章中,我们将探讨不同的方式来处理循环,以便处理用户提供给我们的信息。从一个简单的菜单开始,我们转向了更华丽、外观更佳的方式;现在,是时候更进一步,看看select构造,它的任务是让我们轻松创建菜单。它的语法与for构造相似:

select placeholder [in list] do command_1 command_2 command_n done

因此,正如我们所看到的,这个结构与for非常相似,并支持列表,在标准错误中展开为一系列以数字开头的元素。如果省略了in list部分,列表将从命令行中给定的位置参数构建,例如,如果我们使用了[in $@]。一旦打印出列表中的元素,将显示PS3提示,并读取并存储到REPLY变量中的stdin行。如果读取了行上的内容,则每个单词都会显示出来并附带一个数字;如果行为空,则再次显示提示,但给出ifEOF字符作为输入(Ctrl+D)时退出循环。作为快捷方式,您可以使用break退出。让我们看一个例子:

#!/bin/bash echo "Just select the fruit you like:" select fruit in apple banana orange mango do
 echo "You picked $fruit (Option $REPLY)" done

这个简单的脚本将向您展示一个从in list中获取的选项菜单,并等待选择。一旦用户输入选择,它就会被回显,并且循环再次开始,显示可用选项:

zarrelli:~$ ./simple-select.sh Just select the fruit you like: 1) apple 2) banana 3) orange 4) mango #? 3 You picked orange (Option 3) #? o You picked (Option o) #? pear You picked (Option pear) #? 

正如我们所看到的,我们无法控制用户向我们提供的内容,所以这是我们必须自己实现的事情。此外,提示是我们见过的最不性感的东西,但我们可以通过为PS3变量赋值来更改它,所以只需在 sha-bang 下面添加PS3="Your choice is: "。保存并重新运行脚本:

Just select the fruit you like: Enter the number of the file you want to protect: 1) apple 2) banana 3) orange 4) mango Your choice is: 

现在好多了。很好,现在另一个小问题:脚本永远不会退出,所以我们如何强制它退出呢?让我们看一些有趣的修改:

zarrelli:~$ cat case-select.sh #!/bin/bash PS3="Your choice is: " echo "Just select the fruit you like:" select fruit in apple banana orange mango do
 case "$fruit" in mango) echo "You chose $fruit, so we wanna break free!" break ;; *) echo "You chose $fruit" ;; esac done

我们在select内嵌套了一个case结构,这样我们就可以评估用户给出的选择并做出相应反应。无论如何,我们只是打印出用户的选择,但如果他选择了4,我们会打印出选择并使用break退出:

zarrelli:~$ ./case-select.sh Just select the fruit you like: 1) apple 2) banana 3) orange 4) mango Your choice is: 2 You chose banana Your choice is: 4 You chose mango, so we wanna break free!

但是我们可以做得更有趣,特别是如果我们想与系统进行交互。让我们再次尝试制作一个备份脚本,并利用我们刚刚做的事情:

#!/bin/bash while true do
 clear cat <<MENU BACKUP UTIL v 1.0 ------------------ 1\. Backup a file/directory 2\. Restore a file/directory 0\. Quit ------------------ MENU PS3="Which file do you want to backup? " touch EXIT 
  read -p "Please select an option, 0 or Q to exit: " option
 case $option in 1 | [Bb]) echo "You chose the first option, Backup" clear select file in * do case "$file" in                    EXIT)
 echo "Ok, we exit!" rm EXIT break ;; *)
 echo "Compressing file $file" tar cvzf "${file}".tgz "$file" || exit 1 echo "File $file compressed." ls "${file}".tgz echo "Press a key to return to main menu..." read                break
 ;; esac done ;; 2 | [Rr])         echo "You chose the second option, Restore"
 sleep 3 ;; 0 | [Qq]) echo "You chose the third options, Quit, so we quit!" break ;; *) echo "Not a valid choice, please select an option..." sleep 3 ;; esac done rm EXIT

我们使用while true创建了主循环,这样脚本将始终运行,除非我们明确退出它,然后使用here文档,向用户显示一个整洁的菜单,然后使用case结构来评估用户在下一步中给出的答案。case语句中的第一个选项清除屏幕并嵌入一个选择结构,还提供了使用文件名扩展来操作文件列表的方法。由于我们事先不知道当前目录中会有多少文件,并且不能修改列表,我们可以依赖一个技巧,在脚本开始时创建一个名为EXIT的文件,并在脚本结束时删除它。输出将类似于这样:

BACKUP UTIL v 1.0
------------------
1\. Backup a file/directory
2\. Restore a file/directory
0\. Quit
------------------
Please select an option, 0 or Q to exit:

然后选择选项 1:

文件会自动编号,而没有 EXIT 占位符。目录中的文件都会被编号,并且我们的 EXIT 策略会显示出来,尽管它不是最后一个选项;但是重命名它会让我们将它放在任何我们想要的位置。在select中,我们找到另一个 case,因为我们要评估用户给出的答案,并处理正确的文件。所以,如果用户没有选择与 EXIT 对应的选项,我们不会从循环中退出并清理文件,而是继续压缩它,并退出到主循环,这会显示主菜单。所有这些都很好,但你意识到这里存在一个大问题吗?如果你选择一个在 select 菜单中并不存在的选项会发生什么?

1) backup-menu.sh 2) case-select.sh 3) c-for.sh 4) counter-brace.sh 5) counter-function.sh 6) counter-simple.sh 7) EXIT 8) for-continue.sh 9) for-pair-input.sh 10) for-pair-set.sh 11) for-pair.sh 12) simple-select.sh 13) until-simple.sh 14) while-simple.sh Which file do you want to backup? 15 Compressing file tar: Substituting `.' for empty member name tar: : Cannot stat: No such file or directory tar: Exiting with failure status due to previous errors

好吧,这是可以预料的,因为我们没有对输入进行真正的检查,或者更准确地说,我们只检查了我们预期接收的内容,而不是意外的内容。所以让我们修改默认选项以适应内部的 case 语句:

*)
 if [ -z "$file" ] then       echo "Please, select one of the number displayed"
 sleep 3 continue fi echo "Compressing file $file" tar cvzf "${file}".tgz "$file" || exit 1 echo "File $file compressed." ls "${file}".tgz echo "Press a key to return to main menu..." read break ;;

我们仅仅在传递给$file variable的内容中添加了一个检查:如果该变量为空且不指向任何文件名,我们将显示一条消息,等待三秒钟,然后重新启动循环。我们也可以使用read,而不仅仅是等待,这样可以强制用户按下一个键继续;这将使警告消息保持显示,直到用户做出反应。

从各个示例中我们可以看到,创建用户菜单在 Bash 中有不止一种方法;在接下来的章节中,我们将使用这些方法并让它们变得更加华丽。但谈到用户交互时,我们还有一个话题需要面对,而且它非常有趣:如何处理传递给脚本的命令行参数。所以,如果我们不想显示菜单,而是希望在命令行接收参数,我们该如何操作呢?我们已经看到过一些内容,但有一个很好的内建功能可以让我们的工作更加轻松,现在是时候看看getops了。

CLI,将参数传递给命令行

Geopts 是一个 Bash 内建命令,广泛用于高效地解析传递给脚本的开关和参数。我们已经看到过其他完成此任务的方法,但getops使得处理它变得非常简单,因为它可以自动识别传递给脚本的开关和参数。它的语法如下:

getops options variable

我们传递给getops的第一个是选项的字符串,即经典的-a -x -f之类的,没有任何前导的破折号,比如getops axfgetops ax:f。如果你看到某个选项后面跟着一个冒号,这意味着该选项需要一个参数,如下所示:

./our_script.sh -x our_argument -a

在我们的示例中,-x有一个参数,而-a是一个简单的开关,或者我们也可以称之为标志,它可以存在也可以不存在,但不需要任何参数。选项可以用小写或大写字母,或者数字来指定。getops内建有一些预定义变量供其内部使用:

  • OPTARG保存着选项的参数或未知选项的标志。

  • OPTBIND保存下一个要解析的选项的索引。

  • OPTERR的值为 0 或 1,用来设置getops的错误信息显示。默认值为 1,所以这里会显示错误信息。

很明显,getops适用于解析短选项,但它无法处理长选项样式,因此-a是可以的,但--all则无法解析。这是一个限制,但也只是风格问题。我们来看一个简单的例子,边注释边看getops在实际应用中是如何工作的:

#!/bin/bash while getopts ":ax:f" option do
 case $option in a | f) echo "You selected $option!" ;;
 x) echo "You selected $option with argument $OPTARG" ;;
 ?) echo "Invalid switch: -$OPTARG"
 ;; :) echo "No arguments provided: -$OPTARG"
 ;; esac done

所以,我们的脚本以一个 while 循环开始,这是因为当getops遇到无法解析的内容时,它会退出并返回一个名为fail的状态,这个条件会在遇到第一个非选项参数或遇到--时触发。接下来,我们看到的是getops内建命令,后面跟着ax:f,意味着它期望如下:

-a -x argument -f

getops会读取所有选项,直到遇到第一个非选项参数,并将其存储在一个变量中,在我们的例子里叫做 option。现在,最后这一部分有点复杂。看一下getopts ":ax:f"

你注意到第一个选项前的:了吗?它禁用了 getops 的标准错误信息,并改变了标准变量的使用方式。

如果选项无效,变量(在我们的例子中是option)会用?字符来存储错误信息;同时,OPTARG会存储用户提供的无效字符。

如果有参数,变量会通过冒号:进行实例化,而OPTARG会保存选项字符。让我们用不同的选项运行这个脚本,看看它是如何工作的:

zarrelli:~/$ ./getops-simple.sh -a You selected a! zarrelli:~/$ ./getops-simple.sh -f You selected f! zarrelli:~/$ ./getops-simple.sh -f -a You selected f! You selected a! zarrelli:~/$ ./getops-simple.sh -x No arguments provided: -x zarrelli:~/$ ./getops-simple.sh -x hello You selected x with argument hello zarrelli:~/$ ./getops-simple.sh -x hello -a -f You selected x with argument hello You selected a! You selected f! zarrelli:~/$ ./getops-simple.sh -z Invalid switch: -z

很好,但不要被愚弄,我们这里有两个主要问题,你能看出来吗?我们来看看第一个问题:

zarrelli:~/$ ./getops-simple.sh zarrelli:~/$

好吧,没有给出任何开关,没有输出,这样可不行:记住,绝不能让用户没有任何反馈。永远让你的脚本给用户显示一些东西,让他们知道他们做了什么,否则他们可能会反复尝试调用。我们可以通过在 while 循环前添加一个小片段代码来处理这种情况,该片段用于统计命令行上的参数个数:

if (( $# == 0 )) then 
echo "Please, give at least one option on the command line" exit 1 fi

没什么特别的,我们只是检查传递给命令行的参数数量是否等于0,如果是的话,我们就输出一条信息并以错误状态退出:

zarrelli:~$ ./getops-arguments.sh Please, give at least one option on the command line Is it this all about our errors? Not precisely: zarrelli:~$ ./getops-arguments.sh -a Hello You selected a!

Hello是传递给命令行的一个参数,但-a不接受options参数,那么我们该如何获取Hello呢?请注意,这两个调用之间是有区别的:

zarrelli:~$ ./getops-arguments.sh -x Hello You selected x with argument Hello zarrelli:~$ ./getops-arguments.sh -a Hello You selected a!

第一个Hello是一个选项的参数,而第二个是命令行上的一个简单参数,它与选项无关,因为-a不接受任何参数。因此,我们第一次能够处理到Hello,但在第二种情况下却无法处理。我们该如何克服这个限制呢?让我们重写之前的例子,并在脚本的末尾添加以下几行:

echo "And the argument was $*" shift "$((OPTIND-1))" echo "And the argument was $*"

现在让我们再次运行脚本:

zarrelli:~$ ./getops-arguments.sh -x whatever -a Hello You selected x with argument whatever You selected a! And the argument was -x whatever -a Hello And the argument was Hello

就是这样。注意到我们使用了 shift,它帮助我们获取了参数;这个技巧基于 OPTIND 存储的值,它对应于 getops 最后一次调用时解析的选项数量。如果我们回顾一下 getops 的工作原理:每次调用时,它将下一个选项放入用于存储选项的变量中,如果该变量不存在,则会进行初始化,并将下一个要解析的参数的索引存入 OPTIND 变量。因此,第一次运行时,OPTIND 的值为 1。请记住,OPTIND 永远不会被 shell 重置,因此如果你需要多次调用 getops,你需要自己将该变量重新初始化为 1。然后,我们使用 shift 来处理位置参数,因为这个内建函数可以根据指定的参数将位置参数向左移动。因此,shift "$((OPTIND-1))" 会将下一个 getops 参数的位置向左移一个位置。让我们重写上一部分脚本:

case $option in
 a | f) echo "You selected $option with $OPTIND=$OPTIND and the command line argument $*!" ;; x) echo "You selected $option with argument $OPTARG with $OPTIND=$OPTIND and the command line $*!"
 ;; ?) echo "Invalid switch: -$OPTARG with $OPTIND=$OPTIND" ;; :) echo "No arguments provided: -$OPTARG with $OPTIND=$OPTIND" ;; esac done echo "$OPTIND at the end of the loop is $OPTIND" shift "$((OPTIND-1))" echo "But at the end of the script we have this left on the command line: $*"

现在,再次运行它:

zarrelli:~$ ./getops-arguments.sh -f -a Hello You selected f with $OPTIND=2 and the command line argument -f -a Hello! You selected a with $OPTIND=3 and the command line argument -f -a Hello! $OPTIND at the end of the loop is 3

但是在脚本的最后,我们在命令行上留下了这个:Hello

那么,发生了什么呢?当你运行脚本时,OPTIND 的初始值为 1,每次调用 getops 时,OPTIND 会增加 1。因此,既然我们在命令行上有两个选项需要处理,在 getops 循环结束时,OPTIND 的值将是 2+1,也就是 3。现在,如果我们对命令行使用 "$((OPTIND-1))" 进行移位,这意味着我们将命令行的参数向左移动了 2 个位置(3-1)。请记住,当你将位置参数向左移时,它们基本上会丢失,剩下的就是其余的参数。在我们的例子中,如果我们将位置参数向左移动 2 个位置,我们就去掉了 -f-a,剩下的就是第一个非选项参数,"Hello"。就是这样!如果我们在循环后打印命令行内容 $@,结果就是 Hello。现在,是时候建立我们将在接下来的章节中使用的工具,并将到目前为止学到的所有部分汇集起来了。首先:脚本将变得更复杂,并且可能会变得有些凌乱。所以,如果我们回顾一下前几章关于引用文件的内容,我们现在做的是创建一个库来存放所有我们将频繁使用的公共函数和设置。采用这种风格将帮助我们保持脚本的整洁和简洁,并更轻松地掌握其内容。所以,首先,创建一个我们称之为 library.lib 的库文件,并开始在其中编写一些函数:

# Library file holding common functions and setting # Functions non_zero_input() {
 if (( $1 == 0 )) then  echo "Please, give at least one option on the command line"
 exit 1 fi }

现在,让我们以如下方式重写前面脚本的第一部分:

#!/bin/bash source library.lib non_zero_input "$#"

现在,是时候在没有任何选项的情况下运行脚本了:

zarrelli:~$ ./getops-library.sh 
Please, give at least one option on the command line.

如我们所见,零长度输入的检查已被移至库中,然后再引用回主脚本。所以,现在我们有了两个优点:

  • 我们的主脚本中行数更少了

  • non_zero_function 现在对所有将引用我们刚创建的库的脚本都可用

但是现在,是时候来点花样了。有没有想过为输出增添一些亮点?只需按以下方式修改这个库:

# Library file holding common functions and setting # Functions #---------- non_zero_input() {
 if (( $1 == 0 )) then  echo "Please, give at least one option on the command line"
 exit 1 fi } color_print() {
 printf "$1$2${CReset}n" } # Colors - foreground #-------------------- Black='330;30m' Red='33[0;31m' Green='33[0;32m' Yellow='33[0;33m' Blue='33[0;34m' Purple='33[0;35m' Cyan='33[0;36m' White='33[0;37m' # Colors - Reset #--------------- CReset='33[0m'

我们向库中添加了一些 ANSI 颜色码,并将它们分配给有意义的变量名,还创建了一个小的 color_print 函数。ANSI 转义码或序列是用来管理文本终端上颜色和属性的方法,它们以 ESC 字符(八进制 033)开头,后面跟着一个 ASCII 范围内 64 到 95 之间的字符。我们仅添加了一些前景颜色,但通过谷歌搜索,你会找到一长串可以分配给背景色、粗体字符、反转等的转义字符。color_print 函数仅仅是利用这些控制码能够做的事情的一个小例子,它使用了 printf,虽然 echo -e 也能处理转义字符,足以用来打印颜色和属性,但 printf 更加灵活。请注意,color_print 函数在 printf 结束时使用了 '33[0m',这是一个重置控制字符,会将你对输出所做的所有更改恢复为默认设置:一旦你修改了颜色或属性,所有内容都会按照这些修改进行打印,直到你明确使用重置转义序列恢复为默认值。现在,是时候利用我们刚刚学到的东西,修改我们刚刚创建的脚本,让它以这种方式结束:

done echo "$OPTIND at the end of the loop is $OPTIND" shift "$((OPTIND-1))" echo $@ echo -e "${Green}But${CReset} at the end of the script we have this left on the command line: ${Red}$@${CReset}" color_print ${Yellow} "But we can use our color_print function to have a fancy output: $@"

这里有两个不同的示例,展示了如何使用转义码来管理输出:

echo -e "${Green}But${CReset} at the end of the script we have this left on the command line: ${Red}$@${CReset}"

单词 But 前面是我们从库中获取的 Green 变量的值,后面跟着 CReset 变量值,所以 echo 会在打印 But 之前将输出切换到绿色前景色,并在重置转义序列的作用下,在打印完成后恢复为标准颜色(通常是白色)。然后,在打印命令行参数之前,它会切换到 Red,完成后再次恢复。最后一行是使用从库文件中获取的 color_print 函数打印的:

color_print ${Yellow} "But we can use our color_print function to have a fancy output: $@"

从函数定义中我们可以看到,它接受两个参数,一个是转义码,另一个是要打印的字符串,后面跟着一个重置码;在我们的案例中我们选择了 Yellow,那么让我们看看接下来的截图中会有什么样的结果:

![内联转义码或临时函数让我们的输出更具表现力不错吧?其实使用 Bash 给输出加上颜色有很多方法,比如通过与 dialog 程序交互,它会给你一个类似 curses 的界面:zarrelli:~$ dialog --begin 10 30 --backtitle "Example menu" --title "This is a Message Box" --msgbox 'Your message goes here!' 10 30

这是一个简单的消息框,它让我想起了老版 Linux 安装程序中的 zenity,它会为你提供一个 GTK+ 界面:

zarrelli:~$ ls -l | zenity --text-info --height=600 --width 800 

Zenity 允许你使用 GTK+ 装饰创建漂亮的界面

我们可以使用的东西取决于我们希望与客户进行的交互级别;例如,处理服务的脚本可能不需要任何花哨的东西。关于可移植性的考虑:要使用 dialog 和 zenity,你必须安装它们;它们并不默认随 Linux 系统一起提供。对于 zenity,请记住,它只在图形界面上显示其优点;如果通过串口或基于文本的终端运行,它最多只会显示像 curses 这样的界面。如果你想使用比 ANSII 转义码更先进的东西,可以使用 tput 命令,它随 Linux 提供;通过使用 terminfotermcap 数据库,它可以让你以更有趣的方式与终端进行交互:

#!/bin/bash
fred=$(tput setaf 1)
fgreen=$(tput setaf 2)
fwhite=$(tput setaf 7)
bblue=$(tput setab 4)
esmso=$(tput smso)
xsmso=$(tput rmso)
dim=$(tput dim)
reset=$(tput sgr0)
hide=$(tput civis)
box() {
printf ${hide} 
printf ${bblue} 
width=$(tput cols)
height=$(tput lines)
message="Width is: ${esmso}${fgreen}$width${fwhite} Height is: ${dim}${fred}$height${reset}"
length=${#message}
clear
tput cup $((height / 2)) $(((width / 2) - ((length - 29) / 2)))
printf "$message"
}
trap box WINCH
box
while true
do
:
done 

使用 tput 显示的自动更新消息

这个简单的脚本使用 tput 和一系列数字来改变输出的颜色。tput setaf x 设置前景色为与 x 整数对应的值:

0 black 1 red 2 green 3 yellow 4 blue 5 magenta 6 cyan 7 white

对于背景,我们使用 tput setbg x 和相同的代码列表。我们使用命令替换获取命令的输出,并使用 printf 来相应地修改输出。其他 tput 命令的值是显而易见的:

  • tput smso 进入突出显示模式

  • tput rmso 退出突出显示模式

  • tput dim 使输出变得不那么亮

  • tput sgro 恢复到标准终端输出

  • tput cvis 隐藏光标

然后,我们创建了一个名为 box 的函数,它隐藏光标,并将背景设置为蓝色。这里的有趣部分是:

  • tput cols 获取终端的列数

  • tput lines 获取终端的行数

我们将两个命令的输出存储在宽度和高度变量中,并使用消息变量来组成一个字符串,输出我们终端的尺寸,使用 tput 的颜色来格式化输出,背景有一个漂亮的旗帜。我们以非常规的方式使用了各种属性,但你可以自己尝试,看看能得到什么样的输出。

查看 man terminfoman termcapman tput,了解你可以使用 tput 做的所有事情。继续编写脚本,我们获取消息的长度并清除脚本,清除屏幕,最后使用 tput cup x y

我们将光标移动到正确的位置,以便将消息居中显示在终端上。我们需要补偿用于 tput 属性的字符。在函数外部,我们使用一个陷阱,拦截发送到进程的窗口变化信号,当控制该进程的终端改变其大小时。这样,每次用户改变窗口大小时,陷阱就会调用box函数,从而计算并打印新的高度和宽度到终端。我们在最后留下了一个无限循环,一旦第一个框架迭代完成,它便开始执行:这个无限循环保持脚本处于空闲状态,等待窗口变化信号的捕获。一旦捕获到信号,就会调用box函数,计算新的高度和宽度,并显示最新的消息。

摘要

我们的脚本开始变得更加复杂且有趣;我们正在从处理 Shell 脚本转向编程,并利用它创造一些实用的东西。本书的下一部分将深入到一些实际编程,创建一些应用程序,展示如何为我们的日常生活作为系统管理员或好奇的用户,打造一些可靠且有用的工具。

第七章:连接到现实世界

现在我们进入了现实世界,正在创造一些对日常工作有用的工具;在这个过程中,我们将关注编程中常见的陷阱,并学习如何使我们的脚本更可靠。不管脚本长短,我们必须始终问自己相同的问题:

  • 我们到底想要达成什么目标?

  • 我们有多少时间?

  • 我们是否具备所需的所有资源?

  • 我们是否具备完成任务所需的知识?

我们将从编写一个 Nagios 插件开始,这将帮助我们广泛理解这个监控系统是如何运作的,以及如何使脚本与其他程序动态交互。

什么是 Nagios?

Nagios 是最广泛采用的开源 IT 基础设施监控工具之一,其主要有趣之处在于它本身并不知道如何监控任何东西。听起来像是开玩笑,但实际上,Nagios 可以被定义为一个评估核心,它接受一些信息作为输入,并根据这些信息做出反应。那么,这些信息是如何收集的呢?这并不是这个工具的主要关切,这也引出了一个有趣的观点:Nagios 将收集监控数据的任务交给一个外部插件,插件需要知道以下细节:

  • 如何连接到被监控的服务

  • 如何收集来自被监控服务的数据

  • 如何评估数据

如果收集到的值超出了或在警报阈值范围内,通知 Nagios 触发警报。

因此,一个插件做了很多事情,可能有人会问,Nagios 到底在做什么?可以把它想象成一个信息交换舱,信息不断地流进流出,决策是基于设置的配置做出的;核心触发插件去监控某个服务;插件本身返回一些信息,然后 Nagios 根据这些信息做出决策:

  • 是否触发警报

  • 发送通知

  • 通知谁

  • 持续多长时间

  • 如果采取任何措施以恢复正常状态

核心的 Nagios 程序除了真正敲开服务的大门,索取信息并决定这些信息是否显示出问题外,其他一切都做了。

主动和被动检查

要理解如何编写一个插件,我们首先需要广泛理解 Nagios 检查的工作原理。Nagios 检查有两种不同类型。

主动检查

基于时间范围,或者手动触发的情况下,主动检查会看到插件主动连接到服务并收集信息。一个典型的例子是插件检查磁盘空间:一旦被调用,它通常会与操作系统进行交互,执行 df 命令,处理输出,提取与磁盘空间相关的值,评估是否超过某些阈值,并返回状态,例如 OK、WARNING、CRITICAL 或 UNKNOWN。

被动检查

在这种情况下,Nagios 并不会触发任何操作,而是等待服务通过某种方式主动联系,服务必须被监控。看起来有些困惑,但我们可以通过一个实际的例子来说明。你怎么监控一个磁盘备份是否成功完成?一个简单的回答是:了解备份任务的开始时间和持续时间后,我们可以定义一个时间并调用脚本,在那个指定时间检查任务。

很好,但当我们进行规划时,我们必须充分理解现实生活的运行方式,备份并不是我们客厅里的一只小宠物,它更像是一头野兽,做它想做的事。备份的持续时间可能因不可预测的因素而有所不同。

比如,你的典型备份任务会在 2 小时内复制 1 TB 的数据,开始时间为 03:00,从一个 6 TB 的磁盘中进行备份。那么,下一个备份任务将从 03:00+02:00=05:00 AM 开始,可能有几分钟的误差。你设置了一个 05:30 的主动检查,它运行了几个月都很顺利。然后,在某个清晨,你收到手机上的通知,备份任务处于 CRITICAL 状态。你醒来,连接到备份控制台,发现早上 06:00,你还在睡觉,备份任务甚至没有被控制台启动。然后,你必须等到 08:00 AM,直到一些同事到达办公室,才发现前一天备份磁盘由于一次非计划的数据传输,增加了 2 TB 的数据。所以,前一个备份任务并没有持续几小时,而是持续了 6 小时,而你监控的备份任务在 09:30 AM 开始。

长话短说,你的主动检查启动得太早了,这就是它失败的原因。也许你会想把时间安排提前几个小时,但千万不要这么做,因为这些时间段不是滑动框架。如果你把检查提前,你就应该将所有后续任务的检查时间也提前。你这样做了一周后,项目经理会要求某人删除那 2 TB 的多余数据(现在对项目没有用),而你的时间安排将提前 2 小时,导致监控变得没用。所以,正如我们之前所说,规划和分析上下文是编写一个好脚本、一个好插件的关键因素。我们有一个不像 web 服务或邮件服务那样 24/7 运行的服务,备份的特殊之处在于它是定期执行的,但我们并不确切知道它什么时候运行。

这种监控的最佳方法是让服务在完成任务并报告结果时主动通知我们。通常,这通过大多数备份程序能够发送简单网络管理协议SNMP)陷阱来实现,告知目的地结果是什么;在我们的案例中,它将是 Nagios 服务器,Nagios 被配置为接收这个陷阱并进行分析。再加上一个事件时间范围,这样如果我们在,比如说,24 小时内没有收到特定的陷阱,我们就会触发警报,这样你就有保障了:无论是备份任务完成,还是超时,我们都会收到通知。

Nagios 通知流程图

返回代码和阈值

在编写插件代码之前,我们必须面对一些概念,这些概念将成为我们 Nagios 代码库的基石,其中之一就是插件本身的返回代码。正如我们之前讨论的,一旦插件收集了关于服务运行情况的数据,它就会评估这些数据并判断情况是否属于以下状态之一:

返回代码 状态 描述
0 正常 插件检查了服务,并且结果在可接受范围内。
1 警告 插件检查了服务,并且结果超过了警告阈值。我们必须关注这个服务。
2 危急 插件检查了服务,并且结果超出了危急阈值,或者服务没有响应。我们现在必须采取行动。
3 未知 要么是我们向插件传递了错误的参数,要么插件内部出现了某些错误。

所以,我们的插件将检查服务,评估结果,并根据阈值,将表格中列出的一个值和有意义的消息返回给 Nagios,正如我们在下方截图中的描述列所看到的那样:

请注意前面截图中的红色服务检查和消息。

在截图中,我们可以看到一些检查是绿色的,表示正常,它们在描述部分有详细的解释信息。我们在此部分看到的是插件写入 stdout 的输出;这也是我们将作为对 Nagios 的响应来构建的内容。

注意 SSH 检查:它是红色的,并且失败了,因为它在默认端口 22 检查服务,但在这台服务器上,ssh 守护进程监听的是一个不同的端口。这引出一个问题:我们的插件需要一个命令行解析器,能够接收一些配置选项和阈值限制,因为我们需要知道检查什么、在哪里检查以及服务的可接受工作范围是什么:

  • 位置:在 Nagios 中,可以有没有服务检查的主机(除了通过 ping 进行的隐式主机存活检查),但不能有没有主机的服务。因此,任何插件都必须在命令行中接收要执行的主机指示,可以是虚拟主机,但必须指定。

  • 如何:这是我们编写代码的地方;我们必须编写代码行,指示插件如何连接到服务器、查询、收集和解析响应。

  • 定义:我们必须指示插件,通常通过命令行中带有一些有意义的选项,告诉它哪些是可接受的工作限制,以便它可以评估这些限制并决定是否向我们发送 OK、WARNING 或 CRITICAL 消息。

这就是我们脚本的全部内容:谁在何时、如何、多少次通知我们,等等。这些任务由核心处理;Nagios 插件对此并不知情。插件真正需要了解的有效监控内容是识别工作服务的正确值是什么。我们可以向脚本传递两种不同类型的值:

  • 范围:这是一个带有起始点和结束点的数值系列,例如从 3 到 7 或从一个数字到无限大

  • 阈值:这是一个带有相关警报级别的范围

因此,当我们的插件执行检查时,它们会收集一个在范围内或范围外的数值,这取决于我们设置的阈值;然后,根据评估结果,它将通过返回代码和消息回复 Nagios。那么,我们如何在命令行中指定某些范围呢?基本上是以下方式:

[@] start_value:end_value

如果范围从 0 开始,则可以省略从 : 到左边的部分。start_value 必须始终小于 end_value

如果范围从 start_value 开始,表示从该数字到无限大。可以使用 ~ 指定负无穷大。

当收集的值超出指定范围(包括端点)时,会生成警报。

如果指定了 @,当值位于范围内时会生成警报。

让我们看一些如何在命令行中调用脚本并设置阈值的实际示例:

插件调用 含义
./my_plugin -c 10 如果小于 0 或大于 10 则为 CRITICAL
./my_plugin -w 10:20 如果小于 10 或大于 20 则为 WARNING
/my_plugin -w ~:15 -c 16 如果在负无限大和 15 之间则为 WARNING,16 及更高则为 CRITICAL
./my_plugin -c 35: 如果收集的值低于 35 则为 CRITICAL
./my_plugin -w @100:200 如果值在 100200 之间则为 CRITICAL,其他情况为 OK

我们已经涵盖了插件的基本要求,在最简单的形式下,它应该使用以下语法调用:

./my_plugin -h hostaddress|hostname -w value -c value

我们已经讨论过需要将检查与主机关联起来的必要性;我们可以使用主机名或主机地址来实现这一点。我们可以自行决定使用哪种方式,但我们不会填写这一信息片段,因为它将被服务配置作为标准宏引用。我们刚刚介绍了一个新概念,服务配置,在 Nagios 中使我们的脚本工作变得至关重要,因此让我们简要地看看我们正在讨论什么。在开始讨论 Nagios 配置之前,我们需要注意一点:这不是一本关于 Nagios 的书籍,因此我们不会覆盖所有复杂的细节和部分。我们将涉及所有使我们的脚本正常工作所需的主题,并通过一个工作正常的 Nagios 安装能够快速激活我们的新插件。现在让我们看看如何配置一个插件以使其在 Nagios 下工作,然后我们将能够专注于我们的脚本而没有任何分散注意力的干扰。

命令和服务定义

在 Nagios 的一切基础之上是一个插件,这是一个执行检索信息、评估信息、引发警报并提供有意义消息的代理人。单独看,Nagios 不知道如何调用插件,传递哪些选项或如何处理它,因此我们需要一个命令定义,定义脚本将如何被调用。

让我们以ssh服务检查的命令定义为例,它失败是因为用于检查的端口与守护进程正在侦听的端口不同:

# 'check_ssh' command definition define command{ command_name check_ssh command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$' }

我们可以看到这里有一个名为command_name check_ssh的命令定义。

让我们记住check_ssh,因为这将是我们稍后引用此命令定义时使用的句柄。正如我们所看到的,这个定义非常简短;它定义了一个句柄,最重要的是调用插件的命令行。在这种情况下,非常简单:插件接受主机地址就足够进行基本检查。看看$HOSTADDRESS$。这是所谓的Nagios 标准宏之一:基本上是一个占位符,Nagios 将会用主机地址实例化它,你将把这个服务关联到这个命令。

# check that ssh services are running define service { use generic-service host_name localhost service_description SSH check_command check_ssh }

ssh服务定义引入了一些新的东西,这是通过 Nagios 对象继承属性的一个例子。正如我们之前讨论的,脚本执行检查、评估和引发警报;核心部分则完成其余所有工作,还有很多其他事情。看看这个服务定义,它似乎并不复杂,但是集中在第一行use generic-service上。这让人觉得耳熟能详。看看定义,似乎generic-service实际上是一个模板,是吗?

# generic service template definition define service{ name generic-service ; The 'name' of this service template active_checks_enabled 1 ; Active service checks are enabled passive_checks_enabled 1 ; Passive service checks are enabled/accepted parallelize_check 1 ; Active service checks should be parallelized (disabling this can lead to major performance problems) obsess_over_service 1 ; We should obsess over this service (if necessary) check_freshness 0 ; Default is to NOT check service 'freshness' notifications_enabled 1 ; Service notifications are enabled event_handler_enabled 1 ; Service event handler is enabled flap_detection_enabled 1 ; Flap detection is enabled failure_prediction_enabled 1 ; Failure prediction is enabled process_perf_data 1 ; Process performance data retain_status_information 1 ; Retain status information across program restarts retain_nonstatus_information 1 ; Retain non-status information across program restarts
 notification_interval 0 ; Only send notifications on status change by default. is_volatile 0 check_period 24x7 normal_check_interval 5 retry_check_interval 1 max_check_attempts 4 notification_period 24x7 notification_options w,u,c,r contact_groups admins register 0 ; 
DONT REGISTER THIS DEFINITION - ITS NOT A REAL SERVICE, JUST A TEMPLATE! }

好吧,正如我们所见,我们可以在服务方面定义很多内容,这些内容可能会让服务定义显得杂乱无章,所以我们将复杂性隐藏在模板中并调用它,就像引用一个库一样。一旦模板被导入,所有的定义都将应用到调用它的服务上。如果我们想修改模板中的某些值,我们只需在服务定义中写入新的值,因为如果我们有多个具有相同名称和不同值的定义,最终对象会选择最接近的那个。所以,服务层的定义会覆盖模板中的定义。我们不会解释模板中的所有定义,因为它们对我们的目标并没有帮助,毕竟我们的脚本将依赖于没有任何修改的通用服务定义。

让我们回到服务定义,看看第二行host_name localhost。我们已经提到过,每个服务检查必须引用一个(或多个)主机,所以在这里我们看到这个服务适用于哪个主机。我们也可以使用hostgroup_name name_of_the_hostgroup

为了将单个检查应用于多个被包含在主机组定义中的主机。接下来看一下service_description ssh。至于命令定义,这是用来在 Nagios 中引用这个服务定义的标识符:

check_command check_ssh

这是我们调用命令定义并传递可选参数的地方。在我们预定义的配置中,没有要传递给命令的参数,因此没有什么特别的。通过这行代码,服务定义调用了由处理器调用的命令定义中的语法,并可选地将一些参数传递给它。服务、命令、主机和模板的所有配置都遵循相同的结构:

define object { definitions_1 definitions_2 definitions_n }

然后,你可以将不同的定义保存在一个文件中,并将它们封装在各自的代码块中。

我们刚刚看到的是 Nagios 中的 ssh 检查,但实际上它并不起作用,因为它抛出了一个错误。我们需要的是一种方法来更改正在检查的端口。我们该如何完成这项任务?只需记住,实际的插件才是这里的主角,它将推动我们的所有努力,所以让我们调用它,看看它有什么要说的。让我们看看命令行定义:

command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$'

从这里开始,我们知道脚本的位置了,所以让我们调用它:

root:~$ /usr/lib/nagios/plugins/check_ssh check_ssh: Could not parse arguments Usage: check_ssh [-4|-6] [-t <timeout>] [-r <remote version>] [-p <port>] <host>

我们在这里看到的是,脚本接受命令行上的一些参数和选项,但每个脚本通常都会编写一个完整的帮助信息,通过-h选项调用:

root:~$ /usr/lib/nagios/plugins/check_ssh -h check_ssh v2.1.1 (monitoring-plugins 2.1.1) Copyright (c) 1999 Remi Paulmier <remi@sinfomic.fr> Copyright (c) 2000-2007 Monitoring Plugins Development Team <devel@monitoring-plugins.org> Try to connect to an SSH server at specified server and port Usage: check_ssh [-4|-6] [-t <timeout>] [-r <remote version>] [-p <port>] <host> Options: -h, --help Print detailed help screen -V, --version Print version information --extra-opts=[section][@file] Read options from an ini file. See https://www.monitoring-plugins.org/doc/extra-opts.html for usage and examples. -H, --hostname=ADDRESS Host name, IP Address, or unix socket (must be an absolute path) -p, --port=INTEGER Port number (default: 22) -4, --use-ipv4 Use IPv4 connection -6, --use-ipv6 Use IPv6 connection -t, --timeout=INTEGER Seconds before connection times out (default: 10) -r, --remote-version=STRING Warn if string doesn't match expected server version (ex: OpenSSH_3.9p1) -P, --remote-protocol=STRING Warn if protocol doesn't match expected protocol version (ex: 2.0) -v, --verbose Show details for command-line debugging (output may be truncated by the monitoring system) Send email to help@monitoring-plugins.org if you have questions regarding use of this software. To submit patches or suggest improvements, send email to devel@monitoring-plugins.org

让我们记住这个帮助信息,因为它是我们需要在插件中实现的内容。无论如何,我们可以看到,除了其他选项外,我们实际上可以使用选项-p来更改服务正在检查的端口。

让我们检查一下我们的ssh服务器正在哪个地方监听连接:

root:~$ netstat -tapn | grep ssh tcp 0 0 0.0.0.0:1472 0.0.0.0:* LISTEN 685/sshd tcp6 0 0 :::1472 

现在我们知道我们的ssh守护进程正在1472端口监听。所以我们需要手动检查,确保如何使用新的参数和值来调用插件:

root:~$ /usr/lib/nagios/plugins/check_ssh -H localhost -p 1472 SSH OK - OpenSSH_6.7p1 Debian-5+deb8u3 (protocol 2.0) | time=0.011048s;;;0.000000;10.000000

它起作用了,我们处理了-H localhost来识别我们正在执行检查的主机,-p 1472用来查询该ssh守护进程配置的正确端口。现在,让我们关注插件的回复:

SSH OK - OpenSSH_6.7p1 Debian-5+deb8u3 (protocol 2.0) | time=0.011048s;;;0.000000;10.000000

这是 Nagios 插件提供的消息的标准结构:

  1. 服务的名称(SSH)。

  2. 服务状态(OK)。

  3. 被检查服务给出的消息(或者是我们自己构造的消息)。

然后有一些我们之前没有见过的内容:

| time=0.011048s;;;0.000000;10.000000

这是一条管道,后面跟着一个或多个标签,时间就是我们示例中的标签,还有一些通常与服务工作状态相关的值。无论写的是什么,这不是 Nagios 关心的,因为它不会处理输出行的这一部分。这些值是供第三方应用程序,如pnp4nagios或 Nagios 图形,来处理它们并最终绘制出一些性能图形。

Nagios 显示了性能数据,但并没有真正利用它

我们稍后会看到服务的图形是什么样子的,现在让我们记住一件事:插件的输出通常是单行的,即使你有多行输出,最好也保持简洁的信息。

现在,让我们回到 SSH 服务检查的定义,看看如何修改它以启用不同端口的检查。这就是我们之前见过的check_ssh命令:

# 'check_ssh' command definition define command{ command_name check_ssh command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$' }

为了启用任意端口检查的定义,我们必须修改command_line行,使其接受带有参数的新-p

# 'check_ssh' command definition define command{ command_name check_ssh command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$' -p $ARG1$ }

我们做的很简单:我们只是添加了一个-p后跟$ARG1$。这个新部分是什么?在 Nagios 中,你可以将任何你想要的参数传递给脚本,且通过位置变量来引用它们。把$ARG1$看作标准 bash 脚本中的$1;它表示传递给命令行的第一个参数。记住,像-p这样的选项不算作参数。所以$ARG2$是第二个位置参数,$ARG3$是第三个,以此类推。不要忘记前后的美元符号。所以,我们修改了 Nagios 调用插件的方式,现在我们可以给它传递一个额外的参数。剩下的就是实际将额外的参数提供给脚本;这是通过修改ssh的服务定义来完成的。我们之前有如下内容:

# check that ssh services are running define service { use generic-service host_name localhost service_description SSH check_command check_ssh }

这个定义必须修改,以便我们可以存储并传递端口号到命令中,下面是我们如何操作的:

# check that ssh services are running define service { use generic-service host_name localhost service_description SSH check_command check_ssh!1472 }

命令名称后的感叹号(!)是标准字段分隔符,用于标识传递给插件的不同位置参数。让我们举个例子,修改sshcommand_line来接受它:

-p 1472 -4 -P 2.0 -t 30

我们必须修改命令行,以接受五个参数而不是一个:

# 'check_ssh' command definition define command{ command_name check_ssh command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$' -p $ARG1$ -$ARG2$ -r $ARG3$ -P $ARG4$ -t $ARG5$ }

修改非常简单,我们只是用位置变量$ARGn$写下了所有开关及其参数。现在命令行已经准备好接受新值,我们必须填写占位符:

# check that ssh services are running define service { use generic-service host_name localhost service_description SSH check_command check_ssh!1472!4!2.0!30 }

没那么复杂;每个参数必须按命令行预期的顺序书写:

-p - -P -t
1472 4 2.0 30

需要记住的一点是,标准宏不使用位置参数,因此在计算插槽索引时不必考虑它们。

现在,我们已经把所有部分都整理好了,具备了正确的开关和参数值,我们需要写下新的配置。好吧,但在哪里写?配置文件的位置因发行版而异,文件的碎片化方式也不同:有些发行版将命令和服务定义放在一个主机文件中连同主机定义一起,而其他发行版则将它们分散在单独的文件中。我们该如何处理呢?让 Nagios 进程告诉你它如何读取信息:

root:~$ ps ax | grep nagios 803 ? SNs 0:02 /usr/sbin/nagios3 -d /etc/nagios3/nagios.cfg 2502 pts/1 S+ 0:00 grep nagios

ps命令显示 Nagios 从/etc/nagios3/nagios.cfg读取其主配置指令。因此,值得查看它:

# Commands definitions cfg_file=/etc/nagios3/commands.cfg # Debian also defaults to using the check commands defined by the debian # nagios-plugins package cfg_dir=/etc/nagios-plugins/config # Debian uses by default a configuration directory where nagios3-common, # other packages and the local admin can dump or link configuration # files into. cfg_dir=/etc/nagios3/conf.d # OBJECT CONFIGURATION FILE(S) # These are the object configuration files in which you define hosts, # host groups, contacts, contact groups, services, etc. # You can split your object definitions across several config files # if you wish (as shown below), or keep them all in a single config file. # You can specify individual object config files as shown below: #cfg_file=/etc/nagios3/objects/commands.cfg #cfg_file=/etc/nagios3/objects/contacts.cfg #cfg_file=/etc/nagios3/objects/timeperiods.cfg #cfg_file=/etc/nagios3/objects/templates.cfg # Definitions for monitoring a Windows machine #cfg_file=/etc/nagios3/objects/windows.cfg # Definitions for monitoring a router/switch #cfg_file=/etc/nagios3/objects/switch.cfg # Definitions for monitoring a network printer #cfg_file=/etc/nagios3/objects/printer.cfg # You can also tell Nagios to process all config files (with a .cfg # extension) in a particular directory by using the cfg_dir # directive as shown below: #cfg_dir=/etc/nagios3/servers #cfg_dir=/etc/nagios3/printers #cfg_dir=/etc/nagios3/switches #cfg_dir=/etc/nagios3/routers

这是 Nagios 主配置文件中的标准部分,您会在每个安装中都找到它,因此请注意那些没有被#字符注释掉的行:

# Commands definitions cfg_file=/etc/nagios3/commands.cfg # Debian also defaults to using the check commands defined by the debian # nagios-plugins package cfg_dir=/etc/nagios-plugins/config # Debian uses by default a configuration directory where nagios3-common, # other packages and the local admin can dump or link configuration # files into. cfg_dir=/etc/nagios3/conf.d

所以,从主配置文件中,我们可以看到配置存储在一个文件和两个目录中。由于我们处理的是命令插件修改,我们从cfg_dir=/etc/nagios-plugins/config开始。

查找可能包含ssh配置的文件,让我们进入root:~$ cd /etc/nagios-plugins/config并在每个文件中使用grep查找ssh

root:~$ egrep -lr ssh * disk.cfg ssh.cfg

仅使用egrep -l将仅打印出匹配项所在文件的名称;如果你不确定并希望查看实际的匹配行,请使用-ir而不是-lr,这样你将看到更多信息。无论如何,在这两个文件之间,似乎很清楚我们需要修改的是ssh.cfg

让我们打开文件并跳到文件末尾,添加我们的新命令定义:

define command{ command_name check_ssh_arguments command_line /usr/lib/nagios/plugins/check_ssh '$HOSTADDRESS$' -p $ARG1$ -$ARG2$ -P $ARG3$ -t $ARG4$ }

如上所示,我们更改了command_name;由于不能有两个相同句柄的命令定义,我们为我们的目的选择了一个独特的名称。它不会显示给用户,所以不需要华丽,只需要实用和有意义。我们保存文件并继续定义一个新的服务配置;从主配置文件中来看,似乎很清楚我们需要查看cfg_dir=/etc/nagios3/conf.d

所以,让我们进入这个目录:root:~$ cd /etc/nagios3/conf.d,再次使用grep查找ssh

root:~$ egrep -lr ssh * hostgroups_nagios2.cfg services_nagios2.cfg

在这种情况下,不清楚是什么包含了什么,所以使用扩展的grep命令会很有帮助:

root:~$ egrep -ir ssh * hostgroups_nagios2.cfg:# A list of your ssh-accessible servers hostgroups_nagios2.cfg: hostgroup_name ssh-servers hostgroups_nagios2.cfg: alias SSH servers services_nagios2.cfg:# check that ssh services are running services_nagios2.cfg: hostgroup_name ssh-servers services_nagios2.cfg: service_description SSH services_nagios2.cfg: check_command check_ssh

现在,很明显hostgroups_nagios.cfg包含了与主机组相关的配置,其中包括检查ssh服务的主机组配置。第二个文件services_nagios2.cfg包含了ssh服务检查的配置,所以让我们打开它:

# check that ssh services are running define service { hostgroup_name ssh-servers service_description SSH check_command check_ssh use generic-service notification_interval 0 ; set > 0 if you want to be re-notified }

这是我们所寻找的ssh服务检查配置。在生产环境中,我们需要估算配置的影响,因为如果我们现在修改这个定义,它将应用于所有正在检查的服务器。请注意hostgroup_name ssh-servers,我们正在检查一个服务器组,无论这个组包含一个服务器还是一千个服务器,都不重要。

在生产或预演环境中,我们需要查看我们正在检查哪些服务器的 ssh 服务,了解我们的修改是否会对其中一些服务器产生异常影响,如果是的话,就将这些服务器从新的检查中移除,并为它们创建一个特殊的组,使用旧定义进行检查。在我们的例子中,由于这是一个演示安装,并且只有localhost作为唯一组成员,我们可以直接修改现有配置并继续使用:

# check that ssh services are running define service { hostgroup_name ssh-servers service_description SSH check_command check_ssh_arguments!1472!4!2.0!30 use generic-service notification_interval 0 ; set > 0 if you want to be renotified }

这个定义与我们之前制定的非常相似;只不过这里我们处于一个实际场景中。Nagios 配置为在主机组上应用此检查,而不是单个服务器,但由于主机组只有一台服务器——本地主机,两个定义的作用范围相同。接下来我们需要做的是强制 Nagios 重新加载定义,以便我们的新配置能够被核心读取。重新加载或重启都足够:

service nagios3 reload

现在编辑/etc/nagios3/nagios.cfg文件并启用以下配置:

check_external_commands=1

0表示禁用,1表示启用,我们刚刚告诉 Nagios 接受外部命令,因此我们可以重新加载配置service nagios3 reload,进入服务名称,进入服务器详情页面。在这里,我们只需点击“重新安排该服务的下一个检查”。

让我们选择强制检查并提交;无论当前调度如何,都会强制进行新检查。

在 Debian 和 Ubuntu 标准的 Nagios 安装中,你可能会遇到Error: Could not stat() command file '/var/lib/nagios3/rw/nagios.cmd'!

当你尝试强制进行检查时,可以通过以下过程解决:

service nagios3 stop dpkg-statoverride --update --add nagios www-data 2710 /var/lib/nagios3/rw dpkg-statoverride --update --add nagios nagios 751 /var/lib/nagios3 service nagios3 start

如果你的插件遇到任何问题,请在/etc/nagios3/nagios.cfg中启用debug模式,通过设置以下配置:

debug_level=-1 debug_verbosity=2

这将生成大量信息写入debug文件,在我们的安装中,文件位于/var/log/nagios3/nagios.debug,这些信息对于理解发生了什么非常重要,但它们会稍微降低系统性能,因此我们必须仅在需要时启用调试,之后要恢复为正常日志记录。

Nagios 重新加载将强制激活新的配置。但让我们看看调试日志对我们修改后的命令有什么反馈:

[1489655900.213562] [016.0] [pid=13954] Checking service 'SSH' on host 'localhost'... [1489655900.213602] [2320.2] [pid=13954] Raw Command Input: /usr/lib/nagios/plugins/check_ssh -p $ARG1$ -$ARG2$ -P $ARG3$ -t $ARG4$ '$HOSTADDRESS$' [1489655900.213787] [2320.2] [pid=13954] Expanded Command Output: /usr/lib/nagios/plugins/check_ssh -p $ARG1$ -$ARG2$ -P $ARG3$ -t $ARG4$ '$HOSTADDRESS$' [1489655900.213825] [2048.1] [pid=13954] Processing: '/usr/lib/nagios/plugins/check_ssh -p $ARG1$ -$ARG2$ -P $ARG3$ -t $ARG4$ '$HOSTADDRESS$'' [1489655900.213839] [2048.2] [pid=13954] Processing part: '/usr/lib/nagios/plugins/check_ssh -p ' [1489655900.213846] [2048.2] [pid=13954] Not currently in macro. Running output (37): '/usr/lib/nagios/plugins/check_ssh -p ' [1489655900.213906] [2048.2] [pid=13954] Uncleaned macro. Running output (41): '/usr/lib/nagios/plugins/check_ssh -p 1472' [1489655900.213911] [2048.2] [pid=13954] Just finished macro. Running output (41): '/usr/lib/nagios/plugins/check_ssh -p 1472' [1489655900.213921] [2048.2] [pid=13954] Not currently in macro. Running output (43): '/usr/lib/nagios/plugins/check_ssh -p 1472 -' [1489655900.214051] [2048.2] [pid=13954] Uncleaned macro. Running output (44): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4' [1489655900.214064] [2048.2] [pid=13954] Just finished macro. Running output (44): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4' [1489655900.214074] [2048.2] [pid=13954] Not currently in macro. Running output (48): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P ' [1489655900.214109] [2048.2] [pid=13954] Uncleaned macro. Running output (51): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0' [1489655900.214114] [2048.2] [pid=13954] Just finished macro. Running output (51): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0' [1489655900.214123] [2048.2] [pid=13954] Not currently in macro. Running output (55): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t ' [1489655900.214161] [2048.2] [pid=13954] Uncleaned macro. Running output (57): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30' [1489655900.214175] [2048.2] [pid=13954] Just finished macro. Running output (57): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30' [1489655900.214200] [2048.2] [pid=13954] Not currently in macro. Running output (59): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '' [1489655900.214263] [2048.2] [pid=13954] Uncleaned macro. Running output (68): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '127.0.0.1' [1489655900.214276] [2048.2] [pid=13954] Just finished macro. Running output (68): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '127.0.0.1' [1489655900.214299] [2048.2] [pid=13954] Not currently in macro. Running output (69): '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '127.0.0.1'' [1489655900.214310] [2048.1] [pid=13954] Done. Final output: '/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '127.0.0.1''

很明显,Nagios 是如何逐步构建命令行的,因此我们可以理解它如何解析我们的所有定义,以及如何分配我们通过服务定义传递的值。如果我们复制并粘贴最后一行的命令行输出,并在服务器上执行它,我们就可以开始监控这个服务:

/usr/lib/nagios/plugins/check_ssh -p 1472 -4 -P 2.0 -t 30 '127.0.0.1' SSH OK - OpenSSH_6.7p1 Debian-5+deb8u3 (protocol 2.0) | time=0.015731s;;;0.000000;30.000000

我们进行了服务检查,获得了一个状态(OK),以及性能数据:

现在我们的 SSH 服务检查已经正常工作,并且我们也有性能数据。

性能数据是一些有用的信息,可以在图表上绘制后,为您提供一些现成的服务运行模式预测。有了这样的数据和图表,我们可以做到这一点:

  • 采用服务容量管理策略:由于我们可以轻松预测服务的消耗曲线,因此我们可以预测何时需要升级所需的硬件。

  • 了解使用模式:服务可能会以不均匀的模式使用。例如,公司的邮件服务器在办公时间使用最多,在夜间或周末使用较少;数据仓库服务器的磁盘空间在数据整合批处理期间使用更多。因此,当您检查服务时看起来合适,但在其他时刻可能是不足的:图表将显示您如何查看使用曲线随时间变化。

  • 一目了然地找到故障:观察图表中的间隙,您可以轻松地发现服务中断,并且选择要检查的图表片段可以详细展开。

  • 为管理人员创建精美的报告:看起来像是一个玩笑,但繁忙的管理人员更喜欢综合了解服务,而不是数页页的数值数据。

因此,让我们快速看看如何安装其中一个图形工具。由于这不是关于 Nagios 的书籍,我们不会深入讲解,但我们将仅了解启用此第三方服务和使我们的插件性能数据图表化所需的内容。

让我们开始编辑 /etc/nagios3/nagios.cfg 文件。

查找以下配置片段,并修改它们,以便最终结果将是这样:

process_performance_data=1 host_perfdata_command=process-host-perfdata service_perfdata_command=process-service-perfdata

我们启用了性能处理数据,并定义了处理它们的命令的名称;接下来的逻辑步骤是定义我们刚刚指出的命令。编辑 /etc/nagios3/commands.cfg 文件,并添加以下片段:

# ‘process-host-perfdata' command definition define command{ command_name process-host-perfdata command_line /usr/bin/perl /usr/lib/pnp4nagios/libexec/process_perfdata.pl -d HOSTPERFDATA } # ‘process-service-perfdata' command definition define command{ command_name process-service-perfdata command_line /usr/bin/perl /usr/lib/pnp4nagios/libexec/process_perfdata.pl }

我们正在进行 Debian 安装,因此在使用其他发行版或从源安装时,路径和文件名可能会有所不同。

将任何预先存在的片段标记为 command_name process-host-perfdatacommand_name process-service-perfdata

我们将使用新的内容,因为旧的对我们的目的无用,因此再次将它们注释掉。现在我们已经有了命令,并且数据将按预期处理,我们必须告诉 Nagios 如何触发图表可视化。因此,是时候编辑 /etc/nagios3/conf.d/services_nagios2.cfg 并修改先前编辑的 SSH 服务检查配置,使其现在显示为:

# check that ssh services are running define service { hostgroup_name ssh-servers service_description SSH check_command check_ssh_arguments!1967!4!2.0!30 action_url /pnp4nagios/index.php/graph?host=$HOSTNAME$&srv=$SERVICEDESC$ use generic-service notification_interval 0 ; set > 0 if you want to be renotified }

我们添加了一个操作 URL 配置行,以便 Nagios 在服务名称附近绘制一个小的可点击图标。因此,让我们重新启动 Nagios 并转到服务页面,看看有什么新发现:

向任何服务配置中添加action_url字符串将使这个新图标出现。

这个图标是可以点击的,我们只需点击它,结果类似于下一个截图所示:

SSH 服务检查的性能数据现在已经被绘制成图表。

从现在开始,我们的性能数据将被绘制成图表,因此我们的 Nagios 环境已经准备好承载我们的第一个 Nagios 插件。

我们的第一个 Nagios 插件

现在是时候开始创建我们的第一个 Nagios 插件了,实际上我们要检查的内容并不重要,因为我们关心的是如何处理 Nagios 和插件之间的交换,而不是我们要监控什么或怎么监控。一旦我们完成了脚本,就可以重用它的框架来创建任何我们想要的脚本,所以让我们开始吧。

我们的项目涉及使用自监控、分析和报告技术S.M.A.R.T.)检查本地磁盘的状态,我们可以把它看作是嵌入在大多数硬盘和固态硬盘中的系统,其任务是预测和防止问题和故障。因此,能够查询 S.M.A.R.T.系统的插件可以用于捕捉即将发生的故障,通知用户,甚至可以利用 Nagios 中的响应机制触发一些脚本或程序,例如在磁盘即将故障之前将所有数据复制到其他地方,避免数据丢失。

本项目的第一步是安装smartmontools软件包。在 Debian 和 Ubuntu 中,软件包叫做smartmontools;在其他发行版中可能会不同。我们要找的是一个包含smartctl工具的软件包。

这是我们的插件将依赖的程序,它是实际查询磁盘信息的工具,因此我们第一步是找出哪些磁盘连接到了系统:

zarrelli:~$ lsblk -d sda 8:0 0 119.2G 0 disk sr0 11:0 1 1024M 0 rom

这里是我们的系统,它有一个磁盘,名称是sda。如果我们想了解更多关于磁盘的信息,可以安装hwinfo并以 root 身份运行它:

root:~$ hwinfo --disk 27: IDE 00.0: 10600 Disk [Created at block.245] Unique ID: 3OOL.eNwxL8uda61 Parent ID: w7Y8.FuT6qrC8mT0 SysFS ID: /class/block/sda SysFS BusID: 0:0:0:0 SysFS Device Link: /devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0 Hardware Class: disk Model: "TS128GSSD720" Device: "TS128GSSD720" Revision: "2" Serial ID: "REDACTED" Driver: "ahci", "sd" Driver Modules: "ahci" Device File: /dev/sda Device Files: /dev/sda, /dev/disk/by-id/ata-TS128GSSD720_REDACTED Device Number: block 8:0-8:15 BIOS id: 0x80 Geometry (Logical): CHS 15566/255/63 Size: 250069680 sectors a 512 bytes Capacity: 119 GB (128035676160 bytes) Config Status: cfg=new, avail=yes, need=no, active=unknown Attached to: #20 (SATA controller)

既然我们已经了解了我们的磁盘,现在我们需要看看它是否足够“有礼貌”来响应我们的 S.M.A.R.T.请求:

root:~$ smartctl --all /dev/sda smartctl 6.4 2014-10-07 r4002 [x86_64-linux-3.16.0-4-amd64] (local build) Copyright (C) 2002-14, Bruce Allen, Christian Franke, www.smartmontools.org === START OF INFORMATION SECTION === Model Family: SandForce Driven SSDs Device Model: TS128GSSD720 Serial Number: REDACTED LU WWN Device Id: 0 023280 000000000 Firmware Version: 5.0.2 User Capacity: 128,035,676,160 bytes [128 GB] Sector Size: 512 bytes logical/physical Rotation Rate: Solid State Device Device is: In smartctl database [for details use: -P show] ATA Version is: ATA8-ACS, ACS-2 T13/2015-D revision 3 SATA Version is: SATA 3.0, 6.0 Gb/s (current: 3.0 Gb/s) Local Time is: Fri Mar 17 16:34:30 2017 GMT SMART support is: Available - device has SMART capability. SMART support is: Enabled === START OF READ SMART DATA SECTION === SMART overall-health self-assessment test result: PASSED General SMART Values: ...Self-test execution status: (0) The previous self-test routine completed without error or no self-test has ever been run. ...SMART Attributes Data Structure revision number: 10 Vendor Specific SMART Attributes with Thresholds: ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE ...SMART Error Log not supported SMART Self-test Log not supported SMART Selective self-test log data structure revision number 1 SPAN MIN_LBA MAX_LBA CURRENT_TEST_STATUS ... Selective self-test flags (0x0): After scanning selected spans, do NOT read-scan remainder of disk. If Selective self-test is pending on power-up, resume after 0 minute delay.

这从硬盘中提取了大量信息,归根结底,大多数信息对你来说是无用的。为了我们的这个小项目,我们只会考虑一些信息,具体如下:

SMART overall-health self-assessment test result: PASSED 194 Temperature_Celsius 0x0022 036 060 000 Old_age Always - 36 (Min/Max 12/60) Self-test execution status: ( 0) The previous self-test routine completed

所以,我们的插件将仅考虑这三项信息,并根据三个不同的阈值进行处理:

控制 正常 警告 危急
SMART 整体健康自检测试结果 通过 !危急
温度 40: @41:49 :50
自检执行状态 0 !0

我们开始规划脚本。对于overall-health,我们有一个 OK 值,但没有 WARNING,因为任何不同于PASSED的值对我们来说都意味着一个关键情况。对于温度,我们可以根据工作环境调整阈值。通常,温度高达 40 摄氏度被认为是最佳的。41 到 50 摄氏度被认为是可接受的,这意味着从长远来看,可能会对磁盘造成一定损害,因此我们处于 WARNING 状态——还不致命,但我们必须保持关注。

从 50 摄氏度及以上被认为是极其危险的,会对磁盘健康造成威胁,因此我们应该触发 CRITICAL 状态,并且尽快让某人做出反应。Self-test execution status会告诉我们驱动器上的最后一次自检是否成功或有错误,因此任何不是 0(成功)的状态都会触发一个关键状态。

我们已经识别出将触发 Nagios 状态的信息;我们规划了阈值,现在在实际编写插件之前,我们需要找到一种可靠的方式来收集将与阈值进行比较的数据。这里一些正则表达式会很有用,所以让我们从整体健康状态开始,调用smartcl工具并添加过滤器:

root:~$ smartctl --all /dev/sda | grep -i overall-health | awk '{print $6}' PASSED

这个简单的一行命令从smartctl获取完整的输出,然后将其传递给grep,后者选择并输出只包含overall-health字样的行。输出最终被传递给awk,它将输入拆分成列,每个字段由空格分隔,然后打印出第六个字段,即显示PASSED。类似如下的代码会捕捉到整体检查的结果:

root:~$ H_CHECK=$(smartctl --all /dev/sda | grep -i overall-health | awk '{print $6}')

实际上,我们可以通过以下命令来再次验证:

root:~$ H_CHECK=$(smartctl --all /dev/sda | grep overall-health | awk '{print $6}') ; echo $H_CHECK PASSED

也就是说,命令替换将整条命令行的输出放入了H_CHECK变量中,我们也可以打印出来。

这里有一个建议。当涉及到变量时,你可以使用任何你想要的表示方式,这取决于你,但请记住一些经验法则:

  • 保持变量名简短且有意义:像THIS_IS_THE.OVER-ALL.RESULT这样的变量名会使代码变得混乱,所以H_HEALTH是简洁且有意义的。

  • 使用小写、大写或驼峰式命名,如OverAllHealth,但要保持一致性:在脚本中保持你选择的命名风格,这样更容易识别变量。

  • 不要使用关键字、工具函数或保留的名称作为变量名:这会使你的脚本变得不可靠。

现在是获取我们磁盘温度值的时候了:

root:~$ smartctl --all /dev/sda | grep -i Temperature | awk '{print $10}' 35

所以,让我们将这个值存入一个变量:

root:~$ T_CHECK=$(smartctl --all /dev/sda | grep -i Temperature | awk '{print $10}') ; echo $T_CHECK 35

最后,我们必须检查Self-test execution status

root:~$ smartctl --all /dev/sda | grep -i "Self-test execution status" | awk '{print $5}' | tr -d ")"

然后将结果值存入变量:

S_CHECK=$(smartctl --all /dev/sda | grep -i "Self-test execution status" | awk '{print $5}' | tr -d ")") ; echo $S_CHECK 0

现在我们已经有了收集所需信息的方法,是时候为我们的调查设定一些边界了。如果磁盘不存在怎么办?如果它不支持 S.M.A.R.T. 呢?我们的脚本还需要以 root 用户身份调用 smartctl,所以我们将利用 sudo 来简化这个过程。那么,让我们从脚本的前几行开始,这些行包含 sha-bang、许可证、作者和第一个变量。记得根据需要修改代码:

#!/bin/bash # License: GPL # # Author: Giorgio Zarrelli <zarrelli@linux.it> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. #

这是 sha-bang 和一个包含作者的许可证,没什么特别的,所以我们继续:

SMARTCTL="/usr/sbin/smartctl"

由于我们为每个工具的路径使用了命令替换 which,它会返回工具的路径。唯一的缺点是,如果工具不在用户的 $PATH 环境变量中,它不会返回路径,但这不是大问题,除了 smartctl,它不在 $PATH 中;我们只需手动提供完整路径即可。我们不检查 echo 命令,因为它是内建的:

# Nagios return codes STATE_OK=0 STATE_WARNING=1 STATE_CRITICAL=2 STATE_UNKNOWN=3

谁记得 Nagios 插件返回的正确状态码?最好将它们存储在一些方便的变量中:

# Default WARNING and CRITICAL values WARNING_THRESHOLD=${WARNING_THRESHOLD:=41} CRITICAL_THRESHOLD=${CRITICAL_THRESHOLD:=50}

现在,有一点需要注意。如果脚本没有接收到 WARNING 和 CRITICAL 阈值的值,它将从预定义的值中自动分配:

现在我们已经有了一些标题,让我们检查一下变量是否正确地指向了我们的工具:

# Check if we have all the system tools we need path_exists() { for i in "$@" do if [ -e "$i" ]; then echo "$i is a valid path" else echo "$i is not reachable, is this the correct path?" exit 1 fi done } path_exists "$SMARTCTL"

这是我们脚本的第一个函数,它的任务相当简单:它检查 #SMARTCTL 指向的路径是否指向一个文件;如果没有,它会打印一个 WARNING 消息并以错误代码退出。在我们的原型中,即使路径有效,我们也会打印消息,但在最终阶段,我们会设置一个调试条件来启用或禁用这种额外的消息,因为 Nagios 不接受此类消息。我们还将有一个调试选项,如果需要,它将显示我们脚本的内部计算过程。让我们测试一下到目前为止完成的部分,使脚本可执行并运行:

zarrelli:~$ ./check_my_smart.sh /bin/echo is a valid path /usr/sbin/smartctl is a valid path

现在让我们在脚本中添加两个假变量进行检查:

TEST1="" TEST2="/blah/blah"

现在让我们再检查一次:

path_exists "$SMARTCTL" "TEST1" "TEST2"

然后再次运行脚本:

zarrelli:~$ ./check_my_smart.sh /bin/echo is a valid path /usr/sbin/smartctl is a valid path TEST1 is not reachable, is this the correct path?

脚本会在遇到第一个没有正确指向文件路径的变量时退出,所以让我们删除它,移除它在函数中的引用,并再次运行脚本:

zarrelli:~$ ./check_my_smart.sh ; echo $? /bin/echo is a valid path /usr/sbin/smartctl is a valid path TEST2 is not reachable, is this the correct path? 1

同样,脚本会在遇到第一个没有指向文件路径的变量时退出,并返回错误代码;我们将错误信息打印到标准输出。这种行为适合我们,因为我们希望脚本在遇到任何妨碍其正常运行的问题时停止执行,并提供有意义的建议,以便我们根据提示进行修正。我们不再需要假变量了,清除它们:

接下来,检查我们正在检查的磁盘是否真实存在,所以让我们在脚本中再添加一些内容。首先,添加一个变量来保存我们想要监视的磁盘路径:

# Disk to check DISK=${DISK:="/dev/sda"}

接下来,让我们检查一下我们刚刚指定的路径是否指向一个真实的块设备:

# Check for the path to bring us to a block device with SMART capability disk_exists() { if [ -b "$DISK" ] then echo "$DISK is a block device" else echo "$DISK does not point a block device" fi }

对路径进行简单的文件测试可以告诉我们它是否是块设备。知道路径是否指向块设备就足够了吗?不,因为磁盘是块设备,但块设备不一定是磁盘,它可以是磁带驱动器,例如。无论如何,我们不需要专门查找磁盘的测试,因为下一个函数将检查设备的 S.M.A.R.T.能力。只有启用了 S.M.A.R.T.的硬盘才能通过此测试,其他类型的块设备没有此能力,因此在这里我们将区分出哪个是哪个。在继续之前,让我们为我们的函数编写一个详细模式开关,以便我们能够在stdout上打印信息性消息。让我们开始创建一个变量,它将保存详细模式开关的状态值:

# Enable verbose; 0 for disabled, 1 for enabled VERB=${VERB:=1}

现在,让我们重新编写path_exists函数:

path_exists()
{
for i in "$@"
do
if [ -e "$i" ];
then 
(( VERB )) && echo "$i is a valid path"
:
else
if (( VERB ));
then
echo "$i is not reachable, is this the correct path?"
exit 1
fi
fi
done
}

好了,是时候测试脚本了:

zarrelli:~$ ./check_my_smart.sh /bin/echo is a valid path /usr/sbin/smartctl is a valid path /dev/sda is a block device

所有消息都被打印出来了,但如果我们将详细模式的值更改为0,例如VERB=${VERB:=0},会发生什么?

让我们再次调用脚本:

zarrelli:~$ ./check_my_smart.sh /dev/sda is a block device

所有path_exists函数的消息现在都被静音了。我们是如何做到的?简单地使用算术(( ))运算符,如果它评估为非零值,则返回真作为退出状态。我们使用了两种不同的方式来管理详细模式:

(( VERB )) && echo "$i is a valid path"

这种紧凑的表示法对我们脚本流程的影响较小,在必须执行短命令列表时更可取。在这种情况下,如果$VERB评估为非零,就会执行简单的 echo,因此这种表示法适合这种情况。当我们必须执行更长命令列表时,我们可以选择更易读的表示法:

if (( VERB )); then echo "$i is not reachable, is this the correct path?" command_2 command_n fi

在这种情况下,我们可以在 echo 下附加更多命令,如果$VERB评估为非零值,则所有这些命令都将被执行:在列表上级联更多命令将使代码更易读和易于维护。但是,嗯,第二个详细模式开关并不是真正有用,因为代码的这部分会捕获一个问题,并在路径指向非文件时发挥作用,我们始终希望在出现问题时看到错误消息,无论详细程度如何。

所以,清除它,因为它只是一个例子:

if (( VERB )); then echo "$i is not reachable, is this the correct path?" fi

现在,让我们在disk_exists函数中添加详细模式开关:

disk_exists() { if [ -b "$DISK" ] then (( VERB )) && echo "$DISK is a block device" : else echo "$DISK does not point a block device" exit 1 fi }

注意代码中的(:)。这是一个占位符,我们将用 S.M.A.R.T.能力检查代码填充进去。到目前为止,如果路径指向块设备,则脚本什么也不做(:)。如何检查设备是否启用 S.M.A.R.T.?我们可以依赖于smartctl的输出:

root:~$ smartctl -a /dev/sda | grep "^SMART support is:" SMART support is: Available - device has SMART capability. SMART support is: Enabled

太棒了,smartctl的输出包含两行,其中一行显示设备是否具有 S.M.A.R.T.功能,第二行告诉我们它是否启用。

在处理命令输出时要小心:它可能会根据不同版本而变化,因此在尝试捕获某些信息之前,始终先检查命令本身的完整输出。

一旦我们知道在哪里查找,只需简单地捕获我们想要的信息:

root:~$ SMART=$(smartctl -a /dev/sda | grep "^SMART support is:" | awk '{print $4}') ; echo $SMART Available Enabled

截取 smartctl 的输出并仅抓取第四个字段的内容,成功得到了我们寻找的两个关键字:

Available Enabled

它们必须都出现在输出中,才能让我们的检查通过,所以我们重新编写脚本的第一部分:

# Retrieve the full path to the system utilities AWK=$(which awk) ECHO=$(which echo) GREP=$(which grep) SMARTCTL="/usr/sbin/smartctl" # AWK field to print A_FIELD='{print $4}'

我们将使用 awkgrep,所以我们将它们添加到一些方便的变量中。请注意,我们正在解析工具(smartctl)的输出,未来版本中这个输出可能会发生变化,因此我们将正在使用的字段存储在变量中。这样,如果输出相关的关键字发生变化,我们只需在脚本中修改一次。现在,就在 disk_exists 函数之前,我们创建一个新的代码片段:

smart_enabled() {
 SMART=($($SMARTCTL -a "$1" | "$GREP" "$IS_SMART" | "$AWK" "$A_FIELD")) }

我们只是在命令行中做的一个函数,现在,我们将输出存储到一个数组中。我们从简单的构造开始,检查它们是否正确工作。一旦我们有信心,就转向更复杂的解决方案。现在,我们必须带着一个参数调用这个函数;让我们把它放在脚本的最后:

path_exists "$SMARTCTL" disk_exists "$DISK" smart_enabled "$DISK"

到目前为止,一切顺利。我们的脚本正在获取我们寻找的两个关键字。现在,我们可以进一步处理这些关键字,以便如果它们不在 smartctl 的输出中,我们的脚本将退出并报错;然后我们在脚本的开头添加一些内容:

# SMART CAPABILITY INDICATOR IS_SMART="^SMART support is:" SMART_IND=(Available Enabled)

SMART_IND 数组包含我们需要捕获的关键字,以确保我们有支持 S.M.A.R.T. 的硬盘,所以现在我们必须构建一个函数来利用这个新数组:

smart_enabled() { SMART=($($SMARTCTL -a "$1" | "$GREP" "$IS_SMART" | "$AWK" "$A_FIELD")) for i in "${SMART[@]}" do for j in "${SMART_IND[@]}" do if [[ "$i" == "$j" ]]; then (( COUNTER++ )) fi done done if (( COUNTER != ${#SMART_IND[@]} )) then ALT_SMART="$($SMARTCTL -a "$1" | "$GREP" "$ALT_IS_SMART")" if ! [[ -z $ALT_SMART ]] then (( VERB )) && echo "$DISK has SMART capability" smart_check "$B_SEL" "$DISK" else (( VERB )) && echo "Check the device, it seems it does not support SMART" (( VERB )) && echo "The counter matched: $COUNTER times" echo exit "$STATE_UNKNOWN" fi else (( VERB )) && echo "$DISK has SMART capability" smart_check "$B_SEL" "$DISK" fi }

基本上,我们在 smartctl 的输出中使用 grep 查找 IS_SMART 值,然后将结果保存在 IS_SMART 数组中。我们有两个嵌套的循环:外循环遍历 IS_SMART 的值,内循环遍历 SMART_IND 的值。每次两个指示符匹配时,计数器就会增加。在循环结束时,如果计数器不等于 SMART_IND 的长度,我们就知道无法匹配到正确数量的指示符。在某些情况下,你可能没有那种漂亮的 SMART 支持字符串,所以我们可以使用一个替代指示符来匹配,以防第一个字符串未显示:

ALT_IS_SMART="=== START OF SMART DATA SECTION ==="

可能更少,也可能更多,最好在检查时退出并报错。

现在,让我们看看如果我们在一个不支持 SMART 的系统上运行这个脚本会发生什么:

root:~$ ./check_my_smart.sh /bin/echo is a valid path /usr/sbin/smartctl is a valid path /dev/sda is a block device Check the device, it seems it does not support SMART The counter matched: 0 times

很好,当脚本检测到没有 SMART 能力时,它会干净地退出并给出有意义的消息。现在,由于只有在有有效硬盘的情况下才能进行 SMART 检查,我们将从 disk_exists 函数内部调用 smart_enabled 函数。因此,我们将 smart_enabled 函数的调用从脚本的底部移到 disk_exists 函数中:

disk_exists() { if [ -b "$DISK" ] then (( VERB )) && echo "$DISK is a block device" smart_enabled "$DISK" else echo "$DISK does not point a block device" exit 1 fi }

为了在 disk_exists 函数中可用,smart_enable 函数必须事先定义。

我们做了大量的检查,现在是时候创建我们的检查函数,它将处理三种不同的测量类型:

  • 总体健康状况

  • 温度

  • 自检

所以,我们的函数必须接受至少三个参数:

  • 检查的类型

  • 警告阈值

  • 临界阈值

让我们从简单的开始,实现一个整体检查监控,从一些新的变量开始:

# AWK field to print A_FIELD='{print $4}' H_FIELD='{print $6}' # SMART check keywords H_KEY="overall-health" # SMART matches H_MATCH="PASSED" Now, just before the smart_enabled function, let's create a new function: smart_check() { H_CHECK=$($SMARTCTL -a "$1" | "$GREP" "$H_KEY" | "$AWK" "$H_FIELD") if [[ "$H_CHECK" == "$H_MATCH" ]]; then echo "SMART OK: Overall-health check $H_MATCH" exit "$STATE_OK" else echo "SMART CRITICAL: Overall-health check NOT $H_MATCH" exit "$STATE_CRITICAL" fi }

没有什么难的,我们只需 grep 输出,将其放入变量中,并查看它是否匹配我们的锚点(PASSED)。如果匹配,脚本将以STATE_OK值退出,如果不匹配,它将抛出STATE_CRITICAL。让我们来看看,但事先将详细信息级别设置为0

root:~$ ./check_my_smart.sh SMART OK: Overall-health check PASSED

这是一个可接受的插件响应,如果我们将其传递给 Nagios,它将在 Web 界面上显示一个绿色的“OK”字段,所以我们达到了一个里程碑:我们得到了第一个正确的插件回复。现在,由于所有错误必须由 Nagios 捕捉,我们来为这个分配exit 1

echo “SMART UNKNOWN: Please check the plugin” exit “$STATE_UNKNOWN”

所以,所有之前的错误信息现在必须变为可选,就像在path_exists()函数中一样:

path_exists() { for i in "$@" do if [ -e "$i" ]; then (( VERB )) && echo "$i is a valid path" disk_exists "$DISK" else (( VERB )) && echo "$i is not reachable, is this the correct path?" echo “SMART UNKNOWN: Please check the plugin” echo “SMART UNKNOWN: Please check the plugin” exit “$STATE_UNKNOWN” fi done } 

我们可以看到函数有了些许变化;由于我们在path_exists()内调用了disk_exists,我们将函数连接起来,以便在成功的结果后,调用下一个函数。

很好,我们有一个检查overall-health参数的函数,它还会给我们正确的 Nagios 消息和退出代码;但这只是三项检查中的一项,所以我们必须将其作为系列中的一个元素。那么,如果我们想将其纳入更广泛的测试范围,我们该怎么做呢?由于这只是三项检查,我们可以轻松地将它们分组在一个if/then/elif/fi结构中,但让我们从一个新的变量开始:

# BRANCH selector B_SEL=${B_SEL:="HEALTH"}

这是一个分支选择器;如果我们没有指定任何内容,它将默认选择HEALTH并触发三项检查中的一项;现在让我们看看新代码:

smart_check()
{
if (("$#" != 2));
then
echo
exit "$STATE_UNKNOWN"
else
if [[ "$1" == "HEALTH" ]];
then
H_CHECK=$($SMARTCTL -a "$2" | "$GREP" "$H_KEY" | "$AWK" "$H_FIELD")    if [[ "$H_CHECK" == "$H_MATCH" ]];
then
echo "SMART OK: Overall-health check $H_MATCH"
exit "$STATE_OK"
else
echo "SMART CRITICAL: Overall-health check NOT $H_MATCH"
exit "$STATE_CRITICAL"
fi
elif [[ "$1" == "TEMPERATURE" ]];
then
if (( $(echo "scale=2; "$WARNING_THRESHOLD" >= "$CRITICAL_THRESHOLD"" | $BC ) )) ;
then
echo "SMART UNKNOWN: The value of WARNING ($WARNING_THRESHOLD) must be lower than CRITICAL ($CRITICAL_THRESHOLD)"                            exit "$STATE_UNKNOWN"
else
T_CHECK=$($SMARTCTL -a "$2" | "$GREP" "$T_KEY" | "$AWK" "$T_FIELD")   if ! [[ "$T_CHECK" = *[[:digit:]]* ]];
then
echo "SMART UNKNOWN: The $T_KEY check is not available on $DISK"
exit "$STATE_UNKNOWN"
fi
if (( T_CHECK < WARNING_THRESHOLD ));
then
echo "SMART OK: Temperature is $T_CHECK | TEMP=$T_CHECK"
exit "$STATE_OK"
elif (( T_CHECK < CRITICAL_THRESHOLD ));
then
echo "SMART WARNING: Temperature is $T_CHECK | TEMP=$T_CHECK"
exit "$STATE_WARNING"
else
echo "SMART CRITICAL: Temperature is $T_CHECK | TEMP=$T_CHECK"
exit "$STATE_CRITICAL"
fi
fi
elif [[ "$1" == "SELFCHECK" ]];
then
S_CHECK=$($SMARTCTL -a "$2" | "$GREP" "$S_KEY" | "$AWK" "$S_FIELD" | "$TR" -d "$S_DEL")
if ! [[ "$S_CHECK" = *[[:digit:]]* ]];                               then
echo "SMART UNKNOWN: The $S_KEY check is not available on $DISK"
exit "$STATE_UNKNOWN"
fi
if (( S_CHECK == S_MATCH ));
then
echo "SMART OK: Overall-health check $S_MATCH"
exit "$STATE_OK"
else
echo "SMART CRITICAL: Overall-health check NOT $S_MATCH"
exit "$STATE_CRITICAL"
fi
else
echo
exit "$STATE_UNKNOWN"
fi
fi
}

新代码检查传递了多少个参数,如果不是恰好两个,它将抛出错误并以STATE_UNKNOWN退出。如果有两个参数,它接着检查第一个参数是否是一个函数选择器以及它的值。我们只填充了第一个函数,创建了其他两个的占位符,并在没有输入接受的函数选择器值时做了一个捕捉处理。

我们现在可以继续实现自检功能,这与overall-health非常相似,但首先需要一些变量:

TR=$(which tr) S_FIELD='{print $5}' S_KEY="Self-test execution status" S_MATCH=0

你已经可以弄清楚这些是用来做什么的;我们只需要记住,我们在尽可能多地自定义命令时,使用了很多变量,因为我们正在处理一个工具输出,而这个输出可能会在不同版本之间有所变化。它通常在小版本更新中保持相对一致,但通过使用大量变量,如果需要,我们可以快速修改脚本:

eelif [[ "$1" == "SELFCHECK" ]]; then S_CHECK=$($SMARTCTL -a "$2" | "$GREP" "$S_KEY" | "$AWK" "$S_FIELD" | "$TR" -d "$S_DEL") if ! [[ "$S_CHECK" = *[[:digit:]]* ]]; then echo "SMART UNKNOWN: The $S_KEY check is not available on $DISK" exit "$STATE_UNKNOWN" fi if (( S_CHECK == S_MATCH )); then echo "SMART OK: Overall-health check $S_MATCH" exit "$STATE_OK" else echo "SMART CRITICAL: Overall-health check NOT $S_MATCH" exit "$STATE_CRITICAL" fi

我们填充了占位符。这个函数与第一个函数类似,唯一的实际区别是执行了算术评估,并检查值是否匹配且必须是数字。使用SELFCHECK关键字调用该函数时,显示如下:

root:~$ ./check_my_smart.sh SMART OK: Overall-health check 0

很好,现在是进行最后一次检查的时候了,这与其他两个检查有些不同,因为它需要与一些阈值进行比较。我们像往常一样从一些变量开始:

BC=$(which bc) T_FIELD='{print $10}' T_KEY="Temperature" 

现在我们使用代码本身:

elif [[ "$1" == "TEMPERATURE" ]]; then if (( $(echo "scale=2; "$WARNING_THRESHOLD" >= "$CRITICAL_THRESHOLD"" | $BC ) )) ; then echo "SMART UNKNOWN: The value of WARNING ($WARNING_THRESHOLD) must be lower than CRITICAL ($CRITICAL_THRESHOLD)" exit "$STATE_UNKNOWN" else T_CHECK=$($SMARTCTL -a "$2" | "$GREP" "$T_KEY" | "$AWK" "$T_FIELD") if ! [[ "$T_CHECK" = *[[:digit:]]* ]]; then echo "SMART UNKNOWN: The $T_KEY check is not available on $DISK" exit "$STATE_UNKNOWN" fi if (( T_CHECK < WARNING_THRESHOLD )); then echo "SMART OK: Temperature is $T_CHECK | TEMP=$T_CHECK" exit "$STATE_OK" elif (( T_CHECK < CRITICAL_THRESHOLD )); then echo "SMART WARNING: Temperature is $T_CHECK | TEMP=$T_CHECK" exit "$STATE_WARNING" else echo "SMART CRITICAL: Temperature is $T_CHECK | TEMP=$T_CHECK" exit "$STATE_CRITICAL" fi fi fi fi 

这比另外两个检查要复杂一些。首先,我们检查WARNING_THRESHOLD的值是否低于CRITICAL_THRESHOLD;我们使用一个小的命令行计算器和算术运算来完成这个检查。接着,我们检查T_CHECK是否是一个数值,因为我们讨论的是摄氏度(硬盘温度通常以摄氏度报告)。一旦我们消除了这些障碍,就可以按以下方式检查T_CHECK的值是否符合阈值:

$T_CHECK < $WARNING_THRESHOLD IS OK $T_CHECK < $CRITICAL_THRESHOLD IS WARNING EVERYTHING ELSE IS CRITICAL

让我们测试一下这个脚本,使用不同的 WARNING 和 CRITICAL 阈值:

WARNING_THRESHOLD=${WARNING_THRESHOLD:=41} CRITICAL_THRESHOLD=${CRITICAL_THRESHOLD:=50}ro
ot:~$ ./check_my_smart.sh SMART WARNING: Temperature is 41 | TEMP=41 WARNING_THRESHOLD=${WARNING_THRESHOLD:=45} CRITICAL_THRESHOLD=${CRITICAL_THRESHOLD:=50} root:~$ ./check_my_smart.sh SMART OK: Temperature is 41 | TEMP=41 WARNING_THRESHOLD=${WARNING_THRESHOLD:=35} CRITICAL_THRESHOLD=${CRITICAL_THRESHOLD:=40} root:~$ ./check_my_smart.sh SMART CRITICAL: Temperature is 41 | TEMP=41 WARNING_THRESHOLD=${WARNING_THRESHOLD:=50} CRITICAL_THRESHOLD=${CRITICAL_THRESHOLD:=40} root:~$ ./check_my_smart.sh SMART UNKNOWN: The value of WARNING (50) must be lower than CRITICAL (40)

如我们所见,我们的阈值设置得相当合理,值的优先级也是一样的,所以我们很好。注意性能数据;由于这是一个温度指示器,如果我们愿意,之后可以在 Nagios 中将其绘制出来。这里的最后一步是创建一个命令行解析器来获取所有需要的值:

# Print help and usage print_help() { cat << HERE MY SMART CHECK v1.0 ------------------- Please enter one or more of the following options: -d | --disk eg. /dev/sda -t | --test HEALTH TEMPERATURE SELFCHECK -w | --warning eg. -w 41 -c | --critical eg. -c 50 HERE }

我们从打印用法开始。如果用户输入了一些错误的选项,我们给出提示该怎么做:

root:~$ ./check_my_smart.sh -T ITISWRONG Unknown argument: -T

 MY SMART CHECK v1.0 ------------------- Please enter one or more of the following options: -d | --disk eg. /dev/sda -m | --module HEALTH TEMPERATURE SELFCHECK -w | --warning eg. -w 41 -c | --critical eg. -c 50

不错,是不是?但是我们怎么调用那个函数并管理输入呢?让我们看看:

# Parse parameters on the command line while (( $# > 0 )) do case "$1" in -h | --help) print_help exit "${STATE_OK}" ;; -d | --disk) shift DISK="$1" ;; -m | --module) shift B_SEL="$1" ;; -w | --warning) shift WARNING_THRESHOLD="$1" ;; -c | --critical) shift CRITICAL_THRESHOLD="$1" ;; *) echo "Unknown argument: $1" print_help exit "$STATE_UNKNOWN" ;; esac shift done

我们在这个代码块中做了什么?当命令行上的参数数量大于零时,我们解析命令行本身,并使用 case 结构检查选项。每次匹配到一个值时,我们实例化一个变量并移动命令行,这样就准备好处理下一个选项了;这就是我们的命令行解析器。

现在我们的插件准备好为我们的目的服务了,我们必须将它复制到插件目录root:~$ cp check_my_smart.sh /usr/lib/nagios/plugins/;现在让我们检查它的所有权和访问权限。这里显示的应该就足够了:

root:~$ cd /usr/lib/nagios/plugins/ root:~$ ls -lah check_my_smart.sh -rwxr-xr-x 1 root root 6.2K Mar 22 09:32 check_my_smart.sh

一旦脚本到位,我们必须告诉 Nagios 如何调用它,因此需要一个命令定义。让我们进入命令配置目录cd /etc/nagios-plugins/config/并创建check_my_smart.cfg文件,内容如下:

# 'check_my_smart' command definition define command{ command_name check_my_smart command_line /usr/lib/nagios/plugins/check_my_smart.sh -d $ARG1$ -m $ARG2$ $ARG3$ $ARG4$ }

我们不会重复这一点,但始终要检查文件的用户访问权限。如果你不确定应该使用什么权限,可以查看你正在工作的目录中的类似文件。但要注意你所赋予的权限。

我们将使用sudo,因为smartctl工具需要 root 权限才能访问磁盘信息。磁盘和模块选项必须在服务配置中给出,但 WARNING 和 CRITICAL 值是可选的。现在是时候修改/etc/sudoers并添加以下行:

# SMART Nagios plugin sudo nagios ALL=(root) NOPASSWD: /usr/sbin/smartctl

所以,现在 Nagios 用户可以作为 root 用户调用smartctl工具,而不需要输入任何密码。不过,这需要我们在脚本中做一点修改:

SUDO=$(which sudo) SMARTCTL="$SUDO /usr/sbin/smartctl"

这将使我们的脚本能够以 root 用户身份调用smartctl。稍微做点功课:尝试捕获并处理没有为 Nagios 用户启用sudo的情况。你会如何解决这个问题?我们继续前进,写出我们的服务定义到/etc/nagios3/conf.d/localhost_nagios2.cfg,并添加以下行:

# SMART - Check overall-health define service{ use generic-service host_name localhost service_description SMART - oveall-health check_command check_my_smart!/dev/sda!HEALTH } # SMART - Check self-test define service{ use generic-service host_name localhost service_description SMART - self-test check_command check_my_smart!/dev/sda!SELFCHECK } # SMART - Check temperature define service{ use generic-service host_name localhost service_description SMART - temperature action_url /pnp4nagios/index.php/graph?host=$HOSTNAME$&srv=$SERVICEDESC$ check_command check_my_smart!/dev/sda!TEMPERATURE!-w 41!-c 50 }

我们配置了三个新的服务检查,但只有一个需要 action_url,因为只有温度检查会提供随时间变化的值,并且可以有效地绘制成图表。现在,剩下的就是使用 service nagios3 restart 重启 Nagios 并检查一切是否正常,正如我们在下面的截图中看到的那样:

我们新的三个检查功能已上线,磁盘似乎有些过热。

让我们检查一下温度检测是否生成了一些性能数据,并且是否已经被绘制成图表:

多亏了我们的新图表,我们正密切关注温度。

总结

我们刚刚看到如何通过分析目标、规划所需的方法和工具,并解决实现过程中遇到的问题,来处理现实场景中的一个问题。我们通过小步骤、连续进行的方式处理了我们的结果,并在准备好时将所有部分结合在一起,因此我们没有面对一个庞大的复杂问题,而是在每个步骤中解决了遇到的具体问题,通过学习如何继续前进并避免过度思考。现在,我们准备继续并开展一些现代社会中非常有用的工作:我们的个人 Slack 海报工具。

第八章:我们想聊天

在上一章中,我们刚刚深入研究了 Nagios 插件的规划和编码。我们研究了理解插件是什么、期望它做什么以及如何将其与监控系统集成所需的部分;这是因为创建脚本或程序不仅仅是编写代码本身:这只是一个漫长而复杂工作流程的最后一步。

现在,我们将尝试一些稍微不同的事情,创建一个小客户端,将信息发送到 Slack 频道。这将使我们接触一些新话题,比如 JSON,并且了解如何与基于云的服务进行互动。我们不会编写一个功能完备的客户端(可以读取和写入),而只是实现发送功能,因为 Bash 不是构建完整交互式客户端的最佳工具。这里的目标是编写一个工具,我们可以用它向频道发送通知,比如通知频道成员某个任务的结果、定时任务的执行情况等。

Slack 消息服务

SlackSearchable Log of All Conversation and Knowledge 的缩写,它是一个广泛用于小型和大型团队共享信息、文档和想法的协作工具。Slack 一目了然,提供以下功能:

  • 聊天室: 无论是公共还是私人,聊天室允许团队成员讨论任何话题,而不会干扰到其他人。一个频道是持久存在的,可以有一个话题,任何被邀请的人都可以参与其中。

  • 直接消息: 用户可以向其他人或群组发送直接消息,以便进行私人对话。

  • 集成搜索: Slack 中的所有内容都可以搜索,包括聊天中共享的消息、上传的文件以及我们接触过的人;这可能是该平台最有趣的功能之一。

  • 通话: 可以在频道内或直接消息中进行直接或群组通话;并且无需外部应用程序,因此无需离开平台。

  • 团队: 任何人都可以使用团队所有者提供的 URL 或邀请加入团队;这可能是该工具最具社区感的功能。

  • 与外部服务的集成: Slack 可以连接多种外部服务,以增强其功能,从 Google Drive 到 Dropbox,从 GitHub 到 Zendesk,仅举几个例子。

  • 客户端: 该平台拥有大量客户端,无论是本地客户端还是通过网络的客户端,适用于多个平台,例如 Windows、macOS、Linux、Android,仅举几例,因此我们不需要另一个客户端。

所以我们希望通过向某个频道发送消息并将其显示得漂亮来与 Slack 互动。第一步将是创建一个新的团队,网址如下:

slack.com/

创建新团队

在第一步中,我们将创建一个新团队。我们需要完成所有常规操作,输入电子邮件、确认代码并选择团队 URL:

一个团队 URL 将聚集有共同兴趣的人,如果你完成了,邀请一些朋友;你的团队就准备好加入行列了!

我们已经准备好分享我们的消息了。创建团队空间完成后,我们将有两个默认频道可用:

#general #random

我们可以使用默认频道,也可以创建一个新频道;在我们的例子中,我们将创建一个全新的公共频道,名为 #test

在这里,我们将通过 WebHook 发送脚本创建的所有消息。现在,我们引入了一个新术语——WebHook,这对我们的脚本至关重要,因为它是我们与 Slack 交互的一种方式。所以,最好停下来深入理解 Slack 中 WebHook 的概念。

Slack WebHooks

什么是 WebHook?我们可以将其定义为一种使网页对用户输入做出响应的方法,基于简单的 HTTP POST 方法来支持用户定义的 HTTP 回调。听起来还是有点模糊,是吧?换句话说,Slack 有一些端点,敏感的 URL;当你通过 HTTP 向这些端点发送内容时,你实际上是在与 Slack 通信。使这些 WebHook 有趣的是它们是无状态的,因为它们不依赖于与服务保持持续连接;你只需在需要发布或检索一些信息时向 Slack 发送请求。Slack 支持两种不同类型的 WebHook:

  • Incoming WebHook: 这是我们在希望某些消息出现在测试频道时发送消息的 URL

  • Outgoing WebHook: 这是 Slack 用于通知我们某些频道事件的 URL

我们将使用 Incoming WebHook 来进行我们的通知脚本。那么,我们需要哪些东西呢?我们需要以下内容:

  • Slack Incoming WebHook 已连接到我们的某个频道。

  • 一个包含我们要发布的消息的 JSON。

  • 一个应用程序,它将连接到 URL 并发布 JSON。这将是我们的脚本。

  1. 我们的第一个任务是创建一个新的 Incoming WebHook,并将其连接到我们的测试频道。作为团队管理员,我们需要登录到 my.slack.com/services/new/incoming-webhook/,然后从下拉菜单中选择我们的测试频道,如下所示:

  1. 从下拉菜单中选择 #test 频道。

  2. 现在,点击“添加 Incoming WebHooks 集成”按钮,我们将进入 Incoming WebHooks 页面,在这里我们可以找到用于发送消息到测试频道的 URL。

  1. 在 Incoming WebHooks 页面中,您可以找到您新创建的 WebHook。

在我们的例子中,WebHook 的 URL 为:hooks.slack.com/services/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

如前所述,我们有两种方式可以通过这个 WebHook 发送消息:

  • 作为 POST 请求的负载参数中的 JSON 字符串

  • 作为 POST 请求体中的 JSON 字符串

所以,JSON 对我们的消息系统至关重要,但到底什么是 JSON 呢?

什么是 JSON?

JSONJavaScript 对象表示法 是一种开放的标准格式(ECMA-404),广泛用于应用程序之间交换数据。它在 2007 年作为 JavaScript 编程语言的一个子集创建,并迅速被许多语言采纳,成为一种中立的数据传递方式,不受发送和接收应用程序语言的限制。我们可以看到 JSON 文件以两种不同的结构建模。

一个由名称:值对组成的对象,使用括号打开和关闭,每个名称与相应的值之间用冒号分隔,每对之间用逗号分隔,如以下示例:

{
"name" : "Janet",
"state" : "California",
"cake" : "Toffee sticky pudding"
}

数组中的值按顺序排列,数组以方括号打开和关闭,值之间用逗号分隔:[ "1", "2", "3"]

JSON 中的一个值可以是数字、对象、数组、字符串、true、false 或 null。

所以,JSON 将是我们使用 WebHook 将消息传递到频道的格式,它将以由名称:值对组成的对象结构呈现。Slack 中最简单的消息将包含一个简单的 "text" 关键字作为 JSON 的名称部分,消息本身作为其值:

{
    "text": "This is the first line of a message This is 
             the second line."
}

现在,我们已经将第一条消息格式化为整齐的 JSON,我们必须将该内容传递到我们的#test频道。

你喜欢使用 cURL 吗?

发布 JSON 内容的最简单方法之一是使用外部工具,比如 cURL,其任务是通过 URL 传输数据。我们有两种数据传输方式:

  • 直接作为 HTTP POST 请求正文中的 JSON,带有特定的 content-type 头,这是首选方法。

  • 作为 URL 转义的 JSON,作为 POST 正文的一部分,通过 payload 参数传递

在第一种情况下,我们将使用 cURL 配合以下参数:

-X POST

它指定了与 HTTP 服务器通信的方法。默认方法是 GET,但在这里,我们必须使用 POST 来传输一些信息:

-H 'Content-type: application/json' 

此选项允许我们将额外的头信息发送给 HTTP 服务器。在我们的案例中,我们正在发送一个 多用途互联网邮件扩展 (MIME) 类型,通知 Slack 服务器它应该期望在正文中接收一个 JSON(rfc4627)应用程序类型的对象:

--data '{"text":"This is our first message,.n which continues on another line."}' 

--data 选项允许我们将 JSON 对象作为 POST 请求的正文发送,以便它可以传递给 HTTP 服务器进行处理:

hooks.slack.com/services/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

这是最后一部分:cURL 将调用的 WebHook 地址,以进行 POST 请求。现在,我们已经具备了所有必要的信息,所以只是创建我们的命令行问题。如果你还没有安装 cURL 工具,请先安装它,然后在命令行中输入以下命令:

cURL -X POST -H 'Content-type: application/json' --data 
'{
"text":"This is a line of text.nAnd this is another one.
"}' https://hooks.slack.com/services/T4P7TPSP9/B4ND2E2E4/
lIzhH84lg21ZJ0zdaeQHZ7ls

这是我们发送到频道的第一条消息:

一个简单的 cURL 请求给我们带来了第一条消息。

发送消息的第二种方式是将其 URL 编码放入 POST 请求体的有效载荷参数中。您的 URL 将变成一个充满了百分号字符的杂乱字符串,但它是更传统的方式,因此你可能会更有信心使用它。

要在有效载荷参数中发送 JSON 文件,我们需要以下内容:

-X POST 

这指定了用于与 HTTP 服务器通信的方法。默认方法是 GET,但在这里,我们需要 POST 一些信息:

--data-URLencode 'payload={"text":"This is our second message."}'

这个 URL 编码了我们的 JSON,以便它可以被发布。数据部分的结构稍有不同,因为为了符合 CGI 规范,它必须以一个关键字开始:

hooks.slack.com/services/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

最后,我们有了 URL,它与第一种方法相同。让我们组合我们的命令行并试试看:

cURL -X POST  --data-URL
encode 'payload={"text":"This is our second message."}' 
https://hooks.slack.com/services/T4P7TPSP9/B4ND2E2E4/lI
zhH84lg21ZJ0zdaeQHZ7ls

创建一个新团队。

就是这样,我们的第二条消息已经显示在频道中。它仍然是基本形式,没有任何额外的效果,但它完成了工作,帮助我们理解如何与 Slack 服务器互动。无论如何,基本功能已经很好,但为什么不尝试用一些特殊效果来美化我们的对话呢?

格式化我们的消息

我们可以为消息添加一些样式,从文本属性到链接和按钮,让它们不仅仅是一堆简单的文本。实际上,手动修改所有有效载荷来检查哪些属性组合最适合您的消息可能会太麻烦,但 Slack 提供了一个在线的 消息构建器,让你可以在不发布消息的情况下自定义和预览消息。只需打开浏览器,访问 api.slack.com/docs/messages/builder, 并开始享受其中的乐趣:

有效载荷编辑器允许你在发送消息之前进行尝试。

在上方的框中,我们可以根据需要构造有效载荷,并在下方的框中预览它,所以让我们看看可以添加到消息中的一些有趣的内容:

  • 粗体: 好吧,这是经典之作。你只需将文本字符串用两个星号包裹,即可将其加粗,试试这个有效载荷:
{
    "text": "This is a *bold string* and this a *bold* word"
}

这是我们在消息构建器中看到的粗体文本效果。

  • 斜体: 这强调了单词或句子的重点,你只需要将字符串用两个下划线包围即可实现这一效果:
{
    "text": "This is a _string in italics_."
}

  • 代码: 如果你正在编写属于命令行或某种代码的文本,可以将其包裹在反引号之间,使其突出显示:
{
    "text": "We can use the code attribute to write some code    
             like:n`x=y+1`"
}

这是您的代码在频道中的显示效果。

  • 代码块: 那么如何将 多行 代码块包裹起来呢?让我们看看:
{
    "text": "We can use the block code attribute to write some 
     multi line code like:n```y=1nx=y+1nx=2```"
}

多行 代码块与单行代码字符串相比会略有不同。

  • URL 链接: 你可以通过将 URL 包围在<>中,在消息中插入可点击的链接。你可以用两种不同的方式插入链接:

    • 只需将链接本身用<>包围:<http://www.packtpub.com>

    • 在前面的语法中添加"|linked",使得链接字符串指向的 URL 为<http://www.zarrelli.org|this>"

因此,我们的载荷使用这两种语法的形式可能是这样的:

{
    "text": "This is a web link <http://www.packtpub.com> and 
          <http://www.zarrelli.org|this too>"
}

我们有两种不同的方式链接 URL:

  • 电子邮件地址链接: 类似于 URL 链接,只需将电子邮件链接和"|linked"<>包围:
{
    "text": "Just email the <mail-
to:giorgio.zarrelli@gmail.com|author>."
}

点击突出显示的单词,您的电子邮件客户端将启动,并自动填写收件人地址。

  • 日期: 我们可以使用 Unix 时间戳和一些选择器格式化消息中的日期,并可以修改其显示方式。我们可以选择性地添加 URL 链接,但必须始终提供回退文本,以防时间令牌转换失败。此时的关键词是<!date>,但其语法稍复杂:
{
    "text": "<!date^unix_timestamp^ Some optional text {date_selector}|Fallback text>"
}

我们有很多不同的选择器可以用来修改频道中日期和时间的显示方式:

  • {date}: 你的日期将以经典的3 月 26 日形式显示,试试以下载荷:
{
    "text": "<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^ {date}|Fallback>"
}

所以,我们的cURL命令将是:

cURL -X POST -H 'Content-type: application/json' --data 
'{"
text": 
"<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^ {date}|Fallback>"
}' https://hooks.slack.com/services/T4P7TPSP9/B4ND2E2E4/
lIzhH84lg21ZJ
0zdaeQHZ7ls

下面是我们消息在频道中的显示方式:

  • {date_short}: 正如名称所示,这是一个更紧凑的格式,显示为"3 月 26 日"

  • {date_long}: 这将显示一个扩展日期,格式为"星期天,3 月 26 日"

  • {date_pretty}: 这将显示为{date},但当合适时,会使用"昨天""今天""明天"

  • {date_short_pretty}: 这将以{date_short}格式显示日期,但当合适时,会使用"昨天""今天""明天"

  • {date_long_pretty}: 这将显示为{date_long},但当合适时,会使用"昨天""今天""明天"

  • {time}: 这将以 12 小时格式显示时间,在我们的例子中是1:34 PM;但如果客户端设置为 24 小时格式,它将显示为13:34

  • {time_secs}: 这将以 12 小时格式显示时间,精确到秒,如1:34:55 PM;如果客户端设置为 24 小时格式,则会显示为13:34:55

我们还可以将 URL 添加到日期中,这样当你点击日期/时间时,会跳转到相应的网站,格式如下:

{
    "text": 
"<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^{date}^http://www.packtpub.com|Fallback>"
}

显然,你可以混合格式化器,以便得到更有意义的消息,如下所示:

{
    "text": "<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^Let's meet on {date} at 
{time}|Meeting info>"
}

完整的cURL命令如下所示:

cURL -X POST -H 'Content-type: application/json' --data '{"text": 
"<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^Let's meet on {date} at {time}|Meeting info>"}' https://hooks.slack.com/services/T4P7TPSP9/B4ND2E2E4/lIzhH84lg21ZJ
0zdaeQHZ7ls

除了日期,我们还可以在消息中使用一些特殊命令,让观众注意到我们:

  • <!here>: 这将通知所有在频道中活跃的团队成员:
{
  "text": "<!here><!date¹⁴⁹⁰⁵³¹⁶⁹⁵^{date}^http://www.packtpub.com|Fallback>"
}

  • <!channel>: 这将通知所有在频道中的团队成员,无论他们的状态如何。频道名称旁将出现一个通知图标。

  • <!group>: 这是<!channel>的同义词,二者都可以在频道或小组内使用。

  • <!everyone>: 这会通知我们所有的团队成员。通常可以在团队频道中使用,通常称为#general。

使用其中一个通知标签将导致通知图标出现在频道名称附近。

如果我们想引起观众的注意,可以使用在社交网络中流行的经典工具:所谓的表情符号。Slack 允许我们显示任何我们喜欢的表情符号,因此让我们访问unicodey.com/emoji-data/table.htm,,选择你最喜欢的小图案。

一旦我们选择了我们的表情符号,就可以像这样构建一个有效负载:

{
    "text": "Guys, read carefully the examples:bangbang:, take 
your time :hourglass:, be :b:rave and love the :shell:"
}

然后执行cURL

cURL -X POST -H 'Content-type: application/json' --data '{ "text": "Guys, read carefully the examples:bangbang:, take your :hourglass:, be :b:rave and love the :shell:"}' https://hooks.slack.com/services/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

以下截图中的结果非常棒:它们以一种花哨的方式吸引注意力,因此我们的团队成员将不会被我们的消息所困扰!

一条花哨的消息可以很好地传达声明的紧迫性。最后,我们可以通过使用<@user| 可选的处理符号>来直接指定某个用户,我们还可以使用以下方式覆盖我们想要发送消息的频道:

"channel": "#name_of_channel"

所以,我们可以向频道#general中的用户Giorgio发送请求,邀请他加入测试频道:

{ 
   "text": "Hey <@giorgio|Giorgio> did you join the 
    <#test|Test>   channel?", "channel": "#general"
}'

所以,完整的cURL命令行如下:

cURL -X POST -H 'Content-type: application/json' --data '{ "text": "Hey <@giorgio|Giorgio> did you join the <#test|Test> channel?", "channel": "#general"}' https://hooks.slack.com/services/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

使用此消息,用户 Giorgio 将在#general频道收到通知;内容将引起他的注意。

你不需要将消息的全部文本转换为 HTML,但有三个字符必须被转换为 HTML 实体:

&必须替换为&amp

<必须替换为&lt

必须替换为&gt

到目前为止,我们做的事情相对简单——这就是你可以在没有任何障碍的情况下使用普通的 JSON 来完成的,但如果我们想进一步美化我们的消息,我们必须使用消息附件,它将使我们能够发送图片、附加按钮等。因此,我们的下一步将是使用消息附件,看看如何不仅美化我们的消息,还能使它们更有用和更有效。

消息附件

消息附件可以让我们传递更多内容给用户,并且可以用更多的花哨效果显示出来;但我们必须注意 Slack 规定的一个限制:每条消息最多只能包含 20 个附件。这是有道理的,否则我们的消息会变得非常混乱,分散普通用户的注意力。

到目前为止,我们看到的是一个简单的 JSON:一个一级对象,大致是这样的示例:

{
    "text": "This is the first line of a messagen
    This is the second line."
}

在消息附件中,尽管我们将看到更多细节、更多内容修饰符,以及像我们刚刚看到的那种不够复杂的平面结构,我们需要一个结构化的容器,仍然是 JSON;但这次,它将是一个数组,包含多个属性,像这样:

{
    "attachments": [
        {
            "fallback": "Text to be displayed in case of client 
                         not supporting formatted text",
            "color": "#ff1493",
            "pretext": "This goes above the attachment",
            "author_name": "Giorgio Zarrelli",
            "author_link": "http://www.zarrelli.org",
            "author_icon": "https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg",
            "title": "Title example",
            "title_link": "http://www.zarrelli.org",
            "text": "This text is optional and it is shown in the  
                    attachment",
            "fields": [
                {
                    "title": "Priority",
                    "value": "Medium",
                    "short": false
                }
            ],
            "image_URL": 
"http://www.zarrelli.org/path/to/image.jpg",
            "thumb_URL": "https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052-1-e1490610507795.jpg",
            "footer": "Slack API",
            "footer_icon": "https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg",
            "ts": 1490531695
        }
    ]
}

这个片段看起来怎么样?我们可以在下面的截图中看到附件是如何显示的:

一条消息中包含很多内容,并且有一丝粉色的点缀!

这是一个不错的输出,远比之前的基本示例更好;但是事情变得有些复杂,因为 JSON 的结构变得更加复杂,且我们有更多的字段可用。所以,让我们看一下主要指令,这样我们就可以理解它们的含义和限制。

我们正在查看的是 20 个可能附件中的一个,它以一个回退开始。正如名称所示,这是没有任何标记的纯文本,如果客户端不支持格式化文本,它将被显示:

  • color:你可以为消息指定颜色,为左侧栏着色。这对于让消息在讨论流中脱颖而出,或是快速查看其严重性非常有用。颜色有三种预定义属性:

    • good:这将把侧边栏变为绿色。

    • warning:这将把侧边栏变为黄色。

    • danger:这将把侧边栏变为红色。

除了这些预定义的设置外,你可以使用任何在十六进制代码中定义的颜色,将左侧的栏变成一个引人注目的高亮标记:

  • Pretext:这是可选文本,显示在附件上方。

  • author_name:这是消息作者的名字。

  • author_link:任何有效的 URL 都会将作者的名字内容转化为一个链接。只有在author_name可用时才有效。

  • author_icon:任何指向 16x16 像素图像的有效 URL,将显示在author_name的左侧:只有在作者的名字可用时才有效。

如果有的话,作者的信息将显示在每条消息的最开始处。

  • title:正如名称所示,这是消息的标题,它会以更大的字号和粗体文本显示在消息的顶部,但在作者信息之前。

  • title_link:这是一个完整的 URL,将使标题变为可点击的链接。

  • Text:这是消息的实际内容,内容可以使用我们在前面页面中看到的基本格式进行格式化。如果内容超过 500 个字符或 5 行换行,内容将折叠,并且“显示更多”链接将允许用户展开内容。文本字段中的任何 URL 都不会展开。

  • Fields:这是主数组中的一个数组,里面的元素将在附件中以表格形式显示。你可以在数组中放多个哈希值,只需用逗号分隔它们,如下例所示:

"fields": [
         {
                   "title": "Who's in charge",
                   "value": "Giorgio",
                   "short": true
                },
                {
                   "title": "Priority",
                   "value": "Medium",
                   "short": true
                }
          ]

  • title:这是简单文本,显示在值上方,以粗体显示。它不能包含任何标记。

  • value:这可以包含格式化为我们在前面页面中看到的标记的多行文本。

  • short:这个可选项将使值变得足够简短,可以与其他值并排显示,如下所示:

值可以并排显示。

  • image_URL 这是我们选择的任何有效 URL,指向 PNG、JPEG、GIF 或 BMP 格式的有效图像。显示的尺寸为 400 x 500 像素,任何宽度或高度超过该尺寸的图像会自动缩放,保持原始纵横比。该图像将显示在消息附件中。

  • thumb_URL 这是指向 PNG、JPEG、GIF 或 BMP 格式图像文件的任何 URL。它将在附件消息的右侧显示,并会按 75 x 75 像素的比例缩放保持纵横比。文件大小限制为小于 500 KB。

  • footer 这是简短的文本片段,最多 300 个字符,用于向读者提供一些额外的信息。

  • footer_icon 提供指向图像的有效 URL,图像将显示在页脚文本旁边。该图像将以 16 x 16 像素的固定大小显示,仅当你提供了页脚时才会显示。

  • ts 每条消息都有自己的时间戳,当发布时,然而我们可以通过 ts 字段和以纪元时间格式表达的时间信息,向消息附件中提及的事件或发生的事情附加特定的时间戳。

就我们关心的内容而言,重点在于消息格式化。有些花哨的功能如按钮,但这需要一个能够从频道读取并响应用户操作的完整应用程序。目前,我们将坚持通过脚本与频道进行更简单的交互,只是发送格式良好的信息;但没有人阻止你从这个例子出发,构建更复杂且更符合需求的功能。

我们的简易 Slack 聊天脚本

是时候开始规划我们的 Slack 脚本了,第一步是问自己,我们希望它做什么。让我们回顾一下需求,脚本必须执行以下操作:

  • 接受要显示的文本消息:必填

  • 接受消息标题:必填

  • 接受 title_link:可选,仅在标题可用时使用

  • 接受回退消息:必填

  • 接受 author_name:可选

  • 接受 author_link:可选,仅在 author_link 可用时使用

  • 接受 author_icon:可选,仅在 author_link 可用时使用

  • 接受颜色:必填

  • 接受前置文本:可选

  • 接受 fields:必填,必须包括标题、值和短标记

  • 接受 image_URL:可选

  • 接受 thumb_URL:可选

  • 接受 footer:可选

  • 接受 footer_icon:可选,仅在页脚可用时使用

  • 接受 ts:可选

现在,我们有了一个矩阵来开始构建我们的模块并解析命令行,准备开始编码了。我们知道该怎么做,但由于任务的复杂性,我们将一步步进行,逐步添加模块;由于我们的核心信息是 JSON 格式,我们将从编码其结构开始。但在第一步之前,我们应该做些什么呢?我们需要考虑使用哪些工具。作为开始,我们至少需要 cURL,所以请检查你的系统是否已安装它;如果没有,请安装它。我们脚本的第一行将包含 sha-bang,并尝试在PATH中定位该工具:

#!/bin/bash
# License: GPL
# 
# Author: Giorgio Zarrelli <zarrelli@linux.it>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Retrieve the full path to the system utilities
cURL=$(which cURL)

这些第一行代码类似于之前的 Nagios 插件,且这个脚本同样以许可证声明开始。声明许可证可能没什么用,但如果我们计划将脚本提供给公众使用,就有责任告知潜在用户他们能做些什么。此书的作者鼓励在 GNU GPL 许可证下发布软件,因为它使得使用该软件创建新程序并重用代码更加容易。但最终选择使用哪种许可证,取决于程序的创建者。要了解可用的各种 GNU 许可证,我们只需访问www.gnu.org/licenses/licenses.html,查看其中一个许可证,这一定能满足我们的需求。注意,对于这个脚本,我们将使用小写变量,以便熟悉不同编码者采用的不同符号约定。

所以,我们指向了cURL工具,但我们怎么能确认它已安装并且可以访问呢?嗯,我们需要记住,赋值给变量的命令替换结果是which的输出,只有当传递给which的参数在用户$PATH环境变量所指示的目录中可访问时,它才会输出某些内容。做个测试,让我们以 cURL 作为参数调用which

zarrelli:~$ which cURL
/usr/bin/cURL

现在,让我们用一些简短的说明来调用which

zarrelli:~$ which cr234a
zarrelli:~$ 

输出为空,所以我们的cURL变量没有值。再做一次检查,让我们以 root 用户测试ifconfig

root:# which ifconfig
/sbin/ifconfig

然后,让我们再作为非特权用户检查一次:

zarrelli:~$ which ifconfig
zarrelli:~$

因为ifconfig的路径仅在 root 用户的$PATH变量中,所以对于非特权用户,which将返回空值。基于这个情况,我们可以用几行代码实现一个检查:

if [ -z "$cURL" ]
   then
         echo "Cannot reach the utility, is it in the $PATH or 
even installed?"
         exit 1
      else
         echo "The utility is reachable"
fi

所以,如果$cURL变量不为空,表示该工具可访问;如果为空,则会收到警告,并带着错误退出脚本:

zarrelli:~$ ./my_slack.sh 
The utility is reachable

很好,似乎有效。现在让我们把cURL=$(which cURL)改成cURL=$(which cur1l2)

让我们再次运行脚本:

zarrelli:~$ /bin/bash -x my_slack.sh 
++ which cur1l2
+ cURL=
+ '[' -z '' ']'
+ echo 'Cannot reach the utility, is it in the $PATH or even in 
stalled?'
Cannot reach the utility, is it in the $PATH or even installed?
+ exit 1

正确,脚本退出是因为which无法在用户的$PATH变量中找到那个无意义的字符串。所以,这个检查可能会派上用场,但按现在的写法并不那么有用,因此让我们将其做成一个函数:

# Check if which comes back with a path to a utility
check_which()
{
   for i in "$@"
   do
         if [ -z "$i" ];
               then
                     echo "Cannot reach the utility $i, is it in 
the $PATH or even installed?"
                      exit 1
               else
                      :
           fi
    done
}
check_which "$cURL"

我们只需检查输出的路径是否为工具的路径;如果什么也不输出,则变量为空,我们会退出并输出一条信息以及退出代码。如果变量有内容,我们就不做任何处理,因为我们假设这是工具的路径。这个检查类似于我们为 Nagios 插件所做的检查:

# Check if we have all the system tools we need
path_exists()
{
for i in "$@"
do
       if [ -e "$i" ];
               then 
                          (( VERB )) && echo "$i is a valid path"
                          disk_exists "$DISK"
               else
                          (( VERB )) && echo "$i is not reachable, 
is this the correct path?"
                        echo "SMART UNKNOWN: Please check the 
plugin"
                        exit "$STATE_UNKNOWN"
        fi
done
}

另一个检查测试的是变量内容是否指向一个文件。哪个更好?取决于你要检查的内容。如果我们需要验证变量指向一个真实的文件,那么[ -e "$i" ]是我们要找的。否则,当我们想做更通用的检查时,[ -z "$i" ]就能完成任务。

我们在脚本中接下来需要做什么?让我们回顾一下几页前我们做的一个cURL

cURL -X POST -H 'Content-type: application/json' --data '{"text": "<!date¹⁴⁹⁰⁵³¹⁶⁹⁵^ {date}|Fallback>"}' https://hooks.slack.com/services/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 

一旦我们处理完cURL命令,就必须管理请求头:

-X POST
-H 'Content-type: application/json'
--data

这些是静态的,不会改变,因此我们可以直接使用它们而不需要放入变量中。

我们不能忘记我们的 WebHook URL:

# WebHook URLwebhook="https://hooks.slack.com/services/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"

现在,是时候构建我们的第一个静态负载了;这部分有点棘手。由于负载是一个长多行的 JSON,将其写成一长行会很麻烦,因此我们将把这个负担交给一个函数,它将为我们生成这些内容;而且它也会被格式化得很好:

generate_payload()
{
   cat <<MARKER
{
    "attachments": [
       {
            "fallback": "Text to be displayed in case of client 
                          not supporting formatted text",
            "color": "#ff1493",
            "pretext": "This goes above the attachment",
            "author_name": "Giorgio Zarrelli",
            "author_link": "http://www.zarrelli.org",
            "author_icon": "https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg",
            "title": "Title example",
            "title_link": "http://www.zarrelli.org",
            "text": "This text is optional and it is shown in the        
                        attachment",
            "fields": [
                {
                   "title": "Priority",
                   "value": "Medium",
                   "short": false
                }
          ],
           "image_URL": 
"http://www.zarrelli.org/path/to/image.jpg",
           "thumb_URL": "https://www.zarrelli.org/blog/wp-
content/uploads/2017/03/IMG_20161113_150052-1-e1490610507795.jpg",
           "footer": "Slack API",
           "footer_icon": "https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg",
            "ts": 1490531695
        }
    ]
}
MARKER
}

我们使用了here文档,正如我们之前看到的,这是处理 Bash 脚本中多行内容的最佳方法之一。函数将为我们创建内容,因此让我们通过在脚本底部添加generate_payload来验证这一点;然后运行它:

zarrelli:~$ ./my_slack.sh 
{
    "attachments": [
        {
            "fallback": "Text to be displayed in case of client 
                         not supporting formatted text",
            "color": "#ff1493",
            "pretext": "This goes above the attachment",
            "author_name": "Giorgio Zarrelli",
            "author_link": "http://www.zarrelli.org",
            "author_icon": "https://www.zarrelli.org/blog/wp
content/uploads/2017/03/IMG_20161113_150052.jpg",
            "title": "Title example",
            "title_link": "http://www.zarrelli.org",
            "text": "This text is optional and it is shown in the 
                       attachment",
            "fields": [
                {
                    "title": "Priority",
                    "value": "Medium",
                    "short": false
                }
            ],
             "image_URL": 
"http://www.zarrelli.org/path/to/image.jpg",
            "thumb_URL": "https://www.zarrelli.org/blog/wp
content/uploads/2017/03/IMG_20161113_150052-1-e1490610507795.jpg",
            "footer": "Slack API",
            "footer_icon": "https://www.zarrelli.org/blog/wp
content/uploads/2017/03/IMG_20161113_150052.jpg",
            "ts": 1490531695
        }
    ]
}

太好了,它工作了!我们的内容已经生成,我们可以继续构建命令行。让我们删除脚本底部对函数的调用,并添加以下行:

"$cURL" -f -X POST -H 'Content-type: application/json' --data "$(generate_payload)" "$webhook" 

$(generate_payload) 是一个命令替换,它会将函数生成的输出赋值给--data;但不要忘记将其括在双引号中,否则你的输出将逐行处理,而不是作为一个整体。现在是时候保存并执行脚本,检查我们的#test频道:

zarrelli:~$ ./my_slack.sh 
ok

我们检查了脚本,以确保它按预期工作。

很好,脚本运行正常,我们可以在#test频道看到结果,以及命令行中一个小小的OK。嗯,还不错,但我们不能依赖第三方输出来判断是否出了问题,因此我们需要修改命令行,以获取一些有用的响应:

"$cURL" -f -X POST -H 'Content-type: application/json' --data "$(generate_payload)" "$webhook" && echo " - Success exit code: $?" || echo "There was an error, exit code: $?"

我们向cURL添加了一个-f标志,这样在出错时它会悄无声息地退出,让我们可以在输出中写入有意义的信息。它不是百分百可靠的,正如我们将看到的,有时错误信息会泄漏出来,但它仍然是可用的。然后我们添加了这个:

&& echo " - Success exit code: $?" || echo "There was an error, 
exit code: $?"

我们以前已经见过这种测试。我们在检查命令是否成功执行,并将退出代码"$?"输出到stdout。让我们看一下:

zarrelli:~$ ./my_slack.sh 
ok - Success exit code: 0

太好了!cURL 刚刚在命令行上打印了 ok,而且由于执行顺利,我们打印了一个带有 exit codeSuccess 消息。现在,让我们删除最后一行中的 $webhook 中的 k,并再次执行脚本:

zarrelli:~$ ./my_slack.sh 
cURL: (3) <URL> malformed

出现了一个错误,退出代码:3

它应该悄悄失败,但无论如何,我们成功地写出了有意义的错误信息;这正是我们想要的。

我们的第一步已经完成:我们可以向 WebHook 发送一个静态消息,并在 #test 频道显示该消息。这很有趣,但不够灵活。我们真正想要的是根据输入修改消息。为了实现这个目标,我们需要将附件中的所有部分转换为变量,以便能够在命令行中传递值。让我们开始创建一些变量:

# Message attachment variables
fallback=${fallback:="This is a text shown on older clients"}
text=${text:="This line of text is optional"}

现在,我们只需要修改有效负载:

{
            "fallback": "$fallback",
            "color": "#ff1493",
            "pretext": "This goes above the attachment",
            "author_name": "Giorgio Zarrelli",
            "author_link": "http://www.zarrelli.org",
            "author_icon": "https://www.zarrelli.org/
            blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg",
            "title": "Title example",
            "title_link": "http://www.zarrelli.org",
            "text": "$text",
            "fields": [
                {
                    "title": "Priority",
                    "value": "Medium",
                    "short": false
                }
            ],
                "image_URL": 
               "http://www.zarrelli.org/path/to/image.jpg",
                "thumb_URL": "https://www.zarrelli.org/
                 blog/wpcontent/uploads/2017/03/IMG_20
                161113_150052-1-e1490610507795.jpg",
            "footer": "Slack API",
            "footer_icon": "https://www.zarrelli.org/blog/wpcontent/
             uploads/2017/03/IMG_20161113_150052.jpg",
            "ts": 1490531695
        }

现在,让我们运行脚本,看看我们的修改是否生效:

zarrelli:~$ ./my_slack.sh 
ok - Success exit code: 0

看起来它有效;我们可以通过检查 #test 频道来确认结果,正如我们在下面的截图中所见:

我们的变量正在被脚本考虑。

既然我们的变量似乎在正常工作,接下来让我们创建一大堆新的变量:

# Message attachment variables
fallback=${fallback:="This is a text shown on older clients"}
color=${color:="good"}
pretext=${pretext:="Announcement:"} author_name=${author_name:="Giorgio Zarrelli"}
author_link=${author_link:="http://www.zarrelli.org"}
author_icon=${author_icon:="https://www.zarrelli.org/blog/wp
content/uploads/2017/03/IMG_20161113_150052.jpg"}
title=${title:="New message"}
title_link=${title_link:="Announcement:"}
text=${text:="This line of text is optional"}
fields_title=${fields_title:="Priority"}
fields_value=${fields_value:="Medium"}
fields_short=${fields_short:="true"}
im-
age_URL=${image_URL:="http://www.zarrelli.org/path/to/image.jpg"}
thumb_URL=${thumb_URL:="https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052-1-e1490610507795.jpg"}
footer=${footer:="Mastering Bash"}
footer_icon=${footer_icon:="https://www.zarrelli.org/blog/wp-content/uploads/2017/03/IMG_20161113_150052.jpg"}
ts=${ts:="1490531695"}

显然,有效负载必须相应地进行修改:

 {
            "fallback": "$fallback",
            "color": "$color",
            "pretext": "$pretext",
            "author_name": "$author_name",
            "author_link": "$author_link",
            "author_icon": "$author_icon",
            "title": "$title",
            "title_link": "$title_link",
            "text": "$text",
            "fields": [
                {
                    "title": "$fields_title",
                    "value": "$fields_value",
                    "short": "fields_short"
                }
            ],
            "image_URL": "$image_URL",
            "thumb_URL": "$thumb_URL",
            "footer": "$footer",
            "footer_icon": "$footer_icon",
            "ts": "$ts"
    }

再次,让我们测试一下我们的代码:

zarrelli:~$ ./my_slack.sh 
ok - Success exit code: 0

这里的截图显示了我们新格式化的消息:

看起来我们新的有效负载工作得很好。

现在我们已经有了所有的变量,接下来是创建一个菜单,帮助我们管理用户输入。让我们开始编写一个帮助函数;我们在上一章已经看过如何做了,但这次我们有很多选项要处理,因此我们将每个变量都关联一个选项:

-f --fallback
-c --color 
-p --pretext
-an --author_name
-al --author_link
-ai --author_icon
-t --title
-tl --title_link
-tx --text
-ft --fields_title
-fv --fields_value
-fs --fields_short
-iu --image_URL
-tu --thumb_URL
-fr --footer
-fi --footer_icon
-ts –-timestamp

我们使用的短选项和长选项是由我们决定的,但我们必须记住一个黄金法则:这些选项必须对潜在的脚本用户有意义,而不是对我们自己,因此我们需要站在用户的角度,尝试以他们的方式思考。一旦我们确定了最佳选项,就可以开始创建实际的命令行解析器:

# Parse parameters on the command line
while (( $# > 0 ))
   do
               case "$1" in
               -h | --help)
                         print_help
                             exit 1
                             ;;
               -f | --fallback)
                           shift
                           fallback="$1"
                           ;;
                    -c | --color)
                             shift
                          color="$1"
                         ;;
                    -p | -pretext)
                           shift
                         pretext="$1"
                             ;;
                    -an | --author_name)
                            shift
                         author_name="$1"
                           ;;
               -al | --author_link)
                           shift
                         author_link="$1"
                           ;;
         -ai | --author_icon)
                           shift
                         author_icon="$1"
                           ;;
               -t | --title)
                           shift

                           ;;
               -tl | --title_link)
                               shift
                             title_link="$1"
                               ;;
               -tx | --text)
                           shift
                         author_icon="$1"
                           ;;
               -ft | --fields_title)
                           shift
                        fields_
                          ;;
               -fv | --fields_value)
                           shift
                        fields_value="$1"
                          ;;
               -fs | --fields_title)
                           shift
                         fields_short="$1"
                           ;;
               -iu | --image_URL)
                           shift
                         image_URL="$1"
                            ;;
               -tu | --thumb_URL)
                           shift
                         thumb_URL="$1"
                           ;;
               -fr | --footer)
                           shift
                         footer="$1"
                           ;;
               -fi | --footer_icon)
                           shift
                         image_URL="$1"
                           ;;
               -ts | --timestamp)
                           shift
                        ts="$1"
                          ;;
     *) echo "Unknown argument: $1"
                          print_help
                          exit 1
                          ;;
         esac
         shift
       done

现在是 print_help 函数:

# Print help and usage
print_help()
{
   cat << HERE

   Slack sender v1.0
         ---------------
Please enter one or more of the following options: 
            -h | --help
            -f | --fallback
              -c | --color
              -p | -pretext
             -an | --author_name
             -al | --author_link
             -ai | --author_icon
              -t | --title
             -tl | --title_link
             -tx | --text
             -ft | --fields_title
             -fv | --fields_value
             -fs | --fields_title
             -iu | --image_URL
             -tu | --thumb_URL
             -fr | --footer
             -fi | --footer_icon
             -ts | --timestamp
HERE
}

现在,让我们注释掉脚本的最后一行:也就是调用 cURL 的那一行。然后,我们通过 -h 参数来调用脚本:

zarrelli:~$ ./my_slack.sh -h

Slack sender v1.0
     ------------------- Please enter one or more of the following options:
 -h | --help
 -f | --fallback
 -c | --color
 -p | -pretext
 -an | --author_name
 -al | --author_link
 -ai | --author_icon
 -t | --title
 -tl | --title_link
 -tx | --text
 -ft | --fields_title
 -fv | --fields_value
 -fs | --fields_title
 -iu | --image_URL
 -tu | --thumb_URL
 -fr | --footer
 -fi | --footer_icon
 -ts | --timestamp

有一个小问题:即使是 Nagios 插件也有这个问题,因此是时候解决它了。让我们在没有任何参数的情况下调用脚本:

zarrelli:~$ ./my_slack.sh -h
zarrelli:~$

什么都没有,我们没有任何反馈,因此甚至不知道是否可以使用 -h 参数来获取更多信息。我们该如何解决这个障碍呢?好吧,我们有不同的选择;例如,我们可以修改 while 条件,或者采用其他策略:

if (( $# == 0 ))
   then
         echo "No options provided, you can use -h for help but 
this time I will do it for you..."
         print_help
fi

我们必须将这些行放在菜单创建之前,它们会检查输入:如果命令行没有给出任何参数,它将写入错误信息并调用 print_help 函数。现在是时候测试我们的脚本,看看会发生什么:

./my_slack.sh -c warning -an Me -t "My cli test" -tx "This is a cli text"
ok - Success exit code: 0

看起来它有效,#test 频道的截图确认了我们的猜测:

我们的脚本现在接受命令行参数。

总结

我们现在能够向#test频道发送格式化的消息,但这就是我们可以用这个脚本做的全部吗?不,正如你随着时间推移、积累经验会发现的,编程也是在设定我们的努力范围:我们必须定义目标,进行相应的计划,完成目标,并评估结果。在专业环境中,做得过头违反了项目管理的基本规则,即所谓的铁三角,它定义了项目质量作为范围、时间和成本的交集,这三个约束条件驱动我们创建程序。如果在一个程序上花费过长时间或超出目标,成本将会膨胀,整体质量——不是代码质量,而是我们项目的质量——将会受到影响。

这个脚本是一个关于如何规划和执行的示例,展示了如何检查我们需要的信息来编写一个可运行的脚本,以及如何记录我们的步骤。有很多方法可以改进这个框架,例如,允许用户在命令行上传递一个日期,而不是以纪元时间的形式,而是以date命令允许的格式传递,然后通过一个小函数或甚至直接在代码中进行转换。我们还可以,例如,检查选项参数,以便用户在命令行中指定选项时,强制传递一个参数。这是一个操作领域,这些可以是如何玩得开心并进一步开发脚本的建议。我们现在将继续深入探讨如何利用子壳执行多个并行进程,以及,像往常一样,如何玩得开心!

第九章:子 Shell、信号与作业控制

到目前为止,我们看到的内容都相当直接。我们启动了一个脚本,执行了一些命令、实例、变量,并从中做出了些事情,仅此而已——一条命令接着一条命令,一条指令堆叠在上一条指令上。这就是我们所说的串行执行,一条命令接着另一条,就像多米诺骨牌:第一条先到,第一条先被处理;这让人联想到 FIFO 队列的概念,先进先出(First In First Out)。

如果我们想同时处理多个指令怎么办?好吧,我们不能这么做,这也不会是错的:CPU 是一个串行设备,它一次只能处理一条指令。我们用来给我们带来多任务处理的感觉的,是让 CPU 在指令之间快速切换。所以,CPU 不会在完全处理完一条指令后再转到另一条,而是会先处理一点第一条指令,再处理一点第二条,然后再回到第一条指令,继续处理一段时间。正是这种来回切换的方式让我们产生了 CPU 同时处理多项任务的错觉。接着,我们有了多处理器系统,我们可以利用这种架构,将进程分配到不同的 CPU 上,从而实现真正的并行处理。

无论我们决定做什么,一切都始于一个调用,一次对主 shell 新实例的信任跃迁,从而诞生了一个新的子 shell,并且它将致力于我们的任务。那么,首先,什么是子 shell?

什么是子 shell?

让我们从一个更基础的问题开始。什么是 shell?简单来说,shell 是用户与底层操作系统之间的接口。它可以是命令行解释器或图形界面,但 shell 的目的是充当用户与系统核心之间的中介,使前者能够访问后者提供的服务。例如,bash shell 通过终端为我们提供了命令行接口,通过一系列命令让我们与操作系统进行交互,利用其服务来执行任务。

在一个 shell 中,每条命令通常是在前一条命令完成任务之后执行的,但我们可以在一定程度上通过利用一些关键概念来改变这种行为:后台进程、信号和子 shell。

后台进程

让我们从对后台进程的直观定义开始,将其定义为与其启动的终端没有交互的进程。这实际上意味着后台进程与用户没有交互,仅此而已。从技术上讲,我们可以说后台进程是其进程 ID 组与其启动终端的进程 ID 组不同的进程。我们可以将进程组定义为一组具有相同进程组 ID 的进程,该 ID 是一个允许系统整体管理所有进程的标识符。进程组 ID 由组的第一个进程(也称为进程组长)的进程 ID 确定;组中的每个后续进程将从组长的进程 ID 中获取进程组 ID;每个子进程都放置在其父进程的进程组 ID 中。类似地,会话是一组进程组的集合;会话中的第一个进程也是会话领导者,它是唯一允许控制终端(如果有的话)的进程。因此,为用户准备登录会话的进程也是所有“用户会话”期间生成的进程的会话领导者,并且所有进程都将处于会话下的进程组中。当用户会话关闭时,内核会向保持终端前台进程组的会话领导者发送挂起信号SIGHUP)。这是因为当用户关闭其交互式会话时,与终端的连接关闭,前台进程将无法再访问终端,因此它们必须被终止。话虽如此,如果后台进程尝试从终端读取,它将被禁止,并且在尝试向终端写入时会因终端输入信号SIGTTIN)和终端输出信号SIGTTOU)而受到阻止。

信号

在计算机早期,信号是处理异常事件的一种方式,通常用于将条件重置为默认状态。如今,借助诸如作业控制之类的设施,信号被用来实际指导进程执行操作,更多地成为进程间的通信设施,而非最初设想的重置机制。每个信号都与接收该信号的进程必须执行的操作相关联,以下是内核可以发送给进程的一些较为有趣的信号的简要列表:

  • SIGCHLD: 当子进程终止或停止时,会向父进程发送此信号。

  • SIGCONT: 这告诉已被SIGSTOPSGSTP暂停的进程恢复其执行。这三个信号用于作业控制。

  • SIGHUP:当进程的终端关闭时,会发送此信号并终止进程。它得名于早期使用串行线路连接时的时代,那时候连接因线路掉线而挂起。如果发送给守护进程,通常会强制它们重新加载配置文件并重新打开日志文件。

  • SIGINT:当用户按下Ctrl+C时,会向进程发送此信号,打断并终止该进程。该信号可以被进程忽略。

  • SIGKILL:此信号立即终止一个进程。该信号不能被忽略,进程必须立即终止,且不会关闭或保存任何内容。(kill -9)

  • SIGQUIT:当进程接收到此信号时,它会退出并生成核心转储。核心转储是进程使用的内存的复制品,我们可以在其中找到很多有用的信息,比如处理器、寄存器、标志、数据等,这些信息对于调试进程的工作状态非常有用。

  • SIGSTOP:这个信号用于停止一个进程。它无法被忽略。

  • SIGTERM:这是一个终止请求。这是杀死进程的首选方法,因为它允许进程正常关闭,释放资源并保存状态,同时有序地终止所有子进程。进程可以忽略此信号。(kill -15)

  • SIGTRAP:这是当异常或陷阱发生时发送给进程的信号。我们已经略微了解了陷阱,接下来我们会进一步探讨它们。

  • SIGTSTP:这是一个交互式停止信号,用户按下Ctrl+Z时可以发送该信号。进程可以忽略此信号。进程会在当前状态下暂停。

  • SIGTTIN:当一个后台进程尝试从终端读取数据时,会发送此信号。

  • SIGTTOU:当一个后台进程尝试写入终端时,会发送此信号。

  • SIGSEV:当进程发生段错误时,会发送此信号。段错误发生在进程尝试访问它没有权限访问的内存位置时。

所以,我们有了信号、进程组和会话,这引出了 Unix 作业控制。那它是什么呢?在 Unix 中,我们可以控制我们所说的作业,而且我们已经很熟悉这些,因为这是进程组的另一个术语。作业不是一个进程,它是一个进程组。那么,控制是什么意思呢?简单来说,我们可以挂起、恢复或终止一个作业,并向它发送信号。

当在终端上启动一个 shell 会话时,它的进程组将获得访问终端的权限,并成为该终端的前台进程组。这意味着,属于前台组的进程可以从终端读取和写入数据,而其他进程组的进程则无法访问终端,如果它们尝试访问,进程会被停止。所以,从 shell 中,你可以与终端交互并执行不同的操作,例如,检索进程列表及其作业 ID:

zarrelli:~$ ps -fj | awk '{print $2 " -> " $4 " -> " $10 }'
PID -> PGID -> CMD
1422 -> 1422 -> /bin/bash
7886 -> 7886 -> ps
7887 -> 7886 -> awk

如我们所见,psawk进程有相同的进程组 ID,它是组内第一个命令ps的进程 ID。那么,作业控制怎么样呢?让我们看看如何在后台启动一个进程:

zarrelli:~$ sleep 10 &
[1] 8163

sleep 命令只是等待我们指定的秒数作为参数,但关键在于&符号;它会将进程放入后台。我们得到的返回值是作业号[1]和进程 ID;ps会显示更多的详细信息:

zarrelli:~$ ps -jf
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1422 1281 1422 1422 0 08:46 pts/0 00:00:00 
/bin/bash
zarrelli 8163 1422 8163 1422 0 10:25 pts/0 00:00:00 sleep 
10
zarrelli 8166 1422 8166 1422 0 10:25 pts/0 00:00:00 ps -jf

现在,让我们来看一下这个:

zarrelli:~$ (sleep 100 &) ; sleep 20 &
[1] 8632
zarrelli:~$:~$ ps -jf
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1422 1281 1422 1422 0 08:46 pts/0 00:00:00 
/bin/bash
zarrelli 8631 1 8630 1422 0 10:39 pts/0 00:00:00 sleep 
100
zarrelli 8632 1422 8632 1422 0 10:39 pts/0 00:00:00 sleep 
20
zarrelli 8637 1422 8637 1422 0 10:40 pts/0 00:00:00 ps -jf

在这里,我们将一个 sleep 进程放入后台,但使用了()将其在子 shell 中执行,实际上它是在前台执行的;不过现在,主 shell 并未报告任何作业或进程 ID,因为子 shell 无法将任何信息报告给父 shell。我们收到的唯一作业信息是父 shell 中执行的第二个 sleep 指令,且有趣的是,这两个 sleep 进程有相同的组 ID。

作业控制

所以,我们有作业 ID、进程 ID、前台和后台进程,但我们如何控制这些作业呢?我们有一堆可用的命令,来看看如何使用它们:

  • kill 我们可以将作业 ID 传递给这个命令,它将向属于该作业的所有进程发送SIGTERM信号:
zarrelli:~$ sleep 100 &
[1] 9909
zarrelli:~$ kill %1
zarrelli:~$ 
[1]+ Terminated sleep 100

你也可以传递一个特定的信号来发送给进程。例如,kill -15会优雅地终止进程,发送SIGTERM信号;如果进程拒绝终止,kill -9将发送SIGKILL,立即终止进程。

我们可以向进程发送哪些信号?无论是kill -l还是cat /usr/include/asm-generic/signal.h都可以给出所有支持的信号列表。

  • killall 如果我们知道进程的名称,杀死它最简单的方法就是使用killall命令,后跟进程名称:
zarrelli:~$ sleep 100 & 
[1] 10595
zarrelli:~$ killall sleep
[1]+ Terminated sleep 10

killall有一个有趣的用法。让我们运行 sleep 命令四次,每次使用不同的参数:

zarrelli:~$ sleep 100 &
[1] 10672
zarrelli:~$ sleep 200 &
[2] 10689
zarrelli:~$ sleep 300 &
[3] 10690
zarrelli:~$ sleep 400 &
[4] 10693

现在,让我们检查进程列表:

zarrelli:~$ ps -jf
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1422 1281 1422 1422 0 08:46 pts/0 00:00:00 
/bin/bash
zarrelli 10672 1422 10672 1422 0 11:16 pts/0 00:00:00 sleep 
100
zarrelli 10689 1422 10689 1422 0 11:16 pts/0 00:00:00 sleep 
200
zarrelli 10690 1422 10690 1422 0 11:16 pts/0 00:00:00 sleep 
300
zarrelli 10693 1422 10693 1422 0 11:16 pts/0 00:00:00 sleep 
400
zarrelli 10699 1422 10699 1422 0 11:16 pts/0 00:00:00 ps -jf

我们可以看到四个进程:相同的名称和不同的参数。现在,让我们使用killall并给出进程名称 sleep 作为它的参数:

zarrelli:~$ killall sleep
[1] Terminated sleep 100
[2] Terminated sleep 200
[4]+ Terminated sleep 400
[3]+ Terminated sleep 300

所有进程都已一次性被终止。相当方便,不是吗?让我们做最后的检查:

zarrelli:~$ ps -jf
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1422 1281 1422 1422 0 08:46 pts/0 00:00:00 
/bin/bash
zarrelli 10709 1422 10709 1422 0 11:16 pts/0 00:00:00 ps -jf

现在没有更多的 sleep 实例在运行;我们通过一次运行killall将所有进程都杀死了。

  • jobs 这显示在后台运行的进程及其作业 ID:
zarrelli:~$ sleep 100 &
[1] 8892
zarrelli:~$ sleep 200 &
[2] 8893
zarrelli:~$ jobs
[1]-  Running sleep                 100 &
[2]+  Running sleep                 200 &

  • fg 这将后台运行的作业发送到前台。它接受作业 ID 作为参数。如果没有提供作业 ID,则会影响当前作业:
zarrelli@moveaway:~$ sleep 100 &
[1] 9045
zarrelli@moveaway:~$ fg %1
sleep 100

  • bg 这将前台作业发送到后台。如果没有提供作业 ID,则会影响当前作业。

  • suspend 这会挂起 shell,直到接收到SIGCONT信号。

  • logout 这将从登录 shell 注销。

  • disown 这将从 shell 的活动作业表中移除一个作业。

  • wait: 这个有趣的命令会暂停脚本执行,直到所有后台作业结束,或者如果作为参数传入作业 ID 或 PID,它会等到该作业或 PID 结束,并返回它等待的进程的退出状态。

作业 ID 含义 示例
%n 作业编号 Kill %1
%s 命令执行开始的字符串 sleep 200 &[1] 9486kill %sl[1]+ 已终止 sleep 200
%?s 命令执行包含的字符串 sleep 200 &[1] 9504kill %?ee[1]+ 已终止 sleep 200
%% 最近被停止的前台作业或开始的后台作业 sleep 200 &[1] 9536kill %%[1]+ 已终止 sleep 200
%+ 最近被停止的前台作业或开始的后台作业 sleep 200 &[1] 9618fg %+sleep 200
%- 上一个任务 sleep 200 &[1] 9626kill %-[1]+ 已终止 sleep 200
$! | 最近的后台进程 | sleep 200 &[1] 9646sleep 300 &[2] 9647sleep 400 &[3] 9648kill $![3]+ 已终止 sleep 400
  • times: 我们在本书开篇时已经看过这个命令。它为我们提供执行命令期间所用时间的统计数据。

  • builtin: 这会执行一个内建命令,禁用与内建命令同名的功能和非内建命令。

  • command: 这会禁用指定命令的所有别名和功能:

zarrelli@moveaway:~$ ls
Desktop Documents Downloads First session Music Pictures 
progetti Projects Public Templates tmp Videos
[1]- Done sleep 200
[2]+ Done sleep 300
zarrelli:~$ ls
Desktop Documents Downloads First session Music Pictures 
progetti Projects Public Templates tmp Videos
zarrelli:~$ alias ls="ps -jf"
zarrelli:~$ ls
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1373 1267 1373 1373 0 07:36 pts/0 00:00:00 
/bin/bash
zarrelli 10738 1373 10738 1373 0 10:17 pts/0 00:00:00 ps -jf
zarrelli:~$ command ls
Desktop Documents Downloads First session Music Pictures 
progetti Projects Public Templates tmp Videos
zarrelli@moveaway:~$ ls
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 1373 1267 1373 1373 0 07:36 pts/0 00:00:00 /bin/bash
zarrelli 10742 1373 10742 1373 0 10:17 pts/0 00:00:00 ps -jf

  • enable: 这个命令启用或禁用 -n 内建命令,所以如果我们有一个内建命令和一个外部命令,当调用时,内建命令会被忽略,外部命令会被执行。指定 -a 选项会显示所有内建命令及其状态列表,而 -f 选项会将内建命令作为共享库模块从编译的对象文件中加载。

  • Autoload: 这个在 Bash 中默认没有启用,必须通过启用 -f 来加载。它将一个名字标记为函数名,而不是内建命令或外部命令的引用。该命名的函数必须存放在外部文件中,并将从那里加载。

所以,我们已经看过前台和后台进程以及作业控制命令;现在,我们可以看到如何使用子 shell 以及它们为脚本带来的好处。

子 shell 与并行处理

我们在本书的前几章已经稍微讨论过子 shell;它们可以被定义为主 shell 的子进程。所以,子 shell 是命令解释器内的命令解释器。什么时候会发生这种情况呢?通常,当我们运行一个脚本时,它会启动自己的 shell 并从那里执行所有列出的命令;但请注意一个细节:外部命令,除非使用 exec 调用,否则会启动一个子进程,但内建命令则不会。这也是为什么内建命令的执行时间比对应的外部命令执行时间要快的原因,正如我们在本书的前面几页所看到的。

好吧,子 shell 有什么用呢?让我们看一个简单的例子,这样一切都会变得更容易理解:

#!/bin/bash
echo "This is the main subshell"
(echo "And this is the second" ; for i in {1..10} ; do echo $i ; 
done)

没什么特别的。我们在脚本生成的第一个子壳程序中进行回显,然后从子壳程序内部打开一个子壳程序,并使用从110的范围回显变量$i:

zarrelli:~$ ./subshell.sh 
This is the main subshell
And this is the second
1
2
3
4
5
6
7
8
9
10

正如我刚才说的,这个脚本没有什么特别的地方,唯一特殊的是我们用(command_1; command_2; command_n)这种方式调用了一个子壳程序。

括号中的内容会在一个新的子壳程序中执行,该子壳程序与父壳程序隔离;因为发生在子壳程序中的任何事情仅对该环境局部有效:

#!/bin/bash
a=10
echo "The value of a in the main subshell is $a"
(echo "The value of a in the child subshell is $a"; echo "...but 
now it changes"...; a=20; echo "and now a is $a")
echo "But coming back to the main subshell, the value of a has not 
been altered here since the subshell variables are local, a: $a"

现在,让我们运行这段代码:

zarrelli:~$ ./local.sh 
The value of a in the main subshell is 10
The value of a in the child subshell is 10
...but now it changes...
and now a is 20
But coming back to the main subshell, the value of a has not been altered here since the subshell variables are local, a: 10

正如我们从这个例子中看到的,这是从父壳程序到子壳程序的单向继承,没有任何东西可以爬回父壳程序。但我们可以在子壳程序内部生成子壳程序,所以可以有一个嵌套结构,这很不错;但是我们可能会失去对当前所处位置的追踪。最好有一个像$BASH_SUBSHELL这样的方便变量可用:

#!/bin/bash
(
echo "Bash nesting level: $BASH_SUBSHELL. Shell PID: $BASHPID"
(
echo "Bash nesting level: $BASH_SUBSHELL. Shell PID: $BASHPID"
(
echo "Bash nesting level: $BASH_SUBSHELL. Shell PID: $BASHPID"
)
)
)

首先,我们以这种复杂的方式编写代码只是为了突出显示壳程序的嵌套结构;在生产脚本中,我们可以使用更简洁的符号表示法。请注意这两个变量:

  • $BASH_SUBSHELL:这个内部变量从 Bash 版本 3 开始可用,表示子壳程序的级别。

  • $BASHPID:表示壳程序实例的进程 ID。

让我们运行这个脚本,看看输出:

zarrelli:~$ ./nesting.sh
Bash nesting level: 1\. Shell PID: 19787
Bash nesting level: 2\. Shell PID: 19788
Bash nesting level: 3\. Shell PID: 19789

好的,我们有了子壳程序级别的清晰输出,显示了每个壳程序实例的 PID,这表明它们实际上是由每个父壳程序生成的不同进程。我们可能会想使用内部的$SHLVL变量来跟踪壳程序级别,但不幸的是,正如下面的示例所示,它不会受到嵌套壳程序的影响:

echo "Bash level: $BASH_SUBSHELL - $SHLVL" ; (echo "Bash level: $BASH_SUBSHELL - $SHLVL"; (echo "Bash level: $BASH_SUBSHELL - 
$SHLVL")) 
Bash level: 0 - 1
Bash level: 1 - 1
Bash level: 2 – 1

很好,但当我们从嵌套的壳程序中退出时会发生什么?是时候来看另一个例子了:

#!/bin/bash
echo "This is the main subshell"
(
echo "This is the second level subshell";
for i in {1..10}; do if (( i==5 )); then exit; else echo $i; fi; 
done
) 
echo "Out of the second level subshell but still kicking inside 
the first level!"
for i in {1..3}
do echo $i
done

在这些代码行中,我们从110生成了一个内部子壳程序并打印到stdout,直到我们到达5:在这种情况下,我们退出子壳程序并返回到第一层。脚本会继续执行并打印剩下的三个数字吗?运行它就能揭示答案:

zarrelli:~$ ./exit.sh 
This is the main subshell
This is the second level subshell
1 2
3
4
Out of the second level subshell but still kicking inside the 
first level! 1
2
3

是的,退出调用仅影响了内部子壳程序,其余部分的脚本仍然在上层继续运行。

好的,我们看到了关于子壳程序的一些有趣的内容,但我们也可以用它们进行并行执行,那该如何实现呢?像往常一样,我们从一个脚本开始:

#!/bin/bash
(while true
do
  :
done)&
(for i in {1..3}
do
  echo "$i"
done)

首先要注意的是&字符,它的作用是将其后面的命令或壳程序放入后台。在这个例子中,第一个子壳程序有一个无限循环,如果我们不将其放到后台,它会阻止第二个子壳程序的生成及其内容执行。但让我们看看当我们将其放到后台时会发生什么:

./parallel.sh 
1
2
3

所以,第二个子壳程序被正确生成了,for循环也执行了,但第一个无限while循环发生了什么?

ps -fj
UID PID PPID PGID SID C STIME TTY TIME CMD
zarrelli 17311 1223 17311 17311 0 09:07 pts/0 00:00:01 
/bin/bash
zarrelli 21843 1 21842 17311 99 10:46 pts/0 00:00:16 
/bin/bash ./parallel.sh
zarrelli 21863 17311 21863 17311 0 10:47 pts/0 00:00:00 ps -fj

好的,它仍然在内存中运行。你可以使用&不仅仅是为子壳程序,还可以用于任何其他命令:

zarrelli:~$ ls &
[1] 22064
zarrelli:~$ exit.sh local.sh nesting.sh parallel.sh sub
shell.sh
[1]+ Done ls --color=auto

你想让你发出的命令在注销系统后仍然运行吗?只需运行以下命令:

nohup command &

它将在后台的子 shell 中运行,nohup 将捕捉到发送给所有子 shell 和进程的SIGHUP信号,该信号会在主 shell 终止时发送。这样,子 shell 和相关的命令将不会受到终止信号的影响,继续执行。

回到子 shell,为什么你要将整个子 shell 送到后台,而不是单独的命令或复合命令?将子 shell 看作容器:将问题分解为更简单的任务,将后者封装在子 shell 中,并让它们在后台执行,这样你就能节省时间,并使它们并行执行。

我们刚才提到 parallel,但实际上 Bash 并没有为多核架构优化命令和脚本的执行。如果我们想要更好的核心利用率,可以安装一个名为parallel的好工具。我们不会深入讨论这个程序,因为它与 Bash 关系不大,但它是一个值得读者探索的好工具,非常适合核心优化。

zarrelli:~$ parallel --number-of-cpus
1
zarrelli:~$ parallel --number-of-cores
4

parallel 的基本语法相当简单:

parallel command ::: argument_1 argument_2 argument_n

它类似于以下例子:

zarrelli:~$ parallel echo ::: 1 2 3
1
2
3

给出更多用:::分隔的参数将导致 parallel 将它们传递给命令,生成所有可能的组合:

zarrelli:~$ parallel echo ::: 1 2 3 ::: A B C
1 A
1 B
1 C
2 A
2 B
2 C
3 A
3 B
3 C

这里执行的作业数量等于可用核心的数量,但我们可以通过-j+n修改该值,将n个作业添加到核心上。使用-j0启动 parallel,它会尽可能执行尽量多的作业:

zarrelli:~$ parallel --eta --joblog sleep echo {} ::: 1 2 3 4 5 
10
Computers / CPU cores / Max jobs to run
1:local / 4 / 4Computer:jobs running/jobs completed/%of started jobs/Average seconds to complete
ETA: 0s Left: 6 AVG: 0.00s local:4/0/100%/0.0s 1
ETA: 0s Left: 5 AVG: 0.00s local:4/1/100%/0.0s 2
ETA: 0s Left: 4 AVG: 0.00s local:4/2/100%/0.0s 3
ETA: 0s Left: 3 AVG: 0.00s local:3/3/100%/0.0s 4
ETA: 0s Left: 2 AVG: 0.00s local:2/4/100%/0.0s 5
ETA: 0s Left: 1 AVG: 0.00s local:1/5/100%/0.0s 10 ETA: 0s Left: 0 AVG: 0.00s local:0/6/100%/0.0s 
zarrelli:~$ parallel -j0 --eta --joblog sleep echo {} ::: 1 2 3 
4 5 10
Computers / CPU cores / Max jobs to run
1:local / 4 / 6
Computer:jobs running/jobs completed/%of started jobs/Average se
conds to complete
ETA: 0s Left: 6 AVG: 0.00s local:6/0/100%/0.0s 1
ETA: 0s Left: 5 AVG: 0.00s local:5/1/100%/0.0s 2
ETA: 0s Left: 4 AVG: 0.00s local:4/2/100%/0.0s 3
ETA: 0s Left: 3 AVG: 0.00s local:3/3/100%/0.0s 4
ETA: 0s Left: 2 AVG: 0.00s local:2/4/100%/0.0s 5
ETA: 0s Left: 1 AVG: 0.00s local:1/5/100%/0.0s 10 ETA: 0s Left: 0 AVG: 0.00s local:0/6/100%/0.0s 

我们可以用 parallel 做什么呢?嗯,有很多复杂的操作,但这些留给读者去尝试和实验这个好用的工具;我相信在玩弄过程中会出现许多新想法。

总结

我们已经窥探了一些 Bash 的内部细节,学习了 pid 文件、会话、作业,且有很多东西可以尝试。我们还介绍了 parallel 和子 shell。这可能是那些需要一些练习的章节之一。花时间实验并尝试各种想法,以便熟练掌握作业控制和后台进程。现在我们已经了解了进程及如何管理它们,接下来我们将学习如何让它们相互通信并交换信息。是时候谈谈进程间通信(IPC)了!

第十章:让我们来制作一个进程图

进程间通信IPC)是一个很好的方式来描述进程如何相互交流、交换数据并据此作出反应。这种“聊天”可以发生在父进程和子进程之间,在同一主机上的进程之间,或不同主机上的程序之间。进程可以通过不同的方式交换数据;例如,当我们通过 SSH 连接到远程服务器时,客户端与远程主机通信并实际交换数据。这与将一个命令的输出通过管道传递给另一个命令的标准输入时是一样的;这些方式,有时是单向的,有时是双向的,是让不同进程进行通信并增强我们在 Bash 环境中执行操作的手段。

完成 IPC 的方式有很多种,其中有些更为熟悉,有些不太常见,但它们在一定程度上都是有效的,我们在本书中已经看到了一些例子。那么,现在我们将继续讲解几页,深入描述进程如何互动,以及如何利用 IPC 来增强我们的脚本,重点介绍我们可以通过 Bash 访问的那些方法,首先从所谓的管道开始。

管道

我们可以将管道描述为一系列由stdoutstdin连接在一起的进程,这样一个进程的输出就成为下一个进程的输入。这是一种简单的进程间通信(IPC)形式,通常称为匿名管道,它是单向的通信方式:前一个进程的标准输出所产生的内容流入下一个进程的标准输入;后一个进程不会向前一个进程返回任何内容。

让我们通过一个例子来进一步澄清匿名管道的概念,从一个简单的ps命令开始:

zarrelli:~$ ps PID TTY TIME CMD 1427 pts/0 00:00:00 bash 12112 pts/0 00:00:00 ps

我们有一个简单的命令列表:PIDTTYCMD。假设我们只想将输出裁剪为PIDCMD。我们可以使用一些ps选项来修改输出,但谁记得这些选项呢?使用一种能够处理文本并提供我们想要结果的工具会更简单,那为什么不使用awk呢?这里的问题是,awk处理它从输入中接收到的文本,例如读取一个文件。但我们可以绕过这一点,通过管道符号|将它的标准输入与ps的标准输出连接起来:

zarrelli:~$ ps | awk '{print $1, $4}' PID CMD 1427 bash 12113 ps 12114 awk.

在这里,awk接受了ps的输出作为输入,并只打印第一个和第四个字段,空格字符作为标准字段分隔符。如前所述,我们可以将多个进程串联起来:

zarrelli:~$ ps | awk '{print $1, $4}' | tail -n +2 | wc -l 5

在这个例子中,我们将前一组命令的输出通过tail命令进行了管道处理,tail实际上移除了第一行(PID CMD)并将其打印到stdout。然后,我们将这个输出通过wc命令的stdin传递,它随后输出了我们从stdin接收到的行数。这是可能的,因为所有进程都在同一环境中运行,右侧管道中的每个命令都在主 shell 的子进程中运行,并共享相同的文件描述符。因此,只需将数据写入父进程的打开描述符;子进程就能按写入的顺序读取数据:借助内核缓冲区来存放等待读取的位。

很方便且实用,但也有一些严重的限制:

  • 进程必须在同一主机上

  • 进程必须在重叠的时间段内活动:前一个进程必须在生成输出时,后一个进程才能读取

  • 通信是单向的:数据沿着链条向下流动,永远不会再爬回梯子上

我们可以通过使用管道来克服一些限制,管道通常被称为 FIFO 管道,因为它们的工作方式。它们依赖于创建一个文件,任何数量的进程都可以访问这个文件,这与匿名管道有着巨大的不同。命名管道的生命周期取决于文件的存在,而匿名管道则依赖于进程的生命周期;命名管道只要文件存在就会持续,直到系统重启或文件被删除。我们可以使用mkfifomknod创建一个文件,并通过 I/O 重定向来读取或写入数据,示例如下:

#!/bin/bash
pipefile="mypipefile"
if [[ ! -p $pipefile ]] 
then
mknod $pipefile p
fi
while true
do
read row <$pipefile
if [[ "$row" == 'exit' ]]
then
echo "I read $row so exiting"
break
fi
echo $row
done

让我们跟随脚本的流程。首先,我们要确保命名管道已正确创建,因此我们测试一个名为-p的特殊文件,它是一个管道。如果它不存在,脚本将通过mknod $pipefile p命令创建它。

命令行末尾加上的p确保创建的是一个管道文件,而不是一个常规文件。然后,我们希望脚本不断从我们打开的文件中读取,所以我们使用一个无限循环:true 始终为真。在这个无限循环中,我们有read row $pipefile指令,它逐行从管道文件中读取内容并将其存储到row变量中。到目前为止,一切正常。如果我们跳到脚本的最后,我们会看到它仅回显我们输入的内容,但中间有一个小的检查;如果我们输入exit,程序将退出。让我们在一个终端中运行我们的新脚本:

zarrelli:~$ ./pipe.sh 

我们将看到我们的提示符在不停闪烁,而不再返回命令行:脚本被困在无限循环中,正在从命名管道读取数据,直到我们在标准输入中输入exit才会终止。现在,使用输出重定向,我们将一些内容发送到管道文件中:

zarrelli:~$ echo “Hello” > mypipefile zarrelli:~$ echo “Another line” > mypipefile zarrelli:~$ echo “It is time to quit” > mypipefile zarrelli:~$ echo exit > mypipefile

让我们看看运行脚本的终端显示了什么:

zarrelli:~$ ./pipe.sh “Hello” “It is time to quit” I read exit so exiting

就这样。脚本回显了我们发送到pipe文件的所有文本;当它遇到quit字符串时,它就退出了,并给出了一个友好的提示信息。这个例子非常简单,可以通过简单的文件进行复制,但如果切换上下文,想象多个进程协调它们的操作,从管道中写入和读取数据、关键字、命令,并按优雅的顺序做事。所有这些都不需要使用中间临时文件,而是通过一个管道,这个管道在系统重启后会消失,且无需所有进程同时运行。进程可以由 cron 任务手动触发,由其他应用程序触发,或在循环中无限运行。无论是哪种方式,都不重要,因为一切都是异步的。我们有一种方式以异步的方式连接进程,并且是双向的,因为每个进程既可以发送数据,也可以接收数据。这意味着,我们指示其他进程并向其提供数据,或者在需要时被指示并获取数据。所有这一切都非常有用,但在 IPC(进程间通信)方面,还有比这更基础的方式,实际上它并不是真正的进程间通信,因为我们将使用重定向到普通文件。

重定向到文件

事实上,重定向一个进程的输出并不意味着 IPC,但它可以以异步的方式用作 IPC:一个进程将它的输出重定向到一个文件,另一个进程稍后从同一个文件中读取;这可以成为两个进程之间交换信息的一种方式:

zarrelli:~$ myfile=”myfile.txt” ; touch "$myfile" ; echo "$myfile" > controller ; while read -r line; do tar cvzf $line.tgz $line ; done < controller myfile.txt

在这个例子中,我们仅仅将一个文件名存储到一个变量中。我们通过touch创建了文件,然后将文件名存储到controller文件中。将文件名存入controller文件后,我们按行读取文件,每一行的内容被存储到line变量中。最后,line变量的内容用于压缩由myfile变量指向的文件:

zarrelli:~$ ls -lah total 20K drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 09:18 . drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 10 09:17 .. -rw-r--r-- 1 zarrelli zarrelli 11 Apr 10 09:18 controller -rw-r--r-- 1 zarrelli zarrelli 0 Apr 10 09:18 myfile.txt -rw-r--r-- 1 zarrelli zarrelli 123 Apr 10 09:18 myfile.txt.tgz prw-r--r-- 1 zarrelli zarrelli 0 Apr 9 13:05 mypipefile -rwxr--r-- 1 zarrelli zarrelli 223 Apr 9 12:44 pipe.sh

在这里,我们已经准备好了所有的文件,而tar仅仅按照echocontroller文件中存储的指令执行。这是一个相当简单的例子,可以根据需要进行扩展。注意,这种方式不需要任何特殊的文件,进程之间可以没有关联,且可以随时执行,无需任何并发性。或许这种 IPC 方式并不令人惊讶,但如果我们足够留心,仍然可以找到其他有趣的方式让进程之间相互通信,比如命令替换

命令替换

我们已经看过了什么是命令替换:

zarrelli:~$ time=$(date +%H:%M) ; echo $time 10:06

一个命令的输出会作为字符串存储到一个变量中,然后可以在任何需要的地方使用它。所以,举个例子,我们可以这么做:

zarrelli:~$ myfile="myfile.txt.tgz" ; content=$(tar -tzf $myfile) ; echo $content myfile.txt

在这个例子中,我们使用了命令替代对一个tar文件进行测试,该文件的名称通过变量提供。命令替代的输出随后作为参数传递给echo命令,echo显示了tar命令的结果。我们也可以在命令替代部分使用更复杂的命令,但要小心转义问题,因为括号内发生的事情并不总是我们预期的。

这是让进程彼此通信的有效方式吗?是的,是的。方便吗?不完全是。在复杂任务中,命令替代可能会变得复杂,而且由于它是单向流,我们面临与其他方法相同的限制。也就是说,它在 Bash 脚本中被广泛使用,用于快速访问命令路径或将某些信息存储在变量中,例如系统上的实际日期或我们需要的任何小信息。我们有其他选项可以将进程的输出传递给另一个进程的标准输入,但有时我们没有花足够的时间去思考所有可用的方式。例如,使用进程替代。

进程替代

进程替代是一种方便的方法,用于将多个命令/进程的输出传递给另一个进程的输入。管理进程替代的标准方式遵循以下语法:

>(list_of_commands) <(list_of_commands)

注意<>和括号之间的空格;中间不能有任何空格:

zarrelli:~$ wc -l <(ps -fj) 5 /dev/fd/63

在此示例中,ps -fj的输出被作为输入传递给了wc -l,后者计算了输出中的5行。注意/dev/fd/63

这是进程替代使用的文件描述符,它将括号内进程的结果传递给另一个进程。因此,/dev/fd中的文件描述符用于传递数据,这在一些无法利用管道的命令中尤其有用,因为这些命令期望从文件中读取数据,而不是从标准输入中接收数据。以下是一个多进程数据传输的经典例子:

zarrelli:~$ mkdir "test 1" zarrelli:~$ mkdir "test 2" zarrelli:~$ for i in {1..5}; do touch "test 1/$i"; done zarrelli:~$ for i in {1..3}; do touch "test 2/$i"; done zarrelli:~$ diff <(ls "test 1") <(ls "test 2") 4,5d3 < 4 < 5

我们刚刚创建了几个测试目录,在第一个目录中,我们创建了5个空文件,在第二个目录中,创建了3个。然后,我们将diff命令与对test 1test 2目录执行ls命令的输出配合使用。该工具随后向我们展示了test 1中存在但test 2中没有的所有文件,正如我们在两个真实目录上执行命令一样。它很方便,但请仔细考虑其作用范围,因为命令替代在函数内部可用,直到该函数返回。说到作用范围,进程替代是避免将命令管道传递给子 Shell 循环时常见陷阱的好方法:

#!/bin/bash
main_variable=10
echo "We are outside the loop and the global variable called main_variable has a value of: $main_variable"
for i in {1..5}
do 
echo "$i" 
done |
while read j
do
main_variable="$j"
echo "We are inside the loop and main_variable has a value of: $main_variable"
done
echo "We are now past the loop and main_variable has a value of: $main_variable"

管道操作实际上是在子进程中执行循环,这会导致循环中的所有变量仅在子进程中可用。main_variable的值将在内部循环中被修改,但一旦我们退出循环,它会恢复为主值,因为在子进程中设置的每个变量值无法传回调用环境:

zarrelli:~$ ./looping.sh We are outside the loop and the global variable called main_variable has a value of: 10 We are inside the loop and main_variable has a value of: 1 We are inside the loop and main_variable has a value of: 2 We are inside the loop and main_variable has a value of: 3 We are inside the loop and main_variable has a value of: 4 We are inside the loop and main_variable has a value of: 5 We are now past the loop and main_variable has a value of: 10

正如我们所见,main_variable在子 Shell 中发生了变化,而管道后的循环被执行;但它不会影响主 Shell。子 Shell 可能非常棘手,因为你可能没有意识到自己在创建它们,因此不清楚最终的结果是什么。即使设置某些环境变量也无法帮助我们避免这个问题。

环境变量

让我们称之为一个概念验证,而不是一种真实的进程间通信方式。谁会真的想要弄乱环境变量呢?无论如何,我们正在探索一些可行的进程间通信方法,所以我们可以考虑它,尽管在初始阶段我们不会使用它。让我们看一下env

env LS_COLORS=REDACTED XDG_MENU_PREFIX=xfce- LANG=en_GB.utf8 DISPLAY=:0.0 XDG_VTNR=7 SSH_AUTH_SOCK=/tmp/ssh-MgHTC62oCYDp/agent.1121 GLADE_CATALOG_PATH=: XDG_SESSION_ID=2 XDG_GREETER_DATA_DIR=/var/lib/lightdm/data/zarrelli USER=zarrelli GLADE_MODULE_PATH=: DESKTOP_SESSION=xfce PWD=/home/zarrelli HOME=/home/zarrelli GUAKE_TAB_UUID=b07321dd-a221-41bd-8ecc-0ae94b9082b9 SSH_AGENT_PID=1159 QT_ACCESSIBILITY=1 XDG_SESSION_TYPE=x11 XDG_DATA_DIRS=/usr/share/xfce4:/usr/local/share/:/usr/share/:/usr/share XDG_SESSION_DESKTOP=xfce GLADE_PIXMAP_PATH=: GTK_MODULES=gail:atk-bridge TERM=xterm SHELL=/bin/bash XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat0 XDG_CURRENT_DESKTOP=XFCE QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 SHLVL=1 XDG_SEAT=seat0 LANGUAGE=en_GB:en GDMSESSION=xfce LOGNAME=zarrelli DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus XDG_RUNTIME_DIR=/run/user/1000 XAUTHORITY=/home/zarrelli/.Xauthority XDG_SESSION_PATH=/org/freedesktop/DisplayManager/Session0 XDG_CONFIG_DIRS=/etc/xdg PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games SESSION_MANAGER=local/moveaway:@/tmp/.ICE-unix/1169,unix/moveaway:/tmp/.ICE-unix/1169 OLDPWD=/home/zarrelli _=/usr/bin/env

我们裁剪了LS_COLORS变量的内容,但即便如此,仍然有一个突出的问题,就是输出有些杂乱,包含了很多信息,其中大部分对于我们的登录会话至关重要。所以,首先的建议是, tinkering 时一定要对environment变量保持谨慎。

我们必须记住的一点是,shell变量和environment变量之间有很大的区别;让我们来看一个例子:

#!/bin/bash a=10 b=20 echo -e "n" echo "This is the value of a in the main subshell: $a" (a=$((a+b)) ; echo "Inside the nested subshell a now has the value of: $a") echo "Back to the main subshell a has a value of: $a" echo -e "n" echo "And now we will tinker with the environment..." echo "This is the value of a in the main subshell: $a" ( export a=$((a+b)) b=$((a+b)) echo "Inside the nested subshell a now has the value of: $a" echo "Inside the nested subshell b now has the value of: $b" echo "The value of the environment variable a is:" env | grep ^a echo "Here is the value of a at this level of subshell using process substitution:" grep ^a <(env) echo "And they are the same, since the nested shell share the environment variables of the parent shell" echo "b is inherited as well: $b" ) echo "Back to the main subshell the environment variable a has a value of: $a" echo "Back to the main subshell the shell variable b has a value of: $b"

执行它后,我们将得到如下结果:

zarrelli:~$ ./environment.sh 
This is the value of a in the main subshell: 10
Inside the nested subshell a now has the value of: 30
Back to the main subshell a has a value of: 10

现在我们将尝试操作环境:

This is the value of a in the main subshell: 10 Inside the nested subshell a now has the value of: 30 Inside the nested subshell b now has the value of: 50 The value of the environment variable a is: a=30 Here is the value of a at this level of subshell using process substitution: a=30

它们是相同的,因为嵌套的 Shell 共享了父 Shell 的环境变量:

b is inherited as well: 50 Back to the main subshell the environment variable a has a value of: 10 Back to the main subshell the shell variable b has a value of: 20

从这个例子中我们看到的是有关变量正常使用的内容。Shell 变量和环境变量之间没有真正的区别:两者都可以被子进程/子 Shell 访问,并且都不会受到子 Shell 操作的影响。真正的区别出现在当我们有一个通过execve()系统调用执行的子进程时:在这种情况下,Shell 变量不会传递给子进程。我们需要将其导出,才能使其在子 Shell 中可用。如果我们想玩得更开心,还有比这更复杂的事情。Bash 4.0 引入了一个新关键词,它可以为我们的实验提供一个有趣的“游乐场”。

协处理进程

Bash 4.0 引入的coproc关键词允许用户在异步子 Shell 中后台运行一个进程。在进程执行期间,调用 Shell 和协处理进程之间会建立一个管道。最佳结果适用于可以在 CLI 中运行的程序,这些程序能够从stdin读取并写入到stdout,最好是使用无缓冲流。coprocess的语法如下:

coproc (NAME) command (redirections)

括号内的内容是可选的,但如果指定了一个名称,coproc将创建一个带有该名称的协程。如果没有给出名称,默认将使用COPROC;如果以下是一个简单的命令,我们不能定义任何名称,否则它会被当作命令的第一个单词来处理。执行协程的 Shell 的进程 ID 存储在名为NAME_PID的变量中:

Let's see an example: zarrelli:~$ coproc { while true ; do ls ; done } [2] 31067

我们执行了一个无限循环,它的PID显示为31067;我们来检查是否能从COPROC_PID中读取它,这是当没有提供名称时变量的默认名称:

zarrelli:~$ echo $COPROC_PID 31067

在这里,我们可以轻松地从COPROC_PID变量获取PID值。当执行协程时,Shell 会实例化一个名为NAME的数组变量,存储两部分信息:

  • NAME[0]:这是协程的输出文件描述符

  • NAME[1]:这是协程的输入文件描述符

因此,我们可以使用文件描述符进行读写,在我们的示例中它们如下:

zarrelli:~$ echo ${COPROC[0]} 62 zarrelli:~$ echo ${COPROC[1]} 58

另一种查看当前进程打开了哪些文件的方法是:

ls -lah /proc/PID/fd

在我们的例子中,做如下操作:

ls -la /proc/31067/fd total 0 dr-x------ 2 zarrelli zarrelli 0 Apr 11 16:37 . dr-xr-xr-x 9 zarrelli zarrelli 0 Apr 11 15:13 .. lr-x------ 1 zarrelli zarrelli 64 Apr 11 16:37 0 -> pipe:[615372] l-wx------ 1 zarrelli zarrelli 64 Apr 11 16:37 1 -> pipe:[615371] lrwx------ 1 zarrelli zarrelli 64 Apr 11 16:37 2 -> /dev/pts/0 lrwx------ 1 zarrelli zarrelli 64 Apr 11 16:37 255 -> /dev/pts/0 l-wx------ 1 zarrelli zarrelli 64 Apr 11 16:37 60 -> pipe:[608200] lr-x------ 1 zarrelli zarrelli 64 Apr 11 16:37 63 -> pipe:[608199]

这些管道是在用户在命令行上指定任何重定向之前就已经建立的,所以文件描述符可以作为命令行上发出的命令的参数使用,重定向可以用来传递或获取数据;但需要注意的是,文件描述符不会被子 Shell 继承。也就是说,我们可以通过以下语法向协程传递数据:

echo data >&"${COPROC[1]}"

我们可以像下面这样使用read从协程中获取数据:

read variable <&"${COPROC[0]}"

在查看一个简单的例子之前,我们必须记住几个要点:

  • 在 Linux 中,大多数命令在没有用户交互的情况下是被缓冲的。这让我们在从coproc文件描述符读取时感到困惑。为了进行一些简单的实验,bc工具很好用;或者使用带有fflush()awk,或使用expect包中的unbuffer命令来获得无缓冲的输出。

  • 同一时间只能有一个活动的协程。

  • 我们可以使用wait内建命令来等待协程终止。

也就是说,接下来我们来看看如何与后台进程交互:

#!/bin/bash coproc bc_calc { bc; } in=${bc_calc[1]} out=${bc_calc[0]} echo '10*20' >&$in read -u $out myvar echo $myvar

为了让coproc接受我们创建的名称,我们创建了一个只包含一个命令并后跟;的列表,然后通过将文件描述符存储在两个有意义的变量中,我们简化了与文件描述符的操作。所以,我们不再处理01。在下一步中,我们将bc的乘法运算通过其文件描述符回显到bcstdin,并使用-u选项读取它,-u选项正是读取文件描述符所需的选项。最后一步,我们打印了先前存储了乘法结果的输出变量,这个结果是由bc打印在其标准输出上的。

其实还有最后一种方式可以让不同的进程相互通信;我们能回想起来吗?是的,我们在本书一开始就已经看到了它。

/dev/tcp 和 /dev/udp

如果我们查看 /dev 目录,我们会发现许多文件代表物理设备,这些设备可能是硬件设备,也可能不是。这些设备文件可以代表分区;回环设备用于将普通文件当作块设备来访问。例如,ISO 文件可以像 CD-ROM 一样被挂载。有些设备文件非常不常见,但我们已经听说过它们,例如 /dev/null/dev/zero/dev/urandom/dev/tcp/dev/tcp

这些被称为伪设备,它们代表并提供对一些设施的访问。例如,所有这些都被移到或重定向到 /dev/null,它掉入一个黑洞并消失,而 /dev/urandom 是一个在需要时获取随机字符串的好方法:

cat /dev/urandom | head -c 25 | base64 HwUmcXt0zr6a7puLtO1xyKMrAdZrRqIrgw==

通过 /dev/tcp/dev/udp,我们可以通过一个套接字与本地或远程的网络服务进行通信。对于我们的示例,我们将专注于 TCP 套接字,因为它们更适合我们的实验。

那么,什么是套接字呢?想象一个套接字是连接两栋多层建筑之间的一根管道。要将这根管道安装到位,你必须知道每栋建筑的门牌号,以及必须将哪个楼层与另一栋楼的哪个楼层连接。网络套接字也是如此,它由两个元组标识:

origin_ip:origin_port destination_ip:destination_port

因此,Bash 只要我们提供至少远程端的通信通道、IP 或主机名,以及要连接的端口,就能建立与网络服务的连接。这很简单,不是吗?但是怎么做呢?正确的语法如下:

exec file_descriptor_number <> /dev/tcp/ip/port

< 表示打开用于读取的套接字,> 表示用于写入,<> 表示同时用于读取和写入。IP 或主机名之间没有太大区别,但我们必须注意将使用哪些文件描述符。我们有 10 个文件描述符可用,从 0 到 9,但由于 0 = stdin1 = stdout2 = stderr 已经被绑定,我们不能使用它们。所以,剩下的文件描述符是 3 到 9。现在,让我们尝试一个简单的例子:

cat </dev/tcp/time.ien.it/13 11 APR 2017 22:00:46 CEST

我们刚刚通过互联网读取了时间,连接到一个意大利的时间服务器,方式非常简单,但我们还能做更复杂的事情;让我们回顾一下第一章中做过的一个例子。

让我们以读/写模式打开一个套接字连接到一个 web 服务器,并将文件描述符指定为 9

zarrelli:~$ exec 9<> /dev/tcp/172.16.210.128/80 || exit 1

然后,我们将使用 HTTP/1.1 语法向它发送请求,就像我们是一个真实的 web 浏览器一样:

zarrelli:~$ printf 'GET /index2.html HTTP/1.1nHost: 172.16.210.128nConnection: closenn' >&9

我们刚刚请求了一个为这个示例创建的简单 HTML 文件;因为我们请求了它,所以现在是通过文件描述符 9 来读取该页面的时候了:

zarrelli:~$ cat <&9 HTTP/1.1 200 OK Date: Sat, 21 Jan 2017 17:57:33 GMT Server: Apache/2.4.10 (Debian) Last-Modified: Sat, 21 Jan 2017 17:57:12 GMT ETag: "f3-5469e7ef9e35f" Accept-Ranges: bytes Content-Length: 243 Vary: Accept-Encoding Connection: close Content-Type: text/html <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> <HTML> <HEAD> <TITLE>This is a test file</TITLE> </HEAD> <BODY> <P>And we grabbed it through our descriptor! </BODY> </HTML>

在这里,我们可以像操作本地文件一样与远程服务器互动,使用 printcat 来推送和拉取内容。完全是本地操作,但实际上是远程操作。

确实,还有一种方式可以在想要搞弄 IPC 时玩得很开心;尽管这不是一种正式的 IPC 手段,但它太有趣了,以至于我们不能不提到 Netcat。

Netcat

我们甚至可能从未使用过这个工具,但没人能说他们从没听过它被称为 TCP/IP 瑞士军刀,这是因为它的多功能性。你可以花几个小时仅仅探索它所支持的所有可能性。话说回来,netcat 是一个使用 TCP 或 UDP 协议在网络上读取和写入数据的工具;它真正方便的地方在于,它能够保持连接直到远程端断开。这使得它不同于大多数应用程序,它们在最后一段数据传输完毕后就停止工作。netcat 不一样;它能保持通信通道两端的连接,即使没有数据传输,因此你可以用它来进行重复的数据发送。

Netcat 可以在服务器模式或客户端模式下使用,也可以通过在脚本中添加网络功能来使用。所以有很多功能,理解它的最佳方式是运行一些示例。因此,第一步是在终端中打开与远程服务器的连接,终端可以被分为两个面板,如terminator。最后一点,记住 Netcat 默认无法安装在我们的系统上,但各个发行版有相关的安装包。因此,一旦工具安装完成,打开两个 xterm,或将 terminator 分成两个面板并连接到远程服务器。在远程服务器上,检查一个开放的端口,通常是 80008080 是一个常用于代理的端口)或者 9000,这类端口通常比较合适;可以通过执行以下命令检查我们想要的端口是否可用:

root:# netstat -tapnl | grep 9000

Netstat 会列出所有处于监听模式的 TCP 端口,显示端口的数字编号;然后我们使用 grep 来查找我们要检查的端口。如果返回为空,说明该端口是空闲的。第二步,通常被遗忘的是,我们需要确保该端口没有被本地防火墙阻塞。这是因为我们正在掌握 Bash 并且已经在本地桌面上部署了防火墙。我们稍后会讨论如何保护我们的计算机,但现在假设我们已经有了一个简单但可靠的防火墙,比如 ufw。要启用 900 端口,我们只需要执行以下命令:

root:# ufw allow 9000/tcp Rule added Rule added (v6)

规则已添加,它是我们链中的第六条,但它可以是任何其他编号,取决于你有多少条其他规则。记住,要删除规则,只需运行以下命令:

root:# ufw delete 6 Deleting: allow 9000/tcp Proceed with operation (y|n)? y Rule deleted

我们使用了规则编号,你也可以根据需要使用规则名称 allow 9000/tcp。现在,我们已经解除了端口的阻塞,可以在远程服务器上以监听模式运行 netcat

root:# netcat -lvvp 9000 listening on [any] 9000 ...

这将以监听模式启动 netcat,并且 -l 会准备好接受 -p 9000 端口的连接,以详细模式 -vv 启动。我们将无法返回提示符,因为 netcat 会在前台运行,独占终端。现在,在本地系统上,让我们以客户端模式运行 netcat

zarrelli:~$ nc -vv 192.168.0.10 9000 spoton [192.168.0.10] 9000 (?) open

太好了,连接在两端都已打开。请注意,我们使用了netcat命令和nc两种方式来调用 Netcat;我们可以选择任何一种。连接建立后,我们将在监听端看到如下信息:

192.168.0.5: inverse host lookup failed: Unknown host connect to [192.168.0.10] from (UNKNOWN) [192.168.0.5] 60054

被称为Unknown host的消息不应打扰我们;Netcat 会进行反向查找,检查连接来源的主机名。由于这是一个测试环境,我们没有设置任何内部 DNS 解析。如果你不想动 DNS,可以通过在服务器端操作系统上打开/etc/hosts文件,并添加一行,如192.168.0.5 spoton,来解决问题。

192.168.0.t 是客户端的 IP 地址,连接来自该地址,而spoton是我们希望服务器端通过netcat识别和解析的主机名。如果你不想使用 DNS 解析,可以在服务器和客户端上都加上-n选项,这样你将只使用 IP 地址。那么现在,让我们重新尝试连接:

root:# netcat -lvvp 9000 listening on [any] 9000 ... connect to [192.168.0.10] from spoton [192.168.0.5] 60176

看起来更好了,是吗?我们只需在客户端输入一些内容,无论输入什么都会在服务器端回显。我们可以暂停并稍后输入;与此同时,通道将保持开启并等待我们的输入:

客户端在左侧面板上,右侧面板上将其回显到服务器端。现在有些神秘的东西;在服务器端,输入这个:

root:# netcat -lvvp 9000 -c /bin/date listening on [any] 9000 ...

我们刚才所做的是使用-c开关告诉netcat在连接建立后立即执行作为参数指定的命令。该命令将传递给/bin/sh -c进行执行,不做任何进一步检查,如果你没有安装sh shell,只需使用-e来执行命令。现在,在客户端,执行如下操作:

zarrelli:~$ nc -vv 192.168.0.10 9000 spoton [192.168.0.10] 9000 (?) open Wed 12 Apr 11:14:07 BST 2017 sent 0, rcvd 29

连接在执行date命令后立即建立;所以,在我们的客户端,我们可以看到命令的输出以及服务器端的日期和时间。这看起来不那么神秘,对吧?这只是date,能有什么害处呢?好吧,我们来修改服务器的参数,准备好吓一跳吧:

root:# netcat -lvvp 9000 -c /bin/bash listening on [any] 9000 ...

有趣的是,在每次连接时,我们都会执行一个 Bash shell。让我们在客户端打开它:

zarrelli:~$ nc -vv 192.168.0.10 9000 spoton [192.168.0.10] 9000 (?) open

现在,让我们在客户端输入一些无害的命令:

date Wed 12 Apr 11:21:02 BST 2017

好的,这是一个日期,但我们在哪里?

pwd /root

很好,但我们到底是谁?

whoami root

所以,我们是root,但我们站在哪一边?

hostname -I | awk '{print $1}' 192.168.0.10

哎呀,我们在服务器上是root,并且我们以超级用户身份发出了命令,不需要任何身份验证。由于这非常危险,请务必小心使用此选项。或许只是为了娱乐和测试,除此之外没有别的,我们这样做只是为了突出它的潜力和风险。

我们还能做什么?让我们在客户端创建一个文件:

zarrelli:~$ echo “Here I am, a test file” > testfile.txt

在服务器端,我们开始netcat,这次不使用详细模式,因为如果你不想调试连接,且希望将输出重定向到testfile.txt,就不需要详细模式:

zarrelli:~$ netcat -lp 9000 > testfile.txt

我们使用了没有特权的用户。如果我们不想绑定所谓的保留或系统端口,就不需要使用root;低于1024的端口用于提供服务,例如22用于 SSH,80用于 HTTP 等。

现在,回到客户端,让我们把testfile的内容输入给客户端:

zarrelli:~$ cat testfile.txt | nc -w2 192.168.0.10 9000

我们为连接添加了 2 秒的超时,这样一旦文件内容传输完毕,它将在 3 秒后关闭连接。服务器端看到的内容是:

connect to [192.168.0.10] from spoton [192.168.0.5] 32912 sent 0, rcvd 29

我们完成了,只需按Ctrl + C中断 Netcat,并检查filetest.txt的内容:

zarrelli:~$ cat testfile.txt “Here I am, a test file”

就这样,文件的内容已经传输到服务器并保存在testfile.txt中。但是如果我们想传输整个目录或一堆文件呢?我们不能采用相同的策略,因为所有输出都会被重定向到一个文件,这样是行不通的。那么,在服务器端,首先让我们创建一个测试用的dir

zarrelli:~$ mkdir test zarrelli:~$ cd test

现在,让我们运行 Netcat:

zarrelli:~$ nc -lvvp 9000 | tar -xpzf -

这将对输入运行tar,命令如下:

  • x:从输入的归档中提取文件。

  • p:它保持文件的权限。

  • z:它通过gzp过滤接收到的归档,实质上是解压它。

  • f:工作文件归档。在我们的例子中,一切都被发送到stdout

  • -:最后一个破折号意味着它将处理来自stdin的数据,而不是在文件系统中查找文件。

在客户端,让我们进入一个包含我们想要传输的文件和子目录的目录,并查看它们的属性:

zarrelli:~$ ls -lah total 44K drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 12 11:54 . drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 12 12:56 .. -rw-r--r-- 1 zarrelli zarrelli 11 Apr 10 09:20 controller -rwxr--r-- 1 zarrelli zarrelli 121 Apr 11 18:30 coproc.sh -rwxr--r-- 1 zarrelli zarrelli 961 Apr 11 12:19 environment.sh -rwxr--r-- 1 zarrelli zarrelli 382 Apr 11 10:08 looping.sh -rw-r--r-- 1 zarrelli zarrelli 0 Apr 10 09:20 myfile.txt -rw-r--r-- 1 zarrelli zarrelli 122 Apr 10 09:20 myfile.txt.tgz prw-r--r-- 1 zarrelli zarrelli 0 Apr 9 13:05 mypipefile -rwxr--r-- 1 zarrelli zarrelli 223 Apr 9 12:44 pipe.sh drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 1 drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 2 -rw-r--r-- 1 zarrelli zarrelli 29 Apr 12 12:06 testfile.txt

现在,在客户端,运行 Netcat:

tar czf - * | nc -vw2 192.168.0.10 9000

tar命令执行时,使用以下参数:

  • c:创建一个归档。

  • z:通过gzip进行压缩。

  • f:工作文件归档。在我们的例子中,它将从输入中获取文件名。

  • -:最后一个破折号意味着它将处理来自stdin的数据,而不是在文件系统中查找文件。

我们把所有可见的文件和目录作为输入提供给tar;如果我们只想复制其中一些文件,也可以使用单个或多个文件和目录名。一旦命令执行,我们应该会在服务器端看到类似的输出:

connect to [192.168.0.10] from spoton [192.168.0.5] 33022 sent 0, rcvd 1352

看起来文件和目录已经传输完毕。让我们退出 Netcat 并检查一下:

zarrelli:~$ ls -lah total 44K drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 12 12:35 . drwxr-xr-x 70 zarrelli zarrelli 4.0K Apr 12 12:33 .. -rw-r--r-- 1 zarrelli zarrelli 11 Apr 10 09:20 controller -rwxr--r-- 1 zarrelli zarrelli 121 Apr 11 18:30 coproc.sh -rwxr--r-- 1 zarrelli zarrelli 961 Apr 11 12:19 environment.sh -rwxr--r-- 1 zarrelli zarrelli 382 Apr 11 10:08 looping.sh -rw-r--r-- 1 zarrelli zarrelli 0 Apr 10 09:20 myfile.txt -rw-r--r-- 1 zarrelli zarrelli 122 Apr 10 09:20 myfile.txt.tgz prw-r--r-- 1 zarrelli zarrelli 0 Apr 9 13:05 mypipefile -rwxr--r-- 1 zarrelli zarrelli 223 Apr 9 12:44 pipe.sh drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 1 drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 2 -rw-r--r-- 1 zarrelli zarrelli 29 Apr 12 12:06 testfile.txt

就这样,所有文件和目录都已复制,并且权限已被保留。Netcat 有许多功能,可能值得写一本专门的书;我们现在只是略微触及其表面,享受这个过程:

zarrelli:~$ netcat -z -w 1 -vv 192.168.0.10 22 spoton [192.168.0.10] 22 (ssh) open sent 0, rcvd 0

我们可以通过-z将其用作简单的端口扫描器,这将阻止从远程服务器接收任何数据,并且通过-w 1在远程端口没有回复时超时连接。你还可以添加-n来防止 DNS 解析,并通过端口号或名称指定端口,或者指定一个端口范围进行检查,格式为lower_port_number:higher_port_number

zarrelli:~$ netcat -z -w 1 -vv 192.168.0.10 https spoton [192.168.0.10] 443 (https) : Connection timed out sent 0, rcvd 0

如果我们不知道或记不得与主要服务相关的端口,可以从/etc/services文件中检索端口列表。

好吧,我们需要代理吗?

zarrelli:~$ ncat -l 9999 -c 'nc 192.168.0.10 80'

在这种情况下,我们使用了ncat,它是一个类似于 Netcat 的工具,但功能更强大,能够克服 Netcat 的限制。它有一个单向管道,因此在代理时我们可以发送数据,但不能从服务器读取数据。一旦ncat执行了 Netcat 并管理了数据流,我们就可以连接到本地主机的9999端口,并请求一个页面:

telnet localhost 9999 Trying ::1... Connected to localhost. Escape character is '^]'. GET / HTTP/1.1 Host: 192.168.0.10 Connection: close HTTP/1.1 200 OK Date: Wed, 12 Apr 2017 12:41:02 GMT Server: Apache/2.4.10 (Debian) Last-Modified: Thu, 01 Dec 2016 18:52:27 GMT ETag: "29cd-5429d52a85057" Accept-Ranges: bytes Content-Length: 10701 Vary: Accept-Encoding Connection: close Content-Type: text/html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html >

我们只是截取了输出的一部分,包含了整个页面,并突出显示了我们给出的命令来获取页面。无论如何,除了使用ncat,我们还可以使用带命名管道的 Netcat:

zarrelli:~$ mkfifo mypipe

然后,简单地使用输出重定向来建立一个双向通道:

zarrelli:~$ nc -l 9999 0<mypipe | nc 192.168.0.10 80 1>mypipe

现在,使用 telnet 连接到本地主机的9999端口,并执行与另一个示例中相同的 get 请求,或者更好的是,启动你的互联网浏览器并将其指向http://localhost:9999

在离开本章之前,还有一件事,你是否曾经想过拥有一个简单、快速的服务器随时可用?让我们开始创建一个简单的 HTML 页面:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My Netcat Test Page</title>
<meta name="description" content="Test page for Netcat HTTP server">
<meta name="author" content="Giorgio Zarrelli">
<link rel="stylesheet" href="css/styles.css?v=1.0">
</head>
<body>
Hello I am a test page for Netcat used as HTTP server
</body>
</html>

现在,一个无限循环将帮助 Netcat 为我们提供页面:

while true; do nc -lp 9999 < my_index.html; done

最后一步,让我们在我们喜欢的互联网浏览器中打开http://localhost:9999

该页面将被提供,因为它是由 Web 服务器推送的,正如我们从以下截图中看到的:

我们刚创建的 HTML 页面正在作为一个真实的 Web 服务器推送的页面一样提供。

摘要

让服务相互通信并不是那么难,甚至可以非常有趣,尤其是当我们知道如何重定向流并使用正确的工具增添一些趣味时。我们是否已经看到了 Bash 在 IPC 方面的所有功能?没有,而这正是 Shell 的魅力之一:我们可以以不同的方式完成同一任务,且有太多的事情可以做,一章根本不够。再一次,这就是 Shell 的最佳之处:我们从一些示例开始,学习如何使用命令和工具,然后通过尝试新实验、调整选项和参数,扩展我们的知识,掌握我们拥有的工具。

我们已经将 IPC 推到极限,以至于 Netcat 变成了一个简单的 Web 服务器,通过网络推送页面,以便我们能够在浏览器中显示它。这是非常令人惊讶的,它实际上是 Bash 所能做到的极限,但我们将在下一章尝试突破这些界限,看看如何使用 Bash 创建简单的守护进程并提供服务。

第十一章:作为守护进程运行

在我们翻阅本书的过程中,我们看到了很多有趣的东西,玩弄了进程,发送了信号,将任务放到后台,并编写了复杂的脚本。到目前为止所做的一切都有一个目标:让我们从 Bash 中获得最佳的使用效果,让它为我们处理重复任务,并使用内建命令、循环和外部命令来简化我们作为高级用户的日常生活。然而,有时我们需要让我们的脚本长期运行,可能是无限期地活跃,所以下面就需要使用守护进程,而不仅仅是作为普通程序运行。我们必须踏上成为守护进程的那条模糊之路。

什么是守护进程?

那么,守护进程与普通程序有什么不同呢?我们通常想用守护进程来获得以下一些功能:

  • 永久运行

  • 提供服务

  • 即使调用会话结束,仍然可以存活

  • 不会锁定终端

  • 不会锁定任何子目录

这就是我们所知的守护进程的基本功能。想象一下 SSHD 守护进程、FTPD 或 Apache:

  • 在后台运行

  • 提供你与之交互的服务,通过一个套接字

  • 可以启动或停止,但无法从命令行进行进一步的直接交互

  • 在你登录时可用,并且在你注销时依然存在

  • 它们在后台运行

你实际上根本不知道它们是如何做到这一切的。

示例

那么,我们如何将一个脚本转变为守护进程呢?一个初步尝试可能是使用 &。尾随的 & 是 Bash 内建命令,它指示 shell 在子 shell 中后台运行命令。一旦命令执行,shell 不会等待它完成,而是返回代码 0(表示成功),并继续执行其他命令:

zarrelli:~$ ls -lah & ps -jf [1] 13704 total 48K drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 12 14:12 . drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 12 19:37 .. -rw-r--r-- 1 zarrelli zarrelli 11 Apr 10 09:20 controller -rwxr--r-- 1 zarrelli zarrelli 121 Apr 11 18:30 coproc.sh -rwxr--r-- 1 zarrelli zarrelli 961 Apr 11 12:19 environment.sh -rwxr--r-- 1 zarrelli zarrelli 382 Apr 11 10:08 looping.sh -rw-r--r-- 1 zarrelli zarrelli 0 Apr 10 09:20 myfile.txt -rw-r--r-- 1 zarrelli zarrelli 122 Apr 10 09:20 myfile.txt.tgz -rw-r--r-- 1 zarrelli zarrelli 367 Apr 12 14:12 my_index.html prw-r--r-- 1 zarrelli zarrelli 0 Apr 9 13:05 mypipefile -rwxr--r-- 1 zarrelli zarrelli 223 Apr 9 12:44 pipe.sh drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 1 drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 10 12:20 test 2 -rw-r--r-- 1 zarrelli zarrelli 29 Apr 12 12:06 testfile.txt UID PID PPID PGID SID C STIME TTY TIME CMD zarrelli 1385 1272 1385 1385 0 08:14 pts/0 00:00:00 /bin/bash zarrelli 13705 1385 13705 1385 0 10:55 pts/0 00:00:00 ps -jf [1]+ Done ls --color=auto -lah 

我们在示例中看到的是,shell 执行了第一个 ls 命令并返回了这个:

[1] The job number 13704 The process ID

但是,它并没有等待 ls 进程完成工作;它只是将其分叉到一个子 shell 中,然后继续执行 ps 命令。为了进行我们的实验,让我们创建一个空的 shell 和一个具有无限循环的脚本,这个脚本实际上什么也不做:

#!/bin/bash
while true
do
:
done

没什么特别的,唯一有趣的是,一旦启动,脚本将一直执行,直到我们停止它。现在,让我们运行它:

zarrelli:~$ ./while.sh 

好吧,我们说它什么都不做,但实际上它在做一些事情:它占据了你的终端,直到终止或被送入后台,它才会把终端交还给你。所以,我们有两个选择:

Ctrl + C 发送 SIGKILL 信号到进程并终止它:

zarrelli:~$ ./while.sh ^C

Ctrl + Z 发送 SIGTSTP,它会暂停执行:

zarrelli:~$ ./while.sh ^Z [1]+ Stopped ./while.sh

一旦被暂停,我们可以使用其作业 ID 将任务放入后台,在我们的例子中是 [1]

zarrelli:~$ bg %1 [1]+ ./while.sh &

如果现在检查作业的状态,它将是这样的:

zarrelli:~$ jobs [1]+ Running ./while.sh &

我们可以看到,脚本不再停止,而是实际上在后台运行。此时,你可能已经忘记了运行脚本的子壳的 PID,或者你根本不知道有一种快速的方法可以召回它,因为它保存在$!变量中:

zarrelli:~$ echo $! 18672

现在,让我们将这个过程带到前台:

zarrelli:~$ fg %1 ./while.sh

杀死它,因为它再次占用了终端:

zarrelli:~$ ./while.sh ^C

如果我们直接在后台使用&运行多个脚本实例,会发生什么情况呢?

zarrelli:~$ ./while.sh & ./while.sh & ./while.sh & [1] 20167 [2] 20168 [3] 20169

所以,它们都在后台运行,并且拥有各自的作业 ID:

zarrelli:~$ jobs [1] Running ./while.sh & [2]- Running ./while.sh & [3]+ Running ./while.sh &

jobs的输出中有一些新内容,那些是靠近作业 ID 的+字符:

  • +:这标识了fgbg默认会操作的作业

  • -:这标识了如果当前默认作业退出,将成为默认作业的作业

让我们进行一个测试。首先,检查作业的状态:

zarrelli:~$ jobs [1] Running ./while.sh & [2]- Running ./while.sh & [3]+ Running ./while.sh &

它们都在后台运行。让我们将默认作业召回前台:

zarrelli:~$ fg ./while.sh

现在,让我们用Ctrl+Z暂停它:

./while.sh ^Z [3]+ Stopped ./while.sh

所以,我们只是给出了没有参数的fg命令;如预期的那样,ID 为3且带有+字符的作业被拉回了前台。现在,让我们检查作业的状态:

zarrelli:~$ jobs [1] Running ./while.sh & [2]- Running ./while.sh & [3]+ Stopped ./while.sh

第三个作业被停止,但我们可以看到+字符。让我们再次将默认作业召回前台,然后停止它:

zarrelli:~$ fg ./while.sh ^Z [3]+ Stopped ./while.sh

再次,第三个作业是默认的,因为它从未终止,它只是被挂起了。所以,现在是时候优雅地终止它了:

zarrelli:~$ kill -15 %3 [3]+ Terminated ./while.sh

让我们看看在杀死默认作业之后,作业的状态:

zarrelli:~$ jobs [1]- Running ./while.sh & [2]+ Running ./while.sh &

就这样,作业 ID 为2的作业现在是默认的,而编号为1的作业排在第二位。

nohup

nohup是一个可移植操作系统接口POSIX)命令,防止作为参数传递的进程接收到挂起(HUP)信号。如果我们在脚本前加上nohup运行,它将被保护,不受交互式会话关闭时发送给所有进程的 HUP 信号影响。如果标准输出是终端,nohup会将其附加到本地目录中的nohup.out文件中,如果无法在用户的主目录中写入,它会将标准错误重定向到stdout。所以下面是这样的情况:

zarrelli:~$ nohup ./while.sh & [1] 14247
nohup: ignoring input and appending output to 'nohup.out'

脚本在后台运行,正如jobs命令正确报告的那样:

zarrelli:~$ jobs [1]+ Running nohup ./while.sh &

所以,脚本已经被分离,stdout被重定向到nohup.out文件,而stdin被忽略:

zarrelli:~$ ls -lah total 12K drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 13 14:35 . drwxr-xr-x 4 zarrelli zarrelli 4.0K Apr 13 14:32 .. -rw------- 1 zarrelli zarrelli 0 Apr 13 14:35 nohup.out -rwxr--r-- 1 zarrelli zarrelli 35 Apr 13 11:06 while.sh

现在,让我们使用exit退出我们的交互式会话,并重新创建一个新的会话。我们只需打开一个新终端并使用jobs命令:

zarrelli:~$ jobs

没有任何内容,没有作业被列出。为什么?进程还在吗?我们来看看:

zarrelli:~$ ps ax | grep while 14247 ? R 8:49 /bin/bash ./while.sh 14839 pts/0 S+ 0:00 grep while

脚本仍在运行,PID没有变化,那么为什么我们在作业列表中看不到它?因为我们关闭了旧的 shell 并打开了新的 shell;因此,旧的作业列表与旧的 shell 相关联,已经被销毁。这是可取的,因为没有作业 ID,shell 就无法直接控制进程或与之干扰。然后,看看进程列表中的第二个字段:

14247 ? R 8:49 /bin/bash ./while.sh 14839 pts/0 S+ 0:00 grep while

虽然 grep 关联了一个终端 pts/0,但是 while 脚本没有任何关联的终端,因此我们看到了 ?,这正是我们一开始想要的。在继续之前,让我们清理一下,杀掉脚本:

zarrelli:~$ kill 14247

很好,一切都清晰、简单、易懂,是吧?不对。有时我们只是通过 SSH 在远程服务器上启动一个应用程序。我们使用 nohup& 完全从终端中分离,并将它从会话关闭时的 HUP 信号中保护起来;然后当我们尝试注销时,我们的连接就会无限期地挂起。发生了什么?为什么一切看起来都挂起了?这个行为是由于处理 SSH 连接的 OpenSSH 服务器所致:在关闭连接之前,OpenSSH 会等待读取连接到用户运行的进程的 stdoutstderr 管道的 文件结束符 (eof)。这里的问题与 Unix 中文件如何返回 eof 有关,只有在所有引用都被关闭时它才会返回 eof。但当你在通过 SSH 连接的 shell 后台运行一个进程时,该进程会得到与其运行的 shell 的 stdoutstderr 的标准引用。当你关闭 shell 时,OpenSSH 服务器会失去这些引用,因为 shell 已经死掉,因此它永远看不到来自这些引用的 eof 信号。所以,它会使连接无限期挂起。那么,如何防止这种情况呢?其实,解决方法是手动在退出前关闭该进程,或者在启动进程时重定向标准流(stdinstdoutstderr)的引用。

nohup command > foo.out 2> foo.err < /dev/null &

不幸的是,即使重定向有时也不起作用,因为 OpenSSH 对一堆原因和情况非常敏感,不会向进程发送任何 HUP 信号。

disown

如果我们启动一个进程,然后想要在交互式 shell 关闭后仍然保持它的运行状态,该怎么办?让我们回顾一下 shell 退出时会发生什么:在退出之前,它会向所有正在运行的作业发送 SIGHUP 信号。如果一个作业处于暂停状态,shell 会向它发送 SIGCONT 信号以恢复执行,这样它就可以接收到 SIGHUP 信号并优雅地退出。为了完成这个任务,shell 会浏览一个包含所有作业的表格,接下来就是诀窍。让我们在后台启动一个脚本几次:

zarrelli:~$ ./while.sh & ./while.sh & ./while.sh & [1] 8944 [2] 8945 [3] 8946

现在,让我们看看 shell 的作业表:

zarrelli:~$ jobs [1] Running ./while.sh & [2]- Running ./while.sh & [3]+ Running ./while.sh &

我们可以看到预期中的所有三个进程都在运行。现在,开始做有趣的部分:

zarrelli:~$ disown %2

ID 为 2 的作业发生了什么?

zarrelli:~$ jobs [1]- Running ./while.sh & [3]+ Running ./while.sh &

好的,它已经从作业表中消失,但它仍然在那里:

zarrelli:~$ ps -p 8945 PID TTY TIME CMD 8945 pts/0 00:05:18 while.sh

ps 命令后面加上 -ppid 只是用来显示我们选定的进程 PID。它刚刚显示了我们的“脱离”的作业仍然在运行。所以,使用 disown,我们只是把一个作业从 shell 的作业列表中移除了;因此,当 shell 退出时,它不会向该作业发送 SIGHUP 信号,正如如果没有使用 disown 时会发送的那样。实际上,我们甚至可以进一步操作:

zarrelli:~$ disown -h %1 zarrelli:~$ jobs [1]- Running ./while.sh & [3]+ Running ./while.sh &

任务仍然存在,但已经标记为在 shell 退出时不会收到 SIGHUP 信号。你可以选择使用没有 ID-adisown 来移除或标记任务表中的所有 ID。如果没有 ID-r,则操作将仅限于运行中的任务。

在你的交互式 shell 关闭后,背景进程没有被终止吗?我们来检查一下:

shopt | grep huponexit

huponexit 设置为关闭。这可能是背景进程在 shell 退出时未被终止的原因。我们可以通过以下方式暂时将其开启:

shopt -s huponexit; shopt | grep huponexit

为了使其永久生效,可以将其设置在 ~/.bashrc/etc/bashrc 文件中,使用 shopt -s huponexit

双重派生和 setsid

有几种方法可以将进程转为守护进程,虽然可能不太常见,但非常有趣;这些方法包括双重派生setsid

双重派生是将进程转为守护进程的常用方法,意味着派生一个子进程,即通过复制父进程来创建一个子进程。在应用于守护进程化的双重派生中,父进程先派生一个子进程,然后终止它。接着,子进程再次派生自己的子进程并终止。这样,链条末端的两个父进程会死亡,只有孙进程存活并作为守护进程运行。这样做的原因与会话的控制终端分配方式有关,因为被派生的子进程会继承其父进程的控制终端。

在交互式会话中,shell 是第一个被执行的进程,因此它是终端的控制进程,也是会话的会话领导进程,所有在该会话中派生的进程都会继承它的控制终端。通过派生并终止父进程,我们得到了一个孤儿进程,它会自动被重新父化为 init,成为系统主进程的子进程。所有这一切的目的是为了防止子进程成为会话领导进程并获取控制终端;这就是为什么我们需要双重派生并两次终止父进程的原因:我们希望让子进程成为孤儿进程,以便系统将其重新父化为 init,从而防止它变成僵尸进程。因为它不是管道中的第一个进程,所以不能成为会话领导进程,也不能获得控制终端。这样,子进程就被移动到一个不同的会话中,不再控制控制终端,实际上变成了守护进程。

让我们来看看,并将脚本放到后台运行:

zarrelli:~$ ./while.sh & [1] 17460

让我们看看进程的 ID:

zarrelli:~$ ps -Ho pid,ppid,pgid,tpgid,sess,args PID PPID PGID TPGID SESS COMMAND 10355 1401 10355 17515 10355 /bin/bash 17460 10355 17460 17515 10355 /bin/bash ./while.sh 17515 10355 17515 17515 10355 ps -Ho pid,ppid,pgid,tpgid,sess,args

会话 ID 与它派生自的 shell 相同,但它有自己的进程组 ID 和父进程 IDPPID)等于它的父进程 ID。让我们看看脚本在进程树中所处的位置:

zarrelli:~$ pstree | grep -B3 while | `-{gmain} |-login---bash-+-grep | |-pstree | `-while.sh

正如预期的那样,它嵌套在登录会话中,因此它是该会话的一部分。现在,让我们进行双重派生:

zarrelli:~$ (./while.sh &) & [1] 17846

看一下这个过程:

zarrelli:~$ ps -Ho pid,ppid,pgid,tpgid,sess,args PID PPID PGID TPGID SESS COMMAND 10355 1401 10355 17970 10355 /bin/bash 17970 10355 17970 17970 10355 ps -Ho pid,ppid,pgid,tpgid,sess,args 17847 1 17846 17970 10355 /bin/bash ./while.sh

执行while的 shell 的 PPID 现在变得非常有趣;它的值变成了1。这意味着它的父进程不再是登录会话中启动的 shell,而是init进程。但请注意,它仍然共享相同的会话 ID 和相同的终端。我们可以通过pstree再次确认:

zarrelli:~$ pstree | grep -B3 while | `-{probing-thread} |-upowerd-+-{gdbus} | `-{gmain} |-while.sh

由于我们直接被重新归属于第一层init,所以没有任何嵌套。

使用setsid,我们得到一个稍微不同的结果。每当一个不是进程组领导的进程调用setsid时,它会创建一个新的会话,并使调用进程成为该会话的会话领导,成为新创建进程组的进程组领导,并且没有控制终端。因此,我们本质上创建了一个新会话,持有一个新进程组,并且只有一个进程,即调用进程。会话和进程组 ID 都设置为调用进程的进程 ID。我们希望将进程转为守护进程,但有一个缺点,那就是除非我们重定向到文件,否则没有任何输出:

setsid command > file.log

让我们将脚本转为守护进程:

zarrelli:~$ setsid ./while.sh zarrelli:~$ ps -e -Ho pid,ppid,pgid,tpgid,sess,args | grep while 22853 10355 22852 22852 10355 grep while 22572 1 22572 -1 22572 /bin/bash ./while.sh

这一次,我们需要使用ps-e选项来显示所有进程,并且使用grep,因为ps默认情况下只显示与当前用户具有相同效应用户 ID 并且与当前终端相同的进程。在这种情况下,我们更改了终端,所以它不会显示。最后,让我们看一下pstree

zarrelli:~$ pstree | grep -B3 while | `-{probing-thread} |-upowerd-+-{gdbus} | `-{gmain} |-while.sh

正如我们所预期的,由于PPID1,我们在第一层看到了嵌套。该进程,在我们的例子中是执行脚本的 shell,被重新归属于init,没有任何控制终端。

现在我们已经检查了几种将进程有效地放到后台并使其避免会话关闭的方法,我们可以继续进一步,看看如何编写可以自动转为守护进程的脚本,使其进入后台并且无需用户交互地工作。当然,也有一些变通方法,比如使用工具:屏幕和终端复用器,这些工具允许你将会话从终端中分离,以便即使用户注销,进程仍然可以继续运行。无论如何,这不是我们的目标,我们不是在回顾外部工具,而是在尝试从 Bash 中找出最佳方法,所以接下来的段落将会探讨一些不同的方法,如何让 Bash 将我们的脚本转为守护进程。

成为守护进程

守护进程的生活并不轻松,需要经历父进程的无数“残酷死亡”。

使进程成为守护进程所需的第一步是通过fork创建一个新进程,以便父进程可以退出,命令行提示符返回给调用的 shell。这确保新进程不是进程组领导者,因为进程组领导者不能通过调用setsid创建新会话。因此,新的子进程现在可以通过调用setsid提升为进程组领导者和会话领导者。到目前为止,新会话没有控制终端,新的子进程也没有。因此,我们再次调用fork以确保会话和组领导者能够退出。现在,孙子进程不是一个会话,因此它将要打开的终端不能是其控制终端。这就是 Linux 进程生活的艰难之处;如果它不是会话领导者,它将要打开的终端就不是调用进程的控制终端。

现在,进程已经与控制终端分离,但我们仍然有一个问题:它正在锁定它被调用的目录,所以如果我们尝试卸载它,我们会失败。下一步是让进程将工作目录更改为/,即文件系统的根目录(chdir /),或者更改为任何包含进程运行所需文件的目录。我们快完成了。一个好的做法是为进程设置umask 0,以便重置umask。进程可能已经继承了umask并会根据open()调用创建具有相应权限的文件。我们已经接近完成;下一步是让进程关闭从父进程继承的标准文件描述符(stdinstdoutstderr),并打开一组新的文件描述符。

捕获守护进程

在投入创建守护进程的黑魔法之前,你应该学会如何防止它受到任何可能导致其死亡的信号。如我们在前几章中所见,如果进程死亡,它可能会留下混乱,因为它没有时间清理房子。这很可怕,但我们可以做些事情来防止这一切发生:使用陷阱帮助我们处理信号并创建更强大、功能更完善的脚本。在我们的案例中,内建的trap将非常有用,用来监控我们的脚本行为,因为它是一个信号处理程序,可以修改进程如何响应信号。trap的通用语法如下:

trap commands signal_list

命令作为可以执行的列表,其中包括接收到信号时要执行的函数。我们已经看过一些信号及其数值,但trap可以使用一些关键字来处理最常见的信号,如下表所示:

信号 数字值
HUP 1 挂断。意味着控制终端已退出。
INT 2 中断,当按下Ctrl + C时会发生。
QUIT 3 退出。
KILL 9 这是一个无法捕获的信号。接收到该信号时,进程必须退出。
TERM 15 终止,是默认的杀死信号,可以处理,否则进程会优雅地退出。
EXIT 0 退出陷阱在退出时触发。

你可以在一个陷阱中指定一个或多个信号,也可以通过调用 – signal 来重置陷阱的默认行为。

信号,多少种?谁能记得所有的信号?除了 kill 命令,没人能记得:

zarrelli:~$ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 
42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 
46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX 

现在,让我们看看如何使用陷阱进行干净的退出,通过这个小例子:

#!/bin/bash
x=0
while true
do
for i in {1..1000}
do
x="$i"
if (( x == 500 ))
then
echo "The value of x is: $x" >> write.log
fi
done
done

这个脚本有一个无限的 while 循环,其中嵌套着一个 for 循环,遍历 11000 的范围。当 x 的值达到 500 时,它会在 write.log 文件中打印一条消息。退出时,内部循环会重新启动,但外部结构是一个无限循环,将会一直运行下去。让我们运行它,几秒钟后按 Ctrl+C

zarrelli:~$ ./write.sh ^C

所以,我们的脚本把终端锁定在前台运行,为了重新获得控制权,我们必须通过按 Ctrl+C 发出 kill -15,即 TERM 信号。让我们来看看目录:

zarrelli:~$ ls -lh total 28K -rw-r--r-- 1 zarrelli zarrelli 20 Apr 16 07:54 open -rwxr--r-- 1 zarrelli zarrelli 193 Apr 16 13:27 test.sh -rwxr--r-- 1 zarrelli zarrelli 35 Apr 16 11:54 while.sh -rw-r--r-- 1 zarrelli zarrelli 5.2K Apr 16 13:32 write.log -rwxr-xr-x 1 zarrelli zarrelli 152 Apr 16 13:05 write.sh -rwxr-xr-x 1 zarrelli zarrelli 293 Apr 16 13:28 write-term.sh

看起来日志被留下了:

zarrelli:~$ tail -5 write.log The value of x is: 500 The value of x is: 500 The value of x is: 500 The value of x is: 500 The value of x is: 500

是的,实际上是我们的日志,里面充满了我们设置的消息。作为日志,它被留下来也无妨,但如果这只是一个临时文件呢?每次脚本因终止或其他信号退出时,是否愿意在文件系统中留下临时文件?让我们通过创建一个清理函数来改进它:

clean_exit() { echo "ouch, we received a iINT signal. Outta here but first a bit of cleaning" rm write.log exit 0 }

一旦调用,这个函数将在 stdout 上回显一条有意义的信息,删除 write.log 文件,并以成功状态退出。最后一部分是实际的信号处理程序:

trap 'clean_exit' INT

就这些,运行脚本后稍等片刻,然后按 Ctrl+C

zarrelli:~$ ./write-term.sh ^Couch, we received a INT signal. Outta here but first a bit of cleaning

看起来有效;让我们来看看文件系统:

zarrelli:~$ ls -lh ttotal 20K -rw-r--r-- 1 zarrelli zarrelli 20 Apr 16 07:54 open -rwxr--r-- 1 zarrelli zarrelli 193 Apr 16 13:27 test.sh -rwxr--r-- 1 zarrelli zarrelli 35 Apr 16 11:54 while.sh -rwxr-xr-x 1 zarrelli zarrelli 152 Apr 16 13:05 write.sh -rwxr-xr-x 1 zarrelli zarrelli 293 Apr 16 13:28 write-term.sh

干净,write.log 在退出时已被清理。这是预期并期望的行为。我们还可以更进一步,屏蔽进程不受信号影响,让它被忽略。让我们在脚本中添加以下行:

trap ‘' TERM

现在,让我们在后台执行脚本:

zarrelli:~$ ./write-term.sh & [1] 16831

好吧,既然我们要处理守护进程,我们就不必担心杀死无辜的进程:

zarrelli:~$ kill 16831

哈哈!我们杀死了你!

zarrelli:~$ jobs [1]+ Running ./write-term.sh &

嗯,我们得重新考虑一下我们的说法。看起来我们的陷阱工作得非常好。事实上,带有信号但仅用 ‘' 作为参数的陷阱,实际上会让信号被忽略。好吧,我们还有其他的破坏方式,因为我们可以调用 INT

zarrelli:~$ kill -INT 16831 zarrelli:~$ ouch, we received a INT signal. Outta here but first a bit of cleaning [1]+ Done ./write-term.sh

最后,我们以有序的方式退出了脚本,未留下任何日志:

zarrelli:~$ ls -lh total 16K -rw-r--r-- 1 zarrelli zarrelli 20 Apr 16 07:54 open -rwxr--r-- 1 zarrelli zarrelli 35 Apr 16 11:54 while.sh -rwxr-xr-x 1 zarrelli zarrelli 152 Apr 16 13:05 write.sh -rwxr-xr-x 1 zarrelli zarrelli 307 Apr 16 13:39 write-term.sh

文件系统是干净的,write.log 文件没有留下。现在,让我们看一下通过给脚本添加一些内容来使用陷阱的一个巧妙方法。我们先在脚本开头放入 y=0,然后是稍作修改的循环:

for i in {1..3} do if (( x == 3 ))then y="$x"
echo "The value of x is: $x" >> write.log fi trap 'echo "The value of \$y is \"${y}\""' DEBUG done

现在,让我们运行脚本:

zarrelli:~$ ./write-debug.sh The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "0"
The value of $y is "3"
The value of $y is "3"

如果在 Bash 等待命令完成时接收到信号,陷阱将在命令执行完成后才会执行。如果使用内建的 wait,它将在收到设置了 trap 的信号时立即返回,随后 trap 本身会被执行。请注意,trap 通常会以 0 的状态退出,但在这种情况下,退出状态的值将大于 128。

每次执行命令时,变量的值都会打印出来,正如我们所见,使用-x选项调试 Bash 脚本时:

zarrelli:~$ /bin/bash -x ./write-debug.sh + y=0
+ trap clean_exit INT
+ trap '' TERM
+ for i in {1..3}
+ x=1
+ (( x == 3 ))
+ trap 'echo "The value of \$y is \"${y}\""' DEBUG
+ for i in {1..3}
++ echo 'The value of $y is "0"'
The value of $y is "0"
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ x=2
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ (( x == 3 ))
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ trap 'echo "The value of \$y is \"${y}\""' DEBUG
+ for i in {1..3}
++ echo 'The value of $y is "0"'
The value of $y is "0"
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ x=3
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ (( x == 3 ))
++ echo 'The value of $y is "0"'
The value of $y is "0"
+ y=3
++ echo 'The value of $y is "3"'
The value of $y is "3"
+ echo 'The value of x is: 3'
++ echo 'The value of $y is "3"'
The value of $y is "3"
+ trap 'echo "The value of \$y is \"${y}\""' DEBUG

移动到trap行,看看你能通过修改它来收集多少信息,以满足你的需求。所以,玩一会儿,享受为最终魔法触摸做准备的乐趣吧。

与守护进程一起走向黑暗

你认为做守护进程是一项复杂的任务吗?是的,除非你使用一个叫做daemon的好用工具。这个程序的任务是以简单整洁的方式将其他命令或脚本转变为守护进程。这个工具有没有采取任何捷径?没有,它只是通过我们已经看到的所有步骤,将进程从控制终端中分离出来,放到后台,启动一个新会话,清除 umask,并关闭旧的文件描述符。嗯,如果我们自己在 Bash 脚本中做这个,确实会是一项相当困难的任务。这个程序让一切变得简单明了,无需手动处理任何事情。但也有一个缺点:这不是一个标准工具,必须由用户手动安装。其实这不算大问题,因为许多发行版如 Debian 或 Red Hat 都有这个工具的安装包。

该是尝试这个工具的时候了,所以让我们把write.sh脚本转变为守护进程:

root:# daemon -r /root/write.sh

我们刚刚调用了 daemon 程序,传递了脚本的完整路径以及-r选项,这样如果脚本被停止,它会重新启动。让我们看看在我们的系统上会发生什么:

root:# ps -Heo tty,pid,ppid,pgid,tpgid,sess,args | grep write pts/0 2458 2298 2457 2457 2298 grep write ? 2455 1 2454 -1 2454 daemon -r /root/write.sh ? 2456 2455 2454 -1 2454 /bin/bash /root/write.sh

很好,我们的脚本没有控制终端;它正在后台运行,并将日志文件写入我们文件系统的根目录。现在,让我们使用-9选项终止它,因为没有进程可以忽略它:

root:# kill -9 2456

所以我们杀死了这个进程;让我们验证一下:

root:# ps -Heo tty,pid,ppid,pgid,tpgid,sess,args | grep write pts/0 2461 2298 2460 2460 2298 grep write ? 2455 1 2454 -1 2454 daemon -r /root/write.sh ? 2459 2455 2454 -1 2454 /bin/bash /root/write.sh

脚本就在这里。我们实际上终止了它的进程,但由于守护进程的-r选项,它强制重新启动了脚本;于是我们看到了,我们的守护进程正在运行,即使我们终止了它。如果我们真的想读取它,我们必须先终止守护进程程序,然后终止脚本进程:

root:# kill -9 2455 2459 root:# ps -Heo tty,pid,ppid,pgid,tpgid,sess,args | grep write pts/0 2481 2298 2480 2480 2298 grep write

这是运行守护进程的最简单方式,实际上它有很多选项。例如,假设我们希望以用户zarrelli身份运行脚本,并让它更改目录到一个子目录:

root:# daemon -D /home/zarrelli/tmp/ -u zarrelli /home/zarrelli/write.sh

使用-D,我们为chdir指定了一个新的目标,而-u为进程指定了一个新的运行用户,正如我们从ps命令中看到的:

root:# ps -Heo user,tty,pid,ppid,pgid,args | grep write zarrelli ? 2607 1 2606 daemon -D /home/zarrelli/tmp/ -u zarrelli /home/zarrelli/write.sh zarrelli ? 2608 2607 2606 /bin/bash /home/zarrelli/write.sh

正如预期的那样,新的日志文件属于名为zarrelli的用户:

root:# ls -lah /home/zarrelli/tmp/ total 780K drwxr-xr-x 2 zarrelli zarrelli 4.0K Apr 17 04:45 . drwxr-xr-x 3 zarrelli zarrelli 4.0K Apr 17 04:43 .. -rw-r--r-- 1 zarrelli zarrelli 769K Apr 17 04:50 write.log

简单,但我们可能希望有一个在系统启动时运行并在关机时停止的服务。那么,为什么不使用systemd来满足我们的需求呢?第一步,我们创建/etc/systemd/system/writing.service文件。

我们将为systemd管理的服务创建一个基本单元,所以让我们在文件中写入以下单元配置行:

[Unit] Description=Write.sh Daemon After=syslog.target [Service] ExecStart=/root/write.sh Type=simple [Install] WantedBy=default.target

这里没有什么特别的;根据我们要守护的脚本类型,可以选择合适的目标。如果是网络脚本,需要在网络启动后运行,因此network.target在这里更为合适。如果我们只想添加一些日志功能,可以使用syslog.target。我们也可以设置多个目标,这完全取决于我们要守护的是什么。在[Service]下,我们只需要指定脚本可执行文件,并且更重要的是指定执行类型:由于我们的脚本将会无限期运行,永远不会退出。因此,我们需要指定简单的启动方式,这样systemd就会执行该脚本并继续处理,而不是像fork模式那样等待脚本退出。其余的部分非常直接,因此我们保存文件并赋予它适当的权限:

root:# chmod 664 /etc/systemd/system/writing.service

现在,到了启用服务的时刻:

root:# systemctl enable writing.service

/etc/systemd/system/default.target.wants/writing.service创建一个符号链接到/etc/systemd/system/writing.service

重新加载systemd守护进程将有助于使新服务被识别:

root:# systemctl daemon-reload

好了,我们准备好首次执行我们的服务了:

root:# systemctl start writing

这里没有输出,但由于这个服务是由systemd管理的,我们可以询问它当前的状态:

root:# systemctl status writing writing.service - Write.sh Daemon Loaded: loaded (/etc/systemd/system/writing.service; enabled) Active: active (running) since Mon 2017-04-17 06:20:25 EDT; 57s ago Main PID: 1582 (write.sh) CGroup: /system.slice/writing.service └─1582 /bin/bash /root/write.sh Apr 17 06:20:25 spoton systemd[1]: Started Write.sh Daemon.

脚本正在运行;让我们做些其他检查:

root:# ls -lah /write.log -rw-r--r-- 1 root root 469K Apr 17 06:23 /write.log

log文件在那里,正在填充;现在让我们检查终端:

root:# ps -Heo user,tty,pid,ppid,pgid,args | grep write root pts/0 1605 1048 1604 grep write root ? 1582 1 1582 /bin/bash /root/write.sh

这里它来了,没有关联的控制终端。最后一步,当我们不想让守护进程继续运行时,我们需要停止它:

root:# systemctl stop writing No output so let's verify: root:# systemctl status writing writing.service - Write.sh Daemon Loaded: loaded (/etc/systemd/system/writing.service; enabled) Active: inactive (dead) since Mon 2017-04-17 06:25:51 EDT; 3s ago Process: 1582 ExecStart=/root/write.sh (code=killed, signal=TERM) Main PID: 1582 (code=killed, signal=TERM) Apr 17 06:20:25 spoton systemd[1]: Started Write.sh Daemon. Apr 17 06:25:51 spoton systemd[1]: Stopping Write.sh Daemon... Apr 17 06:25:51 spoton systemd[1]: Stopped Write.sh Daemon.

最后,如果我们不希望systemd再管理我们的守护进程,可以直接取消链接它:

root:# systemctl disable writing.service Removed symlink /etc/systemd/system/default.target.wants/writing.service.

现在,重新启动systemd守护进程:

root:# systemctl daemon-reload 

总结:

在这一章中,我们查看了如何将一个进程放到后台并使其在我们注销后继续运行,以及如何让它抵抗我们可能发送给它的大部分信号。接下来的步骤是如何将进程守护化,并利用systemd将其转变为一个系统管理的服务。就这样了吗?当然不是。通过一点创造力,我们可以将现有的碎片和构件拼接在一起,创建我们自己的守护进程脚本和服务,所以这可以成为一个不错的作业,尤其是在雨天的时候。

现在我们将离开守护进程部分,转向一些更相关的系统管理任务,我们将看看如何使用一些简单而强大的工具和服务来定制我们工作的环境,并且如何以最小的努力使其保持合理的安全性。

第十二章:通过 SSH 进行远程连接

什么是 SSH?

对于这样的问题,常见的答案是使用安全外壳并使用 SSH。唯一的缺点是,SSH 并不是一个外壳;它实际上是一个协议,通常称为 SSH1 和 SSH2:这两个版本的协议彼此不兼容。实际上,现在我们主要使用的是 SSH 版本 2 和 OpenSSH 服务器;它是 OpenBSD 项目的服务器程序,适用于多种平台。

SSH 的好处是什么,为什么我们要使用它?简而言之,SSH 提供了三个主要功能:

  • 身份验证:这意味着它可以确保我们知道对方的身份。因此,当有人试图连接到我们的 SSH 服务器时,服务器能够在授予其系统访问权限之前,获取远程方的数字身份证明。

  • 加密:像 Telnet 和 FTP 这样的旧协议简单易用,但它们有一个巨大的缺点,即它们以明文方式传输数据,因此如果有人无法攻破服务器,他们仍然可以尝试窃听与服务器之间的数据传输。SSH 通过加密数据来解决这个问题,从而使数据不容易被读取。

  • 完整性:它防止篡改。如果有人拦截数据并在传输过程中修改它,SSH 将能够察觉到。

一个典型的 SSH 连接经过一系列步骤,包括建立会话和身份验证:

  • 会话:服务器监听一个端口,通常是端口 22。客户端联系服务器,服务器回复支持的协议版本。如果客户端和服务器都支持某个版本,连接将继续。服务器提供一个主机密钥,作为身份的证明;如果客户端在之前的会话中已记录该密钥,则会与保存的副本进行比较。客户端和服务器协商一个会话密钥,用于加密会话(对称密钥)。

一旦建立了安全通道,客户端通过多种选项(如 Kerberos)进行身份验证;它是基于主机的,但通常使用以下几种方法之一:

  • 密码:用户必须在远程服务器上拥有一个受密码保护的账户。这可能是设置过程最简单的方式,但它也有一些缺点,比如我们需要记住远程主机上的用户名和密码,并且它使得自动化登录脚本变得更加困难。

  • 公钥加密:不要与加密密钥混淆,公钥方法实际上依赖于一对 SSH 密钥,一个公钥和一个私钥。一个有趣的地方是,公钥可以用来加密数据,只有私钥才能解密。因此,这是一种非对称加密,因为这两个密钥有不同的作用,我们不能使用公钥解密用相同公钥加密的数据。而且无法从公钥推导出私钥,因此公钥的分发变得安全且简单:任何获得公钥的人都可以加密数据,但只有持有对应私钥的人才能解密数据。因此,公钥可以安全地共享,而私钥必须保密且无法访问。

看看 SSH 会话中如何使用两种不同加密方式非常有趣:

  • 对称加密:用于加密所有通过 SSH 会话传输的数据,它依赖于 Diffie-Hellman(或相关)算法,并依赖一个大素数,过程如下:

    1. 在会话开始时,客户端和服务器都会选择一个大素数,作为种子值。然后,客户端和服务器选择一个加密生成器,如 AES,以及另一个素数,且该素数不会传递给对方。

    2. 现在,我们有了共享的素数、两个私有素数和一个加密生成器,因此每一方可以从其私有素数派生出一个公钥,并与另一方共享。

    3. 一旦共享,双方都解密对方的公钥、算法、私有素数以及共享的素数,从而创建一个新的主密钥,该密钥对双方都是相同的,可以用于加密双方后续的流量。

      在此过程中,建立主密钥的过程涉及服务器使用其主机密钥对交易中使用的数据进行签名,从而实现对客户端的身份验证,客户端现在可以信任该服务器。

  • 非对称加密:用于身份验证阶段,以便将客户端认证到服务器。如我们所见,其中一种身份验证方法是通过一对密钥完成的,一个公钥和一个私钥:

    1. 客户端开始发送它希望用于身份验证的密钥对 ID 和用户名。

    2. 服务器随后检查用户账户是否在系统中可用,并且检查是否存在包含authorized_keys文件的.ssh目录。如果该文件存在,它应该包含服务器存储的公钥,因此客户端发送的 ID 会与存储在该文件中的公钥 ID 进行匹配。

    3. 如果找到客户端的公钥,它将用于加密一个随机数,然后返回给客户端。

    4. 客户端生成公钥并持有其私钥,因此它可以解密服务器发送的数据包并获得秘密随机数。

  1. 在客户端,一组随机数会与会话密钥结合,然后进行哈希计算得到其 MD5 哈希值。

  2. 然后,MD5 哈希值会被发送回服务器,服务器使用会话密钥和原始随机数来计算 MD5 哈希值。如果这两个哈希值匹配,就意味着客户端拥有与用来加密随机数的公钥对应的私钥,从而完成客户端认证。

  • MD5 哈希:我们刚刚提到 MD5 哈希,接下来简要解释一下它的原理,虽然有大量关于加密和哈希算法的书籍。哈希函数用于将任意大小的数据映射到固定大小。这就像是创建一个物体的指纹,但有一个特别的性质:你可以使用哈希函数将原始数据映射到一个固定大小的值,但不能从该固定大小的值反向映射回原始数据。简单来说,哈希函数是单向的。MD5 是一个用于生成 128 位哈希值的算法:无论哈希数据的大小如何,生成的 MD5 哈希值总是 128 位——不多也不少。尽管 MD5 是作为加密手段创建的,但它已被证明易受各种攻击,因此现在通常用于检查数据的完整性,例如验证从安全网站下载的数据未被篡改。

SSH 为我们提供了一个安全的通道,通过网络工作,避免数据被第三方截获,并确保我们的身份被安全认证。大多数时候,我们处理的是 OpenSSH 服务器,但当谈到客户端时,各种操作系统都有很多选择:从命令行程序到图形界面程序,如 putty。最终,选择哪个客户端完全取决于我们自己的偏好,只要我们觉得它更符合使用习惯。一旦选择了合适的客户端,我们就可以连接到服务器;而最安全的方法是使用公钥认证,在下一章中我们将看到如何设置无密码的 SSH 连接。

配置文件

在开始使用 ssh 并查看它为我们提供的功能之前,先花点时间看看用于管理 ssh 服务和客户端的最相关文件。SSHD 守护进程的配置文件通常存储在/etc/ssh目录下,在那里我们可以找到一些有趣的文件:

  • moduli:该文件包含了 ssh 服务器在 Diffie-Hellman 组交换密钥交换方法中使用的质数和生成器,用于创建共享的会话主加密密钥。

  • sshd_config:这是 ssh 守护进程的配置文件。我们稍后会更详细地查看它,了解一些有趣且有用的指令,它们可以改变我们连接远程服务器的方式。

  • ssh_config:这是系统范围的 SSH 客户端配置文件,在用户主目录 ~/.ssh/config 中没有找到特定用户配置文件时会使用它。稍后我们将看到如何使用它。

  • ssh_host_dsa_key:这是 sshd 守护进程使用的 DSA 私钥。

  • ssh_host_dsa_key_pub:这是 sshd 守护进程使用的 DSA 公钥。

  • ssh_host_rsa_key:这是 sshd 守护进程使用的 RSA 私钥。

  • ssh_host_rsa_key_pub:这是 sshd 守护进程使用的 RSA 公钥。

  • ssh_host_key:这是 sshd 用于 SSH 版本 1 协议的 RSA 私钥。

  • ssh_host_key.pub:这是 sshd 用于 SSH 版本 1 协议的 RSA 公钥。

  • ssh_host_ecdsa_key:这是 sshd 守护进程使用的 ECDSA 私钥。

  • ssh_host_ecdsa_key.pub:这是 sshd 守护进程使用的 ECDSA 公钥。

  • ssh_host_ed25519_key:这是 sshd 守护进程使用的 ED25519 私钥。

  • ssh_host_ed25519_key.pub:这是 sshd 守护进程使用的 ED25519 公钥。

这些与主机密钥相关的 RSA、DSA、ECDSA 和 ED25519 缩写分别代表什么?这些缩写指的是用于身份验证密钥的公钥密码体制,涉及了许多争论:有人说 数字签名算法(DSA) 在加密时较慢,但在解密时比 RSA(这个算法的名字源自于其研究者 MIT 的 Ron Rivest、Adi Shamir 和 Leonard Adleman)更快,而 RSA 被认为比 DSA 更安全,另外 椭圆曲线数字签名算法 (ECDSA) 和 Edwards 曲线数字签名算法 (ed25519) 是新兴的算法。这些都是数字签名方案,利用不同的特性如质数或椭圆曲线,确保加密本身是不可破解的,或者更现实地说,是计算上不可行或不太可能。因此,在继续之前,必须明确一点:我们不能确定某种加密方式真的是不可破解的,也无法确定现在看似安全的加密方式未来是否依旧安全。

所以,我们可以做出明智的猜测,选择一个计算量大的且据称没有任何后门的算法。因此,虽然我们永远无法做到百分之百的安全,但我们可以根据一些 OpenSSH 项目的建议做出选择:

  • OpenSSH 7.0 因为其弱点弃用了 DSA 算法。因此,我们可以安全地抛弃这个算法。

  • 不要使用小于 1024 位的密钥。这样做是有道理的,因为较长的密钥计算上会更重,但对于日常使用来说,它们并不会带来显著的额外负担。

  • 不要使用如 Blowfish、CBC、RC4、基于 MD5 的 HMAC 算法和 RIPE-MD160 HMAC 等密码。

  • 不要使用 SSH 版本 1,因为它已经被弃用并且不再受支持。

  • 使用 ECDA 或 ED25519,如果不可行,可以创建至少 2048 或 4096 位的 RSA 密钥。

够复杂了吧?嗯,要理解哪些不应该使用的一条经验法则是阅读我们可以在 www.openssh.com/releasenotes.html 找到的 OpenSSH 项目的发布说明页面,然后查看 “未来弃用通知” 部分。

无论我们在这里找到什么,它都将在未来的发布版本中被弃用和废弃,因此,即使我们不深入研究加密算法背后的数学细节,我们也可以信任 OpenSSH 项目,不使用任何在任何版本中已弃用的内容。在密码学中,这些是一些算法,它们接受一块明文数据并生成一些混淆数据。可以说即使在这种情况下,也存在一些神圣战争,一些主要的算法被认为是较弱的,一些较强的算法被认为是较强的:

  • 数据加密标准(DES):在过去曾被广泛认可,但由于使用小密钥,现在不再被认为是安全的。

  • Triple DES:基于 DES,被认为比较安全,但在现今效率不高。

  • 高级加密标准(AES)或 Rijndael:这是一个相对较新的算法,并且被广泛认可。例如,AES-256 在 TLS/SSL 中使用,被认为是安全的。

  • IDEA:这是一个可行的算法,但由于专利使用的原因,它没有广泛应用。

  • Twofish:使用 128 位块和可变长度密钥,它是我们加密需求的选择之一。

  • Serpent:如果您不知道选择什么,且不能使用 AES,可以选择 Serpent。它的块大小为 128 位,密钥长度为 128、192 和 256 位。比其他选项慢,但安全性较高:它是一个块大小为 128 位的块密码。

在这段简短的插曲之后,我们可以继续查看位于用户 .ssh 配置目录中的另一组 SSH 配置文件(可选):

  • authorized_keys:在此文件中,我们可以找到授予服务器访问权限的公钥列表。正如我们前面看到的,当客户端尝试连接到服务器时,它会寻找帐户,并且如果存在,则在用户主目录中的 .ssh/authorized_keys 文件中查找客户端提供的密钥对的 ID。如果找到 ID,则使用客户端提供的用户和密钥进行身份验证。

  • authorized_keys: 该文件保存了服务器的授权公钥列表。当客户端连接到服务器时,服务器通过检查存储在此文件中的签名公钥来对客户端进行身份验证。

  • known_hosts:这个文件包含客户端已经访问过的服务器的主机公钥。当服务器向客户端发送他们的主机公钥时,它已经在此文件中查找,看看它是否对应于远程主机的先前存储的公钥。

  • config:它保存了用户的 SSH 客户端配置。在无密码连接中非常重要,因为它有助于自动化连接。稍后我们会详细了解更多。

  • id_dsa:这保存了用户的 DSA 私钥。

  • id_dsa.pub:这是用户的 DSA 公钥。

  • id_rsa:这是用户的 RSA 私钥。

  • id_rsa.pub:这是用户的 RSA 公钥。

  • Identity:这是 SSH 版本 1 中用户的 RSA 私钥。

  • Identity.pub:这是 SSH 版本 1 中用户的 RSA 公钥。

这些是我们可能在主机上找到的文件,但可能并不是所有文件都存在,例如,并非所有密钥都会在那里;我们需要创建它们并给它们一些更有意义的名称。在远程主机上我们可以确定会找到并对我们有用的文件是 sshd_config 文件。因为它帮助我们修改守护进程提供 SSH 服务的方式,让我们更详细地查看它,涵盖一些最有趣的指令。

sshd_config 文件

我们将查看一些对日常服务使用最有用的指令,但如果我们需要了解所有配置选项的详细信息,只需调用 man 命令:

man sshd_config

主要的 SSH 守护进程配置文件位于 /etc/ssh/sshd_config,但我们也可以在守护进程启动时使用命令行的 -f 选项指定任何文件。话虽如此,让我们逐步了解并查看最有趣的配置部分:

  • AcceptEnv:此指令允许客户端将环境变量复制到会话环境中并发送给客户端。它可能很有用,但也可能很危险,默认情况下不接受任何客户端环境变量。

  • AllowGroups:默认情况下,只有系统上所有组的成员才能登录,但通过这个指令,你可以将登录权限限制为那些主组或次组与列出的组匹配的用户,甚至可以使用模式匹配,稍后我们会看到。我们只能使用组名,不能使用组 ID,访问指令的处理顺序是:DenyUsersAllowUsersDenyGroupsAllowGroups

  • AllowUsers:默认情况下,所有拥有有效账户的用户都可以登录,但通过这个指令,我们可以将访问限制为那些账户名或模式匹配的成员。我们只能指定用户名,不能使用 ID。我们还可以指定成员为 user@host,这样限制不仅会应用于账户名,还会应用于源主机。这个可以用 CDIR/掩码格式书写。访问指令的处理顺序是:DenyUsersAllowUsersDenyGroupsAllowGroups

  • AuthenticationMethods: 我们可以指定用户必须成功通过的认证方法才能获得系统访问权限。默认值为any,意味着用户只需成功通过任何一种可用的认证方法。如果列出了任何认证方法的组合,例如passwordpublickeykeyboard-interactivepublickey,则用户将被要求至少按顺序成功通过所有认证方法一次。因此,在示例中,用户必须先使用publickey方法成功认证,然后至少使用密码认证。keyboard-interactive方法是一种通用认证,依赖于诸如 PAM、RADIUS 和 RSA Secure ID 等设施,并且可以通过在其后附加bsdauthpamskey等关键字来限制。如果publickey方法被使用多次,例如publickeypublickey,则需要两个不同的公共密钥才能成功认证。无论列出的是哪种方法,必须在配置中启用该方法。

  • AuthorizedKeysFile: 有时,我们只需将客户端的公共认证密钥放在user ~./ssh目录中的authorized_keys文件内,什么也不会发生。其实,这个问题可能是由此指令引起的,因为文件名就是在这里定义的。默认值是.ssh/authorized_keys .ssh/authorized_keys2,但我们还可以找到一些标记,例如%h/.ssh/authorized_keys,其中%h表示正在认证的账户的主目录;或者我们也可以看到%%,表示一个简单的%,而%u则被用户名替代。一旦标记被扩展,结果将作为文件的完整路径或相对于用户主目录的路径来处理。

  • Banner: 这是一个很好的选项,用于在用户身份验证之前向用户显示消息。如果提供none,则不会显示横幅。此选项仅适用于 SSH-2,默认值为 none。

  • ChallengeResponseAuthentication: 这允许挑战-响应认证。默认值为yes

  • ChrootDirectory: 通过指定目录的完整路径,我们可以在用户成功认证后将其chroot到该目录。然而,这并不是一项简单的任务,因为该目录必须由 root 拥有,且不可由其他任何人写入。此外,我们还需要提供会话所需的一些文件,例如 shell、/dev/null/dev/zero/dev/arandom/dev/stdin/dev/stdout/dev/stderr/dev/ttyx。我们还可以找到一些标记,如%h,代表正在认证的账户的主目录;或者我们也可以看到%%,表示一个简单的%,而%u则被用户名替代。

  • Ciphers:此项允许我们指定 SSH-2 所允许的加密算法。这是一个很好的方法来限制我们希望使用的加密算法的数量和种类。默认的加密算法列表,以逗号分隔,包含aes128-ctr,aes192-ctr,aes256-ctr,aes128cm@openssh.com,aes256-gcm@openssh.comchacha20-poly1305@openssh.com

  • ClientAliveCountMax:这是可以发送的客户端存活消息的数量,在此期间守护进程没有收到来自客户端的任何回复。当达到最大值时,守护进程将断开与客户端的连接。默认值为3;此选项仅适用于 SSH-2。

  • ClientAliveInterval:这是一个时间间隔,以秒为单位,表示如果客户端在此时间内未发送任何消息,服务器将通过加密通道向客户端发送一条消息,要求客户端回复。默认值是0。举个例子,假设我们将此选项设置为5,并将之前的ClientAliveCountMax设置为12,那么客户端将在60秒后被断开连接。

  • DenyGroups:默认情况下,所有组的成员都被允许进行身份验证,但通过此指令,我们可以将它们限制为一个由空格分隔的组列表。因此,对于那些其主组或附加组出现在此指令中或通过模式匹配的用户,将无法进行身份验证。

我们已经提到了sshd config中可用的模式,这本质上分为两个字符:

  • *** 匹配 0 个或多个字符**:类似于192.168.*,将匹配所有以192.168开头的 IP 地址;或者*.foo.com将匹配所有foo.com的三级域名以及名为 foo.com 的二级域名。

  • ? 匹配一个字符:例如,192.16?.1将匹配从192.160.1192.168.9.1的所有 IP 地址。

  • 模式列表:顾名思义,这是一个由命令分隔的模式列表。单个模式可以通过前导的感叹号来否定;例如,!*.noway.foo.com,*.foo.com会允许所有foo.com的三级域名,除了那些在.foo.com之前包含noway的域名。

组必须通过名称指定,而不能通过数字 ID 指定;此指令的处理顺序为:DenyUsersAllowUsersDenyGroupsAllowGroups

  • DenyUsers:跟随一个由空格分隔的用户名模式列表,此指令禁止与列出模式匹配的用户账户登录。像往常一样,只能指定用户的名称,而不能指定其 ID,默认情况下所有用户都可以登录。我们还可以指定ser@host形式的成员,以便限制不仅适用于账户名,还适用于来源主机;这也可以用 CDIR/mask 格式写入。访问指令按以下顺序处理:DenyUsersAllowUsersDenyGroupsAllowGroups

  • DisableForwarding:此指令禁用所有类型的转发,如 X11、TCP、ssh-agent 和StreamLocal。如果我们希望精简服务并提高安全性,这是一个很好的指令。

  • ForceCommand:此指令会覆盖客户端发送的任何命令或认证账户~/.ssh/rc中列出的命令;并强制执行该指令中列出的命令。该命令通过账户的 shell 以-c选项执行。默认值为no

  • HostbasedAuthentication:此指令允许/拒绝基于rhostshostS_equive以及成功的公钥jkey客户端主机认证的认证。默认值为no

  • HostKey:此指令指定保存私有主机密钥的文件。默认位置为/etc/ssh/ssh_host_rsa_key/etc/ssh/ssh_host_ecdsa_key/etc/ssh/ssh_host_ed25519_key。我们可以为单一主机定义多个主机密钥,但重要的是这些文件不能被全局或组访问。

  • KbdInteractiveAuthentication:允许/禁止键盘交互式认证。默认值来自ChallengeResponseAuthentication,通常设置为yes

  • KerberosAuthentication:此指令允许/拒绝通过 Kerberos 服务器验证客户端提供的密码。默认值为no

  • ListenAddress:列出了 SSH 守护进程将监听的地址。我们可以使用 IPv4/IPv6 地址、主机名或它们的列表,并可选择性地在后面跟上端口,如下所示:

Listen 192.168.0.10:6592

如果没有指定端口,sshd 将监听在ports指令中列出的端口。默认配置是监听所有本地地址:

  • LoginGraceTime:这是用户完成登录过程的超时时间(秒)。默认值为 120 秒;若设置为 0,则可以禁用超时。

  • LogLevel:在出现任何问题时,我们可以修改 sshd 生成的日志的详细程度。默认级别为INFO,但我们可以将其设置为QUIETFATALERRORINFOVERBOSEDEBUGDEBUG1DEBUG2DEBUG3中的任何一个。DEBUGDEBUG1等效,而每增加一个DEBUGx,日志的详细程度会更高。由于DEBUG可能泄露太多与用户相关的私人信息,因此不推荐使用。

  • Match:使用此指令,我们可以使用条件语句,以便在条件满足时,后续的配置行将覆盖主配置块中的配置。如果一个关键字/配置块出现在多个匹配条件中,只有第一个实例会被考虑作为有效。作为匹配标准,我们可以使用以下指令:user、group、host、local address、local port、address,或者使用 all 匹配所有条件。我们可以匹配单个值、逗号分隔的列表,并且还可以使用通配符和否定操作符。

  • MaxAuthTries:此选项限制每个连接的最大身份验证尝试次数。一旦达到阈值的一半,后续的失败尝试会被记录。默认值为6

  • PasswordAuthentication:此选项允许/拒绝密码身份验证。默认值为yes

  • PermitEmptyPasswords:此选项允许/拒绝在启用密码身份验证时使用空密码。将此选项设置为yes并不安全,默认值为no

  • PermitRootLogin:此选项允许用户以 root 身份登录。它可以有以下值:yesprohibit-passwordwithout-passwordforced-commands-onlyno。如果设置为prohibit-passwordwithout-password,则passwordkeyboard-interactive身份验证对于 root 用户不可用;如果设置为forced-commands-only,则允许通过公钥认证登录,但仅在指定了命令的情况下。

  • PermitTTY:此选项允许/拒绝使用pty(伪终端)进行会话。默认值为yes

  • PermitTunnel:此选项允许/拒绝tun设备转发。它接受yespoint-to-pointethernetno作为参数。Yes启用point-to-pointethernet转发。默认值为no

  • PermitUserRC:如果设置为yes,则会执行~/.ssh/rc中的命令。默认值为yes

  • Port:此选项指定 SSH 守护进程监听的端口号。默认值为22,但我们应该将此端口更改为较高的数字,以避免大多数脚本小子尝试自动破坏服务。

  • PubkeyAuthentication:此选项允许/拒绝公钥身份验证。默认值为yes

  • StrictModes:此选项在登录过程中检查账户文件和主目录的文件模式和所有权。如果设置为yes,则会检查是否存在对全体用户可写的.ssh目录或home目录,如果文件或目录对全体用户可写,则拒绝登录。此选项不适用于ChrootDirectory,其权限和所有权始终会被检查。

  • Subsystem:此选项启用外部子系统的执行,通常是sftp-server。语法是子系统名称后跟一个命令,命令可以带有可选参数,在调用子系统时执行。默认值为配置的no子系统。

  • SyslogFacility:我们可以使用以下任一syslog设施记录来自 SSH 守护进程的消息:DAEMONUSERAUTHLOCAL0LOCAL1LOCAL2LOCAL3LOCAL4LOCAL5LOCAL6LOCAL7。默认值为AUTH

  • TCPKeepAlive:此选项启用服务器向客户端发送TCP keepalive,以便检测是否断开连接。这不是一个简单的选择:临时路由问题可能导致与服务器的强制断开;但是如果没有keepalive,当客户端断开或崩溃时,连接可能会无限期挂起。默认值为yes

  • UseDNS:此选项强制 SSH 守护进程通过 DNS 服务解析主机名,并检查它是否解析到连接客户端的 IP 地址。如果设置为 no,则在 ~/.ssh/authorized_keys 中使用 from= 仍然不支持主机名,只支持 IP 地址;同样适用于 Match Host 指令。将此选项设置为 yes 可能会导致认证时的延迟,因为需要进行 DNS 解析。默认值为 yes

  • UsePAM:此选项启用/禁用可插拔认证模块接口。默认值为 no。如果设置为 yes,则会通过 PAM 启用认证,使用 ChallengeResponseAuthenticationPasswordAuthentication,并配合 PAM 账户和会话模块,因此必须禁用其中一个。有趣的是,启用 PAM 会使 SSH 守护进程以非特权用户身份运行。默认值为 no

  • UsePrivilegeSeparation:如果启用此选项,用户登录后,SSH 守护进程会创建一个拥有认证用户权限的子进程。它可以接受 yesnosandbox 作为参数。如果选择 sandbox,会对子进程的系统调用执行更多强制性限制,从而使得利用受损的子进程攻击主机或本地内核更加困难。默认值为 sandbox

  • X11Forwarding:此选项允许/禁止 X11 转发。如果设置为 yes,可能会将 X11 暴露于攻击之下,因此必须谨慎使用此选项。默认值为 no

我们刚刚看到了一些服务器端的配置,但我们也可以通过配置客户端来改变与 SSH 守护进程的交互方式,因此让我们来看看客户端最有趣的一些选项。

ssh_config

在客户端,我们有几种方式来配置连接的保持方式:

  • 从命令行传递选项给客户端时

  • 从用户主目录中的配置文件 ~/.ssh/config

  • 从系统范围的配置文件 /etc/ssh/ssh_config

对于配置文件,我们必须记住,每个指令只会使用第一个获得的值;因此,如果多次指定同一指令,只有第一个会被评估。所以,我们必须将更具体的选项放在配置文件的前面,而更宽泛的选项则放在后面。

正如我们将在下一段中看到的,我们将检查客户端配置的实际使用,文件被分成了多个部分,这些部分的边界由Host指令限定:任何在该关键字下列出的配置指令都将属于指定的主机,直到下一个Host声明为止。文件中的每一行包含一个配置指令及其值,如果值中包含空格,则可选择性地用双引号括起来;以#或空白开头的行被视为注释。多个值可以用空格或=分隔。牢记这些注意事项,让我们来看看客户端配置文件中最有趣的关键字:

  • Host:此指令可以接受一个主机名作为参数,或者一个模式,可以用!来取反。如果是*,则后续指令适用于所有主机。这里给出的模式或名称应该与我们在命令行中连接远程主机时使用的主机名相匹配。所有在Host关键字后面的指令仅适用于定义的主机,直到下一个HostMatch指令为止。如果主机/模式值被取反,则所有针对该主机的指令也被取反。

  • Match:此指令限制后续指令的适用范围,直到下一个MatchHost声明为止,只有在满足指定值时才会应用。值可以是all,表示总是匹配,或者是canonicalexechostoriginalhostuserlocaluser中的一个或多个。all值必须单独出现或紧接着canonical,这两种选项不需要参数。值可以使用!来取反。

  • canonical:当配置文件在主机名标准化之后重新解析时,匹配此项(稍后我们将看到它的含义)。

  • exec:使用账户的 shell 执行命令;如果命令的exit状态为零,则条件被评估为true。如果命令包含空格,则必须加引号;它可以接受作为参数的令牌(稍后我们将看到它的含义)。

  • host:这与目标主机名匹配,在任何通过HostnameCanonicalizeHostname选项进行的替换后。它可以接受逗号分隔的列表、通配符和取反(!)。例如,看看这里:

match host foo.com exec "test %p = 9999" IdentityFile foo.identity

我们将仅在目标主机的主机名为foo.com且端口号为9999时,使用名为foo.identity的身份文件。

  • originalhost:这与客户端命令行上指定的主机名匹配。

  • user:这与用于在远程主机上登录的用户名匹配。

  • localuser:这与运行 SSH 客户端的本地(客户端侧)用户匹配。

  • BatchMode:用于脚本中的无人值守登录。如果设置为yes,则不会要求输入密码或密码短语,并且在 Debian 中,ServerAliveInterval将设置为300秒。默认值为no

  • BindAddress:在分配了多个 IP 地址的客户端机器中很有用;它指定连接的源地址。如果UsePrivilegedPort设置为yes,则此选项无效。

  • CanonicalDomains:与CanonicalizeHostname一起使用;它设置一个域名后缀列表,用于搜索远程主机以进行连接。

  • CanonicalizeFallbackLocal:如果设置为yes,客户端将尝试使用客户端系统的搜索规则查找不完全的主机名。如果设置为no,且CanonicalizeHostname设置为yes,当远程主机名无法在CanonicalDomains列出的任何域中找到时,将立即失败。默认为yes

  • CanonicalizeHostname:启用主机名的规范化重写。如果设置为no,本地解析器将管理主机名查找;如果设置为always,它将使用CanonicalDomains中列出的域名重写不完全的主机名。CanonicalizePermittedCNAMEs规则将会被应用。如果设置为yes,将对那些不使用ProxyCommand指令的连接执行规范化。

  • CanonicalizePermittedCNAMEs:列出在主机名规范化过程中必须遵循的规则。这些规则可以包含以下一个或多个参数:

    • source_domains:target_domains:前者是一个域名模式的列表,表示在规范化过程中可能跟随主机名的域;target_domains是一个域名模式的列表,表示前述域可能解析为的域。

    • CertificateFile:列出加载证书文件的路径,该文件对应由IntentityFile指令指向的私钥。

    • CheckHostIP:定义客户端是否在known_hosts文件中检查主机 IP,以防止 DNS 欺骗,并将远程主机的 IP 添加到~/.ssh/known_hosts文件中。默认为yes

    • ConnectionAttempts:每秒的连接尝试次数,达到指定次数后退出。默认为1

    • ConnectTimeout:连接尝试的超时时间,单位为秒。

    • ForwardX11:启用通过连接的 X11 重定向并设置 DISPLAY 值。默认为no

    • GatewayPorts:允许/禁止远程主机连接到本地转发端口。默认为no,表示本地转发端口绑定到回环设备地址。如果设置为yes,它们将绑定到*地址。

    • GlobalKnownHostsFile:设置一个或多个文件(用空格分隔),用于存储主机密钥。默认为默认的/etc/ssh/ssh_known_hosts/etc/ssh/ssh_known_hosts2

    • HostKeyAlias:设置一个别名,在搜索或保存主机密钥到hostkey文件时使用,而不是主机名。

    • HostName:指向我们将要登录的远程主机的实际主机名。我们可以使用此字段为远程主机创建一个有意义的别名,可以使用数字 IP、标记(稍后会看到)或简短的名称。

    • IdentityFile: 定义身份认证信息读取的文件。对于 SSH-1,默认为~/.ssh/identity,对于 SSH-2,默认为~/.ssh/id_dsa~/.ssh/id_ecdsa~/.ssh/id_ed25519~/.ssh/id_rsa。如果没有使用CertificateFile指令关联证书,SSH 将尝试读取一个文件,其文件名通过在IdentityFile列出的名称后添加-cert.pub来构造。可以使用令牌作为参数;可以多次使用此指令来添加更多要尝试的身份文件。

    • Include: 包含列出的配置文件。如果文件没有指向绝对路径,则应位于用户配置中的~/.ssh或系统范围配置文件中的/etc/ssh下。可以使用通配符,并且此指令可以作为matchhost关键字的参数列出以进行条件包含。

    • LocalCommand: 一旦本地客户端成功连接到远程主机,我们可以编写要在用户 shell 中执行的命令。接受令牌,但除非启用了PermitLocalCommand,否则将被忽略。

    • LocalForward: 启用本地 TCP 端口通过安全连接转发到远程主机和端口。接受两个参数:[local_address:]portremote_host:port

我们可以指定多个转发,但只有超级用户可以绑定本地特权端口。如果未指定作为参数,本地端口将绑定到从GatewayPorts指令获取的地址。如果给定了 localhost,则只能从本地客户端机器访问监听端口;空地址表示*,因此端口将在所有接口上可访问。

  • NumberOfPasswordPrompts: 定义在登录过程中询问密码的次数,在宣布登录过程失败之前。参数可以是默认为3的整数。

  • Port: 这是客户端将尝试连接的远程服务器上的端口。默认为22

  • PreferredAuthentications: 定义客户端尝试不同身份验证方法的顺序。默认为gssapi-with-michostbasedpublickeykeyboard-interactivepassword

  • Protocol: 定义客户端按优先顺序支持的协议。如果列出了多个,则必须用逗号分隔。如果首选协议失败,将尝试列表中的下一个。默认为2

  • ProxyCommand: 定义用于连接远程服务器的命令;使用用户 shell 的 exec 指令执行。与netcat一起使用时非常有用来代理连接。接受令牌。

  • RemoteForward: 启用远程主机上 TCP 端口的转发,通过安全连接到本地计算机上的端口。接受两个参数:[local_address:]portremote_host:port

我们可以通过仅以超级用户身份登录远程主机来指定多个转发,这允许我们绑定远程特权端口。如果没有指定,local_address 将绑定到回环设备。如果未指定远程主机,或使用 *,则转发端口将在远程主机的所有接口上都能访问。要指定远程地址,必须在 sshd_config 中启用 GatewayPorts 指令。

  • ServerAliveCountMax:定义在未收到远程主机回复的情况下,最大服务器存活消息的数量。一旦达到该阈值,会话将断开。此类消息与 TCPKeepAlive 消息大不相同:前者通过加密通道发送,因此无法伪造,而后者是明文的,可以伪造。默认为 3

  • ServerAliveInterval:定义一个超时值(以秒为单位),超过该时间后,客户端将通过安全通道发送一条消息。如果在指定的时间内未接收到数据,客户端将通过安全通道向服务器发送请求响应的消息。默认为 0,表示永不发送消息。

  • StrictHostKeyChecking:如果设置为 yes,将发生两件事:

    • 客户端将永远不会自动将主机密钥添加到 ~/.ssh/known_hosts 文件中。

    • 客户端将拒绝连接到远程主机,其密钥与存储在 known_hosts 文件中的密钥不同。

如果设置为 yes,客户端将自动添加新密钥;如果设置为 ask,即默认值,客户端将提示用户确认是否将密钥添加到 known_hosts 文件。

  • TCPKeepAlive:启用/禁用客户端发送到远程主机的保持活动消息。默认为 yes;这将允许客户端检测到网络断开或远程主机崩溃的情况。它在脚本中广泛用于无人值守的断开连接检测。

  • Tunnel:启用客户端与远程主机之间的转发,以供 tun 设备使用。参数可以是 yespoint-to-pointethernetno。默认为 yes,即启用默认的点对点模式。

  • TunnelDevice:定义要为客户端和远程主机打开的 tun 设备。参数指定为 client_tun:[host_tun]

设备可以通过其数字 ID 或使用 any 来进行寻址,这将强制使用下一个可用的 tun 设备。如果未定义 host_tun,则默认为 any。默认值为 any:any

  • UsePrivilegedPort:启用/禁用出站连接的特权端口使用。如果设置为 yes,则 ssh 必须是 setuid root,因为只有该用户可以使用特权端口。默认为 no

  • User:指定用于登录的远程帐户的用户名。

  • UserKnownHostsFile:定义一个或多个用户的 host_key 数据库文件。如果指定了多个文件,它们必须用空格分开。默认值为 ~/.ssh/known_hosts~/.ssh/known_hosts2

  • TOKENS:我们在一些配置指令中提到过它们,它们是可以在 SSH 会话中展开的特殊字符组合:

    • %%:展开为字面量 %

    • %C:是 %l%h%p%r 的缩写。

    • %d:展开为客户端用户的主目录。

    • %h:远程主机的主机名。

    • %i:展开为本地用户 ID。

    • %L:客户端的主机名。

    • %l:客户端的主机名,包括域名。

    • %n:命令行中给出的远程主机的原始主机名。

    • %p:远程主机的端口。

    • %r:远程主机的用户名。

    • %u:客户端的用户名。

这些令牌在作为不同配置指令的参数时接受不同的扩展:

  • Match exec 使用 %%%h%L%l%n%p%r%u

  • CertificateFile 使用 %%%d%h%l%r%u

  • ControlPath 使用 %%%C%h%i%L%l%n%p%r%u

  • HostName 使用 %%%h

  • IdentityAgentIdentityFile 使用 %%%d%h%l%r%u

  • LocalCommand 使用 %%%C%d%h%l%n%p%r%u

  • ProxyCommand 使用 %%%h%p%r

我们列出的某些指令在 sshd_config 中,而在 ssh_config 文件中也可用,为了简洁起见,这些指令被省略了。我们在进入下一段之前尽量整理得尽可能简洁,在下一段中你将学习如何使用我们刚才检查过的一些指令来创建无密码连接。

无密码连接

native and practical way to reach this goal. We are talking about passwordless connections, which means we just ssh to a host alias and we are in, no questions asked, and nothing other than an alias to remember.

我们需要设置哪些内容来实现这种便捷的连接方法?我们有几个角色需要配置:我们必须检查服务器设置、生成一些密钥,并配置客户端。

配置服务器

从服务器开始,打开 /etc/ssh/ssd_config 文件,检查以下配置指令:

Port 22

从端口开始。SSH 服务的标准端口是 22,这是大多数脚本小子会使用自动化工具来扫描你的 SSH 守护进程的端口;因此,如果你的服务器是公开可用的,建议将端口更改为非特权端口,例如 9527。这样,很多这些攻击将变得无效:

#ListenAddress :: #ListenAddress 0.0.0.0

如果我们需要将服务绑定到服务器上的特定地址,这是我们需要操作的指令;我们只需取消注释并填写合适的值:

Protocol 2

我们不打算使用协议版本 1,甚至不作为第二选择。我们保持安全,使用协议版本 2:

# HostKeys for protocol version 2 HostKey /etc/ssh/ssh_host_rsa_key HostKey /etc/ssh/ssh_host_dsa_key HostKey /etc/ssh/ssh_host_ecdsa_key HostKey /etc/ssh/ssh_host_ed25519_key

曾经想过系统范围的主机密钥在哪里吗?这里就是它们的位置,我们还可以根据需要决定更改名称和路径。

#Privilege Separation is turned on for security UsePrivilegeSeparation yes

绝对的!我们希望与非特权进程一起工作,以避免任何超级用户权限被滥用。

# Authentication:
LoginGraceTime 120

给我们一些时间来登录。

#PermitRootLogin without-password PermitRootLogin yes

一个安全的做法是将远程主机的 root 账户登录限制为基于密钥的认证。这样,入侵者就无法仅凭猜测密码突破进入;他需要客户端的私钥才能进入,而该密钥是安全存储在客户端,而不是服务器上。无论如何,如果我们想要为 root 账户设置无密码认证的远程登录,我们必须允许 root 账户使用密码登录。一旦确保一切正常,我们会限制不使用密码的登录。

StrictModes yes

很容易忘记在主目录或 SSH 配置文件和密钥上设置世界可写权限,所以最好启用这个指令;它会防止我们在远程用户的主目录权限设置不安全时登录。

PubkeyAuthentication yes

好的,我们正在处理这个问题,因此最好确保这个设置为yes

AuthorizedKeysFile %h/.ssh/authorized_keys

让我们记下公共密钥必须存储的位置。令牌告诉我们它们位于.ssh目录中,该目录位于存放authorized_keys文件的账户的主目录下。

HostbasedAuthentication no

出于安全考虑,让我们丢弃主机身份验证:

PermitEmptyPasswords no 

让我们检查一下。除非我们希望无密码登录,否则绝不应将其切换为yes。但谁会想要这样呢?

UsePAM yes

我们希望将其设置为yes有几个原因。其中一个原因是,启用此选项后,SSH 守护进程不能以 root 身份运行;这是一个安全的选项。

一旦配置好相关项,我们来检查是否也拥有需要的主机密钥,以便向客户端证明服务器身份。根据配置文件的内容,在远程主机上,我们应该有每个支持的算法对应的密钥,存放在/etc/ssh目录中:

root:# ls -lah /etc/ssh/ total 296K drwxr-xr-x 2 root root 4.0K Apr 16 07:32 . drwxr-xr-x 129 root root 12K Apr 17 04:00 .. -rw-r--r-- 1 root root 237K Jul 22 2016 moduli -rw-r--r-- 1 root root 1.7K Jul 22 2016 ssh_config -rw-r--r-- 1 root root 2.6K Apr 16 07:32 sshd_config -rw------- 1 root root 668 Apr 16 07:20 ssh_host_dsa_key -rw-r--r-- 1 root root 601 Apr 16 07:20 ssh_host_dsa_key.pub -rw------- 1 root root 227 Apr 16 07:20 ssh_host_ecdsa_key -rw-r--r-- 1 root root 173 Apr 16 07:20 ssh_host_ecdsa_key.pub -rw------- 1 root root 399 Apr 16 07:20 ssh_host_ed25519_key -rw-r--r-- 1 root root 93 Apr 16 07:20 ssh_host_ed25519_key.pub -rw------- 1 root root 1.7K Apr 16 07:20 ssh_host_rsa_key -rw-r--r-- 1 root root 393 Apr 16 07:20 ssh_host_rsa_key.pub

这就是它们!所以我们没问题。它们通常在我们从发行版包安装 OpenSSH 服务器时创建,但我们也可以选择创建我们自己的主机密钥。让我们看看如何做。首先,我们来看看其中一个密钥的指纹:

root:# ssh-keygen -f /etc/ssh/ssh_host_ecdsa_key.pub -l 256 fe:23:d3:9b:8a:80:30:ad:0d:ac:81:fa:ba:3f:6f:56 /etc/ssh/ssh_host_ecdsa_key.pub (ECDSA)

我们使用了ssh-keygen,这是一种做很多事情的工具,从创建密钥到修改它,或者像本例中那样查看它。结果字符串的第一个字段告诉我们密钥的位长,第二个字段显示实际的密钥,第三个字段指向保存该密钥的文件,最后是加密方法。

使用-lv 选项将为你提供密钥的 ASCII 指纹

但假设我们不信任现有的密钥,我们想要创建一对新的密钥:

root:# cd /etc/ssh root:# ssh-keygen -A ssh-keygen: generating new host keys: RSA1 RSA DSA ECDSA ED25519

这容易吗?是的,很容易:

root:# ls -lh ssh_h* -rw------- 1 root root 668 Apr 24 06:13 ssh_host_dsa_key -rw-r--r-- 1 root root 601 Apr 24 06:13 ssh_host_dsa_key.pub -rw------- 1 root root 227 Apr 24 06:13 ssh_host_ecdsa_key -rw-r--r-- 1 root root 173 Apr 24 06:13 ssh_host_ecdsa_key.pub -rw------- 1 root root 399 Apr 24 06:13 ssh_host_ed25519_key -rw-r--r-- 1 root root 93 Apr 24 06:13 ssh_host_ed25519_key.pub -rw------- 1 root root 976 Apr 24 06:13 ssh_host_key -rw-r--r-- 1 root root 641 Apr 24 06:13 ssh_host_key.pub -rw------- 1 root root 1.7K Apr 24 06:13 ssh_host_rsa_key -rw-r--r-- 1 root root 393 Apr 24 06:13 ssh_host_rsa_key.pub

这是新的密钥文件;让我们再检查一下相同的密钥:

root:# ssh-keygen -f /etc/ssh/ssh_host_ecdsa_key.pub -l 256 24:4d:3e:6b:f4:0f:4b:bf:56:b9:b5:c4:b6:ab:c6:7b /etc/ssh/ssh_host_ecdsa_key.pub (ECDSA)

这两个密钥是不同的:

24:4d:3e:6b:f4:0f:4b:bf:56:b9:b5:c4:b6:ab:c6:7b fe:23:d3:9b:8a:80:30:ad:0d:ac:81:fa:ba:3f:6f:56

我们利用了ssh-keygen-A选项,它会自动为每种类型(rsa1rsadsaecdsaed25519)创建缺失的密钥。密钥会以默认位大小创建,不带密码,并且带有默认注释。现在,再次假设我们想要创建我们自己的ecdsa主机密钥对:

root:# ssh-keygen -t ecdsa -a 1000 -b 521 -C "My hand crafted key" -f /etc/ssh/ssh_host_crafted_ecdsa -o Generating public/private ecdsa key pair. Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /etc/ssh/ssh_host_crafted_ecdsa. Your public key has been saved in /etc/ssh/ssh_host_crafted_ecdsa.pub. The key fingerprint is: 28:74:b2:e6:a1:e5:6d:cd:ca:e7:f2:47:86:6d:39:d6 My hand crafted key The key's randomart image is: +---[ECDSA 521]---+ |                 | |                 | |    o .          | |   . + .         | |     * . So o    | |    * + o. O E   | |   . o o o= .    | |     o... .      | |      o=o.       | +-----------------+

我们的新 ECDSA 主机密钥已经创建

我们使用一些简单的选项创建了新的主机密钥:

  • -t:此选项用于选择 keyboard-interactive 的类型。

  • -a:当将密钥保存为 ed25519 格式,或在选择 -o 选项时保存任何 SSH-2 密钥时,可以选择此选项。它指定用于加密私钥密码的 密钥衍生函数KDF)轮数。它使密码短语检查变得更慢,并且对暴力破解攻击更具抵抗力。整数越高,检查越慢。默认为 64,这个设置已经很好了;我们将其设为 1000 以获得更高的安全性。

  • -b:密钥的比特长度。Ecdsa 可以有 256、384 或 521 位的大小。

  • -C:是您可以与密钥关联的注释。

  • -f:是将保存新密钥的文件路径。

  • -o:将 SSH-2 私钥保存为新的 OpenSSH 格式,而不是通常的 PEM 格式。新格式对暴力破解攻击更具抵抗力,但不支持低于 6.5 版本的 OpenSSH。ed25519 密钥总是以新格式保存,因此它们不需要在命令行中使用此选项。

现在,是时候通过将新密钥添加到主 sshd 配置文件中,让新密钥对服务器可用:

HostKey /etc/ssh/ssh_host_crafted_ecdsa

让我们限制密钥的权限:

root:# chmod 600 *

所以,除了 root 之外,其他任何人都无法访问密钥和配置文件:

root:# ls -lah | grep cra -rw------- 1 root root 751 Apr 24 11:41 ssh_host_crafted_ecdsa -rw------- 1 root root 225 Apr 24 11:39 ssh_host_crafted_ecdsa.pub

我们注意到两点:

  • 我们创建了一个私钥,其名称未以 _key 结尾。我们故意这么做,是为了将其与预先构建的密钥区分开来。key 文件名可以是任何我们想要的内容,但最好给它一个有意义的值。

  • ssh-keygen 自动将 .pub 后缀添加到私钥文件名,并使用生成的名称作为公钥文件名。

SSH 密钥的安全权限是:

700 对于 .ssh 目录,以及

600 对于 .ssh 目录中的密钥文件。

现在,让我们重新加载或重启服务,以便新的密钥可以供 SSH 守护进程使用:

root:# systemctl restart sshd ; systemctl status sshd ● ssh.service - OpenBSD Secure Shell server Loaded: loaded (/lib/systemd/system/ssh.service; enabled) Active: active (running) since Mon 2017-04-24 11:40:51 EDT; 5ms ago Process: 30993 ExecReload=/bin/kill -HUP $MAINPID (code=exited, status=0/SUCCESS) Main PID: 31265 (sshd) CGroup: /system.slice/ssh.service └─31265 /usr/sbin/sshd -D

哇,它工作了,让我们看看日志:

root:# tail -f /var/log/syslog Apr 24 11:40:51 spoton systemd[1]: Stopping OpenBSD Secure Shell server... Apr 24 11:40:51 spoton systemd[1]: Starting OpenBSD Secure Shell server... Apr 24 11:40:51 spoton systemd[1]: Started OpenBSD Secure Shell server. Apr 24 11:40:51 spoton sshd[31265]: Could not load host key: /etc/ssh/ssh_host_crafted_ecdsa

我们差不多完成了。守护进程成功启动,但拒绝加载主机密钥。发生了什么?简单来说,我们在创建密钥时给定了一个密码短语;为了加载密钥,必须提供密码短语,但守护进程无法与之交互并填写它。我们必须删除密码短语:

root:# ssh-keygen -p -f /etc/ssh/ssh_host_crafted_ecdsa

程序会要求输入旧密码,当需要填写新密码时,我们只需按两次 Enter 键,这样就不会为私钥添加密码。现在,让我们重启并检查:

root:# systemctl restart ssh ; systemctl status ssh ; tail -n3 /var/log/syslog

● ssh.service - OpenBSD Secure Shell server Loaded: loaded (/lib/systemd/system/ssh.service; enabled) Active: active (running) since Mon 2017-04-24 11:59:32 EDT; 8ms ago Process: 30993 ExecReload=/bin/kill -HUP $MAINPID (code=exited, status=0/SUCCESS) Main PID: 31517 (sshd) CGroup: /system.slice/ssh.service └─31517 /usr/sbin/sshd -D Apr 24 11:59:32 spoton systemd[1]: Started OpenBSD Secure Shell server. Apr 24 11:59:32 spoton systemd[1]: Stopping OpenBSD Secure Shell server... Apr 24 11:59:32 spoton systemd[1]: Starting OpenBSD Secure Shell server... Apr 24 11:59:32 spoton systemd[1]: Started OpenBSD Secure Shell server.

现在,一切正常,密钥已经加载。

准备远程账户

假设我们要创建一个全新的用户,接下来的操作同样适用于预先存在的用户。首先,我们在远程主机上创建一个新的用户 test_user,并将其配置为仅通过密钥认证进行访问:

root:# useradd -m test_user

所以,我们刚创建了 test_user 账户,并为其提供了一个 home 目录:

root:# ls -lah /home/test_user/ total 20K drwxr-xr-x 2 test_user test_user 4.0K Apr 24 12:50 . drwxr-xr-x 4 root root 4.0K Apr 24 12:50 .. -rw-r--r-- 1 test_user test_user 220 Nov 5 17:22 .bash_logout -rw-r--r-- 1 test_user test_user 3.5K Nov 5 17:22 .bashrc -rw-r--r-- 1 test_user test_user 675 Nov 5 17:22 .profile

请注意,目前还没有.ssh。现在,由于我们希望此账户仅能通过密钥访问,因此让我们将其锁定:

root:# passwd -l test_user

passwd中的-l选项使用一个巧妙的技巧来锁定账户。当我们创建账户时,passwd会要求输入密码;然后它会加密密码并将其写入/etc/shadow文件,如我们在锁定前看到的shadow文件:

root:# root@spoton:~# grep test_user /etc/shadow test_user:$6$yTDup7NC$5eAg6QabTnMvwtqUfbmAcCy74zjHNj6RXafdIEBEmiVyz2DIVkdFgzuuIFuscdAmIBp4B6lqh5tUNfDnK.8Q/1:17280:0:99999:7:::

shadow文件的各个字段可以按如下方式解释:

1ogin name encrypted password date of last password change minimum password age maximum password age password warning period password inactivity period account expiration date reserved field for future use

我们不打算详细解释每个字段,一个简单的man shadow就能提供我们需要的所有信息。真正重要的是第二个字段,它保存着加密的密码。当用户尝试登录时,他们提供的密码会被加密,并与/etc/shadow文件的第二个字段进行比对:如果匹配,则密码正确;如果不匹配,则密码错误,用户登录被拒绝。看看账户锁定后的同一行:

root:# passwd -l test_user test_user:!$6$yTDup7NC$5eAg6QabTnMvwtqUfbmAcCy74zjHNj6RXafdIEBEmiVyz2DIVkdFgzuuIFuscdAmIBp4B6lqh5tUNfDnK.8Q/1:17280:0:99999:7:::

有新变化:密码字段前面加了!,实际上改变了其值。这里的技巧是:像!*这样的字符永远不会是passwd使用crypt(3)函数加密用户密码的结果,因此添加感叹号使得该值无法匹配。无论用户输入什么值,passwd都无法生成感叹号;因此,实际上账户被锁定。

仅为设置 purposes,让我们用临时密码再次启用该账户:

root:# passwd test_user Enter new UNIX password: Retype new UNIX password: passwd: password updated successfully

我们需要使用密码登录才能复制我们的客户端公钥。完成后,我们将再次锁定该账户。

现在我们有了可以使用的用户名和端口:9999。我们可以返回客户端并创建我们的配置。

配置客户端

回到客户端;让我们进入我们想要为其设置连接的用户的home目录。假设local_user希望作为test_user连接到名为spoton的远程主机。

让我们进入local_user的主目录,local_user:~$ cd /home/local_user,并查看其中的内容:

local_user:~$ ls -lah total 20K drwxr-xr-x 2 local_user local_user 4.0K Apr 24 18:34 . drwxr-xr-x 4 root root 4.0K Apr 24 18:34 .. -rw-r--r-- 1 local_user local_user 220 Nov 15 18:49 .bash_logout -rw-r--r-- 1 local_user local_user 3.5K Nov 15 18:49 .bashrc -rw-r--r-- 1 local_user local_user 675 Nov 15 18:49 .profile

这是新账户的常见文件,但存在一个问题:

root:# egrep IdentityFile /etc/ssh/ssh_config # IdentityFile ~/.ssh/identity # IdentityFile ~/.ssh/id_rsa # IdentityFile ~/.ssh/id_dsa # IdentityFile ~/.ssh/id_ecdsa # IdentityFile ~/.ssh/id_ed25519

我们没有设置身份文件,因此需要取消注释其中一行;如果需要,我们也可以更改文件名。暂时,我们将这行添加到ssh_config文件中:

IdentityFile ~/.ssh/id_ecdsa_to_spoton

我们只是修改了文件名,以便明确它将用于名为 spoton 的远程主机。我们可以为连接多个远程服务器或作为不同用户连接同一服务器使用不同的身份文件。因此,最好为密钥文件找到一个有意义的名称,这样可以提醒我们它的用途。现在,我们在客户端配置文件中已有了参考,接下来我们必须创建.ssh目录:

local_user:~$ mkdir .ssh

设置正确的访问权限:

local_user:~$ chmod 700 .ssh

现在,让我们进入.ssh目录并创建我们的密钥;为了简化操作,我们不会对私钥强制设置密码:

local_user:~$ ssh-keygen -t ecdsa -a 64 -b 384 -C "Key for test_user on spoton" -f id_ecdsa_to_spoton -o Generating public/private ecdsa key pair. Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in id_to_spoton. Your public key has been saved in id_to_spoton.pub. The key fingerprint is: SHA256:ZhJMqQ19CpCIB3d9KEaVUhH5ngyOP8LDqGxxkvj967M Key for test_user on spoton The key's randomart image is: +---[ECDSA 384]---+ |ooo+o=*B         | |o.o.*oB o        | | . . Bo=         | | . +..           | |. . o.+S.        | |.+ .. .++        | | .+= .           | |..o * +          | |oo =E=           | +----[SHA256]-----+

我们使用了更小的密钥大小 384,因为在 512 时,我们可能会遇到一些问题,当客户端使用-vvv选项调用时,密钥可能会被拒绝,并显示类似这样的消息:

debug2: input_userauth_pk_ok: fp SHA256:Y7KP6aAFrbzNYYMZLTAiFf71yiE8mzgfzZ6FnrDC964 debug3: sign_and_send_pubkey: ECDSA SHA256:Y7KP6aAFrbzNYYMZLTAiFf71yiE8mzgfzZ6FnrDC964 Load key "/home/local_user/.ssh/id_ecdsa_to_spoton": invalid format

现在,我们有了一对密钥,一个公钥,一个私钥:

local_user:~$ ls -lah total 16K drw------- 2 root root 4.0K Apr 24 19:50 . drwxr-xr-x 3 local_user local_user 4.0K Apr 24 18:52 .. -rw------- 1 local_user local_user 634 Apr 24 19:44 id_ecdsa_to_spoton -rw------- 1 local_user local_user 233 Apr 24 19:44 id_ecdsa_to_spoton.pub

公钥的访问权限设置不正确,所以最好将其修复为更安全的600

local_user:~$ chmod 600 *

如我们所知,私钥必须在客户端上安全存储;但是我们必须将公钥复制到远程主机,并将其添加到test_user~/.ssh/authorized_keys中。

实际上我们有两种方法来做这件事。

手动操作,将公钥复制到远程服务器;将其复制到authorized_keys文件中并修复访问权限。或者,你可以使用ssh-copy-id工具。

让我们使用第二种方法:

local_user:~$ ssh-copy-id -iid_ecdsa_to_spoton.pub -p 9999 test_user@192.168.0.5 /usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "id_ecdsa_to_spoton.pub" The authenticity of host '[192.168.0.5]:9999 ([192.168.0.5]:9999)' can't be established. ECDSA key fingerprint is SHA256:LPSZkMIYkaMJXXnD6GvUGFMAjL6yM6pZwVRUojqmhGw. Are you sure you want to continue connecting (yes/no)? yes /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys test_user@192.168.0.5's password: Number of key(s) added: 1

现在尝试登录到机器:

"ssh -p '9999' 'test_user@192.168.0.5'"

检查确保只有你想要的密钥被添加。

一切看起来都正常,所以让我们使用设置的密码,以test_user身份连接到远程系统。在主目录下的.ssh子目录中检查,是否有一个authorized_keys文件,并且其中包含我们的公钥:

test_user@spoton:~/.ssh$ cd .ssh/ test_user@spoton:~/.ssh$ ls -lah total 12K drwx------ 2 test_user test_user 4.0K Apr 24 14:05 . drwxr-xr-x 3 test_user test_user 4.0K Apr 24 14:05 .. -rw------- 1 test_user test_user 281 Apr 24 14:05 authorized_keys

文件实际上已经存在,并且有正确的访问设置。我们来看看里面的内容:

test_user@spoton:~/.ssh$ cat authorized_keys ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBPlnKFqWXsCj47zKtrZzqj8PUuAvFlpTPzTJ4faHF1Fb2YJkI4Ywc4gmRig/hz+0kAXtanla4pMQtE6NqwyNheqo5rru8czRM9jRigqN8UwF7yZNf0LMxYV2aFCzrGcz6g== Key for test_user on spoton

看起来是正确的密钥;我们注销并查看local_user的公钥身份:

local_user:~$ cat id_ecdsa_to_spoton.pub ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBPlnKFqWXsCj47zKtrZzqj8PUuAvFlpTPzTJ4faHF1Fb2YJkI4Ywc4gmRig/hz+0kAXtanla4pMQtE6NqwyNheqo5rru8czRM9jRigqN8UwF7yZNf0LMxYV2aFCzrGcz6g== Key for test_user on spoton

所以,我们只需尝试使用我们创建的身份文件连接到远程主机;但首先,我们需要确保已经在远程主机上允许了9999端口。一旦确认没有任何中断,我们就可以在客户端侧执行:

local_user:~$ ssh -i /home/local_user/.ssh/id_ecdsa_to_spoton -p 9999 test_user@192.168.0.5 The programs included with the Debian GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. Last login: Mon Apr 24 14:54:43 2017 from moveaway.hereiam test_user@spoton:~$ 

就是这样!我们刚刚无需任何密码登录,但这仍然不够方便。你仍然需要指定身份文件,并记住端口和地址。这样不太方便,但我们可以通过利用客户端在本地账户.ssh目录中期望的本地配置文件来改善我们的体验。所以,在/home/local_user/.ssh中,让我们创建一个名为config的文件,并写入以下指令:

Host * UserKnownHostsFile /dev/null StrictHostKeyChecking no IdentitiesOnly yes Host spoton AddressFamily inet ConnectionAttempts 10 ForwardAgent no ForwardX11 no ForwardX11Trusted no GatewayPorts yes HostBasedAuthentication no HostKeyAlias spotalias HostName 192.168.0.5 IdentityFile ~/.ssh/id_ecdsa_to_spoton PasswordAuthentication no Port 9999 Protocol 2 Compression yes CompressionLevel 9 ServerAliveCountMax 3 ServerAliveInterval 15 TCPKeepAlive no User test_user

我们有两个部分,其中一个适用于任何主机,另一个则更具体地适用于主机-spoton。每当我们想要添加另一个主机时,只需复制并粘贴主机特定的部分,修改HostHostKeyAliasHostnameIdentityFileUser,如果需要,还可以修改Port,就这样。配置文件将随着特定部分的增加而扩展,我们的连接将简单如下:

local_user:~$ ssh spoton Warning: Permanently added 'spotalias,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. The programs included with the Debian GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. Last login: Mon Apr 24 15:25:41 2017 from moveaway.hereiam

就是这样,关键是通过在Host中指定的别名调用 SSH,我们就能连接,不需要密码、端口、地址或身份文件。最后一点,让我们锁定远程用户:

root:# passwd -l test_user passwd: password expiry information changed.

现在,让我们检查账户状态:

root:# passwd -S test_user test_user L 04/24/2017 0 99999 7 -1

L表示账户已被锁定。因此,我们可以返回客户端再次尝试连接:

local_user:~$ ssh spoton Warning: Permanently added 'spotalias,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. The programs included with the Debian GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. Last login: Mon Apr 24 15:31:16 2017 from moveaway.hereiam

就是这样。远程test_user账户已被锁定,任何人都无法使用密码本地登录或通过远程连接登录。除非是拥有正确私钥的人;在这种情况下,我们的客户端名为local_user

我们可以用 ssh 玩很多花样,甚至可以写一本书,但我们将把乐趣限制在这个工具提供的几个有趣功能上。代理和隧道功能将在下一个段落中介绍。

代理和隧道

假设我们需要一种快速的方式绕过防火墙设置退出我们的网络。我们的机器无法进行任何 HTTP/HTTPS 连接,但我们可以访问另一台远程主机,它可以自由访问互联网。那么,来看一个实际的例子。首先,我们使用curl抓取一个远程页面:

local_user:~$ curl www.packtpublishing.com
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html  xml:lang="en">
<head>
<title>Best packtpublishing online</title>
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
<meta name="description" content="Free best online packtpublishing website" />
<meta name="keywords" content="online,packtpublishing,website" />
<link rel="stylesheet" type="text/css" href="online.css" media="screen" />
</head>
<body>
<div class="wrapper">
<div class="top"><p>packtpublishing info at packtpublishing.com</p></div>
<div class="header"><img src="img/header.png" alt="header"></div>
<div class="column" id="a">

<h1>Online packtpublishing top website</h1>
<br />
<a href="inc/online/packtpublishing.php"><img src="img/click2.png" alt="login"></a>
<br />
<h1>deluxemanager+packtpublishing<img src="img/ctc.png" alt="@gmail.com" style="float:right;"></h1>
<br />
www.packtpublishing.com 2014<br />
</div>
</body>
</html>

我们刚刚抓取了www.packtpub.com的主页;不错吧?现在,为了好玩,root 我们使用一个简单的防火墙ufw来阻止任何指向端口80的外发连接:

root:# ufw deny out 80/tcp Rule added Rule added (v6)

现在,我们再次尝试运行curl命令;它将挂起,因为我们已阻止任何指向端口80的外发连接。但现在,让我们输入以下命令:

local_user:~$ ssh -f -N -D 8080 spoton

警告: 已永久将spoton,[192.168.0.5]:9999 (ECDSA)添加到已知主机列表中。

然后:

local_user:~$ curl --proxy socks5h://localhost:8080 www.packtpublishing.com
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html  xml:lang="en">
<head>
…

我们省略了其余的 HTML 代码,但它与之前的curl命令相同,因为我们抓取了相同的页面。发生了什么?我们使用了一些选项来调用 ssh:

  • -f:这会在命令执行之前强制将 ssh 放到后台。

  • -N:这可以防止 ssh 在远程主机上执行任何命令,因为我们只是进行代理。

  • -D:后跟[local_address]:port,定义一个本地动态端口转发,并分配一个套接字监听请求。当连接到此端口时,它会通过安全连接转发到远程主机,同时应用协议将用于判断从远程机器连接到哪里。因此,SSH 将作为一个支持 SOCKS4 和 SOCKS5 协议的 SOCKS 服务器。如果经常使用,端口转发可以设置到账户的ssh config文件中。只需注意,只有超级用户才能转发特权端口。

我们然后将spoton作为目标,因为我们已经保存了相应的配置片段,所以不需要指定地址或密码。但如果没有配置,我们将会写成这样:

local_user:~$ ssh -f -N -D 8080 test_user@192.168.0.5:9999

当系统提示时,输入用户密码。一旦我们的 SOCKS 代理启动,我们就可以配置任何应用程序,例如 curl、Chrome 和 Firefox,来使用它。

不喜欢 SOCKS 代理?那么,为什么不直接将所有流量隧道化到一个安全连接中呢?

local_user:~$ ssh -N -L 8888:www.anomali.com:443 spoton

现在让我们发起一个调用:

local_user:~$ curl -k https://localhost:8888
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="https://www.anomali.com/blog/">here</a>.</p>
</body></html>

这里有趣的是-L选项,它强制将连接到指定的 TCP 端口(8888)的数据通过加密通道转发到远程主机(spoton);然后是端口(9999)或 Unix 套接字,接着连接到最终主机(www.anomali.com)上指定的端口(443)。至于代理,如果我们频繁使用此功能,可以将其设置到账户的 SSH 配置文件中,另外我们可以选择指定一个本地地址来绑定本地端口(如果是 IPv6,则使用括号)。spoton可以替换为远程地址和端口上的远程账户名,如前面的示例所示。注意,我们在 HTTP 上使用了重定向,但这实际上是一个隧道,因此我们可以使用任何协议,而不仅仅是 HTTP。

但我们可以做得更高级:我们可以让别人通过我们访问远程主机。所以,在本地机器上,我们只需执行以下命令:

local_user:~$ ssh -f -N -R 8181:www.anomali.com:443 spoton

然后,在spoton上,我们将使用curl命令:

test_user:~$ curl -k https://localhost:8181
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="https://www.anomali.com/blog/">here</a>.</p>
</body></html>

也就是说,我们通过本地机器从spoton访问www.anomali.com;这正是-R选项允许我们做的:将远程主机(spoton)上的远程端口(8181)的连接转发到本地机器,然后从本地机器转发到外部站点(www.anomali.com)。接着我们使用了-k选项与 curl 配合,防止它因连接不安全而报错,因为显式 URL 与外部站点 SSL 证书不匹配。最后,我们必须记得在继续之前重新启用之前关闭的防火墙端口。

很棒,不是吗?在离开这一章节之前,让我们再玩一些其他的小技巧,纯粹为了好玩。我们回到存储在spotontest_user authorized_keys中的公钥,并在密钥的开头添加一些内容:

command="sudo ifconfig eth0",no-port-forwarding,no-X11-forwarding ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBPlnKFqWXsCj47zKtrZzqj8PUuAvFlpTPzTJ4faHF1Fb2YJkI4Ywc4gmRig/hz+0kAXtanla4pMQtE6NqwyNheqo5rru8czRM9jRigqN8UwF7yZNf0LMxYV2aFCzrGcz6g== Key for test_user on spoton

我们只是给密钥添加了一个命令:每次使用该密钥登录时,命令都会被执行。由于用户没有特权,而该命令需要由 root 用户运行,我们使用了sudo,因此我们需要将以下文件添加到/etc/sudoers.d/目录中:

root:# cat /etc/sudoers.d/test_user test_user ALL = (root) NOPASSWD: /sbin/ifconfig eth0

文件的名称并不重要,重要的是内容,它使得test_user能够以 root 身份运行/sbin/ifconfig eth0。通过这种方式,我们将赋予其一个小而有限的权限。现在,从我们的本地机器作为local_user,让我们使用创建的公钥连接到spoton

local_user:~$ ssh spoton Warning: Permanently added 'spotalias,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. eth0 Link encap:Ethernet HWaddr 00:1d:ba:88:2a:e6 inet addr:192.168.0.5 Bcast:192.168.0.255 Mask:255.255.255.0 inet6 addr: redacted/64 Scope:Global inet6 addr: redacted/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:614498 errors:0 dropped:0 overruns:0 frame:0 TX packets:29212 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:119612772 (114.0 MiB) TX bytes:4249467 (4.0 MiB) Interrupt:16 Connection to 192.168.0 5 closed.

我们可以创建专门的用户/密钥来重新启动服务、查看日志、执行任何维护任务,接着我们只需启动连接,而不必担心权限和命令问题。

如果我们需要运行一个我们没有安装且无法安装的图形化应用程序,但我们知道它在远程主机上可用怎么办?我们必须确保在我们的用户ssh config文件中启用了 X11 转发:

ForwardX11 yes ForwardX11Trusted yes

转发功能已在远程服务器上启用:

X11Forwarding yes

显然,在远程服务器上,必须安装Xorg以及xauth工具;如果我们使用相同的密钥,必须将其从命令片段中清除并恢复到原始值。一旦确认无误,我们只需输入以下内容:

test_user:~$ ssh -n -f -X spoton firefox Warning: Permanently added 'spoton,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. zarrelli:~$ /usr/bin/xauth: file /root/.Xauthority does not exist Xlib: extension "RANDR" missing on display "localhost:10.0".

几秒钟后,Firefox 将在远程主机上启动并运行,但其窗口将显示在我们的本地系统上。

摘要

我们刚刚触及了通过 SSH 可以做的事情的皮毛。比如ssh-agentssh-addssh-keyring,还有许多复杂且棘手的操作,以至于单独一章无法涵盖所有内容。不管怎样,这只是一个起点;一旦我们熟悉了服务器和客户端的使用,就可以开始探索加密连接、跳板主机、代理等领域的深奥世界,了解我们需要知道或想要知道的任何事情。现在,我们需要进一步探讨如何设置定时任务来及时执行我们的脚本,并如何正确记录它们的执行,以便我们始终能够理解我们的创作过程中发生了什么。是时候了解定时任务了,探索atcron和日志记录功能了。

第十三章:定时器的时间

在深入了解守护进程和 SSH 隧道之后,处理cronjob可能看起来像是一项简单的任务,但让我们稍作思考:我们的日常生活中有多少次需要安排一个任务在特定的时间范围内执行,也许是在深夜或者我们度假时?有多少次我们需要一个任务每天在准确的时间执行,每一天都不例外?我们真的想熬夜,或者放弃度假吗?更重要的是,我们能确保自己每天都能按时执行任务吗?简单来说,我们不能。因此,一个安排任务并在需要时执行的方法看似简单,但正是它让系统更易管理,并且为我们节省了大量麻烦。

我们有许多工具和分叉项目可用,但我们将重点关注其中的几个。最著名的老工具是atcron。它们做的事情差不多,但实现方式不同,目标也有所不同。

一次性执行

有时候,我们需要在特定时间执行一个任务,并且不需要重复操作,仅仅是一次性执行。在这种情况下,我们可以使用一个简单的工具,叫做at,它有一个伴侣工具叫做 batch。它的作用是什么?它简单地从输入或文件中读取要执行的内容和时间,然后用/bin/sh调用我们想要执行的命令。不过有一个小小的区别:batch 会在系统负载降到 1.5 以下,或atd运行时指定的任何负载水平时执行任务,而不是在特定的时间执行。

所以,我们介绍了atd;这是什么?这是一个守护进程,用来执行由at工具定义并放入其队列的单次任务,因此它是一个通常以专用守护进程用户身份运行的守护进程:

root:# ps -fC atd UID PID PPID C STIME TTY TIME CMD daemon 722 1 0 Apr25 ? 00:00:00 /usr/sbin/atd -f

所以,这是一个作为系统服务启动的守护进程,但我们有一些其他选项;我们可以传递它来修改它如何处理计划任务。让我们看看有哪些可以使用的选项:

  • -l:定义超过该负载因子的调度任务将不会被执行。如果没有设置限制,默认为 1.5;但是,对于拥有 x 个 CPU 的系统,合适的限制值可能会高于 x-1。

  • -b:此选项设置两个连续任务之间的最小间隔(单位为秒)。默认为60

  • -d:这里遇到问题了吗?此选项将启用调试功能,将错误信息从syslog重定向到stderr,通常是终端。此选项与-f选项一起使用。

  • -f:适合调试,此选项强制atd在前台运行。

  • -s:强制atbatch队列只执行一次。此选项用于与旧版at的向后兼容;它的运行方式类似我们以前运行的atrun命令。

atd运行过程涉及的文件很少:

  • /var/spool/cron/atjobs: 这是 at 创建的任务存储目录,atd 将从中读取队列。它必须由进程的同一所有者(在我们这个例子中是守护进程)拥有,并具有严格的 700 访问权限。

  • /var/spool/cron/atspool: 这是临时保存任务输出的目录。它由一个守护进程用户拥有,访问模式为 700。

  • /etc/at.allow, /etc/at.deny: 在这个文件中,我们可以设置哪些账户可以提交任务给 atd,哪些账户被禁止。

使用 atd 时有一个限制:如果它的排队目录通过 NFS 挂载,则无论你使用何种挂载选项,它都会完全无法工作。

我们看到了一些有趣的文件:

/etc/at.allow /etc/at.deny

可以提交任务给 atd 守护进程的管理员可以使用非常简单的语法:这是一个简单的账户名称列表,每行一个,不带空格。文件解析有优先顺序:/etc/at.allow 是第一个被读取的。如果其中找到任何账户名,这些账户将是唯一允许提交任务的账户。

如果 /etc/at.allow 文件不存在,那么会解析 /etc/at.deny,其中找到的每个账户名都将被禁止向 atd 提交任务。如果文件存在但为空,则表示所有账户都可以提交任务给 atd

如果 /etc/at.allow/etc/at.deny 都不存在,这意味着只有超级用户可以提交任务给 atd

在系统中,我们使用沙箱。我们有 /etc/at.deny 和其中的内容如下:

root:# cat /etc/at.deny alias backup bin daemon ftp games gnats guest irc lp mail man nobody operator proxy qmaild qmaill qmailp qmailq qmailr qmails sync sys www-data

所以对于我们刚才看到的规则,如果 /etc/at.allow 文件不存在,所有列在 /etc/at.deny 中的账户都被禁止提交任务给 atd。这也很合理,因为我们可以看到这些账户与服务相关,或者是没有身份的用户,它们不应该有提交任务的需求。

这就是关于 at 服务部分的所有内容;接下来我们来看一下可以用来安排任务的工具。在客户端,我们可以使用以下工具来提交任务给 atd

atbatch 命令从标准输入或指定文件读取,任务将在稍后的时间执行,使用 /bin/sh

  • at: 这是我们使用的主要工具,它的功能是提交将在特定时间执行的任务。时间指定非常智能且灵活。

  • HH:MM: 这指定了当天运行 at 调用命令的时间。如果时间已经过去,该命令将计划在第二天同一时刻执行。

  • midnight: 这个任务意味着在午夜执行:

root:# at midnight
warning: commands will be executed using /bin/sh
at> ls
at> <EOT>
job 6 at Fri Apr 28 00:00:00 2017

提交任务时,只需在新的一行上按 Ctrl+D

  • noon: 这个任务意味着在中午执行

  • teatime: 这个任务将在下午 4 点执行

  • AM-PM: 我们可以添加后缀 AM 或 PM,将任务安排在早晨或下午的特定时间执行:

root:# echo "systemctl restart ssh" | at 04:43 AM warning: commands will be executed using /bin/sh job 7 at Thu Apr 27 04:43:00 2017 root:# echo "systemctl restart ssh" | at 04:43 PM warning: commands will be executed using /bin/sh job 8 at Thu Apr 27 16:43:00 2017 

  • date month_name [year]:我们还可以设置任务在某个特定的时间,具体到某天、某月,甚至可以选择年份;但是我们需要记住,无论选择哪种格式,日期必须跟随时间规格,而不能位于其前:
root:# echo "systemctl restart ssh" | at 11:45 Aug 28 warning: commands will be executed using /bin/sh job 12 at Mon Aug 28 11:45:00 2017 root:# echo "systemctl restart ssh" | at 11:45 Aug 28 2018 warning: commands will be executed using /bin/sh job 13 at Tue Aug 28 11:45:00 2018

  • MMDD[CC]YY:日期格式为月、日、可选世纪和年份,中间无空格:
root:# echo "systemctl restart ssh" | at 11:45 070527 warning: commands will be executed using /bin/sh job 14 at Mon Jul 5 11:45:00 2027

  • MM/DD/[CC]YY:日期格式为月/日/可选世纪/年份,用斜杠分隔:
root:# echo "systemctl restart ssh" | at 07:23 PM 08222017 warning: commands will be executed using /bin/sh job 15 at Tue Aug 22 19:23:00 2017

  • DD.MM.[CC]YY:日期格式为日.月.可选世纪.年份,使用点分隔:
root:# echo "systemctl restart ssh" | at 18:05 15.09.29 warning: commands will be executed using /bin/sh job 17 at Sat Sep 15 18:05:00 2029

  • [CC]YY-MM-DD:日期格式为可选世纪年-月-日,用破折号分隔:
root:# echo "systemctl restart ssh" | at 18:15 17-09-15 warning: commands will be executed using /bin/sh job 18 at Fri Sep 15 18:15:00 2017 

  • now + minutes | hours | days | weeks:我们也可以设置任务从当前系统时间起,按分钟、小时、天数或周数计算的执行时间:
root:# date ; echo "systemctl restart ssh" | at now + 1 minutes Thu Apr 27 05:22:11 EDT 2017 warning: commands will be executed using /bin/sh job 20 at Thu Apr 27 05:23:00 2017 root:# date ; echo "systemctl restart ssh" | at now + 1 days Thu Apr 27 05:22:59 EDT 2017 warning: commands will be executed using /bin/sh job 21 at Fri Apr 28 05:22:00 2017 root:# date ; echo "systemctl restart ssh" | at now + 2 weeks Thu Apr 27 05:23:05 EDT 2017 warning: commands will be executed using /bin/sh job 22 at Thu May 11 05:23:00 2017

  • today:我们可以设置任务在今天的某个特定时间运行;如果没有定义时间,它将会立即执行:
root:# date ; echo "systemctl restart ssh" | at today Thu Apr 27 05:25:06 EDT 2017 warning: commands will be executed using /bin/sh job 23 at Thu Apr 27 05:25:00 2017 root:# date ; echo "systemctl restart ssh" | at 14:28 today Thu Apr 27 05:25:19 EDT 2017 warning: commands will be executed using /bin/sh job 24 at Thu Apr 27 14:28:00 2017

  • tomorrow:我们可以设置任务在第二天的特定时间运行;如果没有定义时间,它将会在创建任务时的同一时间执行,但会是第二天:
root:# date ; echo "systemctl restart ssh" | at tomorrow Thu Apr 27 05:27:34 EDT 2017 warning: commands will be executed using /bin/sh job 25 at Fri Apr 28 05:27:00 2017 root:# date ; echo "systemctl restart ssh" | at 14:28 tomorrow Thu Apr 27 05:27:41 EDT 2017 warning: commands will be executed using /bin/sh job 26 at Fri Apr 28 14:28:00 2017

时间规格的完整参考资料可见于/usr/share/doc/at/timespec

正如我们从示例中看到的,命令可以从stdin或文件中读取,如果使用-f选项并指定文件名:

root:# echo "systemctl restart ssh" > /root/atjob1 root:# at -f /root/atjob1 14:28 tomorrow warning: commands will be executed using /bin/sh job 27 at Fri Apr 28 14:28:00 2017 

一旦任务被放入队列,它会保留一些来自创建时的状态:

除了BASH_VERSINFODISPLAYEUIDGROUPSSHELLOPTSTERMUID_umask外,其他环境变量都会保留自调用时的状态。

the working directory. the umask.

将会导出到任务中的环境库种类取决于未来的开发;例如,如果我们想为某个程序源代码调度编译任务,我们必须从任务本身设置LD_LIBRARY_PATHLD_PRELOAD等库,因为这些库不会被继承。

一旦任务执行完毕,其结果将显示在stdoutstderr中,并通过/usr/sbin/sendmail发送邮件给用户;但是,如果任务是通过suat执行,并且保留了原始用户 ID,那么结果会发送给最初登录到su的用户。

让我们运行一个简单的命令来生成一些输出,然后确保邮件被发送:

root:# echo "df" | at -m now

现在,我们检查一下根用户的默认别名邮箱(查看/etc/aliases以了解邮件会发送给谁):

From root@debian Thu Apr 27 07:56:28 2017 Return-path: <root@debian> Envelope-to: root@debian Delivery-date: Thu, 27 Apr 2017 07:56:28 -0400 Received: from root by spoton with local (Exim 4.84_2)
 (envelope-from <root@debian>) id 1d3i28-0002vS-Ep for root@debian; Thu, 27 Apr 2017 07:56:28 -0400 Subject: Output from your job 37 To: root@debian Message-Id: <E1d3i28-0002vS-Ep@spoton> From: root <root@debian> Date: Thu, 27 Apr 2017 07:56:28 -0400 Filesystem 1K-blocks Used Available Use% Mounted on /dev/sda1 117913932 12476420 99424792 12% / udev 10240 0 10240 0% /dev tmpfs 779256 9192 770064 2% /run tmpfs 1948140 0 1948140 0% /dev/shm tmpfs 5120 4 5116 1% /run/lock tmpfs 1948140 0 1948140 0% /sys/fs/cgroup tmpfs 389628 0 389628 0% /run/user/0

现在我们已经了解了如何调度一个任务,接下来看看它支持哪些选项:

  • -q: 强制 at 使用指定的队列放置作业。队列用单个字符指定,从 a 到 z 和从 A 到 Z;默认队列分别以 a 代表 atb 代表 batch 命名。具有更高字母的队列具有增加的 niceness,而具有名称 = 的特殊队列专用于实际运行的作业。如果将作业提交到具有大写字母的队列,则会被视为提交到 batch;因此,一旦达到时间规范,只有在系统的负载平均值低于阈值时,作业才会执行。

  • -V: 只是将实用程序的版本号打印到 stderr 并成功退出。

  • -m: 一旦作业完成,向用户发送电子邮件,即使作业本身没有输出。

  • -M: 永远不向用户发送任何电子邮件。

  • -f 文件名: 我们已经看到了这个选项;它强制 at 从指定文件中读取要在作业内运行的命令,而不是从 stdin 中读取。

  • -t [[CC]YY]MMDDhhmm[.ss]: 定义要运行命名为 at/ 的作业的时间。

  • -l: 实际上是 atq 的别名。

  • -r: 实际上是 atrm 的别名。

  • -d: 实际上是 atrm 的别名。

  • -b: 实际上是 batch 的别名。

  • -v: 在读取作业之前显示将执行作业的时间:

root:# at -vf /root/atjob1 14:28 tomorrow Fri Apr 28 14:28:00 2017 warning: commands will be executed using /bin/sh job 31 at Fri Apr 28 14:28:00 2017 

  • -c: 在 stdout 上显示指定的作业:
root:# at -c 21 #!/bin/sh # atrun uid=0 gid=0 # mail root 0 umask 22 XDG_SESSION_ID=68; export XDG_SESSION_ID SSH_CLIENT=192.168.0.10\ 32994\ 9999; export SSH_CLIENT SSH_TTY=/dev/pts/0; export SSH_TTY USER=root; export USER MAIL=/var/mail/root; export MAIL PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH PWD=/root; export PWD LANG=en_US.UTF-8; export LANG SHLVL=1; export SHLVL HOME=/root; export HOME LOGNAME=root; export LOGNAME SSH_CONNECTION=192.168.0.10\ 32994\ 192.168.0.5\ 9999; export SSH_CONNECTION XDG_RUNTIME_DIR=/run/user/0; export XDG_RUNTIME_DIR cd /root || {
 echo 'Execution directory inaccessible' >&2 exit 1 } systemctl restart ssh

  • batch: 当系统平均负载低于特定阈值时运行作业,默认阈值为 1.5。但是有一个重要的注意事项:为了正常工作,batch 依赖于在 /proc 上挂载的 Linux proc 文件系统:
root:# echo "systemctl restart ssh" | batch warning: commands will be executed using /bin/sh job 33 at Thu Apr 27 07:08:00 2017

如果用户在调用 at 时未登录,或者当名为 /var/run/utmp 的文件不可读时,作业结束时的电子邮件将发送到环境变量 LOGNAME 的值作为帐户找到的值。如果此变量不可用,则当前用户 id 将接收该电子邮件。

  • atq: 显示用户的待处理作业列表。如果由超级用户运行,则显示所有帐户的所有计划作业列表。列表的格式在这里:
job_id, date, hour, queue, account name

在这里,非特权用户看到的是:

root:# atq 3 Thu Apr 27 08:00:00 2017 a zarrelli

这就是 root 在同一系统上的同时看到的内容:

root:# atq 2 Wed Apr 26 14:00:00 2017 a root 3 Thu Apr 27 08:00:00 2017 a zarrelli

从列表中可以看出,名称为 aindeed 的队列被指定为单个字符,从 a 到 z 和从 A 到 Z,而默认队列的名称分别为 a 代表 atb 代表 batch。具有更高字母的队列具有增加的 niceness,而名为 = 的特殊队列专门用于实际运行的作业。如果 atq 被给定特定队列作为参数,则只显示该队列中的作业。让我们看看 atq 在没有参数的情况下能显示什么:

root:# atq 5 Wed Apr 26 05:46:00 2017 b root 2 Wed Apr 26 14:00:00 2017 a root 3 Thu Apr 27 08:00:00 2017 a zarrelli

现在,让我们将列表限制为仅显示 batch 队列:

root:# atq -q b 5 Wed Apr 26 05:46:00 2017 b root

因此,atq 支持以下选项:

  • -q: 它是 atq 接受的唯一选项,与 V 一起,限制其输出为指定队列的内容

  • -V: 只是将实用程序的版本号打印到 stderr 并成功退出

  • atrm:删除由任务 ID 标识的任务:

root:# atq 9 Wed Jan 31 11:45:00 2018 a root 3 Thu Apr 27 08:00:00 2017 a zarrelli 13 Tue Aug 28 11:45:00 2018 a root 29 Fri Apr 28 14:28:00 2017 a root

所以,我们有四个任务,其 ID 分别为391329;让我们将它们删除:

root:# atrm 3 9 13 29 And check the queue again: root:# atq root:#

没有其他内容了,我们完成了。最后需要注意的是,atrm只接受一个选项,那就是-V

到目前为止,我们看到的适用于一次性任务,因为at不会允许我们设置某些周期性任务;因此,如果我们想在一段时间内执行一些周期性任务,我们需要依赖另一种工具:著名的 cron 服务。

cron 调度器

服务器上最常用的工具之一实际上是调度器,即使我们没有意识到自己依赖它有多深。我们系统中一些无需人工干预的服务都是由调度器处理的,它负责在几天、几周甚至几个月的特定时间运行它们。所有这些看似简单、重复的任务,虽然对我们环境的正常运行至关重要,却在幕后默默进行,完成我们不愿意做的事情,例如,在需要时轮换所有系统日志。如果我们每天都得做这些任务,在疯狂的时间点,为所有需要维护的服务?不,我们有更重要的事情要做。cron 调度器没有更重要的事情要做;它的目标是每分钟唤醒一次,检查是否有任务需要执行,这使它成为执行繁琐重复任务的最佳人选,也许是每天或每周在同一时间执行。因此,既然本书的目的之一是帮助我们从系统中获得最大效益,我们将深入了解这个谦逊的助手,学习如何配置和管理它,从而帮助我们完成必要但不那么有趣的系统管理任务。

at一样,crontab 依赖三个不同的组件:一个名为cron的工具;一组配置文件,其中最著名的是/etc/crontab;还有一个名为 crontab 的客户端/编辑器。看起来有点混乱吗?让我们按顺序进行,并首先了解一下 cron 服务。

cron

该服务每分钟运行一次,检查存储在其 crontab 文件中的任务,并查看是否需要在当前分钟执行。如果找到任务,则执行;否则,cron 会在下分钟重新运行,依此类推。任务执行后,命令输出将通过邮件发送给 crontab 所有者,或者发送给 crontab 中的 MAILTO 环境变量指定的用户(如果有)。特别地,每分钟,cron 不仅会读取 crontab 文件,还会检查其 spool 目录的修改文件,或者检查 /etc/crontab 是否已更改,如果有变化,它会分析所有 crontab 文件的修改时间并重新加载它们,以便获取任务规范的任何更改。因此,如果我们做了更改,也无需担心重新启动 cron。它会自行管理这些更改,不过 cron 也能够应对时钟变动:如果时间回退少于 3 小时,已运行的任务不会重新执行。然后,如果时间向前调整不到 3 小时,跳过的 任务将在时钟到达新时间时立即执行。这只会影响那些设置了特定执行时间的任务。所以,使用 @hourly 等关键字的任务,以及在小时或分钟规格中使用通配符 * 的任务将不会受到影响。如果时钟变化超过 3 小时,所有任务将按照新的时间设置执行。

每个发行版可以实现不同类型的 cron 服务,并且配置文件的位置可能略有不同。如果不确定,man cronman crontab 可以显示该服务支持的功能以及相关文件所在的路径。

然后,还有一个惊喜:没有 cron。好吧,我们可以笑一笑,因为其实我们称之为 cron 的是该服务的守护进程部分,但提供该服务的实际程序可以根据我们使用的发行版而有所不同。我们有多种调度器可以选择。以下是其中的一些:

  • vixie-cron:这是所有现代 cron 的始祖:Paul Vixie 编写的著名 cron,编码于 1987 年。

  • bcron:它是一个安全性更强的 cron 替代品。

  • cronie:它是基于 Fedora 的 vixie-cron 版本。

  • dcron:Dillon 的 cron 是一个精简版的 cron;它既安全又简单。

  • fcron:它可以作为经典 vixie cron 的不错替代品,并且它是为非持续运行的系统设计的。因此,它有一些有趣的功能,比如能够在启动时调度任务。

这些只是一些 cron 实现,我们相信某个地方可能还有更多的实现,可能是某个分支或者是某个解决特定需求的原创版本。在本书中,我们将引用安装在 Debian 系统上的 vixie-cron。

我们与 cron 的交互选项不多,但让我们来看一下它支持什么:

  • -f:不进行守护进程化,并将保持在前台。这对于调试其运行状态非常有用。

  • -l:启用符合 Linux 标准基准的脚本名称,用于 /etc/cron.d 中的文件(lanana.org/lsbreg/cron/index.html)。只有该目录中的文件会受到影响;/etc/cron.hourly/etc/cron.daily/etc/cron/weekly/etc/cron.monthly 中的文件不会受到影响。

  • -n:在任务执行后的电子邮件主题中包含完全限定的域名;否则,只会使用主机名。

  • -l:设置日志级别。错误总是会被记录;但是,不同的级别解锁了额外的信息,这些信息通过系统日志设施记录,通常是在 cron 设施下的 syslog。可以将单个级别的值相加,得到的结果将启用收集多种信息:

    • 1:记录所有 cron 任务的开始

    • 2:记录所有 cron 任务的结束状态

    • 4:记录所有失败任务的结束状态,因此所有退出状态不为 0 的任务都会被记录

    • 8:记录所有 cron 任务的进程标识号

    • 15:将收集所有在前面级别中捕获的信息(8+4+2+1)

    • 默认的日志级别是 1,但如果我们希望完全禁用日志记录,可以指定 0

使用 cron 时需要注意几点:

cron 守护进程在处理任务时设置了一些环境变量,示例如下:

  • SHELL:设置为 /bin/sh

  • LOGNAME:从与 crontab 所有者相关的 /etc/passwd 行内容中设置

  • HOME:从与 crontab 所有者相关的 /etc/passwd 行内容中设置

  • PATH:设置为 /usr/bin:/bin

如果用户需要设置其他环境变量,最简单的方式是将它们设置在 vixie-cron 的 crontab 定义中;其他实现(如 cronie)不允许这样做,因此你可以在调用属于该任务的脚本或程序之前,在 crontab 行条目中预先添加这些环境变量:

# m h dom mon dow command export HTTP_PROXY=http://192.168.0.1:8080; env >> /var/log/proxy

如果我们查看 syslog 文件,我们可以看到 crontab 被安装:

Apr 28 16:57:35 moveaway crontab[27929]: (root) REPLACE (root) Apr 28 16:57:35 moveaway crontab[27929]: (root) END EDIT (root) Apr 28 16:58:01 moveaway cron[607]: (root) RELOAD (crontabs/root) Apr 28 16:58:01 moveaway CRON[27977]: (root) CMD (export HTTP_PROXY=http://192.168.0.1:8080; env >> /var/log/proxy)

因此,如果我们没有犯任何错误,我们应该看到 /var/log/proxy 文件被创建并更新,其中包含 env 命令被调用时的环境内容:

root:# cat /var/log/proxy LANGUAGE=en_GB:en HOME=/root LOGNAME=root PATH=/usr/bin:/bin LANG=en_GB.UTF-8 SHELL=/bin/sh PWD=/root HTTP_PROXY=http://192.168.0.1:8080 LANGUAGE=en_GB:en HOME=/root LOGNAME=root PATH=/usr/bin:/bin LANG=en_GB.UTF-8 SHELL=/bin/sh PWD=/root

HTTP_PROXY 环境变量为任务设置,我们将看到这些行逐渐增长到文件中,所以稍后我们将看到如何删除该 crontab 或单个任务。

读取一个环境变量,而不是设置它,这个变量叫做 MAILTO。如果定义了它,任务的输出将发送到指定的名称;如果为空,则不会发送邮件。MAILTO 也可以设置为将电子邮件发送给由逗号分隔的用户列表。如果没有设置,MAILTO 将被设置为将任务结果发送给 crontab 所有者。

一些 cron 实现支持 PAM,因此如果它们恰好为用户设置了一个新的 cron 作业,并且我们遇到了一些授权问题,那么我们需要查看以下三个文件:

  • /etc/cron.allow

  • /etc/cron.deny

  • /etc/pam.d/cron

/etc/at.allow 是第一个被读取的文件(如果存在)。如果其中找到任何账户名,则这些账户将是唯一被允许使用 crontab 工具提交作业的账户。如果 /etc/cron.allow 不存在,那么会解析 /etc/cron.deny,其中找到的每个账户名都会被禁止提交作业到 cron。如果文件存在,但为空,则只有超级用户或 /etc/cron.allow 中列出的用户才能提交作业。如果 /etc/cron.allow/etc/cron.deny 都不可用,任何用户都可以提交作业到 cron。

我们已经讨论了 crontab 工具。那么它是什么呢?它实际上是让我们编写 crontab 文件的程序,crontab 文件告诉 cron 执行哪些作业,何时执行,以及由谁执行。事实上,每个用户可以拥有自己的 crontab,该文件存储在 /var/spool/cron/crontabs 中;但我们不应手动编辑这些文件。我们必须依赖 crontab 工具,它将允许我们以正确的方式编辑并安装 crontab 文件。那么,如何使用 crontab 呢?假设我们已经有了一个包含作业规格的文件,我们稍后会看到如何编写它;我们需要执行的唯一命令是:

crontab filename

这将从指定的文件为当前用户安装一个新的 crontab,但我们也可以通过命令行手动输入作业详情,使用 crontab

从前面的命令行可以推测,如果没有传递用户名,crontab 将作用于调用它的用户的 cron 作业。因此,如果我们想列出或修改用户的 crontab,只要我们具有足够的权限(即超级用户权限),我们可以执行以下命令:

root:# crontab -u zarrelli -l no crontab for zarrelli

如果我们想编辑并安装一个新的 cron 作业,我们可以使用以下语法:

crontab -u user -e

查看以下示例:

root:# crontab -e -u zarrelli no crontab for zarrelli - using an empty one Select an editor. To change later, run 'select-editor'. 1\. /bin/nano <---- easiest 2\. /usr/bin/mcedit 3\. /usr/bin/vim.gtk 4\. /usr/bin/vim.tiny Choose 1-4 [1]: 

由于是第一次调用 crontab 工具,系统可能会要求选择默认编辑器;如果没有设置,则会实例化 visual 或 editor 环境变量。如果都没有设置,则使用 /usr/bin/editor

在所示的示例中,Debian 发行版触发了 /etc/alternatives 的配置,该配置提供了指向默认编辑器的链接。

进入编辑器后,每个 cron 作业必须单独指定在一行上,例如 * * * * * ps

我们稍后会看到这一序列的含义;目前,我们只需要退出编辑器并保存内容:

crontab: installing new crontab

完成后,crontab 会通知我们 crontab 已经安装,显示在 stdout 上也许会很有意思:

crontab -u zarrelli -l

如下所示的示例:

root:# crontab -u zarrelli -l # Edit this file to introduce tasks to be run by cron. # # Each task to run has to be defined through a single line # indicating with different fields when the task will be run # and what command to run for the task # # To define the time you can provide concrete values for # minute (m), hour (h), day of month (dom), month (mon), # and day of week (dow) or use '*' in these fields (for 'any').# # Notice that tasks will be started based on the cron's system # daemon's notion of time and timezones. # # Output of the crontab jobs (including errors) is sent through # email to the user the crontab file belongs to (unless redirected). # # For example, you can run a backup of all your user accounts # at 5 a.m every week with: # 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/ # # For more information see the manual pages of crontab(5) and cron(8) # # m h dom mon dow command * * * * * ps

如果我们想删除 crontab,可以使用一个便捷选项,将其完全移除:

crontab -u zarrelli -r 

但要小心——如果你想删除某些任务但保留其他任务,最好的解决方案是再次编辑 crontab,删除我们不想要的任务对应的行,然后保存。新的 crontab 将包含剩余的任务,并会完全替换旧的 crontab

如果我们不确定是否删除 crontab,可以设置一个指向 crontab -i -r 的别名,使用 -r 并在删除 crontab 前提示用户确认:

root:# crontab -ir crontab: really delete root's crontab? (y/n)

我们不会修改一些特定发行版的配置,比如通过 /etc/crontab 文件提供的对 /etc/cron.hourly/etc/cron.daily/etc/cron.weekly/etc/cron.monthly 的支持,否则我们将不得不深入分析所有主流发行版中所有可能的 cron 实现的细节和配置。

我们这里感兴趣的是理解处理 cron 的基本概念,这样无论我们遇到什么不同的实现方式,都能够应对并理解其独特性。现在还有几个有趣的点需要看:一个是我们用来定义任务的语法,另一个是对 anacron 的快速了解:一个我们经常听说的工具。

即使一个 crontab 文件乍一看可能有点难懂,但理解字符序列的含义并不难:第一个字段是任务必须执行的分钟;第二个字段是小时;第三个是日期;第四个是月份;第五个是星期几;第六个是要执行的命令。所以,我们几行前创建的 crontab 可以按以下表格的方式理解:

字段 ***** ***** ***** ***** ***** ps
分钟 X
小时 X
日期 X
月份 X
星期几 X
命令 X

字段的含义

好的,现在我们知道这些字段的含义了,那么那些星号是什么,它们在每个字段中可以写什么呢?另一个表格会让这一切更容易理解:

字段 允许的值 元字符
分钟 0-59 * , - /
小时 0-23 * , - /
日期 1-31 * , - /
月份 1-12 或 Jan-Dec * , - /
星期几 0-7 或 Sun-Sat * , - /

每个字段中可以使用的值

我们快完成了。还有几件事情需要学习,之后我们就能完全理解一行 crontab;但首先,那些元字符是什么,它们意味着什么?

请记住,星期天可以在星期字段中同时用 07 来表示。

  • *:代表每个,可以用于所有字段。所以,在分钟字段中,它表示 cron 每分钟执行一次任务;在小时字段中,每小时执行一次,以此类推。

  • ,:逗号定义一个列表。例如,在月份的日期字段中,1515会指示 cron 在每月的第一天、第五天和第十五天运行任务。

  • -:连字符定义一个包含范围。例如,在星期几的字段中,4-7将强制 cron 在星期四到星期天执行任务。

  • /:正斜杠用于定义步长,它可以与范围一起使用,这样它会跳过范围内的数字值。例如,分钟字段中的1-59/2将给出每小时的所有奇数分钟,因为它从 1 开始,然后等待 2 分钟再执行下一个任务;也可以通过列表来指定:1357911131517192123252729313335373941434547495153555759

如我们所见,步长非常实用。正斜杠也可以与星号结合使用:在小时字段中,*/2表示每隔 2 小时,在月份字段中表示每隔两个月,等等。

某些 cron 实现支持一个额外的字段用于年份,其值从19702099,并支持*-元字符。

在分析 crontab 行之前,我们必须先了解一组特殊标记。我们可以使用一些带有@前缀的特殊关键字来定义重复的任务,如下表所示:

关键字 执行 与...相同
@yearly 每年执行一次,在 1 月 1 日的午夜时分 0 0 1 1 *
@annually 等同于@yearly 0 0 1 1 *
@monthly 每月执行一次,在每月的第一天的午夜时分 0 0 1 * *
@weekly 每周执行一次,在周六与周日的午夜之间 0 0 * * 0
@daily 每天执行一次,在午夜时分 0 0 * * *
@midnight 等同于@daily 0 0 * * *
@hourly 每小时执行一次,在整点时刻 0 * * * *
@reboot 在 cron 守护进程启动时执行

具有特殊含义的关键字

This替换了前五个字段,可以在没有特殊小时或日期约束的情况下使用它,这样可以在一个通用的时间范围内执行任务。

在继续之前,有几点需要注意:

  • 每一行定义 cron 任务的行必须以换行符结尾。

  • 命令字段中的百分号%会被转换为换行符,所有在第一个%之后的数据将被发送到要执行的命令的stdin。可以通过将百分号写成%%来转义,这样它就不会被解释为换行符。

  • 在 crontab 文件中,空行、前导空格和制表符不会被解析。如果一行以#开头,它将被视为注释并且不会被解析。

  • 在 crontab 文件中,我们可以设置一些变量或定义 cron 任务;不允许做其他事情。

  • 变量可以定义为VARIABLE_NAME = value

  • 等号周围的空格是可选的。

  • 变量不会进行扩展或替换。所以,VARIABLE_NAME=$LOGNAME不会实例化VARIABLE_NAME,因为该值字符串甚至没有解析进行扩展或替换。

  • 空值必须用引号括起来,如果值中包含空格,必须使用引号以保留它们。

  • 不支持每个用户的时区。系统默认时区将被使用。

所以,考虑到这一点,让我们看一下 cron 作业规范:

35 1-24/4 * * Mon,Thu /opt/scripts/script.sh
分钟 小时 月中的天数 月份 星期几 命令
在每个 4 小时的第 35 分钟,1 到 24 小时之间,星期一和星期四执行

一个有用的网格来理解作业规范行

通过表格形式,作业规范更容易理解,如果我们使用一些关键字,它会变得更加简单:

@weekly /opt/scripts/script.sh

就这些,定义被缩短为两个简单的字段。但对字段的调整可能会导致一些棘手的情况:

1-59/2 * * * * /opt/scripts/script.sh

这是什么作用?它会每个奇数分钟执行一次脚本,而不是偶数分钟,奇数分钟。

但如果系统重启或是桌面计算机,可能会关闭好几天或几周怎么办?使用 cron 会导致某些作业根本不执行或被跳过,这不是理想的行为。这个时候 anacron 就派上用场了:即使我们在预定时间之后打开桌面,它也会运行作业。怎么做到的呢?很简单,这个工具会记录所有作业及其执行时间,使用的是一系列带有时间戳的文件,保存在/var/spool/anacron中。

让我们有条理地继续,看看驱动 anacron 的文件,文件名是/etc/anacron

为了更好地理解它,我们必须查看其内容:

root:# cat /etc/anacrontab # /etc/anacrontab: configuration file for anacron # See anacron(8) and anacrontab(5) for details. SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin HOME=/root LOGNAME=root # These replace cron's entries 1 5 cron.daily run-parts --report /etc/cron.daily 7 10 cron.weekly run-parts --report /etc/cron.weekly @monthly 15 cron.monthly run-parts --report /etc/cron.monthly

正如我们所看到的,我们可以在 crontab 中使用变量,但作业定义的格式略有不同,并且可以采用以下之一的语法:

period delay job_name command @period_identifier delay job_name command

  • period:以天为单位,指定作业运行的频率。例如,10 表示每 10 天运行一次。

  • @period:允许使用一些关键字来指定频率:@daily@weekly,和@monthly分别表示每天、每周和每月执行一次。

  • delay:以分钟为单位,定义 anacron 在达到阈值后执行计划作业的延迟时间。

  • job-name:我们可以为作业提供任何标识符,但不允许使用斜杠。anacron 将使用它作为作业时间戳文件的名称。

  • command:可以是任何命令。

正如我们所看到的,示例中的标准 anacron 文件将运行 run-parts 实用程序,并传入目录作为参数。而 run-parts 的确切任务是执行它在给定目录中找到的脚本。因此,解读 anacron 行应该变得简单了,不是吗?编辑它也很简单:我们可以手动进行,无需特殊的工具或程序。

anacron 的工作方式非常直接有效:它检查在 anacrontab 文件中指定的每个任务,并查看它是否在字段中指定的最近 x 时间内执行过。如果任务未执行并且达到阈值,它将在等待任务定义第二个字段中设置的延迟后执行任务。任务执行后,anacron 会在与任务相关的时间戳文件中记录日期;下次,它只需要读取该文件就能知道接下来该做什么:

root:# cat /var/spool/anacron/cron.weekly 20170424

一旦所有计划的任务都已执行完毕,anacron 会退出;但是,如果我们发送一个 SIGUSR1 信号给 anacron 来终止它,它将等待任何正在运行的任务完成,然后干净地退出。

但 anacron 还有一个任务,在任务执行完毕后会执行:如果任务产生了任何输出,它会发送邮件给运行 anacron 的用户,通常是 root,或者是 anacrontab 中 MAILTO 环境变量指定的用户。如果实例化了 LOGNAME 变量,它会作为邮件的发送者。

最后,让我们看看 anacron 支持哪些选项:

  • -f:强制 anacron 执行所有已定义的任务,而不考虑时间戳。

  • -u:仅更新任务的时间戳为当前日期,而不执行任务。

  • -s:串行执行任务;即在开始下一个任务之前,anacron 会等待当前任务完成。

  • -n:忽略每个任务的延迟规格,并在达到阈值时立即执行任务。意味着启用 -s 选项。

  • -d:通常 anacron 会在后台运行,但此选项会强制其在前台运行。它对于调试非常有用,因为 anacron 会将运行时消息发送到 stderr 和 syslog。输出的邮件仍然会发送给接收者。

  • -q:不将消息打印到 stderr,并且意味着启用 -d 选项。

  • -t 文件:从指定的文件读取任务定义,而不是默认的 anacrontab。

  • -T:测试 anacrontab 的有效性。如果有任何错误,将显示错误信息,anacron 会返回值 1;否则,返回值为 0

  • -S 目录:使用指定的目录来存储时间戳文件。

  • -V:打印 anacron 版本并退出。

  • -h:打印使用帮助并退出。

概要

我们现在有两种方法来执行任务:一种是守护进程,它在后台分叉并一直工作,执行一些 hopefully 重要的任务;另一种是调度器,非常适合那些必须按周期执行的任务。这些工具确实能帮助我们保持一切井然有序,并通过简化服务器或甚至桌面维护,执行复杂且乏味的任务。但 Bash 不仅仅由命令、脚本、任务和服务组成:它是我们每天工作的家。它是我们的游乐场,我们的工作台,需要熟悉的地方。所以,下一章将讨论一些工具、配置和建议,帮助我们将 Bash 打造成一个舒适的数字生活空间。

第十四章:安全性时刻

安全性无论我们身处何地都很重要。例如,在建筑工地上,像在新构建的操作系统中一样,安全性是确保事情以正确方式完成的关键因素。我们的 shell 在安全性方面也不例外:我们大多数时间都待在环境中,努力完成任务,保持一切井然有序。本章将为我们提供一些快速的解决方案和提示,帮助我们加强安全性并避免最常见的问题。我们不会使用像安全性或其他内核级别增强这样的更高级工具:这些工具本身就需要一本书的篇幅,并且它们是在清理我们的 shell 后才会使用的。我们将进行家务管理,没有什么真正侵入性的操作,只是做一些修饰性工作,力求在安全性、可靠性和可用性之间找到一个平衡;这其实是一个很难实现的目标:加强过多的话,即使是最简单的任务也几乎无法完成。如果我们过于注重安全性,可能会导致我们的系统过于暴露或不安全。因此,我们将尝试找到一个最佳平衡点,确保系统既可用,又相对安全;但最终还是由每个系统的管理员决定这个平衡点应该是什么:我们只能提供一些建议和展示可能的操作。

受限 shell

有多种方法可以限制用户在系统上的操作,并且有很多原因导致我们会限制用户与系统的交互:可能我们只希望用户能够将文件复制进出系统,或者为他们提供一个简单的主目录,让他们可以在里面工作而不去窥探系统中的其他内容。无论我们的目标是什么,我们都可以从使用受限 shell 开始。

Bash 本身提供了额外的安全层,使用以下选项:

  • rbash

  • --restricted

  • -r

使用 --restricted-r 选项调用 rbash 或直接调用 bash,将启动一个受限的 Bash 实例,从而限制用户在该环境中可以执行的操作:

  • 用户不能使用 cd 内建命令更改目录。用户将被阻止设置或取消设置以下环境变量的值:

    • BASH_ENV

    • ENV

    • SHELL

    • PATH

  • 用户无法指定带有斜杠的命令名称,这意味着无法使用绝对路径的命令名称。不能将包含斜杠的文件名作为内建命令 . 的参数传递。因此,用户将无法从主目录外部加载(读取并执行)文件。

  • 不能传递包含斜杠的文件名作为内建命令 hash 的参数,使用 -p 选项时尤为如此。hash 会通过在由环境变量 $PATH 指定的目录中查找,确定作为参数给定的命令的完整文件名。如果给定了 -p filename 选项,hash 会将文件名视为查找命令时的完整路径。因此,不能调用位于主目录以外的命令。

  • 在 Shell 环境启动时,函数定义不会被导入。

  • 启动时,环境变量 SHELLOPTS 的值不会被考虑,因此不会为 Shell 设置任何选项。

  • 使用标准操作符 >, >|, <>, >&, &>, >> 不允许进行重定向。

  • 无法使用内建的 exec 命令来替换 Shell 为其他命令。

  • 无法使用 enable 内建命令通过 -d-f 选项添加或删除内建命令。

  • 无法使用 enable 内建命令来启用或禁用 Bash 内建命令。

  • 对于内建命令,不允许使用 -p 选项,因此无法操作 $PATH

  • 无法使用 set +rset +o restricted 关闭受限模式。

所以,尽管有这些限制,用户还是被限制在他的主目录中。但如何设置一个 rbash 登录 Shell 呢?最简单的方法是找到 Bash 链接并将其重定向到 rbash

root:# which bash /bin/bash root:# which rbash /bin/rbash root:# ls -lah /bin/rbash lrwxrwxrwx 1 root root 4 Nov 5 2016 /bin/rbash -> bash

在这种情况下,rbashbash 之间已经存在链接,但在这种情况下它们没有任何链接,所以我们必须创建一个:

root:# cd /bin root:# ln -s bash rbash

然后,我们必须检查 rbash 是否列在 /etc/shells 中,该文件列出了有效登录 Shell 的完整路径:

root:# cat /etc/shells # /etc/shells: valid login shells /bin/sh /bin/dash /bin/bash /bin/rbash

现在,让我们创建一个具有限制 Shell 的用户:

root:# adduser --shell /bin/rbash restricted Adding user `restricted' ... Adding new group `restricted' (1000) ... Adding new user `restricted' (1000) with group `restricted' ... Creating home directory `/home/restricted' ... Copying files from `/etc/skel' ... Enter new UNIX password: Retype new UNIX password: passwd: password updated successfully Changing the user information for restricted Enter the new value, or press ENTER for the default Full Name []: Room Number []: Work Phone []: Home Phone []: Other []: Is the information correct? [Y/n] y

完成后,让我们 su 到该用户并测试 cd 命令:

root:# cd /home/restricted root:# su restricted restricted:~$ cd restricted:~$ cd: restricted

到这里了。cd 命令被如我们预期的那样限制。让我们检查一下其他的限制:

restricted:~$ pwd /home/restricted restricted:~$ test "Redirection test"> redirected_file rbash: redirected_file: restricted: cannot redirect output

很好,没有重定向,不过这个“笼子”并没有完全隔离:

restricted:~$ ls -lah /sbin/c* -rwxr-xr-x 1 root root 19K Mar 30 2015 /sbin/capsh -rwxr-xr-x 1 root root 243K Mar 29 2015 /sbin/cfdisk -rwxr-xr-x 1 root root 23K Mar 29 2015 /sbin/chcpu -rwxr-xr-x 1 root root 9.4K Aug 23 2014 /sbin/crda -rwxr-xr-x 1 root root 1.2K Jan 22 2015 /sbin/cryptdisks_start -rwxr-xr-x 1 root root 1.2K Jan 22 2015 /sbin/cryptdisks_stop -rwxr-xr-x 1 root root 58K Jan 22 2015 /sbin/cryptsetup -rwxr-xr-x 1 root root 45K Jan 22 2015 /sbin/cryptsetup-reencrypt -rwxr-xr-x 1 root root 11K Mar 29 2015 /sbin/ctrlaltdel

这个受限用户仍然可以在其目录外做一些事情。让我们通过使用本地配置文件来覆盖这个限制:

root:# mkdir /home/restricted/bin root:# ln -s /bin/df /home/restricted/bin/df

现在,让我们删除在主目录中找到的 .bash_profile.profile 文件,如果它们不存在,就创建一个 .bashrc 文件,其中的唯一一行应该是:

PATH=$HOME/bin

现在,让我们阻止用户修改它:

root:# chown root. /home/restricted/.bashrc root:# chmod 755 /home/restricted/.bashrc

现在让我们 su

root:# su restricted

让我们检查一下我们能做些什么:

restricted:~$ cd rbash: cd: restricted

我们不被允许这么做,这一点我们已经知道了。让我们试着列出一些文件:

restricted:~$ ls rbash: ls: command not found

很好,除了我们的 $HOME/bin 目录外没有其他命令可用。让我们再试一次:

restricted:~$ ping rbash: ping: command not found

正如预期的那样,出现了另一个失败。现在让我们尝试一下我们在用户的 $HOME/bin 目录中链接的 df 命令:

restricted:~$ df Filesystem 1K-blocks Used Available Use% Mounted on /dev/sda1 117913932 12494776 99406436 12% / udev 10240 0 10240 0% /dev tmpfs 779256 9192 770064 2% /run tmpfs 1948140 0 1948140 0% /dev/shm tmpfs 5120 4 5116 1% /run/lock tmpfs 1948140 0 1948140 0% /sys/fs/cgroup tmpfs 389628 0 389628 0% /run/user/0

这个方法有效,我们成功限制了用户可以访问的命令,并将其限制在他的主目录内。很棒,看起来已经被隔离了,但还是有一些限制:

受限用户可以通过运行具有 Shell 函数的程序逃离这个笼子。一个经典的例子是 vi 编辑器:

restricted:~$ vi :set shell=/bin/bash :shell restricted:~$ pwd /home/restricted restricted:~$ cd / restricted:~$ ls -lah total 11G drwxr-xr-x 22 root root 4.0K Apr 17 06:32 . drwxr-xr-x 22 root root 4.0K Apr 17 06:32 .. drwxr-xr-x 2 root root 4.0K May 8 05:10 bin drwxr-xr-x 3 root root 4.0K Apr 16 07:51 boot drwxr-xr-x 19 root root 3.2K May 9 04:05 dev drwxr-xr-x 131 root root 12K May 8 05:34 etc drwxr-xr-x 4 root root 4.0K May 8 05:34 home lrwxrwxrwx 1 root root 31 Apr 16 06:14 initrd.img -> /boot/initrd.img-3.16.0-4-amd64 drwxr-xr-x 21 root root 4.0K Apr 17 03:59 lib drwxr-xr-x 2 root root 4.0K Apr 16 06:13 lib64 drwx------ 2 root root 16K Apr 16 06:13 lost+found drwxr-xr-x 3 root root 4.0K Apr 16 06:13 media drwxr-xr-x 2 root root 4.0K Apr 16 06:13 mnt drwxr-xr-x 2 root root 4.0K Apr 16 06:13 opt -rw-r--r-- 1 root root 10G Apr 16 07:55 playground dr-xr-xr-x 113 root root 0 May 9 04:05 proc drwx------ 9 root root 4.0K May 9 04:07 root drwxr-xr-x 21 root root 840 May 9 04:10 run drwxr-xr-x 2 root root 4.0K Apr 17 03:59 sbin drwxr-xr-x 2 root root 4.0K Apr 16 06:13 srv dr-xr-xr-x 13 root root 0 May 9 04:05 sys drwxrwxrwt 8 root root 4.0K May 9 04:11 tmp drwxr-xr-x 10 root root 4.0K Apr 16 06:13 usr drwxr-xr-x 12 root root 4.0K Apr 16 06:32 var lrwxrwxrwx 1 root root 27 Apr 16 06:14 vmlinuz -> boot/
vmlinuz-3.16.0-4-amd64 restricted:~$

逃离受限 Shell 的另一种方法是启动一个没有限制的 Shell:

restricted:~$ cd rbash: cd: restricted restricted:~$ bash restricted:~$ cd / restricted:~$ pwd / restricted:~$

这也意味着任何具有有效 sha-bang 的脚本都会调用完整的 shell,从而逃脱任何限制。这些方法意味着用户可以访问 Bash 或任何具有 shell 功能的程序,否则逃出限制区就不容易。但我们需要牢记一点:这是将某些用户限制在其工作空间中的方法,它将把他们彼此隔离,给予他们独立的家目录,并防止他们无意中破坏系统的其他部分。它并不是一个完备的安全层;对于这类问题,我们应该依赖更为底层的解决方案,如内核级别的安全措施,这超出了本书的讨论范围,因为这需要大量的关于安全、内核编译、第三方产品、加固等方面的解释。再说一次,这将是一本独立的书。

因此,我们希望保持整洁,如何有序地承载远程连接呢?

OpenSSH 的限制性 shell

尽管 OpenSSH 的限制性 shell(www.pizzashack.org/rssh/)严格来说并不是一个 shell 工具,但它的简洁性使其成为帮助保持系统整洁的好工具,尤其是在某个访客敲门时。Rssh 支持多种发行版和平台,提供了一个限制性 shell,不仅支持scpftp,还支持csvrdistrsync。因此,我们可以创建仅允许文件复制或同步的账户,而不提供完全的 shell 访问;这对于保持低调并降低服务器遭受攻击的风险非常有用。

第一阶段是从包或源代码安装 rssh。在我们的示例中,我们将依赖于一个包,因为所使用的发行版 Debian 中已有此包;此外,使用包管理可以确保在需要时,维护者会对该工具进行升级和修补:

root:# apt-get install rssh Reading package lists... Done Building dependency tree Reading state information... Done Suggested packages: cvs rdist subversion makejail The following NEW packages will be installed: rssh 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. Need to get 54.4 kB of archives. After this operation, 119 kB of additional disk space will be used. Get:1 http://ftp.us.debian.org/debian/ jessie/main rssh amd64 2.3.4-4+b1 [54.4 kB] Fetched 54.4 kB in 0s (103 kB/s) Preconfiguring packages ... Selecting previously unselected package rssh. (Reading database ... 62697 files and directories currently installed.) Preparing to unpack .../rssh_2.3.4-4+b1_amd64.deb ... Unpacking rssh (2.3.4-4+b1) ... Processing triggers for man-db (2.7.0.2-5) ... Setting up rssh (2.3.4-4+b1) ...

安装完成后,我们将获得一个新的 shell 二进制文件:

root:# ls -lah /usr/bin/rssh -rwxr-xr-x 1 root root 31K Nov 8 2014 /usr/bin/rssh

现在,让我们使用这个新的二进制文件作为限制性用户的 shell:

root:# chsh -s `which rssh` restricted

接下来,让我们直接在/etc/passwd中验证是否已分配 shell:

root:# egrep restricted /etc/passwd restricted:x:1000:1000:,,,:/home/restricted:/usr/bin/rssh

一切看起来正常,接下来让我们从远程连接到限制性用户所在的系统:

zarrelli:~$ ssh -p 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Restricted@192.168.0.5's password: The programs included with the Debian GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. This account is restricted by rssh. This user is locked out. If you believe this is in error, please contact your system administrator. Connection to 192.168.0.5 closed.

该账户已被锁定,这是rssh的默认行为,因为我们尚未进行配置。因此,接下来我们来看一下在/etc/rssh.conf文件中可以使用的一些主要配置指令,以启用某些协议和每个用户的配置:

  • allowsftp:允许 sftp 连接。

  • allowcvs:允许 cvs 连接。

  • allowrdist:允许 rdist 连接。

  • allowrsync:允许 rsync 连接。

  • allowsvnserve:允许 svnserve 连接。

  • umask:设置在 scp 或 sftp 会话期间创建的文件和目录的 umask。umask 通常由 shell 在用户登录时设置,因此为了避免这种情况,rssh 必须自己设置 umask。

  • logfacility:指定用于日志记录的 syslog 设施或 C 宏。

  • chrootpathrssh 的一个辅助应用程序(rssh_chroot_helper)调用 chroot() 系统调用,改变会话的文件系统根目录。例如:chrootpath=/opt/jails 将会把虚拟文件系统的根目录更改为 /opt/jails,适用于其 shell 为 rssh 的用户。

登录后,/var/caged/users 目录将显示为用户的文件系统根目录,且无法超出该目录。如果使用了此指令,则必须设置 chroot 监狱,为用户提供最小化的环境。稍后我们将看到如何操作。如果在 /etc/passwd 中定义的用户主目录位于 chrootpath 指定的路径中,那么用户将被 chdired 到其主目录,否则将被 chdiredchroot 监狱的根目录。

  • user: 使用这个指令,我们可以为每个用户设置配置,覆盖所有其他指令。user 关键字出现在用冒号(:)分隔的字符串中,具有以下结构:
user = "username:unask:access_digits:chroot_path"

那么,接下来让我们看看每个字段代表什么:

  • username:这是我们希望为其设置配置的帐户名。

  • umask:表示用户的 umask,以八进制形式表示。它遵循与为 Bash shell 设置 umask 时相同的规范。

  • access_digits:这六个二进制数字指定用户是否被允许使用 rsyncrdistcvssftpscpsvnserve,按顺序列出。0 表示不允许用户使用,1 表示允许用户使用。

  • path:指定用户将被限制到的目录路径。

引号不是强制性的,除非路径字段中有空格。在这种情况下,我们可以使用单引号或双引号。= 两边的空格是可以的。所以,像 user=restricted:022:100000: 这样的表达式意味着用户 restricted 的 umask 是 022,并且该用户可以使用 rsync 连接。没有指定 chroot

user = restricted:011:000110:"/usr/local/chroot jails"

之前的语句意味着用户 restricted 拥有 011 的 umask,sftpscp 连接可用,并且它将被限制在 /usr/local/chroot jails 中。

了解更多配置后,让我们通过在 /etc/rssh.conf 中添加 user = restricted:277:000010 来仅启用受限用户的 scp 连接。

现在是检查我们是否终于可以访问远程系统的时候了:

zarrelli:~$ ssh -p 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. restricted@192.168.0.5's password: The programs included with the Debian GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. Last login: Tue May 9 06:15:40 2017 from 192.168.0.10 This account is restricted by rssh. Allowed commands: scp If you believe this is in error, please contact your system administrator. Connection to 192.168.0.5 closed.

这很有趣。消息与上次尝试略有不同:我们仍然无法通过 ssh 登录到远程服务器,但它说明尽管帐户受到 rssh 的限制,我们仍然可以使用 scp。所以,让我们试试看:

zarrelli:~$ scp -P 9999 test_file restricted@192.168.0.5: Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Restricted@192.168.0.5's password: test_file 

看起来它好像成功了。让我们看一下远程服务器:

root:# pwd /home/restricted root:# ls -lah test_file -r-------- 1 restricted rssh_users 0 May 10 05:24 test_file

来了。在受限用户的主目录中,我们的文件访问权限已设置为 400,如预期的那样。简单明了。但这里有一个小问题,我们可以在这里看到问题所在:

使用 FileZilla,我们能够浏览远程主机的整个文件系统。

即使用户被限制在一个协议中,他仍然可以浏览远程文件系统,除了 Unix/POSIX 文件权限之外没有任何限制。在示例中,我们启用了 SFTP 协议,并实际以受限用户身份连接到系统,浏览 /etc 目录。我们能防止这种情况吗?可以,我们可以将用户 chroot 到一个理想的文件系统中,最好是挂载为 nosuid,如果支持的话,还可以选择 noxec 选项。这样,即使用户上传了可执行文件,他也无法运行它或利用任何 suid 权限。这个操作容易做吗?不,创建 chroot 监狱可能非常困难,因为这需要将相关的二进制文件和库文件复制到监狱中;版本和路径可能会根据所使用的发行版以及版本本身发生变化。实际上,rssh 的源 tarball 提供了一个脚本,通过一些修改,实际上可以帮助将所有必要的文件复制到监狱中。我们还可以在互联网上找到一些其他的脚本,这些脚本将帮助我们完成这项敏感的工作。无论如何,提供受限 SFTP 访问到服务器有一个更简单的方法,我们无需远离我们的环境,因为我们可以仅仅使用 OpenSSH 服务器来完成这项任务。

使用 OpenSSH 限制 SFTP 会话

使用 OpenSSH,一切可以通过五行配置和一些命令轻松完成;让我们看看如何操作。我们在远程服务器上。

首先,让我们打开 OpenSSH 配置文件,通常位于 /etc/ssh/sshd_config,并添加以下几行:

Match group sftp-only ChrootDirectory /opt/jails/%u/exchange X11Forwarding no AllowTcpForwarding no ForceCommand internal-sftp

我们应该已经知道这些指令是什么,但让我们回顾一下我们在第十二章中关于 SSH 远程连接的内容,远程 SSH 连接

  • Match:使用这个指令,我们可以使用条件语句,这样如果满足条件,以下的配置行将覆盖主配置块中的内容。如果一个关键字/配置块在多个 Match 子句中出现,只有第一个实例会被考虑。作为匹配条件,我们可以使用以下指令:用户、组、主机、本地地址、本地端口、地址,或者所有条件。我们可以匹配一个列表、模式或否定。在我们的示例中,我们将匹配一个我们稍后会创建的组:属于 sftp-only 组的所有账户将受以下配置行的限制。

  • ChrootDirectory:通过指定目录的完整路径,我们可以在成功认证后将用户 chroot 到该目录中。这不是一项简单的任务,因为该目录必须由 root 拥有,并且不能对其他人可写。此外,我们还必须提供会话所需的一些文件,例如 shell、/dev/null/dev/zero/dev/arandom/dev/stdin/dev/stdout/dev/stderr/dev/ttyx。我们还可以使用一些令牌,例如 %h 代表认证账户的主目录,%% 代表简单的 %%u 则会被替换为用户名。在我们的案例中,我们不需要提供任何二进制文件,因为我们只允许 sftp 连接,由于没有 shell,无法执行任何操作。

  • X11Forwarding:此选项允许/拒绝 X11 转发。如果设置为 yes,可能会使 X11 暴露于攻击之中;因此,这个选项需要谨慎使用。默认值为 no。我们禁止转发 X11:既然不需要此功能,它可能会暴露系统。

  • AllowTcpForwarding:此选项允许/拒绝 TCP 转发,参数可以为 yesallnolocalremote。前两个选项允许转发,第三个选项拒绝转发,而 local 仅允许本地转发;remote 仅允许远程转发。对于我们的示例来说,不涉及任何 shell 或 TCP 转发。

  • ForceCommand:覆盖客户端发送的任何命令或在认证账户的 ~/.ssh/rc 文件中列出的命令,并强制执行此指令中列出的命令。该命令通过账户的 shell 执行,并附带 -c 选项。默认值为 no。在我们的案例中,我们强制执行 OpenSSH 内部的 sftp 子系统。

说到子系统,让我们验证在同一个配置文件中,OpenSSH 是否配置为使用 internal-sftp 子系统:

Subsystem sftp internal-sftp 

我们也许还想在配置文件末尾添加一些额外的限制:

PermitTunnel no AllowAgentForwarding no

  • AllowAgentForwarding:定义是否允许 ssh-agent 转发。默认值为 yes,以增加安全性,由于 sftp 账户实际上不需要此功能,因此我们将禁用它。

  • PermitTunnel:此选项允许/拒绝隧道设备转发。参数可以为 yes、点对点、以太网或 noyes 启用点对点和以太网转发,默认值为 no,我们希望确保它被禁用,因为我们不需要为 sftp 账户启用隧道。

现在我们已经配置好了所有服务部分,接下来重启 OpenSSH 服务器,在我们的例子中就是这个:

root:# service ssh restart

该是时候添加我们的新组并将受限用户移到其中了:

root:# addgroup sftp-only root:# service ssh restart root:# usermod -g sftp-only restricted root:# usermod -s /bin/false restricted

所以,现在我们已经将受限用户添加到 sftp-only 组中,并且没有有效的 shell 用于登录系统:

restricted:x:1000:1003:,,,:/home/restricted:/bin/false

现在,让我们将用户的主目录设置为由 root 拥有,这样用户就无法向其写入:

root:# chown root. /home/restricted/

然后创建一个由 root 拥有的新主目录:

root:# mkdir -p /opt/sftp-jails/restricted/exchange root:# chown -R root. /opt/sftp-jails/

还可以让一个受限用户拥有一个子目录,允许其写入:

root:# chown restricted.root /opt/sftp-jails/restricted/exchange root:# chmod 750 /opt/sftp-jails/restricted/exchange/

一切正常,现在让我们尝试登录:

zarrelli:~$ ssh -p 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. restricted@192.168.0.5's password: Could not chdir to home directory /home/restricted: No such file or directory This service allows sftp connections only. Connection to 192.168.0.5 closed.

这没问题:我们不希望受限用户有完整的 shell,所以让我们尝试 sftp:

zarrelli:~$ sftp -P 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Restricted@192.168.0.5's password: Connected to 192.168.0.5. sftp> 

太好了,我们进来了,但是让我们检查一下我们实际能做什么:

sftp> pwd Remote working directory: / sftp> 

好的,我们在我们的远程根目录中,但里面有什么?

sftp> ls -lah drwxr-xr-x 0 0 0 4.0K May 11 14:56 . drwxr-xr-x 0 0 0 4.0K May 11 14:56 .. drwxr-x--- 0 1000 0 4.0K May 11 15:00 exchange

听起来很熟悉。这是一个围栏,所以让我们逃出去:

sftp> cd / sftp> pwd Remote working directory: / sftp> ls exchange sftp> 

不行,实际上我们不能这样做。至少让我们试着上传一些东西:

sftp> put test_file Uploading test_file to /test_file remote open("/test_file"): Permission denied sftp> 

不行,用户 root 的目录是不可写的,所以让我们 cd 到交换目录再试一次上传:

sftp> cd exchange sftp> put test_file Uploading test_file to /exchange/test_file test_file 100% 0 0.0KB/s 00:00 sftp> 

它绝对有效。让我们把文件拿回来:

sftp> get test_file Fetching /exchange/test_file to test_file sftp> bye

到这里了,账户已经准备好供客户连接和共享数据。但如果我们想要使用密钥进行认证连接呢?

让我们首先修改 /etc/ssh/ssd_config,在文件的最末尾添加以下行:

AuthorizedKeysFile /opt/sftp-jails/authorized_keys/%u/authorized_keys

这将在匹配条件下,并且将被所有 sftp-only 组的用户触发;但为了使其生效,我们必须重新加载配置:

root:# service ssh restart

因此,由于新指令指示 OpenSSH 在 /opt/sftp-jails/authorized_keys/{username}/authorized_keys 中为所有属于 sftp-only 组的用户查找 authorized_keys,让我们开始创建正确的目录:

root:# mkdir -p /opt/sftp-jails/authorized_keys/restricted

这是包含用户认证密钥的用户目录的完整路径,该目录是受限用户的用户名。我们将需要为每个用户创建一个目录,并且最终目录的名称必须与用户名相同。现在,我们必须修剪所有权和访问权限:

root:# cd /opt/sftp-jails root:# chown -R root.sftp-only authorized_keys root:# chown -R restricted.root authorized_keys/restricted

authorized_keys 目录属于用户 root 和组:sftp-only,而子目录 restricted 属于用户 restricted 和组 root:

root:# chmod 750 /opt/sftp-jails/authorized_keys/ root:# chmod 500 /opt/sftp-jails/authorized_keys/restricted/

所有属于 sftp-only 组的用户都可以遍历 authorized_keys 目录,但只有受限用户可以遍历 restricted 目录。现在,让我们将我们的示例密钥复制到最终目标:

root:# cp id_ecdsa_to_spoton.pub /opt/sftp-jails/authorized_keys/restricted/authorized_keys

现在让我们给它正确的所有权和访问权限:

root:# chown restricted.root /opt/sftp-jails/authorized_keys/restricted/authorized_keys root:#chmod 400 /opt/sftp-jails/authorized_keys/restricted/authorized_keys

所以,我们应该以这样的配置结束:

root:# cd /opt/ root:# tree -pug . └── [drwxr-xr-x root root ] sftp-jails
 ├── [drwxr-x--- root sftp-only] authorized_keys │   └── [dr-x------ restricted root ] restricted │       └── [-r-------- restricted root ] authorized_keys └── [drwxr-xr-x root sftp-only] restricted └── [drwxr-x--- restricted root ] exchange └── [-rw-r--r-- restricted sftp-only] test_file

一切看起来都很好,所以我们只需测试一下我们到目前为止做了什么。让我们转到本地服务器,尝试连接远程服务器:

local_user:~$ sftp -i .ssh/id_ecdsa_to_spoton -P 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to 192.168.0.5. sftp> 

.ssh/config file of local_user:
Host spoton-sftp AddressFamily inet ConnectionAttempts 10 ForwardAgent no ForwardX11 no ForwardX11Trusted no GatewayPorts no HostBasedAuthentication no HostKeyAlias sftp-alias HostName 192.168.0.5 IdentityFile ~/.ssh/id_ecdsa_to_spoton PasswordAuthentication no Port 9999 Protocol 2 Compression yes CompressionLevel 9 ServerAliveCountMax 3 ServerAliveInterval 15 TCPKeepAlive no User restricted

让我们尝试一个 ssh 连接:

local_user:~$ ssh spoton-sftp Warning: Permanently added 'sftp-alias,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Could not chdir to home directory /home/restricted: No such file or directory This service allows sftp connections only. Connection to 192.168.0.5 closed.

这是正确的,我们不应该被允许通过 ssh 连接带有 shell 的用户或访问主目录。

让我们尝试一个 sftp 连接:

local_user:~$ sftp spoton-sftp Warning: Permanently added 'sftp-alias,[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to spoton-sftp.

很好,我们使用我们的身份密钥连接了,并且没有指定用户、文件或 IP 地址。所以,让我们进行一些测试:

sftp> pwd Remote working directory: /

我们在我们的用户根目录中;让我们尝试爬到系统根目录:

sftp> cd / sftp> pwd Remote working directory: / sftp> ls exchange 

不行,我们被困在我们的根目录中,无法进入任何上层目录。让我们寻找要上传的文件:

sftp> !ls test_local

让我们尝试将其上传到主目录:

sftp> put test_local Uploading test_local to /test_local remote open("/test_local"): Permission denied

我们没有权限,不出所料。我们需要使用交换子目录来实现我们的目的:

sftp> cd exchange sftp> put test_local Uploading test_local to /exchange/test_local test_local 100% 0 0.0KB/s 00:00 

上传成功!现在,让我们看看交换目录里面有什么:

sftp> ls test_file test_local Ok, we have the old and the new file. Let's grab the old file: sftp> get test_file Fetching /exchange/test_file to test_file sftp> 

完成!一切看起来都很好。或者说,差不多,因为我们对连接期间发生的事情视而不见。由于一切都在一个隔离的环境中,因此没有办法使用像rsyslog这样的系统设施来实际记录用户在sftp会话期间的操作。或者,至少,正常的rsyslog配置无法做到这一点,但有一些方法可以绕过这个限制。我们将要看到的一种方法涉及使用管道;它会让事情变得非常简单。首先,让我们修改/etc/ssh/sshd_config中的一些指令。

旧的SubsystemForceCommand现在必须重新编写为:

Subsystem sftp internal-sftp -l INFO ForceCommand internal-sftp -l INFO

现在,internal-sftp将以INFO级别记录日志,因此我们需要通过套接字将这些信息导出到主日志中。让我们创建一个文件:

root:# cat /etc/rsyslog.d/openssh-sftp.conf module(load="imuxsock") input(type="imuxsock" Socket="/opt/sftp-jails/restricted/dev/log" CreatePath="on") if $programname == 'internal-sftp' then /var/log/openssh-sftp.log & stop

所以,我们所做的是指示rsyslog在受限用户的/dev目录中创建一个 Unix 套接字;sftp子系统将能够通过这个套接字将日志消息发送给rsyslog。是的,但是这些消息是如何写入的呢?它们是通过简单地访问消息本身的某个属性来写入的。在这种情况下,如果生成消息的程序名称是internal-sftp,那么消息将被写入/var/log/openssh-sftp.log。完成后,让我们重启sshdrsyslog

root:# service ssh restart root:# service rsyslog restart

如果你收到来自rsyslog的消息,抱怨imuxsock模块已被加载,只需将第一行注释掉#

现在,我们只需再建立一次连接并执行一些命令,以便填充日志文件:

root:# cat /var/log/openssh-sftp.log May 11 14:28:25 spoton internal-sftp[16080]: session opened for local user restricted from [192.168.0.10] May 11 14:28:26 spoton internal-sftp[16080]: opendir "/" May 11 14:28:26 spoton internal-sftp[16080]: closedir "/" May 11 14:28:35 spoton internal-sftp[16080]: opendir "/exchange" May 11 14:28:35 spoton internal-sftp[16080]: closedir "/exchange" May 11 14:28:39 spoton internal-sftp[16080]: open "/exchange/test_file" flags READ mode 0666 May 11 14:28:39 spoton internal-sftp[16080]: close "/exchange/test_file" bytes read 0 written 0 May 11 14:28:42 spoton internal-sftp[16080]: open "/exchange/test_file" flags WRITE,CREATE,TRUNCATE mode 0644 May 11 14:28:42 spoton internal-sftp[16080]: sent status Permission denied May 11 14:29:00 spoton internal-sftp[16080]: opendir "/exchange" May 11 14:29:00 spoton internal-sftp[16080]: closedir "/exchange" May 11 14:29:05 spoton internal-sftp[16080]: remove name "/exchange/test_file" May 11 14:29:06 spoton internal-sftp[16080]: opendir "/exchange" May 11 14:29:06 spoton internal-sftp[16080]: closedir "/exchange" May 11 14:29:09 spoton internal-sftp[16080]: open "/exchange/test_file" flags WRITE,CREATE,TRUNCATE mode 0644 May 11 14:29:09 spoton internal-sftp[16080]: close "/exchange/test_file" bytes read 0 written 0 May 11 14:29:10 spoton internal-sftp[16080]: opendir "/exchange" May 11 14:29:10 spoton internal-sftp[16080]: closedir "/exchange" May 11 14:30:45 spoton internal-sftp[16080]: session closed for local user restricted from [192.168.0.10]

就这样。现在我们有了一个很好的日志,显示了用户在其sftp会话期间所做的事情;而且该日志本身对任何sftp用户都是不可访问的。在我们的示例中,我们基于生成消息的程序名称来重定向消息;但是我们还有其他标签可以用来过滤。接下来,让我们看看其他更有用的标签:

  • HOSTNAME:消息中显示的主机名。

  • FROMHOST:消息接收到的系统主机名。在链式配置中,这是接收方旁边的系统,而不一定是第一个发送方。

  • syslogfacility:消息报告的设施,以数字形式显示。

  • syslogfacility-text:消息报告的设施,以文本形式显示。

  • syslogseverity:消息以数字形式报告的严重性。

  • syslogseverity-text:消息报告的严重性,以文本形式显示。

通过使用这些属性,我们可以做一些有趣的事情。让我们开始创建另一个与受限用户具有相同组和 shell 的用户:

root:# adduser --shell /bin/false --gid 1003 casualuser Adding user `casualuser' ... Adding new user `casualuser' (1003) with group `sftp-only' ... Creating home directory `/home/casualuser' ... Copying files from `/etc/skel' ... Enter new UNIX password: Retype new UNIX password: passwd: password updated successfully Changing the user information for casualuser Enter the new value, or press ENTER for the default Full Name []: Room Number []: Work Phone []: Home Phone []: Other []: Is the information correct? [Y/n] 

现在,让我们更改用户主目录的所有者:

root:# chown -R root. /home/casualuser/

让我们创建一个新监狱,复制我们已有的:

root:# cd /opt/sftp-jails
 root:# cp -ra restricted/ casualuser

我们现在只需要修复所有权:

root:# cd casualuser root:# chown -R casualuser.root exchange/ root:# cd exchange root:# chown casualuser.sftp-only *

现在是复制密钥的时候了:

root:# cd /opt/sftp-jails/authorized_keys root:# cp -ra restricted casualuser root:# chown -R casualuser.root casualuser/

在我们的示例中,为了简便起见,我们使用的是与受限用户相同的密钥,但我们始终可以创建一个新密钥并将authorized_keys文件复制过去,以便为每个用户分配他们自己的密钥。完成后,让我们尝试连接:

local_user:~$ sftp -i .ssh/id_ecdsa_to_spoton -P 9999 casualuser@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to 192.168.0.5. sftp> ls dev exchange sftp> !ls test_file test_local sftp> put test_file Uploading test_file to /test_file remote open("/test_file"): Permission denied sftp> cd exchange sftp> put test_file Uploading test_file to /exchange/test_file test_file sftp>

好的,用户可以访问并且拥有正确的权限,但日志呢?什么也没有,我们没有为日志设置任何内容,因此让我们通过添加以下行来修改/etc/rsyslog.d/openssh-sftp.conf

input(type="imuxsock" Socket="/opt/sftp-jails/casualuser/dev/log" CreatePath="on")

现在,为了使新的指令生效,让我们重启rsyslog

service rsyslog restart

然后让我们再次连接,生成一些日志行:

local_user:~$ sftp -i .ssh/id_ecdsa_to_spoton -P 9999 casualuser@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to 192.168.0.5. sftp> ls dev exchange sftp> cd exchange sftp> ls test_file test_local sftp> get test_local Fetching /exchange/test_local to test_local sftp> bye

让我们检查日志文件:

May 12 04:02:04 spoton internal-sftp[18573]: session opened for local user casualuser from [192.168.0.10] May 12 04:02:06 spoton internal-sftp[18573]: opendir "/" May 12 04:02:06 spoton internal-sftp[18573]: closedir "/" May 12 04:02:11 spoton internal-sftp[18573]: opendir "/exchange" May 12 04:02:11 spoton internal-sftp[18573]: closedir "/exchange" May 12 04:02:15 spoton internal-sftp[18573]: open "/exchange/test_local" flags READ mode 0666 May 12 04:02:15 spoton internal-sftp[18573]: close "/exchange/test_local" bytes read 0 written 0 May 12 04:02:18 spoton internal-sftp[18573]: session closed for local user casualuser from [192.168.35.219] May 12 04:06:45 spoton internal-sftp[18631]: session opened for local user casualuser from [192.168.0.10]

这是预期的结果。我们在新用户的监狱中创建了一个 Unix 套接字;并且我们正在接收由internal-sftp子系统为账户会话发送的消息。很好,但有些混淆。所有用户的日志消息都会包含在一个文件中,而像May 12 04:02:11 spoton internal-sftp[18573]: closedir "/exchange"这样的命令消息并没有被用户账户名标识,而是通过会话 ID[18631]来区分,因此可以追踪到会话期间执行的所有操作,并追溯到执行操作的用户。但总体来说,这并不容易阅读。我们能做什么呢?嗯,像往常一样,我们需要使用一些想象力和创造力,弯曲规则以获得一些优势。让我们动手修改openssh-sftprsyslog配置文件:

/etc/rsyslog.d/openssh-sftp.conf

让我们打开它并将内容替换为以下几行:

#module(load="imuxsock") input(type="imuxsock" HOSTNAME="restricted" Socket="/opt/sftp-jails/restricted/dev/log" CreatePath="on") input(type="imuxsock" HostName="casualuser" Socket="/opt/sftp-jails/casualuser/dev/log" CreatePath="on") if $hostname == 'restricted' then /var/log/openssh-sftp/restricted-sftp.log if $hostname == 'casualuser' then /var/log/openssh-sftp/casualuser-sftp.log & stop

我们做了什么?我们使用了一个属性操作字符串,使我们能够将主机名属性与来自特定套接字的消息关联。然后,我们添加了两条规则,将消息重定向到每个用户的日志文件,基于消息本身中找到的主机名属性。我们故意使用了不同大小写的主机名属性,目的是显示该属性名是大小写不敏感的。现在是时候重启rsyslogd了:

root:# service rsyslog restart

日志设施已经准备好了,让我们再连接一次,制造一些噪音

local_user:~$ sftp -i .ssh/id_ecdsa_to_spoton -P 9999 casualuser@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to 192.168.0.5. sftp> ls dev exchange sftp> cd exchange sftp> ls test_file test_local sftp> get test_local Fetching /exchange/test_local to test_local sftp> cd .. sftp> put test_file Uploading test_file to /test_file remote open("/test_file"): Permission denied sftp> cd / sftp> 

现在是检查的时候了:

root:# ls -lah /var/log/openssh-sftp/casualuser-sftp.log -rw-r----- 1 root adm 757 May 12 05:14 /var/log/openssh-sftp/casualuser-sftp.log

我们以普通用户身份连接,的确我们看到一个名为casualuser-sftp.log的文件,正如我们预期的那样出现在那里。让我们看一下里面的内容:

root:# cat /var/log/openssh-sftp/casualuser-sftp.log May 12 05:13:48 casualuser internal-sftp[19004]: session opened for local user casualuser from [192.168.0.10] May 12 05:13:49 casualuser internal-sftp[19004]: opendir "/" May 12 05:13:49 casualuser internal-sftp[19004]: closedir "/" May 12 05:13:54 casualuser internal-sftp[19004]: opendir "/exchange" May 12 05:13:54 casualuser internal-sftp[19004]: closedir "/exchange" May 12 05:14:13 casualuser internal-sftp[19004]: open "/exchange/test_local" flags READ mode 0666 May 12 05:14:13 casualuser internal-sftp[19004]: close "/exchange/test_local" bytes read 0 written 0 May 12 05:14:23 casualuser internal-sftp[19004]: open "/test_file" flags WRITE,CREATE,TRUNCATE mode 0644 May 12 05:14:23 casualuser internal-sftp[19004]: sent status Permission denied May 12 05:41:50 casualuser internal-sftp[19004]: session closed for local user casualuser from [192.168.0.10]

就这样。我们的 sftp会话已经完全记录,现在如果我们想知道普通用户做了什么,我们只需要打开日志文件并阅读它。一个有趣的点是,每行消息现在都包含了属于该用户的名字。嗯,实际上它会是主机名,但我们通过扩展规则从系统中获取了我们想要的信息。真的是这样吗?让我们通过连接一个受限用户做最后检查,看是否生成了新的日志文件:

local_user:~$ sftp -i .ssh/id_ecdsa_to_spoton -P 9999 restricted@192.168.0.5 Warning: Permanently added '[192.168.0.5]:9999' (ECDSA) to the list of known hosts. Connected to 192.168.0.5. sftp> mkdir test Couldn't create directory: Permission denied sftp> cd exchange sftp> mkdir test sftp> ls test test_file test_local sftp> dc test Invalid command. sftp> cd test sftp> bye

现在,我们已经作为受限用户执行了一些命令,让我们看看我们需要的文件是否真的在预期的位置:

root:# ls -lah /var/log/openssh-sftp/restricted-sftp.log -rw-r----- 1 root adm 607 May 12 05:47 /var/log/openssh-sftp/restricted-sftp.log

文件正确地存在,所以让我们看看里面的内容:

root:# cat /var/log/openssh-sftp/restricted-sftp.log May 12 05:43:47 restricted internal-sftp[19147]: session opened for local user restricted from [192.168.0.10] May 12 05:47:04 restricted internal-sftp[19147]: mkdir name "/test" mode 0777 May 12 05:47:04 restricted internal-sftp[19147]: sent status Permission denied May 12 05:47:11 restricted internal-sftp[19147]: mkdir name "/exchange/test" mode 0777 May 12 05:47:13 restricted internal-sftp[19147]: opendir "/exchange" May 12 05:47:13 restricted internal-sftp[19147]: closedir "/exchange" May 12 05:47:22 restricted internal-sftp[19147]: session closed for local user restricted from [192.168.0.10]

内容已经在那里;所有受限用户执行的操作都已被记录,并且超出了他们的访问权限。最后再做一次检查:

root:# grep restricted casualuser-sftp.log | wc -l 0

这证实了没有任何标记为受限的行出现在casualuser-sftp.log中,因此每个用户都有自己的日志文件。

所以,我们现在拥有一个完全功能的sftp服务器,以及单独的监狱环境和每个用户的日志记录。现在只剩下一个小细节要给我们的服务器,这将带我们回到过去:我们正在谈论一个显示给用户的横幅。它看起来可能不像是那么有用,或者属于过去的时代,但事实并非如此。当有人连接到我们的服务器时,必须通知他这是一个私人设施,不允许进行任何非法操作。至少有两个原因说明这样做是有用的:

  • 如果一个未经授权的用户错误地连接到我们的服务器,他必须知道自己并不在他所认为的地方;因此,我们给他一个断开连接的机会,并且不会采取进一步的行动。

  • 如果一个不明身份的用户故意连接到我们的服务器,必须通知他不被允许这样做。如果他继续操作,那么我们以后可以将其作为他试图对我们的设施进行非法操作的证据。

它看起来可能很简单,但有了横幅,没人能再说“我不知道”。不,用户已被通知,这一点很重要。由于我们不想吓到访客,让我们用figlet制作一个带有爵士风格的横幅,figlet是一个可以为我们的消息应用漂亮字体的工具,准备在终端上显示。在我们的例子中,我们使用的是 Debian,因此输入root:# apt-get install figlet就能安装这个工具。默认的字体集并不丰富,但可以从该项目的官方网站下载更多:www.figlet.org/fontdb.cgi

让我们首先测试一下系统上已经安装的字体。在 Debian 环境中,字体文件位于/usr/share/figlet/,但这可能会根据所使用的发行版有所不同。因此,为了测试所有字体并查看我们最喜欢的字体,我们需要一个一行的 for 循环:

root:# for i in $(ls -1 /usr/share/figlet/*.flf | awk -F "." '{print $1}') ; do figlet -f $i test ; echo $i ; done

一个简单的for循环将展示我们所有可用的字体中的消息。所以,让我们创建一个包含欢迎横幅的测试文件:

Welcome to our restricted sftp server

我们将其保存为一个名为header.txt的文件,并传递给figlet stdin

root:# figlet -cf slant < header.txt > /opt/sftp/jails/sftp-banner

我们将得到类似下图所示的效果,字体会很漂亮地居中显示:

一个简单的for循环将展示我们所有可用的字体中的消息。现在,让我们为footer.txt文件添加一些有意义的警告:

WARNING This service is restricted to authorized users only. All activities on this system are logged.

让我们通过figlet传递这条消息:

root:# figlet -cf digital < footer.txt >> /opt/sftp-jails/sftp-banner

既然我们已经有了横幅,让我们稍微整理一下它:

root:# rm footer.txt header.txt root:# chown root.root /opt/sftp-jails/sftp-banner root:# chmod 500 /opt/sftp-jails/sftp-banner

就这些。所以我们尝试重新连接服务器,并且我们会收到像这里显示的那样的欢迎消息:

一条漂亮的欢迎消息将提醒访问者此 sftp 站点的限制。

很好,但假设我们看到了一种喜欢的字体,但它没有安装。为了这个示例,我们假设我们想使用鳄鱼字体:

root:# figlet -cf alligator test figlet: alligator: Unable to open font file

它没有安装,因此我们无法使用它,但这只是下载并复制字体目录的问题:

root:# wget -P /usr/share/figlet/ http://www.figlet.org/fonts/alligator.flf --2017-05-12 08:18:46-- http://www.figlet.org/fonts/alligator.flf Resolving www.figlet.org (www.figlet.org)... 188.226.162.120 Connecting to www.figlet.org (www.figlet.org)|188.226.162.120|:80... connected. HTTP request sent, awaiting response... 200 OK Length: 11285 (11K) [text/plain] Saving to: ‘/usr/share/figlet/alligator.flf' /usr/share/figlet/alligator.flf 100%[=======================================================================================================>] 11.02K --.-KB/s in 0s 2017-05-12 08:18:46 (44.3 MB/s) - ‘/usr/share/figlet/alligator.flf' saved [11285/11285]

现在我们只需再次输入之前的命令:

 root:# figlet -cf alligator test

字体可供 figlet 使用,因此结果就是我们在以下截图中看到的内容:

安装字体其实只是将其复制到字体目录中

所以,让我们来点乐趣吧。尝试改变并创建你喜欢的样式:一个横幅不一定非得枯燥乏味。

总结

这是本书的最后总结,上一章的主题是 figlet;这并非偶然。我们在所有章节中试图阐明的是,Bash 是有趣的。我们是否涵盖了所有可能的话题和示例?不,根本没有,而这正是最伟大的地方:我们有太多东西可以探索,太多方法可以让 Bash 完成难以想象的任务。只要想想某件事,然后尝试使用 shell:在大多数情况下,稍微动点脑筋就能找到创造性的方式克服障碍,并且为完成的结果会心一笑。本书名为《精通 Bash》,但没有一本书能涵盖我们可以发现的关于这个 shell 的所有内容。所以,这不是终点,这只是一个更进一步的步骤,也许比平时更高,但依然是我们最喜爱的环境、我们钟爱的 GNU/Linux 操作系统中不断探索旅程的一小步。

posted @ 2025-07-05 15:46  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报