You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
222 lines
8.3 KiB
222 lines
8.3 KiB
from base64 import b64encode
|
|
import datetime
|
|
import hashlib
|
|
import hmac
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
from typing import Dict, List
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
from zope import component
|
|
from zope.interface import implementer
|
|
|
|
from .VHostManager import VHostManager
|
|
from ceo_common.errors import CloudStackAPIError, InvalidDomainError, InvalidIPError
|
|
from ceo_common.logger_factory import logger_factory
|
|
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \
|
|
IMailService
|
|
from ceo_common.model import Term
|
|
import ceo_common.utils as utils
|
|
|
|
logger = logger_factory(__name__)
|
|
|
|
|
|
@implementer(ICloudService)
|
|
class CloudService:
|
|
VALID_DOMAIN_RE = re.compile(r'^(?:[0-9a-z-]+\.)+[a-z]+$')
|
|
|
|
def __init__(self):
|
|
cfg = component.getUtility(IConfig)
|
|
self.api_key = cfg.get('cloudstack_api_key')
|
|
self.secret_key = cfg.get('cloudstack_secret_key')
|
|
self.base_url = cfg.get('cloudstack_base_url')
|
|
self.members_domain = 'Members'
|
|
self.vhost_mgr = VHostManager(
|
|
vhost_dir=cfg.get('cloud vhosts_config_dir'),
|
|
)
|
|
self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account')
|
|
self.vhost_domain = cfg.get('cloud vhosts_members_domain')
|
|
self.vhost_ip_min = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_min'))
|
|
self.vhost_ip_max = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_max'))
|
|
|
|
state_dir = '/run/ceod'
|
|
if not os.path.isdir(state_dir):
|
|
os.mkdir(state_dir)
|
|
self.pending_deletions_file = os.path.join(state_dir, 'cloudstack_pending_account_deletions.json')
|
|
|
|
def _create_url(self, params: Dict[str, str]) -> str:
|
|
# See https://docs.cloudstack.apache.org/en/latest/developersguide/dev.html#the-cloudstack-api
|
|
if 'apiKey' not in params and 'apikey' not in params:
|
|
params['apiKey'] = self.api_key
|
|
params['response'] = 'json'
|
|
request_str = '&'.join(
|
|
key + '=' + quote(val)
|
|
for key, val in params.items()
|
|
)
|
|
sig_str = '&'.join(
|
|
key.lower() + '=' + quote(val).lower()
|
|
for key, val in sorted(params.items())
|
|
)
|
|
sig = hmac.new(self.secret_key.encode(), sig_str.encode(), hashlib.sha1).digest()
|
|
encoded_sig = b64encode(sig).decode()
|
|
url = self.base_url + '?' + request_str + '&signature=' + quote(encoded_sig)
|
|
return url
|
|
|
|
def _get_domain_id(self, domain_name: str) -> str:
|
|
url = self._create_url({
|
|
'command': 'listDomains',
|
|
'details': 'min',
|
|
'name': domain_name,
|
|
})
|
|
resp = requests.get(url)
|
|
resp.raise_for_status()
|
|
d = resp.json()['listdomainsresponse']
|
|
assert d['count'] == 1, 'there should be one domain found'
|
|
return d['domain'][0]['id']
|
|
|
|
def _get_all_accounts(self, domain_id: str) -> List[Dict]:
|
|
url = self._create_url({
|
|
'command': 'listAccounts',
|
|
'domainid': domain_id,
|
|
'details': 'min',
|
|
})
|
|
resp = requests.get(url)
|
|
resp.raise_for_status()
|
|
d = resp.json()['listaccountsresponse']
|
|
if 'account' not in d:
|
|
# The API returns an empty dict if there are no accounts
|
|
return []
|
|
return d['account']
|
|
|
|
def _delete_account(self, account_id: str):
|
|
url = self._create_url({
|
|
'command': 'deleteAccount',
|
|
'id': account_id,
|
|
})
|
|
resp = requests.post(url)
|
|
resp.raise_for_status()
|
|
|
|
def create_account(self, user: IUser):
|
|
domain_id = self._get_domain_id(self.members_domain)
|
|
|
|
url = self._create_url({
|
|
'command': 'ldapCreateAccount',
|
|
'accounttype': '0',
|
|
'domainid': domain_id,
|
|
'username': user.uid,
|
|
})
|
|
resp = requests.post(url)
|
|
d = resp.json()['createaccountresponse']
|
|
if not resp.ok:
|
|
raise CloudStackAPIError(d['errortext'])
|
|
|
|
def purge_accounts(self) -> Dict:
|
|
accounts_deleted = []
|
|
accounts_to_be_deleted = []
|
|
result = {
|
|
'accounts_deleted': accounts_deleted,
|
|
'accounts_to_be_deleted': accounts_to_be_deleted,
|
|
}
|
|
|
|
current_term = Term.current()
|
|
beginning_of_term = current_term.to_datetime()
|
|
now = utils.get_current_datetime()
|
|
delta = now - beginning_of_term
|
|
if delta.days < 30:
|
|
# one-month grace period
|
|
return result
|
|
|
|
ldap_srv = component.getUtility(ILDAPService)
|
|
mail_srv = component.getUtility(IMailService)
|
|
domain_id = self._get_domain_id(self.members_domain)
|
|
accounts = self._get_all_accounts(domain_id)
|
|
|
|
if os.path.isfile(self.pending_deletions_file):
|
|
state = json.load(open(self.pending_deletions_file))
|
|
last_check = datetime.datetime.fromtimestamp(state['timestamp'])
|
|
delta = now - last_check
|
|
if delta.days < 7:
|
|
logger.debug(
|
|
'Skipping account purge because less than one week has '
|
|
'passed since the warning emails were sent out'
|
|
)
|
|
accounts_to_be_deleted.extend(state['accounts_to_be_deleted'])
|
|
return result
|
|
username_to_account_id = {
|
|
account['name']: account['id']
|
|
for account in accounts
|
|
}
|
|
for username in state['accounts_to_be_deleted']:
|
|
if username not in username_to_account_id:
|
|
continue
|
|
user = ldap_srv.get_user(username)
|
|
if user.membership_is_valid():
|
|
continue
|
|
account_id = username_to_account_id[username]
|
|
|
|
self._delete_account(account_id)
|
|
self.vhost_mgr.delete_all_vhosts_for_user(username)
|
|
|
|
accounts_deleted.append(username)
|
|
mail_srv.send_cloud_account_has_been_deleted_message(user)
|
|
logger.info(f'Deleted cloud account for {username}')
|
|
os.unlink(self.pending_deletions_file)
|
|
return result
|
|
|
|
state = {
|
|
'timestamp': int(now.timestamp()),
|
|
'accounts_to_be_deleted': accounts_to_be_deleted,
|
|
}
|
|
for account in accounts:
|
|
username = account['name']
|
|
account_id = account['id']
|
|
user = ldap_srv.get_user(username)
|
|
if user.membership_is_valid():
|
|
continue
|
|
accounts_to_be_deleted.append(username)
|
|
mail_srv.send_cloud_account_will_be_deleted_message(user)
|
|
logger.info(
|
|
f'A warning email was sent to {username} because their '
|
|
'cloud account will be deleted'
|
|
)
|
|
if accounts_to_be_deleted:
|
|
json.dump(state, open(self.pending_deletions_file, 'w'))
|
|
return result
|
|
|
|
def _is_valid_domain(self, username: str, domain: str) -> bool:
|
|
subdomain = username + '.' + self.vhost_domain
|
|
if not (domain == subdomain or domain.endswith('.' + subdomain)):
|
|
return False
|
|
if self.VALID_DOMAIN_RE.match(domain) is None:
|
|
return False
|
|
if len(domain) > 80:
|
|
return False
|
|
return True
|
|
|
|
def _is_valid_ip_address(self, ip_address: str) -> bool:
|
|
try:
|
|
addr = ipaddress.ip_address(ip_address)
|
|
except ValueError:
|
|
return False
|
|
return self.vhost_ip_min <= addr <= self.vhost_ip_max
|
|
|
|
def create_vhost(self, username: str, domain: str, ip_address: str):
|
|
if self.vhost_mgr.get_num_vhosts(username) >= self.max_vhosts_per_account:
|
|
raise Exception(f'Only {self.max_vhosts_per_account} vhosts '
|
|
'allowed per account')
|
|
if not self._is_valid_domain(username, domain):
|
|
raise InvalidDomainError()
|
|
if not self._is_valid_ip_address(ip_address):
|
|
raise InvalidIPError()
|
|
self.vhost_mgr.create_vhost(username, domain, ip_address)
|
|
|
|
def delete_vhost(self, username: str, domain: str):
|
|
if not self._is_valid_domain(username, domain):
|
|
raise InvalidDomainError()
|
|
self.vhost_mgr.delete_vhost(username, domain)
|
|
|
|
def get_vhosts(self, username: str) -> List[Dict]:
|
|
return self.vhost_mgr.get_vhosts(username)
|
|
|