如何构建 Netboot 服务器,第 4 部分

本系列中构建的网络引导服务器的一个重要限制是所提供的操作系统映像是只读的。 某些用例可能需要最终用户修改图像。 为了 example,讲师可能希望学生安装和配置 MariaDB 和 Node.js 等软件包,作为课程演练的一部分。

可写网络启动映像的另一个好处是最终用户的“个性化”操作系统可以跟随他们到他们以后可能使用的不同工作站。

将引导菜单应用程序更改为使用 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

验证您的证书的值。 确保“主题”行中的“CN”值与 iPXE 客户端用于连接到引导菜单服务器的 DNS 名称匹配:

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

接下来,更新 bootmenu 应用程序的 listen 指令以使用 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 需要“CAP_DAC_READ_SEARCH”功能,因此将其添加到引导菜单应用程序的 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 引导加载程序,以嵌入并信任您为引导菜单应用程序创建的自签名证书:

$ echo '#define DOWNLOAD_PROTO_HTTPS' >> $HOME/ipxe/src/config/local/general.h
$ sed -i 's/^chain https:/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

将一个库添加到使用 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 手册页。

重新定义引导菜单应用程序,使其仅在提供有效的用户名和密码时才返回网络引导模板:

# 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 应用程序现在查找相对于其 WorkingDirectory 的 lib 目录。 但是,默认情况下,工作目录设置为 systemd 单元的服务器的根目录。 因此,您必须更新 systemd 单元以将 WorkingDirectory 设置为引导菜单应用程序的根目录:

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

更新模板以使用重新定义的引导菜单应用程序:

# 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$inloginnchain https://$MY_BOOTMENU_SERVER/boot?instance=fc$i&username=${username}&password=${password:uristring} || goto failed" menu.html.ep; done

上面最后一个命令的结果应该是三个类似下面的文件:

菜单.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 目标可写

现在用户身份验证通过 iPXE 工作,您可以在用户连接时根据需要在只读图像之上创建每个用户的可写覆盖。 用一个 写时复制 与简单地为每个用户复制原始图像文件相比,overlay 具有三个优点:

  1. 可以非常快速地创建副本。 这允许按需创建。
  2. 该副本不会增加服务器上的磁盘使用量。 除了原始图像之外,仅存储用户写入其个人图像副本的内容。
  3. 由于每个副本的大多数扇区都是服务器存储上的相同扇区,因此当后续用户访问他们的操作系统副本时,它们很可能已经加载到 RAM 中。 这提高了服务器的性能,因为 RAM 比磁盘 I/O 快。

使用写时复制的一个潜在缺陷是,一旦创建了覆盖,就不能更改覆盖它们的图像。 如果它们被改变,所有的覆盖都将被破坏。 然后必须删除覆盖并用新的空白覆盖替换。 即使只是以读写模式安装映像文件,也会导致足够的文件系统更新破坏覆盖。

由于如果修改原始图像可能会损坏覆盖,请运行以下命令将原始图像标记为不可变:

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

您可以使用 lsattr 查看不可变标志的状态并使用 chattr -i 取消设置不可变标志。 当设置了不可变标志时,即使是 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

现在,更新引导菜单应用程序:

# 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 密码可防止一个用户通过间接方式挂载另一个用户的 iSCSI 目标。 该应用程序仅将正确的 iSCSI 目标密码返回给已成功通过身份验证的用户。

mktgt 脚本以 sudo 因为它需要root权限来创建目标。

$username 和 $chapscrt 变量也传递给渲染命令,因此可以在必要时将它们合并到返回给用户的模板中。

接下来,更新我们的引导模板,以便它们可以读取用户名和 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,您可以在其中输入“imgstat”来查看传递给内核的参数。 如果一切看起来都正确,您可以键入“exit”以离开 shell 并继续引导过程。

现在允许 bootmenu 用户以 root 身份运行 mktgt 脚本(并且仅该脚本) sudo:

# 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.*n)(?:^ .*n)*/mg;

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

   foreach (split /n/) {
      /^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 {$a <=> $b} 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 target's 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. 如果 /.cow/ 稀疏文件不存在,它会创建它。
  2. 它会创建 /dev/mapper/ 设备节点,如果 iSCSI 目标尚不存在,该设备节点将用作 iSCSI 目标的写时复制后备存储。
  3. 如果 iqn.: iSCSI 目标不存在,它会创建它。 或者,如果目标确实存在,它会关闭与它的任何现有连接,因为一次只能从一个位置以读写模式打开图像。
  4. 它(重新)将 iqn.: iSCSI 目标上的 chap 密码设置为新的随机值。
  5. 它打印新的章节密码 标准输出 如果之前的所有任务都成功完成。

您应该能够通过使用有效的测试参数从命令行运行 mktgt 脚本来测试它。 为了 example:

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

从命令行运行时,如果成功,mktgt 脚本应该打印出 iSCSI 目标的 8 个字符的随机密码,或者如果失败,则打印出出错的行号。

有时,您可能想要删除 iSCSI 目标,而不必停止整个服务。 为了 example,用户可能无意中损坏了他们的个人图像,在这种情况下,您需要系统地撤消上述 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]n";

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.*n)(?:^ .*n)*/mg;

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

   foreach (split /n/) {
      /^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 deletedn";
   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 deletedn";
}

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 deletedn";
}
END
# chmod +x $HOME/bin/rmtgt

为了 example,要使用上述脚本完全删除 fc29-jsmith 目标,包括其后备存储设备节点及其稀疏文件,请运行以下命令:

# rmtgt fc29 jsmith +f

一旦您确认 mktgt 脚本工作正常,您可以重新启动 bootmenu 服务。 下次有人启动网络时,他们应该会收到他们可以写入的网络启动映像的个人副本:

# systemctl restart bootmenu.service

用户现在应该能够修改根文件系统,如下图所示: