529 lines
18 KiB
Python
529 lines
18 KiB
Python
# hobo - portal to configure and deploy applications
|
|
# Copyright (C) 2015-2019 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import datetime
|
|
import json
|
|
import random
|
|
import re
|
|
import socket
|
|
import time
|
|
import urllib.parse
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.postgres.fields import JSONField
|
|
from django.core.cache import cache
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import URLValidator
|
|
from django.db import models
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.encoding import force_text
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import ugettext_lazy as _
|
|
|
|
from .utils import Zone, get_installed_services
|
|
|
|
SECRET_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)'
|
|
FLOAT_RE = re.compile(r'^\s*[0-9]+\.[0-9]+\s*')
|
|
|
|
|
|
AUTO_VARIABLES = (
|
|
'default_from_email',
|
|
'global_email_prefix',
|
|
'email_signature',
|
|
'email_sender_name',
|
|
'global_title',
|
|
'robots_txt',
|
|
'meta_description',
|
|
'meta_keywords',
|
|
'sms_url',
|
|
'sms_sender',
|
|
)
|
|
|
|
|
|
class Variable(models.Model):
|
|
name = models.CharField(max_length=100, verbose_name=_('name'))
|
|
label = models.CharField(max_length=100, blank=True, verbose_name=_('label'))
|
|
value = models.TextField(
|
|
verbose_name=_('value'), blank=True, help_text=_('start with [ or { for a JSON document')
|
|
)
|
|
auto = models.BooleanField(default=False)
|
|
service_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE)
|
|
service_pk = models.PositiveIntegerField(null=True)
|
|
service = GenericForeignKey('service_type', 'service_pk')
|
|
last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
|
|
|
|
def get_field_label(self):
|
|
if self.label:
|
|
return self.label
|
|
return self.name
|
|
|
|
def _parse_value_as_json(self):
|
|
if self.value and (
|
|
self.value[0] in '{[' or self.value in ('true', 'false', 'null') or FLOAT_RE.match(self.value)
|
|
):
|
|
try:
|
|
return json.loads(self.value)
|
|
except ValueError:
|
|
raise ValidationError('invalid JSON document')
|
|
return self.value
|
|
|
|
def json_getter(self):
|
|
try:
|
|
return self._parse_value_as_json()
|
|
except ValidationError:
|
|
return self.value
|
|
|
|
def json_setter(self, value):
|
|
self.value = json.dumps(value)
|
|
|
|
json = property(json_getter, json_setter)
|
|
|
|
def clean(self):
|
|
self._parse_value_as_json()
|
|
|
|
|
|
class ServiceBase(models.Model):
|
|
class Meta:
|
|
abstract = True
|
|
|
|
title = models.CharField(_('Title'), max_length=50)
|
|
slug = models.SlugField(_('Slug'))
|
|
base_url = models.CharField(_('Base URL'), max_length=200, validators=[URLValidator()])
|
|
legacy_urls = JSONField(null=True, default=list, blank=True)
|
|
secret_key = models.CharField(_('Secret Key'), max_length=60)
|
|
template_name = models.CharField(_('Template'), max_length=60, blank=True)
|
|
secondary = models.BooleanField(_('Secondary Service'), default=False)
|
|
|
|
last_operational_check_timestamp = models.DateTimeField(null=True)
|
|
last_operational_success_timestamp = models.DateTimeField(null=True)
|
|
last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
|
|
|
|
variables = GenericRelation(Variable, content_type_field='service_type', object_id_field='service_pk')
|
|
|
|
@classmethod
|
|
def is_enabled(cls):
|
|
return True
|
|
|
|
def is_operational(self):
|
|
return (
|
|
self.last_operational_success_timestamp is not None
|
|
and self.last_operational_success_timestamp == self.last_operational_check_timestamp
|
|
)
|
|
|
|
def check_operational(self):
|
|
once_now = now()
|
|
self.last_operational_check_timestamp = once_now
|
|
try:
|
|
zone = self.get_admin_zones()[0]
|
|
response = requests.get(zone.href, timeout=5, allow_redirects=False)
|
|
response.raise_for_status()
|
|
self.last_operational_success_timestamp = once_now
|
|
except requests.RequestException:
|
|
pass
|
|
self.save(update_fields=('last_operational_check_timestamp', 'last_operational_success_timestamp'))
|
|
|
|
def wants_frequent_checks(self):
|
|
# decides if a "being deployed..." spinner should be displayed (and
|
|
# automatically hidden) next to the service.
|
|
if self.last_operational_success_timestamp is not None:
|
|
# if the service has been marked as operational, we don't need a
|
|
# spinner at all.
|
|
return False
|
|
if self.last_operational_check_timestamp is None:
|
|
# if the service has never been checked, sure we wants a spinner.
|
|
return True
|
|
two_minutes = datetime.timedelta(minutes=2)
|
|
# monitor actively for two minutes max.
|
|
return (self.last_operational_check_timestamp - self.last_update_timestamp) < two_minutes
|
|
|
|
def as_dict(self):
|
|
as_dict = dict([(x, y) for (x, y) in self.__dict__.items() if isinstance(y, (int, str))])
|
|
as_dict['base_url'] = self.get_base_url_path()
|
|
if self.legacy_urls:
|
|
as_dict['legacy_urls'] = self.legacy_urls
|
|
as_dict['service-id'] = self.Extra.service_id
|
|
as_dict['service-label'] = force_text(self.Extra.service_label)
|
|
as_dict['variables'] = dict(((v.name, v.json) for v in self.variables.all()))
|
|
as_dict['secondary'] = self.secondary
|
|
if self.get_saml_sp_metadata_url():
|
|
as_dict['saml-sp-metadata-url'] = self.get_saml_sp_metadata_url()
|
|
if self.get_saml_idp_metadata_url():
|
|
as_dict['saml-idp-metadata-url'] = self.get_saml_idp_metadata_url()
|
|
if self.get_backoffice_menu_url():
|
|
as_dict['backoffice-menu-url'] = self.get_backoffice_menu_url()
|
|
if self.get_provisionning_url():
|
|
as_dict['provisionning-url'] = self.get_provisionning_url()
|
|
return as_dict
|
|
|
|
@property
|
|
def name(self):
|
|
return self.title
|
|
|
|
def clean(self, *args, **kwargs):
|
|
for service in get_installed_services():
|
|
if self.pk == service.pk:
|
|
continue
|
|
if service.slug == self.slug:
|
|
raise ValidationError(_('This slug is already used. It must be unique.'))
|
|
if service.title == self.title and service.secondary is False and self.secondary is False:
|
|
raise ValidationError(_('This title is already used. It must be unique.'))
|
|
return super(ServiceBase, self).clean(*args, **kwargs)
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.base_url = self.base_url.strip().lower()
|
|
if not self.base_url.endswith('/'):
|
|
self.base_url += '/'
|
|
if not self.secret_key:
|
|
self.secret_key = get_random_string(50, SECRET_CHARS)
|
|
|
|
is_new = self.id is None
|
|
super(ServiceBase, self).save(*args, **kwargs)
|
|
|
|
if is_new and settings.SERVICE_EXTRA_VARIABLES:
|
|
for variable in settings.SERVICE_EXTRA_VARIABLES.get(self.Extra.service_id, []):
|
|
v = Variable()
|
|
if type(variable) is dict:
|
|
v.name = variable.get('name')
|
|
v.label = variable.get('label')
|
|
else:
|
|
v.name = variable
|
|
v.service = self
|
|
v.save()
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return None
|
|
|
|
def get_saml_idp_metadata_url(self):
|
|
return None
|
|
|
|
def get_base_url_path(self):
|
|
base_url = self.base_url
|
|
if not base_url.endswith('/'):
|
|
base_url += '/'
|
|
return base_url
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return None
|
|
|
|
def get_provisionning_url(self):
|
|
return self.get_base_url_path() + '__provision__/'
|
|
|
|
def is_resolvable(self):
|
|
try:
|
|
netloc = urllib.parse.urlparse(self.base_url).netloc
|
|
if netloc and socket.gethostbyname(netloc):
|
|
return True
|
|
except socket.gaierror:
|
|
return False
|
|
|
|
def has_valid_certificate(self):
|
|
if not self.is_resolvable():
|
|
return False
|
|
try:
|
|
requests.get(self.base_url, timeout=5, verify=True, allow_redirects=False)
|
|
return True
|
|
except requests.exceptions.SSLError:
|
|
return False
|
|
except requests.exceptions.ConnectionError:
|
|
return False
|
|
|
|
def is_running(self):
|
|
if not self.is_resolvable():
|
|
return False
|
|
r = requests.get(self.get_admin_zones()[0].href, timeout=5, verify=False, allow_redirects=False)
|
|
return r.status_code >= 200 and r.status_code < 400
|
|
|
|
def security_data(self):
|
|
security_data = {
|
|
'level': 'NaN',
|
|
'label': '',
|
|
}
|
|
if not self.is_resolvable():
|
|
return security_data
|
|
security_data['level'] = 0
|
|
resp = requests.get(self.base_url, timeout=5, verify=False, allow_redirects=False)
|
|
missing_headers = []
|
|
for header in (
|
|
'X-Content-Type-Options',
|
|
'X-Frame-Options',
|
|
'X-XSS-Protection',
|
|
'Strict-Transport-Security',
|
|
):
|
|
if not resp.headers.get(header):
|
|
missing_headers.append(header)
|
|
if missing_headers:
|
|
security_data['level'] = len(missing_headers)
|
|
security_data['label'] = _('Missing headers: %s') % ', '.join(missing_headers)
|
|
else:
|
|
security_data['label'] = _('HTTP security headers are set.')
|
|
return security_data
|
|
|
|
def get_health_dict(self):
|
|
properties = [
|
|
('is_resolvable', 120),
|
|
('has_valid_certificate', 3600),
|
|
('is_running', 60),
|
|
('is_operational', 60),
|
|
('security_data', 60),
|
|
]
|
|
result = {}
|
|
for name, cache_duration in properties:
|
|
cache_key = '%s_%s' % (self.slug, name)
|
|
value = cache.get(cache_key)
|
|
if value is None:
|
|
value = getattr(self, name)()
|
|
cache.set(cache_key, value, cache_duration * (0.5 + random.random()))
|
|
result[name] = value
|
|
return result
|
|
|
|
def change_base_url(self, base_url):
|
|
service_dict = self.as_dict()
|
|
timestamp = datetime.datetime.now()
|
|
legacy_urls = {
|
|
'base_url': service_dict['base_url'],
|
|
'timestamp': str(time.mktime(timestamp.timetuple()) + timestamp.microsecond / 1e6),
|
|
}
|
|
for url_key in (
|
|
'saml-sp-metadata-url',
|
|
'saml-idp-metadata-url',
|
|
'backoffice-menu-url',
|
|
'provisionning-url',
|
|
):
|
|
if url_key in service_dict:
|
|
legacy_urls[url_key] = service_dict[url_key]
|
|
if not self.legacy_urls:
|
|
self.legacy_urls = []
|
|
self.legacy_urls.insert(0, legacy_urls)
|
|
self.base_url = base_url
|
|
|
|
|
|
class Authentic(ServiceBase):
|
|
use_as_idp_for_self = models.BooleanField(verbose_name=_('Use as IdP'), default=False)
|
|
|
|
class Meta:
|
|
verbose_name = _('Authentic Identity Provider')
|
|
verbose_name_plural = _('Authentic Identity Providers')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'authentic'
|
|
service_label = _('Authentic')
|
|
service_default_slug = 'idp'
|
|
|
|
def get_admin_zones(self):
|
|
return [
|
|
Zone(_('User Management'), 'users', self.get_base_url_path() + 'manage/users/'),
|
|
Zone(_('Role Management'), 'roles', self.get_base_url_path() + 'manage/roles/'),
|
|
]
|
|
|
|
def get_saml_idp_metadata_url(self):
|
|
return self.get_base_url_path() + 'idp/saml2/metadata'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'manage/menu.json'
|
|
|
|
def get_provisionning_url(self):
|
|
return None
|
|
|
|
|
|
class Wcs(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('w.c.s. Web Forms')
|
|
verbose_name_plural = _('w.c.s. Web Forms')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'wcs'
|
|
service_label = _('w.c.s.')
|
|
service_default_slug = 'eservices'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'backoffice/menu.json'
|
|
|
|
def get_admin_zones(self):
|
|
return [
|
|
Zone(self.title, 'webforms', self.get_base_url_path() + 'admin/'),
|
|
]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'saml/metadata'
|
|
|
|
|
|
class Passerelle(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Passerelle')
|
|
verbose_name_plural = _('Passerelle')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'passerelle'
|
|
service_label = _('Passerelle')
|
|
service_default_slug = 'passerelle'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'webservices', self.get_base_url_path() + 'manage/')]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'manage/menu.json'
|
|
|
|
|
|
class Combo(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Combo Portal')
|
|
verbose_name_plural = _('Combo Portals')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'combo'
|
|
service_label = _('Combo')
|
|
service_default_slug = 'portal'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'portal', self.get_base_url_path() + 'manage/')]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'manage/menu.json'
|
|
|
|
|
|
class Fargo(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Fargo document box')
|
|
verbose_name_plural = _('Fargo document box')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'fargo'
|
|
service_label = _('Fargo')
|
|
service_default_slug = 'porte-doc'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'document-box', self.get_base_url_path() + 'admin/')]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
|
|
class Welco(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Welco Mail Channel')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'welco'
|
|
service_label = 'Welco'
|
|
service_default_slug = 'courrier'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'multichannel-guichet', self.get_base_url_path() + 'admin/')]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'menu.json'
|
|
|
|
|
|
class Chrono(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Chrono Agendas')
|
|
verbose_name_plural = _('Chrono Agendas')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'chrono'
|
|
service_label = _('Chrono')
|
|
service_default_slug = 'agendas'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'calendar', self.get_base_url_path() + 'manage/')]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'manage/menu.json'
|
|
|
|
|
|
class Hobo(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Hobo Deployment Server')
|
|
verbose_name_plural = _('Hobo Deployment Servers')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'hobo'
|
|
service_label = _('Deployment Server')
|
|
service_default_slug = 'hobo'
|
|
|
|
# historically an hobo instance did not store information about itself,
|
|
# it only stored info about other hobo's.
|
|
# now we also store info about an instance on the instance itself, via local=True
|
|
local = models.BooleanField(default=False)
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'hobo', self.get_base_url_path())]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
if self.local:
|
|
return self.get_base_url_path() + 'menu.json'
|
|
return None
|
|
|
|
def as_dict(self):
|
|
res = super().as_dict()
|
|
if self.local:
|
|
del res['id']
|
|
del res['secondary']
|
|
del res['service-label']
|
|
del res['template_name']
|
|
del res['variables']
|
|
return res
|
|
|
|
|
|
class BiJoe(ServiceBase):
|
|
class Meta:
|
|
verbose_name = _('Statistics')
|
|
verbose_name_plural = _('Statistics')
|
|
ordering = ['title']
|
|
|
|
class Extra:
|
|
service_id = 'bijoe'
|
|
service_label = _('Statistics')
|
|
service_default_slug = 'statistics'
|
|
|
|
def get_admin_zones(self):
|
|
return [Zone(self.title, 'bijoe', self.get_base_url_path())]
|
|
|
|
def get_saml_sp_metadata_url(self):
|
|
return self.get_base_url_path() + 'accounts/mellon/metadata/'
|
|
|
|
def get_backoffice_menu_url(self):
|
|
return self.get_base_url_path() + 'manage/menu.json'
|
|
|
|
|
|
AVAILABLE_SERVICES = [Authentic, Wcs, Passerelle, Combo, Fargo, Welco, Chrono, BiJoe, Hobo]
|