From ba232ec8f438010521075351fba6fe8f2efbffdd Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Fri, 11 Mar 2016 12:19:34 +0000 Subject: [PATCH] Added support for Let's encrypt --- TODO.md | 10 ++ orchestra/admin/html.py | 2 +- orchestra/admin/utils.py | 3 +- orchestra/contrib/domains/admin.py | 14 ++- orchestra/contrib/letsencrypt/actions.py | 98 ++++++++++++++++++ orchestra/contrib/letsencrypt/admin.py | 8 ++ orchestra/contrib/letsencrypt/backends.py | 57 ++++++++++ orchestra/contrib/letsencrypt/forms.py | 32 ++++++ orchestra/contrib/letsencrypt/helpers.py | 48 +++++++++ orchestra/contrib/letsencrypt/settings.py | 11 ++ orchestra/contrib/orchestration/backends.py | 6 +- orchestra/contrib/orchestration/helpers.py | 32 +++--- orchestra/contrib/orchestration/manager.py | 3 +- .../contrib/websites/backends/wordpress.py | 29 ++++++ orchestra/contrib/websites/directives.py | 18 ++-- orchestra/contrib/websites/models.py | 2 +- orchestra/static/orchestra/images/add.png | Bin 0 -> 356 bytes orchestra/static/orchestra/images/add.svg | 89 ++++++++++++++++ 18 files changed, 434 insertions(+), 28 deletions(-) create mode 100644 orchestra/contrib/letsencrypt/actions.py create mode 100644 orchestra/contrib/letsencrypt/admin.py create mode 100644 orchestra/contrib/letsencrypt/backends.py create mode 100644 orchestra/contrib/letsencrypt/forms.py create mode 100644 orchestra/contrib/letsencrypt/helpers.py create mode 100644 orchestra/contrib/letsencrypt/settings.py create mode 100644 orchestra/static/orchestra/images/add.png create mode 100644 orchestra/static/orchestra/images/add.svg diff --git a/TODO.md b/TODO.md index ee444c49..914184a2 100644 --- a/TODO.md +++ b/TODO.md @@ -430,3 +430,13 @@ mkhomedir_helper or create ssh homes with bash.rc and such # Automatically re-run backends until success? only timedout executions? # TODO save serialized versions ob backendoperation.instance in order to allow backend reexecution of deleted objects +# websites active list_display +# account for account.is_active on service is_active filters like systemusers + +# upgrade to django 1.9 and make margins wider +# lets encrypt: DNS vs HTTP challange + +# Warning websites with ssl options without https protocol + + + diff --git a/orchestra/admin/html.py b/orchestra/admin/html.py index d17cefd5..208e0b34 100644 --- a/orchestra/admin/html.py +++ b/orchestra/admin/html.py @@ -6,7 +6,7 @@ MONOSPACE_FONTS = ('Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans M def monospace_format(text): - style="font-family:%s;padding-left:110px;" % MONOSPACE_FONTS + style="font-family:%s;padding-left:110px;white-space:pre-wrap;" % MONOSPACE_FONTS return mark_safe('
%s
' % (style, text)) diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index a48e94c1..86da90c8 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -169,7 +169,8 @@ def get_object_from_url(modeladmin, request): def display_mono(field): def display(self, log): - return monospace_format(escape(getattr(log, field))) + content = getattr(log, field) + return monospace_format(escape(content)) display.short_description = field return display diff --git a/orchestra/contrib/domains/admin.py b/orchestra/contrib/domains/admin.py index 8e03fb25..33b187ef 100644 --- a/orchestra/contrib/domains/admin.py +++ b/orchestra/contrib/domains/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin +from django.core.urlresolvers import reverse from django.db.models.functions import Concat, Coalesce -from django.utils.translation import ugettext_lazy as _ +from django.templatetags.static import static +from django.utils.translation import ugettext, ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin from orchestra.admin.utils import admin_link, change_url @@ -91,8 +93,16 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin): admin_url, title, website.name, site_link) links.append(link) return '
'.join(links) + add_url = reverse('admin:websites_website_add') + add_url += '?account=%i&domains=%i' % (domain.account_id, domain.pk) + context = { + 'title': _("Add website"), + 'url': add_url, + 'image': '' % static('orchestra/images/add.png'), + } + add_link = '%(image)s' % context site_link = get_on_site_link('http://%s' % domain.name) - return _("No website %s") % site_link + return _("No website %s %s") % (add_link, site_link) display_websites.admin_order_field = 'websites__name' display_websites.short_description = _("Websites") display_websites.allow_tags = True diff --git a/orchestra/contrib/letsencrypt/actions.py b/orchestra/contrib/letsencrypt/actions.py new file mode 100644 index 00000000..52118506 --- /dev/null +++ b/orchestra/contrib/letsencrypt/actions.py @@ -0,0 +1,98 @@ +from django.contrib import messages, admin +from django.template.response import TemplateResponse +from django.utils.translation import ungettext, ugettext_lazy as _ + +from orchestra.contrib.orchestration import Operation, helpers + +from .helpers import is_valid_domain, read_live_lineages, configure_cert +from .forms import LetsEncryptForm + + +def letsencrypt(modeladmin, request, queryset): + wildcards = set() + domains = set() + queryset = queryset.prefetch_related('domains') + for website in queryset: + for domain in website.domains.all(): + if domain.name.startswith('*.'): + wildcards.add(domain.name) + else: + domains.add(domain.name) + form = LetsEncryptForm(domains, wildcards, initial={'domains': '\n'.join(domains)}) + action_value = 'letsencrypt' + if request.POST.get('post') == 'generic_confirmation': + form = LetsEncryptForm(domains, wildcards, request.POST) + if form.is_valid(): + cleaned_data = form.cleaned_data + domains = set(cleaned_data['domains']) + operations = [] + for website in queryset: + website_domains = [d.name for d in website.domains.all()] + encrypt_domains = set() + for domain in domains: + if is_valid_domain(domain, website_domains, wildcards): + encrypt_domains.add(domain) + website.encrypt_domains = encrypt_domains + operations.extend(Operation.create_for_action(website, 'encrypt')) + modeladmin.log_change(request, request.user, _("Encrypted!")) + if not operations: + messages.error(request, _("No backend operation has been executed.")) + else: + logs = Operation.execute(operations) + helpers.message_user(request, logs) + live_lineages = read_live_lineages(logs) + errors = 0 + successes = 0 + no_https = 0 + for website in queryset: + try: + configure_cert(website, live_lineages) + except LookupError: + errors += 1 + messages.error(request, _("No lineage found for website %s") % website.name) + else: + if website.protocol == website.HTTP: + no_https += 1 + website.save(update_fields=('name',)) + successes += 1 + context = { + 'name': website.name, + 'errors': errors, + 'successes': successes, + 'no_https': no_https + } + if errors: + msg = ungettext( + _("No lineages found for websites {name}."), + _("No lineages found for {errors} websites."), + errors) + messages.error(request, msg % context) + if successes: + msg = ungettext( + _("{name} website has successfully been encrypted."), + _("{successes} websites have been successfully encrypted."), + successes) + messages.success(request, msg.format(**context)) + if no_https: + msg = ungettext( + _("{name} website does not have HTTPS protocol enabled."), + _("{no_https} websites do not have HTTPS protocol enabled."), + no_https) + messages.warning(request, msg.format(**context)) + return + opts = modeladmin.model._meta + app_label = opts.app_label + context = { + 'title': _("Let's encrypt!"), + 'action_name': _("Encrypt"), + 'action_value': action_value, + 'queryset': queryset, + 'opts': opts, + 'obj': website if len(queryset) == 1 else None, + 'app_label': app_label, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + 'form': form, + } + return TemplateResponse(request, 'admin/orchestra/generic_confirmation.html', + context, current_app=modeladmin.admin_site.name) +letsencrypt.short_description = "Let's encrypt!" diff --git a/orchestra/contrib/letsencrypt/admin.py b/orchestra/contrib/letsencrypt/admin.py new file mode 100644 index 00000000..1f2ae624 --- /dev/null +++ b/orchestra/contrib/letsencrypt/admin.py @@ -0,0 +1,8 @@ +from orchestra.admin.utils import insertattr +from orchestra.contrib.websites.admin import WebsiteAdmin + +from .import actions + + +insertattr(WebsiteAdmin, 'change_view_actions', actions.letsencrypt) +insertattr(WebsiteAdmin, 'actions', actions.letsencrypt) diff --git a/orchestra/contrib/letsencrypt/backends.py b/orchestra/contrib/letsencrypt/backends.py new file mode 100644 index 00000000..d5599a22 --- /dev/null +++ b/orchestra/contrib/letsencrypt/backends.py @@ -0,0 +1,57 @@ +import os +import textwrap + +from orchestra.contrib.orchestration import ServiceController + +from . import settings + + +class LetsEncryptController(ServiceController): + model = 'websites.Website' + verbose_name = "Let's encrypt!" + actions = ('encrypt',) + + def prepare(self): + super().prepare() + self.cleanup = [] + context = { + 'letsencrypt_auto': settings.LETSENCRYPT_AUTO_PATH, + } + self.append(textwrap.dedent(""" + %(letsencrypt_auto)s --non-interactive --no-self-upgrade \\ + --keep --expand --agree-tos certonly --webroot \\""") % context + ) + + def encrypt(self, website): + context = self.get_context(website) + self.append(" --webroot-path %(webroot)s \\" % context) + self.append(" --email %(email)s \\" % context) + self.append(" -d %(domains)s \\" % context) + self.cleanup.append("rm -rf %(webroot)s/.well-known" % context) + + def commit(self): + self.append(" || exit_code=$?") + for cleanup in self.cleanup: + self.append(cleanup) + context = { + 'letsencrypt_live': os.path.normpath(settings.LETSENCRYPT_LIVE_PATH), + } + self.append(textwrap.dedent(""" + # Report back the lineages in order to infere each certificate path + echo '' + find %(letsencrypt_live)s/* -maxdepth 0 + echo ''""") % context + ) + super().commit() + + def get_context(self, website): + try: + content = website.content_set.get(path='/') + except website.content_set.model.DoesNotExist: + raise + return { + 'letsencrypt_auto': settings.LETSENCRYPT_AUTO_PATH, + 'webroot': content.webapp.get_path(), + 'email': website.account.email, + 'domains': ' \\\n -d '.join(website.encrypt_domains), + } diff --git a/orchestra/contrib/letsencrypt/forms.py b/orchestra/contrib/letsencrypt/forms.py new file mode 100644 index 00000000..ef8cbf39 --- /dev/null +++ b/orchestra/contrib/letsencrypt/forms.py @@ -0,0 +1,32 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ungettext, ugettext_lazy as _ + +from .helpers import is_valid_domain + + +class LetsEncryptForm(forms.Form): + domains = forms.CharField(widget=forms.Textarea) + + def __init__(self, domains, wildcards, *args, **kwargs): + self.domains = domains + self.wildcards = wildcards + super().__init__(*args, **kwargs) + if wildcards: + help_text = _("You can add domains maching the following wildcards: %s") + self.fields['domains'].help_text += help_text % ', '.join(wildcards) + + def clean_domains(self): + domains = self.cleaned_data['domains'].split() + cleaned_domains = set() + for domain in domains: + domain = domain.strip() + if domain not in self.domains: + domain = domain.strip() + if not is_valid_domain(domain, self.domains, self.wildcards): + raise ValidationError(_( + "%s domain is not included on selected websites, " + "nor matches with any wildcard domain.") % domain + ) + cleaned_domains.add(domain) + return cleaned_domains diff --git a/orchestra/contrib/letsencrypt/helpers.py b/orchestra/contrib/letsencrypt/helpers.py new file mode 100644 index 00000000..9577d577 --- /dev/null +++ b/orchestra/contrib/letsencrypt/helpers.py @@ -0,0 +1,48 @@ +import os + + +def is_valid_domain(domain, existing, wildcards): + if domain in existing: + return True + for wildcard in wildcards: + if domain.startswith(wildcard.lstrip('*')) and domain.count('.') == wildcard.count('.'): + return True + return False + + +def read_live_lineages(logs): + live_lineages = {} + for log in logs: + reading = False + for line in log.stdout.splitlines(): + line = line.strip() + if line == '': + break + if reading: + live_lineages[line.split('/')[-1]] = line + elif line == '': + reading = True + return live_lineages + + +def configure_cert(website, live_lineages): + for domain in website.domains.all(): + try: + path = live_lineages[domain.name] + except KeyError: + pass + else: + maps = ( + ('ssl-ca', os.path.join(path, 'chain.pem')), + ('ssl-cert', os.path.join(path, 'cert.pem')), + ('ssl-key', os.path.join(path, 'privkey.pem')), + ) + for directive, path in maps: + try: + directive = website.directives.get(name=directive) + except website.directives.model.DoesNotExist: + directive = website.directives.model(name=directive, website=website) + directive.value = path + directive.save() + return + raise LookupError("Lineage not found") diff --git a/orchestra/contrib/letsencrypt/settings.py b/orchestra/contrib/letsencrypt/settings.py new file mode 100644 index 00000000..5883d84a --- /dev/null +++ b/orchestra/contrib/letsencrypt/settings.py @@ -0,0 +1,11 @@ +from orchestra.contrib.settings import Setting + + +LETSENCRYPT_AUTO_PATH = Setting('LETSENCRYPT_AUTO_PATH', + '/home/httpd/letsencrypt/letsencrypt-auto' +) + + +LETSENCRYPT_LIVE_PATH = Setting('LETSENCRYPT_LIVE_PATH', + '/etc/letsencrypt/live' +) diff --git a/orchestra/contrib/orchestration/backends.py b/orchestra/contrib/orchestration/backends.py index f8f9651d..a7aaa0f3 100644 --- a/orchestra/contrib/orchestration/backends.py +++ b/orchestra/contrib/orchestration/backends.py @@ -113,8 +113,10 @@ class ServiceBackend(plugins.Plugin, metaclass=ServiceMount): related = obj for attribute in field.split('__'): related = getattr(related, attribute) - return related - return None + if type(related).__name__ == 'RelatedManager': + return related.all() + return [related] + return [] @classmethod def get_backends(cls, instance=None, action=None): diff --git a/orchestra/contrib/orchestration/helpers.py b/orchestra/contrib/orchestration/helpers.py index 601ea43f..fb8da5cc 100644 --- a/orchestra/contrib/orchestration/helpers.py +++ b/orchestra/contrib/orchestration/helpers.py @@ -123,32 +123,40 @@ def message_user(request, logs): async_msg = '' if async: async_msg = ungettext( - _('{async} backend is running on the background'), + _('{name} is running on the background'), _('{async} backends are running on the background'), async) if errors: - msg = ungettext( - _('{errors} out of {total} backend has fail to execute'), - _('{errors} out of {total} backends have fail to execute'), - errors) + if total == 1: + msg = _('{name} has fail to execute'), + else: + msg = ungettext( + _('{errors} out of {total} backends has fail to execute'), + _('{errors} out of {total} backends have fail to execute'), + errors) if async_msg: msg += ', ' + str(async_msg) - msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url) + msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url, + name=log.backend) messages.error(request, mark_safe(msg + '.')) elif successes: if async_msg: - msg = ungettext( - _('{successes} out of {total} backend has been executed'), - _('{successes} out of {total} backends have been executed'), - successes) + if total == 1: + msg = _('{name} has been executed') + else: + msg = ungettext( + _('{successes} out of {total} backends has been executed'), + _('{successes} out of {total} backends have been executed'), + successes) msg += ', ' + str(async_msg) else: msg = ungettext( - _('{total} backend has been executed'), + _('{name} has been executed'), _('{total} backends have been executed'), total) msg = msg.format( - total=total, url=url, async_url=async_url, async=async, successes=successes + total=total, url=url, async_url=async_url, async=async, successes=successes, + name=log.backend ) messages.success(request, mark_safe(msg + '.')) else: diff --git a/orchestra/contrib/orchestration/manager.py b/orchestra/contrib/orchestration/manager.py index b5f6cfca..c955ee44 100644 --- a/orchestra/contrib/orchestration/manager.py +++ b/orchestra/contrib/orchestration/manager.py @@ -154,8 +154,7 @@ def collect(instance, action, **kwargs): if backend_cls.is_main(instance): instances = [(instance, action)] else: - candidate = backend_cls.get_related(instance) - if candidate: + for candidate in backend_cls.get_related(instance): if candidate.__class__.__name__ == 'ManyRelatedManager': if 'pk_set' in kwargs: # m2m_changed signal diff --git a/orchestra/contrib/websites/backends/wordpress.py b/orchestra/contrib/websites/backends/wordpress.py index 90d84460..d138e9b7 100644 --- a/orchestra/contrib/websites/backends/wordpress.py +++ b/orchestra/contrib/websites/backends/wordpress.py @@ -1,3 +1,4 @@ +import os import textwrap from orchestra.contrib.orchestration import ServiceController @@ -35,3 +36,31 @@ class WordPressURLController(ServiceController): 'url': content.get_absolute_url(), 'db_name': content.webapp.data.get('db_name'), } + + +class WordPressForceSSLController(ServiceController): + """ sets FORCE_SSL_ADMIN to true when website supports HTTPS """ + verbose_name = "WordPress Force SSL" + model = 'websites.Content' + related_models = ( + ('websites.Website', 'content_set'), + ) + default_route_match = "content.webapp.type == 'wordpress-php'" + + def save(self, content): + context = self.get_context(content) + site = content.website + if site.protocol in (site.HTTP_AND_HTTPS, site.HTTPS_ONLY, site.HTTPS): + self.append(textwrap.dedent(""" + if [[ ! $(grep FORCE_SSL_ADMIN %(wp_conf_path)s) ]]; then + echo "Enabling FORCE_SSL_ADMIN for %(webapp_name)s webapp" + sed -i -E "s#^(define\('NONCE_SALT.*)#\\1\\n\\ndefine\('FORCE_SSL_ADMIN', true\);#" \\ + %(wp_conf_path)s + fi""") % context + ) + + def get_context(self, content): + return { + 'webapp_name': content.webapp.name, + 'wp_conf_path': os.path.join(content.webapp.get_path(), 'wp-config.php'), + } diff --git a/orchestra/contrib/websites/directives.py b/orchestra/contrib/websites/directives.py index 86611b01..190f1adc 100644 --- a/orchestra/contrib/websites/directives.py +++ b/orchestra/contrib/websites/directives.py @@ -21,7 +21,7 @@ class SiteDirective(plugins.Plugin, metaclass=plugins.PluginMount): help_text = "" unique_name = False unique_value = False - unique_location = False + is_location = False @classmethod @lru_cache() @@ -62,8 +62,10 @@ class SiteDirective(plugins.Plugin, metaclass=plugins.PluginMount): value = directive.get('value', None) # location uniqueness location = None - if self.unique_location and value is not None: - location = normurlpath(directive['value'].split()[0]) + if self.is_location and value is not None: + if not value and self.is_location: + value = '/' + location = normurlpath(value.split()[0]) if location is not None and location in locations: errors['value'].append(ValidationError( "Location '%s' already in use by other content/directive." % location @@ -89,6 +91,8 @@ class SiteDirective(plugins.Plugin, metaclass=plugins.PluginMount): def validate(self, directive): directive.value = directive.value.strip() + if not directive.value and self.is_location: + directive.value = '/' if self.regex and not re.match(self.regex, directive.value): raise ValidationError({ 'value': ValidationError(_("'%(value)s' does not match %(regex)s."), @@ -106,7 +110,7 @@ class Redirect(SiteDirective): regex = r'^[^ ]*\s[^ ]+$' group = SiteDirective.HTTPD unique_value = True - unique_location = True + is_location = True def validate(self, directive): """ inserts default url-path if not provided """ @@ -164,7 +168,7 @@ class SecRuleRemove(SiteDirective): help_text = _("Space separated ModSecurity rule IDs.") regex = r'^[0-9\s]+$' group = SiteDirective.SEC - unique_location = True + is_location = True class SecEngine(SecRuleRemove): @@ -172,7 +176,7 @@ class SecEngine(SecRuleRemove): verbose_name = _("SecRuleEngine Off") help_text = _("URL-path with disabled modsecurity engine.") regex = r'^/[^ ]*$' - unique_location = False + is_location = False class WordPressSaaS(SiteDirective): @@ -182,7 +186,7 @@ class WordPressSaaS(SiteDirective): group = SiteDirective.SAAS regex = r'^/[^ ]*$' unique_value = True - unique_location = True + is_location = True class DokuWikiSaaS(WordPressSaaS): diff --git a/orchestra/contrib/websites/models.py b/orchestra/contrib/websites/models.py index ea73bdfa..aa31dc6d 100644 --- a/orchestra/contrib/websites/models.py +++ b/orchestra/contrib/websites/models.py @@ -117,7 +117,7 @@ class WebsiteDirective(models.Model): related_name='directives') name = models.CharField(_("name"), max_length=128, db_index=True, choices=SiteDirective.get_choices()) - value = models.CharField(_("value"), max_length=256) + value = models.CharField(_("value"), max_length=256, blank=True) def __str__(self): return self.name diff --git a/orchestra/static/orchestra/images/add.png b/orchestra/static/orchestra/images/add.png new file mode 100644 index 0000000000000000000000000000000000000000..0f22d0e4d95ec708727c4d01b8ca506a182f3d49 GIT binary patch literal 356 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u@pObhHwBu4M$1`kk47*5m^jW ze;tGwoit`w00kvWTq8Eak7 zF|~Apy|+^#gX{TP8$lVVrgkU!<5QMR`=)SuS{s|<@-6n>jm^BizY21)IHTge)K1!N zFYvKk`V7~ryZi<39-c5`c$;|ZNQ}a(V?t-b6{YsxFEe7exy;n({RZofC1GnB0(7P| z6j`3xG&?VT?ZGXXzd0u|_?+WocwuM9+;B2i_E>y5XR2@~$6krR^`Y7}bKWp-*u3{> z{->W`T9Y>BxE#}J|EnW*ru*op+b{bc-}a@oKnbwUtm*srk_4fTctDJW8 wYGM5B2fL@goF`J{(R)^w>+$y2m%|(U)4wHL_v6b21`h*+r>mdKI;Vst0I=SVIRF3v literal 0 HcmV?d00001 diff --git a/orchestra/static/orchestra/images/add.svg b/orchestra/static/orchestra/images/add.svg new file mode 100644 index 00000000..615c787c --- /dev/null +++ b/orchestra/static/orchestra/images/add.svg @@ -0,0 +1,89 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + +