diff --git a/ceo_common/errors.py b/ceo_common/errors.py index 2550fc2..fdc31b9 100644 --- a/ceo_common/errors.py +++ b/ceo_common/errors.py @@ -73,3 +73,8 @@ class InvalidMembershipError(Exception): class CloudStackAPIError(Exception): pass + + +class InvalidDomainError(Exception): + def __init__(self): + super().__init__('domain is invalid') diff --git a/ceo_common/interfaces/ICloudService.py b/ceo_common/interfaces/ICloudService.py index 27bba73..8f58a5c 100644 --- a/ceo_common/interfaces/ICloudService.py +++ b/ceo_common/interfaces/ICloudService.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, List from zope.interface import Interface @@ -21,3 +21,22 @@ class ICloudService(Interface): Another message will be emailed to the users after their cloud account has been deleted. """ + + def create_vhost(username: str, domain: str, ip_address: str): + """ + Create a new vhost record for the given domain and IP address. + """ + + def delete_vhost(username: str, domain: str): + """ + Delete the vhost record for the given user and domain. + """ + + def get_vhosts(username: str) -> List[Dict]: + """ + Get the vhost records for the given user. Each record has the form + { + "domain": "app.username.m.csclub.cloud", + "ip_address": "172.19.134.12" + } + """ diff --git a/ceo_common/interfaces/IVHostManager.py b/ceo_common/interfaces/IVHostManager.py new file mode 100644 index 0000000..ad5debd --- /dev/null +++ b/ceo_common/interfaces/IVHostManager.py @@ -0,0 +1,36 @@ +from typing import List, Dict + +from zope.interface import Interface + + +class IVHostManager(Interface): + """Performs operations on the CSC Cloud.""" + + def create_vhost(username: str, domain: str, ip_address: str): + """ + Create a new vhost record for the given domain and IP address. + """ + + def delete_vhost(username: str, domain: str): + """ + Delete the vhost record for the given user and domain. + """ + + def delete_all_vhosts_for_user(username: str): + """ + Delete all vhost records for the given user. + """ + + def get_num_vhosts(username: str) -> int: + """ + Get the number of vhost records for the given user. + """ + + def get_vhosts(username: str) -> List[Dict]: + """ + Get the vhost records for the given user. Each record has the form + { + "domain": "app.username.m.csclub.cloud", + "ip_address": "172.19.134.12" + } + """ diff --git a/ceo_common/interfaces/__init__.py b/ceo_common/interfaces/__init__.py index 4f32ca8..44a7397 100644 --- a/ceo_common/interfaces/__init__.py +++ b/ceo_common/interfaces/__init__.py @@ -10,3 +10,4 @@ from .IMailService import IMailService from .IMailmanService import IMailmanService from .IHTTPClient import IHTTPClient from .IDatabaseService import IDatabaseService +from .IVHostManager import IVHostManager diff --git a/ceod/model/CloudService.py b/ceod/model/CloudService.py index c86d6a8..a12956a 100644 --- a/ceod/model/CloudService.py +++ b/ceod/model/CloudService.py @@ -4,6 +4,7 @@ import hashlib import hmac import json import os +import re from typing import Dict, List from urllib.parse import quote @@ -11,7 +12,9 @@ import requests from zope import component from zope.interface import implementer -from ceo_common.errors import InvalidMembershipError, CloudStackAPIError +from .VHostManager import VHostManager +from ceo_common.errors import InvalidMembershipError, CloudStackAPIError, \ + InvalidDomainError from ceo_common.logger_factory import logger_factory from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \ IMailService @@ -23,12 +26,21 @@ 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'), + ssl_cert_path=cfg.get('cloud vhosts_ssl_cert_path'), + ssl_key_path=cfg.get('cloud vhosts_ssl_key_path'), + ) + self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account') + self.vhost_domain = cfg.get('cloud vhosts_members_domain') state_dir = '/run/ceod' if not os.path.isdir(state_dir): @@ -172,3 +184,29 @@ class CloudService: 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 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() + 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) diff --git a/ceod/model/VHostManager.py b/ceod/model/VHostManager.py new file mode 100644 index 0000000..9a97507 --- /dev/null +++ b/ceod/model/VHostManager.py @@ -0,0 +1,101 @@ +import glob +import os +import re +import subprocess +from typing import List, Dict + +from flask import current_app +import jinja2 +from zope.interface import implementer + +from ceo_common.logger_factory import logger_factory +from ceo_common.interfaces import IVHostManager + +PROXY_PASS_IP_RE = re.compile(r'^\s+proxy_pass\s+http://(?P[\d.]+);$') +VHOST_FILENAME_RE = re.compile(r'^member_(?P[0-9a-z-]+)_(?P[0-9a-z-]+)$') +logger = logger_factory(__name__) + + +@implementer(IVHostManager) +class VHostManager: + def __init__( + self, + vhost_dir: str, + ssl_cert_path: str, + ssl_key_path: str, + ): + self.vhost_dir = vhost_dir + self.ssl_cert_path = ssl_cert_path + self.ssl_key_path = ssl_key_path + self.jinja_env = jinja2.Environment( + loader=jinja2.PackageLoader('ceod.model'), + ) + + @staticmethod + def _vhost_filename(username: str, domain: str) -> str: + """Generate a filename for the vhost record""" + # sanity check... + assert '..' not in domain and '/' not in domain + return 'member' + '_' + username + '_' + domain + + def _vhost_filepath(self, username: str, domain: str) -> str: + """Generate an absolute path for the vhost record""" + return os.path.join(self.vhost_dir, self._vhost_filename(username, domain)) + + def _vhost_files(self, username: str) -> List[str]: + """Return a list of all vhost files for this user.""" + return glob.glob(os.path.join(self.vhost_dir, 'member_' + username + '_*')) + + def _reload_web_server(self): + if current_app.config.get('ENV') == 'development' or \ + current_app.config.get('TESTING'): + logger.info('Not reloading web server because we are in development') + return + subprocess.run(['systemctl', 'reload', 'nginx'], check=True) + + def create_vhost(self, username: str, domain: str, ip_address: str): + template = self.jinja_env.get_template('nginx_cloud_vhost_config.j2') + body = template.render( + username=username, domain=domain, ip_address=ip_address, + ssl_cert_path=self.ssl_cert_path, ssl_key_path=self.ssl_key_path) + filepath = self._vhost_filepath(username, domain) + logger.info(f'Writing a new vhost ({domain} -> {ip_address}) to {filepath}') + with open(filepath, 'w') as fo: + fo.write(body) + self._reload_web_server() + + def delete_vhost(self, username: str, domain: str): + filepath = self._vhost_filepath(username, domain) + logger.info(f'Deleting {filepath}') + os.unlink(filepath) + self._reload_web_server() + + def get_num_vhosts(self, username: str) -> int: + return len(self._vhost_files(username)) + + def get_vhosts(self, username: str) -> List[Dict]: + vhosts = [] + for filepath in self._vhost_files(username): + filename = os.path.basename(filepath) + match = VHOST_FILENAME_RE.match(filename) + assert match is not None, f"'{filename}' does not match expected pattern" + domain = match.group('domain') + ip_address = None + for line in open(filepath): + match = PROXY_PASS_IP_RE.match(line) + if match is None: + continue + ip_address = match.group('ip_address') + break + assert ip_address is not None, f"Could not find IP address in {filename}" + vhosts.append({'domain': domain, 'ip_address': ip_address}) + return vhosts + + def delete_all_vhosts_for_user(self, username: str): + filepaths = self._vhost_files(username) + if not filepaths: + return + for filepath in filepaths: + logger.info(f'Deleting {filepath}') + os.unlink(filepath) + self._reload_web_server() diff --git a/ceod/model/__init__.py b/ceod/model/__init__.py index b88889c..61be811 100644 --- a/ceod/model/__init__.py +++ b/ceod/model/__init__.py @@ -9,3 +9,4 @@ from .FileService import FileService from .SudoRole import SudoRole from .MailService import MailService from .MailmanService import MailmanService +from .VHostManager import VHostManager diff --git a/ceod/model/templates/nginx_cloud_vhost_config.j2 b/ceod/model/templates/nginx_cloud_vhost_config.j2 new file mode 100644 index 0000000..869606d --- /dev/null +++ b/ceod/model/templates/nginx_cloud_vhost_config.j2 @@ -0,0 +1,25 @@ +# This file is automatically managed by ceod. +# DO NOT EDIT THIS FILE MANUALLY. +# If you want to modify it, please move it to another directory. + +server { + listen 80; + listen [::]:80; + server_name {{ domain }}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name {{ domain }}; + ssl_certificate {{ ssl_cert_path }}; + ssl_certificate_key {{ ssl_key_path }}; + + location / { + proxy_pass http://{{ ip_address }}; + } + + access_log /var/log/nginx/member-{{ username }}-access.log; + error_log /var/log/nginx/member-{{ username }}-error.log; +} diff --git a/tests/ceod_dev.ini b/tests/ceod_dev.ini index 16944c8..5aa1e64 100644 --- a/tests/ceod_dev.ini +++ b/tests/ceod_dev.ini @@ -73,3 +73,10 @@ host = localhost api_key = REPLACE_ME secret_key = REPLACE_ME base_url = http://localhost:8080/client/api + +[cloud vhosts] +config_dir = /run/ceod/member-vhosts +ssl_cert_path = /etc/ssl/private/csclub.cloud.chain +ssl_key_path = /etc/ssl/private/csclub.cloud.key +max_vhosts_per_account = 10 +members_domain = m.csclub.cloud diff --git a/tests/ceod_test_local.ini b/tests/ceod_test_local.ini index fdc988f..c89098c 100644 --- a/tests/ceod_test_local.ini +++ b/tests/ceod_test_local.ini @@ -72,3 +72,10 @@ host = coffee api_key = REPLACE_ME secret_key = REPLACE_ME base_url = http://localhost:8080/client/api + +[cloud vhosts] +config_dir = /run/ceod/member-vhosts +ssl_cert_path = /etc/ssl/private/csclub.cloud.chain +ssl_key_path = /etc/ssl/private/csclub.cloud.key +max_vhosts_per_account = 10 +members_domain = m.csclub.cloud