441 lines
14 KiB
Perl
Executable File
441 lines
14 KiB
Perl
Executable File
#!/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 = <STDIN>;
|
|
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} ne 'off' and $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 <<EOT;
|
|
create database $self->{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 .= "<VirtualHost *:80>\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".
|
|
"</VirtualHost>\n".
|
|
"<VirtualHost *:443>\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".
|
|
" <Directory $self->{siteroot}/public>\n".
|
|
" Options -Indexes -FollowSymlinks +SymLinksIfOwnerMatch\n".
|
|
" AllowOverride all\n".
|
|
" Require all granted\n".
|
|
" </Directory>\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 .= "</VirtualHost>\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:
|