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.
This commit is contained in:
Ilia Ross
2026-08-22 01:03:48 +02:00
parent 850cc1f498
commit 6183a4006a
8 changed files with 214 additions and 16 deletions
+21 -3
View File
@@ -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);
+10 -4
View File
@@ -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")) {
+2
View File
@@ -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 <tt>logrotate</tt>
log_elogrotate=The <tt>logrotate</tt> configuration is not available on this system
log_error=Error log destination
log_error_file=Write to <tt>miniserv.error</tt>
log_error_journal=Write to the <tt>systemd journal</tt>
+8
View File
@@ -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");
+22 -4
View File
@@ -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);
+11 -5
View File
@@ -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")) {
+2
View File
@@ -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 <tt>logrotate</tt>
log_elogrotate=The <tt>logrotate</tt> configuration is not available on this system
log_error=Error log destination
log_error_file=Write to <tt>miniserv.error</tt>
log_error_journal=Write to the <tt>systemd journal</tt>
+138
View File
@@ -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";