libvirtd
) 和一个控制台工具(virsh
)。本文演示了如何在 LibVirt 中新加一个 API,并且在 libvirtd
和 virsh
中使用新的 API 完成新的功能。为了方便说明,在文章的示例中只演示了添加一个 API,如果要看完整的示例,可以查看项目 Arondight/libvirt-add-new-api-demo,这是一个相对完整的示例,项目中新 API 的说明以及 Patch 的使用可以参见其中的 README.txt
。
首先你需要有一套可以编译的 LibVirt 源码,在本文的示例中我们使用了 v2.5.0
版本的源码,你可以通过以下指令来得到它。
1 | git clone https://github.com/libvirt/libvirt.git |
LibVirt 的编译需要Gnulib 的源码,不过因为网络的原因在墙内其 Git 仓库很难获取,所以这里使用 GitHub 上的镜像仓库,并通过环境变量引入。你可以设置好这一切并编译一遍源码。在上面的指令执行成功后执行。
1 | git clone https://github.com/coreutils/gnulib.git |
如果你的编译依赖完备的话,LibVirt 可以正确编译并通过测试。如果你没有得到预期的结果,请检查你的编译环境并安装缺失的软件包。
示例中我们添加的 API 为 virConnectGetMagicFileContent
,功能为获取运行虚拟化的机器上某个文件内容的最多前 32 个字节。
首先要做的是为 LibVirt 添加公共 API,这个 API 也是 LibVirt 为用户展现的 API。此后通过一连串调用,我们会在 libvirtd
和 virsh
中通过调用这个公共 API 来完成新功能。这里需要修改的文件有如下几个。
include/libvirt/libvirt-*.h
: 这里需要完成公共 API 的声明,此后通过包含头文件 include/libvirt/libvirt.h
可调用此 API。src/libvirt_public.syms
: 这里需要将新 API 导出为全局符号,这样公共 API 得以允许被其他函数访问,如果你在步骤 [1]
中定义了一个需要被其他函数访问的数据结构,同样你也需要将它导出为全局符号。src/libvirt-*.c
: 这里需要实现步骤 [1]
中声明的 API,一般来说这里只调用驱动提供的 API 即可,具体功能需要在每个 hypervisor 的驱动中单独实现。首先要说明的是,公共 API 必须要有合乎规范的注释。在编译时,docs/apibuild.py
会检查宏和公共 API 的注释是否符合要求,如果发现不合格的注释,将中断整个编译过程。注释在声明和定义处皆可。
对于一个宏,注释的格式如下。
1 | /** |
对于一个 API,注释的格式如下。
1 | /** |
注意:API 注释中的单词
Returns
标明了这是返回值的注释,不能随意修改。
目录 include/libvirt
下有众多以 libvirt-
开头的头文件,公共 API 分散在其中。因为新的 API 返回在运行虚拟化的主机上某个文件的某段内容,所以我们在头文件 include/libvirt/libvirt-host.h
声明这个 API。
1 | diff --git a/include/libvirt/libvirt-host.h b/include/libvirt/libvirt-host.h |
这个 Patch 做的事情非常简单:定义了三个以后会用到的宏,并且声明了公共 API。因为这个功能需要访问远程主机上的文件,所以公共 API 需要一个参数 virConnectPtr
,通过这个指针我们可以调用具体的 remote 或 hypervisor 驱动(前者用于远程调用,后者是真正操纵虚拟化的驱动,例如 QEMU 驱动)。
除了这个文件以外,还需要将公共 API 在 src/libvirt_public.syms
中导出。
1 | diff --git a/src/libvirt_public.syms b/src/libvirt_public.syms |
完成这一步工作之后,新的公共 API 就可以被其他的函数所调用。
对应头文件 include/libvirt/libvirt-host.h
,我们需要在文件 src/libvirt-host.c
中实现新 API。
1 | diff --git a/src/libvirt-host.c b/src/libvirt-host.c |
在这个 Patch 里我们虽然实现了公共 API,但是没有在其中做具体的操作,而是根据参数 conn
调用了驱动 connectGetMagicFileContent
,具体的工作将由该驱动完成。现在我们无法直接判断该驱动是一个 reomte 驱动还是 hypervisor 驱动,通常来说如果你正在使用一个运行 libvirtd
的远程主机,那么此处将是一个 remote 驱动,否则将会直接调用 hypervisor 驱动。
到现在为止,假设我们使用
virsh get-magic
在标准输出上打印出文件的内容时,函数的调用链如下(假设直接调用 hypervisor 驱动)。以后每一部分的工作结束后,我们都将重新整理这个调用链以方便理清我们都做了什么。??? ->
virConnectGetMagicFileContent
@LibVirt ->connectGetMagicFileContent
@hypervisor -> ???
LibVirt 可用的 hypervisor 有很多,这里我们只为最常用的 QEMU 编写驱动。
因为 LibVirt 在用户层面上提供了统一的 API,而这个公共 API 调用了一个确定的驱动 API。因此我们需要在 src/driver-hypervisor.h
中确定这个 API 以提供给公共 API 调用。后面我们会用到几个结构体变量将这个统一的驱动 API 和具体的 hypervisor 驱动函数关联起来,然后在 hypervisor 驱动中具体的实现它,从而提供无关虚拟化细节的 API。
1 | diff --git a/src/driver-hypervisor.h b/src/driver-hypervisor.h |
这里我们声明了一个 virDrvConnectGetMagicFileContent
类型的函数指针变量,并添加到了结构体类型 _virHypervisorDriver
的声明当中,下面在 QEMU 驱动中我们会将这个函数指针指向具体的驱动函数。从而完成 LibVirt API 到 QEMU 驱动函数的调用。
现在我们只需实现 QEMU 的驱动函数,并在结构体变量 qemuHypervisorDriver
中用新的驱动函数为上一节新加的函数指针赋值即可。这样虽然各个 hypervisor 的驱动细节各不相同,但是在 LibVirt 上却表现为一致的接口,从而为用于隐藏了具体的虚拟化细节。
注意通常来说驱动具体的功能并不在此实现,而是在 qemu/qemu_capabilities.h
中提供一个 QEMU 驱动内可见的 API,并在 qemu/qemu_capabilities.c
中通过一系列函数调用完成驱动的具体功能。
1 | diff --git a/src/qemu/qemu_driver.c b/src/qemu/qemu_driver.c |
这里用到一个权限检查函数 virConnectGetMagicFileContentEnsureACL
,目前为止我们还没见过它,而它将在我们编写 remote 驱动时由 src/rpc/gendispatch.pl
生成。
现在我们可以在 src/qemu/qemu_capabilities.c
中实现 QEMU 驱动具体的功能并在 src/qemu/qemu_capabilities.c
中对内部提供一个接口了。这个接口要在 src/qemu/qemu_capabilities.h
中声明以便被 QEMU 驱动使用。
1 | diff --git a/src/qemu/qemu_capabilities.c b/src/qemu/qemu_capabilities.c |
这一部分结束后,直接实现功能的那一部分代码就已经完成了。
现在调用链如下。
??? ->
virConnectGetMagicFileContent
@LibVirt ->remoteConnectGetMagicFileContent
@remote ->qemuConnectGetMagicFileContent
@QEMU ->virQEMUCapsGetMagicFileContent
@QEMU
remote 协议由两台主机的 LibVirt 交换信息所用,当 LibVirt 连接到远程主机时(例如 virsh -c
),之前实现的公共 API 中通过 conn->driver
结构体变量调用的函数会由 remote 驱动处理。本机的 LibVirt 将会请求远程的 LibVirt 执行公共 API,进而执行远程主机具体的 hypervisor 驱动,然后得到返回的数据。既然有信息交换,就必须定义协议。
协议的定义涉及到几个文件,其中需要手动修改的文件如下。
src/remote/remote_driver.c
: 定义了客户端的 remote 驱动处理函数。src/remote/remote_protocol.x
: 协议格式。src/remote_protocol-structs
: 协议格式。以上文件的前两个会被脚本 src/rpc/gendispatch.pl
处理,进而生成以下四个文件。
src/remote/remote_client_bodies.h
: 实现了 remote 驱动客户端 API。daemon/remote_dispatch.h
: 实现了 remote 驱动服务器端 API。src/access/viraccessapicheck.h
:声明了 API 权限检查函数。src/access/viraccessapicheck.c
:实现了 API 权限检查函数。remote 驱动的函数体就实现在前两个头文件中,客户端的 API 经过一系列 API 调用,最终由函数 virNetClientProgramCall
完成信息的交互,其中两个类型为 void *
的参数保存了传递给服务器端 remote 驱动的参数和服务器端返回的数据,这两个参数的类型由两个类型为 xdrproc_t
的参数确定。
在 src/remote/remote_driver.c
中,我们只要简单的修改结构体变量 hypervisor_driver
即可。
1 | diff --git a/src/remote/remote_driver.c b/src/remote/remote_driver.c |
这里我们只是简单的为结构体变量增加了一个元素,这个元素的类型为函数指针 virDrvConnectGetMagicFileContent
,在定义内部 API 时添加到了类型 struct _virHypervisorDriver
的声明当中,值为 remoteConnectGetMagicFileContent
,这是 src/rpc/gendispatch.pl
输出到 src/remote/remote_client_bodies.h
中的函数名。
根据之前说的数据交换方式,我们这里需要定义具体的类型给函数 virNetClientProgramCall
的两个 xdrproc_t
的参数使用。这里针对每个 API 需要定义两个结构体,其名字可以参考其他的结构体和对应的 API。后跟 _args
的结构体为 API 的参数,_ret
的则为返回值,virNetClientProgramCall
会将两个 void *
类型的参数分别解释为两个结构体类型,并通过这两个参数完成和远程主机的交互。如果 remote 驱动不需要参数,那么可以省略以 _args
结尾的结构体。
假设这里我们定义了如下两个结构体。
1 | struct remote_connect_abadcafe_args { |
那么它会在文件 src/remote/remote_client_bodies.h
中生成类似下面的函数。
1 | static int |
除此之外,还需要阅读文件 src/remote/remote_protocol.x
第 403-426 行的注释,特别是 insert@offset
相关的说明,你可能会需要它们的。
文件 src/remote/remote_protocol.x
的 Patch 如下。
1 | diff --git a/src/remote/remote_protocol.x b/src/remote/remote_protocol.x |
除了之前提到的结构体之外,我们还修改了枚举类型 remote_procedure
,关于这个类型的具体修改请参阅文件 src/remote/remote_protocol.x
第 3355-3398 行的详尽注释。
根据设置的参数和返回值结构体,在编译过程中,以下函数会生成。
remoteConnectGetMagicFileContent
: remote 驱动客户端 API,位于文件 src/remote/remote_client_bodies.h
。spatchConnectGetMagicFileContent
: remote 驱动服务器端 API,位于文件 daemon/remote_dispatch.h
。virConnectGetMagicFileContentEnsureACL
:API 权限检查函数,位于文件 src/access/viraccessapicheck.c
(所以请仔细阅读关于 @acl
的注释)。在上面两个步骤做完之后,只需要更新一下 src/remote_protocol-structs
即可。
1 | diff --git a/src/remote_protocol-structs b/src/remote_protocol-structs |
现在调用链如下,因为现在增加了客户端和服务端的概念,所以通过在其后增加
@client
或@server
区分。??? ->
virConnectGetMagicFileContent
@LibVirt@client ->remoteConnectGetMagicFileContent
@remote@client ->remoteDispatchConnectGetMagicFileContent
@remote@server ->virConnectGetMagicFileContent
@LibVirt@server ->qemuConnectGetMagicFileContent
@QEMU@server ->virQEMUCapsGetMagicFileContent
@QEMU@server
最后要做的就是在 virsh
中添加一个命令行选项,完成之前实现的公共 API 的调用,并且将 API 返回的数据打印到屏幕上。
你需要修改 tools/virsh-*.c
以接受新的命令行选项。对于一个新的参数,你需要在 hostAndHypervisorCmds
结构体数组中添加新的元素,并根据这个结构体中元素的值来定义两个结构体数组,类型分别为 vshCmdInfo
和 vshCmdOptDef
,分别用来确定新选项的说明和参数。
针对我们实现公共 API 的位置,这里我们在 tools/virsh-host.c
中添加新的选项。
1 | diff --git a/tools/virsh-host.c b/tools/virsh-host.c |
最后修改一下 tools/virsh.pod
,这个文件将会被 pod2man
处理成 virsh(1)
的手册。POD 是源于Perl 的简单易用的标记语言,可以通过 perldoc perlpod
来查看其语法的更多说明。
1 | diff --git a/tools/virsh.pod b/tools/virsh.pod |
到现在已经完成了包括文档在内的所有工作,如果你要为 LibVirt 添加一个新的功能,所需要做的大约就是这么多。
最终的调用链如下。
cmdGetMagic
@virsh@client ->virConnectGetMagicFileContent
@LibVirt@client ->remoteConnectGetMagicFileContent
@remote@client ->remoteDispatchConnectGetMagicFileContent
@remote@server ->virConnectGetMagicFileContent
@LibVirt@server ->qemuConnectGetMagicFileContent
@QEMU@server ->virQEMUCapsGetMagicFileContent
@QEMU@server
现在我们已经完成了最后一步,可以最后编译一次源码并测试一下功能。
1 | make -j8 |
如果编译无误的话,在一个新的终端里运行 daemon/libvirtd
。
1 | sudo ./run ./daemon/libvirtd |
然后看一看新添加的 API 是否工作正常。
1 | echo 'Hello World!' | sudo tee /var/run/libvirt/magic_file |
如果一切顺利,现在你已经在终端里看到了刚才写入到文件的 Hello World!
:)
GTK 是一个基于事件驱动的框架,就是说 GTK 程序会一直循环在 gtk_main
函数中,直至一个事件发生,然后跳转到对应的事件处理函数中,执行完毕后再次回到 gtk_main
的循环。这听起来很符合逻辑,但是 GTK 中除了事件外,还有一个概念是信号,特别是当你写几个 GTK 程序后就会发现你处理的几乎都是信号。
典型的例如。
1 | g_signal_connect (G_OBJECT (mainWindow), "delete_event", G_CALLBACK (gtk_main_quit), NULL); |
那么 "delete_event"
到底是事件还是信号——它被用于 g_signal_connect
中,但是名字带有 event
字样。
在 GTK 中,事件是 X11 中发生的,GTK 通过 GDK 将 X11 中的 XEvent
转化为 GdkEvent
,其类型 GdkEventType
定义在头文件 gdk/gdkevents.h
中。
GDK 是 Xlib 的一个封装。
而信号与事件不同,是 GTK 本身的概念。在 GTK 中,一个事件发生之后,会通过函数 gtk_widget_event
将事件转化为信号,并通过函数 g_signal_emit
将信号发射出去,如果有回调和该信号绑定,那么这个回调有可能被执行。
举例来说,当 GtkButton
上发生了鼠标点击的动作时,默认地事件和信号的顺序如下。
GDK_BUTTON_PRESS
事件产生 -> 调用 GDK 中针对该事件的回调"button_press_event"
信号发射 -> 调用 GTK 中针对该信号的回调"clicked"
信号发射 -> 调用 GTK 中针对该信号的回调那么 "delete_event"
到底是事件还是信号?它是一个信号,但是只有在事件 GDK_DELETE
发生后才会被发射出去,所以它也代表一个事件。
这也是 GTK 一个稍微有点混乱的地方——事件的发生是通过信号的发射反应的。所以你如果想在 GTK 中处理事件,你需要处理信号。
和 Qt 的信号-槽机制不同,GTK 中采用回调机制来处理信号。GTK 中为信号绑定回调的方式都通过同一个函数 g_signal_connect_data
完成,其原形定义在头文件 gobject/gsignal.h
。
1 | gulong g_signal_connect_data (gpointer instance, const gchar *detailed_signal, |
因为信号处理在 Glib 而非 GTK 中,所以函数名以
g_
而非gtk_
开头。
但是更加常用的是在其上封装的三个宏,与 g_signal_connect_data
定义在同一个头文件中。
1 |
其中。
g_signal_connect
: 为信号绑定一个回调函数,该回调将先于默认回调执行。g_signal_connect_after
: 和 g_singal_connect
类似,但是该回调将在默认回调之后执行。g_signal_connect_swapped
:回调先于默认回调执行,但是回调的参数位置应该和前两个绑定函数的回调参数位置交换。g_signal_connect_swapped
中 swapped
的效果如下。
1 | void handler (GtkWidget *widget, gpointer data); |
个人觉得
g_signal_connect_swapped
最好少用,它只会把水搅浑。
在 Gtk 中,回调的形式有两种,在反应事件的信号回调中,handler 需要额外增加一个 GdkEvent *
参数,用来传入发生的事件。
这两种回调之间的区别如下(假设使用 g_signal_connect
绑定)。
1 | void buttonClickedHandler (GtkWidget *button, gpointer data); |
除了增加了 GdkEvent *
作为参数外,处理事件的回调函数多了一个 gboolean
类型的返回值,这个返回值用于控制该事件处理过程是否继续。
返回值的情况如下。
返回值 | 含义 |
---|---|
TRUE | 该事件已经处理完毕,不再继续调用其他和该事件绑定的回调 |
FALSE | 需要继续执行其他与该事件绑定的回调函数 |
因为 GTK 先捕获事件再转化为信号,所以直接反应事件的信号在其他信号之前被发射,所以同一个 GtkWidget
上处理事件的回调总在其他信号回调之前被执行。
所以“回调的形式”一节的代码片中,假设 buttonClickedHandler
和 keyPressEventHandler
分别被绑定到一个 GtkButton
的 "event"
和 "clicked"
信号上,如果 keyPressEventHandler
返回 TRUE
,那么buttonClickedHandler
将不会被调用。
说一下最近发生的两件事吧。
第一件事是在 Github 上找到了自己三年前用的一些配置文件,于是晚上本地拉了一个空分支 fetch 下来后 push 了上去。因为整套配置文件都是用软连接分发的,而目录架构又三年多没有变过,所以当想用 Vim 打开里面的配置看看当时写成什么样子的时候,却看到了一个很陌生的界面。因为仓库检出到了三年前的分支,所以我看到了三年前自己使用的 Vim。
三年前我用着这个配置写过什么东西,最后那些事情也构成了现在的我。
第二件事是感觉越来越看不清未来的大致轮廓了,将来会做什么行业,会在哪个城市,一切都变得模糊了起来。很多周围的事物,包括以前认为理所当然的事情都变得不确定。“自己以后肯定会继续写程序吧”,“肯定会选择留在北京吧”,“肯定会在技术上有所发展吧”,然而这些“理所当然”的事情自己越发都觉得难以相信。
较之于爱好和方向,最重要的问题已经变成了如何更好地生活,其余的只是生活的手段,包括曾经自己坚持的东西。
说一下这个行业吧,不只是程序员这一行,而是 IT 这个行业,例如程序员、运维、测试之类的。
大学里我对 IT 的印象,编程几乎占了所有的比重。虽然和有的人说大学想学医却最终学了计算机,其实一半是撒谎,能够写程序也是高中时期我的梦想之一。所以从某些意义上来说,我也算选对了一个大学。所以从这个意义上,我也理所当然的毕业后成了一个程序员。虽然毕业之前很长时间都在踹踹不安,是不是应该考研,万一毕业后生活在社会最底层怎么办,以后应该向哪个领域发展……然而毕业后就发现曾经的不安并没有什么实际的意义,只有去工作才有工资,不管方向是不是很合口,你总得找一份。
第一家公司是一个做手机操作系统的公司,说实话并没写多少代码,基本都是二次开发,晚上经常八点才下班。现在的公司是做电脑操作系统的,编程的工作也不是很多,较之深度反而更加看重广度,好在一般不到六点就走人了。
然而无论如何,就结果而言我并没有像学生时代设想的那样成为一个 100% 的程序员。
也是一半撒谎,有时我会和别人说,有点后悔当初没有按照父母的安排在家乡进事业单位,否则就能过上安安稳稳的生活了,然而至少就现在而言我半点不曾后悔过。毕业后跟我去了同一个大学的两个兄弟留在了家乡,那里物价不贵,房租便宜,生活节奏很慢,晚上从七点开始,冬天六点多下班公交可能就停了。而我只身来了北京,一个晚上从九点开始的城市,物价很贵,房租普遍一个月一千五,房价高的几乎没有留下的可能性。
然而将安稳的生活和四倍的工资相比,我选择了后者,纵使代价是只能在一个远离家乡的地方漂着。
好几次我妈给我打电话,说北京干够了就回家吧。挂上电话想想自己是不是真的能回去呢。IT 是一个很矛盾的行业,相对于传统行业好混一点,但是只有一线城市才有更多的机会。相较于在一个金钱贬值的地方多赚一点,似乎在三线城市有一份收入不高但安稳的工作也挺不错吧。然而无论混的好坏,很少见到一个做 IT 的出来几年就回去的。如果真的回了家乡,那么敲键盘就很难赚到钱了,除了敲键盘,你还有什么吃饭的技能呢。
你只能在一个自我价值贬值的地方才能够提升自身的存在价值,我觉得对于不少人而言做 IT 就是这么个东西,至少对于现在的我而言是这样的。
说一些其他的事情吧。
最近开始变得越来越懒了,下班回来以后在群里扯扯淡,打开哔哩哔哩追追番,然后发呆一会儿,就到了睡觉的时间。然而就在三四个月前的时候还经常晚上在电脑前敲代码很久,那个时期我真的会去认认真真的花上业余时间去学一些东西。当然现在也想学一些东西,例如 Java,例如服务器端开发,例如 NodeJS,有一段时间还曾经想学 Common Lisp,然而这些东西我都没有认真的开始学过。有时候也在想,以后转行该怎么办,总得有一门拿得出手的手艺,所以总得有一个确定而且熟悉的方向。然而又转念一想,以后做什么还都是未知,也没有必要非得这么着急去学精一门技能吧。
我说不好这中间到底有什么改变了,然而相对于学习新的知识,能够生活的轻松一点成了我当下切实的愿望。
最近看了两部很喜欢的动画,一部是《为美好的世界献上祝福!》,另一部是《Re:从零开始的异世界生活》,两部动画都是发生在异世界的故事。在异世界中认识各式各样的人,发生各种各样的故事,因为不同的目的开始旅行,因为不同的目的停止旅行。大概我很喜欢这种浪漫主义的故事,所以这样的作品对我有着致命的诱惑力。然而如果真的说给我一次去异世界的机会,恐怕我不会踏上那段旅程吧。相对于一段全新的生活,现代科技和文明带来的生活保障恐怕重要的多。然而我还是无法抵挡任何“异世界中踏上旅途”的设定,这种作品对我而言可以说是精神鸦片一样的存在。
大约我的生活太缺少这样非日常的元素了。
说一下现在吧。
在学生时代我大约会很喜欢说自己的现在,然而此时此刻却写不出什么东西——现在就那样,没啥好说的。然而无论自己觉得现在的生活是否有意义,我所做的琐事也会一点点构建自身,像开头说的那样,三年后这些琐事也会构成我的一部分吧。
说来也巧,也正是三年前我开始尝试着去看动漫,此后一发不可收拾。当时很喜欢的一部作品是奈绪蘑菇的《空之境界》,这部作品本篇一共三本小说,后来蘑菇又写了《空之境界 未来福音》作为故事的补完。《未来福音》的画廊中讲了式和干也在《矛盾螺旋》之后的新年里去神社做参拜的故事,在矛盾螺旋事件结束后,式和干也在新年参拜中各自许下自己的新年愿望。当面对式“肯定又是类似世界和平之类”的玩笑时,干也让人很舒服的笑着说“嘛,差不多吧”,然后一句画外音让我至今难忘。
]]>惟愿你与围绕你的这个世界,未来也能一直幸福下去。
祈愿中满溢着来自未来的福音。
提 GPG 之前需要提一个软件叫 PGP。PGP 是“Pretty Good Privacy” 的缩写,中文直译为“完美隐私”,名字言简意赅,上来就把软件的用途拍用户脸上。然而 PGP 不是自由软件,所以自由软件基金会决定开发一个替代 PGP 的自由软件,于是有了 GPG(GnuPG)。
GPG 可以提供对信息、文件的签名和验证,或者是加密和解密,主要用于不安全网络上的信息传输。为此 GPG 需要一个密钥环,GPG 使用私钥和公钥分别完成签名和加密,对应地验证和解密由公钥和私钥完成。
然而 GPG 密钥环并不只有一对公钥和私钥,如果称公钥和其对应的私钥为一个密钥对的话,那么一个 GPG 密钥环可以拥有很多个密钥对,每一个密钥对都由一个钥匙号(key ID)标识,被称为钥匙。其中有一个钥匙拥有签名其他钥匙的功能(可以在密钥环中创建钥匙),这个钥匙被称为主钥,其他的钥匙则被称为从钥。
下面列出了我在使用的一个密钥环,首先是公钥。
1 | $ gpg --list-keys |
然后是私钥。
1 | $ gpg --list-secret-keys |
GPG 列出的每个密钥环第一行一定是主钥,其余的则为从钥,可以看到上面的密钥环中只有一个主钥和一个从钥。每个密钥后面有许多信息描述它的属性,例如 sec rsa4096/B66CC194 2016-04-15 [SC]
代表这是一个主钥的私钥,加密算法为 rsa,长度 4096 位,主钥的钥匙号为 B66CC194
,创建于 2016 年 4 月 15 日,功能为 SC
。
不难看出一个 GPG 密钥环一共有四种类型的密钥,如果按照上面指令的样例输出来看的话则如下表。
属性 | 代表 | 含义 |
---|---|---|
sec | SECret key | 主钥的私钥 |
pub | PUBlic key | 主钥的公钥 |
ssb | Secret SuBkey | 从钥的私钥 |
sub | public SUBkey | 从钥的公钥 |
至于这些钥匙的作用可以查看它们的功能,常用的功能有三种。
功能 | 代表 | 含义 |
---|---|---|
S | Signing | 签名和验证信息 |
E | Encryption | 加密和解密信息 |
C | Certification | 签名和验证钥匙 |
注意功能是针对一个钥匙而言的,由其中的公钥和私钥共同完成。其中加密和解密分别由钥匙的公钥和私钥完成,签名和验证则分别由私钥和公钥完成。一般地,GPG 密钥环中钥匙的公钥需要公布到网络上,也就意味着:
默认地,GPG 生成的密钥环,主钥用于签名和验证,从钥用于加密和解密。
首先需要生成一个 GPG 密钥环,GPG 在生成密钥的时候会使用一个根据你的操作生成的随机数,所以你可以在 GPG 生成密钥的时候多做一些操作,例如点鼠标、敲键盘、复制文件等等。你可以利用 dd
指令在生成密钥的期间做一些读写操作以让随机数字发生器获得足够的熵数。
1 | $ sudo dd if=/dev/random of=/dev/null bs=4M |
然后可以生成 GPG 密钥环,推荐使用 --full-gen-key
选项来启用所有的功能。
1 | $ gpg --full-gen-key |
其中需要注意的事情有以下几项:
method1 and method2
的选项是生成主钥和一个从钥,默认可以用于签名和加密,形如 method
的选项只生成主钥,默认只能用于签名。现在你可以将你的公钥上传到任意 GPG 服务器上了,这可以方便他人导入公钥以验证你的签名。通过服务器的交换机制,全球所有的 GPG 服务器都会得到你的公钥。你可以列出你现在所拥有的公钥。
1 | $ gpg --list-keys |
可以看到两个公钥,分别属于主钥 B66CC194
和从钥 F96E3CB7
。上传时指定主钥的钥匙号(key ID)即可,GPG 会将密钥环中的公钥上传到指定的服务器。
1 | $ gpg --keyserver keys.gnupg.net --send-keys <key ID> |
GitHub 刚刚发布了支持 GPG 签名的消息,所以你可以选择使用 GitHub 托管你的仓库。首先你需要以文本形式导出你主钥的公钥。
1 | $ gpg -a -o gnupg.pub --export <key ID> |
然后打开你的 GitHub 密钥管理界面,根据文件 gnupg.pub
为你的 GitHub 账户配置用于验证签名的公钥。
注意:这一步不是必须的,你不一定要使用 GitHub,或许你更喜欢使用其他的商业产品,或者自己搭建一个 Git 服务器。Git 本身就是支持 GPG 签名的,GitHub 对 GPG 的支持仅是把验证结果在网页上显示出来(使用你上传的公钥)。
然而不幸的是,任何人都可以冒充你的名义上传公钥到 GPG 服务器,所以对方搜到以你的名义发布的公钥,不一定真的是你发布的。为了避免这个问题,你需要公布主钥的指纹。GPG 导入公钥后需要手动设置信任度。这时候对方就可以通过对比计算得到的主钥指纹和你提供的主钥指纹,来确定导入的主钥的合法性。
你可以像下面一样导出指纹。
1 | $ gpg --fingerprint <key ID> | perl -nE '$.-2 or s/^\h+// and print' | tee fingerprint |
然后将 fingerprint
文件提交到你的项目仓库中,或者公布在网络的其他位置。
- 你只需(只能)导出主钥的指纹,对方也只需要验证主钥的指纹,因为主钥的公钥可以验证从钥。
- 你可以通过
--export-ownertrust
和--import-ownertrust
来直接导出和导入信任度,但是不推荐这样做。
首先你需要为 Git 设置一个用于签名的私钥,通常来说所有的个人项目都用一个私钥进行签名,所以建议设置为全局配置。
1 | $ git config --global user.signingkey <key ID> |
然后就可以使用这个私钥来签名提交。
1 | $ git commit -S |
或者签名标签了。
1 | $ git tag -s <tag> |
如果你想全局默认使用 GPG 签名提交,可以全局将 commit.gpgsign
设置为 true
。
1 | $ git config --global commit.gpgsign true |
任何情况向下都不要把私钥泄露给除了你之外的任何人。如果需要向对方发送加密信息,请让对方提供指纹,导入对方的公钥进行加密,而不要用自己的公钥加密后再把自己的私钥发送过去。
你可以根据你得到的信息在任何 GPG 服务器上查找对应的公钥,典型的例如查看指纹(后 8 位数字为钥匙号),然后根据得到的钥匙号到服务器上查找钥匙。
1 | $ gpg --keyserver keys.gnupg.net --search-keys <key ID> |
选择对应的编号,会自动下载并导入该公钥。你也可以根据用户名和邮箱进行查找。
导入后的公钥需要设置信任度才能使用该公钥进行验证,你可以通过类似下面的指令编辑该公钥的信息。
1 | $ gpg --edit-key <key ID> |
你所看到的应该是一个文本交互界面,下面是一个样例。
1 | gpg (GnuPG) 2.1.11; Copyright (C) 2016 Free Software Foundation, Inc. |
你可以键入 fpr
来打印这个主钥的指纹,和你得到的主钥指纹进行对比,如果一致则键入 trust
来设置主钥的信任度。如果主钥被设置为绝对可信的(ultimately),GPG 会根据主钥的公钥验证从钥的签名,最终完成信任建立。最后键入 quit
退出。
现在你可以用导入的公钥来验证你 git clone
下来的仓库的提交和标签了,同样你需要首先告诉 Git 应该使用哪个公钥对这个仓库进行验证。一般来说不同作者的项目公钥都不同,建议不要将用于验证的公钥设置为全局。
1 | $ git config user.signingkey <key ID> |
然后可以像下面这样验证一个提交。
1 | $ git verify-commit <commit ID> |
或者验证一个标签。
1 | $ git tag -v <tag> |
动作 | 指令 |
---|---|
二进制方式签名文件 | gpg -u <key ID> -s <file> |
纯文本方式签名文件 | gpg -u <key ID> --clearsign <file> |
签名文件并独立存放签名 | gpg -u <key ID> --detach-sign <file> |
验证文件 | gpg --verify-files <file> |
通过独立的签名文件验证文件 | gpg --verify-files <file_sig> <file> |
如果不指定
-u
选项,会使用第一个密钥环中主钥的私钥进行签名。
动作 | 指令 |
---|---|
二进制方式加密文件 | gpg -r <key ID> -e <file> |
纯文本方式加密文件 | gpg -r <key ID> -a -e <file> |
解密文件 | gpg <file> |
]]>
- 这里你需要指定从钥的钥匙号,如果
-r
选项被省略,GPG 会交互式的请求一个钥匙号。- 如果你想在加密的同时签名文件,在加密指令中额外指定一个
-s
选项。
校验和的计算方法在文档 RFC 1071 中有如下说明:
(1) Adjacent octets to be checksummed are paired to form 16-bit
integers, and the 1’s complement sum of these 16-bit integers is
formed.(2) To generate a checksum, the checksum field itself is cleared,
the 16-bit 1’s complement sum is computed over the octets
concerned, and the 1’s complement of this sum is placed in the
checksum field.
即首先将校验和字段清零,将待求和数据调整为偶数字节(如为奇数字节则最后一个字节扩展为字)。然后用反码相加法(进位加到低位上)、以字为单位累加待求和数据。最后将累加结果取反并截低 16 位作为校验和。
之所以使用反码相加法,是为了让计算结果和网络序或主机序无关。
根据这个规则,计算校验和的的 C 语言函数可以做如下实现。
1 | uint16_t |
下面会使用这个校验和计算函数分别计算 IP、ICMP、TCP 和 UDP 包的校验和。
IP 包校验和的计算范围在 RFC 791 中有如下说明:
The checksum field is the 16 bit one’s complement of the one’s
complement sum of all 16 bit words in the header. For purposes of
computing the checksum, the value of the checksum field is zero.
即 IP 包的校验和只计算包头。
根据描述,IP 包的校验和可用 C 语言做如下计算。
1 | struct iphdr *ipheader; |
ICMP 包校验和的计算范围在 RFC 792 中有如下说明:
The checksum is the 16-bit ones’s complement of the one’s
complement sum of the ICMP message starting with the ICMP Type.
For computing the checksum , the checksum field should be zero.
This checksum may be replaced in the future.
即 ICMP 包的计算范围包括包头和数据。
根据描述,假设 IP 包校验和已经计算完毕,那么其中的 ICMP 包校验和可以用 C 语言做如下计算。
1 | struct icmphdr *icmpheader; |
TCP 和 UDP 校验和的计算要稍微麻烦一些,因为需要引入一个伪首部(pseudo header),伪首部的结构在 RFC 768 中有如下说明:
The pseudo header conceptually prefixed to the UDP header contains the
source address, the destination address, the protocol, and the UDP
length. This information gives protection against misrouted datagrams.
This checksum procedure is the same as is used in TCP.0 7 8 15 16 23 24 31 +--------+--------+--------+--------+ | source address | +--------+--------+--------+--------+ | destination address | +--------+--------+--------+--------+ | zero |protocol| UDP length | +--------+--------+--------+--------+
可见 TCP 和 UDP 的伪首部结构完全一致。
根据描述,伪首部的结构可以用 C 语言结构体做如下实现。
1 | typedef struct pseudohdr |
TCP 包校验和的计算方法在 RFC 793 中有如下说明:
The checksum field is the 16 bit one’s complement of the one’s
complement sum of all 16 bit words in the header and text. If a
segment contains an odd number of header and text octets to be
checksummed, the last octet is padded on the right with zeros to
form a 16 bit word for checksum purposes. The pad is not
transmitted as part of the segment. While computing the checksum,
the checksum field itself is replaced with zeros.The checksum also covers a 96 bit pseudo header conceptually
可见,算法和之前提到的校验和算法完全一致,根据描述校验和的计算需要包含伪首部和整个 TCP 包。
根据描述,假设 IP 包校验和已经计算完毕,那么其中的 TCP 包校验和可以用 C 语言做如下计算。
1 | char *tcpsumblock; /* 伪首部 + TCP 头 + 数据 */ |
UDP 包校验和的计算方法在 RFC 768 中有如下说明:
Checksum is the 16-bit one’s complement of the one’s complement sum of a
pseudo header of information from the IP header, the UDP header, and the
data, padded with zero octets at the end (if necessary) to make a
multiple of two octets.The pseudo header conceptually prefixed to the UDP header contains the
source address, the destination address, the protocol, and the UDP
length. This information gives protection against misrouted datagrams.
This checksum procedure is the same as is used in TCP.
所以 UDP 包校验和的计算方法和 TCP 包如出一辙,同样包含了一个伪首部。
具体的实现可以参考之前计算 TCP 包校验的 C 语言实现。
]]>这篇文章主要介绍了如何利用 SSH 反向隧道穿透 NAT,并演示了如何维持一条稳定的 SSH 隧道。
假设有机器 A 和 B,A 有公网 IP,B 位于 NAT 之后并无可用的端口转发,现在想由 A 主动向 B 发起 SSH 连接。由于 B 在 NAT 后端,无可用公网 IP + 端口 这样一个组合,所以 A 无法穿透 NAT,这篇文章应对的就是这种情况。
首先有如下约定,因为很重要所以放在前面:
机器代号 | 机器位置 | 地址 | 账户 | ssh/sshd 端口 | 是否需要运行 sshd |
---|---|---|---|---|---|
A | 位于公网 | a.site | usera | 22 | 是 |
B | 位于 NAT 之后 | localhost | userb | 22 | 是 |
C | 位于 NAT 之后 | localhost | userc | 22 | 否 |
这里默认你的系统 init 程序为
systemd
,如果你使用其他的 init 程序,如果没有特殊理由还是换到一个现代化的 GNU/Linux 系统吧……
这种手段实质上是由 B 向 A 主动地建立一个 SSH 隧道,将 A 的 6766 端口转发到 B 的 22 端口上,只要这条隧道不关闭,这个转发就是有效的。有了这个端口转发,只需要访问 A 的 6766 端口反向连接 B 即可。
首先在B 上建立一个 SSH 隧道,将 A 的 6766 端口转发到 B 的 22 端口上:
1 | B $ ssh -p 22 -qngfNTR 6766:localhost:22 usera@a.site |
然后在A 上利用 6766 端口反向 SSH 到 B:
1 | A $ ssh -p 6766 userb@localhost |
要做的事情其实就是这么简单。
然而不幸的是 SSH 连接是会超时关闭的,如果连接关闭,隧道无法维持,那么 A 就无法利用反向隧道穿透 B 所在的 NAT 了,为此我们需要一种方案来提供一条稳定的 SSH 反向隧道。
一个最简单的方法就是 autossh
,这个软件会在超时之后自动重新建立 SSH 隧道,这样就解决了隧道的稳定性问题,如果你使用Arch Linux,你可以这样获得它:
1 | $ sudo pacman -S autossh |
下面在B 上做之前类似的事情,不同的是该隧道会由 autossh
来维持:
1 | B $ autossh -p 22 -M 6777 -NR 6766:localhost:22 usera@a.site |
-M
参数指定的端口用来监听隧道的状态,与端口转发无关。
之后你可以在 A 上通过 6766 端口访问 B 了:
1 | A $ ssh -p 6766 userb@localhost |
然而这又有了另外一个问题,如果 B 重启隧道就会消失。那么需要有一种手段在 B 每次启动时使用 autossh
来建立 SSH 隧道。很自然的一个想法就是做成服务,之后会给出在 systemd
下的一种解决方案。
之所以标题这么起,是因为自己觉得这件事情有点类似于 UDP 打洞,即通过一台在公网的机器,让两台分别位于各自 NAT 之后的机器可以建立 SSH 连接。
下面演示如何使用 SSH 反向隧道,让 C 连接到 B。
首先在A 上编辑 sshd
的配置文件 /etc/ssh/sshd_config
,将 GatewayPorts
开关打开:
1 | GatewayPorts yes |
然后重启 sshd
:
1 | A $ sudo systemctl restart sshd |
然后在B 上对之前用到的 autossh
指令略加修改:
1 | B $ autossh -p 22 -M 6777 -NR '*:6766:localhost:22' usera@a.site |
之后在C 上利用A 的 6766 端口 SSH 连接到B:
1 | C $ ssh -p 6766 userb@a.site |
至此你已经轻而易举的穿透了两层 NAT。
整合一下前面提到的,最终的解决方案如下:
首先打开A 上 sshd
的 GatewayPorts
开关,并重启 sshd
(如有需要)。
然后在B 上新建一个用户autossh,根据权限最小化思想,B 上的 autossh
服务将以autossh 用户的身份运行,以尽大可能避免出现安全问题:
1 | B $ sudo useradd -m autossh |
紧接着在B 上为autossh 用户创建 SSH 密钥,并上传到 A:
1 | B $ su - autossh |
注意该密钥不要设置密码,也就是运行 ssh-keygen
指令时尽管一路回车,不要输入额外的字符。
然后在B 上创建以autossh 用户权限调用 autossh
的 service 文件。将下面文本写入到文件 /lib/systemd/system/autossh.service
,并设置权限为 644:
1 | [Unit] |
在 B 上让 network-online.target
生效:
1 | B $ systemctl enable NetworkManager-wait-online |
如果你使用
systemd-networkd
,你需要启用的服务则应当是systemd-networkd-wait-online
。
然后设置该服务自动启动:
1 | B $ sudo systemctl enable autossh |
如果你愿意,在这之后可以立刻启动它:
1 | B $ sudo systemctl start autossh |
然后你可以在A 上使用这条反向隧道穿透 B 所在的 NAT SSH 连接到 B:
1 | A $ ssh -p 6766 userb@localhost |
或者是在C 上直接穿透两层 NAT SSH 连接到 B:
1 | C $ ssh -p 6766 userb@a.site |
如果你对 SSH 足够熟悉,你可以利用这条隧道做更多的事情,例如你可以在反向连接时指定动态端口转发:
1 | C $ ssh -p 6766 -qngfNTD 7677 userb@a.site |
假设C 是你家中的电脑,A 是你的 VPS,B 是你公司的电脑。如果你这样做了,那么为浏览器设置端口为 7677
的 sock4
本地(localhost)代理后,你就可以在家里的浏览器上看到公司内网的网页。
1 |
|
惟愿可以遇到美好的人和事。
]]>折腾完大约是下午接近 4 点的样子,足足搞了快 5 个小时。透过窄窄的窗子所看到的已经是很惨淡的阳光了。回过神来今天已经是公历新年后的第 3 天了,或许因为最近 1 个半月想了很多的事情,不知不觉已经将一年最后的时间消耗殆尽,不难过也不开心。
因为搞乱了生活节奏的原因,难得的假期里并没有什么新年的实感,没想过什么愿望,也没制定什么计划,不过依着心里还留存的一点点向往,希望以后能收获一些好的事物吧。
]]>