mirror of
https://github.com/webmin/webmin.git
synced 2026-06-09 22:40:23 +01:00
3317 lines
98 KiB
Prolog
3317 lines
98 KiB
Prolog
# grub2-lib.pl
|
|
# Helpers for the GRUB 2 Webmin module.
|
|
|
|
BEGIN { push(@INC, ".."); }; ## no critic
|
|
use strict;
|
|
use warnings;
|
|
use WebminCore;
|
|
use Cwd qw(abs_path);
|
|
use File::Basename qw(basename dirname);
|
|
use File::Find;
|
|
use File::Path qw(make_path remove_tree);
|
|
use Fcntl qw(O_CREAT O_EXCL O_WRONLY);
|
|
use Errno qw(EEXIST);
|
|
|
|
our (%config, %text);
|
|
our ($module_root_directory, $module_var_directory);
|
|
our ($grub2_config_change_flag, $grub2_generate_time_flag);
|
|
|
|
&init_config();
|
|
$grub2_config_change_flag = $module_var_directory."/config-flag";
|
|
$grub2_generate_time_flag = $module_var_directory."/generate-flag";
|
|
&load_grub2_defaults();
|
|
|
|
# grub2_mark_regenerate_needed()
|
|
# Updates the flag indicating that grub.cfg needs to be regenerated.
|
|
sub grub2_mark_regenerate_needed
|
|
{
|
|
&open_lock_tempfile(my $fh, ">$grub2_config_change_flag", 0, 1);
|
|
&close_tempfile($fh);
|
|
return;
|
|
}
|
|
|
|
# grub2_mark_generated()
|
|
# Updates the flag indicating that grub.cfg has been regenerated.
|
|
sub grub2_mark_generated
|
|
{
|
|
&open_lock_tempfile(my $fh, ">$grub2_generate_time_flag", 0, 1);
|
|
&close_tempfile($fh);
|
|
return;
|
|
}
|
|
|
|
# grub2_needs_regenerate()
|
|
# Returns true when a saved source change has not been regenerated yet.
|
|
sub grub2_needs_regenerate
|
|
{
|
|
my @cst = stat($grub2_config_change_flag);
|
|
my @gst = stat($grub2_generate_time_flag);
|
|
return 0 if (!@cst);
|
|
return 1 if (!@gst);
|
|
return $cst[9] > $gst[9] ? 1 : 0;
|
|
}
|
|
|
|
# grub2_action_links(&acl, [return-url])
|
|
# Returns header action links for applying pending GRUB source changes.
|
|
sub grub2_action_links
|
|
{
|
|
my ($acl, $return_url) = @_;
|
|
return '' if (!$acl->{'apply'} || !&grub2_command('mkconfig_cmd'));
|
|
$return_url ||= &grub2_this_url();
|
|
my $label = $text{'index_generate'};
|
|
$label = &ui_tag('b', $label) if (&grub2_needs_regenerate());
|
|
return &ui_link("generate.cgi?redir=".&urlize($return_url), $label);
|
|
}
|
|
|
|
# grub2_this_url()
|
|
# Returns the current module URL for apply-action redirects.
|
|
sub grub2_this_url
|
|
{
|
|
my $url = $ENV{'SCRIPT_NAME'} || '';
|
|
$url .= "?$ENV{'QUERY_STRING'}"
|
|
if (defined($ENV{'QUERY_STRING'}) && $ENV{'QUERY_STRING'} ne '');
|
|
return $url;
|
|
}
|
|
|
|
# load_grub2_defaults()
|
|
# Fills missing runtime config values from the bundled module defaults.
|
|
sub load_grub2_defaults
|
|
{
|
|
my %defaults;
|
|
if ($module_root_directory && -r "$module_root_directory/config") {
|
|
# Start with bundled defaults so unconfigured installs have sane paths.
|
|
&read_file("$module_root_directory/config", \%defaults);
|
|
}
|
|
foreach my $k (keys %defaults) {
|
|
if (!defined($config{$k}) || $config{$k} eq '') {
|
|
$config{$k} = $defaults{$k};
|
|
}
|
|
}
|
|
&discover_grub2_runtime_defaults();
|
|
}
|
|
|
|
# discover_grub2_runtime_defaults()
|
|
# Corrects missing generic defaults to the GRUB 2 layout installed locally.
|
|
sub discover_grub2_runtime_defaults
|
|
{
|
|
# Prefer common distro paths before falling back to the packaged config.
|
|
&prefer_existing_file('grub_cfg',
|
|
'/boot/grub2/grub.cfg',
|
|
'/boot/grub/grub.cfg',
|
|
'/boot/efi/EFI/redhat/grub.cfg',
|
|
'/boot/efi/EFI/rocky/grub.cfg',
|
|
'/boot/efi/EFI/almalinux/grub.cfg',
|
|
'/boot/efi/EFI/centos/grub.cfg',
|
|
'/boot/efi/EFI/debian/grub.cfg',
|
|
);
|
|
&prefer_existing_file('grubenv_file',
|
|
'/boot/grub2/grubenv',
|
|
'/boot/grub/grubenv',
|
|
'/boot/efi/EFI/redhat/grubenv',
|
|
'/boot/efi/EFI/rocky/grubenv',
|
|
'/boot/efi/EFI/almalinux/grubenv',
|
|
'/boot/efi/EFI/centos/grubenv',
|
|
'/boot/efi/EFI/debian/grubenv',
|
|
);
|
|
&prefer_existing_dir('bls_dir', '/boot/loader/entries');
|
|
&prefer_existing_dir('theme_dir',
|
|
'/boot/grub2/themes',
|
|
'/boot/grub/themes',
|
|
);
|
|
&prefer_existing_dir('background_dir',
|
|
'/boot/grub2/backgrounds',
|
|
'/boot/grub/backgrounds',
|
|
);
|
|
&prefer_existing_command('mkconfig_cmd',
|
|
qw(grub2-mkconfig grub-mkconfig));
|
|
&prefer_existing_command('install_cmd',
|
|
qw(grub2-install grub-install));
|
|
&prefer_existing_command('set_default_cmd',
|
|
qw(grub2-set-default grub-set-default));
|
|
&prefer_existing_command('reboot_once_cmd',
|
|
qw(grub2-reboot grub-reboot));
|
|
&prefer_existing_command('editenv_cmd',
|
|
qw(grub2-editenv grub-editenv));
|
|
# Optional helpers unlock safer validation and BLS updates when installed.
|
|
&prefer_existing_command('script_check_cmd',
|
|
qw(grub2-script-check grub-script-check));
|
|
&prefer_existing_command('mkpasswd_cmd',
|
|
qw(grub2-mkpasswd-pbkdf2 grub-mkpasswd-pbkdf2));
|
|
&prefer_existing_command('grubby_cmd', qw(grubby));
|
|
}
|
|
|
|
# prefer_existing_file(key, paths...)
|
|
# Uses the first existing path when the configured file is missing.
|
|
sub prefer_existing_file
|
|
{
|
|
my ($key, @paths) = @_;
|
|
my $current = $config{$key};
|
|
return if (defined($current) && $current ne '' && -e $current);
|
|
foreach my $path (@paths) {
|
|
if (-e $path) {
|
|
$config{$key} = $path;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
# prefer_existing_dir(key, paths...)
|
|
# Uses the first existing directory when the configured directory is missing.
|
|
sub prefer_existing_dir
|
|
{
|
|
my ($key, @paths) = @_;
|
|
my $current = $config{$key};
|
|
return if (defined($current) && $current ne '' && -d $current);
|
|
foreach my $path (@paths) {
|
|
if (-d $path) {
|
|
$config{$key} = $path;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
# prefer_existing_command(key, commands...)
|
|
# Uses the first available GRUB command when the configured command is missing.
|
|
sub prefer_existing_command
|
|
{
|
|
my ($key, @commands) = @_;
|
|
my $current = $config{$key};
|
|
return if (defined($current) && $current ne '' && &has_command($current));
|
|
foreach my $cmd (@commands) {
|
|
my $found = &has_command($cmd);
|
|
if ($found) {
|
|
$config{$key} = $found;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
# grub2_config_value(key)
|
|
# Returns a module configuration value after defaults have been loaded.
|
|
sub grub2_config_value
|
|
{
|
|
my ($key) = @_;
|
|
return $config{$key};
|
|
}
|
|
|
|
# grub2_command(key)
|
|
# Returns a usable configured command path, if available.
|
|
sub grub2_command
|
|
{
|
|
my ($key) = @_;
|
|
my $cmd = &grub2_config_value($key);
|
|
return if (!defined($cmd) || $cmd eq '');
|
|
return &has_command($cmd);
|
|
}
|
|
|
|
# grub2_version_text()
|
|
# Returns a friendly GRUB version string for page subtitles.
|
|
sub grub2_version_text
|
|
{
|
|
foreach my $key (qw(install_cmd mkconfig_cmd editenv_cmd set_default_cmd)) {
|
|
my $cmd = &grub2_command($key);
|
|
next if (!$cmd);
|
|
# Any installed GRUB helper can report the package version.
|
|
my $out = &backquote_command(
|
|
quotemeta($cmd).' --version 2>&1 </dev/null', 1);
|
|
next if ($? || !$out);
|
|
$out =~ s/\r?\n.*\z//s;
|
|
$out =~ s/^\s+|\s+\z//g;
|
|
if ($out =~ /\((?:GRUB|GRUB2)\)\s+(\S+)/i ||
|
|
$out =~ /\bGRUB\s+(\S+)/i) {
|
|
my $version = $1;
|
|
return &text('index_version', $version);
|
|
}
|
|
return $out;
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_any_installed()
|
|
# Returns true if GRUB 2 files or commands are present on this system.
|
|
sub grub2_any_installed
|
|
{
|
|
return 1 if (&grub2_command('mkconfig_cmd'));
|
|
return 1 if (&grub2_command('install_cmd'));
|
|
return 1 if (&grub2_command('set_default_cmd'));
|
|
return 1 if (&grub2_command('editenv_cmd'));
|
|
return 1 if (-r (&grub2_config_value('default_file') || ''));
|
|
return 1 if (-r (&grub2_config_value('grub_cfg') || ''));
|
|
return 0;
|
|
}
|
|
|
|
# grub2_configured()
|
|
# Returns true if the module has enough files to inspect a GRUB 2 setup.
|
|
sub grub2_configured
|
|
{
|
|
return 1 if (-r (&grub2_config_value('default_file') || ''));
|
|
return 1 if (-r (&grub2_config_value('grub_cfg') || ''));
|
|
return 0;
|
|
}
|
|
|
|
# grub2_install_issues()
|
|
# Returns human-readable missing items for the module index.
|
|
sub grub2_install_issues
|
|
{
|
|
my @issues;
|
|
my $default_file = &grub2_config_value('default_file') || '';
|
|
my $grub_cfg = &grub2_config_value('grub_cfg') || '';
|
|
push(@issues, $default_file) if ($default_file ne '' && !-r $default_file);
|
|
push(@issues, $grub_cfg) if ($grub_cfg ne '' && !-r $grub_cfg);
|
|
push(@issues, &grub2_config_value('mkconfig_cmd') || 'grub-mkconfig')
|
|
if (!&grub2_command('mkconfig_cmd'));
|
|
return @issues;
|
|
}
|
|
|
|
# grub2_default_keys()
|
|
# Returns settings edited by the structured defaults page.
|
|
sub grub2_default_keys
|
|
{
|
|
return qw(
|
|
GRUB_DEFAULT
|
|
GRUB_TIMEOUT_STYLE
|
|
GRUB_TIMEOUT
|
|
GRUB_TERMINAL_OUTPUT
|
|
GRUB_GFXMODE
|
|
GRUB_CMDLINE_LINUX_DEFAULT
|
|
GRUB_CMDLINE_LINUX
|
|
GRUB_DISABLE_RECOVERY
|
|
GRUB_DISABLE_OS_PROBER
|
|
GRUB_THEME
|
|
GRUB_BACKGROUND
|
|
GRUB_COLOR_NORMAL
|
|
GRUB_COLOR_HIGHLIGHT
|
|
);
|
|
}
|
|
|
|
# grub2_bls_update_available([&entries])
|
|
# Returns true when existing BLS entries can be updated with grubby.
|
|
sub grub2_bls_update_available
|
|
{
|
|
my ($entries) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
return 0 if (!&grub2_command('grubby_cmd'));
|
|
return (grep { ($_->{'source'} || '') eq 'bls' } @$entries) ? 1 : 0;
|
|
}
|
|
|
|
# grub2_defaults_updates_need_generate(&old-values, &updates, bls-args-updated?)
|
|
# Returns true when saved defaults still need grub-mkconfig regeneration.
|
|
sub grub2_defaults_updates_need_generate
|
|
{
|
|
my ($old_values, $updates, $bls_args_updated) = @_;
|
|
my %bls_updated = ref($bls_args_updated) eq 'HASH' ? %$bls_args_updated :
|
|
$bls_args_updated ? (
|
|
'GRUB_CMDLINE_LINUX' => 1,
|
|
'GRUB_CMDLINE_LINUX_DEFAULT' => 1,
|
|
) : ();
|
|
foreach my $key (keys %$updates) {
|
|
my $old = defined($old_values->{$key}) ? $old_values->{$key} : '';
|
|
my $new = defined($updates->{$key}) ? $updates->{$key} : '';
|
|
next if ($old eq $new);
|
|
# grubby already applied these BLS-facing changes to live entries.
|
|
next if ($bls_updated{$key} &&
|
|
($key eq 'GRUB_CMDLINE_LINUX' ||
|
|
$key eq 'GRUB_CMDLINE_LINUX_DEFAULT' ||
|
|
$key eq 'GRUB_DISABLE_RECOVERY'));
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# grub2_password_file()
|
|
# Returns the managed GRUB password script path.
|
|
sub grub2_password_file
|
|
{
|
|
my $file = &grub2_config_value('password_file');
|
|
return $file if (defined($file) && $file ne '');
|
|
my $dir = &grub2_config_value('grub_dir') || '/etc/grub.d';
|
|
return "$dir/01_webmin_password";
|
|
}
|
|
|
|
# grub2_default_efi_directory()
|
|
# Returns a likely EFI system partition mount point, if one exists.
|
|
sub grub2_default_efi_directory
|
|
{
|
|
foreach my $dir ('/boot/efi', '/efi') {
|
|
return $dir if (-d $dir);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
# grub2_default_bootloader_id([efi-dir])
|
|
# Returns a likely EFI boot loader ID from existing GRUB files.
|
|
sub grub2_default_bootloader_id
|
|
{
|
|
my ($efi_dir) = @_;
|
|
foreach my $file (&grub2_config_value('grub_cfg'),
|
|
&grub2_config_value('grubenv_file')) {
|
|
next if (!defined($file));
|
|
if ($file =~ m{\A/(?:boot/efi|efi)/EFI/([^/]+)/}) {
|
|
return $1 if (&valid_bootloader_id_candidate($1));
|
|
}
|
|
}
|
|
$efi_dir ||= &grub2_default_efi_directory();
|
|
return '' if ($efi_dir eq '' || !-d "$efi_dir/EFI");
|
|
opendir(my $dh, "$efi_dir/EFI") || return '';
|
|
my @dirs = grep { -d "$efi_dir/EFI/$_" && &valid_bootloader_id_candidate($_) }
|
|
readdir($dh);
|
|
closedir($dh);
|
|
my @matches;
|
|
foreach my $dir (sort @dirs) {
|
|
my $path = "$efi_dir/EFI/$dir";
|
|
if (-e "$path/grub.cfg" || -e "$path/grubenv" ||
|
|
&efi_vendor_dir_has_loader($path)) {
|
|
push(@matches, $dir);
|
|
}
|
|
}
|
|
return @matches == 1 ? $matches[0] : '';
|
|
}
|
|
|
|
# efi_vendor_dir_has_loader(dir)
|
|
# Returns true if an EFI vendor directory contains a likely GRUB/shim loader.
|
|
sub efi_vendor_dir_has_loader
|
|
{
|
|
my ($dir) = @_;
|
|
opendir(my $dh, $dir) || return 0;
|
|
my $found = grep { /^(?:grub.*|shim.*)\.efi\z/i && -f "$dir/$_" }
|
|
readdir($dh);
|
|
closedir($dh);
|
|
return $found ? 1 : 0;
|
|
}
|
|
|
|
# valid_bootloader_id_candidate(value)
|
|
# Returns true if a value is suitable for --bootloader-id.
|
|
sub valid_bootloader_id_candidate
|
|
{
|
|
my ($value) = @_;
|
|
return 0 if (!defined($value) || $value eq '');
|
|
return 0 if ($value =~ /[\r\n\0]/ || $value =~ /^-/ ||
|
|
$value !~ /\A[A-Za-z0-9_.+-]+\z/);
|
|
return 0 if ($value =~ /^(?:boot|microsoft)\z/i);
|
|
return 1;
|
|
}
|
|
|
|
# grub2_boot_mode([efi-firmware-dir])
|
|
# Returns uefi when the system booted via EFI firmware, or bios otherwise.
|
|
sub grub2_boot_mode
|
|
{
|
|
my ($efi_dir) = @_;
|
|
$efi_dir ||= '/sys/firmware/efi';
|
|
return -d $efi_dir ? 'uefi' : 'bios';
|
|
}
|
|
|
|
# grub2_secure_boot_status([efi-firmware-dir], [efivars-dir], [mokutil-cmd])
|
|
# Returns enabled, disabled, unknown, or not_applicable for Secure Boot.
|
|
sub grub2_secure_boot_status
|
|
{
|
|
my ($efi_dir, $efivars_dir, $mokutil_cmd) = @_;
|
|
$efi_dir ||= '/sys/firmware/efi';
|
|
return 'not_applicable' if (&grub2_boot_mode($efi_dir) ne 'uefi');
|
|
if (!defined($mokutil_cmd)) {
|
|
$mokutil_cmd = &has_command('mokutil') || '';
|
|
}
|
|
if ($mokutil_cmd ne '') {
|
|
my $out = &backquote_command(
|
|
quotemeta($mokutil_cmd).' --sb-state 2>&1 </dev/null', 1);
|
|
if (!$?) {
|
|
return 'enabled' if ($out =~ /SecureBoot\s+enabled/i);
|
|
return 'disabled'
|
|
if ($out =~ /SecureBoot\s+disabled/i ||
|
|
$out =~ /validation\s+is\s+disabled/i);
|
|
}
|
|
}
|
|
$efivars_dir ||= "$efi_dir/efivars";
|
|
my $efivar_status = &grub2_secure_boot_efivar_status($efivars_dir);
|
|
return $efivar_status if ($efivar_status);
|
|
return 'unknown';
|
|
}
|
|
|
|
# grub2_secure_boot_efivar_status(efivars-dir)
|
|
# Reads the SecureBoot EFI variable from efivarfs when mokutil is absent.
|
|
sub grub2_secure_boot_efivar_status
|
|
{
|
|
my ($efivars_dir) = @_;
|
|
return if (!defined($efivars_dir) || $efivars_dir eq '' ||
|
|
!-d $efivars_dir);
|
|
opendir(my $dh, $efivars_dir) || return;
|
|
my @vars = grep { /^SecureBoot-/ && -r "$efivars_dir/$_" } readdir($dh);
|
|
closedir($dh);
|
|
foreach my $var (@vars) {
|
|
my $data = &read_file_contents("$efivars_dir/$var");
|
|
next if (!defined($data) || length($data) < 5);
|
|
return ord(substr($data, 4, 1)) ? 'enabled' : 'disabled';
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_default_platform_target()
|
|
# Returns the likely grub-install platform target for this boot mode.
|
|
sub grub2_default_platform_target
|
|
{
|
|
my $machine = &backquote_command('uname -m 2>/dev/null </dev/null');
|
|
$machine =~ s/^\s+|\s+\z//g if (defined($machine));
|
|
if (&grub2_boot_mode() eq 'uefi') {
|
|
return 'x86_64-efi' if ($machine =~ /^(?:x86_64|amd64)\z/i);
|
|
return 'i386-efi' if ($machine =~ /^i[3-6]86\z/i);
|
|
return 'arm64-efi' if ($machine =~ /^(?:aarch64|arm64)\z/i);
|
|
return 'arm-efi' if ($machine =~ /^arm/i);
|
|
return 'riscv64-efi' if ($machine =~ /^riscv64\z/i);
|
|
}
|
|
return 'i386-pc' if ($machine =~ /^(?:x86_64|amd64|i[3-6]86)\z/i);
|
|
return '';
|
|
}
|
|
|
|
# grub2_platform_module_dirs(platform)
|
|
# Returns possible GRUB module directories for a platform target.
|
|
sub grub2_platform_module_dirs
|
|
{
|
|
my ($platform) = @_;
|
|
return () if (!defined($platform) || $platform eq '');
|
|
return (
|
|
"/usr/lib/grub/$platform",
|
|
"/usr/lib/grub2/$platform",
|
|
"/usr/share/grub/$platform",
|
|
"/usr/share/grub2/$platform",
|
|
);
|
|
}
|
|
|
|
# grub2_platform_module_dir(platform)
|
|
# Returns the first module directory containing modinfo.sh.
|
|
sub grub2_platform_module_dir
|
|
{
|
|
my ($platform) = @_;
|
|
foreach my $dir (&grub2_platform_module_dirs($platform)) {
|
|
return $dir if (-r "$dir/modinfo.sh");
|
|
}
|
|
return '';
|
|
}
|
|
|
|
# grub2_color_file()
|
|
# Returns the managed GRUB menu color generator script path.
|
|
sub grub2_color_file
|
|
{
|
|
my $file = &grub2_config_value('color_file');
|
|
return $file if (defined($file) && $file ne '');
|
|
my $dir = &grub2_config_value('grub_dir') || '/etc/grub.d';
|
|
return "$dir/06_webmin_colors";
|
|
}
|
|
|
|
# grub2_theme_dir()
|
|
# Returns the directory where Webmin installs GRUB themes.
|
|
sub grub2_theme_dir
|
|
{
|
|
my $dir = &grub2_config_value('theme_dir');
|
|
return $dir if (defined($dir) && $dir ne '');
|
|
my $cfg = &grub2_config_value('grub_cfg') || '';
|
|
return "$1/themes" if ($cfg =~ m{\A(/boot/grub2|/boot/grub)/});
|
|
return '/boot/grub2/themes' if (-d '/boot/grub2');
|
|
return '/boot/grub/themes';
|
|
}
|
|
|
|
# grub2_background_dir()
|
|
# Returns the directory where Webmin installs GRUB background images.
|
|
sub grub2_background_dir
|
|
{
|
|
my $dir = &grub2_config_value('background_dir');
|
|
return $dir if (defined($dir) && $dir ne '');
|
|
my $theme_dir = &grub2_theme_dir();
|
|
return "$1/backgrounds" if ($theme_dir =~ m{\A(.+)/themes/?\z});
|
|
my $cfg = &grub2_config_value('grub_cfg') || '';
|
|
return "$1/backgrounds" if ($cfg =~ m{\A(/boot/grub2|/boot/grub)/});
|
|
return '/boot/grub2/backgrounds' if (-d '/boot/grub2');
|
|
return '/boot/grub/backgrounds';
|
|
}
|
|
|
|
# grub2_color_names()
|
|
# Returns color names accepted by GRUB menu color settings.
|
|
sub grub2_color_names
|
|
{
|
|
return qw(
|
|
black blue green cyan red magenta brown light-gray dark-gray
|
|
light-blue light-green light-cyan light-red light-magenta yellow white
|
|
);
|
|
}
|
|
|
|
# grub2_validate_setting_path(value, label)
|
|
# Returns an error if a GRUB defaults path is not safe to save.
|
|
sub grub2_validate_setting_path
|
|
{
|
|
my ($value, $label) = @_;
|
|
return if (!defined($value) || $value eq '');
|
|
return &text('defaults_epath', $label) if ($value =~ /[\r\n\0]/);
|
|
return &text('defaults_eabspath', $label) if ($value !~ m{^/});
|
|
return &text('defaults_epathchars', $label)
|
|
if ($value !~ m{\A/[A-Za-z0-9._/+ -]+\z});
|
|
return;
|
|
}
|
|
|
|
# grub2_validate_theme_path(value, label)
|
|
# Returns an error if a GRUB theme setting cannot affect generated menus.
|
|
sub grub2_validate_theme_path
|
|
{
|
|
my ($value, $label) = @_;
|
|
my $err = &grub2_validate_setting_path($value, $label);
|
|
return $err if ($err);
|
|
return if (!defined($value) || $value eq '');
|
|
return &text('defaults_etheme_archive', $value)
|
|
if ($value =~ /\.(?:tar|tar\.(?:gz|bz2|xz|zst)|tgz|tbz2|txz|zip)\z/i);
|
|
return &text('defaults_etheme_file', $value) if (!-r $value || -d $value);
|
|
return;
|
|
}
|
|
|
|
# grub2_validate_background_path(value, label)
|
|
# Returns an error if a GRUB background image path is unusable.
|
|
sub grub2_validate_background_path
|
|
{
|
|
my ($value, $label) = @_;
|
|
my $err = &grub2_validate_setting_path($value, $label);
|
|
return $err if ($err);
|
|
return if (!defined($value) || $value eq '');
|
|
return &text('defaults_ebackground_file', $value) if (!-r $value || -d $value);
|
|
return &text('defaults_ebackground_type', $value)
|
|
if ($value !~ /\.(?:png|jpe?g|tga)\z/i);
|
|
return;
|
|
}
|
|
|
|
# grub2_validate_gfxmode(value)
|
|
# Returns an error if a GRUB graphics mode setting is unsafe or malformed.
|
|
sub grub2_validate_gfxmode
|
|
{
|
|
my ($value) = @_;
|
|
$value = '' if (!defined($value));
|
|
return if ($value eq '');
|
|
return $text{'defaults_egfxmode'}
|
|
if ($value !~
|
|
/\A(?:auto|keep|\d{3,5}x\d{3,5}(?:x\d{1,2})?)(?:,(?:auto|keep|\d{3,5}x\d{3,5}(?:x\d{1,2})?))*\z/);
|
|
return;
|
|
}
|
|
|
|
# grub2_install_theme_source(source)
|
|
# Installs a theme file, directory, archive, or URL under the GRUB boot tree.
|
|
sub grub2_install_theme_source
|
|
{
|
|
my ($source) = @_;
|
|
$source = '' if (!defined($source));
|
|
$source =~ s/^\s+|\s+\z//g;
|
|
return ('', $text{'defaults_etheme_source'}) if ($source eq '');
|
|
|
|
my ($file, $label, $cleanup, $err) = &grub2_prepare_theme_source($source);
|
|
return ('', $err) if ($err);
|
|
my ($theme_file, $tmpdir, $installed);
|
|
eval {
|
|
my $type = &grub2_theme_archive_type($label || $file);
|
|
if ($type) {
|
|
# Archives are extracted only after their member list is validated.
|
|
($tmpdir, $err) = &grub2_extract_theme_archive($file,
|
|
$type);
|
|
die "$err\n" if ($err);
|
|
$theme_file = &grub2_find_theme_file($tmpdir);
|
|
die $text{'defaults_etheme_notfound'}."\n"
|
|
if (!$theme_file);
|
|
}
|
|
else {
|
|
# Direct sources may be a theme.txt file or a directory containing one.
|
|
($theme_file, $err) = &grub2_theme_file_from_source($file);
|
|
die "$err\n" if ($err);
|
|
}
|
|
($installed, $err) =
|
|
&grub2_install_theme_directory(&grub2_dirname($theme_file),
|
|
$label || $file);
|
|
die "$err\n" if ($err);
|
|
1;
|
|
} || do {
|
|
$err = $@ || $!;
|
|
$err =~ s/\s+\z// if (defined($err));
|
|
};
|
|
if ($cleanup) {
|
|
# Remove downloaded sources even when validation or install failed.
|
|
if (-d $cleanup) {
|
|
remove_tree($cleanup);
|
|
}
|
|
else {
|
|
&grub2_unlink_temp($cleanup);
|
|
}
|
|
}
|
|
remove_tree($tmpdir) if ($tmpdir && -d $tmpdir);
|
|
return ('', $err) if ($err);
|
|
return ($installed);
|
|
}
|
|
|
|
# grub2_install_background_source(source)
|
|
# Copies a background image into the configured GRUB boot tree.
|
|
sub grub2_install_background_source
|
|
{
|
|
my ($source) = @_;
|
|
$source = '' if (!defined($source));
|
|
$source =~ s/^\s+|\s+\z//g;
|
|
return ('') if ($source eq '');
|
|
my $err = &grub2_validate_background_path($source,
|
|
$text{'defaults_background'});
|
|
return ('', $err) if ($err);
|
|
my $dir = &grub2_background_dir();
|
|
return ('', &text('defaults_econfigpath', $dir)) if ($dir !~ m{^/});
|
|
make_path($dir, { mode => oct("0755") }) if (!-d $dir);
|
|
return ('', &text('defaults_edir', $dir)) if (!-d $dir);
|
|
# Already-installed backgrounds can be reused without copying.
|
|
return ($source) if (&grub2_path_is_under($source, $dir));
|
|
|
|
my $base = basename($source);
|
|
# Sanitize the filename because the destination lives under /boot.
|
|
$base =~ s/[^A-Za-z0-9._+-]/_/g;
|
|
$base =~ s/\A[._-]+//;
|
|
$base = 'webmin-background.png' if ($base eq '');
|
|
my $dest = "$dir/$base";
|
|
$err = &grub2_copy_background_file($source, $dest);
|
|
return ('', $err) if ($err);
|
|
return ($dest);
|
|
}
|
|
|
|
# grub2_copy_background_file(source, destination)
|
|
# Copies one regular image file into a GRUB-readable location.
|
|
sub grub2_copy_background_file
|
|
{
|
|
my ($source, $dest) = @_;
|
|
my $real = eval { abs_path($source) };
|
|
return &text('defaults_ebackground_file', $source)
|
|
if (!$real || !-f $real || !-r $real);
|
|
my $in;
|
|
return "$source : $!" if (!CORE::open($in, '<', $real));
|
|
CORE::binmode($in);
|
|
my $dir = &grub2_dirname($dest);
|
|
make_path($dir, { mode => oct("0755") }) if ($dir ne '' && !-d $dir);
|
|
open_tempfile(my $out, ">$dest");
|
|
my $buf;
|
|
my $err = '';
|
|
local $! = 0;
|
|
while (read($in, $buf, 32768)) {
|
|
# Stream the copy so large images do not need to be loaded at once.
|
|
print_tempfile($out, $buf);
|
|
}
|
|
$err = "$!" if ($!);
|
|
my $cerr = close($in) ? '' : "$source : $!";
|
|
close_tempfile($out);
|
|
chmod(oct("0644"), $dest);
|
|
return $err if ($err);
|
|
return $cerr if ($cerr);
|
|
return;
|
|
}
|
|
|
|
# grub2_prepare_theme_source(source)
|
|
# Returns a local source file path for a local path or downloaded URL.
|
|
sub grub2_prepare_theme_source
|
|
{
|
|
my ($source) = @_;
|
|
if (&grub2_theme_source_is_url($source)) {
|
|
# Remote sources are downloaded to a private temp directory first.
|
|
return &grub2_download_theme_source($source);
|
|
}
|
|
return ('', '', '', &text('defaults_eabspath', $text{'defaults_theme_source'}))
|
|
if ($source !~ m{^/});
|
|
return ('', '', '', &text('defaults_etheme_file', $source))
|
|
if (!-e $source || !-r $source);
|
|
return ($source, $source, '');
|
|
}
|
|
|
|
# grub2_theme_source_is_url(source)
|
|
# Returns true when a theme source is an HTTP, HTTPS, or FTP URL.
|
|
sub grub2_theme_source_is_url
|
|
{
|
|
my ($source) = @_;
|
|
return defined($source) && $source =~ m{\A(?:https?|ftp)://}i ? 1 : 0;
|
|
}
|
|
|
|
# grub2_download_theme_source(url)
|
|
# Downloads a remote theme source using Webmin's HTTP or FTP helpers.
|
|
sub grub2_download_theme_source
|
|
{
|
|
my ($url) = @_;
|
|
my ($host, $port, $page, $ssl, $user, $pass) = &parse_http_url($url);
|
|
return ('', '', 0, $text{'defaults_etheme_url'})
|
|
if (!$host || !$page || ($ssl != 0 && $ssl != 1 && $ssl != 2));
|
|
my $base = $page;
|
|
$base =~ s/\?.*\z//;
|
|
$base = basename($base);
|
|
$base = 'theme-source' if ($base eq '' || $base =~ /\.\./);
|
|
# The downloaded filename is only a label; keep it filesystem-safe anyway.
|
|
$base =~ s/[^A-Za-z0-9._+-]/_/g;
|
|
my $tmpdir = &tempname("grub2-theme-download-$$-".int(rand(1000000)));
|
|
make_path($tmpdir, { mode => oct("0700") });
|
|
return ('', '', '', &text('defaults_edir', $tmpdir)) if (!-d $tmpdir);
|
|
my $temp = "$tmpdir/$base";
|
|
my $err = '';
|
|
if ($ssl == 2) {
|
|
# parse_http_url uses ssl==2 for FTP URLs in Webmin helpers.
|
|
my $ffile = $page;
|
|
$ffile =~ s{\A/}{};
|
|
&ftp_download($host, $ffile, $temp, \$err, undef, $user, $pass,
|
|
$port, 1);
|
|
}
|
|
else {
|
|
&http_download($host, $port, $page, $temp, \$err, undef, $ssl,
|
|
$user, $pass, 60, undef, 1);
|
|
}
|
|
if ($err) {
|
|
remove_tree($tmpdir);
|
|
return ('', '', '', &text('defaults_etheme_download', $err));
|
|
}
|
|
return ($temp, $url, $tmpdir);
|
|
}
|
|
|
|
# grub2_theme_archive_type(path-or-url)
|
|
# Returns the supported archive type for a source label.
|
|
sub grub2_theme_archive_type
|
|
{
|
|
my ($label) = @_;
|
|
$label = '' if (!defined($label));
|
|
$label =~ s/[?#].*\z//;
|
|
return 'targz' if ($label =~ /\.(?:tar\.gz|tgz)\z/i);
|
|
return 'tarbz2' if ($label =~ /\.(?:tar\.bz2|tbz2)\z/i);
|
|
return 'tarxz' if ($label =~ /\.(?:tar\.xz|txz)\z/i);
|
|
return 'tar' if ($label =~ /\.tar\z/i);
|
|
return 'zip' if ($label =~ /\.zip\z/i);
|
|
return '';
|
|
}
|
|
|
|
# grub2_theme_file_from_source(source)
|
|
# Returns a theme.txt file from a local source file or directory.
|
|
sub grub2_theme_file_from_source
|
|
{
|
|
my ($source) = @_;
|
|
if (-d $source) {
|
|
my $theme = &grub2_find_theme_file($source);
|
|
return ($theme) if ($theme);
|
|
return ('', $text{'defaults_etheme_notfound'});
|
|
}
|
|
return ('', &text('defaults_etheme_file', $source)) if (!-f $source);
|
|
return ($source) if (basename($source) eq 'theme.txt');
|
|
return ('', &text('defaults_etheme_nottheme', $source));
|
|
}
|
|
|
|
# grub2_extract_theme_archive(file, type)
|
|
# Extracts a validated theme archive into a private temporary directory.
|
|
sub grub2_extract_theme_archive
|
|
{
|
|
my ($file, $type) = @_;
|
|
my ($list_cmd, $err) = &grub2_archive_command($type, $file, 'list');
|
|
return ('', $err) if ($err);
|
|
# Validate the listing before extracting anything to disk.
|
|
my $out = &backquote_command($list_cmd.' 2>&1 </dev/null');
|
|
return ('', $out || $text{'defaults_etheme_archive_list'}) if ($?);
|
|
$err = &grub2_validate_archive_members(split(/\r?\n/, $out || ''));
|
|
return ('', $err) if ($err);
|
|
|
|
my $tmpdir = &tempname("grub2-theme-extract-$$-".int(rand(1000000)));
|
|
make_path($tmpdir, { mode => oct("0700") });
|
|
return ('', &text('defaults_edir', $tmpdir)) if (!-d $tmpdir);
|
|
my ($extract_cmd, $xerr) = &grub2_archive_command($type, $file, 'extract',
|
|
$tmpdir);
|
|
if ($xerr) {
|
|
remove_tree($tmpdir);
|
|
return ('', $xerr);
|
|
}
|
|
# Validate again after extraction to catch symlinks or unusual archive output.
|
|
$out = &backquote_command($extract_cmd.' 2>&1 </dev/null');
|
|
if ($?) {
|
|
remove_tree($tmpdir);
|
|
return ('', $out || $text{'defaults_etheme_extract'});
|
|
}
|
|
$err = &grub2_validate_extracted_theme_tree($tmpdir);
|
|
if ($err) {
|
|
remove_tree($tmpdir);
|
|
return ('', $err);
|
|
}
|
|
return ($tmpdir);
|
|
}
|
|
|
|
# grub2_archive_command(type, file, mode, [directory])
|
|
# Returns a tar or unzip command for listing or extracting an archive.
|
|
sub grub2_archive_command
|
|
{
|
|
my ($type, $file, $mode, $dir) = @_;
|
|
if ($type eq 'zip') {
|
|
my $unzip = &has_command('unzip');
|
|
return ('', &text('defaults_etheme_cmd', 'unzip')) if (!$unzip);
|
|
return ($mode eq 'list' ?
|
|
quotemeta($unzip).' -Z -l '.quotemeta($file) :
|
|
quotemeta($unzip).' -q -o '.quotemeta($file).
|
|
' -d '.quotemeta($dir));
|
|
}
|
|
my $tar = &has_command('tar');
|
|
return ('', &text('defaults_etheme_cmd', 'tar')) if (!$tar);
|
|
my %flags = (
|
|
'tar' => [ 'tvf', 'xf' ],
|
|
'targz' => [ 'tzvf', 'xzf' ],
|
|
'tarbz2' => [ 'tjvf', 'xjf' ],
|
|
'tarxz' => [ 'tJvf', 'xJf' ],
|
|
);
|
|
return ('', $text{'defaults_etheme_type'}) if (!$flags{$type});
|
|
my $flag = $mode eq 'list' ? $flags{$type}->[0] : $flags{$type}->[1];
|
|
my $cmd = quotemeta($tar).' '.$flag.' '.quotemeta($file);
|
|
$cmd .= ' -C '.quotemeta($dir) if ($mode ne 'list');
|
|
return ($cmd);
|
|
}
|
|
|
|
# grub2_validate_archive_members(member, ...)
|
|
# Rejects archive paths that could write outside the extraction directory.
|
|
sub grub2_validate_archive_members
|
|
{
|
|
foreach my $raw (@_) {
|
|
my ($member, $type) = &grub2_archive_member_from_list_line($raw);
|
|
next if (!defined($member));
|
|
$member =~ s/^\s+|\s+\z//g;
|
|
next if ($member eq '');
|
|
# Permit regular files and directories only; reject links and devices.
|
|
return &text('defaults_etheme_member', $member)
|
|
if (defined($type) && $type !~ /^[-d]\z/);
|
|
# Prevent archive traversal, absolute paths, and Windows separators.
|
|
return &text('defaults_etheme_member', $member)
|
|
if ($member =~ m{\A/} ||
|
|
$member =~ m{(?:\A|/)\.\.(?:/|\z)} ||
|
|
$member =~ /[\0\\]/);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_archive_member_from_list_line(line)
|
|
# Returns an archive member path and type from tar or zip verbose output.
|
|
sub grub2_archive_member_from_list_line
|
|
{
|
|
my ($line) = @_;
|
|
return if (!defined($line));
|
|
$line =~ s/^\s+|\s+\z//g;
|
|
return if ($line eq '' || $line =~ /^Archive:/ ||
|
|
$line =~ /^Zip file size:/ || $line =~ /^\d+\s+files?,/);
|
|
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\S+\s+\d+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.+)\z/) {
|
|
return ($2, $1);
|
|
}
|
|
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\d+\s+\S+\s+\S+\s+(.+)\z/) {
|
|
return ($2, $1);
|
|
}
|
|
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\S+\s+\d+\s+\S+\s+\S+\s+(.+)\z/) {
|
|
return ($2, $1);
|
|
}
|
|
return ($line, '-');
|
|
}
|
|
|
|
# grub2_validate_extracted_theme_tree(dir)
|
|
# Rejects unsafe extracted files before copying to /boot.
|
|
sub grub2_validate_extracted_theme_tree
|
|
{
|
|
my ($dir) = @_;
|
|
my $root = eval { abs_path($dir) };
|
|
return &text('defaults_edir', $dir) if (!$root);
|
|
my $err;
|
|
find({
|
|
no_chdir => 1,
|
|
wanted => sub {
|
|
return if ($err);
|
|
my $path = $File::Find::name;
|
|
return if ($path eq $dir);
|
|
my @st = lstat($path);
|
|
if (!@st || (!-d _ && !&grub2_theme_regular_source($path,
|
|
$root))) {
|
|
$err = &text('defaults_etheme_member', $path);
|
|
}
|
|
},
|
|
}, $dir);
|
|
return $err;
|
|
}
|
|
|
|
# grub2_find_theme_file(dir)
|
|
# Finds the most likely theme.txt under a source directory.
|
|
sub grub2_find_theme_file
|
|
{
|
|
my ($dir) = @_;
|
|
my @themes;
|
|
find({
|
|
no_chdir => 1,
|
|
wanted => sub {
|
|
push(@themes, $File::Find::name)
|
|
if (-f $File::Find::name &&
|
|
basename($File::Find::name) eq 'theme.txt');
|
|
},
|
|
}, $dir);
|
|
@themes = sort {
|
|
my $ad = () = $a =~ /\//g;
|
|
my $bd = () = $b =~ /\//g;
|
|
$ad <=> $bd || length($a) <=> length($b) || $a cmp $b;
|
|
} @themes;
|
|
return $themes[0] || '';
|
|
}
|
|
|
|
# grub2_install_theme_directory(source-dir, source-label)
|
|
# Copies one theme directory into the configured GRUB theme directory.
|
|
sub grub2_install_theme_directory
|
|
{
|
|
my ($srcdir, $label) = @_;
|
|
my $theme_dir = &grub2_theme_dir();
|
|
return ('', &text('defaults_econfigpath', $theme_dir))
|
|
if ($theme_dir !~ m{^/});
|
|
make_path($theme_dir, { mode => oct("0755") }) if (!-d $theme_dir);
|
|
return ('', &text('defaults_edir', $theme_dir)) if (!-d $theme_dir);
|
|
my $theme_file = "$srcdir/theme.txt";
|
|
return ('', $text{'defaults_etheme_notfound'}) if (!-r $theme_file);
|
|
return ($theme_file) if (&grub2_path_is_under($theme_file, $theme_dir));
|
|
|
|
my $name = &grub2_safe_theme_name(basename($srcdir));
|
|
if ($name eq '' || $name =~ /^grub2-theme-(?:download|extract)/) {
|
|
$name = &grub2_safe_theme_name(&grub2_theme_source_name($label));
|
|
}
|
|
$name = 'webmin-theme' if ($name eq '');
|
|
my $dest = &grub2_unique_theme_destination($theme_dir, $name);
|
|
my $err = &grub2_copy_theme_tree($srcdir, $dest);
|
|
if ($err) {
|
|
remove_tree($dest) if (-d $dest);
|
|
return ('', $err);
|
|
}
|
|
return ("$dest/theme.txt");
|
|
}
|
|
|
|
# grub2_theme_source_name(source-label)
|
|
# Returns a useful theme name from a source path or URL.
|
|
sub grub2_theme_source_name
|
|
{
|
|
my ($label) = @_;
|
|
$label = '' if (!defined($label));
|
|
$label =~ s/[?#].*\z//;
|
|
if ($label =~ m{/theme\.txt\z}i) {
|
|
$label =~ s{/theme\.txt\z}{};
|
|
}
|
|
my $name = basename($label);
|
|
$name =~ s/\.(?:tar\.gz|tar\.bz2|tar\.xz|tgz|tbz2|txz|tar|zip)\z//i;
|
|
$name =~ s/\.theme\z//i;
|
|
return $name;
|
|
}
|
|
|
|
# grub2_safe_theme_name(name)
|
|
# Normalizes a directory name for installing under the GRUB theme directory.
|
|
sub grub2_safe_theme_name
|
|
{
|
|
my ($name) = @_;
|
|
$name = '' if (!defined($name));
|
|
$name =~ s/^\s+|\s+\z//g;
|
|
$name =~ s/[^A-Za-z0-9._+-]+/_/g;
|
|
$name =~ s/\A[._-]+//;
|
|
$name =~ s/[._-]+\z//;
|
|
return $name;
|
|
}
|
|
|
|
# grub2_unique_theme_destination(parent, name)
|
|
# Returns a non-existing destination directory for a copied theme.
|
|
sub grub2_unique_theme_destination
|
|
{
|
|
my ($parent, $name) = @_;
|
|
my $dest = "$parent/$name";
|
|
return $dest if (!-e $dest);
|
|
for (my $i = 1; $i < 1000; $i++) {
|
|
my $try = "$parent/$name-$i";
|
|
return $try if (!-e $try);
|
|
}
|
|
return "$parent/$name-".time();
|
|
}
|
|
|
|
# grub2_path_is_under(path, parent)
|
|
# Returns true if a path is already below a parent directory.
|
|
sub grub2_path_is_under
|
|
{
|
|
my ($path, $parent) = @_;
|
|
my $p = eval { abs_path($path) };
|
|
my $d = eval { abs_path($parent) };
|
|
return 0 if (!$p || !$d);
|
|
return $p eq $d || index($p, $d.'/') == 0 ? 1 : 0;
|
|
}
|
|
|
|
# grub2_copy_theme_tree(source, destination)
|
|
# Copies regular theme files into a new GRUB-readable directory.
|
|
sub grub2_copy_theme_tree
|
|
{
|
|
my ($src, $dest) = @_;
|
|
my $src_abs = eval { abs_path($src) };
|
|
return &text('defaults_etheme_file', $src) if (!$src_abs);
|
|
my $err;
|
|
find({
|
|
no_chdir => 1,
|
|
wanted => sub {
|
|
return if ($err);
|
|
my $path = $File::Find::name;
|
|
my $rel = substr($path, length($src_abs));
|
|
$rel =~ s{\A/}{};
|
|
return if ($rel eq '');
|
|
if ($rel =~ m{(?:\A|/)\.\.(?:/|\z)}) {
|
|
$err = &text('defaults_etheme_member', $rel);
|
|
return;
|
|
}
|
|
my $target = "$dest/$rel";
|
|
my @st = lstat($path);
|
|
if (!@st) {
|
|
$err = &text('defaults_etheme_member', $rel);
|
|
return;
|
|
}
|
|
if (-d _) {
|
|
make_path($target, { mode => oct("0755") });
|
|
return;
|
|
}
|
|
my $source = &grub2_theme_regular_source($path, $src_abs);
|
|
if (!$source) {
|
|
$err = &text('defaults_etheme_member', $rel);
|
|
return;
|
|
}
|
|
my $tdir = &grub2_dirname($target);
|
|
make_path($tdir, { mode => oct("0755") }) if (!-d $tdir);
|
|
my $in;
|
|
if (!CORE::open($in, '<', $source)) {
|
|
$err = "$source : $!";
|
|
return;
|
|
}
|
|
CORE::binmode($in);
|
|
open_tempfile(my $out, ">$target");
|
|
my $buf;
|
|
while (read($in, $buf, 32768)) {
|
|
print_tempfile($out, $buf);
|
|
}
|
|
if (!close($in)) {
|
|
$err = "$path : $!";
|
|
}
|
|
close_tempfile($out);
|
|
chmod(oct("0644"), $target);
|
|
},
|
|
}, $src_abs);
|
|
return $err;
|
|
}
|
|
|
|
# grub2_theme_regular_source(path, source-root)
|
|
# Returns a safe regular source file, dereferencing in-tree symlinks only.
|
|
sub grub2_theme_regular_source
|
|
{
|
|
my ($path, $root) = @_;
|
|
my @st = lstat($path);
|
|
return if (!@st);
|
|
if (-l _) {
|
|
my $real = eval { abs_path($path) };
|
|
return if (!$real || !&grub2_resolved_path_is_under($real, $root));
|
|
return $real if (-f $real);
|
|
return;
|
|
}
|
|
return $path if (-f _);
|
|
return;
|
|
}
|
|
|
|
# grub2_resolved_path_is_under(path, resolved-parent)
|
|
# Returns true if a resolved path is below a resolved parent directory.
|
|
sub grub2_resolved_path_is_under
|
|
{
|
|
my ($path, $parent) = @_;
|
|
return 0 if (!defined($path) || !defined($parent) ||
|
|
$path eq '' || $parent eq '');
|
|
return $path eq $parent || index($path, $parent.'/') == 0 ? 1 : 0;
|
|
}
|
|
|
|
# read_grub_defaults([file])
|
|
# Reads and parses a GRUB default settings file.
|
|
sub read_grub_defaults
|
|
{
|
|
my ($file) = @_;
|
|
$file ||= &grub2_config_value('default_file');
|
|
my $data = '';
|
|
if ($file && -r $file) {
|
|
$data = &read_file_contents($file);
|
|
}
|
|
return &parse_grub_defaults_text($data, $file);
|
|
}
|
|
|
|
# parse_grub_defaults_text(text, [file])
|
|
# Parses shell-style GRUB default assignments while preserving all lines.
|
|
sub parse_grub_defaults_text
|
|
{
|
|
my ($data, $file) = @_;
|
|
$data = '' if (!defined($data));
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data);
|
|
my @parsed;
|
|
my %values;
|
|
my %assignments;
|
|
for (my $i = 0; $i < @lines; $i++) {
|
|
my $line = $lines[$i];
|
|
my $entry = { 'raw' => $line, 'line' => $i };
|
|
if ($line =~ /^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
|
|
# Preserve formatting metadata so saving can make minimal diffs.
|
|
my ($indent, $export, $key, $rest) = ($1, $2 || '', $3, $4);
|
|
my ($raw_value, $comment) = &split_shell_comment($rest);
|
|
$entry->{'type'} = 'assignment';
|
|
$entry->{'indent'} = $indent;
|
|
$entry->{'export'} = $export ? 1 : 0;
|
|
$entry->{'key'} = $key;
|
|
$entry->{'raw_value'} = $raw_value;
|
|
$entry->{'comment'} = $comment;
|
|
$entry->{'value'} = &decode_shell_value($raw_value);
|
|
$values{$key} = $entry->{'value'};
|
|
push(@{$assignments{$key}}, $entry);
|
|
}
|
|
else {
|
|
# Comments and unknown shell code are carried through unchanged.
|
|
$entry->{'type'} = 'raw';
|
|
}
|
|
push(@parsed, $entry);
|
|
}
|
|
return {
|
|
'file' => $file,
|
|
'lines' => \@parsed,
|
|
'values' => \%values,
|
|
'assignments' => \%assignments,
|
|
};
|
|
}
|
|
|
|
# split_shell_comment(text)
|
|
# Splits a shell assignment value from a trailing unquoted comment.
|
|
sub split_shell_comment
|
|
{
|
|
my ($text) = @_;
|
|
my $quote = '';
|
|
my $escape = 0;
|
|
for (my $i = 0; $i < length($text); $i++) {
|
|
my $ch = substr($text, $i, 1);
|
|
if ($escape) {
|
|
# Inside double quotes, escaped characters cannot start comments.
|
|
$escape = 0;
|
|
next;
|
|
}
|
|
if ($quote eq '"') {
|
|
if ($ch eq '\\') {
|
|
$escape = 1;
|
|
}
|
|
elsif ($ch eq '"') {
|
|
$quote = '';
|
|
}
|
|
next;
|
|
}
|
|
if ($quote eq "'") {
|
|
$quote = '' if ($ch eq "'");
|
|
next;
|
|
}
|
|
if ($ch eq '"' || $ch eq "'") {
|
|
$quote = $ch;
|
|
next;
|
|
}
|
|
if ($ch eq '#') {
|
|
my $before = $i == 0 ? '' : substr($text, $i - 1, 1);
|
|
if ($i == 0 || $before =~ /\s/) {
|
|
# Shell comments start at # only when it begins a word.
|
|
my $value = substr($text, 0, $i);
|
|
my $comment = substr($text, $i);
|
|
$value =~ s/\s+\z//;
|
|
return ($value, $comment);
|
|
}
|
|
}
|
|
}
|
|
$text =~ s/\s+\z//;
|
|
return ($text, '');
|
|
}
|
|
|
|
# decode_shell_value(value)
|
|
# Returns a display value for a simple shell assignment value.
|
|
sub decode_shell_value
|
|
{
|
|
my ($value) = @_;
|
|
$value = '' if (!defined($value));
|
|
$value =~ s/^\s+|\s+\z//g;
|
|
if ($value =~ /^'(.*)'\z/s) {
|
|
return $1;
|
|
}
|
|
if ($value =~ /^"(.*)"\z/s) {
|
|
my $inner = $1;
|
|
$inner =~ s/\\(["\\\$`])/$1/g;
|
|
return $inner;
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
# format_shell_value(key, value)
|
|
# Formats a Perl string as a conservative shell assignment value.
|
|
sub format_shell_value
|
|
{
|
|
my ($key, $value) = @_;
|
|
$value = '' if (!defined($value));
|
|
if ($key eq 'GRUB_TIMEOUT' && $value =~ /^-?\d+\z/) {
|
|
# Numeric timeout values should remain bare for readability.
|
|
return $value;
|
|
}
|
|
if ($key =~ /^GRUB_DISABLE_/ && $value =~ /^(true|false)\z/) {
|
|
# GRUB boolean defaults are conventionally unquoted.
|
|
return $value;
|
|
}
|
|
if ($value =~ /^[A-Za-z0-9_.,:\/+=-]+\z/) {
|
|
return $value;
|
|
}
|
|
$value =~ s/(["\\\$`])/\\$1/g;
|
|
return '"'.$value.'"';
|
|
}
|
|
|
|
# format_grub_assignment(&line, key, value)
|
|
# Returns one shell assignment line, preserving indentation and export.
|
|
sub format_grub_assignment
|
|
{
|
|
my ($line, $key, $value) = @_;
|
|
my $prefix = $line && defined($line->{'indent'}) ? $line->{'indent'} : '';
|
|
$prefix .= 'export ' if ($line && $line->{'export'});
|
|
my $comment = $line && defined($line->{'comment'}) && $line->{'comment'} ne ''
|
|
? ' '.$line->{'comment'} : '';
|
|
return $prefix.$key.'='.&format_shell_value($key, $value).$comment;
|
|
}
|
|
|
|
# set_grub_default_values(&parsed, &updates)
|
|
# Returns full default-file text with selected assignments updated.
|
|
sub set_grub_default_values
|
|
{
|
|
my ($parsed, $updates) = @_;
|
|
my %seen;
|
|
my @out;
|
|
foreach my $line (@{$parsed->{'lines'}}) {
|
|
my $key = $line->{'key'};
|
|
# Older Webmin blocks are removed instead of perpetuated.
|
|
next if (($line->{'raw'} || '') =~ /^\s*#\s*Added by Webmin\s*\z/);
|
|
if ($line->{'type'} eq 'assignment' && exists($updates->{$key})) {
|
|
$seen{$key} = 1;
|
|
if (defined($updates->{$key})) {
|
|
# Update the first matching assignment in place.
|
|
push(@out, &format_grub_assignment($line, $key,
|
|
$updates->{$key}));
|
|
}
|
|
next;
|
|
}
|
|
push(@out, $line->{'raw'});
|
|
}
|
|
my @missing = grep { exists($updates->{$_}) && !$seen{$_} &&
|
|
defined($updates->{$_}) } &grub2_default_keys();
|
|
if (@missing) {
|
|
push(@out, '') if (@out && $out[-1] ne '');
|
|
foreach my $key (@missing) {
|
|
# Append settings not already present, in the module's stable order.
|
|
push(@out, &format_grub_assignment(undef, $key,
|
|
$updates->{$key}));
|
|
}
|
|
}
|
|
pop(@out) while (@out && $out[-1] eq '');
|
|
return join("\n", @out)."\n";
|
|
}
|
|
|
|
# validate_grub_defaults_text(text, [file])
|
|
# Validates the shell syntax of a GRUB default settings file.
|
|
sub validate_grub_defaults_text
|
|
{
|
|
my ($data, $file) = @_;
|
|
$file ||= &grub2_config_value('default_file') || '/etc/default/grub';
|
|
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
|
|
my $dir = &grub2_dirname($file);
|
|
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
|
|
my ($temp, $terr) = &grub2_make_temp_file($dir, 'defaults', $data);
|
|
return $terr if ($terr);
|
|
my ($out, $failed);
|
|
my $die;
|
|
eval {
|
|
my $shell = &grub2_command('shell_cmd') ||
|
|
&grub2_config_value('shell_cmd') || '/bin/sh';
|
|
$out = &backquote_command(
|
|
quotemeta($shell).' -n '.quotemeta($temp).
|
|
' 2>&1 </dev/null');
|
|
$failed = $?;
|
|
1;
|
|
} || do { $die = $@ || $!; };
|
|
&grub2_unlink_temp($temp);
|
|
return $die if ($die);
|
|
if ($failed) {
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return $out || 'shell syntax check failed';
|
|
}
|
|
return;
|
|
}
|
|
|
|
# save_grub_defaults_values(&updates)
|
|
# Validates and writes selected GRUB default settings.
|
|
sub save_grub_defaults_values
|
|
{
|
|
my ($updates) = @_;
|
|
my $file = &grub2_config_value('default_file');
|
|
my $parsed = &read_grub_defaults($file);
|
|
my $data = &set_grub_default_values($parsed, $updates);
|
|
my $err = &validate_grub_defaults_text($data, $file);
|
|
return $err if ($err);
|
|
open_lock_tempfile(my $fh, ">$file");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
return;
|
|
}
|
|
|
|
# grub2_manual_files()
|
|
# Returns descriptors for files allowed in the manual editor.
|
|
sub grub2_manual_files
|
|
{
|
|
my @files;
|
|
my %seen;
|
|
my $default_file = &grub2_config_value('default_file');
|
|
my $custom_file = &grub2_config_value('custom_file');
|
|
# Always include the primary structured files first for predictable menus.
|
|
&add_grub2_manual_file(\@files, \%seen, 'default_file', $default_file,
|
|
'default');
|
|
&add_grub2_manual_file(\@files, \%seen, 'custom_file', $custom_file,
|
|
'custom');
|
|
|
|
my $grub_dir = &grub2_config_value('grub_dir') || '';
|
|
if ($grub_dir ne '' && -d $grub_dir && opendir(my $dh, $grub_dir)) {
|
|
foreach my $base (sort readdir($dh)) {
|
|
# Hide dotfiles and only expose regular generator/text files.
|
|
next if ($base =~ /^\./);
|
|
my $file = "$grub_dir/$base";
|
|
next if (!&grub2_manual_file_safe($file, $grub_dir, 1));
|
|
my $type = defined($custom_file) && $file eq $custom_file ? 'custom' :
|
|
(-x $file || $base =~ /^\d+_/) ? 'grub_script' :
|
|
'text';
|
|
&add_grub2_manual_file(\@files, \%seen, 'grub_dir', $file,
|
|
$type);
|
|
}
|
|
closedir($dh);
|
|
}
|
|
|
|
my $bls_dir = &grub2_config_value('bls_dir') || '';
|
|
if ($bls_dir ne '' && -d $bls_dir && opendir(my $dh, $bls_dir)) {
|
|
foreach my $base (sort readdir($dh)) {
|
|
# Disabled rescue files deliberately do not appear in the editor.
|
|
next if ($base =~ /^\./ || $base !~ /\.conf\z/);
|
|
my $file = "$bls_dir/$base";
|
|
next if (!&grub2_manual_file_safe($file, $bls_dir, 1));
|
|
&add_grub2_manual_file(\@files, \%seen, 'bls_dir', $file,
|
|
'bls');
|
|
}
|
|
closedir($dh);
|
|
}
|
|
|
|
return @files;
|
|
}
|
|
|
|
# add_grub2_manual_file(&files, &seen, key, file, type)
|
|
# Adds one allowlisted file descriptor, preserving first-seen ordering.
|
|
sub add_grub2_manual_file
|
|
{
|
|
my ($files, $seen, $key, $file, $type) = @_;
|
|
return if (!defined($file) || $file eq '' || $seen->{$file}++);
|
|
return if (!&grub2_manual_file_safe($file, undef, 0));
|
|
push(@$files, { 'key' => $key, 'file' => $file, 'type' => $type });
|
|
}
|
|
|
|
# grub2_manual_file_safe(file, [parent-dir], must-exist?)
|
|
# Returns true if a path is safe for the manual editor allowlist.
|
|
sub grub2_manual_file_safe
|
|
{
|
|
my ($file, $parent, $must_exist) = @_;
|
|
return 0 if (!defined($file) || $file eq '');
|
|
my @st = lstat($file);
|
|
return $must_exist ? 0 : 1 if (!@st);
|
|
# Symlinks would be followed by the Webmin write path, so reject them here.
|
|
return 0 if (-l _ || !-f _);
|
|
return $parent ? &grub2_path_is_under($file, $parent) : 1;
|
|
}
|
|
|
|
# grub2_manual_file(file)
|
|
# Returns the manual-edit descriptor for an allowed file path.
|
|
sub grub2_manual_file
|
|
{
|
|
my ($file) = @_;
|
|
foreach my $f (&grub2_manual_files()) {
|
|
return $f if ($f->{'file'} eq $file);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# validate_manual_grub_file(file, data)
|
|
# Validates a manually edited GRUB file where a safe validator exists.
|
|
sub validate_manual_grub_file
|
|
{
|
|
my ($file, $data) = @_;
|
|
my $info = &grub2_manual_file($file);
|
|
return $text{'manual_efile'} if (!$info);
|
|
if ($info->{'type'} eq 'default') {
|
|
# /etc/default/grub is shell syntax, so validate with sh.
|
|
return &validate_grub_defaults_text($data, $file);
|
|
}
|
|
if ($info->{'type'} eq 'grub_script') {
|
|
# /etc/grub.d scripts are shell fragments executed by grub-mkconfig.
|
|
return &validate_grub_defaults_text($data, $file);
|
|
}
|
|
if ($info->{'type'} eq 'custom') {
|
|
# Wrap custom menuentry bodies before running grub-script-check.
|
|
return &grub2_validate_grub_script_text(
|
|
&grub2_custom_script_text($data), $file);
|
|
}
|
|
if ($info->{'type'} eq 'bls') {
|
|
return &validate_bls_entry_text($data);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# validate_bls_entry_text(text)
|
|
# Validates the key/value syntax used by Boot Loader Specification entries.
|
|
sub validate_bls_entry_text
|
|
{
|
|
my ($data) = @_;
|
|
$data = '' if (!defined($data));
|
|
return $text{'manual_ebls'} if ($data =~ /\0/);
|
|
my $seen;
|
|
my $line_no = 0;
|
|
foreach my $line (split(/\n/, $data, -1)) {
|
|
$line_no++;
|
|
next if ($line =~ /^\s*(?:#|\z)/);
|
|
# BLS lines are simple keys followed by a non-empty value.
|
|
if ($line !~ /^\s*[A-Za-z0-9_.-]+\s+\S/) {
|
|
return &text('manual_eblsline', $line_no);
|
|
}
|
|
$seen = 1;
|
|
}
|
|
return $text{'manual_ebls'} if (!$seen);
|
|
return;
|
|
}
|
|
|
|
# save_manual_grub_file(file, data)
|
|
# Writes one allowlisted GRUB file from the manual editor.
|
|
sub save_manual_grub_file
|
|
{
|
|
my ($file, $data) = @_;
|
|
my $info = &grub2_manual_file($file);
|
|
return $text{'manual_efile'} if (!$info);
|
|
my $err = &validate_manual_grub_file($file, $data);
|
|
return $err if ($err);
|
|
if ($info->{'type'} eq 'custom') {
|
|
# Custom saves preserve executable mode and use the custom writer path.
|
|
return &grub2_with_file_lock($file, sub {
|
|
&grub2_write_custom_file($file, $data);
|
|
return;
|
|
});
|
|
}
|
|
open_lock_tempfile(my $fh, ">$file");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
return;
|
|
}
|
|
|
|
# grub2_read_security_config([file])
|
|
# Returns the current Webmin-managed GRUB password state.
|
|
sub grub2_read_security_config
|
|
{
|
|
my ($file) = @_;
|
|
$file ||= &grub2_password_file();
|
|
my %rv = (
|
|
'file' => $file,
|
|
'exists' => -e $file ? 1 : 0,
|
|
'managed' => 1,
|
|
'enabled' => 0,
|
|
'user' => 'root',
|
|
'hash' => '',
|
|
);
|
|
return \%rv if (!-e $file);
|
|
if (!-r $file) {
|
|
# Existing but unreadable files are treated as unmanaged for safety.
|
|
$rv{'managed'} = 0;
|
|
$rv{'unreadable'} = 1;
|
|
return \%rv;
|
|
}
|
|
my $data = &read_file_contents($file);
|
|
if ($data !~ /Webmin managed GRUB password protection/) {
|
|
# Do not parse or overwrite administrator-owned password scripts.
|
|
$rv{'managed'} = 0;
|
|
return \%rv;
|
|
}
|
|
if ($data =~ /^\s*password_pbkdf2\s+((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)\s+(grub\.pbkdf2\.[A-Za-z0-9.]+)\s*$/m) {
|
|
# Enabled state requires both a superuser token and a PBKDF2 hash.
|
|
my ($user) = &parse_grub_word($1);
|
|
$rv{'enabled'} = 1;
|
|
$rv{'user'} = $user if (defined($user) && $user ne '');
|
|
$rv{'hash'} = $2;
|
|
}
|
|
elsif ($data =~ /^\s*set\s+superusers=((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)/m) {
|
|
my ($user) = &parse_grub_word($1);
|
|
$rv{'user'} = $user if (defined($user) && $user ne '');
|
|
}
|
|
return \%rv;
|
|
}
|
|
|
|
# grub2_save_security_config(&settings)
|
|
# Saves the Webmin-managed GRUB password protection script.
|
|
sub grub2_save_security_config
|
|
{
|
|
my ($settings) = @_;
|
|
my $file = &grub2_password_file();
|
|
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
|
|
my $dir = &grub2_dirname($file);
|
|
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
|
|
my $current = &grub2_read_security_config($file);
|
|
return $text{'security_eunmanaged'}
|
|
if ($current->{'exists'} && !$current->{'managed'});
|
|
|
|
my $enabled = $settings->{'enabled'} ? 1 : 0;
|
|
my $user = defined($settings->{'user'}) && $settings->{'user'} ne '' ?
|
|
$settings->{'user'} : ($current->{'user'} || 'root');
|
|
my $hash = $current->{'hash'} || '';
|
|
|
|
if ($enabled) {
|
|
my $err = &grub2_validate_security_user($user);
|
|
return $err if ($err);
|
|
my $newhash = $settings->{'hash'} || '';
|
|
my $pass = $settings->{'password'} || '';
|
|
my $pass2 = $settings->{'password2'} || '';
|
|
$newhash = '' if ($newhash ne '' && $newhash eq $hash);
|
|
if ($newhash ne '' && ($pass ne '' || $pass2 ne '')) {
|
|
# Avoid ambiguity between pasted hashes and newly entered passwords.
|
|
return $text{'security_epassmode'};
|
|
}
|
|
if ($newhash ne '') {
|
|
# A pasted PBKDF2 hash replaces the stored hash without clear text.
|
|
$err = &grub2_validate_password_hash($newhash);
|
|
return $err if ($err);
|
|
$hash = $newhash;
|
|
}
|
|
elsif ($pass ne '' || $pass2 ne '') {
|
|
# Generate a fresh PBKDF2 hash only when password fields are used.
|
|
($hash, $err) = &grub2_make_password_hash($pass, $pass2);
|
|
return $err if ($err);
|
|
}
|
|
return $text{'security_epass'} if ($hash eq '');
|
|
}
|
|
return if (!$enabled && !$current->{'exists'});
|
|
|
|
my $data = &grub2_format_password_script($enabled, $user, $hash);
|
|
my $err = &validate_grub_defaults_text($data, $file);
|
|
return $err if ($err);
|
|
if ($enabled) {
|
|
# Validate the emitted GRUB commands separately from the shell wrapper.
|
|
$err = &grub2_validate_grub_script_text(
|
|
&grub2_format_password_grub_script($user, $hash), $file);
|
|
return $err if ($err);
|
|
}
|
|
return &grub2_with_file_lock($file, sub {
|
|
&grub2_write_password_file($file, $data);
|
|
return;
|
|
});
|
|
}
|
|
|
|
# grub2_validate_security_user(user)
|
|
# Returns an error if a GRUB superuser name is unsafe.
|
|
sub grub2_validate_security_user
|
|
{
|
|
my ($user) = @_;
|
|
$user = '' if (!defined($user));
|
|
return $text{'security_euser'}
|
|
if ($user eq '' || $user =~ /[\r\n\0]/ ||
|
|
$user !~ /\A[A-Za-z0-9_.@+-]+\z/ || $user =~ /^-/);
|
|
return;
|
|
}
|
|
|
|
# grub2_validate_password_hash(hash)
|
|
# Returns an error if a pasted GRUB PBKDF2 hash is unsafe.
|
|
sub grub2_validate_password_hash
|
|
{
|
|
my ($hash) = @_;
|
|
$hash = '' if (!defined($hash));
|
|
return $text{'security_ehash'}
|
|
if ($hash !~ /\Agrub\.pbkdf2\.[A-Za-z0-9.]+\z/);
|
|
return;
|
|
}
|
|
|
|
# grub2_make_password_hash(password, confirmation)
|
|
# Runs grub-mkpasswd-pbkdf2 and returns (hash, error).
|
|
sub grub2_make_password_hash
|
|
{
|
|
my ($pass, $pass2) = @_;
|
|
$pass = '' if (!defined($pass));
|
|
$pass2 = '' if (!defined($pass2));
|
|
return ('', $text{'security_epass'}) if ($pass eq '');
|
|
return ('', $text{'security_epassmatch'}) if ($pass ne $pass2);
|
|
return ('', $text{'security_epasschars'}) if ($pass =~ /[\r\n\0]/);
|
|
my $cmd = &grub2_command('mkpasswd_cmd');
|
|
return ('', $text{'security_emkpasswd'}) if (!$cmd);
|
|
my $input = $pass."\n".$pass."\n";
|
|
# Feed the password twice on stdin, matching grub-mkpasswd-pbkdf2 prompts.
|
|
my $out = '';
|
|
my $outref = \$out;
|
|
my $rv = &execute_command(quotemeta($cmd), \$input, $outref, $outref, 0, 1);
|
|
if ($rv) {
|
|
$out =~ s/^\s+|\s+\z//g;
|
|
return ('', $out || $text{'security_ehashgen'});
|
|
}
|
|
if ($out =~ /(grub\.pbkdf2\.[A-Za-z0-9.]+)/) {
|
|
return ($1);
|
|
}
|
|
return ('', $text{'security_ehashgen'});
|
|
}
|
|
|
|
# grub2_format_password_script(enabled?, user, hash)
|
|
# Returns a shell generator script managed by Webmin.
|
|
sub grub2_format_password_script
|
|
{
|
|
my ($enabled, $user, $hash) = @_;
|
|
my $header = "#!/bin/sh\n".
|
|
"# Webmin managed GRUB password protection\n".
|
|
"# Edit this file from Webmin's GRUB 2 module.\n";
|
|
return $header."exit 0\n" if (!$enabled);
|
|
return $header."cat <<'EOF'\n".
|
|
&grub2_format_password_grub_script($user, $hash).
|
|
"EOF\n";
|
|
}
|
|
|
|
# grub2_format_password_grub_script(user, hash)
|
|
# Returns GRUB commands that configure one password-protected superuser.
|
|
sub grub2_format_password_grub_script
|
|
{
|
|
my ($user, $hash) = @_;
|
|
return 'set superusers='.&grub2_quote_word($user)."\n".
|
|
"export superusers\n".
|
|
"password_pbkdf2 $user $hash\n";
|
|
}
|
|
|
|
# grub2_write_password_file(file, data)
|
|
# Writes the managed password script with root-only execute permissions.
|
|
sub grub2_write_password_file
|
|
{
|
|
my ($file, $data) = @_;
|
|
open_tempfile(my $fh, ">$file");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
chmod(oct("0700"), $file);
|
|
return;
|
|
}
|
|
|
|
# grub2_save_color_script()
|
|
# Saves the Webmin-managed generator script for GRUB menu colors.
|
|
sub grub2_save_color_script
|
|
{
|
|
my $file = &grub2_color_file();
|
|
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
|
|
my $dir = &grub2_dirname($file);
|
|
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
|
|
if (-e $file) {
|
|
return $text{'defaults_ecolorfile'} if (!-r $file);
|
|
my $current = &read_file_contents($file);
|
|
# Never overwrite administrator-owned GRUB generator scripts.
|
|
return $text{'defaults_ecolorfile'}
|
|
if ($current !~ /Webmin managed GRUB menu colors/);
|
|
}
|
|
my $data = &grub2_format_color_script();
|
|
my $err = &validate_grub_defaults_text($data, $file);
|
|
return $err if ($err);
|
|
return &grub2_with_file_lock($file, sub {
|
|
&grub2_write_color_file($file, $data);
|
|
return;
|
|
});
|
|
}
|
|
|
|
# grub2_format_color_script()
|
|
# Returns a shell generator script that emits GRUB menu color commands.
|
|
sub grub2_format_color_script
|
|
{
|
|
my $default_file = &grub2_config_value('default_file') || '/etc/default/grub';
|
|
my $source = '';
|
|
if ($default_file =~ m{^/} && $default_file !~ /[\r\n\0]/) {
|
|
# Source defaults at generation time so color changes need one script only.
|
|
$source = 'webmin_grub2_defaults_file='.
|
|
&format_shell_value('WEBMIN_GRUB2_DEFAULTS_FILE',
|
|
$default_file)."\n".
|
|
"if [ -r \"\$webmin_grub2_defaults_file\" ]; then\n".
|
|
"\t. \"\$webmin_grub2_defaults_file\"\n".
|
|
"fi\n\n";
|
|
}
|
|
my $script = <<'EOF';
|
|
#!/bin/sh
|
|
# Webmin managed GRUB menu colors
|
|
# Reads GRUB_COLOR_NORMAL and GRUB_COLOR_HIGHLIGHT from the GRUB defaults file.
|
|
|
|
EOF
|
|
$script .= $source;
|
|
$script .= <<'EOF';
|
|
|
|
webmin_grub2_emit_color()
|
|
{
|
|
name=$1
|
|
value=$2
|
|
case "$value" in
|
|
''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_/-]*|*/*/*)
|
|
return
|
|
;;
|
|
esac
|
|
case "$value" in
|
|
*/*)
|
|
printf 'set %s=%s\n' "$name" "$value"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
webmin_grub2_emit_color color_normal "$GRUB_COLOR_NORMAL"
|
|
webmin_grub2_emit_color color_highlight "$GRUB_COLOR_HIGHLIGHT"
|
|
webmin_grub2_emit_color menu_color_normal "$GRUB_COLOR_NORMAL"
|
|
webmin_grub2_emit_color menu_color_highlight "$GRUB_COLOR_HIGHLIGHT"
|
|
EOF
|
|
return $script;
|
|
}
|
|
|
|
# grub2_write_color_file(file, data)
|
|
# Writes the managed color generator script with executable permissions.
|
|
sub grub2_write_color_file
|
|
{
|
|
my ($file, $data) = @_;
|
|
open_tempfile(my $fh, ">$file");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
chmod(oct("0755"), $file);
|
|
return;
|
|
}
|
|
|
|
# grub2_boot_entries([file])
|
|
# Parses generated grub.cfg menuentry and submenu lines.
|
|
sub grub2_boot_entries
|
|
{
|
|
my ($file) = @_;
|
|
$file ||= &grub2_config_value('grub_cfg');
|
|
return () if (!$file || !-r $file);
|
|
my $data = &read_file_contents($file);
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data);
|
|
my @entries;
|
|
my @submenus;
|
|
my $section_file = $file;
|
|
my $depth = 0;
|
|
for (my $i = 0; $i < @lines; $i++) {
|
|
my $line = $lines[$i];
|
|
if ($line =~ /^### BEGIN (.+) ###\s*\z/) {
|
|
# grub-mkconfig annotates which generator script emitted following lines.
|
|
$section_file = $1;
|
|
}
|
|
elsif ($line =~ /^### END /) {
|
|
$section_file = $file;
|
|
}
|
|
elsif ($line =~ /^\s*blscfg\s*$/) {
|
|
# blscfg imports BLS files at this position in the generated menu.
|
|
my @path = map { $_->{'title'} } @submenus;
|
|
push(@entries, &grub2_bls_entries(scalar(@entries),
|
|
undef, \@path));
|
|
}
|
|
elsif ($line =~ /^\s*submenu\s+/) {
|
|
my ($title, $id) = &parse_grub_statement($line, 'submenu');
|
|
my ($opens, $closes) = &count_grub_braces($line);
|
|
# Track submenu stack depth so child entries get a stable path.
|
|
push(@submenus, {
|
|
'title' => $title,
|
|
'id' => $id,
|
|
'depth' => $depth + $opens - $closes,
|
|
}) if (defined($title) && $opens > $closes);
|
|
}
|
|
elsif ($line =~ /^\s*menuentry\s+/) {
|
|
my ($title, $id) = &parse_grub_statement($line, 'menuentry');
|
|
if (defined($title)) {
|
|
# Details are read from the menuentry body, not just the title line.
|
|
my @path = map { $_->{'title'} } @submenus;
|
|
my $end = &grub2_statement_block_end(\@lines, $i);
|
|
my %details = &grub2_menuentry_details(\@lines, $i,
|
|
$end);
|
|
push(@entries, {
|
|
'index' => scalar(@entries),
|
|
'title' => $title,
|
|
'id' => $id,
|
|
'line' => $i + 1,
|
|
'path' => \@path,
|
|
'source_file' => $section_file,
|
|
%details,
|
|
});
|
|
}
|
|
}
|
|
my ($opens, $closes) = &count_grub_braces($line);
|
|
$depth += $opens - $closes;
|
|
while (@submenus && $depth < $submenus[-1]->{'depth'}) {
|
|
# Drop submenu context once its closing brace has been passed.
|
|
pop(@submenus);
|
|
}
|
|
$depth = 0 if ($depth < 0);
|
|
}
|
|
return @entries;
|
|
}
|
|
|
|
# grub2_menuentry_details(&lines, start-index, end-index)
|
|
# Extracts kernel and initrd details from one generated menuentry block.
|
|
sub grub2_menuentry_details
|
|
{
|
|
my ($lines, $start, $end) = @_;
|
|
my %details;
|
|
for (my $i = $start + 1; $i < $end; $i++) {
|
|
my $line = $lines->[$i];
|
|
if ($line =~ /^\s*linux(?:efi|16)?\s+(.+?)\s*\z/) {
|
|
# The first linux line gives the kernel path and remaining arguments.
|
|
my ($kernel, $opts) = &parse_grub_word($1);
|
|
$details{'linux'} = $kernel
|
|
if (defined($kernel) && $kernel ne '' &&
|
|
!defined($details{'linux'}));
|
|
$opts =~ s/^\s+|\s+\z//g if (defined($opts));
|
|
$details{'options'} = $opts
|
|
if (defined($opts) && $opts ne '' &&
|
|
!defined($details{'options'}));
|
|
}
|
|
elsif ($line =~ /^\s*initrd(?:efi|16)?\s+(.+?)\s*\z/) {
|
|
# Preserve multiple initrd words as a single displayed value.
|
|
my $rest = $1;
|
|
my ($initrd, $extra) = &parse_grub_word($rest);
|
|
$extra =~ s/^\s+|\s+\z//g if (defined($extra));
|
|
$details{'initrd'} = defined($extra) && $extra ne '' ?
|
|
$rest : $initrd
|
|
if (defined($initrd) && $initrd ne '' &&
|
|
!defined($details{'initrd'}));
|
|
}
|
|
}
|
|
if (!defined($details{'version'}) && defined($details{'linux'})) {
|
|
my $version = &grub2_kernel_version_from_path($details{'linux'});
|
|
$details{'version'} = $version if ($version);
|
|
}
|
|
return %details;
|
|
}
|
|
|
|
# grub2_kernel_version_from_path(path)
|
|
# Returns the kernel version embedded in common Linux image filenames.
|
|
sub grub2_kernel_version_from_path
|
|
{
|
|
my ($kernel) = @_;
|
|
return if (!defined($kernel) || $kernel eq '');
|
|
return $1 if ($kernel =~ m{(?:\A|/)(?:vmlinuz|vmlinux|kernel|bzImage)-(.+)\z});
|
|
return;
|
|
}
|
|
|
|
# grub2_bls_entries([start-index], [dir], [&submenu-path])
|
|
# Parses Boot Loader Specification entries used by RHEL-style GRUB configs.
|
|
sub grub2_bls_entries
|
|
{
|
|
my ($start_index, $dir, $path) = @_;
|
|
$start_index ||= 0;
|
|
$dir ||= &grub2_config_value('bls_dir');
|
|
return () if (!$dir || !-d $dir);
|
|
opendir(my $dh, $dir) || return ();
|
|
my @files = sort grep { /\.conf\z/ && -r "$dir/$_" } readdir($dh);
|
|
closedir($dh);
|
|
my @entries;
|
|
foreach my $file (@files) {
|
|
my $entry = &parse_bls_entry("$dir/$file", $file, $path);
|
|
next if (!$entry);
|
|
push(@entries, $entry);
|
|
}
|
|
# GRUB shows BLS kernels newest-first, unlike lexical filename order.
|
|
@entries = &grub2_sort_bls_entries(@entries);
|
|
for (my $i = 0; $i < @entries; $i++) {
|
|
$entries[$i]->{'index'} = $start_index + $i;
|
|
}
|
|
return @entries;
|
|
}
|
|
|
|
# grub2_has_bls_rescue_entries([&entries])
|
|
# Returns true if the generated menu contains BLS rescue entries.
|
|
sub grub2_has_bls_rescue_entries
|
|
{
|
|
my ($entries) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
foreach my $entry (@$entries) {
|
|
return 1 if (&grub2_entry_is_bls_rescue($entry));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# grub2_entry_is_bls_rescue(&entry)
|
|
# Returns true if a parsed BLS entry is a rescue entry.
|
|
sub grub2_entry_is_bls_rescue
|
|
{
|
|
my ($entry) = @_;
|
|
return 0 if (($entry->{'source'} || '') ne 'bls');
|
|
return 1 if (($entry->{'version'} || '') =~ /^0-rescue(?:-|\z)/);
|
|
return 1 if (($entry->{'filename'} || '') =~ /(?:^|-)0-rescue(?:-|\.conf\z)/);
|
|
return 1 if (($entry->{'title'} || '') =~ /\b0-rescue-/);
|
|
return 0;
|
|
}
|
|
|
|
# grub2_has_non_bls_recovery_entries([&entries])
|
|
# Returns true if generated/custom entries look like recovery or rescue entries.
|
|
sub grub2_has_non_bls_recovery_entries
|
|
{
|
|
my ($entries) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
foreach my $entry (@$entries) {
|
|
next if (($entry->{'source'} || '') eq 'bls');
|
|
my $title = $entry->{'title'} || '';
|
|
my $path = join(' ', @{$entry->{'path'} || []});
|
|
return 1 if ($title =~ /\b(?:recovery|rescue)\b/i ||
|
|
$path =~ /\b(?:recovery|rescue)\b/i);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# grub2_disabled_bls_rescue_files([dir])
|
|
# Returns BLS rescue entries hidden by Webmin.
|
|
sub grub2_disabled_bls_rescue_files
|
|
{
|
|
my ($dir) = @_;
|
|
$dir ||= &grub2_config_value('bls_dir');
|
|
return () if (!$dir || !-d $dir);
|
|
opendir(my $dh, $dir) || return ();
|
|
my $suffix = &grub2_disabled_bls_rescue_suffix();
|
|
my @bases = sort grep { /\Q$suffix\E\z/ && -r "$dir/$_" } readdir($dh);
|
|
closedir($dh);
|
|
my @files;
|
|
foreach my $base (@bases) {
|
|
my $file = "$dir/$base";
|
|
my $entry = &parse_bls_entry($file, $base, []);
|
|
push(@files, $file)
|
|
if ($entry && &grub2_entry_is_bls_rescue($entry));
|
|
}
|
|
return @files;
|
|
}
|
|
|
|
# grub2_set_bls_rescue_disabled(disabled?, [&entries])
|
|
# Hides or restores BLS rescue entries without deleting their files.
|
|
sub grub2_set_bls_rescue_disabled
|
|
{
|
|
my ($disabled, $entries) = @_;
|
|
my $suffix = &grub2_disabled_bls_rescue_suffix();
|
|
my @moves;
|
|
if ($disabled) {
|
|
# Hiding renames BLS rescue files so GRUB's blscfg no longer sees them.
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
my %seen;
|
|
foreach my $entry (@$entries) {
|
|
next if (!&grub2_entry_is_bls_rescue($entry));
|
|
my $from = $entry->{'file'} || '';
|
|
next if ($from eq '' || !-e $from || $seen{$from}++);
|
|
my $to = $from.$suffix;
|
|
return &text('defaults_ebls_rescue_exists', $from, $to)
|
|
if (-e $to);
|
|
push(@moves, [ $from, $to ]);
|
|
}
|
|
}
|
|
else {
|
|
# Restoring reverses our suffix only when the original target is free.
|
|
foreach my $from (&grub2_disabled_bls_rescue_files()) {
|
|
my $to = $from;
|
|
$to =~ s/\Q$suffix\E\z//;
|
|
next if ($to eq $from || -e $to);
|
|
push(@moves, [ $from, $to ]);
|
|
}
|
|
}
|
|
foreach my $move (@moves) {
|
|
my ($from, $to) = @$move;
|
|
# Lock both names so Webmin logging can capture the rename safely.
|
|
&lock_file($from);
|
|
&lock_file($to);
|
|
my $ok = &rename_file($from, $to);
|
|
my $err = "$!";
|
|
&unlock_file($to);
|
|
&unlock_file($from);
|
|
return &text('defaults_ebls_rescue_move', $from, $to, $err)
|
|
if (!$ok);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_disabled_bls_rescue_suffix()
|
|
# Returns suffix used for BLS rescue entries hidden by Webmin.
|
|
sub grub2_disabled_bls_rescue_suffix
|
|
{
|
|
return ".disabled";
|
|
}
|
|
|
|
# grub2_kernel_options_source_keys([&entries])
|
|
# Returns detected sources for Linux kernel command line options.
|
|
sub grub2_kernel_options_source_keys
|
|
{
|
|
my ($entries) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
my %sources;
|
|
foreach my $entry (@$entries) {
|
|
my $opts = $entry->{'options'} || '';
|
|
if (($entry->{'source'} || '') eq 'bls') {
|
|
# BLS entries can contain direct options or defer to grubenv kernelopts.
|
|
if (&grub2_entry_uses_kernelopts($entry)) {
|
|
$sources{'kernelopts'} = 1;
|
|
}
|
|
else {
|
|
$sources{'bls'} = 1;
|
|
}
|
|
}
|
|
elsif (($entry->{'linux'} || '') ne '' || $opts ne '') {
|
|
$sources{'defaults'} = 1;
|
|
}
|
|
}
|
|
return grep { $sources{$_} } qw(kernelopts bls defaults);
|
|
}
|
|
|
|
# grub2_entry_uses_kernelopts(&entry)
|
|
# Returns true when a BLS entry defers kernel options to grubenv.
|
|
sub grub2_entry_uses_kernelopts
|
|
{
|
|
my ($entry) = @_;
|
|
my $opts = $entry->{'options'} || '';
|
|
return $opts =~ /(?:^|\s)(?:\$kernelopts|\$\{kernelopts\})(?:\s|\z)/ ?
|
|
1 : 0;
|
|
}
|
|
|
|
# grub2_kernel_options_source_text([&entries])
|
|
# Returns localized text for detected Linux kernel option sources.
|
|
sub grub2_kernel_options_source_text
|
|
{
|
|
my ($entries) = @_;
|
|
my @keys = &grub2_kernel_options_source_keys($entries);
|
|
return $text{'index_not_set'} if (!@keys);
|
|
return join(', ',
|
|
map { $text{'index_kernel_options_source_'.$_} || $_ } @keys);
|
|
}
|
|
|
|
# grub2_bls_kernel_option_warnings([&entries], [&env])
|
|
# Returns warnings for BLS entries whose kernel options live outside defaults.
|
|
sub grub2_bls_kernel_option_warnings
|
|
{
|
|
my ($entries, $env) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
if (!$env) {
|
|
my %read_env = &grub2_read_env();
|
|
$env = \%read_env;
|
|
}
|
|
my @sources = &grub2_kernel_options_source_keys($entries);
|
|
return () if (!grep { $_ eq 'kernelopts' || $_ eq 'bls' } @sources);
|
|
my @warnings;
|
|
# Direct BLS options need grubby to keep existing entries in sync.
|
|
push(@warnings, $text{'index_warn_bls_options'})
|
|
if ((grep { $_ eq 'bls' } @sources) &&
|
|
!&grub2_bls_update_available($entries));
|
|
if (grep { $_ eq 'kernelopts' } @sources) {
|
|
# kernelopts is stored in grubenv, so report when it is missing or unmanaged.
|
|
push(@warnings, $text{'index_warn_kernelopts_source'})
|
|
if (!&grub2_bls_update_available($entries));
|
|
push(@warnings, $text{'index_warn_kernelopts_missing'})
|
|
if (!defined($env->{'kernelopts'}) ||
|
|
$env->{'kernelopts'} eq '');
|
|
}
|
|
return @warnings;
|
|
}
|
|
|
|
# grub2_update_bls_kernel_args(old-args, new-args, [&kernel-targets])
|
|
# Applies changed kernel options to existing BLS entries with grubby.
|
|
sub grub2_update_bls_kernel_args
|
|
{
|
|
my ($old_args, $new_args, $targets) = @_;
|
|
my $cmd = &grub2_command('grubby_cmd');
|
|
return $text{'defaults_egrubby'} if (!$cmd);
|
|
my ($remove, $add) = &grub2_kernel_args_delta($old_args, $new_args);
|
|
return if (!@$remove && !@$add);
|
|
my @targets = $targets && @$targets ? @$targets : ('ALL');
|
|
foreach my $target (@targets) {
|
|
# Pass only the delta so grubby preserves unrelated boot-critical args.
|
|
my @run = (quotemeta($cmd), quotemeta('--update-kernel='.$target));
|
|
push(@run, quotemeta('--remove-args='.join(' ', @$remove))) if (@$remove);
|
|
push(@run, quotemeta('--args='.join(' ', @$add))) if (@$add);
|
|
my $out = &backquote_logged(join(' ', @run).' 2>&1 </dev/null');
|
|
if ($?) {
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return $out || $text{'defaults_egrubby_failed'};
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_bls_kernel_arg_targets([&entries], [include-rescue?])
|
|
# Returns grubby kernel targets for BLS entries.
|
|
sub grub2_bls_kernel_arg_targets
|
|
{
|
|
my ($entries, $include_rescue) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
my (@targets, %seen);
|
|
foreach my $entry (@$entries) {
|
|
next if (($entry->{'source'} || '') ne 'bls');
|
|
# Default-only args should not be applied to rescue entries.
|
|
next if (!$include_rescue && &grub2_entry_is_bls_rescue($entry));
|
|
my $target = &grub2_bls_kernel_target($entry->{'linux'});
|
|
next if ($target eq '' || $seen{$target}++);
|
|
push(@targets, $target);
|
|
}
|
|
return @targets;
|
|
}
|
|
|
|
# grub2_bls_kernel_target(linux-path)
|
|
# Converts a BLS linux path to the kernel path expected by grubby.
|
|
sub grub2_bls_kernel_target
|
|
{
|
|
my ($linux) = @_;
|
|
return '' if (!defined($linux) || $linux eq '');
|
|
return $linux if ($linux =~ m{^/boot/});
|
|
my $boot = &grub2_bls_boot_dir();
|
|
return $boot.$linux if ($linux =~ m{^/});
|
|
return $boot.'/'.$linux;
|
|
}
|
|
|
|
# grub2_bls_boot_dir()
|
|
# Returns the boot directory that contains configured BLS entries.
|
|
sub grub2_bls_boot_dir
|
|
{
|
|
my $bls_dir = &grub2_config_value('bls_dir') || '';
|
|
return $1 if ($bls_dir =~ m{\A(.+)/loader/entries/?\z});
|
|
my $parent = &grub2_dirname($bls_dir);
|
|
return $parent if ($parent ne '');
|
|
return '/boot';
|
|
}
|
|
|
|
# grub2_kernel_args_delta(old-args, new-args)
|
|
# Returns argument tokens to remove and add while preserving unrelated tokens.
|
|
sub grub2_kernel_args_delta
|
|
{
|
|
my ($old_args, $new_args) = @_;
|
|
my @old = &grub2_split_kernel_args($old_args);
|
|
my @new = &grub2_split_kernel_args($new_args);
|
|
my (%old, %new);
|
|
# Track full tokens so replacing foo=old with foo=new removes and adds cleanly.
|
|
$old{$_}++ foreach (@old);
|
|
$new{$_}++ foreach (@new);
|
|
my (@remove, @add);
|
|
foreach my $arg (@old) {
|
|
next if ($new{$arg});
|
|
push(@remove, $arg);
|
|
}
|
|
foreach my $arg (@new) {
|
|
next if ($old{$arg});
|
|
push(@add, $arg);
|
|
}
|
|
return (\@remove, \@add);
|
|
}
|
|
|
|
# grub2_split_kernel_args(args)
|
|
# Splits a Linux kernel command line using GRUB/shell-style words.
|
|
sub grub2_split_kernel_args
|
|
{
|
|
my ($args) = @_;
|
|
$args = '' if (!defined($args));
|
|
my @rv;
|
|
while (defined($args) && $args =~ /\S/) {
|
|
my ($word, $rest) = &parse_grub_word($args);
|
|
last if (!defined($word));
|
|
# Empty quoted strings are ignored because kernel args are token-based.
|
|
push(@rv, $word) if ($word ne '');
|
|
$args = $rest;
|
|
}
|
|
return @rv;
|
|
}
|
|
|
|
# grub2_sort_bls_entries(@entries)
|
|
# Returns BLS entries in the same newest-first order that GRUB displays.
|
|
sub grub2_sort_bls_entries
|
|
{
|
|
my @entries = @_;
|
|
my $use_version = @entries &&
|
|
!grep { !defined($_->{'version'}) || $_->{'version'} eq '' } @entries;
|
|
my @sorted = sort {
|
|
my $akey = $use_version ? $a->{'version'} : $a->{'filename'};
|
|
my $bkey = $use_version ? $b->{'version'} : $b->{'filename'};
|
|
# rpmvercmp handles kernel release components better than string compare.
|
|
my $cmp = &grub2_rpmvercmp($akey, $bkey);
|
|
$cmp ||= &grub2_rpmvercmp($a->{'filename'}, $b->{'filename'});
|
|
$cmp ||= ($a->{'filename'} || '') cmp ($b->{'filename'} || '');
|
|
$cmp;
|
|
} @entries;
|
|
return reverse(@sorted);
|
|
}
|
|
|
|
# parse_bls_entry(file, basename, [&submenu-path])
|
|
# Parses one Boot Loader Specification entry file.
|
|
sub parse_bls_entry
|
|
{
|
|
my ($file, $base, $path) = @_;
|
|
my $data = &read_file_contents($file);
|
|
my %entry = (
|
|
'line' => 1,
|
|
'path' => [ @{$path || []} ],
|
|
'source' => 'bls',
|
|
'file' => $file,
|
|
'filename' => $base,
|
|
'source_file' => $file,
|
|
);
|
|
foreach my $line (split(/\r?\n/, $data || '')) {
|
|
next if ($line =~ /^\s*(?:#|\z)/);
|
|
if ($line =~ /^\s*([A-Za-z0-9_.-]+)\s+(.+?)\s*\z/) {
|
|
# BLS permits repeated keys; the last one wins like most parsers.
|
|
my ($key, $value) = ($1, $2);
|
|
$entry{$key} = $value;
|
|
}
|
|
}
|
|
return if (!$entry{'title'});
|
|
$base =~ s/\.conf\z//;
|
|
$entry{'id'} ||= $base;
|
|
$entry{'title'} ||= $entry{'version'} || $entry{'id'};
|
|
return \%entry;
|
|
}
|
|
|
|
# grub2_rpmvercmp(left, right)
|
|
# Compares version-like strings with the rpmvercmp rules used by GRUB BLS.
|
|
sub grub2_rpmvercmp
|
|
{
|
|
my ($left, $right) = @_;
|
|
$left = '' if (!defined($left));
|
|
$right = '' if (!defined($right));
|
|
return 0 if ($left eq $right);
|
|
my ($la, $ra) = ($left, $right);
|
|
while ($la ne '' || $ra ne '') {
|
|
# Skip separators; rpmvercmp compares only alphanumeric and tilde runs.
|
|
$la =~ s/\A[^A-Za-z0-9~]+//;
|
|
$ra =~ s/\A[^A-Za-z0-9~]+//;
|
|
if ($la =~ /\A~/ || $ra =~ /\A~/) {
|
|
# Tilde sorts before the empty string and before regular segments.
|
|
return -1 if ($la =~ /\A~/ && $ra !~ /\A~/);
|
|
return 1 if ($la !~ /\A~/ && $ra =~ /\A~/);
|
|
$la =~ s/\A~//;
|
|
$ra =~ s/\A~//;
|
|
next;
|
|
}
|
|
last if ($la eq '' || $ra eq '');
|
|
my $left_num = $la =~ /\A[0-9]/;
|
|
my ($ls, $rs);
|
|
if ($left_num) {
|
|
# Numeric segments compare by length after trimming leading zeros.
|
|
($ls) = $la =~ /\A([0-9]+)/;
|
|
($rs) = $ra =~ /\A([0-9]+)/;
|
|
return 1 if (!defined($rs));
|
|
$la = substr($la, length($ls));
|
|
$ra = substr($ra, length($rs));
|
|
$ls =~ s/\A0+//;
|
|
$rs =~ s/\A0+//;
|
|
$ls = '0' if ($ls eq '');
|
|
$rs = '0' if ($rs eq '');
|
|
return length($ls) <=> length($rs)
|
|
if (length($ls) != length($rs));
|
|
my $cmp = $ls cmp $rs;
|
|
return $cmp if ($cmp);
|
|
}
|
|
else {
|
|
# Alphabetic segments compare lexically and sort below numeric runs.
|
|
($ls) = $la =~ /\A([A-Za-z]+)/;
|
|
($rs) = $ra =~ /\A([A-Za-z]+)/;
|
|
return -1 if (!defined($rs));
|
|
$la = substr($la, length($ls));
|
|
$ra = substr($ra, length($rs));
|
|
my $cmp = $ls cmp $rs;
|
|
return $cmp if ($cmp);
|
|
}
|
|
}
|
|
return 0 if ($la eq '' && $ra eq '');
|
|
return $la eq '' ? -1 : 1;
|
|
}
|
|
|
|
# grub2_custom_entries([file])
|
|
# Parses editable custom GRUB menu entries with source line ranges.
|
|
sub grub2_custom_entries
|
|
{
|
|
my ($file) = @_;
|
|
$file ||= &grub2_config_value('custom_file');
|
|
return () if (!$file || !-r $file);
|
|
my $data = &read_file_contents($file);
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data);
|
|
return &grub2_custom_entries_from_lines(\@lines, $file);
|
|
}
|
|
|
|
# grub2_custom_entries_from_lines(&lines, file)
|
|
# Parses editable custom GRUB entries from already-read file lines.
|
|
sub grub2_custom_entries_from_lines
|
|
{
|
|
my ($lines, $file) = @_;
|
|
my (@entries, @submenus);
|
|
my $depth = 0;
|
|
for (my $i = 0; $i < @$lines; $i++) {
|
|
my $line = $lines->[$i];
|
|
if ($line =~ /^\s*submenu\s+/) {
|
|
# Custom entries may be nested; keep a submenu path like grub.cfg.
|
|
my ($title, $id) = &parse_grub_statement($line, 'submenu');
|
|
my ($opens, $closes) = &count_grub_braces($line);
|
|
push(@submenus, {
|
|
'title' => $title,
|
|
'id' => $id,
|
|
'depth' => $depth + $opens - $closes,
|
|
}) if (defined($title) && $opens > $closes);
|
|
}
|
|
elsif ($line =~ /^\s*menuentry\s+/) {
|
|
my ($title, $id) = &parse_grub_statement($line, 'menuentry');
|
|
if (defined($title)) {
|
|
# Store source ranges so edits can replace exact blocks.
|
|
my @path = map { $_->{'title'} } @submenus;
|
|
push(@entries, {
|
|
'custom_index' => scalar(@entries),
|
|
'title' => $title,
|
|
'id' => $id,
|
|
'path' => \@path,
|
|
'source_file' => $file,
|
|
'start' => $i,
|
|
'end' => &grub2_statement_block_end($lines, $i),
|
|
});
|
|
}
|
|
}
|
|
my ($opens, $closes) = &count_grub_braces($line);
|
|
$depth += $opens - $closes;
|
|
while (@submenus && $depth < $submenus[-1]->{'depth'}) {
|
|
# Closing braces pop submenu context before subsequent entries.
|
|
pop(@submenus);
|
|
}
|
|
$depth = 0 if ($depth < 0);
|
|
}
|
|
return @entries;
|
|
}
|
|
|
|
# grub2_custom_entry_by_index(index)
|
|
# Returns one custom entry descriptor by non-negative custom index.
|
|
sub grub2_custom_entry_by_index
|
|
{
|
|
my ($index) = @_;
|
|
return if (!defined($index) || $index !~ /^\d+\z/);
|
|
my @entries = &grub2_custom_entries();
|
|
return if ($index >= @entries);
|
|
return $entries[$index];
|
|
}
|
|
|
|
# grub2_custom_entry_body(&entry, [file])
|
|
# Returns the editable body inside a custom menuentry block.
|
|
sub grub2_custom_entry_body
|
|
{
|
|
my ($entry, $file) = @_;
|
|
return '' if (!$entry);
|
|
$file ||= $entry->{'source_file'} || &grub2_config_value('custom_file');
|
|
return '' if (!$file || !-r $file);
|
|
my $data = &read_file_contents($file);
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data);
|
|
return '' if ($entry->{'end'} <= $entry->{'start'} + 1);
|
|
my @body = @lines[$entry->{'start'} + 1 .. $entry->{'end'} - 1];
|
|
@body = &grub2_unindent_custom_body(\@body);
|
|
return join("\n", @body).(@body ? "\n" : "");
|
|
}
|
|
|
|
# grub2_unindent_custom_body(&lines)
|
|
# Removes the common storage indentation from a custom entry body.
|
|
sub grub2_unindent_custom_body
|
|
{
|
|
my ($lines) = @_;
|
|
my $prefix;
|
|
foreach my $line (@$lines) {
|
|
next if ($line !~ /\S/);
|
|
my ($indent) = $line =~ /^([ \t]*)/;
|
|
if (!defined($prefix)) {
|
|
# First nonblank line sets the candidate common indentation.
|
|
$prefix = $indent;
|
|
}
|
|
else {
|
|
# Trim the prefix until every nonblank line shares it.
|
|
while ($prefix ne '' && index($indent, $prefix) != 0) {
|
|
chop($prefix);
|
|
}
|
|
}
|
|
last if (defined($prefix) && $prefix eq '');
|
|
}
|
|
return @$lines if (!defined($prefix) || $prefix eq '');
|
|
my @out = @$lines;
|
|
foreach my $line (@out) {
|
|
$line =~ s/^\Q$prefix\E//;
|
|
}
|
|
return @out;
|
|
}
|
|
|
|
# grub2_save_custom_entry(index|undef, title, id, body)
|
|
# Adds or replaces a custom GRUB menuentry in the configured custom file.
|
|
sub grub2_save_custom_entry
|
|
{
|
|
my ($index, $title, $id, $body) = @_;
|
|
my $file = &grub2_config_value('custom_file') || '';
|
|
return $text{'custom_efile'} if ($file eq '');
|
|
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
|
|
my $dir = &grub2_dirname($file);
|
|
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
|
|
my $err = &grub2_validate_custom_entry($title, $id, $body);
|
|
return $err if ($err);
|
|
my $entry_text = &grub2_format_custom_entry($title, $id, $body);
|
|
my @entry_lines = split(/\n/, $entry_text);
|
|
return &grub2_with_file_lock($file, sub {
|
|
my @lines = &grub2_custom_file_lines($file);
|
|
if (defined($index) && $index ne '') {
|
|
# Replacement uses the latest parsed ranges under the file lock.
|
|
my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
|
|
my $entry = $index =~ /^\d+\z/ ? $entries[$index] : undef;
|
|
return $text{'custom_eentry'} if (!$entry);
|
|
splice(@lines, $entry->{'start'},
|
|
$entry->{'end'} - $entry->{'start'} + 1, @entry_lines);
|
|
}
|
|
else {
|
|
# New entries are appended after a blank separator when needed.
|
|
push(@lines, '') if (@lines && $lines[-1] ne '');
|
|
push(@lines, @entry_lines);
|
|
}
|
|
my $data = join("\n", @lines)."\n";
|
|
&grub2_write_custom_file($file, $data);
|
|
return;
|
|
});
|
|
}
|
|
|
|
# grub2_delete_custom_entry_indexes(index, ...)
|
|
# Deletes entries by custom-file index.
|
|
sub grub2_delete_custom_entry_indexes
|
|
{
|
|
my (@indexes) = @_;
|
|
return $text{'delete_enone'} if (!@indexes);
|
|
my $file = &grub2_config_value('custom_file') || '';
|
|
return $text{'delete_ecustom'} if ($file eq '' || !-r $file);
|
|
return &grub2_with_file_lock($file, sub {
|
|
my @lines = &grub2_custom_file_lines($file);
|
|
my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
|
|
my %seen;
|
|
my @ranges;
|
|
foreach my $index (@indexes) {
|
|
# Validate all selected indexes before deleting any ranges.
|
|
return $text{'custom_eentry'}
|
|
if (!defined($index) || $index !~ /^\d+\z/ ||
|
|
!$entries[$index]);
|
|
next if ($seen{$index}++);
|
|
push(@ranges,
|
|
[ $entries[$index]->{'start'}, $entries[$index]->{'end'} ]);
|
|
}
|
|
return &grub2_delete_custom_ranges($file, \@ranges, \@lines);
|
|
});
|
|
}
|
|
|
|
# grub2_move_custom_entry(index, direction)
|
|
# Moves one custom entry up or down within the custom file.
|
|
sub grub2_move_custom_entry
|
|
{
|
|
my ($index, $direction) = @_;
|
|
return $text{'custom_eentry'} if (!defined($index) || $index !~ /^\d+\z/);
|
|
return $text{'custom_emove'} if ($direction !~ /^(up|down)\z/);
|
|
my $file = &grub2_config_value('custom_file') || '';
|
|
return $text{'delete_ecustom'} if ($file eq '' || !-r $file);
|
|
return &grub2_with_file_lock($file, sub {
|
|
my @lines = &grub2_custom_file_lines($file);
|
|
my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
|
|
return $text{'custom_eentry'} if (!$entries[$index]);
|
|
return $text{'custom_emove'} if ($direction eq 'up' && $index == 0);
|
|
return $text{'custom_emove'} if ($direction eq 'down' && $index == $#entries);
|
|
my $entry = $entries[$index];
|
|
my $other = $direction eq 'up' ? $entries[$index - 1] :
|
|
$entries[$index + 1];
|
|
# Moving across submenu paths would alter the meaning of the custom file.
|
|
return $text{'custom_emove'} if (!&grub2_paths_equal($entry, $other));
|
|
my @block = @lines[$entry->{'start'} .. $entry->{'end'}];
|
|
my $len = scalar(@block);
|
|
splice(@lines, $entry->{'start'}, $len);
|
|
my $insert = $direction eq 'up' ? $other->{'start'} :
|
|
$other->{'end'} - $len + 1;
|
|
splice(@lines, $insert, 0, @block);
|
|
my $data = join("\n", @lines)."\n";
|
|
&grub2_write_custom_file($file, $data);
|
|
return;
|
|
});
|
|
}
|
|
|
|
# grub2_validate_custom_entry(title, id, body)
|
|
# Validates one custom entry before writing it to the custom file.
|
|
sub grub2_validate_custom_entry
|
|
{
|
|
my ($title, $id, $body) = @_;
|
|
$title = '' if (!defined($title));
|
|
$id = '' if (!defined($id));
|
|
$body = '' if (!defined($body));
|
|
return $text{'custom_etitle'} if ($title eq '' || $title =~ /[\r\n\0]/);
|
|
return $text{'custom_eid'} if ($id =~ /[\r\n\0]/ ||
|
|
($id ne '' && $id !~ /^[A-Za-z0-9_.:+=,\@-]+\z/));
|
|
return $text{'custom_eid'} if ($id =~ /^-/);
|
|
return $text{'custom_ebody'} if ($body =~ /\0/);
|
|
# Validate the full wrapped menuentry, not just the user-entered body.
|
|
return &grub2_validate_grub_script_text(
|
|
&grub2_format_custom_entry($title, $id, $body));
|
|
}
|
|
|
|
# grub2_validate_grub_script_text(text, [context-file])
|
|
# Validates GRUB script with grub-script-check when available, or braces.
|
|
sub grub2_validate_grub_script_text
|
|
{
|
|
my ($data, $context_file) = @_;
|
|
my $cmd = &grub2_command('script_check_cmd');
|
|
if ($cmd) {
|
|
# Prefer GRUB's own parser when it is installed.
|
|
my $file = $context_file || &grub2_config_value('custom_file') || '';
|
|
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
|
|
my $dir = &grub2_dirname($file);
|
|
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
|
|
my ($temp, $terr) = &grub2_make_temp_file($dir, 'script', $data);
|
|
return $terr if ($terr);
|
|
my ($out, $failed);
|
|
my $die;
|
|
eval {
|
|
$out = &backquote_command(
|
|
quotemeta($cmd).' '.quotemeta($temp).
|
|
' 2>&1 </dev/null');
|
|
$failed = $?;
|
|
1;
|
|
} || do { $die = $@ || $!; };
|
|
&grub2_unlink_temp($temp);
|
|
return $die if ($die);
|
|
if ($failed) {
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return $out || $text{'custom_evalidate'};
|
|
}
|
|
return;
|
|
}
|
|
my $depth = 0;
|
|
foreach my $line (split(/\n/, $data)) {
|
|
# Fallback validation catches unbalanced braces on minimal systems.
|
|
my ($opens, $closes) = &count_grub_braces($line);
|
|
$depth += $opens - $closes;
|
|
return $text{'custom_ebraces'} if ($depth < 0);
|
|
}
|
|
return if ($depth == 0);
|
|
return $text{'custom_ebraces'};
|
|
}
|
|
|
|
# grub2_custom_script_text(text)
|
|
# Returns the GRUB script portion of a 40_custom-style file.
|
|
sub grub2_custom_script_text
|
|
{
|
|
my ($data) = @_;
|
|
$data = '' if (!defined($data));
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data, -1);
|
|
if (@lines >= 2 && $lines[0] =~ /^#!/ &&
|
|
$lines[1] =~ /^\s*exec\s+tail\s+-n\s+\+3\b/) {
|
|
# Standard 40_custom has a shell wrapper that GRUB never sees.
|
|
splice(@lines, 0, 2);
|
|
}
|
|
return join("\n", @lines);
|
|
}
|
|
|
|
# grub2_format_custom_entry(title, id, body)
|
|
# Returns normalized GRUB script for one custom menuentry.
|
|
sub grub2_format_custom_entry
|
|
{
|
|
my ($title, $id, $body) = @_;
|
|
$body = '' if (!defined($body));
|
|
$body =~ s/\r\n/\n/g;
|
|
$body =~ s/\r/\n/g;
|
|
my @body = split(/\n/, $body);
|
|
@body = &grub2_unindent_custom_body(\@body);
|
|
@body = ('true') if (!grep { /\S/ } @body);
|
|
my $line = 'menuentry '.&grub2_quote_word($title);
|
|
$line .= ' --id '.&grub2_quote_word($id) if (defined($id) && $id ne '');
|
|
$line .= ' {';
|
|
# Store custom bodies with one tab so future edits can unindent cleanly.
|
|
my @indented = map { "\t".$_ } @body;
|
|
return join("\n", $line, @indented, '}')."\n";
|
|
}
|
|
|
|
# grub2_quote_word(text)
|
|
# Quotes one GRUB command word using double-quote shell syntax.
|
|
sub grub2_quote_word
|
|
{
|
|
my ($text) = @_;
|
|
$text = '' if (!defined($text));
|
|
$text =~ s/(["\\\$`])/\\$1/g;
|
|
return '"'.$text.'"';
|
|
}
|
|
|
|
# grub2_paths_equal(&entry-a, &entry-b)
|
|
# Returns true when two entries belong to the same submenu path.
|
|
sub grub2_paths_equal
|
|
{
|
|
my ($a, $b) = @_;
|
|
return join("\n", @{$a->{'path'} || []}) eq
|
|
join("\n", @{$b->{'path'} || []});
|
|
}
|
|
|
|
# grub2_with_file_lock(file, &code)
|
|
# Runs a code reference while holding a Webmin lock on a file.
|
|
sub grub2_with_file_lock
|
|
{
|
|
my ($file, $code) = @_;
|
|
my ($ret, $die);
|
|
&lock_file($file);
|
|
eval {
|
|
# The callback performs any validation that must happen under the lock.
|
|
$ret = $code->();
|
|
1;
|
|
} || do { $die = $@ || $!; };
|
|
&unlock_file($file);
|
|
return $die if ($die);
|
|
return $ret;
|
|
}
|
|
|
|
# grub2_write_custom_file(file, data)
|
|
# Writes the custom file and ensures it remains executable.
|
|
sub grub2_write_custom_file
|
|
{
|
|
my ($file, $data) = @_;
|
|
open_tempfile(my $fh, ">$file");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
chmod(oct("0755"), $file);
|
|
return;
|
|
}
|
|
|
|
# grub2_make_temp_file(dir, prefix, [data])
|
|
# Creates a private temporary file in a live config directory.
|
|
sub grub2_make_temp_file
|
|
{
|
|
my ($dir, $prefix, $data) = @_;
|
|
return ('', &text('defaults_edir', $dir)) if ($dir eq '' || !-d $dir);
|
|
&seed_random();
|
|
my $last_err;
|
|
for (my $i = 0; $i < 20; $i++) {
|
|
my $temp = "$dir/.webmin-grub2-$prefix-$$-".
|
|
int(rand(1000000000))."-$i";
|
|
my $fh;
|
|
# O_EXCL avoids racing another Webmin process in the same directory.
|
|
if (!sysopen($fh, $temp, O_WRONLY|O_CREAT|O_EXCL, oct("0600"))) {
|
|
$last_err = $!;
|
|
next if ($! == EEXIST);
|
|
next;
|
|
}
|
|
binmode($fh);
|
|
if (defined($data) && !(print $fh $data)) {
|
|
my $err = $!;
|
|
close($fh);
|
|
unlink($temp);
|
|
return ('', $err);
|
|
}
|
|
if (!close($fh)) {
|
|
my $err = $!;
|
|
unlink($temp);
|
|
return ('', $err);
|
|
}
|
|
push(@main::temporary_files, $temp);
|
|
return ($temp);
|
|
}
|
|
return ('', "Failed to create temporary file in $dir : $last_err");
|
|
}
|
|
|
|
# grub2_unlink_temp(file)
|
|
# Removes a temporary file and unregisters it from Webmin cleanup.
|
|
sub grub2_unlink_temp
|
|
{
|
|
my ($temp) = @_;
|
|
return if (!defined($temp) || $temp eq '');
|
|
unlink($temp);
|
|
@main::temporary_files = grep { $_ ne $temp } @main::temporary_files;
|
|
}
|
|
|
|
# grub2_custom_file_lines(file)
|
|
# Returns current or new custom file contents as editable lines.
|
|
sub grub2_custom_file_lines
|
|
{
|
|
my ($file) = @_;
|
|
my $data;
|
|
if ($file && -r $file) {
|
|
# Existing custom files are preserved, including their shell wrapper.
|
|
$data = &read_file_contents($file);
|
|
}
|
|
else {
|
|
# New custom files use the conventional 40_custom wrapper.
|
|
$data = "#!/bin/sh\nexec tail -n +3 \$0\n";
|
|
}
|
|
$data =~ s/\r\n/\n/g;
|
|
$data =~ s/\r/\n/g;
|
|
my @lines = split(/\n/, $data, -1);
|
|
pop(@lines) if (@lines && $lines[-1] eq '');
|
|
return @lines;
|
|
}
|
|
|
|
# parse_grub_statement(line, keyword)
|
|
# Returns the title and ID from a menuentry or submenu line.
|
|
sub parse_grub_statement
|
|
{
|
|
my ($line, $keyword) = @_;
|
|
$line =~ s/^\s*\Q$keyword\E\s+//;
|
|
my ($title, $rest) = &parse_grub_word($line);
|
|
my $id;
|
|
# Accept both explicit --id and distro scripts using $menuentry_id_option.
|
|
if (defined($rest) &&
|
|
$rest =~ /(?:--id|\$menuentry_id_option)\s+((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)/) {
|
|
($id) = &parse_grub_word($1);
|
|
}
|
|
return ($title, $id);
|
|
}
|
|
|
|
# parse_grub_word(text)
|
|
# Parses one GRUB shell-style word.
|
|
sub parse_grub_word
|
|
{
|
|
my ($text) = @_;
|
|
return if (!defined($text));
|
|
$text =~ s/^\s+//;
|
|
return if ($text eq '');
|
|
my $quote = substr($text, 0, 1);
|
|
if ($quote eq "'" || $quote eq '"') {
|
|
# Parse one quoted word and return the unparsed remainder to the caller.
|
|
my $out = '';
|
|
my $escape = 0;
|
|
for (my $i = 1; $i < length($text); $i++) {
|
|
my $ch = substr($text, $i, 1);
|
|
if ($escape) {
|
|
$out .= $ch;
|
|
$escape = 0;
|
|
next;
|
|
}
|
|
if ($quote eq '"' && $ch eq '\\') {
|
|
$escape = 1;
|
|
next;
|
|
}
|
|
if ($ch eq $quote) {
|
|
return ($out, substr($text, $i + 1));
|
|
}
|
|
$out .= $ch;
|
|
}
|
|
return ($out, '');
|
|
}
|
|
if ($text =~ /^(\S+)(.*)\z/s) {
|
|
# Unquoted words end at the next whitespace.
|
|
return ($1, $2);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# count_grub_braces(line)
|
|
# Counts unquoted braces on a GRUB config line.
|
|
sub count_grub_braces
|
|
{
|
|
my ($line) = @_;
|
|
my ($opens, $closes) = (0, 0);
|
|
my $quote = '';
|
|
my $escape = 0;
|
|
for (my $i = 0; $i < length($line); $i++) {
|
|
my $ch = substr($line, $i, 1);
|
|
if ($escape) {
|
|
# Escaped characters inside double quotes are not syntax braces.
|
|
$escape = 0;
|
|
next;
|
|
}
|
|
if ($quote eq '"') {
|
|
if ($ch eq '\\') {
|
|
$escape = 1;
|
|
}
|
|
elsif ($ch eq '"') {
|
|
$quote = '';
|
|
}
|
|
next;
|
|
}
|
|
if ($quote eq "'") {
|
|
$quote = '' if ($ch eq "'");
|
|
next;
|
|
}
|
|
if ($ch eq '"' || $ch eq "'") {
|
|
$quote = $ch;
|
|
next;
|
|
}
|
|
# Comments terminate syntax scanning for this line.
|
|
last if ($ch eq '#');
|
|
$opens++ if ($ch eq '{');
|
|
$closes++ if ($ch eq '}');
|
|
}
|
|
return ($opens, $closes);
|
|
}
|
|
|
|
# grub2_statement_block_end(&lines, start-index)
|
|
# Returns the final line index for a GRUB statement block.
|
|
sub grub2_statement_block_end
|
|
{
|
|
my ($lines, $start) = @_;
|
|
my $depth = 0;
|
|
my $seen_open = 0;
|
|
for (my $i = $start; $i < @$lines; $i++) {
|
|
my ($opens, $closes) = &count_grub_braces($lines->[$i]);
|
|
$seen_open ||= $opens;
|
|
$depth += $opens - $closes;
|
|
# A block ends on the first line that balances the opening brace.
|
|
return $i if ($seen_open && $depth <= 0);
|
|
}
|
|
return $start;
|
|
}
|
|
|
|
# grub2_entry_selector(&entry)
|
|
# Returns the safest selector to pass to grub-set-default or grub-reboot.
|
|
sub grub2_entry_selector
|
|
{
|
|
my ($entry) = @_;
|
|
return $entry->{'id'} if ($entry->{'id'});
|
|
my @path = (@{$entry->{'path'} || []}, $entry->{'title'});
|
|
return if (grep { !defined($_) || />/ } @path);
|
|
return join('>', @path);
|
|
}
|
|
|
|
# grub2_entry_by_index(index)
|
|
# Returns a parsed boot entry by non-negative index.
|
|
sub grub2_entry_by_index
|
|
{
|
|
my ($index) = @_;
|
|
return if (!defined($index) || $index !~ /^\d+\z/);
|
|
my @entries = &grub2_boot_entries();
|
|
return if ($index >= @entries);
|
|
return $entries[$index];
|
|
}
|
|
|
|
# grub2_entry_selection_roles(&entries, [&parsed-defaults], [&env])
|
|
# Returns entry indexes mapped to active default and next-boot roles.
|
|
sub grub2_entry_selection_roles
|
|
{
|
|
my ($entries, $parsed, $env) = @_;
|
|
$entries ||= [ &grub2_boot_entries() ];
|
|
$parsed ||= &read_grub_defaults();
|
|
if (!$env) {
|
|
my %read_env = &grub2_read_env();
|
|
$env = \%read_env;
|
|
}
|
|
my %roles;
|
|
my $default = $parsed->{'values'}->{'GRUB_DEFAULT'};
|
|
$default = '0' if (!defined($default) || $default eq '');
|
|
if ($default eq 'saved') {
|
|
# saved resolves through grubenv, not directly through grub.cfg.
|
|
&grub2_mark_entry_role(\%roles, $entries, $env->{'saved_entry'}, 'saved');
|
|
}
|
|
else {
|
|
&grub2_mark_entry_role(\%roles, $entries, $default, 'default');
|
|
}
|
|
&grub2_mark_entry_role(\%roles, $entries, $env->{'next_entry'}, 'next');
|
|
return %roles;
|
|
}
|
|
|
|
# grub2_mark_entry_role(&roles, &entries, selector, role)
|
|
# Adds one selection role to the entry matched by a GRUB selector.
|
|
sub grub2_mark_entry_role
|
|
{
|
|
my ($roles, $entries, $selector, $role) = @_;
|
|
return if (!defined($selector) || $selector eq '');
|
|
foreach my $entry (@$entries) {
|
|
if (&grub2_entry_matches_selector($entry, $selector)) {
|
|
push(@{$roles->{$entry->{'index'}}}, $role);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
# grub2_entry_matches_selector(&entry, selector)
|
|
# Returns true when a selector names an entry by index, ID, title, or path.
|
|
sub grub2_entry_matches_selector
|
|
{
|
|
my ($entry, $selector) = @_;
|
|
return 0 if (!defined($entry) || !defined($selector) || $selector eq '');
|
|
return 1 if ($selector =~ /^\d+\z/ && $selector == $entry->{'index'});
|
|
return 1 if (defined($entry->{'id'}) && $entry->{'id'} eq $selector);
|
|
return 1 if (defined($entry->{'title'}) && $entry->{'title'} eq $selector);
|
|
my @path = (@{$entry->{'path'} || []}, $entry->{'title'});
|
|
return 0 if (grep { !defined($_) || />/ } @path);
|
|
return join('>', @path) eq $selector;
|
|
}
|
|
|
|
# grub2_delete_custom_ranges(file, &ranges)
|
|
# Removes line ranges from the custom file.
|
|
sub grub2_delete_custom_ranges
|
|
{
|
|
my ($file, $ranges, $lines) = @_;
|
|
my @lines = $lines ? @$lines : &grub2_custom_file_lines($file);
|
|
foreach my $range (sort { $b->[0] <=> $a->[0] } @$ranges) {
|
|
# Delete from bottom to top so earlier indexes remain valid.
|
|
splice(@lines, $range->[0], $range->[1] - $range->[0] + 1);
|
|
}
|
|
my $new_data = join("\n", @lines);
|
|
$new_data .= "\n" if (@lines);
|
|
&grub2_write_custom_file($file, $new_data);
|
|
return;
|
|
}
|
|
|
|
# grub2_run_entry_command(command-key, &entry)
|
|
# Runs a GRUB command that takes one menu entry selector.
|
|
sub grub2_run_entry_command
|
|
{
|
|
my ($key, $entry) = @_;
|
|
my $cmd = &grub2_command($key);
|
|
return $text{'runtime_ecmd'} if (!$cmd);
|
|
my $selector = &grub2_entry_selector($entry);
|
|
return $text{'runtime_eselector'}
|
|
if (!defined($selector) || $selector eq '' || $selector =~ /^-/);
|
|
# Quote the selector because titles and submenu paths may contain spaces.
|
|
my $out = &backquote_logged(
|
|
quotemeta($cmd).' '.quotemeta($selector).' 2>&1 </dev/null');
|
|
if ($?) {
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return $out || $text{'runtime_err'};
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_read_env()
|
|
# Reads key/value state from grubenv using grub-editenv when available.
|
|
sub grub2_read_env
|
|
{
|
|
my %env;
|
|
my $file = &grub2_config_value('grubenv_file') || '';
|
|
my $cmd = &grub2_command('editenv_cmd');
|
|
if ($cmd && $file ne '' && -e $file) {
|
|
# grub-editenv handles the binary environment format when available.
|
|
my $out = &backquote_command(
|
|
quotemeta($cmd).' '.quotemeta($file).' list 2>&1 </dev/null',
|
|
1);
|
|
if (!$?) {
|
|
foreach my $line (split(/\r?\n/, $out || '')) {
|
|
if ($line =~ /^([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
|
|
$env{$1} = $2;
|
|
}
|
|
}
|
|
return %env;
|
|
}
|
|
}
|
|
if ($file ne '' && -r $file) {
|
|
# Plain fallback helps in tests and on systems with text grubenv files.
|
|
my $data = &read_file_contents($file);
|
|
foreach my $line (split(/\r?\n/, $data || '')) {
|
|
if ($line =~ /^([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
|
|
$env{$1} = $2;
|
|
}
|
|
}
|
|
}
|
|
return %env;
|
|
}
|
|
|
|
# grub2_validate_install_options(&options)
|
|
# Returns an error if GRUB installation options are unsafe or incomplete.
|
|
sub grub2_validate_install_options
|
|
{
|
|
my ($opts) = @_;
|
|
my $target = $opts->{'target'} || '';
|
|
my $efi_dir = $opts->{'efi_dir'} || '';
|
|
my $platform = $opts->{'platform'} || '';
|
|
my $directory = $opts->{'directory'} || '';
|
|
my $boot_directory = $opts->{'boot_directory'} || '';
|
|
my $bootloader_id = $opts->{'bootloader_id'} || '';
|
|
return $text{'install_etarget'} if ($target eq '' && $efi_dir eq '');
|
|
if ($target ne '') {
|
|
# BIOS targets must be existing absolute device paths under /dev.
|
|
return $text{'install_etarget'}
|
|
if ($target =~ /[\r\n\0]/ || $target !~ m{\A/} ||
|
|
$target !~ m{\A/dev/} ||
|
|
$target !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
|
|
$target =~ m{/(?:\.|\.\.)(?:/|\z)} || $target =~ /^-/);
|
|
return &text('install_etarget_missing', $target) if (!-e $target);
|
|
}
|
|
if ($efi_dir ne '') {
|
|
# EFI installs target an existing absolute ESP mount path.
|
|
return $text{'install_eefi'}
|
|
if ($efi_dir =~ /[\r\n\0]/ || $efi_dir !~ m{\A/} ||
|
|
$efi_dir !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
|
|
$efi_dir =~ m{/(?:\.|\.\.)(?:/|\z)});
|
|
return &text('install_eefi_missing', $efi_dir) if (!-d $efi_dir);
|
|
}
|
|
if ($platform ne '') {
|
|
# Platform targets are GRUB names such as x86_64-efi or arm64-efi.
|
|
return $text{'install_eplatform'}
|
|
if ($platform =~ /[\r\n\0]/ ||
|
|
$platform !~ /\A[A-Za-z0-9_-]+\z/ || $platform =~ /^-/);
|
|
}
|
|
if ($directory ne '') {
|
|
# Custom module directories must contain modinfo.sh for grub-install.
|
|
return $text{'install_edirectory'}
|
|
if ($directory =~ /[\r\n\0]/ || $directory !~ m{\A/} ||
|
|
$directory !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
|
|
$directory =~ m{/(?:\.|\.\.)(?:/|\z)});
|
|
return &text('install_edirectory_missing', $directory)
|
|
if (!-d $directory);
|
|
return &text('install_edirectory_modinfo', $directory)
|
|
if (!-r "$directory/modinfo.sh");
|
|
}
|
|
elsif ($platform ne '' && !&grub2_platform_module_dir($platform)) {
|
|
# Without an explicit directory, require a discoverable platform directory.
|
|
return &text('install_eplatform_modules', $platform,
|
|
join(', ', &grub2_platform_module_dirs($platform)));
|
|
}
|
|
if ($boot_directory ne '') {
|
|
# --boot-directory is optional but still needs the same path hygiene.
|
|
return $text{'install_eboot_directory'}
|
|
if ($boot_directory =~ /[\r\n\0]/ ||
|
|
$boot_directory !~ m{\A/} ||
|
|
$boot_directory !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
|
|
$boot_directory =~ m{/(?:\.|\.\.)(?:/|\z)});
|
|
return &text('install_eboot_directory_missing', $boot_directory)
|
|
if (!-d $boot_directory);
|
|
}
|
|
if ($bootloader_id ne '') {
|
|
# EFI boot loader IDs become paths below EFI/, so keep them simple.
|
|
return $text{'install_ebootloader'}
|
|
if ($bootloader_id =~ /[\r\n\0]/ ||
|
|
$bootloader_id !~ /\A[A-Za-z0-9_.+-]+\z/ ||
|
|
$bootloader_id =~ /^-/);
|
|
}
|
|
foreach my $key (qw(recheck removable no_nvram force)) {
|
|
return $text{'install_eoption'}
|
|
if (defined($opts->{$key}) && $opts->{$key} !~ /^[01]\z/);
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_install_command(&options)
|
|
# Returns shell and display forms of the grub-install command.
|
|
sub grub2_install_command
|
|
{
|
|
my ($opts) = @_;
|
|
my $cmd = &grub2_command('install_cmd');
|
|
return ('', '') if (!$cmd);
|
|
my @run = (quotemeta($cmd));
|
|
my @display = ($cmd);
|
|
foreach my $pair (
|
|
[ 'recheck', '--recheck' ],
|
|
[ 'removable', '--removable' ],
|
|
[ 'no_nvram', '--no-nvram' ],
|
|
[ 'force', '--force' ],
|
|
)
|
|
{
|
|
my ($key, $arg) = @$pair;
|
|
if ($opts->{$key}) {
|
|
# Boolean options are emitted before path-like options.
|
|
push(@run, quotemeta($arg));
|
|
push(@display, $arg);
|
|
}
|
|
}
|
|
if (($opts->{'efi_dir'} || '') ne '') {
|
|
# EFI installs can omit a BIOS-style block-device target.
|
|
push(@run, quotemeta('--efi-directory='.$opts->{'efi_dir'}));
|
|
push(@display, '--efi-directory='.$opts->{'efi_dir'});
|
|
}
|
|
if (($opts->{'platform'} || '') ne '') {
|
|
push(@run, quotemeta('--target='.$opts->{'platform'}));
|
|
push(@display, '--target='.$opts->{'platform'});
|
|
}
|
|
if (($opts->{'directory'} || '') ne '') {
|
|
push(@run, quotemeta('--directory='.$opts->{'directory'}));
|
|
push(@display, '--directory='.$opts->{'directory'});
|
|
}
|
|
if (($opts->{'boot_directory'} || '') ne '') {
|
|
push(@run, quotemeta('--boot-directory='.$opts->{'boot_directory'}));
|
|
push(@display, '--boot-directory='.$opts->{'boot_directory'});
|
|
}
|
|
if (($opts->{'bootloader_id'} || '') ne '') {
|
|
push(@run, quotemeta('--bootloader-id='.$opts->{'bootloader_id'}));
|
|
push(@display, '--bootloader-id='.$opts->{'bootloader_id'});
|
|
}
|
|
if (($opts->{'target'} || '') ne '') {
|
|
push(@run, quotemeta($opts->{'target'}));
|
|
push(@display, $opts->{'target'});
|
|
}
|
|
return (join(' ', @run).' </dev/null', join(' ', @display));
|
|
}
|
|
|
|
# grub2_install_bootloader(&options, [&callback])
|
|
# Runs grub-install with explicit administrator-selected options.
|
|
sub grub2_install_bootloader
|
|
{
|
|
my ($opts, $callback) = @_;
|
|
my $cmd = &grub2_command('install_cmd');
|
|
return $text{'install_ecmd'} if (!$cmd);
|
|
my $err = &grub2_validate_install_options($opts);
|
|
return $err if ($err);
|
|
my ($run, $display) = &grub2_install_command($opts);
|
|
my ($out, $failed);
|
|
my $die;
|
|
eval {
|
|
&grub2_generate_progress($callback, 'command', $display);
|
|
if ($callback) {
|
|
# Progress mode streams output and records the executed command.
|
|
$out = &grub2_run_command_progress($run, $callback);
|
|
$failed = $?;
|
|
}
|
|
else {
|
|
# Non-progress callers still get logged command execution.
|
|
$out = &backquote_logged($run.' 2>&1');
|
|
$failed = $?;
|
|
}
|
|
&grub2_generate_progress($callback,
|
|
$failed ? 'command_failed' : 'command_done', $display);
|
|
1;
|
|
} || do { $die = $@ || $!; };
|
|
return $die if ($die);
|
|
if ($failed) {
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return $out || $text{'install_failed'};
|
|
}
|
|
return;
|
|
}
|
|
|
|
# grub2_install_log_target(&options)
|
|
# Returns a concise installation target string for logs.
|
|
sub grub2_install_log_target
|
|
{
|
|
my ($opts) = @_;
|
|
return $opts->{'target'} if (($opts->{'target'} || '') ne '');
|
|
return &text('install_log_efi', $opts->{'efi_dir'})
|
|
if (($opts->{'efi_dir'} || '') ne '');
|
|
return '';
|
|
}
|
|
|
|
# grub2_generate_config([&callback])
|
|
# Runs grub-mkconfig to a test file, then replaces the live generated menu.
|
|
sub grub2_generate_config
|
|
{
|
|
my ($callback) = @_;
|
|
my $cmd = &grub2_command('mkconfig_cmd');
|
|
return $text{'generate_missing'} if (!$cmd);
|
|
my $target = &grub2_config_value('grub_cfg') || '';
|
|
return $text{'index_warn_missing_cfg'} if ($target eq '');
|
|
return &text('defaults_econfigpath', $target) if ($target !~ m{^/});
|
|
my $dir = &grub2_dirname($target);
|
|
return &text('index_missing_detail', $dir) if ($dir eq '' || !-d $dir);
|
|
my ($temp, $terr) = &grub2_make_temp_file($dir, 'mkconfig');
|
|
return $terr if ($terr);
|
|
my ($out, $failed, $data, $validation_err);
|
|
my $die;
|
|
eval {
|
|
# Always generate to a sibling temp file before touching the live menu.
|
|
my $run = quotemeta($cmd).' -o '.quotemeta($temp).' </dev/null';
|
|
my $display = $cmd.' -o '.$temp;
|
|
&grub2_generate_progress($callback, 'command', $display);
|
|
if ($callback) {
|
|
$out = &grub2_run_command_progress($run, $callback);
|
|
$failed = $?;
|
|
}
|
|
else {
|
|
$out = &backquote_logged($run.' 2>&1');
|
|
$failed = $?;
|
|
}
|
|
if (!$failed) {
|
|
&grub2_generate_progress($callback, 'command_done', $display);
|
|
&grub2_generate_progress($callback, 'check', $temp);
|
|
if (-s $temp) {
|
|
# Non-empty output is the first guard against broken generators.
|
|
$data = &read_file_contents($temp);
|
|
}
|
|
if (defined($data) && $data ne '') {
|
|
# grub-script-check catches syntax errors before replacement.
|
|
$validation_err =
|
|
&grub2_validate_grub_script_text($data, $target);
|
|
}
|
|
if (defined($data) && $data ne '' && !$validation_err) {
|
|
&grub2_generate_progress($callback, 'check_done', $temp);
|
|
}
|
|
else {
|
|
&grub2_generate_progress($callback, 'check_failed', $temp);
|
|
}
|
|
}
|
|
else {
|
|
&grub2_generate_progress($callback, 'command_failed', $display);
|
|
}
|
|
1;
|
|
} || do { $die = $@ || $!; };
|
|
if ($die) {
|
|
&grub2_unlink_temp($temp);
|
|
return $die;
|
|
}
|
|
if ($failed || !defined($data) || $data eq '' || $validation_err) {
|
|
# Leave the existing grub.cfg untouched on all generation/check failures.
|
|
&grub2_unlink_temp($temp);
|
|
$out =~ s/^\s+|\s+\z//g if (defined($out));
|
|
return &text('generate_evalidate', $validation_err)
|
|
if ($validation_err);
|
|
return $out || ($failed ? $text{'generate_failed'} :
|
|
$text{'generate_empty'});
|
|
}
|
|
&grub2_unlink_temp($temp);
|
|
&grub2_generate_progress($callback, 'replace', $target);
|
|
# Re-open the live target via Webmin's locked tempfile writer for logging.
|
|
open_lock_tempfile(my $fh, ">$target");
|
|
print_tempfile($fh, $data);
|
|
close_tempfile($fh);
|
|
&grub2_generate_progress($callback, 'replace_done', $target);
|
|
return;
|
|
}
|
|
|
|
# grub2_run_command_progress(command, &callback)
|
|
# Runs one command and streams combined stdout/stderr to a callback.
|
|
sub grub2_run_command_progress
|
|
{
|
|
my ($cmd, $callback) = @_;
|
|
my $out = '';
|
|
&additional_log('exec', undef, $cmd);
|
|
local *GRUB2CMD;
|
|
my $pid = &open_execute_command(\*GRUB2CMD, $cmd, 2, 0);
|
|
if (!$pid) {
|
|
# Match shell-style command failure status for callers checking $?
|
|
$? = 1 << 8;
|
|
return "$cmd : $!";
|
|
}
|
|
while (defined(my $line = readline(\*GRUB2CMD))) {
|
|
# Stream line-by-line so progress pages update during long commands.
|
|
$out .= $line;
|
|
&grub2_generate_progress($callback, 'output', $line);
|
|
}
|
|
close(\*GRUB2CMD);
|
|
return $out;
|
|
}
|
|
|
|
# grub2_generate_progress(&callback, event, value)
|
|
# Sends a generation progress event when a callback was supplied.
|
|
sub grub2_generate_progress
|
|
{
|
|
my ($callback, $event, $value) = @_;
|
|
return if (!$callback);
|
|
$callback->($event, $value);
|
|
return;
|
|
}
|
|
|
|
# grub2_config_files()
|
|
# Returns files and directories that should be included in config backups.
|
|
sub grub2_config_files
|
|
{
|
|
my @files;
|
|
foreach my $key (qw(default_file grub_cfg grub_dir custom_file password_file
|
|
color_file theme_dir background_dir grubenv_file bls_dir)) {
|
|
my $file = &grub2_config_value($key);
|
|
push(@files, $file) if (defined($file) && $file ne '');
|
|
}
|
|
return &unique(@files);
|
|
}
|
|
|
|
# grub2_status_warnings()
|
|
# Returns actionable warnings for the index page.
|
|
sub grub2_status_warnings
|
|
{
|
|
my @warnings;
|
|
my $default_file = &grub2_config_value('default_file') || '';
|
|
my $grub_cfg = &grub2_config_value('grub_cfg') || '';
|
|
push(@warnings, $text{'index_warn_missing_default'})
|
|
if ($default_file ne '' && !-r $default_file);
|
|
push(@warnings, $text{'index_warn_missing_cfg'})
|
|
if ($grub_cfg ne '' && !-r $grub_cfg);
|
|
push(@warnings, $text{'index_warn_mkconfig'})
|
|
if (!&grub2_command('mkconfig_cmd'));
|
|
my %env = &grub2_read_env();
|
|
return @warnings if ($default_file eq '' || !-r $default_file);
|
|
my $parsed = &read_grub_defaults($default_file);
|
|
if (($parsed->{'values'}->{'GRUB_DEFAULT'} || '') eq 'saved' &&
|
|
!$env{'saved_entry'}) {
|
|
push(@warnings, $text{'index_warn_saved'});
|
|
}
|
|
my $theme = $parsed->{'values'}->{'GRUB_THEME'} || '';
|
|
if ($theme ne '') {
|
|
my $terr = &grub2_validate_theme_path($theme, $text{'defaults_theme'});
|
|
push(@warnings, &text('index_warn_theme_invalid', $terr)) if ($terr);
|
|
}
|
|
if ($theme ne '' &&
|
|
($parsed->{'values'}->{'GRUB_TERMINAL_OUTPUT'} || '') eq 'console') {
|
|
push(@warnings, $text{'index_warn_theme_console'});
|
|
}
|
|
return @warnings;
|
|
}
|
|
|
|
# grub2_dirname(path)
|
|
# Returns the directory component of a Unix path.
|
|
sub grub2_dirname
|
|
{
|
|
my ($path) = @_;
|
|
return '' if (!defined($path) || $path eq '');
|
|
my $dir = dirname($path);
|
|
return '' if ($dir eq '.');
|
|
return $dir;
|
|
}
|
|
|
|
1;
|