Merge branch 'master' of github.com:webmin/webmin
Some checks failed
Tests / prove (push) Has been cancelled
webmin.dev: webmin/webmin / build (push) Has been cancelled

This commit is contained in:
Jamie Cameron
2026-05-19 16:54:33 -07:00
37 changed files with 5573 additions and 308 deletions

12
.github/workflows/code-review.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Code Review
on:
pull_request:
branches:
- master
jobs:
code-review:
uses: webmin/webmin-ci-cd/.github/workflows/code-review.yml@main
secrets:
CODE_REVIEW_API_KEY: ${{ secrets.CODE_REVIEW_API_KEY }}

19
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Tests
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
prove:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Perl::Critic
run: sudo apt-get update && sudo apt-get install -y libperl-critic-perl
- name: prove -lr
run: prove -lr

View File

@@ -26,3 +26,4 @@ jobs:
DEV_SSH_PRV_KEY: ${{ secrets.DEV_SSH_PRV_KEY }}
ALL_GPG_PH2: ${{ secrets.ALL_GPG_PH2 }}
CODE_REVIEW_API_KEY: ${{ secrets.CODE_REVIEW_API_KEY }}
CODE_REVIEW_SMTP_PASSWORD: ${{ secrets.CODE_REVIEW_SMTP_PASSWORD }}

View File

@@ -1809,6 +1809,7 @@ foreach my $g (&list_groups()) {
return $g;
}
}
return;
}
=head2 check_password_restrictions(username, password)

View File

@@ -16,8 +16,9 @@ my ($user, $script, $action, $type, $object, $p) = @_;
my $g = $type eq 'group' ? "_g" : "";
if ($action eq 'modify') {
if ($p->{'old'} ne $p->{'name'}) {
return &text('log_rename'.$g, "<tt>$p->{'old'}</tt>",
"<tt>$p->{'name'}</tt>");
return &text('log_rename'.$g,
"<tt>".&html_escape($p->{'old'})."</tt>",
"<tt>".&html_escape($p->{'name'})."</tt>");
}
else {
return &text('log_modify'.$g,
@@ -26,7 +27,8 @@ if ($action eq 'modify') {
}
elsif ($action eq 'create') {
if ($p->{'clone'}) {
return &text('log_clone'.$g, "<tt>$p->{'clone'}</tt>",
return &text('log_clone'.$g,
"<tt>".&html_escape($p->{'clone'})."</tt>",
"<tt>".&html_escape($object)."</tt>");
}
else {
@@ -36,21 +38,23 @@ elsif ($action eq 'create') {
}
elsif ($action eq 'delete') {
if ($type eq "users" || $type eq "groups") {
return &text('log_delete_'.$type, $object);
return &text('log_delete_'.$type, &html_escape($object));
}
else {
return &text('log_delete'.$g, "<tt>$object</tt>");
return &text('log_delete'.$g,
"<tt>".&html_escape($object)."</tt>");
}
}
elsif ($action eq 'joingroup') {
return &text('log_joingroup', $object, $p->{'group'});
return &text('log_joingroup', &html_escape($object),
&html_escape($p->{'group'}));
}
elsif ($action eq 'acl') {
return &text('log_acl', "<tt>$object</tt>",
return &text('log_acl', "<tt>".&html_escape($object)."</tt>",
"<i>".&html_escape($p->{'moddesc'})."</i>");
}
elsif ($action eq 'reset') {
return &text('log_reset', "<tt>$object</tt>",
return &text('log_reset', "<tt>".&html_escape($object)."</tt>",
"<i>".&html_escape($p->{'moddesc'})."</i>");
}
elsif ($action eq 'cert') {
@@ -60,7 +64,9 @@ elsif ($action eq 'switch') {
return &text('log_switch', "<tt>".&html_escape($object)."</tt>");
}
elsif ($action eq 'twofactor') {
return &text('log_twofactor', $object, $p->{'provider'}, $p->{'id'});
return &text('log_twofactor', &html_escape($object),
&html_escape($p->{'provider'}),
&html_escape($p->{'id'}));
}
elsif ($action eq 'forgot') {
return &text('log_forgot_'.$type, &html_escape($p->{'user'}),

1381
acl/t/run-tests.t Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -436,6 +436,13 @@ foreach $v (@virt) {
return \@get_config_cache;
}
# flush_config_cache()
# Delete all in-memory config caches
sub flush_config_cache
{
undef(@get_config_cache);
}
# get_config_file(filename, [&seen-files])
# Returns a list of config hash refs from some file
sub get_config_file
@@ -788,6 +795,428 @@ unlink($file);
&delete_webfile_link($file);
}
# can_manage_vhost_files()
# Returns 1 if this system uses Debian-style available/enabled site dirs
sub can_manage_vhost_files
{
return 0 if ($gconfig{'os_type'} ne 'debian-linux');
my $avail = &vhost_available_dir();
my $enabled = &vhost_enabled_dir();
return $avail && -d $avail && $enabled && -d $enabled &&
&simplify_path(&resolve_links($avail)) ne
&simplify_path(&resolve_links($enabled));
}
# vhost_available_dir()
# Returns the configured directory of available Apache virtual host files
sub vhost_available_dir
{
return $config{'virt_file'} ? &server_root($config{'virt_file'}) : undef;
}
# vhost_enabled_dir()
# Returns the configured directory of enabled Apache virtual host symlinks
sub vhost_enabled_dir
{
return $config{'link_dir'} ? &server_root($config{'link_dir'}) : undef;
}
# get_vhost_available_files()
# Returns real config files from the directory used for new virtual hosts
sub get_vhost_available_files
{
my @rv;
return @rv if (!&can_manage_vhost_files());
my $avail = &vhost_available_dir();
opendir(AVAIL, $avail) || return @rv;
foreach my $f (sort { lc($a) cmp lc($b) } readdir(AVAIL)) {
next if ($f eq "." || $f eq "..");
my $file = $avail."/".$f;
my $rfile = &simplify_path(&resolve_links($file));
next if (!$rfile || !-f $rfile || !-r $rfile);
push(@rv, $rfile);
}
closedir(AVAIL);
return &unique(@rv);
}
# find_virtuals_in_file(file)
# Returns VirtualHost blocks parsed from one config file
sub find_virtuals_in_file
{
my ($file) = @_;
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return ( ) if (!-r $rfile);
my @conf = &get_config_file($rfile);
return grep { $_->{'file'} eq $rfile }
&find_directive_struct("VirtualHost", \@conf);
}
# is_default_vhost(&virt)
# Returns 1 if a VirtualHost looks like a default/catch-all host
sub is_default_vhost
{
my ($virt) = @_;
return 1 if (!$virt);
return 1 if ($virt->{'value'} =~ /_default_/i);
return 1 if (!&find_directive("ServerName", $virt->{'members'}));
return 0;
}
# can_manage_vhost_file(file)
# Returns 1 if all virtual hosts in a file are manageable by this user
sub can_manage_vhost_file
{
my ($file) = @_;
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return 0 if (!$rfile || !-f $rfile || !-r $rfile);
my @virts = &find_virtuals_in_file($rfile);
return 0 if (!@virts);
foreach my $virt (@virts) {
return 0 if (&is_default_vhost($virt));
return 0 if (!&can_edit_virt($virt));
}
return 1;
}
# can_manage_vhost_state_file(file)
# Returns 1 if a virtual host file can have its enabled state managed here
sub can_manage_vhost_state_file
{
my ($file) = @_;
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return 0 if (!$rfile || !-f $rfile);
my %available = map { $_, 1 } &get_vhost_available_files();
return 0 if (!$available{$rfile});
return &can_manage_vhost_file($rfile);
}
# get_virtual_list_rows(&config)
# Returns row hashes for the virtual-host list, preserving sites-available order
sub get_virtual_list_rows
{
my ($conf) = @_;
my @active = grep { &can_edit_virt($_) }
&find_directive_struct("VirtualHost", $conf);
if (&can_manage_vhost_files()) {
my @rows;
my %active_by_file;
foreach my $v (@active) {
my $file = &simplify_path(&resolve_links($v->{'file'}));
$file ||= $v->{'file'};
push(@{$active_by_file{$file}}, $v);
}
my %done_virt;
foreach my $file (&get_vhost_available_files()) {
my @filevirts = @{$active_by_file{$file} || [ ]};
my $active = @filevirts ? 1 : 0;
if (!@filevirts) {
@filevirts = grep { &can_edit_virt($_) &&
!&is_default_vhost($_) }
&find_virtuals_in_file($file);
}
foreach my $v (@filevirts) {
push(@rows, { 'virt' => $v,
'active' => $active,
'file' => $file });
$done_virt{$v}++;
}
}
foreach my $v (@active) {
next if ($done_virt{$v});
push(@rows, { 'virt' => $v,
'active' => 1,
'file' => $v->{'file'} });
}
return @rows;
}
return map { { 'virt' => $_, 'active' => 1, 'file' => $_->{'file'} } }
@active;
}
# vhost_file_link(file)
# Returns the enabled symlink path for a virtual host file
sub vhost_file_link
{
my ($file) = @_;
return undef if (!&can_manage_vhost_files());
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
my $avail = &vhost_available_dir();
my $short;
if (opendir(AVAIL, $avail)) {
foreach my $f (sort { lc($a) cmp lc($b) } readdir(AVAIL)) {
next if ($f eq "." || $f eq "..");
my $afile = $avail."/".$f;
my $rafile = &simplify_path(&resolve_links($afile));
if ($rafile && $rafile eq $rfile) {
$short = $f;
last;
}
}
closedir(AVAIL);
}
$short ||= $rfile;
$short =~ s/^.*\///;
return &vhost_enabled_dir()."/".$short;
}
# vhost_file_links(file)
# Returns enabled symlinks for a virtual host file
sub vhost_file_links
{
my ($file) = @_;
my @rv;
return @rv if (!&can_manage_vhost_files());
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
my $enabled = &vhost_enabled_dir();
opendir(LINKDIR, $enabled) || return @rv;
foreach my $f (readdir(LINKDIR)) {
next if ($f eq "." || $f eq "..");
my $link = $enabled."/".$f;
next if (!-l $link);
my $rlink = &simplify_path(&resolve_links($link));
if ($rlink && $rlink eq $rfile) {
push(@rv, $link);
}
}
closedir(LINKDIR);
return @rv;
}
# vhost_file_enabled(file)
# Returns 1 if a virtual host file has an enabled symlink
sub vhost_file_enabled
{
my ($file) = @_;
return scalar(&vhost_file_links($file)) ? 1 : 0;
}
# enable_vhost_file(file)
# Enables a virtual host file and rolls back if apache configtest fails
sub enable_vhost_file
{
my ($file) = @_;
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return $text{'enable_efile'} if (!&can_manage_vhost_state_file($rfile));
my $verr = &virtualmin_vhost_file_state_error($rfile, "enable");
return $verr if ($verr);
my $link = &vhost_file_link($rfile);
$link || return $text{'enable_elinkdir'};
return undef if (&vhost_file_enabled($rfile));
if (-e $link || -l $link) {
return &text('enable_elinkexists', "<tt>".&html_escape($link)."</tt>");
}
&symlink_logged($rfile, $link) ||
return &text('enable_elink', "<tt>".&html_escape($link)."</tt>",
"<tt>".&html_escape($!)."</tt>");
my $err = &test_config();
if ($err) {
&unlink_logged($link);
return &text('enable_etest', "<tt>".&html_escape($err)."</tt>");
}
&flush_config_cache();
&update_last_config_change();
return undef;
}
# disable_vhost_file(file)
# Disables a virtual host file and rolls back if apache configtest fails
sub disable_vhost_file
{
my ($file) = @_;
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return $text{'enable_efile'} if (!&can_manage_vhost_state_file($rfile));
my $verr = &virtualmin_vhost_file_state_error($rfile, "disable");
return $verr if ($verr);
my @links = &vhost_file_links($file);
return undef if (!@links);
my @restore = map { [ $_, readlink($_) ] } @links;
my @removed;
foreach my $link (@links) {
if (!&unlink_logged($link)) {
foreach my $r (@removed) {
&symlink_logged($r->[1], $r->[0])
if (defined($r->[1]) && !-e $r->[0] && !-l $r->[0]);
}
return &text('enable_eunlink',
"<tt>".&html_escape($link)."</tt>",
"<tt>".&html_escape($!)."</tt>");
}
my ($restore) = grep { $_->[0] eq $link } @restore;
push(@removed, $restore) if ($restore);
}
my $err = &test_config();
if ($err) {
foreach my $r (@restore) {
&symlink_logged($r->[1], $r->[0])
if (defined($r->[1]) && !-e $r->[0] && !-l $r->[0]);
}
return &text('enable_etest', "<tt>".&html_escape($err)."</tt>");
}
&flush_config_cache();
&update_last_config_change();
return undef;
}
# virtualmin_available()
# Returns 1 if Virtualmin is installed and supported on this system
sub virtualmin_available
{
return $main::apache_virtualmin_available
if (defined($main::apache_virtualmin_available));
$main::apache_virtualmin_available = &foreign_check("virtual-server");
return $main::apache_virtualmin_available;
}
# virtualmin_domain_by_name(name)
# Returns a Virtualmin domain object by domain name, if one exists
sub virtualmin_domain_by_name
{
my ($name) = @_;
return undef if (!&virtualmin_available());
return $main::apache_virtualmin_domain_by_name_cache{$name}
if (exists($main::apache_virtualmin_domain_by_name_cache{$name}));
&foreign_require("virtual-server");
my $d = &virtual_server::get_domain_by("dom", $name);
$main::apache_virtualmin_domain_by_name_cache{$name} = $d;
return $d;
}
# virtual_names(&virt)
# Returns all hostnames from ServerName and ServerAlias directives
sub virtual_names
{
my ($virt) = @_;
my @rv;
my $sn = &find_directive("ServerName", $virt->{'members'});
push(@rv, $sn) if ($sn);
foreach my $sa (&find_directive_struct("ServerAlias", $virt->{'members'})) {
push(@rv, @{$sa->{'words'} || [ ]});
if (!@{$sa->{'words'} || [ ]} && $sa->{'value'}) {
push(@rv, $sa->{'value'});
}
}
return grep { $_ && $_ ne "*" } &unique(@rv);
}
# virtualmin_domain_for_vhost_file(file)
# Returns the Virtualmin domain object for a virtual host file, if any
sub virtualmin_domain_for_vhost_file
{
my ($file) = @_;
return undef if (!&virtualmin_available());
my $rfile = &simplify_path(&resolve_links($file));
$rfile ||= $file;
return $main::apache_virtualmin_domain_for_file_cache{$rfile}
if (exists($main::apache_virtualmin_domain_for_file_cache{$rfile}));
foreach my $virt (&find_virtuals_in_file($file)) {
next if (!&can_edit_virt($virt));
foreach my $name (&virtual_names($virt)) {
my $d = &virtualmin_domain_by_name($name);
if (!$d && $name =~ /^www\.(\S+)/i) {
$d = &virtualmin_domain_by_name($1);
}
if ($d) {
$main::apache_virtualmin_domain_for_file_cache{$rfile} = $d;
return $d;
}
}
}
$main::apache_virtualmin_domain_for_file_cache{$rfile} = undef;
return undef;
}
# vhost_file_state(file)
# Returns the effective enabled state for a virtual host file
sub vhost_file_state
{
my ($file) = @_;
my $d = &virtualmin_domain_for_vhost_file($file);
if ($d) {
return { 'enabled' => $d->{'disabled'} ? 0 : 1,
'source' => 'virtualmin',
'domain' => $d };
}
return { 'enabled' => &vhost_file_enabled($file) ? 1 : 0,
'source' => 'apache' };
}
# vhost_file_toggle_action(file)
# Returns the action needed to toggle a virtual host file's effective state
sub vhost_file_toggle_action
{
my ($file) = @_;
return &vhost_file_state($file)->{'enabled'} ? "disable" : "enable";
}
# virtualmin_domain_state_link(&domain, enabled?)
# Returns a link to the Virtualmin state change form for some domain
sub virtualmin_domain_state_link
{
my ($d, $enabled) = @_;
my $page = $enabled ? "disable_domain.cgi" : "enable_domain.cgi";
my $label = $enabled ? $text{'enable_virtualmin_disable_label'} :
$text{'enable_virtualmin_enable_label'};
my $url = "../virtual-server/".$page."?dom=".&urlize($d->{'id'});
return &ui_link(&quote_escape($url), "\"".$label."\"");
}
# virtualmin_vhost_file_state_error(file, action)
# Returns an error if a Virtualmin-owned site is being enabled or disabled here
sub virtualmin_vhost_file_state_error
{
my ($file, $action) = @_;
return undef if ($action ne "enable" && $action ne "disable");
my $state_info = &vhost_file_state($file);
return undef if ($state_info->{'source'} ne "virtualmin");
my $d = $state_info->{'domain'};
return undef if (!$d);
my $state = lc($state_info->{'enabled'} ? $text{'index_enabled'} :
$text{'index_disabled'});
my $dom = "<tt>".&html_escape($d->{'dom'})."</tt>";
my $link = &virtualmin_domain_state_link($d, $state_info->{'enabled'});
return $state_info->{'enabled'} ?
&text('enable_evirtualmin_disable', $dom, $state, $link) :
&text('enable_evirtualmin_enable', $dom, $state, $link);
}
# delete_virtuals_from_file(file, &virtualhosts...)
# Deletes VirtualHost blocks from one file and removes the file if empty
sub delete_virtuals_from_file
{
my ($file, @virts) = @_;
return 0 if (!@virts);
my $lref = &read_file_lines($file);
foreach my $virt (sort { $b->{'line'} <=> $a->{'line'} } @virts) {
my $len = $virt->{'eline'} - $virt->{'line'} + 1;
splice(@$lref, $virt->{'line'}, $len);
}
my $empty = 1;
foreach my $line (@$lref) {
if ($line =~ /\S/) {
$empty = 0;
last;
}
}
&flush_file_lines($file);
if ($empty) {
foreach my $link (&vhost_file_links($file)) {
&unlink_logged($link);
}
&unlink_logged($file);
}
&flush_config_cache();
&update_last_config_change();
return scalar(@virts);
}
# renumber(&config, line, file, offset)
# Recursively changes the line number of all directives from some file
# beyond the given line.
@@ -1544,9 +1973,10 @@ return undef;
# if necessary.
sub before_changing
{
my @extra = grep { $_ } @_;
if ($config{'test_always'} || $access{'test_always'}) {
local $conf = &get_config();
local @files = &unique(map { $_->{'file'} } @$conf);
local @files = &unique((map { $_->{'file'} } @$conf), @extra);
local $/ = undef;
local $f;
foreach $f (@files) {
@@ -1969,10 +2399,11 @@ return @rv;
sub create_webfile_link
{
local ($file) = @_;
if ($config{'link_dir'}) {
my $linkdir = &vhost_enabled_dir();
if ($linkdir) {
local $short = $file;
$short =~ s/^.*\///;
local $linksrc = "$config{'link_dir'}/$short";
local $linksrc = "$linkdir/$short";
&lock_file($linksrc);
symlink($file, $linksrc);
&unlock_file($linksrc);
@@ -1985,16 +2416,16 @@ if ($config{'link_dir'}) {
sub delete_webfile_link
{
local ($file) = @_;
if ($config{'link_dir'}) {
local $short = $file;
$short =~ s/^.*\///;
opendir(LINKDIR, $config{'link_dir'});
$file = &simplify_path(&resolve_links($file));
my $linkdir = &vhost_enabled_dir();
if ($linkdir && opendir(LINKDIR, $linkdir)) {
foreach my $f (readdir(LINKDIR)) {
if ($f ne "." && $f ne ".." &&
(&simplify_path(
&resolve_links($config{'link_dir'}."/".$f)) eq $file ||
$short eq $f)) {
&unlink_logged($config{'link_dir'}."/".$f);
if ($f ne "." && $f ne "..") {
my $link = $linkdir."/".$f;
next if (!-l $link);
if (&simplify_path(&resolve_links($link)) eq $file) {
&unlink_logged($link);
}
}
}
closedir(LINKDIR);

View File

@@ -3,31 +3,107 @@
require './apache-lib.pl';
&ReadParse();
&error_setup($text{'delete_err'});
@d = split(/\0/, $in{'d'});
$file_action = $in{'toggle'} ? "toggle" : undef;
&error_setup($file_action ? $text{'enable_err'} : $text{'delete_err'});
$access{'vaddr'} || &error($text{'delete_ecannot'});
$conf = &get_config();
@d = split(/\0/, $in{'d'});
$can_vhost_files = &can_manage_vhost_files();
@d || &error($text{'delete_enone'});
if ($file_action) {
&can_manage_vhost_files() || &error($text{'enable_elinkdir'});
foreach $d (@d) {
if ($d =~ /^file\t([^\t]+)/) {
$file = $1;
}
elsif ($d !~ /^file\t/) {
($vmembers, $vconf) = &get_virtual_config($d);
next if (!$vconf || !&can_edit_virt($vconf));
$file = $vconf->{'file'};
}
else {
next;
}
$rfile = $file ? &simplify_path(&resolve_links($file)) : undef;
$files{$rfile}++ if ($rfile && -f $rfile &&
&can_manage_vhost_state_file($rfile));
}
@files = keys %files;
@files || &error($text{'enable_enone'});
foreach $file (@files) {
$action = &vhost_file_toggle_action($file);
$err = &virtualmin_vhost_file_state_error($file, $action);
$err && &error($err);
$file_actions{$file} = $action;
}
foreach $file (@files) {
$err = $file_actions{$file} eq "enable" ?
&enable_vhost_file($file) :
&disable_vhost_file($file);
$err && &error($err);
}
&webmin_log($file_action, "vhostfile", scalar(@files));
&redirect("");
exit;
}
if (!$in{'delete'}) {
&error($text{'delete_eaction'});
}
# Get them all
foreach $d (@d) {
if ($d =~ /^file\t([^\t]+)\t(\d+)$/) {
push(@{$file_lines{$1}}, $2);
next;
}
elsif ($d =~ /^file\t/) {
next;
}
($vmembers, $vconf) = &get_virtual_config($d);
$vconf || &error($text{'delete_egone'});
&can_edit_virt($vconf) || &error(&text('delete_ecannot2',
&virtual_name($vconf)));
$can_vhost_files && &is_default_vhost($vconf) &&
&error($text{'delete_edefault'});
push(@virts, $vconf);
}
if (%file_lines) {
foreach $file (keys %file_lines) {
$rfile = &simplify_path(&resolve_links($file));
next if (!$rfile || !-f $rfile ||
!&can_manage_vhost_state_file($rfile));
@fvirts = &find_virtuals_in_file($rfile);
foreach $line (@{$file_lines{$file}}) {
($vconf) = grep { $_->{'line'} == $line } @fvirts;
$vconf || &error($text{'delete_egone'});
&can_edit_virt($vconf) ||
&error(&text('delete_ecannot2',
&virtual_name($vconf)));
&is_default_vhost($vconf) &&
&error($text{'delete_edefault'});
push(@{$file_virts{$rfile}}, $vconf);
}
}
}
@virts || %file_virts || &error($text{'delete_enone'});
# Delete their structures
&before_changing();
&before_changing(keys %file_virts);
foreach $vconf (@virts) {
&lock_file($vconf->{'file'});
&save_directive_struct($vconf, undef, $conf, $conf);
&delete_file_if_empty($vconf->{'file'});
}
foreach $file (keys %file_virts) {
&lock_file($file);
$deleted += &delete_virtuals_from_file($file, @{$file_virts{$file}});
&unlock_file($file);
}
&flush_file_lines();
&unlock_all_files();
&update_last_config_change();
&after_changing();
&webmin_log("virts", "delete", scalar(@virts));
$deleted += scalar(@virts);
&webmin_log("virts", "delete", $deleted);
&redirect("");

View File

@@ -102,6 +102,10 @@ if (&can_edit_virt()) {
push(@vproxy, undef);
$sn ||= &get_system_hostname();
push(@vurl, $defport ? "http://$sn:$defport/" : "http://$sn/");
push(@vfile, undef);
push(@vstatus, "");
push(@vsel, undef);
push(@vfilemanage, 0);
$showing_default++;
}
@@ -128,16 +132,23 @@ elsif ($httpd_modules{'core'} >= 1.2) {
$ba = &find_directive("ServerName", $conf);
$nv{&to_ipaddress($ba ? $ba : &get_system_hostname())}++;
}
@virt = grep { &can_edit_virt($_) } @virt;
$can_vhost_files = &can_manage_vhost_files();
@vrows = &get_virtual_list_rows($conf);
if ($config{'show_order'} == 1) {
# sort by server name
@virt = sort { &server_name_sort($a) cmp &server_name_sort($b) } @virt;
@vrows = sort { &server_name_sort($a->{'virt'}) cmp
&server_name_sort($b->{'virt'}) } @vrows;
}
elsif ($config{'show_order'} == 2) {
# sort by IP address
@virt = sort { &server_ip_sort($a) cmp &server_ip_sort($b) } @virt;
@vrows = sort { &server_ip_sort($a->{'virt'}) cmp
&server_ip_sort($b->{'virt'}) } @vrows;
}
foreach $v (@virt) {
@virt = map { $_->{'virt'} } grep { $_->{'active'} } @vrows;
%available_vhost_file = map { $_, 1 } &get_vhost_available_files()
if ($can_vhost_files);
foreach $r (@vrows) {
$v = $r->{'virt'};
$vm = $v->{'members'};
if ($v->{'words'}->[0] =~ /^\[(\S+)\]:(\d+)$/) {
# IPv6 address and port
@@ -163,7 +174,7 @@ foreach $v (@virt) {
$idx = &indexof($v, @$conf);
push(@vidx, $idx);
push(@vname, $text{'index_virt'});
push(@vlink, "virt_index.cgi?virt=$idx");
push(@vlink, $r->{'active'} ? "virt_index.cgi?virt=$idx" : undef);
$sname = &find_directive("ServerName", $vm);
local $daddr = $addr eq "_default_" ||
($addr eq "*" && $httpd_modules{'core'} < 1.2);
@@ -225,10 +236,34 @@ foreach $v (@virt) {
}
$sp = undef if ($sp == 80 && $prot eq "http" ||
$sp == 443 && $prot eq "https");
push(@vurl, $sp ? "$prot://$sn:$sp/" : "$prot://$sn/");
push(@vurl, $r->{'active'} ?
($sp ? "$prot://$sn:$sp/" : "$prot://$sn/") : undef);
local $rfile = $r->{'file'} ? &simplify_path(&resolve_links($r->{'file'}))
: undef;
push(@vfile, $rfile);
local $status = "";
if ($can_vhost_files && $rfile && $available_vhost_file{$rfile}) {
local $enabled = &vhost_file_state($rfile)->{'enabled'};
$status = $enabled ? $text{'index_enabled'} :
$text{'index_disabled'};
}
push(@vstatus, $status);
local $file_manage = $can_vhost_files && $rfile &&
$available_vhost_file{$rfile} &&
&can_manage_vhost_state_file($rfile);
push(@vfilemanage, $file_manage ? 1 : 0);
local $sel;
if ($r->{'active'} && (!$can_vhost_files || !&is_default_vhost($v))) {
$sel = $idx;
}
elsif (!$r->{'active'} && $can_vhost_files && $rfile &&
$available_vhost_file{$rfile} && $file_manage) {
$sel = "file\t".$rfile."\t".$v->{'line'};
}
push(@vsel, $sel);
}
if (@vlink == 1 && !$access{'global'} && $access{'virts'} ne "*" &&
if (@vlink == 1 && $vlink[0] && !$access{'global'} && $access{'virts'} ne "*" &&
!$access{'create'} && $access{'noconfig'}) {
# Can only manage one vhost, so go direct to it
&redirect($vlink[0]);
@@ -297,7 +332,9 @@ if ($access{'global'}) {
# work out select links
print &ui_tabs_start_tab("mode", "list");
#print $text{'index_desclist'},"<p>\n";
$showdel = $access{'vaddr'} && ($vidx[0] || $vidx[1]);
$showdel = $access{'vaddr'} &&
grep { defined($_) && $_ ne "" } @vsel;
$showtoggle = $can_vhost_files && grep { $_ } @vfilemanage;
@links = ( );
if ($showdel) {
push(@links, &select_all_link("d"),
@@ -326,8 +363,10 @@ if ($config{'max_servers'} && @vname > $config{'max_servers'}) {
}
elsif ($config{'show_list'} && scalar(@vname)) {
# as list for people with lots of servers
$list_form = "vhosts_form";
if ($showdel) {
print &ui_form_start("delete_vservs.cgi", "post");
print &ui_form_start("delete_vservs.cgi", "post", undef,
"id='$list_form'");
}
print &ui_links_row(\@links);
print &ui_columns_start([
@@ -337,19 +376,23 @@ elsif ($config{'show_list'} && scalar(@vname)) {
$text{'index_port'},
$text{'index_name'},
$text{'index_root'},
$can_vhost_files ? ( $text{'index_status'} ) : ( ),
$text{'index_url'} ], 100);
for($i=0; $i<@vname; $i++) {
local @cols;
push(@cols, &ui_link($vlink[$i], $vname[$i]) );
push(@cols, $vlink[$i] ? &ui_link($vlink[$i], $vname[$i]) :
$vname[$i] );
push(@cols, &html_escape($vaddr[$i]));
push(@cols, &html_escape($vport[$i]));
push(@cols, $vserv[$i] || $text{'index_auto'});
push(@cols, &html_escape($vproxy[$i]) ||
&html_escape($vroot[$i]));
push(@cols, &ui_link($vurl[$i], $text{'index_view'}) );
if ($showdel && $vidx[$i]) {
push(@cols, $vstatus[$i]) if ($can_vhost_files);
push(@cols, $vurl[$i] ? &ui_link($vurl[$i], $text{'index_view'}) :
"" );
if ($showdel && defined($vsel[$i]) && $vsel[$i] ne "") {
print &ui_checked_columns_row(\@cols, undef,
"d", $vidx[$i]);
"d", $vsel[$i]);
}
elsif ($showdel) {
print &ui_columns_row([ "", @cols ]);
@@ -361,13 +404,23 @@ elsif ($config{'show_list'} && scalar(@vname)) {
print &ui_columns_end();
print &ui_links_row(\@links);
if ($showdel) {
print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]);
if ($showtoggle) {
print &ui_form_end_side_by_side($list_form,
[ [ "delete", $text{'index_delete'} ] ],
[ [ "toggle", $text{'index_toggle'}, undef,
undef, "form=\"$list_form\"" ] ]);
}
else {
print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]);
}
}
}
else {
# as icons for niceness
$list_form = "vhosts_form";
if ($showdel) {
print &ui_form_start("delete_vservs.cgi", "post");
print &ui_form_start("delete_vservs.cgi", "post", undef,
"id='$list_form'");
}
print &ui_links_row(\@links);
print "<table width=100% cellpadding=5>\n";
@@ -376,8 +429,9 @@ else {
print '<div class="row icons-row inline-row">';
&generate_icon("images/virt.gif", $vname[$i], $vlink[$i],
undef, undef, undef,
$vidx[$i] && $access{'vaddr'} ?
&ui_checkbox("d", $vidx[$i]) : "");
defined($vsel[$i]) && $vsel[$i] ne "" &&
$access{'vaddr'} ?
&ui_checkbox("d", $vsel[$i]) : "");
print "</div>\n";
print "</td> <td valign=top>\n";
print "$vdesc[$i]<br>\n";
@@ -397,12 +451,24 @@ else {
print "<b>$text{'index_root'}</b> ",
&html_escape($vroot[$i]),"</td> </tr>\n";
}
if ($can_vhost_files && $vstatus[$i]) {
print "<tr><td colspan=2><b>$text{'index_status'}</b> ",
$vstatus[$i],"</td></tr>\n";
}
print "</table></td> </tr>\n";
}
print "</table>\n";
print &ui_links_row(\@links);
if ($showdel) {
print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]);
if ($showtoggle) {
print &ui_form_end_side_by_side($list_form,
[ [ "delete", $text{'index_delete'} ] ],
[ [ "toggle", $text{'index_toggle'}, undef,
undef, "form=\"$list_form\"" ] ]);
}
else {
print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]);
}
}
}
print &ui_tabs_end_tab();
@@ -492,4 +558,3 @@ return $addr eq '_default_' || $addr eq '*' ? undef :
$addr =~ /^\[(\S+)\]$/ && &check_ip6address($1) ? $1 :
&to_ipaddress($addr);
}

View File

@@ -34,6 +34,9 @@ index_listen=Listen on address (if needed)
index_port=Port
index_name=Server Name
index_root=Document Root
index_status=State
index_enabled=Enabled
index_disabled=Disabled
index_url=URL
index_view=Open..
index_adddir=Allow access to this directory
@@ -57,6 +60,7 @@ index_fmode1=Virtual servers file $1
index_fmode1d=New file under virtual servers directory $1
index_fmode2=Selected file..
index_delete=Delete Selected Servers
index_toggle=Toggle State
cvirt_ecannot=You are not allowed to create a virtual server
cvirt_err=Failed to create virtual server
@@ -1032,6 +1036,7 @@ log_stop=Stopped webserver
log_apply=Applied changes
log_manual=Manually edited configuration file $1
log_virts_delete=Deleted $1 virtual servers
log_toggle_vhostfile=Toggled state of $1 virtual host files
search_title=Find Servers
search_notfound=No matching virtual servers found
@@ -1148,6 +1153,22 @@ delete_err=Failed to delete virtual servers
delete_enone=None selected
delete_ecannot=You are not allowed to delete servers
delete_ecannot2=You are not allowed to edit the server $1
delete_eaction=No action was selected
delete_egone=The selected virtual server no longer exists
delete_edefault=The default virtual server cannot be deleted
enable_err=Failed to change virtual host file state
enable_enone=No manageable virtual host files were selected
enable_efile=Virtual host file does not exist or cannot be managed
enable_elinkdir=No enabled virtual host links directory is configured
enable_elink=Failed to create symbolic link $1 : $2
enable_eunlink=Failed to remove symbolic link $1 : $2
enable_elinkexists=The symbolic link $1 already exists
enable_etest=Apache configuration test failed after changing the virtual host file state : $1
enable_evirtualmin_disable=This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site disabling should be done in Virtualmin using $3.
enable_evirtualmin_enable=This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site enabling should be done in Virtualmin using $3.
enable_virtualmin_disable_label=Disable and Delete &#x21fe; Disable Virtual Server
enable_virtualmin_enable_label=Disable and Delete &#x21fe; Enable Virtual Server
syslog_desc=Apache error log

409
apache/t/vhost-files.t Normal file
View File

@@ -0,0 +1,409 @@
#!/usr/bin/perl
# Tests for Debian-style Apache sites-available/sites-enabled handling.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Cwd qw(abs_path);
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..', '..'));
my $tmp = abs_path(tempdir(CLEANUP => 1));
my $webmin_config = File::Spec->catdir($tmp, 'webmin-config');
my $webmin_var = File::Spec->catdir($tmp, 'webmin-var');
my $apache_root = File::Spec->catdir($tmp, 'apache2');
my $available = File::Spec->catdir($apache_root, 'sites-available');
my $enabled = File::Spec->catdir($apache_root, 'sites-enabled');
my $apache_conf = File::Spec->catfile($apache_root, 'apache2.conf');
make_path($webmin_config, $webmin_var, "$webmin_config/apache",
"$webmin_var/apache", $apache_root, $available, $enabled);
sub write_text
{
my ($file, $text) = @_;
open(my $fh, '>', $file) || die "Failed to write $file: $!";
print $fh $text;
close($fh) || die "Failed to close $file: $!";
}
sub read_text
{
my ($file) = @_;
open(my $fh, '<', $file) || die "Failed to read $file: $!";
local $/ = undef;
my $text = <$fh>;
close($fh) || die "Failed to close $file: $!";
return $text;
}
sub vhost_conf
{
my ($name, $rootdir) = @_;
my $name_line = defined($name) ? " ServerName $name\n" : "";
return "<VirtualHost *:80>\n".
$name_line.
" DocumentRoot $rootdir\n".
"</VirtualHost>\n";
}
my $default = File::Spec->catfile($available, '000-default.conf');
my $alpha = File::Spec->catfile($available, 'alpha.conf');
my $beta = File::Spec->catfile($available, 'beta.conf');
my $charlie = File::Spec->catfile($available, 'charlie.conf');
write_text($default, vhost_conf(undef, '/srv/default'));
write_text($alpha, vhost_conf('alpha.example', '/srv/alpha'));
write_text($beta, vhost_conf('beta.example', '/srv/beta'));
write_text($charlie, vhost_conf('charlie.example', '/srv/charlie'));
write_text($apache_conf,
"ServerRoot \"$apache_root\"\n".
"Listen 80\n".
"IncludeOptional $enabled/*.conf\n");
symlink($default, File::Spec->catfile($enabled, '000-default.conf')) ||
die "Failed to symlink default: $!";
symlink($alpha, File::Spec->catfile($enabled, 'alpha.conf')) ||
die "Failed to symlink alpha: $!";
symlink($charlie, File::Spec->catfile($enabled, 'charlie.conf')) ||
die "Failed to symlink charlie: $!";
write_text(File::Spec->catfile($webmin_config, 'config'),
"os_type=debian-linux\n".
"os_version=12\n".
"real_os_type=Debian Linux\n".
"real_os_version=12\n");
write_text(File::Spec->catfile($webmin_config, 'miniserv.conf'),
"root=$root\n");
write_text(File::Spec->catfile($webmin_config, 'apache', 'config'),
"httpd_dir=$apache_root\n".
"httpd_path=/bin/true\n".
"httpd_conf=$apache_conf\n".
"apachectl_path=/bin/true\n".
"httpd_version=2.4.57\n".
"test_apachectl=0\n".
"test_config=1\n".
"virt_file=$available\n".
"link_dir=$enabled\n");
$ENV{'WEBMIN_CONFIG'} = $webmin_config;
$ENV{'WEBMIN_VAR'} = $webmin_var;
$ENV{'FOREIGN_MODULE_NAME'} = 'apache';
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $root;
$ENV{'REMOTE_USER'} = 'root';
unshift(@INC, $root);
require File::Spec->catfile($root, 'apache', 'apache-lib.pl');
{
no warnings 'once';
$main::text{'enable_elinkdir'} = 'No enabled virtual host links directory is configured';
$main::text{'enable_efile'} = 'Virtual host file does not exist or cannot be managed';
$main::text{'enable_elink'} = 'Failed to create symbolic link $1 : $2';
$main::text{'enable_eunlink'} = 'Failed to remove symbolic link $1 : $2';
$main::text{'enable_elinkexists'} = 'The symbolic link $1 already exists';
$main::text{'enable_etest'} = 'Apache configuration test failed after changing the virtual host file state : $1';
$main::text{'enable_evirtualmin_disable'} = 'This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site disabling should be done in Virtualmin using $3.';
$main::text{'enable_evirtualmin_enable'} = 'This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site enabling should be done in Virtualmin using $3.';
$main::text{'enable_virtualmin_disable_label'} = 'Disable and Delete &#x21fe; Disable Virtual Server';
$main::text{'enable_virtualmin_enable_label'} = 'Disable and Delete &#x21fe; Enable Virtual Server';
$main::text{'index_enabled'} = 'Enabled';
$main::text{'index_disabled'} = 'Disabled';
$main::text{'eafter'} = 'Apache configuration test failed : $1';
}
sub apache_config
{
main::flush_config_cache();
my $conf = main::get_config();
ok($conf, 'test apache config can be parsed');
return $conf;
}
sub row_names
{
return [ map {
scalar(main::find_directive('ServerName', $_->{'virt'}->{'members'})) || ''
} @_ ];
}
sub row_states
{
return [ map { $_->{'active'} ? 'enabled' : 'disabled' } @_ ];
}
subtest 'sites-available files are manageable and ordered' => sub {
ok(main::can_manage_vhost_files(),
'sites-available/enabled dirs are manageable');
is_deeply(
[ main::get_vhost_available_files() ],
[ $default, $alpha, $beta, $charlie ],
'available files are listed in stable filename order',
);
my @rows = main::get_virtual_list_rows(apache_config());
is_deeply(row_names(@rows),
[ '', 'alpha.example', 'beta.example', 'charlie.example' ],
'disabled rows stay in sites-available order');
is_deeply(row_states(@rows),
[ 'enabled', 'enabled', 'disabled', 'enabled' ],
'row active state follows sites-enabled symlinks');
ok(!main::can_manage_vhost_file($default),
'default virtual host file is not file-state manageable');
};
subtest 'disable removes only the enabled symlink' => sub {
no warnings 'once';
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
main::restart_last_restart_time();
my $old = time() - 10;
utime($old, $old, $main::last_restart_time_flag);
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::disable_vhost_file($alpha), undef, 'disable succeeds');
}
ok(main::needs_config_restart(),
'disable marks config as needing apply');
ok(-f $alpha, 'disable leaves the sites-available file in place');
ok(!-e File::Spec->catfile($enabled, 'alpha.conf'),
'disable removes the sites-enabled symlink');
my @rows = main::get_virtual_list_rows(apache_config());
is_deeply(row_names(@rows),
[ '', 'alpha.example', 'beta.example', 'charlie.example' ],
'disabled row remains in the same list position');
is_deeply(row_states(@rows),
[ 'enabled', 'disabled', 'disabled', 'enabled' ],
'disabled row status is updated');
};
subtest 'enable creates a symlink without touching the source file' => sub {
no warnings 'once';
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
main::restart_last_restart_time();
my $old = time() - 10;
utime($old, $old, $main::last_restart_time_flag);
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::enable_vhost_file($beta), undef, 'enable succeeds');
}
ok(main::needs_config_restart(),
'enable marks config as needing apply');
my $link = File::Spec->catfile($enabled, 'beta.conf');
ok(-f $beta, 'enable leaves the sites-available file in place');
ok(-l $link, 'enable creates the sites-enabled symlink');
is(readlink($link), $beta, 'enabled symlink points to the available file');
ok(main::vhost_file_enabled($beta), 'vhost_file_enabled sees the symlink');
};
subtest 'same-name symlink to another target is not disabled' => sub {
my $otherdir = File::Spec->catdir($tmp, 'other-sites');
my $other = File::Spec->catfile($otherdir, 'charlie.conf');
my $link = File::Spec->catfile($enabled, 'charlie.conf');
make_path($otherdir);
write_text($other, vhost_conf('other.example', '/srv/other'));
unlink($link) || die "Failed to remove charlie link: $!";
symlink($other, $link) || die "Failed to symlink other charlie: $!";
ok(!main::vhost_file_enabled($charlie),
'same-name symlink to another file is not considered enabled');
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::disable_vhost_file($charlie), undef, 'disable is a no-op');
}
ok(-l $link, 'same-name symlink to another target is preserved');
is(readlink($link), $other, 'preserved symlink target is unchanged');
};
subtest 'disabled default virtual hosts stay hidden' => sub {
my $disabled_default = File::Spec->catfile($available,
'zz-disabled-default.conf');
write_text($disabled_default, vhost_conf(undef, '/srv/disabled-default'));
my @rows = main::get_virtual_list_rows(apache_config());
ok(!(grep { $_->{'file'} eq $disabled_default } @rows),
'disabled catch-all virtual host file is not listed as a normal vhost');
};
subtest 'legacy webfile link helpers resolve relative link_dir' => sub {
my $relative = File::Spec->catfile($available, 'relative.conf');
my $link = File::Spec->catfile($enabled, 'relative.conf');
write_text($relative, vhost_conf('relative.example', '/srv/relative'));
unlink($link);
{
no warnings 'once';
local $main::config{'link_dir'} = 'sites-enabled';
main::create_webfile_link($relative);
ok(-l $link, 'relative link_dir creates link under ServerRoot');
is(readlink($link), $relative,
'created relative link_dir symlink points to the vhost file');
main::delete_webfile_link($relative);
ok(!-e $link && !-l $link,
'relative link_dir delete removes the enabled symlink');
}
};
subtest 'file-level actions require access to every virtual host in the file' => sub {
my $mixed = File::Spec->catfile($available, 'mixed.conf');
write_text($mixed,
vhost_conf('alpha.example', '/srv/mixed-alpha').
vhost_conf('hidden.example', '/srv/mixed-hidden'));
{
no warnings 'once';
local $main::access{'virts'} = 'alpha.example:80';
ok(!main::can_manage_vhost_file($mixed),
'mixed-access file cannot be managed by a restricted user');
}
ok(main::can_manage_vhost_file($mixed),
'shared file can be managed when all contained vhosts are allowed');
};
subtest 'state helpers enforce allowed files and ACLs directly' => sub {
my $outside = File::Spec->catfile($tmp, 'outside.conf');
write_text($outside, vhost_conf('outside.example', '/srv/outside'));
is(main::enable_vhost_file($outside),
'Virtual host file does not exist or cannot be managed',
'enable rejects files outside sites-available');
my $mixed = File::Spec->catfile($available, 'state-mixed.conf');
write_text($mixed,
vhost_conf('alpha.example', '/srv/state-alpha').
vhost_conf('hidden.example', '/srv/state-hidden'));
{
no warnings 'once';
local $main::access{'virts'} = 'alpha.example:80';
is(main::enable_vhost_file($mixed),
'Virtual host file does not exist or cannot be managed',
'enable rejects mixed-access files without relying on caller validation');
}
};
subtest 'change rollback covers extra disabled vhost files' => sub {
my $rollback = File::Spec->catfile($available, 'rollback.conf');
my $original = vhost_conf('rollback.example', '/srv/rollback');
write_text($rollback, $original);
my @virts = main::find_virtuals_in_file($rollback);
is(scalar(@virts), 1, 'rollback fixture has one vhost');
{
no warnings qw(redefine once);
local %main::before_changing;
local $main::config{'test_always'} = 1;
local *main::test_config = sub { return 'bad config'; };
local *main::error = sub { die $_[0]; };
main::before_changing($rollback);
is(main::delete_virtuals_from_file($rollback, @virts), 1,
'disabled vhost file deletion removes the vhost');
ok(!-e $rollback, 'empty disabled vhost file is deleted');
like(eval { main::after_changing(); 1 } ? '' : $@,
qr/bad config/, 'failed post-change test reports an error');
}
ok(-f $rollback, 'rollback recreates the disabled vhost file');
is(read_text($rollback), $original,
'rollback restores the disabled vhost file contents');
};
subtest 'apache configtest failure rolls back link changes' => sub {
my $delta = File::Spec->catfile($available, 'delta.conf');
my $delta_link = File::Spec->catfile($enabled, 'delta.conf');
write_text($delta, vhost_conf('delta.example', '/srv/delta'));
{
no warnings 'redefine';
local *main::test_config = sub { return 'bad config'; };
like(main::enable_vhost_file($delta), qr/bad config/,
'failed enable reports apache configtest output');
}
ok(!-e $delta_link, 'failed enable removes the new symlink');
symlink($delta, $delta_link) || die "Failed to symlink delta: $!";
{
no warnings 'redefine';
local *main::test_config = sub { return 'bad config'; };
like(main::disable_vhost_file($delta), qr/bad config/,
'failed disable reports apache configtest output');
}
ok(-l $delta_link, 'failed disable restores the removed symlink');
is(readlink($delta_link), $delta, 'restored symlink target is unchanged');
};
subtest 'Virtualmin-managed virtual host files cannot be toggled directly' => sub {
my $enabled_domain = File::Spec->catfile($available, 'vm-enabled.conf');
my $disabled_domain = File::Spec->catfile($available, 'vm-disabled.conf');
write_text($enabled_domain,
vhost_conf('www.vm-enabled.example', '/srv/vm-enabled'));
write_text($disabled_domain,
vhost_conf('vm-disabled.example', '/srv/vm-disabled'));
{
no warnings qw(redefine once);
local %main::apache_virtualmin_domain_for_file_cache;
local %main::apache_virtualmin_domain_by_name_cache;
local *main::virtualmin_available = sub { return 1; };
local *main::virtualmin_domain_by_name = sub {
my ($name) = @_;
return $name eq 'vm-enabled.example' ?
{ 'dom' => $name, 'id' => '12345',
'disabled' => '' } :
$name eq 'vm-disabled.example' ?
{ 'dom' => $name, 'id' => '67890',
'disabled' => 'web' } :
undef;
};
my $disable_err =
main::virtualmin_vhost_file_state_error($enabled_domain,
'disable');
my $enabled_state = main::vhost_file_state($enabled_domain);
is($enabled_state->{'source'}, 'virtualmin',
'Virtualmin is the effective state source for managed files');
ok($enabled_state->{'enabled'},
'Virtualmin enabled domain is reported as enabled');
is(main::vhost_file_toggle_action($enabled_domain), 'disable',
'toggle action follows the Virtualmin enabled state');
like($disable_err, qr/currently enabled/,
'Virtualmin state is included for enabled domains');
like($disable_err, qr/Disable Virtual Server/,
'disabling directs users to Virtualmin disable action');
like($disable_err,
qr{virtual-server/disable_domain\.cgi\?dom=12345},
'disabling links to the Virtualmin disable form');
my $enable_err =
main::virtualmin_vhost_file_state_error($disabled_domain,
'enable');
my $disabled_state = main::vhost_file_state($disabled_domain);
is($disabled_state->{'source'}, 'virtualmin',
'Virtualmin remains the state source for disabled domains');
ok(!$disabled_state->{'enabled'},
'Virtualmin disabled domain is reported as disabled');
is(main::vhost_file_toggle_action($disabled_domain), 'enable',
'toggle action follows the Virtualmin disabled state');
like($enable_err, qr/currently disabled/,
'Virtualmin state is included for disabled domains');
like($enable_err, qr/Enable Virtual Server/,
'enabling directs users to Virtualmin enable action');
like($enable_err,
qr{virtual-server/enable_domain\.cgi\?dom=67890},
'enabling links to the Virtualmin enable form');
is(main::virtualmin_vhost_file_state_error($alpha, 'disable'),
undef, 'non-Virtualmin virtual host files can still be toggled');
}
};
done_testing();

View File

@@ -4,11 +4,12 @@
# client. From then on, direct TCP connections can be made to this port
# to send requests and get replies.
$main::allow_rpc_only = 1;
BEGIN { push(@INC, "."); };
use WebminCore;
use POSIX;
use Socket;
$main::allow_rpc_only = 1;
$force_lang = $default_lang;
&init_config();

View File

@@ -111,8 +111,9 @@ if (!$release || !-d "$tardir/$dir") {
next if ($f =~ /^\./ || $f =~ /\.git$/ ||
$f =~ /\.(tar|wbm|wbt)\.gz$/ ||
$f eq "README.md" || $f =~ /^makemodule.*\.pl$/ ||
$f eq "linux.sh" || $f eq "freebsd.sh" ||
$f eq "LICENCE" || $f eq "version");
$f eq "linux.sh" || $f eq "freebsd.sh" ||
$f eq "LICENCE" || $f eq "version" ||
(-d "$m/$f" && ($f eq "t" || $f eq "xt")));
$flist .= " $m/$f";
}
closedir(DIR);

View File

@@ -244,6 +244,7 @@ system("find $usr_dir -name .git | xargs rm -rf");
system("find $usr_dir -name .github | xargs rm -rf");
system("find $usr_dir -name RELEASE | xargs rm -rf");
system("find $usr_dir -name RELEASE.sh | xargs rm -rf");
system("find $usr_dir -type d \\( -name t -o -name xt \\) | xargs rm -rf");
if (-r "$usr_dir/$mod/EXCLUDE") {
system("cd $usr_dir/$mod && cat EXCLUDE | xargs rm -rf");
system("rm -f $usr_dir/$mod/EXCLUDE");
@@ -621,10 +622,10 @@ if ($dsc_file) {
$diffmd5 =~ s/\s+.*\n//g;
my @diffst = stat($diff_file);
# Create a tar file of the module directory
# Create a tar file of the cleaned staged module directory
my $tar_file = $dsc_file;
$tar_file =~ s/[^\/]+$//; $tar_file .= "$prefix$mod-$ver.tar.gz";
system("cd $par ; tar czf $tar_file $source_mod");
system("cd $usr_dir ; tar czf $tar_file $mod");
my $md5 = `md5sum $tar_file`;
$md5 =~ s/\s+.*\n//g;
my @st = stat($tar_file);

View File

@@ -266,7 +266,7 @@ system("/usr/bin/find /tmp/makemodulerpm -name .git | xargs rm -rf");
system("/usr/bin/find /tmp/makemodulerpm -name .github | xargs rm -rf");
system("/usr/bin/find /tmp/makemodulerpm -name RELEASE | xargs rm -rf");
system("/usr/bin/find /tmp/makemodulerpm -name RELEASE.sh | xargs rm -rf");
system("/usr/bin/find /tmp/makemodulerpm -name t | xargs rm -rf");
system("/usr/bin/find /tmp/makemodulerpm -type d \\( -name t -o -name xt \\) | xargs rm -rf");
if (-r "/tmp/makemodulerpm/$mod/EXCLUDE") {
system("cd /tmp/makemodulerpm/$mod && cat EXCLUDE | xargs rm -rf");
system("rm -f /tmp/makemodulerpm/$mod/EXCLUDE");

View File

@@ -6779,15 +6779,14 @@ return $newhash eq $hash;
}
# encrypt_sha512(password, [salt])
# Hashes a password, possibly with the given salt, with SHA512
# Hashes a password, possibly with the given salt, with SHA512. The salt
# arg may be a full $6$salt$hash form (verification) or a bare $6$salt$
# (fresh hashing) — either way it must be passed to crypt() intact so
# crypt() selects SHA512. Only synthesise a new salt when none is given.
sub encrypt_sha512
{
my ($passwd, $salt) = @_;
if ($salt =~ /^\$6\$([^\$]+)/) {
# Extract actual salt from already encrypted password
$salt = $1;
}
$salt ||= '$6$'.substr(time(), -8).'$';
$salt = '$6$'.substr(time(), -8).'$' if (!$salt || $salt !~ /^\$6\$/);
return crypt($passwd, $salt);
}

View File

@@ -1175,7 +1175,8 @@ foreach my $svc (read_etc_service_defs($services_file)) {
foreach my $svc (setup_quick_service_defs()) {
merge_quick_service(\%defs, $svc);
}
return sort { lc($a->{'label'}) cmp lc($b->{'label'}) } values %defs;
my @sorted = sort { lc($a->{'label'}) cmp lc($b->{'label'}) } values %defs;
return @sorted;
}
# service_search_text(&service)
@@ -2543,7 +2544,7 @@ foreach my $service (@services) {
my $port = $map->{lc($proto || '')}->{lc($service)};
return $port if (defined($port));
}
return undef;
return;
}
# read_etc_services([services-file])
@@ -2616,12 +2617,12 @@ return 0;
sub configured_port_from_address
{
my ($value, $default) = @_;
return undef if (!defined($value) || $value eq '');
return if (!defined($value) || $value eq '');
return $1 if ($value =~ /^(\d+)$/);
return $1 if ($value =~ /^\[[^\]]+\]:(\d+)$/);
return $1 if ($value =~ /^[^:]+:(\d+)$/);
return $default if (defined($default) && $value =~ /\S/);
return undef;
return;
}
# address_is_loopback(address)

View File

@@ -38,6 +38,11 @@ print &ui_table_row($text{'acl_root'},
print &ui_table_row($text{'acl_global'},
&ui_yesno_radio("global", $o->{'global'}));
# Can manually edit configuration files?
print &ui_table_row($text{'acl_manual'},
&ui_yesno_radio("manual",
defined($o->{'manual'}) ? $o->{'manual'} : $o->{'global'}));
# Can edit log files?
print &ui_table_row($text{'acl_logs'},
&ui_yesno_radio("logs", $o->{'logs'}));
@@ -59,6 +64,7 @@ $o->{'edit'} = $in{'edit'};
$o->{'create'} = $in{'create'};
$o->{'root'} = $in{'root'};
$o->{'global'} = $in{'global'};
$o->{'manual'} = $in{'manual'};
$o->{'logs'} = $in{'logs'};
$o->{'user'} = $in{'user'};
$o->{'stop'} = $in{'stop'};

View File

@@ -6,11 +6,65 @@ use warnings;
require './nginx-lib.pl';
our (%text, %in, %config, %access);
&ReadParse();
&error_setup($text{'delete_err'});
my @items = split(/\0/, $in{'d'} || "");
my $file_action = $in{'toggle'} ? "toggle" :
$in{'enable'} ? "enable" :
$in{'disable'} ? "disable" : undef;
&error_setup($file_action ? $text{'enable_err'} : $text{'delete_err'});
$access{'edit'} || &error($text{'server_ecannotedit'});
my @ids = split(/\0/, $in{'d'} || "");
@ids || &error($text{'delete_enone'});
if ($file_action) {
&can_manage_server_files() || &error($text{'enable_elinkdir'});
my %add_to = map { $_, 1 } &get_add_to_files();
my %files;
foreach my $item (@items) {
my $file;
if ($item =~ /^file\t([^\t]+)/) {
$file = $1;
}
else {
my $server = &find_server($item);
next if (!$server || !&can_edit_server($server));
$file = $server->{'file'};
}
my $rfile = $file ? &resolve_links($file) : undef;
$files{$rfile}++ if ($rfile && -f $rfile && $add_to{$rfile} &&
&can_manage_server_file($rfile));
}
my @files = keys %files;
@files || &error($text{'enable_enone'});
my %file_actions;
foreach my $file (@files) {
my $action = $file_action eq "toggle" ?
&server_file_toggle_action($file) : $file_action;
my $err = &virtualmin_server_file_state_error($file, $action);
$err && &error($err);
$file_actions{$file} = $action;
}
foreach my $file (@files) {
my $err = $file_actions{$file} eq "enable" ?
&enable_server_file($file) :
&disable_server_file($file);
$err && &error($err);
}
&webmin_log($file_action, "serverfile", scalar(@files));
&redirect("");
exit;
}
if (!$in{'delete'}) {
&error($text{'delete_eaction'});
}
my (@ids, %file_lines);
foreach my $item (@items) {
if ($item =~ /^file\t([^\t]+)\t(\d+)$/) {
push(@{$file_lines{$1}}, $2);
}
elsif ($item !~ /^file\t/) {
push(@ids, $item);
}
}
@ids || %file_lines || &error($text{'delete_enone'});
# Validate the selected server blocks before locking config files.
foreach my $id (@ids) {
@@ -19,36 +73,54 @@ foreach my $id (@ids) {
&can_edit_server($server) || &error($text{'server_ecannot'});
&is_default_server_block($server) && &error($text{'delete_edefault'});
}
my %add_to = map { $_, 1 } &get_add_to_files();
my %file_servers;
foreach my $file (keys %file_lines) {
my $rfile = &resolve_links($file);
next if (!$rfile || !-f $rfile || !$add_to{$rfile} ||
!&can_manage_server_file($rfile));
my @servers = &find_servers_in_file($rfile);
foreach my $line (@{$file_lines{$file}}) {
my ($server) = grep { $_->{'line'} == $line } @servers;
$server || &error($text{'server_egone'});
&can_edit_server($server) || &error($text{'server_ecannot'});
&is_default_server_block($server) && &error($text{'delete_edefault'});
push(@{$file_servers{$rfile}}, $server);
}
}
@ids || %file_servers || &error($text{'delete_enone'});
&lock_all_config_files();
my $conf = &get_config();
my $http = &find("http", $conf);
if (!$http) {
&unlock_all_config_files();
&error(&text('index_ehttp', "<tt>$config{'nginx_config'}</tt>"));
}
my @servers;
foreach my $id (@ids) {
my $server = &find_server($id);
if (!$server) {
if (@ids) {
&lock_all_config_files();
my $conf = &get_config();
my $http = &find("http", $conf);
if (!$http) {
&unlock_all_config_files();
&error($text{'server_egone'});
&error(&text('index_ehttp', "<tt>$config{'nginx_config'}</tt>"));
}
if (!&can_edit_server($server)) {
&unlock_all_config_files();
&error($text{'server_ecannot'});
foreach my $id (@ids) {
my $server = &find_server($id);
if (!$server) {
&unlock_all_config_files();
&error($text{'server_egone'});
}
if (!&can_edit_server($server)) {
&unlock_all_config_files();
&error($text{'server_ecannot'});
}
if (&is_default_server_block($server)) {
&unlock_all_config_files();
&error($text{'delete_edefault'});
}
push(@servers, $server);
}
if (&is_default_server_block($server)) {
&unlock_all_config_files();
&error($text{'delete_edefault'});
foreach my $server (@servers) {
&save_directive($http, [ $server ], [ ]);
}
push(@servers, $server);
&flush_config_file_lines();
&unlock_all_config_files();
}
foreach my $server (@servers) {
&save_directive($http, [ $server ], [ ]);
}
&flush_config_file_lines();
&unlock_all_config_files();
foreach my $server (@servers) {
&delete_server_link($server);
}
@@ -57,5 +129,12 @@ foreach my $server (@servers) {
next if ($done_file{$server->{'file'}}++);
&delete_server_file_if_empty($server);
}
&webmin_log("delete", "servers", scalar(@servers));
foreach my $file (keys %file_servers) {
&lock_file($file);
&delete_servers_from_file($file, @{$file_servers{$file}});
&unlock_file($file);
}
my $count = scalar(@servers) +
scalar(map { @$_ } values %file_servers);
&webmin_log("delete", "servers", $count);
&redirect("");

View File

@@ -6,13 +6,14 @@ use warnings;
require './nginx-lib.pl';
&ReadParse();
our (%text, %in, %access);
$access{'global'} || &error($text{'index_eglobal'});
&can_edit_manual_config() || &error($text{'manual_ecannot'});
&ui_print_header(undef, $text{'manual_title'}, "");
my @files = &get_all_config_files();
my @files = &get_manual_config_files();
$in{'file'} ||= $files[0];
&indexof($in{'file'}, @files) >= 0 || &error($text{'manual_efile'});
$in{'file'} = &resolve_manual_config_file($in{'file'}, @files) ||
&error($text{'manual_efile'});
# Show file selector
print &ui_form_start("edit_manual.cgi");
@@ -38,4 +39,3 @@ print &ui_table_end();
print &ui_form_end([ [ undef, $text{'save'} ] ]);
&ui_print_footer("", $text{'index_return'});

View File

@@ -28,10 +28,18 @@ if ($in{'id'}) {
my @spages = ( $access{'logs'} ? ( "slogs" ) : ( ),
"sdocs", "ssl", "fcgi", "sssi", "sgzip", "sproxy",
"saccess", "srewrite", );
my @slinks = map { "edit_".$_.".cgi?id=".&urlize($in{'id'}) } @spages;
my @stitles = map { $text{$_."_title"} } @spages;
my @sicons = map { "images/".$_.".gif" } @spages;
if (&can_edit_manual_config() && &can_edit_manual_file($server->{'file'})) {
push(@slinks, "edit_manual.cgi?file=".&urlize($server->{'file'}));
push(@stitles, $text{'manual_server'});
push(@sicons, "images/manual.gif");
}
&icons_table(
[ map { "edit_".$_.".cgi?id=".&urlize($in{'id'}) } @spages ],
[ map { $text{$_."_title"} } @spages ],
[ map { "images/".$_.".gif" } @spages ],
\@slinks,
\@stitles,
\@sicons,
);
# Show table for locations

View File

@@ -48,7 +48,8 @@ print &ui_tabs_start(\@tabs, "mode", $mode, 1);
if ($access{'global'}) {
# Show icons for global config types
print &ui_tabs_start_tab("mode", "global");
my @gpages = ( "net", "mime", "logs", "docs", "ssi", "misc", "manual" );
my @gpages = ( "net", "mime", "logs", "docs", "ssi", "misc",
&can_edit_manual_config() ? ( "manual" ) : ( ) );
&icons_table(
[ map { "edit_".$_.".cgi" } @gpages ],
[ map { $text{$_."_title"} } @gpages ],
@@ -60,21 +61,41 @@ if ($access{'global'}) {
# Show list of server blocks
print &ui_tabs_start_tab("mode", "list");
my @allservers = &find("server", $http);
my @servers = grep { &can_edit_server($_) } @allservers;
if (@servers) {
my $can_files = &can_manage_server_files();
my @add_to_files = $can_files ? &get_add_to_files() : ( );
my %add_to_file = map { $_, 1 } @add_to_files;
my @rows = &get_server_list_rows($http);
if (@rows) {
my $can_delete = $access{'edit'};
my $has_proxy;
foreach my $r (@rows) {
my (undef, $proxy) = &server_root_proxy_state($r->{'server'});
$has_proxy ||= $proxy;
}
my @heads = ( $can_delete ? ( "" ) : ( ),
$text{'index_name'},
$text{'index_ip'},
$text{'index_port'},
$text{'index_root'} );
$text{'index_root'},
$has_proxy ? ( $text{'index_proxytarget'} ) : ( ),
$can_files ? ( $text{'index_status'} ) : ( ),
$text{'index_url'} );
my @data;
foreach my $s (@servers) {
foreach my $r (@rows) {
my $s = $r->{'server'};
my $name = &find_value("server_name", $s);
$name ||= "";
my $default = &is_default_server_block($s);
my $showname = !$default ?
&html_escape($name) : $text{'default_server_block'};
my $id = &server_id($s);
my $name_sort = ($default ? "0 " : "1 ").
lc($default ? $text{'default_server_block'} : $name);
my $name_sort_attr = &quote_escape($name_sort);
my $shownamelink = $r->{'active'} ?
"<a href='edit_server.cgi?id=".
&urlize($id)."'>".$showname."</a>" :
$showname;
# Extract all IPs and ports from listen directives
my (@ips, @ports);
@@ -86,43 +107,67 @@ if (@servers) {
push(@ports, $port);
}
my $rootdir = &find_value("root", $s);
my $root = $rootdir;
if (!$root) {
my @locs = &find("location", $s);
my ($rootloc) = grep { $_->{'value'} eq '/' } @locs;
if ($rootloc) {
$rootdir = &find_value("root", $rootloc);
$root = $rootdir ||
"<i>$text{'index_noroot'}</i>";
my @cols;
my $status = "";
if ($can_files) {
if ($add_to_file{$r->{'file'}}) {
my $enabled =
&server_file_state($r->{'file'})->{'enabled'};
$status = $enabled ? $text{'index_enabled'} :
$text{'index_disabled'};
}
else {
$root = "<i>$text{'index_norootloc'}</i>";
}
$rootdir ||= "";
}
my $id = $name.";".$rootdir;
my @cols = (
"<a href='edit_server.cgi?id=".&urlize($id)."'>".
$showname."</a>",
push(@cols, { 'type' => 'string',
'value' => $shownamelink,
'td' => "data-sort=\"$name_sort_attr\" ".
"data-order=\"$name_sort_attr\"" });
push(@cols,
join("<br>", @ips),
join("<br>", @ports),
$root );
if ($can_delete && !$default) {
&server_root_summary($s) );
push(@cols, &server_proxy_summary($s)) if ($has_proxy);
push(@cols, $status) if ($can_files);
my $url = $r->{'active'} ? &server_url($s) : undef;
push(@cols, $url ? &ui_link(&quote_escape($url),
$text{'index_view'}, undef,
'target="_blank" rel="noopener noreferrer"') : "");
if ($can_delete && $r->{'active'} && !$default) {
unshift(@cols, { 'type' => 'checkbox',
'name' => 'd',
'value' => $id });
}
elsif ($can_delete && $can_files && !$r->{'active'} &&
!$default && $add_to_file{$r->{'file'}}) {
unshift(@cols, { 'type' => 'checkbox',
'name' => 'd',
'value' => "file\t".$r->{'file'}.
"\t".$s->{'line'} });
}
elsif ($can_delete) {
unshift(@cols, "");
}
push(@data, \@cols);
}
if ($can_delete) {
print &ui_form_columns_table(
"delete_servers.cgi",
[ [ "delete", $text{'index_delete'} ] ],
1, [ ], [ ], \@heads, 100, \@data);
my $list_form = "server_blocks_form";
my $has_checkbox = grep {
ref($_->[0]) && $_->[0]->{'type'} eq 'checkbox'
} @data;
my $links = $has_checkbox ?
&ui_links_row([ &select_all_link("d", 0),
&select_invert_link("d", 0) ]) : "";
my @left_buttons = ( [ "delete", $text{'index_delete'} ] );
my @right_buttons = $can_files ?
( [ "toggle", $text{'index_toggle'}, undef, undef,
"form=\"$list_form\"" ] ) : ( );
print &ui_form_start("delete_servers.cgi", "post", undef,
"id='$list_form'");
print $links;
print &ui_columns_table(\@heads, 100, \@data);
print $links;
print &ui_form_end_side_by_side($list_form,
\@left_buttons,
\@right_buttons);
}
else {
print &ui_columns_table(\@heads, 100, \@data);

View File

@@ -2,15 +2,22 @@ index_version=Nginx version $1
index_econfig=The Nginx configuration file $1 was not found on your system. Use the <a href='$2'>module configuration</a> page to enter the correct path.
index_ecmd=The Nginx command $1 was not found on your system. Use the <a href='$2'>module configuration</a> page to enter the correct path.
index_ehttp=No <tt>http</tt> section was found in your Nginx config file $1. Maybe it is not setup as a webserver?
index_name=Server block name
index_name=Server Block Name
default_server_block=Default Server
index_ip=IP addresses
index_port=Port numbers
index_root=Root directory
index_status=State
index_ip=Address
index_port=Port
index_root=Root Directory
index_proxytarget=Proxy Target
index_url=URL
index_view=Open..
index_toggle=Toggle State
index_enabled=Enabled
index_disabled=Disabled
index_any=Any IPv4 address
index_any6=Any IPv6 address
index_noroot=No root location
index_norootloc=Not a directory
index_noroot=No root directory
index_noproxy=No proxy target
index_none=No server blocks have been created yet.
index_noneaccess=No server blocks that you have access to have been created yet.
index_add=Add a new Nginx server block.
@@ -25,6 +32,7 @@ index_stopdesc=Shut down all running Nginx webserver processes.
index_start=Start Nginx Webserver
index_startdesc=Start up the Nginx webserver using the current configuration.
index_restart=Apply Nginx Configuration
index_apply_changes=Apply Changes
index_restartdesc=Apply the current configuration by stopping and re-starting the Nginx webserver.
index_delete=Delete Selected Server Blocks
index_eglobal=You are not allowed to edit global settings
@@ -143,12 +151,14 @@ opt_essi_types=$1 is not a valid MIME type
opt_essi_value_length=Maximum parameter length must be a number
manual_title=Edit Configuration Files
manual_server=Edit Configuration File
manual_file=Editing configuration file:
manual_ok=Switch
manual_efile=Selected file is not part of the Nginx configuration!
manual_test=Test new configuration before saving?
manual_elink=Dangling symbolic link!
manual_err=Failed to save configuration file
manual_ecannot=You are not allowed to manually edit configuration files!
server_create=Create a New Server Block
server_edit=Edit Server Block
@@ -200,8 +210,21 @@ server_eclash=A server block with the same name already exists
server_pp=Proxy to $1
server_eexist=No Nginx server found
delete_err=Failed to delete server blocks
delete_eaction=No action was selected
delete_enone=No server blocks were selected to delete
delete_edefault=The default server block cannot be deleted
enable_err=Failed to change server file state
enable_enone=No manageable server files were selected
enable_efile=Server file does not exist or cannot be managed
enable_elinkdir=No enabled server-block links directory is configured
enable_elink=Failed to create symbolic link $1 : $2
enable_eunlink=Failed to remove symbolic link $1 : $2
enable_elinkexists=The symbolic link $1 already exists
enable_etest=Nginx configuration test failed after changing the server file state : $1
enable_evirtualmin_disable=This Nginx server block is managed by Virtualmin virtual server $1, which is currently $2. Site disabling should be done in Virtualmin using $3.
enable_evirtualmin_enable=This Nginx server block is managed by Virtualmin virtual server $1, which is currently $2. Site enabling should be done in Virtualmin using $3.
enable_virtualmin_disable_label=Disable and Delete &#x21fe; Disable Virtual Server
enable_virtualmin_enable_label=Disable and Delete &#x21fe; Enable Virtual Server
slogs_title=Server Block Logging
slogs_header=Log file options
@@ -334,6 +357,10 @@ log_manual=Manually edited config file $1
log_create_server=Created server block $1
log_modify_server=Modified server block $1
log_delete_server=Deleted server block $1
log_delete_servers=Deleted $1 server blocks
log_toggle_serverfile=Toggled state of $1 server block files
log_enable_serverfile=Enabled $1 server block files
log_disable_serverfile=Disabled $1 server block files
log_slogs_server=Changed logging options for $1
log_ssl_server=Change SSL configuration for $1
log_sdocs_server=Changed document options for $1
@@ -398,6 +425,7 @@ acl_create=Can create server blocks?
acl_stop=Can stop and start Nginx?
acl_root=Allowed directories for locations
acl_global=Can edit global settings?
acl_manual=Can manually edit raw configuration files?
acl_logs=Can configure log files?
acl_user=Write password files as user

View File

@@ -26,6 +26,12 @@ elsif ($type eq 'server') {
return &text('log_'.$action.'_server',
"<tt>".&html_escape($object)."</tt>");
}
elsif ($type eq 'servers') {
return &text('log_'.$action.'_servers', $object);
}
elsif ($type eq 'serverfile') {
return &text('log_'.$action.'_serverfile', $object);
}
elsif ($type eq 'location') {
return &text('log_'.$action.'_location',
"<tt>".&html_escape($object)."</tt>",
@@ -41,4 +47,3 @@ else {
}
return undef;
}

View File

@@ -11,8 +11,11 @@ eval "use WebminCore;";
our %access = &get_module_acl();
our ($get_config_cache, $get_config_parent_cache, %list_directives_cache,
@list_modules_cache, @open_config_files);
our (%config, %text, %in, $module_root_directory);
our ($last_config_change_flag, $last_restart_time_flag);
our (%config, %text, %in, $module_root_directory, $module_var_directory);
&set_nginx_config_defaults();
$last_config_change_flag = $module_var_directory."/config-flag";
$last_restart_time_flag = $module_var_directory."/restart-flag";
my @lock_all_config_files_cache;
@@ -522,9 +525,11 @@ if ($parent->{'type'}) {
sub flush_config_file_lines
{
my ($parent) = @_;
foreach my $f (&unique(@open_config_files)) {
my @files = &unique(@open_config_files);
foreach my $f (@files) {
&flush_file_lines($f);
}
&update_last_config_change() if (@files);
@open_config_files = ( );
}
@@ -565,6 +570,29 @@ if ($parent->{'type'}) {
return &unique(@rv);
}
# get_manual_config_files([&parent])
# Returns all config files this user can manually edit
sub get_manual_config_files
{
my ($parent) = @_;
my @files = map { &resolve_links($_) || $_ } &get_all_config_files($parent);
@files = &unique(@files);
return @files if (!$access{'vhosts'});
return grep { &can_edit_manual_file($_) } @files;
}
# resolve_manual_config_file(file, [files...])
# Resolves a submitted manual config file path, if it is allowed
sub resolve_manual_config_file
{
my ($file, @files) = @_;
return undef if (!$file);
@files = &get_manual_config_files() if (!@files);
my $rfile = &resolve_links($file);
$rfile ||= $file;
return &indexof($rfile, @files) >= 0 ? $rfile : undef;
}
# directive_indent(&directive, &parent, &file-lines)
# Returns the exact whitespace prefix to use when writing a directive
sub directive_indent
@@ -1480,7 +1508,11 @@ return $? ? $out : undef;
sub start_nginx
{
my $out = &backquote_logged("$config{'start_cmd'} 2>&1 </dev/null");
return $? ? $out : undef;
if ($?) {
return $out;
}
&update_last_restart_time();
return undef;
}
# apply_nginx()
@@ -1489,7 +1521,11 @@ return $? ? $out : undef;
sub apply_nginx
{
my $out = &backquote_logged("$config{'apply_cmd'} 2>&1 </dev/null");
return $? ? $out : undef;
if ($?) {
return $out;
}
&update_last_restart_time();
return undef;
}
# nginx_action_links()
@@ -1502,7 +1538,11 @@ if (&is_nginx_running()) {
if ($access{'stop'}) {
push(@rv, &ui_link("stop.cgi?$args", $text{'index_stop'}));
}
push(@rv, &ui_link("restart.cgi?$args", $text{'index_restart'}));
my $needs = &needs_config_restart();
my $apply = $text{'index_apply_changes'} || $text{'index_restart'};
my $label = $needs ? "<b>$apply</b>" : $apply;
my $url = "restart.cgi?$args";
push(@rv, &ui_link($url, $label));
}
elsif ($access{'stop'}) {
push(@rv, &ui_link("start.cgi?$args", $text{'index_start'}));
@@ -1510,6 +1550,33 @@ elsif ($access{'stop'}) {
return join("<br>\n", @rv);
}
# update_last_config_change()
# Updates the flag file indicating when the config was changed
sub update_last_config_change
{
&open_lock_tempfile(my $fh, ">$last_config_change_flag", 0, 1);
&close_tempfile($fh);
}
# update_last_restart_time()
# Updates the flag file indicating when the config was applied
sub update_last_restart_time
{
&open_lock_tempfile(my $fh, ">$last_restart_time_flag", 0, 1);
&close_tempfile($fh);
}
# needs_config_restart()
# Returns 1 if a restart is needed after a config change
sub needs_config_restart
{
my @cst = stat($last_config_change_flag);
my @rst = stat($last_restart_time_flag);
return 0 if (!@cst);
return 1 if (!@rst);
return $cst[9] > $rst[9] ? 1 : 0;
}
# this_url()
# Returns the current module URL
sub this_url
@@ -1530,6 +1597,485 @@ my $out = &backquote_logged("$config{'nginx_cmd'} -t 2>&1 </dev/null");
return $? || $out !~ /syntax\s+is\s+ok/ ? $out : undef;
}
# can_manage_server_files()
# Returns 1 if this system uses Debian-style available/enabled site dirs
sub can_manage_server_files
{
return $config{'add_to'} && -d $config{'add_to'} &&
$config{'add_link'} && -d $config{'add_link'};
}
# get_add_to_files()
# Returns config files from the directory used for new server blocks
sub get_add_to_files
{
my @rv;
if ($config{'add_to'} && -d $config{'add_to'}) {
opendir(ADDTO, $config{'add_to'}) || return @rv;
foreach my $f (sort { lc($a) cmp lc($b) } readdir(ADDTO)) {
next if ($f eq "." || $f eq "..");
my $file = $config{'add_to'}."/".$f;
my $rfile = &resolve_links($file);
next if (!$rfile || !-f $rfile || !-r $rfile);
push(@rv, $rfile);
}
closedir(ADDTO);
}
return &unique(@rv);
}
# find_servers_in_file(file)
# Returns server blocks parsed from one config file
sub find_servers_in_file
{
my ($file) = @_;
my $rfile = &resolve_links($file);
$rfile ||= $file;
return ( ) if (!-r $rfile);
my $conf = &read_config_file($rfile);
return grep { $_->{'file'} eq $rfile } &find_recursive("server", $conf);
}
# can_manage_server_file(file)
# Returns 1 if all server blocks in a file are manageable by this user
sub can_manage_server_file
{
my ($file) = @_;
my $rfile = &resolve_links($file);
$rfile ||= $file;
return 0 if (!$rfile || !-f $rfile || !-r $rfile);
my @servers = &find_servers_in_file($rfile);
return 0 if (!@servers);
foreach my $server (@servers) {
return 0 if (!&can_edit_server($server));
}
return 1;
}
# delete_servers_from_file(file, &servers...)
# Deletes server blocks from one config file and removes the file if empty
sub delete_servers_from_file
{
my ($file, @servers) = @_;
return 0 if (!@servers);
my $lref = &read_file_lines($file);
foreach my $server (sort { $b->{'line'} <=> $a->{'line'} } @servers) {
my $len = $server->{'eline'} - $server->{'line'} + 1;
splice(@$lref, $server->{'line'}, $len);
}
my $empty = 1;
foreach my $line (@$lref) {
if ($line =~ /\S/) {
$empty = 0;
last;
}
}
&flush_file_lines($file);
if ($empty) {
foreach my $link (&server_file_links($file)) {
&unlink_logged($link);
}
&unlink_logged($file);
}
&update_last_config_change();
return scalar(@servers);
}
# get_server_list_rows(&http)
# Returns row hashes for the server blocks list, preserving sites-available order
sub get_server_list_rows
{
my ($http) = @_;
my @allservers = &find("server", $http);
my @servers = grep { &can_edit_server($_) } @allservers;
my $default_first = sub {
return ( grep { &is_default_server_block($_->{'server'}) } @_ ),
( grep { !&is_default_server_block($_->{'server'}) } @_ );
};
if (&can_manage_server_files()) {
my @rows;
my %active_by_file;
foreach my $s (@servers) {
my $file = &resolve_links($s->{'file'});
$file ||= $s->{'file'};
push(@{$active_by_file{$file}}, $s);
}
my %done_server;
foreach my $file (&get_add_to_files()) {
my @fileservers = @{$active_by_file{$file} || [ ]};
my $active = @fileservers ? 1 : 0;
if (!@fileservers) {
@fileservers = grep { &can_edit_server($_) }
&find_servers_in_file($file);
}
foreach my $s (@fileservers) {
push(@rows, { 'server' => $s,
'active' => $active,
'file' => $file });
$done_server{$s}++;
}
}
foreach my $s (@servers) {
next if ($done_server{$s});
push(@rows, { 'server' => $s,
'active' => 1,
'file' => $s->{'file'} });
}
return &$default_first(@rows);
}
return &$default_first(
map { { 'server' => $_, 'active' => 1, 'file' => $_->{'file'} } }
@servers);
}
# server_file_link(file)
# Returns the enabled symlink path for a server file
sub server_file_link
{
my ($file) = @_;
return undef if (!&can_manage_server_files());
my $short = $file;
$short =~ s/^.*\///;
return $config{'add_link'}."/".$short;
}
# server_file_links(file)
# Returns enabled symlinks for a server file
sub server_file_links
{
my ($file) = @_;
my @rv;
return @rv if (!&can_manage_server_files());
my $rfile = &resolve_links($file);
$rfile ||= $file;
opendir(LINKDIR, $config{'add_link'}) || return @rv;
foreach my $f (readdir(LINKDIR)) {
next if ($f eq "." || $f eq "..");
my $link = $config{'add_link'}."/".$f;
next if (!-l $link);
my $rlink = &resolve_links($link);
if ($rlink && $rlink eq $rfile) {
push(@rv, $link);
}
}
closedir(LINKDIR);
return @rv;
}
# server_file_enabled(file)
# Returns 1 if a server file has an enabled symlink
sub server_file_enabled
{
my ($file) = @_;
return scalar(&server_file_links($file)) ? 1 : 0;
}
# enable_server_file(file)
# Enables a server file and rolls back if nginx -t fails
sub enable_server_file
{
my ($file) = @_;
my $rfile = &resolve_links($file);
$rfile ||= $file;
my $link = &server_file_link($rfile);
$link || return $text{'enable_elinkdir'};
return undef if (&server_file_enabled($rfile));
if (-e $link || -l $link) {
return &text('enable_elinkexists', "<tt>".&html_escape($link)."</tt>");
}
&symlink_logged($rfile, $link) ||
return &text('enable_elink', "<tt>".&html_escape($link)."</tt>",
"<tt>".&html_escape($!)."</tt>");
my $err = &test_config();
if ($err) {
&unlink_logged($link);
return &text('enable_etest', "<tt>".&html_escape($err)."</tt>");
}
&update_last_config_change();
return undef;
}
# disable_server_file(file)
# Disables a server file and rolls back if nginx -t fails
sub disable_server_file
{
my ($file) = @_;
my @links = &server_file_links($file);
return undef if (!@links);
my @restore = map { [ $_, readlink($_) ] } @links;
my @removed;
foreach my $link (@links) {
if (!&unlink_logged($link)) {
foreach my $r (@removed) {
&symlink_logged($r->[1], $r->[0])
if (defined($r->[1]) && !-e $r->[0] && !-l $r->[0]);
}
return &text('enable_eunlink',
"<tt>".&html_escape($link)."</tt>",
"<tt>".&html_escape($!)."</tt>");
}
my ($restore) = grep { $_->[0] eq $link } @restore;
push(@removed, $restore) if ($restore);
}
my $err = &test_config();
if ($err) {
foreach my $r (@restore) {
&symlink_logged($r->[1], $r->[0])
if (defined($r->[1]) && !-e $r->[0] && !-l $r->[0]);
}
return &text('enable_etest', "<tt>".&html_escape($err)."</tt>");
}
&update_last_config_change();
return undef;
}
# virtualmin_available()
# Returns 1 if Virtualmin is installed and supported on this system
sub virtualmin_available
{
return $main::nginx_virtualmin_available
if (defined($main::nginx_virtualmin_available));
$main::nginx_virtualmin_available = &foreign_check("virtual-server");
return $main::nginx_virtualmin_available;
}
# virtualmin_domain_by_name(name)
# Returns a Virtualmin domain object by domain name, if one exists
sub virtualmin_domain_by_name
{
my ($name) = @_;
return undef if (!&virtualmin_available());
return $main::nginx_virtualmin_domain_by_name_cache{$name}
if (exists($main::nginx_virtualmin_domain_by_name_cache{$name}));
&foreign_require("virtual-server");
my $d = &virtual_server::get_domain_by("dom", $name);
$main::nginx_virtualmin_domain_by_name_cache{$name} = $d;
return $d;
}
# server_names(&server)
# Returns all names from server_name directives in a server block
sub server_names
{
my ($server) = @_;
my @rv;
foreach my $sn (&find("server_name", $server)) {
push(@rv, @{$sn->{'words'} || [ ]});
if (!@{$sn->{'words'} || [ ]} && $sn->{'value'}) {
push(@rv, $sn->{'value'});
}
}
return grep { $_ && $_ ne "_" && $_ ne "-" } &unique(@rv);
}
# virtualmin_domain_for_server_file(file)
# Returns the Virtualmin domain object for a server file, if any
sub virtualmin_domain_for_server_file
{
my ($file) = @_;
return undef if (!&virtualmin_available());
my $rfile = &resolve_links($file);
$rfile ||= $file;
return $main::nginx_virtualmin_domain_for_file_cache{$rfile}
if (exists($main::nginx_virtualmin_domain_for_file_cache{$rfile}));
foreach my $server (&find_servers_in_file($file)) {
foreach my $name (&server_names($server)) {
my $d = &virtualmin_domain_by_name($name);
if ($d) {
$main::nginx_virtualmin_domain_for_file_cache{$rfile} = $d;
return $d;
}
}
}
$main::nginx_virtualmin_domain_for_file_cache{$rfile} = undef;
return undef;
}
# server_file_state(file)
# Returns the effective enabled state for a server file
sub server_file_state
{
my ($file) = @_;
my $d = &virtualmin_domain_for_server_file($file);
if ($d) {
return { 'enabled' => $d->{'disabled'} ? 0 : 1,
'source' => 'virtualmin',
'domain' => $d };
}
return { 'enabled' => &server_file_enabled($file) ? 1 : 0,
'source' => 'nginx' };
}
# server_file_toggle_action(file)
# Returns the action needed to toggle a server file's effective state
sub server_file_toggle_action
{
my ($file) = @_;
return &server_file_state($file)->{'enabled'} ? "disable" : "enable";
}
# virtualmin_domain_state_link(&domain, enabled?)
# Returns a link to the Virtualmin state change form for some domain
sub virtualmin_domain_state_link
{
my ($d, $enabled) = @_;
my $page = $enabled ? "disable_domain.cgi" : "enable_domain.cgi";
my $label = $enabled ? $text{'enable_virtualmin_disable_label'} :
$text{'enable_virtualmin_enable_label'};
my $url = "../virtual-server/".$page."?dom=".&urlize($d->{'id'});
return &ui_link(&quote_escape($url), "\"".$label."\"");
}
# virtualmin_server_file_state_error(file, action)
# Returns an error if a Virtualmin-owned site is being enabled or disabled here
sub virtualmin_server_file_state_error
{
my ($file, $action) = @_;
return undef if ($action ne "enable" && $action ne "disable");
my $state_info = &server_file_state($file);
return undef if ($state_info->{'source'} ne "virtualmin");
my $d = $state_info->{'domain'};
return undef if (!$d);
my $state = lc($state_info->{'enabled'} ? $text{'index_enabled'} :
$text{'index_disabled'});
my $dom = "<tt>".&html_escape($d->{'dom'})."</tt>";
my $link = &virtualmin_domain_state_link($d, $state_info->{'enabled'});
return $state_info->{'enabled'} ?
&text('enable_evirtualmin_disable', $dom, $state, $link) :
&text('enable_evirtualmin_enable', $dom, $state, $link);
}
# proxy_pass_value(&proxy_pass)
# Returns the target URL from a proxy_pass directive
sub proxy_pass_value
{
my ($pp) = @_;
my @w = @{$pp->{'words'}};
return (@w ? $w[0] : undef) || $pp->{'value'};
}
# server_proxy_target(&server|&location)
# Returns the first proxy_pass target under a server or location block
sub server_proxy_target
{
my ($conf) = @_;
my ($pp) = &find_recursive("proxy_pass", $conf);
return undef if (!$pp);
return &proxy_pass_value($pp);
}
# server_proxy_pairs(&server)
# Returns location path and proxy_pass target pairs for a server block
sub server_proxy_pairs
{
my ($server) = @_;
my @rv;
foreach my $loc (&find("location", $server)) {
my $path = &location_path($loc) || "/";
foreach my $pp (&find_recursive("proxy_pass", $loc)) {
my $target = &proxy_pass_value($pp);
push(@rv, [ $path, $target ])
if (defined($target) && $target ne "");
}
}
foreach my $pp (&find("proxy_pass", $server)) {
my $target = &proxy_pass_value($pp);
push(@rv, [ "/", $target ])
if (defined($target) && $target ne "");
}
return @rv;
}
# server_root_summary(&server)
# Returns the root directory for a server block, or a missing-root message
sub server_root_summary
{
my ($server) = @_;
my $root = &server_root_value($server);
return defined($root) && $root ne "" ? &html_escape($root) :
"<i>$text{'index_noroot'}</i>";
}
# server_root_value(&server)
# Returns the configured root directory for a server block
sub server_root_value
{
my ($server) = @_;
my $root = &find_value("root", $server);
return $root if ($root);
my @locs = &find("location", $server);
my ($rootloc) = grep { &location_path($_) eq '/' } @locs;
if ($rootloc) {
$root = &find_value("root", $rootloc);
return $root if ($root);
}
return undef;
}
# server_proxy_summary(&server)
# Returns the most relevant proxy target for a server block
sub server_proxy_summary
{
my ($server) = @_;
my @pairs = &server_proxy_pairs($server);
return "<i>$text{'index_noproxy'}</i>" if (!@pairs);
return join("<br>", map {
&html_escape($_->[0])." &#x21fe; ".&html_escape($_->[1])
} @pairs);
}
# server_root_proxy_summary(&server)
# Returns the root directory or most relevant proxy target for a server block
sub server_root_proxy_summary
{
my ($server) = @_;
my $root = &server_root_value($server);
return &html_escape($root) if (defined($root) && $root ne "");
my $pp = &server_proxy_target($server);
return &text('server_pp', "<tt>".&html_escape($pp)."</tt>")
if ($pp);
return &server_root_summary($server);
}
# server_root_proxy_state(&server)
# Returns booleans for whether a server has root and proxy_pass directives
sub server_root_proxy_state
{
my ($server) = @_;
my $has_root = &find_recursive("root", $server) ? 1 : 0;
my $has_proxy = &server_proxy_target($server) ? 1 : 0;
return ($has_root, $has_proxy);
}
# server_url(&server)
# Returns the browser URL for a server block
sub server_url
{
my ($server) = @_;
my $name = &find_value("server_name", $server);
return undef if (&is_default_server_block($server));
return undef if (!$name || $name !~ /^[A-Za-z0-9.-]+$/);
my ($best_scheme, $best_port);
foreach my $l (&find("listen", $server)) {
my @w = @{$l->{'words'}};
my $addr = shift(@w);
next if (!$addr);
my (undef, $port) = &split_ip_port($addr);
my $ssl = grep { $_ eq "ssl" } @w;
my $scheme = $ssl || $port == 443 ? "https" : "http";
if (!$best_scheme || $scheme eq "https") {
($best_scheme, $best_port) = ($scheme, $port);
}
}
$best_scheme ||= "http";
$best_port ||= $best_scheme eq "https" ? 443 : 80;
$best_port = undef if ($best_scheme eq "http" && $best_port == 80 ||
$best_scheme eq "https" && $best_port == 443);
return $best_scheme."://".$name.($best_port ? ":".$best_port : "")."/";
}
# find_server(id)
# Convenience function to find an HTTP server object with some ID
sub find_server
@@ -1677,7 +2223,8 @@ if ($config{'add_link'}) {
my $link = $server->{'file'};
$link =~ s/^.*\///;
$link = $config{'add_link'}."/".$link;
&symlink_logged($server->{'file'}, $link);
&update_last_config_change()
if (&symlink_logged($server->{'file'}, $link));
}
}
@@ -1689,6 +2236,7 @@ sub delete_server_link
my ($server) = @_;
if ($config{'add_link'}) {
my $file = $server->{'file'};
my $changed;
my $short = $file;
$short =~ s/^.*\///;
opendir(LINKDIR, $config{'add_link'});
@@ -1696,10 +2244,11 @@ if ($config{'add_link'}) {
if ($f ne "." && $f ne ".." &&
(&resolve_links($config{'add_link'}."/".$f) eq $file ||
$short eq $f)) {
&unlink_logged($config{'add_link'}."/".$f);
$changed++ if (&unlink_logged($config{'add_link'}."/".$f));
}
}
closedir(LINKDIR);
&update_last_config_change() if ($changed);
}
}
@@ -1714,7 +2263,8 @@ foreach my $l (@$lref) {
$count++ if ($l =~ /\S/);
}
if (!$count) {
&unlink_logged($server->{'file'});
&update_last_config_change()
if (&unlink_logged($server->{'file'}));
}
}
@@ -1775,6 +2325,22 @@ return 0 if (!$name);
return &indexoflc($name, split(/\s+/, $access{'vhosts'})) >= 0;
}
# can_edit_manual_config()
# Returns 1 if the user can manually edit raw configuration files
sub can_edit_manual_config
{
return defined($access{'manual'}) ? $access{'manual'} : $access{'global'};
}
# can_edit_manual_file(file)
# Returns 1 if the user can manually edit some raw configuration file
sub can_edit_manual_file
{
my ($file) = @_;
return 1 if (!$access{'vhosts'});
return &can_manage_server_file($file);
}
# can_directory(dir)
# Check if some directory is under one of the allowed roots
sub can_directory

View File

@@ -7,16 +7,11 @@ require './nginx-lib.pl';
&ReadParseMime();
our (%text, %in, %access);
&error_setup($text{'manual_err'});
$access{'global'} || &error($text{'index_eglobal'});
&can_edit_manual_config() || &error($text{'manual_ecannot'});
my @files = &get_all_config_files();
&indexof($in{'file'}, @files) >= 0 || &error($text{'manual_efile'});
# Follow links to get the real file
while(-l $in{'file'}) {
$in{'file'} = readlink($in{'file'});
}
$in{'file'} || &error($text{'manual_elink'});
my @files = &get_manual_config_files();
$in{'file'} = &resolve_manual_config_file($in{'file'}, @files) ||
&error($text{'manual_efile'});
$in{'data'} =~ s/\r//g;
my $fh = "CONF";
@@ -43,6 +38,6 @@ else {
&print_tempfile($fh, $in{'data'});
&close_tempfile($fh);
}
&update_last_config_change();
&webmin_log("manual", undef, $in{'file'});
&redirect("");

521
nginx/t/server-files.t Normal file
View File

@@ -0,0 +1,521 @@
#!/usr/bin/perl
# Tests for Debian-style Nginx sites-available/sites-enabled handling.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Cwd qw(abs_path);
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..', '..'));
my $tmp = abs_path(tempdir(CLEANUP => 1));
my $webmin_config = File::Spec->catdir($tmp, 'webmin-config');
my $webmin_var = File::Spec->catdir($tmp, 'webmin-var');
my $available = File::Spec->catdir($tmp, 'sites-available');
my $enabled = File::Spec->catdir($tmp, 'sites-enabled');
my $nginx_conf = File::Spec->catfile($tmp, 'nginx.conf');
make_path($webmin_config, $webmin_var, "$webmin_config/nginx",
$available, $enabled);
sub write_text
{
my ($file, $text) = @_;
open(my $fh, '>', $file) || die "Failed to write $file: $!";
print $fh $text;
close($fh) || die "Failed to close $file: $!";
}
sub server_conf
{
my ($name, $body) = @_;
return "server {\n".
"\tserver_name $name;\n".
"\tlisten 80;\n".
$body.
"}\n";
}
my $alpha = File::Spec->catfile($available, 'alpha.conf');
my $beta = File::Spec->catfile($available, 'beta.conf');
my $charlie = File::Spec->catfile($available, 'charlie.conf');
my $default = File::Spec->catfile($available, 'default');
write_text($alpha, server_conf('alpha.example', "\troot /srv/alpha;\n"));
write_text($beta, server_conf('beta.example',
"\tlocation / {\n".
"\t\tproxy_pass http://127.0.0.1:8080;\n".
"\t}\n"));
write_text($charlie, server_conf('charlie.example', "\troot /srv/charlie;\n"));
write_text($default, server_conf('_', "\troot /srv/default;\n"));
write_text($nginx_conf,
"events {\n".
"}\n".
"http {\n".
"\tinclude $enabled/*;\n".
"}\n");
symlink($alpha, File::Spec->catfile($enabled, 'alpha.conf')) ||
die "Failed to symlink alpha: $!";
symlink($charlie, File::Spec->catfile($enabled, 'charlie.conf')) ||
die "Failed to symlink charlie: $!";
symlink($default, File::Spec->catfile($enabled, 'default')) ||
die "Failed to symlink default: $!";
write_text(File::Spec->catfile($webmin_config, 'config'),
"os_type=unix\n".
"os_version=1\n".
"real_os_type=Unix\n".
"real_os_version=1\n");
write_text(File::Spec->catfile($webmin_config, 'miniserv.conf'),
"root=$root\n");
write_text(File::Spec->catfile($webmin_config, 'nginx', 'config'),
"nginx_config=$nginx_conf\n".
"nginx_cmd=/bin/true\n".
"add_to=$available\n".
"add_link=$enabled\n");
$ENV{'WEBMIN_CONFIG'} = $webmin_config;
$ENV{'WEBMIN_VAR'} = $webmin_var;
$ENV{'FOREIGN_MODULE_NAME'} = 'nginx';
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $root;
$ENV{'REMOTE_USER'} = 'root';
unshift(@INC, $root);
require File::Spec->catfile($root, 'nginx', 'nginx-lib.pl');
{
no warnings 'once';
$main::text{'server_pp'} = 'Proxy to $1';
$main::text{'index_noroot'} = 'No root directory';
$main::text{'index_noproxy'} = 'No proxy target';
}
sub http_config
{
main::flush_config_cache();
my $http = main::find('http', main::get_config());
ok($http, 'test nginx config has an http block');
return $http;
}
sub row_names
{
return [ map { scalar main::find_value('server_name', $_->{'server'}) } @_ ];
}
sub row_states
{
return [ map { $_->{'active'} ? 'enabled' : 'disabled' } @_ ];
}
subtest 'sites-available files are manageable and ordered' => sub {
ok(main::can_manage_server_files(), 'sites-available/enabled dirs are manageable');
is_deeply(
[ main::get_add_to_files() ],
[ $alpha, $beta, $charlie, $default ],
'available files are listed in stable filename order',
);
my @rows = main::get_server_list_rows(http_config());
is_deeply(row_names(@rows),
[ '_', 'alpha.example', 'beta.example', 'charlie.example' ],
'default site is first and other sites stay in sites-available order');
is_deeply(row_states(@rows),
[ 'enabled', 'enabled', 'disabled', 'enabled' ],
'row active state follows sites-enabled symlinks');
};
subtest 'disable removes only the enabled symlink' => sub {
no warnings 'once';
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::disable_server_file($alpha), undef, 'disable succeeds');
}
ok(main::needs_config_restart(),
'disable marks config as needing apply');
ok(-f $alpha, 'disable leaves the sites-available file in place');
ok(!-e File::Spec->catfile($enabled, 'alpha.conf'),
'disable removes the sites-enabled symlink');
my @rows = main::get_server_list_rows(http_config());
is_deeply(row_names(@rows),
[ '_', 'alpha.example', 'beta.example', 'charlie.example' ],
'disabled row remains in the same list position');
is_deeply(row_states(@rows),
[ 'enabled', 'disabled', 'disabled', 'enabled' ],
'disabled row status is updated');
};
subtest 'enable creates a symlink without touching the source file' => sub {
no warnings 'once';
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::enable_server_file($beta), undef, 'enable succeeds');
}
ok(main::needs_config_restart(),
'enable marks config as needing apply');
my $link = File::Spec->catfile($enabled, 'beta.conf');
ok(-f $beta, 'enable leaves the sites-available file in place');
ok(-l $link, 'enable creates the sites-enabled symlink');
is(readlink($link), $beta, 'enabled symlink points to the available file');
ok(main::server_file_enabled($beta), 'server_file_enabled sees the symlink');
};
subtest 'legacy create/delete link helpers still manage symlinks' => sub {
no warnings 'once';
my $echo = File::Spec->catfile($available, 'echo.conf');
my $echo_link = File::Spec->catfile($enabled, 'echo.conf');
write_text($echo, server_conf('echo.example', "\troot /srv/echo;\n"));
my $server = { 'file' => $echo };
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
main::create_server_link($server);
ok(-l $echo_link, 'create_server_link creates expected symlink');
is(readlink($echo_link), $echo, 'created symlink points to server file');
ok(main::needs_config_restart(),
'create_server_link marks config as needing apply');
main::update_last_restart_time();
my $old = time() - 10;
utime($old, $old, $main::last_restart_time_flag);
main::delete_server_link($server);
ok(!-e $echo_link, 'delete_server_link removes expected symlink');
ok(-f $echo, 'delete_server_link leaves server file in place');
ok(main::needs_config_restart(),
'delete_server_link marks config as needing apply');
};
subtest 'disabled server blocks can be deleted from available files' => sub {
my $multi = File::Spec->catfile($available, 'multi.conf');
write_text($multi,
server_conf('one.example', "\troot /srv/one;\n").
server_conf('two.example', "\troot /srv/two;\n"));
my ($one_server) = grep {
main::find_value('server_name', $_) eq 'one.example'
} main::find_servers_in_file($multi);
is(main::delete_servers_from_file($multi, $one_server), 1,
'delete_servers_from_file removes one disabled server block');
ok(-f $multi, 'file remains when another server block is present');
is_deeply(
[ map { scalar main::find_value('server_name', $_) }
main::find_servers_in_file($multi) ],
[ 'two.example' ],
'only the unselected disabled server block remains');
my ($two_server) = main::find_servers_in_file($multi);
is(main::delete_servers_from_file($multi, $two_server), 1,
'delete_servers_from_file removes the last disabled server block');
ok(!-e $multi, 'empty available file is removed after last block delete');
};
subtest 'same-name symlink to another target is not disabled' => sub {
my $otherdir = File::Spec->catdir($tmp, 'other-sites');
my $other = File::Spec->catfile($otherdir, 'charlie.conf');
my $link = File::Spec->catfile($enabled, 'charlie.conf');
make_path($otherdir);
write_text($other, server_conf('other.example', "\troot /srv/other;\n"));
unlink($link) || die "Failed to remove charlie link: $!";
symlink($other, $link) || die "Failed to symlink other charlie: $!";
ok(!main::server_file_enabled($charlie),
'same-name symlink to another file is not considered enabled');
{
no warnings 'redefine';
local *main::test_config = sub { return undef; };
is(main::disable_server_file($charlie), undef, 'disable is a no-op');
}
ok(-l $link, 'same-name symlink to another target is preserved');
is(readlink($link), $other, 'preserved symlink target is unchanged');
};
subtest 'file-level actions require access to every server in the file' => sub {
my $mixed = File::Spec->catfile($available, 'mixed.conf');
write_text($mixed,
server_conf('alpha.example', "\troot /srv/mixed-alpha;\n").
server_conf('hidden.example', "\troot /srv/mixed-hidden;\n"));
{
no warnings 'once';
local $main::access{'vhosts'} = 'alpha.example';
ok(!main::can_manage_server_file($mixed),
'mixed-access file cannot be managed by a restricted user');
}
ok(main::can_manage_server_file($mixed),
'mixed-access file can be managed when vhost access is unrestricted');
};
subtest 'nginx -t failure rolls back link changes' => sub {
my $delta = File::Spec->catfile($available, 'delta.conf');
my $delta_link = File::Spec->catfile($enabled, 'delta.conf');
write_text($delta, server_conf('delta.example', "\troot /srv/delta;\n"));
{
no warnings 'redefine';
local *main::test_config = sub { return 'bad config'; };
like(main::enable_server_file($delta), qr/bad config/,
'failed enable reports nginx -t output');
}
ok(!-e $delta_link, 'failed enable removes the new symlink');
symlink($delta, $delta_link) || die "Failed to symlink delta: $!";
{
no warnings 'redefine';
local *main::test_config = sub { return 'bad config'; };
like(main::disable_server_file($delta), qr/bad config/,
'failed disable reports nginx -t output');
}
ok(-l $delta_link, 'failed disable restores the removed symlink');
is(readlink($delta_link), $delta, 'restored symlink target is unchanged');
};
subtest 'root and proxy summaries are detected' => sub {
my ($alpha_server) = main::find_servers_in_file($alpha);
my ($beta_server) = main::find_servers_in_file($beta);
my ($default_server) = main::find_servers_in_file($default);
my $path_proxy = File::Spec->catfile($available, 'path-proxy.conf');
write_text($path_proxy,
"server {\n".
"\tserver_name path.example;\n".
"\tlisten 443 ssl http2;\n".
"\tlocation /webmin {\n".
"\t\tproxy_pass https://127.0.0.1:10000/;\n".
"\t\tproxy_http_version 1.1;\n".
"\t}\n".
"}\n");
my $named = File::Spec->catfile($available, 'named-proxy.conf');
write_text($named,
"server {\n".
"\tserver_name named.example;\n".
"\tlisten 80;\n".
"\tlocation / {\n".
"\t\ttry_files \$uri \@backend;\n".
"\t}\n".
"\tlocation \@backend {\n".
"\t\tproxy_pass http://127.0.0.1:8081;\n".
"\t}\n".
"}\n");
my ($path_proxy_server) = main::find_servers_in_file($path_proxy);
my ($named_server) = main::find_servers_in_file($named);
is_deeply([ main::server_root_proxy_state($alpha_server) ], [ 1, 0 ],
'root-only server state is detected');
is(main::server_root_summary($alpha_server), '/srv/alpha',
'root-only server root column shows the root directory');
is(main::server_proxy_summary($alpha_server), '<i>No proxy target</i>',
'root-only server proxy column shows a missing-proxy message');
is(main::server_root_proxy_summary($alpha_server), '/srv/alpha',
'root-only summary shows the root directory');
is(main::server_url($alpha_server), 'http://alpha.example/',
'root-only server URL uses HTTP default port');
is(main::server_url($default_server), undef,
'default server has no URL link target');
is_deeply([ main::server_root_proxy_state($beta_server) ], [ 0, 1 ],
'proxy-only server state is detected');
is(main::server_root_summary($beta_server), '<i>No root directory</i>',
'proxy-only server root column shows a missing-root message');
like(main::server_proxy_summary($beta_server),
qr{/ &#x21fe; http://127\.0\.0\.1:8080},
'proxy-only server proxy column shows the path and proxy target');
like(main::server_root_proxy_summary($beta_server),
qr{http://127\.0\.0\.1:8080},
'proxy-only summary shows the proxy target');
is(main::server_url($beta_server), 'http://beta.example/',
'proxy-only server URL uses HTTP default port');
is_deeply([ main::server_root_proxy_state($path_proxy_server) ], [ 0, 1 ],
'non-root-location proxy state is detected');
is(main::server_root_summary($path_proxy_server), '<i>No root directory</i>',
'non-root-location proxy root column shows a missing-root message');
like(main::server_proxy_summary($path_proxy_server),
qr{/webmin &#x21fe; https://127\.0\.0\.1:10000/},
'non-root-location proxy column shows the path and proxy target');
like(main::server_root_proxy_summary($path_proxy_server),
qr{https://127\.0\.0\.1:10000/},
'non-root-location proxy summary shows the proxy target');
is(main::server_url($path_proxy_server), 'https://path.example/',
'SSL listener URL uses HTTPS default port');
is_deeply([ main::server_root_proxy_state($named_server) ], [ 0, 1 ],
'named-location proxy state is detected');
like(main::server_proxy_summary($named_server),
qr{\@backend &#x21fe; http://127\.0\.0\.1:8081},
'named-location proxy column shows the path and proxy target');
like(main::server_root_proxy_summary($named_server),
qr{http://127\.0\.0\.1:8081},
'named-location proxy summary shows the proxy target');
is(main::server_url($named_server), 'http://named.example/',
'named-location proxy URL uses HTTP default port');
};
subtest 'config change apply flag tracks pending changes' => sub {
no warnings 'once';
unlink($main::last_config_change_flag);
unlink($main::last_restart_time_flag);
ok(!main::needs_config_restart(),
'no apply needed when no change flag exists');
main::update_last_config_change();
ok(main::needs_config_restart(),
'apply needed after config change');
main::update_last_restart_time();
ok(!main::needs_config_restart(),
'apply not needed after config has been applied');
my $old = time() - 10;
utime($old, $old, $main::last_restart_time_flag);
main::update_last_config_change();
ok(main::needs_config_restart(),
'apply needed when config change is newer than last apply');
};
subtest 'manual edit ACL is separately configurable' => sub {
no warnings 'once';
{
local %main::access = ( 'global' => 1 );
ok(main::can_edit_manual_config(),
'manual edit defaults to global ACL for existing users');
}
{
local %main::access = ( 'global' => 0 );
ok(!main::can_edit_manual_config(),
'manual edit is denied when global ACL default is denied');
}
{
local %main::access = ( 'global' => 1, 'manual' => 0 );
ok(!main::can_edit_manual_config(),
'manual edit can be disabled for global users');
}
{
local %main::access = ( 'global' => 0, 'manual' => 1 );
ok(main::can_edit_manual_config(),
'manual edit can be explicitly enabled');
}
};
subtest 'manual edit files respect vhost ACL' => sub {
my $single = File::Spec->catfile($available, 'manual-single.conf');
my $shared = File::Spec->catfile($available, 'manual-shared.conf');
my $single_link = File::Spec->catfile($tmp, 'manual-single-link.conf');
my $shared_link = File::Spec->catfile($tmp, 'manual-shared-link.conf');
write_text($single,
server_conf('single.example', "\troot /srv/single;\n"));
write_text($shared,
server_conf('single.example', "\troot /srv/shared-single;\n").
server_conf('other.example', "\troot /srv/shared-other;\n"));
symlink($single, File::Spec->catfile($enabled, 'manual-single.conf')) ||
die "Failed to symlink manual-single: $!";
symlink($shared, File::Spec->catfile($enabled, 'manual-shared.conf')) ||
die "Failed to symlink manual-shared: $!";
symlink($single, $single_link) ||
die "Failed to symlink manual-single-link: $!";
symlink($shared, $shared_link) ||
die "Failed to symlink manual-shared-link: $!";
main::flush_config_cache();
{
local %main::access = ( 'manual' => 1,
'vhosts' => 'single.example' );
my @files = main::get_manual_config_files();
ok(main::can_edit_manual_file($single),
'restricted user can manually edit their own single-server file');
ok(!main::can_edit_manual_file($shared),
'restricted user cannot manually edit a shared server file');
is_deeply(
[ grep { $_ eq $single || $_ eq $shared } @files ],
[ $single ],
'manual file list excludes shared files for restricted users');
is(main::resolve_manual_config_file($single_link, @files), $single,
'submitted symlink resolves to an authorized config file');
is(main::resolve_manual_config_file($shared_link, @files), undef,
'submitted symlink to unauthorized file is rejected');
}
{
local %main::access = ( 'manual' => 1 );
ok(main::can_edit_manual_file($shared),
'unrestricted user can manually edit shared server files');
}
};
subtest 'Virtualmin-managed server files cannot be toggled directly' => sub {
my $enabled_domain = File::Spec->catfile($available, 'vm-enabled.conf');
my $disabled_domain = File::Spec->catfile($available, 'vm-disabled.conf');
write_text($enabled_domain,
server_conf('vm-enabled.example www.vm-enabled.example',
"\troot /srv/vm-enabled;\n"));
write_text($disabled_domain,
server_conf('vm-disabled.example',
"\troot /srv/vm-disabled;\n"));
{
no warnings qw(redefine once);
local *main::virtualmin_available = sub { return 1; };
local *main::virtualmin_domain_by_name = sub {
my ($name) = @_;
return $name eq 'vm-enabled.example' ?
{ 'dom' => $name, 'id' => '12345',
'disabled' => '' } :
$name eq 'vm-disabled.example' ?
{ 'dom' => $name, 'id' => '67890',
'disabled' => 'web' } :
undef;
};
my $disable_err =
main::virtualmin_server_file_state_error($enabled_domain,
'disable');
my $enabled_state = main::server_file_state($enabled_domain);
is($enabled_state->{'source'}, 'virtualmin',
'Virtualmin is the effective state source for managed files');
ok($enabled_state->{'enabled'},
'Virtualmin enabled domain is reported as enabled');
is(main::server_file_toggle_action($enabled_domain), 'disable',
'toggle action follows the Virtualmin enabled state');
like($disable_err, qr/currently enabled/,
'Virtualmin state is included for enabled domains');
like($disable_err, qr/Disable Virtual Server/,
'disabling directs users to Virtualmin disable action');
like($disable_err,
qr{virtual-server/disable_domain\.cgi\?dom=12345},
'disabling links to the Virtualmin disable form');
my $enable_err =
main::virtualmin_server_file_state_error($disabled_domain,
'enable');
my $disabled_state = main::server_file_state($disabled_domain);
is($disabled_state->{'source'}, 'virtualmin',
'Virtualmin remains the state source for disabled domains');
ok(!$disabled_state->{'enabled'},
'Virtualmin disabled domain is reported as disabled');
is(main::server_file_toggle_action($disabled_domain), 'enable',
'toggle action follows the Virtualmin disabled state');
like($enable_err, qr/currently disabled/,
'Virtualmin state is included for disabled domains');
like($enable_err, qr/Enable Virtual Server/,
'enabling directs users to Virtualmin enable action');
like($enable_err,
qr{virtual-server/enable_domain\.cgi\?dom=67890},
'enabling links to the Virtualmin enable form');
is(main::virtualmin_server_file_state_error($alpha, 'disable'),
undef, 'non-Virtualmin server files can still be toggled');
}
};
done_testing();

View File

@@ -4,13 +4,13 @@
# other webmin servers. State is preserved by starting a process for each
# session that listens for requests on a named pipe (and dies after a few
# seconds of inactivity)
# access{'rpc'} 0=not allowed 1=allowed 2=allowed if root or admin, 3=allowed
# access{'rpc'} 0=not allowed 1=allowed 2=allowed if root or admin, 3=RPC only
$main::allow_rpc_only = 1;
BEGIN { push(@INC, "."); };
use WebminCore;
use POSIX;
$main::allow_rpc_only = 1;
&init_config();
if ($ENV{'REQUEST_METHOD'} eq 'POST') {
local $got;
@@ -169,4 +169,3 @@ unlink($fifo1);
unlink($fifo2);
exit;
}

148
t/README.md Normal file
View File

@@ -0,0 +1,148 @@
# Webmin test suite
Two kinds of tests live under this tree:
- Repo-root `t/` for core code (`miniserv.pl`, `web-lib-funcs.pl`, top-level
scripts, `bin/`).
- Per-module `t/` for module-internal coverage (see `nftables/t/` for the
established pattern: `perlcritic.t` plus a `run-tests.t` smoke harness).
## Running tests
```sh
prove -lr # everything, including modules t/
prove -lr t # everything under repo-root t/
prove t/compile.t # one test file
WEBMIN_COMPILE_T_FILTER='^\./acl/' prove t/compile.t # one module
```
`prove` and Test::More are core, though on RPM-based distros, you need
`perl-Test-Harness`.
## Coverage reports
```sh
HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lr
cover
```
You need Devel::Cover installed.
## What's here
| File | What it checks |
| --- | --- |
| `compile.t` | Every `.pl`, `.cgi`, and shebang-perl script in `bin/` parses cleanly (`perl -c`). Catches breakage from bulk refactors without browsing every page. ~12s for the full tree. |
| `miniserv.t` | Contract test for `miniserv.pl` functions — status codes, headers, body rendering, log behaviour. Demonstrates the require-and-stub pattern below. |
## The require-and-stub pattern
Many Webmin scripts mix sub definitions with a main body that opens sockets,
reads `/etc/webmin/*`, or spawns CGIs. To test individual subs in isolation
we need to `require` the script as a library without running the main body.
Two complementary idioms can be used. Both work; they look different because
the underlying script does.
### Block wrap (main body precedes sub definitions)
Used by `miniserv.pl`. The executable preamble runs at file scope (so any
`my` vars stay file-scoped for the subs below); the main body wraps in
`unless (caller)`:
```perl
#!/usr/bin/perl
use Foo; # use lines and pragmas stay outside the guard
unless (caller) {
# main body: arg parsing, setup, the actual work
...
} # end of unless (caller)
sub helper { ... }
sub other_helper { ... }
```
### One-liner (sub definitions precede the main call)
Used by most `bin/` CLI tools, which already define `sub main` and dispatch
to it at the bottom:
```perl
#!/usr/bin/env perl
use strict; use warnings;
sub main {
...
return 0;
}
exit main(\@ARGV) if !caller(0);
sub helper { ... }
```
`!caller(0)` is true at script invocation (depth-0 frame absent) and false
under `require`.
## Sub-stubbing in tests
`miniserv.t` is the canonical example. The pattern:
1. `require` the script. The guard skips its main body.
2. Replace side-effecting subs (socket I/O, logging, disk reads) with
capture-buffer overrides under `no warnings 'redefine'`.
3. Populate package globals (`%miniserv::config`, `@miniserv::roots`, etc.)
yourself instead of running the config loader.
4. Call the sub under test. Assert on contract (status code, presence of
required headers, structural balance of emitted markup) — not on
cosmetics like exact wording or class names.
Tying tests to the contract rather than the rendering lets the UI evolve
without breaking the test, while still catching real regressions.
## Tiered coverage policy
Not all 268 modules deserve the same investment.
- **Tier 1 — security-critical, network-facing.** `miniserv.pl`,
`web-lib-funcs.pl`, `acl/`, auth, file upload paths. Comprehensive
contract tests; mock filesystem and `backquote_command` for parser
coverage of external-binary output.
- **Tier 2 — active refactor surface.** Currently `nftables/`, `firewalld/`,
and whichever module is being reviewed. Mandatory tests for new code;
`perlcritic.t` at severity 5 as a gate.
- **Tier 3 — stable OS-config wrappers.** Covered by `compile.t` plus
optional per-module `perlcritic.t`. Don't chase line coverage on parsers
for config files that haven't changed in a decade.
The goal is not coverage-as-a-number. It's:
- Every parser round-trips its serializer.
- Every privilege boundary has a test.
- Every external-binary call has a mock-driven test for its output parser.
## Adding a per-module test directory
```
yourmodule/
t/
perlcritic.t # see nftables/t/perlcritic.t for the template
run-tests.t # see nftables/t/run-tests.t for the WEBMIN_CONFIG / tmpdir setup
```
A module's tests are reachable from `prove -lr` at the repo root (no
path arg, so the recursive walk starts at the cwd). `prove -lr t` only
walks within `t/` and will miss `<module>/t/`.
## Caveats
- `WEBMIN_COMPILE_T_STRICT=1` turns missing-CPAN-module skips into failures.
Use this in CI on a fully-provisioned image; leave it off on dev boxes
where optional deps (`Pod::Simple::Wiki`, `Encode::Detect::Detector`) may
not be installed.
- `.pl` is also the Polish translation suffix in Webmin. `compile.t` skips
`<file>.pl` when a sibling `<file>` (no extension) exists; this catches
`config.info.pl` and `module.info.pl` data files without a hardcoded list.

90
t/compile.t Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/local/bin/perl
# Verify every .pl and .cgi in the tree parses (perl -c).
#
# Catches syntax and `use` breakage from bulk refactors without having
# to load every page in a browser. The test is the first line of defence
# for the "we changed thousands of files mechanically, did anything
# break" question.
#
# Skipped:
# - $file.pl when a sibling $file (no .pl) exists. Webmin uses .pl as
# the Polish translation suffix, so config.info.pl, module.info.pl,
# etc. are data files, not Perl.
# - Files that fail only because of a missing CPAN module. The file
# itself parses, but `use Foo::Bar` can't resolve at compile time.
# Treated as a skip so missing optional deps don't gate the suite.
# Set WEBMIN_COMPILE_T_STRICT=1 to turn these into failures.
#
# Speed: ~12 seconds for the full tree (~3.4k files). Narrow with
# WEBMIN_COMPILE_T_FILTER=<regex> when iterating on one module.
use strict;
use warnings;
use Test::More;
use File::Find;
use File::Basename qw(dirname);
use File::Spec;
use Cwd qw(abs_path);
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..'));
chdir($root) or die "chdir($root): $!";
my $filter = $ENV{WEBMIN_COMPILE_T_FILTER};
my $strict = $ENV{WEBMIN_COMPILE_T_STRICT};
my @files;
find({
no_chdir => 1,
wanted => sub {
return if -d;
# .pl and .cgi files, plus extensionless files in bin/ with a
# perl shebang. The shebang check keeps us from compile-checking
# arbitrary non-perl files just because they share a directory.
my $name = $File::Find::name;
my $is_pl_or_cgi = $name =~ /\.(pl|cgi)\z/;
my $is_bin_dotless = $name =~ m{^\./bin/([^/]+)\z} && $1 !~ /\./;
return unless $is_pl_or_cgi || $is_bin_dotless;
# Skip the Polish translations that share the .pl suffix.
if ($is_pl_or_cgi && $name =~ m{(.+)\.pl\z}) {
my $base = $1;
return if -f "$base";
}
# For extensionless bin/ scripts, require a perl shebang.
if ($is_bin_dotless) {
open(my $fh, '<', $name) or return;
my $shebang = <$fh>;
close($fh);
return unless defined $shebang && $shebang =~ /^#!.*\bperl\b/;
}
push(@files, $name);
},
}, '.');
@files = sort @files;
@files or BAIL_OUT("found no .pl/.cgi/bin scripts under $root");
if ($filter) {
@files = grep { /$filter/ } @files;
@files or do { diag("filter '$filter' matched zero files"); plan skip_all => "no files match filter"; };
}
diag("compile-checking " . scalar(@files) . " files");
for my $f (@files) {
my $rel = $f;
$rel =~ s{^\./}{};
my $out = qx{perl -I. -c -- "$rel" 2>&1};
if ($out =~ /\bsyntax OK\b/) {
pass("$rel compiles");
}
elsif (!$strict && $out =~ /Can't locate (\S+\.pm) in \@INC/) {
SKIP: { skip("$rel: missing optional CPAN module $1", 1); }
}
else {
fail("$rel compiles");
diag($out);
}
}
done_testing();

View File

@@ -1,142 +0,0 @@
#!/usr/bin/perl
# Tests for miniserv::http_error.
#
# miniserv.pl is loaded as a module; its top-level script body is skipped
# by the `unless (caller) { ... }` guard, so we only get the sub
# definitions plus a handful of pure-constant globals (@itoa64, @weekday,
# @month, @miniserv_argv). Everything else (%config, @roots, $datestr,
# etc.) we populate ourselves.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..', 'miniserv.pl'));
require $script;
# Capture buffers populated by the overridden I/O subs.
our @written;
our @errlog;
our @reqlog;
# Replace the subs that would otherwise touch SOCK, STDERR, the log file,
# or read disk. Each capturing override is the minimum needed to keep
# http_error's control flow intact. `once` is suppressed because these
# package globals are only ever written from this file.
{
no warnings qw(redefine once);
*miniserv::write_data = sub { push @written, join('', @_); };
*miniserv::write_keep_alive = sub { };
*miniserv::log_error = sub { push @errlog, join('', @_); };
*miniserv::log_request = sub { push @reqlog, [@_]; };
*miniserv::embed_error_styles = sub { return ''; };
*miniserv::server_info = sub { return 'MiniServ/test'; };
*miniserv::reset_byte_count = sub { };
*miniserv::byte_count = sub { return 0; };
}
{
no warnings 'once';
%miniserv::config = ();
@miniserv::roots = ('/tmp');
@miniserv::preroots = ();
$miniserv::datestr = 'Sun, 01 Jan 2026 00:00:00 GMT';
}
# Call http_error with capture buffers reset. noexit=1 is REQUIRED — the
# real sub calls exit() otherwise. shutdown(SOCK,1)/close(SOCK) at the end
# of http_error warn because SOCK is not a real socket here; the localized
# warn handler swallows that one specific noise.
sub run_http_error {
my (%args) = @_;
@written = ();
@errlog = ();
@reqlog = ();
no warnings 'once';
local $miniserv::reqline = $args{reqline};
local $miniserv::loghost = $args{loghost};
local $miniserv::authuser = $args{authuser};
local $SIG{__WARN__} = sub { };
miniserv::http_error(
$args{code}, $args{msg}, $args{body},
1, # noexit
$args{noerr},
);
return join('', @written);
}
# minimal call: code + message, no body, no reqline
# Assert on the contract, not the cosmetics: the status code, the
# presence of required headers, and that the caller's code + message
# reach the rendered page. Specific wording, header values, decoration
# (&mdash;), and class names are presentation and may change.
{
my $out = run_http_error(code => 404, msg => 'Not Found');
like($out, qr{^HTTP/1\.0 404\b}, 'status line carries 404');
like($out, qr{\r\nServer:\s+\S}, 'Server header present and non-empty');
like($out, qr{\r\nDate:\s+\S}, 'Date header present and non-empty');
like($out, qr{\r\nContent-type:\s*text/html\b.*\butf-?8\b}i, 'Content-type is HTML with utf-8 charset');
like($out, qr{<title>[^<]*404[^<]*</title>}, 'title surfaces the code');
like($out, qr{<title>[^<]*Not Found[^<]*</title>}, 'title surfaces the message');
like($out, qr{<h2\b[^>]*>[^<]*Not Found[^<]*</h2>}, 'message appears in a heading');
unlike($out, qr{<p\b}, 'no paragraph emitted when body arg absent');
is(scalar @errlog, 1, 'log_error called once');
like($errlog[0], qr/Not Found/, 'log_error received the caller message');
is(scalar @reqlog, 0, 'log_request skipped when reqline empty');
}
# body argument renders as a paragraph
{
my $out = run_http_error(code => 500, msg => 'Server Error', body => 'something broke');
like($out, qr{<p\b[^>]*>[^<]*\Qsomething broke\E[^<]*</p>}, 'body argument rendered in a paragraph');
}
# reqline triggers log_request
{
run_http_error(
code => 403, msg => 'Forbidden', body => 'no access',
reqline => 'GET /secret HTTP/1.0',
loghost => '127.0.0.1', authuser => 'bob',
);
is(scalar @reqlog, 1, 'log_request called when reqline is set');
is($reqlog[0][0], '127.0.0.1', 'log_request host arg');
is($reqlog[0][1], 'bob', 'log_request user arg');
is($reqlog[0][2], 'GET /secret HTTP/1.0', 'log_request request arg');
is($reqlog[0][3], 403, 'log_request code arg');
}
# noerr suppresses log_error
{
run_http_error(code => 401, msg => 'Unauthorized', noerr => 1);
is(scalar @errlog, 0, 'log_error suppressed when noerr is true');
}
# error_handler config that points to a missing file falls through
# This exercises the early branch without triggering `goto rerun` (which
# only resolves inside handle_request).
{
local $miniserv::config{'error_handler'} = 'definitely-not-a-real-file.cgi';
my $out = run_http_error(code => 500, msg => 'Server Error');
like($out, qr{^HTTP/1\.0 500\b}, 'falls through to standard path when handler file missing');
}
# HTML scaffolding is balanced
{
my $out = run_http_error(code => 400, msg => 'Bad Request', body => 'oops');
for my $tag (qw(html head title body h2 p)) {
my $open = () = $out =~ /<$tag\b[^>]*>/g;
my $close = () = $out =~ /<\/$tag>/g;
is($open, $close, "<$tag> tags balanced (open=$open, close=$close)");
cmp_ok($open, '>=', 1, "<$tag> appears at least once");
}
# Sanity-check element ordering: head before body, title inside head.
like($out, qr{<html>.*<head>.*<title>.*</title>.*</head>.*<body[^>]*>.*</body>\s*</html>}s,
'top-level elements appear in the expected order');
}
done_testing();

1439
t/miniserv.t Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
#!/usr/bin/perl
# Tests for web-lib-funcs.pl filter_javascript.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..', 'web-lib-funcs.pl'));
require $script;
is(
main::filter_javascript('<video/onloadstart=alert(1) src=1>'),
'<video/xonloadstart=alert(1) src=1>',
'slash-separated HTML5 event handler is disabled',
);
is(
main::filter_javascript('<img src=x onload=alert(1)>'),
'<img src=x xonload=alert(1)>',
'classic event handler is disabled',
);
is(
main::filter_javascript('<div onwheel = alert(1) onpointerdown=alert(2)>'),
'<div xonwheel = alert(1) xonpointerdown=alert(2)>',
'multiple modern event handlers are disabled',
);
is(
main::filter_javascript(
'<a data-onload="safe" href="javascript:alert(1)">link</a>'),
'<a data-onload="safe" href="xjavascript:alert(1)">link</a>',
'non-handler attributes are preserved while script URIs are disabled',
);
done_testing();

View File

@@ -5741,6 +5741,16 @@ if ($module_name) {
$module_root_directory = &module_root_directory($module_name);
}
if (!$main::allow_rpc_only &&
$main::webmin_script_type eq 'web' &&
!$main::no_acl_check &&
!defined($ENV{'FOREIGN_MODULE_NAME'})) {
# Check if this user is RPC-only
if (&webmin_user_can_rpc() == 2) {
&error($text{'erpconly'});
}
}
if ($module_name && !$main::no_acl_check &&
(!defined($ENV{'FOREIGN_MODULE_NAME'}) ||
defined($ENV{'FOREIGN_MODULE_SEC_CHECK'})) &&
@@ -5759,16 +5769,6 @@ if ($module_name && !$main::no_acl_check &&
$main::no_acl_check++;
}
if (!$main::allow_rpc_only &&
$main::webmin_script_type eq 'web' &&
!$main::no_acl_check &&
!defined($ENV{'FOREIGN_MODULE_NAME'})) {
# Check if this user is RPC-only
if (&webmin_user_can_rpc() == 2) {
&error($text{'erpconly'});
}
}
# Check the Referer: header for nasty redirects
my @referers = split(/\s+/, $gconfig{'referers'});
my $referer_site;
@@ -10179,10 +10179,15 @@ sub filter_javascript
my ($rv, $type) = @_;
if (!$type || $type eq 'html') {
$rv =~ s/<\s*script[^>]*>([\000-\377]*?)<\s*\/script\s*>//gi;
$rv =~ s/(on(Abort|BeforeUnload|Blur|Change|Click|ContextMenu|Copy|Cut|DblClick|Drag|DragEnd|DragEnter|DragLeave|DragOver|DragStart|DragDrop|Drop|Error|Focus|FocusIn|FocusOut|HashChange|Input|Invalid|KeyDown|KeyPress|KeyUp|Load|MouseDown|MouseEnter|MouseLeave|MouseMove|MouseOut|MouseOver|MouseUp|Move|Paste|PageShow|PageHide|Reset|Resize|Scroll|Search|Select|Submit|Toggle|Unload)=)/x$1/gi;
$rv =~ s/(javascript(:|&colon;|&#58;|&#x3A;))/x$1/gi;
$rv =~ s/(vbscript(:|&colon;|&#58;|&#x3A;))/x$1/gi;
$rv =~ s/<([^>]*\s|)(on\S+=)(.*)>/<$1x$2$3>/gi;
my $event_attr = qr/on[a-z][a-z0-9_:-]*\s*=/i;
my $event_attrs;
do {
$event_attrs = 0;
$event_attrs += $rv =~ s{(<[^>]*?)([\s/]+)($event_attr)}{$1$2x$3}g;
$event_attrs += $rv =~ s{(<)($event_attr)}{$1x$2}g;
} while ($event_attrs);
}
if ($type eq 'pdf') {
$rv =~ s/([\n]*)<<[\n((?:.*?|\n)*?)][\w\s\/]+[\n((?:.*?|\n)*?)][\w\s\/]+JavaScript[\w\s\/]*[\n((?:.*?|\n)*?)][\w\s\/]+\s.*?>>[\n]*/$1/gmsi;

View File

@@ -83,7 +83,7 @@ for(my $i=0; $i<@wlinks; $i++) {
}
}
print &ui_alert_box(&filter_javascript($in{'message'}), 'success', undef, 1,
print &ui_alert_box(&html_escape($in{'message'}), 'success', undef, 1,
&html_escape($in{'title'})) if ($in{'message'});
&icons_table(\@wlinks, \@wtitles, \@wicons);

View File

@@ -1962,7 +1962,15 @@ my ($title, $msg) = @_;
if (!$gconfig{'restart_async'}) {
&restart_miniserv();
my $msg_redir = "";
$msg_redir = "?title=".&urlize($title)."&message=".&urlize($msg) if $msg;
if ($msg) {
$title = defined($title) ? &html_strip($title, " ") : "";
$msg = &html_strip($msg, " ");
$title =~ s/\s+/ /g;
$title =~ s/^\s+|\s+$//g;
$msg =~ s/\s+/ /g;
$msg =~ s/^\s+|\s+$//g;
$msg_redir = "?title=".&urlize($title)."&message=".&urlize($msg);
}
&redirect($msg_redir);
return;
}

View File

@@ -19,6 +19,8 @@ BEGIN { push(@INC, "."); };
use WebminCore;
use POSIX;
use Socket;
$main::allow_rpc_only = 1;
$force_lang = $default_lang;
$trust_unknown_referers = 2; # Only trust if referer was not set
&init_config();
@@ -317,4 +319,3 @@ $xmlerr .= "</methodResponse>\n";
return $xmlerr;
}