pyceo/ceod/model/ClubWebHostingService.py

263 lines
11 KiB
Python

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<club_name>[0-9a-z-]+)/www/?$')
# This is where all the <Directory> 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'])
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)
# See /etc/apache2/.git/hooks/pre-commit on caffeine
self._run(
['git', 'commit', '-m', '[ceo] disable club websites'],
cwd=self.apache_dir,
env={**os.environ, 'APACHE_CONFIG_CRON': '1'})
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)
if directive == 'DocumentRoot':
directive_value = self.aug.get(directive_path + '/arg')
match = APACHE_USERDIR_RE.match(directive_value)
if match is not None:
club_name = match.group('club_name')
elif directive == 'ServerAdmin':
directive_value = self.aug.get(directive_path + '/arg')
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 <Directory> 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 not os.path.isdir(www):
return False
try:
# We're just going to look one level deep; that should be good enough.
filenames = os.listdir(www)
except os.error:
# If we're unable to read the directory (e.g. permissions error),
# then this means that the Apache user (www-data) can't read it either.
# So we can just return False here.
return False
for filename in filenames:
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[club_name]['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