aboutsummaryrefslogtreecommitdiffstats
path: root/roles/web
diff options
context:
space:
mode:
Diffstat (limited to 'roles/web')
-rwxr-xr-xroles/web/files/acme-client.sh117
-rw-r--r--roles/web/files/conf.d/security_headers.conf79
-rw-r--r--roles/web/files/conf.d/ssl.conf69
-rwxr-xr-xroles/web/files/deploy.sh17
-rw-r--r--roles/web/files/nginx.conf94
-rw-r--r--roles/web/handlers/main.yml3
-rw-r--r--roles/web/tasks/acme-domainkey.yml21
-rw-r--r--roles/web/tasks/main.yml128
-rw-r--r--roles/web/tasks/nginx-gensites.yml26
-rw-r--r--roles/web/templates/domains.txt.j24
10 files changed, 558 insertions, 0 deletions
diff --git a/roles/web/files/acme-client.sh b/roles/web/files/acme-client.sh
new file mode 100755
index 0000000..1f4b15d
--- /dev/null
+++ b/roles/web/files/acme-client.sh
@@ -0,0 +1,117 @@
+#!/bin/sh
+#
+# This script can be both used to request/obtain new certificate(s) from
+# Let's Encrypt through ACME challenges:
+# $ ./acme-client.sh -n -N
+# to expand the domains listed in the certificate:
+# $ ./acme-client.sh -e
+# and be used to renew the obtained certificate(s) (default action):
+# $ ./acme-client.sh
+# which can be called by periodic(8).
+#
+# This script will be weekly executed in order to renew the certificate(s).
+# See "/etc/periodic.conf".
+#
+# Output files:
+# * .../etc/acme/privkey.pem : account private key
+# * .../etc/ssl/acme/private/<domain>.pem : domain private key
+#
+# XXX/TODO:
+# * How to remove/revoke a SAN from the certificate?
+#
+#
+# Aaron LI
+# 2017-04-19
+#
+
+umask 027
+
+BASEDIR="/usr/local/etc/acme"
+SSLDIR="/usr/local/etc/ssl/acme"
+DOMAINSFILE="${BASEDIR}/domains.txt"
+CHALLENGEDIR="/usr/local/www/acme/.well-known/acme-challenge"
+# Default to show verbose information
+VERBOSE="true"
+# Additional arguments for "acme-client"
+ARGS=""
+
+
+usage() {
+ cat << _EOF_
+usage:
+`basename $0` [-h] [-efLnNv] [-d domains.txt]
+
+ -e : allow expanding the domains listed in the certificate
+ -f : force updating the certificate signature even if its too soon
+ -n : create a new 4096-bit RSA account key if one does not already exist
+ -N : create a new 4096-bit RSA domain key if one does not already exist
+ -q : be quiet (default to show verbose information)
+
+ -d domains.txt : text file with one domain and its sub-domains per line
+ (default: ${DOMAINSFILE})
+_EOF_
+}
+
+
+while getopts "efhnNqd:" opt; do
+ case "$opt" in
+ h)
+ usage
+ exit 1
+ ;;
+ e)
+ ARGS="${ARGS} -e"
+ ;;
+ f)
+ ARGS="${ARGS} -F"
+ ;;
+ n)
+ ARGS="${ARGS} -n"
+ ;;
+ N)
+ ARGS="${ARGS} -N"
+ ;;
+ q)
+ VERBOSE="false"
+ ;;
+ d)
+ DOMAINSFILE="${OPTARG}"
+ ;;
+ [?])
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+if [ "${VERBOSE}" = "true" ]; then
+ ARGS="${ARGS} -v"
+fi
+
+# HACK???
+[ ! -f "/etc/ssl/cert.pem" ] && \
+ ln -sv /usr/local/etc/ssl/cert.pem /etc/ssl/cert.pem
+
+[ ! -d "${CHALLENGEDIR}" ] && mkdir -pv ${CHALLENGEDIR}
+[ ! -d "${SSLDIR}/private" ] && mkdir -pvm700 "${SSLDIR}/private"
+
+printf "\n=== $(date) ===\n=== CMD: $0 $* ===\n"
+
+grep -v '^\s*#' "${DOMAINSFILE}" | while read domain line; do
+ printf "-------------------------------------------------------------\n"
+ printf "[${domain}] ${line}\n"
+ printf "-------------------------------------------------------------\n"
+ CERTSDIR="${SSLDIR}/${domain}"
+ [ ! -d "${CERTSDIR}" ] && mkdir -pm755 "${CERTSDIR}"
+ set +e # RC=2 when time to expire > 30 days
+ acme-client -b -C "${CHALLENGEDIR}" \
+ -k "${SSLDIR}/private/${domain}.pem" \
+ -c "${CERTSDIR}" \
+ ${ARGS} \
+ ${domain} ${line}
+ RC=$?
+ set -e
+ [ $RC -ne 0 -a $RC -ne 2 ] && exit $RC
+done
+
+exit 0
diff --git a/roles/web/files/conf.d/security_headers.conf b/roles/web/files/conf.d/security_headers.conf
new file mode 100644
index 0000000..f4a7135
--- /dev/null
+++ b/roles/web/files/conf.d/security_headers.conf
@@ -0,0 +1,79 @@
+#
+# /usr/local/etc/nginx/security_headers
+#
+# Security headers for Nginx/HTTP(s)
+#
+# Aaron LI
+# 2017-11-22
+#
+# Credits
+# -------
+# * Hardening your HTTP response headers
+# https://scotthelme.co.uk/hardening-your-http-response-headers/
+# * Nginx add_header configuration pitfall
+# https://blog.g3rt.nl/nginx-add_header-pitfall.html
+# * Nginx - ngx_http_headers_module - add_header
+# https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header
+#
+# Tools
+# -----
+# * Security Headers Analyzer
+# https://securityheaders.io/
+#
+# 2017-11-23: Be less paranoid, due to the reverse proxy services ...
+#
+
+#
+# NOTE: Use "always" for security headers.
+#
+# WARNING: The "add_header" directive (and some others) are inherited
+# from the *previous* level *IF AND ONLY IF* there are NO
+# "add_header" directives defined on the *current* level.
+# Such behavior leads to the *pitfall* that the added headers
+# may get *cleared*! In consequence, this common header
+# configuration file *must* be included within every context
+# that has "add_header" directives!
+#
+
+# Instruct the client to force a HTTPS connection to the domain and all
+# its subdomains for 2 year.
+# See also: https://hstspreload.org/
+#add_header Strict-Transport-Security
+# "max-age=63072000; includeSubdomains; preload" always;
+add_header Strict-Transport-Security
+ "max-age=31536000; includeSubdomains" always;
+
+# The Content Security Policy (CSP) header allows to define a whitelist
+# of approved sources of content for the site. By restricting the assets
+# that a browser can load, CSP can act as an effective countermeasure to
+# XSS attacks.
+#
+# Enforce TLS on all assets and prevent mixed content warnings.
+add_header Content-Security-Policy
+ "default-src https: data: 'unsafe-inline' 'unsafe-eval'" always;
+# Only allow to load assets from self!
+#add_header Content-Security-Policy "default-src 'self'" always;
+
+# The X-Frame-Options (XFO) header protects the visitors against
+# clickjacking attacks.
+# Only allow yourselves to frame your own site.
+add_header X-Frame-Options "SAMEORIGIN" always;
+# Do not allow the site to be framed at all!
+#add_header X-Frame-Options "DENY" always;
+
+# Enable the cross-site scripting filter built into most browsers, and
+# tell the browser to block the response if it detects an attack rather
+# than sanitizing the script.
+add_header X-XSS-Protection "1; mode=block" always;
+
+# Prevent a browser from trying to MIME-sniff the content type and forces
+# it to stick with the declared content-type.
+add_header X-Content-Type-Options "nosniff" always;
+
+# Allow a site to control how much information the browser includes with
+# navigations away from a document.
+#
+# The browser will send the full URL to requests to the same origin, but
+# only send the origin when requests are cross-origin. No information
+# allowed to be sent when a scheme downgrade happens.
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
diff --git a/roles/web/files/conf.d/ssl.conf b/roles/web/files/conf.d/ssl.conf
new file mode 100644
index 0000000..acda0eb
--- /dev/null
+++ b/roles/web/files/conf.d/ssl.conf
@@ -0,0 +1,69 @@
+#
+# /usr/local/etc/nginx/conf.d/ssl.conf
+#
+# SSL/TLS settings for Nginx
+#
+# Aaron LI
+# 2017-04-25
+#
+# Credits
+# -------
+# * Cipherli.st - Strong Ciphers for Apache, nginx and Lighttpd
+# https://cipherli.st/
+# * Strong SSL Security on nginx
+# https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
+# * Mozilla - Security - Server Side TLS
+# https://wiki.mozilla.org/Security/Server_Side_TLS
+# https://mozilla.github.io/server-side-tls/ssl-config-generator/
+# * Let's Encrypt & Nginx
+# https://letsecure.me/secure-web-deployment-with-lets-encrypt-and-nginx/
+# * Nginx SSL and TLS Deployment Best Practice
+# https://www.linode.com/docs/web-servers/nginx/nginx-ssl-and-tls-deployment-best-practices
+# * Best nginx configuration for improved security (and performance)
+# https://gist.github.com/plentz/6737338
+# * Hardening your HTTP response headers
+# https://scotthelme.co.uk/hardening-your-http-response-headers/
+#
+# Tools
+# -----
+# * Qualys SSL Labs SSL Server Test
+# https://www.ssllabs.com/ssltest/
+# * Security Headers Analyzer
+# https://securityheaders.io/
+#
+
+
+# Diffie-Hellman group:
+# $ openssl dhparam -out /usr/local/etc/ssl/dhparam2048.pem 2048
+# or even go with 4096-bit DH pool:
+# $ openssl dhparam -out /usr/local/etc/ssl/dhparam4096.pem 4096
+# NOTE: This may take up to tens of minutes ...
+#ssl_dhparam /usr/local/etc/ssl/dhparam2048.pem;
+ssl_dhparam /usr/local/etc/ssl/dhparam4096.pem;
+
+# Only use the latest TLS protocols
+# TLSv1.3 requires nginx >= 1.13
+#ssl_protocols TLSv1.2 TLSv1.3;
+ssl_protocols TLSv1.2;
+ssl_prefer_server_ciphers on;
+# Credit: https://mozilla.github.io/server-side-tls/ssl-config-generator/
+ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
+
+ssl_session_timeout 1d;
+ssl_session_cache shared:SSL:50m;
+# Credit: https://github.com/mozilla/server-side-tls/issues/135
+ssl_session_tickets off;
+
+# The Online Certificate Status Protocol (OCSP) was created to speed up
+# the process that operating systems and browsers use to check for
+# certificate revocation.
+# Allow the server to send its cached OCSP record to the client during
+# the TLS handshake, bypassing the OCSP responder and saving a roundtrip
+# between the client and the OCSP responder.
+#
+# NOTE: If the "ssl_certificate" file does NOT contain intermediate
+# certificates, the certificate of the server certificate issuer
+# should be present in the "ssl_trusted_certificate" file.
+#
+ssl_stapling on;
+ssl_stapling_verify on;
diff --git a/roles/web/files/deploy.sh b/roles/web/files/deploy.sh
new file mode 100755
index 0000000..6c503d6
--- /dev/null
+++ b/roles/web/files/deploy.sh
@@ -0,0 +1,17 @@
+#!/bin/sh -e
+#
+# Restart the services after renewing the certificate(s) to deploy the
+# changed certificate(s).
+#
+# This script will be weekly executed. See "/etc/periodic.conf".
+#
+# Aaron LI
+#
+
+# Services to be restarted after ACME certificate update
+SERVICES="nginx dovecot postfix"
+
+for srv in ${SERVICES}; do
+ echo "ACME deploy: restarting ${srv} ..."
+ service ${srv} restart
+done
diff --git a/roles/web/files/nginx.conf b/roles/web/files/nginx.conf
new file mode 100644
index 0000000..760ca02
--- /dev/null
+++ b/roles/web/files/nginx.conf
@@ -0,0 +1,94 @@
+#
+# /usr/local/etc/nginx/nginx.conf
+# DragonFly BSD
+#
+#
+# References
+# ----------
+# * A Guide to Caching with NGINX and NGINX Plus
+# https://www.nginx.com/blog/nginx-caching-guide/
+# * Reverse Proxy with Caching
+# https://www.nginx.com/resources/wiki/start/topics/examples/reverseproxycachingexample/
+# * Compression and Decompression
+# https://www.nginx.com/resources/admin-guide/compression-and-decompression/
+# * Nginx location priority
+# https://stackoverflow.com/a/5238430/4856091
+# * Nginx add_header configuration pitfall
+# https://blog.g3rt.nl/nginx-add_header-pitfall.html
+#
+# Tools
+# -----
+# * Qualys SSL Labs SSL Server Test
+# https://www.ssllabs.com/ssltest/
+# * Security Headers Analyzer
+# https://securityheaders.io/
+# * KeyCDN HTTP/2 Test
+# https://tools.keycdn.com/http2-test
+#
+#
+# Aaron LI
+# 2017-04-16
+#
+
+worker_processes 1;
+
+events {
+ worker_connections 1024;
+}
+
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout 65;
+
+ # Compression
+ gzip on;
+ gzip_types text/plain application/xml; # text/html always compressed
+ gzip_proxied no-cache no-store private expired auth;
+ gzip_min_length 1000;
+
+ # Don't show the Nginx version number (in error pages / headers)
+ server_tokens off;
+
+ # SSL/TLS settings
+ include conf.d/ssl.conf;
+
+ # Security headers
+ #
+ # WARNING: The "add_header" directive (and some others) are inherited
+ # from the *previous* level *IF AND ONLY IF* there are NO
+ # "add_header" directives defined on the *current* level.
+ # Such behavior leads to the *pitfall* that the added headers
+ # may get *cleared*! In consequence, this common header
+ # configuration file *must* be included within every context
+ # that has "add_header" directives!
+ #
+ include conf.d/security_headers.conf;
+
+ # Proxy Caching
+ #
+ # This setup a cache zone named "CACHE" given 10 MB for metadata storage,
+ # maximum 1 GB for cached contents which will be cleared after 24 hours
+ # without access.
+ #
+ # NOTE: The `proxy_cache_path` directive must be placed in `http` context.
+ #
+ # NOTE: The caching is not efficient since the traffic is rather low.
+ # So disable caching to save a bit memory.
+ #
+ #proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m
+ # inactive=24h max_size=1g use_temp_path=off;
+ #proxy_cache_valid 200 302 60m;
+ #proxy_cache_valid any 1m;
+ #proxy_cache_use_stale error timeout invalid_header updating
+ # http_500 http_502 http_503 http_504;
+ #add_header X-Cache-Status $upstream_cache_status;
+
+ # Site-specific settings
+ include sites/*.conf;
+}
diff --git a/roles/web/handlers/main.yml b/roles/web/handlers/main.yml
new file mode 100644
index 0000000..765d2c1
--- /dev/null
+++ b/roles/web/handlers/main.yml
@@ -0,0 +1,3 @@
+---
+- name: reload-nginx
+ command: rcreload nginx
diff --git a/roles/web/tasks/acme-domainkey.yml b/roles/web/tasks/acme-domainkey.yml
new file mode 100644
index 0000000..ac409c2
--- /dev/null
+++ b/roles/web/tasks/acme-domainkey.yml
@@ -0,0 +1,21 @@
+---
+- name: (local) acme - check domain private key existence
+ become: false
+ stat:
+ path: "{{ playbook_dir }}/private/acme/{{ domain }}.pem"
+ delegate_to: localhost
+ register: stat_result
+
+- name: (local) acme - generate domain private key (4096 bit)
+ become: false
+ command: >
+ openssl genrsa
+ -out "{{ playbook_dir }}/private/acme/{{ domain }}.pem" 4096
+ delegate_to: localhost
+ when: not stat_result.stat.exists
+
+- name: acme - copy domain private key
+ copy:
+ src: "{{ playbook_dir }}/private/acme/{{ domain }}.pem"
+ dest: /usr/local/etc/ssl/acme/private/{{ domain }}.pem
+ mode: 0400
diff --git a/roles/web/tasks/main.yml b/roles/web/tasks/main.yml
new file mode 100644
index 0000000..5d736a4
--- /dev/null
+++ b/roles/web/tasks/main.yml
@@ -0,0 +1,128 @@
+---
+- name: install package
+ pkgng:
+ name: "{{ item }}"
+ state: present
+ with_items:
+ - nginx
+ - acme-client
+
+- name: (local) ssl/tls - check dhparam existence
+ become: false
+ stat:
+ path: "{{ playbook_dir }}/ssl/dhparam4096.pem"
+ delegate_to: localhost
+ register: stat_result
+
+- name: (local) ssl/tls - generate dhparam (4096 bit)
+ become: false
+ command: >
+ openssl dhparam
+ -out "{{ playbook_dir }}/ssl/dhparam4096.pem" 4096
+ delegate_to: localhost
+ when: not stat_result.stat.exists
+
+- name: ssl/tls - copy dhparam
+ copy:
+ src: "{{ playbook_dir }}/ssl/dhparam4096.pem"
+ dest: /usr/local/etc/ssl/dhparam4096.pem
+ mode: 0444
+
+- name: nginx - copy conf.d/ config directory
+ copy:
+ src: conf.d/ # trailing '/' -> directory contents
+ dest: /usr/local/etc/nginx/conf.d/
+
+- name: nginx - create sites/ directory
+ file:
+ path: /usr/local/etc/nginx/sites
+ state: directory
+
+- name: nginx - generate sites
+ include_tasks: nginx-gensites.yml
+
+- name: nginx - copy nginx.conf
+ copy:
+ src: nginx.conf
+ dest: /usr/local/etc/nginx/nginx.conf
+ # XXX: Validation runs aganist a temporary file, thus nginx fails to
+ # include other config files!
+ #validate: "nginx -t -c %s"
+ notify: reload-nginx
+
+- name: nginx - check configuration
+ command: nginx -t
+
+- name: nginx - enable and start
+ command: rcenable nginx
+
+- name: newsyslog - nginx log rotation
+ blockinfile:
+ path: /etc/newsyslog.conf
+ marker: '# {mark} ANSIBLE MANAGED - nginx'
+ block: |
+ /var/log/nginx/access.log 644 7 * @T00 Z /var/run/nginx.pid
+ /var/log/nginx/error.log 644 7 * @T00 Z /var/run/nginx.pid
+
+- name: acme - copy scripts
+ copy:
+ src: "{{ item }}"
+ dest: /usr/local/etc/acme/{{ item | basename }}
+ mode: 0755
+ with_items:
+ - acme-client.sh
+ - deploy.sh
+
+- name: (local) acme - check account private key existence
+ become: false
+ stat:
+ path: "{{ playbook_dir }}/private/acme/privkey.pem"
+ delegate_to: localhost
+ register: stat_result
+
+- name: (local) acme - generate account private key (4096 bit)
+ become: false
+ command: >
+ openssl genrsa
+ -out "{{ playbook_dir }}/private/acme/privkey.pem" 4096
+ delegate_to: localhost
+ when: not stat_result.stat.exists
+
+- name: acme - copy account private key
+ copy:
+ src: "{{ playbook_dir }}/private/acme/privkey.pem"
+ dest: /usr/local/etc/acme/privkey.pem
+ mode: 0400
+
+- name: acme - create domain private directory
+ file:
+ path: /usr/local/etc/ssl/acme/private/
+ state: directory
+ mode: 0700
+
+# Credit: https://shasawas.wordpress.com/2016/05/23/how-to-loop-over-a-set-of-tasks-in-ansible/
+- name: acme - generate and copy domain private keys
+ include_tasks: acme-domainkey.yml domain={{ item.name }}
+ with_items: "{{ domains }}"
+
+- name: acme - generate domains.txt
+ template:
+ src: domains.txt.j2
+ dest: /usr/local/etc/acme/domains.txt
+
+- name: acme - create challenge directory
+ file:
+ path: /usr/local/www/acme/.well-known/acme-challenge
+ state: directory
+ group: www
+ recurse: true
+
+- name: nginx - force reload
+ command: rcreload nginx
+
+- name: acme - request domain certificates
+ command: sh /usr/local/etc/acme/acme-client.sh -e
+
+- name: nginx - re-generate sites
+ include_tasks: nginx-gensites.yml
+ notify: reload-nginx
diff --git a/roles/web/tasks/nginx-gensites.yml b/roles/web/tasks/nginx-gensites.yml
new file mode 100644
index 0000000..2b25a84
--- /dev/null
+++ b/roles/web/tasks/nginx-gensites.yml
@@ -0,0 +1,26 @@
+---
+- name: domains - check certificate existence
+ stat:
+ path: /usr/local/etc/ssl/acme/{{ item.name }}/fullchain.pem
+ register: stat
+ with_items: "{{ domains }}"
+
+- name: domains - save certificate status in a variable
+ set_fact:
+ domains_hascert: >
+ {{ domains_hascert |
+ default({}) |
+ combine({item.0.name: item.1.stat.exists}) }}
+ with_together:
+ - "{{ domains }}"
+ - "{{ stat.results }}"
+
+- debug: var=domains_hascert
+
+- name: nginx - generate sites
+ template:
+ src: "{{ item }}"
+ dest: /usr/local/etc/nginx/sites/{{ item | basename | regex_replace('\.j2', '') }}
+ # NOTE: `with_fileglob` always operates from `files/`
+ with_fileglob:
+ - "../templates/sites/*.j2"
diff --git a/roles/web/templates/domains.txt.j2 b/roles/web/templates/domains.txt.j2
new file mode 100644
index 0000000..dd59388
--- /dev/null
+++ b/roles/web/templates/domains.txt.j2
@@ -0,0 +1,4 @@
+{% for domain in domains %}
+{{ domain.name }} {% for sub in domain.sub %} {{ sub }}.{{ domain.name }}{% endfor %}
+
+{% endfor %}