diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b1ae06b..db4f438ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Changelog #### 2.654 (August, 2026) * Add incremental ban time options to the Fail2Ban module +* Add Btrfs subvolume quota management to the Disk Quotas module, with full and simple accounting modes +* Add support for creating `vfsv1` Linux quota files for limits above 4 TiB, while preserving existing quota file formats +* Add support for openSUSE 16 vendor and local Logrotate configuration overlays [#2682](https://github.com/webmin/webmin/issues/2682) * Fix to ignore failures when adding IPv6 link-local (fe80::) addresses that may already be configured automatically * Fixed creation of permissions new log files in the System Logs module (thanks to Kevin Carter) * Update the Authentic theme to the latest version with various improvements: diff --git a/firewall/firewall-lib.pl b/firewall/firewall-lib.pl index 7b3b5c069..f66adda43 100755 --- a/firewall/firewall-lib.pl +++ b/firewall/firewall-lib.pl @@ -527,7 +527,7 @@ local @oldjumps = grep { $_->{'chain'} eq 'INPUT' && # Get all new fail2ban chain rules and inputs that jump to them local @newrules = grep { $_->{'chain'} =~ /^f2b-/ } @$newrules; local @newjumps = grep { $_->{'chain'} eq 'INPUT' && - $_->{'j'}->[1] =~ /^f2b-/ } @newrules; + $_->{'j'}->[1] =~ /^f2b-/ } @$newrules; # Re-create the chains my @oldchains = &unique(map { $_->{'chain'} } @oldrules); diff --git a/lang/en b/lang/en index be1fa3b29..2247b5102 100644 --- a/lang/en +++ b/lang/en @@ -195,6 +195,7 @@ pam_restart=Restart login_notsecure=Not Secure login_notsecure_desc=This connection is not secure and could let a man-in-the-middle attack intercept your password or session cookie. Click here to switch to an HTTPS connection, unless you are on a trusted local network or behind a secure reverse proxy. +login_notsecure_http_desc=This connection is not encrypted and could let a man-in-the-middle attack intercept your password or session cookie. HTTPS is not enabled in Webmin; enable it unless you are on a trusted local network or behind a secure reverse proxy. acl_root=Root directory for file chooser acl_otherdirs=Other visible directories in file chooser diff --git a/login-lib.pl b/login-lib.pl new file mode 100755 index 000000000..eb22a83b6 --- /dev/null +++ b/login-lib.pl @@ -0,0 +1,27 @@ +# login-lib.pl +# Common functions for the built-in login pages. + +# get_login_http_warning(&miniserv-config) +# Returns the insecure-login warning HTML, or undef if none is needed +sub get_login_http_warning +{ +my ($miniserv) = @_; +return undef if ($ENV{'HTTPS'} eq 'ON' || + (!$miniserv->{'ssl'} && $miniserv->{'no_ssl_warn'})); + +my $warning = "⚠ $text{'login_notsecure'}"; +my $description = $text{'login_notsecure_http_desc'}; +if ($miniserv->{'ssl'}) { + $warning = ui_tag('a', $warning, + { 'href' => "javascript:void(0);", + 'class' => 'inherit-color', + 'onclick' => "window.location.href = ". + "window.location.href.replace(/^http:/, 'https:'); return false;", + }); + $description = $text{'login_notsecure_desc'}; + } +return ui_tag('span', $warning, + { class => 'not-secure', title => $description }); +} + +1; diff --git a/logrotate/backup_config.pl b/logrotate/backup_config.pl index 9a8c1be45..d25f2f837 100755 --- a/logrotate/backup_config.pl +++ b/logrotate/backup_config.pl @@ -5,8 +5,18 @@ do 'logrotate-lib.pl'; # Returns files and directories that can be backed up sub backup_config_files { -local $conf = &get_config(); -return &unique(map { $_->{'file'} } @$conf); +# Keep backup behavior unchanged on systems without the vendor overlay. +if (!$config{'vendor_logrotate_conf'} && !$config{'vendor_add_file'}) { + local $conf = &get_config(); + return &unique(map { $_->{'file'} } @$conf); + } + +# Back up only writable files. Use the complete effective file list so an +# empty local file that intentionally shadows a vendor file is preserved. +local ($conf, $lnum, $files) = &get_config(); +return &unique(grep { !&is_vendor_main_config($_) && + !&is_vendor_config_file($_) } + @$files); } # pre_backup(&files) diff --git a/logrotate/config-openSUSE-Linux-16.0-ALL b/logrotate/config-openSUSE-Linux-16.0-ALL index 6ddca8e5c..af724fb7f 100644 --- a/logrotate/config-openSUSE-Linux-16.0-ALL +++ b/logrotate/config-openSUSE-Linux-16.0-ALL @@ -1,5 +1,8 @@ sort_mode=0 logrotate_conf=/etc/logrotate.conf +vendor_logrotate_conf=/usr/etc/logrotate.conf add_file=/etc/logrotate.d +vendor_add_file=/usr/etc/logrotate.d scan_add_file=1 logrotate=logrotate +logrotate_all=/usr/sbin/logrotate-all diff --git a/logrotate/delete_logs.cgi b/logrotate/delete_logs.cgi index ea0af75f2..bf6477aaf 100755 --- a/logrotate/delete_logs.cgi +++ b/logrotate/delete_logs.cgi @@ -12,6 +12,19 @@ require './logrotate-lib.pl'; # Delete the sections $parent = &get_config_parent(); $conf = $parent->{'members'}; + +# Copy each selected vendor file to the local override tree before changing +# it. Reload the parsed configuration after copying so all line references +# point at the writable files. +%vendor_files = map { $conf->[$_]->{'file'}, 1 } + grep { &is_vendor_config_file($conf->[$_]->{'file'}) } @d; +if (%vendor_files) { + foreach $f (keys %vendor_files) { + &ensure_local_config_override($f); + } + $parent = &get_config_parent(); + $conf = $parent->{'members'}; + } foreach $d (sort { $b <=> $a } @d) { $log = $conf->[$d]; &lock_file($log->{'file'}); diff --git a/logrotate/edit_log.cgi b/logrotate/edit_log.cgi index 5124ec254..6ef61531d 100755 --- a/logrotate/edit_log.cgi +++ b/logrotate/edit_log.cgi @@ -20,6 +20,23 @@ else { $lconf = $log->{'members'}; } +# Explain which side of the vendor/local overlay is displayed and where a +# copy-on-write edit will be saved before presenting the form. +if ($in{'global'} && &is_vendor_main_config(&get_main_config_file())) { + print &ui_alert_box(&text('global_vendor', + "".&html_escape($config{'logrotate_conf'}).""), + 'info'); + } +elsif ($log && &is_vendor_config_file($log->{'file'})) { + print &ui_alert_box(&text('edit_vendor', + "".&html_escape(&get_local_override_file( + $log->{'file'})).""), 'info'); + } +elsif ($log && (my $vendor = &get_vendor_config_file($log->{'file'}))) { + print &ui_alert_box(&text('edit_override', + "".&html_escape($vendor).""), 'info'); + } + print &ui_form_start("save_log.cgi", "post", undef, "id='edit_log_form'"); print &ui_hidden("new", $in{'new'}),"\n"; print &ui_hidden("idx", $in{'idx'}),"\n"; diff --git a/logrotate/edit_sched.cgi b/logrotate/edit_sched.cgi index 97bfb8d02..61d64d9dd 100755 --- a/logrotate/edit_sched.cgi +++ b/logrotate/edit_sched.cgi @@ -5,7 +5,13 @@ require './logrotate-lib.pl'; &ui_print_header(undef, $text{'sched_title'}, ""); -print "

",&text('sched_desc', "$config{'logrotate'}"),"

\n"; +# Show the wrapper or fallback command on vendor-overlay systems, while +# retaining the original short program name everywhere else. +my $sched_command = ($config{'logrotate_all'} || + $config{'vendor_logrotate_conf'} || $config{'vendor_add_file'}) ? + &get_scheduled_logrotate_command() : $config{'logrotate'}; +print "

",&text('sched_desc', "". + &html_escape($sched_command).""),"

\n"; # Find the job, looking in daily directories too &foreign_require("cron", "cron-lib.pl"); diff --git a/logrotate/force.cgi b/logrotate/force.cgi index 0a8bbf2d1..c1fde6870 100755 --- a/logrotate/force.cgi +++ b/logrotate/force.cgi @@ -10,8 +10,12 @@ $SIG{'TERM'} = 'IGNORE'; print $text{'force_doing'},"\n"; &clean_environment(); -my (undef, undef, $files) = &get_config($config{'logrotate_conf'}); -my @configs = ($config{'logrotate_conf'}, &get_add_file_configs($files)); + +# Force the same effective main and drop-in configs selected by the distro +# wrapper, while avoiding duplicate files already reached through includes. +my $main = &get_main_config_file(); +my (undef, undef, $files) = &get_config($main); +my @configs = ($main, &get_add_file_configs($files)); my $configs = join(" ", map { "e_path($_) } @configs); $out = &backquote_logged("$config{'logrotate'} -f $configs 2>&1"); &reset_environment(); diff --git a/logrotate/index.cgi b/logrotate/index.cgi index 7ee559eb6..802205c62 100755 --- a/logrotate/index.cgi +++ b/logrotate/index.cgi @@ -19,11 +19,12 @@ if (!&has_command($config{'logrotate'})) { &ui_print_footer("/", $text{'index'}); exit; } -if (!-r $config{'logrotate_conf'}) { +my $main_config = &get_main_config_file(); +if (!-r $main_config) { &ui_print_header(undef, $text{'index_title'}, "", "intro", 1, 1); &ui_print_endpage( &ui_config_link('index_econf', - [ "$config{'logrotate_conf'}", undef ])); + [ "$main_config", undef ])); } # Get the version @@ -52,9 +53,19 @@ foreach $c ($config{'sort_mode'} ? local $p = &get_period($c->{'members'}) || $defp; local $r = &find_value("postrotate", $c->{'members'}); $r =~ s/\n/
\n/g; + local $label = join(" ", map { "$_
" } + @{$c->{'name'}}); + + # Distinguish read-only vendor entries from writable local files + # that shadow a vendor entry at the same relative path. + if (&is_vendor_config_file($c->{'file'})) { + $label .= "$text{'index_vendor'}"; + } + elsif (&get_vendor_config_file($c->{'file'})) { + $label .= "$text{'index_override'}"; + } push(@table, [ &ui_link("edit_log.cgi?idx=".$c->{'index'}, - join(" ", map { "$_
" } - @{$c->{'name'}}) ), + $label), $text{'period_'.$p} || "$text{'index_notset'}", $r ? "$r" diff --git a/logrotate/install_check.pl b/logrotate/install_check.pl index 60a66a796..0d5ca01c0 100755 --- a/logrotate/install_check.pl +++ b/logrotate/install_check.pl @@ -6,7 +6,9 @@ do 'logrotate-lib.pl'; # For mode 0, returns 1 if installed, 0 if not. sub is_installed { -return 0 if (!-r $config{'logrotate_conf'} && !-r $config{'sample_conf'}); +# Accept the vendor main config when the optional local override is absent. +my $main = &get_main_config_file(); +return 0 if (!-r $main && !-r $config{'sample_conf'}); return 0 if (!&has_command($config{'logrotate'})); return $_[0] ? 2 : 1; } diff --git a/logrotate/lang/en b/logrotate/lang/en index 8d57c99be..9cc0c7d95 100644 --- a/logrotate/lang/en +++ b/logrotate/lang/en @@ -19,6 +19,8 @@ index_force=Force Log Rotation index_forcedesc=Force the immediate rotation of all log files, even if it is not yet time. index_logrotate=Logrotate index_delete=Delete Selected Log Rotations +index_vendor=Vendor configuration +index_override=Local vendor override period_daily=Daily period_weekly=Weekly @@ -65,11 +67,17 @@ edit_default=Default ($1) edit_sharedscripts=Only run scripts once for all files? edit_now=Rotate Now edit_clone=Clone +edit_vendor=This is a vendor-provided configuration. Saving or deleting it will first create the local override $1; the file under /usr/etc will not be changed. +edit_override=This local configuration overrides the vendor file $1. global_title=Global Options global_header=Default options for all log files +global_vendor=These defaults currently come from the vendor configuration. Saving will first create the writable local configuration $1; the file under /usr/etc will not be changed. save_err=Failed to save log +save_eoverride=Cannot create local override $1 because that path already exists and is not a regular file. +save_ecopy=Failed to create local override $1: $2 +save_evendorwrite=Refusing to modify vendor configuration $1 without first creating a local override. save_efile=Missing or invalid log filename save_esize=Missing or invalid maximum size save_eminsize=Missing or invalid minimum size diff --git a/logrotate/logrotate-lib.pl b/logrotate/logrotate-lib.pl index 907f95975..e02dbae51 100755 --- a/logrotate/logrotate-lib.pl +++ b/logrotate/logrotate-lib.pl @@ -15,10 +15,15 @@ if (!-r $config{'logrotate_conf'} && -r $config{'sample_conf'}) { ©_source_dest($config{'sample_conf'}, $config{'logrotate_conf'}); } +# get_config_parent() +# Returns the parsed global config while keeping the writable local file as +# its save target. Callers must materialize that file before global writes. sub get_config_parent { if (!$get_config_parent_cache) { local ($conf, $lines) = &get_config(); + # Even when members came from the vendor config, never make /usr the + # destination for newly-added global directives. $get_config_parent_cache = { 'members' => $conf, 'file' => $config{'logrotate_conf'}, 'line' => 0, @@ -28,28 +33,235 @@ if (!$get_config_parent_cache) { return $get_config_parent_cache; } +# get_main_config_file() +# Returns the local main config, or the vendor default if no local one exists +sub get_main_config_file +{ +return $config{'logrotate_conf'} if (-e $config{'logrotate_conf'}); +return $config{'vendor_logrotate_conf'} + if ($config{'vendor_logrotate_conf'}); +return $config{'logrotate_conf'}; +} + +# is_vendor_main_config(file) +# Returns 1 if a file is the vendor-provided main config +sub is_vendor_main_config +{ +my ($file) = @_; +return $config{'vendor_logrotate_conf'} && + &same_file($file, $config{'vendor_logrotate_conf'}); +} + +# relative_config_path(file, directory) +# Returns a file's path relative to a config directory +sub relative_config_path +{ +my ($file, $dir) = @_; +return undef if (!$file || !$dir); +$dir =~ s/\/+$//; +$dir .= '/'; +return $file =~ /^\Q$dir\E(.+)$/ ? $1 : undef; +} + +# is_vendor_config_file(file) +# Returns 1 if a drop-in comes from the vendor directory +sub is_vendor_config_file +{ +my ($file) = @_; +return defined(&relative_config_path( + $file, $config{'vendor_add_file'})); +} + +# get_local_override_file(vendor-file) +# Returns the local path that overrides a vendor drop-in +sub get_local_override_file +{ +my ($file) = @_; +my $rel = &relative_config_path($file, $config{'vendor_add_file'}); +return undef if (!defined($rel) || !$config{'add_file'}); +return $config{'add_file'}.'/'.$rel; +} + +# get_vendor_config_file(local-file) +# Returns the vendor file shadowed by a local drop-in, if any +sub get_vendor_config_file +{ +my ($file) = @_; +my $rel = &relative_config_path($file, $config{'add_file'}); +return undef if (!defined($rel) || !$config{'vendor_add_file'}); +my $vendor = $config{'vendor_add_file'}.'/'.$rel; +return -f $vendor ? $vendor : undef; +} + +# flush_logrotate_config_cache() +# Clears parsed config state after creating a local override +sub flush_logrotate_config_cache +{ +%get_config_cache = ( ); +%get_config_lnum_cache = ( ); +%get_config_files_cache = ( ); +$get_config_parent_cache = undef; +} + +# copy_vendor_config(source, destination) +# Copies a vendor config to the writable local tree +sub copy_vendor_config +{ +my ($source, $dest) = @_; + +# An existing independent regular destination is already a usable override. +# Refuse links to the vendor file, symlinks, and other non-regular file types +# so the local path cannot redirect writes back into the read-only tree. +if (-e $dest || -l $dest) { + if (-f $dest && !-l $dest) { + if (&same_file($source, $dest)) { + &error(&text('save_evendorwrite', "". + &html_escape($source)."")); + } + else { + &flush_logrotate_config_cache(); + return $dest; + } + } + &error(&text('save_eoverride', "". + &html_escape($dest)."")); + } + +# Create missing subdirectories before copying the complete vendor file. +# Following a source symlink produces an editable snapshot, not another link. +my $dir = $dest; +$dir =~ s/\/[^\/]+$//; +&make_dir_recursive($dir, 0755) if (!-d $dir); +my ($ok, $err) = ©_source_dest($source, $dest, 1); + +# Do not leave a partial override behind after a copy or chmod failure, since +# even an incomplete local file would hide the valid vendor configuration. +if (!$ok || !&set_ownership_permissions(undef, undef, 0644, $dest)) { + $err ||= $!; + &unlink_file($dest) if (-e $dest || -l $dest); + &error(&text('save_ecopy', "".&html_escape($dest)."", + &html_escape($err))); + } + +# Force the next read to select and parse the newly-created local file. +&flush_logrotate_config_cache(); +return $dest; +} + +# ensure_local_main_config() +# Creates a writable local main config when only the vendor default exists +sub ensure_local_main_config +{ +my $main = &get_main_config_file(); +return $config{'logrotate_conf'} + if (!&is_vendor_main_config($main)); +return ©_vendor_config($main, $config{'logrotate_conf'}); +} + +# ensure_local_config_override(vendor-file) +# Creates a writable local copy that shadows a vendor drop-in +sub ensure_local_config_override +{ +my ($file) = @_; +my $local = &get_local_override_file($file); +return $file if (!$local); +return ©_vendor_config($file, $local); +} + +# list_config_dir_files(directory, [relative-subdirectory]) +# Returns relative and absolute paths for regular files below a directory +sub list_config_dir_files +{ +my ($dir, $subdir) = @_; +my $path = $subdir ? $dir.'/'.$subdir : $dir; +opendir(my $dh, $path) || return ( ); +my @names = sort { $a cmp $b } readdir($dh); +closedir($dh); +my @rv; +foreach my $name (@names) { + next if ($name eq '.' || $name eq '..'); + my $rel = $subdir ? $subdir.'/'.$name : $name; + my $file = $dir.'/'.$rel; + + # Match find without -L: ignore symlinks, recurse into real directories, + # and return only regular files with paths relative to the scanned root. + next if (-l $file); + if (-d $file) { + push(@rv, &list_config_dir_files($dir, $rel)); + } + elsif (-f $file) { + push(@rv, [ $rel, $file ]); + } + } +return @rv; +} + # get_add_file_configs([&already-loaded-files]) -# Returns configs loaded externally from the add-file directory +# Returns the effective vendor and local configs loaded by logrotate-all sub get_add_file_configs { my ($files) = @_; -return ( ) if (!$config{'scan_add_file'} || !$config{'add_file'} || - !-d $config{'add_file'}); +return ( ) if (!$config{'scan_add_file'}); + +# Collect the same relative names produced by the wrapper's recursive find. +# Processing the local tree last records its regular files directly. +my %effective; +foreach my $dir ($config{'vendor_add_file'}, $config{'add_file'}) { + next if (!$dir || !-d $dir); + foreach my $entry (&list_config_dir_files($dir)) { + $effective{$entry->[0]} = $entry->[1]; + } + } + +# Match the wrapper's stable lexical order and omit files already reached by +# an explicit include in the main configuration. The existence check also +# honors a local non-regular counterpart exactly as the wrapper does. my @rv; -foreach my $f (glob("$config{'add_file'}/*")) { - next if (!-f $f || $files && +foreach my $name (sort { $a cmp $b } keys %effective) { + my $local = $config{'add_file'} ? + $config{'add_file'}.'/'.$name : undef; + my $f = $local && -e $local ? $local : $effective{$name}; + next if ($files && grep { &same_file($_, $f) } @$files); push(@rv, $f); } return @rv; } +# get_scheduled_logrotate_command() +# Returns the distro wrapper, or a command for the effective config files +sub get_scheduled_logrotate_command +{ +# The distro wrapper discovers the effective drop-in set on every run, so it +# remains correct when packages or administrators add files later. +if ($config{'logrotate_all'} && -x $config{'logrotate_all'}) { + return "e_path($config{'logrotate_all'}); + } + +# Preserve the historical command exactly on systems that do not opt into +# external or vendor configuration discovery. +if (!$config{'vendor_logrotate_conf'} && !$config{'vendor_add_file'} && + !$config{'scan_add_file'}) { + return &has_command($config{'logrotate'})." ". + $config{'logrotate_conf'}; + } + +# If the configured wrapper is unavailable, build a usable command from the +# effective main config and the drop-ins visible at schedule creation time. +my $main = &get_main_config_file(); +my (undef, undef, $files) = &get_config($main); +my @configs = ($main, &get_add_file_configs($files)); +my $program = &has_command($config{'logrotate'}) || $config{'logrotate'}; +return "e_path($program).' '. + join(' ', map { "e_path($_) } @configs); +} + # get_config([file]) # Returns a list of logrotate config file entries sub get_config { my ($argfile) = @_; -my $file = $argfile || $config{'logrotate_conf'}; +my $file = $argfile || &get_main_config_file(); if (!$argfile && $get_config_cache{$file}) { return wantarray ? ( $get_config_cache{$file}, $get_config_lnum_cache{$file}, @@ -210,17 +422,57 @@ return undef; } # save_directive(&parent, &old|name, &new, [indent]) -# Update a single entry in the config, identified by either name or -# the direcctive being replaced +# Updates one entry identified by either its name or parsed object sub save_directive { my ($parent, $oldv, $newv, $indent) = @_; my $conf = $parent->{'members'}; my $old = !defined($oldv) ? undef : ref($oldv) ? $oldv : &find($oldv, $conf); -my $lref = &read_file_lines($old ? $old->{'file'} : $parent->{'file'}); my $new = !defined($newv) ? undef : ref($newv) ? $newv : { 'name' => $old ? $old->{'name'} : $oldv, 'value' => $newv }; + +# Deleting an entry that is already absent is a true no-op. In particular, +# do not put a missing local main config into the writable line cache. +return if (!$old && !$new); + +# Find the file behind this write. Existing directives use their own file, +# new sections may name a separate file, and other additions use the parent +# section or effective main config. +my $vendor_file; +my $write_file = $parent->{'file'}; +if ($old) { + $write_file = $old->{'file'}; + } +elsif ($new && $new->{'file'} && + !($parent->{'global'} && !$new->{'members'})) { + $write_file = $new->{'file'}; + } +if ($write_file) { + my $shadowed_vendor = &get_vendor_config_file($write_file); + if (&is_vendor_main_config($write_file) || + &is_vendor_config_file($write_file)) { + $vendor_file = $write_file; + } + elsif ($shadowed_vendor && + (!-f $write_file || -l $write_file || + &same_file($write_file, $shadowed_vendor))) { + $vendor_file = $shadowed_vendor; + } + } +if (!$vendor_file && !$old && $parent->{'global'} && + &same_file($write_file, $parent->{'file'}) && + &is_vendor_main_config(&get_main_config_file())) { + $vendor_file = &get_main_config_file(); + } + +# Copying changes which file owns the parsed objects, so callers must create +# and reload a local override before editing. Never write through a stale +# object that still points at the vendor tree. +&error(&text('save_evendorwrite', + "".&html_escape($vendor_file)."")) if ($vendor_file); + +my $lref = &read_file_lines($write_file); my @lines = &directive_lines($new, $indent) if ($new); my $gparent = &get_config_parent(); if ($old && $new) { @@ -324,10 +576,12 @@ return @rv; } # delete_if_empty(file) -# Remove a file if it has no more lines in the config +# Removes a file if it has no more parsed entries, unless it is a local +# override whose continued existence is needed to hide a vendor file sub delete_if_empty { my ($file) = @_; +return if (&get_vendor_config_file($file)); my $conf = &get_config(); my %files = map { $_, 1 } &unique(map { $_->{'file'} } @$conf); &unlink_file($file) if (!$files{$file}); diff --git a/logrotate/save_log.cgi b/logrotate/save_log.cgi index ad3a194b9..0be324331 100755 --- a/logrotate/save_log.cgi +++ b/logrotate/save_log.cgi @@ -4,18 +4,56 @@ require './logrotate-lib.pl'; &ReadParse(); + +# Resolve a new section's destination before loading any parsed objects. If +# its relative name already exists in the vendor tree, materialize the whole +# local override before appending the new section. +@files = split(/\s+/, $in{'file'}); +if ($in{'new'} || + (!$in{'global'} && !$in{'delete'} && !$in{'now'})) { + &error_setup($text{'save_err'}); + foreach $f (@files) { + $f =~ /^\/\S+$/ || &error($text{'save_efile'}); + } + @files || &error($text{'save_enofiles'}); + $in{'file'} =~ s/\r//g; + } +if ($in{'new'}) { + $cfilename = $files[0] =~ /\/([^\/]+)$/ ? $1 : undef; + $new_config_file = &get_add_file($cfilename); + $vendor_file = &get_vendor_config_file($new_config_file); + if ($vendor_file) { + &ensure_local_config_override($vendor_file); + } + elsif (&same_file($new_config_file, $config{'logrotate_conf'}) && + &is_vendor_main_config(&get_main_config_file())) { + &ensure_local_main_config(); + } + } + +# On systems with vendor configuration below /usr, create the writable local +# main config before changing global options. The parent object intentionally +# keeps this local path as its write destination. +&ensure_local_main_config() if ($in{'global'}); $parent = &get_config_parent(); $conf = $parent->{'members'}; -@files = split(/\s+/, $in{'file'}); + +# A local drop-in shadows the whole vendor file, so copy it intact before +# editing or deleting one section. Rotate Now is read-only and needs no copy. +if (!$in{'global'} && !$in{'new'} && !$in{'now'} && + &is_vendor_config_file($conf->[$in{'idx'}]->{'file'})) { + &ensure_local_config_override($conf->[$in{'idx'}]->{'file'}); + $parent = &get_config_parent(); + $conf = $parent->{'members'}; + } if ($in{'global'}) { # Editing the global options $log = $parent; } elsif ($in{'new'}) { # Adding a new section - $cfilename = $files[0] =~ /\/([^\/]+)$/ ? $1 : undef; $log = { 'members' => [ ], - 'file' => &get_add_file($cfilename) }; + 'file' => $new_config_file }; $logfile = $in{'file'}; } else { @@ -52,11 +90,6 @@ else { &lock_file($log->{'file'}); &error_setup($text{'save_err'}); if (!$in{'global'}) { - foreach $f (@files) { - $f =~ /^\/\S+$/ || &error($text{'save_efile'}); - } - @files || &error($text{'save_enofiles'}); - $in{'file'} =~ s/\r//g; $log->{'name'} = [ split(/\n/, $in{'file'}) ]; } diff --git a/logrotate/save_sched.cgi b/logrotate/save_sched.cgi index 23a6b027f..c1688eed5 100755 --- a/logrotate/save_sched.cgi +++ b/logrotate/save_sched.cgi @@ -11,9 +11,10 @@ if ($in{'idx'} ne "") { $oldjob = $job = $jobs[$in{'idx'}]; } else { + # Prefer the distro wrapper, when available, so future runs discover the + # then-current vendor and local drop-in set. $job = { 'user' => 'root', - 'command' => &has_command($config{'logrotate'})." ". - $config{'logrotate_conf'}, + 'command' => &get_scheduled_logrotate_command(), 'active' => 1 }; } &lock_file(&cron::cron_file($job)); diff --git a/logrotate/t/run-tests.t b/logrotate/t/run-tests.t index e5efba992..9ab92d237 100644 --- a/logrotate/t/run-tests.t +++ b/logrotate/t/run-tests.t @@ -7,34 +7,55 @@ use File::Basename qw(dirname); use File::Path qw(make_path); use File::Temp qw(tempdir); -my $module_dir = abs_path(dirname(abs_path($0))."/.."); +# Locate the repository and load its common test helpers. +my $test_dir = dirname(abs_path($0)); +my $module_dir = abs_path("$test_dir/.."); my $root_dir = abs_path("$module_dir/.."); +require "$root_dir/t/test-lib.pl"; + +# Build an isolated openSUSE-style /etc and /usr/etc configuration layout. my $config_dir = tempdir(CLEANUP => 1); my $var_dir = tempdir(CLEANUP => 1); my $fixture_dir = tempdir(CLEANUP => 1); -my $add_dir = "$fixture_dir/logrotate.d"; -my $main_file = "$fixture_dir/logrotate.conf"; -make_path("$config_dir/logrotate", $add_dir); - -sub write_text -{ -my ($file, $text) = @_; -open(my $fh, ">", $file) or die "open $file: $!"; -print $fh $text; -close($fh) or die "close $file: $!"; -} +my $local_add_dir = "$fixture_dir/etc/logrotate.d"; +my $vendor_add_dir = "$fixture_dir/usr/etc/logrotate.d"; +my $local_main_file = "$fixture_dir/etc/logrotate.conf"; +my $vendor_main_file = "$fixture_dir/usr/etc/logrotate.conf"; +my $wrapper = "$fixture_dir/usr/sbin/logrotate-all"; +make_path("$config_dir/logrotate", $local_add_dir, + "$local_add_dir/nested", "$vendor_add_dir/deep", + "$vendor_add_dir/nested", dirname($wrapper)); +# Populate both trees with vendor-only, local-only, nested, and overridden +# files so the fixture exercises the wrapper's key overlay rules. +my $vendor_main_text = + "weekly\n/var/log/vendor-main.log {\n\trotate 4\n}\n"; write_text("$config_dir/config", "os_type=linux\nos_version=0\n"); write_text("$config_dir/logrotate/config", "sort_mode=0\n". - "logrotate_conf=$main_file\n". - "add_file=$add_dir\n". + "logrotate_conf=$local_main_file\n". + "vendor_logrotate_conf=$vendor_main_file\n". + "add_file=$local_add_dir\n". + "vendor_add_file=$vendor_add_dir\n". "scan_add_file=1\n". - "logrotate=logrotate\n"); -write_text($main_file, "weekly\n/var/log/main.log {\n\trotate 4\n}\n"); -write_text("$add_dir/one", "/var/log/one.log {\n\tdaily\n}\n"); -write_text("$add_dir/two", "/var/log/two.log {\n\tmonthly\n}\n"); + "logrotate=/bin/echo\n". + "logrotate_all=$wrapper\n"); +write_text($vendor_main_file, $vendor_main_text); +write_text("$vendor_add_dir/one", "/var/log/vendor-one.log {\n\tdaily\n}\n"); +write_text("$vendor_add_dir/shared", + "/var/log/vendor-shared.log {\n\tdaily\n}\n"); +write_text("$vendor_add_dir/deep/vendor", + "/var/log/deep-vendor.log {\n\tmonthly\n}\n"); +write_text("$local_add_dir/local-only", + "/var/log/local-only.log {\n\tweekly\n}\n"); +write_text("$local_add_dir/shared", + "/var/log/local-shared.log {\n\tweekly\n}\n"); +write_text("$local_add_dir/nested/local", + "/var/log/nested-local.log {\n\tweekly\n}\n"); +write_text($wrapper, "#!/bin/sh\nexit 0\n"); +chmod(0755, $wrapper) or die "chmod $wrapper: $!"; +# Point Webmin at the isolated fixture before loading the module library. $ENV{'WEBMIN_CONFIG'} = $config_dir; $ENV{'WEBMIN_VAR'} = $var_dir; $ENV{'FOREIGN_MODULE_NAME'} = 'logrotate'; @@ -42,6 +63,8 @@ $ENV{'FOREIGN_ROOT_DIRECTORY'} = $root_dir; chdir($module_dir) or die "chdir $module_dir: $!"; require "$module_dir/logrotate-lib.pl"; +# clear_config_cache() +# Forces each test phase to parse the configuration from disk again sub clear_config_cache { no warnings 'once'; @@ -51,6 +74,8 @@ no warnings 'once'; $main::get_config_parent_cache = undef; } +# log_names(config) +# Returns only the log path names from parsed rotation sections sub log_names { my ($config) = @_; @@ -58,40 +83,297 @@ return [ map { $_->{'name'}->[0] } grep { $_->{'members'} } @$config ]; } +# The vendor main file is the initial fallback because no local main exists. +is(main::get_main_config_file(), $vendor_main_file, + 'vendor main config is used when no local main config exists'); +ok(main::is_vendor_main_config($vendor_main_file), + 'vendor main config is recognized'); + +# Match the wrapper's existence test rather than requiring a regular file. +my $nonregular_main = "$fixture_dir/etc/nonregular-main"; +make_path($nonregular_main); +{ +local $main::config{'logrotate_conf'} = $nonregular_main; +is(main::get_main_config_file(), $nonregular_main, + 'local main path wins whenever it exists'); +} +{ +local $main::config{'logrotate_conf'} = "$fixture_dir/etc/missing-main"; +local $main::config{'vendor_logrotate_conf'} = + "$fixture_dir/usr/etc/missing-main"; +is(main::get_main_config_file(), $main::config{'vendor_logrotate_conf'}, + 'configured vendor main path is used whenever the local path is absent'); +} + +# The effective list is sorted by relative path, with local files replacing +# vendor files that have the same relative path. +my @effective_add_files = ( + "$vendor_add_dir/deep/vendor", + "$local_add_dir/local-only", + "$local_add_dir/nested/local", + "$vendor_add_dir/one", + "$local_add_dir/shared", + ); my ($config, undef, $files) = main::get_config(); is_deeply(log_names($config), - [ '/var/log/main.log', '/var/log/one.log', '/var/log/two.log' ], - 'opt-in scan loads sections from add_file directory'); + [ '/var/log/vendor-main.log', '/var/log/deep-vendor.log', + '/var/log/local-only.log', '/var/log/nested-local.log', + '/var/log/vendor-one.log', '/var/log/local-shared.log' ], + 'vendor and local trees are recursively merged with local precedence'); is_deeply([ map { $_->{'index'} } grep { $_->{'members'} } @$config ], - [ 1, 2, 3 ], 'scanned sections keep stable top-level indexes'); -is_deeply($files, - [ $main_file, "$add_dir/one", "$add_dir/two" ], - 'file cache contains the primary and scanned configuration files'); -my (undef, undef, $primary_files) = main::get_config($main_file); -is_deeply([ main::get_add_file_configs($primary_files) ], - [ "$add_dir/one", "$add_dir/two" ], - 'force rotation adds externally loaded configuration files'); + [ 1, 2, 3, 4, 5, 6 ], + 'effective sections keep stable top-level indexes'); +is_deeply($files, [ $vendor_main_file, @effective_add_files ], + 'file cache contains the effective main and merged drop-ins'); +ok(!grep({ $_ eq "$vendor_add_dir/shared" } @$files), + 'local file hides the same relative vendor file'); +my (undef, undef, $primary_files) = main::get_config($vendor_main_file); +is_deeply([ main::get_add_file_configs($primary_files) ], + \@effective_add_files, + 'externally loaded configuration files match the effective overlay'); +is(main::get_scheduled_logrotate_command(), main::quote_path($wrapper), + 'scheduled rotations use the distribution wrapper'); + +# Disabling the opt-in must restore the behavior used by other distributions. $main::config{'scan_add_file'} = 0; clear_config_cache(); ($config, undef, $files) = main::get_config(); -is_deeply(log_names($config), [ '/var/log/main.log' ], - 'add_file is not scanned without explicit opt-in'); -is_deeply($files, [ $main_file ], - 'file cache excludes add_file directory when scanning is disabled'); +is_deeply(log_names($config), [ '/var/log/vendor-main.log' ], + 'vendor and local trees are not scanned without explicit opt-in'); +is_deeply($files, [ $vendor_main_file ], + 'file cache excludes external directories when scanning is disabled'); +# The low-level writer must fail closed if a caller skips copy-on-write. +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { + main::save_directive(main::get_config_parent(), 'weekly', ''); + }; +like($@, qr/Refusing to modify vendor configuration/, + 'direct writes to the vendor main configuration are rejected'); +} + +# Deleting an already-absent option is a no-op and must not cache an empty +# local main file that a later unscoped flush could accidentally create. +main::save_directive(main::get_config_parent(), + 'missing-vendor-option', undef); +main::flush_file_lines(); +ok(!-e $local_main_file, + 'missing global option deletion leaves the local main config absent'); + +# A new section with an explicit vendor destination must also fail closed. +my $vendor_target = "$vendor_add_dir/one"; +my $vendor_target_text = read_text($vendor_target); +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { + main::save_directive(main::get_config_parent(), undef, + { 'file' => $vendor_target, + 'name' => [ '/var/log/unsafe-vendor-write.log' ], + 'members' => [ ] }); + }; +like($@, qr/Refusing to modify vendor configuration/, + 'new sections cannot target a vendor drop-in directly'); +} +is(read_text($vendor_target), $vendor_target_text, + 'rejecting a new vendor section leaves its destination unchanged'); + +# A section without its own file would create an incomplete local main config. +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { + main::save_directive(main::get_config_parent(), undef, + { 'name' => [ '/var/log/unsafe-main-write.log' ], + 'members' => [ ] }); + }; +like($@, qr/Refusing to modify vendor configuration/, + 'new sections cannot replace the vendor main config implicitly'); +} +ok(!-e $local_main_file, + 'rejecting an implicit main write does not create a partial override'); + +# Adding a fresh local drop-in must not put the absent local main in the line +# cache, because the normal unscoped flush would then create it as an empty +# file and hide the complete vendor main configuration. +my $new_local_dropin = "$local_add_dir/new-local"; +main::save_directive(main::get_config_parent(), undef, + { 'file' => $new_local_dropin, + 'name' => [ '/var/log/new-local.log' ], + 'members' => [ { 'name' => 'weekly' } ] }); +main::flush_file_lines(); +ok(-f $new_local_dropin, + 'new sections are written to their explicit local drop-in'); +ok(!-e $local_main_file, + 'adding a local drop-in does not create an empty local main config'); +is(read_text($vendor_main_file), $vendor_main_text, + 'adding a local drop-in leaves the vendor main config unchanged'); + +# A missing local file cannot safely replace a whole same-named vendor file. +my $missing_local_override = "$local_add_dir/one"; +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { + main::save_directive(main::get_config_parent(), undef, + { 'file' => $missing_local_override, + 'name' => [ '/var/log/incomplete-override.log' ], + 'members' => [ ] }); + }; +like($@, qr/Refusing to modify vendor configuration/, + 'new sections cannot create incomplete vendor overrides'); +} +ok(!-e $missing_local_override, + 'rejecting an incomplete override leaves its local path absent'); + +# Editing global options materializes an exact local copy before parsing. $main::config{'scan_add_file'} = 1; -write_text($main_file, - "weekly\ninclude $add_dir\n/var/log/main.log {\n\trotate 4\n}\n"); +clear_config_cache(); +is(main::ensure_local_main_config(), $local_main_file, + 'editing the vendor main config creates a local main config'); +is(read_text($local_main_file), $vendor_main_text, + 'local main config starts as an exact vendor copy'); +is(read_text($vendor_main_file), $vendor_main_text, + 'copying the main config does not alter the vendor file'); +is(main::get_main_config_file(), $local_main_file, + 'local main config takes precedence after it is created'); + +# A new section may be appended after the same-named vendor file has been +# copied in full, which is the preflight performed by save_log.cgi. +is(main::ensure_local_config_override($vendor_target), + $missing_local_override, + 'new-section preflight creates the complete local override'); +my $prepared_parent = main::get_config_parent(); +main::save_directive($prepared_parent, undef, + { 'file' => $missing_local_override, + 'name' => [ '/var/log/appended-local.log' ], + 'members' => [ { 'name' => 'weekly' } ] }); +main::flush_file_lines($missing_local_override); +like(read_text($missing_local_override), qr{/var/log/vendor-one\.log}, + 'prepared override retains the original vendor section'); +like(read_text($missing_local_override), qr{/var/log/appended-local\.log}, + 'prepared override receives the new local section'); +is(read_text($vendor_target), $vendor_target_text, + 'appending locally leaves the same-named vendor file unchanged'); + +# Editing a vendor drop-in must also be prepared before parsed objects change. +my $vendor_dropin = "$vendor_add_dir/deep/vendor"; +my $local_dropin = "$local_add_dir/deep/vendor"; +($config, undef, $files) = main::get_config(); +my ($deep_log) = grep { $_->{'members'} && + $_->{'name'}->[0] eq '/var/log/deep-vendor.log' } + @$config; +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { main::save_directive($deep_log, 'monthly', '', "\t"); }; +like($@, qr/Refusing to modify vendor configuration/, + 'direct writes to a vendor drop-in are rejected'); +} +is(main::ensure_local_config_override($vendor_dropin), $local_dropin, + 'editing a vendor drop-in creates its matching local override'); +is(read_text($local_dropin), read_text($vendor_dropin), + 'local drop-in starts as an exact copy of the whole vendor file'); +is(main::get_local_override_file($vendor_dropin), $local_dropin, + 'vendor drop-in maps to the correct writable path'); +is(main::get_vendor_config_file($local_dropin), $vendor_dropin, + 'local override maps back to the shadowed vendor file'); + +($config, undef, $files) = main::get_config(); +($deep_log) = grep { $_->{'members'} && + $_->{'name'}->[0] eq '/var/log/deep-vendor.log' } + @$config; +is($deep_log->{'file'}, $local_dropin, + 'parser switches to the local copy after an override is created'); +main::save_directive($deep_log, 'monthly', undef, "\t"); +main::flush_file_lines($local_dropin); +unlike(read_text($local_dropin), qr/^\s*monthly\s*$/m, + 'prepared drop-in can be changed through its local override'); +like(read_text($vendor_dropin), qr/^\s*monthly\s*$/m, + 'changing the local override leaves the vendor drop-in unchanged'); + +# An empty local file must remain both effective and backup-visible because +# its existence is what prevents the vendor file from becoming active again. +write_text($local_dropin, ''); +clear_config_cache(); +main::delete_if_empty($local_dropin); +ok(-e $local_dropin, + 'empty local override is retained so the vendor file stays disabled'); +(undef, undef, $files) = main::get_config(); +ok(grep({ $_ eq $local_dropin } @$files), + 'empty local override remains in the effective file cache for backups'); +ok(!grep({ $_ eq $vendor_dropin } @$files), + 'empty local override continues to hide the vendor file'); + +# Explicit includes and external discovery must not parse the same file twice. +write_text($local_main_file, + "weekly\ninclude $local_add_dir\n". + "/var/log/main.log {\n\trotate 4\n}\n"); clear_config_cache(); ($config, undef, $files) = main::get_config(); -is_deeply(log_names($config), - [ '/var/log/one.log', '/var/log/two.log', '/var/log/main.log' ], - 'explicitly included files are not loaded a second time'); -is(scalar(grep { main::same_file($_, "$add_dir/one") } @$files), 1, +is(scalar(grep { $_->{'members'} && + $_->{'name'}->[0] eq '/var/log/local-only.log' } + @$config), 1, + 'explicitly included files are not parsed a second time'); +is(scalar(grep { main::same_file($_, "$local_add_dir/local-only") } + @$files), 1, 'explicit include is represented once in the file cache'); -(undef, undef, $primary_files) = main::get_config($main_file); -is_deeply([ main::get_add_file_configs($primary_files) ], [ ], - 'force rotation does not repeat explicitly included files'); + +# A local path selected by the wrapper's existence check wins even when find +# discovers the relative name only from the regular vendor file. +my $edge_dir = tempdir(CLEANUP => 1); +my $edge_local_dir = "$edge_dir/etc/logrotate.d"; +my $edge_vendor_dir = "$edge_dir/usr/etc/logrotate.d"; +my $edge_target = "$edge_dir/local-target"; +make_path($edge_local_dir, $edge_vendor_dir); +write_text("$edge_vendor_dir/linked", "vendor\n"); +write_text($edge_target, "local\n"); +symlink($edge_target, "$edge_local_dir/linked") or + die "symlink $edge_local_dir/linked: $!"; +{ +local $main::config{'add_file'} = $edge_local_dir; +local $main::config{'vendor_add_file'} = $edge_vendor_dir; +is_deeply([ main::get_add_file_configs() ], [ "$edge_local_dir/linked" ], + 'local existing path overrides the matching vendor file'); + +# Discovery follows the wrapper's existence rule, but editing must not follow +# a local symlink when it shadows a same-named vendor configuration. +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { + main::save_directive( + { 'members' => [ ], 'file' => "$edge_dir/parent" }, + undef, + { 'file' => "$edge_local_dir/linked", + 'name' => [ '/var/log/symlink-write.log' ], + 'members' => [ ] }); + }; +like($@, qr/Refusing to modify vendor configuration/, + 'local symlink overrides are rejected for editing'); +} +is(read_text($edge_target), "local\n", + 'rejecting a symlink override leaves its target unchanged'); +} + +# A regular local path must still be rejected when it is a hard link to its +# vendor source, because otherwise an apparently local write would alter /usr. +my $hardlink_dir = tempdir(CLEANUP => 1); +my $hardlink_vendor = "$hardlink_dir/vendor"; +my $hardlink_local = "$hardlink_dir/local"; +write_text($hardlink_vendor, "vendor\n"); +link($hardlink_vendor, $hardlink_local) or + die "link $hardlink_local: $!"; +{ +no warnings qw(once redefine); +local *main::error = sub { die $_[0]; }; +eval { main::copy_vendor_config($hardlink_vendor, $hardlink_local); }; +ok($@, 'a hard-linked local override is rejected'); +} +is(read_text($hardlink_vendor), "vendor\n", + 'rejecting a hard-linked override leaves the vendor source unchanged'); done_testing(); diff --git a/makedist.pl b/makedist.pl index 5b6040e3a..6f8175433 100755 --- a/makedist.pl +++ b/makedist.pl @@ -30,7 +30,7 @@ $vers || usage(); "miniserv.pl", "miniserv-lib.pl", "os_list.txt", "perlpath.pl", "setup.sh", "setup.pl", "setup.bat", "setup-repos.sh", "version", "web-lib.pl", "web-lib-funcs.pl", - "config_save.cgi", "chooser.cgi", "miniserv.pem", + "config_save.cgi", "chooser.cgi", "config-aix", "update-from-repo.sh", "README.md", "newmods.pl", "copyconfig.pl", "config-hpux", "config-freebsd", "changepass.pl", "help.cgi", "user_chooser.cgi", @@ -40,7 +40,7 @@ $vers || usage(); "webmin-systemd", "webmin-init", "webmin-daemon", "config-openbsd", "config-macos", "LICENCE", - "session_login.cgi", "acl_security.pl", + "session_login.cgi", "login-lib.pl", "acl_security.pl", "defaultacl", "rpc.cgi", "date_chooser.cgi", "safeacl", "install-module.pl", "LICENCE.ja", "favicon.ico", "config-netbsd", "fastrpc.cgi", diff --git a/miniserv.pem b/miniserv.pem deleted file mode 100644 index 16ef412af..000000000 --- a/miniserv.pem +++ /dev/null @@ -1,49 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDejCCAmKgAwIBAgIUI7oycX7XtLsNCJb1v2GGl1pZS28wDQYJKoZIhvcNAQEL -BQAwNzESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJVUzEUMBIGA1UEBwwL -U2FudGEgQ2xhcmEwHhcNMjIxMDEwMjEyNzI4WhcNMzIxMDA3MjEyNzI4WjA3MRIw -EAYDVQQDDAlsb2NhbGhvc3QxCzAJBgNVBAYTAlVTMRQwEgYDVQQHDAtTYW50YSBD -bGFyYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANSUOgVKjclwwmdL -OD3jSKHjuS9YqmfVqB+AyUfE97Lq9qvmydbSrpaEvXgmcG8Qlh6PwtIH9dSmCYl8 -obftWC4ReN4ubl5meyEc0BRZmEPxC8j6s18S8ICTEQ7ZiNxoDwAciXA2Op6sAuS4 -42HxcArHFpRXYMrgwGP4mes4j3L6ugWivvpz0zGDMNG+zWlMnlx9NJ4klSVvDueW -bsAd+KPuzF5S4QaTJ0oASu5UCK/JmHpDtXFGDwm72fWNdfspRYblSrmxFFiZUNoQ -t9iggMMaPlxIwajLTvrDi/Jzp0OXKdu5fRRsgPmzvgq/SWH9kDidZwpxw67BC1rm -g/EDtmMCAwEAAaN+MHwwHQYDVR0OBBYEFCjRwPscxpufLiIXn8nPb5S8ruIMMB8G -A1UdIwQYMBaAFCjRwPscxpufLiIXn8nPb5S8ruIMMA8GA1UdEwEB/wQFMAMBAf8w -FAYDVR0RBA0wC4IJbG9jYWxob3N0MBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqG -SIb3DQEBCwUAA4IBAQAAMHPYjlF++zsrpVU44HqfwkQ6Y123eqGWw6HXDF9ga+oW -aAD6iHIf06rmpFQ/GnU5QzIAR0QkhCCcnpCFIX5Quluv5aQ1pxBtLuRW8QB7jugg -m/Bk204Ck4dj5EgJ5CGOj5yVjKanaMXa3hLp1dYMkas6VQyYBdMAJosrGWdFczvQ -/bpfgPWF0DZhzskTdTWce3rv6VHc6biDGUHNaCH7dtTJfenUZfgtNXMNl4raBQMC -83mEfJYhe1pqJRvzoC0dTeYeF/66Q5CfIxSpb2cMCtNl6wWqS4WJtQCOBCoKqQtH -9qDGxQCiISyMTqiTUU9GYYWsTZ9do8ZSc5VvO6uf ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUlDoFSo3JcMJn -Szg940ih47kvWKpn1agfgMlHxPey6var5snW0q6WhL14JnBvEJYej8LSB/XUpgmJ -fKG37VguEXjeLm5eZnshHNAUWZhD8QvI+rNfEvCAkxEO2YjcaA8AHIlwNjqerALk -uONh8XAKxxaUV2DK4MBj+JnrOI9y+roFor76c9MxgzDRvs1pTJ5cfTSeJJUlbw7n -lm7AHfij7sxeUuEGkydKAEruVAivyZh6Q7VxRg8Ju9n1jXX7KUWG5Uq5sRRYmVDa -ELfYoIDDGj5cSMGoy076w4vyc6dDlynbuX0UbID5s74Kv0lh/ZA4nWcKccOuwQta -5oPxA7ZjAgMBAAECggEBAMeftmiXnVRIblafvV3onKFh/tnmUoeVjP6gauPZpJ3A -pgnBfVMdn1O7rU0yE61IjyB98f5X+VNK5HPWtOrKmF8Si0VhpsVBSWlL7F7fz+wl -ZOLEAkNKvsyOzpr6TtpjYYrCQZI9aojskP8GXIjyODv2v05oS33Y1vJKVwgboiHe -d3CbRn9nbarNdJ5FHrv6qbz9xiRREdsNeYTafzPNzKXnkLrJ9T0lsOItichwXk+e -e47fYi7cqmPc8mZ+cx3ct8z4RF/iGmdLc/nVd9k68/ola7DEtNVGGCw/1zAgAiJG -vKbVK/rWJ0qWKXjW2Vf0Uc7M4gmhU9tmq9wYXmYUKrkCgYEA/5f+VmpdDUAu75el -D8D7L6LvJDH4NoClpbHJ3srJ1gpGniL2mWR9xplRe7Sth9rWMziPo2vcpb0n8CKB -799sTLyY/F4/pjlnQrLGhs6ZipdCnx0Wp6cCcNTchJV7fsvJWQi96z69m9z4aOCY -ls5J0lgostgGmqJ0tUTLAJn4af0CgYEA1Oq6wx/2+NkaQSL3JhlBmybbaK/L7B5X -Bn2uwxaIBGKsWaH02VNx3kv7tbsEJ9bj2Zsf47CAJaw8ojNyT754YDBjnqawoI+G -RGP5Rjz7+IiW3EgACHQBhmxASjBTpCFcpszxjLwhL6i/0yyaosIq0459iz4dN9he -27nNjM48398CgYA8zQVdvTOhgVEpAaPsWXXnYRUOQSzQmk3NWruw2gClgBBIcfzD -hJo+8h3mFZbqKG6oBJ52u6PjcVncz/ik/TsgDgU+k5UEj8c2oJyFWQRBabYZb6wB -+cKk4J4MlBqqkQO2wFOdcHFecHRPTo494ZjCBuK3mJgJlPe6X0EDRZ1IaQKBgACF -Ei+nxFKXNRkznKbbKO3YCnEW/Mh2yn+ZjvOq+moIU6NkYdSl/4ErblHjQv9V5viB -CHLl22o8IWnD9mN7c4/IjnW0snmx4AIKvHEPdQ4GR1gCVP2wStCpMv77XzjnW/KM -TYqqaupS3yUE1tvO3YvmrSfJ3whj3tdqfLX7SurBAoGBAOW8Pn8/kIB0l5Hm5lKm -lh52bhoBzptiPfnddwXROl2IGieALZU/lKUvZv79aVJuoY86+qOfSZqiUypGiIPR -+VWa7deV7Stugf6KmnRzOp0ZdS/SCX9rppoJmdwORW58cfIhmdQV+vKlCL9ZO0HF -QqbKXdpb8BfqgsmIwTV+7zbF ------END PRIVATE KEY----- diff --git a/miniserv.pl b/miniserv.pl index efefad1df..3ad64bef7 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -915,6 +915,13 @@ while(1) { # Initialize SSL for this connection if ($use_ssl) { my $byte = ''; + # Don't wait forever for a client that + # connects but never sends anything + my $pmask; + vec($pmask, fileno(SOCK), 1) = 1; + select($pmask, undef, undef, + $config{'peek_timeout'} || 60) + || exit; # Look at the first byte of the socket # buffer but don't consume it recv(SOCK, $byte, 1, MSG_PEEK); diff --git a/mount/linux-lib.pl b/mount/linux-lib.pl index 26cd4524c..0a3af656b 100755 --- a/mount/linux-lib.pl +++ b/mount/linux-lib.pl @@ -1924,7 +1924,30 @@ elsif ($_[0] =~ /^ext\d+$/) { ($u, $g) = ("usrjquota", "grpjquota"); $jufile ||= "aquota.user"; $jgfile ||= "aquota.group"; - $options{"jqfmt"} = "vfsv0"; + if (!$options{"jqfmt"}) { + # Keep the format of existing external quota files when + # switching them to journaled quotas. + my $jqfmt; + foreach my $qfile ($jufile, $jgfile) { + next if (!-s "$_[2]/$qfile"); + if (open(my $qfh, "<", "$_[2]/$qfile")) { + my $header; + if (read($qfh, $header, 8) == 8) { + my (undef, $version) = unpack("V2", $header); + $jqfmt = $version == 0 ? "vfsv0" : + $version == 1 ? "vfsv1" : undef; + } + close($qfh); + } + last if ($jqfmt); + } + if (!$jqfmt) { + my $qver = &backquote_command("quota -V 2>&1"); + $jqfmt = $qver =~ /\s(\d+)\.\d+/ && $1 >= 4 ? + "vfsv1" : "vfsv0"; + } + $options{"jqfmt"} = $jqfmt; + } } else { $jufile = ""; diff --git a/net/debian-linux-lib.pl b/net/debian-linux-lib.pl index 693f69084..694f7f2d7 100755 --- a/net/debian-linux-lib.pl +++ b/net/debian-linux-lib.pl @@ -230,12 +230,12 @@ if ($cfg->{'bridge'}) { # Set bonding parameters if(($cfg->{'bond'} == 1) && ($gconfig{'os_version'} >= 5)) { - push(@options, [&bonding_option('mode').' '.$cfg->{'mode'}]); - push(@options, [&bonding_option('miimon').' '.$cfg->{'miimon'}]) if ($cfg->{'miimon'}); - push(@options, [&bonding_option('updelay').' '.$cfg->{'updelay'}]) if ($cfg->{'updelay'}); - push(@options, [&bonding_option('downdelay').' '.$cfg->{'downdelay'}]) if ($cfg->{'downdelay'}); - push(@options, [&bonding_option('primary').' '.$cfg->{'primary'}]) if ($cfg->{'primary'}); - push(@options, ['slaves '.$cfg->{'partner'}]); + push(@options, [&bonding_option('mode'), $cfg->{'mode'}]); + push(@options, [&bonding_option('miimon'), $cfg->{'miimon'}]) if ($cfg->{'miimon'}); + push(@options, [&bonding_option('updelay'), $cfg->{'updelay'}]) if ($cfg->{'updelay'}); + push(@options, [&bonding_option('downdelay'), $cfg->{'downdelay'}]) if ($cfg->{'downdelay'}); + push(@options, [&bonding_option('primary'), $cfg->{'primary'}]) if ($cfg->{'primary'}); + push(@options, ['slaves', $cfg->{'partner'}]); } elsif ($cfg->{'bond'} == 1) { push(@options, ['up', '/sbin/ifenslave '.$cfg->{'name'}." ". diff --git a/net/delete_bifcs.cgi b/net/delete_bifcs.cgi index bd60b7d65..a8598d7f1 100755 --- a/net/delete_bifcs.cgi +++ b/net/delete_bifcs.cgi @@ -51,6 +51,10 @@ foreach $d (reverse(@d)) { else {&unload_module($b->{'name'});} } } + # Remove the virtual device after deactivation + if (defined(&destroy_interface_device)) { + &destroy_interface_device($b); + } } # Delete config diff --git a/net/linux-lib.pl b/net/linux-lib.pl index f0727db96..4bbb78f22 100755 --- a/net/linux-lib.pl +++ b/net/linux-lib.pl @@ -329,9 +329,22 @@ if (&has_command("ip") && $a->{'bond'} && $a->{'up'} && !$old) { } } -if (($a->{'bond'} || $a->{'vlan'} || !&has_command("ifconfig")) && - &has_command("ip")) { - # For a real interface, activate or de-activate the link +if (&has_command("ip") && $a->{'bridge'} && $a->{'up'} && !$old) { + # Create the bridge before assigning addresses to it. + my $cmd = "ip link add ".quotemeta($a->{'name'})." type bridge"; + my $out = &backquote_logged("$cmd 2>&1"); + &error("Failed to create bridge device : $out") if ($?); + if ($a->{'bridgeto'}) { + $cmd = "ip link set dev ".quotemeta($a->{'bridgeto'}). + " master ".quotemeta($a->{'name'}); + $out = &backquote_logged("$cmd 2>&1"); + &error("Failed to add interface to bridge : $out") if ($?); + } + } + +if (&has_command("ip")) { + # Manage link state for all interfaces when ip is used, since ip is also + # used for address assignment below regardless of ifconfig availability. if ($a->{'virtual'} eq '' && $a->{'up'} && (!$old || !$old->{'up'})) { # Bring up my $cmd = "ip link set dev ".quotemeta($devname)." up"; @@ -597,6 +610,22 @@ else { } } +# destroy_interface_device(&details) +# Remove a virtual network device (bond, VLAN, bridge) from the kernel. +# Should be called after deactivate_interface when deleting, not just +# deactivating, a virtual interface. +sub destroy_interface_device +{ +my ($a) = @_; +my $name = $a->{'fullname'} || $a->{'name'}; +if (&has_command("ip") && $a->{'virtual'} eq '' && + &iface_type($name) =~ /(?:Bonded|VLAN|Bridge)$/) { + my $out = &backquote_logged( + "ip link delete ".quotemeta($name)." 2>&1"); + &error("Failed to delete virtual interface : $out") if ($?); + } +} + # use_ifup_command(&iface) # Returns 1 if the ifup command must be used to bring up some interface. # True on Debian 5.0+ for non-ethernet, typically bonding and VLAN tagged interfaces. diff --git a/net/save_bifc.cgi b/net/save_bifc.cgi index 1006669a5..b27c9c059 100755 --- a/net/save_bifc.cgi +++ b/net/save_bifc.cgi @@ -32,6 +32,10 @@ if ($in{'delete'} || $in{'unapply'}) { else { &deactivate_interface($act); } + # Remove the virtual device after deactivation + if (defined(&destroy_interface_device)) { + &destroy_interface_device($b); + } } } @@ -294,7 +298,7 @@ else { $b->{'bond'} = 1; $in{'partner'} =~ /^\S+( +\S+)*$/ || &error($text{'bonding_epartner'}); $b->{'partner'} = $in{'partner'}; - $in{'bondmode'} =~ /^\d*$/ || &error($text{'bonding_ebondmode'}); + $in{'bondmode'} =~ /^[0-6]$/ || &error($text{'bonding_ebondmode'}); $b->{'mode'} = $in{'bondmode'}; $in{'primary'} =~ /^\S*$/ || &error($text{'bonding_eprimary'}); $b->{'primary'} = $in{'primary'}; diff --git a/net/t/run-tests.t b/net/t/run-tests.t index d0c6f4ae4..8aeb2f6ac 100644 --- a/net/t/run-tests.t +++ b/net/t/run-tests.t @@ -964,4 +964,89 @@ is_deeply(\@commands, "cd / ; ifconfig eth0.10 10\\.0\\.0\\.2 netmask 255\\.255\\.255\\.0 up 2>&1" ], "Linux VLAN interface falls back to vconfig without ip"); +# Test: Bond deactivation only brings it down, does not delete device +@commands = ( ); +{ +no warnings 'redefine'; +local *main::has_command = sub { + return $_[0] eq "ip" ? "/sbin/ip" : undef; + }; +main::deactivate_interface({ + 'name' => 'bond0', + 'fullname' => 'bond0', + 'virtual' => '', + 'address' => '10.0.0.2', + 'netmask' => '255.255.255.0', + 'address6' => [ ], + 'netmask6' => [ ], + 'up' => 1 + }); +} +is_deeply(\@commands, [ + "ip addr del 10\\.0\\.0\\.2\\/24 dev bond0 2>&1", + "ip link set dev bond0 down 2>&1" + ], "Linux bond deactivation removes address and brings link down"); + +# Test: Bond deletion removes virtual device after deactivation +@commands = ( ); +{ +no warnings 'redefine'; +no warnings 'once'; +local $main::gconfig{'os_type'} = 'debian-linux'; +local $main::gconfig{'os_version'} = 12; +local *main::has_command = sub { + return $_[0] eq "ip" ? "/sbin/ip" : + $_[0] eq "ifup" ? "/sbin/ifup" : undef; + }; +main::deactivate_interface({ + 'name' => 'bond0', + 'fullname' => 'bond0', + 'virtual' => '', + 'address' => '10.0.0.2', + 'netmask' => '255.255.255.0', + 'address6' => [ ], + 'netmask6' => [ ], + 'up' => 1 + }); +# Simulate delete path: destroy_interface_device after deactivation +my $b = { 'name' => 'bond0', 'fullname' => 'bond0', 'virtual' => '' }; +main::destroy_interface_device($b); +} +is_deeply(\@commands, [ + "ip addr del 10\\.0\\.0\\.2\\/24 dev bond0 2>&1", + "ip link set dev bond0 down 2>&1", + "ip link delete bond0 2>&1" + ], "Linux bond deletion removes device after deactivation"); + +# Test: VLAN deletion removes virtual device after deactivation +@commands = ( ); +{ +no warnings 'redefine'; +no warnings 'once'; +local $main::gconfig{'os_type'} = 'debian-linux'; +local $main::gconfig{'os_version'} = 12; +local *main::has_command = sub { + return $_[0] eq "ip" ? "/sbin/ip" : + $_[0] eq "ifup" ? "/sbin/ifup" : undef; + }; +main::deactivate_interface({ + 'name' => 'eth0.10', + 'fullname' => 'eth0.10', + 'virtual' => '', + 'address' => '10.0.10.2', + 'netmask' => '255.255.255.0', + 'address6' => [ ], + 'netmask6' => [ ], + 'up' => 1 + }); +# Simulate delete path: destroy_interface_device after deactivation +my $b = { 'name' => 'eth0.10', 'fullname' => 'eth0.10', 'virtual' => '' }; +main::destroy_interface_device($b); +} +is_deeply(\@commands, [ + "ip addr del 10\\.0\\.10\\.2\\/24 dev eth0\\.10 2>&1", + "ip link set dev eth0\\.10 down 2>&1", + "ip link delete eth0\\.10 2>&1" + ], "Linux VLAN deletion removes device after deactivation"); + done_testing(); diff --git a/package-updates/CHANGELOG b/package-updates/CHANGELOG index c6dd08cd9..806fb24bd 100644 --- a/package-updates/CHANGELOG +++ b/package-updates/CHANGELOG @@ -1,3 +1,5 @@ +---- Changes since 2.641 ---- +Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages. ---- Changes since 1.490 ---- First version of this module. ---- Changes since 1.500 ---- diff --git a/package-updates/index.cgi b/package-updates/index.cgi index 8df9e0eb5..e0f820d28 100755 --- a/package-updates/index.cgi +++ b/package-updates/index.cgi @@ -9,6 +9,7 @@ if ($in{'clear'}) { $in{'search'} = ''; } $has_repos = defined(&software::list_package_repos); +$has_holds = &supports_package_holds(); # Start of mode tabs print &ui_tabs_start([ [ 'pkgs', $text{'index_tabpkgs'} ], @@ -23,7 +24,10 @@ $in{'mode'} ||= 'updates'; # Show mode selector (all, updates only, updates and new) @grid = ( ); -foreach $m ('current', 'updates', 'security', 'new') { +@modes = ('current', 'updates'); +push(@modes, 'held') if ($has_holds); +push(@modes, 'security', 'new'); +foreach $m (@modes) { $mmsg = $text{'index_mode_'.$m}; if ($in{'mode'} eq $m) { push(@mlinks, "$mmsg"); @@ -68,15 +72,21 @@ foreach $p (sort { $a->{'name'} cmp $b->{'name'} } (@current, @avail)) { $c = $current{$p->{'name'}."/".$p->{'system'}}; $a = $avail{$p->{'name'}."/".$p->{'system'}}; - if ($a && $c && (&compare_versions($a, $c) > 0 || $upmode)) { + if ($a && $c && (&compare_versions($a, $c) > 0 || $upmode || + $in{'mode'} eq 'held')) { # An update is available - $msg = "". - &text('index_new', $a->{'version'}).""; - $need = 1; + $msg = $a->{'held'} ? + "". + &text('index_held', $c->{'version'}, $a->{'version'}). + "" : + "". + &text('index_new', $a->{'version'}).""; + $need = $a->{'held'} ? 0 : 1; next if ($in{'mode'} eq 'security' && !$a->{'security'}); next if ($in{'mode'} ne 'updates' && $in{'mode'} ne 'current' && - $in{'mode'} ne 'security'); + $in{'mode'} ne 'security' && + $in{'mode'} ne 'held'); } elsif ($a && !$c) { # Could be installed, but isn't currently @@ -143,18 +153,20 @@ if ($in{'mode'} eq 'new' && !$in{'search'}) { } else { # Show the packages, if any + $update_label = $in{'mode'} eq 'new' ? $text{'index_install'} : + $in{'mode'} eq 'held' ? $text{'index_updateheld'} : + $text{'index_update'}; if (@rows) { print &text('index_count', scalar(@rows)),"
\n"; print &ui_form_start("update.cgi", "post"); - print &ui_submit($in{'mode'} eq 'new' ? $text{'index_install'} - : $text{'index_update'}, "ok_top" ); + print &ui_submit($update_label, "ok_top" ); print &ui_submit($text{'index_refresh'}, "refresh_top"), "
"; } + @buttons = ( [ "ok", $update_label ] ); + push(@buttons, [ "refresh", $text{'index_refresh'} ]); print &ui_form_columns_table( "", - [ [ "ok", $in{'mode'} eq 'new' ? $text{'index_install'} - : $text{'index_update'} ], - [ "refresh", $text{'index_refresh'} ] ], + \@buttons, 1, undef, [ [ "mode", $in{'mode'} ], @@ -296,4 +308,3 @@ if ($has_repos) { print &ui_tabs_end(1); &ui_print_footer("/", $text{'index'}); - diff --git a/package-updates/lang/en b/package-updates/lang/en index df86114cf..c865d450a 100644 --- a/package-updates/lang/en +++ b/package-updates/lang/en @@ -6,8 +6,10 @@ index_source=Source index_bad2=Update to version $1 not yet available : $1 index_bad=Update to version $1 advised : $2 index_new=New version $1 +index_held=Held at version $1; version $2 is available index_ok=Running latest $1 index_update=Update Selected Packages +index_updateheld=Update Selected Held Packages index_install=Install Selected Packages index_return=package list index_header=Scheduled checking options @@ -39,6 +41,7 @@ index_webmintheme=Webmin theme $1 index_mode=States to display: index_mode_current=Installed index_mode_updates=Only updates +index_mode_held=Held updates index_mode_new=Only new index_mode_security=Only security updates index_allsel=Packages to show: @@ -46,6 +49,7 @@ index_all_0=Only Virtualmin related index_all_1=All packages index_none_all=No packages managed by an update system were found on your system! index_none_updates=No packages available to be updated were found. +index_none_held=No held packages with available updates were found. index_none_both=No new packages or packages available to be updated were found. index_none_new=No new packages for installation were found. index_none_security=No packages available for security updates were found. @@ -100,6 +104,10 @@ update_rusure=Are you sure you wish to install the $1 packages listed below? Thi update_oldver=Current version update_newver=New version update_confirm=Install Now +update_confirmheld=Update Held Packages +update_heldnote=These packages are held. This action explicitly updates them once, and leaves them held for future updates. +update_enotheld=Package $1 is not currently held by APT +update_enoheldops=No update operation was found for the selected held packages. Refresh the package list and try again. update_none=None update_ops=Building complete list of packages .. update_rebootdesc=One of the installed packages requires a reboot to be fully applied. @@ -114,6 +122,8 @@ log_schedup=Background installed $1 updated packages log_sched=Enabled scheduled updates log_unsched=Disabled scheduled updates log_refresh=Refreshed available packages +log_hold=Held updates for $1 packages +log_unhold=Unheld updates for $1 packages log_enable_repos=Enabled $1 package repositories log_disable_repos=Disabled $1 package repositories log_delete_repos=Deleted $1 package repositories @@ -140,6 +150,10 @@ view_source=Installation source view_changelog=Changelog for available version view_software=Manage Package view_update=Update Package +view_updateheld=Update Held Package +view_hold=Hold Package +view_unhold=Unhold Package +view_held=Held at version $1 view_install=Install Package system_yum=YUM @@ -159,4 +173,12 @@ repos_title=Delete Repositories repos_rusure=Are you sure you want to delete the $1 selected package repositories? Packages installed from them will still be available, but may not be updatable. repos_ok=Delete Now +hold_enotsupported=The active package update system does not support package holds +hold_enone=No packages were selected +hold_enotinstalled=Package $1 is not installed +hold_enotheld=Package $1 is not currently held +hold_esystem=Package $1 is not managed by the active package update system +hold_efailed=Failed to hold packages: $1 +unhold_efailed=Failed to unhold packages: $1 + __norefs=1 diff --git a/package-updates/log_parser.pl b/package-updates/log_parser.pl index fed1dbbd4..c5f7618ff 100644 --- a/package-updates/log_parser.pl +++ b/package-updates/log_parser.pl @@ -21,6 +21,9 @@ elsif ($action eq 'update') { elsif ($action eq 'schedup') { return &text('log_schedup', $object); } +elsif ($action eq 'hold' || $action eq 'unhold') { + return &text('log_'.$action, $object); + } elsif ($action eq 'sched') { return $text{$object ? 'log_sched' : 'log_unsched'}; } diff --git a/package-updates/package-updates-lib.pl b/package-updates/package-updates-lib.pl index 54eed7eb2..9612d7397 100644 --- a/package-updates/package-updates-lib.pl +++ b/package-updates/package-updates-lib.pl @@ -21,6 +21,7 @@ eval "use WebminCore;"; $available_cache_file = &cache_file_path("available.cache"); $current_cache_file = &cache_file_path("current.cache"); $updates_cache_file = &cache_file_path("updates.cache"); +$held_updates_cache_file = &cache_file_path("held-updates.cache"); $cron_cmd = "$module_config_directory/update.pl"; $yum_cache_file = &cache_file_path("yumcache"); @@ -308,30 +309,74 @@ sub supports_updates_available return defined(&software::update_system_updates); } -# updates_available(no-cache) +# supports_package_holds() +# Returns true if the current update system can list and change package holds. +sub supports_package_holds +{ +return defined(&software::list_update_system_holds) && + defined(&software::update_system_hold); +} + +# list_package_holds() +# Returns the package names currently held by the update system. +sub list_package_holds +{ +return ( ) if (!&supports_package_holds()); +return &software::list_update_system_holds(); +} + +# package_is_held(package, [holds]) +# Returns true if a package is in a supplied or freshly-read list of holds. +sub package_is_held +{ +my ($name, $holds) = @_; +my @holds = $holds ? @$holds : &list_package_holds(); +return 1 if (grep { $_ eq $name } @holds); +if ($software::update_system eq 'apt' && + defined(&software::strip_apt_package_arch)) { + my $base = &software::strip_apt_package_arch($name); + return 1 if (grep { + &software::strip_apt_package_arch($_) eq $base + } @holds); + } +return 0; +} + +# update_package_holds(&packages, hold) +# Holds or unholds packages. Returns undef on success, or an error message. +sub update_package_holds +{ +my ($packages, $hold) = @_; +return $text{'hold_enotsupported'} if (!&supports_package_holds()); +return &software::update_system_hold($packages, $hold); +} + +# updates_available(no-cache, [include-held]) # Returns an array of hash refs of package updates available, according to # the update system, with caching. sub updates_available { -my ($nocache) = @_; -if (!scalar(@updates_available_cache)) { - if ($nocache || &cache_expired($updates_cache_file)) { +my ($nocache, $include_held) = @_; +my $cache_file = $include_held ? $held_updates_cache_file : + $updates_cache_file; +my $cache = $include_held ? \@held_updates_available_cache : + \@updates_available_cache; +if (!scalar(@$cache)) { + if ($nocache || &cache_expired($cache_file)) { # Get from original source - @updates_available_cache = &software::update_system_updates(); - foreach my $a (@updates_available_cache) { + @$cache = &software::update_system_updates($include_held); + foreach my $a (@$cache) { $a->{'update'} = $a->{'name'}; $a->{'system'} = $software::update_system; } - &write_cache_file($updates_cache_file, - \@updates_available_cache); + &write_cache_file($cache_file, $cache); } else { # Use on-disk cache - @updates_available_cache = - &read_cache_file($updates_cache_file); + @$cache = &read_cache_file($cache_file); } } -return @updates_available_cache; +return @$cache; } # package_install(package-name, [system], [new-install], [flags]) @@ -343,12 +388,14 @@ my ($name, $system, $install, $flags) = @_; $system ||= $software::update_system; my @rv; my $pkg; +my $include_held = $system eq 'apt' && defined($flags) && + $flags eq '--allow-change-held-packages'; # First get from list of updates ($pkg) = grep { $_->{'update'} eq $name && ($_->{'system'} eq $system || !$system) } sort { &compare_versions($b, $a) } - &list_possible_updates(0); + &list_possible_updates(0, 0, $include_held); if (!$pkg) { # Then try list of all available packages ($pkg) = grep { $_->{'update'} eq $name && @@ -458,14 +505,14 @@ if (defined(&software::update_system_operations)) { return ( ); } -# list_possible_updates([nocache], [nocache-no-data]) +# list_possible_updates([nocache], [nocache-no-data], [include-held]) # Returns a list of updates that are available. Each element in the array # is a hash ref containing a name, version, description and severity flag. # Intended for calling from themes. Nocache 0=cache everything, 1=flush all # caches, 2=flush only current. Nocache-no-data prohibits collecting data sub list_possible_updates { -my ($nocache, $nocache_no_data) = @_; +my ($nocache, $nocache_no_data, $include_held) = @_; my @rv; return @rv if ($nocache_no_data); my @current = &list_current($nocache); @@ -476,9 +523,10 @@ if (&supports_updates_available()) { foreach my $c (@current) { $currentmap{$c->{'name'},$c->{'system'}} ||= $c; } - foreach my $a (&updates_available($nocache == 1)) { + foreach my $a (&updates_available($nocache == 1, $include_held)) { my $c = $currentmap{$a->{'name'},$a->{'system'}}; next if (!$c); + next if ($a->{'held'} && !$include_held); next if ($a->{'version'} eq $c->{'version'} && $a->{'epoch'} eq $c->{'epoch'}); push(@rv, { 'name' => $a->{'name'}, @@ -489,6 +537,7 @@ if (&supports_updates_available()) { 'epoch' => $a->{'epoch'}, 'oldepoch' => $c->{'epoch'}, 'security' => $a->{'security'}, + 'held' => $a->{'held'}, 'source' => $a->{'source'}, 'desc' => $c->{'desc'} || $a->{'desc'} }); } @@ -700,10 +749,13 @@ sub flush_package_caches { unlink($current_cache_file); unlink($updates_cache_file); +unlink($held_updates_cache_file); unlink($available_cache_file); unlink($available_cache_file.'0'); unlink($available_cache_file.'1'); @packages_available_cache = ( ); +@updates_available_cache = ( ); +@held_updates_available_cache = ( ); %read_cache_file_cache = ( ); } @@ -713,6 +765,8 @@ unlink($available_cache_file.'1'); sub list_for_mode { my ($mode, $nocache) = @_; +return grep { $_->{'held'} } + &list_possible_updates($nocache, 0, 1) if ($mode eq 'held'); return $mode eq 'updates' || $mode eq 'security' ? &list_possible_updates($nocache) : &list_available($nocache); } diff --git a/package-updates/save_view.cgi b/package-updates/save_view.cgi index 2fd5f2866..fbff27020 100755 --- a/package-updates/save_view.cgi +++ b/package-updates/save_view.cgi @@ -8,8 +8,14 @@ if ($in{'software'}) { &redirect("../software/edit_pack.cgi?package=".&urlize($in{'name'}). "&version=".&urlize($in{'version'})); } -else { +elsif ($in{'hold'} || $in{'unhold'}) { + $action = $in{'hold'} ? "hold" : "unhold"; &redirect("update.cgi?u=".&urlize($in{'name'}."/".$in{'system'}). - "&all=$in{'all'}&mode=$in{'mode'}"); + "&$action=1&mode=".&urlize($in{'mode'})); + } +else { + $mode = $in{'held'} ? "held" : $in{'mode'}; + &redirect("update.cgi?u=".&urlize($in{'name'}."/".$in{'system'}). + "&all=$in{'all'}&mode=".&urlize($mode)); } diff --git a/package-updates/update.cgi b/package-updates/update.cgi index af66b8d30..4a2b88602 100755 --- a/package-updates/update.cgi +++ b/package-updates/update.cgi @@ -19,7 +19,38 @@ else { $redir =~ /\?/ ? "$redir&tab=pkgs" : "$redir?tab=pkgs"; } -if ($in{'refresh'} || $in{'refresh_top'}) { +$hold_action = $in{'hold'} ? 1 : $in{'unhold'} ? 0 : undef; +if (defined($hold_action)) { + # Hold or unhold selected packages + &supports_package_holds() || &error($text{'hold_enotsupported'}); + @holdpkgs = split(/\0/, $in{'u'}); + @holdpkgs || &error($text{'hold_enone'}); + @current = &list_current(1); + %current = map { $_->{'name'}."/".$_->{'system'}, 1 } @current; + @held = &list_package_holds(); + @holdnames = ( ); + foreach $ps (@holdpkgs) { + ($p, $s) = split(/\//, $ps, 2); + $current{$p."/".$s} || &error(&text('hold_enotinstalled', $p)); + $s eq $software::update_system || + &error(&text('hold_esystem', $p)); + if (!$hold_action && !&package_is_held($p, \@held)) { + &error(&text('hold_enotheld', $p)); + } + push(@holdnames, $p); + } + @holdnames = &unique(@holdnames); + $err = &update_package_holds(\@holdnames, $hold_action); + &error(&text($hold_action ? 'hold_efailed' : 'unhold_efailed', $err)) + if ($err); + &flush_package_caches(); + $logaction = $hold_action ? 'hold' : 'unhold'; + &webmin_log($logaction, "packages", scalar(@holdnames), + { 'packages' => \@holdnames }); + &redirect("index.cgi?mode=".&urlize($in{'mode'}). + "&search=".&urlize($in{'search'})); + } +elsif ($in{'refresh'} || $in{'refresh_top'}) { &ui_print_unbuffered_header(undef, $text{'refresh_title'}, ""); # Clear all caches @@ -40,6 +71,21 @@ else { # Upgrade some packages my @pkgs = split(/\0/, $in{'u'}); @pkgs || &error($text{'update_enone'}); + $allow_held = 0; + if ($in{'mode'} eq 'held') { + # The held-updates page is the only UI that can explicitly + # override an APT hold for a single update transaction. + &supports_package_holds() || &error($text{'hold_enotsupported'}); + @held = &list_package_holds(); + foreach $ps (@pkgs) { + ($p, $s) = split(/\//, $ps, 2); + $s eq 'apt' && &package_is_held($p, \@held) || + &error(&text('update_enotheld', $p)); + } + $allow_held = 1; + } + $install_flags = $allow_held ? '--allow-change-held-packages' : + $in{'flags'}; &ui_print_unbuffered_header(undef, $in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, ""); @@ -57,6 +103,7 @@ else { push(@pkgnames, $p); } @ops = &list_package_operations(join(" ", @pkgnames), $s); + &error($text{'update_enoheldops'}) if (!@ops && $allow_held); } if (@ops) { @@ -74,9 +121,14 @@ else { foreach $ps (@pkgs) { $confform .= &ui_hidden("u", $ps); } + $confform .= &ui_alert_box($text{'update_heldnote'}, + 'warn', undef, undef, '') + if ($allow_held && !$bottom); $confform .= &text('update_rusure', scalar(@ops)),"

\n" if (!$bottom); - $confform .= &ui_form_end([ [ "confirm", $text{'update_confirm'} ] ]); + $confform .= &ui_form_end([ [ "confirm", + $allow_held ? $text{'update_confirmheld'} : + $text{'update_confirm'} ] ]); }; print &$getconfform(); @@ -127,7 +179,7 @@ else { "
\n"; print "


\n"; } else { @@ -138,7 +190,7 @@ else { print &text($msg, "@{[&html_escape($p)]}"),"
\n"; print "