#!/usr/bin/perl use strict; use warnings; use utf8; use Getopt::Long; use File::Slurp; use JSON; use FindBin; my $self; sub sys { my $cmd = shift; my %opts = @_; $opts{die} = 1 if !defined $opts{die}; #print "++ $cmd\n"; system $cmd; if ($? == -1) { die "-- $cmd\nfailed to execute: $!\n"; } elsif ($? & 127) { my $signal = $? & 127; die "-- $cmd\nchild died with signal $signal\n"; } my $ex = $? >> 8; if ($ex > 0 and $opts{die}) { print STDERR "-- $cmd\nexit code $ex\n"; print STDERR "Continue? [y/N]\n"; my $ans = ; exit 1 if $ans !~ /^y/i; } return $ex; } sub check_exists { my $error = 0; for my $x (qw(siteroot apachecf)) { if (-e $self->{$x}) { print STDERR "$x already exists: $self->{$x}\n"; $error = 1; } } my $id_ex = sys "id $self->{user} >/dev/null 2>&1", die => 0; if ($id_ex == 0) { print STDERR "User '$self->{user}' already exists.\n"; $error = 1; } exit 1 if $error; } sub check_dns { my $ipa = qx(ip a); my $badname = ''; for my $d (@{$self->{domains}}) { my $host = qx(host $d $self->{resolver}); my $found = 0; while ($host =~ /has.*address (\S+)/g) { my $addr = $1; $found = 1; print "Resolving $d -> $addr"; if ($ipa !~ /\Q$addr\E/) { print " NOT ON THIS SERVER\n"; $badname .= " $d"; } else { print "\n"; } } if (!$found) { print "Resolving $d -> NOT FOUND\n"; $badname .= " $d"; } } if ($badname ne '') { die "These domain names do not resolv to an address on this server:\n". " $badname\n"; } } sub generate_random { for my $x (qw(dbpasswd ftppasswd adminurl)) { my $passwd = qx(pwgen 12 1); chomp $passwd; if (length($passwd) != 12) { die "pwgen generated invalid password: $passwd\n"; } $self->{$x} = $passwd; print "Generating random: $x = $passwd\n"; } } sub filesystem { sys "mkdir $self->{siteroot}"; if ($self->{quota} > 0) { my $kb = $self->{quota} * 1024; sys "setquota $self->{user} $kb $kb 0 0 /www"; } } sub user { sys "adduser --disabled-password --home $self->{siteroot} --no-create-home --firstuid 10000 --gecos '' --shell /bin/false $self->{user}"; } sub siteroot_setup { my @dirs = qw(public tmp); if ($self->{use_site_log}) { push @dirs, 'log'; } for my $d (@dirs) { my $path = "$self->{siteroot}/$d"; next if -d $path; sys "mkdir $path"; } my $indexfn = "$self->{siteroot}/public/index.html"; if (!-e $indexfn) { write_file($indexfn, "$self->{domains}->[0] site temporarily unavailable.\n"); } my $robotsfn = "$self->{siteroot}/public/robots.txt"; if (!-e $robotsfn) { write_file($robotsfn, "User-agent: *\nDisallow: /\n"); } if ($self->{use_owner}) { write_file("$self->{siteroot}/.INFO", sprintf("%s\nowner=%s\n", scalar localtime time, $self->{site_owner})); } sys "www-reset-acl $self->{site}"; } sub mysql { if (-e "/var/lib/mysql/$self->{dbname}") { print "Database already exists, skipping: $self->{dbname}\n"; $self->{dbpasswd} = '-- previous database password --'; return; } if (!open MYSQL, '|-', "mysql -u root -p$self->{dbrootpw}") { die "failed to run mysql\n"; } print MYSQL <{dbname} charset = utf8mb4; grant all privileges on $self->{dbname}.* to $self->{dbuser}\@localhost identified by '$self->{dbpasswd}'; flush privileges; EOT if (!close MYSQL) { if ($!) { die "error closing mysql pipe: $!\n"; } else { die "mysql exit code: $?\n"; } } } sub letsencrypt { my $domlist = read_file($self->{letsencrypt_dom}); my $first = $self->{domains}->[0]; if ($domlist =~ /^\Q$first\E\s+(.*)$/m) { my $san = $1; my $san_notfound = ''; for (my $i = 1; $i < scalar @{$self->{domains}}; $i++) { my $d = $self->{domains}->[$i]; if ($san !~ /\b\Q$d\E\b/) { $san_notfound .= " $d"; } } if ($san_notfound ne '') { die "found domain in letsencrypt domain list:\n". "$first $san\n\n". "missing SAN domains:$san_notfound\n". "I can't handle this.\n"; } print "Letsencrypt certificates already in domains.txt.\n"; } else { append_file($self->{letsencrypt_dom}, join(' ', @{$self->{domains}}) ."\n" ); sys "/etc/dehydrated/dehydrated -c"; } } sub apache { my $lt = scalar localtime time; my $logbase = "\${APACHE_LOG_DIR}/$self->{site}"; if ($self->{use_site_log}) { $logbase = "$self->{siteroot}/log/apache"; } my $c = "# generated: $lt\n"; if ($self->{use_owner}) { $c.="# owner: $self->{site_owner}\n"; } $c .= "\n". " ServerName $self->{domains}->[0]\n"; for (my $i = 1; $i < scalar @{$self->{domains}}; $i++) { $c.=" ServerAlias $self->{domains}->[$i]\n\n"; } $c .= " ErrorLog $logbase.notls.err.log\n". " CustomLog $logbase.notls.log detailed\n". " RewriteEngine On\n". " RewriteRule ^/(.*) https://%{HTTP_HOST}/\$1 [R,L]\n". "\n". "\n"; if ($self->{itk_assignuser}) { $c.=" AssignUserId $self->{user} $self->{group}\n\n"; } $c .= " SSLEngine on\n". " SSLCertificateFile $self->{tlscrt}\n". " SSLCertificateKeyFile $self->{tlskey}\n"; if ($self->{use_ocsp_stapling}) { $c.=" SSLUseStapling on\n". " SSLStaplingReturnResponderErrors off\n". " SSLStaplingFakeTryLater off\n". " SSLStaplingStandardCacheTimeout 86400\n". " SSLStaplingResponderTimeout 2\n"; } $c .= " ServerName $self->{domains}->[0]\n"; for (my $i = 1; $i < scalar @{$self->{domains}}; $i++) { $c.=" ServerAlias $self->{domains}->[$i]\n\n"; } $c .= " ErrorLog $logbase.err.log\n". " CustomLog $logbase.log detailed\n". " DocumentRoot $self->{siteroot}/public\n". " {siteroot}/public>\n". " Options -Indexes -FollowSymlinks +SymLinksIfOwnerMatch\n". " AllowOverride all\n". " Require all granted\n". " \n". " Header always set Strict-Transport-Security \"max-age=31536000;\"\n"; if ($self->{use_admin}) { $c.=" Alias /.$self->{adminurl}/ /var/www/_admin/\n"; } if ($self->{php_fpm} and $self->{php_fpm} =~ /^\d/) { $c.=" #-[[-www-phpfpm\n". " #-]]-www-phpfpm\n"; } $c .= "\n". "# vim: set tabstop=4 shiftwidth=4 expandtab smarttab:\n"; write_file($self->{apachecf}, $c); sys "apachectl graceful"; } sub proftpd { return if !$self->{use_ftpd}; my $uid = qx(id -u $self->{user}); chomp $uid; if ($uid < 10000 or $uid > 11000) { die "got invalid uid for user '$self->{user}': $uid\n"; } my $gid = qx(id -g $self->{user}); chomp $gid; sys "echo $self->{ftppasswd} | ftpasswd --stdin --passwd --file /etc/proftpd/ftpd.passwd --name $self->{ftpuser} --home $self->{siteroot} --shell /bin/false --uid $uid --gid $gid"; } sub php_fpm { if ($self->{php_fpm} and $self->{php_fpm} =~ /^\d/) { sys "www-phpfpm -s $self->{site} -p $self->{php_fpm}"; } } sub find_defcert { for my $d (read_file('/etc/dehydrated/domains.txt')) { chomp $d; if ($d =~ /^[^#]/) { return $d; } } return qx{hostname -f}; } sub read_config { my ($fn) = @_; my $contents; eval { $contents = read_file($fn); }; return if $@; my $cf; eval { my $json = JSON->new->utf8->relaxed; $cf = $json->decode("{ $contents }"); }; if ($@) { print STDERR "warning: configuration $fn has JSON errors: $@\n"; return; } for my $k (keys %$cf) { $self->{$k} = $cf->{$k}; } } $self = { config_files => [ "$FindBin::RealBin/config.dist.json", "$FindBin::RealBin/config.local.json"], use_site_log => 0, use_owner => 0, use_admin => 0, use_ftpd => 0, site => '', domains => [], dbpasswd => '', dbrootpw_file => undef, dbrootpw => undef, letsencrypt_dom => "/etc/dehydrated/domains.txt", resolver => '8.8.8.8', defcert => 0, defcert_name => find_defcert, no_dnscheck => 0, itk_assignuser => 0, use_ocsp_stapling => 1, php_fpm => 'off', quota => 'off', logfile => '/var/log/www-create-site.log', }; sub usage { my $t = "usage: $0 OPTIONS..\n"; if ($self->{use_owner}) { $t.=" -o|--owner NAME new website's owner (mandatory)\n"; } $t .= " -s|--site NAME new website's short name ([a-z][a-z0-9]*)\n". " -d|--domain 'D [D..]' list of domain names for website\n". " --mysqlrootpw PASSWD mysql root password (default: read from file)\n". " --defcert skip letsencrypt, use server's cert: $self->{defcert_name}\n". " --no-dns-check skip dns check (with implicit --defcert)\n". " --[no]ocsp-stapling set SSLUseStapling on (default: ". ($self->{use_ocsp_stapling} ? 'yes' : 'no') .")\n". " --[no]itk-assignuser use mpm-itk AssignUserId (default: ". ($self->{itk_assignuser} ? 'yes' : 'no') .")\n". " --php-fpm {off|VER} create PHP FPM configuration (default: $self->{php_fpm})\n". " --[no]adminurl create site specific admin URL (default: ". ($self->{use_admin} ? 'yes' : 'no') .")\n". " --[no]ftpd create proftpd user (default: ". ($self->{use_ftpd} ? 'yes' : 'no') .")\n". " --quota {off|SIZE} quota size in MB (default: $self->{quota})\n"; return $t; } for my $cfn (@{$self->{config_files}}) { read_config($cfn); } GetOptions( "owner|o=s" => \$self->{site_owner}, "site|s=s" => \$self->{site}, "domain|d=s@" => \$self->{domains}, "mysqlrootpw=s" => \$self->{dbrootpw}, "defcert" => \$self->{defcert}, "no-dns-check" => \$self->{no_dnscheck}, "itk-assignuser" => \$self->{itk_assignuser}, "no-itk-assignuser" => sub { $self->{itk_assignuser} = 0; }, "ocsp-stapling" => \$self->{use_ocsp_stapling}, "no-ocsp-stapling" => sub { $self->{use_ocsp_stapling} = 0; }, "php-fpm=s" => \$self->{php_fpm}, "adminurl" => \$self->{use_admin}, "noadminurl" => sub { $self->{use_admin} = 0; }, "ftpd" => \$self->{use_ftpd}, "noftpd" => sub { $self->{use_ftpd} = 0; }, "quota=s" => \$self->{quota}, 'help|h' => sub { print usage(); exit 0 } ) or die usage(); if (!$self->{site}) { die "no site name given\n". usage(); } if ($self->{use_owner} and !$self->{site_owner}) { die "no site owner given\n"; } if ($self->{site} !~ /^[a-z][a-z0-9]*$/) { die "site contains invalid characters: $self->{site}\n"; } my $domain_text = join(' ', @{$self->{domains}}); $domain_text =~ s/(^\s*|\s*$)//g; $self->{domains} = [split /\s+/, $domain_text]; if (scalar @{$self->{domains}} < 1) { die "no domain name given\n"; } $self->{siteroot} = "/www/$self->{site}"; $self->{apachecf} = "/etc/apache2/sites-enabled/$self->{site}.conf"; $self->{user} = "www-$self->{site}"; $self->{group} = $self->{user}; $self->{dbname} = $self->{site}; $self->{dbuser} = $self->{site}; $self->{ftpuser} = $self->{site}; # $self->{tlscrt} = "/etc/dehydrated/certs/$self->{domains}->[0]/fullchain.pem"; $self->{tlskey} = "/etc/dehydrated/certs/$self->{domains}->[0]/privkey.pem"; if ($self->{defcert}) { $self->{tlscrt} = "/etc/dehydrated/certs/$self->{defcert_name}/fullchain.pem"; $self->{tlskey} = "/etc/dehydrated/certs/$self->{defcert_name}/privkey.pem"; } if (!defined $self->{dbrootpw}) { if (!defined $self->{dbrootpw_file}) { die "no mysql root password, or password file given\n"; } $self->{dbrootpw} = read_file($self->{dbrootpw_file}) =~ s/\s//gr; } check_exists; if (!$self->{no_dnscheck}) { check_dns; } generate_random; user; filesystem; siteroot_setup; mysql; if (!$self->{defcert} and !$self->{no_dnscheck}) { letsencrypt; } apache; proftpd; php_fpm; my $domains = join(' ', @{$self->{domains}}); my $sitedata = "Siteroot: $self->{siteroot}\n". "Domain: $domains\n". "MySQL db: $self->{dbname}\n". "MySQL user: $self->{dbuser}\n". "MySQL passwd: $self->{dbpasswd}\n"; if ($self->{use_admin}) { $sitedata .= "MySQL Adminer: https://$self->{domains}->[0]/.$self->{adminurl}/adminer.php\n"; } if ($self->{use_ftpd}) { $sitedata .= "FTP: $self->{domains}->[0] (port 21, plain + TLS)\n". "FTP user: $self->{ftpuser}\n". "FTP passwd: $self->{ftppasswd}\n"; } print "\n$sitedata"; umask(0066); append_file($self->{logfile}, "\n", scalar localtime time, "\n", $sitedata); # vim: set tabstop=4 shiftwidth=4 expandtab smarttab: