3 min read

A client came to me with what was initially a Virtualmin backup problem:

Copying Apache aliases ..
    .. failed to find target virtual website!

The virtual host existed within /etc/httpd/conf/httpd.conf but there were errors:

# apachectl configtest
httpd: Syntax error on line 394 of /etc/httpd/conf/httpd.conf: Expected </Directory> but saw </VirtualHost>

I fixed a couple of these issues but it quickly became clear that the whole file (around 4000 lines) was corrupt. There were sections of anything from 1-20 lines missing, and the lines seemed to reappear at random later in the file.

The backups had been failing and had been rotated away so restoring wasn't an option, and Apache was still running so disabling/re-enabling the sites was risky as it could easily break Apache and, as mentioned, there were no backups.

I decided to write something to try to parse the Virtualmin configuration, which has each domain as a series of key=value parameters in numbered files under /etc/webmin/virtual-server/domains/ - some of these were just aliases, some had different versions of PHP, only some had SSL enabled.

I wrote a Perl script to generate the config so that the backups could be taken, then reloaded Apache with the new configuration. It worked for me, but I present the Perl script below as a proof of concept only. It makes a lot of assumptions, it might not work in every environment, and so I present it as broken.

This code will only generate the virtual host snippets and not the config file before that point. I copied the existing httpd.conf to httpd.conf.top, deleted everything after the first <VirtualHost>, then: perl httpd.pl > http.conf.bottom && cat httpd.conf.top httpd.conf.bottom > /etc/httpd/conf/httpd.conf.

#!/usr/bin/perl

# Copyright James Lawrie 2021
# james@silvermouse.net
#

use warnings;
use strict;

use Data::Dumper;

my @domains = glob('/etc/webmin/virtual-server/domains/*');

my @sites;
my %aliases;

foreach my $domain (@domains) {
    my %temp_config;
    open my $file, "<", $domain;
    foreach (<$file>) {
        if (/^([^=]+)=([^=]+)$/) {
            chomp ($temp_config{$1} = $2);
        }
    }
    close $file;
    if ($temp_config{"alias_mode"} eq "1") {
	if (!$aliases{$temp_config{"backup_alias_dom"}}) {
	    $aliases{$temp_config{"backup_alias_dom"}} = [];
        }
	push ($aliases{$temp_config{"backup_alias_dom"}}, $temp_config{'dom'});
    }
    else {
	push (@sites, \%temp_config);
    }
}

foreach my $site (@sites) {
    my %config = %{$site};
    print generate_config($config{'dom'}, $config{'ssl'}, %config);
    if ($config{'logrotate'}) {
	    generate_logrotate($config{'dom'});
    }
}

sub generate_config {
    my $site = shift;
    my $ssl  = shift;
    my %config = @_;
    my $port = $ssl ? $config{'web_sslport'} : $config{'web_port'};
    my $ssl_config;
    my $config = "<VirtualHost $config{'dns_ip'}:${port}";
    $config .= $config{ip6} ? " [$config{'ip6'}]:${port}>\n" : ">\n";
    if ($ssl) {
        $config .= <<"EOF";
    SSLEngine on
    SSLCertificateFile $config{'ssl_cert'}
    SSLCertificateKeyFile $config{'ssl_key'}
EOF
        $config .= "    SSLCertificateChainFile $config{'ssl_chain'}\n" if $config{'ssl_chain'};
   }
    $config .= <<"EOF";
    SuexecUserGroup "#$config{'uid'}" "#$config{'gid'}"
    ServerName $site
    ServerAlias www.${site}
    ServerAlias autoconfig.${site}
    ServerAlias autodiscover.${site}
EOF
    my $aliases = $aliases{$config{'dom'}};
    foreach my $alias (@$aliases) {
        $config .= "    ServerAlias ${alias}\n    ServerAlias www.${alias}\n" if $alias;
    }
    $config .= <<"EOF";
    DocumentRoot $config{'public_html_path'}
    ErrorLog /var/log/virtualmin/$config{'dom'}_error_log
    CustomLog /var/log/virtualmin/$config{'dom'}_access_log combined
    RemoveHandler .php
    RemoveHandler .php5
    RemoveHandler .php5.6
    RemoveHandler .php7.0
    RemoveHandler .php7.2
    RemoveHandler .php7.3
    RemoveHandler .php7.4
    php_admin_value engine Off
    DirectoryIndex index.html index.htm index.php index.php4 index.php5
    <Directory $config{'public_html_path'}>
        Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
        AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
        Require all granted
        AddType application/x-httpd-php .php
EOF
    my @fcgis = glob("$config{'home'}/fcgi-bin/*");
    my $default_php = 0;
    foreach (@fcgis) {
	if (/.*(php(.*))\.fcgi/) {
            $config .= "        FCGIWrapper $_ .${1}\n";
            $config .= "        AddHandler fcgid-script ${1}\n";
            $default_php = $2 > $default_php ? $2 : $default_php;
        }
    }
    if ($config{'last_php_version'}) { $default_php = $config{'last_php_version'}; }
    $config .= <<"EOF";
	FCGIWrapper $config{'home'}/fcgi-bin/php${default_php}.fcgi .php
        AddHandler fcgid-script .php
        Require all granted
    </Directory>
    <Directory $config{'cgi_bin_path'}>
        allow from all
        AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
        Require all granted
    </Directory>
    FcgidMaxRequestLen 1073741824
    Redirect /mail/config-v1.1.xml /cgi-bin/autoconfig.cgi
    IPCCommTimeout 61
    FcgidMaxRequestLen 1073741824
    php_value memory_limit 32M
    php_value suhosin.session.encrypt Off
    <Files awstats.pl>
        AuthName "$config{'dom'} statistics"
        AuthType Basic
        AuthUserFile $config{'home'}/.awstats-htpasswd
        require valid-user
    </Files>
</VirtualHost>
EOF
    if ($ssl) {
        $config .= generate_config($config{'dom'}, 0, %config);
    }
    else {
        return $config;
    }
}

sub generate_logrotate {
    my $domain = shift;
    my $logrot = "/etc/logrotate.d/${domain}.conf";
    my $output = <<"EOF";
/var/log/virtualmin/${domain}_access_log /var/log/virtualmin/${domain}_error_log {
	rotate 5
	weekly
	compress
	postrotate
	systemctl reload httpd.service ; sleep 5
	endscript
	sharedscripts
}
EOF
    if (!-e $logrot) {
        open my $fh, ">", $logrot;
        print $fh $output;
    }
}

James Lawrie

James Lawrie

James has over a decade of experience working for companies such as Percona, UKFast, and Bytemark. In his spare time he rides his motorbike, lifts weights, and learns Polish.