Python-DevOps-教程-二-

56 阅读32分钟

Python DevOps 教程(二)

原文:DevOps in Python

协议:CC BY-NC-SA 4.0

六、文本操作

基于 UNIX 的系统的自动化通常涉及文本操作。许多程序是用文本配置文件配置的。文本是许多系统的输出格式和输入格式。虽然像sedgrepawk这样的工具有它们的位置,但是 Python 是复杂文本操作的强大工具。

6.1 字节、字符串和 Unicode

当操作文本或类似文本的流时,很容易编写代码,当遇到外国名字或表情符号时会以有趣的方式失败。这些不再仅仅是理论上的担忧:你将拥有来自全世界的用户,他们坚持要求他们的用户名反映他们如何拼写他们的名字。有人会用表情符号写下git承诺。为了确保写出健壮的代码,公平地说,当他们处理一个凌晨 3 点的页面时,不会失败,看起来不那么有趣,理解“文本”是一件微妙的事情是很重要的。

你可以理解其中的区别,或者当有人试图用表情符号用户名登录时,你可以在凌晨 3 点醒来。

Python 3 有两种不同的类型,它们都代表了 UNIX“文本”文件中常见的内容:字节和字符串。字节对应于 RFC 通常所说的“八位字节流”这是一个适合 8 位的值序列,或者换句话说,是一个范围在 0 到 256(包括 0 但不包括 256)之间的数字序列。当所有这些值都小于 128 时,我们称这个序列为“ASCII”(美国信息交换标准代码),并赋予这些数字 ASCII 赋予它们的含义。当所有这些值都在 32 和 128 之间(包括 32 但不包括 128)时,我们称该序列为“可打印的 ASCII”或“ASCII 文本”前 32 个字符有时被称为“控制字符”键盘上的“Ctrl”键就是一个参考——它最初的目的是能够输入这些字符。

ASCII 仅包含在“美国”使用的英语字母表为了用(几乎)任何语言表示文本,我们使用了 Unicode。Unicode 码位是介于 0 和2∗∗32(包括 0 和不包括2∗∗32)之间的(部分)数字。每个 Unicode 码位都被赋予一个含义。标准的后续版本保留了指定的含义,但增加了更多数字的含义。一个例子是增加了更多的表情符号。国际标准化组织 ISO 在其 10464 标准中批准了 Unicode 的版本。因此,Unicode 有时被称为 ISO-10464。

同样是 ASCII 的 Unicode 点具有相同的含义——如果 ASCII 赋予一个数字“大写 A”,那么 Unicode 也是如此。

正确地说,只有 Unicode 才是“文本”这就是 Python 字符串所代表的。用一个编码完成字节到字符串的转换,反之亦然。目前最流行的编码是 UTF 8。令人困惑的是,将字节转换成文本就是“解码”将文本转换成字节就是“编码”

为了处理文本数据,记住编码和解码之间的区别是至关重要的。记住它的一种方法是,由于 UTF-8 编码,从字符串移动到 UTF-8 编码的数据是“编码”,而从 UTF-8 编码的数据移动到字符串是“解码”

UTF-8 有一个有趣的特性:当给定一个恰好是 ASCII 的 Unicode 字符串时,它将产生带有码位值的字节。这意味着“在视觉上”,编码和解码的形式看起来是一样的。

>>> "hello".encode("utf-8")
b'hello'
>>> "hello".encode("utf-16")
b'\xff\xfeh\x00e\x00l\x00l\x00o\x00'

我们展示了 UTF-16 的例子,以表明这不是编码的一个无关紧要的属性。UTF-8 的另一个特性是,如果字节是而不是 ASCII,并且字节的 UTF-8 解码成功,那么它们不太可能是用不同的编码进行编码的。这是因为 UTF-8 被设计成自同步:从一个随机字节开始,有可能与被检查的有限字节数的字符串同步。自同步旨在允许从截断和损坏中恢复,但作为一个附带的好处,它允许可靠地检测无效字符,从而检测字符串是否是 UTF-8 开始。

这意味着“用 UTF-8 尝试解码”是安全的操作;它将对纯 ASCII 文本做正确的事情,当然,它将对 UTF-8 编码的文本起作用,而对既不是 ASCII 也不是 UTF-8 编码的文本——无论是不同编码的文本还是 JPEG 等二进制格式的文本——将彻底失败。

对于 Python 来说,失败意味着“抛出异常”

>>> snowman = '\N{snowman}'
>>> snowman.encode('utf-16').decode('utf-8')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte

对于随机数据,这也容易失败:

>>> struct.pack('B'12,
                ∗(random.randrange(0, 256)
                for i in range(12))
 ).decode('utf-8')

误差是随机的,因为输入是随机的。一些错误示例可能是:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2 in position 4: invalid continuation byte
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x98 in position 2: invalid start byte

这是一个很好的练习,试着跑几次;几乎永远不会成功。

6.2 弦

Python 字符串对象很微妙。从一个角度来看,它似乎是一个字符序列:一个字符是一个长度为 1 的字符串。

>>> a="hello"
>>> for i, x in enumerate(a):
...     print(i, x, len(x))

...

0 h 1
1 e 1
2 l 1
3 l 1
4 o 1

字符串“hello”有五个元素,每个元素都是长度为 1 的字符串。由于字符串是一个序列,通常的序列操作对它进行操作。

我们可以通过指定两个端点来创建切片:

>>> a[2:4]
'll'

或者只是结尾:

>>> a[:2]
'he'

或者仅仅是开始:

>>> a[3:]
'lo'

我们也可以使用负指数从末尾开始计数:

>>> a[:-3]
'he'

当然,我们可以通过指定一个负步长的扩展片段来反转字符串:

>>> a[::-1]
'olleh'

然而,字符串也有相当多的方法是通用序列接口的而不是部分,在分析文本时非常有用。

startswithendswith方法是有用的,因为文本分析通常在末端。

>>> "hello world".endswith("world")
True

一个鲜为人知的特性是endswith允许一个字符串元组,并将检查它是否以这些字符串中的任何一个结尾:

>>> "hello world".endswith(("universe", "world"))
True

一个有用的例子是测试一些常见的结尾:

>>> filename.endswith((".tgz", ".tar.gz"))

我们可以很容易地在这里测试一个文件是否有一个 gzipped tarball 的通用后缀:或者是tgz或者是tar.gz后缀。

stripsplit方法对于解析许多 UNIX 文件或工具中出现的特定格式非常有用。例如,文件/etc/fstab包含静态挂载。

with open("/etc/fstab") as fpin:
    for line in fpin:
        line = line.rstrip('\n')
        line = line.split('#', 1)[0]
        if not line:
            continue
        device, path, fstype, options, freq, passno = line.split()
        print(f"Mounting {device} on {path}")

这将解析文件并打印摘要。循环中的第一行去掉了换行符。rstrip方法从字符串的右边(末尾)开始剥离。

请注意,rstripstrip都接受要删除的字符序列。这意味着将一个字符串传递给rstrip意味着“该字符串中的任何字符”,而不是“移除该字符串的出现”这并不影响rstrip的单字符参数,但是这意味着更长的字符串几乎总是一种错误的用法。

然后我们删除评论,如果有的话。我们跳过空行。任何不为空的行,我们使用不带参数的split来分割任何空白序列。方便的是,这种约定对几种格式都是通用的,正确的处理内置于split的规范中。

最后,我们使用一个格式字符串来格式化输出,以便于使用。

这是字符串解析的一种典型用法,也是替代 shell 中长管道的那种代码。

最后,字符串上的join方法将它用作“粘合剂”,将字符串的 iterable 粘合在一起。

简单的例子' '.join(["hello", "world"])将返回"hello world",但这只是对join的皮毛。因为它接受一个 iterable,所以我们有能力向它传递任何支持迭代的东西。

>>> names=dict(hello=1,world=2)
>>> ' '.join(names)
'hello world'

由于对字典对象的迭代产生了键的列表,将它传递给 join 意味着我们得到了一个包含键列表的字符串,它们连接在一起。

我们也可以传入一个生成器:

>>> '-∗-'.join(str(x) for x in range(3))
'0-∗-1-∗-2'

这允许在运行中计算序列并连接它们,而不需要序列的中间存储。

关于join的常见问题是,为什么它是“粘合”字符串上的方法,而不是序列上的方法。原因正是这样:我们可以传入任何 iterable,粘合字符串将粘合其中的位。

注意join对单元素的可重复项没有任何作用:

>>> '-∗-'.join(str(x) for x in range(0))
'0'

6.3 正则表达式

正则表达式是用于指定字符串属性的特殊 DSL,也称为“模式”它们在许多工具中很常见,尽管每个实现都有自己的特点。在 Python 中,正则表达式是由re模块实现的。它基本上允许两种交互模式——一种是在文本分析时自动解析正则表达式,另一种是预先解析正则表达式。

一般来说,后一种风格是首选。自动解析正则表达式只适用于交互式循环,在这种情况下,它们会很快被使用并被遗忘。出于这个原因,我们在这里不会真正涉及这种用法。

为了编译一个正则表达式,我们使用re.compile。该函数返回一个正则表达式对象,该对象将查找与表达式匹配的字符串。该对象可以用来做几件事:例如,查找一个匹配,查找所有匹配,甚至替换匹配。

正则表达式迷你语言有很多微妙之处。在这里,我们将只讨论说明如何有效使用正则表达式所需的基础知识。

大多数角色代表他们自己。例如,正则表达式hellohello完全匹配。.代表任何字符。所以hell。将匹配hellohella,但不匹配hell——因为后者没有任何对应于..方括号定界“字符类”的字符:例如,wom[ae]n匹配womenwoman。字符类中也可以有范围——[0-9]匹配任何数字,[a-z]匹配任何小写字符,[0-9a-fA-F]匹配任何十六进制数字(十六进制数字和数字在很多地方都会出现,因为两个十六进制数字正好对应一个标准字节)。

我们还有“重复修饰语”,用来修饰它们前面的表达式。例如,ba?b同时匹配bbbab?代表“零或一”代表任意数字:所以ba∗b代表bbbabbaabbaaab等等。If我们想要“至少一个”,ba+b将匹配ba∗b匹配的几乎所有内容,除了bb。最后,我们有确切的计数器:ba{3}b匹配baaab,而ba{1,2}b匹配babbaab,除此之外别无其他。

为了让一个特殊字符(比如.)匹配它自己,我们在它前面加了一个反斜杠。因为在 Python 字符串中,反斜杠有其他含义,所以 Python 支持“原始”字符串。虽然我们可以使用任何字符串来表示正则表达式,但原始字符串通常更容易。

例如,我们想要一个类似 DOS 的文件名正则表达式:r"[^.]{1,8}\.[^.]{0,3}."这将匹配,比如说,readme.txt但不匹配archive.tar.gz。请注意,要匹配文字。我们用反斜杠对它进行了转义。还要注意,我们使用了一个有趣的角色类:[^.]。这意味着“除了.之外的任何东西”:^意味着“排除”一个字符类内部。

正则表达式也支持分组。分组做两件事:它允许对表达式的一部分进行寻址,它允许将表达式的一部分作为单个对象来处理,以便对它应用重复操作之一。如果只需要后者,这是一个“非捕获”组,用(?:....)表示。

例如,(?:[a-z]{2,5}-){1,4}[0-9]将匹配hello-3hello-world-5,但不匹配a-hello-2(因为第一部分不是两个字符长)或hello-world-this-is-too-long-7,因为它由内部模式的六个重复组成,我们指定最多四个。

这允许任意嵌套;例如,(?:(?:[a-z]{2,5}-){1,4}[0-9];)+允许前面模式的任何分号结束的、分隔的序列:例如,az-2;hello-world-5;将匹配,但this-is-3;not-good-match-6不匹配,因为它在末尾缺少了;

这是一个很好的例子,说明正则表达式有多复杂。很容易在 Python 中使用这种密集的迷你语言来指定难以理解的字符串约束。

一旦我们有了一个正则表达式对象,上面主要有两个方法:matchsearchmatch方法将在字符串的开头寻找匹配,而search将寻找第一个匹配,无论它可能从哪里开始。当他们找到一个匹配时,他们返回一个匹配对象。

>>> reobj = re.compile('ab+a')
>>> m = reobj.search('hello abba world')
>>> m
<_sre.SRE_Match object; span=(6, 10), match="abba">
>>> m.group()
'abba'

经常使用的第一种方法是.group(),它返回字符串匹配的部分。如果正则表达式包含捕获组的*,这个方法可以得到部分匹配。一个捕获组通常标有()。*

>>> reobj = re.compile('(a)(b+)(a)')
>>> m = reobj.search('hello abba world')
>>> m.group()
'abba'
>>> m.group(1)
'a'
>>> m.group(2)
'bb'
>>> m.group(3)
'a'

当组的数量很大时,或者当修改组时,管理组的索引可能是一个挑战。如果需要进行分组分析,也可以分组。

>>> reobj = re.compile('(?P<prefix>a)(?P<body>b+)(?P<suffix>a)')
>>> m = reobj.search('hello abba world')
>>> m.group('prefix')
'a'
>>> m.group('body')
'bb'
>>> m.group('suffix')
'a'

由于正则表达式可能会变得很密集,有一种方法可以使它们变得更容易阅读:详细模式。

>>> reobj = re.compile(r"""
... (?P<prefix>a) # The beginning -- always an a
... (?P<body>b+)  # The middle -- any numbers of b, for emphasis
... (?P<suffix>a) # An a at the end to properly anchor
... """, re.VERBOSE)
>>> m = reobj.search("hello abba world")
>>> m.groups()
('a', 'bb', 'a')
>>> m.group('prefix'), m.group('body'), m.group('suffix')
('a', 'bb', 'a')

当编译带有标志re.VERBOSE的正则表达式时,空白被忽略,类似 Python 的注释:

#到行尾,也被忽略。为了匹配空格或#,它们需要被反斜杠转义。

这允许编写长正则表达式,同时通过明智的换行符、空格和注释使它们更容易理解。

正则表达式松散地基于有限自动机的数学理论。虽然它们确实超越了有限自动机所能匹配的约束,但它们并不完全通用。除此之外,它们不太适合嵌套模式;无论是匹配括号还是 HTML 元素,都不太适合正则表达式。

6.4 JSON

JSON 是一种分层的文件格式,它的优点是易于解析,并且相当容易手动读写。它起源于网络:这个名字代表“JavaScript 对象符号”的确,在网上还是比较流行的;关注 JSON 的一个原因是许多 web APIs 使用 JSON 作为传输格式。

然而,它在其他地方也是有用的。例如,在 JavaScript 项目中,package.json包括这个项目的依赖项。例如,对其进行解析通常有助于确定安全性或合规性审计的第三方依赖性。

理论上,JSON 是用 Unicode 定义的格式,而不是用字节定义的格式。序列化时,它接受数据结构并将其转换为 Unicode 字符串,反序列化时,它接受 Unicode 字符串并返回数据结构。然而,最近该标准被修改以指定一个首选编码:utf-8。有了这个增加,现在格式也被定义为一个字节流。

但是,请注意,在某些用例中,编码仍然与格式分离。特别是,当通过 HTTP 发送或接收 JSON 时,HTTP 编码是最终的真理。尽管如此,当没有明确指定编码时,应该采用 UTF-8。

JSON 是一种简单的序列化格式,仅支持几种类型:

  • 用线串

  • 民数记

  • 布尔运算

  • 一种null类型

  • JSON 值的数组

  • “对象”:将字符串映射到 JSON 值的字典

请注意,JSON 没有完全指定数值范围或精度。如果需要精确的整数,通常可以假设范围-2∗∗532∗∗53是可以精确表示的。

尽管 Python json库能够直接读写文件,但实际上我们几乎总是将任务分开;我们根据需要读取尽可能多的数据,并将字符串直接传递给 JSON。

json模块中最重要的两个功能是loadsdumps。末尾的s代表“字符串”,这是这些函数接受和返回的内容。

>>> thing = [{"hello": 1, "world": 2}, None, True]
>>> json.dumps(thing)
'[{"hello": 1, "world": 2}, null, true]'
>>> json.loads(_)
[{'hello': 1, 'world': 2}, None, True]

Python 中的None对象映射到 JSON null对象,Python 中的布尔映射到 JSON 中的布尔,数字和字符串映射到数字和字符串。请注意,Python JSON 解析库根据所使用的符号来决定一个数字应该映射到整数还是浮点数:

>>> json.loads("1")
1
>>> json.loads("1.0")
1.0

重要的是要记住,不是所有的 JSON 加载库都做出相同的决定,在某些情况下,这可能会导致互操作性问题。

出于调试的原因,能够“漂亮地打印”JSON 通常是有用的。通过一些额外的参数,dumps函数可以做到这一点。支持漂亮印刷的通常论据如下:

json.dumps(thing, sort_keys=True, indent=4)

如果我们想往返到一个等价的,但漂亮的版本,我们甚至可以这样做:

json.dumps(json.loads(encoded_string), sort_keys=True, indent=4)

最后,在命令行中,模块: json.tool 将自动执行以下操作:

$ python -m json.tool < somefile.json | less

这是浏览转储的 JSON 并寻找感兴趣的信息的一种简单方法。

注意,在 Python 3.7 及以上版本中,sort_keys要慎用;由于所有的字典都是按插入排序的,而不是使用sort_keys将保持字典中的原始顺序。

JSON 中一个经常遗漏的类型是日期-时间类型。通常这用字符串来表示,这是解析 JSON 的“模式”最常见的需求,以便知道哪些字符串要转换成datetime对象。

6.5 CSV

CSV 格式有几个优点。它是受约束的:它总是在二维数组中表示标量类型。正因如此,能进去的惊喜并不多。此外,它还是一种可以导入到 Microsoft Excel 或 Google Sheets 等电子表格应用中的格式。这在准备报告时很方便。

此类报告的示例包括为财务部门支付第三方服务的费用明细,或者为管理层提供的关于管理的事件和恢复时间的报告。在所有这些情况下,拥有一个易于生成和导入到电子表格应用中的格式可以轻松实现任务的自动化。

csv.writer写 CSV 文件。一个典型的例子是序列化一个同质数组,一个相同类型的数组。

@attr.s(frozen=True, auto_attribs=True)
class LoginAttempt:
    username: str
    time_stamp: int
    success: bool

该类表示某个用户在给定时间的登录尝试,并记录了该尝试的成功。对于安全审计,我们需要向审计员发送一个 Excel 文件,记录登录尝试。

def write_attempts(attempts, fname):
    with open(fname, 'w') as fpout:
        writer = csv.writer(fpout)
        writer.writerow(['Username', 'Timestamp', 'Success'])
        for attempt in attempts:
            writer.writerow([
                attempt.username,
                attempt.time_stamp,
                str(attempt.success),
            ])

注意,按照惯例,第一行应该是“标题行”尽管 Python API 并不强制要求这样做,但强烈建议遵循这一约定。在这个例子中,我们首先写了一个带有字段名称的“标题行”。

然后我们循环尝试。请注意,CSV 只能表示字符串和数字,因此我们没有依赖于关于如何写出布尔值的简单标准,而是显式地这样做了。

这样,如果审计员要求该字段为“是/否”,我们可以改变我们的显式序列化步骤来匹配。

在读取 CSV 文件时,有两种主要方法。

使用csv.reader将返回一个迭代器,该迭代器以列表的形式逐行解析。然而,假设遵循了第一行是字段名称的约定,csv.DictReader将不会为第一行产生任何内容,并为随后的每一行产生一个字典,使用字段名称作为键。这使得在面对终端用户添加字段或改变它们的顺序时能够进行更健壮的解析。

>>> reader = csv.DictReader(fileobj)
>>> list(reader)
[OrderedDict([('Username', 'alice'),
              ('Timestamp', '1514793600.0'),
              ('Success', 'False')]),
 OrderedDict([('Username', 'bob'),
              ('Timestamp', '1539154800.0'),
              ('Success', 'True')])]

读取我们在前面的例子中编写的相同的 CSV 将产生合理的结果。字典将字段名映射到值。需要注意的是,所有类型都被遗忘了,所有内容都以字符串的形式返回。不幸的是,CSV 不保存类型信息。

有时候,用.split来“即兴”解析 CSV 文件是很诱人的。然而,CSV 有相当多的不明显的极限情况。

例如,

1,"Miami, FL","he""llo"

被正确地解析为

('1', 'Miami, FL', 'he"llo')

出于同样的原因,避免使用除了csv.writer .之外的任何东西来编写 CSV 文件是一个好主意

6.6 总结

许多 DevOps 任务所需的大部分内容以文本形式出现:日志、数据结构的 JSON 转储或付费许可证的 CSV 文件。理解什么是“文本”,以及如何在 Python 中操作它,可以实现作为 DevOps 基石的许多自动化,无论是通过构建自动化、监控结果分析,还是仅仅准备摘要以方便他人使用。

七、requests

许多系统都公开了基于 web 的 API。有了requests库,自动化基于 web 的 API 很容易。它被设计成易于使用,同时仍然公开了许多强大的功能。使用requests几乎总是比使用 Python 的标准库 HTTP 客户端设施要好。

7.1 会议

如前所述,最好在requests中使用显式会话。重要的是要记住,没有请求中的会话,就没有所谓的工作;当使用“函数”时,它使用全局会话对象。

这是有问题的,原因有几个。首先,这正是那种“全局可变共享状态”,这会导致很难诊断错误。例如,当连接到使用 cookie 的网站时,请求连接到同一网站的另一个用户可以覆盖 cookie。这导致了可能相距甚远的代码片段之间微妙的交互。

它有问题的另一个原因是,这使得代码对 unittest 来说很重要。必须显式模拟request.get/request.post函数,而不是提供一个假的Session对象。

最后但同样重要的是,有些功能只有在使用显式的Session对象时才可访问。如果以后需要使用它,例如,因为我们希望为所有请求添加一个跟踪头或一个自定义用户代理,那么重构所有代码以使用显式会话可能会很微妙。

对于任何期望长寿的代码来说,使用显式会话对象要好得多。出于类似的原因,最好不要让大部分代码构造自己的Session对象,而是将它作为参数获取。

这允许在离主代码更近的地方初始化会话。这很有用,因为这意味着关于使用哪个代理以及何时使用代理的决定可以在更接近最终用户需求的时候做出,而不是在抽象的库代码中。

requests.Session()构造一个会话对象。之后,唯一的互动应该是与对象。session 对象拥有所有的 HTTP 方法:s.gets.puts.posts.patchs.options

会话可用作上下文:

with requests.Session() as s:
    s.get(...)

在上下文结束时,所有挂起的连接都将被清除。这有时很重要,尤其是如果一个 web 服务器有严格的使用限制,我们无论如何都不能超过。

注意,依靠 Python 的引用计数来关闭连接可能是危险的。这不仅没有得到语言的保证(例如,在 PyPy 中也不会是真的),而且一些小事情可以很容易地阻止这种情况的发生。例如,会话可以被捕获为堆栈跟踪中的局部变量,并且该堆栈跟踪可以包含在循环数据结构中。这意味着连接在很长一段时间内都不会关闭:直到 Python 执行了一个循环垃圾收集周期。

会话支持一些变量,我们可以对这些变量进行修改,以便以特定的方式发送所有请求。最常见的需要编辑的是s.auth。我们将在后面详细介绍requests的认证功能。

另一个对变异有用的变量是session.headers。这些是随每个请求一起发送的默认标头。这有时对User-Agent变量很有用。特别是当使用requests来测试我们自己的 web APIs 时,在代理中拥有一个标识字符串是非常有用的。这将允许我们检查服务器日志,并区分哪些请求来自测试,哪些来自真实用户。

session.headers = {'User-Agent': 'Python/MySoftware ' + __version__ }

这将允许检查哪个版本的测试代码导致了问题。特别是如果测试代码使服务器崩溃,并且我们想要禁用它,这在诊断中是非常宝贵的。

该会话还在cookies成员中包含一个CookieJar。如果我们想要显式刷新或检查 cookies,这是很有用的。如果我们想要有可重启的会话,我们还可以用它将 cookies 持久化到磁盘上并恢复它们。

我们可以改变 cookie jar 或者大规模替换它:任何兼容cookielib.CookieJar的对象都可以工作。

最后,会话中可以有一个客户端证书,用于需要这种身份验证的情况。这可以是一个pem文件(密钥和证书连接在一起),也可以是一个包含证书和密钥文件路径的元组。

7.2 休息

REST 名称代表“代表性状态转移”它是一个松散的、应用松散的 web 信息表示标准。它通常用于将面向行的数据库结构几乎直接映射到 web 上,允许编辑操作;当以这种方式使用时,它通常也被称为“CRUD”模型:创建、检索、更新和删除。

当对 CRUD 使用 REST 时,经常会用到一些 web 操作。

第一个是创建到POST的地图,通过session.post访问。从某种意义上来说,尽管它是名单上的第一个,但却是四个中最不“宁静”的。这是因为它的语义不是“重放”安全的。

这意味着如果session.post引发了一个网络级错误,例如socket.error,那么如何处理就不明显了;这个对象真的被创建了吗?如果对象中的一个字段必须是惟一的,例如,用户的电子邮件地址,那么重放是安全的:如果创建操作较早成功,重放将会失败。

然而,这取决于应用语义,这意味着一般情况下不可能重放。

幸运的是,通常用于其他操作的 HTTP 方法是“重放安全”这个性质也被称为幂等性,它受到幂等函数的数学概念的启发(尽管并不完全相同)。这意味着如果发生网络故障,再次发送操作是安全的。

如果服务器遵循正确的 HTTP 语义,随后的所有操作都是重放安全的。

更新操作通常用PUT(对于整体对象更新)或PATCH(当改变特定字段时)来实现。

删除操作用 HTTP DELETE实现。这里的重放安全性是微妙的;不管一个重放是成功还是失败,最后我们都处于一个已知的状态。

用 HTTP GET实现的 Retrieve 几乎总是只读操作,replay safe 也是如此:在网络故障后重试是安全的。

如今,大多数 REST 服务都使用 JSON 作为状态表示。requests库对 JSON 有特殊的支持。

>>> pprint(s.get("https://httpbin.org/json").json())
{'slideshow': {'author': 'Yours Truly',
               'date': 'date of publication',
               'slides': [{'title': 'Wake up to WonderWidgets!', 'type': 'all'},
                          {'items': ['Why <em>WonderWidgets</em> are great',
                                     'Who <em>buys</em> WonderWidgets'],
                           'title': 'Overview',
                           'type': 'all'}],
               'title': 'Sample Slide Show'}}

请求的返回值Response有一个.json()方法,该方法假设返回值是 JSON 并解析它。虽然这仅节省了一个步骤,但在多阶段流程中,这是一个非常有用的步骤,在这种流程中,我们得到一些 JSON 编码的响应,只是为了在下一个请求中使用它。

也可以将请求体自动编码为 JSON:

>>> resp = s.put("https://httpbin.org/put", json=dict(hello=5,world=2))
>>> resp.json()['json']
{'hello': 5, 'world': 2}

将这两者结合起来,通过多步骤的过程,通常是有用的。

>>> res = s.get("https://api.github.com/repos/python/cpython/pulls")
>>> commits_url = res.json()[0]['commits_url']
>>> commits = s.get(commits_url).json()
>>> print(commits[0]['commit']['message'])

这个从 CPython 项目的第一个 pull 请求中获取 commit 消息的例子是一个使用好的 REST API 的典型例子。一个好的 REST API 包括作为资源标识符的 URL。我们可以将这些 URL 传递给进一步的请求,以获取更多信息。

7.3 安全性

HTTP 安全模型依赖于认证机构,通常简称为“CAs”。证书颁发机构对属于特定域(或不太常见的 IP)的公钥进行加密签名。为了启用密钥轮换和撤销,认证机构不使用他们的根密钥(浏览器信任的那个)来签署公钥。相反,他们签署一个“签名密钥”,它签署公钥。在这些“链”中,每个密钥签署下一个密钥,直到最终的密钥是服务器正在使用的那个密钥,这些链可以变得很长:通常有三级或四级深链。

由于证书对进行签名,并且域通常托管在同一个 IP 上,因此请求证书的协议包括“服务器名称指示”,即 SNI。SNI 发送客户端想要连接的服务器名称,但不加密。然后,服务器使用适当的证书进行响应,并使用加密技术证明它拥有与签名的公钥相对应的私钥。

最后,可选地,客户端可以参与对其自身身份的加密证明。这是通过有点名不副实的“客户端证书”来实现的客户端必须使用证书和私有密钥进行初始化。然后,客户端发送证书,如果服务器信任认证机构,则证明它拥有相应的私钥。

客户端证书很少在浏览器中使用,但有时会被程序使用。对于一个程序来说,它们通常是更容易部署的秘密:包括requests在内的大多数客户端已经支持从文件中读取它们。这使得使用通过文件提供秘密的系统来部署它们成为可能,比如 Kubernetes。这也意味着通过普通的 UNIX 系统权限来管理它们的权限更加容易。

*请注意,客户端证书通常不属于公共 CA。更确切地说,服务器所有者操作一个本地 CA,它通过一些本地确定的程序为客户端签署证书。这可以是从 IT 人员手动签名到自动签名证书的单点登录门户。

为了认证服务器端证书,requests需要有一个客户端根 ca 源,以便能够成功完成安全连接。根据ssl构建过程的细微之处,它可能会也可能不会访问系统证书库。

确保有一组好的根 ca 的最好方法是安装包certifi。这个包有 Mozilla 兼容的证书,requests将原生使用它。

这在连接到互联网时很有用;几乎所有的网站都经过测试,可以与 Firefox 兼容,因此都有兼容的证书链。如果证书验证失败,就会抛出错误CERTIFICATE VALIDATE FAILED。互联网上有很多不幸的建议,包括在requests文档中,关于传入标志verify=False的“解决方案”。虽然在极少数情况下这个标志有意义,但它几乎从来没有意义。它的使用违反了 TLS 的核心假设:连接是加密和防篡改的。

例如,请求上有一个verify=False意味着任何 cookies 或认证凭证现在都可以被任何有能力修改流内数据包的人截获。不幸的是,这种情况很普遍:ISP 和开放接入点通常都有动机不良的运营商。

更好的替代方法是确保文件系统中存在正确的证书,并通过verify='/full/path'将路径传递给verify参数。至少,这允许我们有一种“第一次使用时信任”的形式:手动从服务中获取证书,并将其写入代码。更好的做法是尝试一些带外验证,例如,让某人登录服务器并验证证书。

选择允许什么样的 SSL 版本,或者允许什么样的密码,稍微有点微妙。同样,这么做的理由很少:requests设置了良好、安全的缺省值。然而,有时有一些最重要的问题:例如,出于监管原因避免使用特定的 SSL 密码。

要知道的第一件重要的事情是,requests是围绕urllib3库的包装器。为了改变底层参数,我们需要编写一个定制的HTTPAdapter,并设置我们正在使用的会话对象来使用定制适配器。

from requests.adapters import HTTPAdapter
from requests.packages.urllib3.poolmanager import PoolManager

class MyAdapter(HTTPAdapter):
    pass

s = requests.Session()
s.mount('https://', MyAdapter())

当然,这没有业务逻辑影响:MyAdapter类与HTTPAdapter类没有什么不同。但是现在我们有了定制适配器的机制,我们可以更改 SSL 版本:

class MyAdapter(HTTPAdater)
    def init_poolmanager(self, connections, maxsize, block=False):
        self.poolmanager = PoolManager(num_pools=connections,
                                       maxsize=maxsize,
                                       block=block,
                                       ssl_version=ssl.PROTOCOL_TLS)

ssl_version非常相似,我们也可以使用ciphers=关键字参数来微调密码列表。这个关键字参数应该是一个字符串,它具有用 ?? 分隔的密码名称。

Requests 还支持所谓的“客户端”证书。很少用于用户到服务的通信,但有时用于微服务架构,客户端证书使用与服务器标识自己相同的机制来标识客户端:使用加密签名的证明。客户端需要一个私钥和一个相应的证书。这些证书通常由私有 CA 签署,它是本地基础设施的一部分。

证书和密钥可以连接到同一个文件中,通常称为“PEM”文件。在这种情况下,初始化会话以标识它是通过以下方式完成的:

s = requests.Session()
s.cert = "/path/to/pem/file.pem"

如果证书和私钥在不同的文件中,则它们以元组的形式给出:

s = requests.Session()
s.cert = ("/path/to/client.cert", "/path/to/client.key")

此类关键文件必须小心管理;任何对它们有读取权限的人都可以假装是客户。

7.4 认证

这将是与请求一起发送的默认身份验证。包含在requests本身中,最常用的认证是基本认证

对于基本 auth,这个参数可以只是一个元组,(username, password)。然而,更好的做法是使用一个HTTPBasicAuth实例。这更好地记录了意图,如果我们想切换到其他身份验证形式,这很有用。

还有一些第三方包实现了身份验证接口并提供了定制的身份验证类。该接口非常简单:它希望对象是可调用的,并将使用Request对象调用该对象。预计调用将使Requests变异,通常是通过添加头。

官方文档推荐子类化AuthBase,它只是一个实现了引发NotImplementedError__call__的对象。这几乎没有必要。

例如,下面是一个有用的对象,它将使用 V4 签名协议对 AWS 请求进行签名。

我们做的第一件事是使 URL“规范”规范化是许多签名协议的第一步。由于在签名检查器开始查看内容时,软件的更高级别通常已经解析了内容,所以我们将签名的数据转换成唯一对应于解析版本的标准形式。

最微妙的部分是查询部分。我们解析它,并使用urlparse内置库对它进行重新编码。

def canonical_query_string(query):
    if not query:
        return ""
    parsed = parse_qs(url.query, keep_blank_values=True)
    return "?" + urlencode(parsed, doseq=True)

我们在 URL 规范化函数中使用这个函数:

def to_canonical_url(url):
    url = urlparse(raw_url)
    path = url.path or "/"
    query = canonical_query_string(url.query)
    return (url.scheme +
            "://" +
            url.netloc +
            path +
            querystring)

这里我们确保路径是规范的:我们将一个空路径转换为/

from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

def sign(request, ∗, aws_session, region, service):
    aws_request = AWSRequest(
        method=request.method.upper(),
        url=to_canonical_url(request.url),
        data=request.body,
    )
    credentials = aws_session.get_credentials()
    SigV4Auth(credentials, service, region).add_auth(request)
    request.headers.update(∗∗aws_request.headers.items())

我们创建了一个使用botocore(AWS Python SDK)来签署请求的函数。我们通过用规范的 URL 和相同的数据“伪造”一个AWSRequest对象,要求签名,然后从“伪造的”请求中获取头。

我们的用法如下:

requests_session = requests.Session()
requests_session.auth = functools.partial(sign,
    aws_session=boto3.Session(),
    region='us-east-1',
    service='es',
)

functools.partial是一种从原始函数中获得简单可调用函数的简单方法。注意,在这种情况下,区域和服务是 auth“对象”的一部分一种更复杂的方法是从请求的 URL 中推断出地区和服务,并使用它。这超出了这个简单示例的范围。然而,这应该能让我们很好地了解定制认证方案是如何工作的:我们编写代码来修改请求,使其具有正确的认证头,然后将它作为会话的auth属性放入。

7.5 总结

说“HTTP 流行”感觉是轻描淡写。它无处不在:从用户可访问的服务,通过面向 web 的 API,甚至在许多微服务架构的内部。

有助于所有这些:它可以帮助监控用户可访问的健康服务的一部分,它可以帮助我们访问程序中的 API 来分析数据,它可以帮助我们调试内部服务以了解它们的状态。

它是一个强大的库,有许多方法可以对它进行微调,以发送完全正确的请求,并获得完全正确的功能。*

八、密码系统

密码术是安全架构的许多部分中的必要组件。然而,仅仅在代码中添加加密技术并不能使其更加安全;必须注意诸如秘密生成、秘密存储和明文管理等主题。正确设计安全软件是一件复杂的事情,当涉及到加密技术时更是如此。

安全性设计超出了我们的范围:这一章只讲述 Python 用于加密的基本工具,以及如何使用它们。

8.1 费内特

cryptography模块支持fernet加密标准。它是以一种意大利酒命名的,而不是法国酒。发音的一个很好的近似是“公平网”

fernet对称密码术工作。它不支持部分或流解密:它期望读入整个密文并返回整个明文。这使得它适用于名称、文本文档,甚至图片。然而,视频和磁盘映像并不适合 Fernet。

Fernet 的加密参数是由领域专家选择的,他们研究了可用的加密方法,以及已知的针对这些方法的最佳攻击。使用 Fernet 的一个好处是,它避免了您自己成为专家的需要。然而,为了完整性,我们注意到 Fernet 标准在 CBC 中使用 AES-128 填充 PKCS7,而 HMAC 使用 SHA256 进行认证。

Go、Ruby 和 Erlang 也支持 Fernet 标准,因此有时适用于与其他语言的数据交换。它被特别设计成不安全地使用它比正确地使用它更难。

>>> k = fernet.Fernet.generate_key()
>>> type(k)
<class 'bytes'>

密钥是一个很短的字节串。安全地管理密钥是很重要的:加密技术的好坏取决于它的密钥。例如,如果它保存在一个文件中,那么该文件应该具有最低限度的权限,并且理想情况下托管在一个加密的文件系统上。

generate_key类方法使用操作系统级别的随机字节源来安全地生成密钥。然而,它仍然容易受到操作系统级缺陷的攻击:例如,在克隆虚拟机时,必须注意在开始克隆时,它会刷新随机性的来源。不可否认,这是一个深奥的案例,无论使用什么虚拟化系统,都应该有关于如何刷新其虚拟机中的随机性来源的文档。

>>> frn = fernet.Fernet(k)

fernet 类用一个键初始化。它将确保密钥是有效的。

>>> encrypted = frn.encrypt(b"x marks the spot")
>>> encrypted[:10]
b'gAAAAABb1'

加密很简单。它接受一个字节字符串并返回一个加密的字符串。注意,加密的字符串比源字符串长。原因是它也是用密钥签名的。这意味着对加密字符串的篡改是可以检测到的,Fernet API 通过拒绝解密字符串来处理这个问题。这意味着从解密得到的值是可信的;它确实是由有权使用密钥的人加密的。

>>> frn.decrypt(encrypted)
b'x marks the spot'

解密的方式与加密相同。Fernet 确实包含一个版本标记,所以如果发现其中的漏洞,就有可能将标准转移到不同的加密和散列系统。

Fernet encryption 总是将当前日期添加到签名的加密信息中。因此,在解密前限制消息的年龄是可能的。

>>> frn.decrypt(encrypted, ttl=5)

如果加密信息(有时称为“令牌”)的时间超过五秒,这将失败。这有助于防止重放攻击:捕获并重放先前加密的令牌,而不是新的有效令牌。例如,如果加密的令牌具有允许某些访问的用户名列表,并且使用可破坏的介质来检索,则不再被允许进入的用户可以替换旧令牌。

确保令牌的新鲜将意味着没有这样的列表会被解码,每个人都将被拒绝——这并不比媒体在没有先前有效的令牌的情况下被篡改更糟糕。

这也可以用来确保良好的秘密轮换卫生。通过拒绝解密任何超过一周的内容,我们可以确保,如果秘密轮换基础设施崩溃,我们将大声失败,而不是静静地成功,从而修复它。

为了支持无缝的密钥轮换,Fernet 模块还有一个MultiFernet类。MultiFernet取秘密列表。它用第一个秘密加密,但会尝试用任何秘密解密。

这意味着如果我们在末尾添加一个新的密钥,第一,它不会被用于加密。在添加到末尾是同步的之后,我们可以删除第一个键。现在所有的加密都将通过第二个密钥来完成;并且即使在那些还没有同步的情况下,解密密钥也是可用的。

这一两步过程旨在实现零“无效解密”错误,同时仍允许密钥轮换,这是一项重要的预防措施——经过充分测试的轮换程序意味着,如果密钥泄露,轮换程序可以将它们造成的危害降至最低。

8.2 氯化钠

PyNaCl 是一个包装了 C 库的库。libsodium是丹尼尔·j·伯恩斯坦(Daniel J. Bernstein)的libnacl,的一个分支,这就是为什么PyNaCl被这样命名的原因。(氯化钠是 Salt 的化学分子式。fork 采用第一个元素的名称。)

PyNaCl 支持对称和非对称加密。然而,由于加密支持 Fernet 的对称加密,PyNaCl 的主要用途是用于非对称加密。

不对称加密的思想是有一个私钥和一个公钥。公钥可以很容易地从私钥中计算出来,但反之则不然;也就是它所指的“不对称”。公钥是公开的,而私钥必须保密。

一般来说,公钥加密支持两种基本操作。我们可以用公钥加密,但只能用私钥解密。我们还可以用私钥对 T1 进行签名,这种签名方式可以用公钥进行验证。

正如我们之前所讨论的,现代密码实践对认证和 T2 保密的重视程度不相上下。这是因为如果传输秘密的介质容易被窃听,它通常也容易被修改。秘密修改攻击在这个领域已经产生了足够的影响,如果一个密码系统不能保证真实性和保密性,它就不能被认为是完整的。

正因为如此,libsodium以及进一步的PyNaCl不支持没有签名的加密,或者没有签名验证的解密。

为了生成私钥,我们只需使用 class 方法:

>>> from nacl.public import PrivateKey
>>> k = PrivateKey.generate()

k的类型是PrivateKey。然而,在某些时候,我们通常会希望保存私钥。

>>> type(k.encode())
<class 'bytes'>

encode方法将密钥编码为字节流。

>>> kk = PrivateKey(k.encode())
>>> kk == k
True

我们可以从字节流中生成一个私钥,它将是相同的。这意味着我们可以再次以一种我们认为足够安全的方式保存私钥:例如,一个秘密管理器。

为了加密,我们需要一个公钥。公钥可以由私钥生成。

>>> from nacl.public import PublicKey
>>> target = PrivateKey.generate()
>>> public_key = target.public_key

当然,在更现实的场景中,公钥需要存储在某个地方:文件中、数据库中,或者只是通过网络发送。为此,我们需要将公钥转换成字节。

>>> encoded = public_key.encode()
>>> encoded[:4]
b'\xb91>\x95'

当我们得到字节时,我们可以重新生成公钥。它与原始公钥相同。

>>> public_key_2 = PublicKey(key_bytes)
>>> public_key_2 == public_key
True

PyNaCl Box代表一对密钥:第一个私有,第二个公共。Box用私钥签名,然后用公钥加密。我们加密的每条消息都会被签名。

>>> from nacl.public import PrivateKey, PublicKey, Box
>>> source = PrivateKey.generate()
>>> with open("target.pubkey", "rb") as fpin:
...     target_public_key = PublicKey(fpin.read())
>>> enc_box = Box(source, target_public_key)
>>> result = enc_box.encrypt(b"x marks the spot")
>>> result[:4]
b'\xe2\x1c0\xa4'

这个使用source私钥签名,使用target的公钥加密。

当我们解密时,我们需要建立逆盒。这发生在不同的计算机上:一台有target 私钥但只有源的公钥 .的计算机

>>> from nacl.public import PrivateKey, PublicKey, Box
>>> with open("source.pubkey", "rb") as fpin:
...     source_public_key = PublicKey(fpin.read())
>>> with open("target.private_key", "rb") as fpin:
...     target = PrivateKey(fpin.read())
>>> dec_box = Box(target, source_public_key)
>>> dec_box.decrypt(result)
b'x marks the spot'

解密盒使用target私钥解密,并使用source的公钥验证签名。如果信息被篡改,解密操作将自动失败。这意味着不可能访问没有正确签名的纯文本信息。

PyNaCl 中另一个有用的功能是加密签名。在没有加密的情况下签名有时是有用的:例如,我们可以通过签名来确保只使用被认可的二进制文件。这允许存储二进制文件的权限是宽松的,只要我们相信保持签名密钥安全的权限足够强。

签名还涉及非对称加密。私钥用于签名,公钥用于验证签名。这意味着,例如,我们可以将公钥登记到源代码控制中,并避免需要对验证部分进行任何进一步的配置。

我们首先必须生成私有签名密钥。这类似于生成用于解密的密钥。

>>> from nacl.signing import SigningKey
>>> key = SigningKey.generate()

我们通常需要将这个密钥(安全地)存放在某个地方,以便重复使用。同样,值得记住的是,能够访问签名密钥的任何人都可以对他们想要的任何数据进行签名。为此,我们可以使用编码:

>>> encoded = key.encode()
>>> type(encoded)
<class 'bytes'>

可以从编码版本中重构密钥。产生一把相同的钥匙。

>>> key_2 = SigningKey(encoded)
>>> key_2 == key
True

为了验证,我们需要验证密钥。由于这是非对称加密,验证密钥可以从签名密钥中计算出来,但反之则不行。

>>> verify_key = key.verify_key

我们通常需要将验证密钥存储在某个地方,所以我们需要能够将其编码为字节。

>>> verify_encoded = verify_key.encode()
>>> verify_encoded[:4]
b'\x08\xb1\x9e\xf4'

我们可以重建验证密钥。给出了一把相同的钥匙。像所有的...Key类一样,它支持一个接受编码键并返回 key 对象的构造函数。

>>> from nacl.signing import VerifyKey
>>> verify_key_2 = VerifyKey(verify_encoded)
>>> verify_key == verify_key_2
True

当我们签署一条消息时,我们得到一个有趣的对象:

>>> message = b"The number you shall count is three"
>>> result = key.sign(message)
>>> result
b'\x1a\xd38[....'

它以字节显示。但它不是字节:

>>> type(result)
<class 'nacl.signing.SignedMessage'>

我们可以分别从中提取消息和签名:

>>> result.message
b'The number you shall count is three'
>>> result.signature
b'\x1a\xd38[...'

如果我们想把签名保存在一个单独的地方,这是很有用的。例如,如果原始文件在对象存储中,那么出于各种原因,对其进行变更可能是不可取的。在这种情况下,我们可以将签名“放在一边”另一个原因是为了不同的目的维护不同的签名,或者允许密钥轮换。

如果我们确实想写完整的签名消息,最好将结果显式转换为字节。

>>> encoded = bytes(result)

验证返回验证过的消息。这是使用签名的最佳方式;这样,代码就不可能处理未经验证的消息。

>>> verify_key.verify(encoded)
b'The number you shall count is three'

但是,如果有必要从其他地方读取对象本身,然后将它传递给验证器,这也很容易做到。

>>> verify_key.verify(b'The number you shall count is three',
...                   result.signature)
b'The number you shall count is three'

最后,我们可以直接使用结果对象进行验证。

>>> verify_key.verify(result)
b'The number you shall count is three'

8.3 密码库

密码的安全存储是一件微妙的事情。它如此微妙的最大原因是它必须与不使用密码最佳实践的人打交道。如果所有的密码都是强密码,并且人们从不在不同的站点之间重复使用密码,那么密码存储将会非常简单。

然而,人们通常选择熵值很小的密码(123456仍然不合理地流行,password也是如此),他们有一个用于所有网站的“标准密码”,他们经常容易受到网络钓鱼攻击和社会工程攻击,他们会将密码泄露给未经授权的第三方。

并非所有这些威胁都可以通过正确存储密码来阻止,但至少可以减轻和削弱其中的许多威胁。

这个库是由精通软件安全的人编写的,并且试图至少消除保存密码时最明显的错误。密码从不以纯文本格式保存,而是经过哈希处理。

请注意,密码的哈希算法针对不同的用例进行了优化,而不是针对其他原因使用的哈希算法:例如,他们试图否认的事情之一是暴力源映射攻击。

Passlib 使用针对密码存储优化的最新审核算法对密码进行哈希处理,旨在避免任何可能的旁路攻击。此外,“salt”总是用于散列密码。

虽然不理解这些东西也可以使用passlib,但是为了避免在使用passlib.时出现错误,理解这些东西是值得的

哈希意味着获取用户的密码,并通过一个相当容易计算但很难逆转的函数来运行。这意味着,即使攻击者能够访问密码数据库,他们也无法恢复用户的密码并冒充他们。

攻击者试图获取原始密码的一种方法是尝试他们能想到的所有密码组合,对它们进行哈希运算,并查看它们是否等于一个密码。为了避免这种情况,使用了计算困难的特殊算法。这意味着攻击者必须使用大量资源来尝试许多密码,因此即使只尝试了几百万个密码,也需要很长时间来进行比较。最后,攻击者可以使用一种叫做“彩虹表”的东西来预先计算许多常见密码的哈希值,并一次性将它们与密码数据库进行比较。为了避免这种情况,密码在被散列之前被“加 Salt”:添加一个随机前缀(“Salt”),密码被散列,Salt 作为散列值的前缀。当从用户处接收到密码时,在对其进行散列比较之前,从散列值的开始处检索 salt。

从头开始做这一切很难,甚至更难做好。“正确”并不仅仅意味着让用户登录,还意味着对密码数据库被盗具有弹性。因为没有关于那方面的反馈,所以最好使用经过良好测试的库。

该库是存储不可知的:它不关心密码存储在哪里。然而,它关心的是有可能更新散列密码。这样,哈希密码可以根据需要更新为新的哈希方案。虽然passlib确实支持各种低级接口,但是最好使用CryptContext的高级接口。这个名字有误导性,因为它不加密;它是对 Unix 内置的类似功能的引用。

要做的第一件事是决定支持的散列列表。注意,并不是所有的散列都必须是好的 T2 散列;如果我们过去支持坏散列,它们仍然必须在列表中。在这个例子中,我们选择argon2作为我们的首选散列,但是允许更多的选项。

>>> hashes = ["argon2", "pbkdf2_sha256", "md5_crypt", "des_crypt"]

注意md5des有严重的漏洞,不适合在实际应用中使用。我们添加它们是因为可能有旧的散列在使用它们。相比之下,即使pbkdf2_sha256很可能比argon2差,也没有更新的迫切需要。我们想将md5des标记为不推荐使用。

>>> deprecated = ["md5_crypt", "des_crypt"]

最后,做出决定后,我们构建加密上下文:

>>> from passlib.context import CryptContext
>>> ctx = CryptContext(schemes=hashes, deprecated=deprecated)

可以配置其他细节,如回合数。这几乎总是不必要的,因为缺省值应该足够好。

有时我们会希望将这些信息保存在某个配置中(例如,一个环境变量或文件)并加载它;这样,我们可以在不修改代码的情况下更新哈希表。

>>> serialized = ctx.to_string()
>>> new_ctx = CryptContext.from_string(serialized)

保存字符串时,注意它确实包含换行符;这可能会影响它的保存位置。如果需要,总是可以将其转换为 base64。

在用户创建或更改密码时,我们需要在存储密码之前对其进行哈希运算。这是通过上下文中的hash方法完成的。

>>> res = ctx.hash("good password")

登录时,第一步是从存储中检索散列。在检索散列值并从用户界面获得用户的密码后,我们需要检查它们是否匹配,如果散列值使用的是不推荐的协议,可能还需要更新散列值。

>>> ctx.verify_and_update("good password", res)
(True, None)

如果第二个元素为真,我们需要用结果更新散列。一般来说,指定一个特定的哈希算法并不是一个好主意,但是要信任上下文默认值。然而,为了展示更新,我们可以用弱算法强制上下文散列。

>>> res = ctx.hash("good password", scheme="md5_crypt")

在这种情况下,verify_and_update会告诉我们应该更新哈希:

>>> ctx.verify_and_update("good password", res)
(True, '$5$...')

在这种情况下,我们需要在密码散列存储中存储第二个元素。

8.4 TLS 证书

传输层安全性(TLS)是一种保护传输中数据的加密方法。因为一种潜在的攻击是中间人,所以能够验证端点是否正确是很重要的。由于这个原因,公钥由认证机构签名。有时,拥有一个本地证书颁发机构是很有用的。

这在微服务架构中非常有用,在这种架构中,验证每个服务是否正确可以实现更安全的安装。另一个有用的例子是构建一个内部测试环境,在这种情况下,使用真正的认证机构有时是不值得的;很容易将本地证书颁发机构安装为本地信任的,并用它签署相关证书。

另一个有用的地方是运行测试。当运行集成测试时,我们希望建立一个真实的集成环境。理想情况下,这些测试中的一些会检查这一点;事实上,使用的是 TLS 而不是纯文本。如果为了测试的目的,我们降级到纯文本通信,这是不可能测试的。事实上,许多生产安全漏洞的根本原因是,为了支持纯文本通信而插入测试的代码在生产中被意外启用(或可能被恶意启用);此外,测试不存在这样的错误是不可能的,因为测试环境确实有明文通信。

出于同样的原因,在测试环境中允许未经验证的 TLS 连接是危险的。这意味着代码有一个非验证流,它可能在生产中被意外打开或恶意打开,并且无法通过测试来防止。

手动创建证书需要访问cryptography中的hazmat层。这样命名是因为这是危险的;我们必须明智地选择加密算法和参数,错误的选择会导致不安全的模式。

为了执行加密,我们需要一个“后端”这是因为最初它旨在支持多个后端。这种设计大部分都被否决了,但是我们仍然需要创建它并传播它。

>>> from cryptography.hazmat.backends import default_backend

最后,我们准备好生成我们的私钥。对于这个例子,我们将使用2048位,这被认为是截至 2019 年的“合理安全”。关于哪些尺寸提供了多少安全性的完整讨论超出了本章的范围。

>>> from cryptography.hazmat.primitives.asymmetric import rsa
>>> private_key = rsa.generate_private_key(
...     public_exponent=65537,
...     key_size=2048,
...     backend=default_backend()
... )

在非对称加密中,从私钥计算公钥是可能的(也是快速的)。

>>> public_key = private_key.public_key()

这一点很重要,因为证书只涉及公共密钥。因为私钥从不共享,所以对它做出任何断言都是不值得的,而且是非常危险的。

下一步是创建一个证书构建器。证书生成器将用于添加关于公钥的“断言”。在这种情况下,我们将通过自签名证书来完成,因为 CA 证书是自签名的。

>>> from cryptography import x509
>>> builder = x509.CertificateBuilder()

然后我们添加名字。有些名称是必需的,尽管其中包含特定内容并不重要。

>>> from cryptography.x509.oid import NameOID
>>> builder = builder.subject_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'Simple Test CA'),
... ]))
>>> builder = builder.issuer_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'Simple Test CA'),
... ]))

我们需要确定一个有效范围。为此,能够有一个“天”间隔以便于计算是有用的。

>>> import datetime

>>> one_day = datetime.timedelta(days=1)

我们想让有效范围“稍微早一点”开始这样,它将对具有一定偏斜量的时钟有效。

>>> today = datetime.date.today()
>>> yesterday = today - one_day
>>> builder = builder.not_valid_before(yesterday)

由于该证书将用于测试,我们不需要它长期有效。我们将使它在 30 天内有效。

>>> next_month = today + (30 ∗ day)
>>> builder = builder.not_valid_after(next_month)

序列号需要唯一地标识证书。因为保存足够的信息来记住我们使用的序列号是复杂的,所以我们选择了不同的途径:选择一个随机的序列号。同一个序列号被选择两次的概率极低。

>>> builder = builder.serial_number(x509.random_serial_number())

然后,我们添加我们生成的公钥。这个证书由关于这个公钥的断言组成。

>>> builder = builder.public_key(public_key)

由于这是一个 CA 证书,我们需要将其标记为 CA 证书。

>>> builder = builder.add_extension(
... x509.BasicConstraints(ca=True, path_length=None),
... critical=True)

最后,在我们将所有断言添加到构建器中之后,我们需要生成散列并对其进行签名。

>>> from cryptography.hazmat.primitives import hashes
>>> certificate = builder.sign(
...    private_key=private_key, algorithm=hashes.SHA256(),
...    backend=default_backend()
... )

就是这个!我们现在有了一个私钥和一个自称是 CA 的自签名证书。但是,我们需要将它们存储在文件中。

PEM 文件格式有利于简单的连接。事实上,通常证书就是这样存储的:与私钥在同一个文件中(因为没有私钥它们就没有用)。

>>> from cryptography.hazmat.primitives import serialization
>>> private_bytes = private_key.private_bytes(
... encoding=serialization.Encoding.PEM,
... format=serialization.PrivateFormat.TraditionalOpenSSL,
... encryption_algorithm=serialization.NoEncrption())
>>> public_bytes = certificate.public_bytes(
... encoding=serialization.Encoding.PEM)
>>> with open("ca.pem", "wb") as fout:
...    fout.write(private_bytes + public_bytes)
>>> with open("ca.crt", "wb") as fout:
...    fout.write(public_bytes)

这使我们现在能够成为 CA。

一般来说,对于真正的证书颁发机构,我们需要生成一个证书签名请求(CSR ),以证明私钥的所有者确实想要那个证书。然而,由于我们是证书颁发机构,我们可以直接创建证书。

为证书颁发机构创建私钥和为服务创建私钥没有区别。

>>> service_private_key = rsa.generate_private_key(
...    public_exponent=65537,
...    key_size=2048,
...    backend=default_backend()
... )

因为我们需要对公钥进行签名,所以我们需要再次从私钥计算它:

>>> service_public_key = service_private_key.public_key()

我们为服务证书创建一个新的生成器:

>>> builder = x509.CertificateBuilder()

对于服务来说,COMMON_NAME很重要;这是客户端验证域名的依据。

>>> builder = builder.subject_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'service.test.local')
... ]))

我们假设服务将作为service.test.local被访问,使用一些本地测试解析。我们再一次将我们的证书有效期限制在一个月左右。

>>> builder = builder.not_valid_before(yesterday)
>>> builder = builder.not_valid_after(next_month)

这一次,我们对服务的公钥进行签名:

>>> builder = builder.public_key(public_key)

然而,我们用 CA 的私钥签署*;我们不希望这个证书是自签名的。*

>>> certificate = builder.sign(
...    private_key=private_key, algorithm=hashes.SHA256(),
...    backend=default_backend()
... )

同样,我们用密钥和证书编写一个 PEM 文件:

>>> private_bytes = service_private_key.private_bytes(
... encoding=serialization.Encoding.PEM,
... format=serialization.PrivateFormat.TraditionalOpenSSL,
... encryption_algorithm=serialization.NoEncrption())
>>> public_bytes = certificate.public_bytes(
... encoding=serialization.Encoding.PEM)
>>> with open("service.pem", "wb") as fout:
...    fout.write(private_bytes + public_bytes)

service.pem文件的格式可以被大多数流行的 web 服务器使用:Apache、Nginx、HAProxy 等等。通过使用txsni扩展,Twisted web 服务器也可以直接使用它。

如果我们将ca.crt文件添加到信任根,并在我们的客户端将从service.test.local解析的 IP 上运行一个 Nginx 服务器,那么当我们将客户端连接到 https://service.test.local 时,它们将验证证书确实有效。

8.5 总结

密码术是一种强大的工具,但是容易被误用。通过使用众所周知的高级函数,我们降低了使用加密技术的许多风险。虽然这并不能替代适当的风险分析和建模,但确实使这项工作变得更加容易。

Python 有几个第三方库,里面有经过严格审查的代码,使用它们是个好主意。

九、Paramiko

Paramiko 是一个实现 SSH 协议的库,通常用于远程管理 UNIX 系统。SSH 最初是作为“telnet”命令的安全替代物而发明的,但很快成为事实上的远程管理工具。即使是使用定制代理来管理服务器群的系统,比如Salt,通常也会使用 SSH 引导来安装定制代理。当一个系统被描述为的“无代理”,例如 Ansible,通常意味着它使用 SSH 作为底层管理协议。

Paramiko 包装了协议,允许高级和低级抽象。在这一章中,我们将主要关注高层次的抽象。

在深入研究细节之前,值得注意的是 Paramiko 与 Jupyter 的协同作用。使用 Jupyter 笔记本,并在其中运行 Paramiko,可以实现强大的自动记录远程控制控制台。将多个浏览器连接到同一台笔记本电脑的能力意味着它具有为远程服务器共享故障排除会话的固有能力,而无需繁琐的屏幕共享。

9.1 SSH 安全性

“SSH”中的“S”代表“安全”使用 SSH 的原因是因为我们相信它允许我们安全地控制和配置远程主机。然而,安全是一个微妙的话题。即使底层的加密原语和协议使用它们的方式是安全的,我们也必须正确使用它们,以防止误用造成为成功攻击打开大门的问题。

为了安全地使用 SSH,理解 SSH 如何考虑安全性是很重要的。不幸的是,它是在“安全性的可承受性”没有被认为是高度优先的时候建造的。SSH 很容易使用,这就否定了它带来的所有安全性好处。

SSH 协议建立了相互信任——客户端确信服务器是可信的,服务器也确信客户端是可信的。有几种方法可以建立这种信任,但是在这个解释中,我们将介绍公钥方法。这是最常见的一种。

服务器的公钥由一个指纹识别。该指纹以两种方式之一确认服务器的身份。一种方式是通过先前建立的安全通道进行通信,并保存在文件中。

例如,当 AWS EC2 服务器启动时,它将指纹打印到它的虚拟控制台。控制台的内容可以使用 AWS API 调用(使用 web 的 TLS 模型保护)来检索,并解析以检索指纹。

可悲的是,另一种更受欢迎的方式是豆腐模式——“第一次使用时的信任”。这意味着在初始连接中,指纹被认为是可信的,并存储在本地的安全位置。在任何后续尝试中,将对照存储的指纹检查指纹,不同的指纹将被标记为错误。

指纹是服务器公钥的散列。如果指纹相同,则意味着公钥相同。服务器可以证明它知道对应于给定公钥的私钥。换句话说,服务器可以说“这是我的指纹”,并证明它确实是具有该指纹的服务器。因此,如果指纹得到确认,我们就与服务器建立了加密信任。

另一方面,用户可以向服务器表明他们信任哪些公钥。同样,这通常是通过一些带外机制实现的:供系统管理员放入公钥的 web API、共享文件系统或从网络读取信息的引导脚本。不管是如何做到的,用户的目录可以包含一个文件,意思是“请授权连接,该连接可以证明它们具有与来自我的这个公钥相对应的私钥。”

当 SSH 连接建立后,客户机将如上所述验证服务器的身份,然后提供证据证明它拥有一个与服务器上的某个公钥相对应的私钥。如果这两个步骤都成功,则可以双向验证连接,并且可以用于运行命令和修改文件。

9.2 客户端密钥

客户端私钥和公钥保存在相邻的文件中。通常用户已经有一个现有的密钥,但如果没有,这很容易补救。

paramiko开始,生成密钥本身很容易。我们选择一个ECDSA键。EC代表“椭圆曲线”对于相同的密钥长度,椭圆曲线非对称加密比基于素数的加密具有更好的抗攻击性。在 EC 加密的“部分解决方案”方面也有很少的进展,因此加密社区的共识是它们可能对“非公开”参与者更安全。

>>> from paramiko import ecdsakey
>>> k = ecdsakey.ECDSAKey.generate()

与非对称加密技术一样,从私钥部分计算公钥部分既快速又简单。

>>> public_key = k.get_base64()

因为这是公开的,所以我们不必太担心把它写到文件中。

>>> with open("key.pub", "w") as fp:
...    fp.write(public_key)

然而,当我们写出密钥的私有部分时,我们希望确保文件权限是安全的。我们这样做的方法是,在打开文件之后,但在写入任何敏感数据之前,我们改变模式。

请注意,这并不完全安全;如果写入错误的目录,文件可能会有错误的用户,并且由于一些文件系统会单独同步数据和元数据,因此在错误的时间崩溃可能会导致数据在文件中,但附加了错误的文件模式。这仅仅是我们安全地写一个文件需要做的最少的事情。

>>> import os

>>> with open("key.priv", "w") as fp:
...   os.chmod(0o600, "key.priv")
...   k.write_private_key(fp)

我们选择模式0o600,是八进制600。如果我们写与这个八进制代码相对应的位,它们是110000000,,翻译成rw-------:所有者的读写权限,非所有者组成员没有权限,其他任何人都没有权限。

现在通过一些带外机制,我们需要将公钥推送到相关的服务器。

例如,根据云服务的不同,代码如下:

set_user_data(machine_id,
f"""
ssh_authorized_keys:
   - ssh-ecdsa {public_key}
""")

其中set_user_data是使用云 API 实现的,将在任何使用 cloudinit 的服务器上工作。

有时做的另一件事是使用 Docker 容器作为堡垒。这意味着我们希望用户通过 SSH 进入容器,并从容器进入他们需要运行命令的特定机器。

在这种情况下,构建时的一条简单的COPY指令(或者运行时的一条docker cp,视情况而定)就可以实现目标。请注意,向 Docker 注册中心发布包含公钥的图像是完全可以的——事实上,这是一个安全操作的要求是公钥定义的一部分。

9.3 主机身份

如前所述,SSH 中针对中间人攻击的最常见的第一道防线是所谓的豆腐原则——“第一次使用时的信任”为此,在连接到主机后,其指纹必须保存在缓存中。

该缓存的位置过去很简单——用户主目录中的一个文件。然而,更现代的不可变的、一次性的环境、多用户机器和其他复杂因素使这变得更加复杂。

很难提出比“与尽可能多的可信来源分享”更普遍的建议然而,为了实现该准则,Paramiko 确实提供了一些设施:

  • 客户端可以设置一个MissingHostKeyPolicy,它是支持接口的任何实例。这意味着我们可以用逻辑来记录密钥,或者从外部数据库中查询它。

  • UNIX 系统上最常见格式的抽象,known_hosts文件。这允许 Paramiko 与常规 SSH 客户端共享使用密钥的经验——通过读取密钥和记录新条目。

9.4 连接

虽然有较低级别的连接方式,但推荐的高级接口是SSHClient

因为需要关闭SSHClient实例,所以使用contextlib.closing作为上下文管理器是一个好主意(如果可能的话):

with contextlib.context(SSHClient()) as client:
    client.connect(some_host)
    ## Do things with client

在接近顶层的地方这样做允许我们使用client作为函数的参数,同时保证它将在最后关闭。

有时,在连接之前,我们希望在客户机上配置各种策略。在返回准备好连接的客户端的函数中这样做有时很有用。

以下是与验证真实性的方式相关的一些有用的连接准备方法:

  • load_system_host_keys将从由其他系统管理的源加载密钥。这意味着它们将用于验证主机,但如果我们选择保存密钥,它们将不会被保存。

  • load_host_keys将从我们管理的来源加载密钥。这意味着,如果我们选择保存密钥,这些密钥也会被一起保存。例如,我们可能有一个包含连续文件的目录,keys.1keys.2,。。。并从最新版本加载。我们可以在保存时保存到较新的文件,从而有一个安全的方法来从问题中恢复(只需加载以前的版本)。

  • set_missing_host_policy(policy)需要一个方法为missing_host_key的对象。这个方法会用client, hostname, key调用,它的行为会决定做什么;异常将停止连接,而成功的返回将允许连接继续进行。例如,这可以将主机的密钥放在一个“临时”文件中,并引发异常。用户可以查看临时文件,遵循验证过程,并将密钥添加到下一次迭代加载的文件中。

connect方法有相当多的参数。除了hostname都是可选的。比较重要的有以下几条:

  • hostname–要连接的服务器。

  • 如果我们在 22 以外的特殊端口上运行,则需要。这有时是作为安全协议的一部分来完成的;尝试连接到端口 22 会自动拒绝来自该 IP 的所有进一步连接,而真正的服务器运行在5022或只能通过 API 发现的端口上。

  • username–虽然默认是本地用户,但这种情况越来越少。云虚拟机映像通常有一个默认的“系统”用户。

  • pkey–用于认证的私钥。如果我们想通过某种编程方式获得私钥(例如,从秘密管理器中检索),这是很有用的。

  • allow_agent–默认情况下这是True,原因很简单。这通常是一个好的选择,因为这意味着私钥本身将永远不会被我们的进程加载,并且通过扩展,不会被我们的进程泄露:没有意外的日志记录、调试控制台或内存转储是易受攻击的。

  • look_for_keys–设置为False,不给出其他按键选项,强制使用代理。

9.5 运行命令

SSH 中的“SH”代表 shell。最初的 SSH 是作为 telnet 的替代品发明的,它的主要工作仍然是在远程机器上运行命令。请注意,“远程”是一个比喻,并不总是字面意思。SSH 有时用于控制虚拟机,有时甚至是容器,它们可能就在附近运行。

Paramiko 客户端连接后,它可以在远程主机上运行命令。这是使用客户端方法exec_command完成的。注意,这个方法把要执行的命令作为字符串,而不是列表。这意味着在命令中插入用户值时必须格外小心,以确保用户没有完全的执行权限。

返回值是命令的标准输入、输出和错误。这意味着小心地与命令通信以避免死锁的责任牢牢地掌握在最终用户手中。

客户端还有一个方法invoke_shell,它将创建一个远程 shell 并允许对它进行编程访问。它返回一个Channel对象,直接连接到 shell。在通道上使用send方法会将数据发送到 shell——就像一个人在终端上打字一样。类似地,recv方法允许检索输出。

请注意,这可能很难做到,尤其是在时机方面。一般来说,使用exec_command要安全得多。很少需要打开显式 shell,除非我们需要在终端中正确运行命令。例如,远程运行visudo将需要一个真正的类似 shell 的访问。

9.6 模拟外壳

我们已经提到客户端有一个invoke_shell,它将创建一个远程 shell 并允许对它进行编程访问。

虽然我们可以在返回的Channel上使用sendrecv方法,但有时将它作为文件使用更容易。

>>> channel = client.invoke_shell()
>>> input = channel.makefile("wb")
>>> output = channel.makefile("rb")

现在可以用input.write写命令的输入,用output.write读。请注意,这仍然很微妙:计时和缓冲效应仍然会导致不确定性问题。

注意,不需要重新连接,总是可以channel.close并创建一个新的 shell。因此,确保 shell 的使用是幂等的是一个好主意。在这种情况下,简单的超时可以帮助从流被“阻塞”、关闭和重试的情况中恢复。

9.7 远程文件

为了启动文件管理,我们调用客户端的open_sftp方法。这将返回一个SFTPClient对象。我们将在这个对象上使用方法来进行所有的远程文件操作。

在内部,这将在同一个 TCP 连接上启动一个新的 SSH 通道。这意味着,即使来回传输文件,该连接仍可用于向远程主机发送命令。SSH 没有“当前目录”的概念虽然SFTPClient模拟了它,但是最好避免依赖它,而是对所有文件操作使用完全限定的路径。这将使代码更容易重构,并且不会对操作顺序有微妙的依赖。

元数据管理

有时我们不想改变数据,而只是改变文件系统的属性。SFTPClient对象允许我们进行我们期望的正常操作。

chmod方法对应于os.chmod——它采用相同的参数。由于chmod的第二个参数是一个被解释为许可位域的整数,所以最好用八进制表示法来表达。因此,将文件设置为“常规”权限(所有者读/写,全局读取)的最佳方式是:

client.chmod("/etc/some_config", 0o644)

注意,从 C 中借用的0644符号在 Python 3 中不工作(在 Python 2 中已被否决)。0o644符号更加明确和 Pythonic 化。

可悲的是,没有什么能保护我们不去理会这样的废话:

client.chmod("/etc/some_config", 644)

(这将对应于目录列表中的-w----r--,这并不安全——但是非常混乱!)

更多的元数据操作方法有:

  • chown

  • listdir_iter–用于检索文件名和元数据

  • stat, lstat–用于检索文件元数据

  • posix_rename–用于自动更改文件的名称(不要使用rename–它有令人困惑的不同语义,在这一点上是为了向后兼容)

  • mkdirrmdir–创建和删除目录

  • utime–设置文件的访问和修改次数

上传

用 Paramiko 上传文件到远程主机主要有两种方式。一种是简单用put。这绝对是最简单的方法——给它一个本地路径和一个远程路径,它就会复制文件。该函数还接受其他参数——主要是一个回调函数,用于调用中间进度。然而,在实践中,如果需要这种复杂性,最好以不同的方式上传。

SFTPClient上的open方法返回一个打开的类似文件的对象。编写一个远程逐块或逐行复制的循环相当简单。在这种情况下,进度逻辑可以嵌入到循环本身中,而不是必须提供回调函数,并仔细维护调用之间的状态。

9.7.3 下载

与上传非常相似,有两种方法可以从远程主机检索文件。一种是通过get方法,它获取远程和本地文件的名称,并管理复制。

另一种方法是再次使用open方法,这次是以读模式而不是写模式,逐块或逐行复制。同样,如果需要进度指示器,或者需要来自用户的反馈,这是更好的方法。

9.8 摘要

大多数基于 UNIX 的服务器可以使用 SSH 协议进行远程管理。Paramiko 是 Python 中自动化管理任务的一种强大方法,同时对任何服务器做了最少的假设:它运行一个 SSH 服务器,并且我们有登录的权限。

十、Salt

Salt 属于一类称为“配置管理系统”的系统,旨在使管理大量机器更加容易。它通过对不同的机器应用相同的规则来做到这一点,确保它们配置中的任何差异都是有意的。

它是用 Python 编写的,更重要的是,可以用 Python 扩展。例如,在使用YAML文件的地方,salt将允许定义字典的 Python 文件。

10.1 Salt 的基本知识

salt(有时也称为“SaltStack”)系统是一个系统配置管理框架。它旨在将操作系统带入特定的配置。它基于“收敛环”的概念。当运行salt时,它做三件事:

  • 计算所需的配置,

  • 计算系统与所需配置的差异,

  • 发出命令,使系统达到所需的配置。

Salt 的一些扩展超越了“操作系统”的概念,将一些 SaaS 产品配置成所需的配置:例如,支持 Amazon Web Services、PagerDuty 或一些 DNS 服务(那些由 libcloud 支持的服务)。

由于在典型环境中,并非所有操作系统都需要以完全相同的方式进行配置,因此 Salt 允许检测系统的属性,并指定哪些配置适用于哪些系统。在运行时,Salt 使用这些来决定什么是完整的期望状态,并执行它。

有几种方法可以使用salt:

  • 本地:运行一个本地命令,该命令将执行所需的步骤。

  • SSH:服务器将 ssh 进入客户端并运行命令,这些命令将采取所需的步骤。

  • 本地协议:客户端将连接到服务器,并采取服务器指示的任何步骤。

使用ssh模式消除了在远程主机上安装专用客户机的需要,因为在大多数配置中,已经安装了 SSH 服务器。然而,Salt 管理远程主机的本地协议有几个优点。

首先,它允许客户端连接到服务器,从而简化了发现过程——我们需要的只是客户端到服务器的发现过程。它的伸缩性也更好。最后,它允许我们控制在远程客户机中安装哪些 Python 模块,这对于 Salt 扩展有时是必不可少的。

在某些 Salt 配置需要一个需要定制模块的扩展的情况下,我们可以采用一种混合方法:使用基于 SSH 的配置让主机知道服务器在哪里,以及如何连接到服务器;然后指定如何使主机达到所需配置。

这意味着服务器将有两个部分:一部分使用 SSH 将系统带入基本配置,其中包括一个salt客户端;第二部分等待客户端连接,以便将其发送到配置的其余部分。

这具有解决“秘密自举”问题的优点。我们使用不同的机制验证客户端主机的 SSH 密钥,当通过 Salt 连接到它时,注入 Salt 秘密以允许主机连接到它。

当我们选择混合方法时,需要找到所有机器的方法。当使用一些云基础设施时,可以使用 API 查询来实现。无论我们如何获得这些信息,我们都需要让 Salt 能够访问这些信息。

这是使用花名册完成的。花名册是 YAML 的档案。顶层是“逻辑机器名”这很重要,因为这将是使用 Salt 对机器进行寻址的方式。

file_server:              # logical name of machine
    user: moshe           # username
    sudo: True            # boolean
    priv: /usr/local/key  # path to private key
print_server:             # logical name of machine
    user: moshe           # username
    sudo: True            # boolean
    priv: /usr/local/key2 # path to private key

在理想情况下,机器的所有参数都是相同的。user是 SSH 的用户身份。布尔值是是否需要 sudo:这几乎总是正确的。唯一的例外是我们作为管理用户(通常是 root)使用 SSH。因为避免 SSH 作为 root 用户是一个最佳实践,所以在大多数环境中它被设置为True

priv字段是私钥的路径。或者也可以agent-forwarding使用 SSH 代理。这通常是个好主意,因为它为密钥泄漏提供了额外的屏障。

花名册可以放在任何地方,但默认情况下 Salt 会在/etc/salt/roster中寻找它。将这个文件放在不同的位置是很微妙的:salt-ssh将默认从/etc/salt/master中找到它的配置。因为将花名册放在别处的通常原因是为了避免接触到/etc/salt目录,这意味着我们通常需要使用-c选项配置一个显式的主配置文件。

或者,可以使用Saltfilesalt-ssh将在当前目录中的Saltfile中寻找选项。

salt-ssh:
  config_dir: some/directory

如果我们在config_dir中输入值.,它将在当前目录中查找一个master文件。我们可以将master文件中的roster_file字段设置为本地路径(例如roster),以确保整个配置是本地的,并且可以在本地访问。如果事情是由版本控制系统管理的,这可能会有所帮助。

在定义了花名册之后,开始检查 Salt 系统是否在运行是很有用的。

命令

$ salt '∗' test.ping

将向名册上的所有机器(或者,稍后当我们使用 minions 时,所有连接的 minions)发送 ping 命令。它们都应该返回 True。如果机器不可访问、SSH 凭证错误或存在其他常见配置问题,此命令将失败。

因为这个命令对远程机器没有任何影响,所以在开始执行任何更改之前,最好先运行它。这将确保系统配置正确。

还有其他几个test功能,用于对系统进行更复杂的检查。

test.false命令将故意失败。这有助于了解失败的样子。例如,当通过更高层次的抽象(如连续部署系统)运行 Salt 时,这对于看到故障是可见的(例如,发送适当的通知)是有用的。

test.collatztest.fib函数执行繁重的计算,并返回计算时间和结果。这用于测试性能。例如,如果机器根据可用功率或外部温度动态调整 CPU 速度,这可能是有用的,并且我们想要测试这是否是性能问题的原因。

salt命令行上,很多东西被解析成 Python 对象。shell 解析规则和salt-解析规则的交互有时很难预测。在检查事物是如何被解析的时候,test.kwarg命令会很有用。它返回字典作为关键字参数传入的值。举个例子,

$ salt '∗' test.kwarg word="hello" number=5
                      simple_dict='{thing: 1, other_thing: 2}'

我将归还字典

{'word': 'hello', 'number': 5,
 'simple_dict': {'thing': 1, 'other_thing': 2}}

因为 shell 解析规则和 Salt 解析规则的组合有时很难预测,所以这是一个有用的命令,可以调试这些组合,并找出哪些内容被过度引用或引用不足。

我们可以通过逻辑名来定位特定的机器,而不是'∗'。当看到特定机器的问题时,这通常是有用的;在尝试各种修复(例如,更改防火墙设置或 SSH 私有密钥)时,它允许快速反馈机制。

虽然测试连接是否工作良好很重要,但是使用 Salt 的原因是为了远程控制机器。虽然 Salt 的主要用途是同步到一个已知的状态,但是它也可以用于运行特定的命令。

$ salt '∗' cmd.run 'mkdir /src'

这将导致所有连接的机器创建一个目录/src。更复杂的命令是可能的,并且也可能只针对特定的机器。

Salt 中理想状态的专业术语是“高状态”这个名字经常引起混淆,因为它似乎是“低状态”的反义词,而“低状态”几乎没有被描述过。然而,“highstate”这个名字代表“high- level state”:它描述了状态的目标。

“低”状态,即低水平状态,是 Salt 达到目标的步骤。由于将目标编译成低级状态是在内部完成的,所以在面向用户的文档中没有提到“低级”状态,从而导致混乱。

应用所需状态的方法如下:

$ salt '∗' state.highstate

由于“highstate”这个名称有太多的混淆,为了减少混淆,创建了一个别名:

$ salt '∗' state.apply

同样,这两者做完全相同的事情:它们计算出所有机器的期望状态,然后发出命令来达到它。

状态在sls文件中描述。这些文件通常是 YAML 格式的,描述了期望的状态。

通常的配置方式是用一个文件top.sls来描述哪些文件适用于哪些机器。top.sls名称是默认情况下salt将用作顶层文件的名称。

一个简单的同构环境可能是:

# top.sls

base:
  '∗':
    - core
    - monitoring
    - kubelet

这个例子将让所有的机器应用来自core.sls的配置(假设,确保安装了基本的包,配置了正确的用户,等等)。);从monitoring.sls(假设,确保监控机器的工具已安装并运行);和kubelet.sls,定义如何安装和配置kubelet

事实上,Salt 的大部分时间将用于为工作负载编排工具(如 Kubernetes 或 Docker Swarm)配置机器。

10.2 Salt 的概念

Salt 引入了相当多的术语和概念。

一个宠臣就是 Salt 代理。即使在“无代理”的基于 SSH 的通信中,仍然有一个 minionSalt 做的第一件事是发送一个 minion 的代码,然后启动它。

一个 Salt 主人给奴才们发命令。

Salt 状态是一个扩展名为.sls的文件,包含状态声明:

name_of_state:
  state.identifier:
    - parameters
    - to
    - state

例如:

process_tools:
  pkg.installed:
    - pkgs:
      - procps

这将确保软件包procps(其中包括ps命令)将被安装。

大多数 Salt 状态被写成幂等:如果它们已经生效,就不再生效。例如,如果已经安装了软件包,Salt 将什么也不做。

Salt 模块不同于 Python 模块。在内部,它们确实对应于模块,但只是一些模块。

不像状态,模块运行事情。这意味着没有幂等性的保证,甚至没有尝试。

通常,Salt 状态会用一些逻辑来包装一个模块,以决定它是否需要运行该模块:例如,在安装一个包之前,pkg.installed会检查该包是否已经安装。

一个支柱是一种将参数附加到特定的爪牙上的方式,它可以被不同的状态重用。

如果一个支柱过滤掉一些爪牙,那么这些爪牙保证永远不会接触到支柱中的价值观。这意味着柱子是储存秘密的理想选择,因为它们不会被送到错误的爪牙那里。

为了更好地保护秘密,可以使用gpg来加密柱子中的秘密。因为gpg是基于非对称加密的,所以可以公布公钥,例如,在保存状态和支柱的同一个源代码控制存储库中。

这意味着任何人都可以向配置中添加秘密,但是在主服务器上需要私钥来应用这些配置。

由于 GPG 是灵活的,它是可能的目标加密到几个密钥。作为一个最佳实践,最好将密钥加载到一个gpg-agent中。这意味着当主人需要秘密时,它将使用gpg,T1 将与gpg-agent通信。

这意味着私钥永远不会直接暴露给 Salt 主机。

一般来说,Salt 按顺序处理状态中的指令。但是,一个状态总是可以指定require。指定依赖关系时,最好让依赖状态有一个自定义的、可读的名称。这使得依赖关系更具可读性。

Extract archive:
  archive.extracted:
    - name: /src/some-files
    - source: /src/some-files.tgz
    - archive_format: tar
  - require:
    - file: Copy archive
Copy archive:
  file.managed:
    - name: /src/some-files.tgz
    - source: salt://some-files.tgz

拥有显式可读的名称有助于我们确保依赖于正确的状态。注意,即使ExtractCopy之前,它仍然会等待复制完成。

也可以颠倒关系:

Extract archive:
  archive.extracted:
    - name: /src/some-files
    - source: /src/some-files.tgz
    - archive_format: tar
Copy archive:
  file.managed:
    - name: /src/some-files.tgz
    - source: salt://some-files.tgz
  - require_in:
    - archive: Extract archive.

一般来说,就像这个例子一样,颠倒关系并不能改善事情。但是,这有时可以用来最小化或本地化对共享存储库中文件的更改。

还有其他可能的关系,都有被倒置的能力;onchanges指定只有当另一个状态引起实际变化时,才应该重新应用该状态,而onfail指定只有当另一个状态应用失败时,才应该重新应用该状态。这对于设置警报或确保系统返回到已知状态非常有用。

可能还有一些更深奥的关系,比如watchprereq,它们更加专门化。

当使用内置的 Salt 通信而不是 SSH 方法时,minions 将生成密钥。这些密钥需要被接受或拒绝。一种方法是使用salt-key命令。

正如我们前面提到的,引导信任的一种方式是使用 SSH。在这种情况下,使用 Salt 将解析后的输出从运行salt-key -F master转移到 minion,然后在 minion 的配置中的master_finger字段下设置它。

类似地,在 minion 上远程运行salt-call key.finger --local(例如,用salt 'minion' cmd.run)并在接受之前将其与待定密钥进行比较。这可以是自动化的,并导致一个验证链。

根据可用的原语,还有其他方法来引导信任。例如,如果硬件密钥管理(HKM)设备可用,它们可以用来签署奴才的和主的密钥。

可信平台模块(TPM)也可以用于相互确保信任。这两种机制都超出了我们目前的范围。

颗粒(如“一粒 Salt”)将系统参数化。它们与柱子的不同之处在于仆从决定颗粒;该配置在 minions 上存储和修改。

有些颗粒,比如fqdn,是在小黄人身上自动检测出来的。也可以在 minion 配置文件中定义其他粒度。

有可能从母版中推出颗粒。在引导小兵的时候也可以从其他地方获取谷物。例如,在 AWS 上,可以将UserData设置为一个颗粒。

Salt 环境是目录层次结构,每个层次结构定义一个单独的 topfile。可以将 Minions 分配给一个环境,或者在使用salt '∗' state.highstate saltenv=...应用 highstate 时选择一个环境。

Salt file_roots是一个目录列表,其功能类似于一个路径;当寻找一个文件时,Salt 将按顺序在它们中搜索,直到它找到它。它们可以基于每个环境进行配置,是区分环境的主要因素。

10.3 Salt 格式

到目前为止,我们的示例SLS文件是 YAML 文件。然而,Salt 将 YAML 文件解释为 YAML 文件的 Jinja 模板。当我们想要基于颗粒或柱子定制字段时,这是很有用的。

例如,包含我们构建 Python 包所需的东西的包的名称在CentOSDebian之间是不同的。

下面的SLS片段展示了如何在异构环境中将不同的包定位到不同的机器上。

{% if grains['os'] == 'CentOs' %}
python-devel:
{% elif grains['os'] == 'Debian' %}
python-dev:
{% endif %}
  pkg:
    - installed

需要注意的是,Jinja 处理步骤完全忽略了 YAML 格式。它将文件视为纯文本,进行格式化,然后 Salt 对结果使用 YAML 解析器。

这意味着 Jinja 只有在某些情况下才可能生成无效文件。事实上,我们在上面的例子中嵌入了这样一个 bug 如果操作系统既不是CentOS也不是Debian,结果将是一个不正确缩进的 YAML 文件,它将以奇怪的方式解析失败。

为了解决这个问题,我们想提出一个明确的异常:

{% if grains['os'] == 'CentOs' %}
python-devel:
{% elif grains['os'] == 'Debian' %}
python-dev:
{% else %}
{{ raise('Unrecognized operating system', grains['os']) }}
{% endif %}
  pkg:
    - installed

这在适当的时候引发了一个异常,万一一台机器被添加到我们的花名册中,而不是 Salt 抱怨 YAML 的一个解析错误。

每当用 Jinja 做一些重要的事情时,这种小心是很重要的,因为 Jinja 插值和 YAML 解析这两个层并不知道彼此。Jinja 不知道它应该生成 YAML,YAML 解析器也不知道 Jinja 源代码是什么样子。

Jinja 支持过滤以便处理值。Jinja 内置了一些过滤器,但是 Salt 用一个自定义列表扩展了它。

有趣的滤镜中有YAML_ENCODE。有时我们需要在我们的.sls文件中有一个,它就是 YAML 本身:例如,我们需要复制的 YAML 配置文件的内容。

将 YAML 植入 YAML 通常是不愉快的;必须特别注意正确的逃生。使用YAML_ENCODE,可以对以 YAML 本地语言书写的值进行编码。

出于类似的原因,JSON_ENCODE_DICTJSON_ENCODE_LIST对于将 JSON 作为输入的系统很有用。

定制过滤器的列表很长,这是一个版本之间经常变化的事情。规范文档将位于 Salt 文档网站docs.saltstack.com上的“Jinja - >过滤器”下

尽管到目前为止我们称SLS文件为由 Jinja 和 YAML 处理的文件,但这是不准确的。这是默认的处理,但也可以用特殊指令覆盖该处理。

Salt 本身只关心最终结果是一个类似 YAML(或者,在我们的例子中,等同于类似 JSON)的数据结构:一个包含递归字典、列表、字符串和数字的字典。

将文本转换成这种数据结构的过程在 Salt 的说法中称为“呈现”。这与常见的用法相反,在常见用法中,呈现意味着将文本转换为文本,解析意味着将文本转换为*,因此在阅读 Salt 文档时需要注意这一点。*

能做渲染的东西就是渲染器。可以编写自定义渲染器,但是在内置渲染器中,最有趣的是py渲染器。

我们指出应该用顶部带有#!pypy渲染器来解析文件。

在这种情况下,该文件被解释为 Python 文件。Salt 查找函数run,运行它,并将返回值视为状态。

运行时,__grains____pillar__包含纹理和支柱数据。

作为一个例子,我们可以用一个py渲染器实现相同的逻辑。

#!py

def run():
    if __grains__['os'] == 'CentOS':
        package_name = 'python-devel'
    elif __grains__['os'] == 'Debian':
        package_name = 'python-dev'
    else:
        raise ValueError("Unrecognized operating system",
                         __grains__['os'])
return { package_name: dict(pkg='installed') }

由于py渲染器不是两个不相关的解析器的组合,错误有时更容易诊断。如果我们重新引入第一个版本中的错误,我们会得到:

#!py

def run():
    if __grains__['os'] == 'CentOS':
        package_name = 'python-devel'
    elif __grains__['os'] == 'Debian':
        package_name = 'python-dev'
return { package_name: dict(pkg='installed') }

在这种情况下,结果将是一个NameError指出错误的行和丢失的名称。

权衡的结果是,如果配置很大,而且大部分是静态的,那么以 YAML 的形式阅读它会更简单。

10.4 Salt 延伸

由于 Salt 是用 Python 写的,所以在 Python 中是完全可扩展的。一般来说,为新的事物扩展 Salt 的最简单的方法是将文件放在 Salt 主机上的file_roots目录中。不幸的是,还没有针对 Salt 扩展的包管理器。当运行state.apply或显式运行saltutil.sync_state时,这些文件会自动同步到附属程序。如果我们想要测试,例如,在不引起任何变化的情况下进行状态的模拟运行,但是用修改模块的*,后者是有用的。*

国家

状态模块位于环境的根目录下。如果我们想要在环境之间共享状态模块,可以创建一个定制的根并在正确的环境之间共享这个根。

下面是一个模块示例,该模块确保特定目录下没有名称为“mean”的文件。这可能不是很有用,尽管一般来说,确保不需要的文件不在那里可能很重要。例如,我们可能想强制不使用.git目录。

def enforce_no_mean_files(name):
    mean_files = __salt__'files.find'
    # ...continues below...

函数名映射到SLS状态文件中的状态名。如果我们把这个代码放在mean.py中,处理这个状态的合适方式应该是mean.enforce_no_mean_files

找到文件的正确方法,或者说,在 Salt 状态扩展中做任何事情的正确方法,是调用 Salt executors。在大多数非玩具示例中,这将意味着编写一个匹配对:一个 Salt 执行器扩展和一个 Salt 状态扩展。

因为我们想一次处理一件事,所以我们使用一个预先编写的 Salt 执行器:file模块,它有find功能。

def enforce_no_mean_files(name):
    # ...continued...
    if mean_files = []:
        return dict(
           name=name,
           result=True,
           comment='No mean files detected',
           changes=[],
        )
    # ...continues below...

状态模块负责的事情之一,实际上通常是最重要的事情,是如果状态已经达到,则什么也不做。这就是收敛循环的意义所在:针对已经实现收敛的情况进行优化。

def enforce_no_mean_files(name):
    # ...continued...
    changes = dict(
       old=mean_files,
       new=[],
    )
    # ...continues below...

我们现在知道将会发生什么变化。在这里计算它意味着我们可以保证测试和非测试模式下响应的一致性。

def enforce_no_mean_files(name):
    # ...continued...
    changes = dict(
    if __opts__['test']:
        return dict(
           name=name,
           result=None,
           comment=f"The state of {name} will be changed",
           changes=changes,
        )
    # ...continues below...

下一个重要职责是支持test模式。在应用状态之前总是进行测试被认为是一种最佳实践。我们希望清楚地阐明如果激活该模块将会产生的变化。

def enforce_no_mean_files(name):
    # ...continued...
    changes = dict(
    for fname in mean_files:
        __salt__'file.remove'
    # ...continues below...

一般来说,我们应该只调用一个函数:执行模块中与状态模块匹配的函数。因为在这个例子中我们使用file作为我们的执行模块,我们在一个循环中调用remove函数。

def enforce_no_mean_files(name):
    # ...continued...
    changes = dict(
    return dict(
        name=name,
        changes=changes,
        result=True,
        comment=f"The state of {name} was changed",
    )
    # ...continues below...

最后,我们返回一个字典,它具有与测试模式中记录的相同的更改,但是带有一个注释,表明这些更改已经运行。

这是状态模块的典型结构:一个(或多个)函数,接受一个名称(可能还有更多参数),然后返回一个结果。“检查是否需要更改”,“检查我们是否处于测试模式”,然后“实际执行更改”的结构也是典型的。

执行

由于历史原因,执行模块放在文件根目录的_modules子目录中。类似于执行模块,当应用state.highstate时,它们也同步,当通过saltutil.sync_all显式同步时也同步。

作为一个例子,我们编写一个执行模块来删除几个文件,以便简化我们上面的状态模块。

def multiremove(files):
    for fname in files:
        __salt__'file.remove'

注意__salt__在执行模块中也是可用的。然而,虽然它可以交叉调用其他执行模块(在本例中,file),但它不能交叉调用状态模块。

我们将这段代码放在_modules/multifile中,我们可以将我们的状态模块改为:

__salt__'multifile.mutiremove'

代替

for fname in mean_files:
    __salt__'file.remove'

执行模块通常比状态模块简单,如下例所示。在这个玩具示例中,执行模块几乎不做任何事情,只是协调对其他执行模块的调用。

然而,这并非完全不典型。Salt 有如此多的管理机器的逻辑,以至于一个执行模块所要做的通常只是协调对其他执行模块的调用。

公用事业

当编写几个执行或状态模块时,有时会有一些公共代码可以被分解出来。

这些代码可以放在所谓的“实用模块”中工具模块位于文件根目录的_utils目录下,将作为__utils__字典使用。

例如,我们可以在状态模块中排除返回值的计算:

def return_value(name, old_files):
    if len(old_files) == 0:
        comment = "No changes made"
        result = True
    elif __opts__['test']:
        comment = f"{name} will be changed"
        result = None
    else:
        comment = f"{name} has been changed"
        result = True
    changes = dict(old=old_files, new=[])
    return dict(
        name=name,
        comment=comment,
        result=result,
        changes=changes,
    )

如果我们使用执行模块和实用模块,我们会得到一个更简单的状态模块:

def enforce_no_mean_files(name):
    mean_files = __salt__'files.find'
    if len(mean_files) == 0 or __opts__['test']:
        return __utils__'removal.return_value'
    __salt__'multifile.mutiremove'
    return __utils__'removal.return_value'

在这种情况下,我们可以把函数作为一个常规函数放在模块中;将它放在一个工具模块中是用来展示如何在工具模块中调用函数。

10.4.4 额外的第三方依赖性

有时候拥有第三方依赖是很有用的,尤其是在编写新的状态和执行模块的时候。这在安装一个插件时很容易做到;我们只是确保在具有这些第三方依赖项的虚拟环境中安装该插件。

当在 SSH 中使用 Salt 时,这一点就明显不那么微不足道了。在这种情况下,有时最好从 SSH 引导到一个真正的 minion。实现这一点的一种方法是在 SSH“minion”目录中有一个持久状态,并让安装的 minion 在 SSH minion 中设置一个“completely_disable”。这将确保 SSH 配置不会与常规的 minion 配置发生冲突。

10.5 摘要

Salt 是一个基于 Python 的配置管理系统。对于重要的配置,可以使用 Python 来表达期望的系统配置,这有时比模板化 YAML 文件更有效。也可以用 Python 对进行扩展,以定义新的原语。