From a5be2f9d39ba8025b3c690af91f42653c551d004 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 19 May 2026 23:00:18 +0200 Subject: [PATCH] Add Debian-style sites-available file management to Apache * Note: Bring the Apache module to parity with the Nginx module's Debian sites-available/sites-enabled handling: list disabled vhost files alongside active ones, toggle their state via symlink with apachectl configtest rollback, and delete VirtualHost blocks from inactive files. When Virtualmin manages a vhost, defer enable/disable to Virtualmin's own forms instead of touching the symlink directly. https://forum.virtualmin.com/t/enable-disable-toggle-buttons-in-ngnix-module/137238/4?u=ilia --- apache/apache-lib.pl | 442 ++++++++++++++++++++++++++++++++++++++- apache/delete_vservs.cgi | 84 +++++++- apache/index.cgi | 103 +++++++-- apache/lang/en | 21 ++ apache/t/vhost-files.t | 344 ++++++++++++++++++++++++++++++ 5 files changed, 964 insertions(+), 30 deletions(-) create mode 100644 apache/t/vhost-files.t diff --git a/apache/apache-lib.pl b/apache/apache-lib.pl index ab7529b2c..35812222d 100755 --- a/apache/apache-lib.pl +++ b/apache/apache-lib.pl @@ -436,6 +436,13 @@ foreach $v (@virt) { return \@get_config_cache; } +# flush_config_cache() +# Delete all in-memory config caches +sub flush_config_cache +{ +undef(@get_config_cache); +} + # get_config_file(filename, [&seen-files]) # Returns a list of config hash refs from some file sub get_config_file @@ -788,6 +795,427 @@ unlink($file); &delete_webfile_link($file); } +# can_manage_vhost_files() +# Returns 1 if this system uses Debian-style available/enabled site dirs +sub can_manage_vhost_files +{ +return 0 if ($gconfig{'os_type'} ne 'debian-linux'); +my $avail = &vhost_available_dir(); +my $enabled = &vhost_enabled_dir(); +return $avail && -d $avail && $enabled && -d $enabled && + &simplify_path(&resolve_links($avail)) ne + &simplify_path(&resolve_links($enabled)); +} + +# vhost_available_dir() +# Returns the configured directory of available Apache virtual host files +sub vhost_available_dir +{ +return $config{'virt_file'} ? &server_root($config{'virt_file'}) : undef; +} + +# vhost_enabled_dir() +# Returns the configured directory of enabled Apache virtual host symlinks +sub vhost_enabled_dir +{ +return $config{'link_dir'} ? &server_root($config{'link_dir'}) : undef; +} + +# get_vhost_available_files() +# Returns real config files from the directory used for new virtual hosts +sub get_vhost_available_files +{ +my @rv; +return @rv if (!&can_manage_vhost_files()); +my $avail = &vhost_available_dir(); +opendir(AVAIL, $avail) || return @rv; +foreach my $f (sort { lc($a) cmp lc($b) } readdir(AVAIL)) { + next if ($f eq "." || $f eq ".."); + my $file = $avail."/".$f; + my $rfile = &simplify_path(&resolve_links($file)); + next if (!$rfile || !-f $rfile || !-r $rfile); + push(@rv, $rfile); + } +closedir(AVAIL); +return &unique(@rv); +} + +# find_virtuals_in_file(file) +# Returns VirtualHost blocks parsed from one config file +sub find_virtuals_in_file +{ +my ($file) = @_; +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return ( ) if (!-r $rfile); +my @conf = &get_config_file($rfile); +return grep { $_->{'file'} eq $rfile } + &find_directive_struct("VirtualHost", \@conf); +} + +# is_default_vhost(&virt) +# Returns 1 if a VirtualHost looks like a default/catch-all host +sub is_default_vhost +{ +my ($virt) = @_; +return 1 if (!$virt); +return 1 if ($virt->{'value'} =~ /_default_/i); +return 1 if (!&find_directive("ServerName", $virt->{'members'})); +return 0; +} + +# can_manage_vhost_file(file) +# Returns 1 if all virtual hosts in a file are manageable by this user +sub can_manage_vhost_file +{ +my ($file) = @_; +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return 0 if (!$rfile || !-f $rfile || !-r $rfile); +my @virts = &find_virtuals_in_file($rfile); +return 0 if (!@virts); +foreach my $virt (@virts) { + return 0 if (&is_default_vhost($virt)); + return 0 if (!&can_edit_virt($virt)); + } +return 1; +} + +# can_manage_vhost_state_file(file) +# Returns 1 if a virtual host file can have its enabled state managed here +sub can_manage_vhost_state_file +{ +my ($file) = @_; +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return 0 if (!$rfile || !-f $rfile); +my %available = map { $_, 1 } &get_vhost_available_files(); +return 0 if (!$available{$rfile}); +return &can_manage_vhost_file($rfile); +} + +# get_virtual_list_rows(&config) +# Returns row hashes for the virtual-host list, preserving sites-available order +sub get_virtual_list_rows +{ +my ($conf) = @_; +my @active = grep { &can_edit_virt($_) } + &find_directive_struct("VirtualHost", $conf); +if (&can_manage_vhost_files()) { + my @rows; + my %active_by_file; + foreach my $v (@active) { + my $file = &simplify_path(&resolve_links($v->{'file'})); + $file ||= $v->{'file'}; + push(@{$active_by_file{$file}}, $v); + } + my %done_virt; + foreach my $file (&get_vhost_available_files()) { + my @filevirts = @{$active_by_file{$file} || [ ]}; + my $active = @filevirts ? 1 : 0; + if (!@filevirts) { + @filevirts = grep { &can_edit_virt($_) } + &find_virtuals_in_file($file); + } + foreach my $v (@filevirts) { + push(@rows, { 'virt' => $v, + 'active' => $active, + 'file' => $file }); + $done_virt{$v}++; + } + } + foreach my $v (@active) { + next if ($done_virt{$v}); + push(@rows, { 'virt' => $v, + 'active' => 1, + 'file' => $v->{'file'} }); + } + return @rows; + } +return map { { 'virt' => $_, 'active' => 1, 'file' => $_->{'file'} } } + @active; +} + +# vhost_file_link(file) +# Returns the enabled symlink path for a virtual host file +sub vhost_file_link +{ +my ($file) = @_; +return undef if (!&can_manage_vhost_files()); +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +my $avail = &vhost_available_dir(); +my $short; +if (opendir(AVAIL, $avail)) { + foreach my $f (sort { lc($a) cmp lc($b) } readdir(AVAIL)) { + next if ($f eq "." || $f eq ".."); + my $afile = $avail."/".$f; + my $rafile = &simplify_path(&resolve_links($afile)); + if ($rafile && $rafile eq $rfile) { + $short = $f; + last; + } + } + closedir(AVAIL); + } +$short ||= $rfile; +$short =~ s/^.*\///; +return &vhost_enabled_dir()."/".$short; +} + +# vhost_file_links(file) +# Returns enabled symlinks for a virtual host file +sub vhost_file_links +{ +my ($file) = @_; +my @rv; +return @rv if (!&can_manage_vhost_files()); +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +my $enabled = &vhost_enabled_dir(); +opendir(LINKDIR, $enabled) || return @rv; +foreach my $f (readdir(LINKDIR)) { + next if ($f eq "." || $f eq ".."); + my $link = $enabled."/".$f; + next if (!-l $link); + my $rlink = &simplify_path(&resolve_links($link)); + if ($rlink && $rlink eq $rfile) { + push(@rv, $link); + } + } +closedir(LINKDIR); +return @rv; +} + +# vhost_file_enabled(file) +# Returns 1 if a virtual host file has an enabled symlink +sub vhost_file_enabled +{ +my ($file) = @_; +return scalar(&vhost_file_links($file)) ? 1 : 0; +} + +# enable_vhost_file(file) +# Enables a virtual host file and rolls back if apache configtest fails +sub enable_vhost_file +{ +my ($file) = @_; +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return $text{'enable_efile'} if (!&can_manage_vhost_state_file($rfile)); +my $verr = &virtualmin_vhost_file_state_error($rfile, "enable"); +return $verr if ($verr); +my $link = &vhost_file_link($rfile); +$link || return $text{'enable_elinkdir'}; +return undef if (&vhost_file_enabled($rfile)); +if (-e $link || -l $link) { + return &text('enable_elinkexists', "".&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).""); + } +&flush_config_cache(); +&update_last_config_change(); +return undef; +} + +# disable_vhost_file(file) +# Disables a virtual host file and rolls back if apache configtest fails +sub disable_vhost_file +{ +my ($file) = @_; +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return $text{'enable_efile'} if (!&can_manage_vhost_state_file($rfile)); +my $verr = &virtualmin_vhost_file_state_error($rfile, "disable"); +return $verr if ($verr); +my @links = &vhost_file_links($file); +return undef if (!@links); +my @restore = map { [ $_, readlink($_) ] } @links; +my @removed; +foreach my $link (@links) { + if (!&unlink_logged($link)) { + foreach my $r (@removed) { + &symlink_logged($r->[1], $r->[0]) + if (defined($r->[1]) && !-e $r->[0] && !-l $r->[0]); + } + return &text('enable_eunlink', + "".&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).""); + } +&flush_config_cache(); +&update_last_config_change(); +return undef; +} + +# virtualmin_available() +# Returns 1 if Virtualmin is installed and supported on this system +sub virtualmin_available +{ +return $main::apache_virtualmin_available + if (defined($main::apache_virtualmin_available)); +$main::apache_virtualmin_available = &foreign_check("virtual-server"); +return $main::apache_virtualmin_available; +} + +# virtualmin_domain_by_name(name) +# Returns a Virtualmin domain object by domain name, if one exists +sub virtualmin_domain_by_name +{ +my ($name) = @_; +return undef if (!&virtualmin_available()); +return $main::apache_virtualmin_domain_by_name_cache{$name} + if (exists($main::apache_virtualmin_domain_by_name_cache{$name})); +&foreign_require("virtual-server"); +my $d = &virtual_server::get_domain_by("dom", $name); +$main::apache_virtualmin_domain_by_name_cache{$name} = $d; +return $d; +} + +# virtual_names(&virt) +# Returns all hostnames from ServerName and ServerAlias directives +sub virtual_names +{ +my ($virt) = @_; +my @rv; +my $sn = &find_directive("ServerName", $virt->{'members'}); +push(@rv, $sn) if ($sn); +foreach my $sa (&find_directive_struct("ServerAlias", $virt->{'members'})) { + push(@rv, @{$sa->{'words'} || [ ]}); + if (!@{$sa->{'words'} || [ ]} && $sa->{'value'}) { + push(@rv, $sa->{'value'}); + } + } +return grep { $_ && $_ ne "*" } &unique(@rv); +} + +# virtualmin_domain_for_vhost_file(file) +# Returns the Virtualmin domain object for a virtual host file, if any +sub virtualmin_domain_for_vhost_file +{ +my ($file) = @_; +return undef if (!&virtualmin_available()); +my $rfile = &simplify_path(&resolve_links($file)); +$rfile ||= $file; +return $main::apache_virtualmin_domain_for_file_cache{$rfile} + if (exists($main::apache_virtualmin_domain_for_file_cache{$rfile})); +foreach my $virt (&find_virtuals_in_file($file)) { + next if (!&can_edit_virt($virt)); + foreach my $name (&virtual_names($virt)) { + my $d = &virtualmin_domain_by_name($name); + if (!$d && $name =~ /^www\.(\S+)/i) { + $d = &virtualmin_domain_by_name($1); + } + if ($d) { + $main::apache_virtualmin_domain_for_file_cache{$rfile} = $d; + return $d; + } + } + } +$main::apache_virtualmin_domain_for_file_cache{$rfile} = undef; +return undef; +} + +# vhost_file_state(file) +# Returns the effective enabled state for a virtual host file +sub vhost_file_state +{ +my ($file) = @_; +my $d = &virtualmin_domain_for_vhost_file($file); +if ($d) { + return { 'enabled' => $d->{'disabled'} ? 0 : 1, + 'source' => 'virtualmin', + 'domain' => $d }; + } +return { 'enabled' => &vhost_file_enabled($file) ? 1 : 0, + 'source' => 'apache' }; +} + +# vhost_file_toggle_action(file) +# Returns the action needed to toggle a virtual host file's effective state +sub vhost_file_toggle_action +{ +my ($file) = @_; +return &vhost_file_state($file)->{'enabled'} ? "disable" : "enable"; +} + +# virtualmin_domain_state_link(&domain, enabled?) +# Returns a link to the Virtualmin state change form for some domain +sub virtualmin_domain_state_link +{ +my ($d, $enabled) = @_; +my $page = $enabled ? "disable_domain.cgi" : "enable_domain.cgi"; +my $label = $enabled ? $text{'enable_virtualmin_disable_label'} : + $text{'enable_virtualmin_enable_label'}; +my $url = "../virtual-server/".$page."?dom=".&urlize($d->{'id'}); +return &ui_link("e_escape($url), "\"".$label."\""); +} + +# virtualmin_vhost_file_state_error(file, action) +# Returns an error if a Virtualmin-owned site is being enabled or disabled here +sub virtualmin_vhost_file_state_error +{ +my ($file, $action) = @_; +return undef if ($action ne "enable" && $action ne "disable"); +my $state_info = &vhost_file_state($file); +return undef if ($state_info->{'source'} ne "virtualmin"); +my $d = $state_info->{'domain'}; +return undef if (!$d); +my $state = lc($state_info->{'enabled'} ? $text{'index_enabled'} : + $text{'index_disabled'}); +my $dom = "".&html_escape($d->{'dom'}).""; +my $link = &virtualmin_domain_state_link($d, $state_info->{'enabled'}); +return $state_info->{'enabled'} ? + &text('enable_evirtualmin_disable', $dom, $state, $link) : + &text('enable_evirtualmin_enable', $dom, $state, $link); +} + +# delete_virtuals_from_file(file, &virtualhosts...) +# Deletes VirtualHost blocks from one file and removes the file if empty +sub delete_virtuals_from_file +{ +my ($file, @virts) = @_; +return 0 if (!@virts); +my $lref = &read_file_lines($file); +foreach my $virt (sort { $b->{'line'} <=> $a->{'line'} } @virts) { + my $len = $virt->{'eline'} - $virt->{'line'} + 1; + splice(@$lref, $virt->{'line'}, $len); + } +my $empty = 1; +foreach my $line (@$lref) { + if ($line =~ /\S/) { + $empty = 0; + last; + } + } +&flush_file_lines($file); +if ($empty) { + foreach my $link (&vhost_file_links($file)) { + &unlink_logged($link); + } + &unlink_logged($file); + } +&flush_config_cache(); +&update_last_config_change(); +return scalar(@virts); +} + # renumber(&config, line, file, offset) # Recursively changes the line number of all directives from some file # beyond the given line. @@ -1985,16 +2413,16 @@ if ($config{'link_dir'}) { sub delete_webfile_link { local ($file) = @_; +$file = &simplify_path(&resolve_links($file)); if ($config{'link_dir'}) { - local $short = $file; - $short =~ s/^.*\///; opendir(LINKDIR, $config{'link_dir'}); foreach my $f (readdir(LINKDIR)) { - if ($f ne "." && $f ne ".." && - (&simplify_path( - &resolve_links($config{'link_dir'}."/".$f)) eq $file || - $short eq $f)) { - &unlink_logged($config{'link_dir'}."/".$f); + if ($f ne "." && $f ne "..") { + my $link = $config{'link_dir'}."/".$f; + next if (!-l $link); + if (&simplify_path(&resolve_links($link)) eq $file) { + &unlink_logged($link); + } } } closedir(LINKDIR); diff --git a/apache/delete_vservs.cgi b/apache/delete_vservs.cgi index c5901f6d5..19a035b78 100755 --- a/apache/delete_vservs.cgi +++ b/apache/delete_vservs.cgi @@ -3,19 +3,90 @@ require './apache-lib.pl'; &ReadParse(); -&error_setup($text{'delete_err'}); +@d = split(/\0/, $in{'d'}); +$file_action = $in{'toggle'} ? "toggle" : undef; +&error_setup($file_action ? $text{'enable_err'} : $text{'delete_err'}); $access{'vaddr'} || &error($text{'delete_ecannot'}); $conf = &get_config(); -@d = split(/\0/, $in{'d'}); +$can_vhost_files = &can_manage_vhost_files(); @d || &error($text{'delete_enone'}); +if ($file_action) { + &can_manage_vhost_files() || &error($text{'enable_elinkdir'}); + foreach $d (@d) { + if ($d =~ /^file\t([^\t]+)/) { + $file = $1; + } + elsif ($d !~ /^file\t/) { + ($vmembers, $vconf) = &get_virtual_config($d); + next if (!$vconf || !&can_edit_virt($vconf)); + $file = $vconf->{'file'}; + } + else { + next; + } + $rfile = $file ? &simplify_path(&resolve_links($file)) : undef; + $files{$rfile}++ if ($rfile && -f $rfile && + &can_manage_vhost_state_file($rfile)); + } + @files = keys %files; + @files || &error($text{'enable_enone'}); + foreach $file (@files) { + $action = &vhost_file_toggle_action($file); + $err = &virtualmin_vhost_file_state_error($file, $action); + $err && &error($err); + $file_actions{$file} = $action; + } + foreach $file (@files) { + $err = $file_actions{$file} eq "enable" ? + &enable_vhost_file($file) : + &disable_vhost_file($file); + $err && &error($err); + } + &webmin_log($file_action, "vhostfile", scalar(@files)); + &redirect(""); + exit; + } +if (!$in{'delete'}) { + &error($text{'delete_eaction'}); + } + # Get them all foreach $d (@d) { + if ($d =~ /^file\t([^\t]+)\t(\d+)$/) { + push(@{$file_lines{$1}}, $2); + next; + } + elsif ($d =~ /^file\t/) { + next; + } ($vmembers, $vconf) = &get_virtual_config($d); + $vconf || &error($text{'delete_egone'}); &can_edit_virt($vconf) || &error(&text('delete_ecannot2', &virtual_name($vconf))); + $can_vhost_files && &is_default_vhost($vconf) && + &error($text{'delete_edefault'}); push(@virts, $vconf); } +if (%file_lines) { + foreach $file (keys %file_lines) { + $rfile = &simplify_path(&resolve_links($file)); + next if (!$rfile || !-f $rfile || + !&can_manage_vhost_state_file($rfile)); + @fvirts = &find_virtuals_in_file($rfile); + foreach $line (@{$file_lines{$file}}) { + ($vconf) = grep { $_->{'line'} == $line } @fvirts; + $vconf || &error($text{'delete_egone'}); + &can_edit_virt($vconf) || + &error(&text('delete_ecannot2', + &virtual_name($vconf))); + &is_default_vhost($vconf) && + &error($text{'delete_edefault'}); + push(@{$file_virts{$rfile}}, $vconf); + } + } + } +@virts || %file_virts || &error($text{'delete_enone'}); # Delete their structures &before_changing(); @@ -28,6 +99,11 @@ foreach $vconf (@virts) { &unlock_all_files(); &update_last_config_change(); &after_changing(); -&webmin_log("virts", "delete", scalar(@virts)); +foreach $file (keys %file_virts) { + &lock_file($file); + $deleted += &delete_virtuals_from_file($file, @{$file_virts{$file}}); + &unlock_file($file); + } +$deleted += scalar(@virts); +&webmin_log("virts", "delete", $deleted); &redirect(""); - diff --git a/apache/index.cgi b/apache/index.cgi index e5c896e89..f0ccc7efe 100755 --- a/apache/index.cgi +++ b/apache/index.cgi @@ -102,6 +102,10 @@ if (&can_edit_virt()) { push(@vproxy, undef); $sn ||= &get_system_hostname(); push(@vurl, $defport ? "http://$sn:$defport/" : "http://$sn/"); + push(@vfile, undef); + push(@vstatus, ""); + push(@vsel, undef); + push(@vfilemanage, 0); $showing_default++; } @@ -128,16 +132,23 @@ elsif ($httpd_modules{'core'} >= 1.2) { $ba = &find_directive("ServerName", $conf); $nv{&to_ipaddress($ba ? $ba : &get_system_hostname())}++; } -@virt = grep { &can_edit_virt($_) } @virt; +$can_vhost_files = &can_manage_vhost_files(); +@vrows = &get_virtual_list_rows($conf); if ($config{'show_order'} == 1) { # sort by server name - @virt = sort { &server_name_sort($a) cmp &server_name_sort($b) } @virt; + @vrows = sort { &server_name_sort($a->{'virt'}) cmp + &server_name_sort($b->{'virt'}) } @vrows; } elsif ($config{'show_order'} == 2) { # sort by IP address - @virt = sort { &server_ip_sort($a) cmp &server_ip_sort($b) } @virt; + @vrows = sort { &server_ip_sort($a->{'virt'}) cmp + &server_ip_sort($b->{'virt'}) } @vrows; } -foreach $v (@virt) { +@virt = map { $_->{'virt'} } grep { $_->{'active'} } @vrows; +%available_vhost_file = map { $_, 1 } &get_vhost_available_files() + if ($can_vhost_files); +foreach $r (@vrows) { + $v = $r->{'virt'}; $vm = $v->{'members'}; if ($v->{'words'}->[0] =~ /^\[(\S+)\]:(\d+)$/) { # IPv6 address and port @@ -163,7 +174,7 @@ foreach $v (@virt) { $idx = &indexof($v, @$conf); push(@vidx, $idx); push(@vname, $text{'index_virt'}); - push(@vlink, "virt_index.cgi?virt=$idx"); + push(@vlink, $r->{'active'} ? "virt_index.cgi?virt=$idx" : undef); $sname = &find_directive("ServerName", $vm); local $daddr = $addr eq "_default_" || ($addr eq "*" && $httpd_modules{'core'} < 1.2); @@ -225,10 +236,34 @@ foreach $v (@virt) { } $sp = undef if ($sp == 80 && $prot eq "http" || $sp == 443 && $prot eq "https"); - push(@vurl, $sp ? "$prot://$sn:$sp/" : "$prot://$sn/"); + push(@vurl, $r->{'active'} ? + ($sp ? "$prot://$sn:$sp/" : "$prot://$sn/") : undef); + local $rfile = $r->{'file'} ? &simplify_path(&resolve_links($r->{'file'})) + : undef; + push(@vfile, $rfile); + local $status = ""; + if ($can_vhost_files && $rfile && $available_vhost_file{$rfile}) { + local $enabled = &vhost_file_state($rfile)->{'enabled'}; + $status = $enabled ? $text{'index_enabled'} : + $text{'index_disabled'}; + } + push(@vstatus, $status); + local $file_manage = $can_vhost_files && $rfile && + $available_vhost_file{$rfile} && + &can_manage_vhost_state_file($rfile); + push(@vfilemanage, $file_manage ? 1 : 0); + local $sel; + if ($r->{'active'} && (!$can_vhost_files || !&is_default_vhost($v))) { + $sel = $idx; + } + elsif (!$r->{'active'} && $can_vhost_files && $rfile && + $available_vhost_file{$rfile} && $file_manage) { + $sel = "file\t".$rfile."\t".$v->{'line'}; + } + push(@vsel, $sel); } -if (@vlink == 1 && !$access{'global'} && $access{'virts'} ne "*" && +if (@vlink == 1 && $vlink[0] && !$access{'global'} && $access{'virts'} ne "*" && !$access{'create'} && $access{'noconfig'}) { # Can only manage one vhost, so go direct to it &redirect($vlink[0]); @@ -297,7 +332,9 @@ if ($access{'global'}) { # work out select links print &ui_tabs_start_tab("mode", "list"); #print $text{'index_desclist'},"

\n"; -$showdel = $access{'vaddr'} && ($vidx[0] || $vidx[1]); +$showdel = $access{'vaddr'} && + grep { defined($_) && $_ ne "" } @vsel; +$showtoggle = $can_vhost_files && grep { $_ } @vfilemanage; @links = ( ); if ($showdel) { push(@links, &select_all_link("d"), @@ -326,8 +363,10 @@ if ($config{'max_servers'} && @vname > $config{'max_servers'}) { } elsif ($config{'show_list'} && scalar(@vname)) { # as list for people with lots of servers + $list_form = "vhosts_form"; if ($showdel) { - print &ui_form_start("delete_vservs.cgi", "post"); + print &ui_form_start("delete_vservs.cgi", "post", undef, + "id='$list_form'"); } print &ui_links_row(\@links); print &ui_columns_start([ @@ -337,19 +376,23 @@ elsif ($config{'show_list'} && scalar(@vname)) { $text{'index_port'}, $text{'index_name'}, $text{'index_root'}, + $can_vhost_files ? ( $text{'index_status'} ) : ( ), $text{'index_url'} ], 100); for($i=0; $i<@vname; $i++) { local @cols; - push(@cols, &ui_link($vlink[$i], $vname[$i]) ); + push(@cols, $vlink[$i] ? &ui_link($vlink[$i], $vname[$i]) : + $vname[$i] ); push(@cols, &html_escape($vaddr[$i])); push(@cols, &html_escape($vport[$i])); push(@cols, $vserv[$i] || $text{'index_auto'}); push(@cols, &html_escape($vproxy[$i]) || &html_escape($vroot[$i])); - push(@cols, &ui_link($vurl[$i], $text{'index_view'}) ); - if ($showdel && $vidx[$i]) { + push(@cols, $vstatus[$i]) if ($can_vhost_files); + push(@cols, $vurl[$i] ? &ui_link($vurl[$i], $text{'index_view'}) : + "" ); + if ($showdel && defined($vsel[$i]) && $vsel[$i] ne "") { print &ui_checked_columns_row(\@cols, undef, - "d", $vidx[$i]); + "d", $vsel[$i]); } elsif ($showdel) { print &ui_columns_row([ "", @cols ]); @@ -361,13 +404,23 @@ elsif ($config{'show_list'} && scalar(@vname)) { print &ui_columns_end(); print &ui_links_row(\@links); if ($showdel) { - print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]); + if ($showtoggle) { + print &ui_form_end_side_by_side($list_form, + [ [ "delete", $text{'index_delete'} ] ], + [ [ "toggle", $text{'index_toggle'}, undef, + undef, "form=\"$list_form\"" ] ]); + } + else { + print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]); + } } } else { # as icons for niceness + $list_form = "vhosts_form"; if ($showdel) { - print &ui_form_start("delete_vservs.cgi", "post"); + print &ui_form_start("delete_vservs.cgi", "post", undef, + "id='$list_form'"); } print &ui_links_row(\@links); print "\n"; @@ -376,8 +429,9 @@ else { print '
'; &generate_icon("images/virt.gif", $vname[$i], $vlink[$i], undef, undef, undef, - $vidx[$i] && $access{'vaddr'} ? - &ui_checkbox("d", $vidx[$i]) : ""); + defined($vsel[$i]) && $vsel[$i] ne "" && + $access{'vaddr'} ? + &ui_checkbox("d", $vsel[$i]) : ""); print "
\n"; print "\n"; } + if ($can_vhost_files && $vstatus[$i]) { + print "\n"; + } print "
\n"; print "$vdesc[$i]
\n"; @@ -397,12 +451,24 @@ else { print "$text{'index_root'} ", &html_escape($vroot[$i]),"
$text{'index_status'} ", + $vstatus[$i],"
\n"; } print "\n"; print &ui_links_row(\@links); if ($showdel) { - print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]); + if ($showtoggle) { + print &ui_form_end_side_by_side($list_form, + [ [ "delete", $text{'index_delete'} ] ], + [ [ "toggle", $text{'index_toggle'}, undef, + undef, "form=\"$list_form\"" ] ]); + } + else { + print &ui_form_end([ [ "delete", $text{'index_delete'} ] ]); + } } } print &ui_tabs_end_tab(); @@ -492,4 +558,3 @@ return $addr eq '_default_' || $addr eq '*' ? undef : $addr =~ /^\[(\S+)\]$/ && &check_ip6address($1) ? $1 : &to_ipaddress($addr); } - diff --git a/apache/lang/en b/apache/lang/en index 466f83f5b..011c32837 100644 --- a/apache/lang/en +++ b/apache/lang/en @@ -34,6 +34,9 @@ index_listen=Listen on address (if needed) index_port=Port index_name=Server Name index_root=Document Root +index_status=State +index_enabled=Enabled +index_disabled=Disabled index_url=URL index_view=Open.. index_adddir=Allow access to this directory @@ -57,6 +60,7 @@ index_fmode1=Virtual servers file $1 index_fmode1d=New file under virtual servers directory $1 index_fmode2=Selected file.. index_delete=Delete Selected Servers +index_toggle=Toggle State cvirt_ecannot=You are not allowed to create a virtual server cvirt_err=Failed to create virtual server @@ -1032,6 +1036,7 @@ log_stop=Stopped webserver log_apply=Applied changes log_manual=Manually edited configuration file $1 log_virts_delete=Deleted $1 virtual servers +log_toggle_vhostfile=Toggled state of $1 virtual host files search_title=Find Servers search_notfound=No matching virtual servers found @@ -1148,6 +1153,22 @@ delete_err=Failed to delete virtual servers delete_enone=None selected delete_ecannot=You are not allowed to delete servers delete_ecannot2=You are not allowed to edit the server $1 +delete_eaction=No action was selected +delete_egone=The selected virtual server no longer exists +delete_edefault=The default virtual server cannot be deleted + +enable_err=Failed to change virtual host file state +enable_enone=No manageable virtual host files were selected +enable_efile=Virtual host file does not exist or cannot be managed +enable_elinkdir=No enabled virtual host links directory is configured +enable_elink=Failed to create symbolic link $1 : $2 +enable_eunlink=Failed to remove symbolic link $1 : $2 +enable_elinkexists=The symbolic link $1 already exists +enable_etest=Apache configuration test failed after changing the virtual host file state : $1 +enable_evirtualmin_disable=This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site disabling should be done in Virtualmin using $3. +enable_evirtualmin_enable=This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site enabling should be done in Virtualmin using $3. +enable_virtualmin_disable_label=Disable and Delete ⇾ Disable Virtual Server +enable_virtualmin_enable_label=Disable and Delete ⇾ Enable Virtual Server syslog_desc=Apache error log diff --git a/apache/t/vhost-files.t b/apache/t/vhost-files.t new file mode 100644 index 000000000..5d5c854e5 --- /dev/null +++ b/apache/t/vhost-files.t @@ -0,0 +1,344 @@ +#!/usr/bin/perl +# Tests for Debian-style Apache sites-available/sites-enabled handling. + +use strict; +use warnings; +use Test::More; +use File::Basename qw(dirname); +use File::Path qw(make_path); +use File::Spec; +use File::Temp qw(tempdir); +use Cwd qw(abs_path); + +my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..', '..')); +my $tmp = abs_path(tempdir(CLEANUP => 1)); +my $webmin_config = File::Spec->catdir($tmp, 'webmin-config'); +my $webmin_var = File::Spec->catdir($tmp, 'webmin-var'); +my $apache_root = File::Spec->catdir($tmp, 'apache2'); +my $available = File::Spec->catdir($apache_root, 'sites-available'); +my $enabled = File::Spec->catdir($apache_root, 'sites-enabled'); +my $apache_conf = File::Spec->catfile($apache_root, 'apache2.conf'); + +make_path($webmin_config, $webmin_var, "$webmin_config/apache", + "$webmin_var/apache", $apache_root, $available, $enabled); + +sub write_text +{ +my ($file, $text) = @_; +open(my $fh, '>', $file) || die "Failed to write $file: $!"; +print $fh $text; +close($fh) || die "Failed to close $file: $!"; +} + +sub vhost_conf +{ +my ($name, $rootdir) = @_; +my $name_line = defined($name) ? " ServerName $name\n" : ""; +return "\n". + $name_line. + " DocumentRoot $rootdir\n". + "\n"; +} + +my $default = File::Spec->catfile($available, '000-default.conf'); +my $alpha = File::Spec->catfile($available, 'alpha.conf'); +my $beta = File::Spec->catfile($available, 'beta.conf'); +my $charlie = File::Spec->catfile($available, 'charlie.conf'); + +write_text($default, vhost_conf(undef, '/srv/default')); +write_text($alpha, vhost_conf('alpha.example', '/srv/alpha')); +write_text($beta, vhost_conf('beta.example', '/srv/beta')); +write_text($charlie, vhost_conf('charlie.example', '/srv/charlie')); +write_text($apache_conf, + "ServerRoot \"$apache_root\"\n". + "Listen 80\n". + "IncludeOptional $enabled/*.conf\n"); + +symlink($default, File::Spec->catfile($enabled, '000-default.conf')) || + die "Failed to symlink default: $!"; +symlink($alpha, File::Spec->catfile($enabled, 'alpha.conf')) || + die "Failed to symlink alpha: $!"; +symlink($charlie, File::Spec->catfile($enabled, 'charlie.conf')) || + die "Failed to symlink charlie: $!"; + +write_text(File::Spec->catfile($webmin_config, 'config'), + "os_type=debian-linux\n". + "os_version=12\n". + "real_os_type=Debian Linux\n". + "real_os_version=12\n"); +write_text(File::Spec->catfile($webmin_config, 'miniserv.conf'), + "root=$root\n"); +write_text(File::Spec->catfile($webmin_config, 'apache', 'config'), + "httpd_dir=$apache_root\n". + "httpd_path=/bin/true\n". + "httpd_conf=$apache_conf\n". + "apachectl_path=/bin/true\n". + "httpd_version=2.4.57\n". + "test_apachectl=0\n". + "test_config=1\n". + "virt_file=$available\n". + "link_dir=$enabled\n"); + +$ENV{'WEBMIN_CONFIG'} = $webmin_config; +$ENV{'WEBMIN_VAR'} = $webmin_var; +$ENV{'FOREIGN_MODULE_NAME'} = 'apache'; +$ENV{'FOREIGN_ROOT_DIRECTORY'} = $root; +$ENV{'REMOTE_USER'} = 'root'; + +unshift(@INC, $root); +require File::Spec->catfile($root, 'apache', 'apache-lib.pl'); + +{ + no warnings 'once'; + $main::text{'enable_elinkdir'} = 'No enabled virtual host links directory is configured'; + $main::text{'enable_efile'} = 'Virtual host file does not exist or cannot be managed'; + $main::text{'enable_elink'} = 'Failed to create symbolic link $1 : $2'; + $main::text{'enable_eunlink'} = 'Failed to remove symbolic link $1 : $2'; + $main::text{'enable_elinkexists'} = 'The symbolic link $1 already exists'; + $main::text{'enable_etest'} = 'Apache configuration test failed after changing the virtual host file state : $1'; + $main::text{'enable_evirtualmin_disable'} = 'This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site disabling should be done in Virtualmin using $3.'; + $main::text{'enable_evirtualmin_enable'} = 'This Apache virtual host is managed by Virtualmin virtual server $1, which is currently $2. Site enabling should be done in Virtualmin using $3.'; + $main::text{'enable_virtualmin_disable_label'} = 'Disable and Delete ⇾ Disable Virtual Server'; + $main::text{'enable_virtualmin_enable_label'} = 'Disable and Delete ⇾ Enable Virtual Server'; + $main::text{'index_enabled'} = 'Enabled'; + $main::text{'index_disabled'} = 'Disabled'; +} + +sub apache_config +{ +main::flush_config_cache(); +my $conf = main::get_config(); +ok($conf, 'test apache config can be parsed'); +return $conf; +} + +sub row_names +{ +return [ map { + scalar(main::find_directive('ServerName', $_->{'virt'}->{'members'})) || '' + } @_ ]; +} + +sub row_states +{ +return [ map { $_->{'active'} ? 'enabled' : 'disabled' } @_ ]; +} + +subtest 'sites-available files are manageable and ordered' => sub { + ok(main::can_manage_vhost_files(), + 'sites-available/enabled dirs are manageable'); + is_deeply( + [ main::get_vhost_available_files() ], + [ $default, $alpha, $beta, $charlie ], + 'available files are listed in stable filename order', + ); + + my @rows = main::get_virtual_list_rows(apache_config()); + is_deeply(row_names(@rows), + [ '', 'alpha.example', 'beta.example', 'charlie.example' ], + 'disabled rows stay in sites-available order'); + is_deeply(row_states(@rows), + [ 'enabled', 'enabled', 'disabled', 'enabled' ], + 'row active state follows sites-enabled symlinks'); + ok(!main::can_manage_vhost_file($default), + 'default virtual host file is not file-state manageable'); +}; + +subtest 'disable removes only the enabled symlink' => sub { + no warnings 'once'; + unlink($main::last_config_change_flag); + unlink($main::last_restart_time_flag); + main::restart_last_restart_time(); + my $old = time() - 10; + utime($old, $old, $main::last_restart_time_flag); + + { + no warnings 'redefine'; + local *main::test_config = sub { return undef; }; + is(main::disable_vhost_file($alpha), undef, 'disable succeeds'); + } + ok(main::needs_config_restart(), + 'disable marks config as needing apply'); + + ok(-f $alpha, 'disable leaves the sites-available file in place'); + ok(!-e File::Spec->catfile($enabled, 'alpha.conf'), + 'disable removes the sites-enabled symlink'); + + my @rows = main::get_virtual_list_rows(apache_config()); + is_deeply(row_names(@rows), + [ '', 'alpha.example', 'beta.example', 'charlie.example' ], + 'disabled row remains in the same list position'); + is_deeply(row_states(@rows), + [ 'enabled', 'disabled', 'disabled', 'enabled' ], + 'disabled row status is updated'); +}; + +subtest 'enable creates a symlink without touching the source file' => sub { + no warnings 'once'; + unlink($main::last_config_change_flag); + unlink($main::last_restart_time_flag); + main::restart_last_restart_time(); + my $old = time() - 10; + utime($old, $old, $main::last_restart_time_flag); + + { + no warnings 'redefine'; + local *main::test_config = sub { return undef; }; + is(main::enable_vhost_file($beta), undef, 'enable succeeds'); + } + ok(main::needs_config_restart(), + 'enable marks config as needing apply'); + + my $link = File::Spec->catfile($enabled, 'beta.conf'); + ok(-f $beta, 'enable leaves the sites-available file in place'); + ok(-l $link, 'enable creates the sites-enabled symlink'); + is(readlink($link), $beta, 'enabled symlink points to the available file'); + ok(main::vhost_file_enabled($beta), 'vhost_file_enabled sees the symlink'); +}; + +subtest 'same-name symlink to another target is not disabled' => sub { + my $otherdir = File::Spec->catdir($tmp, 'other-sites'); + my $other = File::Spec->catfile($otherdir, 'charlie.conf'); + my $link = File::Spec->catfile($enabled, 'charlie.conf'); + make_path($otherdir); + write_text($other, vhost_conf('other.example', '/srv/other')); + unlink($link) || die "Failed to remove charlie link: $!"; + symlink($other, $link) || die "Failed to symlink other charlie: $!"; + + ok(!main::vhost_file_enabled($charlie), + 'same-name symlink to another file is not considered enabled'); + { + no warnings 'redefine'; + local *main::test_config = sub { return undef; }; + is(main::disable_vhost_file($charlie), undef, 'disable is a no-op'); + } + ok(-l $link, 'same-name symlink to another target is preserved'); + is(readlink($link), $other, 'preserved symlink target is unchanged'); +}; + +subtest 'file-level actions require access to every virtual host in the file' => sub { + my $mixed = File::Spec->catfile($available, 'mixed.conf'); + write_text($mixed, + vhost_conf('alpha.example', '/srv/mixed-alpha'). + vhost_conf('hidden.example', '/srv/mixed-hidden')); + + { + no warnings 'once'; + local $main::access{'virts'} = 'alpha.example:80'; + ok(!main::can_manage_vhost_file($mixed), + 'mixed-access file cannot be managed by a restricted user'); + } + ok(main::can_manage_vhost_file($mixed), + 'shared file can be managed when all contained vhosts are allowed'); +}; + +subtest 'state helpers enforce allowed files and ACLs directly' => sub { + my $outside = File::Spec->catfile($tmp, 'outside.conf'); + write_text($outside, vhost_conf('outside.example', '/srv/outside')); + is(main::enable_vhost_file($outside), + 'Virtual host file does not exist or cannot be managed', + 'enable rejects files outside sites-available'); + + my $mixed = File::Spec->catfile($available, 'state-mixed.conf'); + write_text($mixed, + vhost_conf('alpha.example', '/srv/state-alpha'). + vhost_conf('hidden.example', '/srv/state-hidden')); + { + no warnings 'once'; + local $main::access{'virts'} = 'alpha.example:80'; + is(main::enable_vhost_file($mixed), + 'Virtual host file does not exist or cannot be managed', + 'enable rejects mixed-access files without relying on caller validation'); + } +}; + +subtest 'apache configtest failure rolls back link changes' => sub { + my $delta = File::Spec->catfile($available, 'delta.conf'); + my $delta_link = File::Spec->catfile($enabled, 'delta.conf'); + write_text($delta, vhost_conf('delta.example', '/srv/delta')); + + { + no warnings 'redefine'; + local *main::test_config = sub { return 'bad config'; }; + like(main::enable_vhost_file($delta), qr/bad config/, + 'failed enable reports apache configtest output'); + } + ok(!-e $delta_link, 'failed enable removes the new symlink'); + + symlink($delta, $delta_link) || die "Failed to symlink delta: $!"; + { + no warnings 'redefine'; + local *main::test_config = sub { return 'bad config'; }; + like(main::disable_vhost_file($delta), qr/bad config/, + 'failed disable reports apache configtest output'); + } + ok(-l $delta_link, 'failed disable restores the removed symlink'); + is(readlink($delta_link), $delta, 'restored symlink target is unchanged'); +}; + +subtest 'Virtualmin-managed virtual host files cannot be toggled directly' => sub { + my $enabled_domain = File::Spec->catfile($available, 'vm-enabled.conf'); + my $disabled_domain = File::Spec->catfile($available, 'vm-disabled.conf'); + write_text($enabled_domain, + vhost_conf('www.vm-enabled.example', '/srv/vm-enabled')); + write_text($disabled_domain, + vhost_conf('vm-disabled.example', '/srv/vm-disabled')); + + { + no warnings qw(redefine once); + local %main::apache_virtualmin_domain_for_file_cache; + local %main::apache_virtualmin_domain_by_name_cache; + local *main::virtualmin_available = sub { return 1; }; + local *main::virtualmin_domain_by_name = sub { + my ($name) = @_; + return $name eq 'vm-enabled.example' ? + { 'dom' => $name, 'id' => '12345', + 'disabled' => '' } : + $name eq 'vm-disabled.example' ? + { 'dom' => $name, 'id' => '67890', + 'disabled' => 'web' } : + undef; + }; + + my $disable_err = + main::virtualmin_vhost_file_state_error($enabled_domain, + 'disable'); + my $enabled_state = main::vhost_file_state($enabled_domain); + is($enabled_state->{'source'}, 'virtualmin', + 'Virtualmin is the effective state source for managed files'); + ok($enabled_state->{'enabled'}, + 'Virtualmin enabled domain is reported as enabled'); + is(main::vhost_file_toggle_action($enabled_domain), 'disable', + 'toggle action follows the Virtualmin enabled state'); + like($disable_err, qr/currently enabled/, + 'Virtualmin state is included for enabled domains'); + like($disable_err, qr/Disable Virtual Server/, + 'disabling directs users to Virtualmin disable action'); + like($disable_err, + qr{virtual-server/disable_domain\.cgi\?dom=12345}, + 'disabling links to the Virtualmin disable form'); + + my $enable_err = + main::virtualmin_vhost_file_state_error($disabled_domain, + 'enable'); + my $disabled_state = main::vhost_file_state($disabled_domain); + is($disabled_state->{'source'}, 'virtualmin', + 'Virtualmin remains the state source for disabled domains'); + ok(!$disabled_state->{'enabled'}, + 'Virtualmin disabled domain is reported as disabled'); + is(main::vhost_file_toggle_action($disabled_domain), 'enable', + 'toggle action follows the Virtualmin disabled state'); + like($enable_err, qr/currently disabled/, + 'Virtualmin state is included for disabled domains'); + like($enable_err, qr/Enable Virtual Server/, + 'enabling directs users to Virtualmin enable action'); + like($enable_err, + qr{virtual-server/enable_domain\.cgi\?dom=67890}, + 'enabling links to the Virtualmin enable form'); + + is(main::virtualmin_vhost_file_state_error($alpha, 'disable'), + undef, 'non-Virtualmin virtual host files can still be toggled'); + } +}; + +done_testing();