diff --git a/bundles/apt/items.py b/bundles/apt/items.py new file mode 100644 index 0000000..df661a4 --- /dev/null +++ b/bundles/apt/items.py @@ -0,0 +1,13 @@ +actions = { + 'apt_update': { + 'command': 'apt-get update', + 'needed_by': { + 'pkg_apt:', + }, + 'triggered': True, + 'cascade_skip': False, + }, +} + +for package, options in node.metadata.get('apt/packages', {}).items(): + pkg_apt[package] = options diff --git a/bundles/gitea/files/app.ini b/bundles/gitea/files/app.ini index 2e96228..96c0681 100644 --- a/bundles/gitea/files/app.ini +++ b/bundles/gitea/files/app.ini @@ -15,8 +15,8 @@ MEMBERS_PAGING_NUM = 100 PROTOCOL = http SSH_DOMAIN = ${domain} DOMAIN = ${domain} -HTTP_ADDR = 127.0.0.1 -HTTP_PORT = 22000 +HTTP_ADDR = 0.0.0.0 +HTTP_PORT = 3500 ROOT_URL = https://${domain}/ DISABLE_SSH = false SSH_PORT = 22 diff --git a/bundles/letsencrypt/files/config b/bundles/letsencrypt/files/config new file mode 100644 index 0000000..2d4b2b6 --- /dev/null +++ b/bundles/letsencrypt/files/config @@ -0,0 +1,5 @@ +CONFIG_D=/etc/dehydrated/conf.d +BASEDIR=/var/lib/dehydrated +WELLKNOWN="${BASEDIR}/acme-challenges" +DOMAINS_TXT="/etc/dehydrated/domains.txt" +HOOK="/etc/dehydrated/hook.sh" diff --git a/bundles/letsencrypt/files/domains.txt b/bundles/letsencrypt/files/domains.txt new file mode 100644 index 0000000..ea7e427 --- /dev/null +++ b/bundles/letsencrypt/files/domains.txt @@ -0,0 +1,3 @@ +% for domain, aliases in sorted(node.metadata.get('letsencrypt/domains', {}).items()): +${domain} ${' '.join(sorted(aliases))} +% endfor diff --git a/bundles/letsencrypt/files/hook.sh b/bundles/letsencrypt/files/hook.sh new file mode 100644 index 0000000..4cdf79d --- /dev/null +++ b/bundles/letsencrypt/files/hook.sh @@ -0,0 +1,37 @@ +deploy_cert() {<%text> + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" +% for service, config in node.metadata.get('letsencrypt/concat_and_deploy', {}).items(): + + # concat_and_deploy ${service} + if [ "$DOMAIN" = "${config['match_domain']}" ]; then + cat $KEYFILE > ${config['target']} + cat $FULLCHAINFILE >> ${config['target']} +% if 'chown' in config: + chown ${config['chown']} ${config['target']} +% endif +% if 'chmod' in config: + chmod ${config['chmod']} ${config['target']} +% endif +% if 'commands' in config: +% for command in config['commands']: + ${command} +% endfor +% endif + fi +% endfor +} + + +exit_hook() {<%text> + local ERROR="${1:-}" + +% for service in sorted(node.metadata.get('letsencrypt/reload_after', set())): + systemctl reload-or-restart ${service} +% endfor +} + +<%text> +HANDLER="$1"; shift +if [[ "${HANDLER}" =~ ^(deploy_cert|exit_hook)$ ]]; then + "$HANDLER" "$@" +fi diff --git a/bundles/letsencrypt/files/letsencrypt-ensure-some-certificate b/bundles/letsencrypt/files/letsencrypt-ensure-some-certificate new file mode 100644 index 0000000..e0248cb --- /dev/null +++ b/bundles/letsencrypt/files/letsencrypt-ensure-some-certificate @@ -0,0 +1,31 @@ +#!/bin/sh + +domain=$1 +just_check=$2 + +cert_path="/var/lib/dehydrated/certs/$domain" + +already_exists=false +if [ -f "$cert_path/privkey.pem" -a -f "$cert_path/fullchain.pem" -a -f "$cert_path/chain.pem" ] +then + already_exists=true +fi + +if [ "$just_check" = true ] +then + if [ "$already_exists" = true ] + then + exit 0 + else + exit 1 + fi +fi + +if [ "$already_exists" != true ] +then + rm -r "$cert_path" + mkdir -p "$cert_path" + openssl req -x509 -newkey rsa:4096 -nodes -days 3650 -subj "/CN=$domain" -keyout "$cert_path/privkey.pem" -out "$cert_path/fullchain.pem" + chmod 0600 "$cert_path/privkey.pem" + cp "$cert_path/fullchain.pem" "$cert_path/chain.pem" +fi diff --git a/bundles/letsencrypt/items.py b/bundles/letsencrypt/items.py new file mode 100644 index 0000000..9b86c36 --- /dev/null +++ b/bundles/letsencrypt/items.py @@ -0,0 +1,50 @@ +assert node.has_bundle('nginx') + +directories = { + '/etc/dehydrated/conf.d': {}, + '/var/lib/dehydrated/acme-challenges': {}, +} + +files = { + '/etc/dehydrated/domains.txt': { + 'content_type': 'mako', + 'triggers': { + 'action:letsencrypt_update_certificates', + }, + }, + '/etc/dehydrated/config': { + 'triggers': { + 'action:letsencrypt_update_certificates', + }, + }, + '/etc/dehydrated/hook.sh': { + 'content_type': 'mako', + 'mode': '0755', + }, + '/etc/dehydrated/letsencrypt-ensure-some-certificate': { + 'mode': '0755', + }, +} + +actions['letsencrypt_update_certificates'] = { + 'command': 'dehydrated --cron --accept-terms --challenge http-01', + 'triggered': True, + 'needs': { + 'svc_systemd:nginx', + }, +} + +for domain, _ in node.metadata.get('letsencrypt/domains').items(): + actions['letsencrypt_ensure-some-certificate_{}'.format(domain)] = { + 'command': '/etc/dehydrated/letsencrypt-ensure-some-certificate {}'.format(domain), + 'unless': '/etc/dehydrated/letsencrypt-ensure-some-certificate {} true'.format(domain), + 'needs': { + 'file:/etc/dehydrated/letsencrypt-ensure-some-certificate', + }, + 'needed_by': { + 'svc_systemd:nginx', + }, + 'triggers': { + 'action:letsencrypt_update_certificates', + }, + } diff --git a/bundles/letsencrypt/metadata.py b/bundles/letsencrypt/metadata.py new file mode 100644 index 0000000..d735ccb --- /dev/null +++ b/bundles/letsencrypt/metadata.py @@ -0,0 +1,16 @@ +defaults = { + 'apt': { + 'packages': { + 'dehydrated': {}, + }, + }, + 'cron': { + 'letsencrypt_renew': '{} 4 * * * root /usr/bin/dehydrated --cron --accept-terms --challenge http-01 > /dev/null'.format((node.magic_number % 60)), + 'letsencrypt_cleanup': '{} 4 * * 0 root /usr/bin/dehydrated --cleanup > /dev/null'.format((node.magic_number % 60)), + }, + 'pacman': { + 'packages': { + 'dehydrated': {}, + }, + }, +} diff --git a/bundles/nginx/files/nginx.conf b/bundles/nginx/files/nginx.conf new file mode 100644 index 0000000..be51300 --- /dev/null +++ b/bundles/nginx/files/nginx.conf @@ -0,0 +1,34 @@ +user www-data; +worker_processes 10; + +pid /var/run/nginx.pid; + + +events { + worker_connections 500; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + charset UTF-8; + override_charset on; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 15; + client_body_timeout 12; + client_header_timeout 12; + send_timeout 10; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + client_body_buffer_size 10K; + client_header_buffer_size 1k; + client_max_body_size 1M; + large_client_header_buffers 2 1k; + + include /etc/nginx/sites/*; +} diff --git a/bundles/nginx/files/port80.conf b/bundles/nginx/files/port80.conf new file mode 100644 index 0000000..90bbd90 --- /dev/null +++ b/bundles/nginx/files/port80.conf @@ -0,0 +1,13 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + location /.well-known/acme-challenge/ { + alias /var/lib/dehydrated/acme-challenges/; + } + + location / { + return 404; + } +} diff --git a/bundles/nginx/files/site_template b/bundles/nginx/files/site_template new file mode 100644 index 0000000..06ddb38 --- /dev/null +++ b/bundles/nginx/files/site_template @@ -0,0 +1,121 @@ +server { +% if domain_aliases: + server_name ${domain} ${' '.join(sorted(domain_aliases))}; +% else: + server_name ${domain}; +% endif + root ${webroot if webroot else '/var/www/{}/'.format(vhost)}; + index index.php index.html index.htm; + + listen 80; + listen [::]:80; + +% if ssl: + location / { + return 308 https://$host$request_uri; + } + +% if ssl == 'letsencrypt': + location /.well-known/acme-challenge/ { + alias /var/lib/dehydrated/acme-challenges/; + } +% endif +} + +server { +% if domain_aliases: + server_name ${domain} ${' '.join(sorted(domain_aliases))}; +% else: + server_name ${domain}; +% endif + root ${webroot if webroot else '/var/www/{}/'.format(vhost)}; + index index.php index.html index.htm; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + +% if ssl == 'letsencrypt': + ssl_certificate /var/lib/dehydrated/certs/${domain}/fullchain.pem; + ssl_certificate_key /var/lib/dehydrated/certs/${domain}/privkey.pem; +% else: + ssl_certificate /etc/nginx/ssl/${vhost}.crt; + ssl_certificate_key /etc/nginx/ssl/${vhost}.key; +% endif + ssl_dhparam /etc/ssl/certs/dhparam.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; +% endif + + resolver 8.8.8.8 8.8.4.4 valid=300s; + resolver_timeout 5s; + + access_log /var/log/nginx/access-${vhost}.log; + error_log /var/log/nginx/error-${vhost}.log; + +% if max_body_size: + client_max_body_size ${max_body_size}; +% elif proxy or php: + client_max_body_size 5M; +% endif + +% if not do_not_set_content_security_headers: + add_header Referrer-Policy same-origin; + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; +% endif + add_header Permissions-Policy interest-cohort=(); + + location /.well-known/acme-challenge/ { + alias /var/lib/dehydrated/acme-challenges/; + } + +% if security_txt: + location = /.well-known/security.txt { + alias /etc/nginx/security.txt.d/${vhost}; + } +% endif + +% if proxy: +% for location, options in proxy.items(): + location ${location} { + proxy_pass ${options['target']}; + proxy_http_version ${options.get('http_version', '1.1')}; + proxy_set_header Host ${domain}; +% if options.get('websockets', False): + proxy_set_header Connection "upgrade"; + proxy_set_header Upgrade $http_upgrade; +% endif + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +% if ssl: + proxy_set_header X-Forwarded-Proto HTTPS; +% endif + proxy_set_header X-Forwarded-Host ${domain}; +% for option, value in options.get('proxy_set_header', {}).items(): + proxy_set_header ${option} ${value}; +% endfor +% if location != '/': + proxy_set_header X-Script-Name ${location}; +% endif + proxy_buffering off; + } +% endfor +% endif + +% if php: + location ~ \.php$ { + include fastcgi.conf; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:/run/php/php${php_version}-fpm.sock; + } +% endif + +% if extras: +<%include file="extras/${node.name}/${vhost}" /> +% endif +} diff --git a/bundles/nginx/files/stub_status b/bundles/nginx/files/stub_status new file mode 100644 index 0000000..7cb9326 --- /dev/null +++ b/bundles/nginx/files/stub_status @@ -0,0 +1,6 @@ +server { + listen 127.0.0.1:22999 default_server; + server_name _; + + stub_status; +} diff --git a/bundles/nginx/items.py b/bundles/nginx/items.py new file mode 100644 index 0000000..c49270d --- /dev/null +++ b/bundles/nginx/items.py @@ -0,0 +1,121 @@ +from datetime import datetime, timedelta + +directories = { + '/etc/nginx/sites': { + 'purge': True, + 'triggers': { + 'svc_systemd:nginx:restart', + }, + }, + '/etc/nginx/ssl': { + 'purge': True, + 'triggers': { + 'svc_systemd:nginx:restart', + }, + }, + '/var/www': {}, +} + +files = { + '/etc/nginx/nginx.conf': { + 'content_type': 'mako', + 'context': { + 'username': 'www-data', + **node.metadata['nginx'], + }, + 'triggers': { + 'svc_systemd:nginx:restart', + }, + }, + '/etc/nginx/sites/stub_status': { + 'triggers': { + 'svc_systemd:nginx:restart', + }, + }, + '/etc/nginx/sites/000-port80.conf': { + 'source': 'port80.conf', + 'triggers': { + 'svc_systemd:nginx:restart', + }, + }, +} + +actions = { + 'nginx-generate-dhparam': { + 'command': 'openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048', + 'unless': 'test -f /etc/ssl/certs/dhparam.pem', + }, +} + +svc_systemd = { + 'nginx': { + 'needs': { + 'action:nginx-generate-dhparam', + 'pkg_apt:nginx', + }, + }, +} + +for vhost, config in node.metadata.get('nginx/vhosts', {}).items(): + files[f'/etc/nginx/sites/{vhost}'] = { + 'source': 'site_template', + 'content_type': 'mako', + 'context': { + 'create_access_log': config.get('access_log', node.metadata.get('nginx/access_log', False)), + 'php_version': node.metadata.get('php/version', ''), + 'vhost': vhost, + **config, + }, + 'needs': set(), + 'needed_by': { + 'svc_systemd:nginx', + 'svc_systemd:nginx:restart', + }, + 'triggers': { + 'svc_systemd:nginx:restart', + }, + } + + if not 'webroot' in config: + directories[f'/var/www/{vhost}'] = {} + + if node.has_bundle('zfs'): + directories[f'/var/www/{vhost}']['needs'] = { + 'bundle:zfs', + } + + directories[f'/var/www/{vhost}'].update(config.get('webroot_config', {})) + + if config.get('ssl', 'letsencrypt') == 'letsencrypt': + files[f'/etc/nginx/sites/{vhost}']['needs'].add('action:letsencrypt_ensure-some-certificate_{}'.format(config['domain'])) + files[f'/etc/nginx/sites/{vhost}']['needed_by'].add('action:letsencrypt_update_certificates') + + elif config.get('ssl', 'letsencrypt'): + files[f'/etc/nginx/ssl/{vhost}.crt'] = { + 'content_type': 'mako', + 'source': 'ssl_template', + 'context': { + 'domain': config['ssl'], + }, + 'needed_by': { + 'svc_systemd:nginx', + 'svc_systemd:nginx:restart', + }, + 'triggers': { + 'svc_systemd:nginx:reload', + }, + } + files[f'/etc/nginx/ssl/{vhost}.key'] = { + 'content': repo.vault.decrypt_file('ssl/{}.key.pem.vault'.format(config['ssl'])), + 'mode': '0600', + 'needed_by': { + 'svc_systemd:nginx', + 'svc_systemd:nginx:restart', + }, + 'triggers': { + 'svc_systemd:nginx:reload', + }, + } + + files[f'/etc/nginx/sites/{vhost}']['needs'].add(f'file:/etc/nginx/ssl/{vhost}.crt') + files[f'/etc/nginx/sites/{vhost}']['needs'].add(f'file:/etc/nginx/ssl/{vhost}.key') diff --git a/bundles/nginx/metadata.py b/bundles/nginx/metadata.py new file mode 100644 index 0000000..2714c32 --- /dev/null +++ b/bundles/nginx/metadata.py @@ -0,0 +1,45 @@ +from bundlewrap.metadata import atomic + +defaults = { + 'apt': { + 'packages': { + 'nginx': {}, + }, + }, + 'nginx': { + 'worker_connections': 768, + }, +} + + +@metadata_reactor.provides( + 'letsencrypt/domains', + 'letsencrypt/reload_after', + 'nginx/vhosts', +) +def letsencrypt(metadata): + if not node.has_bundle('letsencrypt'): + raise DoNotRunAgain + + domains = {} + vhosts = {} + + for vhost, config in metadata.get('nginx/vhosts', {}).items(): + if config.get('ssl', 'letsencrypt') == 'letsencrypt': + domain = config.get('domain', vhost) + domains[domain] = config.get('domain_aliases', set()) + vhosts[vhost] = { + 'ssl': 'letsencrypt', + } + + return { + 'letsencrypt': { + 'domains': domains, + 'reload_after': { + 'nginx', + }, + }, + 'nginx': { + 'vhosts': vhosts, + }, + } diff --git a/bundles/postgresql/items.py b/bundles/postgresql/items.py index 61589b5..e7101c5 100644 --- a/bundles/postgresql/items.py +++ b/bundles/postgresql/items.py @@ -1,7 +1,3 @@ -pkg_apt = { - 'postgresql': {}, -} - if node.has_bundle('zfs'): pkg_apt[postgresql]\ .setdefault('needs', [])\ diff --git a/bundles/postgresql/metadata.py b/bundles/postgresql/metadata.py index 382c899..44c2cb7 100644 --- a/bundles/postgresql/metadata.py +++ b/bundles/postgresql/metadata.py @@ -11,6 +11,11 @@ defaults = { }, 'databases': {}, }, + 'apt': { + 'packages': { + 'postgresql': {}, + }, + }, } if node.has_bundle('zfs'): diff --git a/nodes/mailserver.py b/nodes/mailserver.py index 0967ef4..d870fc8 100644 --- a/nodes/mailserver.py +++ b/nodes/mailserver.py @@ -1 +1,30 @@ -{} +{ + 'hostname': '162.55.188.157', + 'groups': [ + 'debian-10', + ], + 'bundles': [ + 'nginx', + 'letsencrypt', + ], + 'metadata': { + 'nginx': { + 'vhosts': { + 'nextcloud': { + 'domain': 'test.ckn.li', + 'ssl': 'letsencrypt', + 'letsencrypt': { + 'active': True, + 'force_ssl': False, + }, + 'proxy': { + '/': { + 'target': 'https://mail.sublimity.de:443', + 'websocket': True, + }, + }, + }, + }, + }, + }, +}