From 6183a4006aa9298e8e1451b8d6b03d0eeb2675f8 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 22 Aug 2026 01:03:48 +0200 Subject: [PATCH] Add an option to rotate Miniserv logs using logrotate This PR adds the ability for the logging pages to hand the Miniserv access and error logs, and the Webmin actions log, to logrotate instead of having Miniserv periodically delete them. The generated section uses copy-truncate rotation, since Miniserv only reopens its error log when the file disappears. The option only appears when the Log File Rotation module is available and configured correctly. --- usermin/change_log.cgi | 24 ++++++- usermin/edit_log.cgi | 14 +++-- usermin/lang/en | 2 + web-lib-funcs.pl | 8 +++ webmin/change_log.cgi | 26 ++++++-- webmin/edit_log.cgi | 16 +++-- webmin/lang/en | 2 + webmin/webmin-lib.pl | 138 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 16 deletions(-) diff --git a/usermin/change_log.cgi b/usermin/change_log.cgi index c95a2a11c..4a165b60e 100755 --- a/usermin/change_log.cgi +++ b/usermin/change_log.cgi @@ -13,14 +13,24 @@ my $journal_changed = defined($in{'error_journal'}) && &webmin::miniserv_systemd_journal_available("usermin.service") && ($miniserv{'errorlog'} eq '-' ? 1 : 0) != ($in{'error_journal'} ? 1 : 0); +# Either Miniserv clears the logs itself, or logrotate takes them over. There +# is no setting for the latter, so it is on when clearing is off and a section +# already rotates the logs. +my $logrotate = $in{'logclear'} == 2 ? 1 : 0; +!$logrotate || &webmin::miniserv_logrotate_available() || + &error($text{'log_elogrotate'}); +my $was_logrotate = !int($miniserv{'logclear'}) && + &webmin::miniserv_logrotate_available() && + &webmin::get_miniserv_logrotate_section(\%miniserv) ? 1 : 0; + # Validate and save the access-log settings. -!$in{'logclear'} || $in{'logtime'} =~ /^[1-9][0-9]*$/ || - &error(&text('log_ehours', $in{'logtime'})); $miniserv{'log'} = $in{'log'}; $miniserv{'loghost'} = $in{'loghost'}; $miniserv{'logtrust'} = $in{'logtrust'}; $miniserv{'logclf'} = $in{'logclf'}; -$miniserv{'logclear'} = $in{'logclear'}; +$miniserv{'logclear'} = $logrotate ? 0 : $in{'logclear'}; +!$miniserv{'logclear'} || $in{'logtime'} =~ /^[1-9][0-9]*$/ || + &error(&text('log_ehours', $in{'logtime'})); $miniserv{'logtime'} = $in{'logtime'}; # Save the error destination and matching systemd drop-in when supported. @@ -35,6 +45,14 @@ else { } &unlock_file($usermin_miniserv_config); +# Hand the Miniserv logs to logrotate, or take them back from it +if ($logrotate) { + &webmin::setup_miniserv_logrotate(\%miniserv, "usermin"); + } +elsif ($was_logrotate) { + &webmin::remove_miniserv_logrotate(\%miniserv); + } + # Restart through systemd when stderr needs to be re-attached. if ($journal_changed) { &webmin::restart_miniserv_systemd_service("usermin.service", 2); diff --git a/usermin/edit_log.cgi b/usermin/edit_log.cgi index 746be0d46..094b21633 100755 --- a/usermin/edit_log.cgi +++ b/usermin/edit_log.cgi @@ -21,11 +21,17 @@ print &ui_table_row($text{'log_trust'}, &ui_yesno_radio("logtrust", int($miniserv{'logtrust'}))); print &ui_table_row($text{'log_clf'}, &ui_yesno_radio("logclf", int($miniserv{'logclf'}))); +my @clear_opts = ( [ 1, &text('log_period', + &ui_textbox("logtime", $miniserv{'logtime'}, 10)) ] ); +my $logclear = int($miniserv{'logclear'}); +if (&webmin::miniserv_logrotate_available()) { + push(@clear_opts, [ 2, $text{'log_logrotate'} ]); + $logclear = 2 if (!$logclear && + &webmin::get_miniserv_logrotate_section(\%miniserv)); + } +push(@clear_opts, [ 0, $text{'no'} ]); print &ui_table_row($text{'log_clear'}, - &ui_radio("logclear", int($miniserv{'logclear'}), - [ [ 1, &text('log_period', - &ui_textbox("logtime", $miniserv{'logtime'}, 10)) ], - [ 0, $text{'no'} ] ])); + &ui_radio("logclear", $logclear, \@clear_opts)); # Only systemd services can safely inherit stderr into the journal. if (&webmin::miniserv_systemd_journal_available("usermin.service")) { diff --git a/usermin/lang/en b/usermin/lang/en index d19370253..089046662 100644 --- a/usermin/lang/en +++ b/usermin/lang/en @@ -10,6 +10,8 @@ log_trust=Log client IP address when behind proxy? log_clf=Use combined log format (including referrer and user agent)? log_clear=Periodically clear log files? log_period=Yes, every $1 hours +log_logrotate=Yes, rotate using logrotate +log_elogrotate=The logrotate configuration is not available on this system log_error=Error log destination log_error_file=Write to miniserv.error log_error_journal=Write to the systemd journal diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 03310b033..8c754760f 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -7621,6 +7621,14 @@ my ($param_action, my $m = $param_module ? $param_module : &get_module_name(); +# When logrotate has emptied the actions log, throw away the file changes +# and annotations that belonged to the rotated entries +if ($gconfig{'logrotate'} && -e $webmin_logfile && !-s _) { + &unlink_file("$ENV{'WEBMIN_VAR'}/diffs"); + &unlink_file("$ENV{'WEBMIN_VAR'}/files"); + &unlink_file("$ENV{'WEBMIN_VAR'}/annotations"); + } + if ($gconfig{'logclear'}) { # check if it is time to clear the log my @st = stat("$webmin_logfile.time"); diff --git a/webmin/change_log.cgi b/webmin/change_log.cgi index 0ef1cc2ca..ad5a6f39f 100755 --- a/webmin/change_log.cgi +++ b/webmin/change_log.cgi @@ -20,8 +20,18 @@ $miniserv{'log'} = $in{'log'}; $miniserv{'loghost'} = $in{'loghost'}; $miniserv{'logtrust'} = $in{'logtrust'}; $miniserv{'logclf'} = $in{'logclf'}; -$miniserv{'logclear'} = $in{'logclear'}; -!$in{'logclear'} || $in{'logtime'} =~ /^[1-9][0-9]*$/ || + +# Either Miniserv clears the logs itself, or logrotate takes them over. There +# is no setting for the latter, so it is on when clearing is off and a section +# already rotates the logs. +my $logrotate = $in{'logclear'} == 2 ? 1 : 0; +!$logrotate || &miniserv_logrotate_available() || + &error($text{'log_elogrotate'}); +my $was_logrotate = !int($miniserv{'logclear'}) && + &miniserv_logrotate_available() && + &get_miniserv_logrotate_section(\%miniserv) ? 1 : 0; +$miniserv{'logclear'} = $logrotate ? 0 : $in{'logclear'}; +!$miniserv{'logclear'} || $in{'logtime'} =~ /^[1-9][0-9]*$/ || &error(&text('log_ehours', $in{'logtime'})); $miniserv{'logtime'} = $in{'logtime'}; if ($in{'perms_def'}) { @@ -60,9 +70,18 @@ else { } &unlock_file($ENV{'MINISERV_CONFIG'}); +# Hand the Miniserv and actions logs to logrotate, or take them back from it +if ($logrotate) { + &setup_miniserv_logrotate(\%miniserv, "webmin", [ $webmin_logfile ]); + } +elsif ($was_logrotate) { + &remove_miniserv_logrotate(\%miniserv, [ $webmin_logfile ]); + } + $gconfig{'log'} = $in{'log'}; $gconfig{'logtime'} = $in{'logtime'}; -$gconfig{'logclear'} = $in{'logclear'}; +$gconfig{'logclear'} = $miniserv{'logclear'}; +$gconfig{'logrotate'} = $logrotate; $gconfig{'logusers'} = $in{'uall'} ? '' : join(" ", split(/\0/, $in{'users'})); $gconfig{'logmodules'} = @@ -95,4 +114,3 @@ else { &show_restart_page(); } &webmin_log("log", undef, undef, \%in); - diff --git a/webmin/edit_log.cgi b/webmin/edit_log.cgi index 107843594..e221e736d 100755 --- a/webmin/edit_log.cgi +++ b/webmin/edit_log.cgi @@ -31,12 +31,18 @@ print &ui_table_row($text{'log_trust'}, print &ui_table_row($text{'log_clf'}, &ui_yesno_radio("logclf", int($miniserv{'logclf'}))); -# Clear logs regularly +# Clear logs regularly, or hand them over to logrotate when available +my @clear_opts = ( [ 1, &text('log_period', + &ui_textbox("logtime", $miniserv{'logtime'}, 10)) ] ); +my $logclear = int($miniserv{'logclear'}); +if (&miniserv_logrotate_available()) { + push(@clear_opts, [ 2, $text{'log_logrotate'} ]); + $logclear = 2 if (!$logclear && + &get_miniserv_logrotate_section(\%miniserv)); + } +push(@clear_opts, [ 0, $text{'no'} ]); print &ui_table_row($text{'log_clear2'}, - &ui_radio("logclear", int($miniserv{'logclear'}), - [ [ 1, &text('log_period', - &ui_textbox("logtime", $miniserv{'logtime'}, 10)) ], - [ 0, $text{'no'} ] ])); + &ui_radio("logclear", $logclear, \@clear_opts)); # A systemd service can keep Miniserv errors in the journal instead. if (&miniserv_systemd_journal_available("webmin.service")) { diff --git a/webmin/lang/en b/webmin/lang/en index da1cd4c6e..8c8e19c9f 100644 --- a/webmin/lang/en +++ b/webmin/lang/en @@ -93,6 +93,8 @@ log_trust=Log client IP address when behind proxy? log_clf=Use combined log format (including referrer and user agent)? log_clear2=Periodically clear log files? log_period=Yes, every $1 hours +log_logrotate=Yes, rotate using logrotate +log_elogrotate=The logrotate configuration is not available on this system log_error=Error log destination log_error_file=Write to miniserv.error log_error_journal=Write to the systemd journal diff --git a/webmin/webmin-lib.pl b/webmin/webmin-lib.pl index e8e6ffe6c..5398ea1e5 100755 --- a/webmin/webmin-lib.pl +++ b/webmin/webmin-lib.pl @@ -83,6 +83,8 @@ else { $logfile =~ s![^/]+$!miniserv.error!; $miniserv->{'errorlog'} = $logfile; &unlink_file($dropin) if (-e $dropin); + # Take the directory too, unless other drop-ins are still using it + rmdir($dropin_dir) if (-d $dropin_dir); } # Apply the Miniserv and service-manager settings as one UI operation. @@ -110,6 +112,142 @@ else { } return 1; } + +=head2 miniserv_logrotate_available() + +Returns 1 if logrotate is installed and can be configured from Webmin. + +=cut +sub miniserv_logrotate_available +{ +return &foreign_available("logrotate") && &foreign_installed("logrotate") + ? 1 + : 0; +} + +=head2 miniserv_log_files(&miniserv) + +Returns the Miniserv access and error log paths that rotation applies to. + +=cut +sub miniserv_log_files +{ +my ($miniserv) = @_; +my $errorlog = $miniserv->{'logfile'}; +$errorlog =~ s![^/]+$!miniserv.error!; +$errorlog = $miniserv->{'errorlog'} if ($miniserv->{'errorlog'} =~ /^\//); +return &unique($miniserv->{'logfile'}, $errorlog); +} + +=head2 get_miniserv_logrotate_section(&miniserv) + +Returns the logrotate section that covers the Miniserv access log, if any. + +=cut +sub get_miniserv_logrotate_section +{ +my ($miniserv) = @_; +&foreign_require("logrotate"); +my ($logfile) = &miniserv_log_files($miniserv); +# Global directives like weekly and rotate share the list with log sections, +# but their name is a plain string instead of a list of log files +foreach my $c (@{&logrotate::get_config()}) { + next if (ref($c->{'name'}) ne 'ARRAY'); + return $c if (&indexof($logfile, @{$c->{'name'}}) >= 0); + } +return undef; +} + +=head2 setup_miniserv_logrotate(&miniserv, name, [&extra-logs]) + +Adds any missing Miniserv and extra logs to the access-log section, or creates +a section when none exists. Copy-truncate rotation lets Miniserv keep its open +error log handle, as it only re-opens that log when the file disappears. + +=cut +sub setup_miniserv_logrotate +{ +my ($miniserv, $name, $extra) = @_; +my @logs = &unique(&miniserv_log_files($miniserv), @{$extra || [ ]}); +my $lconf = &get_miniserv_logrotate_section($miniserv); + +# Skip any log that some other section already rotates +my %covered; +foreach my $c (@{&logrotate::get_config()}) { + next if (ref($c->{'name'}) ne 'ARRAY'); + foreach my $log (@{$c->{'name'}}) { + $covered{$log} = 1; + } + } +my @missing = grep { !$covered{$_} } @logs; +return 0 if ($lconf && !@missing); + +# Vendor files cannot be edited in place, so copy first on systems that +# keep their logrotate config under /usr +my $file = $lconf ? $lconf->{'file'} : &logrotate::get_add_file($name); +&logrotate::ensure_writable_config_file($file); +my $parent = &logrotate::get_config_parent(); +if ($lconf) { + # Copying re-reads the config, so look the section up again + $lconf = &get_miniserv_logrotate_section($miniserv); + push(@{$lconf->{'name'}}, @missing); + } +else { + $lconf = { 'file' => &logrotate::get_add_file($name), + 'name' => [ grep { !$covered{$_} } @logs ], + 'members' => [ { 'name' => 'weekly' }, + { 'name' => 'rotate', 'value' => 7 }, + { 'name' => 'missingok' }, + { 'name' => 'notifempty' }, + { 'name' => 'compress' }, + { 'name' => 'copytruncate' } ] }; + } +&lock_file($lconf->{'file'}); +&logrotate::save_directive($parent, + defined($lconf->{'index'}) ? $lconf : undef, + $lconf); +&flush_file_lines($lconf->{'file'}); +&unlock_file($lconf->{'file'}); +&logrotate::flush_logrotate_config_cache(); +return 1; +} + +=head2 remove_miniserv_logrotate(&miniserv, [&extra-logs]) + +Removes the Miniserv logs and any extra logs from their logrotate section. The +section and its file are deleted when they cover nothing else. + +=cut +sub remove_miniserv_logrotate +{ +my ($miniserv, $extra) = @_; +my $lconf = &get_miniserv_logrotate_section($miniserv); +return 0 if (!$lconf); +&logrotate::ensure_writable_config_file($lconf->{'file'}); + +# Copying re-reads the config, so look the section up again +$lconf = &get_miniserv_logrotate_section($miniserv); +my %logs = map { $_, 1 } (&miniserv_log_files($miniserv), @{$extra || [ ]}); +my @leftover = grep { !$logs{$_} } @{$lconf->{'name'}}; +my $parent = &logrotate::get_config_parent(); +&lock_file($lconf->{'file'}); +if (@leftover) { + # Other logs share the section, so only drop ours + $lconf->{'name'} = \@leftover; + &logrotate::save_directive($parent, $lconf, $lconf); + &flush_file_lines($lconf->{'file'}); + &unlock_file($lconf->{'file'}); + &logrotate::flush_logrotate_config_cache(); + } +else { + &logrotate::save_directive($parent, $lconf, undef); + &flush_file_lines($lconf->{'file'}); + &unlock_file($lconf->{'file'}); + &logrotate::flush_logrotate_config_cache(); + &logrotate::delete_if_empty($lconf->{'file'}); + } +return 1; +} our $primary_port = $primary_ssl ? 443 : 80; our $webmin_key_email = "jcameron\@webmin.com";