Add site state toggles and proxy-aware server list

https://github.com/webmin/webmin/issues/2688
This commit is contained in:
Ilia Ross
2026-05-18 22:08:31 +02:00
parent c1ba586dba
commit 251fef722d
6 changed files with 913 additions and 61 deletions

View File

@@ -6,11 +6,61 @@ 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'});
foreach my $file (@files) {
my $err = $file_action eq "toggle" ?
&server_file_enabled($file) ?
&disable_server_file($file) :
&enable_server_file($file) :
$file_action 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 +69,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 +125,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

@@ -60,21 +60,45 @@ 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_html = &html_escape($name_sort);
my $name_sort_span =
"<span style='position:absolute;left:-10000px;".
"width:1px;height:1px;overflow:hidden' ".
"aria-hidden='true'>$name_sort_html</span>";
my $shownamelink = $r->{'active'} ?
$name_sort_span."<a href='edit_server.cgi?id=".
&urlize($id)."'>".$showname."</a>" :
$name_sort_span.$showname;
# Extract all IPs and ports from listen directives
my (@ips, @ports);
@@ -86,43 +110,66 @@ 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_enabled($r->{'file'});
$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_html' ".
"data-order='$name_sort_html'" });
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.
@@ -200,8 +207,17 @@ 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 status
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 status : $1
slogs_title=Server Block Logging
slogs_header=Log file options
@@ -334,6 +350,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

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

@@ -1530,6 +1530,366 @@ 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);
}
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>");
}
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>");
}
return undef;
}
# 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

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

@@ -0,0 +1,345 @@
#!/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 'redefine';
local *main::test_config = sub { return undef; };
is(main::disable_server_file($alpha), undef, 'disable succeeds');
}
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 'redefine';
local *main::test_config = sub { return undef; };
is(main::enable_server_file($beta), undef, 'enable succeeds');
}
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 {
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 };
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');
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');
};
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');
};
done_testing();