add VHostManager

This commit is contained in:
Max Erenberg 2021-11-26 22:04:07 -05:00
parent 0798419e34
commit 7425d69feb
10 changed files with 242 additions and 2 deletions

View File

@ -73,3 +73,8 @@ class InvalidMembershipError(Exception):
class CloudStackAPIError(Exception): class CloudStackAPIError(Exception):
pass pass
class InvalidDomainError(Exception):
def __init__(self):
super().__init__('domain is invalid')

View File

@ -1,4 +1,4 @@
from typing import Dict from typing import Dict, List
from zope.interface import Interface from zope.interface import Interface
@ -21,3 +21,22 @@ class ICloudService(Interface):
Another message will be emailed to the users after their cloud account Another message will be emailed to the users after their cloud account
has been deleted. 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"
}
"""

View File

@ -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"
}
"""

View File

@ -10,3 +10,4 @@ from .IMailService import IMailService
from .IMailmanService import IMailmanService from .IMailmanService import IMailmanService
from .IHTTPClient import IHTTPClient from .IHTTPClient import IHTTPClient
from .IDatabaseService import IDatabaseService from .IDatabaseService import IDatabaseService
from .IVHostManager import IVHostManager

View File

@ -4,6 +4,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import re
from typing import Dict, List from typing import Dict, List
from urllib.parse import quote from urllib.parse import quote
@ -11,7 +12,9 @@ import requests
from zope import component from zope import component
from zope.interface import implementer 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.logger_factory import logger_factory
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \ from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \
IMailService IMailService
@ -23,12 +26,21 @@ logger = logger_factory(__name__)
@implementer(ICloudService) @implementer(ICloudService)
class CloudService: class CloudService:
VALID_DOMAIN_RE = re.compile(r'^(?:[0-9a-z-]+\.)+[a-z]+$')
def __init__(self): def __init__(self):
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
self.api_key = cfg.get('cloudstack_api_key') self.api_key = cfg.get('cloudstack_api_key')
self.secret_key = cfg.get('cloudstack_secret_key') self.secret_key = cfg.get('cloudstack_secret_key')
self.base_url = cfg.get('cloudstack_base_url') self.base_url = cfg.get('cloudstack_base_url')
self.members_domain = 'Members' 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' state_dir = '/run/ceod'
if not os.path.isdir(state_dir): if not os.path.isdir(state_dir):
@ -172,3 +184,29 @@ class CloudService:
if accounts_to_be_deleted: if accounts_to_be_deleted:
json.dump(state, open(self.pending_deletions_file, 'w')) json.dump(state, open(self.pending_deletions_file, 'w'))
return result 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)

101
ceod/model/VHostManager.py Normal file
View File

@ -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<ip_address>[\d.]+);$')
VHOST_FILENAME_RE = re.compile(r'^member_(?P<username>[0-9a-z-]+)_(?P<domain>[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()

View File

@ -9,3 +9,4 @@ from .FileService import FileService
from .SudoRole import SudoRole from .SudoRole import SudoRole
from .MailService import MailService from .MailService import MailService
from .MailmanService import MailmanService from .MailmanService import MailmanService
from .VHostManager import VHostManager

View File

@ -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;
}

View File

@ -73,3 +73,10 @@ host = localhost
api_key = REPLACE_ME api_key = REPLACE_ME
secret_key = REPLACE_ME secret_key = REPLACE_ME
base_url = http://localhost:8080/client/api 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

View File

@ -72,3 +72,10 @@ host = coffee
api_key = REPLACE_ME api_key = REPLACE_ME
secret_key = REPLACE_ME secret_key = REPLACE_ME
base_url = http://localhost:8080/client/api 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