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
##