From 251fef722dbeeb450b165d76e685243979af92da Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 18 May 2026 22:08:31 +0200 Subject: [PATCH] Add site state toggles and proxy-aware server list https://github.com/webmin/webmin/issues/2688 --- nginx/delete_servers.cgi | 129 +++++++++++--- nginx/index.cgi | 101 ++++++++--- nginx/lang/en | 32 +++- nginx/log_parser.pl | 7 +- nginx/nginx-lib.pl | 360 +++++++++++++++++++++++++++++++++++++++ nginx/t/server-files.t | 345 +++++++++++++++++++++++++++++++++++++ 6 files changed, 913 insertions(+), 61 deletions(-) create mode 100644 nginx/t/server-files.t 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();