在本系列教程中所構建的網路引導伺服器有一個很重要的限制,那就是所提供的操作系統鏡像是隻讀的。一些使用場景或許要求終端用戶能夠修改操作系統鏡像。例如,一些教師或許希望學生能夠安裝和配置一些像 MariaDB 和 Node.js 這樣的包來做為他們課程練習的一部分。

可寫鏡像的另外的好處是,終端用戶「私人定製」的操作系統,在下次不同的工作站上使用時能夠「跟著」他們。

修改 Bootmenu 應用程序以使用 HTTPS

為 bootmenu 應用程序創建一個自簽名的證書:

$ sudo -i
# MY_NAME=$(</etc/hostname)
# MY_TLSD=/opt/bootmenu/tls
# mkdir $MY_TLSD
# openssl req -newkey rsa:2048 -nodes -keyout $MY_TLSD/$MY_NAME.key -x509 -days 3650 -out $MY_TLSD/$MY_NAME.pem

驗證你的證書的值。確保 Subject 行中 CN 的值與你的 iPXE 客戶端連接你的網路引導伺服器所使用的 DNS 名字是相匹配的:

# openssl x509 -text -noout -in $MY_TLSD/$MY_NAME.pem

接下來,更新 bootmenu 應用程序去監聽 HTTPS 埠和新創建的證書及密鑰:

# sed -i "s#listen => .*#listen => [https://$MY_NAME:443?cert=$MY_TLSD/$MY_NAME.pem&key=$MY_TLSD/$MY_NAME.key&ciphers=AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA],#" /opt/bootmenu/bootmenu.conf

注意 iPXE 當前支持的 加密演算法是有限制的。

GnuTLS 要求 「CAPDACREAD_SEARCH」 能力,因此將它添加到 bootmenu 應用程序的 systemd 服務:

# sed -i /^AmbientCapabilities=/ s/$/ CAP_DAC_READ_SEARCH/ /etc/systemd/system/bootmenu.service
# sed -i s/Serves iPXE Menus over HTTP/Serves iPXE Menus over HTTPS/ /etc/systemd/system/bootmenu.service
# systemctl daemon-reload

現在,在防火牆中為 bootmenu 服務添加一個例外規則並重啟動該服務:

# MY_SUBNET=192.0.2.0
# MY_PREFIX=24
# firewall-cmd --add-rich-rule="rule family=ipv4 source address=$MY_SUBNET/$MY_PREFIX service name=https accept"
# firewall-cmd --runtime-to-permanent
# systemctl restart bootmenu.service

使用 wget 去驗證是否工作正常:

$ MY_NAME=server-01.example.edu
$ MY_TLSD=/opt/bootmenu/tls
$ wget -q --ca-certificate=$MY_TLSD/$MY_NAME.pem -O - https://$MY_NAME/menu

添加 HTTPS 到 iPXE

更新 init.ipxe 去使用 HTTPS。接著使用選項重新編譯 ipxe 引導載入器,以便它包含和信任你為 bootmenu 應用程序創建的自簽名證書:

$ echo #define DOWNLOAD_PROTO_HTTPS >> $HOME/ipxe/src/config/local/general.h
$ sed -i s/^chain http:/chain https:/ $HOME/ipxe/init.ipxe
$ cp $MY_TLSD/$MY_NAME.pem $HOME/ipxe
$ cd $HOME/ipxe/src
$ make clean
$ make bin-x86_64-efi/ipxe.efi EMBED=../init.ipxe CERT="../$MY_NAME.pem" TRUST="../$MY_NAME.pem"

你現在可以將啟用了 HTTPS 的 iPXE 引導載入器複製到你的客戶端上,並測試它能否正常工作:

$ cp $HOME/ipxe/src/bin-x86_64-efi/ipxe.efi $HOME/esp/efi/boot/bootx64.efi

添加用戶驗證到 Mojolicious 中

為 bootmenu 應用程序創建一個 PAM 服務定義:

# dnf install -y pam_krb5
# echo auth required pam_krb5.so > /etc/pam.d/bootmenu

添加一個庫到 bootmenu 應用程序中,它使用 Authen-PAM 的 Perl 模塊去執行用戶驗證:

# dnf install -y perl-Authen-PAM;
# MY_MOJO=/opt/bootmenu
# mkdir $MY_MOJO/lib
# cat << END > $MY_MOJO/lib/PAM.pm
package PAM;

use Authen::PAM;

sub auth {
my $success = 0;

my $username = shift;
my $password = shift;

my $callback = sub {
my @res;
while (@_) {
my $code = shift;
my $msg = shift;
my $ans = "";

$ans = $username if ($code == PAM_PROMPT_ECHO_ON());
$ans = $password if ($code == PAM_PROMPT_ECHO_OFF());

push @res, (PAM_SUCCESS(), $ans);
}
push @res, PAM_SUCCESS();

return @res;
};

my $pamh = new Authen::PAM(bootmenu, $username, $callback);

{
last unless ref $pamh;
last unless $pamh->pam_authenticate() == PAM_SUCCESS;
$success = 1;
}

return $success;
}

return 1;
END

以上的代碼是一字不差是從 Authen::PAM::FAQ 的 man 頁面中複製來的。

重定義 bootmenu 應用程序,以使它僅當提供了有效的用戶名和密碼之後返回一個網路引導模板:

# cat << END > $MY_MOJO/bootmenu.pl
#!/usr/bin/env perl

use lib lib;

use PAM;
use Mojolicious::Lite;
use Mojolicious::Plugins;
use Mojo::Util (url_unescape);

plugin Config;

get /menu;
get /boot => sub {
my $c = shift;

my $instance = $c->param(instance);
my $username = $c->param(username);
my $password = $c->param(password);

my $template = menu;

{
last unless $instance =~ /^fc[[:digit:]]{2}$/;
last unless $username =~ /^[[:alnum:]]+$/;
last unless PAM::auth($username, url_unescape($password));
$template = $instance;
}

return $c->render(template => $template);
};

app->start;
END

bootmenu 應用程序現在查找 lib 命令去找到相應的 WorkingDirectory。但是,默認情況下,對於 systemd 單元它的工作目錄設置為伺服器的 root 目錄。因此,你必須更新 systemd 單元去設置 WorkingDirectory 為 bootmenu 應用程序的根目錄:

# sed -i "/^RuntimeDirectory=/ a WorkingDirectory=$MY_MOJO" /etc/systemd/system/bootmenu.service
# systemctl daemon-reload

更新模塊去使用重定義後的 bootmenu 應用程序:

# cd $MY_MOJO/templates
# MY_BOOTMENU_SERVER=$(</etc/hostname)
# MY_FEDORA_RELEASES="28 29"
# for i in $MY_FEDORA_RELEASES; do echo #!ipxe > fc$i.html.ep; grep "^kernel|initrd" menu.html.ep | grep "fc$i" >> fc$i.html.ep; echo "boot || chain https://$MY_BOOTMENU_SERVER/menu" >> fc$i.html.ep; sed -i "/^:f$i$/,/^boot /c :f$i
login
chain https://$MY_BOOTMENU_SERVER/boot?instance=fc$i&username=${username}&password=${password:uristring} || goto failed" menu.html.ep; done

上面的最後的命令將生成類似下面的三個文件:

menu.html.ep

#!ipxe

set timeout 5000

:menu
menu iPXE Boot Menu
item --key 1 lcl 1. Microsoft Windows 10
item --key 2 f29 2. RedHat Fedora 29
item --key 3 f28 3. RedHat Fedora 28
choose --timeout ${timeout} --default lcl selected || goto shell
set timeout 0
goto ${selected}

:failed
echo boot failed, dropping to shell...
goto shell

:shell
echo type exit to get the back to the menu
set timeout 0
shell
goto menu

:lcl
exit

:f29
login
chain https://server-01.example.edu/boot?instance=fc29&username=${username}&password=${password:uristring} || goto failed

:f28
login
chain https://server-01.example.edu/boot?instance=fc28&username=${username}&password=${password:uristring} || goto failed

fc29.html.ep

#!ipxe
kernel --name kernel.efi ${prefix}/vmlinuz-4.19.5-300.fc29.x86_64 initrd=initrd.img ro ip=dhcp rd.peerdns=0 nameserver=192.0.2.91 nameserver=192.0.2.92 root=/dev/disk/by-path/ip-192.0.2.158:3260-iscsi-iqn.edu.example.server-01:fc29-lun-1 netroot=iscsi:192.0.2.158::::iqn.edu.example.server-01:fc29 console=tty0 console=ttyS0,115200n8 audit=0 selinux=0 quiet
initrd --name initrd.img ${prefix}/initramfs-4.19.5-300.fc29.x86_64.img
boot || chain https://server-01.example.edu/menu

fc28.html.ep

#!ipxe
kernel --name kernel.efi ${prefix}/vmlinuz-4.19.3-200.fc28.x86_64 initrd=initrd.img ro ip=dhcp rd.peerdns=0 nameserver=192.0.2.91 nameserver=192.0.2.92 root=/dev/disk/by-path/ip-192.0.2.158:3260-iscsi-iqn.edu.example.server-01:fc28-lun-1 netroot=iscsi:192.0.2.158::::iqn.edu.example.server-01:fc28 console=tty0 console=ttyS0,115200n8 audit=0 selinux=0 quiet
initrd --name initrd.img ${prefix}/initramfs-4.19.3-200.fc28.x86_64.img
boot || chain https://server-01.example.edu/menu

現在,重啟動 bootmenu 應用程序,並驗證用戶認證是否正常工作:

# systemctl restart bootmenu.service

使得 iSCSI Target 可寫

現在,用戶驗證通過 iPXE 可以正常工作,在用戶連接時,你可以按需在只讀鏡像的上面創建每用戶可寫的 overlay(疊加層)。使用一個 寫時複製 的疊加層與簡單地為每個用戶複製原始鏡像相比有三個好處:

  1. 副本創建非常快。這樣就可以按需創建。
  2. 副本並不增加伺服器上的磁碟使用。除了原始鏡像之外,僅存儲用戶寫入個人鏡像的內容。
  3. 由於每個副本的扇區大多都是伺服器的存儲器上的相同扇區,在隨後的用戶訪問這些操作系統的副本時,它們可能已經載入到內存中,這樣就提升了伺服器的性能,因為對內存的訪問速度要比磁碟 I/O 快得多。

使用寫時複製的一個潛在隱患是,一旦疊加層創建後,疊加層之下的鏡像就不能再改變。如果它們改變,所有它們之上的疊加層將出錯。因此,疊加層必須被刪除並用新的、空白的進行替換。即便只是簡單地以讀寫模式載入的鏡像,也可能因為某些文件系統更新導致疊加層出錯。

由於這個隱患,如果原始鏡像被修改將導致疊加層出錯,因此運行下列的命令,將原始鏡像標記為不可改變:

# chattr +i </path/to/file>

你可以使用 lsattr </path/to/file> 去查看不可改變標誌,並可以使用 chattr -i </path/to/file> 取消設置不可改變標誌。在設置了不可改變標誌之後,即便是 root 用戶或以 root 運行的系統進程也不修改或刪除這個文件。

停止 tgtd.service 之後,你就可以改變鏡像文件:

# systemctl stop tgtd.service

當仍有連接打開的時候,運行這個命令一般需要一分鐘或更長的時間。

現在,移除只讀的 iSCSI 出口。然後更新模板中的 readonly-root 配置文件,以使鏡像不再是隻讀的:

# MY_FC=fc29
# rm -f /etc/tgt/conf.d/$MY_FC.conf
# TEMP_MNT=$(mktemp -d)
# mount /$MY_FC.img $TEMP_MNT
# sed -i s/^READONLY=yes$/READONLY=no/ $TEMP_MNT/etc/sysconfig/readonly-root
# sed -i s/^Storage=volatile$/#Storage=auto/ $TEMP_MNT/etc/systemd/journald.conf
# umount $TEMP_MNT

將 journald 日誌從發送到內存修改回預設值(如果 /var/log/journal 存在的話記錄到磁碟),因為一個用戶報告說,他的客戶端由於應用程序生成了大量的系統日誌而產生內存溢出錯誤,導致它的客戶端被卡住。而將日誌記錄到磁碟的負面影響是客戶端產生了額外的寫入流量,這將在你的網路引導伺服器上可能增加一些沒有必要的 I/O。你應該去決定到底使用哪個選擇 —— 記錄到內存還是記錄到硬碟 —— 哪個更合適取決於你的環境。

因為你的模板鏡像在以後不能做任何的更改,因此在它上面設置不可更改標誌,然後重啟動 tgtd.service:

# chattr +i /$MY_FC.img
# systemctl start tgtd.service

現在,更新 bootmenu 應用程序:

# cat << END > $MY_MOJO/bootmenu.pl
#!/usr/bin/env perl

use lib lib;

use PAM;
use Mojolicious::Lite;
use Mojolicious::Plugins;
use Mojo::Util (url_unescape);

plugin Config;

get /menu;
get /boot => sub {
my $c = shift;

my $instance = $c->param(instance);
my $username = $c->param(username);
my $password = $c->param(password);

my $chapscrt;
my $template = menu;

{
last unless $instance =~ /^fc[[:digit:]]{2}$/;
last unless $username =~ /^[[:alnum:]]+$/;
last unless PAM::auth($username, url_unescape($password));
last unless $chapscrt = `sudo scripts/mktgt $instance $username`;
$template = $instance;
}

return $c->render(template => $template, username => $username, chapscrt => $chapscrt);
};

app->start;
END

新版本的 bootmenu 應用程序調用一個定製的 mktgt 腳本,如果成功,它將為每個它自己創建的新的 iSCSI 目標返回一個隨機的 CHAP 密碼。這個 CHAP 密碼可以防止其它用戶的 iSCSI 目標以間接方式掛載這個用戶的目標。這個應用程序只有在用戶密碼認證成功之後才返回一個正確的 iSCSI 目標密碼。

mktgt 腳本要加 sudo 前綴來運行,因為它需要 root 許可權去創建目標。

$username$chapscrt 變數也傳遞給 render 命令,因此在需要的時候,它們也能夠被納入到模板中返回給用戶。

接下來,更新我們的引導模板,以便於它們能夠讀取用戶名和 chapscrt 變數,並傳遞它們到所屬的終端用戶。也要更新模板以 rw(讀寫)模式載入根文件系統:

# cd $MY_MOJO/templates
# sed -i "s/:$MY_FC/:$MY_FC-<%= $username %>/g" $MY_FC.html.ep
# sed -i "s/ netroot=iscsi:/ netroot=iscsi:<%= $username %>:<%= $chapscrt %>@/" $MY_FC.html.ep
# sed -i "s/ ro / rw /" $MY_FC.html.ep

運行上面的命令後,你應該會看到如下的引導模板:

#!ipxe
kernel --name kernel.efi ${prefix}/vmlinuz-4.19.5-300.fc29.x86_64 initrd=initrd.img rw ip=dhcp rd.peerdns=0 nameserver=192.0.2.91 nameserver=192.0.2.92 root=/dev/disk/by-path/ip-192.0.2.158:3260-iscsi-iqn.edu.example.server-01:fc29-<%= $username %>-lun-1 netroot=iscsi:<%= $username %>:<%= $chapscrt %>@192.0.2.158::::iqn.edu.example.server-01:fc29-<%= $username %> console=tty0 console=ttyS0,115200n8 audit=0 selinux=0 quiet
initrd --name initrd.img ${prefix}/initramfs-4.19.5-300.fc29.x86_64.img
boot || chain https://server-01.example.edu/menu

注意:如果在 插入 變數後需要查看引導模板,你可以在 boot 命令之前,在它自己的行中插入 shell命令。然後在你網路引導你的客戶端時,iPXE 將在那裡給你提供一個用於交互的 shell,你可以在 shell 中輸入 imgstat 去查看傳遞到內核的參數。如果一切正確,你可以輸入 exit 去退出 shell 並繼續引導過程。

現在,通過 sudo 允許 bootmenu 用戶以 root 許可權去運行 mktgt 腳本(僅這個腳本):

# echo "bootmenu ALL = NOPASSWD: $MY_MOJO/scripts/mktgt *" > /etc/sudoers.d/bootmenu

bootmenu 用戶不應該寫訪問 mktgt 腳本或在它的家目錄下的任何其它文件。在 /opt/bootmenu 目錄下的所有文件的屬主應該是 root,並且不應該被其它任何 root 以外的用戶可寫。

sudo 在使用 systemd 的 DynamicUser 選項下不能正常工作,因此創建一個普通用戶帳戶,並設置 systemd 服務以那個用戶運行:

# useradd -r -c iPXE Boot Menu Service -d /opt/bootmenu -s /sbin/nologin bootmenu
# sed -i s/^DynamicUser=true$/User=bootmenu/ /etc/systemd/system/bootmenu.service
# systemctl daemon-reload

最後,為寫時複製覆蓋創建一個目錄,並創建管理 iSCSI 目標的 mktgt 腳本和它們的覆蓋支持存儲:

# mkdir /$MY_FC.cow
# mkdir $MY_MOJO/scripts
# cat << END > $MY_MOJO/scripts/mktgt
#!/usr/bin/env perl

# if another instance of this script is running, wait for it to finish
"$ENV{FLOCKER}" eq MKTGT or exec "env FLOCKER=MKTGT flock /tmp $0 @ARGV";

# use "RETURN" to print to STDOUT; everything else goes to STDERR by default
open(RETURN, >&, STDOUT);
open(STDOUT, >&, STDERR);

my $instance = shift or die "instance not provided";
my $username = shift or die "username not provided";

my $img = "/$instance.img";
my $dir = "/$instance.cow";
my $top = "$dir/$username";

-f "$img" or die "$img is not a file";
-d "$dir" or die "$dir is not a directory";

my $base;
die unless $base = `losetup --show --read-only --nooverlap --find $img`;
chomp $base;

my $size;
die unless $size = `blockdev --getsz $base`;
chomp $size;

# create the per-user sparse file if it does not exist
if (! -e "$top") {
die unless system("dd if=/dev/zero of=$top status=none bs=512 count=0 seek=$size") == 0;
}

# create the copy-on-write overlay if it does not exist
my $cow="$instance-$username";
my $dev="/dev/mapper/$cow";
if (! -e "$dev") {
my $over;
die unless $over = `losetup --show --nooverlap --find $top`;
chomp $over;
die unless system("echo 0 $size snapshot $base $over p 8 | dmsetup create $cow") == 0;
}

my $tgtadm = /usr/sbin/tgtadm --lld iscsi;

# get textual representations of the iscsi targets
my $text = `$tgtadm --op show --mode target`;
my @targets = $text =~ /(?:^T.*
)(?:^ .*
)*/mg;

# convert the textual representations into a hash table
my $targets = {};
foreach (@targets) {
my $tgt;
my $sid;

foreach (split /
/) {
/^Target (d+)(?{ $tgt = $targets->{$^N} = [] })/;
/I_T nexus: (d+)(?{ $sid = $^N })/;
/Connection: (d+)(?{ push @{$tgt}, [ $sid, $^N ] })/;
}
}

my $hostname;
die unless $hostname = `hostname`;
chomp $hostname;

my $target = iqn. . join(., reverse split(., $hostname)) . ":$cow";

# find the target id corresponding to the provided target name and
# close any existing connections to it
my $tid = 0;
foreach (@targets) {
next unless /^Target (d+)(?{ $tid = $^N }): $target$/m;
foreach (@{$targets->{$tid}}) {
die unless system("$tgtadm --op delete --mode conn --tid $tid --sid $_->[0] --cid $_->[1]") == 0;
}
}

# create a new target if an existing one was not found
if ($tid == 0) {
# find an available target id
my @ids = (0, sort keys %{$targets});
$tid = 1; while ($ids[$tid]==$tid) { $tid++ }

# create the target
die unless -e "$dev";
die unless system("$tgtadm --op new --mode target --tid $tid --targetname $target") == 0;
die unless system("$tgtadm --op new --mode logicalunit --tid $tid --lun 1 --backing-store $dev") == 0;
die unless system("$tgtadm --op bind --mode target --tid $tid --initiator-address ALL") == 0;
}

# (re)set the provided targets chap password
my $password = join(, map(chr(int(rand(26))+65), 1..8));
my $accounts = `$tgtadm --op show --mode account`;
if ($accounts =~ / $username$/m) {
die unless system("$tgtadm --op delete --mode account --user $username") == 0;
}
die unless system("$tgtadm --op new --mode account --user $username --password $password") == 0;
die unless system("$tgtadm --op bind --mode account --tid $tid --user $username") == 0;

# return the new password to the iscsi target on stdout
print RETURN $password;
END
# chmod +x $MY_MOJO/scripts/mktgt

上面的腳本將做以下五件事情:

  1. 創建 /<instance>.cow/<username> 稀疏文件(如果不存在的話)。
  2. 創建 /dev/mapper/<instance>-<username> 設備節點作為 iSCSI 目標的寫時複製支持存儲(如果不存在的話)。
  3. 創建 iqn.<reverse-hostname>:<instance>-<username> iSCSI 目標(如果不存在的話)。或者,如果已存在了,它將關閉任何已存在的連接,因為在任何時刻,鏡像只能以只讀模式從一個地方打開。
  4. 它在 iqn.<reverse-hostname>:<instance>-<username> iSCSI 目標上(重新)設置 chap 密碼為一個新的隨機值。
  5. (如果前面的所有任務都成功的話)它在 標準輸出 上顯示新的 chap 密碼。

你應該可以在命令行上通過使用有效的測試參數來運行它,以測試 mktgt 腳本能否正常工作。例如:

# echo `$MY_MOJO/scripts/mktgt fc29 jsmith`

當你從命令行上運行時,mktgt 腳本應該會輸出 iSCSI 目標的一個隨意的八字元隨機密碼(如果成功的話)或者是出錯位置的行號(如果失敗的話)。

有時候,你可能需要在不停止整個服務的情況下刪除一個 iSCSI 目標。例如,一個用戶可能無意中損壞了他的個人鏡像,在那種情況下,你可能需要按步驟撤銷上面的 mktgt 腳本所做的事情,以便於他下次登入時他將得到一個原始鏡像。

下面是用於撤銷的 rmtgt 腳本,它以相反的順序做了上面 mktgt 腳本所做的事情:

# mkdir $HOME/bin
# cat << END > $HOME/bin/rmtgt
#!/usr/bin/env perl

@ARGV >= 2 or die "usage: $0 <instance> <username> [+d|+f]
";

my $instance = shift;
my $username = shift;

my $rmd = ($ARGV[0] eq +d); #remove device node if +d flag is set
my $rmf = ($ARGV[0] eq +f); #remove sparse file if +f flag is set
my $cow = "$instance-$username";

my $hostname;
die unless $hostname = `hostname`;
chomp $hostname;

my $tgtadm = /usr/sbin/tgtadm;
my $target = iqn. . join(., reverse split(., $hostname)) . ":$cow";

my $text = `$tgtadm --op show --mode target`;
my @targets = $text =~ /(?:^T.*
)(?:^ .*
)*/mg;

my $targets = {};
foreach (@targets) {
my $tgt;
my $sid;

foreach (split /
/) {
/^Target (d+)(?{ $tgt = $targets->{$^N} = [] })/;
/I_T nexus: (d+)(?{ $sid = $^N })/;
/Connection: (d+)(?{ push @{$tgt}, [ $sid, $^N ] })/;
}
}

my $tid = 0;
foreach (@targets) {
next unless /^Target (d+)(?{ $tid = $^N }): $target$/m;
foreach (@{$targets->{$tid}}) {
die unless system("$tgtadm --op delete --mode conn --tid $tid --sid $_->[0] --cid $_->[1]") == 0;
}
die unless system("$tgtadm --op delete --mode target --tid $tid") == 0;
print "target $tid deleted
";
sleep 1;
}

my $dev = "/dev/mapper/$cow";
if ($rmd or ($rmf and -e $dev)) {
die unless system("dmsetup remove $cow") == 0;
print "device node $dev deleted
";
}

if ($rmf) {
my $sf = "/$instance.cow/$username";
die "sparse file $sf not found" unless -e "$sf";
die unless system("rm -f $sf") == 0;
die unless not -e "$sf";
print "sparse file $sf deleted
";
}
END
# chmod +x $HOME/bin/rmtgt

例如,使用上面的腳本去完全刪除 fc29-jsmith 目標,包含它的支持存儲設備節點和稀疏文件,可以按下列方式運行命令:

# rmtgt fc29 jsmith +f

一旦你驗證 mktgt 腳本工作正常,你可以重啟動 bootmenu 服務。下次有人從網路引導時,他們應該能夠接收到一個他們可以寫入的、可」私人定製「的網路引導鏡像的副本:

# systemctl restart bootmenu.service

現在,就像下面的截屏示範的那樣,用戶應該可以修改根文件系統了:


via: fedoramagazine.org/how-

作者:Gregory Bartholomew 選題:lujun9972 譯者:qhwdw 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


推薦閱讀:
相關文章