add VHostManager
This commit is contained in:
parent
0798419e34
commit
7425d69feb
|
@ -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')
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
"""
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue