Skip to content

Commit

Permalink
Fixes #36833 - Add SecureBoot support for arbitrary operating systems…
Browse files Browse the repository at this point in the history
… to "Grub2 UEFI" PXE loaders

This feature consists of four patches, one each for foreman,
smart-proxy, foreman-installer, and puppet-foreman_proxy.

This patch adds support for individual Network Bootstrap Programs (NBP)
in order to enable network based installations of SecureBoot enabled
hosts for arbitrary operating systems.

SecureBoot expects to follow a chain of trust from the initial boot of
the host to the loading of Linux kernel modules. The very first shim
that is loaded determines which distribution is allowed to be booted or
kexec'ed until next reboot.

Currently the "Grub2 UEFI SecureBoot" PXE loaders use NBPs provided by
the vendor of the Foreman/Smart Proxy host system. All hosts receive and
execute the same binary. On SecureBoot enabled hosts, this limits
installations to operating systems by the vendor of the Foreman/
Smart Proxy host system.

Providing shim and GRUB2 by the vendor of the operating system to be
installed allows Foreman to install any operating system on SecureBoot
enabled hosts over network.

To achieve this, the host's DHCP filename option is set to a shim/GRUB2
binary in a host specific directory based on their MAC address.
Corresponding shim and GRUB2 binaries are copied into that directory
along with the generated GRUB2 configuration files.
When provisioning a host, the Smart Proxy checks in a dedicated
directory inside the TFTP root - the so called "bootloader universe" -
if NBPs are present matching the operating system, operating system
version, and architecture of the host to be installed. If this is the
case, these NBPs are copied from the bootloader universe directory to
the host specific directory. If not, as a fallback the default NBPs
provided by the vendor of the Foreman/Smart Proxy host system are
copied from the `:tftproot:/grub2` directory to the host specific
directory.

Up to now, shim and GRUB2 binaries have to be retrieved and set up in
the bootloader universe directory manually according to the
documentation. An automatic way to provide OS dependent NBPs will be
added in future.

In case there are no NBPs present in the bootloader universe matching
the operating system, operating system version, and architecture of
the host to be installed, the behaviour of the "Grub2 UEFI" PXE
loaders does not change to the behavior prior to this feature.

Implementation notes:
---------------------
* To be future proof (e.g. to be able to provide NBPs in the bootloader
  universe for other PXE loaders without running into any filename
  conflicts) and for better structure, the PXE kind is prepended as a
  first directory level inside the bootloader universe.
* The operating system version inside the bootloader universe consists
  of the major and minor version (if applicable) of the operating system
  separated by a dot (`.`). If no NBPs are configured for a specific
  operating system version the fallback directory `default` is used.
* To simplify things on Foreman side in future, symlinks are used for
  the shim (boot-sb.efi) and GRUB2 (boot.efi) binaries.
* Inside the TFTP root directory a new directory `host-config` is
  created for storing all the host specific directories.
* Inside the TFTP root directory a new directory `bootloader-universe`
  is created for storing all the OS specific boot files.
* For storage efficiency the shim and GRUB2 binaries from the
  bootloader universe or the `:tftproot:/grub2` directory are
  symlinked to the host specific directory.

Full example:
-------------
[root@vm ~]# hammer host info --id 241 | grep -E "(MAC address|Operating System)"
MAC address: 00:50:56:b4:75:5e
Operating System: AlmaLinux 8.9

[root@vm ~]# tree /var/lib/tftpboot/bootloader-universe/
/var/lib/tftpboot/bootloader-universe/
└── pxegrub2
    └── almalinux
        ├── 8.9
        │   └── x86_64
        │       ├── boot.efi -> grubx64.efi
        │       ├── boot-sb.efi -> shimx64.efi
        │       ├── grubx64.efi
        │       └── shimx64.efi
        └── default
            └── x86_64
                ├── boot.efi -> grubx64.efi
                ├── boot-sb.efi -> shimx64.efi
                ├── grubx64.efi
                └── shimx64.efi

[root@vm ~]# hammer host update --id 241 --build true

[root@vm ~]# tree /var/lib/tftpboot/host-config
/var/lib/tftpboot/host-config
└── 00-50-56-a3-41-a8
    └── grub2
        ├── boot.efi -> ../../../bootloader-universe/grubx64.efi
        ├── boot-sb.efi -> ../../../bootloader-universe/shimx64.efi
        ├── grub.cfg
        ├── grub.cfg-00:50:56:a3:41:a8
        ├── grub.cfg-01-00-50-56-a3-41-a8
        ├── grubx64.efi -> ../../../bootloader-universe/grubx64.efi
        ├── os_info
        └── shimx64.efi -> ../../../bootloader-universe/shimx64.efi

[root@vm ~]# grep -B2 00-50-56-b4-75-5e /var/lib/dhcpd/dhcpd.leases
hardware ethernet 00:50:56:b4:75:5e;
fixed-address 192.168.145.84;
supersede server.filename = "host-config/00-50-56-b4-75-5e/grub2/boot-sb.efi";

[root@vm ~]# pesign -S -i /var/lib/tftpboot/host-config/00-50-56-b4-75-5e/grub2/boot-sb.efi | grep "Microsoft Windows UEFI Driver Publisher"
The signer's common name is Microsoft Windows UEFI Driver Publisher
  • Loading branch information
Jan Löser authored and goarsna committed Sep 19, 2024
1 parent 7df1868 commit de99fe9
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 10 deletions.
118 changes: 112 additions & 6 deletions modules/tftp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ def delete_file(file)
logger.debug "TFTP: Skipping a request to delete a file which doesn't exists"
end
end

def delete_host_dir(mac)
host_dir = File.join(path, 'host-config', dashed_mac(mac).downcase)
logger.debug "TFTP: Removing directory '#{host_dir}'."
FileUtils.rm_rf host_dir
end

def setup_bootloader(mac:, os:, release:, arch:, bootfile_suffix:)
end

def dashed_mac(mac)
mac.tr(':', '-')
end
end

class Syslinux < Server
Expand All @@ -75,7 +88,7 @@ def pxe_default
end

def pxeconfig_file(mac)
["#{pxeconfig_dir}/01-" + mac.tr(':', "-").downcase]
["#{pxeconfig_dir}/01-" + dashed_mac(mac).downcase]
end
end
class Pxelinux < Syslinux; end
Expand All @@ -90,21 +103,114 @@ def pxe_default
end

def pxeconfig_file(mac)
["#{pxeconfig_dir}/menu.lst.01" + mac.delete(':').upcase, "#{pxeconfig_dir}/01-" + mac.tr(':', '-').upcase]
["#{pxeconfig_dir}/menu.lst.01" + mac.delete(':').upcase, "#{pxeconfig_dir}/01-" + dashed_mac(mac).upcase]
end
end

class Pxegrub2 < Server
def pxeconfig_dir
"#{path}/grub2"
def bootloader_path(os, release, arch)
[release, "default"].each do |version|
bootloader_path = File.join(path, 'bootloader-universe/pxegrub2', os, version, arch)

logger.debug "TFTP: Checking if bootloader universe is configured for OS '#{os}' version '#{version}' (#{arch})."
logger.debug "TFTP: Checking if directory '#{bootloader_path}' exists."

return bootloader_path if Dir.exist?(bootloader_path)

logger.debug "TFTP: Directory '#{bootloader_path}' does not exist."
end
nil
end

def create_bootloader_universe_symlinks(bootloader_path, pxeconfig_dir_mac)
symlinks = []
Dir.glob(File.join(bootloader_path, '*.efi')).each do |source_file|
symlinks << { source: Pathname.new(source_file), symlink: Pathname.new(File.join(pxeconfig_dir_mac, File.basename(source_file))) }
end
create_symlinks(symlinks)
end

def create_default_symlinks(bootfile_suffix, pxeconfig_dir_mac)
pxeconfig_dir = pxeconfig_dir()

grub_source = "grub#{bootfile_suffix}.efi"
shim_source = "shim#{bootfile_suffix}.efi"

symlinks = [
{ source: grub_source, symlink: "boot.efi" },
{ source: grub_source, symlink: grub_source },
{ source: shim_source, symlink: "boot-sb.efi" },
{ source: shim_source, symlink: shim_source },
]
symlinks.each do |link|
link[:source] = Pathname.new(File.join(pxeconfig_dir, link[:source]))
link[:symlink] = Pathname.new(File.join(pxeconfig_dir_mac, link[:symlink]))
end

create_symlinks(symlinks)
end

def create_symlinks(symlinks)
symlinks.each do |link|
relative_source_path = link[:source].relative_path_from(link[:symlink].parent)

logger.debug "TFTP: Creating relative symlink: #{link[:symlink]} -> #{relative_source_path}"
FileUtils.ln_s(relative_source_path.to_s, link[:symlink].to_s, force: true)
end
end

# Configures bootloader files for a host in its host-config directory
#
# @param mac [String] The MAC address of the host
# @param os [String] The lowercase name of the operating system of the host
# @param release [String] The major and minor version of the operating system of the host
# @param arch [String] The architecture of the operating system of the host
# @param bootfile_suffix [String] The architecture specific boot filename suffix
def setup_bootloader(mac:, os:, release:, arch:, bootfile_suffix:)
pxeconfig_dir_mac = pxeconfig_dir(mac)

logger.debug "TFTP: Deploying host specific bootloader files to '#{pxeconfig_dir_mac}'."

FileUtils.mkdir_p(pxeconfig_dir_mac)
FileUtils.rm_f(Dir.glob("#{pxeconfig_dir_mac}/*.efi"))

bootloader_path = bootloader_path(os, release, arch)

if bootloader_path
logger.debug "TFTP: Creating symlinks from bootloader universe."
create_bootloader_universe_symlinks(bootloader_path, pxeconfig_dir_mac)
else
logger.debug "TFTP: Creating symlinks from default bootloader files."
create_default_symlinks(bootfile_suffix, pxeconfig_dir_mac)
end
end

def del(mac)
super mac
delete_host_dir mac
end

def pxeconfig_dir(mac = nil)
if mac
File.join(path, 'host-config', dashed_mac(mac).downcase, 'grub2')
else
File.join(path, 'grub2')
end
end

def pxe_default
["#{pxeconfig_dir}/grub.cfg"]
end

def pxeconfig_file(mac)
["#{pxeconfig_dir}/grub.cfg-01-" + mac.tr(':', '-').downcase, "#{pxeconfig_dir}/grub.cfg-#{mac.downcase}"]
pxeconfig_dir_mac = pxeconfig_dir(mac)
[
"#{pxeconfig_dir_mac}/grub.cfg",
"#{pxeconfig_dir_mac}/grub.cfg-01-#{dashed_mac(mac).downcase}",
"#{pxeconfig_dir_mac}/grub.cfg-#{mac.downcase}",
"#{pxeconfig_dir}/grub.cfg-01-" + dashed_mac(mac).downcase,
"#{pxeconfig_dir}/grub.cfg-#{mac.downcase}",
]
end
end

Expand Down Expand Up @@ -146,7 +252,7 @@ def pxe_default
end

def pxeconfig_file(mac)
["#{pxeconfig_dir}/01-" + mac.tr(':', "-").downcase + ".ipxe"]
["#{pxeconfig_dir}/01-" + dashed_mac(mac).downcase + ".ipxe"]
end
end

Expand Down
5 changes: 3 additions & 2 deletions modules/tftp/tftp_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ def instantiate(variant, mac = nil)
Object.const_get("Proxy").const_get('TFTP').const_get(variant.capitalize).new
end

def create(variant, mac)
def create(variant, mac, os: nil, release: nil, arch: nil, bootfile_suffix: nil)
tftp = instantiate variant, mac
log_halt(400, "TFTP: Failed to setup host specific bootloader directory: ") { tftp.setup_bootloader(mac: mac, os: os, release: release, arch: arch, bootfile_suffix: bootfile_suffix) }
log_halt(400, "TFTP: Failed to create pxe config file: ") { tftp.set(mac, (params[:pxeconfig] || params[:syslinux_config])) }
end

Expand Down Expand Up @@ -48,7 +49,7 @@ def create_default(variant)
end

post "/:variant/:mac" do |variant, mac|
create variant, mac
create variant, mac, os: params[:targetos], release: params[:release], arch: params[:arch], bootfile_suffix: params[:bootfile_suffix]
end

delete "/:variant/:mac" do |variant, mac|
Expand Down
2 changes: 2 additions & 0 deletions modules/tftp/tftp_plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Proxy::TFTP
class Plugin < ::Proxy::Plugin
plugin :tftp, ::Proxy::VERSION

capability :bootloader_universe

rackup_path File.expand_path("http_config.ru", __dir__)

default_settings :tftproot => '/var/lib/tftpboot',
Expand Down
2 changes: 1 addition & 1 deletion test/tftp/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def test_features
mod = response['tftp']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:tftp])
assert_equal([], mod['capabilities'])
assert_equal(["bootloader_universe"], mod['capabilities'])

expected_settings = { 'tftp_servername' => 'tftp.example.com' }
assert_equal(expected_settings, mod['settings'])
Expand Down
8 changes: 7 additions & 1 deletion test/tftp/tftp_server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ class TftpPxegrub2ServerTest < Test::Unit::TestCase

def setup_paths
@subject = Proxy::TFTP::Pxegrub2.new
@pxe_config_files = ["grub2/grub.cfg-01-aa-bb-cc-dd-ee-ff", "grub2/grub.cfg-aa:bb:cc:dd:ee:ff"]
@pxe_config_files = [
"host-config/aa-bb-cc-dd-ee-ff/grub2/grub.cfg",
"host-config/aa-bb-cc-dd-ee-ff/grub2/grub.cfg-01-aa-bb-cc-dd-ee-ff",
"host-config/aa-bb-cc-dd-ee-ff/grub2/grub.cfg-aa:bb:cc:dd:ee:ff",
"grub2/grub.cfg-01-aa-bb-cc-dd-ee-ff",
"grub2/grub.cfg-aa:bb:cc:dd:ee:ff",
]
@pxe_default_files = ["grub2/grub.cfg"]
end
end
Expand Down

0 comments on commit de99fe9

Please sign in to comment.