diff --git a/.drone/common.sh b/.drone/common.sh index de52459..963e480 100644 --- a/.drone/common.sh +++ b/.drone/common.sh @@ -6,6 +6,9 @@ sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > / cp /tmp/resolv.conf /etc/resolv.conf rm /tmp/resolv.conf +# mock out systemctl +ln -s /bin/true /usr/local/bin/systemctl + get_ip_addr() { getent hosts $1 | cut -d' ' -f1 } diff --git a/.drone/mail-setup.sh b/.drone/mail-setup.sh index 0e83a78..0265936 100755 --- a/.drone/mail-setup.sh +++ b/.drone/mail-setup.sh @@ -18,5 +18,8 @@ apt update apt install -y netcat-openbsd auth_setup mail +# for the VHostManager +mkdir -p /run/ceod/member-vhosts + # sync with phosphoric-acid nc -l 0.0.0.0 9000 & diff --git a/ceo/cli/cloud.py b/ceo/cli/cloud.py index 150549a..0e44922 100644 --- a/ceo/cli/cloud.py +++ b/ceo/cli/cloud.py @@ -3,8 +3,8 @@ from zope import component from ceo_common.interfaces import IConfig -from ..utils import http_post -from .utils import handle_sync_response +from ..utils import http_post, http_put, http_get, http_delete +from .utils import Abort, handle_sync_response, print_colon_kv @click.group(short_help='Perform operations on the CSC cloud') @@ -44,3 +44,42 @@ def purge(): result = handle_sync_response(resp) click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted'])) click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted'])) + + +@cloud.group(short_help='Manage your virtual hosts') +def vhosts(): + pass + + +@vhosts.command(name='add', short_help='Add a virtual host') +@click.argument('domain') +@click.argument('ip_address') +def add_vhost(domain, ip_address): + body = {'ip_address': ip_address} + if '/' in domain: + raise Abort('invalid domain name') + resp = http_put('/api/cloud/vhosts/' + domain, json=body) + handle_sync_response(resp) + click.echo('Done.') + + +@vhosts.command(name='delete', short_help='Delete a virtual host') +@click.argument('domain') +def delete_vhost(domain): + if '/' in domain: + raise Abort('invalid domain name') + resp = http_delete('/api/cloud/vhosts/' + domain) + handle_sync_response(resp) + click.echo('Done.') + + +@vhosts.command(name='list', short_help='List virtual hosts') +def list_vhosts(): + resp = http_get('/api/cloud/vhosts') + result = handle_sync_response(resp) + vhosts = result['vhosts'] + if not vhosts: + click.echo('No vhosts found.') + return + pairs = [(d['domain'], d['ip_address']) for d in vhosts] + print_colon_kv(pairs) diff --git a/ceo/utils.py b/ceo/utils.py index f915a9a..e977759 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -42,6 +42,10 @@ def http_delete(path: str, **kwargs) -> requests.Response: return http_request('DELETE', path, **kwargs) +def http_put(path: str, **kwargs) -> requests.Response: + return http_request('PUT', path, **kwargs) + + def get_failed_operations(data: List[Dict]) -> List[str]: """ Get a list of the failed operations using the JSON objects diff --git a/ceo_common/errors.py b/ceo_common/errors.py index 2550fc2..6cf844a 100644 --- a/ceo_common/errors.py +++ b/ceo_common/errors.py @@ -73,3 +73,13 @@ class InvalidMembershipError(Exception): class CloudStackAPIError(Exception): pass + + +class InvalidDomainError(Exception): + def __init__(self): + super().__init__('domain is invalid') + + +class InvalidIPError(Exception): + def __init__(self): + super().__init__('IP address 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/ceo_common/model/HTTPClient.py b/ceo_common/model/HTTPClient.py index 67fbc11..6a38e23 100644 --- a/ceo_common/model/HTTPClient.py +++ b/ceo_common/model/HTTPClient.py @@ -27,8 +27,8 @@ class HTTPClient: host = host + '.' + self.base_domain if method == 'GET': - # This is the only GET endpoint which requires auth - need_auth = path.startswith('/api/members') + need_auth = path.startswith('/api/members') or \ + path.startswith('/api/cloud') delegate = False else: need_auth = True diff --git a/ceod/api/cloud.py b/ceod/api/cloud.py index 497d09f..be7467b 100644 --- a/ceod/api/cloud.py +++ b/ceod/api/cloud.py @@ -1,8 +1,9 @@ -from flask import Blueprint +from flask import Blueprint, request from zope import component -from .utils import requires_authentication_no_realm, authz_restrict_to_syscom -from ceo_common.interfaces import ICloudService, ILDAPService +from .utils import requires_authentication_no_realm, authz_restrict_to_syscom, \ + get_valid_member_or_throw +from ceo_common.interfaces import ICloudService bp = Blueprint('cloud', __name__) @@ -10,9 +11,8 @@ bp = Blueprint('cloud', __name__) @bp.route('/accounts/create', methods=['POST']) @requires_authentication_no_realm def create_account(auth_user: str): + user = get_valid_member_or_throw(auth_user) cloud_srv = component.getUtility(ICloudService) - ldap_srv = component.getUtility(ILDAPService) - user = ldap_srv.get_user(auth_user) cloud_srv.create_account(user) return {'status': 'OK'} @@ -22,3 +22,30 @@ def create_account(auth_user: str): def purge_accounts(): cloud_srv = component.getUtility(ICloudService) return cloud_srv.purge_accounts() + + +@bp.route('/vhosts/', methods=['PUT']) +@requires_authentication_no_realm +def create_vhost(auth_user: str, domain: str): + get_valid_member_or_throw(auth_user) + cloud_srv = component.getUtility(ICloudService) + body = request.get_json(force=True) + ip_address = body['ip_address'] + cloud_srv.create_vhost(auth_user, domain, ip_address) + return {'status': 'OK'} + + +@bp.route('/vhosts/', methods=['DELETE']) +@requires_authentication_no_realm +def delete_vhost(auth_user: str, domain: str): + cloud_srv = component.getUtility(ICloudService) + cloud_srv.delete_vhost(auth_user, domain) + return {'status': 'OK'} + + +@bp.route('/vhosts', methods=['GET']) +@requires_authentication_no_realm +def get_vhosts(auth_user: str): + cloud_srv = component.getUtility(ICloudService) + vhosts = cloud_srv.get_vhosts(auth_user) + return {'vhosts': vhosts} diff --git a/ceod/api/error_handlers.py b/ceod/api/error_handlers.py index d9e17f0..43ec699 100644 --- a/ceod/api/error_handlers.py +++ b/ceod/api/error_handlers.py @@ -8,7 +8,7 @@ from werkzeug.exceptions import HTTPException from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \ UserAlreadyExistsError, GroupAlreadyExistsError, BadRequest, \ UserAlreadySubscribedError, InvalidMembershipError, \ - CloudStackAPIError + CloudStackAPIError, InvalidDomainError, InvalidIPError from ceo_common.logger_factory import logger_factory __all__ = ['register_error_handlers'] @@ -24,7 +24,9 @@ def generic_error_handler(err: Exception): """Return JSON for all errors.""" if isinstance(err, HTTPException): status_code = err.code - elif isinstance(err, BadRequest): + elif any(isinstance(err, cls) for cls in [ + BadRequest, InvalidDomainError, InvalidIPError + ]): status_code = 400 elif isinstance(err, ldap3.core.exceptions.LDAPStrongerAuthRequiredResult) \ or isinstance(err, InvalidMembershipError): diff --git a/ceod/api/utils.py b/ceod/api/utils.py index 5b7c2da..acd255f 100644 --- a/ceod/api/utils.py +++ b/ceod/api/utils.py @@ -7,14 +7,25 @@ import traceback from typing import Callable, List from flask import current_app, stream_with_context +from zope import component from .spnego import requires_authentication +from ceo_common.errors import InvalidMembershipError +from ceo_common.interfaces import IUser, ILDAPService from ceo_common.logger_factory import logger_factory from ceod.transactions import AbstractTransaction logger = logger_factory(__name__) +def get_valid_member_or_throw(username: str) -> IUser: + ldap_srv = component.getUtility(ILDAPService) + user = ldap_srv.get_user(username) + if not user.membership_is_valid(): + raise InvalidMembershipError() + return user + + def requires_authentication_no_realm(f: Callable) -> Callable: """ Like requires_authentication, but strips the realm out of the principal string. diff --git a/ceod/model/CloudService.py b/ceod/model/CloudService.py index c86d6a8..fd64e3d 100644 --- a/ceod/model/CloudService.py +++ b/ceod/model/CloudService.py @@ -2,8 +2,10 @@ 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 @@ -11,7 +13,8 @@ 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 CloudStackAPIError, InvalidDomainError, InvalidIPError from ceo_common.logger_factory import logger_factory from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \ IMailService @@ -23,12 +26,23 @@ 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') + 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): @@ -88,8 +102,6 @@ class CloudService: resp.raise_for_status() def create_account(self, user: IUser): - if not user.membership_is_valid(): - raise InvalidMembershipError() domain_id = self._get_domain_id(self.members_domain) url = self._create_url({ @@ -146,7 +158,10 @@ class CloudService: 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}') @@ -172,3 +187,38 @@ 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 _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) diff --git a/ceod/model/VHostManager.py b/ceod/model/VHostManager.py new file mode 100644 index 0000000..a4a86a9 --- /dev/null +++ b/ceod/model/VHostManager.py @@ -0,0 +1,97 @@ +import glob +import os +import re +import subprocess +from typing import List, Dict + +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): + logger.debug('Reloading nginx') + 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/docs/openapi.yaml b/docs/openapi.yaml index a017305..fe3250c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -713,16 +713,7 @@ paths: description: Activate a cloud account for the calling user responses: "200": - description: Success - content: - application/json: - schema: - type: object - properties: - status: - type: string - description: '"OK"' - example: {"status": "OK"} + "$ref": "#/components/responses/SimpleSuccessResponse" "403": "$ref": "#/components/responses/InvalidMembershipErrorResponse" /cloud/accounts/purge: @@ -757,6 +748,79 @@ paths: description: usernames of accounts which were deleted items: type: string + /cloud/vhosts/{domain}: + put: + tags: ['cloud'] + servers: + - url: https://biloba.csclub.uwaterloo.ca:9987/api + summary: Create a vhost + description: Add a new virtual host configuration. + parameters: + - name: domain + in: path + description: domain name of the virtual host + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + ip_address: + type: string + description: IP address of the virtual host + example: {"ip_address": "172.19.134.11"} + responses: + "200": + "$ref": "#/components/responses/SimpleSuccessResponse" + "403": + "$ref": "#/components/responses/InvalidMembershipErrorResponse" + delete: + tags: ['cloud'] + servers: + - url: https://biloba.csclub.uwaterloo.ca:9987/api + summary: Delete a vhost + description: Delete a virtual host configuration. + parameters: + - name: domain + in: path + description: domain name of the virtual host + required: true + schema: + type: string + responses: + "200": + "$ref": "#/components/responses/SimpleSuccessResponse" + /cloud/vhosts: + get: + tags: ['cloud'] + servers: + - url: https://biloba.csclub.uwaterloo.ca:9987/api + summary: List all vhosts + description: List all virtual host configurations for the calling user. + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + vhosts: + type: array + description: virtual hosts + items: + type: object + properties: + domain: + type: string + description: domain name of the virtual host + ip_address: + type: string + description: IP address of the virtual host + example: {"vhosts": [{"domain": "ctdalek.m.csclub.cloud", "ip_address": "172.19.134.11"}]} components: securitySchemes: GSSAPIAuth: @@ -932,3 +996,14 @@ components: InvalidMembershipErrorResponse: <<: *ErrorResponse description: Membership is invalid or expired + SimpleSuccessResponse: + description: Success + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: '"OK"' + example: {"status": "OK"} diff --git a/docs/redoc-static.html b/docs/redoc-static.html index 0ccf8ea..708eff9 100644 --- a/docs/redoc-static.html +++ b/docs/redoc-static.html @@ -352,7 +352,8 @@ data-styled.g108[id="sc-dWBRfb"]{content:"jnEbBv,"}/*!sc*/ .cAOCuf{font-size:0.929em;line-height:20px;background-color:#2F8132;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/ .iZkjfb{font-size:0.929em;line-height:20px;background-color:#bf581d;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/ .gemyvL{font-size:0.929em;line-height:20px;background-color:#cc3333;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/ -data-styled.g109[id="sc-jHcXXw"]{content:"bXnXQF,cAOCuf,iZkjfb,gemyvL,"}/*!sc*/ +.inNGOu{font-size:0.929em;line-height:20px;background-color:#95507c;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/ +data-styled.g109[id="sc-jHcXXw"]{content:"bXnXQF,cAOCuf,iZkjfb,gemyvL,inNGOu,"}/*!sc*/ .gBwOdz{position:absolute;width:100%;z-index:100;background:#fafafa;color:#263238;box-sizing:border-box;box-shadow:0px 0px 6px rgba(0,0,0,0.33);overflow:hidden;border-bottom-left-radius:4px;border-bottom-right-radius:4px;-webkit-transition:all 0.25s ease;transition:all 0.25s ease;visibility:hidden;-webkit-transform:translateY(-50%) scaleY(0);-ms-transform:translateY(-50%) scaleY(0);transform:translateY(-50%) scaleY(0);}/*!sc*/ data-styled.g110[id="sc-bQCEYZ"]{content:"gBwOdz,"}/*!sc*/ .fKFAhr{padding:10px;}/*!sc*/ @@ -429,7 +430,7 @@ data-styled.g140[id="sc-amkrK"]{content:"icZuVc,"}/*!sc*/ -
Authorizations:

Responses

Response samples

Content type
application/json
{
  • "accounts_to_be_deleted": [
    ],
  • "accounts_deleted": [
    ]
}

positions

Show current positions

Shows the list of positions and members holding them.

+

Response samples

Content type
application/json
{
  • "accounts_to_be_deleted": [
    ],
  • "accounts_deleted": [
    ]
}

Create a vhost

Add a new virtual host configuration.

+
Authorizations:
path Parameters
domain
required
string

domain name of the virtual host

+
Request Body schema: application/json
ip_address
string

IP address of the virtual host

+

Responses

Request samples

Content type
application/json
{
  • "ip_address": "172.19.134.11"
}

Response samples

Content type
application/json
{
  • "status": "OK"
}

Delete a vhost

Delete a virtual host configuration.

+
Authorizations:
path Parameters
domain
required
string

domain name of the virtual host

+

Responses

Response samples

Content type
application/json
{
  • "status": "OK"
}

List all vhosts

List all virtual host configurations for the calling user.

Authorizations:

Responses

Response samples

Content type
application/json
{
  • "president": "user0",
  • "vice-president": "user1",
  • "sysadmin": "user2",
  • "treasurer": null
}

Update positions

Update members for each positions. Members not specified in the parameters will be removed from the position and unsubscribed from the exec's mailing list. New position holders will be subscribed to the mailing list.

+

Response samples

Content type
application/json
{
  • "vhosts": [
    ]
}

positions

Show current positions

Shows the list of positions and members holding them.

+
Authorizations:

Responses

Response samples

Content type
application/json
{
  • "president": "user0",
  • "vice-president": "user1",
  • "sysadmin": "user2",
  • "treasurer": null
}

Update positions

Update members for each positions. Members not specified in the parameters will be removed from the position and unsubscribed from the exec's mailing list. New position holders will be subscribed to the mailing list.

Authorizations:
Request Body schema: application/json

New position holders

property name*
string

Responses

Request samples

Content type
application/json
{
  • "president": "user0",
  • "vice-president": "user1",
  • "sysadmin": "user2",
  • "treasurer": null
}

Response samples

Content type
text/plain
{"status": "in progress", "operation": "update_positions_ldap"}
+

Request samples

Content type
application/json
{
  • "president": "user0",
  • "vice-president": "user1",
  • "sysadmin": "user2",
  • "treasurer": null
}

Response samples

Content type
text/plain
{"status": "in progress", "operation": "update_positions_ldap"}
 {"status": "in progress", "operation": "update_exec_group_ldap"}
 {"status": "in progress", "operation": "subscribe_to_mailing_list"}
 {"status": "completed", "result": "OK"}
-
+