from collections import defaultdict import contextlib import glob import os import re import subprocess from threading import Lock import traceback from typing import List from augeas import Augeas from zope import component from zope.interface import implementer from ceo_common.interfaces import ILDAPService, IMailService, \ IClubWebHostingService, IConfig from ceo_common.logger_factory import logger_factory from ceo_common.model import Term # NOTE: We are assuming that the name of a club is the same as its home # directory (under /users). While this rule is not enforced, it has been true # up until now. # FIXME: don't assume this APACHE_USERDIR_RE = re.compile(r'^/users/(?P[0-9a-z-]+)/www/?$') # This is where all the directives for disabled clubs are stored. APACHE_DISABLED_CLUBS_FILE = 'conf-available/disable-club.conf' # This is the file which contains the snippet to disable a single club. APACHE_DISABLING_SNIPPET_FILE = 'snippets/disable-club.conf' # This is the maximum number of consecutive terms for which a club is allowed # to have no active club reps. After this many terms have passed, the club's # website may be disabled. MAX_TERMS_WITH_NO_ACTIVE_CLUB_REPS = 3 logger = logger_factory(__name__) def is_a_disabling_snippet(snippet_path: str) -> bool: return snippet_path.startswith('snippets/disable-') @implementer(IClubWebHostingService) class ClubWebHostingService: def __init__(self, augeas_root='/'): cfg = component.getUtility(IConfig) self.aug_root = augeas_root self.apache_dir = os.path.join(augeas_root, 'etc/apache2') self.sites_available_dir = os.path.join(self.apache_dir, 'sites-available') self.conf_available_dir = os.path.join(self.apache_dir, 'conf-available') self.clubs_home = cfg.get('clubs_home') self.aug = None self.clubs = None self.made_at_least_one_change = False self.lock = Lock() @contextlib.contextmanager def _hold_lock(self): try: self.lock.acquire() yield finally: self.lock.release() @contextlib.contextmanager def begin_transaction(self): with self._hold_lock(): try: self.aug = Augeas(self.aug_root) self.clubs = defaultdict(lambda: {'disabled': False, 'email': None}) self._get_club_emails() self._get_disabled_sites() yield finally: if self.aug is not None: self.aug.close() self.aug = None self.clubs = None self.made_at_least_one_change = False def _run(self, args: List[str], **kwargs): subprocess.run(args, check=True, **kwargs) def _reload_web_server(self): logger.debug('Reloading Apache') self._run(['systemctl', 'reload', 'apache2']) # This requires the APACHE_CONFIG_CRON environment variable to be # set to 1 (e.g. in a systemd drop-in) # See /etc/apache2/.git/hooks/pre-commit on caffeine def _git_commit(self): if not os.path.isdir(os.path.join(self.apache_dir, '.git')): logger.debug('No git folder found in Apache directory') return logger.debug('Committing changes to git repository') self._run(['git', 'add', APACHE_DISABLED_CLUBS_FILE], cwd=self.apache_dir) self._run(['git', 'commit', '-m', '[ceo] disable club websites'], cwd=self.apache_dir) def commit(self): if not self.made_at_least_one_change: return logger.debug('Saving Augeas changes') self.aug.save() self._reload_web_server() self._git_commit() def _get_club_emails(self): config_file_paths = glob.glob(self.sites_available_dir + '/club-*.conf') for config_file_path in config_file_paths: club_name = None club_email = None filename = os.path.basename(config_file_path) directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive') for directive_path in directive_paths: directive = self.aug.get(directive_path) directive_value = self.aug.get(directive_path + '/arg') if directive == 'DocumentRoot': match = APACHE_USERDIR_RE.match(directive_value) if match is not None: club_name = match.group('club_name') elif directive == 'ServerAdmin': club_email = directive_value if club_name is not None: self.clubs[club_name]['email'] = club_email def _get_disabled_sites(self): config_file_paths = glob.glob(self.conf_available_dir + '/disable-*.conf') for config_file_path in config_file_paths: filename = os.path.basename(config_file_path) directory_paths = self.aug.match(f'/files/etc/apache2/conf-available/{filename}/Directory') for directory_path in directory_paths: directory = self.aug.get(directory_path + '/arg') match = APACHE_USERDIR_RE.match(directory) if match is None: continue club_name = match.group('club_name') directive_paths = self.aug.match(directory_path + '/directive') for directive_path in directive_paths: if self.aug.get(directive_path) != 'Include': continue included_file = self.aug.get(directive_path + '/arg') if is_a_disabling_snippet(included_file): self.clubs[club_name]['disabled'] = True def disable_club_site(self, club_name: str): logger.info(f'Disabling website for {club_name}') directory_path = f'/files/etc/apache2/{APACHE_DISABLED_CLUBS_FILE}/Directory' num_directories = len(self.aug.match(directory_path)) # Create a new section directory_path += '[%d]' % (num_directories + 1) # FIXME: use self.clubs_home here instead (need to update unit tests) self.aug.set(directory_path + '/arg', f'/users/{club_name}/www') self.aug.set(directory_path + '/directive', 'Include') self.aug.set(directory_path + '/directive/arg', APACHE_DISABLING_SNIPPET_FILE) self.clubs[club_name]['disabled'] = True self.made_at_least_one_change = True def _site_uses_php(self, club_name: str) -> bool: www = f'{self.clubs_home}/{club_name}/www' if os.path.isdir(www): # We're just going to look one level deep; that should be good enough. for filename in os.listdir(www): filepath = os.path.join(www, filename) if os.path.isfile(filepath) and filename.endswith('.php'): return True return False # This method needs to be called from within a transaction (uses self.clubs) def _need_to_disable_inactive_club_site(self, club_name: str) -> bool: if self.clubs[club_name]['disabled']: # already disabled - nothing to do return False if not self._site_uses_php(club_name): # These are the only sites which are actually a security concern # to us - it's OK for a purely static website to be unmaintained. return False return True def disable_sites_for_inactive_clubs( self, dry_run: bool = False, remove_inactive_club_reps: bool = False, ) -> List[str]: ldap_srv = component.getUtility(ILDAPService) mail_srv = component.getUtility(IMailService) all_clubs = ldap_srv.get_clubs() club_rep_uids = list({ member for club in all_clubs for member in club.members }) club_reps = ldap_srv.get_club_reps_non_member_terms(club_rep_uids) # If a club rep's last non-member term is before cutoff_term, then # they are considered inactive cutoff_term = Term.current() - MAX_TERMS_WITH_NO_ACTIVE_CLUB_REPS active_club_reps = { club_rep for club_rep, non_member_terms in club_reps.items() if len(non_member_terms) > 0 and max(non_member_terms) >= cutoff_term } # a club is inactive if it does not have at least one active club rep inactive_clubs = [ club for club in all_clubs if not any(map(lambda member: member in active_club_reps, club.members)) ] # STEP 1: update the Apache configs with self.begin_transaction(): clubs_to_disable = [ club.cn for club in inactive_clubs if self._need_to_disable_inactive_club_site(club.cn) ] if dry_run: return clubs_to_disable for club_name in clubs_to_disable: self.disable_club_site(club_name) self.commit() # self.clubs is set to None once the transaction closes, so make a copy now clubs_info = self.clubs.copy() # STEP 2: send emails to clubs whose websites were disabled clubs_who_were_not_notified = set() for club_name in clubs_to_disable: address = clubs_info['email'] if address is None: clubs_who_were_not_notified.add(club_name) continue try: mail_srv.send_club_website_has_been_disabled_message(club_name, address) except Exception: trace = traceback.format_exc() logger.error(f'Failed to send email to {address}:\n{trace}') clubs_who_were_not_notified.add(club_name) # STEP 3: remove inactive club reps from Unix groups if remove_inactive_club_reps: for club in all_clubs: if club.cn in clubs_who_were_not_notified: continue # club.members gets modified after calling club.remove_member(), # so we need to make a copy members = club.members.copy() for member in members: if member not in active_club_reps: logger.info(f'Removing {member} from {club.cn}') club.remove_member(member) return clubs_to_disable