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