# 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 {'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 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 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 [ '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 $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 {'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 = 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 &1 {'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).' &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).' &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;