[CLI翻译]最小的安全Bash脚本模板

453 阅读8分钟

原文地址:betterdev.blog/minimal-saf…

原文作者:betterdev.blog/

发布时间:2020年12月14日

Bash脚本。几乎所有人迟早都要写一个。几乎没有人说 "是的,我喜欢写它们"。这也是为什么几乎所有人在写它们的时候都不太重视的原因。

我不会试图让你成为一个Bash专家(因为我也不是),但我会告诉你一个最小的模板,它将使你的脚本更安全。你不需要感谢我,你未来的自己会感谢你。

为什么要用Bash写脚本?

最近在我的Twitter上出现了关于Bash脚本的最佳总结。

twitter.com/JakeWharton…

但Bash和另一种广受喜爱的语言有一些共同之处。就像JavaScript一样,它不会轻易消失。虽然我们可以希望Bash不会成为字面上一切的主要语言,但它总是在某个地方接近。

Bash继承了shell的宝座,几乎可以在每一个Linux上找到,包括Docker镜像。而这也是大部分后台运行的环境。因此,如果你需要为服务器应用启动、CI/CD步骤或集成测试运行编写脚本,Bash就能满足你的需求。

要将几个命令粘合在一起,将输出从一个命令传递到另一个命令,并只是启动一些可执行文件,Bash是最简单和最原生的解决方案。虽然用其他语言写更大、更复杂的脚本是非常有意义的,但你不能指望Python、Ruby、fish或其他任何你认为最好的解释器,到处都有。而且在把它添加到某个prod服务器、Docker镜像或CI环境之前,你可能应该三思而后行。

然而Bash远非完美。语法是个噩梦。错误处理很困难。到处都有地雷。而我们必须处理它。

Bash脚本模板

闲话少说,就在这里。

#!/usr/bin/env bash

set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

usage() {
  cat <<EOF
Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]

Script description here.

Available options:

-h, --help      Print this help and exit
-v, --verbose   Print script debug info
-f, --flag      Some flag description
-p, --param     Some param description
EOF
  exit
}

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

die() {
  local msg=$1
  local code=${2-1} # default exit status 1
  msg "$msg"
  exit "$code"
}

parse_params() {
  # default values of variables set from params
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

parse_params "$@"
setup_colors

# script logic here

msg "${RED}Read parameters:${NOFORMAT}"
msg "- flag: ${flag}"
msg "- param: ${param}"
msg "- arguments: ${args[*]-}"

我们的想法是不要让它太长。我不想滚动500行到脚本逻辑。同时,我希望任何脚本都有一些强大的基础。但是Bash并没有让这一点变得简单,缺乏任何形式的依赖性管理。

一个解决方案是有一个单独的脚本,包含所有的模板和实用功能,并在开始时执行它。缺点是总是要把第二个文件附加到各个地方,这样就失去了 "简单Bash脚本 "的想法。所以我决定在模板中只放我认为是最低限度的东西,以保持它可能的简短。

现在让我们更详细地看看它。

选择Bash

#!/usr/bin/env bash

脚本传统上以shebang开头。为了达到最好的兼容性,它引用/usr/bin/env,而不是直接引用/bin/bash。不过,如果你读了 StackOverflow 问题中的评论,即使这样做有时也会失败。

快速失败

set -Eeuo pipefail

set命令可以改变脚本执行选项。例如,通常Bash不会在意某些命令是否失败,返回一个非零的退出状态码。它只是高兴地跳到下一个。现在看看这个小脚本:

#!/usr/bin/env bash
cp important_file ./backups/
rm important_file

如果backups目录不存在,会发生什么?完全正确,你会在控制台中得到一个错误信息,但在你能够做出反应之前,该文件将被第二个命令删除。

关于set -Eeuo pipefail变化的选项以及它们将如何保护你的细节,我参考了我在书签中的文章,现在已经有几年了。

虽然你应该知道,有一些反对设置这些选项的论点

获取位置

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

这一行尽力定义脚本的位置目录,然后我们cd到它。为什么这样做呢?

通常我们的脚本是在相对于脚本位置的路径上操作,复制文件和执行命令,假设脚本目录也是一个工作目录。而事实的确如此,只要我们从它的目录下执行脚本就可以了。

但如果,我们的CI配置执行脚本是这样的。

/opt/ci/project/script.sh

那么我们的脚本就不是在项目目录下运行,而是在CI工具的某个完全不同的工作目录下运行。我们可以解决这个问题,在执行脚本之前先进入该目录。

cd /opt/ci/project && ./script.sh

但在脚本端解决这个问题会更好。所以,如果脚本从同一目录下读取一些文件或执行另一个程序,可以这样调用。

cat "$script_dir/my_file"

同时,该脚本不会改变workdir的位置。如果脚本是在其他目录下执行,而用户提供了某个文件的相对路径,我们仍然可以读取它。

尝试清理

trap cleanup SIGINT SIGTERM ERR EXIT

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}

trap想象脚本的的finally代码块。在脚本结束时--正常的,由错误或外部信号引起的--将执行cleanup()函数。例如,在这里你可以尝试删除所有由脚本创建的临时文件。

请记住,cleanup()函数不仅可以在最后调用,而且可以在脚本完成任何部分工作后调用。并非所有你试图清理的资源都会存在。

显示有用的帮助

usage() {
  cat <<EOF
Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]

Script description here.

...
EOF
  exit
}

usage() 放在相对靠近脚本顶部的位置,它将以两种方式发挥作用。

  • 为那些不知道所有选项的人提供帮助 而又不想翻遍整个脚本去发现它们的人提供帮助。
  • 作为一个最基本的文档,当有人修改脚本时(例如你,2周后,甚至不记得当初写过它)。

我不主张在这里记录每一个功能。但是一个简短的、漂亮的脚本使用信息是最基本的要求。

打印漂亮的信息

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

首先,如果你不想在文本中使用颜色,请删除setup_colors()函数。我保留它是因为我知道如果我不需要每次都在谷歌上搜索颜色代码的话,我会更频繁地使用颜色。

其次,这些颜色只用于 msg() 函数,而不是 echo 命令。

msg() 函数是用来打印所有非脚本输出的内容。这包括所有的日志和消息,而不仅仅是错误。引用伟大的12因素CLI应用程序的文章。

简而言之,stdout是用来输出的,stderr是用来传递信息的。

对构建CLI应用略知一二Jeff Dickey认为

这就是为什么在大多数情况下,你不应该为stdout使用颜色。

msg()打印的信息被发送到stderr流中,并且支持特殊的序列,比如颜色。如果 stderr 输出不是一个交互式终端或者传递了一个标准参数,那么颜色就会被禁用。

使用方法

msg "This is a ${RED}very important${NOFORMAT} message, but not a script output value!"

要检查当stderr不是交互式终端时它的表现,可以在脚本中添加一行类似上面的内容。然后执行它,将 stderr 重定向到 stdout,并将其管道化到 cat。管道操作使得输出不再直接发送到终端,而是发送到下一个命令,所以现在应该禁用颜色。

$ ./test.sh 2>&1 | cat
This is a very important message, but not a script output value!

解析任何参数

parse_params() {
  # default values of variables set from params
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

如果有什么东西是有意义的,可以在脚本中参数化,我通常会这样做。即使脚本只在一个地方使用。这使得复制和重用它变得更容易,这往往是早晚的事。另外,即使有些东西需要硬编码,通常在更高层次上有比Bash脚本更好的地方。

CLI参数主要有三种类型--标志、命名参数和位置参数。parse_params()函数对它们都有支持。

唯一一个不在这里处理的常见参数模式是多个单字母标志的连接。为了能够将两个标志传递为-ab,而不是-a -b,需要一些额外的代码。

while循环是一种手动解析参数的方式。在其他语言中, 你应该使用一个内置的解析器可用的库, 但是, 这是Bash.

模板里有一个例子标志(-f)和命名参数(-p). 只需改变或复制它们来添加其他参数. 而且不要忘了在之后更新 usage()

这里很重要的一点,通常当你只是拿第一个google结果来做Bash参数解析的时候,都会忽略掉,就是在未知选项上抛出一个错误。脚本收到一个未知选项,意味着用户希望它做一些脚本无法实现的事情。所以用户的期望和脚本的行为可能是完全不同的。在坏事发生之前,最好完全防止执行。

Bash中解析参数有两种选择。就是getoptgetopts。使用它们有支持的,也有反对的。我发现这些工具并不是最好的,因为默认情况下,macOS上的getopt行为完全不同,而且getopts不支持长参数(比如--help)。

使用模板

就像你在网上找到的大多数代码一样,复制粘贴就可以了。

好吧,其实,这是很诚实的建议。对于Bash,没有通用的npm install等价物。

复制之后,你只需要修改4个东西。

  • usage()文字和脚本描述
  • cleanup()内容
  • parse_params()中的参数--保留--help--no-color,但替换掉例子中的:-f-p
  • 脚本逻辑

便携性

我在MacOS(使用默认的、过时的Bash 3.2)和几个Docker镜像上测试了这个模板。Debian、Ubuntu、CentOS、Amazon Linux、Fedora。它可以工作。

显然,它不能在缺少Bash的环境中工作,比如Alpine Linux。Alpine作为一个极简系统,使用的是非常轻量级的ash(Almquist shell)。

你可以问一个问题,如果使用Bourne shell兼容的脚本,几乎可以在任何地方工作,是不是会更好。至少在我的情况下,答案是否定的。Bash更安全,更强大(但仍然不容易使用),所以我可以接受一些Linux发行版缺乏支持,我很少需要处理。

进一步阅读

在创建CLI脚本时,无论是用Bash还是其他更好的语言,都有一些通用规则。这些资源将指导你如何使你的小脚本和大型CLI应用程序可靠。

结束语

我不是第一个也不是最后一个创建Bash脚本模板的人。一个很好的选择就是这个项目,虽然对于我的日常需求来说有点太大了。毕竟,我尽量保持Bash脚本的小(和罕见)。

在编写Bash脚本时,使用支持ShellCheck linter的IDE,比如JetBrains IDE。它可以防止你做一堆会适得其反的事情

我的Bash脚本模板也以GitHub Gist的形式提供(在MIT授权下)。

script-template.sh

如果你发现模板有任何问题,或者你认为缺少一些重要的东西--请在评论中告诉我。

更新 2020-12-15

在这里、RedditHackerNews上的大量评论之后,我对模板做了一些改进。请看gist中的修订历史。


www.deepl.com 翻译