C-网络编程实用指南-全-

C 网络编程实用指南(全)

原文:zh.annas-archive.org/md5/94bfb6c420800d1138b132e3597a0ba6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Packt 公司大约一年前首次联系我,希望我写这本书。这是一段漫长的旅程,有时比预期的要艰难,但我学到了很多。你现在手中的这本书是许多漫长日子的结晶,我很自豪终于能把它呈现给大家。

我认为 C 语言是一种美丽的编程语言。在日常使用的其他任何语言中,都没有像 C 语言那样让你如此接近机器。我用 C 语言编写过只有 16 字节 RAM 的 8 位微控制器程序,就像我用它编写过具有多核、多 GHz 处理器的现代桌面程序一样。C 语言在这两种环境中都能高效工作,这确实令人印象深刻。

网络编程是一个有趣的话题,但也是一个非常深奥的话题;在许多层面上都在发生很多事情。一些编程语言隐藏了这些抽象。例如,在 Python 编程语言中,你只需一行代码就可以下载整个网页。在 C 语言中就不是这样!在 C 语言中,如果你想下载一个网页,你必须知道一切是如何工作的。你需要了解套接字,你需要了解传输控制协议TCP),你还需要了解 HTTP。在 C 语言网络编程中,没有任何东西是隐藏的。

C 语言是学习网络编程的绝佳语言。这不仅是因为我们可以看到所有的细节,还因为所有流行的操作系统都使用用 C 语言编写的内核。没有其他语言能像 C 语言那样给你提供同样的第一级访问权限。在 C 语言中,一切都在你的控制之下——你可以按照自己的意愿精确地布局你的数据结构,精确地管理内存,甚至可以按照自己的意愿“自食其果”。

当我开始写这本书的时候,我调查了其他与用 C 语言学习网络编程相关的资源。我发现了很多错误信息——不仅在网络上,甚至在印刷品中。有很多 C 语言网络代码是错误的。关于 C 语言套接字的互联网教程经常使用已弃用的函数,并且完全忽略了内存安全性。当涉及到网络编程时,你不能采取“它工作所以足够好,巧合编程”的方法。你必须使用推理。

在这本书中,我特别注意以现代和安全的方式处理网络编程。示例程序被精心设计,以便与 IPv4 和 IPv6 一起工作,并且尽可能以可移植、操作系统无关的方式编写。无论何时有机会出现内存错误,我都会尽力注意并指出这些关注点。安全性往往被放在次要位置。我相信安全性很重要,并且应该从一开始就在系统中进行规划。因此,除了教授网络基础知识外,这本书还花了很多时间与安全协议一起工作,例如 TLS。

我希望你能像我写作这本书一样享受阅读这本书。

这本书面向谁

本书面向希望将网络功能添加到其软件中的 C 或 C++程序员。它也适用于只想学习网络编程和常见网络协议的学生或专业人士。

假设读者已经对 C 编程语言有一定了解。这包括对指针、基本数据结构和手动内存管理的基本熟练度。

本书涵盖内容

第一章,介绍网络和协议,介绍了与网络相关的重要概念。本章包括示例程序,以确定您的 IP 地址。

第二章,掌握套接字 API,介绍了套接字编程 API,并指导你构建你的第一个网络程序——一个微型的 Web 服务器。

第三章,TCP 连接的深入概述,专注于 TCP 套接字的编程。在本章中,我们为客户端和服务器端开发了示例程序。

第四章,建立 UDP 连接,涵盖了使用用户数据报协议UDP)套接字进行编程。

第五章,主机名解析和 DNS,解释了如何将主机名转换为 IP 地址。在本章中,我们构建了一个示例程序,使用 UDP 手动执行 DNS 查询查找。

第六章,构建一个简单的 Web 客户端,介绍了 HTTP——推动网站运行的协议。我们直接进入并使用 C 构建了一个 HTTP 客户端。

第七章,构建一个简单的 Web 服务器,描述了如何使用 C 构建一个功能齐全的 Web 服务器。该程序能够为任何现代 Web 浏览器提供静态网站服务。

第八章,使您的程序发送电子邮件,描述了简单邮件传输协议SMTP)——推动电子邮件的协议。在本章中,我们开发了一个程序,能够通过互联网发送电子邮件。

第九章,使用 HTTPS 和 OpenSSL 加载安全 Web 页面,探讨了 TLS——确保网页安全的协议。在本章中,我们开发了一个 HTTPS 客户端,能够安全地下载网页。

第十章,实现安全 Web 服务器,继续探讨安全主题,并探索构建安全 HTTPS Web 服务器的方法。

第十一章,使用 libssh 建立 SSH 连接,继续探讨安全协议主题。本章涵盖了使用安全壳SSH)连接到远程服务器、执行命令和安全下载文件的内容。

第十二章,网络监控和安全,讨论了用于测试网络功能、解决问题和监听不安全通信协议的工具和技术。

第十三章,套接字编程技巧和陷阱,详细介绍了 TCP 以及套接字编程中出现的许多重要边缘情况。所涵盖的技术对于创建健壮的网络程序非常有价值。

第十四章,物联网的 Web 编程,概述了物联网(IoT)应用的设计和编程。

附录 A,问题答案,提供了每章末尾的阅读理解问题的答案。

附录 B,在 Windows 上设置您的 C 编译器,提供了在 Windows 上设置开发环境以编译本书中所有示例程序的说明。

附录 C,在 Linux 上设置您的 C 编译器,提供了准备您的 Linux 计算机以能够编译本书中所有示例程序的设置说明。

附录 D,在 macOS 上设置您的 C 编译器,提供了配置您的 macOS 系统以能够编译本书中所有示例程序的逐步说明。

附录 E,示例程序,按章节列出本书代码库中包含的每个示例程序。

要充分利用本书

预期读者精通 C 编程语言。这包括熟悉内存管理、指针的使用和基本数据结构。

推荐使用 Windows、Linux 或 macOS 开发机器;您可以参考附录中的设置说明。

本书采用实践学习方法,包括 44 个示例程序。在阅读本书的同时完成这些示例将有助于巩固概念。

本书代码采用 MIT 开源许可证发布。鼓励读者使用、修改、改进甚至发布对这些示例程序的更改。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

本书代码包也公开托管在 GitHub 上,地址为github.com/codeplea/hands-on-network-programming-with-c。如果代码有更新,它将在该 GitHub 仓库中更新。每章介绍示例程序时,都会以下载书籍代码所需的命令开始。

下载彩色图像

我们还提供包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789349863_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、变量名、函数名、目录名、文件名、文件扩展名、路径名、URL 和用户输入。以下是一个示例:“使用select()函数等待网络数据。”

代码块设置如下:

/* example program */

#include <stdio.h>
int main() {
    printf("Hello World!\n");
    return 0;
}

任何命令行输入或输出都写作如下:

gcc hello.c -o hello
./hello

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评论呢?潜在的读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问packt.com

第一部分 - 网络编程入门

本节将帮助读者掌握网络基础知识、相关网络协议以及基本的套接字编程。

以下章节包含在本节中:

第一章,网络与协议简介

第二章,掌握套接字 API

第三章,TCP 连接的深入概述

第四章,建立 UDP 连接

第五章,主机名解析和 DNS

第一章:介绍网络和协议

在本章中,我们将回顾计算机网络的基本原理。我们将探讨试图解释网络主要问题的抽象模型,并解释主要网络协议——互联网协议的操作。我们将探讨地址族,并以编写程序列出计算机的本地 IP 地址结束。

本章涵盖了以下主题:

  • 网络编程与 C 语言

  • OSI 层模型

  • TCP/IP 参考模型

  • 互联网协议

  • IPv4 地址和 IPv6 地址

  • 域名

  • 互联网协议路由

  • 网络地址转换

  • 客户端-服务器模式

  • 从 C 语言程序中列出 IP 地址

技术要求

本章的大部分内容集中在理论和概念上。然而,我们在本章末尾介绍了一些示例程序。要编译这些程序,您需要一个良好的 C 编译器。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器,以了解编译器的设置。

本书代码可在以下位置找到:github.com/codeplea/Hands-On-Network-Programming-with-C.

从命令行,您可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap01

在 Windows 上,使用 MinGW,您可以使用以下命令来编译和运行代码:

gcc win_list.c -o win_list.exe -liphlpapi -lws2_32
win_list

在 Linux 和 macOS 上,您可以使用以下命令:

gcc unix_list.c -o unix_list
./unix_list

互联网与 C 语言

现在,互联网无需介绍。当然,数百万台台式机、笔记本电脑、路由器和服务器已经连接到互联网,并且已经连接了几十年。然而,现在还有数十亿额外的设备也连接到了互联网——移动电话、平板电脑、游戏系统、车辆、冰箱、电视、工业机械、监控系统、门铃,甚至灯泡。新的物联网(IoT)趋势使得每天都有越来越多的人急于连接更多不太可能的设备。

现在估计有超过 200 亿台设备连接到互联网。这些设备使用各种各样的硬件。它们通过以太网连接、Wi-Fi、蜂窝网络、电话线、光纤和其他媒体连接,但它们可能有一个共同点;它们可能都使用C语言。

C 编程语言的使用无处不在。几乎每个网络栈都是用 C 编写的。这适用于 Windows、Linux 和 macOS。如果你的手机使用 Android 或 iOS,那么尽管这些应用程序是用不同的语言编写的(Java 和 Objective C),但内核和网络代码是用 C 编写的。很可能你的互联网数据通过的网络路由器也是用 C 编写的。即使你的调制解调器或路由器的用户界面和高级功能是用另一种语言编写的,但网络驱动程序仍然很可能是用 C 实现的。

网络涵盖了多个不同抽象层次的关注点。你的网络浏览器在格式化网页时的关注点与你的路由器在转发网络数据包时的关注点大不相同。因此,有一个理论模型帮助我们理解这些不同抽象层次上的通信是有用的。现在让我们看看这些模型。

OSI 层模型

很明显,如果构成互联网的所有不同设备都要无缝通信,那么必须有一套定义它们通信的标准。这些标准被称为协议。协议定义了从以太网电缆上的电压级别到网页上 JPEG 图像的压缩方式等所有内容。很明显,当我们谈论以太网电缆上的电压时,我们处于一个与谈论 JPEG 图像格式完全不同的抽象层次。如果你正在编写一个网站,你不想去考虑以太网电缆或 Wi-Fi 频率。同样,如果你正在编写一个互联网路由器,你也不想担心 JPEG 图像是如何压缩的。因此,我们将问题分解成许多更小的部分。

分解问题的常见方法是将关注层次放入层中。每一层随后为它上面的层提供服务,并且每一上层都可以依赖下面的层,而不必担心它们是如何工作的。

网络中最流行的层系统被称为开放系统互联模型(OSI模型)。它在 1977 年标准化,并作为 ISO 7498 发布。它有七个层次:

图片

让我们逐个了解这些层:

  • 物理(1):这是现实世界中物理通信的层次。在这个层次上,我们有关于诸如以太网电缆上的电压级别、连接器上每个引脚的用途、Wi-Fi 的无线电频率以及光纤上的光闪烁等事物的规范。

  • 数据链路(2):这个层次建立在物理层之上。它处理两个节点之间直接通信的协议。它定义了节点之间直接消息的开始和结束(帧定界),错误检测和纠正,以及流量控制。

  • 网络层 (3):网络层提供在不同网络节点之间传输数据序列(称为数据包)的方法。它提供方法通过许多中间节点将数据包从一个节点路由到另一个节点(无需直接物理连接)。这是定义互联网协议的层,我们将在稍后深入探讨。

  • 传输层 (4):在这一层,我们有方法在主机之间可靠地传输可变长度的数据。这些方法涉及分割数据、重新组合数据、确保数据按顺序到达等。通常说传输控制协议TCP)和用户数据报协议UDP)存在于这一层。

  • 会话层 (5):这一层通过添加建立、检查点、暂停、恢复和终止对话的方法来构建在传输层之上。

  • 表示层 (6):这是定义应用程序数据结构和表示的最低层。在此层处理诸如数据编码、序列化和加密等问题。

  • 应用层 (7):用户界面中的应用程序(例如,网页浏览器和电子邮件客户端)存在于这里。这些应用程序利用了下面六层提供的服务。

在 OSI 模型中,一个应用程序,例如网页浏览器,存在于应用层(第 7 层)。该层的一个协议,如用于传输网页的 HTTP,不必关心数据是如何传输的。它可以依赖其下层的服务来有效地传输数据。这在下图中得到了说明:

图片

应该注意的是,根据 OSI 层不同,数据块通常有不同的名称。第 2 层的数据单元称为,因为第 2 层负责消息封装。第 3 层的数据单元被称为数据包,而第 4 层的数据单元如果是 TCP 连接的一部分,则称为;如果是 UDP 消息,则称为数据报

在这本书中,我们经常使用术语“数据包”作为任何层的数据单元的通用术语。然而,“段”一词仅用于 TCP 连接的上下文中,而“数据报”仅指 UDP 数据报。

正如我们在下一节中将要看到的,OSI 模型并不完全符合今天常用的协议。然而,它仍然是一个方便的模型来解释网络问题,并且今天它仍然被广泛用于这个目的。

TCP/IP 层模型

TCP/IP 协议族 是目前最常用的网络通信模型。TCP/IP 参考模型与 OSI 模型略有不同,因为它只有四层而不是七层。

下图说明了 TCP/IP 模型的四层如何与 OSI 模型的七层相对应:

图片

值得注意的是,TCP/IP 模型与 OSI 模型中的层并不完全匹配。这没关系。在这两个模型中,执行相同的函数;只是划分方式不同。

TCP/IP 参考模型是在 TCP/IP 协议已经普遍使用之后开发的。它通过采用一个不那么严格但仍然分层的模型与 OSI 模型不同。因此,OSI 模型有时更适合理解和推理网络问题,但 TCP/IP 模型反映了今天网络通常实施的一个更现实的视角。

TCP/IP 模型的四层如下:

  • 网络访问层(1):在这一层,发生物理连接和数据帧定界。发送以太网或 Wi-Fi 数据包是这一层关注的问题的例子。

  • 互联网层(2):这一层处理地址分组和通过多个互连网络路由分组的问题。在这一层定义了 IP 地址。

  • 主机到主机层(3):主机到主机层提供了 TCP 和 UDP 两种协议,我们将在下一章中讨论。这些协议解决数据顺序、数据分段、网络拥塞和错误纠正等问题。

  • 进程/应用层(4):进程/应用层是 HTTP、SMTP 和 FTP 等协议实现的地方。本书中大多数程序都可以被认为是发生在这一层,同时消耗操作系统对低层实现的提供的功能。

无论你选择哪种抽象模型,现实世界的协议确实在许多层面上工作。低层负责处理高层的数据。因此,这些低级数据结构必须封装来自高层的数據。现在让我们看看如何封装数据。

数据封装

这些抽象的优势在于,在编写应用程序时,我们只需要考虑最高层协议。例如,一个网页浏览器只需要实现专门处理网站的协议——HTTP、HTML、CSS 等。它不需要担心实现 TCP/IP,当然也不必理解以太网或 Wi-Fi 数据包是如何编码的。它可以依赖低层为这些任务提供的现成实现。这些实现由操作系统(例如 Windows、Linux 和 macOS)提供。

当通过网络进行通信时,数据必须在发送方的各层中进行处理,然后再在接收方的各层中重新进行。例如,如果我们有一个网页服务器,主机 A,它正在向接收方,主机 B,传输网页,它可能看起来像这样:

图片

网页包含几段文本,但网页服务器并不仅仅发送文本本身。为了正确渲染文本,它必须被编码在HTML结构中:

在某些情况下,文本已经预先格式化为 HTML 并以这种方式保存,但在这个例子中,我们正在考虑一个动态生成 HTML 的 Web 应用程序,这是动态网页最常见的方法。由于文本不能直接传输,HTML 也不能直接传输。它必须作为 HTTP 响应的一部分进行传输。Web 服务器通过将适当的 HTTP 响应头应用到 HTML 上来完成这项工作:

HTTP 作为 TCP 会话的一部分进行传输。这不是由 Web 服务器显式完成的,而是由操作系统的 TCP/IP 堆栈处理的:

TCP 数据包由 IP 数据包路由:

这通过以太网数据包(或其他协议)在网络上传输:

幸运的是,当我们使用套接字 API 进行网络编程时,低级问题会自动处理。了解幕后发生的事情仍然很有用。如果没有这些知识,处理故障或优化性能将是困难的,如果不是不可能的。

在理论部分介绍完毕后,让我们深入了解推动现代网络的实际协议。

互联网协议

二十年前,有许多相互竞争的网络协议。今天,一个协议非常普遍——互联网协议。它有两个版本——IPv4 和 IPv6。IPv4 完全无处不在,到处都有部署。如果你今天正在部署网络代码,你必须支持 IPv4,否则可能会有一大部分用户无法连接。

IPv4 使用 32 位地址,这限制了它最多只能地址 2³² 或 4,294,967,296 个系统。然而,这 43 亿个地址最初并没有被有效地分配,现在许多 互联网服务提供商ISPs)被迫配给 IPv4 地址。

IPv6 被设计用来取代 IPv4,自 1998 年以来已被互联网工程任务组(IETF)标准化。它使用 128 位地址,这使得它可以地址理论上的 2¹²⁸ = 340,282,366,920,938,463,463,374,607,431,768,211,456,或大约 3.4 x 10³⁸ 个地址。

今天,每个主要的桌面和智能手机操作系统都支持 IPv4 和 IPv6,这被称为 双栈配置。然而,许多应用程序、服务器和网络仍然只配置为使用 IPv4。从实际的角度来看,这意味着你需要支持 IPv4 才能访问互联网的大部分内容。然而,你也应该支持 IPv6 以确保未来兼容性,并帮助世界过渡到 IPv4。

什么是地址?

所有互联网协议流量都会路由到某个地址。这类似于电话通话必须拨打电话号码。IPv4 地址长度为 32 位。它们通常分为四个 8 位部分。每个部分显示为一个介于 0255 之间的十进制数,并由点分隔。

以下是一些 IPv4 地址的示例:

  • 0.0.0.0

  • 127.0.0.1

  • 10.0.0.0

  • 172.16.0.5

  • 192.168.0.1

  • 192.168.50.1

  • 255.255.255.255

一个特殊地址,称为 环回 地址,被预留于 127.0.0.1。这个地址实际上意味着 建立到自己的连接。操作系统会短路这个地址,使得发送到该地址的数据包永远不会进入网络,而是保持在源系统上本地。

IPv4 为私有用途预留了一些地址范围。如果你通过路由器/NAT 使用 IPv4,那么你很可能正在使用这些范围之一中的 IP 地址。这些预留的私有范围如下:

  • 10.0.0.010.255.255.255

  • 172.16.0.0172.31.255.255

  • 192.168.0.0192.168.255.255

IP 地址范围的概念在计算机网络中非常有用,经常出现。因此,存在一种简写表示法来书写它们。使用 无类别域间路由CIDR)表示法,我们可以将前面提到的三个地址范围写成以下形式:

  • 10.0.0.0/8

  • 172.16.0.0/12

  • 192.168.0.0/16

CIDR 表示法通过指定固定位的数量来工作。例如,10.0.0.0/8 指定 10.0.0.0 地址的前八个位是固定的,前八个位仅仅是第一个 10. 部分;地址的剩余 0.0.0 部分可以是任何东西,仍然位于 10.0.0.0/8 块中。因此,10.0.0.0/8 包括从 10.0.0.010.255.255.255

IPv6 地址长度为 128 位。它们以八个由冒号分隔的四位十六进制字符组成。十六进制字符可以是 0-9 或 a-f。以下是一些 IPv6 地址的示例:

  • 0000:0000:0000:0000:0000:0000:0000:0001

  • 2001:0db8:0000:0000:0000:ff00:0042:8329

  • fe80:0000:0000:0000:75f4:ac69:5fa7:67f9

  • ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff

注意,IPv6 地址的标准是使用小写字母。这与计算机中许多其他十六进制用法形成对比。

有几条规则可以缩短 IPv6 地址,使其更容易使用。规则 1 允许省略每个部分前面的零(例如,0db8 = db8)。规则 2 允许用双冒号(::)替换连续的零部分。规则 2 在每个地址中只能使用一次;否则,地址将是模糊的。

应用这两个规则,前面的地址可以缩短如下:

  • ::1

  • 2001:db8::ff00:42:8329

  • fe80::75f4:ac69:5fa7:67f9

  • ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff

与 IPv4 类似,IPv6 也有一个环回地址。它是 ::1

双栈实现还识别一类可以直接映射到 IPv4 地址的特殊 IPv6 地址。这些保留地址以 80 个零位开头,然后是 16 个一位,接着是 32 位的 IPv4 地址。使用 CIDR 表示法,这个地址块是::ffff:0:0/96

这些映射地址通常以 IPv6 格式的前 96 位开头,然后是 IPv4 格式的剩余 32 位。以下是一些示例:

IPv6 地址 映射的 IPv4 地址
::ffff:10.0.0.0 10.0.0.0
::ffff:172.16.0.5 172.16.0.5
::ffff:192.168.0.1  192.168.0.1
::ffff:192.168.50.1 192.168.50.1

您还可能遇到 IPv6 站点本地地址。这些站点本地地址位于fec0::/10范围内,用于私有本地网络。站点本地地址现在已经过时,不应用于新的网络,但许多现有的实现仍在使用它们。

您还应该熟悉另一种地址类型,即链路本地地址。链路本地地址只能在本地链路上使用。路由器从不转发来自这些地址的数据包。它们对于系统在分配 IP 地址之前访问自动配置功能是有用的。链路本地地址位于 IPv4 的169.254.0.0/16地址块或 IPv6 的fe80::/10地址块。

应该注意的是,IPv6 除了地址范围大大扩展之外,还引入了许多 IPv4 没有的新特性。IPv6 地址有新的属性,如作用域和生存期,IPv6 网络接口通常具有多个 IPv6 地址是正常的。IPv6 地址的使用和管理与 IPv4 地址不同。

不论这些差异如何,在这本书中,我们努力编写适用于 IPv4 和 IPv6 都表现良好的代码。

如果您认为 IPv4 地址难以记忆,IPv6 地址则几乎不可能记忆,那么您并不孤单。幸运的是,我们有一个系统来为特定地址分配名称。

域名

互联网协议只能将数据包路由到 IP 地址,而不是名称。因此,如果您尝试连接到网站,例如example.com,则您的系统必须首先将那个域名example.com解析为托管该网站的服务器的 IP 地址。

这是通过连接到一个域名系统DNS)服务器来实现的。您通过事先知道其 IP 地址来连接到域名服务器。域名服务器的 IP 地址通常由您的 ISP 分配。

许多其他域名服务器由不同的组织公开提供。以下是一些免费和公开的 DNS 服务器:

DNS 提供商 IPv4 地址 IPv6 地址
Cloudflare 1.1.1.1 1.1.1.1 2606:4700:4700::1111
1.0.0.1 2606:4700:4700::1001
FreeDNS 37.235.1.174
37.235.1.177
Google Public DNS 8.8.8.8 2001:4860:4860::8888
8.8.4.4 2001:4860:4860::8844
OpenDNS 208.67.222.222 2620:0:ccc::2
208.67.220.220 2620:0:ccd::2

为了解析主机名,你的计算机向你的域名服务器发送一个 UDP 消息,并请求为你正在尝试解析的域名获取一个 AAAA 类型的记录。如果此记录存在,则会返回一个 IPv6 地址。然后你可以连接到该地址的服务器以加载网站。如果没有 AAAA 记录,则你的计算机再次查询服务器,但请求一个 A 记录。如果此记录存在,你将收到服务器的 IPv4 地址。在许多情况下,网站将发布一个 A 记录和一个 AAAA 记录,它们都路由到同一服务器。

同样可能且常见的是存在多个相同类型的记录,每个记录指向不同的地址。这在多个服务器可以提供相同服务的情况下,用于冗余。

我们将在第五章中看到更多关于 DNS 查询的内容,主机名解析和 DNS

现在我们对 IP 地址和名称有了基本的了解,让我们详细了解一下 IP 数据包如何在互联网上路由。

互联网路由

如果所有网络中只包含最多两个设备,那么就无需进行路由。计算机 A 只需直接通过电线发送其数据,而计算机 B 将作为唯一可能性接收它:

图片

今天的互联网估计有 200 亿台设备连接。当你通过互联网建立连接时,你的数据首先传输到你的本地路由器。从那里,它被传输到另一个路由器,该路由器连接到另一个路由器,以此类推。最终,你的数据到达一个连接到接收设备的路由器,此时,数据已到达目的地:

图片

想象一下,前面图中的每个路由器都连接到成百上千个其他路由器和系统。IP 能够发现正确的路径并无缝地传输流量,这是一个惊人的成就。

Windows 包括一个名为tracert的实用程序,它列出了你的系统和目标系统之间的路由器。

下面是一个使用 Windows 10 上的tracert命令追踪到example.com路由的示例:

图片

如示例所示,我们的系统和目标系统(example.com93.184.216.34)之间有 11 个跳数。列出了许多这些中间路由器的 IP 地址,但有一些由于Request timed out消息而缺失。这通常意味着相关的系统不支持互联网控制消息协议ICMP)协议的一部分。在运行tracert时看到几个这样的系统并不罕见。

在基于 Unix 的系统上,追踪路由的实用程序称为traceroute。例如,你会使用traceroute example.com,但获得的信息基本上是相同的。

关于tracerttraceroute的更多信息可以在第十二章找到,网络监控和安全

有时,当 IP 数据包在网络之间传输时,它们的地址必须进行转换。这在使用 IPv4 时尤其常见。让我们看看这个机制的下一个部分。

本地网络和地址转换

家庭和组织通常会有小型的局域网LANs)。如前所述,有一些 IPv4 地址范围是为这些小型本地网络预留的。

这些保留的私有范围如下:

  • 10.0.0.0 至 10.255.255.255

  • 172.16.0.0 至 172.31.255.255

  • 192.168.0.0 至 192.168.255.255

当一个数据包从一个 IPv4 本地网络上的设备发出时,在它被路由到互联网之前,它必须经过网络地址转换NAT)。实现 NAT 的路由器会记住连接是从哪个本地地址建立的。

同一局域网上的设备可以直接通过它们的本地地址相互通信。然而,任何发送到互联网的流量都必须通过路由器进行地址转换。路由器通过修改原始私有局域网 IP 地址为它的公网 IP 地址来完成这项工作:

同样,当路由器接收到回传通信时,它必须将目标地址从其公网 IP 修改为原始发送者的私有 IP。因为它在第一次出站数据包后将其存储在内存中,所以它知道私有 IP 地址:

网络地址转换可能比最初看起来更复杂。除了在数据包中修改源 IP 地址外,它还必须更新数据包中的校验和。否则,数据包会被检测为包含错误并被下一个路由器丢弃。NAT 路由器还必须记住哪个私有 IP 地址发送了数据包,以便路由回复。如果没有记住转换地址,NAT 路由器就不会知道在私有网络上将回复发送到何处。

NAT 在某些情况下也会修改数据包数据。例如,在文件传输协议FTP)中,一些连接信息作为数据包数据的一部分发送。在这些情况下,NAT 路由器将查看数据包数据,以便知道如何转发未来的入站数据包。IPv6 在很大程度上避免了 NAT 的需求,因为每个设备都有自己的公开可寻址的地址,这是可能的(也是常见的)。

你可能会想知道路由器如何知道一条消息是本地可投递的还是必须转发。这是通过使用子网掩码、子网掩码或 CIDR 来完成的。

子网划分和 CIDR

IP 地址可以被分成几个部分。最重要的位用于标识网络或子网络,而最不重要的位用于标识网络上的特定设备。

这与你的家庭地址可以被分割成部分的方式类似。你的家庭地址包括门牌号、街道名称和城市。城市相当于网络部分,街道名称可能是子网部分,而你的门牌号是设备部分。

IPv4 传统上使用掩码表示法来识别 IP 地址部分。例如,考虑一个位于10.0.0.0网络上的路由器,其子网掩码为255.255.255.0。这个路由器可以接收任何传入的数据包,并对其进行与子网掩码的位与操作,以确定数据包是否属于本地子网或需要转发。例如,这个路由器接收一个要发送到10.0.0.105的数据包。它对这个地址与子网掩码255.255.255.0进行位与操作,结果是10.0.0.0。这与路由器的子网匹配,所以流量是本地的。如果考虑一个目的地为10.0.15.22的数据包,与子网掩码进行位与操作的结果是10.0.15.0。这个地址不匹配路由器所在的子网,因此必须转发。

IPv6 使用 CIDR。网络和子网使用我们之前描述的 CIDR 表示法指定。例如,如果 IPv6 子网是/112,那么路由器知道任何匹配前 112 位地址的地址都在本地子网上。

到目前为止,我们只讨论了只有一个发送者和一个接收者的路由。虽然这是最常见的情况,但让我们也考虑一下其他情况。

组播、广播和单播

当一个数据包从一个发送者路由到单个接收者时,它使用单播寻址。这是最简单也是最常见的一种寻址方式。本书中我们讨论的所有协议都使用单播寻址。

广播寻址允许单个发送者同时将数据包发送给所有接收者。它通常用于将数据包发送到整个子网上的每个接收者。

如果广播是一对全的通信,那么多播就是一对多的通信。多播涉及一些群组管理,消息被寻址并发送给群组的成员。

任播寻址的数据包用于在不关心接收者是谁的情况下将消息发送给一个接收者。如果你有多个提供相同功能的服务器,而你只想让其中一个(你不在乎是哪一个)处理你的请求,这很有用。

IPv4 和较低的网络层支持本地广播寻址。IPv4 提供了一些可选(但通常实现)的多播支持。IPv6 强制支持多播,同时提供了比 IPv4 多播更多的功能。尽管 IPv6 不被认为是广播,但其多播功能本质上可以模拟它。

值得注意的是,这些替代寻址方法通常不适用于更广泛的互联网。想象一下,如果有一个对等体能够向所有连接的互联网设备广播一个数据包,那将是一团糟!

如果你可以在本地网络上使用 IP 多播,那么实现它是值得的。发送一个 IP 级别的多播消息比发送多次相同的单播消息节省带宽。

然而,多播通常在应用层进行。也就是说,当应用程序想要将相同的信息发送给多个接收者时,它会多次发送消息——一次发送给每个接收者。在第三章中,TCP 连接的深入概述,我们构建了一个聊天室。这个聊天室可以说使用了应用层多播,但它没有利用 IP 多播。

我们已经介绍了消息如何在网络中路由。现在,让我们看看消息到达特定系统后,如何知道由哪个应用程序负责处理它。

端口号

仅 IP 地址还不够。我们需要端口号。回到电话的比喻,如果 IP 地址是电话号码,那么端口号就像电话分机。

通常,一个 IP 地址会将数据包路由到特定的系统,但端口号用于将数据包路由到该系统上的特定应用程序。

例如,在你的系统上,你可能正在运行多个网络浏览器、电子邮件客户端和视频会议客户端。当你的计算机接收到 TCP 段或 UDP 数据报时,操作系统会查看该数据包中的目标端口号。该端口号用于查找哪个应用程序应该处理它。

端口号存储为无符号 16 位整数。这意味着它们在065,535之间,包括这两个数。

一些常见协议的端口号如下:

端口号 协议
20, 21 TCP 文件传输协议 (FTP)
22 TCP 安全外壳 (SSH) 第十一章,使用 libssh 建立 SSH 连接
23 TCP Telnet
25 TCP 简单邮件传输协议 (SMTP) 第八章,让你的程序发送电子邮件
53 UDP 域名系统 (DNS) 第五章,主机名解析和 DNS
80 TCP 超文本传输协议 (HTTP) 第六章,构建简单的 Web 客户端 第七章,构建简单的 Web 服务器
110 TCP 邮局协议第 3 版 (POP3)
143 TCP 互联网消息访问协议 (IMAP)
194 TCP 互联网中继聊天 (IRC)
443 TCP 通过 TLS/SSL 的 HTTP (HTTPS) 第九章,使用 HTTPS 和 OpenSSL 加载安全网页 第十章,实现安全 Web 服务器
993 TCP 通过 TLS/SSL 的 IMAP (IMAPS)
995 TCP POP3 over TLS/SSL (POP3S)

这些列出的端口号都是由互联网数字分配机构IANA)分配的。它们负责为特定协议的端口号进行官方分配。对于实现自定义协议的应用程序,非官方的端口号使用非常普遍。在这种情况下,应用程序应尝试选择一个不常用的端口号,以避免冲突。

客户端和服务器

在电话类比中,通话必须首先由一方发起。发起方拨打接收方的电话号码,接收方接听。

这也是网络中一个常见的范式,称为客户端-服务器模型。在这个模型中,服务器监听连接。客户端知道服务器监听的地址和端口号,通过发送第一个数据包来建立连接。

例如,example.com上的 Web 服务器监听80端口(HTTP)和443端口(HTTPS)。一个网络浏览器(客户端)必须通过向 Web 服务器地址和端口发送第一个数据包来建立连接。

组装起来

套接字是系统之间通信链路的一端。它是一种抽象,您的应用程序可以通过网络发送和接收数据,这与您的应用程序使用文件句柄读取和写入文件的方式非常相似。

一个开放的套接字由以下五个元素组成的五元组唯一确定:

  • 本地 IP 地址

  • 本地端口

  • 远程 IP 地址

  • 远程端口

  • 协议(UDP 或 TCP)

这个五元组很重要,因为它是操作系统知道哪个应用程序负责接收任何数据包的方式。例如,如果您使用两个网络浏览器同时连接到example.com80端口,那么您的操作系统会通过查看本地 IP 地址、本地端口、远程 IP 地址、远程端口和协议来保持连接的分离。在这种情况下,本地 IP 地址、远程 IP 地址、远程端口(80)和协议(TCP)是相同的。

决定因素是本地端口(也称为临时端口),操作系统将选择一个不同的端口用于连接。这个五元组对于理解 NAT 的工作原理也很重要。一个私有网络可能有多个系统访问相同的公共资源,并且路由器 NAT 必须为每个连接存储这个五元组,以便知道如何将接收到的数据包路由回私有网络。

你的地址是什么?

您可以使用 Windows 上的ipconfig命令或基于 Unix 的系统(如 Linux 和 macOS)上的ifconfig命令来查找您的 IP 地址。

使用 Windows PowerShell 中的ipconfig命令看起来像这样:

在这个例子中,你可以找到 IPv4 地址在以太网适配器 Ethernet0下列出。你的系统可能有更多的网络适配器,每个适配器都有自己的 IP 地址。我们可以判断这台计算机位于本地网络中,因为 IP 地址192.168.182.133位于私有 IP 地址范围内。

在基于 Unix 的系统上,我们使用ifconfigip addr命令。ifconfig命令是旧的方法,现在在某些系统上已弃用。ip addr命令是新的方法,但并非所有系统都支持它。

在 macOS 终端中使用ifconfig命令看起来是这样的:

IPv4 地址列在inet旁边。在这种情况下,我们可以看到它是192.168.182.128。同样,我们看到这台计算机位于本地网络中,因为 IP 地址范围。相同的适配器在inet6旁边列出了 IPv6 地址。

以下截图显示了在 Ubuntu Linux 上使用ip addr命令的情况:

上一张截图显示了本地 IPv4 地址为192.168.182.145。我们还可以看到链路本地 IPv6 地址为fe80::df60:954e:211:7ff0

这些命令,ifconfigip addripconfig,显示了计算机上每个适配器的 IP 地址或地址。你可能有几个。如果你在一个本地网络中,你看到的 IP 地址将是你的本地私有网络 IP 地址。

如果你位于 NAT 后面,通常没有很好的方法知道你的公网 IP 地址。通常,唯一的办法是联系一个提供 API 的互联网服务器,该 API 会告诉你你的 IP 地址。

一些免费和公开的 API 如下:

  • http://api.ipify.org/

  • http://helloacm.com/api/what-is-my-ip-address/

  • http://icanhazip.com/

  • http://ifconfig.me/ip

你可以在网页浏览器中测试这些 API:

列出的每个网页都应该返回你的公网 IP 地址以及不多于其他信息。这些网站在你需要从 NAT 后面程序化地确定公网 IP 地址时很有用。我们在第六章,构建简单的 Web 客户端中讨论了编写能够下载这些网页和其他网页的小型 HTTP 客户端。

现在我们已经看到了确定本地 IP 地址的内置工具,接下来让我们看看如何从 C 语言中实现这一功能。

从 C 语言列出网络适配器

有时,让你的 C 程序知道你的本地地址是有用的。在这本书的大部分内容中,我们能够编写既适用于 Windows 也适用于基于 Unix 的系统(Linux 和 macOS)的代码。然而,列出本地地址的 API 在不同系统之间非常不同。因此,我们将这个程序分为两个:一个用于 Windows,一个用于基于 Unix 的系统。

我们将首先解决 Windows 的情况。

在 Windows 上列出网络适配器

Windows 网络 API 称为Winsock,我们将在下一章中详细介绍它。

每当我们使用 Winsock 时,我们必须做的第一件事就是初始化它。这是通过调用WSAStartup()来完成的。以下是一个小的 C 程序,win_init.c,展示了 Winsock 的初始化和清理:

/*win_init.c*/

#include <stdio.h>
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")

int main() {
    WSADATA d;

    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        printf("Failed to initialize.\n");
        return -1;
    }

    WSACleanup();
    printf("Ok.\n");
    return 0;
}

WSAStartup()函数使用请求的版本调用,在本例中是 Winsock 2.2,以及一个WSADATA结构。WSADATA结构将由WSAStartup()填充,包含有关 Windows Sockets 实现的详细信息。WSAStartup()函数在成功时返回0,在失败时返回非零值。

当 Winsock 程序完成时,它应该调用WSACleanup()

如果你使用 Microsoft Visual C 作为编译器,那么#pragma comment(lib, "ws2_32.lib")告诉 Microsoft Visual C 将可执行文件与 Winsock 库ws2_32.lib链接。

如果你使用 MinGW 作为编译器,则忽略该 pragma。你需要明确告诉编译器链接库,通过添加命令行选项-lws2_32。例如,你可以使用以下命令使用 MinGW 编译此程序:

gcc win_init.c -o win_init.exe -lws2_32

我们将在第二章中更详细地介绍 Winsock 的初始化和使用,掌握 Socket API

现在我们已经知道了如何初始化 Winsock,我们将开始编写一个完整的程序来列出 Windows 上的网络适配器。请参考win_list.c文件以了解整个过程。

首先,我们需要定义_WIN32_WINNT并包含所需的头文件:

/*win_list.c*/

#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif

#include <winsock2.h>
#include <iphlpapi.h>
#include <ws2tcpip.h>
#include <stdio.h>
#include <stdlib.h>

_WIN32_WINNT宏必须首先定义,以便包含正确的 Windows 头文件版本。winsock2.hiphlpapi.hws2tcpip.h是我们列出网络适配器所需的 Windows 头文件。我们还需要stdio.h用于printf()函数和stdlib.h用于内存分配。

接下来,我们包含以下 pragma 来告诉 Microsoft Visual C 必须与可执行文件链接哪些库:

/*win_list.c continued*/

#pragma comment(lib, "ws2_32.lib")
#pragma comment(lib, "iphlpapi.lib")

如果你使用 MinGW 编译,这些行将没有效果。你需要在命令行上显式链接到这些库,例如,gcc win_list.c -o win_list.exe -liphlpapi -lws2_32

然后我们进入main()函数,并使用前面描述的WSAStartup()初始化 Winsock 2.2。我们检查其返回值以检测任何错误:

/*win_list.c continued*/

int main() {

    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        printf("Failed to initialize.\n");
        return -1;
    }

接下来,我们为适配器分配内存,并使用GetAdapterAddresses()函数从 Windows 请求适配器的地址:

/*win_list.c continued*/

    DWORD asize = 20000;
    PIP_ADAPTER_ADDRESSES adapters;
    do {
        adapters = (PIP_ADAPTER_ADDRESSES)malloc(asize);

        if (!adapters) {
            printf("Couldn't allocate %ld bytes for adapters.\n", asize);
            WSACleanup();
            return -1;
        }

        int r = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_INCLUDE_PREFIX, 0,
                adapters, &asize);
        if (r == ERROR_BUFFER_OVERFLOW) {
            printf("GetAdaptersAddresses wants %ld bytes.\n", asize);
            free(adapters);
        } else if (r == ERROR_SUCCESS) {
            break;
        } else {
            printf("Error from GetAdaptersAddresses: %d\n", r);
            free(adapters);
            WSACleanup();
            return -1;
        }
    } while (!adapters);

asize变量将存储我们适配器地址缓冲区的大小。一开始,我们将其设置为20000,并使用malloc()函数为adapters分配 20,000 字节。malloc()函数在失败时将返回0,因此我们检查这一点,如果分配失败则显示错误信息。

接下来,我们调用 GetAdapterAddresses()。第一个参数 AF_UNSPEC 告诉 Windows 我们想要 IPv4 和 IPv6 地址。你可以传入 AF_INETAF_INET6 来请求只获取 IPv4 或只获取 IPv6 地址。第二个参数 GAA_FLAG_INCLUDE_PREFIX 是请求地址列表所必需的。下一个参数是保留的,应该传入 0NULL。最后,我们传入我们的缓冲区 adapters 和其大小的指针 asize

如果我们的缓冲区不足以存储所有地址,那么 GetAdapterAddresses() 将返回 ERROR_BUFFER_OVERFLOW 并将 asize 设置为所需的缓冲区大小。在这种情况下,我们释放 adapters 缓冲区,并使用更大的缓冲区再次尝试调用。

成功时,GetAdapterAddresses() 返回 ERROR_SUCCESS,在这种情况下,我们退出循环并继续。任何其他返回值都是错误。

GetAdapterAddresses() 成功返回时,它将在 adapters 中写入一个链表,其中包含每个适配器的地址信息。我们的下一步是遍历这个链表,并打印每个适配器和地址的信息:

/*win_list.c continued*/

    PIP_ADAPTER_ADDRESSES adapter = adapters;
    while (adapter) {
        printf("\nAdapter name: %S\n", adapter->FriendlyName);

        PIP_ADAPTER_UNICAST_ADDRESS address = adapter->FirstUnicastAddress;
        while (address) {
            printf("\t%s",
                    address->Address.lpSockaddr->sa_family == AF_INET ?
                    "IPv4" : "IPv6");

            char ap[100];

            getnameinfo(address->Address.lpSockaddr,
                    address->Address.iSockaddrLength,
                    ap, sizeof(ap), 0, 0, NI_NUMERICHOST);
            printf("\t%s\n", ap);

            address = address->Next;
        }

        adapter = adapter->Next;
    }

我们首先定义一个新的变量 adapter,我们用它来遍历适配器的链表。第一个适配器在 adapters 的开始处,所以我们最初将 adapter 设置为 adapters。在每个循环的末尾,我们将 adapter = adapter->Next; 设置为获取下一个适配器。当 adapter0 时,循环终止,这意味着我们已经到达了列表的末尾。

我们从 adapter->FriendlyName 获取适配器名称,然后使用 printf() 打印出来。

每个适配器的第一个地址在 adapter->FirstUnicastAddress 中。我们定义一个第二个指针 address,并将其设置为这个地址。地址也存储为链表,所以我们开始一个内部循环,遍历地址。

address->Address.lpSockaddr->sa_family 变量存储地址族类型。如果它被设置为 AF_INET,那么我们知道这是一个 IPv4 地址。否则,我们假设它是一个 IPv6 地址(在这种情况下,族是 AF_INET6)。

接下来,我们分配一个缓冲区 ap 来存储地址的文本表示。调用 getnameinfo() 函数将地址转换为标准表示的地址。我们将在下一章中详细介绍 getnameinfo()

最后,我们可以使用 printf() 打印出我们缓冲区 ap 中的地址。

我们通过释放分配的内存并调用 WSACleanup() 来结束程序:

/*win_list.c continued*/

    free(adapters);
    WSACleanup();
    return 0;
}

在 Windows 上,使用 MinGW,你可以使用以下命令编译和运行程序:

gcc win_list.c -o win_list.exe -liphlpapi -lws2_32
win_list

它应该列出你每个适配器的名称和地址。

现在我们可以在 Windows 上列出本地 IP 地址了,让我们考虑基于 Unix 的系统上的相同任务。

在 Linux 和 macOS 上列出网络适配器

在类 Unix 系统上列出本地网络地址比在 Windows 上要容易一些。加载 unix_list.c 以便跟随。

首先,我们包含必要的系统头文件:

/*unix_list.c*/

#include <sys/socket.h>
#include <netdb.h>
#include <ifaddrs.h>
#include <stdio.h>
#include <stdlib.h>

然后我们进入 main 函数:

/*unix_list.c continued*/

int main() {

    struct ifaddrs *addresses;

    if (getifaddrs(&addresses) == -1) {
        printf("getifaddrs call failed\n");
        return -1;
    }

我们声明一个变量,addresses,用于存储地址。调用getifaddrs()函数分配内存并填充地址的链表。此函数在成功时返回0,在失败时返回-1

接下来,我们使用一个新的指针,address,遍历地址的链表。考虑完每个地址后,我们将address = address->ifa_next设置为获取下一个地址。当address == 0时,我们停止循环,这发生在链表末尾:

/*unix_list.c continued*/

    struct ifaddrs *address = addresses;
    while(address) {
        int family = address->ifa_addr->sa_family;
        if (family == AF_INET || family == AF_INET6) {

            printf("%s\t", address->ifa_name);
            printf("%s\t", family == AF_INET ? "IPv4" : "IPv6");

            char ap[100];
            const int family_size = family == AF_INET ?
                sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6);
            getnameinfo(address->ifa_addr,
                    family_size, ap, sizeof(ap), 0, 0, NI_NUMERICHOST);
            printf("\t%s\n", ap);

        }
        address = address->ifa_next;
    }

对于每个地址,我们识别地址族。我们感兴趣的是AF_INET(IPv4 地址)和AF_INET6(IPv6 地址)。getifaddrs()函数可以返回其他类型,所以我们跳过那些。

对于每个地址,我们继续打印其适配器名称和地址类型,IPv4 或 IPv6。

然后,我们定义一个缓冲区,ap,用于存储文本地址。调用getnameinfo()函数填充此缓冲区,然后我们可以打印它。我们将在下一章更详细地介绍getnameinfo()函数,第二章,掌握套接字 API

最后,我们释放getifaddrs()分配的内存,我们完成了:

/*unix_list.c continued*/

    freeifaddrs(addresses);
    return 0;
}

在 Linux 和 macOS 上,您可以使用以下命令编译和运行此程序:

gcc unix_list.c -o unix_list
./unix_list

它应该列出您适配器的每个名称和地址。

摘要

在本章中,我们简要地探讨了互联网流量的路由方式。我们了解到存在两种互联网协议版本,IPv4 和 IPv6。IPv4 地址数量有限,这些地址正在耗尽。IPv6 的主要优势之一是它为每个系统提供了足够的地址空间,以便每个系统都有自己的唯一公开可路由地址。IPv4 有限的地址空间在很大程度上被路由器执行的地址转换所缓解。我们还探讨了如何使用操作系统提供的实用程序和 API 检测您的本地 IP 地址。

我们看到,用于列出本地 IP 地址的 API 在 Windows 和基于 Unix 的操作系统之间差异很大。在未来的章节中,我们将看到大多数其他网络功能在操作系统之间是相似的,我们可以编写一个在操作系统之间工作的可移植程序。

如果您没有完全掌握本章的细节,那没关系。大部分信息都是有益的背景知识,但对于大多数网络应用程序编程来说并非必需。例如,网络地址转换这样的细节由网络处理,并且通常不需要您的程序明确处理这些细节。

在下一章中,我们将通过介绍套接字编程 API 来巩固本章介绍的概念。

问题

尝试以下问题来测试您对本章知识的掌握:

  1. IPv4 和 IPv6 之间的主要区别是什么?

  2. 使用ipconfigifconfig命令给出的 IP 地址与远程 Web 服务器看到的 IP 地址相同吗?

  3. 什么是 IPv4 回环地址?

  4. 什么是 IPv6 回环地址?

  5. 域名(例如,example.com)是如何解析成 IP 地址的?

  6. 你如何找到你的公网 IP 地址?

  7. 操作系统是如何知道哪个应用程序负责处理传入的数据包的?

答案在附录 A,问题解答中。

第二章:掌握套接字 API

在本章中,我们将真正开始学习网络编程。我们将介绍套接字的概念,并简要解释其背后的历史。我们将讨论 Windows 和类 Unix 操作系统提供的套接字 API 之间的重要差异,并回顾在套接字编程中常用的函数。本章以将一个简单的控制台程序转换为可以通过网页浏览器访问的网络程序的具体示例结束。

本章涵盖了以下主题:

  • 什么是套接字?

  • 套接字编程使用哪些头文件?

  • 如何在 Windows、Linux 和 macOS 上编译套接字程序

  • 面向连接和无连接套接字

  • TCP 和 UDP 协议

  • 常见套接字函数

  • 将简单的控制台程序构建成网络服务器

技术要求

本章中的示例程序可以使用任何现代 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。有关编译器设置,请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器

这本书的代码可以在以下位置找到:github.com/codeplea/Hands-On-Network-Programming-with-C

从命令行,你可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap02

本章中的每个示例程序都是独立的,每个示例都可以在 Windows、Linux 和 macOS 上运行。当为 Windows 编译时,请记住大多数示例程序都需要与 Winsock 库链接。

这可以通过向 gcc 传递 -lws2_32 选项来实现。我们提供了编译每个示例所需的精确命令,当介绍示例时。

什么是套接字?

套接字是系统间通信链路的一个端点。你的应用程序通过套接字发送和接收所有的网络数据。

存在几种不同的套接字应用程序编程接口API)。第一种是伯克利套接字,它在 1983 年与 4.3BSD Unix 一起发布。伯克利套接字 API 获得了巨大的成功,并迅速演变成一个事实上的标准。从那时起,它经过少量修改后就被采纳为 POSIX 标准。术语伯克利套接字、BSD 套接字、Unix 套接字和可移植操作系统接口POSIX)套接字通常可以互换使用。

如果你使用 Linux 或 macOS,那么你的操作系统提供了适当的伯克利套接字实现。

Windows 的套接字 API 被称为 Winsock。它是为了与伯克利套接字高度兼容而创建的。在这本书中,我们努力创建跨平台的代码,使其对伯克利套接字和 Winsock 都有效。

从历史上看,套接字被用于进程间通信(IPC)以及各种网络协议。在这本书中,我们只使用套接字与 TCP 和 UDP 进行通信。

在我们开始使用套接字之前,我们需要做一些设置。让我们直接进入正题!

套接字设置

在我们能够使用套接字 API 之前,我们需要包含套接字 API 头文件。这些文件取决于我们是否使用伯克利套接字或 Winsock。此外,Winsock 在使用之前需要初始化。它还要求在完成时调用清理函数。这些初始化和清理步骤在伯克利套接字中不使用。

我们将使用 C 预处理器来在 Windows 上运行适当的代码,与伯克利套接字系统相比。通过使用预处理器语句 #if defined(_WIN32),我们可以在程序中包含仅在 Windows 上编译的代码。

下面是一个完整的程序,它包括每个平台所需的套接字 API 头文件,并在 Windows 上正确初始化 Winsock:

/*sock_init.c*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#include <stdio.h>

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

    printf("Ready to use socket API.\n");

#if defined(_WIN32)
    WSACleanup();
#endif

    return 0;
}

第一部分包括 Windows 上的 winsock.hws2tcpip.h。为了 Winsock 头文件提供我们需要的所有功能,必须定义 _WIN32_WINNT。我们还包含了 #pragma comment(lib,"ws2_32.lib") 预编译语句。这告诉 Microsoft Visual C 编译器将你的程序链接到 Winsock 库,即 ws2_32.lib。如果你使用 MinGW 作为编译器,则 #pragma 被忽略。在这种情况下,你需要通过命令行使用 -lws2_32 选项告诉编译器链接 ws2_32.lib

如果程序不是在 Windows 上编译的,那么 #else 之后的部分将被编译。这部分包括各种伯克利套接字 API 头文件以及在这些平台上我们需要的其他头文件。

main() 函数中,我们在 Windows 上调用 WSAStartup() 来初始化 Winsock。MAKEWORD 宏允许我们请求 Winsock 版本 2.2。如果我们的程序无法初始化 Winsock,它将打印错误消息并终止。

当使用伯克利套接字时,不需要特殊初始化,套接字 API 总是准备就绪,可以立即使用。

在我们的程序结束之前,如果我们在 Windows 上编译 Winsock,则需要调用 WSACleanup()。此函数允许 Windows 操作系统进行额外的清理。

在 Linux 或 macOS 上编译和运行此程序可以使用以下命令:

gcc sock_init.c -o sock_init
./sock_init

在 Windows 上使用 MinGW 编译可以通过以下命令完成:

gcc sock_init.c -o sock_init.exe -lws2_32
sock_init.exe

注意,使用 MinGW 需要使用 -lws2_32 标志来告诉编译器链接 Winsock 库,即 ws2_32.lib

现在我们已经完成了开始使用套接字 API 所必需的设置,让我们更详细地看看我们将使用这些套接字做什么。

两种类型的套接字

套接字有两种基本类型——面向连接无连接。这些术语指的是协议的类型。初学者有时会混淆无连接这个术语。当然,通过网络进行通信的两个系统在某种程度上是连接的。请记住,这些术语具有特殊含义,我们将在稍后讨论,不应暗示某些协议能够不通过连接发送数据。

今天使用的两种协议是传输控制协议TCP)和用户数据报协议UDP)。TCP 是一种面向连接的协议,而 UDP 是一种无连接的协议。

套接字 API 还支持其他不太常见或过时的协议,这些内容我们在这本书中不予涉及。

在无连接协议(如 UDP)中,每个数据包都是单独寻址的。从协议的角度来看,每个数据包是完全独立且与之前或之后到达的任何数据包无关的。

对于 UDP 的一个好的类比是明信片。当你发送明信片时,没有保证它会到达。也无法知道它是否真的到达了。如果你一次性发送很多明信片,也无法预测它们将按什么顺序到达。完全有可能你发送的第一张明信片被延误,并在最后一张明信片发送数周后才到达。

使用 UDP 时,这些相同的注意事项同样适用。UDP 不保证数据包会到达。UDP 通常不提供一种方法来知道数据包是否未到达,并且 UDP 也不保证数据包会按照发送的顺序到达。正如你所见,UDP 的可靠性并不比明信片高。事实上,你可能会认为它更不可靠,因为在使用 UDP 的情况下,单个数据包可能会重复到达两次!

如果你需要可靠的通信,你可能倾向于开发一种方案,其中为发送的每个数据包编号。对于第一个发送的数据包,编号为一,第二个发送的数据包编号为二,依此类推。你也可以要求接收方为每个数据包发送确认。当接收方收到第一个数据包时,它会发送一个回执消息,“数据包一已接收”。这样,接收方可以确信接收到的数据包是按正确顺序的。如果相同的包两次到达,接收方可以简单地忽略重复的副本。如果某个数据包根本未收到,发送方可以从缺失的确认中知道,并重新发送它。

这种方案本质上就是面向连接的协议,如 TCP 所做的那样。TCP 保证数据按照发送的顺序到达。它防止重复数据两次到达,并重新发送丢失的数据。它还提供了诸如连接终止时的通知和缓解网络拥塞的算法等附加功能。此外,TCP 以 UDP 之上叠加自定义可靠性方案无法达到的效率实现了这些功能。

由于这些原因,许多协议都使用 TCP。HTTP(用于提供网页服务)、FTP(用于文件传输)、SSH(用于远程管理)和 SMTP(用于发送电子邮件)都使用 TCP。我们将在接下来的章节中介绍 HTTP、SSH 和 SMTP。

UDP 被用于 DNS(用于解析域名)。它适合这个目的,因为整个请求和响应可以适合一个单独的数据包。

UDP 也常用于实时应用,如音频流、视频流和多玩家视频游戏。在实时应用中,通常没有必要重发丢失的数据包,因此 TCP 的保证是不必要的。例如,如果你正在流式传输实时视频,并且一些数据包丢失,视频将在下一个数据包到达时简单地继续。没有必要重发(甚至检测)丢失的数据包,因为视频已经超过了那个点。

在你想要发送消息而不期望从另一端得到响应的情况下,UDP 也有优势。这使得它在使用 IP 广播或多播时非常有用。另一方面,TCP 需要双向通信来提供其保证,TCP 也不与 IP 多播或广播一起工作。

如果不需要 TCP 提供的保证,那么 UDP 可以实现更高的效率。这是因为 TCP 通过对数据包编号增加了额外的开销。TCP 还必须延迟顺序错误的到达的数据包,这可能导致实时应用中不必要的延迟。然而,如果你确实需要 TCP 提供的保证,那么几乎总是更倾向于使用 TCP 而不是尝试将那些机制添加到 UDP 中。

既然我们已经了解了我们使用套接字进行通信的模型,让我们看看实际在套接字编程中使用的函数。

套接字函数

套接字 API 为网络编程提供了许多函数。以下是我们在本书中使用的常见套接字函数:

  • socket() 函数用于创建和初始化一个新的套接字。

  • bind() 函数将套接字与特定的本地 IP 地址和端口号关联。

  • listen() 函数用于服务器上,使 TCP 套接字监听新的连接。

  • connect() 函数用于客户端设置远程地址和端口。在 TCP 的情况下,它也建立了连接。

  • accept() 函数用于服务器上创建一个新的套接字以处理传入的 TCP 连接。

  • send()recv() 用于通过套接字发送和接收数据。

  • sendto()recvfrom() 用于在没有绑定远程地址的套接字上发送和接收数据。

  • close()(伯克利套接字)和 closesocket()(Winsock 套接字)用于关闭套接字。在 TCP 的情况下,这也终止了连接。

  • shutdown() 函数用于关闭 TCP 连接的一侧。它有助于确保有序地断开连接。

  • select() 函数用于在一个或多个套接字上等待事件。

  • getnameinfo()getaddrinfo() 提供了一种与主机名和地址无关的协议方式。

  • setsockopt()用于更改一些套接字选项。

  • fcntl()(伯克利套接字)和ioctlsocket()(Winsock 套接字)也用于获取和设置一些套接字选项。

你可能会看到一些使用read()write()的伯克利套接字网络程序。这些函数不能移植到 Winsock,所以我们在这里更喜欢使用send()recv()。与伯克利套接字一起使用的其他一些常见函数是poll()dup()。我们将避免使用这些函数,以保持我们的程序可移植。

本章后面将讨论伯克利套接字和 Winsock 套接字的其它差异。

既然我们已经了解了涉及的函数,接下来让我们考虑程序设计和流程。

套接字程序的结构

正如我们在第一章,“网络和协议简介”中提到的,网络编程通常使用客户端-服务器范式。在这个范式中,服务器在一个已发布的地址上监听新的连接。客户端知道服务器的地址,是建立初始连接的一方。一旦建立连接,客户端和服务器都可以发送和接收数据。这可以持续到客户端或服务器终止连接。

传统的客户端-服务器模型通常意味着客户端和服务器有不同的行为。例如,网页浏览的方式是服务器位于一个已知地址,等待连接。客户端(网页浏览器)建立连接并发送一个请求,包括它想要下载的网页或资源。然后服务器检查它是否知道如何处理这个请求,并相应地做出回应(通过发送网页)。

另一种范式是对等模型。例如,这个模型被 BitTorrent 协议使用。在对等模型中,每个对等方本质上都有相同的职责。虽然 Web 服务器被优化为从服务器向客户端发送请求的数据,但对等协议在数据在对等方之间交换上是平衡的。然而,即使在对等模型中,使用 TCP 或 UDP 的底层套接字也不尽相同。也就是说,对于每个对等连接,一个对等方在监听,另一个在连接。BitTorrent 通过有一个中央服务器(称为追踪器)来工作,该服务器存储了一个对等 IP 地址列表。列表上的每个对等方都同意表现得像服务器并监听新的连接。当一个新对等方想要加入群体时,它会从中央服务器请求一个对等方列表,然后尝试与列表上的对等方建立连接,同时监听来自其他对等方的新连接。总之,对等协议并不是要完全取代客户端-服务器模型;它只是预期每个对等方既是客户端也是服务器。

另一个推动客户端-服务器范式边界的常见协议是 FTP。FTP 服务器监听连接,直到 FTP 客户端连接。在初始连接之后,FTP 客户端向服务器发送命令。如果 FTP 客户端从服务器请求文件,服务器将尝试与 FTP 客户端建立新的连接以传输文件。因此,出于这个原因,FTP 客户端首先以 TCP 客户端的身份建立连接,但后来像 TCP 服务器一样接受连接。

网络程序通常可以描述为四种类型之一——TCP 服务器、TCP 客户端、UDP 服务器或 UDP 客户端。某些协议要求程序实现两种,甚至所有四种类型,但对我们来说,分别考虑这四种类型是有用的。

TCP 程序流程

TCP 客户端程序必须首先知道 TCP 服务器的地址。这通常由用户输入。在网页浏览器的例子中,服务器地址要么是用户直接在地址栏中输入,要么是用户点击链接时已知的。TCP 客户端获取这个地址(例如,http://example.com),并使用getaddrinfo()函数将其解析为struct addrinfo结构。然后客户端通过调用socket()创建套接字。客户端随后通过调用connect()建立新的 TCP 连接。此时,客户端可以使用send()recv()自由交换数据。

TCP 服务器在特定接口的特定端口号上监听连接。程序必须首先使用适当的监听 IP 地址和端口号初始化struct addrinfo结构。getaddrinfo()函数有助于以 IPv4/IPv6 独立的方式进行此操作。然后服务器通过调用socket()创建套接字。套接字必须绑定到监听 IP 地址和端口号。这是通过调用bind()实现的。

服务器程序随后调用listen(),这将套接字置于监听新连接的状态。然后服务器可以调用accept(),这将等待直到客户端与服务器建立连接。当新的连接建立后,accept()返回一个新的套接字。这个新的套接字可以用来通过send()recv()与客户端交换数据。同时,第一个套接字仍然在监听新的连接,重复调用accept()允许服务器处理多个客户端。

从图形上看,TCP 客户端和服务器程序流程如下:

图片

这里给出的程序流程应该作为基本客户端-服务器 TCP 程序交互的良好示例。话虽如此,在这个基本程序流程上可能会有相当大的变化。关于哪一方首先调用send()recv(),或调用多少次,也没有规则。双方可以在连接建立后立即调用send()

此外,请注意,如果 TCP 客户端对要使用的网络接口有特定要求,它可以在connect()之前调用bind()。这在具有多个网络接口的服务器上有时很重要。对于通用软件通常并不重要。

TCP 操作的其他许多变体也是可能的,我们将在第三章,“TCP 连接的深入概述”中探讨一些。

UDP 程序流程

一个 UDP 客户端必须知道远程 UDP 对等方的地址,才能发送第一个数据包。UDP 客户端使用getaddrinfo()函数将地址解析为struct addrinfo结构。一旦完成,客户端将创建适当类型的套接字。然后,客户端可以在套接字上调用sendto()来发送第一个数据包。客户端可以继续在套接字上调用sendto()recvfrom()来发送和接收额外的数据包。请注意,客户端必须使用sendto()发送第一个数据包。UDP 客户端不能先接收数据,因为远程对等方在没有先从客户端接收数据的情况下,将无法知道数据应该发送到哪里。这与 TCP 不同,TCP 首先通过握手建立连接。在 TCP 中,客户端或服务器都可以先发送第一个应用数据。

一个 UDP 服务器监听来自 UDP 客户端的连接。这个服务器应该使用正确的监听 IP 地址和端口号初始化struct addrinfo结构。可以使用getaddrinfo()函数以协议无关的方式完成此操作。然后,服务器使用socket()创建一个新的套接字,并使用bind()将其绑定到监听的 IP 地址和端口号。此时,服务器可以调用recvfrom(),这将使其阻塞,直到从 UDP 客户端接收数据。接收第一个数据后,服务器可以使用sendto()进行回复,或者使用recvfrom()监听更多数据(来自第一个客户端或任何新的客户端)。

从图形上看,UDP 客户端和服务器程序流程如下:

图片

我们在第四章,“建立 UDP 连接”中讨论了此示例程序流程的一些变体。

我们几乎准备好开始实现我们的第一个网络程序了,但在开始之前,我们应该注意一些跨平台的问题。现在让我们来处理这个问题。

伯克利套接字与 Winsock 套接字

如我们之前所述,Winsock 套接字是基于伯克利套接字设计的。因此,它们之间有许多相似之处。然而,也有许多我们需要注意的差异。

在这本书中,我们将尝试创建每个程序,使其能够在 Windows 和基于 Unix 的操作系统中运行。通过定义一些 C 宏来帮助我们,这使得这个过程变得容易得多。

头文件

如我们之前提到的,所需的头文件在不同的实现之间有所不同。我们已经看到如何使用预处理语句轻松克服这些头文件差异。

套接字数据类型

在 UNIX 中,套接字描述符由标准文件描述符表示。这意味着你可以对套接字使用任何标准的 UNIX 文件 I/O 函数。在 Windows 上并非如此,所以我们简单地避免这些函数以保持可移植性。

此外,在 UNIX 中,所有文件描述符(因此也是套接字描述符)都是小的、非负整数。在 Windows 中,套接字句柄可以是任何东西。此外,在 UNIX 中,socket()函数返回一个int,而在 Windows 中返回一个SOCKETSOCKET是 Winsock 头文件中unsigned inttypedef。作为一个解决方案,我发现将typedef int SOCKET#define SOCKET int在非 Windows 平台上是有用的。这样,你可以在所有平台上将套接字描述符存储为SOCKET类型:

#if !defined(_WIN32)
#define SOCKET int
#endif

无效套接字

在 Windows 上,如果socket()失败,则返回INVALID_SOCKET。在 Unix 上,如果socket()失败,则返回一个负数。这尤其成问题,因为 Windows 的SOCKET类型是无符号的。我发现定义一个宏来指示套接字描述符是否有效是有用的:

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#endif

关闭套接字

在 Unix 系统中,所有套接字也都是标准文件描述符。因此,Unix 系统上的套接字可以使用标准的close()函数关闭。在 Windows 上,则使用特殊的关闭函数——closesocket()。使用宏抽象出这种差异是有用的:

#if defined(_WIN32)
#define CLOSESOCKET(s) closesocket(s)
#else
#define CLOSESOCKET(s) close(s)
#endif

错误处理

当套接字函数,如socket()bind()accept()等,在 Unix 平台上出现错误时,错误号会被存储在线程全局的errno变量中。在 Windows 上,可以通过调用WSAGetLastError()来检索错误号。同样,我们可以使用宏来抽象出这种差异:

#if defined(_WIN32)
#define GETSOCKETERRNO() (WSAGetLastError())
#else
#define GETSOCKETERRNO() (errno)
#endif

除了获取错误代码外,检索错误条件的文本描述通常也很有用。请参阅第十三章,套接字编程技巧与陷阱,了解如何进行此操作。

在这些辅助宏处理完毕后,让我们深入到我们的第一个真正的套接字程序。

我们的第一个程序

现在我们对套接字 API 和网络程序的结构有了基本了解,我们准备开始我们的第一个程序。通过构建一个实际的真实世界程序,我们将学习套接字编程实际工作的有用细节。

作为示例任务,我们将构建一个可以告诉你现在是什么时间的 Web 服务器。这对于任何需要知道现在时间的智能手机或网页浏览器的用户来说可能是一个有用的资源。他们只需导航到我们的网页,就可以找到答案。这是一个很好的入门示例,因为它做了一些有用的事情,但仍然足够简单,不会分散我们对网络编程的学习。

一个激励性的例子

在我们开始编写联网程序之前,先解决我们的简单控制台程序问题是有用的。一般来说,在添加联网功能之前,在本地解决程序的功能是一个好主意。

我们的时间显示程序的本地、控制台版本如下:

/*time_console.c*/

#include <stdio.h>
#include <time.h>

int main()
{
    time_t timer;
    time(&timer);

    printf ("Local time is: %s", ctime(&timer));

    return 0;
}

你可以像这样编译和运行它:

$ gcc time_console.c -o time_console
$ ./time_console
Local time is: Fri Oct 19 08:42:05 2018

该程序通过使用内置的 C time()函数获取时间,然后使用ctime()函数将其转换为字符串。

使其联网

现在我们已经确定了程序的功能,我们可以开始编写相同程序的联网版本。

首先,我们需要包含所需的头文件:

/*time_server.c*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

如我们之前讨论的,这检测编译器是否在 Windows 上运行,并为它运行的平台包含适当的头文件。

我们还定义了一些宏,这些宏抽象出了伯克利套接字和 Winsock API 之间的一些差异:

/*time_server.c continued*/

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

我们需要几个标准 C 头文件,希望原因很明显:

/*time_server.c continued*/

#include <stdio.h>
#include <string.h>
#include <time.h>

现在,我们准备开始编写main()函数。如果我们在 Windows 上编译,main()函数首先会初始化 Winsock:

/*time_server.c continued*/

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

我们现在必须确定我们的 Web 服务器应该绑定的本地地址:

/*time_server.c continued*/

    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(0, "8080", &hints, &bind_address);

我们使用getaddrinfo()将所需信息填充到struct addrinfo结构中。getaddrinfo()函数接受一个hints参数,它告诉它我们在寻找什么。在这种情况下,我们首先使用memset()hints清零。然后,我们设置ai_family = AF_INETAF_INET指定我们正在寻找一个 IPv4 地址。我们可以使用AF_INET6来使我们的 Web 服务器监听 IPv6 地址(关于这一点稍后讨论)。

接下来,我们设置ai_socktype = SOCK_STREAM。这表示我们将使用 TCP。如果我们正在做 UDP 服务器,将使用SOCK_DGRAM。最后,ai_flags = AI_PASSIVE被设置。这告诉getaddrinfo()我们希望它绑定到通配符地址。也就是说,我们要求getaddrinfo()设置地址,以便我们可以在任何可用的网络接口上监听。

一旦正确设置了hints,我们就声明一个指向struct addrinfo结构的指针,该结构保存了getaddrinfo()函数的返回信息。然后我们调用getaddrinfo()函数。getaddrinfo()函数有很多用途,但就我们的目的而言,它生成一个适合bind()的地址。为了使其生成这个地址,我们必须将第一个参数传递为NULL,并在hints.ai_flags中设置AI_PASSIVE标志。

getaddrinfo() 的第二个参数是我们监听连接的端口。标准的 HTTP 服务器会使用端口 80。然而,只有 Unix-like 操作系统上的特权用户才能绑定端口 01023。这里端口号的选择是任意的,但我们使用 8080 来避免问题。如果您以超级用户权限运行,如果您喜欢,可以随意将端口号更改为 80。请记住,一次只能有一个程序绑定到特定的端口。如果您尝试使用已被占用的端口,则 bind() 调用将失败。在这种情况下,只需将端口号更改为其他值并再次尝试即可。

常见的情况是程序在这里不使用 getaddrinfo()。相反,它们直接填充 struct addrinfo 结构。使用 getaddrinfo() 的优点是它是协议无关的。使用 getaddrinfo() 使得将我们的程序从 IPv4 转换为 IPv6 变得非常容易。实际上,我们只需要将 AF_INET 更改为 AF_INET6,我们的程序就可以在 IPv6 上运行。如果我们直接填充 struct addrinfo 结构,我们需要对程序进行许多繁琐的更改才能将其转换为 IPv6。

既然我们已经确定了我们的本地地址信息,我们就可以创建套接字:

/*time_server.c continued*/

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);

在这里,我们将 socket_listen 定义为 SOCKET 类型。回想一下,SOCKET 是 Windows 上的 Winsock 类型,而在其他平台上,我们使用宏将其定义为 int。我们调用 socket() 函数来生成实际的套接字。socket() 函数接受三个参数:套接字族、套接字类型和套接字协议。我们在调用 socket() 之前使用 getaddrinfo() 的原因是我们现在可以将 bind_address 的部分作为 socket() 的参数传递。再次强调,这使得在不进行重大重写的情况下更改我们程序的协议变得非常容易。

常见的情况是程序首先调用 socket()。这种做法的问题在于,它使得程序变得更加复杂,因为必须多次输入套接字族、类型和协议。像我们这里这样组织程序会更好。

我们应该检查 socket() 的调用是否成功:

/*time_server.c continued*/ 

   if (!ISVALIDSOCKET(socket_listen)) {
       fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

我们可以使用我们之前定义的 ISVALIDSOCKET() 宏来检查 socket_listen 是否有效。如果套接字无效,我们打印一条错误信息。我们的 GETSOCKETERRNO() 宏用于以跨平台的方式检索错误号。

在套接字成功创建之后,我们可以调用 bind() 来将其与 getaddrinfo() 中的地址关联:

/*time_server.c continued*/

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(bind_address);

bind() 在成功时返回 0,在失败时返回非零值。如果它失败了,我们会像处理 socket() 上的错误一样打印错误号。如果我们要绑定的端口已被占用,bind() 将失败。在这种情况下,要么关闭使用该端口的程序,要么更改您的程序以使用不同的端口。

在我们将地址绑定到 bind_address 之后,我们可以调用 freeaddrinfo() 函数来释放地址内存。

一旦创建并绑定到本地地址的套接字,我们可以使用listen()函数让它开始监听连接:

/*time_server.c continued*/

    printf("Listening...\n");
    if (listen(socket_listen, 10) < 0) {
        fprintf(stderr, "listen() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

listen()的第二个参数,在这个例子中是10,告诉listen()它可以允许多少个连接排队。如果许多客户端同时连接到我们的服务器,而我们处理它们不够快,那么操作系统开始排队这些传入的连接。如果10个连接排队,那么操作系统将拒绝新的连接,直到我们从现有队列中移除一个。

listen()的错误处理方式与我们对bind()socket()的处理方式相同。

在套接字开始监听连接后,我们可以使用accept()函数接受任何传入的连接:

/*time_server.c continued*/

    printf("Waiting for connection...\n");
    struct sockaddr_storage client_address;
    socklen_t client_len = sizeof(client_address);
    SOCKET socket_client = accept(socket_listen,
            (struct sockaddr*) &client_address, &client_len);
    if (!ISVALIDSOCKET(socket_client)) {
        fprintf(stderr, "accept() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

accept()有几个功能。首先,当它被调用时,它将阻塞你的程序,直到建立新的连接。换句话说,你的程序将休眠,直到连接到监听套接字。当建立新的连接时,accept()将为它创建一个新的套接字。你的原始套接字继续监听新的连接,但accept()返回的新套接字可以用来通过新建立的连接发送和接收数据。accept()还填充了连接客户端的地址信息。

在调用accept()之前,我们必须声明一个新的struct sockaddr_storage变量来存储连接客户端的地址信息。struct sockaddr_storage类型保证足够大,可以容纳系统上支持的最大地址。我们还必须告诉accept()我们传递的地址缓冲区的大小。当accept()返回时,它将用连接客户端的地址填充client_address,并用该地址的长度填充client_lenclient_len的值取决于连接是使用 IPv4 还是 IPv6。

我们将accept()的返回值存储在socket_client中。我们通过检测client_socket是否是一个有效的套接字来检查错误。这与我们对socket()的处理方式完全相同。

在这个阶段,已经与远程客户端建立了一个 TCP 连接。我们可以将客户端的地址打印到控制台:

/*time_server.c continued*/

    printf("Client is connected... ");
    char address_buffer[100];
    getnameinfo((struct sockaddr*)&client_address,
            client_len, address_buffer, sizeof(address_buffer), 0, 0,
            NI_NUMERICHOST);
    printf("%s\n", address_buffer);

这一步完全是可选的,但将网络连接记录在某个地方是一个好的实践。

getnameinfo()接受客户端的地址和地址长度。地址长度是必需的,因为getnameinfo()可以与 IPv4 和 IPv6 地址一起工作。然后我们传递一个输出缓冲区和缓冲区长度。这是getnameinfo()将主机名输出写入的缓冲区。接下来的两个参数指定第二个缓冲区和其长度。getnameinfo()将服务名输出到这个缓冲区。我们对此不感兴趣,所以为这两个参数传递了0。最后,我们传递了NI_NUMERICHOST标志,指定我们希望以 IP 地址的形式看到主机名。

由于我们正在编写一个 Web 服务器,我们期望客户端(例如,一个 Web 浏览器)发送给我们一个 HTTP 请求。我们使用 recv() 函数读取这个请求:

/*time_server.c continued*/

    printf("Reading request...\n");
    char request[1024];
    int bytes_received = recv(socket_client, request, 1024, 0);
    printf("Received %d bytes.\n", bytes_received);

我们定义了一个请求缓冲区,以便我们可以存储浏览器的 HTTP 请求。在这种情况下,我们分配了 1,024 字节给它,这对于这个应用程序来说应该足够了。然后使用客户端套接字、请求缓冲区和请求缓冲区大小调用 recv()recv() 返回接收到的字节数。如果还没有收到任何内容,recv() 将阻塞,直到有内容。如果连接被客户端终止,recv() 返回 0-1,具体取决于情况。在这里我们忽略这种情况以简化问题,但在生产环境中你应该始终检查 recv() > 0recv() 的最后一个参数是标志。由于我们不做任何特殊的事情,我们简单地传递 0

我们收到的客户请求应遵循正确的 HTTP 协议。我们将在第六章,构建简单的 Web 客户端,和第七章,构建简单的 Web 服务器中详细介绍 HTTP,在那里我们将处理 Web 客户端和服务器。一个真正的 Web 服务器需要解析请求并查看浏览器请求的是哪个资源。我们的 Web 服务器只有一个功能——告诉我们现在是什么时间。所以,现在我们只是完全忽略这个请求。

如果你想要将浏览器的请求打印到控制台,你可以这样做:

    printf("%.*s", bytes_received, request);

注意我们使用的是 printf() 格式字符串,"%.*s"。这告诉 printf() 我们想要打印特定数量的字符——bytes_received。尝试直接将 recv() 接收到的数据作为 C 字符串打印是一个常见的错误。从 recv() 接收到的数据没有保证是空终止的!如果你尝试用 printf(request)printf("%s", request) 打印它,你很可能会收到一个段错误错误(或者最多打印一些垃圾)。

现在,Web 浏览器已经发送了它的请求,我们可以发送我们的响应回:

/*time_server.c continued*/

    printf("Sending response...\n");
    const char *response =
        "HTTP/1.1 200 OK\r\n"
        "Connection: close\r\n"
        "Content-Type: text/plain\r\n\r\n"
        "Local time is: ";
    int bytes_sent = send(socket_client, response, strlen(response), 0);
    printf("Sent %d of %d bytes.\n", bytes_sent, (int)strlen(response));

首先,我们将 char *response 设置为一个标准的 HTTP 响应头和我们的消息的开始(Local time is:)。我们将在第六章,构建简单的 Web 客户端,和第七章,构建简单的 Web 服务器中详细讨论 HTTP。现在,知道这个响应告诉浏览器三件事——你的请求是有效的;服务器将在所有数据发送完毕后关闭连接,你接收到的数据将是纯文本。

HTTP 响应头以一个空白行结束。HTTP 要求行结束以回车字符后跟换行字符的形式出现。所以,我们响应中的空白行是 \r\n。空白行之后的字符串部分,Local time is:,被浏览器视为纯文本。

我们使用send()函数将数据发送到客户端。这个函数接受客户端的套接字、要发送的数据的指针以及要发送的数据长度。send()的最后一个参数是标志。我们不需要做任何特殊的事情,所以我们传入0

send()返回发送的字节数。你应该通常检查发送的字节数是否符合预期,如果不一致,你应该尝试发送剩余的数据。这里为了简单起见,我们忽略这个细节。(此外,我们只尝试发送几个字节;如果send()无法处理,那么可能非常严重的问题,重新发送也不会有帮助。)

在发送 HTTP 头和我们的消息的开始之后,我们可以发送实际的时间。我们以与time_console.c中相同的方式获取本地时间,并使用send()发送它:

/*time_server.c continued*/

    time_t timer;
    time(&timer);
    char *time_msg = ctime(&timer);
    bytes_sent = send(socket_client, time_msg, strlen(time_msg), 0);
    printf("Sent %d of %d bytes.\n", bytes_sent, (int)strlen(time_msg));

我们必须关闭客户端连接,以向浏览器表明我们已经发送了所有数据:

/*time_server.c continued*/

    printf("Closing connection...\n");
    CLOSESOCKET(socket_client);

如果我们不关闭连接,浏览器将等待更多数据,直到超时。

在这一点上,我们可以调用socket_listen上的accept()来接受额外的连接。这正是真实服务器会做的事情。然而,由于这是一个快速示例程序,我们将关闭监听套接字并终止程序:

/*time_server.c continued*/

    printf("Closing listening socket...\n");
    CLOSESOCKET(socket_listen);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");

    return 0;
}

这就是完整的程序。编译并运行它后,你可以使用网页浏览器导航到它,它将显示当前时间。

在 Linux 和 macOS 上,你可以这样编译和运行程序:

gcc time_server.c -o time_server
./time_server

在 Windows 上,你可以使用 MinGW 编译和运行,以下是一些命令:

gcc time_server.c -o time_server.exe -lws2_32
time_server

当你运行程序时,它会等待连接。你可以打开一个网页浏览器,导航到http://127.0.0.1:8080来加载网页。记住127.0.0.1是 IPv4 回环地址,它连接到运行程序的同台机器。URL 中的:8080部分指定了要连接的端口号。如果省略了这部分,你的浏览器将默认连接到端口80,这是 HTTP 连接的标准。

如果你编译并运行程序,然后在同一台计算机上使用网页浏览器连接到它,你应该看到以下内容:

这里是连接到我们time_server程序端口8080的网页浏览器:

使用 IPv6

请回忆一下在main()函数开头附近的time_server.c中的hints.ai_family = AF_INET部分。如果这一行改为hints.ai_family = AF_INET6,那么你的网络服务器将监听 IPv6 连接而不是 IPv4 连接。这个修改后的文件包含在 GitHub 仓库中,文件名为time_server_ipv6.c

在这种情况下,你应该使用你的网络浏览器导航到http://[::1]:8080以查看网页。::1是 IPv6 回环地址,它告诉网络浏览器连接到运行它的同一台机器。为了在 URL 中使用 IPv6 地址,你需要将它们放在方括号[]中。:8080指定端口号,这与我们为 IPv4 示例所做的方式相同。

这里是编译、运行并将网络浏览器连接到我们的time_server_ipv6程序时应看到的内容:

图片

这里是使用 IPv6 套接字连接到我们服务器的网络浏览器:

图片

查看time_server_ipv6.c以获取完整的程序。

支持 IPv4 和 IPv6

监听的 IPv6 套接字也可以接受使用双栈套接字的 IPv4 连接。并非所有操作系统都支持双栈套接字。特别是 Linux,不同发行版之间的支持情况各不相同。如果你的操作系统支持双栈套接字,那么我强烈建议使用此功能实现你的服务器程序。这允许你的程序与 IPv4 和 IPv6 对等方通信,而无需你进行额外的工作。

我们可以修改time_server_ipv6.c以使用双栈套接字,只需进行微小的添加。在调用socket()之后和调用bind()之前,我们必须清除套接字上的IPV6_V6ONLY标志。这是通过setsockopt()函数完成的:

/*time_server_dual.c excerpt*/

    int option = 0;
    if (setsockopt(socket_listen, IPPROTO_IPV6, IPV6_V6ONLY, (void*)&option, sizeof(option))) {
        fprintf(stderr, "setsockopt() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

我们首先将option声明为整数并将其设置为0IPV6_V6ONLY默认启用,所以我们通过将其设置为0来清除它。在监听套接字上调用setsockopt()。我们传入IPPROTO_IPV6来告诉它我们在操作套接字的哪个部分,并传入IPV6_V6ONLY来告诉它我们正在设置哪个标志。然后我们传入一个指向我们的选项的指针及其长度。setsockopt()在成功时返回0

Windows Vista 及以后的版本支持双栈套接字。然而,许多 Windows 头文件缺少IPV6_V6ONLY的定义。因此,在文件顶部包含以下代码片段可能是有意义的:

/*time_server_dual.c excerpt*/

#if !defined(IPV6_V6ONLY)
#define IPV6_V6ONLY 27
#endif

请记住,套接字最初需要被创建为 IPv6 套接字。这可以通过我们代码中的hints.ai_family = AF_INET6行来完成。

当一个 IPv4 对等方连接到我们的双栈服务器时,连接会被重映射到 IPv6 连接。这会自动发生,并由操作系统处理。当你的程序看到客户端 IP 地址时,它仍然会以一个特殊的 IPv6 地址呈现。这些地址由 IPv6 地址表示,其中前 96 位是前缀——0:0:0:0:0:ffff。地址的最后 32 位用于存储 IPv4 地址。例如,如果一个客户端使用 IPv4 地址192.168.2.107连接,那么你的双栈服务器会将其视为 IPv6 地址::ffff.192.168.2.107

这里是编译、运行并连接到time_server_dual时的样子:

图片

这是一个通过回环 IPv4 地址连接到我们的 time_server_dual 程序的网页浏览器:

图片

注意到浏览器正在导航到 IPv4 地址 127.0.0.1,但我们可以在控制台上看到服务器将连接视为来自 IPv6 地址 ::ffff:127.0.0.1

查看 time_server_dual.c 以获取完整的双栈套接字服务器。

使用 inetd 进行网络

在类 Unix 系统上,例如 Linux 或 macOS,可以使用名为 inetd 的服务将仅控制台应用程序转换为网络应用程序。您可以使用程序的位置、端口号、协议(TCP 或 UDP)以及您希望其运行的用户的配置 inetd(使用 /etc/inetd.conf)。然后 inetd 将在您指定的端口上监听连接。一旦 inetd 接受传入连接,它将启动您的程序并将所有套接字输入/输出通过 stdinstdout 重定向。

使用 inetd,我们可以将 time_console.c 的行为修改得像 time_server.c,只需进行非常小的改动。我们只需要添加一个额外的 printf() 函数用于 HTTP 响应头,从 stdin 读取,并配置 inetd

您可能可以通过 Cygwin 或 Windows Subsystem for Linux 在 Windows 上使用 inetd

摘要

在本章中,我们学习了使用套接字进行网络编程的基础。尽管伯克利套接字(用于类 Unix 操作系统)和 Winsock 套接字(用于 Windows)之间存在许多差异,但我们通过预处理语句来缓解这些差异。这样,我们就可以编写一个可以在 Windows、Linux 和 macOS 上干净编译的程序。

我们介绍了 UDP 协议是无连接的,以及这意味着什么。我们了解到,作为面向连接的协议,TCP 提供了一些可靠性保证,例如自动检测和重发丢失的数据包。我们还看到 UDP 常用于简单的协议(例如 DNS)和实时流媒体应用程序。TCP 用于大多数其他协议。

之后,我们通过将控制台应用程序转换为网络服务器来处理了一个真实示例。我们学习了如何使用 getaddrinfo() 函数编写程序,以及为什么这对于使程序 IPv4/IPv6 无关很重要。我们在服务器上使用 bind()listen()accept() 等待来自网络浏览器的传入连接。然后使用 recv() 从客户端读取数据,并使用 send() 发送回复。最后,我们使用 close()(在 Windows 上为 closesocket())终止连接。

当我们构建网络服务器 time_server.c 时,我们覆盖了很多内容。如果你没有完全理解也没有关系。我们将在第三章 An In-Depth Overview of TCP Connections 和本书的其余部分重新访问许多这些函数。

在下一章,第三章,TCP 连接的深入概述,我们将更深入地探讨 TCP 连接的编程。

问题

尝试这些问题来测试你对本章知识的掌握:

  1. 什么是套接字?

  2. 什么是无连接协议?什么是面向连接的协议?

  3. UDP 是无连接协议还是面向连接的协议?

  4. TCP 是无连接协议还是面向连接的协议?

  5. 哪些类型的应用通常从使用 UDP 协议中获益?

  6. 哪些类型的应用通常从使用 TCP 协议中获益?

  7. TCP 是否保证数据能够成功传输?

  8. 哪些是伯克利套接字和 Winsock 套接字之间的一些主要区别?

  9. bind()函数的作用是什么?

  10. accept()函数的作用是什么?

  11. 在 TCP 连接中,是客户端还是服务器首先发送应用数据?

答案在附录 A,问题答案中。

第三章:TCP 连接的深入概述

在第二章,掌握套接字 API中,我们实现了一个简单的 TCP 服务器,该服务器使用 HTTP 提供网页服务。在本章中,我们将首先实现一个 TCP 客户端。这个客户端能够与任何监听的 TCP 服务器建立 IPv4 或 IPv6 TCP 连接。它将是一个有用的调试工具,我们可以在本书的其余部分中重用它。

我们上一章的 TCP 服务器仅限于接受一个连接。在本章中,我们将探讨多路复用技术,以便我们的程序能够同时处理多个单独的连接。

本章涵盖了以下主题:

  • 使用getaddrinfo()配置远程地址

  • 使用connect()初始化 TCP 连接

  • 以非阻塞方式检测终端输入

  • 使用fork()进行多路复用

  • 使用select()进行多路复用

  • 检测对等端断开连接

  • 实现一个非常基本的微服务

  • TCP 的流式特性

  • send()的阻塞行为

技术要求

本章的示例程序可以使用任何现代 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。有关编译器设置的详细信息,请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器

本书代码可在本书的 GitHub 仓库中找到:github.com/codeplea/Hands-On-Network-Programming-with-C

从命令行,你可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap03

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要链接 Winsock 库。这可以通过将-lws2_32选项传递给gcc来实现。

我们提供了编译每个示例所需的精确命令,正如它被介绍的那样。

本章中的所有示例程序都需要我们在第二章,掌握套接字 API中开发的相同头文件和 C 宏。为了简洁起见,我们将这些语句放在一个单独的头文件chap03.h中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅第二章,掌握套接字 API

chap03.h的内容如下:

/*chap03.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <string.h>

多路复用 TCP 连接

默认情况下,套接字 API 是阻塞的。当你使用accept()等待传入连接时,你的程序执行将被阻塞,直到实际有新的传入连接可用。当你使用recv()读取传入数据时,你的程序执行将被阻塞,直到实际有新数据可用。

在上一章中,我们构建了一个简单的 TCP 服务器。这个服务器只接受一个连接,并且只从这个连接中读取一次数据。当时阻塞不是问题,因为我们的服务器除了为其唯一的客户端提供服务外,没有其他目的。

然而,在一般情况下,阻塞 I/O 可能是一个重大问题。想象一下,我们的服务器来自第二章,掌握套接字 API,需要为多个客户端提供服务。然后,想象一个慢速客户端连接到它。也许这个慢速客户端需要一分钟才能发送它的第一条数据。在这分钟内,我们的服务器会简单地等待recv()调用返回。如果其他客户端正在尝试连接,它们将不得不等待。

类似于在recv()上阻塞实际上是不可接受的。真实的应用程序通常需要能够同时管理多个连接。这在服务器端显然是正确的,因为大多数服务器都是构建来管理许多已连接客户端的。想象一下运行一个网站,其中数百个客户端同时连接。一次只服务一个客户端将是不可行的。

在客户端,阻塞通常也是不可接受的。如果你想象构建一个快速的网页浏览器,它需要能够并行下载许多图片、脚本和其他资源。现代网页浏览器还拥有一个标签页功能,其中许多整个网页可以并行加载。

我们需要一种同时处理多个单独连接的技术。

轮询非阻塞套接字

可以配置套接字使用非阻塞操作。一种方法是调用fcntl()并带有O_NONBLOCK标志(在 Windows 上使用ioctlsocket()并带有FIONBIO标志),尽管也存在其他方法。一旦进入非阻塞模式,对recv()的调用在没有数据的情况下会立即返回。有关更多信息,请参阅第十三章,套接字编程技巧和陷阱

以这种思路构建的程序可以简单地依次检查其每个活跃套接字,持续不断地进行。它会处理任何返回数据的套接字,并忽略任何没有数据的套接字。这被称为轮询。轮询可能会浪费计算机资源,因为大多数时候,没有数据可读。这也使得程序变得有些复杂,因为程序员需要手动跟踪哪些套接字是活跃的以及它们的状态。从recv()返回的值也必须与阻塞套接字的处理方式不同。

由于这些原因,我们在这本书中不会使用轮询。

分支和线程

另一种可能的解决方案是针对每个连接启动一个新的线程或进程。在这种情况下,阻塞套接字是可以接受的,因为它们只阻塞其服务线程/进程,不会阻塞其他线程/进程。这可能是一种有用的技术,但它也有一些缺点。首先,线程编程比较复杂。如果连接之间必须共享任何状态,这一点尤其正确。它也相对不太便携,因为每个操作系统都为这些功能提供了不同的 API。

在基于 Unix 的系统上,例如 Linux 和 macOS,启动一个新的进程非常简单。我们只需使用 fork() 函数。fork() 函数将正在执行的程序分割成两个独立的进程。一个多进程 TCP 服务器可以像这样接受连接:

while(1) {
    socket_client = accept(socket_listen, &new_client, &new_client_length);
    int pid = fork();
    if (pid == 0) { //child process
        close(socket_listen);
        recv(socket_client, ...);
        send(socket_client, ...);
        close(socket_client);
        exit(0);
    }
    //parent process
    close(socket_client);
}

在这个例子中,程序在 accept() 上阻塞。当建立新的连接时,程序调用 fork() 来分割成两个进程。子进程(pid == 0),只服务这个连接。因此,子进程可以自由使用 recv() 而不用担心阻塞。父进程只是在新连接上调用 close() 并返回,继续使用 accept() 监听更多连接。

在 Windows 上使用多个进程/线程要复杂得多。Windows 提供了 CreateProcess()CreateThread() 以及许多其他用于这些功能的函数。然而——我可以客观地说——它们的使用难度都比 Unix 的 fork() 函数要高。

与单进程情况相比,调试这些多进程/线程程序可能要困难得多。在套接字之间进行通信和管理共享状态也更加繁重。因此,我们将避免在本书的其余部分使用 fork() 和其他多进程/线程技术。

话虽如此,本章代码中包含了一个使用 fork() 的示例 TCP 服务器。它命名为 tcp_serve_toupper_fork.c。它不能在 Windows 上运行,但它应该在 Linux 和 macOS 上编译和干净地运行。我建议在查看它之前完成本章的其余部分。

select() 函数

我们首选的多路复用技术是使用 select() 函数。我们可以给 select() 提供一组套接字,它会告诉我们哪些套接字准备好读取。它还可以告诉我们哪些套接字准备好写入,以及哪些套接字有异常。此外,它由 Berkeley 套接字和 Winsock 都支持。使用 select() 可以保持我们的程序便携性。

使用 select() 进行同步多路复用

select() 函数有几个有用的特性。给定一组套接字,它可以用来阻塞,直到该组中的某个套接字准备好读取。它还可以配置为在套接字准备好写入或套接字出现错误时返回。此外,我们还可以配置 select() 在没有这些事件发生的情况下,在指定时间后返回。

select() 函数的 C 函数原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds,
    fd_set *exceptfds, struct timeval *timeout);

在调用 select() 之前,我们必须首先将我们的套接字添加到一个 fd_set 中。如果我们有三个套接字,socket_listensocket_asocket_b,我们将它们添加到一个 fd_set 中,如下所示:

fd_set our_sockets;
FD_ZERO(&our_sockets);
FD_SET(socket_listen, &our_sockets);
FD_SET(socket_a, &our_sockets);
FD_SET(socket_b, &our_sockets);

在使用之前,非常重要的一点是使用 FD_ZERO()fd_set 清零。

然后使用 FD_SET() 逐个将套接字描述符添加到 fd_set 中。可以使用 FD_CLR()fd_set 中移除一个套接字,并且我们可以使用 FD_ISSET() 检查套接字是否存在于集合中。

你可能会看到一些程序直接操作 fd_set。我建议你只使用 FD_ZERO()FD_SET()FD_CLR()FD_ISSET() 来保持伯克利套接字和 Winsock 之间的可移植性。

select() 还要求我们传递一个比我们要监控的最大套接字描述符更大的数字。(在 Windows 上,此参数被忽略,但我们仍然会这样做以提高可移植性。)我们将最大的套接字描述符存储在一个变量中,如下所示:

SOCKET max_socket;
max_socket = socket_listen;
if (socket_a > max_socket) max_socket = socket_a;
if (socket_b > max_socket) max_socket = socket_b;

当我们调用 select() 时,它会修改我们的套接字 fd_set 来指示哪些套接字已准备好。因此,我们在调用它之前想要复制我们的套接字集。我们可以通过简单的赋值来复制一个 fd_set,然后像这样调用 select()

fd_set copy;
copy = our_sockets;

select(max_socket+1, &copy, 0, 0, 0);

这个调用会阻塞,直到至少有一个套接字准备好读取。当 select() 返回时,copy 被修改,使其只包含准备好读取的套接字。我们可以使用 FD_ISSET() 检查哪些套接字仍然在 copy 中,如下所示:

if (FD_ISSET(socket_listen, &copy)) {
    //socket_listen has a new connection
    accept(socket_listen...
}

if (FD_ISSET(socket_a, &copy)) {
    //socket_a is ready to be read from
    recv(socket_a...
}

if (FD_ISSET(socket_b, &copy)) {
    //socket_b is ready to be read from
    recv(socket_b...
}

在前面的例子中,我们将我们的 fd_set 作为 select() 的第二个参数传递。如果我们想监控 fd_set 的可写性而不是可读性,我们将 fd_set 作为 select() 的第三个参数传递。同样,我们可以通过将 fd_set 作为 select() 的第四个参数传递来监控套接字集合的异常。

select() 超时

select() 的最后一个参数允许我们指定一个超时时间。它期望一个指向 struct timeval 的指针。timeval 结构体声明如下:

struct timeval {
    long tv_sec;
    long tv_usec;
}

tv_sec 包含秒数,tv_usec 包含微秒数(1/1,000,000 秒)。如果我们想让 select() 等待最多 1.5 秒,我们可以这样调用它:

struct timeval timeout;
timeout.tv_sec = 1;
timeout.tv_usec = 500000;
select(max_socket+1, &copy, 0, 0, &timeout);

在这种情况下,select()fd_set copy 中的套接字准备好读取或经过 1.5 秒后返回,以先到者为准。

如果 timeout.tv_sec = 0timeout.tv_usec = 0,则 select() 立即返回(在适当更改 fd_set 之后)。正如我们之前看到的,如果我们为超时参数传递一个空指针,那么 select() 不会返回,直到至少有一个套接字准备好读取。

select() 还可以用来监控可写套接字(我们可以调用 send() 而不会阻塞的套接字),以及带有异常的套接字。我们可以通过一个调用检查所有三个条件:

select(max_sockets+1, &ready_to_read, &ready_to_write, &excepted, &timeout);

成功时,select()自身返回它所监控的(最多)三个描述符集中的套接字描述符数量。如果在任何套接字可读/可写/异常之前超时,则返回值为零。select()返回-1以指示错误。

遍历 fd_set

我们可以使用简单的for循环遍历fd_set。本质上,我们从1开始,因为所有套接字描述符都是正数,然后继续到集合中已知的最大套接字描述符。对于每个可能的套接字描述符,我们只需使用FD_ISSET()来检查它是否在集合中。如果我们想为fd_set master中的每个套接字调用CLOSESOCKET(),我们可以这样做:

SOCKET i;
for (i = 1; i <= max_socket; ++i) {
    if (FD_ISSSET(i, &master)) {
        CLOSESOCKET(i);
    }
}

这可能看起来像是一种暴力方法,实际上确实如此。然而,这些是我们必须使用的工具。FD_ISSET()运行非常快,并且处理器在其它套接字操作上花费的时间可能会远远超过以这种方式遍历它们所花费的时间。尽管如此,你可能可以通过将你的套接字存储在数组或链表中来优化这个操作。除非你分析了你的代码并发现简单的for循环迭代是一个显著的瓶颈,否则我不建议你进行这种优化。

非套接字的 select()

在基于 Unix 的系统上,select()也可以用于文件和终端 I/O,这可以非常实用。不过,在 Windows 上则不行。Windows 只支持select()用于套接字。

TCP 客户端

拥有一个可以连接到任何 TCP 服务器的 TCP 客户端对我们来说将非常有用。这个 TCP 客户端将从命令行接收主机名(或 IP 地址)和端口号。它将尝试连接到该地址的 TCP 服务器。如果成功,它将把从该服务器接收到的数据中继到终端,并将终端输入的数据发送到服务器。它将继续,直到它被终止(使用Ctrl + C)或服务器关闭连接。

这对于学习如何编写 TCP 客户端程序非常有用,同时也有助于测试本书中开发的 TCP 服务器程序。

我们的基本程序流程看起来是这样的:

图片

我们的程序首先使用getaddrinfo()从命令行参数解析服务器地址。然后,通过调用socket()创建套接字。新套接字上调用connect()以连接到服务器。我们使用select()来监控套接字输入。select()在非 Windows 系统上还会监控终端/键盘输入。在 Windows 上,我们使用_kbhit()函数来检测终端输入。如果终端输入可用,我们使用send()通过套接字发送它。如果select()指示套接字数据可用,我们使用recv()读取它并将它显示到终端。这个select()循环会一直重复,直到套接字关闭。

TCP 客户端代码

我们通过包含本章开头打印的标题文件chap03.h来开始我们的 TCP 客户端。这个标题文件包含了我们进行跨平台网络所需的各个其他标题和宏:

/*tcp_client.c*/

#include "chap03.h"

在 Windows 上,我们还需要conio.h标题。这是必需的,因为_kbhit()函数,它通过指示终端输入是否等待来帮助我们。我们条件性地包含这个标题,如下所示:

/*tcp_client.c*/

#if defined(_WIN32)
#include <conio.h>
#endif

然后,我们可以开始main()函数并初始化 Winsock:

/*tcp_client.c*/

int main(int argc, char *argv[]) {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

我们希望我们的程序接受它应该连接的服务器的域名和端口号作为命令行参数。这使得我们的程序更加灵活。我们的程序会检查是否提供了这些命令行参数。如果没有,它将显示用法信息:

/*tcp_client.c*/

    if (argc < 3) {
        fprintf(stderr, "usage: tcp_client hostname port\n");
        return 1;
    }

argc包含我们可用的参数值数量。因为第一个参数总是我们的程序名,所以我们检查总共有至少三个参数。实际的值存储在argv[]中。

然后,我们使用这些值来配置远程地址以建立连接:

/*tcp_client.c*/

    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_STREAM;
    struct addrinfo *peer_address;
    if (getaddrinfo(argv[1], argv[2], &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

这与我们在第二章,“掌握套接字 API”中调用getaddrinfo()的方式相似。然而,在第二章,“掌握套接字 API”中,我们希望它配置本地地址,而这次,我们希望它配置远程地址。

我们将hints.ai_socktype = SOCK_STREAM设置为告诉getaddrinfo()我们想要一个 TCP 连接。记住,我们可以将SOCK_DGRAM设置为指示 UDP 连接。

在第二章,“掌握套接字 API”,我们也设置了族。在这里我们不需要设置族,因为我们可以让getaddrinfo()决定使用 IPv4 还是 IPv6 作为合适的协议。

对于getaddrinfo()本身的调用,我们传入主机名和端口号作为前两个参数。这些参数直接从命令行传入。如果它们不合适,则getaddrinfo()返回非零值,并打印错误信息。如果一切顺利,则远程地址存储在peer_address变量中。

getaddrinfo()在接收输入方面非常灵活。主机名可以是域名,如example.com,或 IP 地址,如192.168.17.23::1。端口号可以是数字,如80,或协议,如http

getaddrinfo()配置远程地址后,我们将其打印出来。这实际上并不是必需的,但这是一个很好的调试措施。我们使用getnameinfo()将地址转换回字符串,如下所示:

/*tcp_client.c*/

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(peer_address->ai_addr, peer_address->ai_addrlen,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST);
    printf("%s %s\n", address_buffer, service_buffer);

我们可以创建我们的套接字:

/*tcp_client.c*/

    printf("Creating socket...\n");
    SOCKET socket_peer;
    socket_peer = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_peer)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

这个对socket()的调用与第二章,“掌握套接字 API”中的调用方式完全相同。我们使用peer_address来设置正确的套接字族和协议。这使得我们的程序非常灵活,因为socket()调用会根据需要创建 IPv4 或 IPv6 套接字。

在套接字创建之后,我们调用connect()来与远程服务器建立连接:

/*tcp_client.c */

    printf("Connecting...\n");
    if (connect(socket_peer,
                peer_address->ai_addr, peer_address->ai_addrlen)) {
        fprintf(stderr, "connect() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(peer_address);

connect()函数接受三个参数——套接字、远程地址和远程地址长度。成功时返回0,因此如果它返回非零值,我们打印一条错误消息。这个connect()调用与我们在第二章,《掌握 Socket API》中调用bind()的方式极为相似。其中bind()将套接字与本地地址关联,而connect()将套接字与远程地址关联并初始化 TCP 连接。

在使用peer_address调用connect()之后,我们使用freeaddrinfo()函数释放peer_address的内存。

如果我们已经到达这里,那么已经与远程服务器建立了 TCP 连接。我们通过打印一条消息和发送数据的说明来通知用户:

/*tcp_client.c */

    printf("Connected.\n");
    printf("To send data, enter text followed by enter.\n");

我们程序现在应该循环检查终端和套接字是否有新数据。如果终端有新数据,我们通过套接字发送它。如果从套接字读取到新数据,我们将其打印到终端。

很明显,我们在这里不能直接调用recv()。如果我们这样做,它将阻塞,直到从套接字接收数据。在此期间,如果我们的用户在终端输入数据,该输入将被忽略。相反,我们使用select()。我们开始循环并设置对select()的调用,如下所示:

/*tcp_client.c */

    while(1) {

        fd_set reads;
        FD_ZERO(&reads);
        FD_SET(socket_peer, &reads);
#if !defined(_WIN32)
        FD_SET(0, &reads);
#endif

        struct timeval timeout;
        timeout.tv_sec = 0;
        timeout.tv_usec = 100000;

        if (select(socket_peer+1, &reads, 0, 0, &timeout) < 0) {
            fprintf(stderr, "select() failed. (%d)\n", GETSOCKETERRNO());
            return 1;
        }

首先,我们声明一个变量,fd_set reads,用于存储我们的套接字集合。然后我们使用FD_ZERO()将其清零,并添加我们的唯一套接字,socket_peer

在非 Windows 系统上,我们同样使用select()来监控终端输入。我们通过FD_SET(0, &reads)stdin添加到reads集合中。这是因为0stdin的文件描述符。或者,我们也可以使用FD_SET(fileno(stdin), &reads)达到相同的效果。

Windows 的select()函数仅在套接字上工作。因此,我们无法使用select()来监控控制台输入。因此,我们将select()调用的超时设置为 100 毫秒(100,000 微秒)。如果在 100 毫秒后没有套接字活动,select()返回,我们可以手动检查终端输入。

select()函数返回后,我们检查我们的套接字是否被设置为reads。如果是,那么我们知道需要调用recv()来读取新数据。新数据通过printf()打印到控制台:

/*tcp_client.c*/

        if (FD_ISSET(socket_peer, &reads)) {
            char read[4096];
            int bytes_received = recv(socket_peer, read, 4096, 0);
            if (bytes_received < 1) {
                printf("Connection closed by peer.\n");
                break;
            }
            printf("Received (%d bytes): %.*s",
                    bytes_received, bytes_received, read);
        }

记住,recv()返回的数据不是以空字符终止的。因此,我们使用%.*s printf()格式说明符,它打印指定长度的字符串。

recv()通常返回读取的字节数。如果它返回小于1,则表示连接已结束,我们跳出循环以关闭它。

在检查新的 TCP 数据之后,我们还需要检查终端输入:

/*tcp_client.c */

#if defined(_WIN32)
        if(_kbhit()) {
#else
        if(FD_ISSET(0, &reads)) {
#endif
            char read[4096];
            if (!fgets(read, 4096, stdin)) break;
            printf("Sending: %s", read);
            int bytes_sent = send(socket_peer, read, strlen(read), 0);
            printf("Sent %d bytes.\n", bytes_sent);
        }

在 Windows 上,我们使用 _kbhit() 函数来指示是否有任何控制台输入等待。如果有一个未处理的关键事件被排队,则 _kbhit() 返回非零值。对于基于 Unix 的系统,我们只需检查 select() 是否设置了 stdin 文件描述符,即 0。如果输入准备就绪,我们调用 fgets() 来读取下一行输入。然后,通过 send() 将此输入发送到我们的连接套接字。

注意,fgets() 包含输入中的换行符。因此,我们发送的输入总是以换行符结束。

如果套接字已关闭,send() 返回 -1。在这里我们忽略这种情况。这是因为关闭的套接字会导致 select() 立即返回,我们在下一次调用 recv() 时注意到关闭的套接字。这在 TCP 套接字编程中是一个常见的范式,即在 send() 上忽略错误,而在 recv() 上检测和处理它们。这允许我们将连接关闭逻辑全部放在一个地方,从而简化我们的程序。在本章的后面部分,我们将讨论关于 send() 的其他一些问题。

基于 select() 的终端监控在基于 Unix 的系统上工作得非常好。如果输入是通过管道传入的,它也工作得同样好。例如,您可以使用我们的 TCP 客户端程序通过以下命令发送一个文本文件:cat my_file.txt | tcp_client 192.168.54.122 8080

Windows 终端处理还有待提高。Windows 不提供一种在不阻塞的情况下轻松判断 stdin 是否有输入可用的方法,所以我们使用 _kbhit() 作为一种较差的代理。然而,如果用户按下不可打印的键,例如箭头键,它仍然会触发 _kbhit(),即使没有可读的字符。此外,在第一次按键后,我们的程序将在 fgets() 上阻塞,直到用户按下 Enter 键。(在 Windows 外部,通常在 shell 中缓冲整行,这种情况不会发生。)这种阻塞行为是可以接受的,但您应该知道,任何接收到的 TCP 数据都将在此点之后才显示出来。_kbhit() 不适用于管道输入。当然,在 Windows 上进行适当的管道和控制台输入是可能的,但这非常复杂。

我们需要为每个(PeekNamedPipe()PeekConsoleInput())使用单独的函数,并且处理它们的逻辑将和整个程序一样长!由于处理终端输入不是本书的目的,我们将接受 _kbhit() 函数的限制并继续前进。

到目前为止,我们的程序基本上已经完成。我们可以结束 while 循环,关闭我们的套接字,并清理 Winsock:

/*tcp_client.c */

    }

    printf("Closing socket...\n");
    CLOSESOCKET(socket_peer);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

这就是完整的程序。您可以在 Linux 和 macOS 上像这样编译它:

gcc tcp_client.c -o tcp_client

在 Windows 上使用 MinGW 编译是这样做的:

gcc tcp_client.c -o tcp_client.exe -lws2_32

要运行程序,请记住传递远程主机名/地址和端口号,例如:

tcp_client example.com 80

或者,您可以使用以下命令:

tcp_client 127.0.0.1 8080

测试 TCP 客户端的一个有趣方法是连接到一个实时 Web 服务器并发送一个 HTTP 请求。例如,您可以在端口 80 上连接到 example.com 并发送以下 HTTP 请求:

GET / HTTP/1.1
Host: example.com

然后你必须发送一个空白行来表示请求的结束。你将收到一个 HTTP 响应。它可能看起来像这样:

一个 TCP 服务器

近年来,微服务变得越来越流行。微服务的想法是将大型编程问题分解成许多小的子系统,这些子系统通过网络进行通信。例如,如果你的程序需要格式化字符串,你可以在程序中添加代码来做这件事,但编写代码是困难的。或者,你可以保持程序简单,并连接到一个为你提供字符串格式化的服务。这还有一个额外的优点,即许多程序可以使用这个相同的服务,而无需重新发明轮子。

不幸的是,微服务范式在很大程度上避开了 C 生态系统;直到现在!

作为激励性的例子,我们将构建一个将字符串转换为大写的 TCP 服务器。如果客户端连接并发送 Hello,那么我们的程序将发送 HELLO 回去。这将作为一个非常基础的微服务。当然,现实世界的微服务可能会做些更高级的事情(比如左填充字符串),但这个将字符串转换为大写的服务非常适合我们的教学目的。

为了使我们的微服务变得有用,它确实需要处理许多同时传入的连接。我们再次使用 select() 来查看哪些连接需要服务。

我们的基本程序流程如下:

就像在第二章,掌握 Socket API中一样,我们的 TCP 服务器使用 getaddrinfo() 获取要监听的本地地址。它使用 socket() 创建一个套接字,使用 bind() 将本地地址关联到套接字,并使用 listen() 开始监听新的连接。直到那时,它与第二章,掌握 Socket API中的我们的 TCP 服务器基本相同。

然而,我们的下一步不是调用 accept() 来等待新的连接。相反,我们调用 select(),它会通知我们是否有新的连接可用,或者我们的任何已建立连接是否有新数据准备好。只有当我们知道有新的连接等待时,我们才调用 accept()。所有已建立的连接都放入一个 fd_set 中,该集合被传递给每个后续的 select() 调用。以同样的方式,我们知道哪些连接会在 recv() 上阻塞,我们只为那些我们知道不会阻塞的连接提供服务。

当数据通过 recv() 接收时,我们通过 toupper() 处理它,并使用 send() 将其返回给客户端。

这是一个包含许多新概念的复杂程序。现在不必担心理解所有细节。这个流程只是为了在我们深入研究实际代码之前,给你一个预期的概览。

TCP 服务器代码

我们的 TCP 服务器代码首先包含所需的头文件,开始 main(),并初始化 Winsock。如果这看起来不熟悉,请参阅 第二章,《掌握套接字 API》:

/*tcp_serve_toupper.c*/

#include "chap03.h"
#include <ctype.h>

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后,我们获取本地地址,创建套接字,并执行 bind()。这一切都按照 第二章,《掌握套接字 API》中所述的方式进行:

/*tcp_serve_toupper.c */

    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(0, "8080", &hints, &bind_address);

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_listen)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

注意,我们将监听端口 8080。当然,您可以更改它。我们这里也在做 IPv4 服务器。如果您想监听 IPv6 连接,只需将 AF_INET 更改为 AF_INET6

然后,我们将套接字绑定到本地地址,并使其进入监听状态。同样,这完全按照 第二章,《掌握套接字 API》中所述的方式进行:

/*tcp_serve_toupper.c*/

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(bind_address);

    printf("Listening...\n");
    if (listen(socket_listen, 10) < 0) {
        fprintf(stderr, "listen() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

这是我们与之前方法的分歧点。我们现在定义一个 fd_set 结构,用于存储所有活动套接字。我们还维护一个 max_socket 变量,它保存最大的套接字描述符。目前,我们只将监听套接字添加到集合中。因为它是最唯一的套接字,所以它也必须是最大的,因此我们将 max_socket = socket_listen 也设置好:

/*tcp_serve_toupper.c */

    fd_set master;
    FD_ZERO(&master);
    FD_SET(socket_listen, &master);
    SOCKET max_socket = socket_listen;

在程序稍后部分,我们将随着新连接的建立将它们添加到 master

然后,我们打印状态消息,进入主循环,并设置我们的 select() 调用:

/*tcp_serve_toupper.c */

    printf("Waiting for connections...\n");

    while(1) {
        fd_set reads;
        reads = master;
        if (select(max_socket+1, &reads, 0, 0, 0) < 0) {
            fprintf(stderr, "select() failed. (%d)\n", GETSOCKETERRNO());
            return 1;
        }

这是通过首先将我们的 fd_set master 复制到 reads 中来实现的。记住,select() 会修改它所提供的集合。如果我们没有复制 master,我们就会丢失其数据。

我们将 0(NULL)作为超时值传递给 select(),这样它就不会在 master 集合中的套接字准备好读取之前返回。在程序开始时,master 只包含 socket_listen,但随着程序的运行,我们会将每个新的连接添加到 master

现在,我们遍历每个可能的套接字,查看它是否被 select() 标记为已准备好。如果一个套接字 X 被标记为 select(),则 FD_ISSET(X, &reads) 为真。套接字描述符是正整数,因此我们可以尝试从 max_socketsocket_descriptor 的每个可能的套接字描述符。我们的循环的基本结构如下:

/*tcp_serve_toupper.c */

        SOCKET i;
        for(i = 1; i <= max_socket; ++i) {
            if (FD_ISSET(i, &reads)) {
                //Handle socket
            }
        }

记住,FD_ISSET() 只对准备好读取的套接字为真。在 socket_listen 的情况下,这意味着一个新连接准备好通过 accept() 建立连接。对于所有其他套接字,这意味着数据准备好通过 recv() 读取。我们首先确定当前套接字是否是监听套接字。如果是,我们调用 accept()。此代码片段和随后的代码替换了前面代码中的 //Handle socket 注释:

/*tcp_serve_toupper.c */

                if (i == socket_listen) {
                    struct sockaddr_storage client_address;
                    socklen_t client_len = sizeof(client_address);
                    SOCKET socket_client = accept(socket_listen,
                            (struct sockaddr*) &client_address,
                            &client_len);
                    if (!ISVALIDSOCKET(socket_client)) {
                        fprintf(stderr, "accept() failed. (%d)\n",
                                GETSOCKETERRNO());
                        return 1;
                    }

                    FD_SET(socket_client, &master);
                    if (socket_client > max_socket)
                        max_socket = socket_client;

                    char address_buffer[100];
                    getnameinfo((struct sockaddr*)&client_address,
                            client_len,
                            address_buffer, sizeof(address_buffer), 0, 0,
                            NI_NUMERICHOST);
                    printf("New connection from %s\n", address_buffer);

如果套接字是 socket_listen,那么我们就像在 第二章 中所做的那样 accept() 连接。我们使用 FD_SET() 将新连接的套接字添加到 master 套接字集合中。这允许我们通过后续的 select() 调用来监控它。我们还维护 max_socket。作为最后一步,此代码使用 getnameinfo() 打印出客户端的地址。

如果套接字 i 不是 socket_listen,那么它就变成了对一个已建立连接的请求。在这种情况下,我们需要使用 recv() 来读取它,使用内置的 toupper() 函数将其转换为大写,并将数据发送回去:

/*tcp_serve_toupper.c */

                } else {
                    char read[1024];
                    int bytes_received = recv(i, read, 1024, 0);
                    if (bytes_received < 1) {
                        FD_CLR(i, &master);
                        CLOSESOCKET(i);
                        continue;
                    }

                    int j;
                    for (j = 0; j < bytes_received; ++j)
                        read[j] = toupper(read[j]);
                    send(i, read, bytes_received, 0);
                }

如果客户端断开连接,那么 recv() 返回一个非正数。在这种情况下,我们从 master 套接字集合中删除该套接字,并且我们还调用 CLOSESOCKET() 来清理它。

我们程序现在几乎完成了。我们可以结束 if FD_ISSET() 语句,结束 for 循环,结束 while 循环,关闭监听套接字,并清理 Winsock:

/*tcp_serve_toupper.c */

            } //if FD_ISSET
        } //for i to max_socket
    } //while(1)

    printf("Closing listening socket...\n");
    CLOSESOCKET(socket_listen);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

我们程序被设置为持续监听连接,所以 while 循环结束后的代码永远不会运行。尽管如此,我相信将其包含在内仍然是良好的实践,以防我们以后需要添加终止 while 循环的功能。

这就是完整的将小写转换为大写的微服务 TCP 服务器程序。您可以在 Linux 和 macOS 上编译并运行它,如下所示:

gcc tcp_serve_toupper.c -o tcp_serve_toupper
./tcp_serve_toupper

使用 MinGW 在 Windows 上编译和运行的方法如下:

gcc tcp_serve_toupper.c -o tcp_serve_toupper.exe -lws2_32
tcp_serve_toupper.exe

您可以使用 Ctrl + C 来终止程序的执行。

一旦程序开始运行,我建议打开另一个终端,并运行之前提到的 tcp_client 程序来连接到它:

tcp_client 127.0.0.1 8080

tcp_client 中输入的任何内容都应该以大写形式发送回去。这可能看起来是这样的:

为了测试服务器程序的功能,请尝试打开几个额外的终端,并使用 tcp_client 进行连接。我们的服务器应该能够处理多个并发连接。

本章的代码中还包含了 tcp_serve_toupper_fork.c。这个程序仅在基于 Unix 的操作系统上运行,但它通过使用 fork() 而不是 select() 来执行与 tcp_serve_toupper.c 相同的功能。fork() 函数通常由 TCP 服务器使用,因此我认为熟悉它是很有帮助的。

构建聊天室

还有可能,并且很常见,需要在连接的客户端之间发送数据。我们可以修改我们的 tcp_serve_toupper.c 程序,并很容易地将其变成一个聊天室。

首先,在 tcp_serve_toupper.c 中找到以下代码:

/*tcp_serve_toupper.c excerpt*/

                    int j;
                    for (j = 0; j < bytes_received; ++j)
                        read[j] = toupper(read[j]);
                    send(i, read, bytes_received, 0);

将前面的代码替换为以下代码:

/*tcp_serve_chat.c excerpt*/

                    SOCKET j;
                    for (j = 1; j <= max_socket; ++j) {
                        if (FD_ISSET(j, &master)) {
                            if (j == socket_listen || j == i)
                                continue;
                            else
                                send(j, read, bytes_received, 0);
                        }
                    }

这是通过遍历 master 集合中的所有套接字来实现的。对于每个套接字 j,我们检查它不是监听套接字,并且它不是最初发送数据的同一个套接字。如果不是,我们调用 send() 将接收到的数据回显到它。

你可以像上一个程序一样编译和运行此程序。

在 Linux 和 macOS 上,操作如下:

gcc tcp_serve_chat.c -o tcp_serve_chat
./tcp_serve_chat

在 Windows 上,操作如下:

gcc tcp_serve_chat.c -o tcp_serve_chat.exe -lws2_32
tcp_serve_chat.exe

你应该打开两个或更多额外的窗口,并使用以下代码连接到它:

tcp_client 127.0.0.1 8080

你在任何一个 tcp_client 终端中输入的内容都会发送到所有其他已连接的终端。

下面是一个示例,说明这可能看起来像什么:

在前面的屏幕截图中,我在左上角的终端窗口中运行 tcp_serve_chat。其他三个终端窗口正在运行 tcp_client。正如你所看到的,在任何一个 tcp_client 窗口中输入的任何文本都会发送到服务器,服务器将其转发给其他两个已连接的客户端。

在发送时阻塞

当我们用一定量的数据调用 send() 时,send() 首先将这些数据复制到操作系统提供的输出缓冲区中。如果我们在其输出缓冲区已满时调用 send(),它将阻塞,直到其缓冲区空出足够的空间以接受更多数据。

在某些情况下,如果 send() 会阻塞,它将返回而不复制所有请求的数据。在这种情况下,send() 的返回值指示实际复制的字节数。一个例子是,如果你的程序在 send() 上阻塞,然后从操作系统接收信号。在这些情况下,调用者需要尝试再次使用剩余的数据。

在本章的 TCP 服务器代码 部分,我们忽略了 send() 可能会阻塞或被中断的可能性。在一个完全健壮的应用程序中,我们需要做的是比较 send() 的返回值与我们尝试发送的字节数。如果实际发送的字节数少于请求的,我们应该使用 select() 确定套接字何时准备好接受新数据,然后使用剩余的数据调用 send()。正如你可以想象的那样,当跟踪多个套接字时,这可能会变得相当复杂。

由于操作系统通常提供足够大的输出缓冲区,我们能够通过我们早期的服务器代码避免这种情况。如果我们知道我们的服务器可能会尝试发送大量数据,我们绝对应该检查 send() 的返回值。

以下代码示例假设 buffer 包含要发送到名为 peer_socket 的套接字的数据,buffer_len 字节。此代码将阻塞,直到我们发送了所有 buffer 或发生错误(例如对等方断开连接):

int begin = 0;
while (begin < buffer_len) {
    int sent = send(peer_socket, buffer + begin, buffer_len - begin, 0);
    if (sent == -1) {
        //Handle error
    }
    begin += sent;
}

如果我们正在管理多个套接字并且不想阻塞,那么我们应该将所有具有挂起 send() 的套接字放入一个 fd_set 中,并将其作为 select() 的第三个参数传递。当 select() 在这些套接字上发出信号时,我们知道它们已准备好发送更多数据。

第十三章,套接字编程技巧与陷阱,更详细地讨论了 send() 函数的阻塞行为。

TCP 是一种流协议

初学者常犯的一个错误是认为传递给 send() 的任何数据都可以以相同的数量在另一端的 recv() 中读取。实际上,发送数据类似于从文件中写入和读取。如果我们向文件写入 10 个字节,然后又写入另外 10 个字节,那么文件就有 20 个字节的数据。如果稍后要读取文件,我们可以读取 5 个字节和 15 个字节,或者一次性读取所有 20 个字节,等等。在任何情况下,我们都没有办法知道文件是分两次写入的,每次 10 个字节。

使用 send()recv() 的方式是一样的。如果你发送了 20 个字节,无法知道这些字节被分成了多少个 recv() 调用。有可能一次 recv() 调用就返回了所有 20 个字节,但也有可能第一次 recv() 调用返回了 16 个字节,而需要第二次 recv() 调用来获取最后的 4 个字节。

这可能会使通信变得困难。在许多协议中,正如我们将在本书后面的章节中看到的,接收到的数据需要缓冲起来,直到积累到足够的数据以进行处理。在我们的将小写转换为大写的服务器中,我们避免了这个问题,我们定义了一个协议,它在处理 1 个字节和 100 个字节时效果一样好。这并不是大多数应用协议的情况。

以一个具体的例子来说明,假设我们想让我们的 tcp_serve_toupper 服务器在通过 TCP 套接字接收到 quit 命令时终止。你可以在客户端调用 send(socket, "quit", 4, 0),你可能认为服务器上的 recv() 调用会返回 quit。确实,在你的测试中,这种方式很可能奏效。然而,这种行为并不保证。recv() 调用同样可能只返回 qui,可能需要第二次 recv() 调用来接收最后的 t 字符。如果是这样,考虑一下你将如何解释是否接收到 quit 命令。直接的方法是将从多个 recv() 调用接收到的数据缓冲起来。

我们将在本书的 第二部分,应用层协议概述中介绍处理 recv() 缓冲的技术。

与 TCP 相比,UDP 不是一个流协议。使用 UDP,接收到的数据包与发送时具有完全相同的内容。这有时会使处理 UDP 变得更容易,正如我们将在第四章中看到,建立 UDP 连接

摘要

TCP 实际上是现代互联网体验的骨干。TCP 被用于 HTTP 协议,这是驱动网站运行的协议,以及用于电子邮件的 简单邮件传输协议SMTP)。

在本章中,我们了解到构建 TCP 客户端相当直接。唯一真正棘手的部分是客户端在同时监控套接字数据时还要监控本地终端输入。我们能够在基于 Unix 的系统上通过select()实现这一点,但在 Windows 上则稍微复杂一些。许多实际应用不需要监控终端输入,因此这一步并不总是必要的。

构建适合许多并行连接的 TCP 服务器并没有难多少。在这里,select()非常有用,因为它允许以直接的方式监控监听套接字以寻找新连接,同时监控现有连接以寻找新数据。

我们还简要地提到了一些常见的问题点。TCP 不提供一种原生的数据分区方式。对于需要这种功能的更复杂的协议,我们必须从recv()缓冲数据,直到有足够的数据量可以解释。对于处理大量数据的 TCP 对等方,向send()缓冲也是必要的。

下一章,第四章,建立 UDP 连接,全部关于 UDP,它是 TCP 的对立面。在某些方面,UDP 编程比 TCP 编程简单,但它也非常不同。

问题

尝试回答这些问题以测试你对本章知识的掌握:

  1. 我们如何判断下一次调用recv()是否会阻塞?

  2. 你如何确保select()不会阻塞超过指定的时间?

  3. 当我们使用我们的tcp_client程序连接到 Web 服务器时,为什么需要在 Web 服务器响应之前发送一个空白行?

  4. send()是否会阻塞?

  5. 我们如何判断套接字是否被我们的对等方断开连接?

  6. 通过recv()接收到的数据是否总是与通过send()发送的数据大小相同?

  7. 考虑以下代码:

recv(socket_peer, buffer, 4096, 0);
printf(buffer);

它有什么问题?

还请查看以下代码中存在的问题:

recv(socket_peer, buffer, 4096, 0);
printf("%s", buffer);

答案可以在附录 A 中找到,问题答案

第四章:建立 UDP 连接

在本章中,我们将探讨如何发送和接收用户数据报协议UDP)数据包。UDP 套接字编程与传输控制协议TCP)套接字编程非常相似,因此建议你在开始本章之前阅读并理解第三章,“TCP 连接的深入概述”。

本章涵盖了以下主题:

  • TCP 套接字编程与 UDP 套接字编程之间的区别

  • sendto()recvfrom()函数

  • connect()在 UDP 套接字上的工作方式

  • 仅使用一个套接字实现 UDP 服务器

  • 使用select()来告知何时 UDP 套接字有数据准备好

技术要求

本章的示例程序可以用任何现代 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。有关编译器的设置,请参阅附录 B,“在 Windows 上设置您的 C 编译器”,附录 C,“在 Linux 上设置您的 C 编译器”,和附录 D,“在 macOS 上设置您的 C 编译器”。

本书代码可在本书的 GitHub 仓库中找到:github.com/codeplea/Hands-On-Network-Programming-with-C

您可以使用以下命令从命令行下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap04

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要与 Winsock 库链接。这可以通过将-lws2_32选项传递给gcc来实现。

我们在介绍每个示例时都提供了编译每个示例所需的精确命令。

本章中所有示例程序都需要与我们在第二章,“掌握 Socket API”中开发的相同头文件和 C 宏。为了简洁,我们将这些语句放在一个单独的头文件chap04.h中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅第二章,“掌握 Socket API”。

chap04.h的内容如下所示:

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <string.h>

UDP 套接字的区别

UDP 套接字的 socket API 与我们之前学习的 TCP 套接字 API 只有非常细微的差别。实际上,它们足够相似,以至于我们可以将上一章的 TCP 客户端代码仅通过更改一行代码就转换成一个完全功能的 UDP 客户端:

  1. 从第三章,“TCP 连接的深入概述”中获取tcp_client.c,并找到以下代码行:
hints.ai_socktype = SOCK_STREAM;
  1. 将前面的代码更改为以下内容:
hints.ai_socktype = SOCK_DGRAM;

这种修改包含在本章的代码中,作为udp_client.c

你可以使用之前相同的命令重新编译程序,你将得到一个完全功能的 UDP 客户端。

不幸的是,将上一章的 TCP 服务器更改为 UDP 不会那么容易。TCP 和 UDP 服务器代码的差异足够大,需要稍微不同的方法。

此外,不要假设我们只更改了一行代码,客户端的行为就完全相同——这种情况不会发生。毕竟,这两个程序使用的是不同的协议。

记住从第二章,掌握套接字 API,UDP 并不试图成为一个可靠的协议。丢失的数据包不会自动重传,数据包可能以与发送时不同的顺序接收。甚至可能有一个数据包错误地到达两次!TCP 试图解决所有这些问题,但 UDP 让你自己解决问题。

你知道 UDP 笑话最好的地方是什么吗?我不在乎你是否能理解它。

尽管 UDP(缺乏)可靠性,但它仍然适用于许多应用。让我们看看 UDP 客户端和服务器使用的方法。

UDP 客户端方法

使用 TCP 发送数据需要调用connect()来设置远程地址并建立 TCP 连接。因此,我们使用 TCP 套接字上的send(),如下面的代码所示:

connect(tcp_socket, peer_address, peer_address_length);
send(tcp_socket, data, data_length, 0);

UDP 是一个无连接协议。因此,在发送数据之前不会建立任何连接。UDP 连接永远不会建立。在 UDP 中,数据只是发送和接收。我们可以调用connect()然后send(),就像我们之前提到的,但套接字 API 为 UDP 套接字提供了一个更简单的方式,即sendto()函数。它的工作方式如下:

sendto(udp_socket, data, data_length, 0,
        peer_address, peer_address_length);

在 UDP 套接字上,connect()的工作方式略有不同。对于 UDP 套接字,connect()所做的只是关联一个远程地址。因此,虽然 TCP 套接字上的connect()涉及在网络发送数据包时的握手,但 UDP 套接字上的connect()只会在本地存储一个地址。

因此,UDP 客户端可以根据是否使用connect()send()recv(),或者使用sendto()recvfrom()来以两种不同的方式构建。

以下图表比较了使用两种方法时TCP 客户端UDP 客户端的程序流程:

图片

注意,在使用connect()时,UDP 客户端只接收来自具有给定 IP 地址和端口的对等方的数据。然而,当不使用connect()时,recvfrom()函数会从任何向我们发送地址的数据对等方返回数据!当然,那个对等方需要知道我们的地址和端口。除非我们调用bind(),否则操作系统会自动分配我们的本地地址和端口。

UDP 服务器方法

编程 UDP 服务器与 TCP 略有不同。TCP 需要为每个对等连接管理一个套接字。对于 UDP,我们的程序只需要一个套接字。这个套接字可以与任何数量的对等方通信。

虽然 TCP 程序流程需要我们使用listen()accept()来等待和建立新的连接,但这些函数在 UDP 中并不使用。我们的 UDP 服务器只需绑定到本地地址,然后就可以立即开始发送和接收数据。

UDP 服务器相比,TCP 服务器的程序流程如下:

图片

无论使用 TCP 还是 UDP 服务器,当需要检查/等待传入数据时,我们都会使用select()。区别在于使用select()TCP 服务器可能正在监控多个独立的套接字,而UDP 服务器通常只需要监控一个套接字。如果你的程序同时使用 TCP 和 UDP 套接字,你只需调用一次select()就可以监控它们所有。

第一个 UDP 客户端/服务器

为了使这些要点更加清晰,通过一个完整的 UDP 客户端和 UDP 服务器程序来实践将是有用的。

为了保持简单,我们将创建一个 UDP 客户端程序,该程序简单地发送Hello World字符串到127.0.0.18080端口。我们的 UDP 服务器监听在8080。它打印接收到的任何数据,以及发送者的地址和端口号。

我们将首先实现这个简单的 UDP 服务器。

一个简单的 UDP 服务器

我们将从服务器开始,因为我们已经有一个可用的 UDP 客户端,即udp_client.c

就像我们所有的网络程序一样,我们将从包含必要的头文件开始,从main()函数开始,并初始化 Winsock,如下所示:

/*udp_recvfrom.c*/

#include "chap04.h"

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

如果你按照顺序阅读这本书,现在这段代码应该对你来说非常熟悉。如果没有,请参阅第二章,掌握套接字 API

然后,我们必须配置服务器监听的本地地址。我们使用getaddrinfo()来完成此操作,如下所示:

/*udp_recvfrom.c continued*/

    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_DGRAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(0, "8080", &hints, &bind_address);

这与我们之前的方法略有不同。值得注意的是,我们设置了hints.ai_socktype = SOCK_DGRAM。回想一下,那里使用SOCK_STREAM进行 TCP 连接。我们在这里仍然设置hints.ai_family = AF_INET。这使得我们的服务器监听 IPv4 连接。我们可以将其更改为AF_INET6,使服务器监听 IPv6 连接。

在我们有了本地地址信息之后,我们可以创建套接字,如下所示:

/*udp_recvfrom.c continued*/

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_listen)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

这段代码与 TCP 的情况完全相同。socket()调用使用从getaddrinfo()获取的地址信息来创建适当的套接字类型。

然后,我们必须将新套接字绑定到从getaddrinfo()获取的本地地址。如下所示:

/*udp_recvfrom.c continued*/

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(bind_address);

再次强调,这段代码与 TCP 的情况完全相同。

这里是 UDP 服务器与 TCP 服务器不同的地方。一旦本地地址绑定,我们就可以简单地开始接收数据。不需要调用listen()accept()。我们使用recvfrom()来监听传入的数据,如下所示:

/*udp_recvfrom.c continued*/

    struct sockaddr_storage client_address;
    socklen_t client_len = sizeof(client_address);
    char read[1024];
    int bytes_received = recvfrom(socket_listen,
            read, 1024,
            0,
            (struct sockaddr*) &client_address, &client_len);

在前面的代码中,我们创建了一个 struct sockaddr_storage 来存储客户端的地址。我们还定义了 socklen_t client_len 来保存地址的大小。这使我们的代码在从 IPv4 更改为 IPv6 时更加健壮。最后,我们创建了一个缓冲区 char read[1024] 来存储传入的数据。

recvfrom() 的使用方式与 recv() 类似,但它除了返回接收到的数据外,还会返回发送者的地址。你可以将 recvfrom() 视为 TCP 服务器 accept()recv() 的组合。

一旦我们接收到了数据,我们就可以将其打印出来。请注意,数据可能不是以空字符终止的。可以使用 %.*sprintf() 格式说明符安全地打印数据,如下面的代码所示:

/*udp_recvfrom.c continued*/

    printf("Received (%d bytes): %.*s\n",
            bytes_received, bytes_received, read);

打印发送者的地址和端口号可能也有用。我们可以使用 getnameinfo() 函数将此数据转换为可打印的字符串,如下面的代码所示:

/*udp_recvfrom.c continued*/

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(((struct sockaddr*)&client_address),
            client_len,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST | NI_NUMERICSERV);
    printf("%s %s\n", address_buffer, service_buffer);

getnameinfo() 的最后一个参数 (NI_NUMERICHOST | NI_NUMERICSERV) 告诉 getnameinfo() 我们希望客户端地址和端口号都采用数字形式。如果没有这个参数,它会尝试返回一个主机名或协议名,如果端口号与已知协议匹配。如果你确实需要一个协议名,可以通过传递 NI_DGRAM 标志来告诉 getnameinfo() 你正在处理一个 UDP 端口。这对于那些 TCP 和 UDP 使用不同端口的少数协议来说非常重要。

还值得注意的是,客户端很少会显式设置其本地端口号。因此,getnameinfo() 返回的端口号很可能是客户端操作系统随机选择的一个高数字。即使客户端设置了本地端口号,我们在这里看到的端口号也可能已经被网络地址转换(NAT)所更改。

在任何情况下,如果我们的服务器需要发送数据回客户端,它需要将数据发送到存储在 client_address 中的地址和端口。这可以通过将 client_address 传递给 sendto() 来完成。

一旦数据被接收,我们将通过关闭连接、清理 Winsock 和结束程序来结束我们的简单 UDP 服务器:

/*udp_recvfrom.c continued*/

    CLOSESOCKET(socket_listen);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

你可以使用以下命令在 Linux 和 macOS 上编译和运行 udp_recvfrom.c

gcc udp_recvfrom.c -o udp_recvfrom
./udp_recvfrom

使用 MinGW 在 Windows 上编译和运行的操作如下:

gcc udp_recvfrom.c -o udp_recvfrom.exe -lws2_32
udp_recvfrom.exe

在运行时,它只是等待传入的连接:

图片

你可以使用 udp_client 连接到 udp_recvfrom 进行测试,或者你可以实现 udp_sendto,这是我们接下来要做的。

一个简单的 UDP 客户端

尽管我们已经展示了一个功能较为齐全的 UDP 客户端 udp_client.c,但构建一个非常简单的 UDP 客户端也是值得的。这个客户端仅展示了获取一个可工作的 UDP 客户端所需的最小步骤,并且它使用 sendto() 而不是 send()

让我们以开始每个程序相同的方式开始,通过包含必要的头文件,开始 main(),并初始化 Winsock,如下所示:

/*udp_sendto.c*/

#include "chap04.h"

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

我们随后使用 getaddrinfo() 配置远程地址。在这个最小示例中,我们使用 127.0.0.1 作为远程地址,8080 作为远程端口。这意味着它仅在服务器运行在同一台计算机上时才会连接到 UDP 服务器。

下面是如何配置远程地址的:

/*udp_sendto.c continued*/

    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_DGRAM;
    struct addrinfo *peer_address;
    if (getaddrinfo("127.0.0.1", "8080", &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

注意,我们在 getaddrinfo() 的调用中硬编码了 127.0.0.18080。另外,注意我们已将 hints.ai_socktype = SOCK_DGRAM 设置。这告诉 getaddrinfo() 我们正在通过 UDP 连接。注意我们没有设置 AF_INETAF_INET6。这允许 getaddrinfo() 返回 IPv4 或 IPv6 的适当地址。在这种情况下,它是 IPv4,因为地址 127.0.0.1 是一个 IPv4 地址。我们将在第五章[3d80e3b8-07d3-49f4-b60f-b006a17f7213.xhtml]中更详细地介绍 getaddrinfo()主机名解析和 DNS

我们可以使用 getnameinfo() 打印配置的地址。getnameinfo() 的调用与之前的 UDP 服务器 udp_recvfrom.c 中的调用相同。它的工作方式如下:

/*udp_sendto.c continued*/

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(peer_address->ai_addr, peer_address->ai_addrlen,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST  | NI_NUMERICSERV);
    printf("%s %s\n", address_buffer, service_buffer);

现在我们已经存储了远程地址,我们就可以通过调用 socket() 创建我们的套接字了。我们通过 peer_address 中的字段来创建适当的套接字类型。相应的代码如下:

/*udp_sendto.c continued*/

    printf("Creating socket...\n");
    SOCKET socket_peer;
    socket_peer = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_peer)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

一旦创建套接字,我们就可以直接使用 sendto() 发送数据。不需要调用 connect()。以下是向我们的 UDP 服务器发送 Hello World 的代码:

/*udp_sendto.c continued*/

    const char *message = "Hello World";
    printf("Sending: %s\n", message);
    int bytes_sent = sendto(socket_peer,
            message, strlen(message),
            0,
            peer_address->ai_addr, peer_address->ai_addrlen);
    printf("Sent %d bytes.\n", bytes_sent);

注意,sendto()send() 非常相似,除了我们需要将地址作为最后一个参数传递。

值得注意的是,如果发送失败,我们不会收到错误。send() 只是尝试发送消息,但如果它在途中丢失或误递送,我们无能为力。如果消息很重要,那么实现纠正措施的责任就落在应用协议上。

在我们发送数据后,我们可以重用相同的套接字向另一个地址发送数据(只要它是同一类型的地址,在这个例子中是 IPv4)。我们也可以尝试通过调用 recvfrom() 从 UDP 服务器接收回复。注意,如果我们在这里调用 recvfrom(),我们可能会从任何向我们发送数据的人那里获取数据——并不一定是我们刚刚传输到的服务器。

当我们发送数据时,操作系统会分配一个临时的本地端口号给我们的套接字。这个本地端口号被称为 临时端口号。从那时起,我们的套接字基本上是在这个本地端口上监听回复。如果本地端口很重要,你可以在调用 send() 之前使用 bind() 将特定端口关联起来。

如果同一系统上的多个应用程序正在同一端口连接到远程服务器,操作系统将使用本地临时端口号来保持回复的分离。没有这个,我们就无法知道哪个应用程序应该接收哪个回复。

我们将通过释放 peer_address 的内存、关闭套接字、清理 Winsock 和完成 main() 来结束我们的示例程序,如下所示:

/*udp_sendto.c continued*/

    freeaddrinfo(peer_address);
    CLOSESOCKET(socket_peer);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

您可以使用以下命令在 Linux 和 macOS 上编译 udp_sendto.c

gcc udp_sendto.c -o udp_sendto

使用 MinGW 在 Windows 上编译的方式如下:

gcc udp_sendto.c -o udp_sendto.exe -lws2_32

为了测试它,首先,在一个单独的终端中启动 udp_recvfrom。在 udp_recvfrom 已经运行的情况下,您可以启动 udp_sendto。它应该看起来如下:

如果端口 8080 上没有运行服务器,udp_sendto 仍然会产生相同的输出。udp_sendto 并不知道数据包没有被送达。

一个 UDP 服务器

研究一个设计用来服务多个连接的 UDP 服务器将是有用的。幸运的是,UDP 套接字 API 使这变得非常简单。

我们将从前一章的激励示例开始,即提供一个将所有文本转换为上档的服务。这很有用,因为您可以直接比较这里的 UDP 代码和第三章中的 TCP 服务器代码。

我们的服务器首先设置套接字并将其绑定到本地地址。然后它等待接收数据。一旦它接收到了一个数据字符串,它将字符串转换成全部大写并发送回去。

程序流程如下:

如果您将这个程序的流程与上一章的 TCP 服务器进行比较(第三章TCP 连接的深入概述),您会发现它要简单得多。在 TCP 中,我们必须使用 listen()accept()。在 UDP 中,我们跳过这些调用,直接使用 recvfrom() 接收数据。在我们的 TCP 服务器中,我们必须同时监控一个监听套接字以寻找新的连接,并监控每个已连接客户端的额外套接字。我们的 UDP 服务器只使用一个套接字,因此需要跟踪的内容要少得多。

我们的 UDP 服务器程序首先包括必要的头文件,启动 main() 函数,并初始化 Winsock,如下所示:

/*udp_serve_toupper.c*/

#include "chap04.h"
#include <ctype.h>

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后我们找到我们应该监听的本地地址,创建套接字,并将其绑定。这和我们在之前的服务器 udp_recvfrom.c 中的操作完全一样。这段代码和第三章中的 TCP 服务器代码的唯一区别是,我们使用 SOCK_DGRAM 而不是 SOCK_STREAM。回想一下,SOCK_DGRAM 指定了我们想要一个 UDP 套接字。

这是设置地址和创建新套接字的代码:

/*udp_serve_toupper.c continued*/

    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_DGRAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(0, "8080", &hints, &bind_address);

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_listen)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

将新的套接字绑定到本地地址的方式如下:

/*udp_serve_toupper.c continued*/

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(bind_address);

由于我们的服务器使用 select(),我们需要创建一个新的 fd_set 来存储我们的监听套接字。我们使用 FD_ZERO() 将集合清零,然后使用 FD_SET() 将我们的套接字添加到这个集合中。我们还使用 max_socket 维护集合中的最大套接字:

/*udp_serve_toupper.c continued*/

    fd_set master;
    FD_ZERO(&master);
    FD_SET(socket_listen, &master);
    SOCKET max_socket = socket_listen;

    printf("Waiting for connections...\n");

注意,我们实际上并不需要在这个程序中使用select(),省略它会使程序更简单(参见udp_server_toupper_simple.c)。然而,我们将使用select()因为它使我们的代码更灵活。如果我们需要监听多个端口,例如,我们可以轻松地添加一个额外的套接字,并且如果我们程序需要执行其他功能,我们可以添加一个select()超时。当然,我们的程序不做这些事情,所以我们不需要select(),但我认为大多数程序都需要,所以我们将以这种方式展示。

现在,我们准备好进入主循环。它将套接字集复制到一个新的变量reads中,然后使用select()等待我们的套接字准备好读取。回想一下,如果我们想为下一次读取设置最大等待时间,可以将超时值作为select()的最后一个参数传递。有关select()的更多信息,请参阅第三章TCP 连接的深入概述使用 select()的同步多路复用部分。

一旦select()返回,我们使用FD_ISSET()来判断我们的特定套接字socket_listen是否准备好读取。如果我们有额外的套接字,我们需要为每个套接字使用FD_ISSET()

如果FD_ISSET()返回 true,我们使用recvfrom()从套接字读取。recvfrom()提供了发送者的地址,因此我们必须首先分配一个变量来保存地址,即client_address。一旦我们使用recvfrom()从套接字读取了一个字符串,我们使用 C 的toupper()函数将字符串转换为大写。然后我们使用sendto()将修改后的文本发送回发送者。请注意,sendto()的最后两个参数是我们从recvfrom()得到的客户端地址。

主程序循环可以在以下代码中看到:

/*udp_serve_toupper.c continued*/

    while(1) {
        fd_set reads;
        reads = master;
        if (select(max_socket+1, &reads, 0, 0, 0) < 0) {
            fprintf(stderr, "select() failed. (%d)\n", GETSOCKETERRNO());
            return 1;
        }

        if (FD_ISSET(socket_listen, &reads)) {
            struct sockaddr_storage client_address;
            socklen_t client_len = sizeof(client_address);

            char read[1024];
            int bytes_received = recvfrom(socket_listen, read, 1024, 0,
                    (struct sockaddr *)&client_address, &client_len);
            if (bytes_received < 1) {
                fprintf(stderr, "connection closed. (%d)\n",
                        GETSOCKETERRNO());
                return 1;
            }

            int j;
            for (j = 0; j < bytes_received; ++j)
                read[j] = toupper(read[j]);
            sendto(socket_listen, read, bytes_received, 0,
                    (struct sockaddr*)&client_address, client_len);

        } //if FD_ISSET
    } //while(1)

然后,我们可以关闭套接字,清理 Winsock,并终止程序。请注意,此代码永远不会运行,因为主循环永远不会终止。我们仍然包含此代码作为良好实践;以防程序将来被修改以包含退出函数。

清理代码如下:

/*udp_serve_toupper.c continued*/

    printf("Closing listening socket...\n");
    CLOSESOCKET(socket_listen);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");

    return 0;
}

这就是我们的完整 UDP 服务器程序。你可以在 Linux 和 macOS 上编译和运行它,如下所示:

gcc udp_serve_toupper.c -o udp_serve_toupper
./udp_serve_toupper

使用 MinGW 在 Windows 上编译和运行的方法如下:

gcc udp_serve_toupper.c -o udp_serve_toupper.exe -lws2_32
udp_serve_toupper.exe

你可以使用Ctrl + C来终止程序的执行。

一旦程序开始运行,你应该打开另一个终端窗口,并运行之前提到的udp_client程序来连接到它,如下所示:

udp_client 127.0.0.1 8080

你在udp_client中输入的任何内容都应该以大写形式发送回它。这可能看起来是这样的:

你也可以尝试打开额外的终端窗口,并使用udp_client进行连接。

查看udp_serve_toupper_simple.c以获取一个不使用select()但仍然可以正常工作的实现。

摘要

在本章中,我们了解到使用 UDP 套接字编程比使用 TCP 套接字要简单一些。我们了解到 UDP 套接字不需要 listen()accept()connect() 函数调用。这主要是因为 sendto()recvfrom() 直接处理地址。对于更复杂的程序,我们仍然可以使用 select() 函数来查看哪些套接字已准备好进行 I/O。

我们还了解到 UDP 套接字是无连接的。这与面向连接的 TCP 套接字形成对比。在使用 TCP 时,我们必须在发送数据之前建立连接,但使用 UDP,我们只需将单个数据包直接发送到目标地址。这使 UDP 套接字编程变得简单,但它可能会使应用程序协议设计复杂化,UDP 不会自动重试通信失败,也不能确保数据包按顺序到达。

下一章,第五章,主机名解析和 DNS,全部关于主机名。主机名是通过 DNS 协议解析的,该协议在 UDP 上工作。继续阅读 第五章,主机名解析和 DNS,了解实现现实世界 UDP 协议的方法。

问题

尝试回答这些问题以测试你对本章知识的掌握:

  1. sendto()recvfrom()send()recv() 有何不同?

  2. send()recv() 是否可以在 UDP 套接字上使用?

  3. 在 UDP 套接字的情况下,connect() 函数做什么?

  4. 与 TCP 相比,什么使得使用 UDP 的多路复用更简单?

  5. 与 TCP 相比,UDP 的缺点是什么?

  6. 同一个程序可以使用 UDP 和 TCP 吗?

答案可以在 附录 A 中找到,问题解答

第五章:主机名解析和 DNS

主机名解析是网络编程的一个关键部分。它允许我们使用简单的名称,如 www.example.com,而不是像 ::ffff:192.168.212.115 这样繁琐的地址。将主机名解析为 IP 地址以及将 IP 地址解析为主机名的机制是 域名系统 (DNS)。

在本章中,我们首先深入介绍内置的 getaddrinfo()getnameinfo() 网络套接字函数。稍后,我们将从头开始构建一个使用 用户数据报协议 (UDP) 进行 DNS 查询的程序。

本章我们将涵盖以下主题:

  • DNS 的工作原理

  • 常见的 DNS 记录类型

  • getaddrinfo()getnameinfo() 函数

  • DNS 查询数据结构

  • DNS UDP 协议

  • DNS TCP 回退

  • 实现一个 DNS 查询程序

技术要求

本章的示例程序可以用任何现代 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。有关编译器设置,请参阅附录 B、C 和 D。

本书代码可以在 github.com/codeplea/Hands-On-Network-Programming-with-C 找到。

从命令行,你可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap05

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都应该与 Winsock 库链接。这可以通过将 -lws2_32 选项传递给 gcc 来实现。

我们将在介绍每个示例时提供编译每个示例所需的精确命令。

本章中所有的示例程序都需要我们在 第二章 中开发的相同头文件和 C 宏,即 掌握 Socket API。为了简洁起见,我们将这些语句放在一个单独的头文件 chap05.h 中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅 第二章 的 掌握 Socket API

chap05.h 的内容如下:

/*chap05.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

chap05.h 头文件就绪的情况下,编写可移植的网络程序变得更加容易。现在让我们继续解释 DNS 的工作原理,然后我们将转到实际的示例程序。

主机名解析是如何工作的

DNS 用于为连接到互联网的计算机和系统分配名称。类似于电话簿可以用来将电话号码与姓名联系起来,DNS 允许我们将主机名与 IP 地址联系起来。

当你的程序需要连接到远程计算机,例如 www.example.com,它首先需要找到 www.example.com 的 IP 地址。在这本书到目前为止的内容中,我们一直使用内置的 getaddrinfo() 函数来完成这个目的。当你调用 getaddrinfo() 时,你的操作系统会经过一系列步骤来解析域名。

首先,您的操作系统检查它是否已经知道www.example.com的 IP 地址。如果您最近使用过该主机名,操作系统允许它在本地缓存中记住一段时间。这段时间被称为生存时间TTL),由负责该主机名的 DNS 服务器设置。

如果主机名在本地缓存中没有找到,那么您的操作系统将需要查询一个 DNS 服务器。这个 DNS 服务器通常由您的互联网服务提供商ISP)提供,但也有许多公开可用的 DNS 服务器。当 DNS 服务器收到一个查询时,它也会检查其本地缓存。这很有用,因为许多系统可能依赖于一个 DNS 服务器。如果一个 DNS 服务器在一分钟内收到 1,000 次对gmail.com的请求,它只需要在第一次解析主机名。对于其他 999 次请求,它可以直接从其本地缓存中返回已记住的答案。

如果 DNS 服务器在其缓存中没有找到请求的 DNS 记录,那么它需要查询其他 DNS 服务器,直到它直接连接到负责目标系统的 DNS 服务器。以下是一个逐步分解的查询解析示例:

客户端 A 的 DNS 服务器正在尝试以下方式解析www.example.com

  1. 它首先连接到根 DNS 服务器,并请求www.example.com

  2. 根 DNS 服务器将其引导到询问.com服务器。

  3. 然后,我们的 DNS 服务器连接到负责.com的服务器,并请求www.example.com

  4. .com DNS 服务器给我们服务器另一个服务器的地址——example.com DNS 服务器。

  5. 我们的 DNS 服务器最终连接到那个服务器,并询问www.example.com的记录。

  6. example.com服务器然后分享www.example.com的地址。

  7. 我们的 DNS 服务器将请求转回我们的客户端。

以下图表直观地说明了这一点:

图片

在这个例子中,你可以看到解析www.example.com涉及发送八条消息。查找可能需要更长的时间。这就是为什么 DNS 服务器实现缓存至关重要。假设客户端 B客户端 A之后不久尝试相同的查询;DNS 服务器很可能已经缓存了该值:

图片

当然,如果客户端 A上的程序再次解析www.example.com,它很可能根本不需要联系 DNS 服务器——运行在客户端 A上的操作系统应该已经缓存了结果。

在 Windows 上,您可以使用以下命令显示您的本地 DNS 缓存:

ipconfig /displaydns

在 Linux 或 macOS 上,显示本地 DNS 的命令取决于您的确切系统设置。

为域名记录设置较大的 TTL 值的一个缺点是,您必须至少等待这么长时间才能确保所有客户端都在使用新记录,而不仅仅是检索旧的缓存记录。

除了将主机名链接到 IP 地址的 DNS 记录外,还有其他用于各种目的的 DNS 记录类型。我们将在下一节中回顾一些这些类型。

DNS 记录类型

DNS 有五种主要记录类型——AAAAAMXTXTCNAME*ALL/ANY)。

正如我们所学的,DNS 的主要目的是将主机名转换为 IP 地址。这是通过两种记录类型——类型A和类型AAAA来完成的。这些记录以相同的方式工作,但A记录返回 IPv4 地址,而AAAA记录返回 IPv6 地址。

MX记录类型用于返回邮件服务器信息。例如,如果您想给larry@example.com发送电子邮件,则example.comMX记录将指示哪些邮件服务器接收该域的电子邮件。

TXT记录可以用于存储主机名的任意信息。实际上,这些有时被设置为证明域名所有权或发布电子邮件发送指南。发送者策略框架(SPF)标准使用TXT记录来声明哪些系统可以发送给定域名的邮件。您可以在www.openspf.org/了解更多关于 SPF 的信息。

CNAME记录可以用于为给定名称提供别名。例如,许多网站既可以通过其根域名访问,例如example.com,也可以通过www子域名访问。如果example.comwww.example.com应指向同一地址,则可以为example.com添加AAAAA记录,而为www.example.com添加指向example.comCNAME记录。请注意,DNS 客户端不会直接查询CNAME记录;相反,客户端会请求www.example.comAAAAA记录,DNS 服务器会回复指向example.comCNAME记录。然后 DNS 客户端会继续使用example.com进行查询。

在进行 DNS 查询时,还有一个名为*ALLANY的伪记录类型。如果从 DNS 服务器请求此记录,则 DNS 服务器返回当前查询的所有已知记录类型。请注意,DNS 服务器可以仅响应其缓存中的记录,并且此查询不能保证(甚至可能)实际获取请求域名的所有记录。

在发送 DNS 查询时,每种记录类型都有一个关联的类型 ID。迄今为止讨论的记录的 ID 如下:

记录类型 类型 ID(十进制) 描述
A 1 IPv4 地址记录
AAAA 28 IPv6 地址记录
MX 15 邮件交换记录
TXT 16 文本记录
CNAME 5 规范名称
* 255 所有缓存记录

有许多其他记录类型正在使用中。请参阅本章末尾的进一步阅读部分以获取更多信息。

应该注意的是,一个主机名可能与多个同类型的记录相关联。例如,example.com可能有几个A记录,每个记录都有不同的 IPv4 地址。如果多个服务器可以提供相同的服务,这很有用。

值得一提的 DNS 协议的另一个方面是安全性。现在让我们来看看这一点。

DNS 安全

尽管如今大多数网络流量和电子邮件都是加密的,但 DNS 仍然广泛以未加密的方式使用。确实存在一些协议可以提供 DNS 的安全性,但它们尚未得到广泛采用。希望这种情况在不久的将来会有所改变。

域名系统安全扩展DNSSEC)是 DNS 扩展,它提供了数据认证。这种认证允许 DNS 客户端知道给定的 DNS 回复是真实的,但它不能防止窃听。

DNS over HTTPSDoH)是一种在 HTTPS 上提供名称解析的协议。HTTPS 提供了强大的安全保证,包括抵抗拦截的能力。我们在第九章《使用 HTTPS 和 OpenSSL 加载安全网页》和第十章《实现安全 Web 服务器》中介绍了 HTTPS。

使用不安全的 DNS 有哪些影响?首先,如果 DNS 没有进行认证,那么它可能允许攻击者对域名 IP 地址进行欺骗。这可能会诱骗受害者连接到一个他们认为的example.com服务器,但实际上是一个由攻击者控制的恶意服务器,该服务器位于不同的 IP 地址。如果用户通过安全的协议连接,例如 HTTPS,那么这种攻击将失败。HTTPS 提供了额外的认证来证明服务器身份。然而,如果用户使用不安全的协议连接,例如 HTTP,那么 DNS 攻击可能会成功欺骗受害者连接到错误的服务器。

如果 DNS 进行了认证,那么这些劫持攻击就会被阻止。然而,如果没有加密,DNS 查询仍然容易受到窃听。这可能会让窃听者了解到你访问了哪些网站以及你连接到的其他服务器(例如,你使用的电子邮件服务器)。这并不让攻击者知道你在每个网站上做了什么。例如,如果你对example.com进行 DNS 查询,攻击者会知道你打算访问example.com,但攻击者无法确定你从example.com请求了哪些资源——假设你使用安全的协议(例如 HTTPS)来检索这些资源。具有窃听能力的攻击者无论如何都能看到你与example.com的 IP 地址建立了连接,所以他们在你之前进行 DNS 查找并不会提供太多额外信息。

在解决了某些安全问题之后,让我们看看如何进行实际的 DNS 查找。Winsock 和伯克利套接字提供了一种简单的函数来进行地址查找,称为getaddrinfo(),我们在本书的前几章中已经使用过它。我们将在下一节中从这方面开始。

名称/地址转换函数

对于网络程序来说,通常需要将地址或主机名的基于文本的表示转换为套接字编程 API 所需的地址结构。我们一直在使用的常用函数是getaddrinfo()。这是一个有用的函数,因为它高度可移植(在 Windows、Linux 和 macOS 上可用),并且适用于 IPv4 和 IPv6 地址。

将二进制地址转换回文本格式也是常见的需求。我们使用getnameinfo()来完成这个任务。

使用getaddrinfo()

虽然我们已经在之前的章节中使用过getaddrinfo(),但我们将在这里更详细地讨论它。

getaddrinfo()的声明如下所示:

int getaddrinfo(const char *node,
                const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);

以下代码片段的解释如下:

  • node指定一个主机名或地址作为字符串。有效的示例可以是example.com192.168.1.1::1

  • service指定一个服务或端口号作为字符串。有效的示例可以是http80。或者,可以将空指针传递给service,在这种情况下,结果地址将被设置为端口号0

  • hints是一个指向struct addrinfo的指针,它指定了选择地址的选项。addrinfo结构具有以下字段:

struct addrinfo {
    int              ai_flags;
    int              ai_family;
    int              ai_socktype;
    int              ai_protocol;
    socklen_t        ai_addrlen;
    struct sockaddr *ai_addr;
    char            *ai_canonname;
    struct addrinfo *ai_next;
};

您不应假设字段是按照之前代码中列出的顺序存储的,或者认为没有其他字段存在。不同操作系统之间存在一些差异。

getaddrinfo()的调用只查看*hints中的四个字段。其余的结构应该为零。相关的字段是ai_familyai_socktypeai_protocolai_flags

  • ai_family指定所需的地址族。它可以是为 IPv4 的AF_INET,为 IPv6 的AF_INET6,或为任何地址族的AF_UNSPECAF_UNSPEC定义为0

  • ai_socktype可以是用于 TCP 的SOCK_STREAM(见第三章,TCP 连接的深入概述),或用于 UDP 的SOCK_DGRAM(见第四章,建立 UDP 连接)。将ai_socktype设置为0表示地址可以用于两者。

  • ai_protocol应保留为0。严格来说,TCP 并不是唯一由套接字接口支持的流协议,UDP 也不是唯一支持的报文协议。ai_protocol用于消除歧义,但对我们来说不是必需的。

  • ai_flags指定关于getaddrinfo()应如何工作的附加选项。可以通过按位或操作将多个标志组合在一起使用。在 C 中,按位或操作符使用管道符号|。因此,将两个标志按位或操作在一起将使用(flag_one | flag_two)代码。

您可能用于 ai_flags 字段的常见标志包括:

  • AI_NUMERICHOST 可以用于防止名称查找。在这种情况下,getaddrinfo() 预期 node 是一个地址,如 127.0.0.1,而不是一个主机名,如 example.comAI_NUMERICHOST 可能很有用,因为它可以防止 getaddrinfo() 执行 DNS 记录查找,这可能会很慢。

  • AI_NUMERICSERV 可以用于仅接受 service 参数的端口号。如果使用,则此标志会导致 getaddrinof() 拒绝服务名称。

  • AI_ALL 可以用于请求 IPv4 和 IPv6 地址。在某些 Windows 设置中,AI_ALL 的声明似乎缺失。在这些平台上,它可以定义为 0x0100

  • AI_ADDRCONFIG 强制 getaddrinfo() 只返回与本地机器上配置的接口的族类型匹配的地址。例如,如果您的机器仅使用 IPv4,则使用 AI_ADDRCONFIG | AI_ALL 防止返回 IPv6 地址。如果您计划将套接字连接到 getaddrinfo() 返回的地址,通常使用此标志是一个好主意。

  • AI_PASSIVE 可以与 node = 0 一起使用来请求通配符地址。这是接受主机任何网络地址上连接的本地地址。它在带有 bind() 的服务器上使用。如果 node 不是 0,则 AI_PASSIVE 无效。例如,请参阅第三章,TCP 连接的深入概述,了解用法。

hints 中的所有其他字段应设置为 0。您也可以为 hints 参数传递 0,但在不同操作系统中,这种情况下的默认值可能会有所不同。

getaddrinfo() 的最后一个参数 res 是指向 struct addrinfo 指针的指针,并返回 getaddrinfo() 找到的地址(或地址)。

如果 getaddrinfo() 调用成功,则其返回值是 0。在这种情况下,当您完成使用地址后,应在 *res 上调用 freeaddrinfo()。以下是一个使用 getaddrinfo() 查找 example.com 地址(或地址)的示例:

struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_flags = AI_ALL;
struct addrinfo *peer_address;
if (getaddrinfo("example.com", 0, &hints, &peer_address)) {
    fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
    return 1;
}

注意,我们首先使用 memset() 调用来将 hints 清零。然后设置 AI_ALL 标志,指定我们希望返回 IPv4 和 IPv6 地址。这甚至返回我们没有网络适配器的地址。如果您只想获取机器可以实际连接的地址,则使用 AI_ALL | AI_ADDRCONFIG 作为 ai_flags 字段。我们可以将 hints 的其他字段保留为默认值。

然后,我们声明一个指针来保存返回的地址列表:struct addrinfo *peer_address

如果 getaddrinfo() 调用成功,则 peer_address 包含第一个地址结果。如果有下一个结果,它将在 peer_address->ai_next 中。

我们可以使用以下代码遍历所有返回的地址:

struct addrinfo *address = peer_address;
do {
    /* Work with address... */
} while ((address = address->ai_next));

当我们完成使用 peer_address 后,我们应该使用以下代码来释放它:

freeaddrinfo(peer_address);

现在我们可以将文本地址或名称转换为 addrinfo 结构,那么查看如何将 addrinfo 结构转换回文本格式就很有用了。现在让我们看看这一点。

使用 getnameinfo()

getnameinfo() 可以用于将 addrinfo 结构转换回文本格式。它适用于 IPv4 和 IPv6。它还可以可选地将端口号转换为文本格式的数字或服务名称。

getnameinfo() 的声明可以在以下代码中看到:

int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,
        char *host, socklen_t hostlen,
        char *serv, socklen_t servlen, int flags);

前两个参数是从 struct addrinfoai_addrai_addrlen 字段传递的。

下两个参数 hosthostlen 指定用于存储主机名或 IP 地址文本的字符缓冲区和缓冲区长度。

以下两个参数 servservlen 指定用于存储服务名称的缓冲区和长度。

如果您不需要主机名和服务名称,您可以可选地只传递 hostserv 中的一个。

标志可以是以下标志的按位或组合:

  • NI_NAMEREQD 要求 getnameinfo() 返回主机名而不是地址。默认情况下,getnameinfo() 尝试返回主机名,但如果无法确定则返回地址。如果无法确定主机名,NI_NAMEREQD 将导致返回错误。

  • NI_DGRAM 指定服务基于 UDP 而不是 TCP。这对于具有不同标准服务的 UDP 和 TCP 端口很重要。如果设置了 NI_NUMERICSERV,则忽略此标志。

  • NI_NUMERICHOST 请求 getnameinfo() 返回 IP 地址而不是主机名。

  • NI_NUMERICSERV 请求 getnameinfo() 返回端口号而不是服务名称。

例如,我们可以如下使用 getnameinfo()

char host[100];
char serv[100];
getnameinfo(address->ai_addr, address->ai_addrlen,
        host, sizeof(host),
        serv, sizeof(serv),
        0);

printf("%s %s\n", host, serv);

在前面的代码中,getnameinfo() 尝试执行逆向 DNS 查询。这就像我们在本章中迄今为止所做的 DNS 查询一样,但方向相反。DNS 查询询问该主机名指向哪个 IP 地址?逆向 DNS 查询则相反,询问该 IP 地址指向哪个主机名?请记住,这不是一对一的关系。许多主机名可以指向一个 IP 地址,但一个 IP 地址只能存储一个主机名的 DNS 记录。事实上,许多 IP 地址甚至没有设置逆向 DNS 记录。

如果 addressstruct addrinfo,其中包含 example.com 的地址 80http)端口,那么前面的代码可能打印如下:

example.com http

如果代码为您打印了不同的内容,那么它可能按预期工作。这取决于 address 字段中的地址以及该 IP 地址的逆向 DNS 设置。尝试使用不同的地址进行测试。

如果我们想要 IP 地址而不是主机名,我们可以修改我们的代码如下:

char host[100];
char serv[100];
getnameinfo(address->ai_addr, address->ai_addrlen,
        host, sizeof(host),
        serv, sizeof(serv),
        NI_NUMERICHOST | NI_NUMERICSERV);

printf("%s %s\n", host, serv);

在前一段代码的情况下,它可能打印以下内容:

93.184.216.34 80

使用 NI_NUMERICHOST 通常运行得更快,因为它不需要 getnameinfo() 发送任何逆向 DNS 查询。

其他函数

两个广泛使用的、可以复制getaddrinfo()功能的函数是gethostbyname()getservbyname()gethostbyname()函数已经过时,并被从较新的 POSIX 标准中删除。此外,我建议不要在新代码中使用这些函数,因为它们引入了 IPv4 依赖。完全有可能以这种方式使用getaddrinfo(),使得你的程序不需要知道 IPv4 与 IPv6 的区别,但仍然支持两者。

IP 查找示例程序

为了演示getaddrinfo()getnameinfo()函数,我们将实现一个简短的程序。此程序接受一个名称或 IP 地址作为其唯一参数。然后它使用getaddrinfo()将名称或 IP 地址解析为地址结构,并使用getnameinfo()进行文本转换来打印该 IP 地址。如果与名称关联了多个地址,它将打印每个地址。它还指示任何错误。

首先,我们需要包含本章所需的头文件。我们还为缺少它的系统定义了AI_ALL。代码如下所示:

/*lookup.c*/

#include "chap05.h"

#ifndef AI_ALL
#define AI_ALL 0x0100
#endi

我们可以开始调用main()函数,并检查用户是否传递了一个用于查找的主机名。如果用户没有传递主机名,我们将打印一条有用的提示信息。这段代码如下所示:

/*lookup.c continued*/

int main(int argc, char *argv[]) {

    if (argc < 2) {
        printf("Usage:\n\tlookup hostname\n");
        printf("Example:\n\tlookup example.com\n");
        exit(0);
    }

我们需要以下代码来在 Windows 平台上初始化 Winsock:

/*lookup.c continued*/

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后,我们可以调用getaddrinfo()将主机名或地址转换为struct addrinfo。以下是该代码:

/*lookup.c continued*/

    printf("Resolving hostname '%s'\n", argv[1]);
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_flags = AI_ALL;
    struct addrinfo *peer_address;
    if (getaddrinfo(argv[1], 0, &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

之前的代码首先打印作为第一个命令行参数传递的主机名或地址。此参数存储在argv[1]中。然后我们将hints.ai_flags = AI_ALL设置为指定我们想要所有类型的可用地址,包括 IPv4 和 IPv6 地址。

使用argv[1]调用getaddrinfo()。我们将服务参数传递为0,因为我们不关心端口号。我们只是尝试解析一个地址。如果argv[1]包含一个名称,例如example.com,那么我们的操作系统将执行 DNS 查询(假设主机名尚未在本地缓存中)。如果argv[1]包含一个地址,例如192.168.1.1,那么getaddrinfo()将根据需要简单地填充结果struct addrinfo

如果用户传递了一个无效的地址或找不到记录的主机名,那么getaddrinfo()将返回一个非零值。在这种情况下,我们之前的代码将打印出错误。

现在,peer_address包含所需的地址(或地址),我们可以使用getnameinfo()将它们转换为文本。以下代码实现了这一点:

/*lookup.c continued*/

    printf("Remote address is:\n");
    struct addrinfo *address = peer_address;
    do {
        char address_buffer[100];
        getnameinfo(address->ai_addr, address->ai_addrlen,
                address_buffer, sizeof(address_buffer),
                0, 0,
                NI_NUMERICHOST);
        printf("\t%s\n", address_buffer);
    } while ((address = address->ai_next));

此代码通过首先将peer_address存储在一个新变量address中来实现。然后我们进入一个循环。address_buffer[]被声明为存储文本地址,我们调用getnameinfo()来填充该地址。getnameinfo()的最后一个参数NI_NUMERICHOST表示我们希望它将 IP 地址放入address_buffer而不是主机名。然后可以使用printf()简单地打印出地址缓冲区。

如果 getaddrinfo() 返回多个地址,则下一个地址由 address->ai_next 指向。我们将 address->ai_next 赋值给 address 并在它非零时循环。这是一个遍历链表的简单示例。

在我们打印了我们的地址之后,我们应该使用freeaddrinfo()来释放getaddrinfo()分配的内存。在 Windows 上,我们还应该调用 Winsock 清理函数。我们可以使用以下代码来完成这两件事:

/*lookup.c continued*/

    freeaddrinfo(peer_address);

#if defined(_WIN32)
    WSACleanup();
#endif

    return 0;
}

这就结束了我们的lookup程序。

你可以使用以下命令在 Linux 和 macOS 上编译和运行lookup.c

gcc lookup.c -o lookup
./lookup example.com

在 Windows 上使用 MinGW 编译和运行的方式如下:

gcc lookup.c -o lookup.exe -lws2_32
lookup.exe example.com

以下截图是使用lookup打印example.com的 IP 地址的示例:

图片

虽然getaddrinfo()使执行 DNS 查找变得容易,但了解幕后发生的事情是有用的。现在我们将更详细地查看 DNS 协议。

DNS 协议

当客户端想要将主机名解析为 IP 地址时,它会向 DNS 服务器发送 DNS 查询。这通常是通过 UDP 使用端口53完成的。然后 DNS 服务器执行查找(如果可能的话),并返回一个答案。以下图示说明了这个事务:

图片

如果查询(或更常见的是,答案)太大而无法放入一个 UDP 数据包中,那么查询可以通过 TCP 而不是 UDP 进行。在这种情况下,查询的大小以 16 位值的形式通过 TCP 发送,然后发送查询本身。这被称为TCP 回退通过 TCP 的 DNS 传输。然而,UDP 适用于大多数情况,DNS 在绝大多数情况下都是通过 UDP 使用的。

还需要注意的是,客户端必须知道至少一个 DNS 服务器的 IP 地址。如果客户端不知道任何 DNS 服务器,那么它就有一个类似“鸡生蛋,蛋生鸡”的问题。DNS 服务器通常由你的 ISP 提供。

实际的 UDP 数据格式简单,对于查询和答案都遵循相同的基本格式。

DNS 消息格式

下图描述了 DNS 消息格式:

图片

每个 DNS 消息都遵循该格式,尽管查询会留空答案授权附加部分。DNS 响应通常不使用授权附加。我们不会关注授权附加部分,因为它们对于典型的 DNS 查询不是必需的。

DNS 消息头格式

头部长度正好是 12 字节,对于 DNS 查询或 DNS 响应都是相同的。头格式在以下图中以图形方式展示:

图片

如前图所示,DNS 消息头包含 13 个字段——IDQROPCODEAATCRDRAZRCODEQDCOUNTANCOUNTNSCOUNTARCOUNT

  • ID 是用于标识 DNS 消息的任何 16 位值。客户端允许将任何 16 位放入 DNS 查询中,DNS 服务器将相同的 16 位复制到 DNS 响应的 ID 中。这在客户端发送多个查询的情况下,允许客户端匹配哪个响应是对哪个查询的回复非常有用。

  • QR 是一个 1 位字段。它设置为 0 表示 DNS 查询,或设置为 1 表示 DNS 响应。

  • Opcode 是一个 4 位字段,它指定查询的类型。0 表示标准查询。1 表示将 IP 地址解析为名称的逆向查询。2 表示服务器状态请求。其他值(3 通过 15)是保留的。

  • AA 表示权威答案。

  • TC 表示消息已被截断。在这种情况下,应使用 TCP 重新发送。

  • RD 应当设置为期望递归。我们保留此位设置为指示我们希望 DNS 服务器联系其他服务器,直到它能够完成我们的请求。

  • RA 表示在响应中 DNS 服务器是否支持递归。

  • Z 未使用,应设置为 0

  • RCODE 在 DNS 响应中设置,以指示错误条件。其可能的值如下:

RCODE 描述
0 无错误
1 格式错误
2 服务器故障
3 名称错误
4 未实现
5 被拒绝

请参阅本章 进一步阅读 部分的 RFC 1035域名系统 – 实现 和 规范,以获取有关这些值含义的更多信息。

  • QDCOUNTANCOUNTNSCOUNTARCOUNT 表示它们对应部分中的记录数。QDCOUNT 表示 DNS 查询中的问题数。有趣的是,QDCOUNT 是一个 16 位值,可以存储大量数字,但现实中的 DNS 服务器不允许每条消息中超过一个问题。ANCOUNT 表示答案的数量,DNS 服务器通常在一条消息中返回多个答案。

问题格式

DNS 问题格式 由一个名称后跟两个 16 位值组成——QTYPEQCLASS。以下是这样表示 问题格式

QTYPE 表示我们请求的记录类型,QCLASS 应设置为 1 以表示互联网。

名称字段涉及一种特殊编码。首先,应将主机名分解为其单个标签。例如,www.example.com 将被分解为 wwwexamplecom。然后,每个标签应前面加上 1 个字节,表示标签长度。最后,整个名称以 0 字节结束。

从视觉上看,名称 www.example.com 被编码如下:

如果将 QTYPEQCLASS 字段附加到前面的名称示例中,那么它可以组成一个完整的 DNS 问题。

有时 DNS 响应需要多次重复相同的名称。在这种情况下,DNS 服务器可能会编码一个指向早期名称的指针,而不是多次发送相同的名称。指针由一个 16 位值表示,其中最高两位被设置。最低 14 位指示指针值。这个 14 位值指定名称的位置,作为从消息开始处的偏移量。保留最高两位的额外副作用是限制标签长度为 63 个字符。一个更长的名称需要设置标签长度指定符中的两个高位,但如果两个高位都被设置,则表示这是一个指针,而不是标签长度!

答案格式与问题格式类似,但有一些额外的字段。让我们接下来看看这些字段。

答案格式

DNS 答案格式由与问题相同的三个字段组成;即名称后跟一个 16 位的 TYPE 和一个 16 位的 CLASS。然后答案格式有一个 32 位的 TTL 字段。该字段指定答案允许缓存的秒数。TTL 后跟一个 16 位的长度指定符,RDLENGTH,然后是数据。数据长度为 RDLENGTH,数据的解释取决于 TYPE 指定的类型。

从视觉上看,答案格式在以下图中展示:

图片

请记住,大多数 DNS 服务器在其答案名称中使用名称指针。这是因为 DNS 响应已经在问题部分中包含了相关的名称。答案可以直接指向该名称,而不是再次编码整个名称(第二次或第三次)。

每当在网络上发送二进制数据时,字节序的问题就变得相关了。现在让我们考虑这个问题。

字节序

术语 端序 指的是单个字节在内存中存储或通过网络发送的顺序。

每当我们从 DNS 消息中读取多字节数字时,我们应该意识到它是以大端格式(或所谓的网络字节序)存储的。你使用的计算机可能使用的是小端格式,尽管我们在整本书中都非常小心地以端无关的方式编写代码。我们通过避免直接将多个字节直接转换为整数,而是逐个解释字节来实现这一点。

例如,考虑一个包含单个 8 位值的消息,例如 0x05。我们知道该消息的值是 5。字节以原子方式通过网络链路发送,所以我们还知道接收我们消息的任何人都可以明确地将该消息解释为 5

当我们需要多个字节来存储我们的数字时,字节序的问题就会出现。想象一下,我们想要发送数字999。这个数字太大,无法放入 1 个字节中,所以我们必须将其拆分为 2 个字节——一个 16 位值。因为999 = (3 * 2⁸) + 231,我们知道高位字节存储3,而低位字节存储231。在十六进制中,数字9990x03E7。问题是先发送高位字节还是低位字节。

DNS 协议使用的网络字节序指定高位字节首先发送。因此,数字999在网络上是作为0x03后跟0xE7发送的。

有关更多信息,请参阅本章的进一步阅读部分。

现在我们来看看如何编码整个 DNS 查询。

一个简单的 DNS 查询

要执行一个简单的 DNS 查询,我们将任意数字放入ID,将RD位设置为1,并将QDCOUNT设置为1。然后我们在头部之后添加一个问题。这些数据将作为 UDP 数据包发送到 DNS 服务器的端口53

用 C 语言编写的example.com的 DNS 查询如下:

char dns_query[] = {0xAB, 0xCD,                           /* ID */
                    0x01, 0x00,                           /* Recursion */
                    0x00, 0x01,                           /* QDCOUNT */
                    0x00, 0x00,                           /* ANCOUNT */
                    0x00, 0x00,                           /* NSCOUNT */
                    0x00, 0x00,                           /* ARCOUNT */
                    7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', /* label */
                    3, 'c', 'o', 'm',                     /* label */
                    0,                                    /* End of name */
                    0x00, 0x01,                           /* QTYPE = A */
                    0x00, 0x01                            /* QCLASS */
                    };

这些数据可以直接发送到 DNS 服务器的端口53

DNS 服务器,如果成功,会发送一个 UDP 数据包作为响应。这个数据包将ID设置为与我们的查询匹配。QR设置为指示一个响应。QDCOUNT设置为1,并包含我们的原始问题。ANCOUNT是一个小的正整数,表示消息中包含的答案数量。

在下一节中,我们将实现一个程序来发送和接收 DNS 消息。

DNS 查询程序

现在我们将实现一个实用工具,用于向 DNS 服务器发送 DNS 查询并接收 DNS 响应。

在实际应用中通常不需要这个函数。然而,这是一个更好地理解 DNS 协议并获得发送二进制 UDP 数据包经验的好机会。

我们从一个 DNS 消息中打印名称的函数开始。

打印 DNS 消息名称

DNS 以特定方式编码名称。通常,每个标签由其长度指示,后跟其文本。可以重复多个标签,然后使用单个 0 字节终止名称。

如果一个长度的两个最高位被设置(即0xc0),那么它和下一个字节应该被解释为一个指针。

我们必须始终意识到,DNS 服务器返回的 DNS 响应可能是格式不正确或损坏的。我们必须尽量编写我们的程序,以便在接收到坏消息时不会崩溃。说起来容易做起来难。

我们名称打印函数的声明如下:

/*dns_query.c*/

const unsigned char *print_name(const unsigned char *msg,
        const unsigned char *p, const unsigned char *end);

我们将msg视为消息开始的指针,p视为打印名称的指针,end视为消息结束之后的指针。end是必需的,这样我们就可以检查我们是否没有读取到接收到的消息的末尾。msg也是必需的,但也是为了我们可以解释名称指针。

print_name 函数内部,我们的代码检查一个合适的名称是否可能。因为一个名称应该至少包含一个长度和一些文本,如果 p 已经在末尾两个字符之内,我们可以返回一个错误。该检查的代码如下:

/*dns_query.c*/

    if (p + 2 > end) {
        fprintf(stderr, "End of message.\n"); exit(1);}

我们然后检查 p 是否指向一个名称指针。如果是,我们解释该指针并递归调用 print_name 来打印指向的名称。该代码如下:

/*dns_query.c*/

    if ((*p & 0xC0) == 0xC0) {
        const int k = ((*p & 0x3F) << 8) + p[1];
        p += 2;
        printf(" (pointer %d) ", k);
        print_name(msg, msg+k, end);
        return p;

注意,二进制中的 0xC00b11000000。我们使用 (*p & 0xC0) == 0xC0 来检查名称指针。在这种情况下,我们取 *p 的低 6 位和 p[1] 的所有 8 位来表示指针。由于我们之前检查 p 至少在消息末尾 2 个字节处,我们知道 p[1] 仍然在消息内。知道了名称指针后,我们可以将新的 p 值传递给 print_name()

如果名称不是一个指针,我们简单地逐个标签打印它。打印名称的代码如下:

/*dns_query.c*/

    } else {
        const int len = *p++;
        if (p + len + 1 > end) {
            fprintf(stderr, "End of message.\n"); exit(1);}

        printf("%.*s", len, p);
        p += len;
        if (*p) {
            printf(".");
            return print_name(msg, p, end);
        } else {
            return p+1;
        }
    }

在前面的代码中,*p 被读取到 len 中以存储当前标签的长度。我们小心地检查读取 len + 1 字节不会使我们超出缓冲区的末尾。然后我们可以打印出控制台上的下一个 len 个字符。如果下一个字节不是 0,那么名称将继续,我们应该打印一个点来分隔标签。我们递归调用 print_name() 来打印名称的下一个标签。

如果下一个字节是 0,那么这意味着名称已结束,我们返回。

这就完成了 print_name() 函数。

现在我们将设计一个函数来打印整个 DNS 消息。

打印 DNS 消息

使用我们刚刚定义的 print_name(),我们现在可以构建一个函数来将整个 DNS 消息打印到屏幕上。DNS 消息在请求和响应中都使用相同的格式,所以我们的函数能够打印出任何一种。

我们函数的声明如下:

/*dns_query.c*/

void print_dns_message(const char *message, int msg_length);

print_dns_message() 接收一个指向消息开始的指针,以及一个 int 数据类型来指示消息的长度。

print_dns_message() 函数内部,我们首先检查消息是否足够长,以成为有效的 DNS 消息。回想一下,DNS 头部长度为 12 字节。如果一个 DNS 消息小于 12 字节,我们可以轻松地将其拒绝为无效消息。这也确保了我们至少可以读取头部,而不用担心读取到接收数据的末尾。

检查 DNS 消息长度的代码如下:

/*dns_query.c*/

    if (msg_length < 12) {
        fprintf(stderr, "Message is too short to be valid.\n");
        exit(1);
    }

然后,我们将 message 指针复制到一个新变量 msg 中。我们定义 msg 为一个 unsigned char 指针,这使得某些计算更容易处理。

/*dns_query.c*/

    const unsigned char *msg = (const unsigned char *)message;

如果你想打印出整个原始 DNS 消息,你可以使用以下代码:

/*dns_query.c*/

    int i;
    for (i = 0; i < msg_length; ++i) {
        unsigned char r = msg[i];
        printf("%02d:   %02X  %03d  '%c'\n", i, r, r, r);
    }
    printf("\n");

注意,运行前面的代码将打印出许多行。这可能会很烦人,所以我建议只有在你好奇想看到原始 DNS 消息时才使用它。

消息 ID 可以非常容易地打印出来。回想一下,消息 ID 只是消息的前两个字节。以下代码以良好的十六进制格式打印它:

/*dns_query.c*/

    printf("ID = %0X %0X\n", msg[0], msg[1]);

接下来,我们从消息头中获取 QR 位。这个位是 msg[2] 的最高有效位。我们使用掩码 0x80 来查看它是否被设置。如果是,我们知道这是一个响应消息;否则,它是一个查询。以下代码读取 QR 并打印相应的消息:

/*dns_query.c*/

    const int qr = (msg[2] & 0x80) >> 7;
    printf("QR = %d %s\n", qr, qr ? "response" : "query");

OPCODEAATCRD 字段与 QR 的读取方式几乎相同。打印它们的代码如下:

/*dns_query.c*/

    const int opcode = (msg[2] & 0x78) >> 3;
    printf("OPCODE = %d ", opcode);
    switch(opcode) {
        case 0: printf("standard\n"); break;
        case 1: printf("reverse\n"); break;
        case 2: printf("status\n"); break;
        default: printf("?\n"); break;
    }

    const int aa = (msg[2] & 0x04) >> 2;
    printf("AA = %d %s\n", aa, aa ? "authoritative" : "");

    const int tc = (msg[2] & 0x02) >> 1;
    printf("TC = %d %s\n", tc, tc ? "message truncated" : "");

    const int rd = (msg[2] & 0x01);
    printf("RD = %d %s\n", rd, rd ? "recursion desired" : "");

最后,我们可以读取响应类型消息的 RCODE。由于 RCODE 可以有多个不同的值,我们使用 switch 语句来打印它们。以下是相应的代码:

/*dns_query.c*/

    if (qr) {
        const int rcode = msg[3] & 0x07;
        printf("RCODE = %d ", rcode);
        switch(rcode) {
            case 0: printf("success\n"); break;
            case 1: printf("format error\n"); break;
            case 2: printf("server failure\n"); break;
            case 3: printf("name error\n"); break;
            case 4: printf("not implemented\n"); break;
            case 5: printf("refused\n"); break;
            default: printf("?\n"); break;
        }
        if (rcode != 0) return;
    }

标头中的下一个四个字段是问题计数、答案计数、名称服务器计数和附加计数。我们可以在以下代码中读取并打印它们:

/*dns_query.c*/

    const int qdcount = (msg[4] << 8) + msg[5];
    const int ancount = (msg[6] << 8) + msg[7];
    const int nscount = (msg[8] << 8) + msg[9];
    const int arcount = (msg[10] << 8) + msg[11];

    printf("QDCOUNT = %d\n", qdcount);
    printf("ANCOUNT = %d\n", ancount);
    printf("NSCOUNT = %d\n", nscount);
    printf("ARCOUNT = %d\n", arcount);

这就完成了 DNS 消息头(前 12 个字节)的读取。

在读取消息的其余部分之前,我们定义了两个新变量,如下所示:

/*dns_query.c*/

    const unsigned char *p = msg + 12;
    const unsigned char *end = msg + msg_length;

在前面的代码中,p 变量用于遍历消息。我们将 end 变量设置为消息结束之后的一个位置。这是为了帮助我们检测是否即将读取到消息的末尾——这是一个我们当然希望避免的情况!

我们使用以下代码读取并打印 DNS 消息中的每个问题:

/*dns_query.c*/

    if (qdcount) {
        int i;
        for (i = 0; i < qdcount; ++i) {
            if (p >= end) {
                fprintf(stderr, "End of message.\n"); exit(1);}

            printf("Query %2d\n", i + 1);
            printf("  name: ");

            p = print_name(msg, p, end); printf("\n");

            if (p + 4 > end) {
                fprintf(stderr, "End of message.\n"); exit(1);}

            const int type = (p[0] << 8) + p[1];
            printf("  type: %d\n", type);
            p += 2;

            const int qclass = (p[0] << 8) + p[1];
            printf(" class: %d\n", qclass);
            p += 2;
        }
    }

尽管没有实际的 DNS 服务器会接受包含多个问题的消息,但 DNS RFC 明确定义了编码多个问题的格式。因此,我们使我们的代码通过 for 循环遍历每个问题。首先,调用我们之前定义的 print_name() 函数来打印问题名称。然后,读取并打印问题类型和类别。

打印答案、授权和附加部分比打印问题部分稍微困难一些。这些部分以与问题部分相同的方式开始——有一个名称、一个类型和一个类别。读取名称、类型和类别的代码如下:

/*dns_query.c*/

    if (ancount || nscount || arcount) {
        int i;
        for (i = 0; i < ancount + nscount + arcount; ++i) {
            if (p >= end) {
                fprintf(stderr, "End of message.\n"); exit(1);}

            printf("Answer %2d\n", i + 1);
            printf("  name: ");

            p = print_name(msg, p, end); printf("\n");

            if (p + 10 > end) {
                fprintf(stderr, "End of message.\n"); exit(1);}

            const int type = (p[0] << 8) + p[1];
            printf("  type: %d\n", type);
            p += 2;

            const int qclass = (p[0] << 8) + p[1];
            printf(" class: %d\n", qclass);
            p += 2;

注意,在前面提到的代码中,我们将类别存储在一个名为 qclass 的变量中。这是为了对 C++ 朋友友好,他们不允许将 class 作为变量名。

然后,我们期望找到一个 16 位的 TTL 字段和一个 16 位的长度字段。TTL 字段告诉我们可以缓存答案多少秒。数据长度字段告诉我们答案中包含多少字节的附加数据。我们在以下代码中读取 TTL 和数据长度:

/*dns_query.c*/

            const unsigned int ttl = (p[0] << 24) + (p[1] << 16) +
                (p[2] << 8) + p[3];
            printf("   ttl: %u\n", ttl);
            p += 4;

            const int rdlen = (p[0] << 8) + p[1];
            printf(" rdlen: %d\n", rdlen);
            p += 2;

在读取 rdlen 长度的数据之前,我们应该检查我们不会读取到消息的末尾。以下代码实现了这一点:

/*dns_query.c*/

            if (p + rdlen > end) {
                fprintf(stderr, "End of message.\n"); exit(1);}

然后,我们可以尝试解释答案数据。每种记录类型存储不同的数据。我们需要编写代码来显示每种类型。就我们的目的而言,我们限制这仅限于 AMXAAAATXTCNAME 记录。打印每种类型的代码如下:

/*dns_query.c*/

            if (rdlen == 4 && type == 1) { /* A Record */
                printf("Address ");
                printf("%d.%d.%d.%d\n", p[0], p[1], p[2], p[3]);

            } else if (type == 15 && rdlen > 3) { /* MX Record */
                const int preference = (p[0] << 8) + p[1];
                printf("  pref: %d\n", preference);
                printf("MX: ");
                print_name(msg, p+2, end); printf("\n");

            } else if (rdlen == 16 && type == 28) { /* AAAA Record */
                printf("Address ");
                int j;
                for (j = 0; j < rdlen; j+=2) {
                    printf("%02x%02x", p[j], p[j+1]);
                    if (j + 2 < rdlen) printf(":");
                }
                printf("\n");

            } else if (type == 16) { /* TXT Record */
                printf("TXT: '%.*s'\n", rdlen-1, p+1);

            } else if (type == 5) { /* CNAME Record */
                printf("CNAME: ");
                print_name(msg, p, end); printf("\n");
            }
            p += rdlen;

我们可以完成循环。我们检查是否读取了所有数据,如果没有,则打印一条消息。如果我们的程序是正确的,并且 DNS 消息格式正确,我们应该已经读取了所有数据而没有剩余。以下代码检查这一点:

/*dns_query.c*/

        }
    }

    if (p != end) {
        printf("There is some unread data left over.\n");
    }

    printf("\n");

这就完成了print_dns_message()函数。

我们现在可以定义我们的main()函数来创建 DNS 查询,将其发送到 DNS 服务器,并等待响应。

发送查询

我们从以下代码开始main()

/*dns_query.c*/

int main(int argc, char *argv[]) {

    if (argc < 3) {
        printf("Usage:\n\tdns_query hostname type\n");
        printf("Example:\n\tdns_query example.com aaaa\n");
        exit(0);
    }

    if (strlen(argv[1]) > 255) {
        fprintf(stderr, "Hostname too long.");
        exit(1);
    }

上述代码检查用户是否传递了要查询的域名和记录类型。如果没有,它将打印一条有用的消息。它还检查域名长度是否不超过 255 个字符。超过这个长度的域名不被 DNS 标准允许,现在检查它确保我们不需要分配太多的内存。

然后,我们尝试解释用户请求的记录类型。我们支持以下选项 - aaaaatxtmxany。读取这些类型并存储它们对应的 DNS 整数值的代码如下:

/*dns_query.c*/

    unsigned char type;
    if (strcmp(argv[2], "a") == 0) {
        type = 1;
    } else if (strcmp(argv[2], "mx") == 0) {
        type = 15;
    } else if (strcmp(argv[2], "txt") == 0) {
        type = 16;
    } else if (strcmp(argv[2], "aaaa") == 0) {
        type = 28;
    } else if (strcmp(argv[2], "any") == 0) {
        type = 255;
    } else {
        fprintf(stderr, "Unknown type '%s'. Use a, aaaa, txt, mx, or any.",
                argv[2]);
        exit(1);
    }

就像我们之前所有的程序一样,我们需要初始化 Winsock。以下是其代码:

/*dns_query.c*/

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

我们程序连接到8.8.8.8,这是一个由 Google 运行的可公开 DNS 服务器。请参阅第一章,《网络和协议简介》中的《域名》部分,以获取您可以使用的一些其他公共 DNS 服务器列表。

记住,我们正在连接 UDP 端口53。我们使用getaddrinfo()通过以下代码设置套接字所需的结构:

/*dns_query.c*/

    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_DGRAM;
    struct addrinfo *peer_address;
    if (getaddrinfo("8.8.8.8", "53", &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

我们接着使用getaddrinfo()返回的数据创建我们的套接字。以下代码实现了这一点:

/*dns_query.c*/

    printf("Creating socket...\n");
    SOCKET socket_peer;
    socket_peer = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_peer)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

然后,我们的程序构建 DNS 查询消息的数据。前 12 个字节组成头部,在编译时已知。我们可以用以下代码存储它们:

/*dns_query.c*/

    char query[1024] = {0xAB, 0xCD, /* ID */
                        0x01, 0x00, /* Set recursion */
                        0x00, 0x01, /* QDCOUNT */
                        0x00, 0x00, /* ANCOUNT */
                        0x00, 0x00, /* NSCOUNT */
                        0x00, 0x00 /* ARCOUNT */};

上述代码将我们的查询 ID 设置为0xABCD,设置递归请求,并指示我们附加1个问题。如前所述,1是现实世界中 DNS 服务器支持的唯一问题数量。

我们需要将用户想要的域名编码到查询中。以下代码实现了这一点:

/*dns_query.c*/

    char *p = query + 12;
    char *h = argv[1];

    while(*h) {
        char *len = p;
        p++;
        if (h != argv[1]) ++h;

        while(*h && *h != '.') *p++ = *h++;
        *len = p - len - 1;
    }

    *p++ = 0;

上述代码首先将一个新指针p设置为查询头部的末尾。我们将从p开始添加到查询中。我们还定义了一个指针h,我们用它来遍历域名。

我们可以循环直到*h != 0,因为当我们完成读取域名时*h等于零。在循环内部,我们使用len变量来存储标签开始的位臵。这个位置的值需要设置为指示即将到来的标签的长度。然后我们从*h复制字符到*p,直到找到一个点或域名的末尾。如果找到任何一个,代码将*len设置为标签长度。然后代码进入下一个标签的循环。

最后,在循环外部,我们添加一个终止的 0 字节来完成问题的名称部分。

然后,我们使用以下代码将问题类型和问题类别添加到查询中:

/*dns_query.c*/

    *p++ = 0x00; *p++ = type; /* QTYPE */
    *p++ = 0x00; *p++ = 0x01; /* QCLASS */

然后,我们可以通过比较p与查询开始处来计算查询大小。计算总查询大小的代码如下:

/*dns_query.c*/

    const int query_size = p - query;

现在,查询消息已经形成,其长度已知,我们可以使用sendto()将 DNS 查询发送到 DNS 服务器。发送查询的代码如下:

/*dns_query.c*/

    int bytes_sent = sendto(socket_peer,
            query, query_size,
            0,
            peer_address->ai_addr, peer_address->ai_addrlen);
    printf("Sent %d bytes.\n", bytes_sent);

为了调试目的,我们还可以使用以下代码显示我们发送的查询:

/*dns_query.c*/

    print_dns_message(query, query_size);

上述代码有助于查看我们是否在编码查询时犯了任何错误。

现在查询已经发送,我们使用recvfrom()等待 DNS 响应消息。在实际程序中,您可能希望在这里使用select()来超时。如果首先收到无效消息,监听额外消息可能也是明智的。

接收和显示 DNS 响应的代码如下:

/*dns_query.c*/

    char read[1024];
    int bytes_received = recvfrom(socket_peer,
            read, 1024, 0, 0, 0);

    printf("Received %d bytes.\n", bytes_received);

    print_dns_message(read, bytes_received);
    printf("\n");

我们可以通过从getaddrinfo()释放地址并清理 Winsock 来完成我们的程序。完成main()函数的代码如下:

/*dns_query.c*/

    freeaddrinfo(peer_address);
    CLOSESOCKET(socket_peer);

#if defined(_WIN32)
    WSACleanup();
#endif

    return 0;
}

这就完成了dns_query程序。

您可以通过运行以下命令在 Linux 和 macOS 上编译和运行dns_query.c

gcc dns_query.c -o dns_query
./dns_query example.com a

使用 MinGW 在 Windows 上编译和运行可以通过以下命令完成:

gcc dns_query.c -o dns_query.exe -lws2_32
dns_query.exe example.com a

尝试使用不同的域名和不同的记录类型运行dns_query。特别是,尝试使用mxtxt记录。如果你有勇气,可以尝试使用any记录类型。你可能会发现结果很有趣。

以下截图是使用dns_query查询example.comA记录的示例:

下一张截图显示了dns_query正在查询gmail.commx记录:

注意,UDP 并不总是可靠的。如果我们的 DNS 查询在传输过程中丢失,那么dns_query将无限期地挂起,等待永远不会到来的回复。这可以通过使用select()函数超时并重试来修复。

摘要

本章主要介绍了主机名和 DNS 查询。我们了解了 DNS 的工作原理,并了解到解析主机名可能涉及在网络中发送许多 UDP 数据包。

我们更深入地研究了getaddrinfo(),并展示了为什么它通常是进行主机名查找的首选方式。我们还研究了其姐妹函数getnameinfo(),该函数可以将地址转换为文本,甚至执行反向 DNS 查询。

最后,我们实现了一个从头开始发送 DNS 查询的程序。这个程序是一个很好的学习经验,有助于更好地理解 DNS 协议,并给了我们实现二进制协议的机会。在实现二进制协议时,我们必须特别注意字节序。对于简单的 DNS 消息格式,这是通过逐个仔细解释字节来实现的。

现在我们已经与二进制协议 DNS 一起工作,接下来几章我们将转向基于文本的协议。在下一章,我们将学习 HTTP,这是请求和检索网页所使用的协议。

问题

尝试这些问题来测试你对本章知识的掌握:

  1. 哪个函数以可移植和协议无关的方式填充套接字编程所需的地址?

  2. 哪个套接字编程函数可以将 IP 地址转换回域名?

  3. DNS 查询将域名转换为地址,而反向 DNS 查询将地址转换回域名。如果你对一个域名执行 DNS 查询,然后对结果地址执行反向 DNS 查询,你是否总是能回到最初的域名?

  4. 使用哪些 DNS 记录类型来为域名返回 IPv4 和 IPv6 地址?

  5. 哪种 DNS 记录类型存储有关电子邮件服务器的特殊信息?

  6. getaddrinfo() 总是立即返回吗?或者它可以阻塞?

  7. 当 DNS 响应太大而无法放入单个 UDP 数据包时会发生什么?

答案在 附录 A,问题解答 中。

进一步阅读

想了解更多关于 DNS 的信息,请参考:

第二部分 - 应用层协议概述

在本节中,读者将了解两种最常见的较高级协议,HTTP 和 SMTP,它们建立在 TCP 之上。

以下章节包含在本节中:

第六章,构建简单的 Web 客户端

第七章,构建简单的 Web 服务器

第八章,构建发送电子邮件的程序

第六章:构建简单的 Web 客户端

超文本传输协议HTTP)是推动万维网(WWW)的应用协议。每次您打开网络浏览器进行互联网搜索、浏览维基百科或在社会媒体上发帖时,您都在使用 HTTP。许多移动应用程序也暗中使用了 HTTP。可以说,HTTP 是互联网上使用最广泛的协议之一。

在本章中,我们将查看 HTTP 消息格式。然后我们将实现一个 C 程序,该程序可以请求和接收网页。

本章涵盖了以下主题:

  • HTTP 消息格式

  • HTTP 请求类型

  • 常见 HTTP 头

  • HTTP 响应代码

  • HTTP 消息解析

  • 实现 HTTP 客户端

  • 编码表单数据(POST

  • HTTP 文件上传

技术要求

本章的示例程序可以用任何现代 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC。有关编译器设置的更多信息,请参阅附录 B、C 和 D。

本书代码可在github.com/codeplea/Hands-On-Network-Programming-with-C找到。

从命令行,您可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap06

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要与 Winsock 库链接。这是通过将-lws2_32选项传递给gcc来实现的。

我们提供编译每个示例所需的精确命令,正如它们被介绍时那样。

本章中的所有示例程序都需要我们在第二章,掌握套接字 API中开发的相同头文件和 C 宏。为了简洁起见,我们将这些语句放在一个单独的头文件chap06.h中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅第二章,掌握套接字 API

chap06.h的内容如下:

//chap06.h//

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <clock.h>

HTTP 协议

HTTP 是一种基于文本的客户端-服务器协议,它运行在 TCP 之上。纯 HTTP 运行在 TCP 端口80

应注意,出于安全原因,纯 HTTP 已被大多数情况下弃用。今天,网站应使用 HTTPS,这是 HTTP 的安全版本。HTTPS 通过仅将 HTTP 协议通过传输层安全性TLS)层运行来保护 HTTP。因此,本章中关于 HTTP 的所有内容也适用于 HTTPS。有关 HTTPS 的更多信息,请参阅第九章,使用 HTTPS 和 OpenSSL 加载安全网页

HTTP 通过首先让网络客户端向网络服务器发送 HTTP 请求来工作。然后,网络服务器以 HTTP 响应的形式进行响应。通常,HTTP 请求指示客户端感兴趣的资源,而 HTTP 响应则提供所请求的资源。

从视觉上看,交易在以下图形中得到了说明:

前面的图形说明了GET请求。当Web 客户端只想让Web 服务器发送文档、图像、文件、网页等内容时,会使用GET请求。GET请求是最常见的。它们就是你的浏览器在加载网页或下载文件时发送给Web 服务器的请求。

还有几种其他请求类型也值得提及。

HTTP 请求类型

虽然GET请求是最常见的,但可能有三类请求类型被广泛使用。以下三个常见的 HTTP 请求类型如下:

  • 当客户端想要下载资源时,使用GET

  • HEAD请求与GET请求类似,但客户端只想获取资源信息,而不是资源本身。例如,如果客户端只想知道托管文件的大小,它可以发送一个HEAD请求。

  • POST用于客户端需要向服务器发送信息时。例如,当你在网上提交表单时,你的网络浏览器通常使用POST请求。POST请求通常会导致网络服务器以某种方式改变其状态。网络服务器可能会发送电子邮件、更新数据库或响应POST请求更改文件。

除了GETHEADPOST之外,还有一些很少使用的 HTTP 请求类型。它们如下:

  • PUT用于将文档发送到网络服务器。PUT不常用。POST几乎被普遍用来改变网络服务器状态。

  • DELETE用于请求网络服务器删除文档或资源。同样,在实践中,DELETE很少使用。POST通常用于传达各种类型的网络服务器更新。

  • TRACE用于请求从 Web 代理获取诊断信息。大多数 Web 请求不会通过代理,而且许多 Web 代理不完全支持TRACE。因此,很少需要使用TRACE

  • CONNECT有时用于通过代理服务器初始化 HTTP 连接。

  • OPTIONS用于询问服务器支持哪些 HTTP 请求类型。一个典型的实现OPTIONS的 Web 服务器可能会响应类似于Allow: OPTIONS, GET, HEAD, POST的内容。许多常见的 Web 服务器不支持OPTIONS

如果你发送一个网络服务器不支持请求,那么服务器应该响应一个400 Bad Request代码。

现在我们已经看到了 HTTP 请求的类型,让我们更详细地看看请求格式。

HTTP 请求格式

如果你打开你的网络浏览器并导航到http://www.example.com/page1.htm,你的浏览器需要向www.example.com上的网络服务器发送一个 HTTP 请求。这个 HTTP 请求可能看起来像这样:

GET /page1.htm HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Accept-Language: en-US
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate
Host: example.com
Connection: Keep-Alive

如您所见,浏览器默认发送一个GET请求。这个GET请求是向服务器请求文档/page1.htm。一个GET请求只包含 HTTP 头部信息。因为没有 HTTP 主体,客户端没有向服务器发送数据。客户端只是请求服务器上的数据。相比之下,一个POST请求将包含一个 HTTP 主体。

HTTP 请求的第一行被称为请求行。请求行由三部分组成——请求类型、文档路径和协议版本。每个部分之间由空格分隔。在上面的例子中,请求行是GET /page1.htm HTTP/1.1。我们可以看到请求类型是GET,文档路径是/page1.htm,协议版本是HTTP/1.1

当处理基于文本的网络协议时,始终明确行结束是很重要的。这是因为不同的操作系统采用了不同的行结束约定。HTTP 消息的每一行都以一个回车符,后跟一个换行符结束。在 C 语言中,这看起来像\r\n。实际上,一些 Web 服务器可能容忍其他行结束符。您应该确保您的客户端始终发送正确的\r\n行结束符以实现最大兼容性。

在请求行之后,有各种 HTTP 头部字段。每个头部字段由其名称后跟一个冒号,然后是其值组成。考虑一下User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36这一行。这条User-Agent行是在告诉 Web 服务器正在联系它的软件是什么。一些 Web 服务器会向不同的用户代理提供不同的文档。例如,一些网站向搜索引擎蜘蛛提供完整的文档,同时向实际访客提供付费墙。服务器通常使用用户代理 HTTP 头部字段来确定哪个是哪个。同时,在用户代理字段中存在许多 Web 客户端欺骗的历史。我建议您在您的应用程序中走正道,并使用一个独特的用户代理值清楚地标识您的应用程序。

唯一真正必需的头部字段是HostHost字段告诉 Web 服务器客户端正在请求哪个 Web 主机上的资源。这很重要,因为一个 Web 服务器可能托管多个不同的网站。请求行告诉 Web 服务器想要/page1.htm文档,但它没有指定该页面在哪个服务器上。Host字段填补了这个角色。

Connection: Keep-Alive行告诉 Web 服务器,在当前请求完成后,HTTP 客户端希望发出额外的请求。如果客户端发送了Connection: Close,那么这表明客户端打算在收到 HTTP 响应后关闭 TCP 连接。

Web 客户端必须在 HTTP 请求头部之后发送一个空白行。这个空白行是 Web 服务器知道 HTTP 请求已经结束的方式。如果没有这个空白行,Web 服务器将不知道是否还有额外的头部字段正在发送。在 C 语言中,空白行看起来像这样:\r\n\r\n

现在让我们考虑 Web 服务器会对 HTTP 请求发送什么响应。

HTTP 响应格式

与 HTTP 请求一样,HTTP 响应也由头部部分和主体部分组成。同样,与 HTTP 请求类似,主体部分是可选的。尽管如此,大多数 HTTP 响应都有主体部分。

www.example.com服务器可以对我们发送的 HTTP 请求做出以下响应:

HTTP/1.1 200 OK
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Fri, 14 Dec 2018 16:46:09 GMT
Etag: "1541025663+gzip"
Expires: Fri, 21 Dec 2018 16:46:09 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (ord/5730)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1270

<!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

HTTP 响应的第一行是状态行。状态行由协议版本、响应代码和响应代码描述组成。在上面的示例中,我们可以看到协议版本是HTTP/1.1,响应代码是200,响应代码描述是OK200 OK是当一切顺利时对 HTTP GET请求的典型响应代码。如果服务器找不到客户端请求的资源,它可能会用404 Page Not Found响应代码来响应。

许多 HTTP 响应头部用于辅助缓存。DateEtagExpiresLast-Modified字段都可以被客户端用来缓存文档。

Content-Type字段告知客户端发送的资源类型。在上面的示例中,它是一个 HTML 网页,使用text/html指定。HTTP 可以用来发送所有类型的资源,例如图片、软件和视频。每种资源类型都有一个特定的Content-Type,它告诉客户端如何解释该资源。

Content-Length字段指定了 HTTP 响应主体的字节数。在这种情况下,我们看到请求的资源长度为1270字节。有几种方法可以确定主体长度,但Content-Length字段是最简单的方法。我们将在本章后面的“响应主体长度”部分探讨其他方法。

HTTP 响应头部部分通过一个空白行与 HTTP 响应主体部分分开。在这个空白行之后,跟随的是 HTTP 主体。请注意,HTTP 主体不一定是基于文本的。例如,如果客户端请求了一个图片,那么 HTTP 主体很可能是二进制数据。同时考虑,如果 HTTP 主体是基于文本的,例如一个 HTML 网页,它可以使用自己的行结束约定。它不必使用 HTTP 要求的\r\n行结束符。

如果客户端发送的是 HEAD 请求类型而不是GET,那么服务器将响应与之前完全相同的 HTTP 头部,但不会包含 HTTP 主体。

在定义了 HTTP 响应格式之后,让我们看看一些最常见的 HTTP 响应类型。

HTTP 响应代码

存在许多不同类型的 HTTP 响应代码。

如果请求成功,则服务器会响应一个 200 范围的代码:

  • 200 OK: 客户端的请求成功,服务器发送请求的资源

如果资源已移动,服务器可以响应一个 300 范围的代码。这些代码通常用于将流量从未加密的连接重定向到加密连接,或将流量从 www 子域名重定向到裸域名。如果网站经过重构但希望保持入站链接正常工作,也会使用这些代码。常见的 300 范围代码如下:

  • 301 永久移动: 请求的资源已移动到新的位置。这个位置由服务器在 Location 头字段中指示。所有未来的对该资源的请求都应该使用这个新位置。

  • 307 临时移动: 请求的资源已移动到新的位置。这个位置由服务器在 Location 头字段中指示。这种移动可能不是永久的,因此未来的请求仍然应该使用原始位置。

错误由 400 或 500 范围的响应代码指示。一些常见的如下:

  • 400 错误请求: 服务器不理解/不支持客户端的请求

  • 401 未授权: 客户端没有权限访问请求的资源

  • 403 禁止访问: 客户端被禁止访问请求的资源

  • 500 内部服务器错误: 服务器在尝试满足客户端请求时遇到了错误

除了响应类型之外,HTTP 服务器还必须能够明确地传达响应体的长度。

响应体长度

HTTP 响应体长度可以通过几种不同的方式来确定。最简单的方式是如果 HTTP 服务器在其响应中包含一个 Content-Length 头行。在这种情况下,服务器直接声明了体长度。

如果服务器希望在知道体长度之前开始发送数据,则不能使用 Content-Length 头行。在这种情况下,服务器可以发送一个 Transfer-Encoding: chunked 头行。这个头行指示客户端响应体将以单独的数据块发送。每个数据块以数据块长度开始,该长度以十六进制(十六进制)编码,后跟一个换行符,然后是数据块数据。整个 HTTP 主体以一个零长度的数据块结束。

让我们考虑一个使用分块编码的 HTTP 响应示例:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=ascii
Transfer-Encoding: chunked

44
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eius
37
mod tempor incididunt ut labore et dolore magna aliqua.
0

在前面的示例中,我们看到 HTTP 主体以 44 开头,后跟一个换行符。这个 44 应该被解释为十六进制。我们可以使用内置的 C strtol() 函数来解释十六进制数字。

十六进制数字通常以 0x 前缀来区分它们与十进制数字。我们在这里使用这个前缀来标识它们,但请记住,HTTP 协议不会添加这个前缀。

十六进制的 0x44 数字等于十进制的 68。在 44 和换行符之后,我们看到 68 个字符是请求资源的一部分。在 68 个字符的数据块之后,服务器发送一个换行符。

服务器随后发送了370x37的十进制值是 55。换行后,发送 55 个字符作为块数据。然后服务器发送一个零长度的块来指示响应已结束。

客户端应在解码分块后将完整的 HTTP 响应解释为Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua

除了Content-LengthTransfer-Encoding: chunked之外,还有几种其他方式可以指示 HTTP 响应体长度。然而,除非客户端明确表示支持 HTTP 请求中的附加编码类型,否则服务器仅限于这两种。

您有时会看到服务器在传输完资源后简单地关闭 TCP 连接。这是HTTP/1.0中指示资源大小的一种常见方式。然而,不应使用HTTP/1.1。使用关闭的连接来指示响应长度的问题是它不明确为什么连接被关闭。这可能是因为所有数据都已发送,也可能是因为其他原因。考虑如果在数据传输过程中拔掉网络电缆会发生什么。

现在我们已经了解了 HTTP 请求和响应的基础,让我们看看如何识别网络资源。

URL 中有什么

统一资源定位符URL),也称为网页地址,提供了一种方便的方式来指定特定的网络资源。您可以通过在网页浏览器的地址栏中输入 URL 来导航到该 URL。或者,如果您正在浏览网页并点击链接,该链接将以 URL 的形式表示。

考虑http://www.example.com:80/res/page1.php?user=bob#account这个 URL。从视觉上看,URL 可以分解如下:

图片

URL 可以指示协议、主机、端口号、文档路径和哈希。然而,主机是唯一必需的部分。其他部分可以隐含。

我们可以解析前面图表中的示例 URL:

  • http://:第一个😕/之前的部分表示协议。在这个例子中,协议是http,但它可以是不同的协议,如ftp://https://。如果省略了协议,应用程序通常会做出假设。例如,您的网页浏览器会假设协议是http

  • www.example.com:这指定了主机名。它用于解析 HTTP 客户端可以连接到的 IP 地址。此主机名还必须出现在 HTTP 请求的Host头字段中。这是必需的,因为多个主机名可以解析到同一个 IP 地址。这部分也可以是一个 IP 地址而不是名称。IPv4 地址可以直接使用(http://192.168.50.1/),但 IPv6 地址应放在方括号内(http://[::1]/)。

  • :80:端口号可以通过在主机名后使用冒号来显式指定。如果没有指定端口号,则客户端使用给定协议的默认端口号。http的默认端口号是80https的默认端口号是443。非标准端口号在测试和开发中很常见。

  • /res/page1.php?user/bob:这指定了文档路径。HTTP 服务器通常会在问号之前和之后的部分之间做出区分,但 HTTP 客户端不应对此赋予任何意义。问号之后的部分通常被称为查询字符串。

  • #account:这被称为哈希。哈希指定了文档中的位置,哈希不会发送到 HTTP 服务器。相反,它允许浏览器在从 HTTP 服务器接收整个文档后滚动到文档的特定部分。

现在我们对 URL 有了基本的了解,让我们编写代码来解析它们。

解析 URL

我们将编写一个 C 函数来解析给定的 URL。

函数接受一个 URL 作为输入,并返回主机名、端口号和文档路径作为输出。为了避免需要手动管理内存,输出作为指向输入 URL 特定部分的指针返回。输入 URL 根据需要修改为终止的空指针。

我们的功能首先打印输入的 URL。这对于调试很有用。相应的代码如下:

/*web_get.c excerpt*/

void parse_url(char *url, char **hostname, char **port, char** path) {
    printf("URL: %s\n", url);

函数随后尝试在 URL 中找到://。如果找到,它将读取 URL 的第一部分作为协议。我们的程序只支持 HTTP。如果给定的协议不是 HTTP,则返回错误。解析协议的代码如下:

/*web_get.c excerpt*/

    char *p;
    p = strstr(url, "://");

    char *protocol = 0;
    if (p) {
        protocol = url;
        *p = 0;
        p += 3;
    } else {
        p = url;
    }

    if (protocol) {
        if (strcmp(protocol, "http")) {
            fprintf(stderr,
                    "Unknown protocol '%s'. Only 'http' is supported.\n",
                    protocol);
            exit(1);
        }
    }

在前面的代码中,声明了一个字符指针pprotocol也被声明并设置为0,以表示没有找到协议。调用strstr()在 URL 中搜索://。如果没有找到,则protocol保持为0p被设置为指向 URL 的开始。然而,如果找到了://,则protocol被设置为 URL 的开始,其中包含协议。p被设置为://之后的一个位置,这应该是主机名开始的地方。

如果设置了protocol,代码将检查它是否指向http文本。

在代码的这个点上,p指向主机名的开始。代码可以将主机名保存到返回变量hostname中。然后代码必须通过查找第一个冒号、斜杠或井号来扫描主机名的结束。相应的代码如下:

/*web_get.c excerpt*/

    *hostname = p;
    while (*p && *p != ':' && *p != '/' && *p != '#') ++p;

一旦p前进到主机名的末尾,我们必须检查是否找到了端口号。端口号以冒号开头。如果找到了端口号,我们的代码将把它返回到port变量中;否则,返回默认端口号80。检查端口号的代码如下:

/*web_get.c excerpt*/

    *port = "80";
    if (*p == ':') {
        *p++ = 0;
        *port = p;
    }
    while (*p && *p != '/' && *p != '#') ++p;

在端口号之后,p指向文档路径。函数将这个 URL 的部分返回到path变量中。请注意,我们的函数省略了路径中的第一个/。这是为了简化,因为它允许我们避免分配任何内存。所有文档路径都以/开始,因此当构造 HTTP 请求时,函数调用者可以轻松地将其前置。

设置path变量的代码如下:

/*web_get.c excerpt*/

    *path = p;
    if (*p == '/') {
        *path = p + 1;
    }
    *p = 0;

然后代码尝试找到一个哈希值,如果存在的话。如果存在,它会被一个终止的空字符覆盖。这是因为哈希值永远不会发送到 Web 服务器,并且被我们的 HTTP 客户端忽略。

跳转到哈希值的代码如下:

/*web_get.c excerpt*/

    while (*p && *p != '#') ++p;
    if (*p == '#') *p = 0;

我们的功能现在已经解析出了主机名、端口号和文档路径。然后它打印出这些值以进行调试,并返回。parse_url()函数的最终代码如下:

/*web_get.c excerpt*/

    printf("hostname: %s\n", *hostname);
    printf("port: %s\n", *port);
    printf("path: %s\n", *path);
}

现在我们有了解析 URL 的代码,我们离构建完整的 HTTP 客户端又近了一步。

实现网络客户端

我们现在将实现一个 HTTP 网络客户端。这个客户端接受一个 URL 作为输入。然后它尝试连接到主机并检索 URL 给出的资源。程序显示发送和接收的 HTTP 头信息,并尝试从 HTTP 响应中解析出请求的资源内容。

我们的项目开始于包含章节标题,chap06.h

/*web_get.c*/

#include "chap06.h"

我们接着定义一个常量,TIMEOUT。在程序的后半部分,如果 HTTP 响应完成需要超过TIMEOUT秒,那么我们的程序将放弃请求。你可以按自己的喜好定义TIMEOUT,但在这里我们给它赋值为五秒:

/*web_get.c continued*/

#define TIMEOUT 5.0

现在,请包含上一节中给出的整个parse_url()函数。我们的客户端需要parse_url()来从一个给定的 URL 中找到主机名、端口号和文档路径。

另一个辅助函数用于格式化和发送 HTTP 请求。我们称它为send_request(),其代码如下:

/*web_get.c continued*/

void send_request(SOCKET s, char *hostname, char *port, char *path) {
    char buffer[2048];

    sprintf(buffer, "GET /%s HTTP/1.1\r\n", path);
    sprintf(buffer + strlen(buffer), "Host: %s:%s\r\n", hostname, port);
    sprintf(buffer + strlen(buffer), "Connection: close\r\n");
    sprintf(buffer + strlen(buffer), "User-Agent: honpwc web_get 1.0\r\n");
    sprintf(buffer + strlen(buffer), "\r\n");

    send(s, buffer, strlen(buffer), 0);
    printf("Sent Headers:\n%s", buffer);
}

send_request()通过首先定义一个字符缓冲区来存储 HTTP 请求来实现。然后使用sprintf()函数将内容写入缓冲区,直到 HTTP 请求完成。HTTP 请求以一个空白行结束。这个空白行告诉服务器整个请求头已经接收完毕。

一旦请求被格式化为buffer,就使用send()通过一个打开的套接字发送bufferbuffer也被打印到控制台以进行调试。我们为我们的网络客户端定义了一个额外的辅助函数。这个函数,connect_to_host(),接受一个主机名和端口号,并尝试建立一个新的 TCP 套接字连接到它。

connect_to_host()的第一部分,使用getaddrinfo()解析主机名。然后使用getnameinfo()打印出服务器 IP 地址以进行调试。相应的代码如下:

/*web_get.c continued*/

SOCKET connect_to_host(char *hostname, char *port) {
    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_STREAM;
    struct addrinfo *peer_address;
    if (getaddrinfo(hostname, port, &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(peer_address->ai_addr, peer_address->ai_addrlen,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST);
    printf("%s %s\n", address_buffer, service_buffer);

connect_to_host()函数的第二部分,使用socket()创建一个新的套接字,并通过connect()建立 TCP 连接。如果一切顺利,函数将返回创建的套接字。connect_to_host()函数后半部分的代码如下:

/*web_get.c continued*/

    printf("Creating socket...\n");
    SOCKET server;
    server = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(server)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Connecting...\n");
    if (connect(server,
                peer_address->ai_addr, peer_address->ai_addrlen)) {
        fprintf(stderr, "connect() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }
    freeaddrinfo(peer_address);

    printf("Connected.\n\n");

    return server;
}

如果您从本书的开头开始工作,到connect_to_host()函数中的代码现在应该非常熟悉了。如果不熟悉,请参阅前面的章节,以获取关于getaddrinfo()socket()connect()的更详细解释。第三章,TCP 连接的深入概述,应该特别有帮助。

在处理完辅助函数后,我们现在可以开始定义main()函数。main()函数的起始代码如下:

/*web_get.c continued*/

int main(int argc, char *argv[]) {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

    if (argc < 2) {
        fprintf(stderr, "usage: web_get url\n");
        return 1;
    }
    char *url = argv[1];

在前面的代码中,如果需要,Winsock 被初始化,并检查程序的参数。如果提供了一个 URL 作为参数,它将被存储在url变量中。

我们可以使用以下代码将 URL 解析为其主机名、端口和路径部分:

/*web_get.c continued*/

    char *hostname, *port, *path;
    parse_url(url, &hostname, &port, &path);

程序接着通过建立与目标服务器的连接并发送 HTTP 请求来继续。这可以通过使用我们之前定义的两个辅助函数connect_to_host()send_request()来实现。相应的代码如下:

/*web_get.c continued*/

    SOCKET server = connect_to_host(hostname, port);
    send_request(server, hostname, port, path);

我们的网络客户端有一个特性,即如果请求完成时间过长,则会超时。为了知道已经过去了多少时间,我们需要记录开始时间。这通过调用内置的clock()函数来完成。我们使用以下方式将开始时间存储在start_time变量中:

/*web_get.c continued*/

    const clock_t start_time = clock();

现在需要定义一些变量,以便在接收和解析 HTTP 响应时进行记录。所需的变量如下:

/*web_get.c continued*/

#define RESPONSE_SIZE 8192
    char response[RESPONSE_SIZE+1];
    char *p = response, *q;
    char *end = response + RESPONSE_SIZE;
    char *body = 0;

    enum {length, chunked, connection};
    int encoding = 0;
    int remaining = 0;

在前面的代码中,RESPONSE_SIZE是我们为 HTTP 响应预留内存的最大大小。我们的程序无法解析大于此大小的 HTTP 响应。如果您扩展此限制,可能需要使用malloc()在堆上而不是栈上预留内存。

response是一个字符数组,用于存储整个 HTTP 响应。p是一个char指针,用于跟踪到目前为止已写入response的长度。q是一个稍后使用的额外char指针。我们定义end为一个char指针,它指向response缓冲区的末尾。end非常有用,可以确保我们不会尝试写入预留内存的末尾。

body指针用于在收到 HTTP 响应体后记住其起始位置。

如果你还记得,HTTP 响应体长度可以通过几种不同的方法来确定。我们定义了一个枚举来列出方法类型,并定义了encoding变量来存储实际使用的方法。最后,remaining变量用于记录完成 HTTP 主体或主体块还需要多少字节。

然后我们开始一个循环来接收和处理 HTTP 响应。这个循环首先检查它是否花费了太多时间,并且我们是否还有足够的空间来存储接收到的数据。这个循环的第一部分如下:

/*web_get.c continued*/

    while(1) {

        if ((clock() - start_time) / CLOCKS_PER_SEC > TIMEOUT) {
            fprintf(stderr, "timeout after %.2f seconds\n", TIMEOUT);
            return 1;
        }

        if (p == end) {
            fprintf(stderr, "out of buffer space\n");
            return 1;
        }

然后我们包含接收 TCP 套接字数据的代码。我们的代码使用 select() 并设置一个短的超时时间。这允许我们定期检查请求是否超时。您可能还记得,从前面的章节中,select() 涉及创建 fd_settimeval 结构。以下代码创建了这些对象并调用了 select()

/*web_get.c continued*/

        fd_set reads;
        FD_ZERO(&reads);
        FD_SET(server, &reads);

        struct timeval timeout;
        timeout.tv_sec = 0;
        timeout.tv_usec = 200000;

        if (select(server+1, &reads, 0, 0, &timeout) < 0) {
            fprintf(stderr, "select() failed. (%d)\n", GETSOCKETERRNO());
            return 1;
        }

select() 在超时时间已过或可以从套接字读取新数据时返回。我们的代码需要使用 FD_ISSET() 来确定是否有可读取的新数据。如果是这样,我们将数据读取到 p 指针指向的缓冲区中。

或者,当尝试读取新数据时,我们可能会发现套接字已被网络服务器关闭。如果是这种情况,我们检查是否期望一个关闭的连接来指示传输的结束。如果是这样,即 encoding == connection,我们将打印接收到的 HTTP 主体数据。

读取新数据并检测连接已关闭的代码如下:

/*web_get.c continued*/

        if (FD_ISSET(server, &reads)) {
            int bytes_received = recv(server, p, end - p, 0);
            if (bytes_received < 1) {
                if (encoding == connection && body) {
                    printf("%.*s", (int)(end - body), body);
                }

                printf("\nConnection closed by peer.\n");
                break;
            }

            /*printf("Received (%d bytes): '%.*s'",
                    bytes_received, bytes_received, p);*/

            p += bytes_received;
            *p = 0;

注意,在上述代码中,p 指针被向前移动以指向接收到的数据的末尾。*p 被设置为零,因此我们的接收数据始终以空终止符结束。这允许我们使用期望空终止字符串的标准函数来处理数据。例如,我们使用内置的 strstr() 函数在接收到的数据中搜索,而 strstr() 期望输入字符串是空终止的。

接下来,如果 HTTP 主体尚未找到,我们的代码会在接收到的数据中搜索一个空白行,该空白行指示 HTTP 标头的结束。空白行由两个连续的行结束符编码。HTTP 将行结束符定义为 \r\n,因此我们的代码通过搜索 \r\n\r\n 来检测空白行。

以下代码使用 strstr() 找到 HTTP 标头的结束(即 HTTP 主体的开始),并将 body 指针更新为指向 HTTP 主体开始的位置:

/*web_get.c continued*/

            if (!body && (body = strstr(response, "\r\n\r\n"))) {
                *body = 0;
                body += 4;

打印 HTTP 标头以进行调试可能是有用的。这可以通过以下代码完成:

/*web_get.c continued*/

                printf("Received Headers:\n%s\n", response);

现在已经接收了标头,我们需要确定 HTTP 服务器是使用 Content-Length 还是 Transfer-Encoding: chunked 来指示主体长度。如果它不发送任何一个,那么我们假设在连接关闭后已经接收了整个 HTTP 主体。

如果使用 strstr() 找到 Content-Length,我们将 encoding 设置为 length 并将主体长度存储在 remaining 变量中。实际长度是通过 strtol() 函数从 HTTP 标头中读取的。

如果找不到 Content-Length,则代码会搜索 Transfer-Encoding: chunked。如果找到,我们将 encoding 设置为 chunkedremaining 设置为 0 以指示我们尚未读取到块长度。

如果没有找到 Content-LengthTransfer-Encoding: chunked,则将 encoding = connection 设置为指示我们在连接关闭时认为已接收到 HTTP 正文。

确定使用哪种正文长度方法的代码如下:

/*web_get.c continued*/

                q = strstr(response, "\nContent-Length: ");
                if (q) {
                    encoding = length;
                    q = strchr(q, ' ');
                    q += 1;
                    remaining = strtol(q, 0, 10);

                } else {
                    q = strstr(response, "\nTransfer-Encoding: chunked");
                    if (q) {
                        encoding = chunked;
                        remaining = 0;
                    } else {
                        encoding = connection;
                    }
                }
                printf("\nReceived Body:\n");
            }

前面的代码可以通过进行不区分大小写的搜索或允许一些灵活性来改进其鲁棒性。然而,它应该可以与大多数网络服务器一起正常工作,我们将继续保持其简单性。

如果已经识别出 HTTP 正文开始,并且 encoding == length,则程序只需等待接收 remaining 字节。以下代码检查这一点:

/*web_get.c continued*/

            if (body) {
                if (encoding == length) {
                    if (p - body >= remaining) {
                        printf("%.*s", remaining, body);
                        break;
                    }

在前面的代码中,一旦接收到 remaining 字节的 HTTP 正文,它将打印接收到的正文并从 while 循环中退出。

如果使用 Transfer-Encoding: chunked,则接收逻辑要复杂一些。以下代码处理这种情况:

/*web_get.c continued*/

                } else if (encoding == chunked) {
                    do {
                        if (remaining == 0) {
                            if ((q = strstr(body, "\r\n"))) {
                                remaining = strtol(body, 0, 16);
                                if (!remaining) goto finish;
                                body = q + 2;
                            } else {
                                break;
                            }
                        }
                        if (remaining && p - body >= remaining) {
                            printf("%.*s", remaining, body);
                            body += remaining + 2;
                            remaining = 0;
                        }
                    } while (!remaining);
                }
            } //if (body)

在前面的代码中,remaining 变量用于指示是否期望下一个块长度或块数据。当 remaining == 0 时,程序正在等待接收新的块长度。每个块长度以换行符结束;因此,如果在 strstr() 中找到换行符,我们知道已经接收到了整个块长度。在这种情况下,块长度使用 strtol() 读取,它解释了十六进制块长度。remaining 被设置为期望的块长度。分块消息由零长度的块终止,因此如果读取到 0,代码使用 goto finish 来跳出主循环。

如果 remaining 变量非零,则程序检查是否已接收到至少 remaining 字节的数据。如果是这样,则打印该块,并将 body 指针向前推进到当前块的末尾。这种逻辑会一直循环,直到找到终止的零长度块或数据耗尽。

到目前为止,我们已经展示了解析 HTTP 响应体的所有逻辑。我们只需要结束循环,关闭套接字,程序就完成了。以下是 web_get.c 的最终代码:

/*web_get.c continued*/

        } //if FDSET
    } //end while(1)
finish:

    printf("\nClosing socket...\n");
    CLOSESOCKET(server);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

您可以使用以下命令在 Linux 和 macOS 上编译和运行 web_get.c

gcc web_get.c -o web_get
./web_get http://example.com/

在 Windows 上,使用 MinGW 编译和运行的命令如下:

gcc web_get.c -o web_get.exe -lws2_32
web_get.exe http://example.com/

尝试使用不同的 URL 运行 web_get 并研究输出。你可能对 HTTP 响应头感到有趣。

以下截图显示了我们在 http://example.com/ 上运行 web_get 时发生的情况:

图片

web_get 只支持 GET 查询。POST 查询也很常见且有用。现在让我们看看 HTTP POST 请求。

HTTP POST 请求

HTTP POST 请求将数据从网络客户端发送到网络服务器。与 HTTP GET 请求不同,POST 请求包含一个包含数据的正文(尽管这个正文可以是零长度的)。

POST 体的格式可能不同,应该通过 Content-Type 头部来识别。许多现代基于 Web 的 API 预期 POST 体的数据是 JSON 编码的。

考虑以下 HTTP POST 请求:

POST /orders HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:64.0)
Content-Type: application/json
Content-Length: 56
Connection: close

{"symbol":"VOO","qty":"10","side":"buy","type":"market"}

在前面的例子中,你可以看到 HTTP POST 请求与 HTTP GET 请求相似。明显的区别如下:请求以 POST 开头而不是 GET;包含了一个 Content-Type 头部字段;存在一个 Content-Length 头部字段;并且包含了一个 HTTP 消息体。在那个例子中,HTTP 消息体是 JSON 格式,如 Content-Type 头部所指定的。

编码表单数据

如果你在一个网站(如登录表单)上遇到一个表单,该表单很可能使用 POST 请求将其数据传输到 web 服务器。标准的 HTML 表单使用称为 URL 编码(也称为 百分编码)的格式来编码它发送的数据。当 URL 编码的表单数据在 HTTP POST 请求中提交时,它使用 Content-Type: application/x-www-form-urlencoded 头部。

考虑以下可用于提交的 HTML 表单:

<form method="post" action="/submission.php">

  <label for="name">Name:</label>
  <input name="name" type="text"><br>

  <label for="comment">Comment:</label>
  <input name="comment" type="text"><br>

  <input type="submit" value="submit">

</form>

在你的网页浏览器中,前面的 HTML 可能会渲染成以下截图所示:

当这个表单提交时,其数据被编码在以下类似的 HTTP 请求中:

POST /submission.php HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7)
Accept-Language: en-US
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Connection: keep-alive

name=Alice&comment=Well+Done%21

在前面的 HTTP 请求中,你可以看到使用了 Content-Type: application/x-www-form-urlencoded。在这个格式中,每个表单字段和值通过等号配对,多个表单字段通过 ampersands 连接。

表单字段名称或值中的特殊字符必须进行编码。注意,Well Done! 被编码为 Well+Done%21。空格用加号符号编码,特殊字符由一个百分号后跟它们的两位十六进制值编码(因此,感叹号被编码为 %21)。百分号本身会被编码为 %25

文件上传

当 HTML 表单包含文件上传时,浏览器使用不同的内容类型。在这种情况下,使用 Content-Type: multipart/form-data。当使用 Content-Type: multipart/form-data 时,包含了一个边界指定符。这个边界是一个特殊的分隔符,由发送者设置,用于分隔提交的表单数据的一部分。

考虑以下 HTML 表单:

<form method="post" enctype="multipart/form-data" action="/submit.php">
    <input name="name" type="text"><br>
    <input name="comment" type="text"><br>
    <input name="file" type="file"><br>
    <input type="submit" value="submit">
</form>

如果用户导航到包含前面代码的 HTML 表单的网页,并输入名字 Alice,评论 Well Done!,并选择一个名为 upload.txt 的文件上传,那么浏览器可能会发送以下 HTTP POST 请求:

POST /submit.php HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=-----------233121195710604
Content-Length: 1727

-------------233121195710604
Content-Disposition: form-data; name="name"

Alice
-------------233121195710604
Content-Disposition: form-data; name="comment"

Well Done!
-------------233121195710604
Content-Disposition: form-data; name="file"; filename="upload.txt"
Content-Type: text/plain

Hello.... <truncated>

如你所见,当使用 multipart/form-data 时,每个数据部分由一个边界分隔。这个边界是接收者区分单独字段或上传文件的关键。确保这个边界被选择,以便它不会出现在任何提交的字段或上传的文件中!

摘要

HTTP 是推动现代互联网的协议。它背后是每个网页、每个链接点击、每个加载的图形以及每个表单提交。在本章中,我们了解到 HTTP 是一种基于文本的协议,它运行在 TCP 连接之上。我们学习了客户端请求和服务器响应的 HTTP 格式。

在本章中,我们还实现了一个简单的 C 语言 HTTP 客户端。这个客户端有几个非平凡的任务——解析 URL、格式化 GET 请求的 HTTP 头部、等待响应以及从 HTTP 响应中解析接收到的数据。特别是,我们探讨了处理两种不同的解析 HTTP 体的方法。第一种,也是最简单的方法是 Content-Length,其中整个体的长度被明确指定。第二种方法是分块编码,其中体被发送为单独的块,我们的程序需要在它们之间进行划分。

我们还简要地探讨了 POST 请求及其相关的内容格式。

在下一章,第七章,构建简单的 Web 服务器中,我们将开发 HTTP 客户端的对应物——HTTP 服务器。

问题

尝试以下问题来测试你对本章知识的掌握:

  1. HTTP 使用 TCP 还是 UDP?

  2. 可以通过 HTTP 发送哪些类型的资源?

  3. 常见的 HTTP 请求类型有哪些?

  4. 通常使用哪种 HTTP 请求类型从服务器向客户端发送数据?

  5. 通常使用哪种 HTTP 请求类型从客户端向服务器发送数据?

  6. 确定 HTTP 响应体长度的两种常用方法是什么?

  7. 如何格式化 POST 类型的 HTTP 请求体?

这些问题的答案可以在附录 A,问题答案中找到。

进一步阅读

如需了解更多关于 HTTP 和 HTML 的信息,请参阅以下资源:

第七章:构建简单的 Web 服务器

本章基于前一章,从服务器的角度来查看 HTTP 协议。在其中,我们将构建一个简单的 Web 服务器。这个 Web 服务器将使用 HTTP 协议工作,您可以使用任何标准的 Web 浏览器连接到它。虽然它不会是功能齐全的,但它适合本地提供一些静态文件。它将能够同时处理来自多个客户端的几个并发连接。

本章涵盖了以下主题:

  • 接受和缓冲多个连接

  • 解析 HTTP 请求行

  • 格式化 HTTP 响应

  • 提供文件服务

  • 安全考虑

技术要求

本章的示例程序可以使用任何现代的 C 编译器编译。我们推荐在 Windows 上使用 MinGW,在 Linux 和 macOS 上使用 GCC;有关编译器设置,请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器

本书代码可在github.com/codeplea/Hands-On-Network-Programming-with-C找到。

从命令行,您可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap07

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要链接到Winsock库。这可以通过将-lws2_32选项传递给gcc来实现。

我们提供了编译每个示例所需的精确命令,当它们被介绍时。

本章中的所有示例程序都需要我们在第二章,掌握 Socket API中开发的相同的头文件和 C 宏。为了简洁,我们将这些语句放在一个单独的头文件chap07.h中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅第二章,掌握 Socket API

chap07.h的内容如下:

/*chap07.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

HTTP 服务器

在本章中,我们将实现一个可以从前目录提供静态文件的 HTTP Web 服务器。HTTP 是一种基于文本的客户端-服务器协议,它使用传输控制协议TCP)。

在实现我们的 HTTP 服务器时,我们需要支持来自许多客户端的同时多个并发连接。每个接收到的HTTP 请求都需要被解析,并且我们的服务器需要回复适当的HTTP 响应。如果可能的话,这个HTTP 响应应包括请求的文件。

考虑以下图示中的 HTTP 事务:

在前面的图中,客户端正在从服务器请求/document.htm。服务器找到/document.htm并将其返回给客户端。

我们开发的 HTTP 服务器有些简化,我们只需要查看 HTTP 请求的第一行。这一行被称为请求行。我们的服务器只支持GET类型的请求,因此它需要首先检查请求行是否以GET开头。然后它会解析出请求的资源,在前面的例子中是/document.htm

一个功能更全面的 HTTP 服务器会查看几个其他的 HTTP 头信息。它会查看Host头信息以确定它正在托管哪个网站。我们的服务器只支持托管一个网站,因此这个头信息对我们来说没有意义。

生产服务器还会查看诸如 Accept-Encoding 和 Accept-Language 等头信息,这些信息可以告知适当的响应格式。我们的服务器只是忽略这些,并且它以最直接的方式提供文件服务。

互联网有时可能是一个敌对的环境。一个生产级别的 Web 服务器需要在多个层次上包含安全性。它应该对文件访问和资源分配绝对细致入微。为了清晰解释和简洁,我们本章开发的这个服务器没有进行安全加固,因此出于这个原因,它不应该在公共互联网上使用。

服务器架构

HTTP 服务器是一个复杂的程序。它必须处理多个同时连接、解析复杂的基于文本的协议、以适当的错误处理格式不正确的请求,以及提供文件服务。我们本章开发的例子是从一个生产就绪的服务器中大大简化而来的,但它仍然是几百行代码。我们从将程序分解为单独的函数和数据结构中受益。

在全局层面,我们的程序存储了一个数据结构链表。这个链表为每个连接的客户端包含一个独立的数据结构。这个数据结构存储了有关每个客户端的信息,例如它们的地址、套接字以及迄今为止接收到的数据。我们实现了许多在全局链表上工作的辅助函数。这些函数用于添加新客户端、删除客户端、等待客户端数据、通过套接字查找客户端(因为套接字是由select()返回的)、向客户端提供文件以及向客户端发送错误信息。

我们的服务器主循环可以被简化。它等待新的连接或新的数据。当接收到新数据时,它会检查这些数据是否构成一个完整的 HTTP 请求。如果接收到一个完整的 HTTP 请求,服务器会尝试发送请求的资源。如果 HTTP 请求格式不正确或资源无法找到,那么服务器会向连接的客户端发送错误信息。

服务器的大部分复杂性在于处理多个连接、解析 HTTP 请求和处理错误条件。

服务器还负责告知客户端它发送的每个资源的类型。有几种方法可以实现这一点;让我们接下来考虑它们。

内容类型

这是 HTTP 服务器的职责,告诉其客户端发送的内容类型。这是通过Content-Type头完成的。Content-Type头的值应该是一个有效的媒体类型(以前称为MIME 类型),它已在互联网数字分配机构IANA)注册。参见本章的进一步阅读部分,以获取 IANA 媒体类型列表的链接。

有几种方法可以确定文件的媒体类型。如果你使用的是基于 Unix 的系统,例如 Linux 或 macOS,那么你的操作系统已经提供了这个工具。

在 Linux 或 macOS 上尝试以下命令(将example.txt替换为实际文件名):

file --mime-type example.txt

以下截图显示了其用法:

如前一个截图所示,file实用程序告诉我们index.html的媒体类型是text/html。它还表示smile.png的媒体类型是image/pngtest.txt的媒体类型是text/plain

我们的 Web 服务器仅使用文件的扩展名来确定媒体类型。

常见的文件扩展名及其媒体类型列在以下表格中:

扩展名 媒体类型
.css text/css
.csv text/csv
.gif image/gif
.htm text/html
.html text/html
.ico image/x-icon
.jpeg image/jpeg
.jpg image/jpeg
.js application/javascript
.json application/json
.png image/png
.pdf application/pdf
.svg image/svg+xml
.txt text/plain

如果一个文件的媒体类型未知,那么我们的服务器应该使用application/octet-stream作为默认值。这表示浏览器应该将内容视为一个未知的二进制 blob。

让我们继续编写代码来从文件名获取Content-Type

从文件名返回Content-Type

我们的服务器代码使用一系列if语句来确定基于请求文件的扩展名的正确媒体类型。这不是一个完美的解决方案,但这是一个常见的解决方案,并且适用于我们的目的。

确定文件媒体类型的代码如下:

/*web_server.c except*/

const char *get_content_type(const char* path) {
    const char *last_dot = strrchr(path, '.');
    if (last_dot) {
        if (strcmp(last_dot, ".css") == 0) return "text/css";
        if (strcmp(last_dot, ".csv") == 0) return "text/csv";
        if (strcmp(last_dot, ".gif") == 0) return "image/gif";
        if (strcmp(last_dot, ".htm") == 0) return "text/html";
        if (strcmp(last_dot, ".html") == 0) return "text/html";
        if (strcmp(last_dot, ".ico") == 0) return "image/x-icon";
        if (strcmp(last_dot, ".jpeg") == 0) return "image/jpeg";
        if (strcmp(last_dot, ".jpg") == 0) return "image/jpeg";
        if (strcmp(last_dot, ".js") == 0) return "application/javascript";
        if (strcmp(last_dot, ".json") == 0) return "application/json";
        if (strcmp(last_dot, ".png") == 0) return "image/png";
        if (strcmp(last_dot, ".pdf") == 0) return "application/pdf";
        if (strcmp(last_dot, ".svg") == 0) return "image/svg+xml";
        if (strcmp(last_dot, ".txt") == 0) return "text/plain";
    }

    return "application/octet-stream";
}

get_content_type()函数通过将文件名扩展名与已知扩展名列表进行匹配来工作。这是通过使用strrchr()函数在文件名中找到最后一个点(.)来完成的。如果找到点,则使用strcmp()检查每个扩展名的匹配。当找到匹配项时,返回适当的媒体类型。否则,返回默认值application/octet-stream

让我们继续为我们的服务器构建辅助函数。

创建服务器套接字

在处理 HTTP 服务器的激动人心部分,如消息解析之前,让我们先解决基础知识。我们的 HTTP 服务器,像所有服务器一样,需要创建一个监听套接字以接受新的连接。我们定义了一个名为create_socket()的函数来完成这个目的。此函数首先使用getaddrinfo()来查找监听地址:

/*web_server.c except*/

SOCKET create_socket(const char* host, const char *port) {
    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(host, port, &hints, &bind_address);

create_socket()函数接着使用socket()创建套接字,使用bind()将套接字绑定到监听地址,并使用listen()使套接字进入监听状态。以下代码在调用这些函数的同时检测错误条件:

/*web_server.c except*/

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_listen)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }
    freeaddrinfo(bind_address);

    printf("Listening...\n");
    if (listen(socket_listen, 10) < 0) {
        fprintf(stderr, "listen() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    return socket_listen;
}

如果您按照本书的顺序学习,前面的代码应该非常熟悉。如果不熟悉,请参阅第三章,TCP 连接的深入概述,以获取有关设置 TCP 服务器的信息。

多个连接缓冲

在实现任何服务器软件时,克服的一个重要障碍是同时接受和解析来自多个客户的请求。

考虑一个只发送 HTTP 请求开头,然后延迟,再发送剩余 HTTP 请求的客户。在这种情况下,我们无法在接收到整个 HTTP 请求之前对该客户做出响应。然而,同时,我们也不希望等待时延迟服务其他已连接的客户。因此,我们需要为每个客户分别缓冲接收到的数据。只有当我们从客户那里接收到完整的 HTTP 请求后,我们才能对该客户做出响应。

定义一个 C struct来存储每个已连接客户的详细信息是有用的。我们的程序使用以下结构:

/*web_server.c except*/

#define MAX_REQUEST_SIZE 2047

struct client_info {
    socklen_t address_length;
    struct sockaddr_storage address;
    SOCKET socket;
    char request[MAX_REQUEST_SIZE + 1];
    int received;
    struct client_info *next;
};

这个struct允许我们存储有关每个已连接客户的详细信息。客户的地址存储在address字段中,地址长度在address_length中,套接字在socket字段中。迄今为止从客户那里接收到的所有数据都存储在request数组中;received指示该数组中存储的字节数。next字段是一个指针,允许我们将client_info结构存储在链表中。

为了简化我们的代码,我们将链表的根存储在全局变量clients中。声明如下:

/*web_server.c except*/

static struct client_info *clients = 0;

clients声明为全局变量有助于使我们的代码略微简短且清晰。然而,如果您需要代码可重入(例如,如果您想同时运行多个服务器),您将想要避免全局状态。这可以通过将链表根作为单独的参数传递给每个函数调用来实现。本章的代码仓库在web_server2.c文件中包含了这个替代技术的示例。

定义一些辅助函数,这些函数在client_info数据结构和clients链表上工作,是有用的。我们实现了以下辅助函数来完成这些目的:

  • get_client() 函数接收一个 SOCKET 变量并在我们的链表中搜索相应的 client_info 数据结构。

  • drop_client() 关闭与客户端的连接并将其从 clients 链表中移除。

  • get_client_address() 返回客户端的 IP 地址作为字符串(字符数组)。

  • wait_on_clients() 使用 select() 函数等待直到有客户端有数据可用或新的客户端尝试连接。

  • send_400()send_404() 用于处理 HTTP 错误条件。

  • serve_resource() 尝试将文件传输到已连接的客户端。

现在我们将逐个实现这些函数。

get_client()

我们的 get_client() 函数接受一个 SOCKET 并在连接客户端的链表中搜索,以返回该 SOCKET 对应的 client_info。如果在链表中找不到匹配的 client_info,则分配一个新的 client_info 并将其添加到链表中。因此,get_client() 具有两个作用——它可以找到现有的 client_info,或者它可以创建一个新的 client_info

get_client() 函数接收一个 SOCKET 作为输入,并返回一个 client_info 结构体。以下代码是 get_client() 函数的第一部分:

/*web_server.c except*/

struct client_info *get_client(SOCKET s) {
    struct client_info *ci = clients;

    while(ci) {
        if (ci->socket == s)
            break;
        ci = ci->next;
    }

    if (ci) return ci;

在前面的代码中,我们创建了 get_client() 函数并实现了我们的链表搜索功能。首先,将链表根 clients 保存到一个临时变量 ci 中。如果 ci->socket 是我们要搜索的套接字,则循环中断并返回 ci。如果找不到给定套接字的 client_info 结构体,则代码继续执行并必须创建一个新的 client_info 结构体。以下代码实现了这一点:

/*web_server.c except*/

    struct client_info *n =
        (struct client_info*) calloc(1, sizeof(struct client_info));

    if (!n) {
        fprintf(stderr, "Out of memory.\n");
        exit(1);
    }

    n->address_length = sizeof(n->address);
    n->next = clients;
    clients = n;
    return n;
}

在前面的代码中,使用了 calloc() 函数为新 client_info 结构体分配内存。calloc() 函数还会将数据结构清零,这在这种情况下很有用。然后代码检查内存分配是否成功,如果失败则打印错误信息。

然后代码将 n->address_length 设置为适当的大小。这允许我们稍后直接在 client_info 地址上使用 accept(),因为 accept() 需要最大地址长度作为输入。

n->next 字段设置为当前全局链表根,并将全局链表根 clients 设置为 n。这实现了在链表开头添加新的数据结构。

get_client() 函数通过返回新分配的 client_info 结构体 n 来结束。

drop_client()

drop_client() 函数搜索我们的客户端链表并移除指定的客户端。

整个函数的代码如下:

/*web_server.c except*/

void drop_client(struct client_info *client) {
    CLOSESOCKET(client->socket);

    struct client_info **p = &clients;

    while(*p) {
        if (*p == client) {
            *p = client->next;
            free(client);
            return;
        }
        p = &(*p)->next;
    }

    fprintf(stderr, "drop_client not found.\n");
    exit(1);
}

如前所述的代码所示,首先使用 CLOSESOCKET() 关闭并清理客户端的连接。

函数随后声明了一个指针的指针变量,p,并将其设置为clients。这个指针的指针变量很有用,因为我们可以用它直接更改clients的值。确实,如果要删除的客户是链表中的第一个元素,那么clients需要更新,以便clients指向列表中的第二个元素。

代码使用一个while循环遍历链表。一旦找到*p == client,就将*p设置为client->next,这实际上从链表中移除了客户,随后释放了分配的内存,并返回函数。

虽然drop_client()是一个简单的函数,但它很方便,因为它可以在几种情况下调用。当完成向客户发送资源后必须调用它,同样,当完成向客户发送错误消息后也必须调用它。

get_client_address()

有一个辅助函数将给定客户的 IP 地址转换为文本很有用。这个函数在下面的代码片段中给出:

/*web_server.c except*/

const char *get_client_address(struct client_info *ci) {
    static char address_buffer[100];
    getnameinfo((struct sockaddr*)&ci->address,
            ci->address_length,
            address_buffer, sizeof(address_buffer), 0, 0,
            NI_NUMERICHOST);
    return address_buffer;
}

get_client_address()是一个简单的函数。它首先分配一个char数组来存储 IP 地址。这个char数组被声明为static,这确保了在函数返回后其内存仍然是可用的。这意味着我们不需要担心调用者free()内存。这种方法的不利之处在于get_client_address()具有全局状态,并且不是可重入安全的。请参阅web_server2.c以获取一个可重入安全的替代版本。

在获得char缓冲区后,代码简单地使用getnameinfo()将二进制 IP 地址转换为文本地址;getnameinfo()在前面章节中有详细的介绍,但第五章,主机名解析和 DNS,有特别详细的解释。

wait_on_clients()

我们的服务器能够处理许多并发连接。这意味着我们的服务器必须有一种方式来同时等待来自多个客户的数据。我们定义了一个函数,wait_on_clients(),该函数会阻塞,直到现有客户发送数据,或者新客户尝试连接。这个函数使用了在前面章节中描述的select()。第三章,TCP 连接的深入概述,对select()有详细的解释。

wait_on_clients()函数定义如下:

/*web_server.c except*/

fd_set wait_on_clients(SOCKET server) {
    fd_set reads;
    FD_ZERO(&reads);
    FD_SET(server, &reads);
    SOCKET max_socket = server;

    struct client_info *ci = clients;

    while(ci) {
        FD_SET(ci->socket, &reads);
        if (ci->socket > max_socket)
            max_socket = ci->socket;
        ci = ci->next;
    }

    if (select(max_socket+1, &reads, 0, 0, 0) < 0) {
        fprintf(stderr, "select() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    return reads;
}

在前面的代码中,首先声明了一个新的fd_set并将其清零。然后,将服务器套接字首先添加到fd_set中。然后代码遍历已连接客户的链表,并依次添加每个客户的套接字。在整个过程中维护一个变量max_socket,以存储select()所需的最大套接字号。

在将所有套接字添加到fd_set reads之后,代码调用select(),并且当reads中的一个或多个套接字准备好时,select()返回。

wait_on_clients() 函数返回 reads,以便调用者可以看到哪个套接字已准备好。

send_400()

在客户端发送的 HTTP 请求是我们服务器不理解的情况下,发送代码 400 错误是有帮助的。因为这种类型的错误可以在几种情况下出现,所以我们把这个功能封装在 send_400() 函数中。整个函数如下:

/*web_server.c except*/

void send_400(struct client_info *client) {
    const char *c400 = "HTTP/1.1 400 Bad Request\r\n"
        "Connection: close\r\n"
        "Content-Length: 11\r\n\r\nBad Request";
    send(client->socket, c400, strlen(c400), 0);
    drop_client(client);
}

send_400() 函数首先声明一个包含整个 HTTP 响应的文本数组,该响应是硬编码的。使用 send() 函数发送此文本,然后通过调用我们之前定义的 drop_client() 函数来断开客户端连接。

send_404()

除了处理 400 Bad Request 错误之外,我们的服务器还需要处理请求的资源未找到的情况。在这种情况下,应返回 404 Not Found 错误。我们定义了一个辅助函数来返回此错误,如下所示:

/*web_server.c except*/

void send_404(struct client_info *client) {
    const char *c404 = "HTTP/1.1 404 Not Found\r\n"
        "Connection: close\r\n"
        "Content-Length: 9\r\n\r\nNot Found";
    send(client->socket, c404, strlen(c404), 0);
    drop_client(client);
}

send_404() 函数与之前定义的 send_400() 函数工作方式完全相同。

serve_resource()

serve_resource() 函数向连接的客户端发送请求的资源。我们的服务器期望所有托管文件都在名为 public 的子目录中。理想情况下,我们的服务器不应允许访问 public 目录之外的任何文件。然而,正如我们将看到的,实施这种限制可能比最初看起来更困难。

我们的 serve_resource() 函数接受一个已连接客户端和一个请求的资源路径作为参数。函数开始如下:

/*web_server.c except*/

void serve_resource(struct client_info *client, const char *path) {

    printf("serve_resource %s %s\n", get_client_address(client), path);

将连接客户端的 IP 地址和请求的路径打印出来,以帮助调试。在生产服务器中,您还希望打印其他信息。大多数生产服务器至少记录日期、时间、请求方法、客户端的用户代理字符串和响应代码。

我们的功能随后将请求的路径标准化。有几个事项需要检查。首先,如果路径是 /,那么我们需要提供默认文件。在这种情况下,有一个提供名为 index 的文件的惯例,实际上这正是我们的代码所做的事情。

我们还检查路径长度是否过长。一旦我们确保路径长度低于最大长度,我们就可以使用固定大小的数组来存储它,而不用担心缓冲区溢出。

我们的代码还检查路径中不包含两个连续的点——..。在文件路径中,两个点表示对父目录的引用。然而,出于安全考虑,我们只想允许访问我们的 public 目录。我们不想提供对任何父目录的访问。如果我们允许包含 .. 的路径,那么恶意客户端可以发送 GET /../web_server.c HTTP/1.1 并获取我们的服务器源代码的访问权限!

以下代码用于重定向根请求以及防止长或明显恶意的请求:

/*web_server.c except*/

    if (strcmp(path, "/") == 0) path = "/index.html";

    if (strlen(path) > 100) {
        send_400(client);
        return;
    }

    if (strstr(path, "..")) {
        send_404(client);
        return;
    }

我们现在的代码需要将路径转换为指向 public 目录中的文件。这是通过 sprintf() 函数完成的。首先,预留一个文本数组 full_path,然后使用 sprintf() 将完整路径存储到其中。我们能够为 full_path 预留一个固定分配,因为之前的代码确保了 path 的长度不超过 100 个字符。

设置 full_path 的代码如下:

/*web_server.c except*/

    char full_path[128];
    sprintf(full_path, "public%s", path);

重要的是要注意,目录分隔符在 Windows 和其他操作系统之间是不同的。虽然基于 Unix 的系统使用斜杠 (/),但 Windows 使用反斜杠 (\) 作为其标准。许多 Windows 函数会自动处理转换,但有时这种差异很重要。对于我们的简单服务器,斜杠转换不是绝对必要的。然而,我们仍然包括它作为一种良好的实践。

以下代码在 Windows 上将斜杠转换为反斜杠:

/*web_server.c except*/

#if defined(_WIN32)
    char *p = full_path;
    while (*p) {
        if (*p == '/') *p = '\\';
        ++p;
    }
#endif

上述代码通过遍历 full_path 文本数组并检测斜杠字符来工作。当找到斜杠时,它被简单地覆盖为反斜杠。请注意,C 代码 '\\' 等价于一个反斜杠。这是因为反斜杠在 C 中有特殊含义,因此第一个反斜杠用于转义第二个反斜杠。

在这一点上,我们的服务器可以检查请求的资源是否实际存在。这是通过使用 fopen() 函数完成的。如果 fopen() 由于任何原因失败,那么我们的服务器假设该文件不存在。以下代码在请求的资源不可用的情况下发送一个 404 错误:

/*web_server.c except*/

    FILE *fp = fopen(full_path, "rb");

    if (!fp) {
        send_404(client);
        return;
    }

如果 fopen() 成功,那么我们可以使用 fseek()ftell() 来确定请求文件的尺寸。这是重要的,因为我们需要在 Content-Length 头部中使用文件的大小。以下代码找到文件大小并将其存储在 cl 变量中:

/*web_server.c except*/

    fseek(fp, 0L, SEEK_END);
    size_t cl = ftell(fp);
    rewind(fp);

一旦知道文件大小,我们还想获取文件的类型。这在 Content-Type 头部中使用。我们已定义了一个函数 get_content_type(),这使得这项任务变得简单。内容类型通过以下代码存储在变量 ct 中:

/*web_server.c except*/

    const char *ct = get_content_type(full_path);

一旦找到文件并获取其长度和类型,服务器就可以开始发送 HTTP 响应。我们首先预留一个临时缓冲区来存储头部字段:

/*web_server.c except*/

#define BSIZE 1024
    char buffer[BSIZE];

一旦预留了缓冲区,服务器将相关头部打印到其中,然后依次发送这些头部到客户端。这是通过 sprintf()send() 完成的。以下代码发送 HTTP 响应头部:

/*web_server.c except*/

    sprintf(buffer, "HTTP/1.1 200 OK\r\n");
    send(client->socket, buffer, strlen(buffer), 0);

    sprintf(buffer, "Connection: close\r\n");
    send(client->socket, buffer, strlen(buffer), 0);

    sprintf(buffer, "Content-Length: %u\r\n", cl);
    send(client->socket, buffer, strlen(buffer), 0);

    sprintf(buffer, "Content-Type: %s\r\n", ct);
    send(client->socket, buffer, strlen(buffer), 0);

    sprintf(buffer, "\r\n");
    send(client->socket, buffer, strlen(buffer), 0);

注意,最后的 send() 语句发送 \r\n。这会产生发送一个空白行的效果。这个空白行被客户端用来区分 HTTP 头部与 HTTP 主体开始。

服务器现在可以发送实际的文件内容。这是通过重复调用 fread() 直到整个文件发送完毕来完成的:

/*web_server.c except*/

    int r = fread(buffer, 1, BSIZE, fp);
    while (r) {
        send(client->socket, buffer, r, 0);
        r = fread(buffer, 1, BSIZE, fp);
    }

在前面的代码中,fread()用于读取足够的数据以填充buffer。然后,使用send()将这个缓冲区传输给客户端。这些步骤会一直循环,直到fread()返回0;这表示已经读取了整个文件。

注意,send()在处理大文件时可能会阻塞。在一个真正健壮、准备投入生产的服务器中,你需要处理这种情况。这可以通过使用select()来确定每个套接字何时准备好读取来实现。另一种常见的方法是使用fork()或类似的 API 为每个连接的客户端创建单独的线程/进程。为了简单起见,我们的服务器接受send()在处理大文件时可能会阻塞的限制。请参阅第十三章,Socket 编程技巧与陷阱,以获取有关send()阻塞行为的更多信息。

函数可以通过关闭文件句柄并使用drop_client()来断开客户端的连接:

/*web_server.c except*/

    fclose(fp);
    drop_client(client);
}

这就完成了serve_resource()函数。

请记住,虽然serve_resource()函数试图限制访问仅限于public目录,但这并不充分,且在生产代码中不应在没有仔细考虑额外的访问漏洞的情况下使用serve_resource()。我们将在本章后面讨论更多的安全问题。

在这些辅助函数处理完毕后,实现我们的主服务器循环就变得容易多了。我们将在下一节开始介绍。

主循环

在处理完许多辅助函数后,我们现在可以完成web_server.c。请记住,首先#include chap07.h,并添加我们之前定义的所有类型和函数—struct client_infoget_content_type()create_socket()get_client()drop_client()get_client_address()wait_on_clients()send_400()send_404()serve_resource()

然后,我们可以开始编写main()函数。它首先在 Windows 上初始化 Winsock:

/*web_server.c except*/

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后,我们使用之前定义的函数create_socket()来创建监听套接字。我们的服务器监听端口8080,但你可以自由更改它。在基于 Unix 的系统上,监听低端口是为特权账户保留的。出于安全原因,我们的 Web 服务器应该仅以非特权账户运行。这就是为什么我们使用8080作为端口号而不是 HTTP 标准端口80

创建服务器套接字的代码如下:

/*web_server.c except*/

    SOCKET server = create_socket(0, "8080");

如果你只想接受来自本地系统的连接,而不是来自外部系统的连接,请使用以下代码:

/*web_server.c except*/

    SOCKET server = create_socket("127.0.0.1", "8080");

然后,我们开始一个无限循环,等待客户端连接。我们调用wait_on_clients()等待新的客户端连接或旧的客户端发送新数据:

/*web_server.c except*/

    while(1) {

        fd_set reads;
        reads = wait_on_clients(server);

server随后检测是否有新的client连接。这种情况由serverfd_set reads中被设置来指示。我们使用FD_ISSET()宏来检测这种条件:

/*web_server.c except*/

        if (FD_ISSET(server, &reads)) {
            struct client_info *client = get_client(-1);

            client->socket = accept(server,
                    (struct sockaddr*) &(client->address),
                    &(client->address_length));

            if (!ISVALIDSOCKET(client->socket)) {
                fprintf(stderr, "accept() failed. (%d)\n",
                        GETSOCKETERRNO());
                return 1;
            }

            printf("New connection from %s.\n",
                    get_client_address(client));
        }

一旦检测到新的客户端连接,就使用带有参数-1get_client()进行调用;-1不是一个有效的套接字指定符,因此get_client()创建一个新的struct client_info。这个struct client_info被分配给client变量。

accept()套接字函数用于接受新的连接,并将连接的客户端地址信息放入相应的client字段。accept()返回的新套接字存储在client->socket中。

客户端的地址通过调用get_client_address()来打印。这对于调试很有帮助。

然后,我们的服务器必须处理已经连接的客户端发送数据的情况。这要复杂一些。我们首先遍历客户端链表,并对每个客户端使用FD_ISSET()来确定哪些客户端有可用数据。回想一下,链表根存储在clients全局变量中。

我们从以下内容开始我们的链表遍历:

/*web_server.c except*/

        struct client_info *client = clients;
        while(client) {
            struct client_info *next = client->next;

            if (FD_ISSET(client->socket, &reads)) {

然后我们检查是否有内存可用来存储更多为client接收到的数据。如果客户端的缓冲区已经完全填满,则发送一个400错误。以下代码检查这种条件:

/*web_server.c except*/

                if (MAX_REQUEST_SIZE == client->received) {
                    send_400(client);
                    continue;
                }

知道我们至少还有一些内存来存储接收到的数据,我们可以使用recv()来存储客户端的数据。以下代码使用recv()将新数据写入客户端的缓冲区,同时小心不要溢出该缓冲区:

/*web_server.c except*/

                int r = recv(client->socket,
                        client->request + client->received,
                        MAX_REQUEST_SIZE - client->received, 0);

一个意外断开的客户端会导致recv()返回一个非正数。在这种情况下,我们需要使用drop_client()来清理为该客户端分配的内存:

/*web_server.c except*/

                if (r < 1) {
                    printf("Unexpected disconnect from %s.\n",
                            get_client_address(client));
                    drop_client(client);

如果接收到的数据已成功写入,我们的服务器会在该客户端数据缓冲区的末尾添加一个空终止字符。这允许我们使用strstr()来搜索缓冲区,因为空终止字符告诉strstr()何时停止。

回想一下,HTTP 头和正文由一个空白行分隔。因此,如果strstr()找到一个空白行(\r\n\r\n),我们知道已经接收到了 HTTP 头,我们可以开始解析它。以下代码检测是否已接收到 HTTP 头:

/*web_server.c except*/

                } else {
                    client->received += r;
                    client->request[client->received] = 0;

                    char *q = strstr(client->request, "\r\n\r\n");
                    if (q) {

我们的服务器只处理GET请求。我们还强制任何有效的路径应以斜杠字符开始;strncmp()在以下代码中用于检测这两个条件:

/*web_server.c except*/

                        if (strncmp("GET /", client->request, 5)) {
                            send_400(client);
                        } else {
                            char *path = client->request + 4;
                            char *end_path = strstr(path, " ");
                            if (!end_path) {
                                send_400(client);
                            } else {
                                *end_path = 0;
                                serve_resource(client, path);
                            }
                        }
                    } //if (q)

在前面的代码中,一个合适的GET请求会导致执行else分支。在这里,我们将path变量设置为请求路径的开始,它从 HTTP 请求的第五个字符开始(因为 C 数组从零开始,第五个字符位于client->request + 4)。

请求路径的结束通过找到下一个空格字符来指示。如果找到了,我们就调用我们的serve_resource()函数来满足客户端的请求。

到目前为止,我们的服务器基本上是功能性的。我们只需要完成我们的循环并关闭main()函数。以下代码实现了这一点:

/*web_server.c except*/

                }
            }

            client = next;
        }

    } //while(1)

    printf("\nClosing socket...\n");
    CLOSESOCKET(server);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

注意,我们的服务器实际上没有一种方法可以跳出无限循环。它只是永远地监听连接。作为一个练习,您可能想要添加允许服务器干净关闭的功能。这被省略了,只是为了使代码更简单。也可能有用的是,使用此行代码——while(clients) drop_client(clients);——断开所有已连接客户端。

这就完成了 web_server.c 的代码。我建议您从本书的代码库中下载 web_server.c 并尝试运行它。

您可以使用以下命令在 Linux 和 macOS 上编译和运行 web_server.c

gcc web_server.c -o web_server
./web_server

在 Windows 上,使用 MinGW 编译和运行的命令如下:

gcc web_server.c -o web_server.exe -lws2_32
web_server.exe

以下截图显示了在 macOS 上编译和运行服务器的情况:

图片

如果您使用标准网络浏览器连接到服务器,您应该看到以下截图所示的内容:

图片

您也可以将不同的文件拖放到 public 文件夹中,并尝试创建更复杂的网站。

本章的代码库中还提供了一个替代源文件 web_server2.c。它的行为与我们开发的代码完全一样,但它避免了全局状态(以牺牲一点额外的冗长为代价)。这可能使 web_server2.c 更适合集成到更重要的项目中,并继续开发。

尽管我们开发的 Web 服务器确实可以工作,但它确实存在一些缺点。请在非常仔细地考虑这些缺点之后,再部署此服务器(或任何其他网络代码),其中一些缺点我们将在下面讨论。

安全性和健壮性

在开发网络代码时,最重要的规则之一是您的程序永远不应该信任连接的对方。您的代码永远不应该假设连接的对方以特定的格式发送数据。这对于可能同时与多个客户端通信的服务器代码尤其重要。

如果您的代码没有仔细检查错误和意外条件,那么它将容易受到攻击。

考虑以下代码,它将数据读入缓冲区,直到找到 空格 字符:

char buffer[1028] = {0};
char *p = buffer;

while (!strstr(p, " "))
    p += recv(client, p, 1028, 0);

上述代码很简单。它预留了 1,028 字节的缓冲区空间,然后使用 recv() 将接收到的数据写入该空间。每次读取时,p 指针都会更新,以指示下一个数据应该写入的位置。然后代码循环,直到 strstr() 函数检测到空格字符。

该代码可以用来从客户端读取数据,直到检测到 HTTP 动词。例如,它可以接收数据直到接收到 GET,此时服务器可以开始处理 GET 请求。

前面代码的一个问题是recv()可能会超出为buffer分配的空间的末尾进行写入。这是因为即使已经写入了一些数据,也会将1028传递给recv()。如果一个网络客户能够让你的代码写入到缓冲区末尾之外,那么该客户可能能够完全破坏你的服务器。这是因为数据和可执行代码都存储在你的服务器内存中。恶意客户可能能够在buffer数组之后写入可执行代码,并导致你的程序执行它。即使恶意代码没有被执行,客户仍然可以覆盖服务器内存中的其他重要数据。

前面代码可以通过只传递给recv()剩余的缓冲区空间来修复:

char buffer[1028] = {0};
char *p = buffer;

while (!strstr(p, " "))
    p += recv(client, p, 1028 - (p - buffer), 0);

在这种情况下,recv()无法将超过 1,028 个字节的总量写入buffer。你可能认为内存错误已经解决,但你仍然会犯错。考虑一个发送 1,028 个字节但不含空格字符的客户。然后你的代码调用strstr()寻找空格字符。考虑到buffer现在已完全填满,strstr()无法找到空格字符或空终止字符!在这种情况下,strstr()会继续读取到buffer末尾之后的未分配内存。

因此,你通过只允许recv()写入总共 1,027 个字节来解决这个问题。这保留了一个字节作为空终止字符:

char buffer[1028] = {0};
char *p = buffer;

while (!strstr(p, " "))
    p += recv(client, p, 1027 - (p - buffer), 0);

现在你的代码不会超出buffer数组的边界进行写入或读取,但代码仍然非常糟糕。考虑一个发送 1,027 个字符的客户。或者考虑一个只发送单个空字符的客户。在任一情况下,前面的代码会持续无限循环,从而锁定你的服务器,阻止其他客户被服务。

希望前面的例子说明了在 C 语言中实现服务器所需的谨慎。确实,在任何编程语言中创建错误都很容易,但在 C 语言中需要特别注意以避免内存错误。

服务器软件的另一个问题是,服务器希望允许访问系统上的某些文件,但不允许访问其他文件。恶意客户可以发送一个尝试从你的服务器系统下载任意文件的 HTTP 请求。例如,如果发送了一个如GET /../secret_file.c HTTP/1.1这样的 HTTP 请求到一个天真的 HTTP 服务器,那个服务器可能会将secret_file.c发送给连接的客户,即使它存在于public目录之外!

我们在web_server.c中的代码通过搜索包含..的请求并拒绝这些请求来检测这种尝试的最明显尝试。

一个健壮的服务器应该使用操作系统功能来检测请求的文件是否实际存在于允许的目录中。不幸的是,没有跨平台的方法来做这件事,并且平台相关的选项相当复杂。

请理解,这些问题并非纯粹的理论担忧,而是实际可利用的漏洞。例如,如果你在 Windows 上运行我们的web_server.c程序,并且一个客户端发送了请求GET /this_will_be_funny/PRN HTTP/1.1,你认为会发生什么?

this_will_be_funny目录不存在,并且在该不存在的目录中肯定不存在PRN文件。这些事实可能会让你认为服务器会简单地返回预期的404 Not Found错误。然而,事实并非如此。在 Windows 中,PRN是一个特殊的文件名。当你的服务器调用fopen()对这个特殊名称时,Windows 不会寻找文件,而是连接到一个打印机接口!其他特殊名称包括COM1(连接到串行端口 1)和LPT1(连接到并行端口 1),尽管还有其他。即使这些文件名有扩展名,例如PRN.txt,Windows 也会重定向而不是寻找文件。

一条普遍适用的安全建议是——在只有访问执行所需的最小资源的非特权账户下运行你的网络程序。换句话说,如果你要运行网络服务器,创建一个新的账户来运行它。只给该账户读取访问服务器需要服务的文件。这不是编写安全代码的替代品,而是在作为非特权用户运行时创建了一个最后的障碍。即使运行经过加固、经过行业测试的服务器软件,你也应该应用这条建议。

希望前面的例子能说明编程是复杂的,使用 C 进行安全网络编程可能很困难。最好谨慎处理。通常,你无法确定你已经覆盖了所有漏洞。操作系统并不总是有充分的文档。操作系统 API 的行为往往是非直观的。请小心。

开源服务器

本章开发的代码适用于在受信任网络上运行的受信任应用程序。例如,如果你正在开发一款视频游戏,让它提供显示调试信息的网页可能非常有用。这不必成为安全担忧,因为它可以限制连接到本地机器。

如果你必须在互联网上部署 Web 服务器,我建议你考虑使用已经可用的免费和开源实现。例如,Nginx 和 Apache 等 Web 服务器性能卓越,跨平台,安全,用 C 语言编写,并且完全免费。它们也具有良好的文档,并且容易找到支持。

如果你想要将你的程序暴露在互联网上,你可以使用 CGI 或 FastCGI 与 Web 服务器通信。使用 CGI 时,Web 服务器处理 HTTP 请求。当请求到来时,它会运行你的程序,并在 HTTP 响应体中返回你的程序输出。

或者,许多网络服务器(如 Nginx 或 Apache)作为反向代理工作。这实际上将网络服务器置于你的代码和互联网之间。网络服务器接受并转发 HTTP 消息到你的 HTTP 服务器。这可以稍微保护你的代码免受攻击者侵害。

摘要

在本章中,我们从零开始实现了 C 语言中的 HTTP 服务器。这可不是一件小事!尽管 HTTP 的文本性质使得解析 HTTP 请求变得简单,但我们仍需付出大量努力以确保能够同时服务多个客户端。我们通过为每个客户端分别缓冲接收到的数据来实现这一点。每个客户端的状态信息被组织成一个链表。

另一个困难是确保接收到的数据的安全处理和错误检测。我们了解到,程序员在处理网络数据时必须非常小心,以避免创建安全风险。我们还看到,即使是微不足道的问题,例如 Windows 的特殊文件名,也可能为网络服务器应用程序造成潜在的危险安全漏洞。

在下一章,第八章,让你的程序发送电子邮件,我们将从 HTTP 转向考虑与电子邮件相关的主要协议——简单邮件传输协议 (SMTP)。

问题

尝试以下问题以测试你对本章知识的掌握:

  1. HTTP 客户端如何表明它已经完成了 HTTP 请求的发送?

  2. HTTP 客户端如何知道 HTTP 服务器发送的内容类型?

  3. HTTP 服务器如何识别文件的媒体类型?

  4. 你如何判断文件是否存在于文件系统中,并且你的程序可以读取它?fopen(filename, "r") != 0是一个好的测试吗?

这些问题的答案可以在附录 A,问题答案中找到。

进一步阅读

关于 HTTP 和 HTML 的更多信息,请参考以下内容:

第八章:使您的程序能够发送电子邮件

在本章中,我们将考虑负责在互联网上发送电子邮件的协议。这个协议被称为简单邮件传输协议SMTP)。

在阐述电子邮件传输的内部工作原理之后,我们将构建一个简单的 SMTP 客户端,能够发送简短的电子邮件。

本章涵盖以下主题:

  • SMTP 服务器的工作原理

  • 确定负责特定域的邮件服务器

  • 使用 SMTP

  • 电子邮件编码

  • 防垃圾邮件和发送电子邮件的陷阱

  • SPF、DKIM 和 DMARC

技术要求

本章的示例程序可以用任何现代 C 编译器编译。我们推荐 Windows 上的 MinGW 和 Linux 及 macOS 上的 GCC。有关编译器设置的详细信息,请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器

本书代码可在github.com/codeplea/Hands-On-Network-Programming-with-C找到。

从命令行,您可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap08

本章中的每个示例程序都在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要与 Winsock 库链接。这可以通过将-lws2_32选项传递给gcc来实现。

我们在介绍每个示例时提供编译每个示例所需的精确命令。

本章中的所有示例程序都需要我们在第二章,掌握套接字 API中开发的相同头文件和 C 宏。为了简洁,我们将这些语句放在一个单独的头文件chap08.h中,我们可以在每个程序中包含它。有关这些语句的详细解释,请参阅第二章掌握套接字 API

chap08.h的第一部分包括每个平台所需的网络头文件。以下是相应的代码:

/*chap08.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

我们还定义了一些宏,以便更容易编写可移植的代码,并且我们将包括我们的程序所需的附加头文件:

/*chap08.h continued*/

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

这就完成了chap08.h

电子邮件服务器

SMTP 是负责在服务器之间传递电子邮件的协议。它是一个基于文本的协议,在 TCP 端口25上运行。

并非所有电子邮件都需要在系统之间传递。例如,假设您有一个 Gmail 账户。如果您给同样拥有 Gmail 账户的朋友发送电子邮件,那么 SMTP 不一定被使用。在这种情况下,Gmail 只需要将您的电子邮件复制到他们的收件箱(或执行等效的数据库更新)。

另一方面,考虑一下你向你的朋友 Yahoo!邮箱发送电子邮件的情况。如果你的电子邮件是从你的 Gmail 账户发送的,那么很清楚,Gmail 和 Yahoo!服务器必须进行通信。在这种情况下,你的电子邮件将通过 SMTP 从 Gmail 服务器传输到 Yahoo!服务器。

这种连接关系在以下图中得到了说明:

从你的邮件服务提供商检索电子邮件与在服务提供商之间发送电子邮件是不同的问题。现在,使用网页邮箱从你的邮件服务提供商发送和接收邮件非常流行。网页邮箱服务提供商允许通过网页浏览器访问邮箱。网页浏览器使用 HTTP(或 HTTPS)进行通信。

让我们考虑一封从爱丽丝鲍勃的电子邮件的完整路径。在这个例子中,爱丽丝使用Gmail作为她的邮件服务提供商,而鲍勃使用Yahoo!作为他的邮件服务提供商。爱丽丝和鲍勃都使用标准的网页浏览器来访问他们的邮箱。电子邮件从鲍勃到爱丽丝的路径在以下图中得到了说明:

如前图所示,SMTP仅在邮件服务提供商之间发送邮件时使用。这种 SMTP 的使用方式被称为邮件传输

实际上,这个例子中的电子邮件可以采取其他路径。让我们考虑一下,如果爱丽丝使用桌面电子邮件客户端而不是网页邮箱。尽管这些客户端正在过时,但 Gmail 仍然支持桌面电子邮件客户端,并且它们提供了许多优良的功能。一个典型的桌面客户端会通过Internet Message Access ProtocolIMAP)或Post Office ProtocolPOP)以及 SMTP 连接到邮件服务提供商。在这种情况下,SMTP 被爱丽丝用来将她的邮件发送到她的邮件服务提供商(Gmail)。这种 SMTP 的使用方式被称为邮件提交

Gmail 服务提供商随后再次使用 SMTP 将电子邮件发送到Yahoo!邮件服务器。以下图进行了说明:

在前图中,Gmail 服务器将被视为一个SMTP 中继。一般来说,SMTP 服务器应该只为可信用户中继邮件。如果 SMTP 服务器中继所有邮件,它很快就会被垃圾邮件发送者淹没。

许多邮件服务提供商有一组用于接收传入邮件的邮件服务器,以及一组用于接收用户发出的邮件的邮件服务器。

重要的是要理解 SMTP 用于发送邮件。SMTP 不用于从服务器检索邮件。IMAP 和 POP 是桌面邮件程序从服务器检索邮件的常用协议。

爱丽丝不需要通过她的服务提供商的 SMTP 服务器发送她的邮件。相反,她可以直接将邮件发送到鲍勃的邮件服务提供商,以下图进行了说明:

实际上,人们通常将邮件投递责任委托给他们的邮件提供商。这有几个优点;即,如果目标邮件服务器不可用,邮件提供商可以尝试重新投递。其他优点将在本章后面讨论。

本章中我们开发的程序用于直接将邮件发送到收件人的电子邮件提供商。将邮件发送到中继服务器是没有用的,因为我们不打算实现身份验证技术。通常,SMTP 服务器在没有验证发件人是否拥有他们账户的情况下不会中继邮件。

本章中我们描述的 SMTP 协议是不安全的且未加密的。这对于解释和学习来说很方便,但在现实世界中,你可能希望加密你的电子邮件传输。

SMTP 安全

我们在本章中描述了不安全的 SMTP。在实际应用中,如果通信服务器都支持,SMTP 应该被加密。并非所有服务器都支持。

通过将 SMTP 连接从端口25开始作为明文,然后 SMTP 客户端发出STARTTLS命令以升级到安全、加密的连接来加密 SMTP。这个安全连接通过仅通过 TLS 层运行 SMTP 命令来实现;因此,本章中涵盖的所有内容也适用于安全 SMTP。有关 TLS 的更多信息,请参阅第九章,使用 HTTPS 和 OpenSSL 加载**安全网页

服务器之间的邮件传输总是在端口25上进行的。

许多桌面电子邮件客户端使用 TCP 端口465587进行 SMTP 邮件提交。互联网服务提供商(ISPs)更喜欢这些替代端口进行邮件提交,这允许他们完全阻止端口25。这通常被合理化为一种反垃圾邮件技术。

接下来,让我们看看如何确定哪个邮件服务器接收特定电子邮件地址的邮件。

寻找电子邮件服务器

考虑电子邮件地址bob@example.com。在这种情况下,bob标识用户,而example.com标识服务提供商的域名。这些部分由@符号分隔。

一个域名可能使用多个邮件服务器。同样,一个邮件服务器可以为多个域名提供服务。因此,识别负责接收bob@example.com邮件的邮件服务器或服务器并不像连接到example.com那样简单。相反,必须通过执行 MX 记录的 DNS 查找来识别邮件服务器。

DNS 在第五章中进行了深入探讨,主机名解析和 DNS。我们本章开发的程序可以用来查询 MX 记录。

否则,大多数操作系统都提供用于 DNS 查找的命令行工具。Windows 提供nslookup,而 Linux 和 macOS 提供dig

在 Windows 上,我们可以使用以下命令找到负责接收@gmail.com邮件的服务器:

nslookup -type=mx gmail.com

以下截图显示了此查找:

图片

在 Linux 或 macOS 上,使用以下命令对@gmail.com账户进行 MX 记录查找:

dig mx gmail.com

以下屏幕截图显示了dig的使用:

图片

如前两个屏幕截图所示,Gmail 使用五个邮件服务器。当找到多个 MX 记录时,应首先将邮件发送到具有最低 MX 优先级的邮件服务器。如果邮件无法发送到该服务器,则应尝试发送到具有下一个最低优先级的邮件服务器,依此类推。在撰写本文时,Gmail 的主要邮件服务器,优先级为5,是gmail-smtp-in.l.google.com。这是您连接以发送@gmail.com地址邮件的 SMTP 服务器。

MX 记录也可能具有相同的优先级。雅虎使用具有相同优先级的邮件服务器。以下屏幕截图显示了yahoo.com的 MX 记录:

图片

在前面的屏幕截图中,我们可以看到雅虎使用三个邮件服务器。每个服务器都有一个优先级1。这意味着邮件可以发送到其中的任何一个,没有特别的优先顺序。如果邮件无法发送到第一个选择的邮件服务器,那么应该随机选择另一个服务器重试。

以跨平台方式编程获取 MX 记录可能很困难。请参阅第五章,主机名解析和 DNS,其中对这一主题进行了深入探讨。我们本章开发的 SMTP 客户端假设邮件服务器是事先已知的。

现在我们知道了要连接的服务器,让我们更详细地考虑 SMTP 协议本身。

SMTP 对话

SMTP 是一种基于文本的 TCP 协议,在端口25上工作。SMTP 以锁步、逐个命令的方式工作,客户端发送命令,服务器对每个命令发送响应。

在典型会话中,对话如下:

  1. 客户端首先建立与 SMTP 服务器的连接。

  2. 服务器以问候开始。这个问候表明服务器已准备好接收命令。

  3. 客户端随后发出自己的问候。

  4. 服务器响应。

  5. 客户端发送一个命令,表明邮件的发送者是谁。

  6. 服务器响应以表明发送者已被接受。

  7. 客户端发出另一个命令,指定邮件的接收者。

  8. 服务器响应表明接收者已被接受。

  9. 客户端随后发出一个DATA命令。

  10. 服务器响应要求客户端继续。

  11. 客户端传输电子邮件。

该协议非常简单。在以下 SMTP 会话示例中,mail.example.net是客户端,服务器是mail.example.comCS分别表示客户端或服务器是否发送):

S: 220 mail.example.com SMTP server ready
C: HELO mail.example.net
S: 250 Hello mail.example.net [192.0.2.67]
C: MAIL FROM:<alice@example.net>
S: 250 OK
C: RCPT TO:<bob@example.com>
S: 250 Accepted
C: DATA
S: 354 Enter message, ending with "." on a line by itself
C: Subject: Re: The Cake
C: Date: Fri, 03 May 2019 02:31:20 +0000
C:
C: Do NOT forget to bring the cake!
C: .
S: 250 OK
C: QUIT
S: 221 closing connection

服务器发送的每一项内容都是对客户端命令的回应,除了第一行。第一行仅仅是响应客户端的连接。

你可能会注意到客户端的每个命令都以四个字母的单词开头。每个服务器的响应都以三位数字代码开头。

我们常用的客户端命令如下:

  • HELO用于客户端向服务器标识自己。

  • MAIL用于指定谁在发送邮件。

  • RCPT用于指定一个收件人。

  • DATA用于启动实际电子邮件的传输。这封电子邮件应包括头部和正文。

  • QUIT用于结束会话。

在成功的电子邮件传输中使用的服务器响应代码如下:

  • 220: 服务就绪

  • 250: 请求的命令已被接受并成功完成

  • 354: 开始发送消息

  • 221: 连接正在关闭

错误代码因提供商而异,但通常在 500 范围内。

SMTP 服务器也可以发送跨越多行的回复。在这种情况下,最后一行以响应代码后跟一个空格开始。所有前面的行都以响应代码后跟一个破折号开始。以下是一个尝试投递到不存在的邮箱后的多行响应示例:

C: MAIL FROM:<alice@example.net>
S: 250 OK
C: RCPT TO:<not-a-real-user@example.com>
S: 550-The account you tried to deliver to does not
S: 550-exist. Please double-check the recipient's
S: 550 address for typos and try again.

注意,一些服务器在回复RCPT命令之前会验证收件人地址是否有效,但许多服务器仅在客户端使用DATA命令发送电子邮件后验证收件人地址。

虽然这解释了发送邮件所使用的协议的基本原理,但我们仍然必须考虑电子邮件本身的格式。这将在下一部分介绍。

电子邮件的格式

如果我们将物理邮件进行类比,SMTP 命令MAIL FROMRCPT TO指向信封。这些命令向 SMTP 服务器提供了关于邮件如何投递的信息。在这个类比中,DATA命令将是信封内的信件。由于通常在信封内写上物理信件地址,所以在电子邮件中重复投递信息也很常见,即使这些信息已经通过MAILRCPT命令发送给了 SMTP 服务器。

一个简单的电子邮件可能看起来像以下这样:

From: Alice Doe <alice@example.net>
To: Bob Doe <bob@example.com>
Subject: Re: The Cake
Date: Fri, 03 May 2019 02:31:20 +0000

Hi Bob,

Do NOT forget to bring the cake!

Best,
Alice

整个电子邮件在DATA命令之后被发送到 SMTP 服务器。在一行空白中加一个点表示电子邮件的结束。如果电子邮件中任何行以点开头,SMTP 客户端应将其替换为两个连续的点。这防止客户端过早地表示电子邮件结束。SMTP 服务器知道以两个点开头的任何行应替换为单个点。

电子邮件本身可以分为两部分——头部和正文。这两部分由第一行空白分隔。

头部部分由各种头部组成,这些头部指示电子邮件的属性。FromToSubjectDate是最常见的头部。

电子邮件的正文部分就是发送的消息。

在对电子邮件格式有基本了解之后,我们现在可以开始编写一个简单的 C 程序来发送电子邮件。

一个简单的 SMTP 客户端程序

在对 SMTP 和电子邮件格式有基本理解之后,我们就可以编写一个简单的电子邮件客户端程序了。我们的客户端接受以下输入:目标电子邮件服务器、收件人地址、发件人地址、电子邮件主题行和电子邮件正文文本。

我们的程序首先通过以下语句包含必要的头文件:

/*smtp_send.c*/

#include "chap08.h"
#include <ctype.h>
#include <stdarg.h>

我们还定义了以下两个常量,以便于缓冲区分配和检查:

/*smtp_send.c continued*/

#define MAXINPUT 512
#define MAXRESPONSE 1024

我们的程序需要多次提示用户输入。这是为了获取电子邮件服务器的域名、收件人地址等等。C 提供了 gets() 函数来完成这个目的,但 gets() 在最新的 C 标准中已被弃用。因此,我们实现了自己的函数。

以下函数 get_input() 用于提示用户输入:

/*smtp_send.c continued*/

void get_input(const char *prompt, char *buffer)
{
    printf("%s", prompt);

    buffer[0] = 0;
    fgets(buffer, MAXINPUT, stdin);
    const int read = strlen(buffer);
    if (read > 0)
        buffer[read-1] = 0;
}

get_input() 函数使用 fgets()stdin 读取。传递给 get_input() 的缓冲区假定是 MAXINPUT 字节,我们在文件顶部定义了这个值。

fgets() 函数不会从接收到的输入中删除换行符;因此,我们将输入的最后一个字符覆盖为一个终止的空字符。

也有一个函数可以直接通过网络发送格式化的字符串非常有帮助。我们为此实现了一个名为 send_format() 的函数。它接受一个套接字、一个格式化字符串以及要发送的附加参数。你可以把 send_format() 看作是非常类似于 printf() 的。区别在于 send_format() 通过网络传递格式化的文本而不是打印到屏幕上。

send_format() 的代码如下:

/*smtp_send.c continued*/

void send_format(SOCKET server, const char *text, ...) {
    char buffer[1024];
    va_list args;
    va_start(args, text);
    vsprintf(buffer, text, args);
    va_end(args);

    send(server, buffer, strlen(buffer), 0);

    printf("C: %s", buffer);
}

上述代码首先预留一个缓冲区。然后使用 vsprintf() 将文本格式化到该缓冲区中。调用者需要确保格式化的输出不超过预留的缓冲区空间。我们假设在这个程序中用户是可信的,但在一个生产程序中,你想要添加检查以防止在这里发生缓冲区溢出。

在将输出文本格式化为 buffer 后,我们使用 send() 发送它。我们还打印出发送的文本到屏幕上。在它前面打印一个 C: 来表示文本是由我们,即客户端发送的。

我们 SMTP 客户端比较棘手的部分之一是解析 SMTP 服务器响应。这很重要,因为 SMTP 客户端必须在收到第一个命令的响应之前不发出第二个命令。如果 SMTP 客户端在服务器准备好之前发送新的命令,那么服务器很可能会终止连接。

回想一下,每个 SMTP 响应都以三位数字代码开始。我们想要解析出这个代码来检查错误。每个 SMTP 响应通常后面跟着我们忽略的文本。SMTP 响应通常是单行长,但有时可以跨越多行。在这种情况下,直到倒数第二行的每一行都包含一个破折号字符 -,直接跟在三位响应代码后面。

为了说明多行响应是如何工作的,考虑以下两个响应是等效的:

/*response 1*/

250 Message received!
/*response 2*/

250-Message
250 received!

确保我们的程序识别多行响应很重要;它不能错误地将单个多行响应视为单独的响应。

为了实现这个目的,我们实现了一个名为parse_response()的函数。它接受一个以空字符终止的响应字符串,并返回解析后的响应代码。如果没有找到代码或响应不完整,则返回0。此函数的代码如下:

/*smtp_send.c continued*/

int parse_response(const char *response) {
    const char *k = response;
    if (!k[0] || !k[1] || !k[2]) return 0;
    for (; k[3]; ++k) {
        if (k == response || k[-1] == '\n') {
            if (isdigit(k[0]) && isdigit(k[1]) && isdigit(k[2])) {
                if (k[3] != '-') {
                    if (strstr(k, "\r\n")) {
                        return strtol(k, 0, 10);
                    }
                }
            }
        }
    }
    return 0;
}

parse_response()函数首先检查响应的前三个字符中是否有空终止符。如果在那里找到空字符,那么函数可以立即返回,因为response不足以构成有效的 SMTP 响应。

然后它遍历response输入字符串。循环直到在三个字符外找到一个空终止字符。在每次循环中,使用isdigit()检查当前字符和下一个两个字符是否都是数字。如果是这样,则检查第四个字符k[3]。如果k[3]是一个破折号,则响应继续到下一行。然而,如果k[3]不是一个破折号,则k[0]代表 SMTP 响应的最后一行的开始。在这种情况下,代码检查是否已经接收到行结束;strstr()用于此目的。如果接收到行结束,则代码使用strtol()将响应代码转换为整数。

如果代码在遍历response()而没有返回时,则返回0,并且客户端需要等待从 SMTP 服务器接收更多输入。

parse_response()函数处理完毕后,有一个函数等待网络接收到特定的响应代码是有用的。为此,我们实现了一个名为wait_on_response()的函数,其开始如下:

/*smtp_send.c continued*/

void wait_on_response(SOCKET server, int expecting) {
    char response[MAXRESPONSE+1];
    char *p = response;
    char *end = response + MAXRESPONSE;

    int code = 0;

在前面的代码中,为存储 SMTP 服务器的响应预留了一个response缓冲区变量。一个指针p被设置为这个缓冲区的开始;p将被增加以指向接收到的数据的末尾,但因为它还没有收到数据,所以它从response开始。一个指向缓冲区末尾的end指针变量被设置,这对于确保我们不会尝试写入缓冲区末尾是有用的。

最后,我们将code设置为0以指示尚未接收到任何响应代码。

wait_on_response()函数随后通过以下循环继续:

/*smtp_send.c continued*/

    do {
        int bytes_received = recv(server, p, end - p, 0);
        if (bytes_received < 1) {
            fprintf(stderr, "Connection dropped.\n");
            exit(1);
        }

        p += bytes_received;
        *p = 0;

        if (p == end) {
            fprintf(stderr, "Server response too large:\n");
            fprintf(stderr, "%s", response);
            exit(1);
        }

        code = parse_response(response);

    } while (code == 0);

上述循环的开始使用recv()从 SMTP 服务器接收数据。接收到的数据被写入response数组中的p点。我们小心地使用end确保接收到的数据不会写入response的末尾。

recv()返回后,p被增加以指向接收到的数据的末尾,并设置一个空终止字符。检查p == end确保我们没有写入响应缓冲区的末尾。

我们之前定义的函数 parse_response() 用于检查是否已接收到完整的 SMTP 响应。如果是,则将 code 设置为该响应。如果不是,则 code 等于 0,循环将继续接收更多的数据。

循环结束后,wait_on_response() 函数会检查接收到的 SMTP 响应代码是否符合预期。如果是,则将接收到的数据打印到屏幕上,并返回函数。以下是该代码:

/*smtp_send.c continued*/

    if (code != expecting) {
        fprintf(stderr, "Error from server:\n");
        fprintf(stderr, "%s", response);
        exit(1);
    }

    printf("S: %s", response);
}

这就完成了 wait_on_response() 函数。这个函数非常有用,并且需要在向 SMTP 服务器发送的每个命令之后使用。

我们还定义了一个名为 connect_to_host() 的函数,该函数尝试打开到指定主机名和端口号的 TCP 连接。这个函数与我们在前几章中使用的代码非常相似。

首先,使用 getaddrinfo() 解析主机名,然后使用 getnameinfo() 打印服务器 IP 地址。以下代码实现了这两个目的:

/*smtp_send.c continued*/

SOCKET connect_to_host(const char *hostname, const char *port) {
    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_STREAM;
    struct addrinfo *peer_address;
    if (getaddrinfo(hostname, port, &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(peer_address->ai_addr, peer_address->ai_addrlen,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST);
    printf("%s %s\n", address_buffer, service_buffer);

然后使用 socket() 创建一个套接字,如下所示:

/*smtp_send.c continued*/

    printf("Creating socket...\n");
    SOCKET server;
    server = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(server)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

一旦创建了套接字,就使用 connect() 来建立连接。以下代码展示了 connect() 的使用和 connect_to_host() 函数的结束:

/*smtp_send.c continued*/

    printf("Connecting...\n");
    if (connect(server,
                peer_address->ai_addr, peer_address->ai_addrlen)) {
        fprintf(stderr, "connect() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }
    freeaddrinfo(peer_address);

    printf("Connected.\n\n");

    return server;
}

不要忘记调用 freeaddrinfo() 来释放为服务器地址分配的内存,如前述代码所示。

最后,当那些辅助函数不再需要时,我们可以开始编写 main() 函数。以下代码定义了 main() 函数并在需要时初始化 Winsock:

/*smtp_send.c continued*/

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

请参阅 第二章,掌握 Socket API,以获取有关初始化 Winsock 和建立连接的更多信息。

我们的程序可以通过提示用户输入 SMTP 主机名来继续执行。这个主机名存储在 hostname 中,我们的 connect_to_host() 函数用于打开连接。以下代码展示了这一点:

    /*smtp_send.c continued*/

    char hostname[MAXINPUT];
    get_input("mail server: ", hostname);

    printf("Connecting to host: %s:25\n", hostname);

    SOCKET server = connect_to_host(hostname, "25");

连接建立后,我们的 SMTP 客户端必须等待服务器响应 220 代码后才能发出任何命令。我们使用 wait_on_response() 函数通过以下代码来等待响应:

/*smtp_send.c continued*/

    wait_on_response(server, 220);

一旦服务器准备好接收命令,我们必须发出 HELO 命令。以下代码发送 HELO 命令并等待 250 响应代码:

/*smtp_send.c continued*/

    send_format(server, "HELO HONPWC\r\n");
    wait_on_response(server, 250);

HELO 应该跟随着 SMTP 客户端的主机名;然而,由于我们可能是在开发机器上运行此客户端,所以我们可能没有设置主机名。因此,我们简单地发送 HONPWC,尽管可以使用任何任意的字符串。如果你是从服务器上运行此客户端,那么你应该将 HONPWC 字符串更改为指向你的服务器的域名。

此外,请注意前述代码中使用的行结束符。SMTP 使用的行结束符是一个回车符后跟一个换行符。在 C 语言中,这表示为 "\r\n"

然后我们的程序会提示用户输入发送和接收地址,并发出相应的 SMTP 命令。这是通过get_input()来提示用户,send_format()来发出 SMTP 命令,以及wait_on_response()来接收 SMTP 服务器的响应来完成的:

/*smtp_send.c continued*/

    char sender[MAXINPUT];
    get_input("from: ", sender);
    send_format(server, "MAIL FROM:<%s>\r\n", sender);
    wait_on_response(server, 250);

    char recipient[MAXINPUT];
    get_input("to: ", recipient);
    send_format(server, "RCPT TO:<%s>\r\n", recipient);
    wait_on_response(server, 250);

在指定了发送者和接收者之后,SMTP 的下一步是发出DATA命令。DATA命令指示服务器监听实际电子邮件。它通过以下代码发出:

/*smtp_send.c continued*/

    send_format(server, "DATA\r\n");
    wait_on_response(server, 354);

我们的客户端程序接着会提示用户输入电子邮件的主题行。在指定主题行之后,它可以发送电子邮件头:FromTo,和Subject。以下代码执行此操作:

/*smtp_send.c continued*/

    char subject[MAXINPUT];
    get_input("subject: ", subject);

    send_format(server, "From:<%s>\r\n", sender);
    send_format(server, "To:<%s>\r\n", recipient);
    send_format(server, "Subject:%s\r\n", subject);

添加日期头也很有用。电子邮件使用特殊的日期格式。我们可以使用strftime()函数正确地格式化日期。以下代码将日期格式化为正确的电子邮件头:

/*smtp_send.c continued*/

    time_t timer;
    time(&timer);

    struct tm *timeinfo;
    timeinfo = gmtime(&timer);

    char date[128];
    strftime(date, 128, "%a, %d %b %Y %H:%M:%S +0000", timeinfo);

    send_format(server, "Date:%s\r\n", date);

在前面的代码中,time()函数用于获取当前日期和时间,而gmtime()用于将其转换为timeinfo结构。然后,调用strftime()将日期和时间格式化到临时缓冲区date中。然后,将格式化后的字符串作为电子邮件头发送到 SMTP 服务器。

在发送电子邮件头之后,电子邮件正文由一个空行分隔。以下代码发送这个空行:

/*smtp_send.c continued*/

    send_format(server, "\r\n");

然后,我们可以使用get_input()提示用户输入电子邮件正文。正文逐行传输。当用户完成他们的电子邮件后,他们应该在单独的一行上输入一个句点。这既向我们的客户端也向 SMTP 服务器表明电子邮件已结束。

以下代码将用户输入发送到服务器,直到输入单个句点为止:

/*smtp_send.c continued*/

    printf("Enter your email text, end with \".\" on a line by itself.\n");

    while (1) {
        char body[MAXINPUT];
        get_input("> ", body);
        send_format(server, "%s\r\n", body);
        if (strcmp(body, ".") == 0) {
            break;
        }
    }

如果邮件被 SMTP 服务器接受,它将发送250响应代码。然后,我们的客户端发出QUIT命令并检查221响应代码。221响应代码表示连接正在终止,如下面的代码所示:

/*smtp_send.c continued*/

    wait_on_response(server, 250);

    send_format(server, "QUIT\r\n");
    wait_on_response(server, 221);

我们的 SMTP 客户端通过关闭套接字,清理 Winsock(如果需要),并按以下方式退出:

/*smtp_send.c continued*/

    printf("\nClosing socket...\n");
    CLOSESOCKET(server);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

这就完成了smtp_send.c

你可以使用以下方式在 Windows 上使用 MinGW 编译和运行smtp_send.c

gcc smtp_send.c -o smtp_send.exe -lws2_32
smtp_send.exe

在 Linux 或 macOS 上,编译和运行smtp_send.c的方式如下:

gcc smtp_send.c -o smtp_send
./smtp_send

以下截图显示了使用smtp_send.c发送简单电子邮件的过程:

图片

如果你正在进行大量测试,你可能觉得每次都输入电子邮件地址很麻烦。在这种情况下,你可以通过将文本放入文件并使用cat实用程序将其读取到smtp_send中来自动化它。例如,你可能有一个如下所示的email.txt文件:

/*email.txt*/

mail-server.example.net
bob@example.com
alice@example.net
Re: The Cake
Hi Alice,

What about the cake then?

Bob
.

使用存储在email.txt中的程序输入,你可以使用以下命令发送电子邮件:

cat email.txt | ./smtp_send

希望您可以使用smtp_send发送一些测试电子邮件。然而,您可能会遇到一些障碍。您的 ISP 可能会阻止从您的连接发送的出站电子邮件,并且许多电子邮件服务器不接受来自住宅 IP 地址块的邮件。请参阅本章后面的垃圾邮件阻止陷阱部分以获取更多信息。

虽然smtp_send对于发送基于文本的消息很有用,但您可能想知道如何给电子邮件添加格式。也许您想发送作为附件的文件。下一节将解决这些问题。

增强型电子邮件

我们迄今为止所查看的电子邮件只是简单的文本。现代电子邮件的使用往往需要更复杂的格式化电子邮件。

我们可以使用Content-Type标题来控制电子邮件的内容类型。这与我们在第七章中介绍的 HTTP 使用的Content-Type标题非常相似,即构建一个简单的 Web 服务器

如果缺少内容类型标题,默认假设内容类型为text/plain。因此,以下电子邮件中的Content-Type标题是多余的:

From: Alice Doe <alice@example.net>
To: Bob Doe <bob@example.com>
Subject: Re: The Cake
Date: Fri, 03 May 2019 02:31:20 +0000
Content-Type: text/plain

Hi Bob,

Do NOT forget to bring the cake!

Best,
Alice

如果您想在电子邮件中添加格式支持,这在当今是很常见的,您应该使用text/html内容类型。在下面的电子邮件中,HTML 被用来添加强调:

From: Alice Doe <alice@example.net>
To: Bob Doe <bob@example.com>
Subject: Re: The Cake
Date: Fri, 03 May 2019 02:31:20 +0000
Content-Type: text/html

Hi Bob,<br>
<br>
Do <strong>NOT</strong> forget to bring the cake!<br>
<br>
Best,<br>
Alice<br>

并非所有电子邮件客户端都支持 HTML 电子邮件。因此,将您的消息编码为纯文本和 HTML 两种格式可能很有用。以下电子邮件使用了这种技术:

From: Alice Doe <alice@example.net>
To: Bob Doe <bob@example.com>
Subject: Re: The Cake
Date: Fri, 03 May 2019 02:31:20 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="SEPARATOR"

This is a message with multiple parts in MIME format.
--SEPARATOR
Content-Type: text/plain

Hi Bob,

Do NOT forget to bring the cake!

Best,
Alice
--SEPARATOR
Content-Type: text/html

Hi Bob,<br>
<br>
Do <strong>NOT</strong> forget to bring the cake!<br>
<br>
Best,<br>
Alice<br>
--SEPARATOR--

上述电子邮件示例使用两个标题来指示它是一个多部分消息。第一个标题MIME-Version: 1.0指示我们正在使用哪个版本的多用途互联网邮件扩展MIME)。MIME 用于所有不是纯文本的电子邮件。

第二个标题Content-Type: multipart/alternative; boundary="SEPARATOR"指定了我们正在发送一个多部分消息。它还指定了一个特殊的边界字符序列,用于界定电子邮件的各个部分。在我们的例子中,SEPARATOR被用作边界。确保边界不会出现在电子邮件文本或附件中。在实践中,边界指定符通常是长随机生成的字符串。

一旦设置了边界,电子邮件的每一部分都以单独一行上的--SEPARATOR开始。电子邮件以--SEPARATOR--结束。请注意,消息的每一部分都有自己的标题部分,仅针对该部分。这些标题部分用于指定每一部分的内容类型。

将文件附加到电子邮件中也是非常有用的,我们现在就来介绍这一点。

电子邮件文件附件

如果正在发送多部分电子邮件,可以使用Content-Disposition标题将部分指定为附件。请参阅以下示例:

From: Alice Doe <alice@example.net>
To: Bob Doe <bob@example.com>
Subject: Re: The Cake
Date: Fri, 03 May 2019 02:31:20 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="SEPARATOR"

This is a message with multiple parts in MIME format.
--SEPARATOR
Content-Type: text/plain

Hi Bob,

Please see the attached text file.

Best,
Alice
--SEPARATOR
Content-Disposition: attachment; filename=my_file.txt;
  modification-date="Fri, 03 May 2019 02:26:51 +0000";
Content-Type: application/octet-stream
Content-Transfer-Encoding: base64

VGhpcyBpcyBhIHNpbXBsZSB0ZXh0IG1lc3NhZ2Uu
--SEPARATOR--

上述示例中包含一个名为 my_file.txt 的文件。SMTP 是一个纯文本协议;因此,任何可能包含二进制数据的附件都需要编码成文本格式。Base64 编码通常用于此目的。在这个例子中,头部 Content-Transfer-Encoding: base64 指定了我们将使用 Base64 编码。

my_file.txt 的内容是 This is a simple text message。这句话编码成 Base64 为 VGhpcyBpcyBhIHNpbXBsZSB0ZXh0IG1lc3NhZ2Uu,如前述代码所示。

垃圾邮件拦截陷阱

现在发送电子邮件可能比过去要困难得多。垃圾邮件已经成为一个主要问题,每个服务提供商都在采取措施来遏制它。不幸的是,许多这些措施也可能使得发送合法电子邮件变得更加困难。

许多住宅互联网服务提供商不允许在端口 25 上进行出站连接。如果你的住宅服务提供商阻止了端口 25,那么你将无法建立 SMTP 连接。在这种情况下,你可能需要考虑租用一个虚拟专用服务器来运行本章的代码。

即使你的 ISP 允许在端口 25 上进行出站连接,许多 SMTP 服务器也不会接受来自住宅 IP 地址的邮件。在这些接受邮件的服务器中,许多会将这些邮件直接发送到垃圾邮件文件夹。

例如,如果你尝试将电子邮件发送到 Gmail,你可能会收到以下类似的响应:

550-5.7.1 [192.0.2.67] The IP you're using to send mail is not authorized
550-5.7.1 to send email directly to our servers. Please use the SMTP
550-5.7.1 relay at your service provider instead. Learn more at
550 5.7.1  https://support.google.com/mail/?p=NotAuthorizedError

另一个可能会让你陷入困境的垃圾邮件拦截措施是 发送者策略框架SPF)。SPF 通过列出哪些服务器可以为特定域发送邮件来工作。如果一个发送服务器不在 SPF 列表中,那么接收 SMTP 服务器将拒绝它们的邮件。

域密钥识别邮件DKIM)是一种使用数字签名验证电子邮件的措施。许多流行的电子邮件服务提供商更有可能将非 DKIM 邮件视为垃圾邮件。DKIM 签名非常复杂,超出了本书的范围。

基于域的消息身份验证、报告和一致性DMARC)是一种技术,用于域发布是否需要 SPF 和/或 DKIM 来验证从它们发出的邮件,以及其他事项。

大多数商业电子邮件服务器使用 SPF、DKIM 和 DMARC。如果你发送电子邮件没有这些,你的电子邮件很可能会被视为垃圾邮件。如果你发送的电子邮件违反了这些规定,你的电子邮件可能会被直接拒绝或被标记为垃圾邮件。

最后,许多流行的服务提供商会给发送域和服务器分配一个声誉。这使潜在的电子邮件发送者陷入了一个两难境地。没有建立声誉,电子邮件就无法投递,但没有成功发送大量电子邮件,声誉也无法建立。随着垃圾邮件继续成为一个主要问题,我们可能很快就会来到一个只有知名、可信的 SMTP 服务器才能相互操作的时代。让我们希望这种情况不会发生!

如果你的程序需要可靠地发送电子邮件,你可能会考虑使用电子邮件服务提供商。一个选项是允许 SMTP 中继进行最终投递。一个可能更容易的选项是使用一个运行 HTTP API 的邮件发送服务。

摘要

在本章中,我们探讨了电子邮件如何在互联网上投递。我们深入研究了负责电子邮件投递的协议 SMTP。然后我们构建了一个简单的程序,使用 SMTP 发送简短的电子邮件。

我们还研究了电子邮件格式。我们看到了如何使用 MIME 发送带有文件附件的多部分电子邮件。

我们还看到了在现代互联网上发送电子邮件充满了陷阱。其中许多源于阻止垃圾邮件的尝试。提供商使用的技巧,如封锁住宅 IP 地址、SPF、DKIM、DMARC 和 IP 地址声誉监控,可能会使我们的简单程序难以可靠地投递邮件。

在下一章,第九章,使用 HTTPS 和 OpenSSL 加载安全网页,我们将探讨使用 HTTPS 的加密网络连接。

问题

尝试以下问题来测试你对本章知识的掌握:

  1. SMTP 运行在哪个端口上?

  2. 你如何确定哪个 SMTP 服务器为特定域名接收邮件?

  3. 你如何确定哪个 SMTP 服务器为特定提供商发送邮件?

  4. 为什么 SMTP 服务器在没有身份验证的情况下不会中继邮件?

  5. 当 SMTP 是一个基于文本的协议时,如何将二进制文件作为电子邮件附件发送?

答案可以在附录 A,问题解答中找到。

进一步阅读

关于 SMTP 和电子邮件格式的更多信息,请参阅以下链接:

第三部分 - 理解加密协议和 OpenSSL

在本节中,读者将了解现代网络中常见的安全、加密协议。

以下章节包含在本节中:

第九章,使用 HTTPS 和 OpenSSL 加载安全网页

第十章,实现安全 Web 服务器

第十一章,建立 SSH 连接

第九章:使用 HTTPS 和 OpenSSL 加载安全网页

本章我们将学习如何使用 超文本传输协议安全HTTPS)来建立与 Web 服务器的安全连接。HTTPS 相比 HTTP 提供了多项优势。HTTPS 提供了一种身份验证方法来识别服务器并检测冒名者。它还保护了所有传输数据的安全性,防止拦截者篡改或伪造传输数据。

在 HTTPS 中,通信是通过 传输层安全性TLS)来加密的。在本章中,我们将学习如何使用流行的 OpenSSL 库来提供 TLS 功能。

本章涵盖了以下主题:

  • HTTPS 的背景信息

  • 加密算法类型

  • 服务器如何进行身份验证

  • 基本 OpenSSL 使用

  • 创建简单的 HTTPS 客户端

技术要求

本章的示例程序可以使用任何现代 C 编译器编译。我们推荐 Windows 使用 MinGW,Linux 和 macOS 使用 GCC。你还需要安装 OpenSSL 库。请参阅附录 B 在 Windows 上设置您的 C 编译器、附录 C 在 Linux 上设置您的 C 编译器 和附录 D 在 macOS 上设置您的 C 编译器,了解编译器和 OpenSSL 的安装设置。

本书中的代码可以在 github.com/codeplea/Hands-On-Network-Programming-with-C 找到。

从命令行,你可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap09

本章中的每个示例程序都可在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要与 Winsock 库链接。这可以通过向 gcc 传递 -lws2_32 选项来实现。

每个示例还需要链接到 OpenSSL 库,libssl.alibcrypto.a。这可以通过向 gcc 传递 -lssl -lcrypto 来实现。

我们提供了编译每个示例所需的精确命令。

本章中的所有示例程序都需要与我们在第二章 掌握套接字 API 中开发的相同头文件和 C 宏。为了简洁,我们将这些语句放在一个单独的头文件 chap09.h 中,我们可以在每个程序中包含它。有关这些语句的解释,请参阅第二章 掌握套接字 API。

chap09.h 文件的内容首先包含必要的网络头文件。相应的代码如下:

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

我们还定义了一些宏来帮助编写可移植的代码,如下所示:

/*chap09.h continued*/

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

最后,chap09.h 包含了一些额外的头文件,包括 OpenSSL 库的头文件。以下代码展示了这一点:

/*chap09.h continued*/

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>

#include <openssl/crypto.h>
#include <openssl/x509.h>
#include <openssl/pem.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

HTTPS 概述

HTTPS 为 HTTP 提供安全。我们在第六章“构建简单的 Web 客户端”中介绍了 HTTP。HTTPS 通过在端口443上使用 TCP 的 TLS 来保护 HTTP。TLS 是一种可以为任何 TCP 连接提供安全的协议。

TLS安全套接字层SSL)的继任者,SSL 是一个较早的协议,也被 HTTPS 使用。TLS 和 SSL 是兼容的,本章的大部分信息也适用于 SSL。通常,建立 HTTPS 连接涉及客户端和服务器协商使用哪种协议。理想的结果是客户端和服务器就使用最安全、相互支持的协议和密钥协商一致。

当我们谈论协议安全时,我们通常寻找以下三个要素:

  • 身份验证:我们需要一种方法来防止冒充者伪装成合法的通信伙伴。TLS 提供对等身份验证方法来解决这个问题。

  • 加密:TLS 使用加密来混淆传输的数据。这阻止了窃听者正确解释截获的数据。

  • 完整性:TLS 还确保接收到的数据没有被篡改或伪造。

HTTP最常用于传输网页。网页上的文本首先被编码为超文本标记语言HTML)。HTML为网页提供格式化、布局和样式。然后使用HTTP传输HTML,而HTTP本身是通过TCP连接传输的。

从视觉上看,一个HTTP会话封装如下:

TLS在 TCP 内部工作,以提供安全的通信通道。然后 HTTPS 基本上与HTTP协议相同,但它是在TLS通道中发送的。

从视觉上看,HTTPS 是以以下方式封装的:

当然,如果使用HTTP传输图像、视频或其他数据而不是HTML,相同的抽象仍然适用。

请务必记住,这些抽象在概念层面上是准确的,但有些细节超越了层次。例如,一些 HTTPS 头信息用于引用TLS应用的安全参数。不过,总的来说,将TLS视为保护 HTTPS 使用的TCP连接是合理的。

虽然TLS最常用于 HTTPS 安全,但TLS也被用于保护许多基于TCP的其他协议。我们在第八章“让你的程序发送电子邮件”中提到的电子邮件协议 SMTP 也通常通过TLS进行加密。

在进一步详细介绍使用TLS之前,了解一些加密的必要背景信息是有用的。

加密基础

加密是一种编码数据的方法,只有授权的当事人才能访问它。加密不能防止拦截或干扰,但它阻止了潜在的攻击者访问原始数据。

加密算法被称为密码。加密密码将未加密的数据作为输入,称为明文。密码产生加密数据,称为密文,作为其输出。将明文转换为密文的过程称为加密,将其反向转换的过程称为解密

现代密码使用密钥来控制数据的加密和解密。密钥通常是相对较短的伪随机数据序列。使用给定密钥加密的密文,如果没有正确的密钥,则无法解密。

广义上,密码分为两大类——对称密码和非对称密码。对称密码在加密和解密时使用相同的密钥,而非对称密码则使用两个不同的密钥。

对称密码

下面的图示说明了对称密码:

图片

在前面的图中,明文Hello!使用对称密码进行加密。使用密钥与密码结合产生密文。这个密文可以传输到不安全的通道上,窃听者如果没有知道密钥,就无法解密它。密文的特权接收者使用解密算法和密钥将其转换回明文。

一些通用对称密码(不仅限于 TLS)如下:

  • 美国加密标准AES),也称为Rijndael

  • Camellia

  • 数据加密标准DES

  • 三重 DES

  • 国际数据加密算法IDEA

  • QUAD

  • RC4

  • Salsa20, Chacha20

  • 小型加密算法TEA

对称加密的一个问题是,相同的密钥必须为发送者和接收者所知。生成和传输这个密钥的安全性问题。如果双方没有安全的通信渠道,如何将密钥在双方之间发送?如果他们已经有了安全的通信渠道,那么为什么还需要加密呢?

密钥交换算法试图解决这些问题。密钥交换算法通过允许双方通信方生成相同的秘密密钥来工作。一般来说,双方首先同意一个公开的非秘密密钥。然后,每一方生成自己的秘密密钥并将其与公开密钥结合。这些组合密钥被交换。然后,每一方将其自己的秘密添加到组合密钥中,以得到一个组合的秘密密钥。这个组合的秘密密钥然后为双方所知,但窃听者无法推导出来。

目前最常用的密钥交换算法是Diffie-Hellman 密钥交换算法

虽然密钥交换算法对窃听者具有抵抗力,但它们对拦截没有抵抗力。在拦截的情况下,攻击者可以站在密钥交换的中间,同时假装是相应的每一方。这被称为中间人攻击

非对称密码可以用来解决这些问题中的某些问题。

非对称密码

非对称加密,也称为 公钥加密,试图解决对称加密中的密钥交换和认证问题。在非对称加密中,使用两个密钥。一个密钥可以加密数据,而另一个密钥可以解密它。这些密钥一起生成,并且数学上相关。然而,在事后从其中一个密钥推导出另一个密钥是不可能的。

下图展示了一个非对称加密算法:

图片

使用非对称加密建立安全的通信通道更为简单。每一方都可以生成自己的非对称加密密钥。加密密钥可以无担忧地传输,而解密密钥则保持私密。在这个方案中,这些密钥分别被称为 公钥私钥

Rivest-Shamir-Adleman (RSA) 加密算法是第一种公钥加密算法之一,并且至今仍被广泛使用。较新的 椭圆曲线加密 (ECC) 算法承诺更高的效率,并且正在迅速增加市场份额。

非对称加密也用于实现数字签名。数字签名用于验证数据的真实性。数字签名是通过使用私钥为文档生成签名来创建的。然后可以使用公钥来验证文档是否由私钥持有者签署。

下图说明了数字签名和验证过程:

图片

TLS 使用这些方法的组合来实现安全性。

TLS 使用加密算法的方式

数字签名在 TLS 中至关重要;它们用于验证服务器。如果没有数字签名,TLS 客户端将无法区分真实服务器和冒充者。

TLS 也可以使用数字签名来验证客户端,尽管在实践中这并不常见。大多数网络应用要么不关心客户端认证,要么使用其他更简单的方法,例如密码。

理论上,非对称加密可以用来保护整个通信通道。然而,在实践中,现代的非对称加密算法效率低下,只能保护少量数据。因此,在可能的情况下,首选对称加密算法。TLS 仅使用非对称加密来验证服务器。TLS 使用密钥交换算法和对称加密来保护实际的通信。

对于加密算法,总是会发现漏洞。因此,TLS 连接能够选择双方都支持的最好算法至关重要。这是通过 加密套件 来实现的。加密套件是一系列算法,通常包括 密钥交换算法批量加密算法消息认证算法 (MAC)。

当 TLS 连接首次建立时,TLS 客户端向服务器发送一个首选的加密套件列表。TLS 服务器将从中选择一个用于连接的加密套件。如果服务器不支持客户端提供的任何加密套件,那么将无法建立安全的 TLS 连接。

在处理完一些关于安全性的背景信息之后,我们可以更详细地讨论 TLS。

TLS 协议

在 TCP 连接建立之后,TLS 握手由客户端发起。客户端向服务器发送一系列规范,包括它正在运行的 SSL/TLS 版本、它支持的加密套件以及它希望使用的压缩方法。

服务器选择 SSL/TLS 协议中双方都支持的最高版本来使用。它还从客户端提供的选项中选择一个加密套件和压缩方法。

如果客户端和服务器都不支持任何共同的加密套件,那么将无法建立 TLS 连接。当使用非常旧的浏览器与较新的服务器一起使用时,这种情况并不少见。

在基本设置完成后,服务器向客户端发送其证书。客户端使用这个证书来验证它是否连接到了合法的服务器。我们将在下一节中更详细地讨论证书。

一旦客户端验证了服务器确实是它所声称的身份,就会发起密钥交换。密钥交换完成后,客户端和服务器都拥有一个共享的秘密密钥。所有后续的通信都使用这个密钥和它们选择的对称加密算法进行加密。

证书用于通过数字签名验证服务器身份。接下来让我们探讨它们是如何工作的。

证书

每个 HTTPS 服务器使用一个或多个证书来验证其身份。这个证书必须要么被客户端本身信任,要么被客户端信任的第三方信任。在常见的用法中,例如在网页浏览器中,实际上不可能列出所有受信任的证书。因此,最常见的方法是通过验证受信任的第三方是否信任它们来验证证书。这种信任是通过数字签名来证明的。

例如,一个流行的数字证书颁发机构是 DigiCert Inc. 假设你信任 DigiCert Inc. 并已将他们提供的证书存储在本地;然后你连接到网站 example.com。你可能不信任 example.com,因为你之前没有见过他们的证书。然而,example.com 向你展示,他们的证书已被你信任的 DigiCert Inc. 证书数字签名。因此,你也信任 example.com 网站的证书。

实际上,证书链可以有几层深。只要你能验证数字签名回溯到你信任的证书,你就能验证整个链。

这种方法是 HTTPS 用于验证服务器的常用方法。它确实存在一些问题;即,你必须完全信任证书颁发机构。这是因为证书颁发机构理论上可以向冒名顶替者颁发证书,在这种情况下,你将被迫信任冒名顶替者。证书颁发机构会小心避免这种情况,因为这会破坏它们的声誉。

在撰写本文时,最受欢迎的证书颁发机构如下:

  • IdenTrust

  • Comodo

  • DigiCert

  • GoDaddy

  • GlobalSign

前五个证书颁发机构负责了网络上 90%以上的 HTTPS 证书。

也可能自行签名证书。在这种情况下,不使用任何证书颁发机构。在这些情况下,客户端需要以某种可靠的方式获取并验证您的证书副本,然后才能信任它。

证书通常与域名匹配,但它们也可以识别其他信息,例如公司名称、地址等。

在下一章第十章《实现安全 Web 服务器》中,将更详细地介绍证书。

现在通常一个服务器托管多个不同的域名,每个域名都需要自己的证书。现在让我们考虑这些服务器如何知道要发送哪个证书。

服务器名称标识

许多服务器托管多个域名。证书与域名相关联;因此,TLS 必须提供一种方法让客户端指定它正在连接到哪个域名。你可能还记得,HTTP Host 标头用于此目的。问题是,TLS 连接应在发送 HTTP 数据之前建立。因此,服务器必须在接收到 HTTP Host 标头之前决定要传输哪个证书。

这是通过使用服务器名称指示SNI)来实现的。SNI 是一种技术,当 TLS 使用时,要求客户端向服务器表明它正在尝试连接到哪个域名。然后服务器可以找到用于 TLS 连接的匹配证书。

SNI 相对较新,较旧的浏览器和服务器不支持它。在 SNI 流行之前,服务器有两个选择——它们可以每个 IP 地址只托管一个域名,或者它们可以为每个连接发送所有托管域的证书。

应该注意的是,SNI 涉及在网络中发送未加密的域名。这意味着窃听者可以看到客户端正在连接到哪个主机,尽管他们不知道客户端正在请求该主机的哪些资源。较新的协议,如加密服务器名称标识ESNI),解决了这个问题,但尚未广泛部署。

在对 TLS 协议有基本了解之后,我们准备查看实现它的最流行库——OpenSSL。

OpenSSL

OpenSSL 是一个广泛使用的开源库,为应用程序提供 SSL 和 TLS 服务。我们在本章中使用它来实现 HTTPS 所需的安全连接。

OpenSSL 的安装可能会有些挑战。有关更多信息,请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器

您可以通过运行以下命令来检查是否已安装 OpenSSL 命令行工具:

openssl version

以下截图显示了在 Ubuntu Linux 上的情况:

您还需要确保已安装 OpenSSL 库。以下程序可以用来测试这一点。如果它编译并成功运行,那么您确实已安装并正在使用 OpenSSL 库:

/*openssl_version.c*/

#include <openssl/ssl.h>

int main(int argc, char *argv[]) {
    printf("OpenSSL version: %s\n", OpenSSL_version(SSLEAY_VERSION));
    return 0;
}

如果您正在使用较旧的 OpenSSL 版本,您可能需要将 OpenSSL_version() 函数调用替换为 SSLeay_version()。然而,更好的解决方案是直接升级到较新的 OpenSSL 版本。

以下命令用于在 macOS 和 Linux 上编译前面的 openssl_version.c 程序:

gcc openssl_version.c -o openssl_version -lcrypto
./openssl_version

以下截图显示了编译和运行 openssl_version.c

在 Windows 上,openssl_version 可以使用 MinGW 和以下命令进行编译:

gcc openssl_version.c -o openssl_version.exe -lcrypto
openssl_version.exe

一旦 OpenSSL 库安装并可用,我们就可以开始使用加密套接字。

使用 OpenSSL 加密套接字

OpenSSL 提供的 TLS 可以应用于任何 TCP 套接字。

在您的程序中使用 OpenSSL 之前,初始化它是很重要的。以下代码执行此操作:

SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();

在前面的代码中,调用 SSL_library_init() 是初始化 OpenSSL 库所必需的。第二个调用(调用 OpenSSL_add_all_algorithms())导致 OpenSSL 加载所有可用的算法。作为替代,您也可以只加载您知道需要的算法。对于我们来说,加载所有算法很容易。第三个调用 SSL_load_error_strings() 导致 OpenSSL 加载错误字符串。此调用不是严格必需的,但在出错时提供易于阅读的错误消息很有用。

一旦初始化 OpenSSL,我们就可以创建 SSL 上下文。这是通过调用 SSL_CTX_new() 函数来完成的,它返回一个 SSL_CTX 对象。您可以将此对象视为 SSL/TLS 连接的某种工厂。它包含您想要用于连接的初始设置。大多数程序只需要创建一个 SSL_CTX 对象,并且可以将其用于所有连接。

以下代码创建 SSL 上下文:

SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
if (!ctx) {
    fprintf(stderr, "SSL_CTX_new() failed.\n");
    return 1;
}

SSL_CTX_new()函数接受一个参数。我们使用TLS_client_method(),这表示我们想要通用的、版本灵活的 TLS 方法。我们的客户端在连接时自动与服务器协商最佳相互支持的算法。

如果您使用的是较旧的 OpenSSL 版本,您可能需要在前面代码中将TLS_client_method()替换为TLSv1_2_client_method()。然而,一个更好的解决方案是升级到较新的 OpenSSL 版本。

要安全地建立 TCP 连接,您必须首先有一个 TCP 连接。这个 TCP 连接应该以正常方式建立。以下伪代码展示了这一点:

getaddrinfo(hostname, port, hints, address);
socket = socket(address, type, protocol);
connect(socket, address, type);

关于设置 TCP 连接的更多信息,请参阅第三章,TCP 连接的深入概述

一旦connect()成功返回,并建立了 TCP 连接,您可以使用以下代码来初始化 TLS 连接:

SSL *ssl = SSL_new(ctx);
if (!ctx) {
    fprintf(stderr, "SSL_new() failed.\n");
    return 1;
}

if (!SSL_set_tlsext_host_name(ssl, hostname)) {
    fprintf(stderr, "SSL_set_tlsext_host_name() failed.\n");
    ERR_print_errors_fp(stderr);
    return 1;
}

SSL_set_fd(ssl, socket);
if (SSL_connect(ssl) == -1) {
    fprintf(stderr, "SSL_connect() failed.\n");
    ERR_print_errors_fp(stderr);
    return 1;
}

在前面的代码中,SSL_new()用于创建一个SSL对象。此对象用于跟踪新的 SSL/TLS 连接。

然后我们使用SSL_set_tlsext_host_name()来设置我们试图连接的服务器的域名。这允许 OpenSSL 使用 SNI。此调用是可选的,但如果没有它,当服务器托管多个站点时,服务器不知道应该发送哪个证书。

最后,我们调用SSL_set_fd()SSL_connect()在我们的现有 TCP 套接字上初始化新的 TLS/SSL 连接。

可以查看 TLS 连接正在使用哪种加密套件。以下代码展示了这一点:

printf ("SSL/TLS using %s\n", SSL_get_cipher(ssl));

一旦 TLS 连接建立,可以使用SSL_write()SSL_read()分别发送和接收数据。这些函数的用法几乎与标准套接字send()recv()函数相同。

以下示例展示了通过 TLS 连接传输简单消息:

char *data = "Hello World!";
int bytes_sent = SSL_write(ssl, data, strlen(data));

接收数据,通过SSL_read()完成,如下面的示例所示:

char read[4096];
int bytes_received = SSL_read(ssl, read, 4096);
printf("Received: %.*s\n", bytes_received, read);

当连接结束时,通过调用SSL_shutdown()SSL_free(ssl)释放使用的资源非常重要。以下代码展示了这一点:

SSL_shutdown(ssl);
CLOSESOCKET(socket);
SSL_free(ssl);

当您完成 SSL 上下文的工作时,也应该调用SSL_CTX_free()。在我们的例子中,它看起来像这样:

SSL_CTX_free(ctx);

如果您的程序需要验证连接的对方,那么在 TLS 初始化期间发送的证书非常重要。让我们考虑一下。

证书

一旦 TLS 连接建立,我们可以使用SSL_get_peer_certificate()函数获取服务器的证书。打印证书主题和发行者也很简单,如下面的代码所示:

    X509 *cert = SSL_get_peer_certificate(ssl);
    if (!cert) {
        fprintf(stderr, "SSL_get_peer_certificate() failed.\n");
        return 1;
    }

    char *tmp;
    if (tmp = X509_NAME_oneline(X509_get_subject_name(cert), 0, 0)) {
        printf("subject: %s\n", tmp);
        OPENSSL_free(tmp);
    }

    if (tmp = X509_NAME_oneline(X509_get_issuer_name(cert), 0, 0)) {
        printf("issuer: %s\n", tmp);
        OPENSSL_free(tmp);
    }

    X509_free(cert);

OpenSSL 在 TLS/SSL 握手过程中自动验证证书。您可以使用SSL_get_verify_result()函数获取验证结果。其用法在以下代码中展示:

long vp = SSL_get_verify_result(ssl);
if (vp == X509_V_OK) {
    printf("Certificates verified successfully.\n");
} else {
    printf("Could not verify certificates: %ld\n", vp);
}

如果 SSL_get_verify_result() 返回 X509_V_OK,则 OpenSSL 已验证证书链,并且可以信任连接。如果 SSL_get_verify_result() 不返回 X509_V_OK,则 HTTPS 认证失败,应该放弃连接。

为了使 OpenSSL 成功验证证书,我们必须告诉它我们信任哪些证书颁发机构。这可以通过使用 SSL_CTX_load_verify_locations() 函数来完成。它必须传递存储所有受信任根证书的文件名。假设您的受信任证书存储在 trusted.pem 中,以下代码设置了这一配置:

if (!SSL_CTX_load_verify_locations(ctx, "trusted.pem", 0)) {
    fprintf(stderr, "SSL_CTX_load_verify_locations() failed.\n");
    ERR_print_errors_fp(stderr);
    return 1;
}

决定信任哪些根证书并不容易。每个操作系统都提供了一份受信任证书列表,但没有一种通用的、简单的方法来导入这些列表。使用操作系统的默认列表也不总是适合每个应用程序。出于这些原因,本章的示例中省略了证书验证。然而,为了确保您的 HTTPS 连接安全,适当地实现证书验证是绝对必要的。

除了验证证书的签名外,验证证书实际上是否适用于您连接的特定服务器也很重要!较新的 OpenSSL 版本提供了帮助完成这一任务的函数,但使用较旧的 OpenSSL 版本时,您需要自行处理。有关更多信息,请参阅 OpenSSL 文档。

我们现在已经涵盖了关于 TLS 和 OpenSSL 的足够背景信息,可以开始处理一个具体的示例程序。

一个简单的 HTTPS 客户端

为了将本章的概念结合起来,我们构建了一个简单的 HTTPS 客户端。这个客户端可以连接到指定的 HTTPS 网络服务器并请求根文档 /

我们的程序首先包含所需的章节头文件,定义 main() 函数,并初始化 Winsock,如下所示:

/*https_simple.c*/

#include "chap09.h"

int main(int argc, char *argv[]) {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后,我们使用以下代码初始化 OpenSSL 库:

   /*https_simple.c continued*/

    SSL_library_init();
    OpenSSL_add_all_algorithms();
    SSL_load_error_strings();

SSL_load_error_strings() 函数调用是可选的,但如果遇到问题,它非常有用。

我们还可以创建一个 OpenSSL 上下文。这是通过调用 SSL_CTX_new() 来完成的,如下面的代码所示:

   /*https_simple.c continued*/ 

    SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
    if (!ctx) {
        fprintf(stderr, "SSL_CTX_new() failed.\n");
        return 1;
    }

如果我们要进行证书验证,这将是一个包含 SSL_CTX_load_verify_locations() 函数调用的好地方,正如本章的 证书 部分所解释的。为了简化示例,我们省略了证书验证,但在实际应用中,包含它是很重要的。

我们的程序随后检查是否在命令行中传递了主机名和端口号,如下所示:

/*https_simple.c continued*/

    if (argc < 3) {
        fprintf(stderr, "usage: https_simple hostname port\n");
        return 1;
    }

    char *hostname = argv[1];
    char *port = argv[2];

标准的 HTTPS 端口号是 443。我们的程序允许用户指定任何端口号,这在测试中可能很有用。

然后我们配置套接字连接的远程地址。此代码使用自第三章深入概述 TCP 连接以来我们所使用的相同技术。该代码如下:

/*https_simple.c continued*/

    printf("Configuring remote address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_STREAM;
    struct addrinfo *peer_address;
    if (getaddrinfo(hostname, port, &hints, &peer_address)) {
        fprintf(stderr, "getaddrinfo() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Remote address is: ");
    char address_buffer[100];
    char service_buffer[100];
    getnameinfo(peer_address->ai_addr, peer_address->ai_addrlen,
            address_buffer, sizeof(address_buffer),
            service_buffer, sizeof(service_buffer),
            NI_NUMERICHOST);
    printf("%s %s\n", address_buffer, service_buffer);

我们继续使用 socket() 调用创建套接字,并使用 connect() 函数连接它,如下所示:

/*https_simple.c continued*/

    printf("Creating socket...\n");
    SOCKET server;
    server = socket(peer_address->ai_family,
            peer_address->ai_socktype, peer_address->ai_protocol);
    if (!ISVALIDSOCKET(server)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }

    printf("Connecting...\n");
    if (connect(server,
                peer_address->ai_addr, peer_address->ai_addrlen)) {
        fprintf(stderr, "connect() failed. (%d)\n", GETSOCKETERRNO());
        exit(1);
    }
    freeaddrinfo(peer_address);

    printf("Connected.\n\n");

到目前为止,已建立 TCP 连接。如果我们不需要加密,我们可以直接通过它进行通信。然而,我们将使用 OpenSSL 在我们的 TCP 连接上初始化 TLS/SSL 连接。以下代码创建一个新的 SSL 对象,设置 SNI 的主机名,并初始化 TLS/SSL 握手:

/*https_simple.c continued*/

    SSL *ssl = SSL_new(ctx);
    if (!ctx) {
        fprintf(stderr, "SSL_new() failed.\n");
        return 1;
    }

    if (!SSL_set_tlsext_host_name(ssl, hostname)) {
        fprintf(stderr, "SSL_set_tlsext_host_name() failed.\n");
        ERR_print_errors_fp(stderr);
        return 1;
    }

    SSL_set_fd(ssl, server);
    if (SSL_connect(ssl) == -1) {
        fprintf(stderr, "SSL_connect() failed.\n");
        ERR_print_errors_fp(stderr);
        return 1;
    }

前面的代码在本章前面的 使用 OpenSSL 的加密套接字 部分中进行了说明。

SSL_set_tlsext_host_name() 调用是可选的,但如果您可能连接到托管多个域的服务器,则很有用。如果没有这个调用,服务器将不知道哪些证书与这个连接相关。

有时了解客户端和服务器同意的加密套件也很有用。我们可以使用以下代码打印选定的加密套件:

/*https_simple.c continued*/

    printf("SSL/TLS using %s\n", SSL_get_cipher(ssl));

看看服务器的证书也很有用。以下代码打印服务器的证书:

/*https_simple.c continued*/

    X509 *cert = SSL_get_peer_certificate(ssl);
    if (!cert) {
        fprintf(stderr, "SSL_get_peer_certificate() failed.\n");
        return 1;
    }

    char *tmp;
    if ((tmp = X509_NAME_oneline(X509_get_subject_name(cert), 0, 0))) {
        printf("subject: %s\n", tmp);
        OPENSSL_free(tmp);
    }

    if ((tmp = X509_NAME_oneline(X509_get_issuer_name(cert), 0, 0))) {
        printf("issuer: %s\n", tmp);
        OPENSSL_free(tmp);
    }

    X509_free(cert);

证书的 主题 应该与我们连接到的域名匹配。发行者应该是我们信任的证书机构。注意,前面的代码 验证证书。有关更多信息,请参阅本章的 证书 部分。

我们可以发送我们的 HTTPS 请求。此请求与使用纯 HTTP 相同。我们首先将请求格式化到缓冲区中,然后使用 SSL_write() 通过加密连接发送它。以下代码展示了这一点:

/*https_simple.c continued*/

    char buffer[2048];

    sprintf(buffer, "GET / HTTP/1.1\r\n");
    sprintf(buffer + strlen(buffer), "Host: %s:%s\r\n", hostname, port);
    sprintf(buffer + strlen(buffer), "Connection: close\r\n");
    sprintf(buffer + strlen(buffer), "User-Agent: https_simple\r\n");
    sprintf(buffer + strlen(buffer), "\r\n");

    SSL_write(ssl, buffer, strlen(buffer));
    printf("Sent Headers:\n%s", buffer);

有关 HTTP 协议的更多信息,请参阅第六章构建简单的 Web 客户端。

我们的客户端现在简单地等待来自服务器的数据,直到连接关闭。这是通过在循环中使用 SSL_read() 实现的。以下代码接收 HTTPS 响应:

/*https_simple.c continued*/

    while(1) {
        int bytes_received = SSL_read(ssl, buffer, sizeof(buffer));
            if (bytes_received < 1) {
                printf("\nConnection closed by peer.\n");
                break;
            }

            printf("Received (%d bytes): '%.*s'\n",
                    bytes_received, bytes_received, buffer);
    }

前面的代码还打印了通过 HTTPS 连接接收到的任何数据。请注意,它不会解析接收到的 HTTP 数据,这会更复杂。请参阅本章代码库中的 https_get.c 程序,它是一个更高级的程序,可以解析 HTTP 响应。

我们简单的客户端几乎完成了。我们只需关闭 TLS/SSL 连接,关闭套接字,并进行清理。以下代码执行此操作:

/*https_simple.c continued*/

    printf("\nClosing socket...\n");
    SSL_shutdown(ssl);
    CLOSESOCKET(server);
    SSL_free(ssl);
    SSL_CTX_free(ctx);

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");
    return 0;
}

这就完成了 https_simple.c

您应该能够在 Windows 上使用 MinGW 和以下命令编译和运行它:

gcc https_simple.c -o https_simple.exe -lssl -lcrypto -lws2_32
https_simple example.org 443

如果你使用某些较旧的 OpenSSL 版本,可能还需要额外的链接器选项—-lgdi32

在 macOS 和 Linux 上编译和执行可以使用以下方法:

gcc https_simple.c -o https_simple -lssl -lcrypto
./https_simple example.org 443

如果遇到链接错误,你应该检查你的 OpenSSL 库是否正确安装。尝试首先编译 openssl_version.c 程序可能会有所帮助。

以下截图显示了成功编译 https_simple.c 并使用它连接到 example.org

https_simple 程序应该作为连接作为 HTTPS 客户端的技术的入门级示例。这些相同的技巧可以应用于任何 TCP 连接。

将这些技术应用于书中早期开发的某些程序,如 tcp_clientweb_get,是很容易的。

值得注意的是,虽然 TLS 只与 TCP 连接一起工作,但数据报传输层安全DTLS)旨在为用户数据报协议UDP)数据报提供许多相同的保证。OpenSSL 也提供了对 DTLS 的支持。

其他示例

本章的存储库中还包括了一些其他示例。它们如下:

  • tls_client.c:这是 第三章,TCP 连接的深入概述 中的 tcp_client.c,但它已被修改以建立 TLS 连接。

  • https_get.c:这是 第六章,构建简单的 Web 客户端 中的 web_get.c 程序,但它已被修改为支持 HTTPS。你可以将其视为 https_simple.c 的扩展版本。

  • tls_get_cert.c:这个程序类似于 https_simple.c,但它只是打印出已连接服务器的证书并退出。

请记住,本章中的所有示例都不执行证书验证。在使用这些程序在实际环境中之前,这是一个必须添加的重要步骤。

摘要

在本章中,我们学习了 HTTPS 相较于 HTTP 提供的功能,例如身份验证和加密。我们了解到 HTTPS 实际上是在 TLS 连接上的 HTTP,并且 TLS 可以应用于任何 TCP 连接。

我们还学习了基本的加密概念。我们看到了非对称加密如何使用两个密钥,以及这如何允许数字签名。我们简要介绍了证书的基础知识,并探讨了验证它们的一些困难。

最后,我们通过一个具体的示例建立了与 HTTPS 服务器的 TLS 连接。

本章全部关于 HTTPS 客户端,但在下一章中,我们将关注 HTTPS 服务器的工作方式。

问题

尝试以下问题来测试你对本章知识的掌握:

  1. HTTPS 通常在哪个端口上运行?

  2. 对称加密使用多少个密钥?

  3. 非对称加密使用多少个密钥?

  4. TLS 使用的是对称加密还是非对称加密?

  5. SSL 和 TLS 之间的区别是什么?

  6. 证书有什么作用?

答案在 附录 A,问题解答 中。

进一步阅读

关于 HTTPS 和 OpenSSL 的更多信息,请参阅以下内容:

第十章:实现安全 Web 服务器

在本章中,我们将构建一个简单的 HTTPS 服务器程序。这作为我们在上一章中工作的 HTTPS 客户端的对立面。

HTTPS 由传输层安全性TLS)提供支持。与 HTTPS 客户端不同,HTTPS 服务器预计会使用证书来识别自己。我们将介绍如何监听 HTTPS 连接、提供证书以及通过 TLS 发送 HTTP 响应。

本章涵盖了以下主题:

  • HTTPS 概述

  • HTTPS 证书

  • 使用 OpenSSL 设置 HTTPS 服务器

  • 接受 HTTPS 连接

  • 常见问题

  • OpenSSL 替代方案

  • 直接 TLS 终止替代方案

技术要求

本章从第九章,“使用 HTTPS 和 OpenSSL 加载安全 Web 页面”的结尾继续。本章将继续使用 OpenSSL 库。您必须安装 OpenSSL 库,并且需要了解使用 OpenSSL 编程的基础知识。有关 OpenSSL 的基本信息,请参阅第九章,“使用 HTTPS 和 OpenSSL 加载安全 Web 页面”。

本章中的示例程序可以使用任何现代 C 编译器编译。我们推荐 Windows 上的MinGW和 Linux 及 macOS 上的GCC。您还需要安装 OpenSSL 库。有关编译器和 OpenSSL 安装的说明,请参阅附录 B,“在 Windows 上设置您的 C 编译器”;附录 C,“在 Linux 上设置您的 C 编译器”;以及附录 D,“在 macOS 上设置您的 C 编译器”。

本书代码可在github.com/codeplea/Hands-On-Network-Programming-with-C找到。

您可以从命令行使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap10

本章中的每个示例程序都可在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要与Winsock库链接。这可以通过向gcc传递-lws2_32选项来实现。

每个示例还需要链接到 OpenSSL 库,libssl.alibcrypto.a。这可以通过向 GCC 传递-lssl -lcrypto来实现。

我们将提供编译每个示例所需的精确命令。

本章中所有示例程序都需要与我们在第二章,“掌握 Socket API”,中开发的相同头文件和 C 宏。为了简洁,我们将这些语句放在一个单独的头文件chap10.h中,可以在每个程序中包含它。有关这些语句的解释,请参阅第二章,“掌握 Socket API”。

chap10.h 的内容首先包含所需的网络头文件。相应的代码如下:

/*chap10.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#endif

我们还定义了一些宏来帮助编写可移植的代码:

/*chap10.h continued*/

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

最后,chap10.h 包含了这一章程序所需的额外头文件:

/*chap10.h continued*/

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>

#include <openssl/crypto.h>
#include <openssl/x509.h>
#include <openssl/pem.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

HTTPS 和 OpenSSL 概述

我们首先快速回顾一下 HTTPS 协议,这部分内容在第九章中有详细说明,即使用 HTTPS 和 OpenSSL 加载安全网页。然而,我们仍然建议你在开始这一章之前,先阅读第九章,使用 HTTPS 和 OpenSSL 加载安全网页

HTTPS 使用 TLS 为 HTTP 添加安全性。你会记得从第六章,构建简单的 Web 客户端和第七章,构建简单的 Web 服务器中了解到,HTTP 是一种基于文本的协议,它在 TCP 的 80 端口上工作。TLS 协议可以用于为任何基于 TCP 的协议添加安全性。具体来说,TLS 用于为 HTTPS 提供安全性。所以简单来说,HTTPS 就是带有 TLS 的 HTTP。默认的 HTTPS 端口是 443

OpenSSL 是一个流行的开源库,它提供了 TLS/SSL 和 HTTPS 的功能。我们在本书中使用它来提供实现 HTTPS 客户端和服务器所需的方法。

通常,HTTPS 连接首先使用 TCP 套接字建立。一旦 TCP 连接建立,OpenSSL 就用于在开放的 TCP 连接上协商 TLS 连接。从那时起,OpenSSL 函数用于通过 TLS 连接发送和接收数据。

通信安全的一个重要部分是能够信任连接是到预期的当事人。如果你连接到了冒名顶替者,那么任何数据加密都无法帮助。TLS 使用证书来防止连接到冒名顶替者和中间人攻击。

在我们继续进行 HTTPS 服务器之前,我们需要更详细地了解证书。

证书

证书是 TLS 协议的重要组成部分。尽管证书可以在客户端和服务器端都使用,但 HTTPS 通常只使用服务器证书。这些证书向客户端表明,他们连接到了一个受信任的服务器。

没有证书,客户端就无法判断自己是否连接到了预期的服务器或冒名顶替的服务器。

证书使用信任链模型工作。每个 HTTPS 客户端都有几个他们明确信任的证书颁发机构,证书颁发机构提供数字签名证书的服务。这项服务通常需要收取少量费用,并且通常在请求者进行一些简单的验证之后才会进行。

当 HTTPS 客户端看到一个它信任的权威机构签发的证书时,它也信任该证书。确实,这些信任链可以非常深入。例如,大多数证书颁发机构也允许转售商。在这些情况下,证书颁发机构签发一个中间证书供转售商使用。然后,转售商使用这个中间证书来签发新的证书。客户端信任中间证书,因为它们是由受信任的证书颁发机构签发的,客户端信任转售商签发的证书,因为他们信任中间证书。

证书颁发机构通常提供两种类型的验证。域名验证是指仅在验证证书接收者可以在指定的域名下被访问后,就颁发一个已签名的证书。这通常是通过让证书请求者临时修改 DNS 记录,或回复发送到他们whois联系人的电子邮件来完成的。

Let's Encrypt是一个相对较新的证书颁发机构,它免费颁发证书。他们通过自动化模型来完成这项工作。域名验证是通过让证书请求者通过 HTTP 或 HTTPS 服务一个小文件来完成的。

域名验证是最常见的验证类型。使用域名验证的 HTTPS 服务器向 HTTPS 客户端保证,他们连接到的域名是他们认为的域名。这意味着他们的连接没有被静默劫持或以其他方式拦截。

证书颁发机构还提供扩展验证EV)证书。EV 证书只有在权威机构验证了接收者的身份后才会颁发。这通常是通过使用公共记录和电话通话来完成的。

对于面向公众的 HTTPS 应用程序,从公认的证书颁发机构获取证书是很重要的。然而,这有时可能会很繁琐,对于开发和测试目的,获取一个自签名证书通常要方便得多。我们现在就来做这件事。

使用 OpenSSL 的自签名证书

由公认的权威机构签发的证书对于建立公共网站所需的信任链至关重要。然而,对于测试或开发来说,获取一个自签名证书要容易得多。

对于某些私有的应用程序,其中客户端可以部署带有证书的副本,并且只信任该证书,使用自签名证书也是可以接受的。这被称为证书固定。确实,当正确使用时,证书固定可能比使用证书颁发机构更安全。然而,它不适合面向公众的网站。

我们需要一个证书来测试我们的 HTTPS 服务器。我们使用自签名证书,因为它们最容易获得。这种方法的不利之处在于,网络浏览器不会信任我们的服务器。我们可以通过在浏览器中点击几个警告来解决这个问题。

OpenSSL 提供了工具,使得自签名证书的签发变得非常简单。

自签名证书的基本命令如下:

openssl req -x509 -newkey rsa:2048 -nodes -sha256 -keyout key.pem \
-out cert.pem -days 365

OpenSSL 会询问有关证书上应放置的内容,包括主题、您的姓名、公司、位置等。您可以在所有这些方面使用默认值,因为这对我们的测试目的并不重要。

上述命令将新证书放置在cert.pem中,并将对应的密钥放在key.pem中。我们的 HTTPS 服务器需要这两个文件。cert.pem是发送给连接客户端的证书,而key.pem为我们服务器提供加密密钥,以证明它拥有该证书。保护此密钥的秘密至关重要。

下面是一张显示生成新自签名证书的截图:

您还可以使用 OpenSSL 查看证书。以下命令可以完成此操作:

openssl x509 -text -noout -in cert.pem

如果您在 Windows 上使用 MSYS,您可能会从之前的命令中得到乱码的行结束符。如果是这样,请尝试使用unix2dos来修复它,如下所示:

openssl x509 -text -noout -in cert.pem | unix2dos

下面是一个典型的自签名证书的外观:

现在我们有了可用的证书,我们就可以开始我们的 HTTPS 服务器编程了。

使用 OpenSSL 的 HTTPS 服务器

在开始具体示例之前,让我们先回顾一下在服务器应用程序中使用 OpenSSL 库的一些基本知识。

在使用 OpenSSL 之前,必须对其进行初始化。以下代码初始化了 OpenSSL 库,加载必要的加密算法,并加载有用的错误字符串:

SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();

请参考之前的第九章,使用 HTTPS 和 OpenSSL 加载安全网页,获取更多信息。

我们的服务器还需要创建一个 SSL 上下文对象。该对象充当一种工厂,我们可以从中创建 TLS/SSL 连接。

以下代码创建SSL_CTX对象:

SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
if (!ctx) {
    fprintf(stderr, "SSL_CTX_new() failed.\n");
    return 1;
}

如果您正在使用较旧的 OpenSSL 版本,您可能需要在上述代码中将TLS_server_method()替换为TLSv1_2_server_method()。然而,更好的解决方案是升级到较新的 OpenSSL 版本。

在创建SSL_CTX对象之后,我们可以将其设置为使用我们的自签名证书和密钥。以下代码执行此操作:

if (!SSL_CTX_use_certificate_file(ctx, "cert.pem" , SSL_FILETYPE_PEM)
|| !SSL_CTX_use_PrivateKey_file(ctx, "key.pem", SSL_FILETYPE_PEM)) {
    fprintf(stderr, "SSL_CTX_use_certificate_file() failed.\n");
    ERR_print_errors_fp(stderr);
    return 1;
}

这就完成了 HTTPS 服务器所需的最低限度的 OpenSSL 设置。

服务器应随后监听传入的 TCP 连接。这在第三章,TCP 连接的深入概述中已有详细说明。

在建立新的 TCP 连接后,我们使用accept()返回的套接字创建我们的 TLS/SSL 套接字。

首先,使用我们之前创建的 SSL 上下文创建一个新的SSL对象。以下代码演示了这一点:

SSL *ssl = SSL_new(ctx);
if (!ctx) {
    fprintf(stderr, "SSL_new() failed.\n");
    return 1;
}

然后使用SSL_set_fd()SSL对象链接到我们的打开的 TCP 套接字。调用SSL_accept()函数以建立 TLS/SSL 连接。以下代码演示了这一点:

SSL_set_fd(ssl, socket_client);
if (SSL_accept(ssl) <= 0) {
    fprintf(stderr, "SSL_accept() failed.\n");
    ERR_print_errors_fp(stderr);
    return 1;
}

printf ("SSL connection using %s\n", SSL_get_cipher(ssl));

你可能会注意到这段代码与第九章中 HTTPS 客户端代码非常相似,使用 HTTPS 和 OpenSSL 加载安全网页。唯一的真正区别在于 SSL 上下文对象的设置。

一旦建立了 TLS 连接,就可以使用SSL_write()SSL_read()发送和接收数据。这些函数替换了与 TCP 套接字一起使用的send()recv()函数。

连接完成后,释放资源很重要,如下面的代码所示:

SSL_shutdown(ssl);
CLOSESOCKET(socket_client);
SSL_free(ssl);

当你的程序完成接受新连接后,你也应该释放 SSL 上下文对象。下面的代码展示了这一点:

SSL_CTX_free(ctx);

在理解了基础知识之后,让我们通过实现一个简单的示例程序来巩固我们的知识。

时间服务器示例

在本章中,我们开发了一个简单的显示时间给 HTTPS 客户端的时间服务器。这个程序是time_server.c的改编,来自第二章,掌握 Socket API 入门,它通过纯 HTTP 提供了时间。我们的程序首先包含章节头文件,定义main(),并在 Windows 上初始化 Winsock。代码如下:

/*tls_time_server.c*/

#include "chap10.h"

int main() {

#if defined(_WIN32)
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)) {
        fprintf(stderr, "Failed to initialize.\n");
        return 1;
    }
#endif

然后使用以下代码初始化 OpenSSL 库:

/*tls_time_server.c continued*/

    SSL_library_init();
    OpenSSL_add_all_algorithms();
    SSL_load_error_strings();

必须为我们的服务器创建一个 SSL 上下文对象。这是通过调用SSL_CTX_new()完成的。下面的代码展示了这个调用:

/*tls_time_server.c continued*/

    SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
    if (!ctx) {
        fprintf(stderr, "SSL_CTX_new() failed.\n");
        return 1;
    }

如果你使用的是较旧的 OpenSSL 版本,你可能需要在前面代码中将TLS_server_method()替换为TLSv1_2_server_method()。然而,你可能最好升级到较新的 OpenSSL 版本。

一旦创建了 SSL 上下文,我们就可以将我们的服务器证书与之关联。下面的代码将 SSL 上下文设置为使用我们的证书:

/*tls_time_server.c continued*/

    if (!SSL_CTX_use_certificate_file(ctx, "cert.pem" , SSL_FILETYPE_PEM)
    || !SSL_CTX_use_PrivateKey_file(ctx, "key.pem", SSL_FILETYPE_PEM)) {
        fprintf(stderr, "SSL_CTX_use_certificate_file() failed.\n");
        ERR_print_errors_fp(stderr);
        return 1;
    }

确保你已经生成了适当的证书和密钥。请参考本章早些时候的使用 OpenSSL 的自签名证书部分。

一旦使用适当的证书配置了 SSL 上下文,我们的程序就会以正常方式创建一个监听 TCP 套接字。它从调用getaddrinfo()socket()开始,如下面的代码所示:

/*tls_time_server.c continued*/

    printf("Configuring local address...\n");
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    struct addrinfo *bind_address;
    getaddrinfo(0, "8080", &hints, &bind_address);

    printf("Creating socket...\n");
    SOCKET socket_listen;
    socket_listen = socket(bind_address->ai_family,
            bind_address->ai_socktype, bind_address->ai_protocol);
    if (!ISVALIDSOCKET(socket_listen)) {
        fprintf(stderr, "socket() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

前面代码创建的套接字使用bind()绑定到监听地址。listen()函数用于将套接字设置为监听状态。下面的代码演示了这一点:

  /*tls_time_server.c continued*/

    printf("Binding socket to local address...\n");
    if (bind(socket_listen,
                bind_address->ai_addr, bind_address->ai_addrlen)) {
        fprintf(stderr, "bind() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }
    freeaddrinfo(bind_address);

    printf("Listening...\n");
    if (listen(socket_listen, 10) < 0) {
        fprintf(stderr, "listen() failed. (%d)\n", GETSOCKETERRNO());
        return 1;
    }

如果前面的代码不熟悉,请参阅第三章,TCP 连接的深入概述

注意,前面的代码将监听端口号设置为8080。HTTPS 的标准端口号是443。通常使用高端口进行测试会更方便,因为低端口在某些操作系统上需要特殊权限。

我们的服务器使用while循环接受多个连接。请注意,这并不是真正的多路复用,因为一次只处理一个连接。然而,对于测试目的来说,能够顺序处理多个连接是方便的。我们的自签名证书导致主流浏览器在第一次尝试时拒绝我们的连接。只有在添加异常之后,连接才能成功。通过让我们的代码循环,这使得添加此异常变得更容易。

我们的while循环首先使用accept()等待新的连接。这是通过以下代码完成的:

/*tls_time_server.c continued*/

 while (1) {

        printf("Waiting for connection...\n");
        struct sockaddr_storage client_address;
        socklen_t client_len = sizeof(client_address);
        SOCKET socket_client = accept(socket_listen,
                (struct sockaddr*) &client_address, &client_len);
        if (!ISVALIDSOCKET(socket_client)) {
            fprintf(stderr, "accept() failed. (%d)\n", GETSOCKETERRNO());
            return 1;
        }

一旦连接被接受,我们使用getnameinfo()来打印客户端的地址。这有时对调试很有用。以下代码执行此操作:

/*tls_time_server.c continued*/

        printf("Client is connected... ");
        char address_buffer[100];
        getnameinfo((struct sockaddr*)&client_address,
                client_len, address_buffer, sizeof(address_buffer), 0, 0,
                NI_NUMERICHOST);
        printf("%s\n", address_buffer);

一旦 TCP 连接建立,就需要创建一个 SSL 对象。这是通过调用SSL_new()完成的,如下所示:

/*tls_time_server.c continued*/

        SSL *ssl = SSL_new(ctx);
        if (!ctx) {
            fprintf(stderr, "SSL_new() failed.\n");
            return 1;
        }

通过调用SSL_set_fd()将 SSL 对象与打开的套接字关联起来。然后,通过调用SSL_accept()初始化 TLS/SSL 连接。以下代码显示了这一点:

/*tls_time_server.c continued*/

        SSL_set_fd(ssl, socket_client);
        if (SSL_accept(ssl) <= 0) {
            fprintf(stderr, "SSL_accept() failed.\n");
            ERR_print_errors_fp(stderr);

            SSL_shutdown(ssl);
            CLOSESOCKET(socket_client);
            SSL_free(ssl);

            continue;
        }

        printf ("SSL connection using %s\n", SSL_get_cipher(ssl));

在前面的代码中,调用SSL_accept()函数可能会因许多原因失败。例如,如果连接的客户端不相信我们的证书,或者客户端和服务器无法就加密套件达成一致,那么对SSL_accept()的调用就会失败。当它失败时,我们只需清理分配的资源并使用continue重复我们的监听循环。

一旦 TCP 和 TLS/SSL 连接完全打开,我们使用SSL_read()来接收客户端的请求。我们的程序忽略此请求的内容。这是因为我们的程序只提供时间。客户端请求的内容无关紧要——我们的服务器会以时间响应。

以下代码使用SSL_read()等待并读取客户端的请求:

/*tls_time_server.c continued*/

        printf("Reading request...\n");
        char request[1024];
        int bytes_received = SSL_read(ssl, request, 1024);
        printf("Received %d bytes.\n", bytes_received);

以下代码使用SSL_write()将 HTTP 头部信息传输到客户端:

/*tls_time_server.c continued*/

        printf("Sending response...\n");
        const char *response =
            "HTTP/1.1 200 OK\r\n"
            "Connection: close\r\n"
            "Content-Type: text/plain\r\n\r\n"
            "Local time is: ";
        int bytes_sent = SSL_write(ssl, response, strlen(response));
        printf("Sent %d of %d bytes.\n", bytes_sent, (int)strlen(response));

然后,使用time()ctime()函数格式化当前时间。一旦时间在time_msg中格式化,它也会通过SSL_write()发送到客户端。以下代码显示了这一点:

/*tls_time_server.c continued*/

        time_t timer;
        time(&timer);
        char *time_msg = ctime(&timer);
        bytes_sent = SSL_write(ssl, time_msg, strlen(time_msg));
        printf("Sent %d of %d bytes.\n", bytes_sent, (int)strlen(time_msg));

最后,在数据传输到客户端后,连接被关闭,循环重复。以下代码显示了这一点:

/*tls_time_server.c continued*/

        printf("Closing connection...\n");
        SSL_shutdown(ssl);
        CLOSESOCKET(socket_client);
        SSL_free(ssl);
    }

如果循环终止,关闭监听套接字并清理 SSL 上下文将很有用,如下所示:

/*tls_time_server.c continued*/

    printf("Closing listening socket...\n");
    CLOSESOCKET(socket_listen);
    SSL_CTX_free(ctx);

最后,如果需要,Winsock 应该被清理:

/*tls_time_server.c continued*/

#if defined(_WIN32)
    WSACleanup();
#endif

    printf("Finished.\n");

    return 0;
}

这就完成了tls_time_server.c

您可以使用以下命令在 macOS 或 Linux 上编译和运行程序:

gcc tls_time_server.c -o tls_time_server -lssl -lcrypto
./tls_time_server

在 Windows 上,编译和运行程序使用以下命令:

gcc tls_time_server.c -o tls_time_server.exe -lssl -lcrypto -lws2_32
tls_time_server

如果您遇到链接错误,请确保 OpenSSL 库已正确安装。尝试从第九章,使用 HTTPS 和 OpenSSL 加载安全网页编译openssl_version.c可能会有所帮助。

以下截图显示了运行tls_time_server可能的样子:

图片

你可以通过在网页浏览器中导航到https://127.0.0.1:8080来连接到时间服务器。在第一次连接时,你的浏览器将拒绝自签名证书。以下截图显示了在 Firefox 中这种拒绝的样子:

图片

要访问时间服务器,你需要在浏览器中添加一个异常。每种浏览器的这种方法都不同,但通常有一个高级按钮,可以引导到一个选项,要么添加证书异常,要么继续使用不安全的连接。

一旦浏览器连接建立,你将能够看到由我们的tls_time_server程序提供的当前时间:

图片

tls_time_server程序证明了它在展示如何设置 TLS/SSL 服务器而无需陷入实现完整 HTTPS 服务器的细节中非常有用。然而,本章的代码库还包括了一个更完整的 HTTPS 服务器。

完整的 HTTPS 服务器

本章的代码库中包含了https_server.c。这个程序是对第七章中的web_server.c进行的修改,构建一个简单的 Web 服务器。它可以用来通过 HTTPS 提供简单的静态网站。

https_server.c程序中,基本的 TLS/SSL 连接设置和建立方式与tls_time_server.c中所示相同。一旦建立了安全连接,连接就简单地被当作 HTTP 处理。

https_server使用与tls_time_server相同的技巧进行编译。以下截图显示了如何编译和运行https_server

图片

一旦https_server启动,你可以通过在网页浏览器中导航到https://127.0.0.1:8080来连接到它。你可能会在第一次连接时需要添加一个安全异常。代码被设置为从chap07目录提供静态页面。

以下截图是网页浏览器连接到https_server时的样子:

图片

本章的示例程序展示了 HTTPS 服务器的基本知识。然而,实现一个真正健壮的 HTTPS 服务器确实涉及额外的挑战。现在让我们考虑一些这些挑战。

HTTPS 服务器挑战

本章应仅作为 TLS/SSL 服务器编程的介绍。关于安全网络编程还有很多东西要学习。在部署使用 OpenSSL 的 HTTPS 服务器之前,仔细审查所有 OpenSSL 文档是至关重要的。许多 OpenSSL 函数在本章的示例代码中都被忽略了,它们有边缘情况。

多路复用在 OpenSSL 中也可能变得复杂。在典型的 TCP 服务器中,我们一直使用 select() 函数来指示数据可读。select() 函数直接作用于 TCP 套接字。在带有 TLS/SSL 的服务器上使用 select() 可能很棘手。这是因为 select() 指示 TCP 层上的数据是否可用。这通常,但不总是,意味着可以使用 SSL_read() 读取数据。如果你打算与 select() 一起使用 SSL_read(),那么仔细查阅 OpenSSL 的 SSL_read() 文档是很重要的。本章中的示例程序出于简单起见,忽略了这些可能性。

同样,也有 OpenSSL 的替代方案。现在让我们考虑一些替代方案。

OpenSSL 的替代方案

虽然 OpenSSL 是实现 TLS 的最古老和最广泛部署的库之一,但近年来涌现了许多替代库。其中一些替代方案旨在提供比 OpenSSL 更好的功能、性能或质量控制。

下表包含了一些替代的开源 TLS 库:

TLS 库 网站
cryptlib www.cryptlib.com/
GnuTLS www.gnutls.org/
LibreSSL www.libressl.org/
mbed TLS tls.mbed.org/
网络安全服务 developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS
s2n github.com/awslabs/s2n
wolfSSL www.wolfssl.com/

同样,也有在应用程序中直接进行 TLS 终止的替代方案,这可以简化程序设计。让我们接下来考虑这一点。

TLS 的替代方案

在实现 HTTPS 服务器时,确保一切正确可能很困难,遗漏任何细节都可能完全损害安全性。

作为在服务器本身直接使用 TLS 的替代方案,有时使用 反向代理服务器 是更好的选择。反向代理服务器可以被配置为接受来自客户端的安全连接,然后将这些连接作为纯 HTTP 代理到你的程序。

NginxApache 是两个流行的开源服务器,它们可以作为 HTTPS 反向代理很好地工作。

下面的图示展示了这种设置:

以这种方式配置的反向代理服务器也被称为 TLS 终止代理

更好的替代方案可能是使用 CGIFastCGI 标准创建你的程序。在这种情况下,你的程序直接与标准 Web 服务器通信。Web 服务器处理所有的 HTTPS 和 HTTP 细节。这可以大大简化程序设计,在某些情况下,还可以降低维护成本。

如果你确实使用了现成的 HTTPS 服务器,仍然需要谨慎行事。配置不当可能会无意中危及安全性。

摘要

在本章中,我们从服务器的角度考虑了 HTTPS 协议。我们介绍了证书的工作原理,并展示了使用 OpenSSL 生成自签名证书的方法。

一旦我们有了证书,我们就学习了如何使用 OpenSSL 库来监听 TLS/SSL 连接。我们利用这些知识实现了一个简单的服务器,该服务器通过 HTTPS 显示当前时间。

我们还讨论了实现 HTTPS 服务器的一些陷阱和复杂性。许多应用程序可能从绕过 HTTPS 的实现并依赖反向代理中受益。

在下一章,第十一章 使用 libssh 建立 SSH 连接,我们将探讨另一个安全协议,Secure ShellSSH)。

问题

尝试以下问题来测试你从本章获得的知识:

  1. 客户端如何决定是否应该信任服务器的证书?

  2. 自签名证书的主要问题是什么?

  3. 什么可能导致 SSL_accept() 失败?

  4. 可以使用 select() 来多路复用 HTTPS 服务器的连接吗?

这些问题的答案可以在附录 A 问题解答中找到。

进一步阅读

关于 HTTPS 和 OpenSSL 的更多信息,请参阅以下内容:

第十一章:使用 libssh 建立 SSH 连接

本章全部关于使用安全外壳协议SSH)进行编程。SSH 是一种安全的网络协议,用于与远程服务器进行身份验证、授予命令行访问权限以及安全地传输文件。

SSH 广泛用于远程服务器的配置和管理。很多时候,Web 服务器并没有连接到显示器或键盘。对于这些服务器中的许多,SSH 提供了唯一的命令行访问和管理方法。

本章涵盖了以下主题:

  • SSH 协议概述

  • libssh

  • 建立连接

  • SSH 认证方法

  • 执行远程命令

  • 文件传输

技术要求

本章的示例程序可以使用任何现代的 C 编译器进行编译。我们推荐 Windows 上的MinGW和 Linux 及 macOS 上的GCC。您还需要安装libssh库。请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器,以了解编译器和libssh的安装设置。

本书代码可在github.com/codeplea/Hands-On-Network-Programming-with-C找到。

从命令行,您可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap11

本章中的每个示例程序都可在 Windows、Linux 和 macOS 上运行。

每个示例都需要链接到libssh库。这是通过向gcc传递-lssh选项来实现的。

我们提供了编译每个示例所需的精确命令,就像它被介绍时一样。

为了简洁,我们为每个示例程序使用了一个标准的头文件。这个头文件将其他需要的头文件放在一个地方。其内容如下:

/*chap11.h*/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <libssh/libssh.h>

SSH 协议

在现代互联网上提供服务的多数服务器(如网站和电子邮件)并没有连接键盘或显示器。即使服务器确实有本地输入/输出硬件,远程访问通常也更为方便。

已使用各种协议提供对服务器的远程命令行访问。其中第一个这样的协议是Telnet。使用 Telnet,客户端通过 TCP 端口23上的明文远程连接到服务器。服务器通过这个传输控制协议TCP)连接提供对操作系统命令行的更多或更少的直接访问。客户端向服务器发送明文命令,服务器执行这些命令。命令行输出从服务器发送回客户端。

Telnet 有一个重大的安全缺陷:它不会加密通过网络发送的任何数据。即使在使用 Telnet 时,用户密码也会以明文形式发送。这意味着任何网络窃听者都可能获取用户凭据!

SSH 协议现在已经很大程度上取代了 Telnet。SSH 协议通过 TCP 使用端口 22 进行工作。SSH 使用强加密来防止窃听。

SSH 允许客户端使用 公钥认证 来验证服务器的身份。如果没有对服务器进行公钥认证,冒充者可以伪装成合法的服务器并试图欺骗客户端连接。一旦连接成功,客户端会将其凭证发送给冒充的服务器。

SSH 还提供了许多用于客户端与服务器认证的方法。这包括发送密码或使用公钥认证。我们将在稍后详细探讨这些方法。

SSH 是一个复杂的协议。因此,我们不是尝试自己实现它,而是使用现有的库来提供所需的功能。

libssh

libssh 是一个广泛使用的开源 C 库,实现了 SSH 协议。它允许我们使用 SSH 协议远程执行命令和传输文件。

libssh 以一种抽象网络连接的方式构建。我们不需要担心到目前为止所使用的低级网络 API。libssh 库为我们处理主机名解析和创建所需的 TCP 套接字。

测试 libssh

在继续本章内容之前,确保你已经安装并可用 libssh 库是非常重要的。请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器,以了解 libssh 的安装。

我们使用 libssh 的第一个程序旨在确保它已正确安装。此程序仅打印 libssh 库版本。程序如下:

/*ssh_version.c*/

#include "chap11.h"

int main()
{
    printf("libssh version: %s\n", ssh_version(0));
    return 0;
}

你可以使用以下命令在 Windows 上使用 MinGW 编译和运行 ssh_version.c

gcc ssh_version.c -o ssh_version.exe -lssh
ssh_version

在 Linux 和 macOS 上,编译和运行 ssh_version.c 的命令如下:

gcc ssh_version.c -o ssh_version -lssh
./ssh_version

以下截图显示了 ssh_version.c 在 Linux 上成功编译和运行的情况:

如果你收到关于 libssh.h 未找到的错误消息,你应该检查是否在你的编译器的 include 目录搜索路径中包含了 libssh 库的头文件。如果你看到关于 ssh_version 未定义引用的错误消息,那么请检查你是否忘记将 -lssh 选项传递给你的编译器。

理解 libssh 的下一步是建立实际的 SSH 连接。

建立连接

现在我们已经确保 libssh 正确安装,是时候尝试实际的 SSH 连接了。

在继续之前,您需要访问一个 SSH 服务器。OpenSSH 是一个流行的服务器,适用于 Linux、macOS 和 Windows 10。它适用于测试,但在您的设备上安装之前,请确保您了解其安全实现。有关更多信息,请参阅您操作系统的文档。

如果您想使用远程系统进行测试,许多提供商都提供运行 OpenSSH 的 Linux 虚拟专用服务器VPS)。它们通常每月只需几美元。

让我们继续实现一个使用libssh打开 SSH 连接的程序。

我们将本章的其余程序结构为接受 SSH 服务器的 hostname 和端口号作为命令行参数。我们的程序从以下代码开始,该代码检查这些参数:

/*ssh_connect.c*/

#include "chap11.h"

int main(int argc, char *argv[])
{
    const char *hostname = 0;
    int port = 22;
    if (argc < 2) {
        fprintf(stderr, "Usage: ssh_connect hostname port\n");
        return 1;
    }
    hostname = argv[1];
    if (argc > 2) port = atol(argv[2]);

在上述代码中,argc被检查以确定是否至少传递了主机名作为命令行参数。如果没有,则显示用法消息。否则,服务器的主机名存储在hostname变量中。如果传递了端口号,则存储在port变量中。否则,将存储默认端口22

SSH 通常提供对服务器的完全和全面访问。因此,一些网络犯罪分子会随机扫描 IP 地址以寻找 SSH 连接。当他们成功建立连接时,他们会尝试猜测登录凭证,如果成功,他们将控制服务器。这些攻击针对安全设置得当的服务器不会成功,但它们仍然是一个常见的麻烦。在非默认端口(22)上使用 SSH 通常可以避免这些自动攻击。这就是我们想要确保我们的程序与非默认端口号良好工作的一个原因。

一旦我们的程序获得了主机名和连接端口号,我们继续创建一个 SSH 会话对象。这通过调用ssh_new()来完成,如下所示:

/*ssh_connect.c continued*/

    ssh_session ssh = ssh_new();
    if (!ssh) {
        fprintf(stderr, "ssh_new() failed.\n");
        return 1;
    }

上述代码创建了一个新的 SSH 会话对象,并将其存储在ssh变量中。

一旦创建了 SSH 会话,在完成连接之前,我们需要指定一些选项。ssh_options_set()函数用于设置选项。以下代码展示了设置远程主机名和端口:

/*ssh_connect.c continued*/

    ssh_options_set(ssh, SSH_OPTIONS_HOST, hostname);
    ssh_options_set(ssh, SSH_OPTIONS_PORT, &port);

libssh包括有用的调试工具。通过设置SSH_OPTIONS_LOG_VERBOSITY选项,我们告诉libssh打印出它几乎所做的一切。以下代码导致libssh记录了大量关于它采取哪些行动的信息:

/*ssh_connect.c continued*/

    int verbosity = SSH_LOG_PROTOCOL;
    ssh_options_set(ssh, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);

这种日志记录很有用,但它也可能令人分心。我建议您试一次,然后除非遇到问题,否则禁用它。本章的其余示例将不会使用它。

我们现在可以使用ssh_connect()来初始化 SSH 连接。以下代码展示了这一点:

/*ssh_connect.c continued*/

    int ret = ssh_connect(ssh);
    if (ret != SSH_OK) {
        fprintf(stderr, "ssh_connect() failed.\n%s\n", ssh_get_error(ssh));
        return -1;
    }

注意,ssh_connect()在成功时返回SSH_OK。在失败时,我们使用ssh_get_error()函数来详细说明出了什么问题。

接下来,我们的代码会打印出连接成功的消息:

/*ssh_connect.c continued*/

    printf("Connected to %s on port %d.\n", hostname, port);

SSH 协议允许服务器在连接时向客户端发送一条消息。这条消息被称为横幅。它通常用于识别服务器或提供简短的访问规则。我们可以使用以下代码来打印横幅:

/*ssh_connect.c continued*/

    printf("Banner:\n%s\n", ssh_get_serverbanner(ssh));

我们的ssh_connect.c示例就到这里。我们的程序在终止前简单地断开连接并释放 SSH 会话。以下代码总结了ssh_connect.c

/*ssh_connect.c continued*/

    ssh_disconnect(ssh);
    ssh_free(ssh);

    return 0;
}

你可以使用以下命令在 Windows 上使用 MinGW 编译ssh_connect.c

gcc ssh_connect.c -o ssh_connect.exe -lssh

在 Linux 和 macOS 上,编译ssh_connect.c的命令如下:

gcc ssh_connect.c -o ssh_connect -lssh

以下截图显示了ssh_connect.c在 Linux 上成功编译和运行的情况:

图片

在前面的截图中,你可以看到ssh_connect能够连接到本地运行的 OpenSSH 服务器。

现在我们已经建立了连接,接下来让我们通过服务器认证来继续操作。

SSH 认证

SSH 为服务器(主机)和客户端(用户)提供了认证方法。显然,服务器必须认证客户端的原因是服务器只想授权给授权用户。否则,任何人都可以接管服务器。

然而,客户端也需要认证服务器。如果客户端未能正确认证服务器,那么客户端可能会被欺骗向冒充者发送其密码!

在 SSH 中,服务器使用公钥加密进行认证。从概念上讲,这与 HTTPS 提供服务器认证非常相似。然而,SSH 通常不依赖于证书颁发机构。相反,当使用 SSH 时,大多数客户端只是简单地保留一个它们信任的公钥(或公钥哈希)列表。客户端最初是如何获得这个列表的各不相同。一般来说,如果一个客户端在受信任的环境下连接到服务器,那么它也可以信任该公钥在未来的使用。

libssh实现了记住受信任服务器公钥的功能。这样,一旦服务器被连接并信任一次,libssh就会记住它在未来的信任状态。

一些 SSH 部署还使用其他方法来验证 SSH 主机的公钥。例如,Secure Shell 指纹SSHFP)记录是一种 DNS 记录,用于验证 SSH 公钥。其使用需要安全的 DNS 访问。

无论你决定是否信任(或不信任)服务器的公钥,你首先都需要获取服务器的公钥。让我们看看libssh是如何提供服务器认证功能的访问的。

服务器认证

一旦建立了 SSH 会话,我们可以使用ssh_get_server_publickey()函数来获取服务器的公钥。以下代码展示了这个函数调用:

/*ssh_auth.c excerpt*/

    ssh_key key;
    if (ssh_get_server_publickey(ssh, &key) != SSH_OK) {
        fprintf(stderr, "ssh_get_server_publickey() failed.\n%s\n",
                ssh_get_error(ssh));
        return -1;
    }

获取并显示服务器 SSH 公钥的哈希值通常很有用。用户可以查看哈希值并将这些值与已知密钥进行比较。libssh库提供了ssh_get_publickey_hash()函数来实现这个目的。

以下代码打印出之前获得的公钥的 SHA1 哈希:

/*ssh_auth.c excerpt*/

    unsigned char *hash;
    size_t hash_len;
    if (ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA1,
                &hash, &hash_len) != SSH_OK) {
        fprintf(stderr, "ssh_get_publickey_hash() failed.\n%s\n",
                ssh_get_error(ssh));
        return -1;
    }

    printf("Host public key hash:\n");
    ssh_print_hash(SSH_PUBLICKEY_HASH_SHA1, hash, hash_len);

libssh 使用 Base64 打印 SHA1 哈希。它还会首先添加哈希类型。例如,前面的代码可能会打印以下内容:

Host public key hash:
SHA1:E348CMNeCGGec/bQqEX7aocDTfI

当你完成公钥和哈希的处理后,使用以下代码释放它们的资源:

/*ssh_auth.c excerpt*/

    ssh_clean_pubkey_hash(&hash);
    ssh_key_free(key);

libssh 提供了 ssh_session_is_known_server() 函数来确定服务器的公钥是否已知。以下代码展示了如何使用此代码:

/*ssh_auth.c excerpt*/

    enum ssh_known_hosts_e known = ssh_session_is_known_server(ssh);
    switch (known) {
        case SSH_KNOWN_HOSTS_OK: printf("Host Known.\n"); break;

        case SSH_KNOWN_HOSTS_CHANGED: printf("Host Changed.\n"); break;
        case SSH_KNOWN_HOSTS_OTHER: printf("Host Other.\n"); break;
        case SSH_KNOWN_HOSTS_UNKNOWN: printf("Host Unknown.\n"); break;
        case SSH_KNOWN_HOSTS_NOT_FOUND: printf("No host file.\n"); break;

        case SSH_KNOWN_HOSTS_ERROR:
            printf("Host error. %s\n", ssh_get_error(ssh)); return 1;

        default: printf("Error. Known: %d\n", known); return 1;
    }

如果服务器的公钥已知(之前已信任),则 ssh_session_is_known_server() 返回 SSH_KNOWN_HOSTS_OK。否则,ssh_session_is_known_server() 可以返回其他具有不同含义的值。

SSH_KNOWN_HOSTS_UNKNOWN 表示服务器未知。在这种情况下,用户应验证服务器的哈希值。

SSH_KNOWN_HOSTS_NOT_FOUND 表示 libssh 没有找到主机文件,并自动创建一个。这通常应与 SSH_KNOWN_HOSTS_UNKNOWN 以相同方式处理。

SSH_KNOWN_HOSTS_CHANGED 表示服务器返回的密钥与之前所知的密钥不同,而 SSH_KNOWN_HOSTS_OTHER 表示服务器返回的密钥类型与之前使用的不同。这些可能都表明潜在的攻击!在实际应用中,你应该更明确地通知用户这些风险。

如果用户已验证主机是可信任的,请使用 ssh_session_update_known_hosts() 允许 libssh 保存服务器的公钥哈希。这允许 ssh_session_is_known_server() 在下一次连接时返回 SSH_KNOWN_HOSTS_OK

以下代码说明了提示用户信任连接并使用 ssh_session_update_known_hosts() 的示例:

/*ssh_auth.c excerpt*/

    if (known == SSH_KNOWN_HOSTS_CHANGED ||
            known == SSH_KNOWN_HOSTS_OTHER ||
            known == SSH_KNOWN_HOSTS_UNKNOWN ||
            known == SSH_KNOWN_HOSTS_NOT_FOUND) {
        printf("Do you want to accept and remember this host? Y/N\n");
        char answer[10];
        fgets(answer, sizeof(answer), stdin);
        if (answer[0] != 'Y' && answer[0] != 'y') {
            return 0;
        }

        ssh_session_update_known_hosts(ssh);
    }

请参阅本章代码库中的 ssh_auth.c 以获取一个工作示例。有关更多信息,请参阅 libssh 文档。

在客户端认证服务器之后,服务器需要认证客户端。

客户端认证

SSH 提供了多种客户端认证方法。这些方法包括以下几种:

  • 无认证:这允许任何用户连接

  • 密码认证:这要求用户提供用户名和密码

  • 公钥:这使用公钥加密方法进行认证

  • 键盘交互式:通过让用户回答几个提示进行认证

  • 通用安全服务应用程序接口GSS-API):这允许通过各种其他服务进行认证

密码认证是最常见的方法,但它确实有一些缺点。如果冒充服务器欺骗用户发送他们的密码,那么该用户的密码实际上就受到了损害。公钥用户认证不会像密码认证那样容易受到这种攻击。使用公钥认证时,服务器为每次认证尝试发出一个独特的挑战。这阻止了恶意冒充服务器重新播放之前的认证到合法服务器。

一旦设置了公钥认证,libssh使得使用它变得非常简单。在许多情况下,只需调用ssh_userauth_publickey_auto()函数即可。然而,设置公钥认证本身可能是一个繁琐的过程。

虽然公钥认证更安全,但密码认证仍然很常见。密码认证也更直接,更容易测试。出于这些原因,我们继续在本章中使用密码认证的示例。

无论使用哪种用户认证方法,SSH 服务器都必须知道你试图认证的用户是谁。libssh库允许我们使用之前看到的ssh_set_options()函数提供此信息。在使用ssh_connect()之前应该调用它。要设置用户,可以使用以下代码中的ssh_options_set()函数,并传入SSH_OPTIONS_USER

ssh_options_set(ssh, SSH_OPTIONS_USER, "alice");

在 SSH 会话建立之后,可以使用ssh_userauth_password()函数提供密码。以下代码提示输入密码并将其发送到已连接的 SSH 服务器:

/*ssh_auth.c excerpt*/

    printf("Password: ");
    char password[128];
    fgets(password, sizeof(password), stdin);
    password[strlen(password)-1] = 0;

    if (ssh_userauth_password(ssh, 0, password) != SSH_AUTH_SUCCESS) {
        fprintf(stderr, "ssh_userauth_password() failed.\n%s\n",
                ssh_get_error(ssh));
        return 0;
    } else {
        printf("Authentication successful!\n");
    }

上述代码使用fgets()函数从用户那里获取密码。fgets()函数总是将换行符与输入一起包含,而我们不希望这样。password[strlen(password)-1] = 0代码实际上将密码缩短一个字符,从而移除了换行符。

注意,使用fgets()会导致输入的密码在屏幕上显示。这并不安全,最好在输入密码时隐藏它。不幸的是,没有跨平台的方法可以实现这一点。如果你使用 Linux,可以考虑用getpass()函数代替fgets()

在本章的代码仓库中查看ssh_auth.c,以获取使用用户密码认证与服务器进行认证的工作示例。

你可以使用以下命令在 Windows 上使用 MinGW 编译和运行ssh_auth.c

gcc ssh_auth.c -o ssh_auth.exe -lssh
ssh_auth example.com 22 alice

在 Linux 和 macOS 上,编译和运行ssh_auth.c的命令如下:

gcc ssh_auth.c -o ssh_auth -lssh
./ssh_auth example.com 22 alice

以下截图显示了编译ssh_auth并使用它连接到 Linux 上本地运行的 SSH 服务器:

图片

在前面的截图中,ssh_auth 被用来成功认证本地运行的 SSH 服务器。ssh_auth 程序使用用户名 alice 和密码 password123 进行密码认证。不用说,你需要根据你的 SSH 服务器更改用户名和密码。只有当你使用连接到的服务器上实际用户账户的用户名和密码时,认证才会成功。

在认证后,我们就可以通过 SSH 运行命令了。

执行远程命令

SSH 协议通过通道工作。在我们建立 SSH 连接后,必须打开一个通道才能进行任何实际的工作。其优势是可以在一个连接上打开多个通道。这潜在地允许应用程序同时执行多项操作(看似)。

在 SSH 会话打开并且用户认证后,可以打开一个通道。通过调用 ssh_channel_new() 函数可以打开一个新的通道。以下代码说明了这一点:

/*ssh_command.c excerpt*/

    ssh_channel channel = ssh_channel_new(ssh);
    if (!channel) {
        fprintf(stderr, "ssh_channel_new() failed.\n");
        return 0;
    }

SSH 协议实现了许多类型的通道。会话通道类型用于执行远程命令和传输文件。使用 libssh,我们可以通过 ssh_channel_open_session() 函数请求会话通道。以下代码展示了调用 ssh_channel_open_session()

/*ssh_command.c excerpt*/

    if (ssh_channel_open_session(channel) != SSH_OK) {
        fprintf(stderr, "ssh_channel_open_session() failed.\n");
        return 0;
    }

一旦会话通道打开,我们可以使用 ssh_channel_request_exec() 函数发出命令。以下代码使用 fgets() 提示用户输入命令,并使用 ssh_channel_request_exec() 将命令发送到远程主机:

/*ssh_command.c excerpt*/

    printf("Remote command to execute: ");
    char command[128];
    fgets(command, sizeof(command), stdin);
    command[strlen(command)-1] = 0;

    if (ssh_channel_request_exec(channel, command) != SSH_OK) {
        fprintf(stderr, "ssh_channel_open_session() failed.\n");
        return 1;
    }

命令发送后,我们的程序使用 ssh_channel_read() 接收命令输出。以下代码循环直到读取整个输出:

/*ssh_command.c excerpt*/

    char output[1024];
    int bytes_received;
    while ((bytes_received =
                ssh_channel_read(channel, output, sizeof(output), 0))) {
        if (bytes_received < 0) {
            fprintf(stderr, "ssh_channel_read() failed.\n");
            return 1;
        }
        printf("%.*s", bytes_received, output);
    }

上述代码首先分配一个缓冲区 output 来存储命令输出的接收数据。ssh_channel_read() 函数返回读取的字节数,但在读取完成或发生错误时返回 0。我们的代码在 ssh_channel_read() 返回数据时循环。

在收到命令的全部输出后,客户端应在通道上发送一个 文件结束符 (EOF),关闭通道,并释放通道资源。以下代码展示了这一过程:

/*ssh_command.c excerpt*/

    ssh_channel_send_eof(channel);
    ssh_channel_close(channel);
    ssh_channel_free(channel);

如果你的程序也完成了 SSH 会话,请务必调用 ssh_disconnect()ssh_free()

本章代码库中包含的 ssh_command.c 程序是一个简单的实用程序,它连接到远程 SSH 主机并执行单个命令。

你可以使用以下命令在 Windows 上使用 MinGW 编译 ssh_command.c

gcc ssh_command.c -o ssh_command.exe -lssh

在 Linux 和 macOS 上,编译 ssh_command.c 的命令如下:

gcc ssh_command.c -o ssh_command -lssh

以下截图显示了在 Linux 上编译和运行 ssh_command.c

图片

之前的截图显示了连接到本地 OpenSSH 服务器并执行 ls -l 命令。ssh_command 代码忠实地打印了该命令的输出(这是用户主目录的文件列表)。

libssh 库中的函数 ssh_channel_request_exec() 适用于执行单个命令。然而,SSH 也支持打开一个完全交互式远程 shell 的方法。通常,会话通道会按照之前所示的方式打开。然后调用 libssh 库函数 ssh_channel_request_pty() 来初始化远程 shell。libssh 库提供了许多函数用于以这种方式发送和接收数据。请参阅 libssh 文档以获取更多信息。

现在您能够执行远程命令并接收其输出,也可能需要传输文件。让我们考虑一下下一步。

下载文件

安全复制协议SCP)提供了一种文件传输的方法。它支持上传和下载文件。

libssh 使使用 SCP 变得简单。本章的代码仓库包含一个示例,ssh_download.c,它展示了使用 libssh 在 SCP 上下载文件的基本方法。

在 SSH 会话启动并用户认证后,ssh_download.c 使用以下代码提示用户输入远程文件名:

/*ssh_download.c excerpt*/

    printf("Remote file to download: ");
    char filename[128];
    fgets(filename, sizeof(filename), stdin);
    filename[strlen(filename)-1] = 0;

通过调用 libssh 库函数 ssh_scp_new() 可以初始化一个新的 SCP 会话,如下所示:

/*ssh_download.c excerpt*/

    ssh_scp scp = ssh_scp_new(ssh, SSH_SCP_READ, filename);
    if (!scp) {
        fprintf(stderr, "ssh_scp_new() failed.\n%s\n",
                ssh_get_error(ssh));
        return 1;
    }

在前面的代码中,将 SSH_SCP_READ 传递给 ssh_scp_new()。这指定我们将使用新的 SCP 会话来下载文件。SSH_SCP_WRITE 选项将用于上传文件。libssh 库还提供了 SSH_SCP_RECURSIVE 选项,以帮助上传或下载整个目录树。

成功创建 SCP 会话后,必须调用 ssh_scp_init() 来初始化 SCP 通道。以下代码展示了这一过程:

/*ssh_download.c excerpt*/

    if (ssh_scp_init(scp) != SSH_OK) {
        fprintf(stderr, "ssh_scp_init() failed.\n%s\n",
                ssh_get_error(ssh));
        return 1;
    }

必须调用 ssh_scp_pull_request() 来开始文件下载。此函数返回 SSH_SCP_REQUEST_NEWFILE 以指示远程主机将开始发送新文件。以下代码展示了这一过程:

/*ssh_download.c excerpt*/

    if (ssh_scp_pull_request(scp) != SSH_SCP_REQUEST_NEWFILE) {
        fprintf(stderr, "ssh_scp_pull_request() failed.\n%s\n",
                ssh_get_error(ssh));
        return 1;
    }

libssh 提供了一些我们可以使用的方法来检索远程文件名、文件大小和权限。以下代码检索这些值并将它们打印到控制台:

/*ssh_download.c excerpt*/

    int fsize = ssh_scp_request_get_size(scp);
    char *fname = strdup(ssh_scp_request_get_filename(scp));
    int fpermission = ssh_scp_request_get_permissions(scp);

    printf("Downloading file %s (%d bytes, permissions 0%o\n",
            fname, fsize, fpermission);
    free(fname);

一旦知道文件大小,我们就可以使用 malloc() 分配空间来在内存中存储它。以下代码展示了这一过程:

/*ssh_download.c excerpt*/

    char *buffer = malloc(fsize);
    if (!buffer) {
        fprintf(stderr, "malloc() failed.\n");
        return 1;
    }

然后我们的程序使用 ssh_scp_accept_request() 接受新的文件请求,并使用 ssh_scp_read() 下载文件。以下代码展示了这一过程:

/*ssh_download.c excerpt*/

    ssh_scp_accept_request(scp);
    if (ssh_scp_read(scp, buffer, fsize) == SSH_ERROR) {
        fprintf(stderr, "ssh_scp_read() failed.\n%s\n",
                ssh_get_error(ssh));
        return 1;
    }

可以通过简单的 printf() 调用来将下载的文件打印到屏幕上。当我们完成文件数据后,释放分配的空间也很重要。以下代码打印出文件内容并释放分配的内存:

/*ssh_download.c excerpt*/

    printf("Received %s:\n", filename);
    printf("%.*s\n", fsize, buffer);
    free(buffer);

ssh_scp_pull_request()的额外调用应返回SSH_SCP_REQUEST_EOF。这表示我们已经从远程主机接收了整个文件。以下代码检查来自远程主机的文件结束请求:

/*ssh_download.c excerpt*/

    if (ssh_scp_pull_request(scp) != SSH_SCP_REQUEST_EOF) {
        fprintf(stderr, "ssh_scp_pull_request() unexpected.\n%s\n",
                ssh_get_error(ssh));
        return 1;
    }

上述代码略有简化。远程主机也可能返回其他值,这些值不一定是错误。例如,如果ssh_scp_pull_request()返回SSH_SCP_REQUEST_WARNING,则远程主机已发送警告。这个警告可以通过调用ssh_scp_request_get_warning()来读取,但无论如何,都应该再次调用ssh_scp_pull_request()

文件接收后,应使用ssh_scp_close()ssh_scp_free()来释放资源,如下述代码片段所示:

/*ssh_download.c excerpt*/

    ssh_scp_close(scp);
    ssh_scp_free(scp);

在你的程序完成 SSH 会话后,别忘了调用ssh_disconnect()ssh_free()

整个文件下载示例包含在本章代码中的ssh_download.c文件。

你可以在 Windows 上使用 MinGW 通过以下命令编译ssh_download.c

gcc ssh_download.c -o ssh_download.exe -lssh

在 Linux 和 macOS 上,编译ssh_download.c的命令如下:

gcc ssh_download.c -o ssh_download -lssh

以下截图显示了在 Linux 上成功编译并使用ssh_download.c下载文件的情况:

如前述截图所示,使用 SSH 和 SCP 下载文件非常简单。这可以是一种在计算机之间安全传输数据的有用方式。

摘要

本章简要概述了 SSH 协议及其使用libssh的方式。我们了解了很多关于 SSH 协议的身份验证知识,以及服务器和客户端都必须进行身份验证以确保安全。一旦建立连接,我们就实现了一个简单的程序来在远程主机上执行命令。我们还看到了libssh如何使使用 SCP 下载文件变得非常简单。

SSH 提供了一个安全的通信通道,有效地阻止了窃听者获取截获通信的意义。

在下一章,第十二章,网络监控与安全,我们继续探讨安全主题,通过查看能够有效监听非安全通信通道的工具。

问题

尝试以下问题来测试你对本章知识的掌握:

  1. 使用 Telnet 的一个显著缺点是什么?

  2. SSH 通常运行在哪个端口上?

  3. 为什么客户端验证 SSH 服务器是至关重要的?

  4. 服务器通常是如何进行身份验证的?

  5. SSH 客户端通常是如何进行身份验证的?

这个问题的答案可以在附录 A,问题答案中找到。

进一步阅读

关于 Telnet、SSH 和libssh的更多信息,请参考以下内容:

第四部分 - 零碎事项

在本节的最后部分,我们将研究网络测试并回顾在套接字应用程序编程中需要注意的常见错误。我们将以探索物联网连接设备的设计考虑因素结束。

以下章节包含在本节中:

第十二章,网络监控与安全

第十三章,套接字编程技巧与陷阱

第十四章,物联网网络编程

第十二章:网络监控和安全

在本章中,我们将探讨网络监控的常见工具和技术。这些技术既可以提醒我们注意潜在的问题,也可以帮助我们解决现有的问题。从网络安全的角度来看,网络监控可能有助于检测、记录甚至防止网络入侵。

本章涵盖了以下主题:

  • 检查主机可达性

  • 显示连接路由

  • 显示打开的端口

  • 列出打开的连接

  • 数据包嗅探

  • 防火墙和数据包过滤

技术要求

本章不包含 C 代码。相反,它侧重于有用的工具和实用程序。

本章中使用的工具和实用程序要么是操作系统内置的,要么是免费的开源软件。我们将为每个工具在介绍时提供说明。

网络监控的目的

网络监控是一个常见的 IT 术语,具有广泛的含义。网络监控可以指用于深入了解网络状态的实践、技术和工具。这些技术用于监控网络化系统的可用性和性能,并解决网络问题。

你可能想要练习网络监控的一些原因包括以下:

  • 为了检测网络化系统的可达性

  • 为了衡量网络化系统的可用性

  • 为了确定网络化系统的性能

  • 为了告知关于网络资源分配的决定

  • 为了帮助故障排除

  • 为了基准测试性能

  • 为了逆向工程一个协议

  • 为了调试程序

在本章中,我们探讨了一小部分传统的网络监控技术,这些技术可能在实现网络程序时有用。

在开发或部署网络程序时,你经常会遇到问题。当这种情况发生时,你面临两种可能性。第一种可能性是程序中可能存在错误。第二种可能性是问题是由网络问题引起的。本章中介绍的方法有助于识别和解决网络问题。

你可能问的最基本的问题之一是,这个系统能否到达那个系统?Ping实用程序,可能是最基础的网络工具,旨在回答正是这个问题。让我们接下来考虑它的用法。

测试可达性

可能最基本网络监控工具是 Ping。Ping 使用互联网控制消息协议(ICMP)来检查主机是否可达。它还通常报告往返总时间(延迟)。Ping 在所有常见的操作系统中都作为内置命令或实用程序提供。

ICMP 定义了一组特殊的 IP 消息,这些消息通常对诊断和控制目的非常有用。Ping 通过使用这些消息中的两个:回显请求回显响应。Ping 实用程序向目标主机发送一个回显请求 ICMP 消息。当该主机接收到回显请求时,它应该用一个回显响应消息进行响应。

当接收到回显响应时,Ping 就知道目标主机可达。Ping 还可以报告从发送回显请求到接收到回显响应的往返时间。ICMP 回显消息通常很小且易于处理,因此这个往返时间通常作为网络延迟的最佳估计。

Ping 实用程序接受一个参数:你请求响应的主机名或地址。基于 Unix 的系统上的 Ping 会持续发送数据包。在 Windows 上,使用 -t 标志来启用此行为。按 Ctrl +C 停止。

以下截图显示了在 example.com 上使用 Ping 实用程序:

在前面的截图中,你可以看到发送了几个 ping 消息,并且每个都收到了响应。每个 ping 消息都会报告往返时间,并且还会报告所有发送数据包的摘要。摘要包括最小、平均和最大往返消息时间。

使用 ping 发送更大的消息也是可能的。在 Linux 和 macOS 上,-s 标志指定数据包大小。在 Windows 上,使用 -l 标志。有时,观察数据包大小如何影响延迟和可靠性是有趣的。

以下截图显示了使用更大的 1,000 字节 ping 消息 ping example.com

如果 ping 没有收到回显响应,这并不一定意味着目标主机不可达。这只意味着 ping 没有收到预期的回复。这可能是因为目标机器忽略了回显请求。然而,大多数系统 确实 设置为正确响应 ping 请求,回显请求超时通常意味着目标系统不可达。

有时候,仅仅知道主机可达就足够了,但有时候你可能需要更多信息。了解 IP 数据包在网络中确切路径可能很有用。traceroute 实用程序提供了这些信息。

检查路由

尽管我们在 第一章,“介绍网络和协议”中简要介绍了 traceroute,但更详细地回顾它是有价值的。

虽然 Ping 可以告诉我们两个系统之间是否存在网络路径,但 traceroute 可以揭示这个路径实际上是什么。

Traceroute 使用一个参数:你想要映射路由的主机名或地址。

在 Windows 上,traceroute 被称为 tracert。Tracert 的工作方式与 Linux 和 macOS 上找到的 traceroute 实用程序非常相似。

下面的截图显示了跟踪路由实用程序打印出用于将数据发送到example.com的路由器。-n标志告诉traceroute不要对每个跳数执行反向 DNS 查找。这些查找很少有用,省略它们可以节省一点时间和屏幕空间:

上一张截图显示,在我们和example.com的目标系统之间有四个或五个路由器(或跳数)。跟踪路由还显示了到达每个中间路由器的往返时间。

跟踪路由向每个路由器发送三条消息。这通常揭示了多个网络路径,并且不能保证任何两条消息会精确地走相同的路径。

在前面的例子中,我们看到消息必须首先通过23.92.28.3。从那里,它到达列出的三个不同系统中的一个。消息继续传递,直到它到达第五或第六个跳数,具体取决于它通过网络的确切路径。

这说明了一个有趣的观点:你不应该假设两个连续的数据包会走相同的网络路径。

跟踪路由的工作原理

要理解跟踪路由是如何工作的,我们必须了解互联网协议IP)的一个细节。每个 IP 数据包头部都包含一个称为生存时间TTL)的字段。TTL 是数据包在网络中存活的最大秒数,在此之后将被丢弃。这对于防止 IP 数据包简单地持续存在(即进入无限循环)在网络中非常重要。

一秒以下的 TTL 时间间隔会被向上取整。这意味着在实践中,每个处理 IP 数据包的路由器都会将 TTL 字段减1。因此,TTL 通常用作跳数计数。也就是说,TTL 字段仅仅代表了数据包在网络中还可以走的跳数。

跟踪路由实用程序使用 TTL 来识别网络中的中间路由器。跟踪路由首先将一个消息(例如,UDP 数据报或 ICMP 回显请求)发送到目标主机。然而,跟踪路由将 TTL 字段设置为1。当连接路径中的第一个路由器接收到这条消息时,它会将 TTL 减到零。然后路由器意识到消息已过期并丢弃它。一个表现良好的路由器随后会向原始发送者发送一个 ICMP 时间超限消息。跟踪路由使用这个时间超限消息来识别连接中的第一个路由器。

跟踪路由会重复使用相同的流程,并附加额外的消息。第二条消息使用 TTL 为2发送,这条消息标识了网络路径中的第二个跳数。第三条消息使用 TTL 为3发送,以此类推。最终,消息到达最终目的地,跟踪路由就映射了整个网络路径。

下面的图示说明了跟踪路由使用的方法:

在前面的图中,第一条消息的 TTL 为 1。路由器 1 不会转发这条消息,而是返回一个 ICMP 超时 消息。第二条消息的 TTL 为 2,在超时之前到达第二个路由器。第三条消息到达目的地,目的地回复一个 回显应答 消息。(如果这个 traceroute 是基于 UDP 的,它将期望收到一个 ICMP 端口不可达 消息。)

并非所有路由器都会返回 ICMP 超时 消息,有些网络会过滤掉这些消息。在这些情况下,traceroute 将无法知道这些路由器的地址。traceroute 会打印一个星号代替。理论上,如果连接路径中存在一个不递减 TTL 字段的路由器,那么 traceroute 将无法知道这个路由器的存在。

现在我们已经介绍了 ping 和 traceroute 的工作方式,你可能想知道它们如何用 C 语言实现。不幸的是,尽管它们的算法很简单,但说起来容易做起来难。继续阅读以了解更多信息。

原始套接字

你可能对实现自己的网络测试工具感兴趣。ping 工具看起来很简单,确实很简单。不幸的是,我们一直在使用的套接字编程 API 并没有提供对 ICMP 所基于的 IP 层的访问。

网络编程 API 理论上提供了对 原始套接字 的访问。使用原始套接字,C 程序可以构建要发送的确切 IP 数据包。也就是说,C 程序员可以从头开始构建一个 ICMP 数据包并将其发送到网络上。原始套接字还允许程序直接从网络接收未经解释的数据包。在这种情况下,用户程序将负责分解和解释 ICMP 数据包,而不是操作系统。

在支持原始套接字的系统上,开始使用可能就像将你的 socket() 函数调用更改为以下内容一样简单:

socket(AF_INET, SOCK_RAW, IPPROTO_RAW);

然而,问题是原始套接字并不在所有平台上都得到支持。这是一个难以跨平台处理的主题。特别是 Windows,它对原始套接字的支持因操作系统版本而异。Windows 的最新版本几乎不支持原始套接字。因此,我们在这里不会更详细地介绍原始套接字。

现在我们已经介绍了两个基本的网络故障排除工具,接下来让我们看看那些能告诉我们自己的系统与网络关系的工具。

检查本地连接

了解你本地机器上正在建立哪些连接通常很有用。netstat 命令可以帮助你做到这一点。Netstat 在 Linux、macOS 和 Windows 上都可用。每个版本在命令行选项和输出上都有一些差异,但一般的使用原则是相同的。

我建议使用 -n 标志来运行 netstat。此标志阻止 netstat 对每个地址进行反向 DNS 查询,并且可以显著加快其速度。

在 Linux 上,我们可以使用以下命令来显示打开的 TCP 连接:

netstat -nt

以下截图显示了在 Linux 上运行此命令的结果:

在前面的截图中,你可以看到netstat显示了六列。这些列显示了协议、发送和接收队列、本地地址、远程地址和连接状态。在这个例子中,我们看到有三个连接到端口80。这很可能表明这台电脑正在加载三个网页(因为 HTTP 使用端口80)。

在 Windows 上,netstat -n -p TCP命令显示相同的信息,但省略了套接字队列信息。

队列信息,在基于 Unix 的系统上显示,表示内核已排队等待程序读取的字节数,或者尚未被远程主机确认发送的字节数。小数字是健康的,但如果这些数字变得很大,可能表明网络有问题或程序中存在错误。

看看哪个程序负责每个连接也是有用的。在基于 Unix 的系统上使用-p标志。在 Windows 上,-o标志显示 PID,而-b标志显示可执行文件名。

如果你在一台服务器上工作,查看哪些监听套接字是打开的通常很有用。-l标志指示基于 Unix 的netstat仅显示监听套接字。以下截图显示了监听的 TCP 服务器套接字,包括程序名称:

在前面的截图中,我们可以看到这个系统正在运行 DNS 解析器(systemd-resolve在端口53,仅 IPv4),SSH 守护进程(sshd在端口22),打印机服务(cupsd在端口631),以及一个 Web 服务器(apache2在端口80,仅 IPv6)。

在 Windows 上,netstat没有简单的方法来显示仅监听的套接字。相反,你可以使用-a标志来显示所有内容。使用以下命令可以过滤出仅监听的 TCP 套接字:

netstat -nao -p TCP | findstr LISTEN

下面的截图显示了在 Windows 上使用netstat来仅显示监听 TCP 套接字:

通过了解我们的机器上程序在通信什么,也许查看它们实际在通信什么也是有用的。像tcpdumptshark这样的工具就专门用于此,我们将在下一节介绍它们。

监听连接

除了查看我们电脑上打开的套接字外,我们还可以捕获发送和接收的确切数据。

我们有几个工具选项可供选择:

  • tcpdump是在基于 Unix 的系统上用于数据包捕获的常用程序。然而,它在现代 Windows 系统上不可用。

  • Wireshark是一个非常流行的网络协议分析器,它包含一个非常好的图形用户界面。Wireshark 是免费软件,在 GNU GPL 许可下发布,可在许多平台上使用(包括 Windows、Linux 和 macOS)。

Wireshark 包含 Tshark,这是一个基于命令行的工具,允许我们转储和分析网络流量。程序员通常更喜欢命令行工具,因为它们的界面简单且易于脚本化。它们还有额外的优势,即在没有 GUI 的系统上也能使用。出于这些原因,我们在这个部分专注于使用 Tshark。

Tshark 可以从 www.wireshark.org 获取。

如果你正在运行 Linux,你的发行版可能提供了 Tshark 的软件包。例如,在 Ubuntu Linux 上,以下命令将安装 Tshark:

sudo apt-get update
sudo apt-get install tshark

安装完成后,Tshark 非常易于使用。

你首先需要决定你想要使用哪个或哪些网络接口来捕获流量。所需的接口或接口通过 -i 标志传递给 tshark。在 Linux 上,你可以通过传递 -i any 来监听所有接口。然而,Windows 不提供 any 接口。要在 Windows 上监听多个接口,你需要单独枚举它们,例如,-i 1 -i 2 -i 3

Tshark 使用 -D 标志列出可用的接口。以下截图显示了 Tshark 在 Windows 上枚举可用的网络接口:

如果你想要监控本地流量(即,通信发生在同一台计算机上的两个程序之间),你将想要使用 Loopback 适配器。

一旦你确定了想要监控的网络接口,你可以使用 -i 标志启动 Tshark 并开始捕获流量。使用 Ctrl + C 停止捕获。以下截图显示了 Tshark 的使用情况:

上述截图仅代表在典型 Windows 桌面上运行 Tshark 的几秒钟。正如你所见,即使是相对空闲的系统,也有大量的进出流量。

为了减少噪音,我们需要使用一个捕获过滤器。Tshark 实现了一种小型语言,允许轻松指定要捕获哪些数据包以及要忽略哪些数据包。

通过示例解释过滤器可能最为简单。

例如,如果我们只想捕获到或从 IP 地址 8.8.8.8 的流量,我们将使用 host 8.8.8.8 过滤器。

在以下截图中,我们使用 host 8.8.8.8 过滤器运行了 Tshark:

你可以看到,当 Tshark 运行时,它捕获了两个数据包。第一个数据包是发送到 8.8.8.8 的 DNS 请求。Tshark 通知我们这个 DNS 请求是针对 example.com 的 A 记录。第二个数据包是从 8.8.8.8 收到的 DNS 响应。Tshark 显示 DNS 查询响应指示 example.com 的 A 记录是 93.184.216.34

Tshark 过滤器还支持布尔运算符 andornot。例如,要捕获仅涉及 IP 地址 8.8.8.88.8.4.4 的流量,你可以使用 host 8.8.8.8 过滤器或 host 8.8.4.4 过滤器。

通过端口号进行过滤也非常有用,可以使用 port 来实现。例如,以下截图展示了 Tshark 被用来捕获到 93.184.216.34 端口 80 的流量:

在前面的截图中,我们看到 Tshark 使用了 tshark -i 5 host 93.184.216.34 and port 80 命令。这会捕获网络接口 5 上所有到或来自 93.184.216.34 端口 80 的流量。

TCP 连接以一系列数据包的形式发送。尽管 Tshark 报告捕获了 11 个数据包,但这些数据包都与单个 TCP 连接相关联。

到目前为止,我们一直在以使 Tshark 显示每个数据包摘要的方式使用它。这通常足够了,但有时你可能会想看到数据包的完整内容。

深度数据包检查

如果我们给 tshark 传递 -x 标志,它会显示每个捕获的数据包的 ASCII 和十六进制转储。以下截图展示了这种用法:

在前面的截图中,你可以看到整个 IP 数据包正在被转储。在这种情况下,我们看到前三个数据包代表了一个新的 TCP 连接的三次握手。第四个数据包包含一个 HTTP 请求。

对每个数据包内容的直接洞察并不总是方便的。通常,将数据包捕获到文件中并在稍后进行分析更为实际。使用 tshark-w 选项可以将数据包捕获到文件。你可能还希望使用 -c 选项来限制捕获的数据包数量。这种简单的预防措施可以防止意外用网络流量填满你的整个硬盘。

以下截图展示了如何使用 Tshark 将 50 个数据包捕获到名为 capture.pcap 的文件中:

一旦流量被写入文件,我们就可以在方便的时候使用 Tshark 来分析它。只需运行 tshark -r capture.pcap 即可开始。对于基于文本的协议(如 HTTP 或 SMTP),在文本编辑器中打开捕获文件进行分析也常常很有用。

分析捕获流量的最终方式是使用 Wireshark。Wireshark 允许你加载由 tsharktcpdump 生成的 capture 文件,并使用一个非常友好的图形用户界面进行分析。Wireshark 还能够理解许多标准协议。

以下截图展示了使用 Wireshark 显示从 Tshark 捕获的流量:

如果你需要捕获流量的系统有一个图形用户界面,你也可以使用 Wireshark 直接捕获你的流量。

如果你尝试使用 tshark 和 Wireshark,你会很快发现深入检查网络协议非常容易。你甚至可能会发现一些在你自己的系统上运行的软件做出的有疑问的安全选择。

尽管我们一直专注于监控我们本地系统上的网络流量,但也可以监控所有本地网络流量。

捕获所有网络流量

tshark只能看到到达你机器的流量。这通常意味着你只能用它来监控你电脑上的应用程序的流量。

要捕获你网络上的所有互联网流量,你必须以某种方式安排这些流量到达你的系统,尽管通常不会这样。有两种基本方法可以做到这一点。

第一种方法是使用支持镜像功能的路由器。这个功能使得它可以将所有流量镜像到指定的以太网端口。如果你正在使用这样的路由器,你可以配置它将所有网络流量镜像到特定的端口,然后把你电脑连接到那个端口。从那时起,任何流量捕获工具,如tcpdumptshark,都可以用来记录这个流量。

另一种嗅探所有互联网流量的方法是,在你的路由器和互联网调制解调器之间安装一个集线器(或具有端口镜像功能的交换机)。集线器通过将所有流量镜像到所有端口来工作。集线器曾经是构建网络的一种常见方式。然而,它们已经被更高效的交换机所取代。

在你的路由器和互联网调制解调器之间安装集线器后,你就可以直接将你的系统连接到这个集线器。有了这样的设置,你可以接收所有进入或离开网络的互联网流量。

要监控所有网络流量(例如,甚至同一网络设备之间的流量),你需要使用支持端口镜像的集线器或交换机来构建你的网络。

应当注意的是,有了合适的调制解调器,你有可能无线捕获所有 Wi-Fi 流量。

从安全的角度来看,这是一个值得注意的教训。你不应该认为任何网络流量都是秘密的。只有使用加密适当保护的数据流量才能抵抗监控。

现在我们已经展示了几个用于测试和监控网络流量的工具和技术,接下来让我们考虑重要的网络安全话题。

网络安全

网络安全包括保护网络免受威胁的工具、技术和实践。这些工具包括硬件和软件,可以抵御各种威胁。

虽然这个话题在这里过于宽泛,无法详细讨论,但我们将涵盖你可能会遇到的一些话题。

防火墙是网络安全中最常见的技巧之一。防火墙在两个网络之间充当屏障。通常情况下,它们监控网络流量,并根据定义的一系列规则允许或阻止流量。

防火墙有两种类型:软件和硬件。现在大多数操作系统都提供了软件防火墙。软件防火墙通常配置为拒绝传入连接,除非设置了规则明确允许它。

还可以将软件防火墙配置为默认拒绝传出流量。在这种情况下,除非首先在防火墙配置中添加特定规则,否则程序不允许建立新的连接。

一定要小心,不要假设防火墙可以捕获所有流量。例如,Windows 10 防火墙默认情况下可以配置为拒绝所有出站流量,但它仍然允许 DNS 请求通过。攻击者可以利用这一点通过 DNS 请求窃取数据,尽管用户认为他们通过 Windows 防火墙得到了保护。

硬件防火墙具有各种功能。通常,它们被配置为阻止任何不符合预定义规则的入站连接。在没有防火墙的网络中,路由器通常隐式地提供相同的服务。如果你的路由器提供网络地址转换,那么除非为它预先建立了端口转发规则,否则它甚至不知道如何处理入站连接。

虽然了解网络安全的广泛基础很重要,但作为 C 程序员,我们通常更关心我们自己的程序的安全性。C 语言并没有使安全性变得容易,所以现在让我们更详细地考虑应用程序级的安全性。

应用程序安全和可靠性

在用 C 语言编程时,必须特别关注安全问题。这是因为 C 语言是一种低级编程语言,它可以直接访问系统资源。例如,内存管理在 C 语言中必须手动完成,内存管理中的错误可能会允许网络攻击者写入和执行任意代码。

使用 C 语言时,确保分配的内存缓冲区不会被写入到末尾至关重要。也就是说,每次从网络复制数据到内存时,你必须确保分配了足够的内存来存储数据。如果你的程序错过这一点,哪怕只有一次,也可能为攻击者打开一个窗口,从而控制你的程序。

从安全的角度来看,内存管理在许多高级编程语言中并不是一个关注点。在许多编程语言中,甚至无法编写超出分配内存的操作。当然,这些语言也无法提供 C 程序员所享受的精确控制内存布局和数据结构的能力。

即使你小心翼翼地完美管理内存,仍然有许多安全陷阱需要留意。在实现任何网络协议时,你绝不应该假设程序接收到的数据会遵循协议规范。如果你的程序确实做出了这些假设,它就会对那些不遵循协议的恶意程序开放攻击。这些协议错误在任何编程语言中都是一个关注点,而不仅仅是 C 语言。然而,关于 C 语言,这些协议实现错误可以迅速导致内存错误,而内存错误很快就会变得严重。

如果你正在实现一个作为服务器运行的 C 程序,你应该采用多层次防御的方法。也就是说,你应该以这种方式设置你的程序,使得攻击者在造成损害之前必须克服多个防御措施。

第一层防御是编写没有错误的程序。在处理接收到的网络数据时,仔细考虑如果接收到的数据根本不是你所期望的会发生什么。确保你的程序做正确的事情。

此外,不要以超出程序功能所需的任何更多权限运行你的程序。如果你的程序不需要访问一组敏感文件,确保你的操作系统不允许它访问这些文件。永远不要以 root 身份运行服务器软件。

如果你正在实现一个 HTTP 或 HTTPS 服务器,考虑不要直接将你的程序连接到互联网。相反,使用反向代理服务器作为与互联网的第一个接触点,并让你的软件仅与代理服务器接口。这为攻击提供了额外的隔离层。

最后,如果你能找到替代方案,考虑完全不要用 C 编写网络代码。许多 C 服务器可以被重写为不直接与网络交互的 CGI 程序。TCP 或 UDP 服务器通常可以被重写为使用inetd,从而完全避免套接字编程接口。如果你的程序需要加载网页,考虑使用经过良好测试的库,如libcurl,而不是自己编写。

我们已经介绍了一些网络测试技术。当在实时网络上部署这些技术时,重要的是要考虑周到。让我们以礼仪的注意事项结束。

网络测试礼仪

在进行网络测试时,始终要负责任和道德。一般来说,未经明确许可不要测试他人的网络。否则,最坏的情况可能会让你陷入严重的法律麻烦。

你还应该意识到,一些网络测试技术可能会触发警报。例如,许多网络管理员监控他们网络的负载和性能特征。如果你决定在不通知的情况下对这些网络进行负载测试,可能会触发自动警报,造成不便。

一些其他测试技术可能看起来像攻击。例如,端口扫描是一种有用的技术,其中测试者尝试在不同的端口上建立许多连接。它用于发现系统上哪些端口是开放的。然而,这是一种恶意攻击者用来寻找弱点的常用技术。一些系统管理员认为端口扫描是一种攻击,你永远不应该在没有许可的情况下对系统进行端口扫描。

摘要

在本章中,我们涵盖了网络监控和安全性的广泛主题。我们探讨了用于测试网络设备可达性的工具。我们学习了如何在网络中追踪路径,以及如何在本地机器上监控连接。我们还发现了如何记录和检查网络流量。

我们讨论了网络安全及其可能对 C 语言开发者产生的影响。通过展示如何直接检查网络流量,我们亲身体验了加密对于通信隐私的重要性。还讨论了在应用层进行安全的重要性。

在下一章中,我们将更深入地探讨我们的编码实践如何影响程序行为。我们还将讨论在 C 语言中编写健壮网络应用的一些基本要点。

问题

尝试这些问题来测试您对本章知识的掌握:

  1. 您会使用哪个工具来测试目标系统的可达性?

  2. 哪个工具列出了到达目标系统的路由器?

  3. 原始套接字用于什么?

  4. 哪个工具列出了您系统上的打开 TCP 套接字?

  5. 对于网络化的 C 程序,最大的安全担忧是什么?

这些问题的答案可以在附录 A,问题答案中找到。

进一步阅读

关于本章涵盖主题的更多信息,请参考以下内容:

第十三章:套接字编程技巧和陷阱

本章基于你在本书中获得的全部知识。

套接字编程可能很复杂。有许多陷阱需要避免,以及需要实现的微妙编程技术。在本章中,我们考虑了一些对编写健壮程序至关重要的网络编程的细微细节。

本章涵盖了以下主题:

  • 错误处理和错误描述

  • TCP 握手和有序释放

  • connect()的超时

  • 防止 TCP 死锁

  • TCP 流量控制

  • 避免地址已使用错误

  • 防止SIGPIPE崩溃

  • select()的多路复用限制

技术要求

任何现代 C 编译器都可以编译本章的示例程序。我们建议在 Windows 上使用MinGW,在 Linux 和 macOS 上使用GCC。请参阅附录 B,在 Windows 上设置您的 C 编译器,附录 C,在 Linux 上设置您的 C 编译器,以及附录 D,在 macOS 上设置您的 C 编译器,以了解编译器设置。

本书代码可在以下位置找到:github.com/codeplea/Hands-On-Network-Programming-with-C

从命令行,你可以使用以下命令下载本章的代码:

git clone https://github.com/codeplea/Hands-On-Network-Programming-with-C
cd Hands-On-Network-Programming-with-C/chap13

本章中的每个示例程序都可在 Windows、Linux 和 macOS 上运行。在 Windows 上编译时,每个示例程序都需要链接到Winsock库。这可以通过向gcc传递-lws2_32选项来实现。

本章中的所有示例程序都需要我们在第二章,掌握套接字 API中开发的相同头文件和 C 宏。为了简洁起见,我们将这些语句放在了一个单独的头文件chap13.h中。有关这些语句的解释,请参阅第二章,掌握套接字 API

chap13.h的第一部分包含了每个平台所需的网络头文件。相应的代码如下:

/*chap13.h*/

#if defined(_WIN32)
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0600
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")

#else
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>

#endif

我们还定义了一些宏,以便更容易编写可移植的代码,并包含了我们的程序需要的附加头文件:

/*chap13.h continued*/

#if defined(_WIN32)
#define ISVALIDSOCKET(s) ((s) != INVALID_SOCKET)
#define CLOSESOCKET(s) closesocket(s)
#define GETSOCKETERRNO() (WSAGetLastError())

#else
#define ISVALIDSOCKET(s) ((s) >= 0)
#define CLOSESOCKET(s) close(s)
#define SOCKET int
#define GETSOCKETERRNO() (errno)
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

这就完成了chap13.h

错误处理

错误处理在 C 语言中可能是一个有问题的主题,因为它并不“手把手”地指导程序员。任何分配的内存或资源都必须手动释放,并且这在每种情况下都可能很棘手。

当一个网络程序遇到错误或意外情况时,正常的程序流程会被中断。当设计一个处理多个并发连接的多路复用系统时,这会变得更加困难。

书中的示例程序在错误处理上采取了捷径。几乎所有的程序在检测到错误后都简单地终止。虽然这在现实世界的程序中有时是一个有效的策略,但现实世界的程序通常需要更复杂的错误恢复。

有时,你可以在遇到错误后仅让客户端程序终止。对于简单的命令行工具,这种行为通常是正确的响应。在其他时候,你可能需要让程序自动重试。

事件驱动编程可以提供简化这种逻辑所需的技术。主要的是,你的程序结构是如此设计,以便分配一个数据结构来存储有关每个连接的信息。你的程序使用一个主循环来检查事件,例如可读或可写套接字,然后处理这些事件。以这种方式结构化你的程序时,通常更容易标记一个连接需要执行操作,而不是立即调用一个函数来处理该操作。

通过仔细设计,错误可以像常规事项一样处理,而不是作为正常程序流程的例外。

最终,错误处理是一个非常专业的过程,需要仔细考虑应用需求。对某个系统合适的东西不一定对另一个系统正确。

在任何情况下,健壮的程序设计都要求你仔细考虑如何处理错误。许多程序员只关注成功的路径。也就是说,他们仔细设计程序流程,基于一切都会正确的假设。对于健壮的程序,这是一个错误。同样重要的是要考虑在一切出错的情况下的程序流程。

在本章的其余部分,我们将讨论网络编程可能出错的地方。网络编程可能很微妙,许多这些故障模式都令人惊讶。然而,经过适当的考虑,它们都是可以处理的。

在深入探讨连接可能失败的所有奇怪方式之前,让我们首先关注使错误记录变得更容易一些。在这本书中,到目前为止,我们一直在处理数字错误代码。获取错误文本描述通常更有用。我们将在下一节中查看这种方法。

获取错误描述

在第二章“掌握套接字 API”,我们开发了GETSOCKETERRNO()宏,作为一种跨平台的方法来获取系统调用失败后的错误代码。

为了您的方便,这里重复了GETSOCKETERRNO()宏:

#if defined(_WIN32)
#define GETSOCKETERRNO() (WSAGetLastError())
#else
#define GETSOCKETERRNO() (errno)
#endif

前面的代码在本章中一直为我们服务。它具有简短和简单的优点。

在现实世界的程序中,你可能还想显示基于文本的错误消息,而不仅仅是错误代码。Windows 和基于 Unix 的系统都提供了用于此目的的函数。

我们可以构建一个简单的函数来返回最后一个错误消息作为 C 字符串。此函数的代码如下:

/*error_text.c excerpt*/

const char *get_error_text() {

#if defined(_WIN32)

    static char message[256] = {0};
    FormatMessage(
        FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_IGNORE_INSERTS,
        0, WSAGetLastError(), 0, message, 256, 0);
    char *nl = strrchr(message, '\n');
    if (nl) *nl = 0;
    return message;

#else
    return strerror(errno);
#endif

}

前面的函数使用 Windows 上的 FormatMessage() 和其他操作系统上的 strerror() 格式化错误为文本。

基于 Unix 的系统提供了 strerror() 函数。这个函数只接受错误代码作为其参数,并返回一个指向错误信息字符串的指针。

在 Windows 上获取错误代码描述要复杂一些。我们使用 FormatMessage() 函数来获取文本描述。这个函数有很多选项,但前面代码片段中使用的参数对我们的目的来说效果很好。注意,Windows 错误描述通常以换行符结束。我们的函数使用 strrchr() 来找到最后一个换行符字符,并在该点截断描述。

本章的代码包括一个名为 error_text.c 的程序,该程序演示了这种方法。该程序使用无效参数调用 socket() 函数,然后使用 get_error_text() 显示错误信息:

/*error_text.c excerpt*/

    printf("Calling socket() with invalid parameters.\n");
    socket(0, 0, 0);
    printf("Last error was: %s\n", get_error_text());

注意,错误代码和描述在操作系统之间差异很大。接下来的两个截图显示了该程序在 Windows 和 Linux 上显示的错误信息。

下面的截图显示了 error_text 在 Windows 上的运行情况:

下一个截图显示了 error_text 在 Ubuntu Linux 桌面上的运行情况:

如前两个截图所示,不同的操作系统通常不会以相同的方式报告错误。

现在我们有了更好的方法来调查错误,让我们继续考虑一些可能导致 TCP 套接字失败的方式。

TCP 套接字技巧

传输控制协议TCP)是一个出色的协议,TCP 套接字提供了一个美丽的抽象。它们将不可靠网络上的离散数据包呈现为可靠、连续的数据流。对于程序员来说,从世界任何地方的节点发送和接收数据几乎和读写文件一样简单。

TCP 在隐藏网络缺陷方面表现得非常好。当不稳定网络丢失几个数据包时,TCP 会忠实地整理混乱并按需重新传输。使用 TCP 的应用程序以完美的顺序接收数据。应用程序甚至不知道存在网络问题,当然也不需要解决这个问题。

与所有抽象一样,这种抽象也带来了一些固有的风险。TCP 尽力使网络看起来是可靠的。它通常能成功,但有时,抽象会泄漏。如果你的网络电缆被切断会发生什么?如果你连接的应用程序崩溃会发生什么?TCP 不是魔法。它不能修复这些问题。

当然,当面对严重的网络中断等问题时,抽象必须打破。然而,有时,一些被认为被抽象掉的具体细节可能会引起更微妙的问题。例如,当你尝试发送大量数据,但连接到的对端没有读取它时会发生什么?(答案:数据会积压。)

在本节中,我们更详细地探讨 TCP。我们特别关注这些边缘情况下 TCP 套接字的行为。

TCP 连接的生命周期可以分为三个不同的阶段。具体如下:

  • 设置阶段

  • 数据传输阶段

  • 拆除阶段

每个步骤都可能存在问题。

在设置阶段,我们必须考虑如果目标系统没有响应会发生什么。默认情况下,connect()有时会等待很长时间,试图建立 TCP 连接。有时,你可能希望这样做,但通常并不是这样。

对于数据传输阶段,我们必须小心防止死锁。了解 TCP 拥塞控制机制也可以帮助我们防止我们的连接变得缓慢或使用比必要的更多带宽的退化情况。

最后,了解拆除阶段的具体细节有助于我们确保在连接结束时没有丢失数据。套接字终止的细节也可能导致操作系统在断开连接后长时间保持半死不活的状态。这些挂起的套接字可能会阻止新的程序绑定到它们的本地端口。

让我们从一些关于建立 TCP 连接的三次握手的信息开始,以及如何超时connect()调用。

connect()调用超时

通常,当我们对一个 TCP 套接字调用connect()时,connect()会阻塞直到连接建立。

下面的图示说明了建立典型 TCP 连接的 TCP 三次握手以及它与标准阻塞connect()调用的关系:

图片

标准的 TCP 三次握手包括三个部分。首先,客户端服务器发送一个同步(SYN)消息。然后,服务器响应一个自己的SYN 消息,并结合一个对客户端SYN 消息确认(ACK)消息。然后,客户端服务器SYN 消息进行确认。此时,连接打开并准备好数据传输。

当在客户端侧调用connect()函数时,首先发送一个SYN 消息connect()函数会阻塞直到从服务器收到SYN+ACK 消息。收到SYN+ACK 消息后,connect()将最终ACK 消息入队并返回。

这意味着 connect() 至少会阻塞一个往返网络时间。也就是说,它会从发送 SYN 消息 的时刻开始阻塞,直到接收到 SYN+ACK 消息 的时刻。虽然一个往返网络时间是最佳情况,但在最坏的情况下,它可能会阻塞更长的时间。考虑一下当过载的 服务器 收到 SYN 消息 时会发生什么。服务器可能需要一些时间来回复 SYN+ACK 消息

如果 connect() 无法成功建立连接(即从未收到 SYN+ACK 消息),那么 connect() 调用最终会超时。这个超时时间由操作系统控制。确切的超时时间各不相同,但大约是 20 秒。

没有标准的方法可以扩展 connect() 的超时时间,但如果你想要继续尝试,可以始终再次调用 connect()

有几种方法可以使 connect() 提前超时。一种方法是通过多个进程,如果子进程没有及时连接,则终止它。另一种方法是在基于 Unix 的系统上使用 SIGALARM

通过使用 select() 可以实现跨平台的 connect() 超时。回想一下 第三章,TCP 连接的深入概述,其中提到 select() 允许我们等待带有指定超时的套接字操作。

select() 还有一个额外的优点,就是允许程序在等待 TCP 连接建立的同时执行有用的操作。也就是说,select() 可以用来等待多个 connect() 调用,以及其他套接字事件。这对于需要并行连接多个服务器的客户端来说可以很好地工作。

使用 select() 超时 connect() 调用涉及几个步骤。具体如下:

  1. 将套接字设置为非阻塞操作。在基于 Unix 的系统上,这可以通过 fcntl(O_NONBLOCK) 实现,而在 Windows 上则是通过 ioctlsocket(FIONBIO) 实现。

  2. 调用 connect()。如果步骤 1 成功,这个调用会立即返回。

  3. 检查 connect() 的返回码。返回值为零表示连接成功,这通常表明非阻塞模式设置不正确。connect() 的非零返回值意味着我们应该检查错误码(在 Windows 上是 WSAGetLastError(),在其他平台上是 errno)。错误码 EINPROGRESS(在 Windows 上是 WSAEWOULDBLOCK)表示 TCP 连接正在进行中。任何其他值都表示实际错误。

  4. 设置并调用 select(),指定所需的超时时间。

  5. 将套接字设置回阻塞模式。

  6. 检查套接字是否成功连接。

步骤 1,将套接字设置为非阻塞模式,可以通过以下代码实现:

#if defined(_WIN32)
    unsigned long nonblock = 1;
    ioctlsocket(socket_peer, FIONBIO, &nonblock);
#else
    int flags;
    flags = fcntl(socket_peer, F_GETFL, 0);
    fcntl(socket_peer, F_SETFL, flags | O_NONBLOCK);
#endif

上述代码在 Windows 上运行时略有不同。在 Windows 上,使用带有FIONBIO标志的ioctlsocket()函数来指示非阻塞套接字操作。在非 Windows 系统上,使用fcntl()函数来设置O_NONBLOCK标志以实现相同的目的。

步骤 2步骤 3中,对connect()的调用是正常的。唯一的区别是,你应该在基于 Unix 的系统上预期EINPROGRESS错误代码,在 Windows 上预期WSAEWOULDBLOCK

步骤 4中,select()的设置非常简单。select()函数的使用方式与前面章节中描述的相同。为了方便起见,以下代码展示了使用select()实现此目的的一种方法:

fd_set set;
FD_ZERO(&set);
FD_SET(socket_peer, &set);

struct timeval timeout;
timeout.tv_sec = 5; timeout.tv_usec = 0;
select(socket_peer+1, 0, &set, 0, &timeout);

注意在上述代码中,我们设置了五秒的超时。因此,这个select()调用在连接建立、连接出现错误或经过 5 秒后返回。

步骤 5中,将套接字设置回非阻塞模式可以通过以下代码实现:

#if defined(_WIN32)
    nonblock = 0;
    ioctlsocket(socket_peer, FIONBIO, &nonblock);
#else
    fcntl(socket_peer, F_SETFL, flags);
#endif

步骤 6中,我们正在检查select()调用是否超时、因错误而提前返回,或者因为我们的套接字成功连接而提前返回。

令人惊讶的是,目前没有简单、健壮、跨平台的方法来检查套接字是否在此时刻已连接。我的建议是简单地假设任何被select()标记为可写的套接字已成功连接。只需尝试使用该套接字。大多数 TCP 客户端程序在连接后都会想要调用send()。这个第一次send()调用的返回值表明你是否存在问题。

如果你确实想尝试确定套接字状态而不使用send(),你应该意识到在这种情况下select()信号的一些差异。在基于 Unix 的系统上,一旦连接建立,select()会将套接字标记为可写。如果发生错误,select()会将套接字标记为可读和可写。然而,如果套接字已成功连接并且从远程对等方接收到数据,这也会产生可读和可写的情况。在这种情况下,可以使用getsockopt()函数来确定是否发生错误。在 Windows 上,如果发生错误,select()会将套接字标记为 excepted。

请参考本章代码库中的connect_timeout.c,以获取使用select()实现connect()超时方法的示例。另外,还包括一个用于比较的示例,connect_blocking.c

一旦建立了新的连接,我们的关注点就转向防止数据传输问题。在最坏的情况下,我们的程序可能会与对等方发生死锁,阻止任何数据传输。我们将在下一节中更详细地考虑这个问题。

TCP 流量控制和避免死锁

当设计应用协议和编写网络代码时,我们需要小心防止死锁状态。死锁是指连接两端的双方都在等待对方做某事。最坏的情况是双方最终都无限期地等待。

一个简单的死锁例子是,如果客户端和服务器在连接建立后立即调用recv()。在这种情况下,双方将永远等待永远不会到来的数据。

如果双方同时尝试发送数据,可能会出现一个不太明显的死锁情况。在我们考虑这种情况之前,我们必须首先了解 TCP 连接操作的一些更多细节。

当通过 TCP 连接发送数据时,这些数据会被分成段。一些段会立即发送,但额外的段不会在网络中发送,直到前几个段被连接的对端确认已接收。这是 TCP 的流量控制方案的一部分,有助于防止发送者发送数据比接收者能处理的速度更快。

考虑以下图示:

图片

在前面的图中,客户端服务器发送了三个 TCP 数据段。客户端还有额外的数据准备发送,但它必须等待已发送的数据被确认。一旦收到确认消息客户端就继续发送其剩余的数据

这是确保发送者不会比接收者处理速度更快的 TCP 流量控制机制。

现在,考虑到 TCP 套接字在需要接收确认之前只能发送有限量的数据,想象一下如果 TCP 连接的双方都试图同时发送大量数据会发生什么。在这种情况下,双方都会发送前几个 TCP 段。然后他们都等待对方确认接收后再发送更多。然而,如果双方都没有读取数据,那么双方都不会确认接收数据。这是一个死锁状态。双方都卡在永远等待的状态中。

许多应用协议通过设计防止了这个问题。这些协议自然地在发送和接收数据之间交替。例如,在 HTTP 中,客户端发送一个请求,然后服务器发送一个回复。服务器仅在客户端完成发送后才开始发送数据。

然而,TCP 是一个全双工协议。需要同时向两个方向发送数据的应用程序应该利用 TCP 的这一能力。

作为激励的例子,想象一下实现一个文件传输程序,其中 TCP 连接的双方都在同时发送文件的大块数据。我们如何防止死锁条件?

解决这个问题很简单。双方应该交替调用send()recv()。大量使用select()将有助于我们有效地做到这一点。

回想一下,select()指示哪些套接字已准备好读取,哪些套接字已准备好写入。只有在你知道套接字已准备好写入时,才应调用send()函数。否则,你可能会遇到send()阻塞的风险。在最坏的情况下,send()可能会无限期地阻塞。

因此,发送大量数据的一个程序如下:

  1. 使用剩余数据调用send()

  2. send()的返回值指示send()实际消耗了多少字节。如果你发送的字节数少于预期,那么你的下一次send()调用应该用来传输剩余的数据。

  3. 在读取和写入集合中调用select()时使用你的套接字。

  4. 如果select()指示套接字已准备好读取,则在它上面调用recv()并按需处理接收到的数据。

  5. 如果select()指示套接字已准备好再次写入,则回到步骤 1并使用剩余要发送的数据调用send()

重要的是,send()调用与recv()调用交织在一起。这样,我们可以确保没有数据丢失,并且不会发生死锁条件。

此方法也很好地扩展到具有许多打开套接字的应用程序。每个套接字都添加到select()调用中,并根据需要服务就绪的套接字。你的应用程序需要跟踪每个连接剩余要发送的数据。

还应注意的是,将套接字设置为非阻塞模式可以在某些情况下简化你的程序逻辑。即使是非阻塞套接字,select()仍然可以用作中央阻塞点来等待套接字事件。

本章的代码库中包含两个文件,可以帮助演示死锁状态以及如何使用select()来防止它。第一个文件是server_ignore.c,它实现了一个简单的 TCP 服务器,接受连接然后忽略它们。第二个文件是big_send.c,它初始化一个 TCP 连接然后尝试发送大量数据。通过使用big_send程序连接到server_ignore程序,你可以自己调查send()的阻塞行为。

死锁只是 TCP 连接意外失败的一种方式。虽然死锁可能非常难以诊断,但通过仔细的编程是可以预防的。除了死锁的风险之外,TCP 还带来了其他数据传输的陷阱。接下来,让我们考虑另一个常见的性能问题。

队列控制

正如我们刚刚看到的,TCP 通过实现流量控制来防止发送方压倒接收方。这种流量控制通过只允许发送有限数量的 TCP 段,在需要接收确认之前进行工作。

TCP 还实现了作为网络拥塞避免方案一部分的拥塞控制方法。虽然流量控制对于防止压倒接收方至关重要,但拥塞控制对于防止压倒网络同样关键。

TCP 拥塞控制的一种方式是在发送一定量的数据后暂停,等待接收到的确认。当检测到网络拥塞时,这个数据限制会减少。这样,TCP 不会尝试在网络能够处理之前发送更多的数据。

另一种 TCP 实现拥塞控制的方式是通过TCP 慢启动算法。这种方法为 TCP 提供了一种逐步提升连接至其最大潜力的方式,而不是一次性将大量数据倾倒到网络上。

它的工作原理是这样的——当一个新的 TCP 连接建立时,只允许发送最小量的未确认数据。当这些数据被确认后,限制会增加。每次收到新的确认,限制会进一步增加,直到发生数据包丢失或限制达到期望的最大值。

下图展示了 TCP 慢启动的实际操作:

在前面的图中,你可以看到客户端开始时只发送少量数据。一旦这些数据被确认,客户端愿意在需要另一个确认之前发送更多的数据。一旦收到确认,客户端再次增加其限制,依此类推。

慢启动算法可能会对短暂连接造成问题。在实践中,如果一个连接只需要发送少量数据,那么这个连接永远不会达到其全部潜力。这导致许多协议被设计为保持连接更长时间。例如,过去 HTTP 连接通常只传输一个资源。现在,HTTP 连接保持打开以传输额外资源的情况要普遍得多。这种连接重用避免了 TCP 三次握手和慢启动的开销。

除了避免拥塞外,TCP 还提供了提高带宽效率的方法。

Nagle 算法

TCP 用来提高效率的一种技术是Nagle 算法。Nagle 算法的工作原理是将发送方的小量数据合并在一起,直到有足够的数据可以证明发送的合理性。

考虑在 TCP 连接上发送仅一个字节的数据。每个 TCP 段使用 20 字节来传输 TCP 记账信息。还需要额外的 20 字节用于 IPv4 头部。因此,这 1 字节的应用数据在网络中变成了 41 字节。这是 4000%的额外开销,而且我们还没有计算来自低层(例如,以太网帧开销)的额外开销!

Nagle 算法指出,在任何给定时间,只能有一个小的、未确认的 TCP 段是未完成的。任何小于最大段大小MSS)的段都被认为是小的段。

让我们看看这如何应用于执行小量写入的程序。考虑以下在已连接但其他方面处于空闲状态的 TCP 套接字上调用的代码:

send(my_socket, "a", 1, 0);
send(my_socket, "b", 1, 0);

在第一次send()调用之后,a数据被封装进一个 TCP 消息并发送出去,同时附带其 40 字节的 TCP 和 IPv4 开销。

第二次send()调用会立即返回,但b数据实际上并不会立即发送。Nagle 算法会导致操作系统将b数据排队。它不会发送,直到第一个 TCP 消息被确认或者再次调用send()并带有足够的数据来填满一个完整的最大 TCP 段。

为了让接收方接收到ab,需要的时间是一个往返网络时间的持续时间,加上额外的单向网络时间。

我们可以通过以下代码轻松地将 1.5 倍的往返网络时间缩短到 0.5 倍的往返网络时间:

send(my_socket, "ab", 2, 0);

因此,在可能的情况下,你应该始终优先选择一次进行大量写入到send(),而不是多次小量写入。一次大量写入允许ab在同一 TCP 消息中发送,从而完全绕过 Nagle 算法。

在某些应用程序中,你确实需要在发送一个小数据包后立即发送另一个小数据包。例如,在实时多人在线视频游戏中,你不能排队存储玩家指令;它们必须连续发送。在这些情况下,为了减少延迟,牺牲带宽效率,禁用 Nagle 算法是有意义的。

可以使用setsockopt()函数来禁用 Nagle 算法。以下代码展示了这种方法的应用:

int yes = 1;
if (setsockopt(my_socket, IPPROTO_TCP, TCP_NODELAY,
        (void*)&yes, sizeof(yes)) < 0) {
    fprintf(stderr, "setsockopt() failed. (%d)\n", GETSOCKETERRNO());
}

在禁用 Nagle 算法之前,务必考虑所有选项。当面对性能不佳的网络程序时,一些程序员会首先尝试禁用 Nagle 算法。实际上,禁用 Nagle 算法的决定应该谨慎对待。在实时应用程序中禁用 Nagle 算法通常是有意义的。在其他环境中很少这样做。

例如,假设你已经实现了一个 HTTP 客户端。它似乎有点缓慢,所以你尝试禁用 Nagle 算法。你这样做后,发现它现在运行得快多了。然而,通过禁用 Nagle 算法,你增加了网络开销。你可以通过简单地合并你的send()调用来实现同样的改进。

如果你正在实现一个确实需要发送小的时间敏感数据包的实时算法,使用TCP_NODELAY可能仍然不是适合你的方法。TCP 可以通过许多其他方式引入延迟。例如,如果网络中丢失了一个 TCP 数据包,那么在重新传输该数据包之前,无法发送更多数据。这可能会因为一个数据包的延迟而延迟许多数据包。

许多实时应用程序更喜欢使用 UDP 而不是 TCP。每个 UDP 数据包完全独立于之前或之后发送的任何其他数据包。当然,权衡是可靠性保证较低;消息可能以与设置不同的顺序到达,并且某些消息可能到达两次。尽管如此,许多应用程序可以容忍这种情况。例如,实时视频流可以使用 UDP,其中每个数据包存储视频的一个非常短、带时间戳的部分。如果数据包丢失,不需要重新传输;视频会暂时卡顿,然后在下一个数据包到达时继续。接收到的晚到或顺序错误的数据包可以安全忽略。

虽然 Nagle 算法通常可以很好地提高网络利用率,但如果不了解它是如何工作的,可能会导致问题。除了 Nagle 算法之外,TCP 还实现了许多其他方法来限制网络资源的无谓浪费。有时,这些其他方法之间工作得不好。延迟 ACK就是这样一种可能和 Nagle 算法一起工作得不好的方法。

延迟确认

我们已经看到,许多客户端-服务器协议是通过客户端发送请求然后服务器发送响应来工作的。我们还看到,当 TCP 对等方从网络中读取数据时,它会发送一个确认,让发送者知道数据已成功接收。

因此,典型的客户端-服务器交互可能看起来如下:

图片

在前面的图中,客户端首先向服务器发送一个请求。服务器读取这个请求,并向客户端发送一个 TCP ACK 消息。然后服务器处理请求数据,并回复其响应。

一些 TCP 堆栈实现了一个延迟确认方法来减少网络拥塞。这种技术通过延迟接收数据的确认来实现。希望接收者很快就会发送一个响应,并且确认可以附加在这个响应上。当它起作用时,这通常是经常的,它可以节省带宽。

如果接收者没有发送回复,确认将在短暂的延迟后发送;200 毫秒是典型值。

如果之前的服务器实现了延迟确认,客户端-服务器交互可能看起来如下:

图片

这就是延迟确认工作得很好的时候。

现在,考虑将 Nagle 算法与延迟确认结合使用。如果客户端通过两个小消息发送其请求,那么发送通道不仅会因为往返时间而被阻塞,还会因为额外的确认延迟时间而被阻塞。

这在以下图中得到了说明:

图片

在前面的图中,我们看到客户端在小型数据包中发送了其请求的第一部分。Nagle 算法阻止它发送请求的第二部分,直到它从服务器那里收到确认。同时,服务器收到了请求,但它延迟确认,希望能够在回复上附加ACK 消息服务器处理请求的第一部分,并看到它还没有完整的请求,因此不能发送回复。在延迟期过后,服务器最终发送了一个ACK 消息客户端收到这个ACK 消息并发送剩余的回复。服务器回复其响应。

在这种退化情况下,Nagle 算法和延迟确认技术的交互导致客户端-服务器交互需要两个完整的往返网络时间加上延迟确认时间(这本身可能需要很多往返时间)。

一些程序员在这些情况下会跳出来禁用 Nagle 算法。有时这是必要的,但通常这不是正确的解决方案。

在我们的例子中,仅仅将更大的数据缓冲区传递给send()就可以完全解决这种退化交互。在一次调用中将整个请求传递给send()可以将事务时间从两个往返加上延迟减少到一个往返和没有延迟。

我的建议是,在可能的情况下,最好使用一次大的写入调用send(),而不是多次小的写入。当然,如果你正在实现一个使用 TCP 的实时应用程序,那么你不能池化send()调用。在这种情况下,禁用 Nagle 算法可能是正确的选择。

为了完整性,应该指出,延迟 ACK 通常可以禁用。这是通过在支持它的系统上向setsockopt()传递TCP_QUICKACK来完成的。再次强调,这通常不是必需的。

现在我们已经回顾了一些可能出现在活动 TCP 连接中的隐藏问题,现在是时候转向连接断开操作了。

连接断开

TCP 连接从建立连接过渡到关闭连接的过程是微妙的。让我们更详细地考虑这个问题。

TCP 连接是全双工的。这意味着发送的数据与接收的数据是独立的。数据同时发送和接收。这也意味着在真正断开连接之前,连接必须由双方关闭。

要关闭 TCP 连接,每一方都会发送一个结束FIN)消息,并从其对等方接收一个 ACK 消息。

从每个对等方的角度来看,确切的断开过程取决于它是首先发送 FIN,还是首先收到 FIN。有三种基本的连接断开情况。具体如下:

  1. 你通过发送第一个 FIN 消息来启动断开操作

  2. 你从你的连接对等方收到一个 FIN 消息

  3. 你和你的对等方同时发送 FIN 消息

在第 3 种情况下,双方同时发送 FIN 消息,每一方都认为它处于第 1 种情况。也就是说,每一方都认为它已经发送了第一个 FIN 消息,并且每一方都像第 1 种情况一样断开它的套接字。在实践中,这种情况相当罕见,但确实可能发生。

当一个 TCP 套接字用于全双工通信时,它处于ESTABLISHED状态。关闭的发起方向其对等方发送一个 FIN 消息。对等方回复一个 ACK。在这个时候,连接只是半关闭。发起方不能再发送数据,但它仍然可以接收数据。对等方可以选择继续向关闭的发起方发送更多数据。当对等方准备好完成关闭连接时,它发送自己的 FIN 消息。然后发起方回复最终的 ACK 消息,连接就完全关闭了。

发起方上的 TCP 连接状态转换是ESTABLISHEDFIN-WAIT-1FIN-WAIT-2TIME-WAITCLOSED。在接收方上的 TCP 连接状态转换是ESTABLISHEDCLOSE-WAITLAST-ACKCLOSED

以下图示说明了正常的 TCP 四次关闭握手:

图片

有时,对等方可以将它的ACK 消息FIN 消息合并成一个消息。在这种情况下,连接可以通过只有三个消息而不是四个消息来断开。

在双方同时发起断开连接的情况下,双方都遵循发起方的状态转换。发送和接收的消息是相同的。

网络本质上是不可靠的,所以有可能发起方发送的最后一个ACK 消息会丢失。在这种情况下,由于没有收到ACK 消息对等方会重发它的FIN 消息。如果发起方在发送最后一个ACK 消息后完全关闭了它的套接字,那么它将无法回复这个重发的FIN 消息。因此,发起方在发送最后一个ACK 消息后会进入TIME-WAIT状态。在这个TIME-WAIT状态下,它会用ACK 消息回复对等方重传的任何FIN 消息。经过一段延迟后,发起方离开TIME-WAIT状态并完全关闭它的套接字。

TIME-WAIT延迟通常是一分钟左右的量级,但可以配置得更长。

在这本书中,我们只使用了close()函数(在 Windows 上是closesocket())来断开套接字。这个函数虽然使用简单,但有一个缺点,就是总是完全关闭套接字。也就是说,在调用close()的套接字上无法发送或接收数据。TCP 断开连接的握手过程确实允许在发送 FIN 消息后接收数据。接下来,让我们考虑如何通过程序来实现这一点。

shutdown()函数

正如我们刚才看到的,TCP 连接是通过两个步骤断开的。首先,一方发送一个 FIN 消息,然后另一方发送。然而,每一方都有权继续发送数据,直到它发送了自己的 FIN 消息。

我们使用close()函数(在 Windows 上是closesocket())来断开套接字,因为它很简单。然而,close()函数会关闭套接字的两个端。如果你在应用程序中使用close(),而远程对等方尝试发送更多数据,这将导致错误。然后,你的系统将发送一个重置RST)消息,向对等方表明连接没有有序地关闭。

如果你想要关闭你的发送通道,但仍然保留接收更多数据的选择,你应该使用shutdown()函数。shutdown()函数接受两个参数。第一个参数是一个套接字,第二个是一个int类型的参数,表示如何关闭套接字。

理论上,shutdown()支持三种选项——关闭连接的发送端、关闭接收端和关闭两端。然而,TCP 协议本身并不反映这些选项,并且使用shutdown()来关闭接收端很少有用。

关于shutdown()函数的参数存在一个小的问题。在 Windows 下,你应该使用SD_SEND来调用它。在其他系统上,你应该使用SHUT_WR。这两个值都定义为1,因此你也可以这样调用它。

在跨平台方式下关闭套接字发送通道的代码如下:

if (shutdown(my_socket, 1) /* 1 = SHUT_WR, SD_SEND */) {
    fprintf(stderr, "shutdown() failed. (%d)\n", GETSOCKETERRNO());
}

这种使用shutdown()的方式会导致 TCP FIN 消息在传输队列清空后传输。

你可能会想知道,如果你从对等方接收数据,并且recv()返回0,你如何知道你的对等方是调用了shutdown()还是close()?不幸的是,你无法知道,除非事先有协议。如果它们只使用shutdown()来关闭它们的发送数据通道,那么它们仍然可以接收额外的数据。如果它们使用close(),额外的数据将触发错误状态。

虽然半关闭连接有其用途,但通常使用一个明确指示事务结束的应用协议更容易。例如,考虑第六章中介绍的 HTTP 协议,构建简单的 Web 客户端。使用 HTTP 协议,客户端通过一个空白行来指示其请求的结束。服务器在看到这个空白行时知道它已经收到了完整的请求。然后,服务器通过Content-Length头指定它将发送多少数据。一旦客户端收到了这么多数据,它就知道它没有错过任何东西。然后客户端可以调用close(),并确信服务器不会发送额外的数据。

在许多应用程序中,知道关闭是否有序并不总是有用的。考虑来自第三章的聊天室程序(tcp_serve_chat.c),TCP 连接的深入概述。这个程序没有真正的应用程序协议。那个程序只是将消息从一位客户端发送到其他所有客户端。当一个客户端决定断开连接时,它继续从服务器接收数据并不重要。保证有序的 TCP 释放不会带来任何好处。

因此,当何时使用shutdown()呢?基本上,当应用程序协议没有方法来表示它已经完成发送数据,并且你的应用程序不能容忍丢失数据时,TCP 有序释放是有用的。在这种情况下,shutdown()是一个有用的信号。

请注意,如果你使用线程或进程创建,close()shutdown()的行为会有额外的差异,必须考虑。当调用shutdown()时,它总是影响套接字。相比之下,如果还有其他进程也持有套接字的句柄,close()函数就没有效果。

最后,请注意,即使使用shutdown()关闭套接字,也必须最终调用close()来释放相关的系统资源。

在 TCP 连接拆除过程中出现的一个问题是,发起关闭的一方在保持TIME-WAIT状态时会有较长的延迟。这有时会给 TCP 服务器带来问题。让我们接下来看看这个问题。

防止地址已使用错误

如果你长时间进行 TCP 服务器编程,你最终会遇到以下场景——你的 TCP 服务器有一个或多个打开的连接,然后你终止它(或它崩溃)。你重新启动服务器,但bind()调用失败,出现EADDRINUSE(Windows 上的WSAEADDRINUSE)错误。

当这种情况发生时,你可以等待几分钟,再次尝试,然后它就会工作。这里发生了什么?

实际上,当一个应用程序初始化 TCP 套接字关闭(或通过崩溃导致断开连接)时,该套接字进入TIME-WAIT状态。操作系统会持续跟踪这个套接字一段时间,可能是几分钟。

本章的代码库中包含了一个示例程序server_noreuse.c。你可以通过运行它、接受连接然后终止server_noreuse来重现地址已使用的问题。为了重现这个问题,服务器必须终止打开的连接,而不是客户端。

如果你立即再次启动server_noreuse,你会看到bind()错误。

以下截图显示了 Linux 桌面上的这一情况:

图片

你可以使用netstat命令查看这些半死不活的连接,这些连接阻止了我们的服务器启动。以下命令显示了 Linux 上处于TIME-WAIT状态的连接:

netstat -na | grep TIME

只要这些连接中的任何一个挂起,它就会阻止任何新的进程在相同的本地端口和地址上调用bind()

通过在调用bind()之前在服务器套接字上设置SO_REUSEADDR标志,可以防止bind()调用失败。

以下代码演示了这一点:

int yes = 1;
if (setsockopt(my_socket, SOL_SOCKET, SO_REUSEADDR,
        (void*)&yes, sizeof(yes)) < 0) {
    fprintf(stderr, "setsockopt() failed. (%d)\n", GETSOCKETERRNO());
}

一旦设置了SO_REUSEADDR标志,即使还有几个TIME-WAIT连接仍然挂在该本地端口和地址上,bind()也会成功。

包含了一个示例程序server_reuse.c,用于演示这项技术。

我建议您始终为 TCP 服务器使用SO_REUSEADDR,因为它的缺点很少。唯一的真正缺点是使用SO_REUSEADDR允许您的程序绑定到特定接口,即使另一个程序已经绑定了通配符地址。通常,这不会成为问题,但这是需要记住的事情。

有时候,您可能会看到尝试通过杀死TIME-WAIT状态中的套接字来修复此问题的程序。这可以通过设置套接字 linger 选项来实现。这是危险的!TIME-WAIT状态对于 TCP 的可靠性至关重要,干扰它可能会导致严重问题。

为什么这个地址已使用的问题只针对服务器而不是客户端?因为这个问题在调用bind()时显现出来。客户端程序通常不会调用bind()。如果他们这样做,这也会在客户端引起问题。

当我们还在讨论断开连接的套接字时,当你尝试向已经调用close()的对等方发送数据时会发生什么?让我们考虑一下这个问题。

向断开连接的对等方发送数据

TCP 连接可能失败的基本方式有三种。如下所示:

  • 网络中断

  • 对等应用程序崩溃

  • 对等方的系统崩溃

网络中断阻止数据到达您的对等方。在这种情况下,TCP 会尝试重新传输数据。如果重新建立了连接,TCP 将简单地从上次停止的地方继续。否则,连接最终会超时。这个超时可能长达 10 分钟。

TCP 连接可能失败的第二种方式是对等的应用程序崩溃。在这种情况下,对等方的操作系统会发送一个 FIN 消息。这种情况与对等方在他们的端点调用close()无法区分。如果您的应用程序在收到 FIN 消息后继续发送数据,对等方的系统将发送一个 RST 消息来指示错误。

最后,连接可能会因为对等方的整个系统崩溃而失败。在这种情况下,它将无法发送一个 FIN 消息。这种情况看起来与网络中断相似,TCP 连接最终会超时。然而,考虑一下如果崩溃的系统在连接超时之前重新启动会发生什么。在这种情况下,重新启动的系统最终会从原始连接接收到 TCP 消息。重新启动的系统将不会识别 TCP 连接,并将发送一个 RST 消息作为响应,以指示错误状态。

再次强调,如果你在一个你认为已经关闭的套接字上使用 send(),那么对端会响应一个 RST 消息。这个状态可以通过 recv() 的返回值轻松检测到。

需要考虑的一个更严重的问题是,当在已经从对端收到 RST 消息的套接字上调用 send() 时会发生什么。在基于 Unix 的系统上,默认操作是向你的程序发送一个 SIGPIPE 信号。如果你不处理这个信号,操作系统将终止你的程序。

因此,对于 TCP 服务器来说,处理或禁用 SIGPIPE 信号是至关重要的。未能处理这种情况意味着一个粗鲁的客户端可能会杀死你的服务器。

信号很复杂。如果你已经在你的程序中使用了信号,你可能想处理 SIGPIPE。否则,我建议你通过将 SIGPIPE 处理程序设置为 SIG_IGN 来禁用它。

以下代码在基于 Unix 的系统上禁用 SIGPIPE

#if !defined(_WIN32)
#include <signal.h>
#endif

#if !defined(_WIN32)
signal(SIGPIPE, SIG_IGN);
#endif

作为一种替代方法,你可以在 send() 中使用 MSG_NOSIGNAL,如下面的代码所示:

send(my_socket, buffer, sizeof(buffer), MSG_NOSIGNAL);

如果忽略信号或使用 MSG_NOSIGNALsend() 将返回 -1 并将 errno 设置为 EPIPE

在 Windows 上,尝试在已关闭的套接字上调用 send() 通常会导致 WSAGetLastError() 返回 WSAECONNRESET

本章代码库中包含了一个示例程序,server_crash.c。该程序在端口 8080 上接受 TCP 连接。然后它等待客户端断开连接,然后尝试向该断开连接的客户端发送两次数据。这个程序作为一个工具,可以用来探索不同场景下的返回值、错误代码和函数行为。

套接字的本地地址

在实现服务器时,无论是 TCP 还是 UDP,将监听套接字绑定到本地地址和端口都是非常重要的。如果套接字没有绑定,那么客户端将不知道如何连接。

还可以在客户端使用 bind() 来将套接字与特定的地址和端口关联。在具有多个网络接口的机器上,有时使用 bind() 以这种方式是有用的。使用 bind() 可以允许选择用于出站连接的网络地址。

有时,bind() 用于设置出站连接的本地端口。这通常有几个原因是不好的。首先,它很少有什么实际用途。无论网络地址转换如何,提供给连接服务器的端口号可能都会不同。绑定到本地端口还可能邀请选择一个已经被使用的端口号的错误。通常,操作系统会负责选择一个空闲的端口号。这种使用 bind() 的方式还会引发 TIME-WAIT 的问题,这会导致在关闭一个连接后,没有显著延迟的情况下,无法建立新的连接。

在本书中,我们主要使用bind()将服务器绑定到特定的端口号。它也可以用来将服务器关联到特定的地址。如果一个服务器有多个网络接口,可能你只关心在某个地址上监听连接。在这种情况下,bind()可以很容易地用来限制连接到该地址。它也可以用来通过将套接字绑定到127.0.0.1来限制连接到本地机器。这可能是一些应用程序的重要安全措施。

我们已经使用了select()函数来完成许多任务——超时connect()、在数据可用时发出信号以及防止send()阻塞。然而,select()只适用于监控有限数量的套接字。让我们看看这个限制,以及如何绕过它。

使用大量套接字进行多路复用

在本书中,我们使用了select()在打开的套接字之间进行多路复用。select()函数很棒,因为它在许多平台上都可用。然而,如果你有大量的打开套接字,你可能会迅速遇到select()的限制。

你可以向select()传递的最大套接字数量。这个数字可以通过FD_SETSIZE宏获得。

本章的代码仓库包含一个程序,setsize.c,它打印出FD_SETSIZE的值。

下面的截图显示了在 Windows 10 上编译和运行此程序:

上一张截图显示,在这个系统中FD_SETSIZE的值为64。尽管 Windows 中FD_SETSIZE的默认值相当低,但在其他系统中通常可以看到更高的值。Linux 中FD_SETSIZE的默认值是1024

在 Windows 上,可以很容易地增加FD_SETSIZE。你只需要在包含winsock2.h头文件之前自己定义FD_SETSIZE。例如,以下代码将 Windows 上的FD_SETSIZE增加到1024

#ifndef FD_SETSIZE
#define FD_SETSIZE 1024
#endif
#include <winsock2.h>

这之所以有效,是因为 Winsock 使用FD_SETSIZE来构建fd_set类型。

在 Linux 上,这个技巧不起作用。Linux 将fd_set定义为位掩码,并且在不重新编译内核的情况下无法增加其大小。

有可能的工作区可以有效地欺骗select()在 Linux 上接受大于1023的套接字描述符。通常有效的一个技巧是分配一个fd_set变量的数组。设置套接字的方式如下:

FD_SET(s % FD_SETSIZE, &set_array[s / FD_SETSIZE])

然而,如果你不得不求助于像前面代码那样的黑客手段,你可能会更好地避免使用select()并使用不同的多路复用技术。例如,poll()函数提供了与select()相同的功能,但它没有处理文件描述符数量的限制。

概述

在本章中,我们涵盖了大量的内容。首先,我们回顾了错误处理方法,然后实现了一个函数来获取错误代码的文本描述。

我们随后直接深入 TCP 套接字的复杂细节。我们看到了 TCP 套接字如何隐藏大量复杂性,以及有时理解这些隐藏状态对于获得良好的应用程序性能是必要的。我们看到了 TCP connect()调用早期超时的一个方法,并探讨了如何通过有序释放来终止连接。

我们随后更详细地研究了bind()函数及其在服务器和客户端之间使用上的不同之处。最后,我们讨论了select()函数如何限制程序可以处理的套接字总数,以及如何绕过这一限制。

到目前为止,本书主要关注与个人电脑和服务器相关的网络代码。在下一章,第十四章,物联网网络编程中,我们将关注点转向将互联网接入日常对象——即,物联网

问题

尝试这些问题来测试你从本章获得的知识:

  1. 在检测到网络错误时,是否可以仅终止程序?

  2. 哪些系统功能用于将错误代码转换为文本描述?

  3. 在 TCP 套接字上,一个connect()调用完成需要多长时间?

  4. 如果你在一个断开的 TCP 套接字上调用send()会发生什么?

  5. 你如何确保下一次对send()的调用不会阻塞?

  6. 如果 TCP 连接的两个端点同时尝试发送大量数据会发生什么?

  7. 通过禁用 Nagle 算法,你能提高应用程序的性能吗?

  8. select()可以处理多少个连接?

这些问题的答案可以在附录 A,问题解答中找到。

第十四章:物联网的 Web 编程

在本章中,我们将注意力转向物联网IoT)。物联网是一个令人兴奋的新趋势,其中互联网连接被添加到日常物理对象中。当与嵌入式电子设备和传感器结合时,互联网访问允许物理对象相互交互,并可以从世界任何地方进行控制和监控。

本章节涵盖了以下主题:

  • 定义物联网

  • 连接类型

  • 带宽考虑

  • 控制器类型

  • 物联网的伦理

  • 物联网的安全

技术要求

本章不包含 C 代码。相反,它侧重于物联网领域使用的理论、技术和实践。

什么是物联网?

我们现在生活在一个几乎任何可以连接到互联网的东西都已经连接的世界。这些设备构成了物联网,它们存在于我们生活的方方面面。

在家里的厨房里,具有互联网连接的设备包括冰箱、微波炉、传统烤箱、食品秤、洗碗机、咖啡机,甚至榨汁机。

在家里的其他地方,我们有智能电视、游戏机、恒温器、锅炉、洗衣机、开关、灯泡、瑜伽垫、闹钟、摄像头、门铃、浴室秤、婴儿监视器、音响系统和扬声器——所有这些设备都连接到互联网。当然,你的整个房子可能由网络智能电表供电,提供电力、水和天然气。当你离开家时,你会通过物联网车库门进入你的始终连接的内置 Wi-Fi 汽车吗?许多人都是这样做的。

在工业领域,物联网设备是工业 4.0的核心,通常被认为是第四次工业革命。利用互联网连接将制造供应链中的每个设备相互连接,实现了前所未有的效率优化。

物联网甚至影响了我们种植食物的方式。在农业领域,物联网设备允许农民监控相关的天气条件,如温度、湿度、阳光、降雨和风速。这些信息有助于做出提高产量和质量的决策。

当然,这只是物联网应用的几个例子。这个概念也应用于环境、医疗保健、基础设施、交通等方面的关注。

当向设备添加互联网连接时,你有几个选择。每种连接选项都有其优缺点。我们将在下面进行回顾。

连接选项

一旦你弄清楚你的设备能做什么,并且它从互联网连接中受益,你仍然需要决定如何连接它。有许多选项,各有其权衡。

Wi-Fi

或许 Wi-Fi 不需要过多介绍。几乎每个有互联网接入的现代家庭都提供了 Wi-Fi。这在很大程度上是因为住宅互联网服务提供商ISP)通常使用内置 Wi-Fi 的调制解调器。如果你的物联网设备部署在室内,那么很可能有一个 Wi-Fi 网络可用。这一点对于住宅和商业设备尤其如此。

Wi-Fi 是最受欢迎的无线局域网WLAN)技术,基于 IEEE 802.11 标准。它通常在 2.4 GHz 或 5 GHz 的无线电频率上运行。

Wi-Fi 提供了两种主要的操作模式。在对等ad hoc)模式下,设备直接相互通信。然而,基础设施infrastructure)模式更为常见。在基础设施模式下,WLAN 上的设备都连接到一个无线接入点WAP)。然后 WAP 通常为整个 WLAN 提供互联网接入。

Wi-Fi 提供良好的带宽、低延迟和基本的安全功能,但它的功耗比其他短距离无线标准要高。尽管 Wi-Fi 的新版本可以实现 1 Gbps 的速度,但在实际应用中这些速度还不常见。无论如何,对于物联网设备的应用,我们通常关注的是互联网接入。物联网设备连接到本地路由器的 Wi-Fi 性能通常不是瓶颈,而是路由器可用的互联网服务。

在你的物联网设备中使用 Wi-Fi 连接的一个缺点是设置难度。大多数 Wi-Fi 网络都使用密码进行保护,因此你的设备需要以某种方式获取网络名称和密码,才能连接。如果你的设备内置了屏幕和键盘(或触摸屏),那么输入网络密码是微不足道的。

然而,对于没有大屏幕的物联网设备,配置网络密码有时可能会很复杂。一种解决方案是让您的最终用户将配置信息放在 SD 卡或 USB 存储设备上。然后您的设备从这个存储中读取密码,并可以连接到网络。这种解决方案的问题在于它很繁琐,许多用户可能没有技术能力完成这个设置。

一种替代的设置方法是首先通过计算机或智能手机(可能通过蓝牙)将设备连接起来进行初始配置。这种方法的不利之处在于需要用户获得这样的设置设备(并非每个人都有智能手机),以及需要额外的工作来开发设置应用程序。

第三种设置方法是让物联网设备最初提供自己的 Wi-Fi 网络。换句话说,该设备充当临时的接入点。然后,用户使用笔记本电脑或智能手机直接连接到设备。用户打开浏览器,设备会提供一个网页,用户可以在该网页上配置设备。一旦输入了参数,物联网设备就会连接到本地 Wi-Fi 热点,现在已配置好用于互联网访问。

以下图表说明了这种设置方法:

图片

这种设置方法的优点是无需为设置开发特殊软件。任何可以连接到 Wi-Fi 网络并具有标准网络浏览器的设备都可以工作。

请记住,Wi-Fi 网络偶尔会发生变化。定期更改 Wi-Fi 密码是良好的安全实践,每次更改密码时,物联网设备都需要重新配置。

虽然 Wi-Fi 很方便,但如果有线连接可用,通常更容易、更好。

以太网

如果可用,有线以太网连接对任何物联网设备来说都是理想的。以太网提供简单的设置和极高的可靠性。最新的标准允许带宽达到 400 Gbps,其低延迟是无与伦比的。在某些情况下,甚至可以通过以太网提供电力,进一步简化部署。

以太网连接的缺点当然是需要物理电缆。许多办公室已经将以太网布线到每个房间,以太网连接的物联网设备在那里运行良好。然而,在住宅和工业环境中,铺设新电缆可能会很繁琐。因此,在这些环境中,无线连接通常更容易。

当可用时,以太网设置很简单。很多时候,设备只需插入即可开始工作。

请记住,与以太网和 Wi-Fi 一样,您依赖于您的物联网设备安装地点有互联网接入。这种互联网接入通常比您在本地网络上的以太网连接要慢得多。由于这种互联网接入是由您的客户提供的,其质量可能会有很大差异。

如果 Wi-Fi 和以太网都不可用怎么办?如果您的设备需要移动怎么办?在这些情况下,蜂窝互联网接入可能是您的最佳选择。

细胞网络

细胞网络(移动网络)可以是一个很好的物联网设备连接选项。它尤其有两个原因很有用。首先,蜂窝服务几乎无处不在可用。这使得蜂窝网络非常适合移动物联网设备。其次,所有网络设置都可以在制造时完成。这使得部署变得简单、无烦恼,并且与其他选项相比,可以降低持续支持成本。

细胞连接的缺点是成本较高。首先,物联网设备必须包含一个细胞调制解调器。细胞调制解调器的成本比 Wi-Fi 调制解调器和以太网收发器要高。此外,在设备被允许进入运营商网络之前,还需要进行某些测试和认证。

细胞设备也必须付费获取接入。使用 Wi-Fi 或以太网时,物联网设备被认为能够从本地互联网接入中获取流量。使用细胞网络时,必须有人为每个特定设备支付账单。通常,物联网设备制造商为每个设备提供和维持服务是有意义的。对于需要低带宽的设备,LTE 细胞连接可以每月低于 1.00 美元(美元)的价格维持。任何需要大量带宽的设备,其服务成本都要高得多。

细胞网络的一个额外好处是许多移动网络提供商可以通过虚拟私人网络VPN)路由您的流量。这是由网络运营商完成的,您不会为 VPN 额外带宽的费用付费。VPN 将您的物联网设备与互联网隔离开来,并允许仅与您信任的服务器进行通信。这很方便,因为许多安全担忧都由移动网络提供商解决。

我们已经介绍了三种直接互联网接入的选项,但许多物联网设备不需要直接访问互联网。一些设备与计算机或智能手机协同工作以获取接入。与这种方法一起常用的无线技术之一是蓝牙。

蓝牙

蓝牙是一种流行的短距离无线技术标准,用于无线个人区域网络WPAN)。与 Wi-Fi 一样,蓝牙在 2.4 GHz 无线电频段上运行。

蓝牙可以用于直接互联网接入,但在实践中这是不常见的。蓝牙通常用于将物联网设备连接到另一个具有互联网接入的设备。例如,许多智能手表使用蓝牙与智能手机配对。然后,智能手机通过 Wi-Fi 或细胞连接获得互联网接入。

以下图显示了蓝牙智能手表的连接:

图片

这对于智能手表来说非常有效,因为它通常预期会与智能手机保持近距离。

当然,您可能从哲学上反对将智能手表视为物联网设备。在这种情况下,它更多的是智能手机应用程序的传感器,而不是独立的物联网设备。尽管如此,它还是符合物联网的生态系统。这种搭便车的连接方式可以是一个好选择,不应被忽视。

作为与蓝牙类似的技术,基于IEEE 802.15.4的无线网络通常被部署以允许整个物联网设备网络进行相互通信。

IEEE 802.15.4 WPANs

IEEE 802.15.4低速率无线个人区域网络LR-WPAN)的技术标准。该标准首次于 2003 年定义,为其他协议提供了基础。例如,Zigbee6LoWPANThreadISA100.1WirelessHARTWiSUNMiFi协议都是基于 IEEE 802.15.4 标准的,仅举几个例子。

基于 IEEE 802.15.4 的协议适用于低功耗、有限范围的场景。典型的范围可扩展到大约 10 米,传输速率为 250 Kbps。由于功耗低,电池供电的设备通常可以在单次充电的情况下运行多年。

一些基于 IEEE 802.15.4 的协议,如 6LoWPAN,允许设备直接使用 IP 进行通信,类似于 Wi-Fi 通常的使用方式。其他协议通常仅用于将数据发送到中心节点设备。该中心节点负责处理物联网设备数据,并在需要时通过互联网进行通信。

作为具体例子,家庭自动化网络通常基于 IEEE 802.15.4 标准。在许多情况下,这些网络很少用于为每个物联网设备提供直接互联网访问。相反,通常使用中心节点来允许设备网络进行通信。该中心节点协调数据并将其转发到互联网。

网状网络拓扑在基于 IEEE 802.15.4 的设置中很常见。这允许远离中心节点的设备通过较近的设备进行通信。

下图展示了家庭自动化中的网状网络:

图片

在前面的图中,我们看到一个用于家庭自动化设置的基于 IEEE 802.15.4 的网络。各种物联网设备,如灯具和厨房小工具,与协调器/中心节点通信。该中心节点控制和协调连接的设备。该中心节点本身使用 Wi-Fi 连接到本地网络,这种互联网连接允许远程控制和监控物联网设备。

现在我们已经介绍了一些最受欢迎的连接选项,让我们将注意力转向物联网硬件选择。

硬件选择

在设计物联网设备时,有许多硬件选择。在许多情况下,这些选择最终必须基于设备的功能,而不是它们的连接选项。记住,物联网设备除了连接到互联网之外,还需要有有用的目的。

让我们来看看三种硬件选择——单板计算机、微控制器和现场可编程门阵列FPGA)。

单板计算机

单板计算机SBC)是在单个电路板上构建的完整计算机。它们包括功能计算机的所有常用部件——中央处理器CPU)、随机存取存储器RAM)、非易失性存储器和输入/输出端口。

SBCs 能够运行全功能的操作系统。Linux 被广泛使用。

例如,树莓派 Zero W 的尺寸仅为 2.6 英寸 x 1.2 英寸。它内置 512 MB 的 RAM,并使用 micro SD 卡进行存储。还包含 Wi-Fi 和蓝牙连接功能。目前,树莓派 Zero W 的零售价约为 10 美元(美元)。

以下照片展示了树莓派 Zero W:

图片

由于 SBCs 运行正常的桌面操作系统,如 Linux,因此编程它们很容易。事实上,本书中我们开发的所有网络程序都适用。

除了视频、音频和 USB 的标准计算机连接器外,SBCs 通常还有一些 通用输入/输出GPIO)引脚以进行工作。这赋予了它们一些基本的微控制器类似功能。这些 GPIO 引脚能够读取数字输入信号并提供数字输出信号。在树莓派上,一些引脚还能够提供 脉冲宽度调制PWM) 输出,以及各种板级串行协议——串行外围接口SPI)、集成电路间I2C)和 异步串行

虽然 SBCs 非常容易入门,但它们确实有一些缺点。它们相对较贵,需要大量电力,并且它们使用的通用操作系统并不总是适合嵌入式系统。相比之下,微控制器要便宜得多,并且针对处理实时性能约束进行了优化。

微控制器

微控制器 是一个包含在单个集成电路上的小型计算机。微控制器包含一个功能计算机所需的所有部件,包括 CPU、RAM 和非易失性存储。

虽然 SBCs 通常具有数百或数千兆赫的处理器速度,但微控制器的时钟频率通常低于 100 兆赫。看到时钟频率仅为 1 兆赫或更低的微控制器并不罕见。微控制器通常具有从几十字节到几兆字节的 RAM。这些朴实的规格也意味着微控制器可以在非常少的电力下运行。在睡眠模式下,看到微控制器仅需几纳安培的情况并不少见。

微控制器通常带有大量的 GPIO 引脚和有用的外围设备,例如计时器、实时时钟、通信接口、脉冲宽度调制PWM) 输出、模拟到数字A/D) 转换器比较器、数字到模拟转换器DAC)、看门狗定时器WDT)、串行收发器等等。

这些微控制器外围设备对于物联网设备的核心功能至关重要。例如,PWM 生成器外围设备可用于精确的电机控制,而 ADC 可能需要读取传感器。记住,物联网设备的互联网部分在很大程度上是次要的;设备必须首先执行一些有用的功能。如果无法感知温度,互联网连接的恒温器有什么用?

微控制器有多种架构和总线宽度。8 位、16 位和 32 位控制器都很常见。尽管对于物联网设备来说,8 位控制器可能功率不足。

现代台式电脑几乎总是使用冯·诺依曼架构,但许多微控制器使用哈佛架构。

在冯·诺依曼架构中,这种架构主导着桌面处理器市场,内存存储数据和指令。这需要处理器指令的宽度与它们处理的数据相同。相比之下,哈佛架构机器使用完全独立的内存来存储指令和数据。哈佛架构微控制器使用与数据不同的字大小来存储指令是很常见的。例如,一个 16 位的哈佛架构微控制器可能以 16 位字处理数据,但其指令可能编码为 24 位字。当数据存储在 RAM 中时,指令直接从非易失性存储器读取,例如FLASHEEPROM

还可以找到具有内置网络功能的微控制器,包括对 Wi-Fi、以太网、蓝牙、IEEE 802.15.4 等的支持。

以 Espressif Systems 生产的 ESP8266 32 位微控制器为例,它内置了 Wi-Fi 收发器和 TCP/IP 堆栈。

以下图显示了 ESP8266 微控制器在扩展板上的情况:

没有内置收发器的微控制器可以通过外部调制解调器连接到具有这些功能的设备。

微控制器通常直接编程,而不需要操作系统。一些程序员更喜欢操作系统,并且有许多不同的微控制器操作系统可供选择。这些操作系统通常使微控制器执行各种任务变得更加容易,同时简化程序布局并保留实时能力。需要注意的是,这些微控制器操作系统通常缺乏桌面操作系统中被视为理所当然的内存分段和其他安全特性。

当你不需要太多处理能力,但确实需要实时处理保证时,微控制器非常出色。那么当你需要处理能力和实时保证时怎么办?看看 FPGAs。

FPGAs

现场可编程门阵列FPGAs)是包含可编程逻辑块数组的集成电路。这些逻辑块可以被编程和组合以实现复杂功能。本质上,任何专用集成电路ASIC)可以执行的操作也可以在 FPGA 上完成。

FPGAs 对于需要实时保证的高度要求处理任务非常有用,特别是那些从高度并行处理中受益的任务。例如,一个对单板计算机来说过于苛刻的视频处理算法,更不用说微控制器了,对于 FPGA 来说可能微不足道。

FPGA 使用硬件描述语言HDL)进行编程。最常用的两种语言是VerilogVHSIC 硬件描述语言VHDL)。这些语言与 C 语言大不相同。虽然 C 是一种命令式语言,它提供了完成计算所需的步骤,但 HDL 是声明性语言,它提供了逻辑门应该如何连接的描述。

在实践中,通常会在 FPGA 中实现一个软微处理器核心。这个软处理器可以像任何其他微处理器一样编程,并用于各种不需要太多处理能力的任务。例如,处理视频的 FPGA 可能实现了一个负责系统设置和配置功能的微处理器。这些软处理器通常用 C 语言编程。

无论你的物联网设备使用哪种类型的硬件,如果没有合适的内置通信,你将需要一个外置收发器或调制解调器。

外置收发器和调制解调器

外置收发器和调制解调器可用于 Wi-Fi、蜂窝、以太网、蓝牙以及任何你能想到的协议。

以下照片显示了一个使用蜂窝调制解调器的工业物联网设备:

图片

在前面的图中,下方的电路板实现了设备的功能(在本例中是监控低温液体)。在这个板上,你可以找到电源系统、传感器电子设备和一款低成本、16 位的微控制器。一个蜂窝调制解调器位于顶部的电路板上,该电路板插入到下方电路板上的一个插座中。

外置调制解调器通常通过异步串行与主机(微控制器)通信。所使用的协议在系统之间差异很大。

与调制解调器通信的一个常见协议族是Hayes 命令集,也称为AT 命令。这些命令由简短基于文本的消息组成。AT 命令在某种程度上标准化了拨打电话,但已扩展到管理互联网连接。不幸的是,几乎每个调制解调器制造商对扩展的方式都不同。

如果你正在使用外置调制解调器,你需要查阅该调制解调器的文档以获取与其通信的确切协议。通常没有提供套接字风格的驱动程序,但本书中探讨的许多核心网络概念仍然适用。

在解决了连接问题之后,让我们将注意力转向物联网设备协议

物联网协议

大多数物联网设备通过将数据发送到几个中央服务器——云来工作。这些服务器处理和存储物联网设备数据,并允许远程访问和配置。

例如,以智能恒温器为例。它持续向中央服务器发送温度数据。该服务器存储这些数据。如果用户想查看数据,他们可以将个人电脑或智能手机连接到该中央服务器。他们不会直接连接到物联网设备本身。当他们想更改恒温器设置时,他们向中央服务器发送这个更改,然后服务器将其转发到物联网恒温器。

下面的图示说明了这个概念:

在前面的图中,请注意所有通信都通过服务器进行。智能手机与物联网设备之间没有直接的通信。

使用中央服务器有几个优点。它允许物联网设备进行更少的处理。服务器可以进一步处理这些数据并将其格式化为图表等。服务器还可以发送电子邮件或其他警报,并且服务器能够存储比物联网设备能够存储的更多数据。此外,服务器为物联网设备和用户的设备(如笔记本电脑或智能手机)提供了一个连接的地方。如果没有中央服务器,让用户的设备连接到物联网设备将是一项挑战。

物联网设备实际使用的协议各不相同。许多设备使用 HTTPS。在这种情况下,设备通常会请求一个 HTTPS 页面,同时传递收集到的数据。然后,网络服务器将收集数据并将所需信息返回给物联网设备。

有一些协议标准专门针对物联网设备,例如受限应用协议CoAP)和消息队列遥测传输MQTT)。

然而,定制的 TCP 和 UDP 协议也很常见。这些定制协议在带宽受限的情况下可以实现更高的效率。

也许在未来的几年里,我们将看到更多的协议标准化。这将为设备更好地互操作带来巨大的优势。目前,设备很少与不同制造商的设备互操作。

在部署物联网设备时,另一个重要的考虑因素是设备发货后如何更新代码。

固件更新

物联网设备连接到互联网,这为我们提供了产品更新的优势。当开发新的功能或错误修复时,我们可以利用互联网将更新推送到我们的物联网设备。

使用 SBC(单板计算机)时,这个过程很简单。你只需像更新任何软件一样推送更新。

如果你的产品使用微控制器或 FPGA,那么事情会变得稍微复杂一些。您的设备需要下载固件映像,然后根据需要将其传输到非易失性存储器中。

如果在固件更新阶段设备电源中断,设备可能会处于无法使用状态。这可以通过精心设计来预防。如果设备有足够的内存来存储固件两次,则可以在不覆盖原始固件的情况下下载整个固件更新。在这种情况下,设备可以检测到失败的更新(通过使用校验和、看门狗定时器或其他机制)并恢复到之前的状态。

在任何情况下,任何进行远程更新的物联网设备都应该遵循一些实践。

首先,为了安全起见,任何固件更新都必须经过认证。如果这一步被遗漏,那么攻击者可以向您的设备发送虚假更新。一旦您的设备运行了攻击者的代码,您所做的任何其他安全措施都将无关紧要。我们将在本章后面更详细地讨论安全问题。

尽管关注认证,但您应该考虑允许设备所有者安装未经认证的固件。运行他们自己设备上想要运行的代码是基本用户自由,有些人认为试图阻止这种做法是不道德的。无论如何,技术上不可能控制那些不在您物理控制下的设备。换句话说,如果您试图通过数字版权管理DRM)阻止用户安装自己的固件,您应该预期您的努力最终会被击败。

通常认为允许设备返回其原始出厂配置是一个好的做法。这通常意味着将出厂固件存储在单独的非易失性存储器中,例如低成本闪存芯片。在固件更新失败这种罕见情况下,这一点很重要。如果固件更新失败,设备可能无法启动。如果存储了冗余的固件副本,则可以通过某种物理重置机制将设备回滚到其原始出厂状态。从这个状态开始,可以重试更新。

现在我们已经讨论了一些物联网设计的技术考虑因素,让我们将注意力转向一些伦理问题。

物联网伦理

关于物联网(IoT)的概念,在隐私、安全和其他伦理问题上,已经受到了很多批评。考虑到这些设备在我们生活中的普遍存在,这并不令人惊讶。

大多数物联网设备与中央服务器协同工作。该服务器从它服务的物联网设备中获取几乎所有收集到的数据。这引发了关于数据所有权和隐私的问题。

隐私和数据收集

许多物联网设备在其操作过程中收集大量数据。例如,智能恒温器收集其环境中的温度数据。这对于其功能是必需的。乍一看,这些数据可能看似无害,但一旦您考虑到人们如何使用智能恒温器,您就会意识到这些数据比最初看起来更重要。

在智能恒温器的例子中,人们会根据他们预计何时醒来、何时入睡或何时外出设置不同的温度。从这个数据中,你可以推断出某人早上何时出门上班以及何时回家。当一个人外出度假一周时,这将在数据中清楚地反映出来。

即使一个看似无害的智能恒温器也能深入了解一个人的行为,想象一下智能手机或家庭助手收集了多少数据!

如果我们接受这种数据收集是设备功能固有的,那么其收集似乎是合理的。那么,考虑一下,物联网公司是否有义务保护用户的数据安全或保密。那么,谁拥有这些数据?是收集这些数据的公司拥有的,还是提供这些数据的客户拥有的?传统的理解是,数据完全是收集这些数据的公司的财产,但最近有重新分类的趋势。法律意见不一,这仍然是大部分未知领域。

无论如何,如果您正在收集数据,即使是看似无害的数据,也请尊重它。也请允许您的用户查看和下载存储在他们身上的数据的副本。

生命周期规划

设计物联网设备时的另一个考虑因素是,当该设备不再生产时会发生什么?许多物联网公司会在一段时间后停止支持他们的旧设备,然后这些设备就变得无用。这是因为它们通常需要连接到中央服务器才能运行,一旦这个中央服务器离线,它们实际上就无法运行。

有一个替代方案。在设计物联网设备时,即使没有互联网连接,也可以使其以降低的容量运行。例如,可以设计一个智能恒温器,即使它无法与其中央服务器建立连接,也能继续作为手动恒温器工作。

当您设计您的物联网设备时,请考虑一下,即使您的公司停止对该设备提供支持,它是否还能保持其有用性。在某些情况下,这可能是不可能的,但在许多情况下,似乎公司故意限制他们的设备,只是为了得罪他们的客户。不要成为那些公司的一员。

安全性

近年来,许多物联网设备在安全方面考虑不足。随着公司急于将设备推向市场,安全往往甚至没有进入等式。情况已经变得如此糟糕,以至于现在有一个常见的说法:“物联网中的 S 代表安全*。”

安全问题可能因不安全的物联网设备很少使设备开发者面临责任而加剧。例如,如果你开发了一个不安全的服务器,那么你的公司就会受到攻击。然而,如果你开发了一个不安全的物联网设备,你的公司就没有问题;只是你的客户容易受到攻击。

如果你正在开发一个物联网设备,我恳求你——做正确的事情,尽早并经常考虑安全问题。

根据开放网络应用安全项目OWASP),前十位物联网安全问题如下:

  1. 弱、可猜测或硬编码的密码

  2. 不安全网络服务

  3. 不安全生态系统接口

  4. 缺乏安全更新机制

  5. 使用不安全或过时的组件

  6. 隐私保护不足

  7. 不安全的数据传输和存储

  8. 缺乏设备管理

  9. 不安全默认设置

  10. 缺乏物理加固

你可以从 OWASP 基金会获取更多信息,请访问owasp.org/

许多前面提到的问题只需关心就能轻易解决。例如,许多设备仍然带有硬编码的密码或后门。从技术角度来看,关于这个问题没有太多可说的——只是不要将产品发货时带有硬编码的密码或后门

一些倡导者认为所有物联网设备都应该使用 HTTPS 来提供基本的安全级别——所谓的安全超文本物联网SHIoT)。请参阅第九章,使用 HTTPS 和 OpenSSL 加载安全网页,了解从客户端视角的 HTTPS 信息。

对于嵌入式 HTTPS,你可能发现 OpenSSL 库过于庞大。考虑使用专为嵌入式使用设计的 TLS 实现。这些以嵌入式为首要的库也能够利用内置到常见微控制器中的加密硬件。wolfSSL就是这样一个开源库。除了 TCP 的 TLS 外,wolfSSL 还支持用于保护基于 UDP 协议的 DTLS。

使用 TLS 可以在很大程度上确保物联网设备的安全,但它不是万能的。特别是,证书必须谨慎管理。不要犯下认为加密就足够好的错误。加密只能保护免受被动攻击;为了保护免受主动攻击,需要加密和认证。

有时,最小化带宽是首要任务。这在蜂窝连接中尤其如此。在许多用例中,带宽有限的物联网设备会发现 TLS 和 DTLS 过于庞大。建立新的 TLS 或 DTLS 连接平均需要几个千字节。

如果你的移动网络提供商能够通过不计量 VPN 开销的加密 VPN 隧道你的流量,你可能可以免除在应用级别实现安全通信。在这种情况下,你的设备可以直接使用 TCP 或 UDP,VPN 连接确保这种流量永远不会在互联网上可见。这种级别的安全性并不适用于所有用例,因为你的流量仍然对你的网络提供商可见。

如果您必须自己确保协议的安全,您可以利用这样一个事实:您控制着您的物联网设备的制造。在制造时,可以为每个设备分配一个独特的加密密钥。这种技术称为预共享密钥PSK)。使用 PSK,设备可以使用对称加密来确保其通信安全,从而避免了 TLS、DTLS 和其他重量级解决方案所需的大部分开销。如果进行对称加密,请记住,如果没有同时实施身份验证,加密对主动攻击者来说是无用的。这是通过使用消息认证码MAC)来完成的。

如果你确实使用PSK,请确保为每个设备使用一个独特的密钥。重复使用相同的密钥是不恰当的。

在任何情况下,在实施前后由安全专家运行您的安全方案都是明智的。即使是在加密或身份验证中犯下的微小、几乎可以忽略不计的错误也可能危及整个系统。

摘要

在本章中,我们探讨了物联网设备的高级设计。我们探讨了物联网设备可以构建的不同类型的硬件——单板计算机、微控制器和 FPGA。我们考虑了这些设备连接到互联网的各种方式,包括有线和无线连接,并权衡了与每种方式相关的某些权衡。

我们还考虑了物联网设备可以使用的某些协议选择,并重申了安全的重要性。

问题

尝试这些问题来测试您对本章知识的掌握:

  1. 使用 Wi-Fi 连接的缺点是什么?

  2. 使用以太网连接的缺点是什么?

  3. 使用蜂窝连接的缺点是什么?

  4. 使用带嵌入式 Linux 的单板计算机有哪些优点?缺点是什么?

  5. 在您的物联网设备中使用微控制器有哪些优点?

  6. 在物联网设备通信中,使用 HTTPS 是否总是合适的?

这些问题的答案可以在附录 A,问题解答中找到。

第十五章:答案

第一章,介绍网络和协议

  1. IPv4 和 IPv6 之间有哪些关键区别?

IPv4 仅支持 40 亿个唯一的地址,并且由于它们分配得不够高效,我们现在正在耗尽。IPv6 支持 3.4 x 10³⁸个可能的地址。IPv6 提供了许多其他改进,但这是直接影响我们网络编程的一个。

  1. ipconfigifconfig命令给出的 IP 地址与远程 Web 服务器看到的 IP 地址相同吗?

有时,这些地址会匹配,但并不总是如此。如果你在一个私有 IPv4 网络上,那么你的路由器很可能会执行网络地址转换。远程 Web 服务器然后看到转换后的地址。

如果你有一个公开路由的 IPv4 或 IPv6 地址,那么远程 Web 服务器看到的地址将与ipconfigifconfig报告的地址相匹配。

  1. IPv4 回环地址是什么?

IPv4 回环地址是127.0.0.1,它允许网络程序在相同机器上执行时相互通信。

  1. IPv6 回环地址是什么?

IPv6 回环地址是::1。它的工作方式与 IPv4 回环地址相同。

  1. 域名(例如,example.com)是如何解析成 IP 地址的?

DNS 用于将域名解析为 IP 地址。该协议在第五章中详细说明,主机名解析和 DNS

  1. 如何找到你的公网 IP 地址?

最简单的方法是访问一个为你报告这个信息的网站。

  1. 操作系统如何知道哪个应用程序负责处理传入的数据包?

每个 IP 数据包都有一个本地地址、远程地址、本地端口号、远程端口号和协议类型。这五个属性被操作系统记住,以确定哪个应用程序应该处理任何给定的传入数据包。

第二章,掌握套接字 API

  1. 什么是套接字?

套接字是一个抽象,表示系统间通信链路的一个端点。

  1. 什么是无连接协议?什么是面向连接的协议?

面向连接的协议在更大的数据流上下文中发送数据包。无连接协议独立于之前或之后的数据发送每个数据包。

  1. UDP 是无连接的还是面向连接的协议?

UDP 被认为是一个无连接协议。每个消息都是独立于之前或之后发送的。

  1. TCP 是无连接的还是面向连接的协议?

TCP 被认为是一个面向连接的协议。数据以流的形式按顺序发送和接收。

  1. 哪些类型的应用程序通常从使用 UDP 协议中受益?

UDP 应用程序在牺牲可靠性的同时,从更好的实时性能中受益。它们还能够利用 IP 多播。

  1. 哪些类型的应用程序通常从使用 TCP 协议中受益?

需要可靠数据流传输的应用程序可以从 TCP 协议中受益。

  1. TCP 是否保证数据能够成功传输?

TCP 对可靠性做出了一些保证,但没有任何东西可以真正保证数据能够成功传输。例如,如果有人拔掉了你的调制解调器,没有任何协议可以克服这一点。

  1. 伯克利套接字和 Winsock 套接字之间有哪些主要区别?

头文件是不同的。套接字本身表示为有符号和无符号int。当socket()accept()调用失败时,返回值是不同的。伯克利套接字也是标准文件描述。Winsock 并不总是这样。错误代码不同,并且以不同的方式检索。还有其他差异,但这些是我们程序受影响的主要差异。

  1. bind()函数的作用是什么?

bind()函数将套接字与特定的本地网络地址和端口号关联。它的使用几乎总是必需的,对于服务器而言,而对于客户端通常不是必需的。

  1. accept()函数的作用是什么?

accept()函数将阻塞,直到新的 TCP 客户端连接。然后它返回这个新连接的套接字。

  1. 在 TCP 连接中,是客户端还是服务器先发送应用程序数据?

客户端或服务器都可以先发送数据。它们甚至可以同时发送数据。在实践中,许多客户端-服务器协议(如 HTTP)是通过客户端首先发送请求,然后服务器发送响应来工作的。

第三章,TCP 连接的深入概述

  1. 我们如何判断下一次调用recv()是否会阻塞?

我们使用select()函数来指示哪些套接字可以无阻塞地读取。

  1. 如何确保select()不会阻塞超过指定的时间?

你可以向select()传递一个超时参数。

  1. 当我们使用我们的tcp_client程序连接到 Web 服务器时,为什么需要在 Web 服务器响应之前发送一个空白行?

HTTP,Web 服务器的协议,期望一个空白行来表示请求的结束。没有这个空白行,它就不知道客户端是否会继续发送额外的请求头。

  1. send()函数是否会阻塞?

是的。你可以使用select()来确定套接字何时准备好写入而不阻塞。或者,可以将套接字置于非阻塞模式。有关更多信息,请参阅第十三章,套接字编程技巧和陷阱

  1. 我们如何判断套接字是否被我们的对等方断开连接?

recv()的返回值可以指示套接字是否已断开连接。

  1. 通过recv()接收到的数据是否总是与通过send()发送的数据大小相同?

不。TCP 是一种流协议。没有办法判断从一次recv()调用返回的数据是使用一次还是多次send()调用发送的。

  1. 考虑以下代码:
recv(socket_peer, buffer, 4096, 0);
printf(buffer);

它有什么问题?

也看看这段代码有什么问题:

recv(socket_peer, buffer, 4096, 0);
printf("%s", buffer);

recv() 返回的数据不是以空字符终止的!前述代码片段中的任何一个都可能导致 printf() 读取 recv() 返回的数据的末尾。此外,在第一个代码示例中,接收到的数据可能包含格式说明符(例如 %d),这会导致额外的内存访问违规。

第四章,建立 UDP 连接

  1. sendto()recvfrom()send()recv() 有什么区别?

在调用 connect() 之后,send()recv() 函数是有用的。它们只与传递给 connect() 的一个远程地址一起工作。sendto()recvfrom() 函数可以与多个远程地址一起使用。

  1. send()recv() 可以在 UDP 套接字上使用吗?

是的。在这种情况下,应该首先调用 connect() 函数。然而,sendto()recvfrom() 函数对于 UDP 套接字通常更有用。

  1. 在 UDP 套接字的情况下,connect() 会做什么?

connect() 函数将套接字与远程地址关联。

  1. 与 TCP 相比,什么使得使用 UDP 的多路复用更容易?

一个 UDP 套接字可以与多个远程对等方通信。对于 TCP,每个对等方需要一个套接字。

  1. 与 TCP 相比,UDP 的缺点是什么?

UDP 不尝试修复 TCP 所做的许多错误。例如,TCP 确保数据按发送的顺序到达,TCP 尝试避免造成网络拥塞,并且 TCP 尝试重新发送丢失的数据包。UDP 什么都不做。

  1. 同一个程序可以使用 UDP 和 TCP 吗?

是的。它只需要为两者创建套接字。

第五章,主机名解析和 DNS

  1. 哪个函数以可移植和协议无关的方式填充套接字编程所需的地址?

getaddrinfo() 是用于此目的的函数。

  1. 哪个套接字编程函数可以将 IP 地址转换回名称?

getnameinfo() 可以用于将地址转换回名称。

  1. DNS 查询将名称转换为地址,反向 DNS 查询将地址转换回名称。如果你对一个名称运行 DNS 查询,然后对结果地址运行反向 DNS 查询,你是否总是得到你开始的名称?

有时,你会得到相同的名称,但并不总是这样。这是因为正向和反向查找使用独立的记录。也可能有多个名称指向一个地址,但那个地址只能有一个指向单个名称的记录。

  1. 用于返回名称的 IPv4 和 IPv6 地址的 DNS 记录类型是什么?

A 记录类型返回一个 IPv4 地址,而 AAAA 记录类型返回一个 IPv6 地址。

  1. 哪种 DNS 记录类型存储有关电子邮件服务器的特殊信息?

MX 记录类型用于返回电子邮件服务器信息。

  1. getaddrinfo() 是否总是立即返回?或者它可以阻塞?

如果 getaddrinfo() 正在进行名称查找,它通常会阻塞。在最坏的情况下,需要向多个 DNS 服务器发送许多 UDP 消息,因此这可能会导致明显的延迟。这也是 DNS 缓存重要的原因之一。

如果你只是使用 getaddrinfo() 将文本 IP 地址转换为地址,那么它不应该阻塞。

  1. 当 DNS 响应太大而无法放入单个 UDP 数据包时会发生什么?

DNS 响应的头部将设置 TC 位。这表示消息已被截断。应使用 TCP 重新发送查询。

第六章,构建简单的 Web 客户端

  1. HTTP 使用 TCP 还是 UDP?

HTTP 在 TCP 端口 80 上运行。

  1. 可以通过 HTTP 发送哪些类型的资源?

HTTP 可以用来传输几乎任何计算机文件。它通常用于网页(HTML)及其相关文件(例如样式、脚本、字体和图像)。

  1. 常见的 HTTP 请求类型有哪些?

GETPOSTHEAD 是最常见的 HTTP 请求类型。

  1. 通常用于从服务器向客户端发送数据的 HTTP 请求类型是什么?

GET 是客户端请求从服务器获取资源的常用请求类型。

  1. 通常用于从客户端向服务器发送数据的 HTTP 请求类型是什么?

当客户端需要向服务器发送数据时,使用 POST

  1. 确定 HTTP 响应体长度的两种常用方法是什么?

HTTP 主体长度通常由 Content-Length 标头或使用 Transfer-Encoding: chunked 确定。

  1. POST 类型的 HTTP 请求体是如何格式化的?

这由应用程序决定。客户端应设置 Content-Type 标头以指定它使用的格式。"application/x-www-form-urlencoded" 和 "application/json" 是常见的值。

第七章,构建简单的 Web 服务器

  1. HTTP 客户端如何指示它已发送完 HTTP 请求?

HTTP 请求应以一个空行结束。

  1. HTTP 客户端如何知道 HTTP 服务器发送的内容类型?

HTTP 服务器应使用 Content-Type 标头识别内容。

  1. HTTP 服务器如何识别文件的媒体类型?

识别文件媒体类型的一种常见方法就是查看文件扩展名。尽管如此,服务器也可以使用其他方法。当发送动态页面或数据库中的数据时,将没有文件和文件扩展名。在这种情况下,服务器必须从其上下文中知道媒体类型。

  1. 如何判断文件是否存在于文件系统中并且可以被你的程序读取?fopen(filename, "r") != 0 是一个好的测试吗?

这不是一个简单的问题。一个健壮的程序需要仔细考虑系统特定的 API。Windows 使用特殊的文件名,这可能会使仅依赖于 fopen() 检查文件存在性的程序出错。

第八章,让你的程序发送电子邮件

  1. SMTP 运行在哪个端口上?

SMTP 通过 TCP 端口25进行邮件传输。许多提供商使用替代端口进行邮件提交。

  1. 如何确定哪个 SMTP 服务器接收特定域的邮件?

负责接收特定域邮件的邮件服务器由 MX 类型的 DNS 记录给出。

  1. 如何确定哪个 SMTP 服务器为特定提供商发送邮件?

在一般情况下,这是不可能确定的。在任何情况下,可能有几个服务器负责。有时这些服务器将列在 TXT 类型的 DNS 记录下使用 SPF,但这绝对不是普遍的。

  1. 为什么 SMTP 服务器在没有认证的情况下不会中继邮件?

开放中继 SMTP 服务器是垃圾邮件发送者的目标。SMTP 服务器需要认证以防止滥用。

  1. 当 SMTP 是一个基于文本的协议时,如何将二进制文件作为电子邮件附件发送?

二进制文件必须重新编码为纯文本。最常见的方法是使用Content-Transfer-Encoding: base64

第九章,使用 HTTPS 和 OpenSSL 加载安全网页

  1. HTTPS 通常在哪个端口上运行?

HTTPS 通过 TCP 端口443连接。

  1. 对称加密使用多少个密钥?

对称加密使用一个密钥。数据使用相同的密钥进行加密和解密。

  1. 非对称加密使用多少个密钥?

非对称加密使用两个不同但数学上相关的密钥。数据使用一个密钥加密,使用另一个密钥解密。

  1. TLS 使用对称加密还是非对称加密?

TLS 使用对称和非对称加密算法来工作。

  1. SSL 和 TLS 之间的区别是什么?

TLS 是 SSL 的后继者。SSL 现在已弃用。

  1. 证书有什么用途?

证书允许服务器或客户端验证其身份。

第十章,实现安全 Web 服务器

  1. 客户端如何决定是否应该信任服务器的证书?

客户端可以信任服务器证书的方式有很多。信任链模型是最常见的。在这个模型中,客户端明确信任一个权威机构。然后,客户端隐式信任任何由这个受信任的权威机构签名的证书。

  1. 自签名证书的主要问题是什么?

自签名证书未由受信任的证书颁发机构签名。除非用户添加特殊例外,否则网络浏览器不会知道信任自签名证书。

  1. 什么可以导致SSL_accept()失败?

如果客户端不信任服务器的证书,或者客户端和服务器无法就相互支持的协议版本和加密套件达成一致,则SSL_accept()会失败。

  1. select()能否用于 HTTPS 服务器的连接多路复用?

是的,但请注意,select()在底层的 TCP 连接层上工作,而不是在 TLS 层上。因此,当select()指示套接字有等待的数据时,这并不一定意味着有新的 TLS 数据准备好。

第十一章,使用 libssh 建立 SSH 连接

  1. 使用 Telnet 的显著缺点是什么?

事实上,Telnet 不提供任何安全功能。密码以明文形式发送。

  1. SSH 通常运行在哪个端口上?

SSH 的官方端口是 TCP 端口22。在实践中,通常会在任意端口上运行 SSH 以试图隐藏自己,从攻击者的角度来看,这些攻击者只是麻烦而不是真正的威胁。

  1. 为什么客户端验证 SSH 服务器是至关重要的?

如果客户端没有验证 SSH 服务器的身份,那么它可能会被欺骗向冒充者发送凭证。

  1. 服务器通常是如何进行身份验证的?

SSH 服务器通常使用证书来标识自己。这与使用 HTTPS 时服务器进行身份验证的方式类似。

  1. SSH 客户端通常是如何进行身份验证的?

客户端使用密码进行身份验证仍然很常见。这种方法的不利之处在于,如果客户端被欺骗连接到冒充的服务器,那么他们的密码就会被泄露。SSH 提供了替代方法,包括使用证书来验证客户端,这些方法不易受到重放攻击。

第十二章,网络监控和安全

  1. 您会使用哪个工具来测试目标系统的可达性?

ping工具用于测试可达性。

  1. 哪个工具可以列出到达目标系统的路由器?

traceroute(在 Windows 上为tracert)工具将显示到目标系统的网络路径。

  1. 原始套接字用于什么?

原始套接字允许程序员直接指定进入网络数据包的内容。它们比 TCP 和 UDP 套接字提供更底层的访问,可以用来实现额外的协议,如 ICMP。

  1. 哪些工具可以列出您系统上的开放 TCP 套接字?

netstat 工具可以用来显示您本地系统上的开放连接。

  1. 网络 C 程序的安全方面最大的担忧是什么?

当用 C 语言编写网络应用程序时,必须特别注意内存安全。即使是小小的错误也可能让攻击者破坏您的程序。

第十三章,套接字编程技巧和陷阱

  1. 检测到网络错误时,终止程序是否总是可以接受的?

是的。对于一些在错误情况下终止的应用程序来说,这是正确的做法。对于更复杂的应用程序,可能需要重试并继续的能力。

  1. 用于将错误代码转换为文本描述的系统函数有哪些?

您可以在 Windows 上使用FormatMessage(),在其他平台上使用strerror()来获取错误消息。

  1. 在 TCP 套接字上调用connect()完成需要多长时间?

在 TCP 三次握手完成期间,对connect()的调用通常会阻塞至少一个网络往返时间。

  1. 如果您在断开的 TCP 套接字上调用send()会发生什么?

在基于 Unix 的系统上,您的程序可以接收一个SIGPIPE信号。计划这一点很重要。否则,send()返回-1

  1. 如何确保下一次调用send()不会阻塞?

要么使用select()确保套接字准备好接收更多数据,要么使用非阻塞套接字。

  1. 如果 TCP 连接的两端都试图同时发送大量数据会发生什么?

如果 TCP 连接的两端都在调用send(),但没有调用recv(),那么它们可能会陷入死锁状态。在调用send()之间穿插调用recv()是很重要的。使用select()可以帮助你的程序了解下一步该做什么。

  1. 通过禁用 Nagle 算法能否提高应用程序的性能?

这取决于你的应用程序正在做什么。对于使用 TCP 的实时应用程序,禁用 Nagle 算法通常是在降低延迟的同时牺牲带宽效率的良好权衡。对于其他应用程序,禁用它可能会降低吞吐量,增加网络拥塞,甚至增加延迟。

  1. select()可以处理多少个连接?

这取决于你的平台。它由FD_SETSIZE宏定义,在 Windows 上可以轻松增加,但在其他平台上则不行。通常,上限大约是 1,024 个套接字。

第十四章,物联网的 Web 编程

  1. 使用 Wi-Fi 连接有哪些缺点?

Wi-Fi 对于最终用户设置来说可能很困难。它也不在所有地方都可用。

  1. 使用以太网连接有哪些缺点?

许多设备没有被用于已经布线的区域。

  1. 使用蜂窝连接有哪些缺点?

蜂窝连接成本高昂。与其他方法相比,它也可能具有更高的延迟和更大的电力需求。

  1. 使用带嵌入式 Linux 的单板计算机有哪些优点?有哪些缺点?

能够访问完整的操作系统,如 Linux,可以简化软件开发。然而,单板计算机SBCs)相对较贵,与微控制器相比,提供的板级连接选项和外围设备较少。它们还需要相对较多的电力。

  1. 在物联网设备中使用微控制器有哪些优点?

许多物联网设备无论如何都需要使用微控制器来提供其基本功能。微控制器价格低廉,提供广泛的外围设备,能够满足实时性能约束,并且可以在非常少的电力下运行。

  1. 在物联网设备通信中,使用 HTTPS 是否总是合适的?

HTTPS 对于大多数应用来说是一种不错的物联网通信安全方式;然而,它有大量的处理和带宽开销。每个应用都是独特的,所使用的安全方案应根据你的具体需求来选择。

第十六章:在 Windows 上设置您的 C 编译器

微软 Windows 是最受欢迎的桌面操作系统之一。

在开始之前,我强烈建议您从www.7-zip.org安装7-Zip。7-Zip 将允许您提取库源代码分发的各种压缩归档格式。

让我们继续,并在 Windows 10 上设置 MinGW、OpenSSL 和libssh

安装 MinGW GCC

MinGW 是将 GCC 移植到 Windows 的版本。这是我们推荐的用于本书的编译器。

您可以从www.mingw.org/获取 MinGW。在该页面上找到下载链接,下载并运行MinGW 安装管理器mingw-get)。

MinGW 安装管理器是一个用于安装 MinGW 的 GUI 工具。以下截图显示了它:

图片

点击安装。然后,点击继续。等待一些文件下载,然后再次点击继续。

在这一点上,该工具将向您提供一个可以安装的包列表。您需要为安装标记 mingw32-base-bin、msys-base-bin 和 mingw32-gcc-g++-bin。以下截图显示了这一点:

图片

您还希望选择 mingw32-libz-dev 包。它在 MinGW 库部分列出。以下截图显示了此选择:

图片

我们选择的g++libz包是稍后构建libssh所必需的。

当你准备好继续时,从菜单中选择安装并选择应用更改。

一个新的对话框将显示要进行的更改。以下截图显示了此对话框可能的外观:

图片

点击应用按钮以下载和安装包。安装完成后,您可以关闭 MinGW 安装管理器。

为了能够轻松地从命令行使用 MinGW,您需要将 MinGW 添加到您的PATH

将 MinGW 添加到您的PATH的步骤如下:

  1. 打开系统控制面板(Windows 键 + 暂停/中断)。

  2. 选择高级系统设置:

图片

  1. 从系统属性窗口,导航到高级选项卡并点击环境变量...按钮:

图片

  1. 从这个屏幕上,在系统变量下找到PATH变量。选择它并按编辑...

  2. 点击新建并输入 MinGW 路径—C:\mingw\bin,如下截图所示:

图片

  1. 点击确定以保存您的更改。

一旦 MinGW 已添加到您的PATH,您就可以打开一个新的命令窗口并输入gcc --version以确保gcc已正确安装。以下截图显示了这一点:

图片

安装 Git

您需要安装版本控制软件git来下载本书的代码。

git可以从git-scm.com/download获取。提供了一个便捷的基于 GUI 的安装程序,您应该不会遇到任何问题。在安装时,请确保选中将git添加到您的PATH的选项。以下截图显示了这一步骤:

图片

git安装完成后,您可以通过打开一个新的命令窗口并输入git --version来测试它:

图片

安装 OpenSSL

在 Windows 上启动 OpenSSL 库可能会有些棘手。

如果您有勇气,可以直接从www.openssl.org/source/获取 OpenSSL 库的源代码。当然,在使用之前,您需要构建 OpenSSL。构建 OpenSSL 并不容易,但 OpenSSL 源代码中包含的INSTALLNOTES.WIN文件提供了说明。

一个更简单的替代方案是安装预构建的 OpenSSL 二进制文件。您可以从 OpenSSL 维基百科wiki.openssl.org/index.php/Binaries中找到预构建的 OpenSSL 二进制文件列表。您需要找到与您的操作系统和编译器匹配的二进制文件。安装它们只需将相关文件复制到 MinGW 的includelibbin目录即可。

以下截图显示了一个二进制 OpenSSL 发行版。应将includelib文件夹复制到c:\mingw\并合并到现有文件夹中,而openssl.exe和两个 DLL 文件需要放置在c:\mingw\bin\

图片

您可以尝试从第九章 使用 HTTPS 和 OpenSSL 加载安全网页 中构建openssl_version.c来测试是否已正确安装了所有内容。它应该看起来像以下这样:

图片

安装 libssh

您可以从www.libssh.org/获取最新的libssh库。如果您擅长安装 C 库,不妨试试。否则,请继续阅读逐步说明。

在开始之前,请确保您已成功安装了 OpenSSL 库。这些是libssh库所必需的。

为了构建libssh,我们需要安装 CMake。您可以从cmake.org/获取 CMake。他们提供了一个不错的 GUI 安装程序,您不应该遇到任何困难。确保在安装过程中选择将 CMake 添加到您的PATH选项:

图片

一旦您安装了 CMake 工具和 OpenSSL 库,导航到libssh网站下载libssh源代码。在撰写本文时,版本 0.8.7 是最新的,可以从www.libssh.org/files/0.8/获取。下载并解压libssh源代码。

请查看附带的INSTALL文件。

现在,在libssh源代码目录中打开一个命令窗口。使用以下命令创建一个新的build文件夹:

mkdir build cd build

保持此命令窗口打开。我们将在一会儿进行构建。

从开始菜单或桌面快捷方式启动 CMake 3.14.3(cmake-gui)。

您需要使用“浏览源...”和“浏览构建...”按钮设置源代码和构建位置。以下截图显示了这一过程:

图片

然后,点击“配置”。

在下一屏幕上,选择 MinGW Makefiles 作为此项目的生成器。点击“完成”。

图片

处理可能需要一点时间。

在配置选项中,进行以下更改:

  1. 取消选择 WITH_NACL

  2. 取消选择 WITH_GSSAPI

  3. CMAKE_INSTALL_PREFIX更改为c:\mingw

然后,再次点击“配置”。这可能需要一点时间。如果一切正常,点击“生成”。

现在,您应该能够构建libssh

返回构建目录中的命令窗口。使用以下命令完成构建:

mingw32-make

构建完成后,使用以下命令将文件复制到您的 MinGW 安装中:

mingw32-make install

您可以尝试从第十一章“使用 libssh 建立 SSH 连接”构建ssh_version.c,以测试是否所有安装都正确。它应该看起来像以下这样:

图片

替代方案

在本书中,我们尽可能推荐免费软件。这对于用户自由度很重要,这也是我们全书推荐 GCC 的一个原因。

此外,除了 MinGW GCC,Clang C 编译器也是开源且质量上乘的。本书中的代码也经过测试,在 Windows 上使用 Clang 成功运行。

GCC 和 Clang 等命令行工具通常更容易集成到大型项目所需的复杂工作流程中。这些开源工具还提供了比微软编译器更好的标准合规性。

话虽如此,本书中的代码也可以与微软的编译器一起使用。代码在 Microsoft Visual Studio 2015 和 Microsoft Visual Studio 2017 上进行了测试。

第十七章:在 Linux 上设置您的 C 编译器

Linux是 C 编程的一个优秀选择。它在本书涵盖的三个操作系统中最容易设置,并且对 C 编程的支持最好。

使用 Linux 也允许您走道德的高尚之路,并因支持免费软件而感到良好。

在描述 Linux 的设置时有一个问题,那就是有众多不同的 Linux 发行版,它们有不同的软件。在本附录中,我们将提供在apt包管理器使用的系统上设置所需的命令,例如Debian LinuxUbuntu Linux。如果您使用的是不同的 Linux 发行版,您需要找到与您的系统相关的命令。请参考您发行版的文档以获取帮助。

在深入之前,花点时间确保您的包列表是最新的。这可以通过以下命令完成:

sudo apt-get update

随着apt就绪,设置变得简单。让我们开始吧。

安装 GCC

第一步是安装 C 编译器gcc

假设您的系统使用apt作为其包管理器,尝试以下命令安装gcc并为 C 编程准备您的系统:

sudo apt-get install build-essential

一旦install命令完成,您应该能够运行以下命令来查找已安装的gcc版本:

gcc --version

安装 Git

您需要安装 Git 版本控制软件来下载这本书的代码。

假设您的系统使用apt包管理器,您可以使用以下命令安装 Git:

sudo apt-get install git

使用以下命令检查 Git 是否已成功安装:

git --version

安装 OpenSSL

OpenSSL可能有些棘手。您可以使用以下命令尝试您发行版的包管理器:

sudo apt-get install openssl libssl-dev

问题在于您的发行版可能有一个旧的 OpenSSL 版本。如果是这样,您应该直接从www.openssl.org/source/获取 OpenSSL 库。当然,在可以使用之前,您需要构建 OpenSSL。构建 OpenSSL 并不容易,但INSTALL文件中提供了构建说明。请注意,其构建系统要求您已安装Perl

安装 libssh

您可以尝试使用以下命令通过您的包管理器安装libssh

sudo apt-get install libssh-dev

问题在于这本书中的代码与较旧的libssh版本不兼容。因此,我建议您自己构建libssh

您可以从www.libssh.org/获取最新的libssh库。如果您擅长安装 C 库,请随意尝试。否则,请继续阅读逐步说明。

在开始之前,请确保您已成功安装了 OpenSSL 库。这些库是libssh库所必需的。

我们还需要安装 CMake 来构建libssh。您可以从cmake.org/获取 CMake。您也可以使用以下命令从您的发行版的打包工具中获取它:

sudo apt-get install cmake

最后,libssh 也需要 zlib 库。您可以使用以下命令安装 zlib 库:

sudo apt-get install zlib1g-dev

一旦安装了 CMake、zlib 库和 OpenSSL 库,请从 www.libssh.org/ 网站找到您想要的 libssh 版本。撰写本文时,0.8.7 是最新版本。您可以使用以下命令下载并解压 libssh 源代码:

 wget https://www.libssh.org/files/0.8/libssh-0.8.7.tar.xz
 tar xvf libssh-0.8.7.tar.xz
 cd libssh-0.8.7

我建议您查看 libssh 包含的安装说明。您可以使用 less 命令查看它们。按 Q 键退出 less

less INSTALL

一旦您熟悉了构建说明,您可以使用以下命令尝试构建 libssh

mkdir build
cd build
cmake ..
make

最后一步是使用以下命令安装库:

sudo make install

第十八章:在 macOS 上设置您的 C 编译器

macOS 可以是一个不错的 C 程序员开发环境。让我们开始吧。

安装 Homebrew 和 C 编译器

如果我们使用 Homebrew 软件包管理器,macOS 的设置将大大简化。

Homebrew 软件包管理器使得安装 C 编译器和开发库比其他方式要容易得多。

要安装 Homebrew,导航您的网络浏览器到 brew.sh/

网站提供了安装 Homebrew 的命令。您需要打开一个新的终端窗口并粘贴该命令:

只需按照说明操作,直到 Homebrew 安装完成。

安装 Homebrew 也会导致 Xcode 命令行工具一起安装。这意味着您将有一个准备好的 C 编译器。使用 gcc --version 命令测试您是否有一个工作的 C 编译器:

注意,macOS 安装了 Clang 编译器,但将其别名为 GCC。无论如何,它将适用于我们的目的。

Git 也应该已经安装。您可以使用 git --version 来验证:

安装 OpenSSL

假设您已经安装了 Homebrew,安装 OpenSSL 非常简单。

打开一个新的终端窗口并使用以下命令安装 OpenSSL 库:

brew install openssl@1.1

在撰写本文时,默认的 Homebrew openssl 软件包已过时,因此我们将使用 openssl@1.1 软件包。

以下屏幕截图显示了 Homebrew 安装 openssl@1.1 软件包:

一定要阅读 brew 的任何输出。

brew 命令建议的方法要求您传递 -L-I 到编译器,以告诉它 OpenSSL 库的位置。这很麻烦。

您可能更喜欢将已安装的库文件符号链接到 /usr/local 路径,以便您的编译器可以自动找到它们。是否要这样做取决于您。

如果您想创建符号链接以便编译器可以找到 OpenSSL,请尝试以下命令:

cd /usr/local/include
ln -s ../opt/openssl@1.1/include/openssl .
cd /usr/local/lib
for i in ../opt/openssl@1.1/lib/lib*; do ln -vs $i .; done

这在下面的屏幕截图中显示:

您可以尝试从第九章 加载带有 HTTPS 和 OpenSSL 的安全网页构建 openssl_version.c,以测试是否正确安装了所有内容。它应该看起来像以下这样:

安装 libssh

安装了 Homebrew 后,安装 libssh 也非常简单。

打开一个新的终端窗口并使用以下命令安装 OpenSSL 库:

brew install libssh

这在下面的屏幕截图中显示:

这次,我们不需要处理任何其他选项。您可以直接开始使用 libssh

您可以从第十一章,使用 libssh 建立 SSH 连接,尝试构建 ssh_version.c 来测试是否已正确安装了所有内容。它应该看起来像以下这样:

这就完成了 macOS 的设置。

第十九章:示例程序

本书代码库位于github.com/codeplea/hands-on-network-programming-with-c,包含 44 个示例程序。这些程序在本书中均有详细解释。

代码许可证

本书代码库中提供的示例程序均采用 MIT 许可证发布,许可证文本如下:

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to
whom the Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

本书包含的代码

以下是本书按章节划分的 44 个示例程序列表。

第一章:– 网络和协议简介

本章包含以下示例程序:

  • win_init.c: 初始化 Winsock 的示例代码(仅适用于 Windows)

  • win_list.c: 列出所有本地 IP 地址(仅适用于 Windows)

  • unix_list.c: 列出所有本地 IP 地址(仅适用于 Linux 和 macOS)

第二章:– 掌握套接字 API

本章包含以下示例程序:

  • sock_init.c: 包含所有必要的头文件并初始化的示例程序

  • time_console.c: 将当前日期和时间打印到控制台

  • time_server.c: 提供显示当前日期和时间的网页服务

  • time_server_ipv6.c: 与前面的代码相同,但监听 IPv6 连接

  • time_server_dual.c: 与前面的代码相同,但监听 IPv6/IPv4 双栈连接

第三章:– TCP 连接的深入概述

本章包含以下示例程序:

  • tcp_client.c: 建立 TCP 连接并从控制台发送/接收数据。

  • tcp_serve_toupper.c: 使用select()处理多个连接的 TCP 服务器。将接收到的数据全部转换为大写后回显给客户端。

  • tcp_serve_toupper_fork.c: 与前面的代码相同,但使用fork()代替select()。仅适用于 Linux 和 macOS。

  • tcp_serve_chat.c: 一个将接收到的数据转发给所有其他已连接客户端的 TCP 服务器。

第四章:– 建立 UDP 连接

本章包含以下示例程序:

  • udp_client.c: 从控制台发送/接收 UDP 数据

  • udp_recvfrom.c: 使用recvfrom()接收一个 UDP 数据报

  • udp_sendto.c: 使用sendto()发送一个 UDP 数据报

  • udp_serve_toupper.c: 监听 UDP 数据并将其全部转换为大写后回显给发送者

  • udp_serve_toupper_simple.c: 与前面的代码相同,但不使用select()

第五章:– 主机名解析和 DNS

本章包含以下示例程序:

  • lookup.c: 使用getaddrinfo()查找给定主机名的地址

  • dns_query.c: 编码并发送 UDP DNS 查询,然后监听并解码响应

第六章:– 构建简单的 Web 客户端

本章包含以下示例程序:

  • web_get.c: 一个从给定 URL 下载网络资源的最小化 HTTP 客户端

第七章:– 构建简单的 Web 服务器

本章包含以下示例程序:

  • web_server.c: 能够提供静态网站服务的最小化 Web 服务器

  • web_server2.c: 一个最小化 Web 服务器(无全局变量)

第八章:– 使你的程序发送电子邮件

本章包括以下示例程序:

  • smtp_send.c: 一个简单的发送电子邮件的程序

第九章:– 使用 HTTPS 和 OpenSSL 加载安全 Web 页面

本章的示例使用 OpenSSL。编译时请确保链接到 OpenSSL 库(-lssl -lcrypto):

  • openssl_version.c: 一个报告已安装 OpenSSL 版本的程序

  • https_simple.c: 一个使用 HTTPS 请求网页的最小程序

  • https_get.c: 第六章的 HTTP 客户端,构建简单的 Web 客户端,修改为使用 HTTPS

  • tls_client.c: 第三章的TCP 连接深入概述中的 TCP 客户端程序,修改为使用 TLS

  • tls_get_cert.c: 从 TLS 服务器打印证书

第十章:– 实现安全 Web 服务器

本章的示例使用 OpenSSL。编译时请确保链接到 OpenSSL 库(-lssl -lcrypto):

  • tls_time_server.c: 第二章的掌握套接字 API中的时间服务器,修改为使用 HTTPS

  • https_server.c: 第七章的构建简单的 Web 服务器中的 Web 服务器,修改为使用 HTTPS

第十一章:– 使用 libssh 建立 SSH 连接

本章的示例使用libssh。编译时请确保链接到libssh库(-lssh):

  • ssh_version.c: 一个报告libssh版本的程序

  • ssh_connect.c: 一个建立 SSH 连接的最小客户端

  • ssh_auth.c: 一个尝试使用密码进行 SSH 客户端认证的客户端

  • ssh_command.c: 一个通过 SSH 执行单个远程命令的客户端

  • ssh_download.c: 一个通过 SSH/SCP 下载文件的客户端

第十二章:– 网络监控和安全

本章不包含任何示例程序。

第十三章:– 套接字编程技巧和陷阱

本章包括以下示例程序:

  • connect_timeout.c: 展示如何提前超时connect()调用。

  • connect_blocking.c: 用于与connect_timeout.c进行比较。

  • server_reuse.c: 展示SO_REUSEADDR的使用。

  • server_noreuse.c: 用于与server_reuse.c进行比较。

  • server_crash.c: 这个服务器在客户端断开连接后故意写入 TCP 套接字。

  • error_text.c: 展示如何获取错误代码描述。

  • big_send.c: 连接后发送大量数据的 TCP 客户端。用于展示send()的阻塞行为。

  • server_ignore.c: 一个接受连接然后简单地忽略它们的 TCP 服务器。用于展示send()的阻塞行为。

  • setsize.c: 展示select()可以处理的套接字最大数量。

第十四章:– 物联网的 Web 编程

本章不包含任何示例程序。

posted @ 2025-10-07 17:57  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报