diff --git a/grub2/acl_security.pl b/grub2/acl_security.pl new file mode 100644 index 000000000..e5564aa28 --- /dev/null +++ b/grub2/acl_security.pl @@ -0,0 +1,48 @@ +use strict; +use warnings; +no warnings 'redefine'; +no warnings 'uninitialized'; + +do 'grub2-lib.pl'; + +our (%in, %text); + +# acl_security_form(&options) +# Outputs HTML for editing security options for the GRUB 2 module. +sub acl_security_form +{ +my ($o) = @_; +print &ui_table_span(&ui_tag('b', &html_escape($text{'acl_section_view'}))); +# View is separated because it controls whether the module can be entered. +foreach my $a (qw(view)) { + print &ui_table_row($text{'acl_'.$a}, + &ui_yesno_radio($a, &grub2_check_acl($a, $o)), 3); + } +print &ui_table_hr(); +print &ui_table_span(&ui_tag('b', &html_escape($text{'acl_section_change'}))); +# Change permissions modify GRUB configuration without granting install rights. +foreach my $a (qw(edit security apply runtime)) { + print &ui_table_row($text{'acl_'.$a}, + &ui_yesno_radio($a, &grub2_check_acl($a, $o)), 3); + } +print &ui_table_hr(); +print &ui_table_span(&ui_tag('b', &html_escape($text{'acl_section_admin'}))); +# Admin permissions expose direct file editing, backup, and boot-loader install. +foreach my $a (qw(manual install backup)) { + print &ui_table_row($text{'acl_'.$a}, + &ui_yesno_radio($a, &grub2_check_acl($a, $o)), 3); + } +} + +# acl_security_save(&options) +# Parses the form for security options for the GRUB 2 module. +sub acl_security_save +{ +my ($o) = @_; +foreach my $a (&grub2_acl_keys()) { + # Missing checkbox/radio values fall back to denied. + $o->{$a} = $in{$a} || 0; + } +} + +1; diff --git a/grub2/backup_config.pl b/grub2/backup_config.pl new file mode 100644 index 000000000..fa727f7a7 --- /dev/null +++ b/grub2/backup_config.pl @@ -0,0 +1,13 @@ +use strict; +use warnings; + +do 'grub2-lib.pl'; + +# backup_config_files() +# Returns GRUB 2 files and directories that can be backed up. +sub backup_config_files +{ +return &grub2_config_files(); +} + +1; diff --git a/grub2/config b/grub2/config new file mode 100644 index 000000000..290a7357c --- /dev/null +++ b/grub2/config @@ -0,0 +1,19 @@ +default_file=/etc/default/grub +grub_cfg=/boot/grub/grub.cfg +grub_dir=/etc/grub.d +custom_file=/etc/grub.d/40_custom +password_file=/etc/grub.d/01_webmin_password +color_file=/etc/grub.d/06_webmin_colors +theme_dir=/boot/grub/themes +background_dir=/boot/grub/backgrounds +grubenv_file=/boot/grub/grubenv +bls_dir=/boot/loader/entries +mkconfig_cmd=/usr/sbin/grub-mkconfig +install_cmd=/usr/sbin/grub-install +set_default_cmd=/usr/sbin/grub-set-default +reboot_once_cmd=/usr/sbin/grub-reboot +editenv_cmd=/usr/bin/grub-editenv +script_check_cmd=/usr/bin/grub-script-check +mkpasswd_cmd=/usr/bin/grub-mkpasswd-pbkdf2 +grubby_cmd=/usr/sbin/grubby +shell_cmd=/bin/sh diff --git a/grub2/config-debian-linux b/grub2/config-debian-linux new file mode 100644 index 000000000..290a7357c --- /dev/null +++ b/grub2/config-debian-linux @@ -0,0 +1,19 @@ +default_file=/etc/default/grub +grub_cfg=/boot/grub/grub.cfg +grub_dir=/etc/grub.d +custom_file=/etc/grub.d/40_custom +password_file=/etc/grub.d/01_webmin_password +color_file=/etc/grub.d/06_webmin_colors +theme_dir=/boot/grub/themes +background_dir=/boot/grub/backgrounds +grubenv_file=/boot/grub/grubenv +bls_dir=/boot/loader/entries +mkconfig_cmd=/usr/sbin/grub-mkconfig +install_cmd=/usr/sbin/grub-install +set_default_cmd=/usr/sbin/grub-set-default +reboot_once_cmd=/usr/sbin/grub-reboot +editenv_cmd=/usr/bin/grub-editenv +script_check_cmd=/usr/bin/grub-script-check +mkpasswd_cmd=/usr/bin/grub-mkpasswd-pbkdf2 +grubby_cmd=/usr/sbin/grubby +shell_cmd=/bin/sh diff --git a/grub2/config-openSUSE-Linux-15.0-ALL b/grub2/config-openSUSE-Linux-15.0-ALL new file mode 100644 index 000000000..9c55abce1 --- /dev/null +++ b/grub2/config-openSUSE-Linux-15.0-ALL @@ -0,0 +1,19 @@ +default_file=/etc/default/grub +grub_cfg=/boot/grub2/grub.cfg +grub_dir=/etc/grub.d +custom_file=/etc/grub.d/40_custom +password_file=/etc/grub.d/01_webmin_password +color_file=/etc/grub.d/06_webmin_colors +theme_dir=/boot/grub2/themes +background_dir=/boot/grub2/backgrounds +grubenv_file=/boot/grub2/grubenv +bls_dir=/boot/loader/entries +mkconfig_cmd=/usr/sbin/grub2-mkconfig +install_cmd=/usr/sbin/grub2-install +set_default_cmd=/usr/sbin/grub2-set-default +reboot_once_cmd=/usr/sbin/grub2-reboot +editenv_cmd=/usr/bin/grub2-editenv +script_check_cmd=/usr/bin/grub2-script-check +mkpasswd_cmd=/usr/bin/grub2-mkpasswd-pbkdf2 +grubby_cmd=/usr/sbin/grubby +shell_cmd=/bin/sh diff --git a/grub2/config-redhat-linux b/grub2/config-redhat-linux new file mode 100644 index 000000000..9c55abce1 --- /dev/null +++ b/grub2/config-redhat-linux @@ -0,0 +1,19 @@ +default_file=/etc/default/grub +grub_cfg=/boot/grub2/grub.cfg +grub_dir=/etc/grub.d +custom_file=/etc/grub.d/40_custom +password_file=/etc/grub.d/01_webmin_password +color_file=/etc/grub.d/06_webmin_colors +theme_dir=/boot/grub2/themes +background_dir=/boot/grub2/backgrounds +grubenv_file=/boot/grub2/grubenv +bls_dir=/boot/loader/entries +mkconfig_cmd=/usr/sbin/grub2-mkconfig +install_cmd=/usr/sbin/grub2-install +set_default_cmd=/usr/sbin/grub2-set-default +reboot_once_cmd=/usr/sbin/grub2-reboot +editenv_cmd=/usr/bin/grub2-editenv +script_check_cmd=/usr/bin/grub2-script-check +mkpasswd_cmd=/usr/bin/grub2-mkpasswd-pbkdf2 +grubby_cmd=/usr/sbin/grubby +shell_cmd=/bin/sh diff --git a/grub2/config-suse-linux b/grub2/config-suse-linux new file mode 100644 index 000000000..9c55abce1 --- /dev/null +++ b/grub2/config-suse-linux @@ -0,0 +1,19 @@ +default_file=/etc/default/grub +grub_cfg=/boot/grub2/grub.cfg +grub_dir=/etc/grub.d +custom_file=/etc/grub.d/40_custom +password_file=/etc/grub.d/01_webmin_password +color_file=/etc/grub.d/06_webmin_colors +theme_dir=/boot/grub2/themes +background_dir=/boot/grub2/backgrounds +grubenv_file=/boot/grub2/grubenv +bls_dir=/boot/loader/entries +mkconfig_cmd=/usr/sbin/grub2-mkconfig +install_cmd=/usr/sbin/grub2-install +set_default_cmd=/usr/sbin/grub2-set-default +reboot_once_cmd=/usr/sbin/grub2-reboot +editenv_cmd=/usr/bin/grub2-editenv +script_check_cmd=/usr/bin/grub2-script-check +mkpasswd_cmd=/usr/bin/grub2-mkpasswd-pbkdf2 +grubby_cmd=/usr/sbin/grubby +shell_cmd=/bin/sh diff --git a/grub2/config.info b/grub2/config.info new file mode 100644 index 000000000..f894772ff --- /dev/null +++ b/grub2/config.info @@ -0,0 +1,21 @@ +line1=Configuration files,11 +default_file=GRUB default settings file,0 +grub_cfg=Generated GRUB menu file,0 +grub_dir=GRUB script directory,0 +custom_file=Custom GRUB menu entries file,3,None +password_file=Webmin-managed GRUB password script,3,None +color_file=Webmin-managed GRUB color script,3,None +theme_dir=GRUB theme installation directory,3,None +background_dir=GRUB background image installation directory,3,None +grubenv_file=GRUB environment file,3,None +bls_dir=Boot Loader Specification entries directory,3,None +line2=Executables,11 +mkconfig_cmd=Command to generate the GRUB menu file,0 +install_cmd=Command to install the GRUB boot loader,3,None +set_default_cmd=Command to set the saved default entry,3,None +reboot_once_cmd=Command to set the next boot entry,3,None +editenv_cmd=Command to inspect the GRUB environment,3,None +script_check_cmd=Command to validate GRUB scripts,3,None +mkpasswd_cmd=Command to generate GRUB PBKDF2 password hashes,3,None +grubby_cmd=Command to update BLS kernel options,3,None +shell_cmd=Shell used to validate the default settings file,0 diff --git a/grub2/custom_action.cgi b/grub2/custom_action.cgi new file mode 100755 index 000000000..c173a1195 --- /dev/null +++ b/grub2/custom_action.cgi @@ -0,0 +1,54 @@ +#!/usr/local/bin/perl +# Apply an action to selected custom GRUB 2 entries. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'custom_err'}); +&grub2_assert_acl('manual'); + +# Per-row up/down links post an index and direction directly. +if (defined($in{'idx'}) || defined($in{'dir'})) { + defined($in{'idx'}) && $in{'idx'} =~ /^\d+\z/ || + &error($text{'custom_eentry'}); + # Keep movement inside one submenu by leaving it to the library helper. + defined($in{'dir'}) && $in{'dir'} =~ /^(up|down)\z/ || + &error($text{'custom_emove'}); + my $err = &grub2_move_custom_entry($in{'idx'}, $in{'dir'}); + &error($err) if ($err); + &grub2_mark_regenerate_needed(); + &webmin_log("custom_move", undef, $in{'idx'}); + &redirect("index.cgi?mode=custom"); + } + +# Checked-table actions can receive duplicate browser values; collapse them. +my @selected = split(/\0/, defined($in{'d'}) ? $in{'d'} : ""); +my %seen; +@selected = grep { defined($_) && $_ ne '' && !$seen{$_}++ } @selected; + +my $err; +# Delete accepts multiple selected entries and removes them in one rewrite. +if ($in{'delete'}) { + @selected || &error($text{'delete_enone'}); + $err = &grub2_delete_custom_entry_indexes(@selected); + &error($err) if ($err); + &grub2_mark_regenerate_needed(); + &webmin_log("custom_delete", undef, scalar(@selected)); + } +elsif ($in{'move_up'} || $in{'move_down'}) { + # Bulk move buttons are only safe for one entry at a time. + @selected == 1 || &error($text{'custom_eone'}); + $err = &grub2_move_custom_entry($selected[0], + $in{'move_up'} ? "up" : "down"); + &error($err) if ($err); + &grub2_mark_regenerate_needed(); + &webmin_log("custom_move", undef, $selected[0]); + } +else { + &error($text{'runtime_eaction'}); + } +&redirect("index.cgi?mode=custom"); diff --git a/grub2/defaultacl b/grub2/defaultacl new file mode 100644 index 000000000..0a797b9aa --- /dev/null +++ b/grub2/defaultacl @@ -0,0 +1,8 @@ +view=1 +edit=1 +security=1 +apply=1 +runtime=1 +manual=1 +install=1 +backup=1 diff --git a/grub2/edit_custom.cgi b/grub2/edit_custom.cgi new file mode 100755 index 000000000..312491358 --- /dev/null +++ b/grub2/edit_custom.cgi @@ -0,0 +1,45 @@ +#!/usr/local/bin/perl +# Show a form for adding or editing a custom GRUB 2 menu entry. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'custom_err'}); +&grub2_assert_acl('manual'); + +my $is_new = !defined($in{'idx'}) || $in{'idx'} eq ''; +my ($title, $id, $body) = + ("", "", "echo 'Custom entry not configured'\ntrue\n"); +# Existing entries are looked up by parsed custom-entry index, not line number. +if (!$is_new) { + $in{'idx'} =~ /^\d+\z/ || &error($text{'custom_eentry'}); + my $entry = &grub2_custom_entry_by_index($in{'idx'}); + &error($text{'custom_eentry'}) if (!$entry); + $title = $entry->{'title'} || ""; + $id = $entry->{'id'} || ""; + $body = &grub2_custom_entry_body($entry); + } + +&ui_print_header(undef, $is_new ? $text{'custom_title_new'} : + $text{'custom_title_edit'}, ""); + +print &ui_form_start("save_custom.cgi", "post"); +print &ui_hidden("idx", $in{'idx'}) if (!$is_new); +print &ui_table_start($text{'custom_header'}, "width=100%", 2); +print &ui_table_row(&hlink($text{'custom_entry_title'}, "custom_title"), + &ui_textbox("title", $title, 60)); +print &ui_table_row(&hlink($text{'custom_entry_id'}, "custom_id"), + &ui_textbox("id", $id, 60). + &ui_tag('div', &ui_note($text{'custom_id_note'}, 0))); +print &ui_table_hr(); +# The body is stored as GRUB script text inside a generated menuentry wrapper. +print &ui_table_row(&hlink($text{'custom_entry_body'}, "custom_body"), + &ui_textarea("body", $body, 16, 100), 2); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'save'} ] ]); + +&ui_print_footer("index.cgi?mode=custom", $text{'index_return'}); diff --git a/grub2/edit_defaults.cgi b/grub2/edit_defaults.cgi new file mode 100755 index 000000000..d29d5b7ed --- /dev/null +++ b/grub2/edit_defaults.cgi @@ -0,0 +1,155 @@ +#!/usr/local/bin/perl +# Show a form for editing common GRUB 2 defaults. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%text); + +&ReadParse(); +&error_setup($text{'defaults_err'}); +&grub2_assert_acl('edit'); + +my $parsed = &read_grub_defaults(); +my $values = $parsed->{'values'}; +my @entries = &grub2_boot_entries(); + +&ui_print_header(undef, $text{'defaults_title'}, ""); + +# BLS warnings explain when edits also need grubby or existing entry updates. +foreach my $warning (&grub2_bls_kernel_option_warnings(\@entries)) { + print &ui_alert($warning, 'warning'); + } + +print &ui_form_start("save_defaults.cgi", "post"); +print &ui_table_start($text{'defaults_header'}, "width=100%", 2); +print &ui_table_row( + &hlink($text{'defaults_default'}, "default"), + &default_entry_input(&field_value($values->{'GRUB_DEFAULT'}, "0"), + \@entries), +2, undef, undef, 1); +print &ui_table_hr(); +print &ui_table_row( + &hlink($text{'defaults_timeout_style'}, "timeout_style"), + &ui_select("timeout_style", &field_value($values->{'GRUB_TIMEOUT_STYLE'}), + [ + [ "", $text{'defaults_keep'} ], + [ "menu", $text{'defaults_menu'} ], + [ "hidden", $text{'defaults_hidden'} ], + [ "countdown", $text{'defaults_countdown'} ], + ]) +); +print &ui_table_row( + &hlink($text{'defaults_timeout'}, "timeout"), + &ui_textbox("timeout", &field_value($values->{'GRUB_TIMEOUT'}), 8) +); +print &ui_table_row( + &hlink($text{'defaults_kernelopts_source'}, "kernelopts_source"), + &html_escape(&grub2_kernel_options_source_text(\@entries)) +); +# Recovery is special on BLS systems because rescue entries are separate files. +print &ui_table_row( + &hlink($text{'defaults_disable_recovery'}, "disable_recovery"), + &bool_select("disable_recovery", $values->{'GRUB_DISABLE_RECOVERY'}). + (&grub2_has_bls_rescue_entries() ? + &ui_tag('div', &ui_note($text{'defaults_disable_recovery_bls'}, 0)) : + "") +); +print &ui_table_row( + &hlink($text{'defaults_disable_os_prober'}, "disable_os_prober"), + &bool_select("disable_os_prober", $values->{'GRUB_DISABLE_OS_PROBER'}) +); +print &ui_table_hr(); +print &ui_table_row( + &hlink($text{'defaults_cmdline_default'}, "cmdline_default"), + &ui_textbox("cmdline_default", + &field_value($values->{'GRUB_CMDLINE_LINUX_DEFAULT'}), 30, + undef, undef, undef, "w-100") +); +print &ui_table_row( + &hlink($text{'defaults_cmdline'}, "cmdline"), + &ui_textbox("cmdline", &field_value($values->{'GRUB_CMDLINE_LINUX'}), 70, + undef, undef, undef, "w-100") +); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'save'} ] ]); + +&ui_print_footer("index.cgi", $text{'index_return'}); + +# default_entry_input(value, &entries) +# Returns a selector for known boot entries. +sub default_entry_input +{ +my ($value, $entries) = @_; +my ($select_value, $options) = &default_entry_options($value, $entries); +return &ui_select("default", $select_value, $options, undef, undef, undef, + undef, 'style="field-sizing: content; max-width: 100%;"'); +} + +# default_entry_options(value, &entries) +# Returns select value and options for the default entry field. +sub default_entry_options +{ +my ($current, $entries) = @_; +$current = '0' if (!defined($current) || $current eq ''); +my @options; +my %seen; +my $add = sub { + my ($value, $label) = @_; + # Avoid duplicate selectors when GRUB_DEFAULT already names an entry. + return if (!defined($value) || $value eq '' || $seen{$value}++); + push(@options, [ $value, $label ]); + }; +$add->('saved', $text{'defaults_default_saved'}); +if ($current =~ /^\d+\z/ && $entries->[$current]) { + # Keep numeric defaults meaningful by showing the currently indexed entry. + $add->($current, &text('defaults_default_current_entry', $current, + &default_entry_label($entries->[$current]))); + } +foreach my $entry (@$entries) { + my $selector = &grub2_entry_selector($entry); + next if (!defined($selector) || $selector eq ''); + $add->($selector, &default_entry_label($entry)); + } +if ($current ne '' && !$seen{$current}) { + # Preserve unusual existing values without allowing arbitrary new input. + $add->($current, &text('defaults_default_current', $current)); + } +my $select_value = $seen{$current} ? $current : $options[0]->[0]; +return ($select_value, \@options); +} + +# default_entry_label(&entry) +# Returns a concise label for one parsed generated boot entry. +sub default_entry_label +{ +my ($entry) = @_; +my @path = @{$entry->{'path'} || []}; +my $label = join(' > ', (@path, $entry->{'title'} || '')); +return &text('defaults_default_entry_id', $label, $entry->{'id'}) + if (defined($entry->{'id'}) && $entry->{'id'} ne ''); +return $label; +} + +# bool_select(name, value) +# Returns a tri-state selector for GRUB true/false settings. +sub bool_select +{ +my ($name, $value) = @_; +$value = '' if (!defined($value) || $value !~ /^(true|false)\z/); +return &ui_select($name, $value, + [ + [ "", $text{'defaults_keep'} ], + [ "true", $text{'defaults_true'} ], + [ "false", $text{'defaults_false'} ], + ]); +} + +# field_value(value, [default]) +# Returns a form value without treating the string 0 as empty. +sub field_value +{ +my ($value, $default) = @_; +return defined($value) ? $value : ($default || ''); +} diff --git a/grub2/edit_install.cgi b/grub2/edit_install.cgi new file mode 100755 index 000000000..cec642f4e --- /dev/null +++ b/grub2/edit_install.cgi @@ -0,0 +1,103 @@ +#!/usr/local/bin/perl +# Show a form for installing the GRUB 2 boot loader. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%text); + +&ReadParse(); +&error_setup($text{'install_err'}); +&grub2_assert_acl('install'); + +my $cmd = &grub2_command('install_cmd'); +my $platform = &grub2_default_platform_target(); +my $module_dir = $platform ? &grub2_platform_module_dir($platform) : ''; +my $efi_dir = &grub2_default_efi_directory(); +my $bootloader_id = &grub2_default_bootloader_id($efi_dir); + +&ui_print_header(undef, $text{'install_title'}, "", "install_target"); + +# Installation is unavailable unless the configured grub-install is runnable. +if (!$cmd) { + print &ui_alert($text{'install_ecmd'}, 'warning'); + &ui_print_footer("index.cgi", $text{'index_return'}); + exit; + } +if ($platform && !$module_dir) { + # Missing module directories usually mean the platform package is absent. + print &ui_alert(&text('install_warn_modules', $platform), 'warning'); + } + +print &ui_form_start("install.cgi", "post"); +print &ui_table_start($text{'install_header'}, "width=100%", 2); +print &ui_table_row($text{'install_command'}, + &ui_tag('tt', &html_escape($cmd))); +print &ui_table_row($text{'index_boot_mode'}, &install_boot_mode_cell()); +print &ui_table_row($text{'index_secure_boot'}, &install_secure_boot_cell()); +print &ui_table_row( + &hlink($text{'install_target'}, "install_target"), + &ui_filebox("target", "", 45) +); +print &ui_table_row( + &hlink($text{'install_efi_dir'}, "install_efi_dir"), + &ui_filebox("efi_dir", $efi_dir, 45, 0, undef, undef, 1) +); +print &ui_table_row( + &hlink($text{'install_platform'}, "install_platform"), + &ui_textbox("platform", $platform, 25) +); +print &ui_table_row( + &hlink($text{'install_directory'}, "install_directory"), + &ui_filebox("directory", $module_dir, 45, 0, undef, undef, 1) +); +# Keep --boot-directory opt-in because it changes install layout. +print &ui_table_row( + &hlink($text{'install_boot_directory'}, "install_boot_directory"), + &ui_filebox("boot_directory", "/boot", 45, 0, undef, undef, 1). + &ui_tag('div', + &ui_checkbox("use_boot_directory", 1, + $text{'install_boot_directory_enable'}, 0), + { 'style' => 'margin-left: 2px' }) +); +print &ui_table_row( + &hlink($text{'install_bootloader_id'}, "install_bootloader_id"), + &ui_textbox("bootloader_id", $bootloader_id, 30) +); +print &ui_table_hr(); +print &ui_table_row( + $text{'install_options'}, + &ui_div(&ui_checkbox("recheck", 1, $text{'install_recheck'}, 0)). + &ui_div(&ui_checkbox("removable", 1, $text{'install_removable'}, 0)). + &ui_div(&ui_checkbox("no_nvram", 1, $text{'install_no_nvram'}, 0)). + &ui_div(&ui_checkbox("force", 1, + &hlink($text{'install_force'}, "install_force"), + 0)) +); +print &ui_table_hr(); +print &ui_table_row( + &hlink($text{'install_confirm'}, "install_confirm"), + &ui_checkbox("confirm", 1, $text{'install_confirm_label'}, 0) +); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'install_submit'} ] ]); + +&ui_print_footer("index.cgi", $text{'index_return'}); + +# install_boot_mode_cell() +# Returns the detected firmware boot mode for display. +sub install_boot_mode_cell +{ +my $mode = &grub2_boot_mode(); +return $text{'index_boot_mode_uefi'} if ($mode eq 'uefi'); +return $text{'index_boot_mode_bios'}; +} + +# install_secure_boot_cell() +# Returns the detected Secure Boot state for display. +sub install_secure_boot_cell +{ +my $state = &grub2_secure_boot_status(); +return $text{'index_secure_boot_'.$state} || $text{'index_secure_boot_unknown'}; +} diff --git a/grub2/edit_manual.cgi b/grub2/edit_manual.cgi new file mode 100755 index 000000000..593809640 --- /dev/null +++ b/grub2/edit_manual.cgi @@ -0,0 +1,44 @@ +#!/usr/local/bin/perl +# Show a page for manually editing allowed GRUB 2 files. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'manual_err'}); +&grub2_assert_acl('manual'); + +# The manual editor is restricted to discovered GRUB-related files only. +my @files = &grub2_manual_files(); +@files || &error($text{'manual_enofile'}); +my @paths = map { $_->{'file'} } @files; +my $file = $in{'file'} || $paths[0]; +&grub2_manual_file($file) || &error($text{'manual_efile'}); + +&ui_print_header(undef, $text{'manual_title'}, ""); + +print &ui_form_start("edit_manual.cgi"); +print &ui_tag('b', &html_escape($text{'manual_select'})),"\n"; +print &ui_select("file", $file, \@paths),"\n"; +print &ui_submit($text{'manual_ok'}); +print &ui_form_end(); + +# Lock while reading so the text shown matches the file validation target. +my $data = ""; +if (-r $file) { + &lock_file($file); + $data = &read_file_contents($file); + &unlock_file($file); + } + +print &ui_form_start("save_manual.cgi", "form-data"); +print &ui_hidden("file", $file); +print &ui_table_start(undef, undef, 2); +print &ui_table_row(undef, &ui_textarea("data", $data, 24, 100), 2); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'save'} ] ]); + +&ui_print_footer("index.cgi", $text{'index_return'}); diff --git a/grub2/edit_security.cgi b/grub2/edit_security.cgi new file mode 100755 index 000000000..cbe059987 --- /dev/null +++ b/grub2/edit_security.cgi @@ -0,0 +1,75 @@ +#!/usr/local/bin/perl +# Show a form for editing GRUB 2 password protection. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%text); + +&ReadParse(); +&error_setup($text{'security_err'}); +&grub2_assert_acl('security'); + +my $state = &grub2_read_security_config(); + +&ui_print_header(undef, $text{'security_title'}, "", "security_current"); + +# Refuse to edit administrator-owned password scripts we cannot safely merge. +if ($state->{'exists'} && !$state->{'managed'}) { + print &ui_alert($text{'security_unmanaged'}, 'warning'); + &ui_print_footer("index.cgi", $text{'index_return'}); + exit; + } + +print &ui_form_start("save_security.cgi", "post"); +print &ui_table_start($text{'security_header'}, "width=100%", 2); +print &ui_table_row( + $text{'security_current_state'}, + $state->{'enabled'} ? + &text('security_current_enabled', + &html_escape($state->{'user'} || 'root')) : + $text{'security_current_disabled'} +); +print &ui_table_row( + $text{'security_current_hash'}, + $state->{'hash'} ? $text{'security_current_hash_set'} : + $text{'security_current_hash_missing'} +); +print &ui_table_hr(); +print &ui_table_row( + &hlink($text{'security_enable'}, "security_enable"), + &ui_yesno_radio("enabled", $state->{'enabled'} ? 1 : 0) +); +print &ui_table_row( + &hlink($text{'security_user'}, "security_user"), + &ui_textbox("user", $state->{'user'} || "root", 30) +); +print &ui_table_hr(); +print &ui_table_row( + $text{'security_password_status'}, + $state->{'enabled'} && $state->{'hash'} ? + $text{'security_password_keep'} : + $text{'security_password_required'} +); +# Password fields are optional when keeping the existing PBKDF2 hash. +print &ui_table_row( + &hlink($text{'security_newpass'}, "security_password"), + &ui_password("password", "", 30) +); +print &ui_table_row( + &hlink($text{'security_newpass2'}, "security_password"), + &ui_password("password2", "", 30) +); +print &ui_table_hr(); +# Existing hashes are shown because GRUB stores hashes, not clear text. +print &ui_table_row( + &hlink($text{'security_hash'}, "security_hash"), + &ui_textbox("hash", $state->{'hash'} || "", 30, undef, undef, undef, + "w-100"). + &ui_tag('div', &ui_note($text{'security_hash_note'}, 0)), +2, undef, undef, 1); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'save'} ] ]); + +&ui_print_footer("index.cgi", $text{'index_return'}); diff --git a/grub2/edit_theme.cgi b/grub2/edit_theme.cgi new file mode 100755 index 000000000..5d1e9dd70 --- /dev/null +++ b/grub2/edit_theme.cgi @@ -0,0 +1,208 @@ +#!/usr/local/bin/perl +# Show a form for editing GRUB 2 theme and appearance settings. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%text); + +&ReadParse(); +&error_setup($text{'theme_err'}); +&grub2_assert_acl('edit'); + +my $parsed = &read_grub_defaults(); +my $values = $parsed->{'values'}; +my $current_theme = &field_value($values->{'GRUB_THEME'}); +my $theme_action = $current_theme ne '' ? 'keep' : 'clear'; + +&ui_print_header(undef, $text{'theme_title'}, "", "theme_mode"); + +# Theme source is only used when the action selector requests installation. +print &ui_form_start("save_theme.cgi", "post"); +print &ui_table_start($text{'defaults_theme_header'}, "width=100%", 2); +print &ui_table_row( + &hlink($text{'defaults_theme_current'}, "theme"), + $current_theme ne '' ? + &ui_tag('tt', &html_escape($current_theme)) : + $text{'defaults_theme_none'} +); +print &ui_table_row( + &hlink($text{'defaults_theme_action'}, "theme_mode"), + &ui_select("theme_mode", $theme_action, + [ + [ "keep", $text{'defaults_theme_keep'} ], + [ "install", $text{'defaults_theme_install'} ], + [ "clear", $text{'defaults_theme_clear'} ], + ]) +); +print &ui_table_row( + &hlink($text{'defaults_theme_source'}, "theme_source"), + &ui_textbox("theme_source", "", 60). + " ".&file_chooser_button("theme_source"). + &ui_tag('div', &ui_note($text{'defaults_theme_note'}, 0)) +); +print &ui_table_row( + &hlink($text{'defaults_terminal_output'}, "terminal_output"), + &ui_select("terminal_output", + &field_value($values->{'GRUB_TERMINAL_OUTPUT'}), + [ + [ "", $text{'defaults_keep'} ], + [ "console", $text{'defaults_terminal_console'} ], + [ "gfxterm", $text{'defaults_terminal_gfxterm'} ], + [ "gfxterm console", + $text{'defaults_terminal_gfxterm_console'} ], + [ "serial", $text{'defaults_terminal_serial'} ], + ]) +); +print &ui_table_row( + &hlink($text{'defaults_gfxmode'}, "gfxmode"), + &gfxmode_select(&field_value($values->{'GRUB_GFXMODE'})) +); +print &ui_table_row( + &hlink($text{'defaults_background'}, "background"), + &ui_textbox("background", &field_value($values->{'GRUB_BACKGROUND'}), 60). + " ".&file_chooser_button("background"). + &ui_tag('div', &ui_note($text{'defaults_background_note'}, 0)) +); +print &ui_table_row( + &hlink($text{'defaults_color_normal'}, "color_normal"), + &color_pair_select("color_normal", $values->{'GRUB_COLOR_NORMAL'}, + "white", "black") +); +print &ui_table_row( + &hlink($text{'defaults_color_highlight'}, "color_highlight"), + &color_pair_select("color_highlight", + $values->{'GRUB_COLOR_HIGHLIGHT'}, + "black", "light-gray") +); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'save'} ] ]); +print &color_mode_script(); + +&ui_print_footer("index.cgi", $text{'index_return'}); + +# color_pair_select(name, value, default-foreground, default-background) +# Returns foreground/background selectors for GRUB menu colors. +sub color_pair_select +{ +my ($name, $value, $default_fg, $default_bg) = @_; +my ($fg, $bg) = ($default_fg, $default_bg); +my $mode = "default"; +if (defined($value) && $value =~ /^([^\/]+)\/([^\/]+)\z/) { + # Existing GRUB colors are stored as foreground/background pairs. + ($fg, $bg) = ($1, $2); + $mode = "set"; + } +return &ui_select($name."_mode", $mode, [ + [ "default", $text{'defaults_color_default'} ], + [ "set", $text{'defaults_color_custom'} ], + ]). + &ui_tag('span', + " ".&html_escape($text{'defaults_color_text'})." ". + &ui_select($name."_fg", $fg, &color_options())." ". + " ".&html_escape($text{'defaults_color_background'}). + " ".&ui_select($name."_bg", $bg, &color_options()), + { + 'id' => $name."_custom_colors", + 'style' => 'white-space: nowrap; visibility: '. + ($mode eq 'set' ? 'visible' : 'hidden').';', + }); +} + +# color_mode_script() +# Shows foreground/background selectors only for custom color pairs. +sub color_mode_script +{ +return &ui_tag('script', <<'EOF', { 'type' => 'application/javascript' }); +function grub2_color_mode_select(name) { + // Theme reloads may replace IDs; fall back to the stable field name. + return document.getElementById(name + '_mode') || + document.querySelector('select[name="' + name + '_mode"]'); +} +function grub2_color_mode_changed(name) { + const mode = grub2_color_mode_select(name); + const custom = document.getElementById(name + '_custom_colors'); + if (!mode || !custom) { + return; + } + // Visibility avoids layout jumps when a custom color pair is enabled. + custom.style.visibility = mode.value === 'set' ? 'visible' : 'hidden'; +} +function grub2_color_modes_refresh() { + grub2_color_mode_changed('color_normal'); + grub2_color_mode_changed('color_highlight'); +} +document.addEventListener('change', function(event) { + const target = event.target; + if (!target || !target.name) { + return; + } + if (target.name === 'color_normal_mode') { + grub2_color_mode_changed('color_normal'); + } + else if (target.name === 'color_highlight_mode') { + grub2_color_mode_changed('color_highlight'); + } +}); +grub2_color_modes_refresh(); +document.addEventListener('DOMContentLoaded', grub2_color_modes_refresh); +if (window.MutationObserver && document.body) { + // Re-apply after theme JavaScript re-renders form controls. + new MutationObserver(grub2_color_modes_refresh).observe(document.body, { + childList: true, + subtree: true + }); +} +EOF +} + +# gfxmode_select(value) +# Returns a dropdown of common GRUB graphical resolutions. +sub gfxmode_select +{ +my ($value) = @_; +return &ui_select("gfxmode", $value, &gfxmode_options(), undef, undef, 1); +} + +# gfxmode_options() +# Returns common GRUB graphical resolution choices. +sub gfxmode_options +{ +return [ + [ "", $text{'defaults_gfxmode_default'} ], + [ "auto", $text{'defaults_gfxmode_auto'} ], + map { [ $_, $_ ] } qw( + 640x480 + 800x600 + 1024x768 + 1280x720 + 1280x800 + 1366x768 + 1440x900 + 1600x900 + 1680x1050 + 1920x1080 + 1920x1200 + 2560x1440 + 3840x2160 + ), +]; +} + +# color_options() +# Returns GRUB color choices. +sub color_options +{ +return [ + map { [ $_, $text{'color_'.$_} || $_ ] } &grub2_color_names() +]; +} + +# field_value(value, [default]) +# Returns a form value without treating the string 0 as empty. +sub field_value +{ +my ($value, $default) = @_; +return defined($value) ? $value : ($default || ''); +} diff --git a/grub2/generate.cgi b/grub2/generate.cgi new file mode 100755 index 000000000..6df900154 --- /dev/null +++ b/grub2/generate.cgi @@ -0,0 +1,143 @@ +#!/usr/local/bin/perl +# Generate the GRUB 2 menu file after a successful test generation. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'generate_err'}); +&grub2_assert_acl('apply'); + +my $return_url = $in{'redir'} || "index.cgi"; +my ($current_step, $failed_printed, $failure_output_shown, $command_output) = + ('', 0, 0, ''); + +&ui_print_unbuffered_header(undef, $text{'generate_title'}, ""); + +# The generator emits coarse events so failures can show the right phase. +my $callback = sub { + my ($event, $value) = @_; + if ($event eq 'command') { + # Capture the command text and all output for a single disclosure block. + $current_step = 'command'; + $command_output = $value."\n"; + &print_step_start($text{'generate_regenerating'}); + return; + } + if ($event eq 'output') { + $command_output .= $value; + return; + } + if ($event eq 'command_done') { + # Successful generation keeps noisy mkconfig output collapsed. + &print_step_output($text{'generate_done'}, $command_output); + $current_step = ''; + return; + } + if ($event eq 'command_failed') { + # On command failure the captured output is the most useful detail. + &print_step_output($text{'generate_failed_status'}, + $command_output); + $current_step = ''; + $failed_printed = 1; + $failure_output_shown = 1; + return; + } + if ($event eq 'check') { + # The generated temporary grub.cfg is syntax-checked before replace. + $current_step = 'check'; + &print_step_start($text{'generate_check'}); + return; + } + if ($event eq 'check_done') { + &print_step_done(\$current_step); + return; + } + if ($event eq 'check_failed') { + &print_step_failed(\$current_step, \$failed_printed); + return; + } + if ($event eq 'replace') { + # Replacement only happens after a successful generation and check. + $current_step = 'replace'; + &print_step_start($text{'generate_replace'}); + return; + } + if ($event eq 'replace_done') { + &print_step_done(\$current_step); + return; + } + }; + +my $err = &grub2_generate_config($callback); +if ($err) { + # If the command output was not already shown, print the returned error. + &print_step_failed(\$current_step, \$failed_printed) + if ($current_step); + &print_step_output($text{'generate_failed_status'}, $err) + if (!$failure_output_shown); + } +else { + &grub2_mark_generated(); + &webmin_log("generate", undef, &grub2_config_value('grub_cfg')); + } + +&ui_print_footer($return_url, $text{'generate_return'}); + +# print_step_start(text) +# Prints the first progress line for one generation step. +sub print_step_start +{ +my ($msg) = @_; +print &ui_tag('span', &html_escape($msg." .."), + { 'data-first-print' => undef }); +print "
\n"; +return; +} + +# print_step_output(status, output) +# Prints command output inside an inline details disclosure. +sub print_step_output +{ +my ($status, $output) = @_; +$output = '' if (!defined($output)); +print &ui_details({ + 'html' => 1, + 'title' => &ui_tag('span', &html_escape(".. ".$status), + { 'data-second-print' => undef }), + 'content' => &ui_tag('pre', &html_escape($output), + { 'style' => 'margin-left: 10px;' }), + 'class' => 'inline inlined', + }); +print "
\n"; +return; +} + +# print_step_done(¤t-step) +# Prints a successful progress line and clears the active step. +sub print_step_done +{ +my ($current) = @_; +print &ui_tag('span', &html_escape(".. ".$text{'generate_done'}), + { 'data-second-print' => undef }); +print "
\n"; +$$current = ''; +return; +} + +# print_step_failed(¤t-step, &printed-flag) +# Prints a failed progress line once and clears the active step. +sub print_step_failed +{ +my ($current, $printed) = @_; +return if ($$printed); +print &ui_tag('span', &html_escape(".. ".$text{'generate_failed_status'}), + { 'data-second-print' => undef }); +print "
\n"; +$$current = ''; +$$printed = 1; +return; +} diff --git a/grub2/grub2-lib.pl b/grub2/grub2-lib.pl new file mode 100644 index 000000000..149eb3b11 --- /dev/null +++ b/grub2/grub2-lib.pl @@ -0,0 +1,3347 @@ +# 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_acl_keys() +# Returns the supported GRUB 2 ACL capabilities. +sub grub2_acl_keys +{ +return qw(view edit security apply runtime manual install backup); +} + +# grub2_effective_acl([&raw-acl]) +# Returns normalized ACL settings for the current Webmin user. +sub grub2_effective_acl +{ +my ($rawacl) = @_; +my %raw = $rawacl ? %$rawacl : &get_module_acl(); +return map { $_ => $raw{$_} ? 1 : 0 } &grub2_acl_keys(); +} + +# grub2_check_acl(action, [&raw-acl]) +# Returns true when an effective ACL permits the requested action. +sub grub2_check_acl +{ +my ($action, $rawacl) = @_; +my %acl = &grub2_effective_acl($rawacl); +return $acl{$action} ? 1 : 0; +} + +# grub2_assert_acl(action) +# Fails if the current Webmin user cannot perform an action. +sub grub2_assert_acl +{ +my ($action) = @_; +&grub2_check_acl($action) || + &error("$text{'eacl_np'} $text{'eacl_p'.$action}"); +} + +# grub2_can_enter_module(&acl) +# Returns true if a user has at least one useful module capability. +sub grub2_can_enter_module +{ +my ($acl) = @_; +foreach my $a (&grub2_acl_keys()) { + return 1 if ($acl->{$a}); + } +return 0; +} + +# 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 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 => 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(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 => 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 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 => 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 => 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 => 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(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 (!-f $file); + 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 (!-f $file); + &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}++); +push(@$files, { 'key' => $key, 'file' => $file, 'type' => $type }); +} + +# 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(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(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 undef; +} + +# 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(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, 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; diff --git a/grub2/help/background.html b/grub2/help/background.html new file mode 100644 index 000000000..9c506615e --- /dev/null +++ b/grub2/help/background.html @@ -0,0 +1,4 @@ +
Background image
+

Sets the image used as the GRUB menu background. Select a PNG, JPEG, or TGA +file. Webmin copies it under the configured GRUB background directory in +/boot so GRUB can read it at boot time.

diff --git a/grub2/help/bls_dir.html b/grub2/help/bls_dir.html new file mode 100644 index 000000000..5e2d81435 --- /dev/null +++ b/grub2/help/bls_dir.html @@ -0,0 +1,6 @@ +
BLS entries directory
+

Directory containing Boot Loader Specification entry files, usually +/boot/loader/entries on Enterprise Linux style systems.

+

Each .conf file describes one boot entry. Kernel options may be +stored directly in these files or read from kernelopts in the GRUB +environment.

diff --git a/grub2/help/boot_mode.html b/grub2/help/boot_mode.html new file mode 100644 index 000000000..8dbeabf72 --- /dev/null +++ b/grub2/help/boot_mode.html @@ -0,0 +1,6 @@ +
Boot mode
+

Shows whether the running system appears to have booted through UEFI +firmware or legacy BIOS firmware.

+

This matters because GRUB is installed differently in each mode. UEFI +systems normally use an EFI system partition and a boot loader ID, while BIOS +systems normally install GRUB boot code to a disk device.

diff --git a/grub2/help/cmdline.html b/grub2/help/cmdline.html new file mode 100644 index 000000000..78fd1eb98 --- /dev/null +++ b/grub2/help/cmdline.html @@ -0,0 +1,12 @@ +
Kernel options for all Linux entries
+

Edits GRUB_CMDLINE_LINUX. GRUB menu generators normally add this +value to every generated Linux menu entry, including recovery or rescue +entries.

+

Use this for options that must be present whenever Linux boots, such as +storage, root filesystem, LVM, filesystem flag, security, mitigation, or +crashkernel options.

+

On BLS-based systems, existing boot entries may store options directly in +BLS entry files, or reference $kernelopts, a GRUB environment value +stored in grubenv. Webmin does not edit grubenv directly; +when grubby is available, Webmin asks it to apply changes here to all +existing BLS entries.

diff --git a/grub2/help/cmdline_default.html b/grub2/help/cmdline_default.html new file mode 100644 index 000000000..de1e16ae0 --- /dev/null +++ b/grub2/help/cmdline_default.html @@ -0,0 +1,12 @@ +
Kernel options for regular Linux entries
+

Edits GRUB_CMDLINE_LINUX_DEFAULT. GRUB menu generators normally add +this value to regular Linux menu entries, but not to generated recovery or +rescue entries.

+

Use this for options that make normal boots quieter or more convenient, such +as quiet, splash, or console and graphics workarounds that +are not needed for recovery boots.

+

On BLS-based systems, existing boot entries may store options directly in +BLS entry files, or reference $kernelopts, a GRUB environment value +stored in grubenv. Webmin does not edit grubenv directly; +when grubby is available, Webmin asks it to apply changes here to +existing non-rescue BLS entries.

diff --git a/grub2/help/color_highlight.html b/grub2/help/color_highlight.html new file mode 100644 index 000000000..e1d6b1845 --- /dev/null +++ b/grub2/help/color_highlight.html @@ -0,0 +1,3 @@ +
Selected menu colors
+

Sets the foreground and background colors for the currently selected GRUB +menu entry. Leave both values unset to remove this setting.

diff --git a/grub2/help/color_normal.html b/grub2/help/color_normal.html new file mode 100644 index 000000000..d3eb48ffb --- /dev/null +++ b/grub2/help/color_normal.html @@ -0,0 +1,3 @@ +
Normal menu colors
+

Sets the foreground and background colors for normal GRUB menu entries. +Leave both values unset to remove this setting.

diff --git a/grub2/help/custom_body.html b/grub2/help/custom_body.html new file mode 100644 index 000000000..6660ba788 --- /dev/null +++ b/grub2/help/custom_body.html @@ -0,0 +1,3 @@ +
GRUB commands
+

Commands to run inside the custom menu entry. Do not include the outer +menuentry line; Webmin writes that from the title and ID fields.

diff --git a/grub2/help/custom_id.html b/grub2/help/custom_id.html new file mode 100644 index 000000000..d76623007 --- /dev/null +++ b/grub2/help/custom_id.html @@ -0,0 +1,4 @@ +
Entry ID
+

An optional stable identifier used by GRUB commands such as +grub-set-default and grub-reboot. Leave this blank if the entry does not need a +stable ID.

diff --git a/grub2/help/custom_title.html b/grub2/help/custom_title.html new file mode 100644 index 000000000..dbbaa43bc --- /dev/null +++ b/grub2/help/custom_title.html @@ -0,0 +1,2 @@ +
Menu title
+

The title shown in the GRUB boot menu for this custom entry.

diff --git a/grub2/help/default.html b/grub2/help/default.html new file mode 100644 index 000000000..fc5bfc9a4 --- /dev/null +++ b/grub2/help/default.html @@ -0,0 +1,5 @@ +
Default menu entry
+

Edits GRUB_DEFAULT, the GRUB menu entry booted automatically. +Choose a detected entry, or choose Saved environment entry to use the +entry stored in the GRUB environment. Webmin's Set as default action updates +that saved environment entry.

diff --git a/grub2/help/default_file.html b/grub2/help/default_file.html new file mode 100644 index 000000000..17721fedd --- /dev/null +++ b/grub2/help/default_file.html @@ -0,0 +1,7 @@ +
Default settings file
+

Path to the GRUB defaults file, usually /etc/default/grub. This +file contains shell-style assignments such as GRUB_DEFAULT, +GRUB_TIMEOUT, and kernel command-line settings.

+

Changes here normally affect the generated menu after the GRUB menu is +regenerated. On BLS-based systems, some existing entries may also be updated +with grubby.

diff --git a/grub2/help/disable_os_prober.html b/grub2/help/disable_os_prober.html new file mode 100644 index 000000000..28c50d261 --- /dev/null +++ b/grub2/help/disable_os_prober.html @@ -0,0 +1,3 @@ +
Disable OS prober
+

When enabled, GRUB does not run OS prober while generating the menu. This +prevents automatic entries for other installed operating systems.

diff --git a/grub2/help/disable_recovery.html b/grub2/help/disable_recovery.html new file mode 100644 index 000000000..d59237a53 --- /dev/null +++ b/grub2/help/disable_recovery.html @@ -0,0 +1,9 @@ +
Disable recovery and rescue entries
+

When enabled, Webmin sets GRUB_DISABLE_RECOVERY=true, which tells +GRUB generator scripts to omit recovery-mode entries on distributions that +support this option.

+

On BLS-based systems such as Rocky, AlmaLinux, and RHEL, rescue entries are +separate files under /boot/loader/entries. Webmin hides existing BLS +rescue entries by renaming their entry files, and restores only entries hidden +by Webmin when this option is disabled again. Future kernel or rescue-image +updates may create new BLS rescue entries.

diff --git a/grub2/help/entries.html b/grub2/help/entries.html new file mode 100644 index 000000000..2bd589967 --- /dev/null +++ b/grub2/help/entries.html @@ -0,0 +1,5 @@ +
Boot menu entries
+

Number of boot entries detected from the generated GRUB menu and, when +present, from BLS entry files referenced by that menu.

+

This count is read-only status. Use the generated menu tab to inspect the +entries and use the custom entries tab for entries managed by this module.

diff --git a/grub2/help/gfxmode.html b/grub2/help/gfxmode.html new file mode 100644 index 000000000..a2169f9da --- /dev/null +++ b/grub2/help/gfxmode.html @@ -0,0 +1,9 @@ +
Graphics mode
+

Sets the GRUB graphical screen mode used by themes and the graphical +terminal. The theme provides layout and images, but this setting chooses the +resolution GRUB renders them at.

+ +

Use auto to let GRUB choose, or enter one or more modes such as +1024x768 or 1280x1024x32, separated by commas. If a theme is +cropped or shifted, try a resolution closer to the one the theme was designed +for.

diff --git a/grub2/help/grub_cfg.html b/grub2/help/grub_cfg.html new file mode 100644 index 000000000..2a3d40909 --- /dev/null +++ b/grub2/help/grub_cfg.html @@ -0,0 +1,6 @@ +
Generated menu file
+

Path to the generated GRUB menu file, commonly /boot/grub2/grub.cfg +or /boot/grub/grub.cfg. GRUB reads this file at boot to build the boot +menu and run boot commands.

+

On BLS-based systems this file may contain loader code that reads separate +BLS entry files instead of listing every kernel entry directly.

diff --git a/grub2/help/grub_dir.html b/grub2/help/grub_dir.html new file mode 100644 index 000000000..533368bdf --- /dev/null +++ b/grub2/help/grub_dir.html @@ -0,0 +1,6 @@ +
Script directory
+

Directory containing GRUB menu generator scripts, usually +/etc/grub.d. These scripts are run by grub-mkconfig or +grub2-mkconfig to build the generated menu file.

+

Distribution scripts should usually be left alone. Custom menu entries are +normally placed in the configured custom script instead.

diff --git a/grub2/help/grubenv.html b/grub2/help/grubenv.html new file mode 100644 index 000000000..2ec2a9a77 --- /dev/null +++ b/grub2/help/grubenv.html @@ -0,0 +1,7 @@ +
Environment file
+

Path to the GRUB environment file, commonly /boot/grub2/grubenv or +/boot/grub/grubenv. GRUB uses this file for persistent runtime state +such as saved_entry, next_entry, and sometimes +kernelopts.

+

This module reads the file for status and uses GRUB tools for runtime boot +selection changes.

diff --git a/grub2/help/install_boot_directory.html b/grub2/help/install_boot_directory.html new file mode 100644 index 000000000..69af2a8ac --- /dev/null +++ b/grub2/help/install_boot_directory.html @@ -0,0 +1,6 @@ +
Boot directory
+

When enabled, this path is passed to grub-install as +--boot-directory. Leave it disabled for normal installs, where GRUB +uses its default boot directory, usually /boot.

+

Use this only for custom layouts, chroots, rescue installs, or systems where +the GRUB boot files must be installed under a non-default boot directory.

diff --git a/grub2/help/install_bootloader_id.html b/grub2/help/install_bootloader_id.html new file mode 100644 index 000000000..a6df263bf --- /dev/null +++ b/grub2/help/install_bootloader_id.html @@ -0,0 +1,3 @@ +
Boot loader ID
+ +

Optional name for the EFI boot loader entry, such as GRUB or a distribution name. Leave it blank to let grub-install use its default.

diff --git a/grub2/help/install_cmd.html b/grub2/help/install_cmd.html new file mode 100644 index 000000000..56d14c999 --- /dev/null +++ b/grub2/help/install_cmd.html @@ -0,0 +1,6 @@ +
Boot loader install command
+

Command used to install GRUB boot loader files to a disk or EFI system +partition, usually grub2-install or grub-install.

+

This is separate from regenerating the menu. Installing GRUB changes boot +loader data on disk or in firmware and should be done only when the target is +known to be correct.

diff --git a/grub2/help/install_confirm.html b/grub2/help/install_confirm.html new file mode 100644 index 000000000..8980e9331 --- /dev/null +++ b/grub2/help/install_confirm.html @@ -0,0 +1,3 @@ +
Confirmation
+ +

Installing GRUB changes boot loader data on disk or in EFI firmware. Confirm this only after checking that the target and EFI directory match the system you intend to boot.

diff --git a/grub2/help/install_directory.html b/grub2/help/install_directory.html new file mode 100644 index 000000000..794d7eff9 --- /dev/null +++ b/grub2/help/install_directory.html @@ -0,0 +1,3 @@ +
GRUB module directory
+ +

Optional directory containing GRUB platform files and modinfo.sh. This is normally detected automatically from the platform target, but can be set when the distribution keeps GRUB modules in a non-standard location.

diff --git a/grub2/help/install_efi_dir.html b/grub2/help/install_efi_dir.html new file mode 100644 index 000000000..d766b1ed3 --- /dev/null +++ b/grub2/help/install_efi_dir.html @@ -0,0 +1,3 @@ +
EFI system directory
+ +

For UEFI systems, enter the mounted EFI system partition directory, usually /boot/efi. Webmin passes this to grub-install as the EFI directory.

diff --git a/grub2/help/install_force.html b/grub2/help/install_force.html new file mode 100644 index 000000000..c7db1219f --- /dev/null +++ b/grub2/help/install_force.html @@ -0,0 +1,3 @@ +
Force EFI install
+ +

Some distributions refuse to run grub-install for EFI platforms because the result may not support UEFI Secure Boot. Enable this only when Secure Boot is disabled or when you intentionally need grub-install to proceed anyway.

diff --git a/grub2/help/install_platform.html b/grub2/help/install_platform.html new file mode 100644 index 000000000..25e363588 --- /dev/null +++ b/grub2/help/install_platform.html @@ -0,0 +1,3 @@ +
Platform target
+ +

The GRUB platform to install, such as x86_64-efi, arm64-efi, or i386-pc. Webmin detects this from the current boot mode and CPU architecture when possible.

diff --git a/grub2/help/install_target.html b/grub2/help/install_target.html new file mode 100644 index 000000000..9c0eac707 --- /dev/null +++ b/grub2/help/install_target.html @@ -0,0 +1,3 @@ +
Install target
+ +

Enter the device that GRUB should install to, such as /dev/sda, /dev/nvme0n1, or a stable path below /dev/disk/by-id. For EFI-only installs this may be left blank when an EFI system directory is supplied.

diff --git a/grub2/help/kernelopts_source.html b/grub2/help/kernelopts_source.html new file mode 100644 index 000000000..59da00763 --- /dev/null +++ b/grub2/help/kernelopts_source.html @@ -0,0 +1,11 @@ +
Detected kernel options source
+

Shows where the currently detected Linux boot entries get their kernel +command-line options.

+

Generated menu entries usually come from the GRUB defaults file when the +menu is regenerated. BLS entries may instead store options in +/boot/loader/entries files, or reference $kernelopts.

+

kernelopts is not a setting in /etc/default/grub. It is a +GRUB environment value stored in grubenv and commonly managed with +grubby. Webmin does not edit grubenv directly; when +grubby is available, Webmin asks it to apply changed kernel options to +existing BLS entries.

diff --git a/grub2/help/mkconfig.html b/grub2/help/mkconfig.html new file mode 100644 index 000000000..c29e5d7ec --- /dev/null +++ b/grub2/help/mkconfig.html @@ -0,0 +1,5 @@ +
Menu generation command
+

Command used to rebuild the generated GRUB menu file, such as +grub2-mkconfig or grub-mkconfig.

+

When regenerating the menu, Webmin writes to a temporary file first, checks +that generated file, and replaces the live menu only after the checks pass.

diff --git a/grub2/help/next_entry.html b/grub2/help/next_entry.html new file mode 100644 index 000000000..a087c2dd5 --- /dev/null +++ b/grub2/help/next_entry.html @@ -0,0 +1,5 @@ +
Next boot entry
+

Shows the next_entry value from the GRUB environment file. When set, +GRUB uses this entry for the next boot only and then clears it.

+

The Boot Once action sets this value with the configured GRUB runtime +command.

diff --git a/grub2/help/saved_entry.html b/grub2/help/saved_entry.html new file mode 100644 index 000000000..8769ece69 --- /dev/null +++ b/grub2/help/saved_entry.html @@ -0,0 +1,6 @@ +
Environment default entry
+

Shows the saved_entry value from the GRUB environment file. When +GRUB_DEFAULT=saved, GRUB uses this saved value as the default boot +entry.

+

The Set as default action updates this value with the configured GRUB +runtime command.

diff --git a/grub2/help/secure_boot.html b/grub2/help/secure_boot.html new file mode 100644 index 000000000..1799e6286 --- /dev/null +++ b/grub2/help/secure_boot.html @@ -0,0 +1,6 @@ +
Secure Boot
+

Shows the detected UEFI Secure Boot state. Secure Boot is only meaningful on +UEFI systems; on legacy BIOS systems it is not applicable.

+

Some distributions restrict or discourage direct grub-install use +when Secure Boot is enabled because an incorrectly installed boot loader may no +longer satisfy firmware signature checks.

diff --git a/grub2/help/security_current.html b/grub2/help/security_current.html new file mode 100644 index 000000000..4bd082cd8 --- /dev/null +++ b/grub2/help/security_current.html @@ -0,0 +1,15 @@ +
Current protection
+

GRUB password protection adds a GRUB superuser account to the generated boot +menu. GRUB asks for this password when someone tries to use the GRUB command +line or edit boot menu entries. Depending on how menu entries are generated, it +may also be required to boot entries that are not marked as unrestricted.

+ +

This is useful on kiosks, lab systems, shared consoles, and other systems +where an untrusted person may reach the boot menu. It helps stop simple boot +menu attacks such as editing kernel parameters, starting a GRUB shell, or +booting an unrestricted recovery entry.

+ +

It does not protect against someone who can change firmware boot settings, +boot from other media, remove the disk, modify an unencrypted boot partition, or +log in as root. Use it with firmware security, Secure Boot, encrypted storage, +and normal operating system access controls when those threats matter.

diff --git a/grub2/help/security_enable.html b/grub2/help/security_enable.html new file mode 100644 index 000000000..22b135593 --- /dev/null +++ b/grub2/help/security_enable.html @@ -0,0 +1,3 @@ +
Enable password protection
+

Controls whether Webmin writes GRUB superuser password commands into the +generated boot menu. Regenerate the GRUB menu after saving.

diff --git a/grub2/help/security_file.html b/grub2/help/security_file.html new file mode 100644 index 000000000..3fd90e39b --- /dev/null +++ b/grub2/help/security_file.html @@ -0,0 +1,6 @@ +
Password script
+

Path to the GRUB generator script that defines the Webmin-managed GRUB +superuser and password hash.

+

The script is included when the generated menu is rebuilt. If the file +exists but is not recognized as managed by this module, the password status is +shown as unmanaged.

diff --git a/grub2/help/security_hash.html b/grub2/help/security_hash.html new file mode 100644 index 000000000..6506f4a56 --- /dev/null +++ b/grub2/help/security_hash.html @@ -0,0 +1,4 @@ +
PBKDF2 hash
+

This is the stored GRUB password hash, not the original password. Leave it +unchanged to keep the current password, or replace it with a hash produced by +GRUB's password hash command.

diff --git a/grub2/help/security_mkpasswd.html b/grub2/help/security_mkpasswd.html new file mode 100644 index 000000000..7ba8dc174 --- /dev/null +++ b/grub2/help/security_mkpasswd.html @@ -0,0 +1,5 @@ +
Password hash command
+

Command used to generate GRUB PBKDF2 password hashes, usually +grub2-mkpasswd-pbkdf2 or grub-mkpasswd-pbkdf2.

+

GRUB stores the hash, not the original password. This command is needed when +setting or replacing password protection from this module.

diff --git a/grub2/help/security_password.html b/grub2/help/security_password.html new file mode 100644 index 000000000..98f765f37 --- /dev/null +++ b/grub2/help/security_password.html @@ -0,0 +1,4 @@ +
New password
+

Enter and confirm a new password to replace the stored GRUB PBKDF2 hash. +GRUB does not store the original password, so existing passwords cannot be +displayed.

diff --git a/grub2/help/security_user.html b/grub2/help/security_user.html new file mode 100644 index 000000000..a7dfcae34 --- /dev/null +++ b/grub2/help/security_user.html @@ -0,0 +1,3 @@ +
Superuser name
+

The GRUB user allowed to unlock protected boot menu editing and command-line +access.

diff --git a/grub2/help/terminal_output.html b/grub2/help/terminal_output.html new file mode 100644 index 000000000..e88e306cb --- /dev/null +++ b/grub2/help/terminal_output.html @@ -0,0 +1,4 @@ +
Terminal output
+

Controls the GRUB terminal used for the boot menu. Console output shows the +plain text menu. Graphical terminal output is required for GRUB themes and +background images to appear.

diff --git a/grub2/help/theme.html b/grub2/help/theme.html new file mode 100644 index 000000000..74604cb3c --- /dev/null +++ b/grub2/help/theme.html @@ -0,0 +1,5 @@ +
Theme file
+

Shows the extracted GRUB theme definition file currently saved in the GRUB +defaults file. Webmin installs new themes under the configured GRUB theme +directory in /boot and saves the installed theme.txt path here.

+

The boot menu must use graphical terminal output for themes to appear.

diff --git a/grub2/help/theme_mode.html b/grub2/help/theme_mode.html new file mode 100644 index 000000000..1d4c4baf8 --- /dev/null +++ b/grub2/help/theme_mode.html @@ -0,0 +1,6 @@ +
Theme action
+

Choose whether to keep the current theme setting, remove the theme setting, +or install a new theme from the source below.

+

Installing a theme copies or extracts it into the configured GRUB theme +directory below /boot, then saves the installed theme.txt path in the GRUB +defaults file.

diff --git a/grub2/help/theme_source.html b/grub2/help/theme_source.html new file mode 100644 index 000000000..d4836e13f --- /dev/null +++ b/grub2/help/theme_source.html @@ -0,0 +1,7 @@ +
Theme source
+

Enter a local theme.txt file, a local directory containing theme.txt, a local +.tar.gz, .tar, or .zip theme archive, or an HTTP, +HTTPS, or FTP URL to one of those files.

+

Archives are validated before extraction and installed under the configured +GRUB theme directory in /boot so the boot loader can read the theme +before the root filesystem is mounted.

diff --git a/grub2/help/timeout.html b/grub2/help/timeout.html new file mode 100644 index 000000000..0d9666692 --- /dev/null +++ b/grub2/help/timeout.html @@ -0,0 +1,4 @@ +
Timeout
+

Number of seconds GRUB waits before booting the default entry. Use 0 for +immediate boot or -1 to wait indefinitely when supported by the local GRUB +version.

diff --git a/grub2/help/timeout_style.html b/grub2/help/timeout_style.html new file mode 100644 index 000000000..53d68b2da --- /dev/null +++ b/grub2/help/timeout_style.html @@ -0,0 +1,4 @@ +
Timeout style
+

Controls how GRUB behaves while waiting for the timeout. Menu shows the boot +menu, hidden suppresses it unless interrupted, and countdown displays a compact +countdown when supported.

diff --git a/grub2/images/defaults.svg b/grub2/images/defaults.svg new file mode 100644 index 000000000..111beb686 --- /dev/null +++ b/grub2/images/defaults.svg @@ -0,0 +1,4 @@ + + + + diff --git a/grub2/images/install.svg b/grub2/images/install.svg new file mode 100644 index 000000000..8666038fb --- /dev/null +++ b/grub2/images/install.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grub2/images/manual.svg b/grub2/images/manual.svg new file mode 100644 index 000000000..e9beca11c --- /dev/null +++ b/grub2/images/manual.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grub2/images/security.svg b/grub2/images/security.svg new file mode 100644 index 000000000..2a0afb5c8 --- /dev/null +++ b/grub2/images/security.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grub2/images/theme.svg b/grub2/images/theme.svg new file mode 100644 index 000000000..da8a3b7d8 --- /dev/null +++ b/grub2/images/theme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grub2/index.cgi b/grub2/index.cgi new file mode 100755 index 000000000..b2bbdc3a5 --- /dev/null +++ b/grub2/index.cgi @@ -0,0 +1,405 @@ +#!/usr/local/bin/perl +# Display GRUB 2 boot menu and configuration status. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); +our $module_name; +our $grub2_formno = 0; + +&ReadParse(); +&error_setup($text{'acl_ecannot'}); +my %access = &grub2_effective_acl(); +&error("$text{'eacl_np'} $text{'eacl_pview'}") + if (!&grub2_can_enter_module(\%access)); + +# Show configuration/install guidance before rendering module actions. +if (!&grub2_any_installed()) { + &ui_print_header(&grub2_version_text() || "", $text{'index_title'}, + "", undef, 1, 1); + print &ui_alert($text{'index_missing'}, 'warning'); + foreach my $issue (&grub2_install_issues()) { + print &ui_div(&text('index_missing_detail', + &ui_tag('tt', &html_escape($issue)))); + } + print &ui_p(&ui_link("@{[&get_webprefix()]}/config.cgi?$module_name", + $text{'index_config_link'})); + if ($access{'install'} && &foreign_available("software")) { + # Offer package installation only to users allowed to install GRUB. + &foreign_require("software", "software-lib.pl"); + my $lnk = &software::missing_install_link( + "grub2-common", $text{'index_install_pkg'}, + "../$module_name/", $text{'index_title'}); + print &ui_p($lnk) if ($lnk); + } + &ui_print_footer("/", $text{'index_return'}); + exit; + } + +&ui_print_header(&grub2_version_text() || "", $text{'index_title'}, "", + undef, 1, 1, undef, &grub2_action_links(\%access)); + +foreach my $warning (&grub2_status_warnings()) { + print &ui_alert($warning, 'warning'); + } + +# Only the two entry lists are tabs; global settings live in separate pages. +my @tabs = ( + [ 'entries', $text{'index_entries_tab'} ], + [ 'custom', $text{'index_custom_tab'} ], +); +my %valid = map { $_->[0] => 1 } @tabs; +my $requested = defined($in{'mode'}) ? $in{'mode'} : ''; +my $mode = $requested && $valid{$requested} ? $requested : 'entries'; +print &ui_tabs_start(\@tabs, "mode", $mode, 1); + +print &ui_tabs_start_tab("mode", "entries"); +&print_entries_tab(\%access); +print &ui_tabs_end_tab("mode", "entries"); + +print &ui_tabs_start_tab("mode", "custom"); +&print_custom_tab(\%access); +print &ui_tabs_end_tab("mode", "custom"); + +print &ui_tabs_end(); + +&print_action_buttons(\%access); +&ui_print_footer("/", $text{'index_return'}); + +# print_entries_tab(&access) +# Outputs generated boot menu entries and selected-entry runtime actions. +sub print_entries_tab +{ +my ($access) = @_; +my @entries = &grub2_boot_entries(); +my $parsed = &read_grub_defaults(); +my %env = &grub2_read_env(); +# Selection roles are derived from both defaults and grubenv state. +my %selection = &grub2_entry_selection_roles(\@entries, $parsed, \%env); +my $can_default = $access->{'runtime'} && &grub2_command('set_default_cmd'); +my $can_once = $access->{'runtime'} && &grub2_command('reboot_once_cmd'); +my $show_actions = $can_default || $can_once; +print &ui_div($text{'index_entries_desc'}); +if (!@entries) { + print &ui_alert($text{'index_no_entries'}, 'info'); + return; + } +my @heads = ( + $text{'index_col_title'}, + $text{'index_col_group'}, + $text{'index_col_selection'}, + ($show_actions ? ( $text{'index_col_actions'} ) : ( )), +); +my @tds = ( + "", + "", + "width=10% nowrap", + ($show_actions ? ( "width=10% nowrap" ) : ( )), +); +print &ui_columns_start(\@heads, 100, 0, \@tds); +foreach my $entry (@entries) { + # Path displays submenu nesting; BLS top-level entries have no submenu path. + my @cols = ( + &entry_title_cell($entry), + @{$entry->{'path'} || []} + ? &html_escape(join(' > ', @{$entry->{'path'}})) + : $text{'index_top'}, + &selection_cell($selection{$entry->{'index'}}), + ($show_actions ? ( &entry_actions_cell( + $entry, $can_default, $can_once) ) : ( )), + ); + print &ui_columns_row(\@cols, \@tds); + } +print &ui_columns_end(); +} + +# print_custom_tab(&access) +# Outputs editable custom menu entries from the configured custom file. +sub print_custom_tab +{ +my ($access) = @_; +my $file = &grub2_config_value('custom_file') || ''; +my @entries = &grub2_custom_entries($file); +my $can_edit = $access->{'manual'} && $file ne ''; +print &ui_div($text{'index_custom_desc'}); +if ($file eq '') { + # A blank custom file path means the module cannot safely offer editing. + print &ui_alert($text{'custom_enofile'}, 'info'); + return; + } +if ($can_edit && @entries) { + # Checked-table actions need a stable form number for select-all links. + my $formno = $grub2_formno; + print &ui_form_start("custom_action.cgi", "post", undef, + "id='grub2_custom_form'"); + $grub2_formno++; + &print_custom_links($can_edit, scalar(@entries), $formno); + } +elsif (@entries) { + &print_custom_links($can_edit, scalar(@entries), $grub2_formno); + } +if (!@entries) { + print &ui_br(); + print &ui_p($text{'custom_empty'}); + if ($can_edit) { + # Empty state uses a compact link, matching other Webmin list pages. + print &ui_link("edit_custom.cgi", $text{'custom_add'}, + "plus"); + print &ui_br(); + } + return; + } +# A single editable entry can be deleted, but cannot be reordered. +my $show_order = $can_edit && @entries > 1; +my @tds = $can_edit ? ( + "width=5", + "", + "", + ($show_order ? ( "width=40 style='white-space: nowrap; text-align: center'" ) : ( )), + ) : ( ); +print &ui_columns_start([ + ($can_edit ? ( "" ) : ( )), + $text{'index_col_title'}, + $text{'index_col_group'}, + ($show_order ? ( $text{'index_col_order'} ) : ( )), + ], 100, 0, \@tds); +foreach my $entry (@entries) { + # Custom indexes refer to parsed menuentry blocks in the custom file. + my $idx = $entry->{'custom_index'}; + my $title = &entry_title_cell($entry, "edit_custom.cgi?idx=$idx"); + my @cols = ( + $title, + @{$entry->{'path'} || []} + ? &html_escape(join(' > ', @{$entry->{'path'}})) + : $text{'index_top'}, + ($show_order ? ( &custom_order_cell($idx, \@entries) ) : ( )), + ); + if ($can_edit) { + print &ui_checked_columns_row(\@cols, \@tds, "d", $idx); + } + else { + print &ui_columns_row(\@cols); + } + } +print &ui_columns_end(); +if ($can_edit) { + my @left_buttons; + my @right_buttons = ( + [ "delete", $text{'index_delete_entry'}, undef, undef, + "form='grub2_custom_form'" ], + ); + print &ui_form_end_side_by_side("grub2_custom_form", + \@left_buttons, \@right_buttons); + } +} + +# print_action_buttons(&access) +# Outputs the main module actions allowed by ACLs. +sub print_action_buttons +{ +my ($access) = @_; +my (@links, @titles, @icons); +my $can_status = $access->{'view'}; +my $can_generate = $access->{'apply'} && &grub2_command('mkconfig_cmd'); +if ($access->{'install'}) { + # Primary action tiles are ACL-filtered so unavailable pages stay hidden. + push(@links, "edit_install.cgi"); + push(@titles, $text{'index_install'}); + push(@icons, "images/install.svg"); + } +if ($access->{'edit'}) { + push(@links, "edit_defaults.cgi"); + push(@titles, $text{'index_edit_defaults'}); + push(@icons, "images/defaults.svg"); + } +if ($access->{'security'}) { + push(@links, "edit_security.cgi"); + push(@titles, $text{'index_edit_security'}); + push(@icons, "images/security.svg"); + } +if ($access->{'edit'}) { + push(@links, "edit_theme.cgi"); + push(@titles, $text{'index_edit_theme'}); + push(@icons, "images/theme.svg"); + } +if ($access->{'manual'}) { + push(@links, "edit_manual.cgi"); + push(@titles, $text{'index_manual'}); + push(@icons, "images/manual.svg"); + } +return if (!@links && !$can_status && !$can_generate); +print &ui_hr(); +if (@links) { + print &ui_subheading($text{'index_global'}); + &icons_table(\@links, \@titles, \@icons, scalar(@links) > 5 ? 5 : + scalar(@links)); + } +if ($can_status || $can_generate) { + print &ui_hr() if (@links); + print &ui_buttons_start(); + print &ui_buttons_row("status.cgi", $text{'index_view_status'}, + $text{'index_view_status_msg'}, undef, undef, + undef, "get") if ($can_status); + print &ui_buttons_row("generate.cgi", $text{'index_generate'}, + $text{'index_generate_msg'}, + [ [ "redir", &grub2_this_url() ] ]) + if ($can_generate); + print &ui_buttons_end(); + } +} + +# print_custom_links(can-edit?, entry-count, form-number) +# Outputs checked-table links for custom entries. +sub print_custom_links +{ +my ($can_edit, $count, $formno) = @_; +return if (!$can_edit); +my @left; +if ($count) { + push(@left, &select_all_link("d", $formno), + &select_invert_link("d", $formno)); + } +push(@left, &ui_link("edit_custom.cgi", $text{'custom_add'})); +print &ui_links_row(\@left); +} + +# selection_cell(&roles) +# Returns display text for default and next-boot entry roles. +sub selection_cell +{ +my ($roles) = @_; +return '' if (!$roles || !@$roles); +my @labels = map { $text{'index_selection_'.$_} || $_ } @$roles; +return join(', ', @labels); +} + +# entry_title_cell(&entry, [link]) +# Returns a title cell with useful GRUB entry metadata in inline details. +sub entry_title_cell +{ +my ($entry, $link) = @_; +my $title = &html_escape($entry->{'title'} || ''); +my $summary = $link ? + &ui_tag('a', $title, { href => $link, style => 'padding: 0;' }) : + $title; +return &ui_details({ + 'html' => 1, + 'title' => $summary, + 'content' => &entry_details_content($entry), + 'class' => 'inline inlined', + }); +} + +# entry_details_content(&entry) +# Returns compact metadata for a boot entry details disclosure. +sub entry_details_content +{ +my ($entry) = @_; +my @rows; +my $index = defined($entry->{'index'}) ? $entry->{'index'} : + $entry->{'custom_index'}; +# Only include rows that help identify or troubleshoot the selected entry. +push(@rows, &entry_detail_line($text{'index_col_index'}, $index)) + if (defined($index)); +push(@rows, &entry_detail_line($text{'index_col_id'}, $entry->{'id'})) + if ($entry->{'id'}); +push(@rows, &entry_source_detail_line($entry)) + if ($entry->{'source_file'}); +push(@rows, &entry_detail_line($text{'index_col_version'}, + $entry->{'version'})); +push(@rows, &entry_detail_line($text{'index_col_kernel'}, + $entry->{'linux'})); +push(@rows, &entry_detail_line($text{'index_col_initrd'}, + $entry->{'initrd'})); +push(@rows, &entry_detail_line($text{'index_col_machine_id'}, + $entry->{'machine-id'})); +push(@rows, &entry_detail_line($text{'index_col_options'}, + $entry->{'options'})); +return join('', @rows); +} + +# entry_source_detail_line(&entry) +# Returns source details without implying generator scripts are entry files. +sub entry_source_detail_line +{ +my ($entry) = @_; +my $file = $entry->{'source_file'} || ''; +return '' if (!defined($file) || $file eq ''); +my $custom_file = &grub2_config_value('custom_file') || ''; +my $direct_file = (($entry->{'source'} || '') eq 'bls') || + ($custom_file ne '' && $file eq $custom_file); +# Direct entry files are shortened for readability; generator scripts are not. +my $label = $direct_file ? $text{'index_col_file'} : + $text{'index_col_generator'}; +my $html; +if ($direct_file) { + my $display = &entry_file_display_name($file); + $html = &ui_tag('tt', &html_escape($display), { 'title' => $file }); + } +else { + $html = &ui_tag('tt', &html_escape($file)); + } +if (&grub2_check_acl('manual') && &grub2_manual_file($file)) { + # The manual editor repeats its allowlist check on entry. + $html = &ui_tag('a', $html, { + 'href' => "edit_manual.cgi?file=".&urlize($file), + }); + } +return &entry_detail_line($label, $html, 1); +} + +# entry_file_display_name(file) +# Returns a short display name for a linked entry file. +sub entry_file_display_name +{ +my ($file) = @_; +$file = '' if (!defined($file)); +$file =~ s{.*/}{}; +return $file; +} + +# entry_detail_line(label, value, [html-value?]) +# Returns one escaped metadata line for a boot entry details disclosure. +sub entry_detail_line +{ +my ($label, $value, $html) = @_; +return '' if (!defined($value) || $value eq ''); +my $display = $html ? $value : &ui_tag('tt', &html_escape($value)); +return &ui_tag('div', + &ui_tag('span', &html_escape($label).':', + { 'style' => 'white-space: nowrap;' }). + &ui_tag('span', $display, + { 'style' => 'min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere;' }), + { 'style' => 'display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 0.35em; align-items: start;' }); +} + +# entry_actions_cell(&entry, can-default?, can-once?) +# Returns runtime action links for one generated boot entry. +sub entry_actions_cell +{ +my ($entry, $can_default, $can_once) = @_; +my $idx = $entry->{'index'}; +my @actions; +push(@actions, &ui_link("set_default.cgi?idx=$idx", + $text{'index_set_default'})) if ($can_default); +push(@actions, &ui_link("reboot_once.cgi?idx=$idx", + $text{'index_reboot_once'})) if ($can_once); +return join(' | ', @actions); +} + +# custom_order_cell(index, &entries) +# Returns up/down ordering controls for one custom entry row. +sub custom_order_cell +{ +my ($idx, $entries) = @_; +my $up = $idx > 0 && + &grub2_paths_equal($entries->[$idx], $entries->[$idx - 1]); +my $down = $idx < @$entries - 1 && + &grub2_paths_equal($entries->[$idx], $entries->[$idx + 1]); +# Disable movement across submenu boundaries so nesting remains intact. +return &ui_up_down_arrows("custom_action.cgi?idx=$idx&dir=up", + "custom_action.cgi?idx=$idx&dir=down", + $up, $down); +} diff --git a/grub2/install.cgi b/grub2/install.cgi new file mode 100755 index 000000000..afb7cdbb6 --- /dev/null +++ b/grub2/install.cgi @@ -0,0 +1,146 @@ +#!/usr/local/bin/perl +# Install the GRUB 2 boot loader with progress output. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'install_err'}); +&grub2_assert_acl('install'); + +# Trim text fields before validation so shell arguments are deterministic. +foreach my $field (qw(target efi_dir platform directory boot_directory + bootloader_id)) { + $in{$field} = "" if (!defined($in{$field})); + $in{$field} =~ s/^\s+|\s+\z//g; + } +&error($text{'install_eboot_directory_required'}) + if ($in{'use_boot_directory'} && $in{'boot_directory'} eq ''); +my %opts = ( + 'target' => $in{'target'}, + 'efi_dir' => $in{'efi_dir'}, + 'platform' => $in{'platform'} || &grub2_default_platform_target(), + 'directory' => $in{'directory'}, + 'boot_directory' => $in{'use_boot_directory'} ? $in{'boot_directory'} : '', + 'bootloader_id' => $in{'bootloader_id'}, + 'recheck' => $in{'recheck'} ? 1 : 0, + 'removable' => $in{'removable'} ? 1 : 0, + 'no_nvram' => $in{'no_nvram'} ? 1 : 0, + 'force' => $in{'force'} ? 1 : 0, +); +$in{'confirm'} || &error($text{'install_econfirm'}); +&grub2_command('install_cmd') || &error($text{'install_ecmd'}); +# Validate all paths and identifiers before any progress output is started. +my $precheck = &grub2_validate_install_options(\%opts); +&error($precheck) if ($precheck); + +my ($pre_open, $current_step, $failed_printed, $captured_output) = + (0, '', 0, ''); + +&ui_print_unbuffered_header(undef, $text{'install_progress_title'}, ""); + +my $callback = sub { + my ($event, $value) = @_; + if ($event eq 'command') { + # The first event opens the visible command transcript. + $current_step = 'command'; + &print_step_start($text{'install_installing'}); + print &ui_tag_start('pre', { 'style' => 'margin-left: 10px;' }); + $pre_open = 1; + print &html_escape($value)."\n"; + return; + } + if ($event eq 'output') { + # Some commands emit output before command_done; keep one pre open. + if (!$pre_open) { + print &ui_tag_start('pre', + { 'style' => 'margin-left: 10px;' }); + $pre_open = 1; + } + $captured_output .= $value; + print &html_escape($value); + return; + } + if ($event eq 'command_done') { + # Close the transcript before printing the final status marker. + &close_output(\$pre_open); + &print_step_done(\$current_step); + return; + } + if ($event eq 'command_failed') { + # Print only one failure line even if the caller also returns an error. + &close_output(\$pre_open); + &print_step_failed(\$current_step, \$failed_printed); + return; + } + }; + +my $err = &grub2_install_bootloader(\%opts, $callback); +&close_output(\$pre_open); +if ($err) { + # Avoid duplicating the same error when it was already in command output. + &print_step_failed(\$current_step, \$failed_printed) + if ($current_step); + my $shown = $captured_output || ''; + $shown =~ s/^\s+|\s+\z//g; + print &ui_tag('pre', &html_escape($err), + { 'style' => 'margin-left: 10px;' }) + if ($err ne $shown); + } +else { + &webmin_log("install", undef, &grub2_install_log_target(\%opts)); + } + +&ui_print_footer("index.cgi", $text{'install_return'}); + +# close_output(&open-flag) +# Closes the command output block when it is currently being printed. +sub close_output +{ +my ($open) = @_; +if ($$open) { + print &ui_tag_end('pre'); + $$open = 0; + } +return; +} + +# print_step_start(text) +# Prints the first progress line for the installation step. +sub print_step_start +{ +my ($msg) = @_; +print &ui_tag('span', &html_escape($msg." .."), + { 'data-first-print' => undef }); +print "
\n"; +return; +} + +# print_step_done(¤t-step) +# Prints a successful progress line and clears the active step. +sub print_step_done +{ +my ($current) = @_; +print &ui_tag('span', &html_escape(".. ".$text{'install_done'}), + { 'data-second-print' => undef }); +print "
\n"; +$$current = ''; +return; +} + +# print_step_failed(¤t-step, &printed-flag) +# Prints a failed progress line once and clears the active step. +sub print_step_failed +{ +my ($current, $printed) = @_; +return if ($$printed); +print &ui_tag('span', &html_escape(".. ".$text{'install_failed_status'}), + { 'data-second-print' => undef }); +print "
\n"; +$$current = ''; +$$printed = 1; +return; +} diff --git a/grub2/install_check.pl b/grub2/install_check.pl new file mode 100644 index 000000000..99cdaf272 --- /dev/null +++ b/grub2/install_check.pl @@ -0,0 +1,18 @@ +# install_check.pl + +use strict; +use warnings; +do 'grub2-lib.pl'; + +# is_installed(mode) +# For mode 1, returns 2 if GRUB 2 is installed and configured for Webmin, +# 1 if installed but not configured, or 0 otherwise. +# For mode 0, returns 1 if installed, 0 if not. +sub is_installed +{ +my ($mode) = @_; +return 0 if (!&grub2_any_installed()); +return $mode ? (&grub2_configured() ? 2 : 1) : 1; +} + +1; diff --git a/grub2/lang/en b/grub2/lang/en new file mode 100644 index 000000000..61bfe252d --- /dev/null +++ b/grub2/lang/en @@ -0,0 +1,349 @@ +index_title=GRUB 2 Boot Loader +index_version=GRUB version $1 +index_return=GRUB 2 module +status_title=GRUB status +index_missing=GRUB 2 does not appear to be installed or configured. Install GRUB 2 packages or update this module's configuration paths. +index_missing_detail=Missing or unusable item: $1 +index_config_link=Module configuration +index_install_pkg=GRUB 2 packages +index_summary=Configuration summary +index_boot_mode=Boot mode +index_boot_mode_uefi=UEFI +index_boot_mode_bios=Legacy BIOS +index_secure_boot=Secure Boot +index_secure_boot_enabled=Enabled +index_secure_boot_disabled=Disabled +index_secure_boot_unknown=Unknown +index_secure_boot_not_applicable=Not applicable for BIOS boot +index_default_file=Default settings file +index_grub_cfg=Generated menu file +index_grub_dir=Script directory +index_bls_dir=BLS entries directory +index_mkconfig=Menu generation command +index_install_cmd=Boot loader install command +index_env=Environment file +index_entries=Boot menu entries +index_entry_count=$1 entries +index_kernel_options_source=Kernel options source +index_kernel_options_source_kernelopts=GRUB environment kernelopts +index_kernel_options_source_bls=BLS entry files +index_kernel_options_source_defaults=Generated menu entries +index_no_entries=No boot entries were found in the generated GRUB menu file. +index_entries_tab=Generated menu +index_custom_tab=Custom entries +index_entries_desc=Review entries from the generated GRUB menu and choose one entry for runtime actions; generated kernel, BLS, and distribution entries are not editable from this page. +index_custom_desc=Add, edit, reorder, or delete entries from the configured custom menu file. These entries appear in the generated menu after regeneration. +index_status_desc=Review discovered GRUB paths, common defaults, theme appearance, saved boot selection, and password protection managed by Webmin. +index_security_state=Current protection +index_security_enabled=Enabled +index_security_disabled=Disabled +index_security_unmanaged=Unmanaged file +index_security_user=Superuser +index_security_hash=Password hash +index_security_hash_set=Configured +index_security_hash_missing=Missing +index_security_file=Password script +index_security_mkpasswd=Password hash command +index_boot_selection=Boot selection +index_col_index=Index +index_col_selection=Selection +index_col_order=Order +index_col_title=Title +index_col_id=Entry ID +index_col_group=Submenu +index_col_actions=Actions +index_col_file=File +index_col_generator=Generator script +index_col_version=Version +index_col_kernel=Kernel +index_col_initrd=Initial ramdisk +index_col_machine_id=Machine ID +index_col_options=Kernel options +index_selection_default=Default +index_selection_saved=Saved default +index_selection_next=Boot once +index_top=Top level +index_bls=Boot Loader Specification +index_saved_entry=Environment default entry +index_next_entry=Next boot entry +index_not_set=Not set +index_not_readable=Not readable +index_missing_file=Missing file +index_delete_entry=Delete entry +index_global=Global Options +index_edit_defaults=Edit GRUB Defaults +index_edit_theme=Edit Theme and Appearance +index_edit_security=Edit GRUB Password +index_manual=Edit Config Files Manually +index_view_status=View GRUB status +index_view_status_msg=Review detected paths, defaults, boot selection, theme, and password protection. +index_generate=Regenerate GRUB menu +index_generate_msg=Run GRUB's menu generator and replace the generated menu only after a successful test run. +index_install=Install GRUB Boot Loader +index_set_default=Make Default +index_reboot_once=Boot Once +index_warn_missing_cfg=The generated menu file does not exist. Generate the menu before relying on these settings at boot. +index_warn_missing_default=The default settings file does not exist. Create it or update the module configuration. +index_warn_mkconfig=The menu generation command is not available, so Webmin cannot regenerate the boot menu. +index_warn_saved=The default is set to saved, but the GRUB environment file could not be read. +index_warn_theme_invalid=The configured GRUB theme cannot be used: $1 +index_warn_theme_console=A GRUB theme is configured, but terminal output is set to console. Use graphical terminal output for the theme to appear. +index_warn_bls_options=Detected BLS boot entries store kernel options in BLS entry files. Install grubby or configure its path to update existing BLS entries automatically. +index_warn_kernelopts_source=Detected BLS boot entries read kernel options from grubenv kernelopts. Install grubby or configure its path if existing BLS entries do not change after saving kernel options. +index_warn_kernelopts_missing=BLS boot entries refer to grubenv kernelopts, but no kernelopts value was found in the GRUB environment file. +defaults_title=GRUB default settings +defaults_header=Common default settings +defaults_theme_header=Theme and menu appearance +defaults_default=Default menu entry +defaults_default_saved=Saved environment entry +defaults_default_current=Current value: $1 +defaults_default_current_entry=Current index $1: $2 +defaults_default_entry_id=$1 [$2] +defaults_timeout_style=Timeout style +defaults_timeout=Timeout +defaults_terminal_output=Terminal output +defaults_terminal_console=Console +defaults_terminal_gfxterm=Graphical terminal +defaults_terminal_gfxterm_console=Graphical terminal, then console +defaults_terminal_serial=Serial +defaults_cmdline_default=Kernel options for regular Linux entries +defaults_cmdline=Kernel options for all Linux entries +defaults_kernelopts_source=Detected kernel options source +defaults_disable_recovery=Disable recovery and rescue entries +defaults_disable_recovery_bls=Existing BLS rescue entries are hidden by renaming their entry files. +defaults_disable_os_prober=Disable OS prober +defaults_theme=Theme file +defaults_theme_current=Current theme file +defaults_theme_none=Not set +defaults_theme_action=Theme action +defaults_theme_keep=Keep current theme +defaults_theme_install=Install or select theme source +defaults_theme_clear=Use no theme +defaults_theme_source=Theme source +defaults_gfxmode=Graphics mode +defaults_gfxmode_default=Use default resolution +defaults_gfxmode_auto=Automatic resolution +defaults_background=Background image +defaults_color_normal=Normal menu colors +defaults_color_highlight=Selected menu colors +defaults_color_default=Use default colors +defaults_color_custom=Set custom colors +defaults_color_text=Text +defaults_color_background=Background +defaults_keep=Use GRUB default +defaults_menu=Menu +defaults_hidden=Hidden +defaults_countdown=Countdown +defaults_true=Yes +defaults_false=No +defaults_format_note=Saving these settings rewrites the managed assignments in normalized shell format while preserving other lines and comments. +defaults_err=Failed to save GRUB defaults +defaults_edefault=The default menu entry cannot contain line breaks or null bytes. +defaults_edefault_choice=The selected default menu entry is not one of the detected entries. +defaults_etimeout=The timeout must be -1 or a non-negative number of seconds. +defaults_etimeout_style=Invalid timeout style. +defaults_eterminal_output=Invalid terminal output. +defaults_egfxmode=Invalid graphics mode. Use auto or modes like 1024x768 or 1024x768x32, separated by commas. +defaults_ebool=Invalid boolean value for $1. +defaults_ecmdline=$1 cannot contain line breaks or null bytes. +defaults_egrubby=The grubby command is not available, so existing BLS entries were not updated. +defaults_egrubby_failed=The grubby command failed without output. +defaults_ebls_rescue_exists=Cannot disable BLS rescue entry $1 because $2 already exists. +defaults_ebls_rescue_move=Failed to move BLS rescue entry from $1 to $2: $3 +defaults_edir=Directory $1 does not exist or is not accessible. +defaults_epath=$1 cannot contain line breaks or null bytes. +defaults_eabspath=$1 must be an absolute path. +defaults_econfigpath=Configured file path $1 must be absolute. +defaults_epathchars=$1 contains characters that are not allowed in GRUB file paths. +defaults_ecolor=Invalid color selection for $1. +defaults_ecolorfile=The configured GRUB color script already exists but is not managed by Webmin. Edit it manually or choose another path in module configuration. +defaults_etheme_archive=$1 is a theme archive, not a GRUB theme file. Extract the theme and select its theme.txt file. +defaults_etheme_file=The GRUB theme file $1 does not exist or cannot be read. +defaults_etheme_source=Enter a local theme file, local theme archive, local theme directory, or HTTP, HTTPS, or FTP URL. +defaults_etheme_url=Enter a valid HTTP, HTTPS, or FTP URL for the theme source. +defaults_etheme_download=Failed to download the theme source: $1 +defaults_etheme_mode=Invalid theme action. +defaults_etheme_nottheme=$1 is not a theme.txt file or a supported theme archive. +defaults_etheme_notfound=No theme.txt file was found in the selected theme source. +defaults_etheme_archive_list=Failed to list the theme archive. +defaults_etheme_extract=Failed to extract the theme archive. +defaults_etheme_type=Unsupported theme archive type. +defaults_etheme_cmd=The command $1 is required to install this theme source. +defaults_etheme_member=The theme archive contains an unsafe or unsupported member: $1 +defaults_etheme_terminal=GRUB themes require graphical terminal output. Change Terminal output to Graphical terminal before saving this theme. +defaults_ebackground_file=The GRUB background image $1 does not exist or cannot be read. +defaults_ebackground_type=The GRUB background image $1 must be a PNG, JPEG, or TGA file. +defaults_theme_note=Enter an existing theme.txt, a theme directory, a local .tar.gz or .zip archive, or an HTTP, HTTPS, or FTP URL. Webmin installs the theme under the configured GRUB theme directory in /boot. Use graphical terminal output for themes. +defaults_background_note=Webmin copies this image under the configured GRUB background directory in /boot before saving it. +theme_title=GRUB theme and appearance +theme_err=Failed to save GRUB theme and appearance +security_title=GRUB password protection +security_header=Password protection +security_current_state=Current protection +security_current_enabled=Enabled for superuser $1 +security_current_disabled=Disabled +security_current_hash=Current password +security_current_hash_set=Set, not shown +security_current_hash_missing=Not set +security_enable=Enable password protection +security_user=Superuser name +security_password_status=Password update +security_password_keep=Leave blank to keep the current password, or enter a new one to replace it. +security_password_required=No password is currently set. Enter and confirm a password before enabling protection. +security_newpass=New password +security_newpass2=Confirm password +security_hash=PBKDF2 hash +security_hash_note=This is the stored GRUB password hash, not the original password. Leave it unchanged to keep the current password, or replace it with a pre-generated grub.pbkdf2 hash. +security_unmanaged=The configured password script already exists but is not managed by Webmin. Edit it manually or choose another path in module configuration. +security_err=Failed to save GRUB password protection +security_euser=The GRUB superuser name is required and can only contain letters, numbers, dots, underscores, plus signs, at signs, and hyphens. +security_epass=Enter a password or PBKDF2 hash before enabling password protection. +security_epassmatch=The password and confirmation do not match. +security_epasschars=The password cannot contain line breaks or null bytes. +security_epassmode=Enter either a password or a PBKDF2 hash, not both. +security_ehash=The PBKDF2 hash is invalid. +security_ehashgen=The password hash command did not return a GRUB PBKDF2 hash. +security_emkpasswd=The GRUB password hash command is not available. Install GRUB tools or paste a PBKDF2 hash. +security_eunmanaged=The configured GRUB password script is not managed by Webmin and will not be overwritten. +color_black=Black +color_blue=Blue +color_green=Green +color_cyan=Cyan +color_red=Red +color_magenta=Magenta +color_brown=Brown +color_light-gray=Light gray +color_dark-gray=Dark gray +color_light-blue=Light blue +color_light-green=Light green +color_light-cyan=Light cyan +color_light-red=Light red +color_light-magenta=Light magenta +color_yellow=Yellow +color_white=White +manual_title=Manual GRUB configuration +manual_select=Edit config file: +manual_ok=Edit +manual_err=Failed to save GRUB file +manual_efile=The selected file is not allowed for manual editing. +manual_enofile=No GRUB files are configured for manual editing. +manual_evalidate=The file failed validation: $1 +manual_ebls=The BLS entry must contain key/value lines and may contain comments or blank lines. +manual_eblsline=Line $1 is not valid BLS key/value syntax. +custom_title_new=Add Custom GRUB Entry +custom_title_edit=Edit Custom GRUB Entry +custom_header=Custom boot entry +custom_add=Add new custom boot entry +custom_entry_title=Menu title +custom_entry_id=Entry ID +custom_entry_body=GRUB commands +custom_id_note=Optional. Use letters, numbers, dots, underscores, colons, plus signs, equals signs, commas, at signs, and hyphens only. +custom_empty=No custom boot entries were found. +custom_err=Failed to save custom GRUB entry +custom_efile=No custom GRUB menu file is configured. +custom_enofile=No custom GRUB menu file is configured. +custom_eentry=Invalid custom boot entry. +custom_eone=Select exactly one custom boot entry. +custom_etitle=The menu title is required and cannot contain line breaks or null bytes. +custom_eid=The entry ID contains invalid characters. +custom_ebody=The GRUB commands contain invalid data. +custom_evalidate=The GRUB script failed validation. +custom_ebraces=The GRUB commands have unbalanced braces. +custom_emove=The selected custom entry cannot be moved in that direction. +generate_title=Regenerating GRUB menu +generate_err=Failed to generate the GRUB menu +generate_missing=The GRUB menu generation command is not available. +generate_empty=The generated test menu was empty. +generate_failed=The GRUB menu generation command failed without output. +generate_evalidate=The generated test menu failed validation: $1 +generate_regenerating=Regenerating GRUB menu +generate_check=Checking generated test menu +generate_replace=Replacing generated menu +generate_done=done +generate_failed_status=failed +generate_return=GRUB 2 module +install_title=Install GRUB boot loader +install_progress_title=Installing GRUB boot loader +install_header=Boot loader installation +install_command=Install command +install_target=Install target +install_efi_dir=EFI system directory +install_platform=Platform target +install_directory=GRUB module directory +install_boot_directory=Boot directory +install_bootloader_id=Boot loader ID +install_options=Options +install_boot_directory_enable=Pass boot directory to grub-install +install_recheck=Probe devices again before installing +install_removable=Install as removable EFI media +install_no_nvram=Do not update EFI firmware boot variables +install_force=Force EFI install even though it may not support Secure Boot +install_confirm=Confirmation +install_confirm_label=I understand that installing GRUB to the wrong target can make this system unbootable. +install_submit=Install GRUB +install_installing=Installing GRUB boot loader +install_done=done +install_failed_status=failed +install_return=GRUB 2 module +install_warn_modules=GRUB platform files for $1 were not found. Install the matching GRUB EFI/module package before installing the boot loader, or enter the module directory manually. +install_err=Failed to install the GRUB boot loader +install_ecmd=The GRUB boot loader install command is not available. +install_etarget=Enter an absolute device path under /dev such as /dev/sda, or an EFI system directory. +install_etarget_missing=Install target $1 does not exist. +install_eefi=The EFI system directory must be an absolute path with safe characters. +install_eefi_missing=EFI system directory $1 does not exist. +install_eplatform=The platform target can only contain letters, numbers, underscores, and hyphens. +install_eplatform_modules=GRUB platform files for $1 were not found. Install the matching GRUB EFI/module package or enter the module directory manually. Checked: $2 +install_edirectory=The GRUB module directory must be an absolute path with safe characters. +install_edirectory_missing=GRUB module directory $1 does not exist. +install_edirectory_modinfo=GRUB module directory $1 does not contain modinfo.sh. +install_eboot_directory=The boot directory must be an absolute path with safe characters. +install_eboot_directory_missing=Boot directory $1 does not exist. +install_eboot_directory_required=Enter a boot directory, or clear the boot directory option. +install_ebootloader=The boot loader ID can only contain letters, numbers, dots, underscores, plus signs, and hyphens. +install_eoption=Invalid install option. +install_econfirm=Confirm that you understand the risk before installing GRUB. +install_failed=The GRUB boot loader install command failed without output. +install_log_efi=EFI directory $1 +runtime_err=Failed to update GRUB boot selection +runtime_eentry=Invalid boot entry. +runtime_ecmd=The required GRUB command is not available. +runtime_eselector=Unsafe or invalid GRUB boot entry selector. +runtime_eaction=No GRUB boot action was selected. +delete_enone=Select one or more boot menu entries to delete. +delete_ecustom=Only custom menu entries from the configured custom file can be deleted here. +acl_section_view=View permissions +acl_section_change=Change permissions +acl_section_admin=Administrative permissions +acl_view=View GRUB configuration and boot entries +acl_edit=Edit common GRUB defaults +acl_security=Edit GRUB password protection +acl_apply=Generate the GRUB menu +acl_runtime=Set saved or one-time boot entries +acl_manual=Manually edit allowed GRUB files +acl_install=Install GRUB boot loader +acl_backup=Include GRUB files in backups +acl_ecannot=You are not allowed to perform this GRUB 2 action. +eacl_np=Access denied: +eacl_pview=view GRUB configuration +eacl_pedit=edit GRUB defaults +eacl_psecurity=edit GRUB password protection +eacl_papply=generate the GRUB menu +eacl_pruntime=change GRUB boot selection +eacl_pmanual=manually edit GRUB files +eacl_pinstall=install GRUB boot loader +eacl_pbackup=backup GRUB files +log_defaults=Modified GRUB default settings +log_bls_args=Updated existing BLS kernel options +log_theme=Modified GRUB theme and appearance +log_security=Modified GRUB password protection +log_manual=Modified GRUB file $1 +log_custom_create=Created custom GRUB entry $1 +log_custom_modify=Modified custom GRUB entry $1 +log_custom_move=Moved custom GRUB entry $1 +log_generate=Generated GRUB menu $1 +log_install=Installed GRUB boot loader to $1 +log_default=Set saved GRUB default to $1 +log_once=Set next GRUB boot to $1 +log_custom_delete=Deleted $1 custom GRUB menu entries +__norefs=1 diff --git a/grub2/log_parser.pl b/grub2/log_parser.pl new file mode 100644 index 000000000..b2721a706 --- /dev/null +++ b/grub2/log_parser.pl @@ -0,0 +1,68 @@ +# log_parser.pl +# Functions for parsing this module's logs. + +use strict; +use warnings; +do 'grub2-lib.pl'; + +our %text; + +# parse_webmin_log(user, script, action, type, object, ¶ms, [long]) +# Converts logged information from this module into human-readable form. +sub parse_webmin_log +{ +my ($user, $script, $action, $type, $object, $p, $long) = @_; +# Simple module-wide actions do not need an object value. +if ($action eq 'defaults') { + return $text{'log_defaults'}; + } +if ($action eq 'bls_args') { + return $text{'log_bls_args'}; + } +if ($action eq 'theme') { + return $text{'log_theme'}; + } +if ($action eq 'security') { + return $text{'log_security'}; + } +# Object-bearing actions display paths, selectors, or titles as literals. +if ($action eq 'manual') { + return &text('log_manual', &log_value($object)); + } +if ($action eq 'custom_create') { + return &text('log_custom_create', &log_value($object)); + } +if ($action eq 'custom_modify') { + return &text('log_custom_modify', &log_value($object)); + } +if ($action eq 'custom_move') { + return &text('log_custom_move', &log_value($object)); + } +if ($action eq 'generate') { + return &text('log_generate', &log_value($object)); + } +if ($action eq 'install') { + return &text('log_install', &log_value($object)); + } +if ($action eq 'default') { + return &text('log_default', &log_value($object)); + } +if ($action eq 'once') { + return &text('log_once', &log_value($object)); + } +if ($action eq 'custom_delete') { + return &text('log_custom_delete', $object || 0); + } +return; +} + +# log_value(value) +# Returns a styled inline value for log descriptions. +sub log_value +{ +my ($value) = @_; +$value = '' if (!defined($value)); +return &ui_tag('tt', &html_escape($value)); +} + +1; diff --git a/grub2/module.info b/grub2/module.info new file mode 100644 index 000000000..f5063ec71 --- /dev/null +++ b/grub2/module.info @@ -0,0 +1,7 @@ +name=GRUB 2 +desc=GRUB 2 Boot Loader +category=hardware +os_support=*-linux +longdesc=Configure GRUB 2 boot loader defaults, inspect generated menu entries, and update boot loader configuration safely. +rpm_recommends=grub2-tools +deb_recommends=grub2-common diff --git a/grub2/reboot_once.cgi b/grub2/reboot_once.cgi new file mode 100755 index 000000000..2849c7ce3 --- /dev/null +++ b/grub2/reboot_once.cgi @@ -0,0 +1,22 @@ +#!/usr/local/bin/perl +# Set the one-time next GRUB 2 boot entry. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'runtime_err'}); +&grub2_assert_acl('runtime'); + +# Runtime commands operate on the parsed generated menu index shown on index.cgi. +my $entry = &grub2_entry_by_index($in{'idx'}); +&error($text{'runtime_eentry'}) if (!$entry); +# The helper validates the selector before invoking grub-reboot. +my $err = &grub2_run_entry_command('reboot_once_cmd', $entry); +&error($err) if ($err); +my $selector = &grub2_entry_selector($entry); +&webmin_log("once", undef, $selector); +&redirect(""); diff --git a/grub2/save_custom.cgi b/grub2/save_custom.cgi new file mode 100755 index 000000000..79b5e6f4d --- /dev/null +++ b/grub2/save_custom.cgi @@ -0,0 +1,31 @@ +#!/usr/local/bin/perl +# Save a custom GRUB 2 menu entry. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'custom_err'}); +&grub2_assert_acl('manual'); + +# A missing index means create; a present one must address a parsed entry. +my $idx = defined($in{'idx'}) && $in{'idx'} ne '' ? $in{'idx'} : undef; +if (defined($idx) && $idx !~ /^\d+\z/) { + &error($text{'custom_eentry'}); + } +# Normalize absent fields before validation so empty strings mean intentional. +foreach my $field (qw(title id body)) { + $in{$field} = "" if (!defined($in{$field})); + } +$in{'body'} =~ s/\r//g; +# The library validates GRUB script balance before rewriting the custom file. +my $err = &grub2_save_custom_entry($idx, $in{'title'}, $in{'id'}, + $in{'body'}); +&error($err) if ($err); +&grub2_mark_regenerate_needed(); +&webmin_log(defined($idx) ? "custom_modify" : "custom_create", + undef, $in{'title'}); +&redirect("index.cgi?mode=custom"); diff --git a/grub2/save_defaults.cgi b/grub2/save_defaults.cgi new file mode 100755 index 000000000..c40de4dd1 --- /dev/null +++ b/grub2/save_defaults.cgi @@ -0,0 +1,215 @@ +#!/usr/local/bin/perl +# Save common GRUB 2 defaults. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'defaults_err'}); +&grub2_assert_acl('edit'); + +# Capture both the current defaults file and generated entries for validation. +my $current = &read_grub_defaults(); +my $current_values = $current->{'values'}; +my @entries = &grub2_boot_entries(); +my %updates; +# Default entries must come from the selector, except for the preserved value. +$in{'default'} = '' if (!defined($in{'default'})); +$in{'default'} =~ /[\r\n\0]/ && &error($text{'defaults_edefault'}); +&error($text{'defaults_edefault_choice'}) + if (!&valid_default_entry_value( + $in{'default'}, $current_values->{'GRUB_DEFAULT'}, \@entries)); +$updates{'GRUB_DEFAULT'} = $in{'default'} if ($in{'default'} ne ''); + +my %styles = map { $_ => 1 } ('', qw(menu hidden countdown)); +&error($text{'defaults_etimeout_style'}) + if (!defined($in{'timeout_style'}) || !$styles{$in{'timeout_style'}}); +$updates{'GRUB_TIMEOUT_STYLE'} = + $in{'timeout_style'} eq '' ? undef : $in{'timeout_style'}; + +# Empty timeout removes the local override; otherwise GRUB accepts -1 or more. +if (defined($in{'timeout'}) && $in{'timeout'} ne '') { + $in{'timeout'} =~ /^-?\d+\z/ && $in{'timeout'} >= -1 + || &error($text{'defaults_etimeout'}); + $updates{'GRUB_TIMEOUT'} = $in{'timeout'}; + } +else { + $updates{'GRUB_TIMEOUT'} = undef; + } + +# Kernel command-line values are single-line shell assignment values. +foreach my $field ( + [ 'cmdline_default', 'GRUB_CMDLINE_LINUX_DEFAULT', + $text{'defaults_cmdline_default'} ], + [ 'cmdline', 'GRUB_CMDLINE_LINUX', $text{'defaults_cmdline'} ], + ) +{ + my ($input, $key, $label) = @$field; + $in{$input} = '' if (!defined($in{$input})); + $in{$input} =~ /[\r\n\0]/ && &error(&text('defaults_ecmdline', $label)); + $updates{$key} = $in{$input} eq '' ? undef : $in{$input}; + } + +# Boolean GRUB defaults keep their tri-state UI: inherit, true, or false. +foreach my $field ( + [ 'disable_recovery', 'GRUB_DISABLE_RECOVERY', + $text{'defaults_disable_recovery'} ], + [ 'disable_os_prober', 'GRUB_DISABLE_OS_PROBER', + $text{'defaults_disable_os_prober'} ], + ) +{ + my ($input, $key, $label) = @$field; + $in{$input} = '' if (!defined($in{$input})); + if ($in{$input} eq '') { + $updates{$key} = undef; + } + elsif ($in{$input} =~ /^(true|false)\z/) { + $updates{$key} = $in{$input}; + } + else { + &error(&text('defaults_ebool', $label)); + } + } + +my $err = &save_grub_defaults_values(\%updates); +&error(&text('manual_evalidate', $err)) if ($err); + +# BLS rescue entries are real files, so restore them before changing BLS args. +my $disable_bls_rescue = + (($updates{'GRUB_DISABLE_RECOVERY'} || '') eq 'true') ? 1 : 0; +my $can_handle_bls_rescue = + &grub2_has_bls_rescue_entries(\@entries) || + &grub2_disabled_bls_rescue_files(); +my $bls_rescue_err; +if (!$disable_bls_rescue) { + $bls_rescue_err = &grub2_set_bls_rescue_disabled(0); + # Refresh entry data after restores so grubby sees current BLS files. + @entries = &grub2_boot_entries() if (!$bls_rescue_err); + } + +# On BLS systems, grubby applies kernel arg deltas to existing boot entries. +my %bls_args_updated; +my $bls_err; +if (&grub2_bls_update_available(\@entries)) { + my @locked_bls_files = &lock_bls_update_files( + $current_values, \%updates, \@entries); + ($bls_err, %bls_args_updated) = &update_bls_kernel_args( + $current_values, \%updates, \@entries); + &unlock_bls_update_files(@locked_bls_files); + } +if (!$bls_err && !$bls_rescue_err && $disable_bls_rescue) { + # Hide rescue files after grubby has had a chance to update normal entries. + $bls_rescue_err = &grub2_set_bls_rescue_disabled(1, \@entries); + } +$bls_args_updated{'GRUB_DISABLE_RECOVERY'} = 1 + if (!$bls_rescue_err && $can_handle_bls_rescue && + !&grub2_has_non_bls_recovery_entries(\@entries)); +&grub2_mark_regenerate_needed() + if (&grub2_defaults_updates_need_generate( + $current_values, \%updates, \%bls_args_updated)); +&webmin_log("defaults"); +&error($bls_err) if ($bls_err); +&error($bls_rescue_err) if ($bls_rescue_err); +&redirect("index.cgi"); + +# valid_default_entry_value(value, current-value, &entries) +# Returns true if a posted default entry came from the detected selector. +sub valid_default_entry_value +{ +my ($value, $current, $entries) = @_; +return 1 if (!defined($value) || $value eq ''); +$current = '0' if (!defined($current) || $current eq ''); +return 1 if ($value eq 'saved'); +return 1 if ($value eq $current); +foreach my $entry (@$entries) { + my $selector = &grub2_entry_selector($entry); + return 1 if (defined($selector) && $selector eq $value); + } +return 0; +} + +# update_bls_kernel_args(&old-values, &updates, &entries) +# Applies changed kernel option defaults to existing BLS entries. +sub update_bls_kernel_args +{ +my ($old_values, $updates, $entries) = @_; +my %updated; +foreach my $field ( + [ 'GRUB_CMDLINE_LINUX', undef ], + [ 'GRUB_CMDLINE_LINUX_DEFAULT', + [ &grub2_bls_kernel_arg_targets($entries, 0) ] ], + ) +{ + my ($key, $targets) = @$field; + my ($remove, $add) = + &grub2_kernel_args_delta($old_values->{$key}, $updates->{$key}); + # No delta means this defaults field can be ignored for BLS updates. + next if (!@$remove && !@$add); + next if (defined($targets) && !@$targets); + my $err = &grub2_update_bls_kernel_args( + $old_values->{$key}, $updates->{$key}, $targets); + if ($err) { + # A partial grubby failure falls back to regeneration when needed. + &grub2_mark_regenerate_needed() + if (&grub2_defaults_updates_need_generate( + $old_values, $updates, \%updated)); + return ($err, %updated); + } + $updated{$key} = 1; + } +return (undef, %updated); +} + +# lock_bls_update_files(&old-values, &updates, &entries) +# Locks files that grubby may change so Webmin can diff them. +sub lock_bls_update_files +{ +my ($old_values, $updates, $entries) = @_; +my %lock_all = &kernel_args_changed( + $old_values->{'GRUB_CMDLINE_LINUX'}, $updates->{'GRUB_CMDLINE_LINUX'}) ? + ( all => 1 ) : (); +my %lock_default = &kernel_args_changed( + $old_values->{'GRUB_CMDLINE_LINUX_DEFAULT'}, + $updates->{'GRUB_CMDLINE_LINUX_DEFAULT'}) ? ( default => 1 ) : (); +return () if (!%lock_all && !%lock_default); +my (@locked, %seen); +# kernelopts-based entries may be updated through grubenv instead of .conf files. +if (grep { &grub2_entry_uses_kernelopts($_) } @$entries) { + my $file = &grub2_config_value('grubenv_file') || ''; + if ($file ne '' && -e $file && !$seen{$file}++) { + &lock_file($file); + push(@locked, $file); + } + } +foreach my $entry (@$entries) { + next if (($entry->{'source'} || '') ne 'bls'); + # Default-only changes skip rescue entries, matching the edit form wording. + next if (!%lock_all && &grub2_entry_is_bls_rescue($entry)); + my $file = $entry->{'file'}; + next if (!defined($file) || $file eq '' || $seen{$file}++); + &lock_file($file); + push(@locked, $file); + } +return @locked; +} + +# unlock_bls_update_files(files...) +# Unlocks files after grubby has run. +sub unlock_bls_update_files +{ +foreach my $file (reverse @_) { + &unlock_file($file); + } +} + +# kernel_args_changed(old-args, new-args) +# Returns true when a kernel-args delta would be applied. +sub kernel_args_changed +{ +my ($old_args, $new_args) = @_; +my ($remove, $add) = &grub2_kernel_args_delta($old_args, $new_args); +return @$remove || @$add; +} diff --git a/grub2/save_manual.cgi b/grub2/save_manual.cgi new file mode 100755 index 000000000..48e0d3df0 --- /dev/null +++ b/grub2/save_manual.cgi @@ -0,0 +1,25 @@ +#!/usr/local/bin/perl +# Save a manually edited allowlisted GRUB 2 file. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParseMime(); +&error_setup($text{'manual_err'}); +&grub2_assert_acl('manual'); + +# Re-check the allowlist on save; the form value is not trusted. +my $file = $in{'file'} || ''; +&grub2_manual_file($file) || &error($text{'manual_efile'}); +$in{'data'} = '' if (!defined($in{'data'})); +$in{'data'} =~ s/\r//g; + +# Each file type gets its own validator before the locked write happens. +my $err = &save_manual_grub_file($file, $in{'data'}); +&error(&text('manual_evalidate', $err)) if ($err); +&grub2_mark_regenerate_needed(); +&webmin_log("manual", undef, $file); +&redirect(""); diff --git a/grub2/save_security.cgi b/grub2/save_security.cgi new file mode 100755 index 000000000..c1d0dc0b8 --- /dev/null +++ b/grub2/save_security.cgi @@ -0,0 +1,36 @@ +#!/usr/local/bin/perl +# Save GRUB 2 password protection settings. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'security_err'}); +&grub2_assert_acl('security'); + +# The enable flag is deliberately boolean; all other fields normalize below. +defined($in{'enabled'}) && $in{'enabled'} =~ /^[01]\z/ || + &error($text{'security_err'}); +foreach my $field (qw(user password password2 hash)) { + $in{$field} = "" if (!defined($in{$field})); + } +$in{'hash'} =~ s/^\s+|\s+\z//g; + +# Read current state so disabling an existing script still triggers regenerate. +my $current = &grub2_read_security_config(); +my $err = &grub2_save_security_config({ + 'enabled' => $in{'enabled'}, + 'user' => $in{'user'}, + 'password' => $in{'password'}, + 'password2' => $in{'password2'}, + 'hash' => $in{'hash'}, +}); +&error($err) if ($err); +# A changed password script is included by grub-mkconfig, so refresh the menu. +&grub2_mark_regenerate_needed() + if ($in{'enabled'} || $current->{'exists'}); +&webmin_log("security", undef, $in{'enabled'} ? "enabled" : "disabled"); +&redirect("index.cgi"); diff --git a/grub2/save_theme.cgi b/grub2/save_theme.cgi new file mode 100755 index 000000000..aa867ec48 --- /dev/null +++ b/grub2/save_theme.cgi @@ -0,0 +1,119 @@ +#!/usr/local/bin/perl +# Save GRUB 2 theme and appearance settings. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'theme_err'}); +&grub2_assert_acl('edit'); + +my $parsed = &read_grub_defaults(); +my $current_values = $parsed->{'values'}; +my %updates; + +# Terminal output is constrained because themes require graphical output. +my %terminal_outputs = map { $_ => 1 } + ('', 'console', 'gfxterm', 'gfxterm console', 'serial'); +$in{'terminal_output'} = '' if (!defined($in{'terminal_output'})); +&error($text{'defaults_eterminal_output'}) + if (!$terminal_outputs{$in{'terminal_output'}}); +$updates{'GRUB_TERMINAL_OUTPUT'} = + $in{'terminal_output'} eq '' ? undef : $in{'terminal_output'}; + +# Graphics mode is a small GRUB grammar, not a general shell value. +$in{'gfxmode'} = '' if (!defined($in{'gfxmode'})); +my $gerr = &grub2_validate_gfxmode($in{'gfxmode'}); +&error($gerr) if ($gerr); +$updates{'GRUB_GFXMODE'} = $in{'gfxmode'} eq '' ? undef : $in{'gfxmode'}; + +# Theme mode decides whether a source is installed, kept, or explicitly unset. +my %theme_modes = map { $_ => 1 } qw(keep install clear); +$in{'theme_mode'} = 'keep' if (!defined($in{'theme_mode'})); +&error($text{'defaults_etheme_mode'}) if (!$theme_modes{$in{'theme_mode'}}); +if ($in{'theme_mode'} eq 'install') { + # Refuse a graphical theme if the pending terminal output is console-only. + my $requested_terminal_output = + defined($updates{'GRUB_TERMINAL_OUTPUT'}) ? + $updates{'GRUB_TERMINAL_OUTPUT'} : + $current_values->{'GRUB_TERMINAL_OUTPUT'}; + if (defined($requested_terminal_output) && + $requested_terminal_output eq 'console') { + &error($text{'defaults_etheme_terminal'}); + } + my ($theme, $terr) = &grub2_install_theme_source($in{'theme_source'}); + &error($terr) if ($terr); + $updates{'GRUB_THEME'} = $theme; + } +elsif ($in{'theme_mode'} eq 'clear') { + $updates{'GRUB_THEME'} = undef; + } + +# Background images are copied below the configured GRUB boot tree. +$in{'background'} = '' if (!defined($in{'background'})); +if ($in{'background'} eq '') { + $updates{'GRUB_BACKGROUND'} = undef; + } +else { + my ($background, $berr) = + &grub2_install_background_source($in{'background'}); + &error($berr) if ($berr); + $updates{'GRUB_BACKGROUND'} = $background; + } + +# Color fields use a mode selector so the default/unset state stays explicit. +foreach my $field ( + [ 'color_normal', 'GRUB_COLOR_NORMAL', + $text{'defaults_color_normal'} ], + [ 'color_highlight', 'GRUB_COLOR_HIGHLIGHT', + $text{'defaults_color_highlight'} ], + ) +{ + my ($input, $key, $label) = @$field; + my $mode = $in{$input.'_mode'} || 'default'; + my $fg = $in{$input.'_fg'} || ''; + my $bg = $in{$input.'_bg'} || ''; + my %colors = map { $_ => 1 } &grub2_color_names(); + if ($mode eq 'default') { + $updates{$key} = undef; + } + elsif ($mode eq 'set' && $colors{$fg} && $colors{$bg}) { + $updates{$key} = $fg.'/'.$bg; + } + else { + &error(&text('defaults_ecolor', $label)); + } + } + +my $theme = exists($updates{'GRUB_THEME'}) ? + $updates{'GRUB_THEME'} : $current_values->{'GRUB_THEME'}; +my $terminal_output = exists($updates{'GRUB_TERMINAL_OUTPUT'}) ? + $updates{'GRUB_TERMINAL_OUTPUT'} : + $current_values->{'GRUB_TERMINAL_OUTPUT'}; +# Check the final combined state too, because either field may be unchanged. +if (defined($theme) && $theme ne '' && + defined($terminal_output) && $terminal_output eq 'console') { + &error($text{'defaults_etheme_terminal'}); + } + +# Keep the generator script while any color override exists or used to exist. +my $need_color_script = + (defined($updates{'GRUB_COLOR_NORMAL'}) && + $updates{'GRUB_COLOR_NORMAL'} ne '') || + (defined($updates{'GRUB_COLOR_HIGHLIGHT'}) && + $updates{'GRUB_COLOR_HIGHLIGHT'} ne '') || + -e &grub2_color_file(); + +my $err = &save_grub_defaults_values(\%updates); +&error(&text('manual_evalidate', $err)) if ($err); +if ($need_color_script) { + # The color script reads /etc/default/grub at generation time. + $err = &grub2_save_color_script(); + &error(&text('manual_evalidate', $err)) if ($err); + } +&grub2_mark_regenerate_needed(); +&webmin_log("theme"); +&redirect("index.cgi"); diff --git a/grub2/set_default.cgi b/grub2/set_default.cgi new file mode 100755 index 000000000..2ce6de4d9 --- /dev/null +++ b/grub2/set_default.cgi @@ -0,0 +1,22 @@ +#!/usr/local/bin/perl +# Set the saved GRUB 2 default boot entry. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%in, %text); + +&ReadParse(); +&error_setup($text{'runtime_err'}); +&grub2_assert_acl('runtime'); + +# Runtime commands operate on the parsed generated menu index shown on index.cgi. +my $entry = &grub2_entry_by_index($in{'idx'}); +&error($text{'runtime_eentry'}) if (!$entry); +# The helper validates the selector before invoking grub-set-default. +my $err = &grub2_run_entry_command('set_default_cmd', $entry); +&error($err) if ($err); +my $selector = &grub2_entry_selector($entry); +&webmin_log("default", undef, $selector); +&redirect(""); diff --git a/grub2/status.cgi b/grub2/status.cgi new file mode 100755 index 000000000..1ae492853 --- /dev/null +++ b/grub2/status.cgi @@ -0,0 +1,252 @@ +#!/usr/local/bin/perl +# Display GRUB 2 configuration and runtime status. + +use strict; +use warnings; +require './grub2-lib.pl'; ## no critic + +our (%text); + +&error_setup($text{'acl_ecannot'}); +&grub2_assert_acl('view'); + +my %access = &grub2_effective_acl(); +&ui_print_header(undef, $text{'status_title'}, ""); + +# Missing-install output mirrors index.cgi but keeps this page read-only. +if (!&grub2_any_installed()) { + print &ui_alert($text{'index_missing'}, 'warning'); + foreach my $issue (&grub2_install_issues()) { + print &ui_div(&text('index_missing_detail', + &ui_tag('tt', &html_escape($issue)))); + } + &ui_print_footer("index.cgi", $text{'index_return'}); + exit; + } + +foreach my $warning (&grub2_status_warnings()) { + print &ui_alert($warning, 'warning'); + } + +print &ui_div($text{'index_status_desc'}); + +# The summary uses defaults plus grubenv because GRUB stores both persistently. +my $parsed = &read_grub_defaults(); +my %env = &grub2_read_env(); + +&print_summary($parsed); +&print_boot_selection($parsed, \%env); +&print_security_status(); +&print_theme_status($parsed); + +&ui_print_footer("index.cgi", $text{'index_return'}); + +# print_summary(&parsed-defaults) +# Outputs a compact summary of important GRUB paths, commands, and defaults. +sub print_summary +{ +my ($parsed) = @_; +my $values = $parsed->{'values'}; +my @entries = &grub2_boot_entries(); +print &ui_table_start($text{'index_summary'}, "width=100%", 2); +# Start with path and command discovery so support issues are visible first. +print &status_table_row($text{'index_boot_mode'}, "boot_mode", + &boot_mode_cell()); +print &status_table_row($text{'index_secure_boot'}, "secure_boot", + &secure_boot_cell()); +print &status_table_row($text{'index_default_file'}, "default_file", + &path_cell(&grub2_config_value('default_file'))); +print &status_table_row($text{'index_grub_cfg'}, "grub_cfg", + &path_cell(&grub2_config_value('grub_cfg'))); +print &status_table_row($text{'index_grub_dir'}, "grub_dir", + &path_cell(&grub2_config_value('grub_dir'))); +print &status_table_row($text{'index_bls_dir'}, "bls_dir", + &path_cell(&grub2_config_value('bls_dir'))); +print &status_table_row($text{'index_mkconfig'}, "mkconfig", + &command_cell('mkconfig_cmd')); +print &status_table_row($text{'index_install_cmd'}, "install_cmd", + &command_cell('install_cmd')); +print &ui_table_hr(); +# Defaults below the separator mirror the editable defaults page. +print &status_table_row($text{'index_entries'}, "entries", + &text('index_entry_count', scalar(@entries))); +print &status_table_row($text{'index_kernel_options_source'}, + "kernelopts_source", + &value_cell(&grub2_kernel_options_source_text(\@entries))); +foreach my $pair ( + [ 'GRUB_TIMEOUT_STYLE', $text{'defaults_timeout_style'}, "timeout_style" ], + [ 'GRUB_TIMEOUT', $text{'defaults_timeout'}, "timeout" ], + [ 'GRUB_CMDLINE_LINUX_DEFAULT', $text{'defaults_cmdline_default'}, + "cmdline_default" ], + [ 'GRUB_CMDLINE_LINUX', $text{'defaults_cmdline'}, "cmdline" ], + [ 'GRUB_DISABLE_RECOVERY', $text{'defaults_disable_recovery'}, + "disable_recovery" ], + [ 'GRUB_DISABLE_OS_PROBER', $text{'defaults_disable_os_prober'}, + "disable_os_prober" ], + ) +{ + my ($key, $label, $help) = @$pair; + print &status_table_row($label, $help, &literal_cell($values->{$key})); + } +print &ui_table_end(); +} + +# print_theme_status(&parsed-defaults) +# Outputs theme and graphical menu appearance settings. +sub print_theme_status +{ +my ($parsed) = @_; +my $values = $parsed->{'values'}; +print &ui_hidden_table_start($text{'defaults_theme_header'}, "width=100%", 2, + "theme", 0); +foreach my $pair ( + [ 'GRUB_TERMINAL_OUTPUT', $text{'defaults_terminal_output'}, + "terminal_output" ], + [ 'GRUB_GFXMODE', $text{'defaults_gfxmode'}, "gfxmode" ], + [ 'GRUB_THEME', $text{'defaults_theme'}, "theme" ], + [ 'GRUB_BACKGROUND', $text{'defaults_background'}, "background" ], + [ 'GRUB_COLOR_NORMAL', $text{'defaults_color_normal'}, "color_normal" ], + [ 'GRUB_COLOR_HIGHLIGHT', $text{'defaults_color_highlight'}, + "color_highlight" ], + ) +{ + my ($key, $label, $help) = @$pair; + print &status_table_row($label, $help, &literal_cell($values->{$key})); + } +print &ui_hidden_table_end("theme"); +} + +# print_boot_selection(&parsed-defaults, &env) +# Outputs saved and one-time boot selection state. +sub print_boot_selection +{ +my ($parsed, $env) = @_; +print &ui_hidden_table_start($text{'index_boot_selection'}, "width=100%", 2, + "boot_selection", 0); +print &status_table_row($text{'defaults_default'}, "default", + &literal_cell($parsed->{'values'}->{'GRUB_DEFAULT'})); +print &status_table_row($text{'index_saved_entry'}, "saved_entry", + &literal_cell($env->{'saved_entry'})); +print &status_table_row($text{'index_next_entry'}, "next_entry", + &literal_cell($env->{'next_entry'})); +print &status_table_row($text{'index_env'}, "grubenv", + &path_cell(&grub2_config_value('grubenv_file'))); +print &ui_hidden_table_end("boot_selection"); +} + +# print_security_status() +# Outputs Webmin-managed GRUB password protection state. +sub print_security_status +{ +my $state = &grub2_read_security_config(); +print &ui_alert($text{'security_unmanaged'}, 'warning') + if ($state->{'exists'} && !$state->{'managed'}); +print &ui_hidden_table_start($text{'security_header'}, "width=100%", 2, + "security", 0); +# Password hash contents are never displayed, only whether one is configured. +print &status_table_row($text{'index_security_state'}, "security_current", + &security_state_cell($state)); +print &status_table_row($text{'index_security_user'}, "security_user", + $state->{'enabled'} ? &html_escape($state->{'user'}) : + $text{'index_not_set'}); +print &status_table_row($text{'index_security_hash'}, "security_hash", + $state->{'hash'} ? $text{'index_security_hash_set'} : + $text{'index_security_hash_missing'}); +print &status_table_row($text{'index_security_file'}, "security_file", + &path_cell($state->{'file'})); +print &status_table_row($text{'index_security_mkpasswd'}, "security_mkpasswd", + &command_cell('mkpasswd_cmd')); +print &ui_hidden_table_end("security"); +} + +# status_table_row(label, help, value) +# Returns a standard status table row with contextual help on the label. +sub status_table_row +{ +my ($label, $help, $value) = @_; +return &ui_table_row(&hlink($label, $help), $value); +} + +# path_cell(path) +# Returns escaped path display HTML with missing-state text. +sub path_cell +{ +my ($path) = @_; +return $text{'index_not_set'} if (!defined($path) || $path eq ''); +my $html = &manual_path_link($path, &ui_tag('tt', &html_escape($path))); +return -e $path ? $html : $html.' '.$text{'index_missing_file'}; +} + +# command_cell(config-key) +# Returns escaped command display HTML with availability state. +sub command_cell +{ +my ($key) = @_; +my $cmd = &grub2_command($key); +return &ui_tag('tt', &html_escape($cmd)) if ($cmd); +my $raw = &grub2_config_value($key); +return $text{'index_not_set'} if (!defined($raw) || $raw eq ''); +return &ui_tag('tt', &html_escape($raw)).' '.$text{'index_not_readable'}; +} + +# manual_path_link(path, html) +# Links editable GRUB files to the manual editor when permitted. +sub manual_path_link +{ +my ($path, $html) = @_; +return $html if (!&grub2_check_acl('manual') || !&grub2_manual_file($path)); +# Link only allowlisted paths; generated grub.cfg remains informational. +return &ui_tag('a', $html, { + 'href' => "edit_manual.cgi?file=".&urlize($path), + }); +} + +# security_state_cell(&state) +# Returns text for the password protection status. +sub security_state_cell +{ +my ($state) = @_; +return $text{'index_security_unmanaged'} + if ($state->{'exists'} && !$state->{'managed'}); +return $state->{'enabled'} ? $text{'index_security_enabled'} : + $text{'index_security_disabled'}; +} + +# boot_mode_cell() +# Returns the detected firmware boot mode for display. +sub boot_mode_cell +{ +my $mode = &grub2_boot_mode(); +return $text{'index_boot_mode_uefi'} if ($mode eq 'uefi'); +return $text{'index_boot_mode_bios'}; +} + +# secure_boot_cell() +# Returns the detected Secure Boot state for display. +sub secure_boot_cell +{ +my $state = &grub2_secure_boot_status(); +return $text{'index_secure_boot_'.$state} || $text{'index_secure_boot_unknown'}; +} + +# value_cell(value) +# Returns escaped value display HTML with unset-state text. +sub value_cell +{ +my ($value) = @_; +return $text{'index_not_set'} if (!defined($value) || $value eq ''); +return $text{'defaults_true'} if ($value eq 'true'); +return $text{'defaults_false'} if ($value eq 'false'); +return &html_escape($value); +} + +# literal_cell(value) +# Returns an escaped literal GRUB value with unset and boolean mapping. +sub literal_cell +{ +my ($value) = @_; +return $text{'index_not_set'} if (!defined($value) || $value eq ''); +return $text{'defaults_true'} if ($value eq 'true'); +return $text{'defaults_false'} if ($value eq 'false'); +return &ui_tag('tt', &html_escape($value)); +} diff --git a/grub2/t/perlcritic.t b/grub2/t/perlcritic.t new file mode 100644 index 000000000..cf1ea05a0 --- /dev/null +++ b/grub2/t/perlcritic.t @@ -0,0 +1,68 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; + +BEGIN { + eval { require Perl::Critic; 1 } + or plan skip_all => 'Perl::Critic not installed'; +} + +use File::Find; + +# script_dir() +# Returns the directory containing this test file, without relying on Cwd. +sub script_dir +{ + my $path = $0; + if ($path =~ m{^/}) { + $path =~ s{/[^/]+$}{}; + return $path; + } + my $cwd = `pwd`; + chomp($cwd); + if ($path =~ m{/}) { + $path =~ s{/[^/]+$}{}; + return $cwd.'/'.$path; + } + return $cwd; +} + +my $bindir = script_dir(); +my $module_dir = "$bindir/.."; +my $profile = "$bindir/../../.perlcriticrc"; +if (!-r $profile) { + plan skip_all => 'Perl::Critic profile not installed'; +} +chdir($module_dir) or die "chdir: $!"; + +my @files; +find( + sub { + return if -d; + return if -l; + return unless /\.(pl|cgi)\z/; + return if /\.info\.pl\z/; + push(@files, $File::Find::name); + }, + '.' +); + +@files = sort @files; +if (!@files) { + plan skip_all => 'no perl files to check'; +} + +my $critic = Perl::Critic->new( + -profile => $profile, +); + +foreach my $file (@files) { + my @violations = $critic->critique($file); + is(scalar @violations, 0, "$file perlcritic"); + if (@violations) { + diag join("", @violations); + } +} + +done_testing(); diff --git a/grub2/t/run-tests.t b/grub2/t/run-tests.t new file mode 100644 index 000000000..e23866538 --- /dev/null +++ b/grub2/t/run-tests.t @@ -0,0 +1,1422 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use Cwd qw(abs_path); +use File::Path qw(make_path); +use File::Temp qw(tempdir); + +# script_dir() +# Returns the directory containing this test file, without relying on Cwd. +sub script_dir +{ + my $path = $0; + if ($path =~ m{^/}) { + $path =~ s{/[^/]+$}{}; + return $path; + } + my $cwd = `pwd`; + chomp($cwd); + if ($path =~ m{/}) { + $path =~ s{/[^/]+$}{}; + return $cwd.'/'.$path; + } + return $cwd; +} + +# write_test_file(file, data) +# Writes fixture content atomically enough for local test setup. +sub write_test_file +{ + my ($file, $data) = @_; + open(my $fh, '>', $file) or die "$file: $!"; + print $fh $data; + close($fh); +} + +# slurp_test_file(file) +# Reads a source or fixture file as one scalar for regex-based checks. +sub slurp_test_file +{ + my ($file) = @_; + open(my $fh, '<', $file) or die "$file: $!"; + local $/ = undef; + my $data = <$fh>; + close($fh); + return $data; +} + +# make_script(file, data) +# Writes an executable shell fixture used as a fake GRUB helper. +sub make_script +{ + my ($file, $data) = @_; + write_test_file($file, $data); + chmod 0755, $file or die "chmod $file: $!"; +} + +my $bindir = script_dir(); +my $rootdir = abs_path("$bindir/../..") or die "rootdir: $!"; + +my $confdir = tempdir(CLEANUP => 1); +my $vardir = tempdir(CLEANUP => 1); +write_test_file("$confdir/config", "os_type=linux\nos_version=0\n"); +write_test_file("$confdir/var-path", "$vardir\n"); +$ENV{'WEBMIN_CONFIG'} = $confdir; +$ENV{'WEBMIN_VAR'} = $vardir; +$ENV{'FOREIGN_MODULE_NAME'} = 'grub2'; +$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir; + +chdir("$bindir/..") or die "chdir: $!"; +require "$bindir/../grub2-lib.pl"; +our (%config, %text, $grub2_config_change_flag, $grub2_generate_time_flag); + +my $work = tempdir(CLEANUP => 1); +my $default_file = "$work/default-grub"; +my $custom_file = "$work/40_custom"; +my $cfg_file = "$work/grub.cfg"; +my $env_file = "$work/grubenv"; +my $bls_dir = "$work/loader-entries"; +my $password_file = "$work/grub.d/01_webmin_password"; +my $color_file = "$work/grub.d/06_webmin_colors"; +my $theme_dir = "$work/boot/grub2/themes"; +my $background_dir = "$work/boot/grub2/backgrounds"; +my $os_prober_file = "$work/grub.d/30_os-prober"; +my $readme_file = "$work/grub.d/README"; +my $bls_entry_file = "$bls_dir/rocky-5.14.0.conf"; + +$config{'default_file'} = $default_file; +$config{'custom_file'} = $custom_file; +$config{'password_file'} = $password_file; +$config{'color_file'} = $color_file; +$config{'theme_dir'} = $theme_dir; +$config{'background_dir'} = $background_dir; +$config{'grub_cfg'} = $cfg_file; +$config{'grub_dir'} = "$work/grub.d"; +$config{'grubenv_file'} = $env_file; +$config{'bls_dir'} = $bls_dir; +$config{'script_check_cmd'} = ''; +$config{'grubby_cmd'} = ''; +$config{'shell_cmd'} = '/bin/sh'; +mkdir $config{'grub_dir'} or die "mkdir grub.d: $!"; +mkdir $bls_dir or die "mkdir bls_dir: $!"; + +{ + my %acl = grub2_effective_acl({ view => 1, edit => 0 }); + ok($acl{'view'}, 'view ACL is enabled'); + ok(!$acl{'edit'}, 'edit ACL is disabled'); + ok(!grub2_can_enter_module({ map { $_ => 0 } grub2_acl_keys() }), + 'empty ACL cannot enter module'); +} + +ok(!grub2_needs_regenerate(), 'fresh module does not need regeneration'); +grub2_mark_regenerate_needed(); +ok(grub2_needs_regenerate(), 'config change flag requires regeneration'); +grub2_mark_generated(); +ok(!grub2_needs_regenerate(), 'generate flag clears regeneration need'); +utime(time() + 2, time() + 2, $grub2_config_change_flag); +ok(grub2_needs_regenerate(), 'newer config flag requires regeneration again'); +utime(time() + 3, time() + 3, $grub2_generate_time_flag); +ok(!grub2_needs_regenerate(), 'newer generate flag clears stale change'); + +my $version_cmd = "$work/grub-version"; +make_script($version_cmd, "#!/bin/sh\necho 'grub2-mkconfig (GRUB) 2.06'\n"); +{ + local $config{'install_cmd'} = ''; + local $config{'mkconfig_cmd'} = $version_cmd; + is(grub2_version_text(), 'GRUB version 2.06', + 'GRUB version text is parsed for page headers'); +} + +my $efi_firmware_dir = "$work/firmware-efi"; +make_path($efi_firmware_dir); +is(grub2_boot_mode($efi_firmware_dir), 'uefi', + 'boot mode detection reports UEFI when EFI firmware directory exists'); +is(grub2_boot_mode("$work/no-efi-firmware"), 'bios', + 'boot mode detection reports BIOS when EFI firmware directory is absent'); +my $efivars_dir = "$efi_firmware_dir/efivars"; +make_path($efivars_dir); +write_test_file("$efivars_dir/SecureBoot-webmin-test", + "\0\0\0\0\1"); +is(grub2_secure_boot_status($efi_firmware_dir, $efivars_dir, ''), + 'enabled', 'Secure Boot detection reads enabled EFI variable'); +write_test_file("$efivars_dir/SecureBoot-webmin-test", + "\0\0\0\0\0"); +is(grub2_secure_boot_status($efi_firmware_dir, $efivars_dir, ''), + 'disabled', 'Secure Boot detection reads disabled EFI variable'); +is(grub2_secure_boot_status("$work/no-efi-firmware", "$work/no-efivars", ''), + 'not_applicable', 'Secure Boot detection is not applicable for BIOS boot'); + +my $index_source = slurp_test_file("$bindir/../index.cgi"); +my $defaults_source = slurp_test_file("$bindir/../edit_defaults.cgi"); +my $save_defaults_source = slurp_test_file("$bindir/../save_defaults.cgi"); +my $theme_source = slurp_test_file("$bindir/../edit_theme.cgi"); +my $save_theme_source = slurp_test_file("$bindir/../save_theme.cgi"); +my $edit_install_source = slurp_test_file("$bindir/../edit_install.cgi"); +unlike($index_source, qr/\[\s*'security'\s*,/, + 'index has no separate security tab'); +unlike($index_source, qr/\[\s*'defaults'\s*,/, + 'index has no separate defaults tab'); +unlike($index_source, qr/\[\s*'status'\s*,/, + 'index has no separate status tab'); +unlike($index_source, qr/sub print_defaults_tab\b/, + 'index has no separate default settings panel'); +unlike($index_source, qr/sub print_status_tab\b|sub print_summary\b/, + 'index does not carry status-page rendering code'); +my $status_source = slurp_test_file("$bindir/../status.cgi"); +like($index_source, + qr/ui_print_header\(&grub2_version_text\(\) \|\| "".*?\$text\{'index_title'\}/s, + 'index header displays GRUB version text when available'); +like($index_source, + qr/sub print_action_buttons\b.*?ui_buttons_row\("status\.cgi".*?ui_buttons_row\("generate\.cgi"/ms, + 'index exposes status above the bottom regenerate action'); +like($status_source, qr/grub2_assert_acl\('view'\)/, + 'status page enforces view ACL directly'); +like($status_source, qr/index_boot_mode.*boot_mode_cell/s, + 'status page displays detected boot mode'); +like($status_source, qr/index_secure_boot.*secure_boot_cell/s, + 'status page displays Secure Boot status'); +like($status_source, + qr/&print_summary\(\$parsed\);.*?&print_boot_selection\(\$parsed, \\%env\);.*?&print_security_status\(\);.*?&print_theme_status\(\$parsed\);/ms, + 'status page renders summary, boot selection, password protection, and theme state'); +like($status_source, + qr/sub print_summary\b.*?GRUB_TIMEOUT_STYLE.*?GRUB_DISABLE_OS_PROBER.*?^}/ms, + 'configuration summary includes common default settings'); +like($status_source, + qr/sub print_summary\b.*?ui_table_start\(\$text\{'index_summary'\}.*?ui_table_end\(\)/s, + 'status page keeps configuration summary as a normal table'); +like($status_source, + qr/sub print_theme_status\b.*?ui_hidden_table_start\(\$text\{'defaults_theme_header'\}.*?"theme", 0.*?ui_hidden_table_end\("theme"\)/s, + 'status page puts theme status in a closed collapsible table'); +like($status_source, + qr/sub print_boot_selection\b.*?ui_hidden_table_start\(\$text\{'index_boot_selection'\}.*?"boot_selection", 0.*?ui_hidden_table_end\("boot_selection"\)/s, + 'status page puts boot selection in a closed collapsible table'); +like($status_source, + qr/sub print_security_status\b.*?ui_hidden_table_start\(\$text\{'security_header'\}.*?"security", 0.*?ui_hidden_table_end\("security"\)/s, + 'status page puts password protection in a closed collapsible table'); +like($status_source, + qr/index_install_cmd'\}.*?&ui_table_hr\(\);.*?index_entries'\}/ms, + 'configuration summary separates paths and commands from defaults'); +like($status_source, qr/index_kernel_options_source.*grub2_kernel_options_source_text/s, + 'status page displays detected kernel options source'); +like($status_source, + qr/sub value_cell\b.*defaults_true.*defaults_false/s, + 'status page displays GRUB boolean values as yes or no'); +like($status_source, + qr/sub literal_cell\b.*ui_tag\('tt', &html_escape\(\$value\)\)/s, + 'status page renders literal GRUB values with tt tags'); +like($status_source, + qr/status_table_row\(\$text\{'defaults_default'\}, "default",\s*&literal_cell/s, + 'status page renders the default menu entry as a literal value'); +like($status_source, + qr/status_table_row\(\$text\{'index_saved_entry'\}, "saved_entry",\s*&literal_cell/s, + 'status page renders the environment default entry as a literal value'); +like($status_source, + qr/status_table_row\(\$text\{'index_next_entry'\}, "next_entry",\s*&literal_cell/s, + 'status page renders the next boot entry as a literal value'); +unlike($index_source.$status_source.$theme_source.$edit_install_source, + qr/ui_tag\('code'/, + 'GRUB pages use tt tags instead of code tags for literal values'); +like($status_source, + qr/sub path_cell\b.*manual_path_link\(\$path, &ui_tag\('tt'/s, + 'status page renders paths with tt tags'); +like($status_source, + qr/sub manual_path_link\b.*grub2_check_acl\('manual'\).*grub2_manual_file\(\$path\).*edit_manual\.cgi\?file=/s, + 'status page links allowlisted paths to the manual editor when permitted'); +like($status_source, qr/sub status_table_row\b.*hlink\(\$label, \$help\)/s, + 'status page labels use contextual help links'); +like($status_source, + qr/status_table_row\(\$text\{'index_boot_mode'\}, "boot_mode".*status_table_row\(\$text\{'index_secure_boot'\}, "secure_boot"/s, + 'status page links firmware state rows to help'); +like($status_source, + qr/status_table_row\(\$text\{'index_default_file'\}, "default_file".*status_table_row\(\$text\{'index_grub_cfg'\}, "grub_cfg"/s, + 'status page links GRUB path rows to help'); +like($status_source, + qr/\[ 'GRUB_TIMEOUT_STYLE', \$text\{'defaults_timeout_style'\}, "timeout_style" \]/, + 'status page reuses default-setting help files'); +foreach my $help ( + "boot_mode", "secure_boot", "default_file", "grub_cfg", "grub_dir", + "bls_dir", "mkconfig", "install_cmd", "entries", "saved_entry", + "next_entry", "grubenv", "security_file", "security_mkpasswd", + ) +{ + ok(-r "$bindir/../help/$help.html", "status help file exists for $help"); + } +like($defaults_source, + qr/sub default_entry_input\b.*?ui_select\("default"/ms, + 'defaults editor uses a generated default-entry selector'); +like($defaults_source, + qr/ui_table_row\(\s*&hlink\(\$text\{'defaults_default'\}, "default"\),\s*&default_entry_input/s, + 'defaults editor renders default-entry selector as a standard table row'); +unlike($defaults_source, qr/ui_table_span\(&default_entry_row/, + 'defaults editor does not render default-entry selector as a span row'); +like($defaults_source, qr/field-sizing:\s*content/, + 'defaults editor sizes default-entry selector to selected content'); +unlike($defaults_source, qr/default_custom|defaults_default_custom|__custom__/, + 'defaults editor does not expose a free-form default entry value'); +unlike($defaults_source, qr/defaults_saved_note/, + 'defaults editor keeps saved-entry explanation in help'); +unlike($defaults_source, qr/default_bls_update_input|update_bls/, + 'defaults editor does not expose a BLS update checkbox'); +like($defaults_source, qr/grub2_bls_kernel_option_warnings/, + 'defaults editor warns when BLS kernel option sources need attention'); +like($save_defaults_source, qr/grub2_update_bls_kernel_args/s, + 'defaults save can apply changed all-kernel options to BLS entries'); +like($save_defaults_source, + qr/GRUB_CMDLINE_LINUX_DEFAULT.*?grub2_bls_kernel_arg_targets/s, + 'defaults save can apply changed default-kernel options to BLS entries'); +like($save_defaults_source, + qr/lock_bls_update_files.*?update_bls_kernel_args.*?webmin_log\("defaults"\)/s, + 'defaults save logs BLS updates with the defaults action'); +like($save_defaults_source, + qr/sub lock_bls_update_files\b.*?grub2_entry_uses_kernelopts.*?grubenv_file/ms, + 'defaults save logs grubenv when BLS kernelopts may change'); +like($save_defaults_source, qr/grub2_set_bls_rescue_disabled/s, + 'defaults save can hide BLS rescue entries'); +unlike($save_defaults_source, qr/webmin_log\("bls_args"\)/, + 'defaults save does not create a separate BLS log entry'); +like($save_defaults_source, qr/sub valid_default_entry_value\b.*?grub2_entry_selector/ms, + 'defaults save validates posted default entries against detected entries'); +unlike($index_source, qr/entries_action\.cgi/, + 'generated menu uses per-row actions instead of a checked table'); +like($index_source, qr/set_default\.cgi\?idx=/, + 'generated menu exposes per-row default action'); +like($index_source, qr/reboot_once\.cgi\?idx=/, + 'generated menu exposes per-row one-time boot action'); +unlike($index_source, qr/ui_alert\(\$text\{'custom_empty'\}/, + 'empty custom entries use inline text instead of an alert'); +unlike($index_source, qr/custom_add_msg/, + 'empty custom entries use a standalone add button'); +like($index_source, + qr/if \(!\@entries\).*ui_br\(\).*ui_p\(\$text\{'custom_empty'\}\).*ui_link\("edit_custom\.cgi", \$text\{'custom_add'\},\s*"plus"\)/s, + 'empty custom entries add a break before the message and use a compact add link'); +unlike($index_source, + qr/if \(!\@entries\).*ui_buttons_row\("edit_custom\.cgi"/s, + 'empty custom entries do not use large button rows'); +like($index_source, + qr/my \$show_order = \$can_edit && \@entries > 1;.*\(\$show_order \? \( \$text\{'index_col_order'\} \) : \( \)\).*custom_order_cell/s, + 'custom entries hide the order column until multiple entries can be reordered'); +like($index_source, + qr/sub entry_details_content\b.*index_col_index.*index_col_id.*entry_source_detail_line.*index_col_version.*index_col_kernel.*index_col_initrd.*index_col_machine_id.*index_col_options.*^}/ms, + 'entry details include useful generated entry metadata'); +like($index_source, + qr/sub entry_source_detail_line\b.*index_col_file.*index_col_generator.*ui_tag\('a'.*edit_manual\.cgi\?file=/ms, + 'entry details label generator scripts and link direct entry files to the manual editor'); +like($index_source, + qr/sub entry_source_detail_line\b.*else \{\s*\$html = &ui_tag\('tt', &html_escape\(\$file\)\).*?grub2_check_acl\('manual'\).*?edit_manual\.cgi\?file=/s, + 'entry details link editable generator scripts to the manual editor'); +like($index_source, + qr/sub entry_source_detail_line\b.*entry_file_display_name.*'title'\s*=>\s*\$file.*edit_manual\.cgi\?file=/s, + 'entry details display short direct file names while preserving full paths'); +like($index_source, + qr!sub entry_file_display_name\b.*s\{\.\*/\}\{\}!s, + 'entry file display names strip leading directories'); +like($index_source, + qr/sub entry_detail_line\b.*white-space: pre-wrap.*grid-template-columns: max-content minmax\(0, 1fr\)/s, + 'entry detail values wrap with hanging indentation'); +unlike($index_source, + qr/entry_detail_line\(\$text\{'index_col_group'\}/, + 'entry details do not duplicate submenu metadata'); +unlike($index_source, + qr/entry_detail_line\(\$text\{'index_col_line'\}/, + 'entry details do not include low-value line metadata'); +like($index_source, qr/edit_theme\.cgi/, + 'index exposes GRUB theme and appearance action'); +like($theme_source, qr/color_pair_select\("color_normal".*color_pair_select\("color_highlight".*\$name\."_mode"/s, + 'theme editor uses color-pair modes instead of unset per color'); +like($theme_source, qr/grub2_color_mode_changed/, + 'theme editor hides custom color controls until needed'); +like($theme_source, qr/querySelector\('select\[name="' \+ name \+ '_mode"\]'\)/, + 'theme editor finds color mode selects by name for SPA themes'); +like($theme_source, qr/document\.addEventListener\('change', function\(event\)/, + 'theme editor uses delegated color mode change handling'); +like($theme_source, qr/custom\.style\.visibility.*?visible.*?hidden/s, + 'theme editor uses hidden visibility for inactive color pairs'); +unlike($theme_source, qr/defaults_color_text'\}\)\)\." "/, + 'theme editor does not make color labels bold'); +like($theme_source, qr/sub gfxmode_select\b.*ui_select\("gfxmode".*sub gfxmode_options\b.*1920x1080/s, + 'theme editor provides a graphics mode resolution dropdown'); +like($save_theme_source, qr/\$input\.'_mode'/, + 'theme save uses the color-pair mode field'); +like($save_theme_source, qr/grub2_install_background_source/, + 'theme save installs background images below the GRUB boot tree'); +like($theme_source, qr/ui_print_footer\("index\.cgi"/, + 'theme editor returns to the module index'); +like($save_theme_source, qr/redirect\("index\.cgi"\)/, + 'theme save redirects to the module index'); +like($index_source, qr/edit_install\.cgi/, + 'index exposes GRUB boot loader install action'); +like($edit_install_source, qr/index_boot_mode.*install_boot_mode_cell/s, + 'install form displays boot mode'); +like($edit_install_source, qr/index_secure_boot.*install_secure_boot_cell/s, + 'install form displays Secure Boot state'); +like($edit_install_source, qr/grub2_default_bootloader_id/, + 'install form prefills boot loader ID when detected'); +like($edit_install_source, qr/install_boot_directory.*use_boot_directory/s, + 'install form exposes optional boot directory'); +like($index_source, + qr/sub print_action_buttons\b.*?&icons_table\(/ms, + 'index action shortcuts use an icon table'); +like($index_source, + qr/sub print_action_buttons\b.*?ui_buttons_row\("generate\.cgi"/ms, + 'index exposes a bottom regenerate action button'); +my $generate_source = slurp_test_file("$bindir/../generate.cgi"); +like($generate_source, qr/ui_details/, + 'generate progress keeps command output in details'); +like($generate_source, qr/data-second-print/, + 'generate progress keeps second progress print markers'); +unlike($generate_source, qr/ui_tag_start\('pre'/, + 'generate progress does not print a raw output block'); + +my $defaults = <<'EOF'; +# Existing administrator comment +GRUB_DEFAULT=0 # keep this comment +GRUB_TIMEOUT_STYLE=menu +GRUB_TIMEOUT=5 +GRUB_CMDLINE_LINUX_DEFAULT="quiet splash" +GRUB_DISABLE_OS_PROBER=false +EXTERNAL_SETTING="preserve me" +EOF +write_test_file($default_file, $defaults); + +my $parsed = read_grub_defaults($default_file); +is($parsed->{'values'}->{'GRUB_DEFAULT'}, '0', 'parsed default entry'); +is($parsed->{'values'}->{'GRUB_CMDLINE_LINUX_DEFAULT'}, 'quiet splash', + 'parsed quoted kernel args'); +is($parsed->{'values'}->{'EXTERNAL_SETTING'}, 'preserve me', + 'parsed unmanaged setting'); + +my $new_text = set_grub_default_values($parsed, { + GRUB_DEFAULT => 'saved', + GRUB_TIMEOUT => '10', + GRUB_TIMEOUT_STYLE => undef, + GRUB_TERMINAL_OUTPUT => 'gfxterm', + GRUB_GFXMODE => '1024x768,800x600', + GRUB_CMDLINE_LINUX_DEFAULT => 'quiet splash mitigations=off', + GRUB_CMDLINE_LINUX => 'console=ttyS0', + GRUB_DISABLE_RECOVERY => 'true', + GRUB_DISABLE_OS_PROBER => 'true', + GRUB_THEME => '/boot/grub/themes/webmin/theme.txt', + GRUB_BACKGROUND => '/boot/grub/background.png', + GRUB_COLOR_NORMAL => 'white/black', + GRUB_COLOR_HIGHLIGHT => 'black/light-gray', +}); +like($new_text, qr/# Existing administrator comment/, + 'whole-line comments are preserved'); +like($new_text, qr/GRUB_DEFAULT=saved # keep this comment/, + 'trailing assignment comment is preserved'); +unlike($new_text, qr/GRUB_TIMEOUT_STYLE=/, + 'unset setting is removed'); +like($new_text, qr/EXTERNAL_SETTING="preserve me"/, + 'unmanaged setting is preserved'); +like($new_text, qr/GRUB_CMDLINE_LINUX=console=ttyS0/, + 'missing managed setting is appended'); +like($new_text, qr/GRUB_TERMINAL_OUTPUT=gfxterm/, + 'terminal output setting is appended'); +like($new_text, qr/GRUB_GFXMODE=1024x768,800x600/, + 'graphics mode setting is appended'); +like($new_text, qr{GRUB_THEME=/boot/grub/themes/webmin/theme\.txt}, + 'theme setting is appended'); +like($new_text, qr/GRUB_COLOR_HIGHLIGHT=black\/light-gray/, + 'color setting is appended'); +unlike($new_text, qr/# Added by Webmin/, + 'structured save does not add a Webmin marker comment'); +my $kept_default = set_grub_default_values($parsed, { + GRUB_TIMEOUT => '7', +}); +like($kept_default, qr/GRUB_DEFAULT=0 # keep this comment/, + 'default entry is preserved when not updated'); +my $unset_cmdline = set_grub_default_values($parsed, { + GRUB_CMDLINE_LINUX_DEFAULT => undef, + GRUB_CMDLINE_LINUX => undef, +}); +unlike($unset_cmdline, qr/GRUB_CMDLINE_LINUX_DEFAULT=/, + 'blank default kernel args are removed'); +unlike($unset_cmdline, qr/GRUB_CMDLINE_LINUX=/, + 'blank kernel args are not appended'); +my $webmin_block = parse_grub_defaults_text(<<'EOF', $default_file); +# Added by Webmin +GRUB_COLOR_NORMAL=green/dark-gray +GRUB_COLOR_HIGHLIGHT=light-blue/yellow +EOF +my $changed_block = set_grub_default_values($webmin_block, { + GRUB_COLOR_NORMAL => undef, + GRUB_COLOR_HIGHLIGHT => undef, + GRUB_THEME => '/boot/grub2/themes/webmin/theme.txt', +}); +unlike($changed_block, qr/# Added by Webmin/, + 'managed default marker is not written when replacing appended settings'); +like($changed_block, qr/GRUB_THEME=\/boot\/grub2\/themes\/webmin\/theme\.txt/, + 'new appended setting is written without a Webmin marker'); +unlike($changed_block, qr/GRUB_COLOR_NORMAL=/, + 'removed color setting is not kept in reused Webmin block'); +my $cleared_block = set_grub_default_values($webmin_block, { + GRUB_COLOR_NORMAL => undef, + GRUB_COLOR_HIGHLIGHT => undef, +}); +unlike($cleared_block, qr/# Added by Webmin/, + 'orphan managed default marker is removed'); + +SKIP: { + skip '/bin/sh is unavailable', 2 if (!-x '/bin/sh'); + is(validate_grub_defaults_text($new_text, $default_file), undef, + 'valid defaults pass shell syntax validation'); + ok(validate_grub_defaults_text("if then\n", $default_file), + 'invalid defaults fail shell syntax validation'); +} +like(validate_grub_defaults_text($new_text, 'relative-grub'), + qr/absolute/, 'relative default file path is rejected'); +is(grub2_validate_setting_path('/boot/grub/themes/webmin/theme.txt', + $text{'defaults_theme'}), undef, + 'safe defaults path is accepted'); +like(grub2_validate_setting_path('/tmp/theme$(touch bad).txt', + $text{'defaults_theme'}), + qr/characters/, 'unsafe defaults path characters are rejected'); +my $theme_file = "$work/theme.txt"; +my $background_file = "$work/background.png"; +write_test_file($theme_file, "# theme\n"); +write_test_file($background_file, "png\n"); +is(grub2_validate_theme_path($theme_file, $text{'defaults_theme'}), undef, + 'readable theme file is accepted'); +like(grub2_validate_theme_path("$work/Marathon-TitleScreen.tar.gz", + $text{'defaults_theme'}), + qr/archive/, 'theme archive is rejected'); +like(grub2_validate_theme_path("$work/missing-theme.txt", + $text{'defaults_theme'}), + qr/does not exist|cannot be read/, 'missing theme file is rejected'); +is(grub2_validate_background_path($background_file, + $text{'defaults_background'}), undef, + 'readable background file is accepted'); +like(grub2_validate_background_path("$work/missing-background.png", + $text{'defaults_background'}), + qr/does not exist|cannot be read/, 'missing background file is rejected'); +write_test_file("$work/background.txt", "not an image\n"); +like(grub2_validate_background_path("$work/background.txt", + $text{'defaults_background'}), + qr/PNG|JPEG|TGA/, 'unsupported background image type is rejected'); +my ($installed_background, $background_err) = + grub2_install_background_source($background_file); +is($background_err, undef, 'background image install succeeds'); +like($installed_background, qr{\Q$background_dir\E/background\.png\z}, + 'background image installs below configured boot background directory'); +like(slurp_test_file($installed_background), qr/png/, + 'installed background image is copied'); +my ($same_background, $same_background_err) = + grub2_install_background_source($installed_background); +is($same_background_err, undef, 'already-installed background is accepted'); +is($same_background, $installed_background, + 'already-installed background image is reused'); +my $theme_source_dir = "$work/source-theme"; +make_path("$theme_source_dir/icons"); +write_test_file("$theme_source_dir/theme.txt", "# source theme\n"); +write_test_file("$theme_source_dir/icons/logo.png", "png\n"); +my $theme_icon_link = "$theme_source_dir/icons/Manjaro.i686.svg"; +my $can_symlink = symlink("logo.png", $theme_icon_link); +my ($installed_theme, $install_err) = + grub2_install_theme_source($theme_source_dir); +is($install_err, undef, 'theme directory install succeeds'); +like($installed_theme, qr{\Q$theme_dir\E/source-theme/theme\.txt\z}, + 'theme directory installs below configured boot theme directory'); +like(slurp_test_file($installed_theme), qr/source theme/, + 'installed theme file is copied'); +like(slurp_test_file("$theme_dir/source-theme/icons/logo.png"), qr/png/, + 'installed theme assets are copied'); +SKIP: { + skip 'symlink unavailable', 3 if (!$can_symlink); + my $installed_link = "$theme_dir/source-theme/icons/Manjaro.i686.svg"; + like(slurp_test_file($installed_link), qr/png/, + 'safe theme symlink asset is copied'); + ok(!-l $installed_link, 'safe theme symlink is installed as a file'); + my $bad_theme_dir = "$work/bad-symlink-theme"; + make_path("$bad_theme_dir/icons"); + write_test_file("$bad_theme_dir/theme.txt", "# bad symlink theme\n"); + symlink($background_file, "$bad_theme_dir/icons/outside.svg") + or skip 'second symlink unavailable', 1; + my ($bad_theme, $bad_err) = grub2_install_theme_source($bad_theme_dir); + like($bad_err, qr/unsafe|unsupported/, + 'theme symlink outside source tree is rejected'); +} +my ($same_theme, $same_err) = grub2_install_theme_source($installed_theme); +is($same_err, undef, 'already-installed theme file is accepted'); +is($same_theme, $installed_theme, 'already-installed theme file is reused'); +is(grub2_theme_archive_type('https://example.test/theme.zip?download=1'), + 'zip', 'theme archive type ignores URL query string'); +is(grub2_theme_source_name('https://example.test/themes/Blue/theme.txt?raw=1'), + 'Blue', 'direct theme.txt URL uses parent directory as theme name'); +like(grub2_validate_archive_members('../bad'), qr/unsafe|unsupported/, + 'unsafe archive member is rejected'); +my $download_theme_dir = "$work/grub2-theme-download-123"; +make_path($download_theme_dir); +write_test_file("$download_theme_dir/theme.txt", "# downloaded theme\n"); +my ($download_theme, $download_err) = grub2_install_theme_directory( + $download_theme_dir, 'https://example.test/themes/Downloaded/theme.txt'); +is($download_err, undef, 'downloaded theme.txt directory install succeeds'); +like($download_theme, qr{\Q$theme_dir\E/Downloaded/theme\.txt\z}, + 'downloaded theme.txt URL names theme from URL parent directory'); +SKIP: { + my $tar = has_command('tar'); + skip 'tar is unavailable', 4 if (!$tar); + my $archive_source_dir = "$work/source-theme-archive"; + make_path("$archive_source_dir/icons"); + write_test_file("$archive_source_dir/theme.txt", "# source theme\n"); + write_test_file("$archive_source_dir/icons/logo.png", "png\n"); + my $archive = "$work/source-theme.tar.gz"; + my $cmd = quotemeta($tar).' czf '.quotemeta($archive). + ' -C '.quotemeta($work).' source-theme-archive'; + system($cmd) == 0 or skip 'tar archive creation failed', 4; + my ($archive_theme, $archive_err) = grub2_install_theme_source($archive); + is($archive_err, undef, 'theme tar archive install succeeds'); + like($archive_theme, qr{\Q$theme_dir\E/source-theme-archive/theme\.txt\z}, + 'theme tar archive installs to a unique boot theme directory'); + like(slurp_test_file($archive_theme), qr/source theme/, + 'theme tar archive content is installed'); + my $bad_archive_dir = "$work/bad-archive-theme"; + make_path($bad_archive_dir); + write_test_file("$bad_archive_dir/theme.txt", "# bad archive theme\n"); + symlink('theme.txt', "$bad_archive_dir/link.txt") + or skip 'archive symlink unavailable', 1; + my $bad_archive = "$work/bad-theme.tar.gz"; + my $bad_cmd = quotemeta($tar).' czf '.quotemeta($bad_archive). + ' -C '.quotemeta($work).' bad-archive-theme'; + system($bad_cmd) == 0 or skip 'bad tar archive creation failed', 1; + my ($bad_archive_extract, $bad_archive_err) = + grub2_extract_theme_archive($bad_archive, 'targz'); + remove_tree($bad_archive_extract) if ($bad_archive_extract); + like($bad_archive_err, qr/unsafe|unsupported/, + 'theme archives reject symlink members before extraction'); +} + +my $save_err = save_grub_defaults_values({ + GRUB_DEFAULT => 'saved', + GRUB_TIMEOUT => '3', + GRUB_TIMEOUT_STYLE => 'countdown', + GRUB_TERMINAL_OUTPUT => 'gfxterm', + GRUB_GFXMODE => 'auto', + GRUB_CMDLINE_LINUX_DEFAULT => 'quiet', + GRUB_CMDLINE_LINUX => '', + GRUB_DISABLE_RECOVERY => undef, + GRUB_DISABLE_OS_PROBER => 'true', + GRUB_THEME => '/boot/grub/themes/webmin/theme.txt', + GRUB_BACKGROUND => '/boot/grub/background.png', + GRUB_COLOR_NORMAL => 'white/black', + GRUB_COLOR_HIGHLIGHT => 'black/light-gray', +}); +is($save_err, undef, 'structured save succeeds'); +my $saved = slurp_test_file($default_file); +like($saved, qr/GRUB_DEFAULT=saved/, 'structured save writes default'); +like($saved, qr/GRUB_DISABLE_OS_PROBER=true/, 'structured save writes boolean'); +like($saved, qr/GRUB_TERMINAL_OUTPUT=gfxterm/, + 'structured save writes terminal output'); +like($saved, qr/GRUB_GFXMODE=auto/, + 'structured save writes graphics mode'); +like($saved, qr/GRUB_THEME=\/boot\/grub\/themes\/webmin\/theme\.txt/, + 'structured save writes theme'); +like($saved, qr/GRUB_COLOR_NORMAL=white\/black/, + 'structured save writes colors'); +is(grub2_save_color_script(), undef, 'color generator script save succeeds'); +my $color_data = slurp_test_file($color_file); +like($color_data, qr/Webmin managed GRUB menu colors/, + 'color generator script is managed'); +like($color_data, qr/menu_color_normal/, + 'color generator script emits menu color variables'); +like($color_data, qr/GRUB_COLOR_NORMAL/, + 'color generator script reads defaults color variables'); +like($color_data, qr/webmin_grub2_defaults_file=.*default-grub/, + 'color generator script sources configured defaults file'); +is((stat($color_file))[2] & 0777, 0755, + 'color generator script is executable'); +my $color_output = `$color_file`; +like($color_output, qr/set menu_color_normal=white\/black/, + 'color generator script emits configured normal color'); +like($color_output, qr/set color_highlight=black\/light-gray/, + 'color generator script emits configured highlight color'); +{ + my $unmanaged_color = "$work/grub.d/06_other_colors"; + write_test_file($unmanaged_color, "#!/bin/sh\nexit 0\n"); + local $config{'color_file'} = $unmanaged_color; + is(grub2_save_color_script(), $text{'defaults_ecolorfile'}, + 'unmanaged color script is not overwritten'); +} + +write_test_file($custom_file, "menuentry 'Custom' { true }\n"); +write_test_file($os_prober_file, "#!/bin/sh\nexit 0\n"); +chmod(0755, $os_prober_file); +write_test_file($readme_file, "GRUB script directory notes\n"); +write_test_file($bls_entry_file, <<'EOF'); +title Rocky Linux +version 5.14.0 +linux /vmlinuz-5.14.0 +EOF +ok(grub2_manual_file($default_file), 'default file is manual-edit allowlisted'); +ok(grub2_manual_file($custom_file), 'custom file is manual-edit allowlisted'); +ok(grub2_manual_file($os_prober_file), 'grub.d script is manual-edit allowlisted'); +ok(grub2_manual_file($readme_file), 'grub.d regular file is manual-edit allowlisted'); +ok(grub2_manual_file($bls_entry_file), 'BLS entry is manual-edit allowlisted'); +ok(!grub2_manual_file("$work/not-allowed"), 'unexpected file is rejected'); +is(save_manual_grub_file($default_file, $saved), undef, + 'manual save validates default file'); +is(save_manual_grub_file($custom_file, "menuentry 'X' { true }\n"), undef, + 'manual save permits custom GRUB script'); +like(save_manual_grub_file($custom_file, "menuentry 'Broken' {\n"), + qr/unbalanced|failed/i, + 'manual save rejects invalid custom GRUB script'); +chmod(0644, $custom_file); +is(save_manual_grub_file($custom_file, "menuentry 'X' { true }\n"), undef, + 'manual custom save succeeds after bad file mode'); +ok(((stat($custom_file))[2] & 0111), 'manual custom save makes file executable'); +is(save_manual_grub_file($os_prober_file, "#!/bin/sh\nexit 0\n"), undef, + 'manual save validates grub.d shell scripts'); +like(save_manual_grub_file($os_prober_file, "if then\n"), qr/syntax|unexpected|then/i, + 'manual save rejects invalid grub.d shell scripts'); +is(save_manual_grub_file($bls_entry_file, "title Rocky Linux\nlinux /vmlinuz\n"), undef, + 'manual save validates BLS entries'); +like(save_manual_grub_file($bls_entry_file, "not-a-key\n"), qr/BLS|syntax/i, + 'manual save rejects invalid BLS entries'); + +my $mkpasswd = "$work/grub-mkpasswd-pbkdf2"; +make_script($mkpasswd, <<'EOF'); +#!/bin/sh +read first +read second +[ "$first" = "$second" ] || exit 1 +echo "PBKDF2 hash of your password is grub.pbkdf2.sha512.10000.ABCDEF.123456" +EOF +$config{'mkpasswd_cmd'} = $mkpasswd; +my $security = grub2_read_security_config(); +ok(!$security->{'enabled'}, 'missing password script is disabled'); +is(grub2_save_security_config({ + enabled => 1, + user => 'root', + password => 'secret', + password2 => 'secret', + hash => '', +}), undef, 'password protection can be enabled'); +my $password_data = slurp_test_file($password_file); +like($password_data, qr/set superusers="root"/, + 'password script writes superuser'); +like($password_data, qr/password_pbkdf2 root grub\.pbkdf2\.sha512/, + 'password script writes PBKDF2 hash'); +unlike($password_data, qr/secret/, 'password script does not store clear text'); +is((stat($password_file))[2] & 0777, 0700, + 'password script is root-only executable'); +$security = grub2_read_security_config(); +ok($security->{'enabled'}, 'password state reads enabled'); +is($security->{'user'}, 'root', 'password state reads user'); +ok($security->{'hash'}, 'password state reads hash internally'); +my $existing_hash = $security->{'hash'}; +is(grub2_save_security_config({ + enabled => 1, + user => 'root', + password => 'secret2', + password2 => 'secret2', + hash => $existing_hash, +}), undef, 'unchanged visible hash does not block password replacement'); +is(grub2_save_security_config({ + enabled => 1, + user => 'root', + password => 'secret2', + password2 => 'secret2', + hash => 'grub.pbkdf2.sha512.10000.CHANGED.7890', +}), $text{'security_epassmode'}, + 'changed pasted hash cannot be combined with password replacement'); +is(grub2_save_security_config({ + enabled => 1, + user => 'admin', + password => '', + password2 => '', + hash => '', +}), undef, 'password save keeps existing hash'); +like(slurp_test_file($password_file), qr/set superusers="admin"/, + 'password save updates user while keeping hash'); +is(grub2_save_security_config({ + enabled => 1, + user => '-bad', + password => '', + password2 => '', + hash => '', +}), $text{'security_euser'}, 'password save rejects unsafe user'); +is(grub2_save_security_config({ + enabled => 1, + user => 'root', + password => 'one', + password2 => 'two', + hash => '', +}), $text{'security_epassmatch'}, 'password save rejects mismatch'); +is(grub2_save_security_config({ + enabled => 1, + user => 'root', + password => '', + password2 => '', + hash => 'not-a-hash', +}), $text{'security_ehash'}, 'password save rejects invalid pasted hash'); +is(grub2_save_security_config({ + enabled => 0, + user => 'root', + password => '', + password2 => '', + hash => '', +}), undef, 'password protection can be disabled'); +unlike(slurp_test_file($password_file), qr/password_pbkdf2/, + 'disabled password script emits no password command'); +{ + my $unmanaged_password = "$work/grub.d/01_other_password"; + write_test_file($unmanaged_password, "#!/bin/sh\nexit 0\n"); + local $config{'password_file'} = $unmanaged_password; + is(grub2_save_security_config({ + enabled => 0, + user => 'root', + password => '', + password2 => '', + hash => '', + }), $text{'security_eunmanaged'}, + 'unmanaged password script is not overwritten'); +} + +write_test_file($cfg_file, <<'EOF'); +set default="0" +menuentry 'Ubuntu' --class ubuntu --id 'gnulinux-simple-abc' { + linux /vmlinuz-5.14.0 root=/dev/sda1 quiet + initrd /initramfs-5.14.0.img +} +submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-abc' { + menuentry 'Ubuntu, with Linux 6.8' --id 'gnulinux-6.8-advanced-abc' { + } + menuentry "Ubuntu, rescue mode" { + } +} +EOF + +my @entries = grub2_boot_entries($cfg_file); +is(scalar(@entries), 3, 'parsed three boot entries'); +is($entries[0]->{'title'}, 'Ubuntu', 'parsed top-level title'); +is($entries[0]->{'id'}, 'gnulinux-simple-abc', 'parsed top-level ID'); +is($entries[0]->{'linux'}, '/vmlinuz-5.14.0', + 'parsed generated kernel path'); +is($entries[0]->{'initrd'}, '/initramfs-5.14.0.img', + 'parsed generated initrd path'); +is($entries[0]->{'options'}, 'root=/dev/sda1 quiet', + 'parsed generated kernel options'); +is($entries[0]->{'version'}, '5.14.0', + 'derived generated kernel version'); +is(join(' > ', @{$entries[1]->{'path'}}), 'Advanced options for Ubuntu', + 'parsed submenu path'); +is(grub2_entry_selector($entries[1]), 'gnulinux-6.8-advanced-abc', + 'entry selector prefers ID'); +is(grub2_entry_selector($entries[2]), + 'Advanced options for Ubuntu>Ubuntu, rescue mode', + 'entry selector falls back to menu path'); +is_deeply([ grub2_kernel_options_source_keys(\@entries) ], [ 'defaults' ], + 'ordinary generated entries report defaults as kernel option source'); +is(grub2_kernel_options_source_text(\@entries), + $text{'index_kernel_options_source_defaults'}, + 'ordinary generated entry source text is localized'); +my %ambiguous_path_entry = ( + title => 'Leaf', + path => [ 'Foo>Bar' ], + index => 7, +); +is(grub2_entry_selector(\%ambiguous_path_entry), undef, + 'entry selector rejects path components containing greater-than signs'); +ok(!grub2_entry_matches_selector(\%ambiguous_path_entry, 'Foo>Bar>Leaf'), + 'entry path selector with greater-than signs does not match'); +ok(!grub2_entry_by_index(-1), 'negative boot entry index is rejected'); +ok(!grub2_entry_by_index(99), 'out-of-range boot entry index is rejected'); + +{ + my $selection_defaults = parse_grub_defaults_text("GRUB_DEFAULT=saved\n", + $default_file); + my %selection_env = ( + saved_entry => 'gnulinux-simple-abc', + next_entry => '1', + ); + my %roles = grub2_entry_selection_roles(\@entries, $selection_defaults, + \%selection_env); + is_deeply($roles{0}, [ 'saved' ], 'saved default entry is resolved'); + is_deeply($roles{1}, [ 'next' ], 'next boot entry is resolved'); +} + +{ + my $selection_defaults = parse_grub_defaults_text( + "GRUB_DEFAULT=\"Advanced options for Ubuntu>Ubuntu, rescue mode\"\n", + $default_file); + my %roles = grub2_entry_selection_roles(\@entries, $selection_defaults, {}); + is_deeply($roles{2}, [ 'default' ], 'path default entry is resolved'); +} + +write_test_file($bls_entry_file, <<'EOF'); +title Rocky Linux (5.14.0-570.12.1.el9_6.x86_64) 9.6 +version 5.14.0-570.12.1.el9_6.x86_64 +linux /vmlinuz-5.14.0-570.12.1.el9_6.x86_64 +initrd /initramfs-5.14.0-570.12.1.el9_6.x86_64.img +options $kernelopts +machine-id 224f8b7897fe459aaefa3de1190e8600 +EOF +write_test_file("$work/bls-grub.cfg", <<'EOF'); +insmod blscfg +blscfg +menuentry 'UEFI Firmware Settings' --id 'uefi-firmware' { + fwsetup +} +EOF +my @bls_entries = grub2_boot_entries("$work/bls-grub.cfg"); +is(scalar(@bls_entries), 2, 'BLS and static entries are parsed together'); +is($bls_entries[0]->{'title'}, + 'Rocky Linux (5.14.0-570.12.1.el9_6.x86_64) 9.6', + 'BLS title is parsed'); +is($bls_entries[0]->{'id'}, 'rocky-5.14.0', + 'BLS selector falls back to filename'); +is($bls_entries[0]->{'version'}, '5.14.0-570.12.1.el9_6.x86_64', + 'BLS version is parsed'); +is($bls_entries[0]->{'linux'}, '/vmlinuz-5.14.0-570.12.1.el9_6.x86_64', + 'BLS kernel path is parsed'); +is($bls_entries[0]->{'initrd'}, + '/initramfs-5.14.0-570.12.1.el9_6.x86_64.img', + 'BLS initrd path is parsed'); +is($bls_entries[0]->{'options'}, '$kernelopts', + 'BLS kernel options are parsed'); +is($bls_entries[0]->{'machine-id'}, '224f8b7897fe459aaefa3de1190e8600', + 'BLS machine ID is parsed'); +is_deeply([ grub2_kernel_options_source_keys(\@bls_entries) ], + [ 'kernelopts' ], + 'BLS entries using kernelopts report grubenv as option source'); +is(grub2_kernel_options_source_text(\@bls_entries), + $text{'index_kernel_options_source_kernelopts'}, + 'kernel option source text is localized'); +my %kernelopts_env = ( kernelopts => 'root=/dev/sda1 quiet' ); +is_deeply([ grub2_bls_kernel_option_warnings(\@bls_entries, + \%kernelopts_env) ], + [ $text{'index_warn_kernelopts_source'} ], + 'BLS kernelopts warning omits missing-env warning when value exists'); +is_deeply([ grub2_bls_kernel_option_warnings(\@bls_entries, {}) ], + [ $text{'index_warn_kernelopts_source'}, + $text{'index_warn_kernelopts_missing'} ], + 'BLS kernelopts warning reports missing grubenv kernelopts'); +my @direct_bls_entries = ( + { source => 'bls', options => 'root=/dev/sda1 quiet', + linux => '/vmlinuz-direct' }, +); +is_deeply([ grub2_kernel_options_source_keys(\@direct_bls_entries) ], + [ 'bls' ], + 'direct BLS options report BLS files as option source'); +is_deeply([ grub2_bls_kernel_option_warnings(\@direct_bls_entries, {}) ], + [ $text{'index_warn_bls_options'} ], + 'direct BLS options warn about BLS entry files'); +ok(!grub2_bls_update_available(\@bls_entries), + 'BLS update is unavailable without grubby'); +my ($remove_args, $add_args) = + grub2_kernel_args_delta('quiet crashkernel=old rd.lvm.lv=rl/root', + 'quiet crashkernel=new console=ttyS0'); +is_deeply($remove_args, [ 'crashkernel=old', 'rd.lvm.lv=rl/root' ], + 'kernel arg delta removes deleted or changed arguments'); +is_deeply($add_args, [ 'crashkernel=new', 'console=ttyS0' ], + 'kernel arg delta adds new or changed arguments'); +is_deeply([ grub2_split_kernel_args('quiet "console=ttyS0,115200n8"') ], + [ 'quiet', 'console=ttyS0,115200n8' ], + 'kernel args splitter handles quoted words'); +ok(!grub2_defaults_updates_need_generate( + { GRUB_CMDLINE_LINUX => 'quiet old=1' }, + { GRUB_CMDLINE_LINUX => 'quiet new=2' }, 1), + 'BLS-updated all-kernel args do not require regeneration'); +ok(!grub2_defaults_updates_need_generate( + { GRUB_CMDLINE_LINUX_DEFAULT => 'quiet old=1' }, + { GRUB_CMDLINE_LINUX_DEFAULT => 'quiet new=2' }, + { GRUB_CMDLINE_LINUX_DEFAULT => 1 }), + 'BLS-updated default-kernel args do not require regeneration'); +ok(grub2_defaults_updates_need_generate( + { GRUB_CMDLINE_LINUX_DEFAULT => 'quiet old=1' }, + { GRUB_CMDLINE_LINUX_DEFAULT => 'quiet new=2' }, {}), + 'default-kernel args still require regeneration without BLS update'); +ok(grub2_defaults_updates_need_generate( + { GRUB_CMDLINE_LINUX => 'quiet old=1' }, + { GRUB_CMDLINE_LINUX => 'quiet new=2' }, 0), + 'all-kernel args still require regeneration without BLS update'); +ok(grub2_defaults_updates_need_generate( + { GRUB_CMDLINE_LINUX => 'quiet old=1', GRUB_TIMEOUT => '5' }, + { GRUB_CMDLINE_LINUX => 'quiet new=2', GRUB_TIMEOUT => '10' }, 1), + 'non-BLS default changes still require regeneration'); +ok(!grub2_defaults_updates_need_generate( + { GRUB_TIMEOUT => '5' }, { GRUB_TIMEOUT => '5' }, 0), + 'unchanged defaults do not require regeneration'); +my $grubby = "$work/grubby"; +my $grubby_log = "$work/grubby.log"; +make_script($grubby, < '$grubby_log' +EOF +{ + local $config{'grubby_cmd'} = $grubby; + ok(grub2_bls_update_available(\@bls_entries), + 'BLS update is available with grubby'); + is_deeply([ grub2_bls_kernel_option_warnings(\@direct_bls_entries, {}) ], + [], + 'direct BLS options do not warn when grubby is available'); + is(grub2_update_bls_kernel_args('quiet old=1 keep', + 'quiet new=2 keep'), undef, + 'BLS kernel args update succeeds'); + is(slurp_test_file($grubby_log), + "--update-kernel=ALL\n--remove-args=old=1\n--args=new=2\n", + 'grubby receives the kernel arg delta'); + is_deeply([ grub2_bls_kernel_arg_targets(\@direct_bls_entries, 0) ], + [ "$work/vmlinuz-direct" ], + 'BLS kernel arg targets resolve relative to boot directory'); + is(grub2_update_bls_kernel_args('quiet old=1', + 'quiet new=2', + [ "$work/vmlinuz-direct" ]), undef, + 'BLS default kernel args update succeeds for selected targets'); + is(slurp_test_file($grubby_log), + "--update-kernel=$work/vmlinuz-direct\n". + "--remove-args=old=1\n--args=new=2\n", + 'grubby receives selected BLS kernel target'); +} +is(join(' > ', @{$bls_entries[0]->{'path'}}), '', + 'top-level BLS entries do not use their source as submenu label'); +is($bls_entries[1]->{'title'}, 'UEFI Firmware Settings', + 'static entries after blscfg keep their menu order'); +write_test_file("$work/bls-submenu.cfg", <<'EOF'); +submenu 'BLS submenu' { + blscfg +} +EOF +my @submenu_bls_entries = grub2_boot_entries("$work/bls-submenu.cfg"); +is(join(' > ', @{$submenu_bls_entries[0]->{'path'}}), 'BLS submenu', + 'BLS entries inherit the submenu containing blscfg'); +ok(!grub2_has_bls_rescue_entries(\@bls_entries), + 'ordinary BLS entries are not treated as rescue entries'); +ok(grub2_rpmvercmp('5.14.0-611.55.1.el9_7.aarch64', + '5.14.0-611.49.1.el9_7.aarch64') > 0, + 'rpm-style version comparison orders newer kernels higher'); +ok(grub2_rpmvercmp('5.14.0-611.45.1.el9_7.aarch64', + '0-rescue-224f8b7897fe459aaefa3de1190e8600') > 0, + 'rpm-style version comparison places rescue entries after kernels'); +{ + my $bls_order_dir = "$work/bls-order"; + mkdir $bls_order_dir or die "mkdir bls-order: $!"; + foreach my $ver ( + '0-rescue-224f8b7897fe459aaefa3de1190e8600', + '5.14.0-611.45.1.el9_7.aarch64', + '5.14.0-611.49.1.el9_7.aarch64', + '5.14.0-611.55.1.el9_7.aarch64', + ) { + my $file = "$bls_order_dir/224f8b7897fe459aaefa3de1190e8600-$ver.conf"; + my $title = $ver =~ /^0-rescue/ ? + "Rocky Linux ($ver) 9.4 (Blue Onyx)" : + "Rocky Linux ($ver) 9.7 (Blue Onyx)"; + write_test_file($file, <<"EOF"); +title $title +version $ver +linux /vmlinuz-$ver +EOF + } + local $config{'bls_dir'} = $bls_order_dir; + my @ordered_bls = grub2_boot_entries("$work/bls-grub.cfg"); + is_deeply([ map { $_->{'title'} } @ordered_bls ], + [ + 'Rocky Linux (5.14.0-611.55.1.el9_7.aarch64) 9.7 (Blue Onyx)', + 'Rocky Linux (5.14.0-611.49.1.el9_7.aarch64) 9.7 (Blue Onyx)', + 'Rocky Linux (5.14.0-611.45.1.el9_7.aarch64) 9.7 (Blue Onyx)', + 'Rocky Linux (0-rescue-224f8b7897fe459aaefa3de1190e8600) 9.4 (Blue Onyx)', + 'UEFI Firmware Settings', + ], + 'BLS entries follow GRUB newest-first order before static entries'); + is_deeply([ map { $_->{'index'} } @ordered_bls ], [ 0, 1, 2, 3, 4 ], + 'BLS entry indexes are assigned after sorting'); + ok(grub2_has_bls_rescue_entries(\@ordered_bls), + 'BLS rescue entries are detected separately from recovery entries'); + my $rescue_file = "$bls_order_dir/224f8b7897fe459aaefa3de1190e8600-0-rescue-224f8b7897fe459aaefa3de1190e8600.conf"; + my $disabled_rescue_file = + $rescue_file.grub2_disabled_bls_rescue_suffix(); + is(grub2_set_bls_rescue_disabled(1, \@ordered_bls), undef, + 'BLS rescue entries can be disabled'); + ok(!-e $rescue_file, 'disabled BLS rescue entry is renamed away'); + ok(-e $disabled_rescue_file, + 'disabled BLS rescue entry is kept for restoration'); + is_deeply([ grub2_disabled_bls_rescue_files($bls_order_dir) ], + [ $disabled_rescue_file ], + 'disabled BLS rescue entry is detected'); + ok(!grub2_has_bls_rescue_entries( + [ grub2_bls_entries(0, $bls_order_dir) ]), + 'disabled BLS rescue entries are no longer parsed'); + ok(!grub2_defaults_updates_need_generate( + { GRUB_DISABLE_RECOVERY => 'false' }, + { GRUB_DISABLE_RECOVERY => 'true' }, + { GRUB_DISABLE_RECOVERY => 1 }), + 'BLS-disabled rescue entries do not require menu regeneration'); + is(grub2_set_bls_rescue_disabled(0), undef, + 'BLS rescue entries can be restored'); + ok(-e $rescue_file, 'restored BLS rescue entry returns to conf file'); + ok(!-e $disabled_rescue_file, + 'restored BLS rescue entry removes disabled copy'); +} + +write_test_file($custom_file, <<'EOF'); +menuentry 'Custom one' --id 'custom-one' { true } +menuentry 'Custom two' { true } +EOF +write_test_file("$work/custom-grub.cfg", <<"EOF"); +### BEGIN $custom_file ### +menuentry 'Custom one' --id 'custom-one' { true } +menuentry 'Custom two' { true } +### END $custom_file ### +EOF +{ + local $config{'grub_cfg'} = "$work/custom-grub.cfg"; + my @custom_entries = grub2_boot_entries(); + is($custom_entries[0]->{'source_file'}, $custom_file, + 'custom generated entry keeps source file'); + is($custom_entries[0]->{'index'}, 0, + 'custom generated entry has a generated-menu index'); +} + +write_test_file($custom_file, <<'EOF'); +#!/bin/sh +exec tail -n +3 $0 + +menuentry "Alpha" --id "alpha" { + echo alpha +} +menuentry "Beta" --id "beta" { + echo beta +} +EOF +my @custom_file_entries = grub2_custom_entries(); +is(scalar(@custom_file_entries), 2, 'custom file entries are parsed'); +is($custom_file_entries[0]->{'title'}, 'Alpha', 'custom entry title parsed'); +is(grub2_custom_entry_body($custom_file_entries[0]), "echo alpha\n", + 'custom entry body is extracted without storage indentation'); +is(grub2_format_custom_entry('Indented', 'indented', + "\techo indented\n\ttrue\n"), + "menuentry \"Indented\" --id \"indented\" {\n". + "\techo indented\n\ttrue\n}\n", + 'custom entry formatting avoids accumulating outer indentation'); +is(grub2_save_custom_entry(undef, 'Gamma', 'gamma', "echo gamma\ntrue\n"), + undef, + 'custom entry add succeeds'); +like(slurp_test_file($custom_file), qr/menuentry "Gamma" --id "gamma"/, + 'custom entry add writes menuentry'); +is(grub2_move_custom_entry(2, 'up'), undef, 'custom entry move succeeds'); +my @moved_entries = grub2_custom_entries(); +is($moved_entries[1]->{'title'}, 'Gamma', 'custom entry order changes'); +is(grub2_save_custom_entry(0, 'Alpha edited', 'alpha-edited', + "echo edited\n"), undef, + 'custom entry edit succeeds'); +like(slurp_test_file($custom_file), qr/Alpha edited/, + 'custom entry edit writes new title'); +is(grub2_delete_custom_entry_indexes(1), undef, + 'custom entry indexed delete succeeds'); +unlike(slurp_test_file($custom_file), qr/menuentry "Gamma"/, + 'custom indexed delete removes selected entry'); +is(grub2_validate_custom_entry('Bad', '-bad', 'true'), + $text{'custom_eid'}, 'custom entry rejects dash-leading ID'); +is(grub2_validate_custom_entry("Bad\0", 'bad', 'true'), + $text{'custom_etitle'}, 'custom entry rejects title null byte'); +is(grub2_validate_custom_entry('Bad', "bad\0", 'true'), + $text{'custom_eid'}, 'custom entry rejects ID null byte'); +is(grub2_validate_custom_entry('Bad', 'bad', "}\n"), + $text{'custom_ebraces'}, 'custom entry rejects unbalanced braces'); + +write_test_file($custom_file, <<'EOF'); +submenu "Nested" { + menuentry "Nested one" { + true + } +} +menuentry "Top one" { + true +} +EOF +is(grub2_move_custom_entry(0, 'down'), $text{'custom_emove'}, + 'custom move rejects submenu boundary changes'); + +{ + local $config{'grub_cfg'} = "$work/missing-grub.cfg"; + prefer_existing_file('grub_cfg', "$work/also-missing", $cfg_file); + is($config{'grub_cfg'}, $cfg_file, + 'missing generic file is corrected to existing candidate'); +} + +write_test_file($env_file, "saved_entry=gnulinux-simple-abc\nnext_entry=1\n"); +my %env = grub2_read_env(); +is($env{'saved_entry'}, 'gnulinux-simple-abc', 'parsed saved entry from env'); +is($env{'next_entry'}, '1', 'parsed next entry from env'); + +{ + local $config{'grub_cfg'} = "$work/bls-grub.cfg"; + my @warnings = grub2_status_warnings(); + ok(!grep { $_ eq $text{'index_warn_kernelopts_source'} } @warnings, + 'BLS kernel option source warning is not shown globally'); + ok(!grep { $_ eq $text{'index_warn_kernelopts_missing'} } @warnings, + 'missing grubenv kernelopts warning is not shown globally'); +} + +{ + local $config{'default_file'} = "$work/missing-defaults"; + my @warnings = grub2_status_warnings(); + ok(grep { $_ eq $text{'index_warn_missing_default'} } @warnings, + 'missing defaults file warning is shown'); +} + +{ + my $theme_console_defaults = "$work/theme-console-defaults"; + write_test_file($theme_console_defaults, + "GRUB_THEME=$theme_file\nGRUB_TERMINAL_OUTPUT=console\n"); + local $config{'default_file'} = $theme_console_defaults; + my @warnings = grub2_status_warnings(); + ok(grep { $_ eq $text{'index_warn_theme_console'} } @warnings, + 'theme with console terminal output is warned about'); +} + +{ + my $bad_theme_defaults = "$work/bad-theme-defaults"; + write_test_file($bad_theme_defaults, + "GRUB_THEME=$work/Marathon-TitleScreen.tar.gz\n"); + local $config{'default_file'} = $bad_theme_defaults; + my @warnings = grub2_status_warnings(); + ok(grep { /theme archive/ } @warnings, + 'theme archive saved as GRUB_THEME is warned about'); +} + +my $runtime_log = "$work/runtime.log"; +my $runtime_cmd = "$work/grub-set-default"; +make_script($runtime_cmd, "#!/bin/sh\nprintf '%s\\n' \"\$@\" > '$runtime_log'\n"); +$config{'set_default_cmd'} = $runtime_cmd; +is(grub2_run_entry_command('set_default_cmd', $entries[0]), undef, + 'runtime command succeeds'); +is(slurp_test_file($runtime_log), "gnulinux-simple-abc\n", + 'runtime command passes entry selector'); +my %unsafe_entry = ( 'id' => '-bad', 'title' => 'bad', 'path' => [] ); +is(grub2_run_entry_command('set_default_cmd', \%unsafe_entry), + $text{'runtime_eselector'}, 'dash-leading selector is rejected'); + +my $install_cmd = "$work/grub-install"; +my $install_log = "$work/install.log"; +my $install_target = "/dev/null"; +my $efi_dir = "$work/efi"; +my $install_boot_dir = "$work/boot"; +my $install_module_dir = "$work/grub-modules/x86_64-efi"; +make_path($efi_dir); +make_path($install_boot_dir); +make_path($install_module_dir); +write_test_file("$install_module_dir/modinfo.sh", "# module info\n"); +make_path("$efi_dir/EFI/rocky"); +write_test_file("$efi_dir/EFI/rocky/grub.cfg", "# grub\n"); +make_script($install_cmd, < '$install_log' +echo "installing boot loader" +EOF +$config{'install_cmd'} = $install_cmd; +is(grub2_default_bootloader_id($efi_dir), 'rocky', + 'boot loader ID is inferred from EFI vendor directory'); +{ + local $config{'grub_cfg'} = '/boot/efi/EFI/almalinux/grub.cfg'; + is(grub2_default_bootloader_id($efi_dir), 'almalinux', + 'boot loader ID prefers configured GRUB EFI path'); +} +is(grub2_validate_install_options({ target => $install_target }), undef, + 'boot loader install accepts an existing absolute target'); +is(grub2_validate_install_options({ efi_dir => $efi_dir }), undef, + 'boot loader install accepts EFI-directory-only install'); +is(grub2_validate_install_options({ + target => $install_target, + platform => 'x86_64-efi', + directory => $install_module_dir, + boot_directory => $install_boot_dir, + }), undef, 'boot loader install accepts platform and module directory'); +like(grub2_validate_install_options({ target => 'relative-disk' }), + qr/absolute|target/, 'boot loader install rejects relative target'); +like(grub2_validate_install_options({ + target => "/dev/webmin-grub2-missing-$$", + }), + qr/does not exist/, 'boot loader install rejects missing target'); +like(grub2_validate_install_options({ + target => $install_target, + bootloader_id => '../bad', + }), qr/boot loader ID/, 'boot loader install rejects unsafe ID'); +like(grub2_validate_install_options({ + target => $install_target, + platform => 'webmin-test', + }), qr/platform files/, 'boot loader install reports missing modules'); +like(grub2_validate_install_options({ + target => $install_target, + directory => $efi_dir, + }), qr/modinfo/, 'boot loader install rejects module dir without modinfo'); +like(grub2_validate_install_options({ + target => $install_target, + boot_directory => "$work/no-such-boot", + }), qr/does not exist/, 'boot loader install rejects missing boot directory'); +my @install_events; +is(grub2_install_bootloader({ + target => $install_target, + efi_dir => $efi_dir, + platform => 'x86_64-efi', + directory => $install_module_dir, + boot_directory => $install_boot_dir, + bootloader_id => 'GRUB', + recheck => 1, + no_nvram => 1, + force => 1, + }, sub { push(@install_events, [ @_ ]); }), undef, + 'boot loader install succeeds with progress callback'); +is(slurp_test_file($install_log), + "--recheck\n--no-nvram\n--force\n--efi-directory=$efi_dir\n". + "--target=x86_64-efi\n--directory=$install_module_dir\n". + "--boot-directory=$install_boot_dir\n". + "--bootloader-id=GRUB\n$install_target\n", + 'boot loader install passes expected command arguments'); +ok((grep { $_->[0] eq 'command' && + $_->[1] =~ /^\Q$install_cmd\E --recheck/ && + $_->[1] !~ /\\/ } @install_events), + 'boot loader install reports readable command'); +ok((grep { $_->[0] eq 'output' && $_->[1] =~ /installing boot loader/ } + @install_events), 'boot loader install streams command output'); +ok((grep { $_->[0] eq 'command_done' } @install_events), + 'boot loader install reports command completion'); + +my $mkconfig = "$work/grub-mkconfig"; +make_script($mkconfig, <<'EOF'); +#!/bin/sh +out= +while [ $# -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out=$1 + fi + shift +done +echo "menuentry 'Generated' { true }" > "$out" +EOF +$config{'mkconfig_cmd'} = $mkconfig; +is(grub2_generate_config(), undef, 'mkconfig generation succeeds'); +like(slurp_test_file($cfg_file), qr/menuentry 'Generated'/, + 'generated menu replaces target after test generation'); +my $script_check = "$work/grub-script-check"; +make_script($script_check, <<'EOF'); +#!/bin/sh +data=`cat "$1"` +case "$data" in + *Broken*) echo "syntax broken"; exit 1 ;; +esac +exit 0 +EOF +my $mkconfig_broken = "$work/grub-mkconfig-broken"; +make_script($mkconfig_broken, <<'EOF'); +#!/bin/sh +out= +while [ $# -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out=$1 + fi + shift +done +echo "menuentry 'Broken' { true }" > "$out" +EOF +{ + local $config{'script_check_cmd'} = $script_check; + local $config{'mkconfig_cmd'} = $mkconfig_broken; + my $before = slurp_test_file($cfg_file); + like(grub2_generate_config(), qr/generated test menu failed validation.*syntax broken/is, + 'generated menu is validated before replacement'); + is(slurp_test_file($cfg_file), $before, + 'invalid generated menu does not replace target'); +} +my $mkconfig_progress = "$work/grub-mkconfig-progress"; +make_script($mkconfig_progress, <<'EOF'); +#!/bin/sh +out= +while [ $# -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out=$1 + fi + shift +done +echo "probing entries" +echo "writing menu" +echo "menuentry 'Generated with progress' { true }" > "$out" +EOF +$config{'mkconfig_cmd'} = $mkconfig_progress; +my @progress_events; +is(grub2_generate_config(sub { push(@progress_events, [ @_ ]); }), undef, + 'mkconfig generation with progress callback succeeds'); +ok((grep { $_->[0] eq 'command' } @progress_events), + 'progress callback reports command'); +ok((grep { $_->[0] eq 'command' && + $_->[1] =~ /^\Q$mkconfig_progress\E -o / && + $_->[1] !~ /\\/ } @progress_events), + 'progress callback reports readable command'); +ok((grep { $_->[0] eq 'output' && $_->[1] =~ /probing entries/ } + @progress_events), 'progress callback streams command output'); +ok((grep { $_->[0] eq 'command_done' } @progress_events), + 'progress callback reports command completion'); +ok((grep { $_->[0] eq 'check_done' } @progress_events), + 'progress callback reports test menu check completion'); +ok((grep { $_->[0] eq 'replace' } @progress_events), + 'progress callback reports replacement'); +ok((grep { $_->[0] eq 'replace_done' } @progress_events), + 'progress callback reports completion'); +unlink($grub2_config_change_flag, $grub2_generate_time_flag); +is(grub2_action_links({ apply => 0 }, 'index.cgi'), '', + 'header apply link is hidden without apply ACL'); +like(grub2_action_links({ apply => 1 }, 'index.cgi'), qr/Regenerate GRUB menu/, + 'header apply link is shown with apply ACL'); +grub2_mark_regenerate_needed(); +like(grub2_action_links({ apply => 1 }, 'index.cgi'), + qr/]*>Regenerate GRUB menu<\/b>/, + 'header apply link is bold when regeneration is pending'); +local $ENV{'SCRIPT_NAME'} = '/grub2/status.cgi'; +local $ENV{'QUERY_STRING'} = ''; +like(grub2_action_links({ apply => 1 }), + qr/redir=%2Fgrub2%2Fstatus%2Ecgi/, + 'header apply link defaults to current module URL'); +grub2_mark_generated(); + +my @backup = grub2_config_files(); +ok(grep { $_ eq $default_file } @backup, 'backup includes default file'); +ok(grep { $_ eq $cfg_file } @backup, 'backup includes generated config'); +ok(grep { $_ eq $config{'grub_dir'} } @backup, 'backup includes script dir'); +ok(grep { $_ eq $password_file } @backup, 'backup includes password file'); +ok(grep { $_ eq $color_file } @backup, 'backup includes color script'); +ok(grep { $_ eq $theme_dir } @backup, 'backup includes theme directory'); +ok(grep { $_ eq $background_dir } @backup, + 'backup includes background directory'); +ok(grep { $_ eq $bls_dir } @backup, 'backup includes BLS entries dir'); + +do "$bindir/../log_parser.pl" or die "log_parser: $@ $!"; +like(parse_webmin_log('root', 'save_defaults.cgi', 'defaults', undef, undef, {}), + qr/Modified GRUB default/, 'log parser handles defaults'); +like(parse_webmin_log('root', 'save_defaults.cgi', 'bls_args', undef, + undef, {}), + qr/Updated existing BLS kernel options/, + 'log parser handles BLS kernel option updates'); +like(parse_webmin_log('root', 'save_theme.cgi', 'theme', undef, undef, {}), + qr/Modified GRUB theme/, 'log parser handles theme'); +my $generate_log = + parse_webmin_log('root', 'generate.cgi', 'generate', undef, $cfg_file, {}); +like($generate_log, qr/Generated GRUB menu/, 'log parser handles generation'); +like($generate_log, qr/]*>\Q$cfg_file\E<\/tt>/, + 'log parser renders values with tt tags'); +unlike($generate_log, qr//, + 'log parser does not render values with code tags'); +like(parse_webmin_log('root', 'install.cgi', 'install', undef, + $install_target, {}), + qr/Installed GRUB boot loader/, 'log parser handles install'); +like(parse_webmin_log('root', 'save_security.cgi', 'security', undef, + 'enabled', {}), + qr/Modified GRUB password protection/, 'log parser handles security'); +like(parse_webmin_log('root', 'save_custom.cgi', 'custom_create', undef, + 'Gamma', {}), + qr/Created custom GRUB entry/, 'log parser handles custom create'); +like(parse_webmin_log('root', 'custom_action.cgi', 'custom_delete', undef, + 1, {}), + qr/Deleted 1 custom GRUB menu entries/, + 'log parser handles custom delete'); + +do "$bindir/../install_check.pl" or die "install_check: $@ $!"; +$config{'default_file'} = $default_file; +$config{'grub_cfg'} = $cfg_file; +$config{'mkconfig_cmd'} = $mkconfig; +is(is_installed(0), 1, 'install check detects module as installed'); +is(is_installed(1), 2, 'install check detects module as configured'); + +done_testing();