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 00000000..0f22d0e4
Binary files /dev/null and b/orchestra/static/orchestra/images/add.png differ
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 @@
+
+
+
+