diff --git a/oschooser.pl b/oschooser.pl index d10fb0c7f..c3567be21 100755 --- a/oschooser.pl +++ b/oschooser.pl @@ -160,12 +160,28 @@ EOF } } -# Write the name, version and real name and version to a file +# Keep the point release separate from versions used to select module configs +$fullver = $ver->[1]; +# The Debian family also includes derivatives with their own release versions +if ($auto && $ver->[2] eq 'debian-linux' && + $os_release =~ /^ID=["']?debian["']?$/m && + open(DEBVER, '<', '/etc/debian_version')) { + # Only use a numeric point release from the detected major version + local $/ = undef; + my $debian_version = ; + close(DEBVER); + my ($major) = split(/\./, $ver->[1]); + $fullver = $1 + if ($debian_version =~ /\A\s*(\Q$major\E\.\d+(?:\.\d+)*)\s*\z/); + } + +# Write the name, config versions and full display version to a file open(OUT, ">$out"); print OUT "os_type='",$ver->[2],"'\n"; print OUT "os_version='",$ver->[3],"'\n"; print OUT "real_os_type='",$ver->[0],"'\n"; print OUT "real_os_version='",$ver->[1],"'\n"; +print OUT "real_os_version_full='",$fullver,"'\n"; close(OUT); # has_command(command) diff --git a/setup.pl b/setup.pl index 22edd1ca7..18570ed5c 100755 --- a/setup.pl +++ b/setup.pl @@ -248,10 +248,12 @@ else { $os_version = $osinfo{'os_version'}; $real_os_type = $osinfo{'real_os_type'}; $real_os_version = $osinfo{'real_os_version'}; + $real_os_version_full = $osinfo{'real_os_version_full'} || $real_os_version; $gconfig{'os_type'} = $os_type; $gconfig{'os_version'} = $os_version; $gconfig{'real_os_type'} = $real_os_type; $gconfig{'real_os_version'} = $real_os_version; + $gconfig{'real_os_version_full'} = $real_os_version_full; $path_separator = $gconfig{'os_type'} eq 'windows' ? ';' : ':'; $null_file = $gconfig{'os_type'} eq 'windows' ? "NUL" : "/dev/null"; unlink($temp); @@ -821,6 +823,7 @@ if (!$upgrading) { $gconfig{'os_version'} = $os_version; $gconfig{'real_os_type'} = $real_os_type; $gconfig{'real_os_version'} = $real_os_version; + $gconfig{'real_os_version_full'} = $real_os_version_full; $gconfig{'logclear'} = 1; $gconfig{'log'} = 1; &write_file("$config_directory/config", \%gconfig); diff --git a/setup.sh b/setup.sh index 1cfbcee7c..f5f109188 100755 --- a/setup.sh +++ b/setup.sh @@ -849,6 +849,7 @@ if [ "$upgrading" != 1 ]; then echo "os_version=$os_version" >> $config_dir/config echo "real_os_type=$real_os_type" >> $config_dir/config echo "real_os_version=$real_os_version" >> $config_dir/config + echo "real_os_version_full=${real_os_version_full:-$real_os_version}" >> $config_dir/config echo "lang=en" >> $config_dir/config # Turn on logging by default diff --git a/system-status/system_info.pl b/system-status/system_info.pl index a704e9699..c4ceebdda 100644 --- a/system-status/system_info.pl +++ b/system-status/system_info.pl @@ -56,7 +56,8 @@ if (&show_section('host')) { my $os = &html_escape($gconfig{'os_version'} eq '*' ? $gconfig{'real_os_type'} : $gconfig{'real_os_type'}.' '. - $gconfig{'real_os_version'}); + ($gconfig{'real_os_version_full'} || + $gconfig{'real_os_version'})); push(@table, { 'desc' => $text{'right_os'}, 'value' => $os . ($eol ? " ($eol)" : "") }); diff --git a/t/webmin-os-version-vm.t b/t/webmin-os-version-vm.t new file mode 100644 index 000000000..cfa84e1b2 --- /dev/null +++ b/t/webmin-os-version-vm.t @@ -0,0 +1,267 @@ +#!/usr/bin/perl +# Run on a Debian Webmin VM with WEBMIN_OS_VERSION_VM_TEST=1. + +use strict; +use warnings; +use Test::More; +use File::Basename qw(dirname); +use File::Copy qw(copy); +use File::Path qw(make_path); +use File::Spec; +use File::Temp qw(tempdir); +use Cwd qw(abs_path); + +plan skip_all => 'requires an explicitly enabled Debian Webmin VM' + if (!$ENV{'WEBMIN_OS_VERSION_VM_TEST'} || $^O ne 'linux'); + +my $source = abs_path(File::Spec->catdir(dirname(__FILE__), '..')); +my ($installed) = grep { -f "$_/WebminCore.pm" } + ('/usr/share/webmin', '/usr/libexec/webmin'); +plan skip_all => 'requires Webmin and /etc/debian_version' + if (!$installed || !-r '/etc/debian_version'); +my $tmp = tempdir('webmin-os-version-XXXXXX', TMPDIR => 1, CLEANUP => 1); +require "$source/t/test-lib.pl"; +my $release = read_text('/etc/debian_version'); +chomp($release); +plan skip_all => 'requires a numeric Debian point release' + if ($release !~ /\A(\d+)\.\d+(?:\.\d+)*\z/); +my $major = $1; +my %base = (os_type => 'debian-linux', os_version => $major, + real_os_type => 'Debian Linux', real_os_version => $major); + +# Use installed Webmin helpers with private configuration and cache files. +make_path("$tmp/config/webmin", "$tmp/var/webmin", "$tmp/root"); +write_text("$tmp/config/config", "lang=en\ntempdir=$tmp/work\n" . + join('', map { "$_=$base{$_}\n" } sort keys %base)); +write_text("$tmp/config/miniserv.conf", "root=$installed\n"); +write_text("$tmp/config/webmin.acl", "root: webmin\n"); +write_text("$tmp/config/webmin/config", ''); +@ENV{qw(WEBMIN_CONFIG WEBMIN_VAR SERVER_ROOT LIBROOT SCRIPT_NAME REMOTE_USER)} = + ("$tmp/config", "$tmp/var", $installed, $installed, + '/webmin/test.cgi', 'root'); +unshift(@INC, $installed, "$installed/vendor_perl"); +chdir("$installed/webmin") or die "chdir: $!"; +do "$source/webmin/webmin-lib.pl"; +die $@ if ($@); +ok(defined(&detect_operating_system), 'loads the changed detection library'); + +our (%gconfig, $root_directory, $config_directory, + $detect_operating_system_cache, $realos_cache_file, + $webmin_yum_repo_file, $webmin_apt_repo_file, $global_apt_repo_file); +copy("$source/oschooser.pl", "$tmp/root/oschooser.pl") or die "copy: $!"; +copy("$source/os_list.txt", "$tmp/root/os_list.txt") or die "copy: $!"; +$root_directory = "$tmp/root"; +$webmin_yum_repo_file = "$tmp/no-yum-repo"; +$webmin_apt_repo_file = "$tmp/no-apt-repo"; +$global_apt_repo_file = "$tmp/no-global-repo"; +my $uptime = 172800; + +# Isolate OS notifications from unrelated modules and remote update checks. +no warnings qw(redefine once); +local *get_system_uptime = sub { return $uptime; }; +local *load_theme_library = sub { }; +local *shared_root_directory = sub { return 0; }; +local *foreign_available = sub { return $_[0] eq 'webmin'; }; +local *foreign_check = sub { return 0; }; +local *foreign_installed = sub { return 0; }; +local *get_module_acl = sub { return (); }; + +# Real detection must retain the existing config versions and add the full one. +my %detected = detect_operating_system(); +is($detected{'real_os_version_full'}, $release, + 'detection reads the installed Debian point release'); +is($detected{'real_os_version'}, $major, + 'detection preserves the existing real OS version'); +is($detected{'os_version'}, $major, + 'detection preserves module configuration matching'); + +# A new cache should be reused, while pre-field caches must be upgraded. +my %old_cache = (%base, time => time()); +write_file($detect_operating_system_cache, \%old_cache); +my %migrated = detect_operating_system(undef, 1); +is($migrated{'real_os_version_full'}, $release, + 'fresh detection cache without the full version is rebuilt'); +my %saved_cache; +read_file($detect_operating_system_cache, \%saved_cache); +is($saved_cache{'real_os_version_full'}, $release, + 'detection cache persists the full version'); +my %cached = (%detected, real_os_version_full => "$major.999", time => time()); +write_file($detect_operating_system_cache, \%cached); +my %reused = detect_operating_system(undef, 1); +is($reused{'real_os_version_full'}, "$major.999", + 'complete fresh detection cache is reused'); + +# Both cache layers must migrate before the dashboard reads the saved field. +write_file($detect_operating_system_cache, \%old_cache); +write_file($realos_cache_file, \%old_cache); +my @notifs = get_webmin_notifications(1); +is($gconfig{'real_os_version_full'}, $release, + 'notification refresh fills the missing global field'); +is_deeply([@gconfig{sort keys %base}], [@base{sort keys %base}], + 'full-version migration preserves all existing OS fields'); +my %saved; +read_file("$config_directory/config", \%saved); +is($saved{'real_os_version_full'}, $release, + 'full version is written to the global config'); +is(scalar(@notifs), 0, 'a display-only change does not prompt for an OS upgrade'); +{ + # Repeated page loads must not rewrite the unchanged global configuration. + my $writer = \&write_file; + my $writes = 0; + local *write_file = sub { + $writes++ if ($_[0] eq "$config_directory/config"); + return $writer->(@_); + }; + get_webmin_notifications(1); + is($writes, 0, 'unchanged full version does not rewrite the config'); +} + +# Expiry and reboot must refresh both caches after a point-release update. +foreach my $case ([ 'daily expiry', 172800, 90000 ], + [ 'reboot', 60, 3600 ]) { + my ($name, $seconds_up, $cache_age) = @$case; + $uptime = $seconds_up; + my $then = time() - $cache_age; + my %stale = (%base, real_os_version_full => "$major.0", time => $then); + $gconfig{'real_os_version_full'} = "$major.0"; + write_file($detect_operating_system_cache, \%stale); + write_file($realos_cache_file, \%stale); + utime($then, $then, $realos_cache_file) or die "utime: $!"; + my @messages = get_webmin_notifications(1); + is($gconfig{'real_os_version_full'}, $release, + "$name refreshes the saved point release"); + is(scalar(@messages), 0, "$name does not prompt for a point release"); + read_file("$config_directory/config", \%saved); + is($saved{'real_os_version_full'}, $release, + "$name persists the refreshed full version"); +} + +# A detected major upgrade must not relabel an unconfirmed configuration. +{ + local %gconfig = (%gconfig, real_os_version => $major - 1, + os_version => $major - 1, real_os_version_full => ($major - 1).'.9'); + my @messages = get_webmin_notifications(1); + is($gconfig{'real_os_version_full'}, ($major - 1).'.9', + 'unconfirmed major upgrade keeps the previous full version'); + like(join('', @messages), qr/fix_os\.cgi/, + 'major upgrade still requires the existing confirmation'); +} + +# Applying detected settings must also pass the field to Usermin. +{ + my %usermin = (%base, real_os_version_full => "$major.0"); + local *foreign_installed = sub { return $_[0] eq 'usermin'; }; + local *foreign_require = sub { }; + local $usermin::usermin_config = "$tmp/usermin-config"; + local *usermin::get_usermin_miniserv_config = sub { }; + local *usermin::get_usermin_config = sub { %{$_[0]} = %usermin; }; + local *usermin::put_usermin_config = sub { + %usermin = %{$_[0]}; + write_file($usermin::usermin_config, \%usermin); + }; + apply_new_os_version(\%detected); + is($gconfig{'real_os_version_full'}, $release, + 'applying detected settings stores the full version in Webmin'); + is($usermin{'real_os_version_full'}, $release, + 'applying detected settings synchronizes the full version to Usermin'); + apply_new_os_version(\%base); + is($gconfig{'real_os_version_full'}, $major, + 'older detection data cannot leave a stale Webmin full version'); + is($usermin{'real_os_version_full'}, $major, + 'older detection data cannot leave a stale Usermin full version'); +} + +# Other distributions and mismatched Debian releases use their detected version. +foreach my $case ([ 'Ubuntu Linux', '24.04', 'debian-linux' ], + [ 'Debian Linux', $major + 1, 'debian-linux' ], + [ 'Rocky Linux', '9.8', 'redhat-linux' ], + [ 'AlmaLinux', '10.3', 'redhat-linux' ]) { + my ($type, $version, $internal) = @$case; + write_text("$tmp/os-list", "$type\t$version\t$internal\t$version\t1\n"); + my %os = detect_operating_system("$tmp/os-list"); + is($os{'real_os_version_full'}, $version, + "$type $version does not borrow the host Debian point release"); +} + +# Full-version detection must not depend on the display name. +write_text("$tmp/os-list", + "Debian GNU/Linux\t$major\tdebian-linux\t$major\t1\n"); +my %renamed = detect_operating_system("$tmp/os-list"); +is($renamed{'real_os_version_full'}, $release, + 'full version uses OS identity rather than the display name'); + +# Exercise the real Rocky and AlmaLinux rules with isolated release files. +make_path("$tmp/releases"); +my $redhat_rules = join("\n", grep { /^(Rocky Linux|AlmaLinux)\t/ } + split(/\n/, read_text("$source/os_list.txt"))) . "\n"; +$redhat_rules =~ s{/etc/}{$tmp/releases/}g; +write_text("$tmp/redhat-os-list", $redhat_rules); +foreach my $distro ([ 'Rocky Linux', 'rocky' ], [ 'AlmaLinux', 'almalinux' ]) { + foreach my $version ('9.8', '10.3') { + foreach my $fallback (0, 1) { + # Each case has either its own release file or the Red Hat fallback. + unlink("$tmp/releases/$_-release") + foreach ('rocky', 'almalinux', 'redhat'); + my $file = $fallback ? 'redhat' : $distro->[1]; + write_text("$tmp/releases/$file-release", + "$distro->[0] release $version (Test)\n"); + my %os = detect_operating_system("$tmp/redhat-os-list"); + is_deeply({ map { $_ => $os{$_} } keys %base, + 'real_os_version_full' }, + { real_os_type => $distro->[0], + os_type => 'redhat-linux', os_version => $version + 8, + real_os_version => $version, + real_os_version_full => $version }, + "$distro->[0] $version keeps point and config versions from $file-release"); + } + } +} + +# The settings pages must update or reset the field along with OS selections. +{ + our (%in, %access, %uconfig, $usermin_config, $usermin_miniserv_config); + local $INC{'./webmin-lib.pl'} = 1; + local $INC{'./usermin-lib.pl'} = 1; + local $ENV{'MINISERV_CONFIG'} = "$tmp/settings-miniserv"; + local $usermin_config = "$tmp/usermin-settings"; + local $usermin_miniserv_config = "$tmp/usermin-miniserv"; + local %access = (os => 1); + local *ReadParse = sub { }; + local *get_miniserv_config = sub { %{$_[0]} = (); }; + local *put_miniserv_config = sub { }; + local *show_restart_page = sub { }; + local *webmin_log = sub { }; + local *redirect = sub { }; + local *restart_usermin_miniserv = sub { }; + local *get_usermin_config = sub { }; + local *get_usermin_miniserv_config = sub { + %{$_[0]} = (root => "$tmp/root"); + }; + local *put_usermin_config = sub { + write_file($usermin_config, $_[0]); + }; + local *put_usermin_miniserv_config = sub { }; + local *list_operating_systems = sub { return (); }; + local *webmin::list_operating_systems = \&list_operating_systems; + local *webmin::detect_operating_system = \&detect_operating_system; + foreach my $module ('webmin', 'usermin') { + foreach my $mode ('automatic', 'manual', 'unchanged') { + local %gconfig = (%gconfig, %base, real_os_version_full => $release); + local %uconfig = %gconfig; + my $version = $mode eq 'manual' ? $major - 1 : $major; + local %in = (update => $mode eq 'automatic', + type => 'Debian Linux', version => $version, + itype => 'debian-linux', iversion => $version); + do "$source/$module/change_os.cgi"; + die $@ if ($@); + my %stored; + read_file($module eq 'webmin' ? "$config_directory/config" : + $usermin_config, \%stored); + is($stored{'real_os_version_full'}, + $mode eq 'manual' ? $version : $release, + "$module $mode settings keep the full version consistent"); + } + } +} + +done_testing(); diff --git a/usermin/change_os.cgi b/usermin/change_os.cgi index b68440a42..f439375d8 100755 --- a/usermin/change_os.cgi +++ b/usermin/change_os.cgi @@ -15,6 +15,8 @@ if ($in{'update'}) { %osinfo = &webmin::detect_operating_system($osfile); $uconfig{'real_os_type'} = $osinfo{'real_os_type'}; $uconfig{'real_os_version'} = $osinfo{'real_os_version'}; + $uconfig{'real_os_version_full'} = $osinfo{'real_os_version_full'} || + $osinfo{'real_os_version'}; $uconfig{'os_type'} = $osinfo{'os_type'}; $uconfig{'os_version'} = $osinfo{'os_version'}; } @@ -26,6 +28,8 @@ elsif ($in{'type'} ne $uconfig{'real_os_type'} || $uconfig{'real_os_type'} = $in{'type'}; $in{'version'} || &error($text{'os_eversion'}); $uconfig{'real_os_version'} = $in{'version'}; + # Discard the detected display version when OS settings are overridden + $uconfig{'real_os_version_full'} = $in{'version'}; ($os) = grep { $_->{'realtype'} eq $in{'type'} } &webmin::list_operating_systems($osfile); $uconfig{'os_type'} = $in{'itype'}; diff --git a/webmin/change_os.cgi b/webmin/change_os.cgi index 4a27af0df..5d5c695ac 100755 --- a/webmin/change_os.cgi +++ b/webmin/change_os.cgi @@ -11,6 +11,8 @@ if ($in{'update'}) { %osinfo = &detect_operating_system(); $gconfig{'real_os_type'} = $osinfo{'real_os_type'}; $gconfig{'real_os_version'} = $osinfo{'real_os_version'}; + $gconfig{'real_os_version_full'} = $osinfo{'real_os_version_full'} || + $osinfo{'real_os_version'}; $gconfig{'os_type'} = $osinfo{'os_type'}; $gconfig{'os_version'} = $osinfo{'os_version'}; } @@ -22,6 +24,8 @@ elsif ($in{'type'} ne $gconfig{'real_os_type'} || $gconfig{'real_os_type'} = $in{'type'}; $in{'version'} || &error($text{'os_eversion'}); $gconfig{'real_os_version'} = $in{'version'}; + # Discard the detected display version when OS settings are overridden + $gconfig{'real_os_version_full'} = $in{'version'}; ($os) = grep { $_->{'realtype'} eq $in{'type'} } &list_operating_systems(); $gconfig{'os_type'} = $in{'itype'}; diff --git a/webmin/webmin-lib.pl b/webmin/webmin-lib.pl index 5398ea1e5..0935866a3 100755 --- a/webmin/webmin-lib.pl +++ b/webmin/webmin-lib.pl @@ -1383,8 +1383,9 @@ else { =head2 detect_operating_system([os-list-file], [with-cache]) -Returns a hash containing os_type, os_version, real_os_type and -real_os_version, suitable for the current system. +Returns a hash containing os_type, os_version, real_os_type, real_os_version +and real_os_version_full for the current system. The full version includes +the point release for display without changing configuration version matching. =cut sub detect_operating_system @@ -1393,13 +1394,14 @@ my $file = $_[0] || "$root_directory/os_list.txt"; my $cache = $_[1]; if ($cache) { # Check the cache file, and only re-check the OS if older than - # 1 day, or if we have rebooted recently + # 1 day, if we have rebooted recently, or if the full version is missing my %cache; my $uptime = &get_system_uptime(); my $lastreboot = $uptime ? time()-$uptime : undef; if (&read_file($detect_operating_system_cache, \%cache) && $cache{'os_type'} && $cache{'os_version'} && - $cache{'real_os_type'} && $cache{'real_os_version'}) { + $cache{'real_os_type'} && $cache{'real_os_version'} && + $cache{'real_os_version_full'}) { if ($cache{'time'} > time()-24*60*60 && $cache{'time'} > $lastreboot) { return %cache; @@ -1449,30 +1451,33 @@ my %miniserv; &get_miniserv_config(\%miniserv); &load_theme_library(); # So that UI functions work -# Need OS upgrade, but only once per day or if the system was rebooted +# Refresh OS details daily, after a reboot, or when the full version is missing my $now = time(); my $uptime = &get_system_uptime(); if (&foreign_available("webmin")) { my %realos; my @st = stat($realos_cache_file); - if (!@st || $now - $st[9] > 24*60*60 || + &read_file($realos_cache_file, \%realos); + if (!@st || !$realos{'real_os_version_full'} || + $now - $st[9] > 24*60*60 || $uptime && $now - $st[9] > $uptime) { %realos = &detect_operating_system(undef, 1); &write_file($realos_cache_file, \%realos); } - else { - &read_file($realos_cache_file, \%realos); - } my $new_real = $realos{'real_os_version'}; my $old_real = $gconfig{'real_os_version'}; $new_real =~ s/\.\d+$//; $old_real =~ s/\.\d+$//; if ($realos{'real_os_type'} eq $gconfig{'real_os_type'} && $new_real eq $old_real && - $realos{'real_os_version'} ne $gconfig{'real_os_version'}) { - # Only the minor OS version has changed, just silently update it + ($realos{'real_os_version'} ne $gconfig{'real_os_version'} || + $realos{'real_os_version_full'} && + $realos{'real_os_version_full'} ne $gconfig{'real_os_version_full'})) { + # Save minor and display version changes without an OS upgrade prompt &lock_file("$config_directory/config"); $gconfig{'real_os_version'} = $realos{'real_os_version'}; + $gconfig{'real_os_version_full'} = $realos{'real_os_version_full'} || + $realos{'real_os_version'}; $gconfig{'os_version'} = $realos{'os_version'}; &write_file("$config_directory/config", \%gconfig); &unlock_file("$config_directory/config"); @@ -2908,6 +2913,8 @@ my %osinfo = %{$_[0]}; &lock_file("$config_directory/config"); $gconfig{'real_os_type'} = $osinfo{'real_os_type'}; $gconfig{'real_os_version'} = $osinfo{'real_os_version'}; +$gconfig{'real_os_version_full'} = $osinfo{'real_os_version_full'} || + $osinfo{'real_os_version'}; $gconfig{'os_type'} = $osinfo{'os_type'}; $gconfig{'os_version'} = $osinfo{'os_version'}; foreach my $key ('os_eol_db', 'os_eol_expired', @@ -2927,6 +2934,8 @@ if (&foreign_installed("usermin")) { &usermin::get_usermin_config(\%uconfig); $uconfig{'real_os_type'} = $osinfo{'real_os_type'}; $uconfig{'real_os_version'} = $osinfo{'real_os_version'}; + $uconfig{'real_os_version_full'} = $osinfo{'real_os_version_full'} || + $osinfo{'real_os_version'}; $uconfig{'os_type'} = $osinfo{'os_type'}; $uconfig{'os_version'} = $osinfo{'os_version'}; &usermin::put_usermin_config(\%uconfig);