HEX
Server: Apache/2.4.41 (FreeBSD) OpenSSL/1.0.2s mod_fcgid/2.3.9
System: FreeBSD salazo 12.0-RELEASE-p1303-ZFS hostBSD 12.0-RELEASE-p1303-ZFS DMR amd64
User: admin (1000)
PHP: 7.4.3
Disabled: NONE
Upload Files
File: /usr/sbin/install_apache_cert.pl
#!/usr/iports/bin/perl

$< = $>;

# keep path
delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};

use Data::Dumper;

# straight forward apache cert / letsencrypt installer

# use a shell that knows about STDERR
$ENV{'SHELL'} = '/bin/sh';


##
#  START parse args
##

if(!@ARGV){

	error('missing arguments');
	usage();

}

my $vhost_name, $ip, $port, $use_letsencrypt, $renew_letsencrypt, $cert_file, $key_file, $chain_file;

my $given_arg;
foreach $given_arg(@ARGV){

	$given_arg = untaint($given_arg);

	# port
	if($given_arg =~ m/^:?([0-9]+)$/){

		# two ports
		if(defined($port)){

			error("two ports ($port / $1)");
			usage();

		}

		$port = $1;
	
	}

	# ip + port
	elsif($given_arg =~ m/^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}):?([0-9]+)?$/){

		# two ips
		if(defined($ip)){

			error("two ips($ip / $1)");
			usage();

		}

		$ip = $1;

		if(defined($2)){

			# two ports
			if(defined($port)){

				error("two ports ($port / $1)");
				usage();

			}

			$port = $2;

		}
	
	}

	# letsencrypt
	elsif($given_arg =~ m/^letsencrypt$/i){

		$use_letsencrypt = 1;

	}

	# renew letsencrypt
	elsif($given_arg =~ m/^renew-letsencrypt$/i){

		$port = 443;
		$use_letsencrypt = 1;
		$renew_letsencrypt = 1;

	}

	# cert_file, key_file or chain_file
	elsif(-e $given_arg){

		# cert
		my $result = `/usr/iports/bin/openssl x509 -noout -text -in $given_arg 2>/dev/null | /usr/bin/grep -c 'Subject Alternative Name' 2>/dev/null`;
		chomp $result;
		if($result eq '1'){

			# two cert files
			if(defined($cert_file)){

				error("two cert files ($cert_file / $given_arg)");
				usage();

			}

			$cert_file = $given_arg;

		}

		# key or chain
		else {

			# key
			$result = `/usr/iports/bin/openssl rsa -noout -check -in $given_arg 2>/dev/null | /usr/bin/grep -c 'RSA key ok' 2>/dev/null`;
			chomp $result;
			if($result eq '1'){
	
				# two key files
				if(defined($key_file)){

					error("two key files ($key_file / $given_arg)");
					usage();

				}

				$key_file = $given_arg;

			}	
			
			# chain
			else {

				$result = `/usr/iports/bin/openssl x509 -noout -text -in $given_arg 2>/dev/null | /usr/bin/grep -c 'Certificate:' 2>/dev/null`;
				chomp $result;
				if($result eq '1'){

					# two chain files
					if(defined($chain_file)){

						error("two chain files ($chain_file / $given_arg)");
						usage();

					}

					$chain_file = $given_arg;

				}

				# unknown
				else {

					error("unknown file ($given_arg)");
					usage();

				}

			}

		}
			
	}		

	# vhost name
	elsif($given_arg =~ m/^[0-9a-z\.-]+$/i){

		# two vhost names
		if(defined($vhost_name)){

			error("two vhostnames given ($vhost_name / $given_arg)");
			usage();

		}

		$vhost_name = $given_arg;

	}

	# unknown
	else {

		error("unknown argument ($given_arg)");
		usage();	

	}

}

if(
	!defined($vhost_name) &&
	!defined($ip) &&
	!defined($port)
){
	
	error('missing argument vhostname ip port');
	usage();

}
elsif(
	!defined($use_letsencrypt) &&
	!defined($cert_file) &&
	!defined($key_file) &&
	!defined($chain_file)
){

	error('missing argument');
	usage();

}
elsif(
	(
		defined($use_letsencrypt) ||
		defined($renew_letsencrypt)
	) && (
		defined($cert_file) ||
		defined($key_file) ||
		defined($chain_file)
	) 
){

	error('bad arguments');
	usage();

}

##
 # END parse args
##

# shortcircuit letsencrypt renew
if($renew_letsencrypt){
	my $result = `/usr/iports/bin/letsencrypt renew 2>&1`;
	if($result =~ m/\(failure\)/m){
		error($result);
		exit 1;	
	}
	
	exit 0;
}

##
#  START parse apache config
##

my $httpd = '/usr/iports/bin/sudo /usr/iports/sbin/httpd';
if(!-e '/etc/sudoers'){
		
	# apache httpd from running processes
	my $httpd=`/bin/ps -eo command 2>/dev/null | /usr/bin/grep -m1 [h]ttpd 2>/dev/null`;
	chomp $httpd;

	if(!defined($httpd) || $httpd eq ''){

		$httpd = 'httpd';

	} else {

		$httpd = untaint($httpd);
	
	}
}

PARSE_APACHE_CONFIG:
my $vhost_dump = `$httpd -t -D DUMP_VHOSTS 2>/dev/null`;

my ($line, $current_ip, $current_port, $current_vhost_name);
my %vhost_config = {};
my @vhosts_to_change = ();

foreach $line (split(/[\r\n]+/,$vhost_dump)){

	# ignore
	if($line =~ m/(VirtualHost configuration:|default server|alias)/){
		
		next;

	}

	# ip + port
	elsif($line =~ m/^\s*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}):([0-9]+)(.*)$/){

		$current_ip = $1;
		$current_port = $2;

		# parse rest of line
		$line = $3;

	}

	# vhost name + file
	if($line =~ m/\s([^\s]+)\s+\(([^\(]+):([0-9]+)\)\s*$/){
		
		# check ip + port
		if(
			!defined($current_ip) ||
			!defined($current_port)
		){
			
			error("failed to parse apache config at $line");
			exit 1;

		}			
		
		$current_vhost_name = $1;
		
		$vhost_config{$current_ip}{$current_port}{$current_vhost_name}{vhost_name} = $current_vhost_name;
		$vhost_config{$current_ip}{$current_port}{$current_vhost_name}{ip} = $current_ip;
		$vhost_config{$current_ip}{$current_port}{$current_vhost_name}{port} = $current_port;
		$vhost_config{$current_ip}{$current_port}{$current_vhost_name}{file} = $2;
		$vhost_config{$current_ip}{$current_port}{$current_vhost_name}{line} = $3;
			
		# check match
		if(
			(!defined($vhost_name) || $current_vhost_name eq $vhost_name) &&
			(!defined($ip) || $current_ip eq $ip) &&
			(!defined($port) || $current_port eq $port)
		){

			push @vhosts_to_change, $vhost_config{$current_ip}{$current_port}{$current_vhost_name};
			
		}

	}

}

if(!@vhosts_to_change){

	# check missing www.
	if(
		defined($vhost_name) && 
		$vhost_name !~ m/^www\./
	){
		$vhost_name = 'www.' . $vhost_name;
		goto PARSE_APACHE_CONFIG;

	}

	else {
		error("httpd: #$httpd#");
		error("dump: #$vhost_dump#");
		error('no matching vhost found in apache config');
		exit 1;

	}

}
elsif(@vhosts_to_change != 1){

	# check port 80
	if(!defined($port)){

		$port = '80';
		goto PARSE_APACHE_CONFIG;

	}

	else {

		if(!$renew_letsencrypt){

			error('multiple matching vhosts found in apache config');
			exit 1;

		}

		my $vhost;
		foreach $vhost (@vhosts_to_change){

			$vhost = $vhost->{vhost_name};
			$vhost =~ s/^www\.//;
			my $result = `/usr/iports/bin/letsencrypt -q renew --cert-name $vhost 2>&1`;
			if($result =~ m/\(failure\)/m){
				error($result);
				exit 1;
			}
		}

		exit 0;

	}

}

my ($vhost) = (@vhosts_to_change);
my $ssl_vhost_exists = 0;

if(exists($vhost_config{$vhost->{ip}}{443}{$vhost->{vhost_name}})){

	$ssl_vhost_exists = 1;
	$vhost = $vhost_config{$vhost->{ip}}{443}{$vhost->{vhost_name}};

}

##
 # END parse apache config
##

##
#  START patch apache config
##

my $config = $vhost->{file};

my $lines_total = `/usr/bin/wc -l $config 2>/dev/null | /usr/bin/sed 's/^ *// ; s/ .*//' 2>/dev/null`;
chomp $lines_total;
$lines_total = untaint($lines_total);

my $lines_before = $vhost->{line} - 1;

# part before vhost
my $config_before = $config . '.before';
`/usr/bin/head -n $lines_before $config > $config_before 2>/dev/null`;

# part with and after vhost
my $lines_after = $lines_total - $vhost->{line} + 1;
my $config_after = $config . '.after';
`/usr/bin/tail -n $lines_after $config > $config_after 2>/dev/null`;

# part with vhost
my $lines_vhost = `/usr/bin/egrep -m1 -n '^\s*<\/VirtualHost\s*>' $config_after 2>/dev/null | /usr/bin/sed 's/[^0-9].*\$//' 2>/dev/null`;
chomp $lines_vhost;
$lines_vhost = untaint($lines_vhost);

my $config_vhost = $config . '.vhost';
`/usr/bin/head -n $lines_vhost $config_after > $config_vhost 2>/dev/null`;

##
#  START letsencrypt
##

if(defined($use_letsencrypt)){

	# create letsencrypt cert
	my $email = `/usr/bin/egrep '^ *ServerAdmin' $config_vhost 2>/dev/null | /usr/bin/sed 's/^.*ServerAdmin //' 2>/dev/null`;
	chomp($email);
	$email =~ s/^[\s\'\"]*//;
	$email =~ s/[\s\'\"]*$//;

	my $webroot = `/usr/bin/egrep '^ *DocumentRoot' $config_vhost 2>/dev/null | /usr/bin/sed 's/^.*DocumentRoot //' 2>/dev/null`;
	chomp($webroot);
	$webroot =~ s/^[\s\'\"]*//;
	$webroot =~ s/[\s\'\"]*$//;

	my $domains = `/usr/bin/egrep '^ *ServerAlias' $config_vhost 2>/dev/null | /usr/bin/sed 's/^.*ServerAlias //' 2>/dev/null`;
	chomp($domains);
	$domains =~ s/^[\s\'\"]*//;
	$domains =~ s/[\s\'\"]*$//;
	$domains .= ' ' . $vhost_name;
	my @domains_unique = split(/\s+/,$domains);
	@domains_unique = sort keys %{{ map { join('.',reverse split(/\./,$_)) => 1 } @domains_unique }};
	foreach (@domains_unique){
		$_ = join('.',reverse split(/\./,$_));
		# ignore unresolveable www.sub.domain.tld		
		if(m/^www\.[^\.]+\.[^\.]+\./ && `/usr/bin/host $_ 2>/dev/null` =~ m/NXDOMAIN/){
			$_ = undef;
		}
	}
	$domains = join(',',grep { defined } @domains_unique);

	my $result = '';

	# certbot >= 0.32.0 needed for dry-run
	# https://community.letsencrypt.org/t/problem-with-renew-certificates-the-request-message-was-malformed-method-not-allowed/107889/6
	
	my $version = `/usr/iports/bin/letsencrypt --version 2>&1`;
	chomp $version;
	($version) = $version =~ m/0\.([0-9]+).*/;
	if(defined($version) && $version >= 32){ 
		$result = `/usr/iports/bin/letsencrypt certonly --dry-run --agree-tos --renew-by-default --non-interactive --webroot --email='certs\@hostnet.de' -w $webroot --domains $domains 2>&1`;
		if($result !~ m/The dry run was successful/m){
			$result =~ s/.*IMPORTANT\sNOTES:[^\w]*//s;
			error($result);
			exit 1;
		}
	}

	$result = `/usr/iports/bin/letsencrypt certonly --agree-tos --renew-by-default --non-interactive --webroot --email=$email -w $webroot --domains $domains 2>&1`;
	if($result !~ m/Congratulations/m){
		$result =~ s/.*IMPORTANT\sNOTES:[^\w]*//s;
		error($result);
		exit 1;
	}

	my ($cert_path) = $result =~ m/^.*have been saved at[^\/]+([a-zA-z0-9\.\-\/\r\n]+)/s;
        $cert_path =~ s/\/[^\/]*$//;
        $cert_path =~ s/[\r\n]+//gs;

	if(
		! defined($cert_path) ||
		$cert_path =~ m/^\s*$/ ||
		! -d $cert_path
	){
	 	error("failed to read cert_path from letsencrypt output \"$result\"");
		exit 1;
	}

	$cert_file = $cert_path . '/cert.pem';
	$key_file = $cert_path . '/privkey.pem';
	$chain_file = $cert_path . '/chain.pem';

	if(
		! -s $cert_file ||
		! -s $key_file ||
		! -s $chain_file
	){
	 	error("failed to read $cert_file $key_file $chain_file");
		exit 1;
	}

	my $cronjob = "$0 RENEW-LETSENCRYPT >/dev/null 2>&1 ; /usr/sbin/restart_apache >/dev/null 2>&1";
	$result = `/usr/bin/egrep -c "^[^#]*$cronjob" /etc/crontab 2>/dev/null`;
	chomp $result;
	if($result eq '0'){

		my $minute = int(rand(60));
		my $hour = int(rand(24));
		`echo -e "$minute\t$hour\t*\t*\t*\troot\t$cronjob" >> /etc/crontab 2>/dev/null`;

		$result = `/usr/bin/egrep -c "^[^#]*$cronjob" /etc/crontab 2>/dev/null`;
		chomp $result;
		if($result eq '0'){
		 	error("failed to add cronjob $cronjob to /etc/crontab");
			exit 1;
		}

	};

}		

##
 # END letsencrypt
##

# part after vhost
$lines_after -= $lines_vhost;
`/usr/bin/tail -n $lines_after $config > $config_after 2>/dev/null`;

# part with ssl vhost
my $config_ssl_vhost = $config_vhost . '.ssl';

# create
if(!$ssl_vhost_exists){

	`echo '<IfModule ssl_module>' > $config_ssl_vhost 2>/dev/null`;
	`/usr/bin/sed '\$d ; s/$vhost->{ip}:$vhost->{port}/$vhost->{ip}:443/' $config_vhost >> $config_ssl_vhost 2>/dev/null`;
	`echo -n 'SSLEngine on\nSSLCertificateFile $cert_file\nSSLCertificateKeyFile $key_file\nSSLCertificateChainFile $chain_file\n' >> $config_ssl_vhost 2>/dev/null`;
	`echo 'Header always set Strict-Transport-Security "max-age=15768000"' >> $config_ssl_vhost 2>/dev/null`;
	`echo -n '<Files ~ "\.(cgi|shtml|pl|php)\$">\n\tSSLOptions +StdEnvVars\n</Files>\n' >> $config_ssl_vhost 2>/dev/null`;
	`echo -n '</VirtualHost>\n</IfModule>\n\n' >> $config_ssl_vhost 2>/dev/null`;

} 

# change
else {

	`/usr/bin/sed 's#^ *SSLCertificateFile .*\$#SSLCertificateFile $cert_file# ; s#^ *SSLCertificateKeyFile .*\$#SSLCertificateKeyFile $key_file# ; s#^ *SSLCertificateChainFile .*\$#SSLCertificateChainFile $chain_file#' $config_vhost > $config_ssl_vhost 2>/dev/null`;
	`/bin/cat /dev/null > $config_vhost 2>/dev/null`;

}

# new config
my $config_new = $config . '.new';
`/bin/cat $config_before $config_ssl_vhost $config_vhost $config_after > $config_new 2>/dev/null`;
`/bin/rm $config_before $config_ssl_vhost $config_vhost $config_after 2>/dev/null`;

my $result = `$httpd -t -f $config_new 2>&1 | /usr/bin/grep -c 'Syntax OK' 2>/dev/null`;
chomp $result;
if($result ne '1'){

	error("apache config check failed for $config_new");
	exit 1;

};

# backup $ activate
my ($sec,$min,$hour,$mday,$mon,$year,undef) = localtime();
my $timestamp=sprintf("%04d%02d%02d%02d%02d%02d",$year+1900,$mon,$mday,$hour,$min,$sec);
my $config_bak = $config . '.' . $timestamp;
`/bin/cat $config > $config_bak 2>/dev/null`;
`/bin/cat $config_new > $config 2>/dev/null`;
`/bin/rm $config_new 2>/dev/null`;

my $httpd_pidfile=`$httpd -t -D DUMP_RUN_CFG 2>/dev/null | /usr/bin/grep PidFile | /usr/bin/sed 's/^[^\"]*\"// ; s/\"\$//' 2>/dev/null`;
chomp $httpd_pidfile;
$httpd_pidfile = untaint($httpd_pidfile);

if(-s $httpd_pidfile){
	my $httpd_pid = `/bin/cat $httpd_pidfile 2>/dev/null`;
	chomp $httpd_pid;
	$httpd_pid = untaint($httpd_pid);
	
	`kill -USR1 $httpd_pid 2>/dev/null`;
	$result = "ok\n";

	if(defined($use_letsencrypt)){

		$result .= "letsencrypt cert created\n";

	}

	if(!$ssl_vhost_exists){

		$result .= "vhost created in: $config\n";

	} else {
		
		$result .= "vhost modified in: $config\n";

	}

	$result .= "backup: $config_bak\n";

	print "$result";
	exit 0;

}

error('failed to restart apache');
exit 1;


##
 # END patch apache config
##

##
#  START subs
##

sub error {

	my ($error) = @_;

	if(!defined($error)){

		$error = 'no error message given';

	}

	print STDERR "$error\n";

}

sub usage {

	error("usage: $0 vhostname [ip] [port] LETSENCRYPT | crt_file key_file chain_file / $0 RENEW-LETSENCRYPT");
	exit 1;

}

sub untaint {
	
	my ($tainted) = @_;
	my ($untainted) = ($tainted =~ m/^(.*)$/);
	return $untainted;
	
}

##
 # END subs
##