263 lines
11 KiB
Python
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
|