diff --git a/nginx/delete_servers.cgi b/nginx/delete_servers.cgi
index fed1a6ef7..d9f06dc4c 100755
--- a/nginx/delete_servers.cgi
+++ b/nginx/delete_servers.cgi
@@ -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', "$config{'nginx_config'}"));
- }
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', "$config{'nginx_config'}"));
}
- 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("");
diff --git a/nginx/index.cgi b/nginx/index.cgi
index 58960c305..f24587be3 100755
--- a/nginx/index.cgi
+++ b/nginx/index.cgi
@@ -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 =
+ "$name_sort_html";
+ my $shownamelink = $r->{'active'} ?
+ $name_sort_span."".$showname."" :
+ $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 ||
- "$text{'index_noroot'}";
+ 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 = "$text{'index_norootloc'}";
- }
- $rootdir ||= "";
}
- my $id = $name.";".$rootdir;
- my @cols = (
- "".
- $showname."",
+ push(@cols, { 'type' => 'string',
+ 'value' => $shownamelink,
+ 'td' => "data-sort='$name_sort_html' ".
+ "data-order='$name_sort_html'" });
+ push(@cols,
join("
", @ips),
join("
", @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("e_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);
diff --git a/nginx/lang/en b/nginx/lang/en
index 3e18b44ed..35acbb821 100644
--- a/nginx/lang/en
+++ b/nginx/lang/en
@@ -2,15 +2,22 @@ index_version=Nginx version $1
index_econfig=The Nginx configuration file $1 was not found on your system. Use the module configuration page to enter the correct path.
index_ecmd=The Nginx command $1 was not found on your system. Use the module configuration page to enter the correct path.
index_ehttp=No http 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
diff --git a/nginx/log_parser.pl b/nginx/log_parser.pl
index f78fb98fd..d628a6def 100755
--- a/nginx/log_parser.pl
+++ b/nginx/log_parser.pl
@@ -26,6 +26,12 @@ elsif ($type eq 'server') {
return &text('log_'.$action.'_server',
"".&html_escape($object)."");
}
+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',
"".&html_escape($object)."",
@@ -41,4 +47,3 @@ else {
}
return undef;
}
-
diff --git a/nginx/nginx-lib.pl b/nginx/nginx-lib.pl
index 6b751e3c8..88f01eb36 100644
--- a/nginx/nginx-lib.pl
+++ b/nginx/nginx-lib.pl
@@ -1530,6 +1530,366 @@ my $out = &backquote_logged("$config{'nginx_cmd'} -t 2>&1 {'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', "".&html_escape($link)."");
+ }
+&symlink_logged($rfile, $link) ||
+ return &text('enable_elink', "".&html_escape($link)."",
+ "".&html_escape($!)."");
+my $err = &test_config();
+if ($err) {
+ &unlink_logged($link);
+ return &text('enable_etest', "".&html_escape($err)."");
+ }
+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',
+ "".&html_escape($link)."",
+ "".&html_escape($!)."");
+ }
+ 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', "".&html_escape($err)."");
+ }
+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) :
+ "$text{'index_noroot'}";
+}
+
+# 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 "$text{'index_noproxy'}" if (!@pairs);
+return join("
", map {
+ &html_escape($_->[0])." ⇾ ".&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', "".&html_escape($pp)."")
+ 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
diff --git a/nginx/t/server-files.t b/nginx/t/server-files.t
new file mode 100644
index 000000000..892b83cb1
--- /dev/null
+++ b/nginx/t/server-files.t
@@ -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), 'No proxy target',
+ '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), 'No root directory',
+ 'proxy-only server root column shows a missing-root message');
+ like(main::server_proxy_summary($beta_server),
+ qr{/ ⇾ 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), 'No root directory',
+ 'non-root-location proxy root column shows a missing-root message');
+ like(main::server_proxy_summary($path_proxy_server),
+ qr{/webmin ⇾ 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 ⇾ 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();