-
Notifications
You must be signed in to change notification settings - Fork 0
ctindall/nopanel
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
_________ NOPANEL _________ Table of Contents _________________ 1 NoPanel: multitenant hosting without the bloat .. 1.1 Sites ..... 1.1.1 Creating Sites ..... 1.1.2 Deleting Sites ..... 1.1.3 Backing Up Sites ..... 1.1.4 Backup It All Up ..... 1.1.5 Listing Sites ..... 1.1.6 Managing Certificates .. 1.2 Installation 1 NoPanel: multitenant hosting without the bloat ================================================ Sometimes a shared webserver makes sense, but you still want to save your sanity. NoPanel is a solution to this need. It provides a minimal framework for organizing many Apache docroots on the same server, separating user accounts from one another, and performing other basic tasks like backups and site setup. More important is what NoPanel *doesn't* do: - It does not provide a web user interface for administration. - It does not require a database for administration. - It eschews magic files and other ersatz-databases wherever applicable. - It doesn't use a 4,000 line Perl script when 8 lines of bash will do. I use this simple setup on my own servers, and thought it would be nice to share it with the community. 1.1 Sites ~~~~~~~~~ In NoPanel, a "site" is a single domain, a single Apache config file, and a single docroot. There is no yaml, json, or other configuration file, so in order to keep everything straight, we need to rely on some conventions. 1.1.1 Creating Sites -------------------- Each site in NoPanel requires a "name", which will be used as the name of the Linux system user (and group) that this site uses. Since you might be typing this quite often, take a second and think about what username you would like to use. While NoPanel technically doesn't care, it will be easier for you, a human, if you come up with some ration scheme. Personally, I use the convention <client descriptor>_<site descriptor>. For example, if I were setting up two sites for Frank's Pet Shop and they had two domains, one for their public-facing site, and the other a private wiki for employees, I might call the two sites `franks_main' and `franks_wiki'. In any case, the `create_site.sh' script will need to know the name you choose, and the domain of the site: ,---- | domain=$1 | user=$2 | | if [[ "$domain" == "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ "$user" == "" ]]; then | echo "No user provided." 1>&2 | exit 1 | fi `---- The docroot of a site lives in in `/var/www/$domain', and the Apache configuration lives in =/etc/-apache2/sites-available/$domain.conf. ,---- | docroot="/var/www/$domain" | conf="/etc/apache2/sites-available/$domain.conf" | echo -e "I'll try to create a site with domain '$domain' and matching user '$user'.\n" `---- We create the user without a homedir, /with/ a user group of the same name, and with no login shell (or more accurately, the login shell set to /bin/nologin. This is under the assumption that only you, root, will be the only one messing with these files at all. Remember, there's no control panel here. In 2018, if a user knows enough to be logging into a shell, he can get his own VPS for $12 a year and doesn't want to be on your shared server. ,---- | echo -n "Creating new user and user-group..." | useradd -M -U -s /bin/nologin "$user" | echo "Done!" `---- The Apache config file is created using a template which looks like this: ,---- | <VirtualHost *:8080> | ServerName {domain} | ServerAlias www.{domain} | | DocumentRoot /var/www/{domain} | | LogLevel info | | ErrorLog /var/log/apache2/{domain}-error.log | CustomLog /var/log/apache2/{domain}-access.log combined | | AssignUserId {user} {user} | </VirtualHost> `---- Rather than use a complex templating engine, we rely on a few `sed' expressions to replace "single-hug" placeholders with their actual values. ,---- | conf_file=/etc/apache2/sites-available/$domain.conf | echo -n "Creating new apache config '$conf_file'..." | { | sed -e "s/{domain}/$domain/g" -e "s/{user}/$user/g" <<CONFTEMPLATE | | <VirtualHost *:8080> | ServerName {domain} | ServerAlias www.{domain} | | DocumentRoot /var/www/{domain} | | LogLevel info | | ErrorLog /var/log/apache2/{domain}-error.log | CustomLog /var/log/apache2/{domain}-access.log combined | | AssignUserId {user} {user} | </VirtualHost> | | CONFTEMPLATE | }>$conf_file `---- Notice that the VirtualHost listens on port 8080, since in my usual configuration, Apache is actually sitting behind some kind of front-end proxy or load balancer. This isn't always on the same machine, but I've standardized on having Apache listen on 8080 even when the load-balancer is on a different machine. As with other decisions in NoPanel, this one was made to reduce the amount of site-specific configuration that is necessary for the types of sites I typically deal with. As previously mentioned, the DocumentRoot is in `/var/www/$domain'. `LogLevel info', as is configured here, is one level above "debug" and logs quite a bit. I find this helpful when troubleshooting. With large disks, low-traffic sites, and proper log rotation, the volume is rarely a problem. To make it easy to find logs relating to a particular site, each domain has its own logs. Now we're ready to create the docroot itself... ,---- | echo -n "Creating new document in '$docroot'..." | mkdir -p "$docroot" | echo "Done!" `---- ...and to populate it with a test page: ,---- | echo -n "Creating test page at '$docroot/index.php'..." | echo "<?php phpinfo(); ?>" > $docroot/index.php | chown -R $user. $docroot | echo "Done!" `---- The last thing we need to do is "enable" the site with `a2ensite' (which itself just symlinks the config file into `/etc/apache/sites-enabled/') and reload the Apache config. ,---- | a2ensite $domain | | echo -n "Reloading Apache..." | service apache2 reload | echo "Done!" | | echo "Test site should be available at 'http://$domain' (barring DNS snafus)." `---- Like it says on the time, you should then be able to check out the `phpinfo()' page at the proper domain, provided DNS is set up, or you have the appropriate entries in your local hosts file. Taken together, we have a straightforward Bash function of about two-dozen non-blank lines: ,---- | function create_site { | domain=$1 | user=$2 | | if [[ "$domain" == "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ "$user" == "" ]]; then | echo "No user provided." 1>&2 | exit 1 | fi | | docroot="/var/www/$domain" | conf="/etc/apache2/sites-available/$domain.conf" | echo -e "I'll try to create a site with domain '$domain' and matching user '$user'.\n" | | echo -n "Creating new user and user-group..." | useradd -M -U -s /bin/nologin "$user" | echo "Done!" | | echo -n "Creating new document in '$docroot'..." | mkdir -p "$docroot" | echo "Done!" | | conf_file=/etc/apache2/sites-available/$domain.conf | echo -n "Creating new apache config '$conf_file'..." | { | sed -e "s/{domain}/$domain/g" -e "s/{user}/$user/g" <<CONFTEMPLATE | | <VirtualHost *:8080> | ServerName {domain} | ServerAlias www.{domain} | | DocumentRoot /var/www/{domain} | | LogLevel info | | ErrorLog /var/log/apache2/{domain}-error.log | CustomLog /var/log/apache2/{domain}-access.log combined | | AssignUserId {user} {user} | </VirtualHost> | | CONFTEMPLATE | }>$conf_file | | echo -n "Creating test page at '$docroot/index.php'..." | echo "<?php phpinfo(); ?>" > $docroot/index.php | chown -R $user. $docroot | echo "Done!" | | a2ensite $domain | | echo -n "Reloading Apache..." | service apache2 reload | echo "Done!" | | echo "Test site should be available at 'http://$domain' (barring DNS snafus)." | } `---- 1.1.2 Deleting Sites -------------------- To delete a site from NoPanel, we need to be absolutely sure that we need to know the domain of the site in question, and we need to be darn sure that we aren't getting a blank domain and deleting all of `/var/www': ,---- | domain="$1" | docroot="$(cd "/var/www/$domain"; pwd)" | | if [[ "$domain" == "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ ! -e "$docroot" ]]; then | echo "'$docroot' does not exist." 1>&2 | exit 1 | fi | | if [[ ! -d "$docroot" ]]; then | echo "'$docroot' is not a directory." 1>&2 | exit 1 | fi | | if [[ "$docroot" == "/var/www" ]]; then | echo "Not deleting '$docroot'. Please double-check your input." 1>&2 | exit 1 | fi `---- Once we're sure of that, we can determine the Linux user for the site in question, and make sure it's a sane one: ,---- | user="$(stat -c "%U" "$docroot")" | | if [[ "$user" == "" ]]; then | echo "No user found for site '$domain'." 1>&2 | exit 1 | fi | | if [[ "$user" == "root" ]]; then | echo "Not deleting user '$user'." 1>&2 | exit 1 | fi `---- Next we need to find the config file for the site: ,---- | conf_file="/etc/apache2/sites-available/$domain.conf" | | if [[ ! -f "$conf_file" ]]; then | echo "Conf file '$conf_file' does not exist." 1>&2 | exit 1 | fi `---- Finally, we can start doing some damage. First, we disable the site's config: ,---- | a2dissite "$domain" `---- Then, we delete the config file itself: ,---- | rm "$conf_file" `---- Remove the document root: ,---- | rm -rfv "$docroot" `---- Finally, we delete the user: ,---- | userdel "$user" `---- ...and reload the Apache config: ,---- | service apache2 reload `---- And that's it! That site won't bother us any more. ,---- | function delete_site { | domain="$1" | docroot="$(cd "/var/www/$domain"; pwd)" | | if [[ "$domain" == "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ ! -e "$docroot" ]]; then | echo "'$docroot' does not exist." 1>&2 | exit 1 | fi | | if [[ ! -d "$docroot" ]]; then | echo "'$docroot' is not a directory." 1>&2 | exit 1 | fi | | if [[ "$docroot" == "/var/www" ]]; then | echo "Not deleting '$docroot'. Please double-check your input." 1>&2 | exit 1 | fi | | user="$(stat -c "%U" "$docroot")" | | if [[ "$user" == "" ]]; then | echo "No user found for site '$domain'." 1>&2 | exit 1 | fi | | if [[ "$user" == "root" ]]; then | echo "Not deleting user '$user'." 1>&2 | exit 1 | fi | | conf_file="/etc/apache2/sites-available/$domain.conf" | | if [[ ! -f "$conf_file" ]]; then | echo "Conf file '$conf_file' does not exist." 1>&2 | exit 1 | fi | | a2dissite "$domain" | rm "$conf_file" | rm -rfv "$docroot" | userdel "$user" | } `---- 1.1.3 Backing Up Sites ---------------------- Backing up a site in NoPanel results in a tidy single-file tarball that contains the site's files, config, username, and (/Real Soon Now/) dumps of its databases. As always, we'll first sanity-check our input: ,---- | domain="$1" | docroot="$(cd "/var/www/$domain"; pwd)" | user="$(stat -c "%U" "$docroot")" | | if [[ "$domain" = "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ "$user" == "" ]]; then | echo "No user found for site '$domain'." 1>&2 | exit 1 | fi | | if [[ ! -e "$docroot" ]]; then | echo "'$docroot' does not exist." 1>&2 | exit 1 | fi | | if [[ ! -d "$docroot" ]]; then | echo "'$docroot' is not a directory." 1>&2 | exit 1 | fi | | if [[ "$docroot" == "/var/www" ]]; then | echo "Not backing up all of '$docroot'. Please double-check your input." 1>&2 | exit 1 | fi `---- Next, we'll need a temporary directory to work in: ,---- | tmpdir="$(mktemp -d)" `---- Now we can move the files over: ,---- | mkdir -p $tmpdir | rsync -aP /var/www/$domain/ $tmpdir/docroot | chown -R $user. $tmpdir/docroot `---- ...save the username: ,---- | echo "$user" > $tmpdir/USER `---- ...and tar the whole thing up: ,---- | olddir="$(pwd)" | cd $tmpdir | tar -czvf /root/backups/$domain-$(date +%F-%s).tar.gz . `---- Finally, we want to clean up our temporary files: ,---- | rm -rf $tmpdir | cd "$olddir" `---- The finished function: ,---- | function backup_site { | | domain="$1" | docroot="$(cd "/var/www/$domain"; pwd)" | user="$(stat -c "%U" "$docroot")" | | if [[ "$domain" = "" ]]; then | echo "No domain provided." 1>&2 | exit 1 | fi | | if [[ "$user" == "" ]]; then | echo "No user found for site '$domain'." 1>&2 | exit 1 | fi | | if [[ ! -e "$docroot" ]]; then | echo "'$docroot' does not exist." 1>&2 | exit 1 | fi | | if [[ ! -d "$docroot" ]]; then | echo "'$docroot' is not a directory." 1>&2 | exit 1 | fi | | if [[ "$docroot" == "/var/www" ]]; then | echo "Not backing up all of '$docroot'. Please double-check your input." 1>&2 | exit 1 | fi | | | tmpdir="$(mktemp -d)" | | mkdir -p $tmpdir | rsync -aP /var/www/$domain/ $tmpdir/docroot | chown -R $user. $tmpdir/docroot | | olddir="$(pwd)" | cd $tmpdir | tar -czvf /root/backups/$domain-$(date +%F-%s).tar.gz . | | rm -rf $tmpdir | cd "$olddir" | } `---- 1.1.4 Backup It All Up ---------------------- With `backup_site' and `list_sites' in our quiver, backing up all sites becomes not much more than a oneliner: ,---- | function backup_all { | list_sites | awk '{print $1}' | while read domain; | do | backup_site "$domain"; | done | } `---- 1.1.5 Listing Sites ------------------- So far, we can create, delete, and backup NoPanel sites. What if we just want to list them? Due to the "convention over configuration" approach NoPanel takes, this is actually just a one liner, but we'll pack the functionality into the final script anyway. ,---- | function list_sites { | find /var/www/ -mindepth 1 -maxdepth 1 -type d -printf '%f\t%u\n' | grep -v ^\\. | grep -v ^html | grep -v ^htpasswd | } `---- This simply finds all directories immediately beneath `/var/www' and filters out the default `html' directory that Ubuntu provides, as well as the `htpasswd' dir, where we keep Apache credential files (more on this later). 1.1.6 Managing Certificates --------------------------- The [Let's Encrypt Project] is a wonderful thing. They run a Certificate Authority and provide totally-automated provisioning of free SSL certificates. While they don't (yet) off all the goodies of a "real" CA like extended validation, and the supplied certs are only good for 90 days, it's still a really good option for most sites. NoPanel uses an HAProxy layer in front of all HTTP/HTTPS connections, which is the perfect place to terminate TLS connections. If you haven't already, use the `nopanel.sh install' command to install and configure HAProxy for your NoPanel environment. [Let's Encrypt Project] https://letsencrypt.org/ * 1.1.6.1 Installing or Renewing Certificates As mentioned, Let's Encrypt certificates have short validity periods, so unless you want to be manually validating and re-installing every 3 months, it's best to put some automation in place. Our HAProxy config is our first ally in renewing our cert. In particular, we want to look at the `https-in' frontend. This will be doing some of the work for us. ,---- | frontend https-in | bind *:443 ssl crt /etc/haproxy/certs/ | http-request set-header X-Forwarded-Proto https | | #mark ACME (Let's Encrypt) challenge requests | acl acme path_beg /.well-known/acme-challenge/ | | # domains in api.lst should be proxied to the API server | acl api_host hdr(Host) -f /etc/haproxy/api.lst | use_backend api if api_host !acme #unless they're ACME challenges | | default_backend apache `---- ,---- | backend apache | server hweb 127.0.0.1:8080 | | backend api | server api api:80 `---- The `https-in' backend listens on port 443 on all interfaces, and terminates connections based on the certs it finds in `/etc/haproxy/certs'. For good measure, we also set the `X-Forwarded-Proto' header so that downstream applications know that we're terminating SSL ahead of them. We'll talk about the rest later, but the `acme' ACL will make sure that, not matter where any other traffic for this domain ultimately goes (to another box, to some non-Apache daemon locally, to the moon, whatever), that requests beginning with `/.well-known/acme-challenge' will be sent to Apache. This is important because Let's Encrypt validates your ownership of a domain, and therefore allows you to get a cert for it, by making your prove that you can post arbitrary files somewhere below that path. With that in place, we can build our certificate renewal function. As usual, we start off with some configuration and input checking: ,---- | expiration_cutoff=14 #minimum number of days before expiration to renew the cert | domain="$1" | webroot="/var/www/$domain" | haproxy_cert_dir="/etc/haproxy/certs" | letsencrypt_basedir=/etc/letsencrypt | certfile="$letsencrypt_basedir/live/$domain/cert.pem" | email="[email protected]" | | if [[ ! -d "$webroot" ]]; then | echo "There is no directory '$webroot'." 1>&2 | exit 1 | fi `---- In order to be nice to the Let's Encrypt server infrastructure, we don't actually want to call out to try to validate unless it's necessary. To do so, we'll calculaute how many days are left until expiration of our cert. If it's under `$expiration_cutoff', we'll just skip it for now. I set this to 2 weeks to give me plenty of time to manuall renew a cert if something goes wrong here. To do the actual calculation, we need to know what time it is now, in seconds since the Unix epoch. We also need to know when the cert expires, which we ascertain by using the `openssl' binary to dump the cert, then grep out the "Not After" header. Finally, we subtract the two and convert the result to days. For floating point calculations like this in shell scripts, I like to call out to `bc', the *nix arbitrary precision calculator. If there is no cert yet, assume it expires today to force a "renewal". ,---- | if [[ -e "$certfile" ]] | then | timestamp_now=$(date -d "now" +%s) | expiration_timestamp=$(date -d "$(openssl x509 -in $certfile -text -noout|grep "Not After"| cut -c 25-)" +%s) | days_until_expiration=$(echo \( $expiration_timestamp - $timestamp_now \) / 86400 | bc) | else | days_until_expiration=0 | fi `---- The actual ACME validation is handled by the `letsencrypt' command. We use the `-w' switch to force it into "webroot" mode, using the Apache docroot to prove that we are able to post the validation key to `/.well-known/acme-challenge/'. If we succeed, we still need to stitch together the `fullchain.pem' and `privkey.pem' files that the `letsencrypt' tool gives us into one file, since that's the format HAProxy expects. Note that first we use `dig' to try and resolve the domain. If it doesn't even resolve, there's no point in trying to validate it. ,---- | if [[ "$days_until_expiration" -lt "$expiration_cutoff" ]] | then | for d in "$domain" "www.$domain" | do | if [[ "$(dig +short $d)" != ""]] | then #the domain actually resolves, so let's go ahead | if letsencrypt certonly \ | --webroot \ | --keep-until-expiring \ | --email "$email" \ | --agree-tos \ | -w $webroot \ | -d $d | then | cat /etc/letsencrypt/live/$d/fullchain.pem \ | /etc/letsencrypt/live/$d/privkey.pem > $haproxy_cert_dir/$d.pem | | service haproxy reload | fi | else # The domain doesn't even resolve. Either the | # 'www.' subdomain for this site isn't used, or this is a | # deprecated site. TODO: log/notify on this. | echo "" | fi | done | fi `---- ,---- | function renew_cert { | <<renew_cert_input_check>> | | <<renew_cert_calculate>> | | <<renew_cert_do_challenge>> | } `---- 1.2 Installation ~~~~~~~~~~~~~~~~ So far, we've talked only about things that can be done once your server is already set up and running NoPanel. What, you might ask, do I do if I'm starting from a bare-nothing Ubuntu install? Fear not. NoPanel is intended to be simple to install. In fact, it's a single shellscript, `nopanel.sh', which you will want to copy somewhere in your path and `chmod +x'. ,---- | ~# cp nopanel.sh /usr/bin `---- Next, avail yourself of the `nopanel.sh install' command. It's quite a simple little function. First, we install all the necessary system packages: ,---- | apt-get install -y haproxy apache2 bc letsencrypt php-imagick php-pear php-pecl-http php7.0 php7.0-bcmath php7.0-bz2 php7.0-cgi php7.0-cli php7.0-common php7.0-curl php7.0-dba php7.0-dev php7.0-enchant php7.0-fpm php7.0-gd php7.0-gmp php7.0-imap php7.0-interbase php7.0-intl php7.0-json php7.0-ldap php7.0-mbstring php7.0-mysql php7.0-sqlite3 php7.0-sybase php7.0-tidy php7.0-xml php7.0-xmlrpc php7.0-xsl php7.0-zip `---- Next, we install the HAproxy configuration: ,---- | { | cat <<HAPROXYCONF | <<haproxy_config>> | HAPROXYCONF | }>/etc/haproxy/haproxy.cfg.new `---- Finally, start Apache and HAproxy. Actually, we stop them first, in case one or the other is already running. ,---- | service apache2 stop | service apache2 start | | service haproxy reload `---- This leaves us with a few lines to get our system up and running: ,---- | function install { | apt-get install -y haproxy apache2 bc letsencrypt php-imagick php-pear php-pecl-http php7.0 php7.0-bcmath php7.0-bz2 php7.0-cgi php7.0-cli php7.0-common php7.0-curl php7.0-dba php7.0-dev php7.0-enchant php7.0-fpm php7.0-gd php7.0-gmp php7.0-imap php7.0-interbase php7.0-intl php7.0-json php7.0-ldap php7.0-mbstring php7.0-mysql php7.0-sqlite3 php7.0-sybase php7.0-tidy php7.0-xml php7.0-xmlrpc php7.0-xsl php7.0-zip | | { | cat <<HAPROXYCONF | global | log /dev/log local0 | log /dev/log local1 notice | chroot /var/lib/haproxy | stats socket /run/haproxy/admin.sock mode 660 level admin | stats timeout 30s | user haproxy | group haproxy | daemon | | # Default SSL material locations | ca-base /etc/ssl/certs | crt-base /etc/ssl/private | | # Default ciphers to use on SSL-enabled listening sockets. | # For more information, see ciphers(1SSL). This list is from: | # https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ | ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS | ssl-default-bind-options no-sslv3 | | defaults | log global | mode http | option httplog | option dontlognull | timeout connect 5000 | timeout client 50000 | timeout server 50000 | errorfile 400 /etc/haproxy/errors/400.http | errorfile 403 /etc/haproxy/errors/403.http | errorfile 408 /etc/haproxy/errors/408.http | errorfile 500 /etc/haproxy/errors/500.http | errorfile 502 /etc/haproxy/errors/502.http | errorfile 503 /etc/haproxy/errors/503.http | errorfile 504 /etc/haproxy/errors/504.http | | frontend http-in | bind *:80 | | #domains in no_ssl.lst shouldn't be redirected to HTTPS | acl no_ssl_host hdr(Host) -f /etc/haproxy/no_ssl.lst | redirect scheme https code 301 if !{ ssl_fc } !no_ssl_host | | #mark ACME (Let's Encrypt) challenge requests | acl acme path_beg /.well-known/acme-challenge/ | | # domains in api.lst should be proxied to the API server | acl api_host hdr(Host) -f /etc/haproxy/api.lst | use_backend api if api_host !acme #unless they're ACME challenges | | default_backend apache | | frontend https-in | bind *:443 ssl crt /etc/haproxy/certs/ | http-request set-header X-Forwarded-Proto https | | #mark ACME (Let's Encrypt) challenge requests | acl acme path_beg /.well-known/acme-challenge/ | | # domains in api.lst should be proxied to the API server | acl api_host hdr(Host) -f /etc/haproxy/api.lst | use_backend api if api_host !acme #unless they're ACME challenges | | default_backend apache | | backend apache | server hweb 127.0.0.1:8080 | | backend api | server api api:80 | HAPROXYCONF | }>/etc/haproxy/haproxy.cfg.new | | service apache2 stop | service apache2 start | | service haproxy reload | } `----
About
Making multitenant LAMP less stressful.
Resources
Stars
Watchers
Forks
Releases
No releases published
Packages 0
No packages published