Compare commits

...

11 Commits

Author SHA1 Message Date
Ilia Ross
2ac67d82a4 Merge branch 'master' into dev/dnf-hold 2026-08-24 00:30:32 +02:00
Ilia Ross
16ee9f455a Fix PostgreSQL initialization to use SCRAM-SHA-256 auth by default
Some checks are pending
Tests / prove (push) Waiting to run
Package and upload artifacts / build (push) Waiting to run
https://forum.virtualmin.com/t/postgresql-defautl-install-issues/137798/3?u=ilia
2026-08-24 00:29:52 +02:00
Ilia Ross
86d3b95f3b Update changelog 2026-08-23 23:13:35 +02:00
Ilia Ross
f01d65a26e Add DNF versionlock support to Package Updates
This PR adds DNF 4 and DNF 5 versionlock support to Package Updates, including listing, holding, unholding, and explicitly updating held packages.

DNF 4 requires the versionlock plugin to be installed, while DNF 5 supports versionlock natively.

Hold controls are shown only when the versionlock command is available.

It also previews DNF transactions before confirmation so users can see which packages will be installed or updated in the table, similar to APT.
2026-08-23 23:03:46 +02:00
Jamie Cameron
d16d9ea205 Merge pull request #2821 from webmin/dev/miniserv-logrotate
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
Add an option to rotate Miniserv logs using logrotate
2026-08-21 21:47:19 -07:00
Ilia Ross
f7da47cd5e Update changelog 2026-08-22 01:19:22 +02:00
Ilia Ross
0660a18276 Add function to make vendor configs editable 2026-08-22 01:17:49 +02:00
Ilia Ross
6183a4006a 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.
2026-08-22 01:03:48 +02:00
Ilia Ross
850cc1f498 Fix error log check on systems without systemd unit
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-08-21 21:24:23 +02:00
Ilia Ross
523853963e Add option to send Miniserv errors to the systemd journal
Add a Logging page to the Usermin Configuration module for the access-log settings, guarded by a new "log" ACL.

Also, Webmin and Usermin logging pages add new an "Error log destination" choice on systemd systems. Selecting the journal sets errorlog=- and installs a local StandardError=journal drop-in, leaving the packaged unit untouched.

https://forum.virtualmin.com/t/miniserv-webserver-log-growing-too-big-should-be-rotated/136562/10?u=ilia
2026-08-21 21:02:58 +02:00
Ilia Ross
3c26b2be3b Fix FQDN detection on systemd systems
ⓘ Do not return a short name from /etc/hostname when the full name is requested; resolve it with hostname -f first, honoring the no_hostname_f option, and otherwise fall back to the existing file-based logic.

Also check for /run/systemd/system instead of reading /proc/1/comm as it's much cheaper.

Fixes #2054
2026-08-21 19:37:47 +02:00
25 changed files with 1206 additions and 58 deletions

View File

@@ -1,4 +1,12 @@
## Changelog ## Changelog
#### 2.661 (September, 2026)
* Add options to send Webmin and Usermin errors to the systemd journal [forum.virtualmin.com/t/136562](https://forum.virtualmin.com/t/miniserv-webserver-log-growing-too-big-should-be-rotated/136562)
* Add webserver logging controls to Usermin Configuration module
* Add option to rotate Webmin and Usermin webserver logs using `logrotate` instead of periodically clearing them [#2821](https://github.com/webmin/webmin/pull/2821)
* Add DNF 4 and 5 package hold management to the Software Package Updates module
* Fix DNF update confirmations by previewing packages and dependencies that will be installed or updated
* Fix PostgreSQL initialization on EL systems to use SCRAM-SHA-256 authentication by default
#### 2.660 (August 20, 2026) #### 2.660 (August 20, 2026)
* Add support for creating `vfsv1` Linux quota files for limits above 4 TiB, while preserving existing quota file formats * Add support for creating `vfsv1` Linux quota files for limits above 4 TiB, while preserving existing quota file formats
* Add Btrfs subvolume quota management to the Disk Quotas module, with full and simple accounting modes * Add Btrfs subvolume quota management to the Disk Quotas module, with full and simple accounting modes

View File

@@ -168,6 +168,31 @@ return $file if (!$local);
return &copy_vendor_config($file, $local); return &copy_vendor_config($file, $local);
} }
# ensure_writable_config_file(file)
# Makes a config file safe to edit, by creating the local override that
# shadows it when it belongs to the vendor tree. Callers must use this before
# saving directives, as save_directive refuses to write to a vendor file.
sub ensure_writable_config_file
{
my ($file) = @_;
if (&is_vendor_main_config($file)) {
return &ensure_local_main_config();
}
elsif (&is_vendor_config_file($file)) {
return &ensure_local_config_override($file);
}
elsif (my $vendor = &get_vendor_config_file($file)) {
# A local path that shadows a vendor file, but does not exist yet
return &ensure_local_config_override($vendor);
}
elsif ($file eq &get_config_parent()->{'file'} &&
&is_vendor_main_config(&get_main_config_file())) {
# New directives go to the main config, which is still the vendor one
return &ensure_local_main_config();
}
return $file;
}
# list_config_dir_files(directory, [relative-subdirectory]) # list_config_dir_files(directory, [relative-subdirectory])
# Returns relative and absolute paths for regular files below a directory # Returns relative and absolute paths for regular files below a directory
sub list_config_dir_files sub list_config_dir_files

View File

@@ -1,4 +1,5 @@
---- Changes since 2.660 ---- ---- Changes since 2.660 ----
Added support for managing DNF 4 and 5 version locks when the versionlock command is installed.
Added a "New updates are found" choice to the "Send email when" option that sends a complete report only when new updates become available, so notify-only schedules no longer repeat the same message every run. Added a "New updates are found" choice to the "Send email when" option that sends a complete report only when new updates become available, so notify-only schedules no longer repeat the same message every run.
---- Changes since 2.641 ---- ---- Changes since 2.641 ----
Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages. Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages.

View File

@@ -107,7 +107,7 @@ update_newver=New version
update_confirm=Install Now update_confirm=Install Now
update_confirmheld=Update Held Packages 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_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_enotheld=Package $1 is not currently held
update_enoheldops=No update operation was found for the selected held packages. Refresh the package list and try again. update_enoheldops=No update operation was found for the selected held packages. Refresh the package list and try again.
update_none=None update_none=None
update_ops=Building complete list of packages .. update_ops=Building complete list of packages ..

View File

@@ -314,8 +314,12 @@ return defined(&software::update_system_updates);
# Returns true if the current update system can list and change package holds. # Returns true if the current update system can list and change package holds.
sub supports_package_holds sub supports_package_holds
{ {
return defined(&software::list_update_system_holds) && return 0 if (!defined(&software::list_update_system_holds) ||
defined(&software::update_system_hold); !defined(&software::update_system_hold) ||
!defined(&software::update_system_hold_flags));
return &software::supports_update_system_holds()
if (defined(&software::supports_update_system_holds));
return 1;
} }
# list_package_holds() # list_package_holds()
@@ -389,8 +393,9 @@ my ($name, $system, $install, $flags) = @_;
$system ||= $software::update_system; $system ||= $software::update_system;
my @rv; my @rv;
my $pkg; my $pkg;
my $include_held = $system eq 'apt' && defined($flags) && my $include_held = $system eq $software::update_system &&
$flags eq '--allow-change-held-packages'; &supports_package_holds() && defined($flags) &&
$flags eq &software::update_system_hold_flags();
# First get from list of updates # First get from list of updates
($pkg) = grep { $_->{'update'} eq $name && ($pkg) = grep { $_->{'update'} eq $name &&
@@ -490,14 +495,13 @@ unlink($current_cache_file);
return @rv; return @rv;
} }
# list_package_operations(package|packages, system) # list_package_operations(package|packages, system, [flags])
# Given a package (or space-separate package list), returns a list of all # Returns packages and dependencies that would be installed or updated.
# dependencies that will be installed
sub list_package_operations sub list_package_operations
{ {
my ($name, $system) = @_; my ($name, $system, $flags) = @_;
if (defined(&software::update_system_operations)) { if (defined(&software::update_system_operations)) {
my @rv = &software::update_system_operations($name); my @rv = &software::update_system_operations($name, $flags);
foreach my $p (@rv) { foreach my $p (@rv) {
$p->{'system'} = $system; $p->{'system'} = $system;
} }

View File

@@ -73,18 +73,18 @@ else {
@pkgs || &error($text{'update_enone'}); @pkgs || &error($text{'update_enone'});
$allow_held = 0; $allow_held = 0;
if ($in{'mode'} eq 'held') { if ($in{'mode'} eq 'held') {
# The held-updates page is the only UI that can explicitly # Only Held updates can override a hold for one transaction.
# override an APT hold for a single update transaction.
&supports_package_holds() || &error($text{'hold_enotsupported'}); &supports_package_holds() || &error($text{'hold_enotsupported'});
@held = &list_package_holds(); @held = &list_package_holds();
foreach $ps (@pkgs) { foreach $ps (@pkgs) {
($p, $s) = split(/\//, $ps, 2); ($p, $s) = split(/\//, $ps, 2);
$s eq 'apt' && &package_is_held($p, \@held) || $s eq $software::update_system &&
&package_is_held($p, \@held) ||
&error(&text('update_enotheld', $p)); &error(&text('update_enotheld', $p));
} }
$allow_held = 1; $allow_held = 1;
} }
$install_flags = $allow_held ? '--allow-change-held-packages' : $install_flags = $allow_held ? &software::update_system_hold_flags() :
$in{'flags'}; $in{'flags'};
&ui_print_unbuffered_header(undef, &ui_print_unbuffered_header(undef,
$in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, ""); $in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, "");
@@ -102,7 +102,8 @@ else {
($p, $s) = split(/\//, $ps); ($p, $s) = split(/\//, $ps);
push(@pkgnames, $p); push(@pkgnames, $p);
} }
@ops = &list_package_operations(join(" ", @pkgnames), $s); @ops = &list_package_operations(join(" ", @pkgnames), $s,
$install_flags);
&error($text{'update_enoheldops'}) if (!@ops && $allow_held); &error($text{'update_enoheldops'}) if (!@ops && $allow_held);
} }

View File

@@ -986,6 +986,11 @@ return undef;
sub setup_postgresql sub setup_postgresql
{ {
return undef if (!$config{'setup_cmd'}); return undef if (!$config{'setup_cmd'});
# Use SCRAM defaults for new clusters when supported by the EL setup wrapper
local $ENV{'PGSETUP_INITDB_OPTIONS'} = '--auth-host=scram-sha-256'
if ($config{'setup_cmd'} =~ /\bpostgresql-setup\b/ &&
&get_postgresql_version(1) >= 10 &&
!$ENV{'PGSETUP_INITDB_OPTIONS'});
local $temp = &transname(); local $temp = &transname();
local $rv = &system_logged("($config{'setup_cmd'}) >$temp 2>&1"); local $rv = &system_logged("($config{'setup_cmd'}) >$temp 2>&1");
local $out = `cat $temp`; local $out = `cat $temp`;

View File

@@ -1,6 +1,7 @@
---- Changes since 2.641 ---- ---- Changes since 2.641 ----
Fix Alpine Linux mysql/mariadb package installs names due missing server utils (means at least Alpine Linux package installation is supported since Alpine linux v 3.16 up to edge) Fix Alpine Linux mysql/mariadb package installs names due missing server utils (means at least Alpine Linux package installation is supported since Alpine linux v 3.16 up to edge)
Added APT functions for listing, holding, unholding and explicitly updating held packages. Added APT functions for listing, holding, unholding and explicitly updating held packages.
Added DNF 4 and 5 functions for listing, locking, unlocking and explicitly updating version-locked packages.
---- Changes since 1.130 ---- ---- Changes since 1.130 ----
Packages can now be installed directly from yum, if installed. Packages can now be installed directly from yum, if installed.
The entire system can also be upgraded from yum. The entire system can also be upgraded from yum.

View File

@@ -20,6 +20,13 @@ $name =~ s/:[A-Za-z0-9][A-Za-z0-9._-]*$//;
return $name; return $name;
} }
# update_system_hold_flags()
# Returns the APT option for explicitly updating held packages.
sub update_system_hold_flags
{
return '--allow-change-held-packages';
}
# update_system_install([package], [&in], [no-force], [flags]) # update_system_install([package], [&in], [no-force], [flags])
# Install some package with apt # Install some package with apt
sub update_system_install sub update_system_install
@@ -105,15 +112,17 @@ $? = $status;
return @rv; return @rv;
} }
# update_system_operations(packages) # update_system_operations(packages, [flags])
# Given a list of packages, returns a list containing packages that will # Returns packages APT would install or update. The optional hold flag includes
# actually get installed, each of which is a hash ref with name and version. # held packages in the simulation.
sub update_system_operations sub update_system_operations
{ {
my ($packages) = @_; my ($packages, $flags) = @_;
$ENV{'UCF_FORCE_CONFFOLD'} = 'YES'; $ENV{'UCF_FORCE_CONFFOLD'} = 'YES';
$ENV{'DEBIAN_FRONTEND'} = 'noninteractive'; $ENV{'DEBIAN_FRONTEND'} = 'noninteractive';
my $cmd = "apt-get -s install ". my $holdflag = defined($flags) &&
$flags eq &update_system_hold_flags() ? " $flags" : "";
my $cmd = "apt-get -s$holdflag install ".
join(" ", map { quotemeta($_) } split(/\s+/, $packages)). join(" ", map { quotemeta($_) } split(/\s+/, $packages)).
" </dev/null 2>&1"; " </dev/null 2>&1";
&clean_language(); &clean_language();
@@ -261,8 +270,8 @@ close(DUMP);
return @rv; return @rv;
} }
# update_system_updates() # update_system_updates([include-holds])
# Returns a list of available package updates # Returns available package updates, optionally including held packages.
sub update_system_updates sub update_system_updates
{ {
my ($include_holds) = @_; my ($include_holds) = @_;

View File

@@ -266,6 +266,11 @@ yum_input=Package from YUM
yum_install=Installing package(s) with command $1 .. yum_install=Installing package(s) with command $1 ..
yum_ok=.. install complete yum_ok=.. install complete
yum_failed=.. install failed! yum_failed=.. install failed!
yum_unholdfailed=.. failed to temporarily remove the version lock on $1 : $2
yum_reholdfailed=.. failed to restore the version lock on $1 : $2
yum_versionlock_missing=The DNF versionlock command is not installed
yum_versionlock_none=No packages were specified
yum_versionlock_failed=dnf versionlock $1 failed
yum_find=Browse YUM .. yum_find=Browse YUM ..
yum_package=Package yum_package=Package
yum_version=Version yum_version=Version

View File

@@ -19,17 +19,61 @@ sub list_update_system_commands
return ($yum_command); return ($yum_command);
} }
# get_dnf_version()
# Returns the DNF major version, or zero when using YUM.
sub get_dnf_version
{
return 0 if ($yum_command !~ /(?:^|\/)dnf(?:-\d+)?$/);
if (!defined($dnf_version)) {
&clean_language();
my $out = &backquote_command(
"$yum_command --version 2>&1 </dev/null");
&reset_environment();
$dnf_version = $out =~ /^dnf5\s+version\s+(\d+)/m ? $1 : 4;
}
return $dnf_version;
}
# supports_update_system_holds()
# Reports whether DNF provides the versionlock command.
sub supports_update_system_holds
{
if (!defined($supports_dnf_versionlock)) {
$supports_dnf_versionlock = 0;
if (&get_dnf_version()) {
&clean_language();
my $out = &backquote_command(
"$yum_command --help 2>&1 </dev/null");
&reset_environment();
$supports_dnf_versionlock = 1
if ($out =~ /^\s*versionlock\s+/m);
}
}
return $supports_dnf_versionlock;
}
# update_system_hold_flags()
# Returns the DNF option that exposes version-locked updates.
sub update_system_hold_flags
{
return &get_dnf_version() >= 5 ? '--setopt=disable_excludes=*' :
'--disableplugin=versionlock';
}
# update_system_install([packages], [&in], [no-force], [flags]) # update_system_install([packages], [&in], [no-force], [flags])
# Install some package with yum # Installs or updates packages with YUM or DNF.
sub update_system_install sub update_system_install
{ {
local $update = $_[0] || $in{'update'}; local $update = $_[0] || $in{'update'};
local $in = $_[1]; local $in = $_[1];
local $force = !$_[2]; local $force = !$_[2];
local $flags = $_[3]; local $flags = $_[3];
local $versionlock_update = defined($flags) &&
$flags eq &update_system_hold_flags();
local $runflags = $versionlock_update ? undef : $flags;
local $qflags; local $qflags;
$qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $flags))) $qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $runflags)))
if ($flags); if ($runflags);
$update =~ s/\.\*/\*/g; $update =~ s/\.\*/\*/g;
local $enable; local $enable;
if ($in->{'enablerepo'}) { if ($in->{'enablerepo'}) {
@@ -45,7 +89,28 @@ if (@names == 1) {
} }
$update = join(" ", map { quotemeta($_) } @names); $update = join(" ", map { quotemeta($_) } @names);
# Work out command to use - for DNF, upgrades need to use the update command # Temporarily unlock selected packages, then restore their holds after the
# transaction.
local @relock;
if ($versionlock_update) {
my @packages = &unique(@updates);
my $unlock_error = &delete_update_system_holds(
\@packages, \@relock);
if ($unlock_error) {
my $relock_error = &restore_update_system_holds(\@relock);
if ($relock_error) {
print &text('yum_reholdfailed',
"<tt>".&html_escape(join(" ", @packages))."</tt>",
&html_escape($relock_error)),"<p>\n";
}
print &text('yum_unholdfailed',
"<tt>".&html_escape(join(" ", @packages))."</tt>",
&html_escape($unlock_error)),"<p>\n";
return ( );
}
}
# Use update for installed DNF packages and install for everything else.
local $cmd; local $cmd;
if ($yum_command =~ /dnf$/) { if ($yum_command =~ /dnf$/) {
local @pinfo = &package_info($updates[0]); local @pinfo = &package_info($updates[0]);
@@ -62,9 +127,9 @@ else {
# Work out the command to run, which may enable some repos # Work out the command to run, which may enable some repos
my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @names); my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @names);
$uicmd .= " $flags" if ($flags); $uicmd .= " $runflags" if ($runflags);
my $fullcmd = "$yum_command $enable -y $cmd $update"; my $fullcmd = "$yum_command $enable -y $cmd $update";
$fullcmd .= " $qflags" if ($flags); $fullcmd .= " $qflags" if ($qflags);
foreach my $u (@updates) { foreach my $u (@updates) {
my $repo = &update_system_repo($u); my $repo = &update_system_repo($u);
if ($repo) { if ($repo) {
@@ -110,7 +175,7 @@ while(<CMD>) {
} }
} }
elsif (/^\s+(Updating|Installing|Upgrading)\s+:\s+(\S+)/) { elsif (/^\s+(Updating|Installing|Upgrading)\s+:\s+(\S+)/) {
# Line like : # Older DNF and YUM progress lines, for example:
# Updating : wbt-virtual-server-theme 1/2 # Updating : wbt-virtual-server-theme 1/2
# or # or
# Installing : 2:nmap-5.51-2.el6.i686 1/1 # Installing : 2:nmap-5.51-2.el6.i686 1/1
@@ -119,12 +184,11 @@ while(<CMD>) {
$pkg =~ s/\-\d.*$//; # Strip version number from end $pkg =~ s/\-\d.*$//; # Strip version number from end
push(@rv, $pkg); push(@rv, $pkg);
} }
elsif (/\]\s+(Upgrading|Installing)\s+(\S+)/) { elsif (/\]\s+(Upgrading|Installing|Downgrading|Reinstalling)\s+(\S+)/) {
# Line like : # DNF 5 progress line, for example:
# [3/8] Upgrading libcurl-0:8.11.1-5.fc42 100% ... # [3/8] Upgrading libcurl-0:8.11.1-5.fc42 100% ...
local $pkg = $2; local $pkg = &update_system_nevra_name($2);
$pkg =~ s/:\d.*$//; # Strip version number from end push(@rv, $pkg) if ($pkg);
push(@rv, $pkg);
} }
if (!/ETA/ && !/\%\s+done\s+\d+\/\d+\s*$/) { if (!/ETA/ && !/\%\s+done\s+\d+\/\d+\s*$/) {
print &html_escape($_."\n"); print &html_escape($_."\n");
@@ -134,8 +198,19 @@ while(<CMD>) {
} }
} }
close(CMD); close(CMD);
local $status = $?;
# Restore holds at the versions now installed.
if (@relock) {
local $relock_error = &restore_update_system_holds(\@relock);
if ($relock_error) {
print &text('yum_reholdfailed',
"<tt>".&html_escape(join(" ", &unique(@updates)))."</tt>",
&html_escape($relock_error)),"<p>\n";
}
}
print "</pre>\n"; print "</pre>\n";
if ($? || $nopackage) { if ($status || $nopackage) {
print "$text{'yum_failed'}<p>\n"; print "$text{'yum_failed'}<p>\n";
return ( ); return ( );
} }
@@ -167,12 +242,26 @@ for(my $i=0; $i<$n; $i++) {
return @rv; return @rv;
} }
# update_system_operations(packages) # update_system_nevra_name(nevra)
# Given a list of packages, returns a list containing packages that will # Extracts the package name from a NEVRA string printed by DNF.
# actually get installed, each of which is a hash ref with name and version. sub update_system_nevra_name
{
my ($nevra) = @_;
$nevra =~ s/^\d+://; # Older DNF may put the epoch before the name
return $1 if ($nevra =~ /^(.+)-\d+:/);
return $1 if ($nevra =~ /^(.+)-\d[^-]*-[^-]+(?:\.[^.]+)?$/);
return undef;
}
# update_system_operations(packages, [flags])
# Returns packages YUM or DNF would install or update. DNF previews the
# transaction directly; YUM uses shell mode.
sub update_system_operations sub update_system_operations
{ {
my ($packages) = @_; my ($packages, $flags) = @_;
if ($yum_command =~ /(?:^|\/)dnf(?:-\d+)?$/) {
return &update_system_dnf_operations($packages, $flags);
}
my $temp = &transname(); my $temp = &transname();
&open_tempfile(SHELL, ">$temp", 0, 1); &open_tempfile(SHELL, ">$temp", 0, 1);
&print_tempfile(SHELL, "install $packages\n"); &print_tempfile(SHELL, "install $packages\n");
@@ -198,6 +287,87 @@ close(SHELL);
return @rv; return @rv;
} }
# update_system_dnf_operations(packages, [flags])
# Returns packages DNF would install or update in a simulated transaction.
sub update_system_dnf_operations
{
my ($packages, $flags) = @_;
my @rv;
my $hold_override = defined($flags) &&
$flags eq &update_system_hold_flags();
my $runflags = defined($flags) && !$hold_override ?
&trim(join(" ", map { quotemeta($_) } split(/\s+/, $flags))) : "";
my @relock;
if ($hold_override) {
my @selected = &unique(split(/\s+/, $packages));
my $error = &delete_update_system_holds(\@selected, \@relock);
if ($error) {
&restore_update_system_holds(\@relock) if (@relock);
return ( );
}
}
# DNF 5 does not upgrade installed packages with install, so use upgrade for
# its previews.
my $action = &get_dnf_version() >= 5 ? 'upgrade' : 'install';
my $command = "$yum_command --assumeno $action ".
join(" ", map { quotemeta($_) } split(/\s+/, $packages));
$command .= " $runflags" if ($runflags);
$command .= " 2>/dev/null";
&clean_language();
&open_execute_command(DNF, $command, 1, 1);
my ($intable, $skip, $wrapped);
while(<DNF>) {
s/\r|\n//g;
if (/^\s*Package\s+Arch(itecture)?\s+Version\s+Repo/i) {
# Start reading the transaction table.
$intable = 1;
}
elsif (/^\s*Transaction\s+Summary/i) {
last;
}
elsif (!$intable || /^=+$/) {
next;
}
elsif (/^\S/) {
# Ignore sections that do not add package versions.
$skip = !/^(Installing|Upgrading|Reinstalling|Downgrading)/i;
$wrapped = undef;
}
elsif ($skip) {
next;
}
elsif (/^\s+(\S+)\s*$/) {
# Save a long package name wrapped onto its own line.
$wrapped = $1;
}
elsif (/^\s+replacing\s/i) {
# Ignore an old package shown below its replacement.
$wrapped = undef;
}
elsif (/^\s+\S/) {
# Parse a complete row, or the remainder of a wrapped row.
my @cols = split(/\s+/, &trim($_));
unshift(@cols, $wrapped) if ($wrapped);
$wrapped = undef;
next if (@cols < 4);
my $pkg = { 'name' => $cols[0],
'arch' => $cols[1],
'version' => $cols[2] };
if ($pkg->{'version'} =~ s/^(\S+)://) {
$pkg->{'epoch'} = $1;
}
push(@rv, $pkg);
}
}
close(DNF);
&reset_environment();
if (@relock) {
my $error = &restore_update_system_holds(\@relock);
return ( ) if ($error);
}
return @rv;
}
# show_update_system_opts() # show_update_system_opts()
# Returns HTML for enabling a repository, if any are disabled # Returns HTML for enabling a repository, if any are disabled
sub show_update_system_opts sub show_update_system_opts
@@ -333,14 +503,19 @@ while(<PKG>) {
close(PKG); close(PKG);
} }
# update_system_updates() # update_system_updates([include-holds])
# Returns a list of package updates available from yum # Returns available package updates, optionally including version-locked ones.
sub update_system_updates sub update_system_updates
{ {
my ($include_holds) = @_;
local @rv; local @rv;
local %done; local %done;
if ($yum_command =~ /dnf/) { if ($yum_command =~ /dnf/) {
&open_execute_command(PKG, "$yum_command check-update 2>/dev/null", 1, 1); my $holdflag = $include_holds && &supports_update_system_holds() ?
" ".&update_system_hold_flags() : "";
$holdflag =~ s/\*/\\*/g;
&open_execute_command(PKG,
"$yum_command$holdflag check-update 2>/dev/null", 1, 1);
} }
else { else {
&open_execute_command(PKG, "$yum_command check-update 2>/dev/null | tr '\n' '#' | sed -e 's/# / /g' | tr '#' '\n'", 1, 1); &open_execute_command(PKG, "$yum_command check-update 2>/dev/null | tr '\n' '#' | sed -e 's/# / /g' | tr '#' '\n'", 1, 1);
@@ -361,10 +536,157 @@ while(<PKG>) {
last if (/Obsoleting\s+Packages/i); last if (/Obsoleting\s+Packages/i);
} }
close(PKG); close(PKG);
if (&supports_update_system_holds()) {
my %holds = map { $_, 1 } &list_update_system_holds();
foreach my $pkg (@rv) {
$pkg->{'held'} = 1 if ($holds{$pkg->{'name'}});
}
@rv = grep { !$_->{'held'} } @rv if (!$include_holds);
}
&set_yum_security_field(\%done); &set_yum_security_field(\%done);
return @rv; return @rv;
} }
# update_system_hold_spec_name(spec)
# Extracts the package name from an exact DNF 4 versionlock entry.
sub update_system_hold_spec_name
{
my ($spec) = @_;
return undef if ($spec =~ /^!/);
return $1 if ($spec =~ /^(.+)-\d+:[^-]+-[^-]+\.[^.]+$/);
return $1 if ($spec =~ /^\d+:(.+)-[^-]+-[^-]+\.[^.]+$/);
return undef;
}
# list_update_system_hold_specs()
# Returns exact locks that Webmin can safely manage by package name.
sub list_update_system_hold_specs
{
return ( ) if (!&supports_update_system_holds());
my @locks;
&clean_language();
&open_execute_command(VLOCK,
"$yum_command -q versionlock list 2>/dev/null", 1, 1);
if (&get_dnf_version() >= 5) {
# DNF 5 formats each lock as a multi-line package block.
my ($lock, @blocks);
while(<VLOCK>) {
s/\r|\n//g;
if (/^Package name:\s*(\S+)/) {
push(@blocks, $lock) if ($lock);
$lock = { 'name' => $1, 'spec' => $1 };
}
elsif ($lock && /^evr\s*=\s*(\S+)/) {
$lock->{'exact'} = 1;
}
elsif ($lock && /\S/ && !/^\s*#/) {
$lock->{'custom'} = 1;
}
elsif (!/\S/) {
push(@blocks, $lock) if ($lock);
$lock = undef;
}
}
push(@blocks, $lock) if ($lock);
# Ignore globs, duplicate entries and custom conditions because deleting
# them by name could remove rules that Webmin cannot restore.
my (%blocks, %custom);
foreach my $block (@blocks) {
$blocks{$block->{'name'}}++;
$custom{$block->{'name'}} = 1
if (!$block->{'exact'} || $block->{'custom'});
}
@locks = grep { !$custom{$_->{'name'}} &&
$blocks{$_->{'name'}} == 1 &&
$_->{'name'} !~ /[\*\?\[\]]/ } @blocks;
}
else {
# DNF 4 prints standard entries as name-epoch:version-release.arch.
while(<VLOCK>) {
s/\r|\n//g;
my $name = &update_system_hold_spec_name($_);
push(@locks, { 'name' => $name, 'spec' => $_ }) if ($name);
}
}
close(VLOCK);
&reset_environment();
return @locks;
}
# list_update_system_holds()
# Returns package names held by exact DNF version locks.
sub list_update_system_holds
{
my %holds = map { $_->{'name'}, 1 } &list_update_system_hold_specs();
return sort keys %holds;
}
# run_update_system_hold(action, package)
# Runs one versionlock operation. Returns undef on success, or error text.
sub run_update_system_hold
{
my ($action, $package) = @_;
my $cmd = "$yum_command -q versionlock $action ".quotemeta($package);
my $out;
&clean_language();
my $status = &execute_command_logged($cmd, undef, \$out, \$out);
&reset_environment();
if ($status) {
$out = &trim($out);
return $out || &text('yum_versionlock_failed', $action);
}
return undef;
}
# delete_update_system_holds(&packages, &removed)
# Unlocks selected packages and records each removed hold for restoration.
sub delete_update_system_holds
{
my ($packages, $removed) = @_;
my %held = map { $_, 1 } &list_update_system_holds();
my $error;
foreach my $package (&unique(@$packages)) {
next if (!$held{$package});
$error = &run_update_system_hold('delete', $package);
last if ($error);
push(@$removed, $package);
}
return $error;
}
# restore_update_system_holds(&packages)
# Re-locks packages at their currently installed versions.
sub restore_update_system_holds
{
my ($packages) = @_;
foreach my $package (&unique(@$packages)) {
my $error = &run_update_system_hold('add', $package);
return $error if ($error);
}
return undef;
}
# update_system_hold(&packages, hold)
# Holds or unholds DNF packages by name. Returns undef on success, or error text.
sub update_system_hold
{
my ($packages, $hold) = @_;
return $text{'yum_versionlock_missing'}
if (!&supports_update_system_holds());
my @packages = &unique(@$packages);
return $text{'yum_versionlock_none'} if (!@packages);
if ($hold) {
foreach my $package (@packages) {
my $error = &run_update_system_hold('add', $package);
return $error if ($error);
}
return undef;
}
my @removed;
return &delete_update_system_holds(\@packages, \@removed);
}
# get_yum_config() # get_yum_config()
# Returns entries from the YUM config file, as a list of hash references # Returns entries from the YUM config file, as a list of hash references
sub get_yum_config sub get_yum_config
@@ -543,4 +865,3 @@ else {
} }
1; 1;

View File

@@ -24,7 +24,9 @@ is(strip_apt_package_arch('ncurses-base'), 'ncurses-base',
{ {
no warnings qw(once redefine); no warnings qw(once redefine);
my $command;
local *backquote_command = sub { local *backquote_command = sub {
$command = $_[0];
return "Inst libtinfo6:amd64 [6.3-2ubuntu0.1] ". return "Inst libtinfo6:amd64 [6.3-2ubuntu0.1] ".
"(6.3-2ubuntu0.2 Ubuntu:22.04/jammy-updates [amd64])\n"; "(6.3-2ubuntu0.2 Ubuntu:22.04/jammy-updates [amd64])\n";
}; };
@@ -34,6 +36,9 @@ local *reset_environment = sub { };
my @ops = update_system_operations('libtinfo6'); my @ops = update_system_operations('libtinfo6');
is($ops[0]->{'name'}, 'libtinfo6', is($ops[0]->{'name'}, 'libtinfo6',
'normalizes package names from simulated APT operations'); 'normalizes package names from simulated APT operations');
update_system_operations('libtinfo6', update_system_hold_flags());
like($command, qr/^apt-get -s --allow-change-held-packages install /,
'overrides holds while resolving an explicit held-package update');
} }
{ {

347
t/software-yum.t Normal file
View File

@@ -0,0 +1,347 @@
#!/usr/local/bin/perl
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
use Cwd qw(abs_path);
our (%config, %packages, %text);
our ($yum_command, $supports_dnf_versionlock, $dnf_version);
sub has_command
{
return $_[0] eq 'dnf' ? '/usr/bin/dnf' : undef;
}
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..'));
chdir($root) or die "chdir($root): $!";
do './software/yum-lib.pl' or die $@ || $!;
{
no warnings qw(once redefine);
local *clean_language = sub { };
local *reset_environment = sub { };
local *backquote_command = sub {
return $_[0] =~ /--version/ ? "4.14.0\n" :
" versionlock control package version locks\n";
};
local $dnf_version;
local $supports_dnf_versionlock;
is(get_dnf_version(), 4, 'detects DNF 4');
ok(supports_update_system_holds(),
'detects the DNF 4 versionlock command');
is(update_system_hold_flags(), '--disableplugin=versionlock',
'uses the DNF 4 held-update discovery option');
}
{
no warnings qw(once redefine);
local *clean_language = sub { };
local *reset_environment = sub { };
local *backquote_command = sub {
return $_[0] =~ /--version/ ? "dnf5 version 5.4.2.1\n" :
" versionlock Manage versionlock configuration\n";
};
local $dnf_version;
local $supports_dnf_versionlock;
is(get_dnf_version(), 5, 'detects DNF 5');
ok(supports_update_system_holds(),
'detects the DNF 5 versionlock command');
is(update_system_hold_flags(), '--setopt=disable_excludes=*',
'uses the DNF 5 held-update discovery option');
}
{
no warnings qw(once redefine);
local *clean_language = sub { };
local *reset_environment = sub { };
local *backquote_command = sub {
return $_[0] =~ /--version/ ? "4.14.0\n" :
"No such command: versionlock\n";
};
local $dnf_version;
local $supports_dnf_versionlock;
ok(!supports_update_system_holds(),
'hides holds when DNF does not expose versionlock');
}
{
no warnings qw(once redefine);
my $output =
"bash-0:5.1.8-8.el9.*\n".
"python3-*\n".
"0:python3-pip-21.2.3-8.el9.*\n".
"!blocked-0:2.0-1.el9.*\n";
local *supports_update_system_holds = sub { return 1; };
local *get_dnf_version = sub { return 4; };
local *clean_language = sub { };
local *reset_environment = sub { };
local *open_execute_command = sub {
my ($fh) = @_;
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
or die "open simulated DNF 4 locks: $!";
};
is_deeply([ list_update_system_holds() ], [ qw(bash python3-pip) ],
'lists DNF 4 exact locks without raw patterns or excludes');
}
{
no warnings qw(once redefine);
my $output =
"# Added by 'dnf versionlock add nano'\n".
"Package name: nano\n".
"evr = 8.7.1-2.fc44\n\n".
"Package name: coreutils\n".
"evr != 9.10-4.fc44\n\n".
"Package name: bash\n".
"evr = 5.3.0-2.fc44\n".
"arch = aarch64\n\n".
"Package name: python3-*\n".
"evr = 3.14.0-1.fc44\n\n".
"Package name: nano\n".
"evr > 9\n";
local *supports_update_system_holds = sub { return 1; };
local *get_dnf_version = sub { return 5; };
local *clean_language = sub { };
local *reset_environment = sub { };
local *open_execute_command = sub {
my ($fh) = @_;
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
or die "open simulated DNF 5 locks: $!";
};
is_deeply([ list_update_system_holds() ], [ ],
'skips DNF 5 names with duplicate, custom or glob lock rules');
$output = "Package name: nano\nevr = 8.7.1-2.fc44\n";
is_deeply([ list_update_system_holds() ], [ 'nano' ],
'lists a simple DNF 5 exact lock by package name');
}
{
no warnings qw(once redefine);
is(update_system_hold_spec_name('bash-0:5.1.8-8.el9.*'), 'bash',
'decodes DNF 4 name-epoch-version entries');
is(update_system_hold_spec_name('0:python3-pip-21.2.3-8.el9.*'),
'python3-pip', 'decodes legacy DNF 4 epoch-name entries');
is(update_system_hold_spec_name('python3-*'), undef,
'does not treat a raw pattern as an exact lock');
is(update_system_hold_spec_name('!bash-0:5.1.8-8.el9.*'), undef,
'does not treat an exclude as an exact lock');
is(update_system_nevra_name('nano-0:8.7.1-2.fc44'), 'nano',
'extracts a DNF 5 package name from transaction output');
is(update_system_nevra_name(
'webmin-virtualmin-support-2:4.3.202602061237-1.noarch'),
'webmin-virtualmin-support', 'extracts a hyphenated NEVRA name');
is(update_system_nevra_name('nano-8.7.1-2.fc44.aarch64'), 'nano',
'extracts a package name when no epoch is printed');
}
{
no warnings qw(once redefine);
my @commands;
local *supports_update_system_holds = sub { return 1; };
local *unique = sub {
my %seen;
return grep { !$seen{$_}++ } @_;
};
local *clean_language = sub { };
local *reset_environment = sub { };
local *trim = sub {
my ($value) = @_;
$value =~ s/^\s+|\s+$//g;
return $value;
};
local *list_update_system_holds = sub { return ('bash'); };
local *execute_command_logged = sub {
my ($command, undef, $stdout) = @_;
push(@commands, $command);
$$stdout = '';
return 0;
};
is(update_system_hold([ 'bash', 'bash', 'coreutils' ], 1), undef,
'adds DNF version locks successfully');
is(update_system_hold([ 'bash', 'coreutils' ], 0), undef,
'removes only package names reported as exactly locked');
is_deeply(\@commands,
[ '/usr/bin/dnf -q versionlock add bash',
'/usr/bin/dnf -q versionlock add coreutils',
'/usr/bin/dnf -q versionlock delete bash' ],
'uses name-based add and delete commands on both DNF generations');
}
{
no warnings qw(once redefine);
my $dnf_output =
"[3/6] Upgrading bash-0:5.3.0-2.fc44 100% | 1.0 MiB/s | 1.0 MiB | 00m01s\n";
my $executed_command;
my @lock_actions;
local *update_system_hold_flags = sub { return 'held-update'; };
local *append_architectures = sub { return @_; };
local *package_info = sub { return ('bash'); };
local *update_system_repo = sub { return undef; };
local *additional_log = sub { };
local *html_escape = sub { return $_[0]; };
local *text = sub { return $_[0]; };
local *unique = sub {
my %seen;
return grep { !$seen{$_}++ } @_;
};
local *delete_update_system_holds = sub {
my ($packages, $removed) = @_;
push(@lock_actions, [ 'delete', [ @$packages ] ]);
push(@$removed, 'bash');
return undef;
};
local *restore_update_system_holds = sub {
my ($packages) = @_;
push(@lock_actions, [ 'restore', [ @$packages ] ]);
return undef;
};
local *open_execute_command = sub {
my ($fh, $command) = @_;
$executed_command = $command;
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
or die "open simulated DNF install: $!";
};
my $printed = '';
open(my $stdout, '>', \$printed) or die "open captured stdout: $!";
local *STDOUT = $stdout;
$? = 0;
my @installed = update_system_install('bash', { }, 1, 'held-update');
is_deeply(\@installed, [ 'bash' ],
'returns a DNF 5 package updated while held');
unlike($executed_command, qr/held-update|disableplugin|disable_excludes/,
'runs the transaction without a global versionlock bypass');
is_deeply(\@lock_actions,
[ [ 'delete', [ 'bash' ] ], [ 'restore', [ 'bash' ] ] ],
'temporarily unlocks and then re-locks only the selected package');
}
{
no warnings qw(once redefine);
my $dnf_output =
"bash.aarch64 5.1.8-10.el9 baseos\n".
"coreutils.aarch64 8.32-40.el9 baseos\n";
my @commands;
local *supports_update_system_holds = sub { return 1; };
local *list_update_system_holds = sub { return ('bash'); };
local *set_yum_security_field = sub { };
local *get_dnf_version = sub { return 4; };
local *open_execute_command = sub {
my ($fh, $command) = @_;
push(@commands, $command);
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
or die "open simulated DNF updates: $!";
};
my @normal = update_system_updates(0);
is_deeply([ map { $_->{'name'} } @normal ], [ 'coreutils' ],
'DNF 4 regular updates exclude held packages');
my @with_holds = update_system_updates(1);
is_deeply([ map { $_->{'name'} } @with_holds ],
[ 'bash', 'coreutils' ], 'DNF 4 held-update query includes locks');
like($commands[1], qr/--disableplugin=versionlock check-update/,
'DNF 4 uses its plugin bypass for held-update discovery');
@commands = ( );
local *get_dnf_version = sub { return 5; };
@with_holds = update_system_updates(1);
like($commands[0], qr/--setopt=disable_excludes=\\\* check-update/,
'DNF 5 disables excludes for held-update discovery');
ok($with_holds[0]->{'held'}, 'marks a DNF 5 locked update as held');
}
{
no warnings qw(once redefine);
my $dnf_output =
"Last metadata expiration check: 0:10:00 ago.\n".
"Dependencies resolved.\n".
"================================================================\n".
" Package Architecture Version Repository Size\n".
"================================================================\n".
"Upgrading:\n".
" tzdata noarch 2026c-1.fc44 updates 497 k\n".
" vim-minimal aarch64 2:9.1.2000-2.fc44 updates 647 k\n".
"Installing dependencies:\n".
" wbt-virtual-server-theme\n".
" noarch 21.20-1 virtualmin 2.5 M\n".
"Removing dependent packages:\n".
" oldpkg noarch 1.0-1 system 1 k\n".
"\nTransaction Summary:\n".
"Upgrade 2 Packages\n";
my $command;
my @lock_actions;
my $dnf_major = 4;
local *update_system_hold_flags = sub {
return '--setopt=disable_excludes=*';
};
local *get_dnf_version = sub { return $dnf_major; };
local *open_execute_command = sub {
my ($fh, $cmd) = @_;
$command = $cmd;
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
or die "open simulated DNF transaction: $!";
};
local *clean_language = sub { };
local *reset_environment = sub { };
local *trim = sub {
my ($value) = @_;
$value =~ s/^\s+|\s+$//g;
return $value;
};
local *unique = sub {
my %seen;
return grep { !$seen{$_}++ } @_;
};
local *delete_update_system_holds = sub {
my ($packages, $removed) = @_;
push(@lock_actions, [ 'delete', [ @$packages ] ]);
push(@$removed, @$packages);
return undef;
};
local *restore_update_system_holds = sub {
my ($packages) = @_;
push(@lock_actions, [ 'restore', [ @$packages ] ]);
return undef;
};
my @ops = update_system_operations('tzdata vim-minimal');
like($command, qr{^/usr/bin/dnf --assumeno install },
'uses a simulated install for DNF 4 operations');
unlike($command, qr/disableplugin|disable_excludes/,
'keeps versionlock active for regular operations');
is_deeply([ map { $_->{'name'} } @ops ],
[ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
'parses install and upgrade rows from a DNF transaction table');
$dnf_major = 5;
@ops = update_system_operations('tzdata vim-minimal',
'--setopt=disable_excludes=*');
like($command, qr{^/usr/bin/dnf --assumeno upgrade },
'uses a simulated upgrade for installed packages on DNF 5');
unlike($command, qr/disableplugin|disable_excludes/,
'does not pass the discovery-only bypass to a transaction preview');
is_deeply(\@lock_actions,
[ [ 'delete', [ qw(tzdata vim-minimal) ] ],
[ 'restore', [ qw(tzdata vim-minimal) ] ] ],
'temporarily unlocks selected packages for a held-update preview');
is_deeply([ map { $_->{'name'} } @ops ],
[ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
'parses the held-package preview after restoring its locks');
is($ops[1]->{'epoch'}, '2', 'splits epochs from preview versions');
is($ops[2]->{'arch'}, 'noarch', 'handles wrapped package names');
}
done_testing();

View File

@@ -43,6 +43,6 @@ $o->{'bootup'} = $in{'bootup'};
sub get_icons sub get_icons
{ {
return ( "access" ,"bind" ,"ui" ,"umods" ,"os" ,"lang" ,"upgrade" ,"session" ,"assignment" ,"categories" ,"themes", "referers", "anon", "ssl" ,"configs" ,"acl" ,"restrict" ,"users" ,"defacl", "sessions", "blocked", "advanced" ); return ( "access" ,"bind" ,"log" ,"ui" ,"umods" ,"os" ,"lang" ,"upgrade" ,"session" ,"assignment" ,"categories" ,"themes", "referers", "anon", "ssl" ,"configs" ,"acl" ,"restrict" ,"users" ,"defacl", "sessions", "blocked", "advanced" );
} }

65
usermin/change_log.cgi Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/local/bin/perl
# Save Usermin webserver logging options
require './usermin-lib.pl';
&ReadParse();
$access{'log'} || &error($text{'acl_ecannot'});
&error_setup($text{'log_err'});
&get_usermin_miniserv_config(\%miniserv);
# Only a change of error destination on a systemd-managed service needs the
# drop-in and a full restart; otherwise the option is ignored.
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.
$miniserv{'log'} = $in{'log'};
$miniserv{'loghost'} = $in{'loghost'};
$miniserv{'logtrust'} = $in{'logtrust'};
$miniserv{'logclf'} = $in{'logclf'};
$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.
&lock_file($usermin_miniserv_config);
if ($journal_changed) {
&webmin::set_miniserv_error_destination(\%miniserv,
$usermin_miniserv_config, "usermin.service",
$in{'error_journal'});
}
else {
&put_usermin_miniserv_config(\%miniserv);
}
&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);
}
else {
&restart_usermin_miniserv();
}
&webmin_log("log");
&redirect("");

View File

@@ -29,3 +29,4 @@ advanced=1
mobile=1 mobile=1
twofactor=1 twofactor=1
web=1 web=1
log=1

46
usermin/edit_log.cgi Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/local/bin/perl
# Display Usermin webserver logging options
require './usermin-lib.pl';
$access{'log'} || &error($text{'acl_ecannot'});
&ui_print_header(undef, $text{'log_title'}, "");
&get_usermin_miniserv_config(\%miniserv);
print &text('log_desc', "<tt>$miniserv{'logfile'}</tt>"),"<p>\n";
print &ui_form_start("change_log.cgi", "post");
print &ui_table_start($text{'log_header'}, undef, 2);
# Control the Usermin access log and its built-in expiry mechanism.
print &ui_table_row($text{'log_status'},
&ui_radio("log", $miniserv{'log'} ? 1 : 0,
[ [ 1, $text{'log_enable'} ],
[ 0, $text{'log_disable'} ] ]));
print &ui_table_row($text{'log_resolv'},
&ui_yesno_radio("loghost", int($miniserv{'loghost'})));
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", $logclear, \@clear_opts));
# Only systemd services can safely inherit stderr into the journal.
if (&webmin::miniserv_systemd_journal_available("usermin.service")) {
print &ui_table_row($text{'log_error'},
&ui_radio("error_journal", $miniserv{'errorlog'} eq '-' ? 1 : 0,
[ [ 0, $text{'log_error_file'} ],
[ 1, $text{'log_error_journal'} ] ]));
}
print &ui_table_end();
print &ui_form_end([ [ "save", $text{'save'} ] ]);
&ui_print_footer("", $text{'index_return'});

BIN
usermin/images/log.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

View File

@@ -55,6 +55,7 @@ if (!$miniserv{'root'} && !-d $miniserv{'root'}) {
@links = ( "edit_access.cgi", @links = ( "edit_access.cgi",
"edit_bind.cgi", "edit_bind.cgi",
"edit_log.cgi",
"edit_ui.cgi", "edit_ui.cgi",
"edit_mods.cgi", "edit_mods.cgi",
"edit_os.cgi", "edit_os.cgi",

View File

@@ -1,4 +1,22 @@
index_title=Usermin Configuration index_title=Usermin Configuration
log_title=Logging
log_desc=Usermin can be configured to write a log of web server hits, in the standard CLF log file format. If logging is enabled, you can also choose whether IP addresses or hostnames are recorded, and how often the log file is cleared. When enabled, logs are written to the file $1.
log_header=Webserver logging options
log_status=Logging active?
log_enable=Enable logging
log_disable=Disable logging
log_resolv=Log resolved hostnames?
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>
log_err=Failed to save logging options
log_ehours='$1' is not a valid number of hours
index_econfig=The directory $1 either does not exist on your system, or is not the Usermin configuration directory. Maybe Usermin is not installed, or your <a href='$2'>module configuration</a> is incorrect. index_econfig=The directory $1 either does not exist on your system, or is not the Usermin configuration directory. Maybe Usermin is not installed, or your <a href='$2'>module configuration</a> is incorrect.
index_return=Usermin configuration index_return=Usermin configuration
index_stop=Stop Usermin index_stop=Stop Usermin

View File

@@ -4859,11 +4859,25 @@ return if ($nocache == 2);
if (!$system_hostname[$m]) { if (!$system_hostname[$m]) {
if ($gconfig{'os_type'} ne 'windows') { if ($gconfig{'os_type'} ne 'windows') {
# If systemd system try /etc/hostname straight away # If systemd system try /etc/hostname straight away
my $initsys = &trim(&backquote_command("cat /proc/1/comm 2>/dev/null")); if (-d "/run/systemd/system") {
if ($initsys eq 'systemd') {
my $hostname = &read_file_contents("/etc/hostname"); my $hostname = &read_file_contents("/etc/hostname");
$hostname =~ s/\r|\n//g; $hostname =~ s/\r|\n//g;
# Cache the short name before resolving the full name
if ($hostname) { if ($hostname) {
my $shortname = $hostname;
$shortname =~ s/\..*$//;
$system_hostname[1] = $shortname;
}
# Resolve a short static hostname before caching it
if ($hostname && !$m && $hostname !~ /\./ &&
!$gconfig{'no_hostname_f'}) {
my $fqdn;
my $ex = &execute_command("hostname -f", undef,
\$fqdn, undef, 0, 1);
$fqdn = &trim($fqdn);
$hostname = $fqdn if (!$ex && $fqdn =~ /\./);
}
if ($hostname && ($m || $hostname =~ /\./)) {
$hostname =~ s/\..*$// if ($m); $hostname =~ s/\..*$// if ($m);
$system_hostname[$m] = $hostname; $system_hostname[$m] = $hostname;
return $hostname; return $hostname;
@@ -7607,6 +7621,14 @@ my ($param_action,
my $m = $param_module ? $param_module : &get_module_name(); 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'}) { if ($gconfig{'logclear'}) {
# check if it is time to clear the log # check if it is time to clear the log
my @st = stat("$webmin_logfile.time"); my @st = stat("$webmin_logfile.time");

View File

@@ -9,12 +9,29 @@ require './webmin-lib.pl';
&lock_file($ENV{'MINISERV_CONFIG'}); &lock_file($ENV{'MINISERV_CONFIG'});
&get_miniserv_config(\%miniserv); &get_miniserv_config(\%miniserv);
my ($miniserv_log, $in_log) = ($miniserv{'log'}, $in{'log'}); my ($miniserv_log, $in_log) = ($miniserv{'log'}, $in{'log'});
# Only a change of error destination on a systemd-managed service needs the
# drop-in and a full restart; otherwise the option is ignored.
my $journal_changed = defined($in{'error_journal'}) &&
&miniserv_systemd_journal_available("webmin.service") &&
($miniserv{'errorlog'} eq '-' ? 1 : 0) != ($in{'error_journal'} ? 1 : 0);
$miniserv{'log'} = $in{'log'}; $miniserv{'log'} = $in{'log'};
$miniserv{'loghost'} = $in{'loghost'}; $miniserv{'loghost'} = $in{'loghost'};
$miniserv{'logtrust'} = $in{'logtrust'}; $miniserv{'logtrust'} = $in{'logtrust'};
$miniserv{'logclf'} = $in{'logclf'}; $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'})); &error(&text('log_ehours', $in{'logtime'}));
$miniserv{'logtime'} = $in{'logtime'}; $miniserv{'logtime'} = $in{'logtime'};
if ($in{'perms_def'}) { if ($in{'perms_def'}) {
@@ -42,12 +59,29 @@ if (defined($in{'login'})) {
delete($miniserv{'failed_script'}); delete($miniserv{'failed_script'});
} }
} }
&put_miniserv_config(\%miniserv); # Save the error destination and matching systemd drop-in when supported.
if ($journal_changed) {
&set_miniserv_error_destination(\%miniserv,
$ENV{'MINISERV_CONFIG'}, "webmin.service",
$in{'error_journal'});
}
else {
&put_miniserv_config(\%miniserv);
}
&unlock_file($ENV{'MINISERV_CONFIG'}); &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{'log'} = $in{'log'};
$gconfig{'logtime'} = $in{'logtime'}; $gconfig{'logtime'} = $in{'logtime'};
$gconfig{'logclear'} = $in{'logclear'}; $gconfig{'logclear'} = $miniserv{'logclear'};
$gconfig{'logrotate'} = $logrotate;
$gconfig{'logusers'} = $gconfig{'logusers'} =
$in{'uall'} ? '' : join(" ", split(/\0/, $in{'users'})); $in{'uall'} ? '' : join(" ", split(/\0/, $in{'users'}));
$gconfig{'logmodules'} = $gconfig{'logmodules'} =
@@ -71,6 +105,12 @@ if ($miniserv_log != $in_log) {
} }
} }
&show_restart_page(); # Restart through systemd when stderr needs to be re-attached.
if ($journal_changed) {
&restart_miniserv_systemd_service("webmin.service", 2);
&redirect("");
}
else {
&show_restart_page();
}
&webmin_log("log", undef, undef, \%in); &webmin_log("log", undef, undef, \%in);

View File

@@ -31,12 +31,26 @@ print &ui_table_row($text{'log_trust'},
print &ui_table_row($text{'log_clf'}, print &ui_table_row($text{'log_clf'},
&ui_yesno_radio("logclf", int($miniserv{'logclf'}))); &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'}, print &ui_table_row($text{'log_clear2'},
&ui_radio("logclear", int($miniserv{'logclear'}), &ui_radio("logclear", $logclear, \@clear_opts));
[ [ 1, &text('log_period',
&ui_textbox("logtime", $miniserv{'logtime'}, 10)) ], # A systemd service can keep Miniserv errors in the journal instead.
[ 0, $text{'no'} ] ])); if (&miniserv_systemd_journal_available("webmin.service")) {
print &ui_table_row($text{'log_error'},
&ui_radio("error_journal", $miniserv{'errorlog'} eq '-' ? 1 : 0,
[ [ 0, $text{'log_error_file'} ],
[ 1, $text{'log_error_journal'} ] ]));
}
print &ui_table_hr(); print &ui_table_hr();
@@ -101,4 +115,3 @@ print &ui_table_end();
print &ui_form_end([ [ "save", $text{'save'} ] ]); print &ui_form_end([ [ "save", $text{'save'} ] ]);
&ui_print_footer("", $text{'index_return'}); &ui_print_footer("", $text{'index_return'});

View File

@@ -93,6 +93,11 @@ log_trust=Log client IP address when behind proxy?
log_clf=Use combined log format (including referrer and user agent)? log_clf=Use combined log format (including referrer and user agent)?
log_clear2=Periodically clear log files? log_clear2=Periodically clear log files?
log_period=Yes, every $1 hours 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>
log_forusers=Users to log Webmin actions for log_forusers=Users to log Webmin actions for
log_uall=Log actions by all users log_uall=Log actions by all users
log_users=Only log actions by .. log_users=Only log actions by ..

View File

@@ -43,6 +43,211 @@ if (!-r $update_cache) {
our $primary_host = "webmin.com"; our $primary_host = "webmin.com";
our $primary_ssl = $can_http_ssl; our $primary_ssl = $can_http_ssl;
=head2 miniserv_systemd_journal_available(unit)
Returns 1 if a systemd service is available for receiving Miniserv errors.
=cut
sub miniserv_systemd_journal_available
{
my ($unit) = @_;
return &has_command("systemctl") &&
(-r "/etc/systemd/system/$unit" ||
-r "/usr/lib/systemd/system/$unit" ||
-r "/lib/systemd/system/$unit");
}
=head2 set_miniserv_error_destination(&miniserv, config, unit, journal)
Selects the Miniserv error file or the systemd journal, using a local systemd
drop-in so that the packaged service unit remains unchanged.
=cut
sub set_miniserv_error_destination
{
my ($miniserv, $config_file, $unit, $journal) = @_;
$unit =~ /^(webmin|usermin)\.service$/ || return 0;
my $dropin_dir = "/etc/systemd/system/$unit.d";
my $dropin = "$dropin_dir/10-miniserv-error-log.conf";
# The dash keeps Miniserv's stderr attached to the service manager.
if ($journal) {
$miniserv->{'errorlog'} = '-';
&make_dir($dropin_dir, 0755) if (!-d $dropin_dir);
&write_file_contents($dropin,
"[Service]\nStandardError=journal\n");
}
else {
my $logfile = $miniserv->{'logfile'};
$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.
&write_file($config_file, $miniserv);
&system_logged("systemctl daemon-reload >/dev/null 2>&1");
}
=head2 restart_miniserv_systemd_service(unit, [delay])
Fully restarts a Miniserv systemd service so that stderr is re-attached to the
selected destination. A delay allows the current Webmin response to finish.
=cut
sub restart_miniserv_systemd_service
{
my ($unit, $delay) = @_;
$unit =~ /^(webmin|usermin)\.service$/ || return 0;
$delay = int($delay);
if ($delay) {
&system_logged("(sleep $delay; systemctl --no-block restart $unit) " .
">/dev/null 2>&1 </dev/null &");
}
else {
&system_logged("systemctl restart $unit >/dev/null 2>&1 </dev/null");
}
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 $primary_port = $primary_ssl ? 443 : 80;
our $webmin_key_email = "jcameron\@webmin.com"; our $webmin_key_email = "jcameron\@webmin.com";