Merge pull request #2821 from webmin/dev/miniserv-logrotate

Add an option to rotate Miniserv logs using logrotate
This commit is contained in:
Jamie Cameron
2026-08-21 21:47:19 -07:00
committed by GitHub
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";