-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathbespoke.py
623 lines (525 loc) · 20.6 KB
/
bespoke.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
"""
SRCF-specific tools.
Most methods identify users and groups using the `Member` and `Society` database models.
"""
from datetime import date, datetime
import logging
import os
import pwd
import shutil
from subprocess import CalledProcessError
import time
from typing import List, Optional
from requests import Session as RequestsSession
from sqlalchemy.orm import Session as SQLASession
from sqlalchemy.orm.exc import NoResultFound
from srcf.controllib import jobs
from srcf.database import Domain, HTTPSCert, Job, MailHandler, Member, Society
from srcf.database.queries import get_member, get_society
from srcf.database.summarise import summarise_society
from .common import (Collect, command, make, Owner, owner_home, owner_name, require_host, Result,
State, Unset)
from .mailman import MailList
from . import hosts, unix
from ..email import send
LOG = logging.getLogger(__name__)
def log_to_file(path: str, message: str) -> Result[Unset]:
"""
Write a timestamped line to a log file.
"""
with open(path, "a") as log:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log.write("{} -- {}\n".format(now, message))
return Result(State.success)
def get_crontab(owner: Owner) -> Optional[str]:
"""
Fetch the owning user's crontab, if one exists on the current server.
"""
try:
proc = command(["/usr/bin/crontab", "-u", owner_name(owner), "-l"], output=True)
except CalledProcessError:
return None
if proc.stdout:
return proc.stdout.decode("utf-8")
else:
return None
def clear_crontab(owner: Owner) -> Result[Unset]:
"""
Clear the owning user's crontab, if one exists on the current server.
"""
if not get_crontab(owner):
return Result(State.unchanged)
command(["/usr/bin/crontab", "-u", owner_name(owner), "-r"])
return Result(State.success)
def get_mailman_lists(owner: Owner, sess: RequestsSession = RequestsSession()) -> List[MailList]:
"""
Query mailing lists owned by the given member or society.
"""
prefix = owner_name(owner)
resp = sess.get("https://lists.srcf.net/getlists.cgi", params={"prefix": prefix})
return [MailList(name) for name in resp.text.splitlines()]
def _create_member(sess: SQLASession, crsid: str, preferred_name: Optional[str],
surname: Optional[str], email: Optional[str],
mail_handler: MailHandler = MailHandler.forward, is_member: bool = True,
is_user: bool = True) -> Result[Member]:
member = Member(crsid=crsid,
preferred_name=preferred_name,
surname=surname,
email=email,
mail_handler=mail_handler.name,
member=is_member,
user=is_user)
sess.add(member)
# Populate UID and GID from the database.
sess.flush()
LOG.debug("Created member record: %r", member)
return Result(State.created, member)
def _update_member(sess: SQLASession, member: Member, preferred_name: Optional[str],
surname: Optional[str], email: Optional[str],
mail_handler: MailHandler = MailHandler.forward,
is_member: bool = True, is_user: bool = True) -> Result[Unset]:
member.preferred_name = preferred_name
member.surname = surname
member.email = email
member.mail_handler = mail_handler.name
member.member = is_member
member.user = is_user
if not sess.is_modified(member):
return Result(State.unchanged)
LOG.debug("Updated member record: %r", member)
return Result(State.success)
@Result.collect_value
def ensure_member(sess: SQLASession, crsid: str, preferred_name: Optional[str],
surname: Optional[str], email: Optional[str],
mail_handler: MailHandler = MailHandler.forward, is_member: bool = True,
is_user: bool = True) -> Collect[Member]:
"""
Register or update a member in the database.
"""
try:
member = get_member(crsid, sess, include_non_members=True)
except KeyError:
res_record = yield from _create_member(sess, crsid, preferred_name, surname, email,
mail_handler, is_member, is_user)
member = res_record.value
else:
yield _update_member(sess, member, preferred_name, surname, email, mail_handler,
is_member, is_user)
return member
def _create_society(sess: SQLASession, name: str, description: str,
role_email: Optional[str] = None) -> Result[Society]:
society = Society(society=name,
description=description,
role_email=role_email)
sess.add(society)
# Populate UID and GID from the database.
sess.flush()
LOG.debug("Created society record: %r", society)
return Result(State.created, society)
def _update_society(sess: SQLASession, society: Society, description: str,
role_email: Optional[str]) -> Result[Unset]:
society.description = description
society.role_email = role_email
if not sess.is_modified(society):
return Result(State.unchanged)
LOG.debug("Updated society record: %r", society)
return Result(State.success)
def delete_society(sess: SQLASession, society: Society) -> Result[Unset]:
"""
Drop a society record from the database.
"""
if society.admins:
raise ValueError("Remove society admins for {} first".format(society))
if society.domains:
raise ValueError("Remove domains for {} first".format(society))
sess.delete(society)
LOG.debug("Deleted society record: %r", society)
return Result(State.success)
@Result.collect_value
def ensure_society(sess: SQLASession, name: str, description: str,
role_email: Optional[str] = None) -> Collect[Society]:
"""
Register or update a society in the database.
For existing societies, this will synchronise member relations with the given list of admins.
"""
try:
society = get_society(name, sess)
except KeyError:
res_record = yield from _create_society(sess, name, description, role_email)
society = res_record.value
else:
yield _update_society(sess, society, description, role_email)
return society
def _add_to_society(sess: SQLASession, member: Member, society: Society) -> Result[Unset]:
if member in society.admins:
return Result(State.unchanged)
society.admins.add(member)
LOG.debug("Added society admin: %r %r", member, society)
return Result(State.success)
def _remove_from_society(sess: SQLASession, member: Member, society: Society) -> Result[Unset]:
if member not in society.admins:
return Result(State.unchanged)
society.admins.remove(member)
LOG.debug("Removed society admin: %r %r", member, society)
return Result(State.success)
@Result.collect
def add_society_admin(sess: SQLASession, member: Member, society: Society,
group: unix.Group) -> Collect[None]:
"""
Add a new admin to a society account.
"""
yield _add_to_society(sess, member, society)
yield unix.add_to_group(unix.get_user(member.uid), group)
yield link_soc_home_dir(member, society)
@Result.collect
def remove_society_admin(sess: SQLASession, member: Member, society: Society,
group: unix.Group) -> Collect[None]:
"""
Remove an existing admin from a society account.
"""
yield _remove_from_society(sess, member, society)
yield unix.remove_from_group(unix.get_user(member.uid), group)
yield link_soc_home_dir(member, society)
def populate_home_dir(member: Member) -> Result[Unset]:
"""
Copy the contents of ``/etc/skel`` to a new user's home directory.
This must be done before creating anything else in the directory.
"""
target = owner_home(member)
if os.listdir(target):
# Avoid potentially clobbering existing files.
return Result(State.unchanged)
unix.copytree_chown_chmod("/etc/skel", target, member.uid, member.gid)
return Result(State.success)
@Result.collect
def create_public_html(owner: Owner) -> Collect[None]:
"""
Create a user's public_html directory inside their public directory.
For creations before April 2023, also add a symlink to it in their home directory.
"""
user = unix.get_user(owner.uid)
public_html = os.path.join(owner_home(owner, True), "public_html")
yield unix.mkdir(public_html, user)
symlink_cutoff = date(2023, 4, 1) # accounts provisioned from this date don't get the symlink
if date.today() < symlink_cutoff:
link = os.path.join(owner_home(owner), "public_html")
yield unix.symlink(link, public_html)
@Result.collect
def link_soc_home_dir(member: Member, society: Society) -> Collect[None]:
"""
Add or remove a user's society symlink based on their admin membership.
"""
link = os.path.join(owner_home(member), society.society)
target = owner_home(society)
yield unix.symlink(link, target, member in society.admins)
@Result.collect
def set_home_exim_acl(owner: Owner) -> Collect[None]:
"""
Grant access to the user's ``.forward`` file for Exim.
"""
yield unix.set_nfs_acl(owner_home(owner), "[email protected]", "RX")
def create_forwarding_file(owner: Owner) -> Result[Unset]:
"""
Write a default ``.forward`` file matching the user's external email address.
"""
path = os.path.join(owner_home(owner), ".forward")
if os.path.exists(path):
return Result(State.unchanged)
with open(path, "w") as f:
f.write("{}\n".format(owner.email))
user = pwd.getpwnam(owner_name(owner))
os.chown(path, user.pw_uid, user.pw_gid)
LOG.debug("Created forwarding file: %r", path)
return Result(State.created)
def create_legacy_mailbox(member: Member) -> Result[Unset]:
"""
Send an email to a user's legacy mailbox.
"""
if os.path.exists(os.path.join("/var/mail", member.crsid)):
return Result(State.unchanged)
res_send = send((member.name, "real-{}@srcf.net".format(member.crsid)),
"plumbing/legacy_mailbox.j2", {"target": member})
return Result(State.created, parts=(res_send,))
def empty_legacy_mailbox(member: Member) -> Result[Unset]:
"""
Delete all messages inside a user's legacy mailbox.
"""
path = os.path.join("/var/mail", member.crsid)
try:
stats = os.stat(path)
except FileNotFoundError:
return Result(State.unchanged)
if stats.st_size == 0:
return Result(State.unchanged)
os.truncate(path, 0)
return Result(State.success)
@Result.collect
def scrub_user(owner: Owner) -> Collect[None]:
"""
Anonymise the Unix user of a member or society.
"""
try:
user = unix.get_user(owner.uid)
except KeyError:
return
else:
cls = "soc" if isinstance(owner, Society) else "user"
yield unix.set_real_name(user, "")
if isinstance(owner, Society):
yield unix.set_home_dir(user, "/nonexistent")
yield unix.rename_user(user, "ex{}{}".format(cls, owner.uid))
def scrub_group(owner: Owner) -> Result[Unset]:
"""
Anonymise the Unix group of a member or society.
"""
try:
group = unix.get_group(owner.gid)
except KeyError:
return Result(State.unchanged)
else:
cls = "soc" if isinstance(owner, Society) else "user"
return unix.rename_group(group, "ex{}{}".format(cls, owner.gid))
def scrub_member_jobs(sess: SQLASession, owner: Owner) -> Result[Unset]:
"""
Erase sensitive fields of all jobs submitted to the Control Panel by this member or society.
"""
state = State.unchanged
query = sess.query(Job)
if isinstance(owner, Member):
query = query.filter((Job.owner_crsid == owner.crsid) |
((Job.type == jobs.Signup.JOB_TYPE) &
(Job.args.contains({"crsid": owner.crsid}))))
elif isinstance(owner, Society):
query = query.filter(Job.args.contains({"society": owner.society}))
else:
raise TypeError(owner)
for job in query:
cls = jobs.all_jobs[job.type]
if cls not in jobs.SENSITIVE_ARGS:
continue
for field in jobs.SENSITIVE_ARGS[cls]:
value = job.args.get(field)
if value and value != "<redacted>":
LOG.debug("Scrubbing job #%d (%s), field %r", job.job_id, job.type, field)
job.args[field] = "<redacted>"
state = State.success
return Result(state)
def update_quotas() -> Result[Unset]:
"""
Apply quotas from member and society limits to the filesystem.
"""
# TODO: Port to SRCFLib, replace with entrypoint.
command(["/usr/local/sbin/srcf-update-quotas"])
return Result(State.success)
def get_custom_domains(sess: SQLASession, owner: Owner) -> List[Domain]:
"""
Retrieve all custom domains assigned to a member or society.
"""
if isinstance(owner, Member):
class_ = "user"
elif isinstance(owner, Society):
class_ = "soc"
else:
raise TypeError(owner)
return list(sess.query(Domain).filter(Domain.class_ == class_,
Domain.owner == owner_name(owner)))
def add_custom_domain(sess: SQLASession, owner: Owner, name: str,
root: Optional[str] = None) -> Result[Domain]:
"""
Assign a domain name to a member or society website.
"""
if isinstance(owner, Member):
class_ = "user"
elif isinstance(owner, Society):
class_ = "soc"
else:
raise TypeError(owner)
try:
domain = sess.query(Domain).filter(Domain.domain == name).one()
except NoResultFound:
domain = Domain(domain=name,
class_=class_,
owner=owner_name(owner),
root=root)
sess.add(domain)
state = State.created
LOG.debug("Created domain record: %r", domain)
else:
domain.class_ = class_
domain.owner = owner_name(owner)
domain.root = root
if sess.is_modified(domain):
state = State.success
LOG.debug("Updated domain record: %r", domain)
else:
state = State.unchanged
return Result(state, domain)
def remove_custom_domain(sess: SQLASession, owner: Owner, name: str) -> Result[Unset]:
"""
Unassign a domain name from a member or society.
"""
try:
domain = sess.query(Domain).filter(Domain.domain == name).one()
except NoResultFound:
state = State.unchanged
else:
sess.delete(domain)
state = State.success
LOG.debug("Deleted domain record: %r", domain)
return Result(state)
def queue_https_cert(sess: SQLASession, domain: str) -> Result[HTTPSCert]:
"""
Add an existing domain to the queue for requesting an HTTPS certificate.
"""
assert sess.query(Domain).filter(Domain.domain == domain).count()
try:
cert = sess.query(HTTPSCert).filter(HTTPSCert.domain == domain).one()
except NoResultFound:
cert = HTTPSCert(domain=domain)
sess.add(cert)
state = State.created
LOG.debug("Created HTTPS cert record: %r", cert)
else:
state = State.unchanged
return Result(state, cert)
@require_host(hosts.WEB)
def generate_apache_groups() -> Result[Unset]:
"""
Synchronise the Apache groups file, providing ``srcfmembers`` and ``srcfusers`` groups.
"""
# TODO: Port to SRCFLib, replace with entrypoint.
command(["/usr/local/sbin/srcf-updateapachegroups"])
return Result(State.success)
def queue_list_subscription(member: Member, *lists: str) -> Result[Unset]:
"""
Subscribe the user to one or more mailing lists.
"""
if not lists:
return Result(State.unchanged)
# TODO: Port to SRCFLib, replace with entrypoint.
entry = '"{}" <{}>'.format(member.name, member.email)
args = ["/usr/local/sbin/srcf-enqueue-mlsub"]
for name in lists:
args.append("soc-srcf-{}:{}".format(name, entry))
command(args)
LOG.debug("Queued list subscriptions: %r %r", member, lists)
return Result(State.success)
def generate_sudoers() -> Result[Unset]:
"""
Update sudo permissions to allow admins to exdcute commands under their society accounts.
"""
# TODO: Port to SRCFLib, replace with entrypoint.
command(["/usr/local/sbin/srcf-generate-society-sudoers"])
return Result(State.success)
def export_members() -> Result[Unset]:
"""
Regenerate the legacy membership lists.
"""
# TODO: Port to SRCFLib, replace with entrypoint.
command(["/usr/local/sbin/srcf-memberdb-export"])
return Result(State.success)
@require_host(hosts.USER)
@Result.collect
def update_nis(wait: bool = False) -> Collect[None]:
"""
Synchronise UNIX users and passwords over NIS.
If a new user or group has just been created, and is about to be used, set ``wait`` to avoid
the caching of non-existent UIDs or GIDs.
"""
res = yield from make("/var/yp")
if res:
LOG.debug("Updated NIS")
if wait:
time.sleep(16)
return res
@require_host(hosts.LIST)
def configure_mailing_list(name: str) -> Result[Unset]:
"""
Apply default options to a new mailing list, and create the necessary mail aliases.
"""
command(["/usr/sbin/config_list", "--inputfile", "/root/mailman-newlist-defaults", name])
LOG.debug("Configured mailing list: %r", name)
return Result(State.success)
@require_host(hosts.LIST)
def generate_mailman_aliases() -> Result[Unset]:
"""
Refresh the Exim alias file for Mailman lists.
"""
# TODO: Port to SRCFLib, replace with entrypoint.
command(["/usr/local/sbin/srcf-generate-mailman-aliases"])
return Result(State.success)
def archive_website(owner: Owner) -> Result[Optional[str]]:
"""
Rename the web root of a user or society with a timestamp to archive it locally.
"""
public_html = os.path.join(owner_home(owner, True), "public_html")
if not os.path.exists(public_html):
return Result(State.unchanged, None)
target = "{}_{}".format(public_html, datetime.now().strftime("%Y-%m-%d-%H%M%S"))
os.rename(public_html, target)
return Result(State.success, target)
def _archive_files(society: Society, root: str) -> Result[Optional[str]]:
home = owner_home(society)
public = owner_home(society, True)
try:
os.mkdir(root)
except FileExistsError:
pass
name = "soc-{}-{}.tar.bz2".format(society.society, date.today().strftime("%Y%m%d"))
target = os.path.join(root, name)
paths = tuple(filter(os.path.exists, (home, public)))
if not paths:
return Result(State.unchanged, None)
if os.path.exists(target):
raise FileExistsError(target)
command(["/bin/tar", "cjf", target, *paths])
LOG.debug("Archived society files: %r", paths)
return Result(State.success, target)
def _archive_crontab(society: Society, root: str) -> Result[Optional[str]]:
crontab = get_crontab(society)
if not crontab:
return Result(State.unchanged, None)
target = os.path.join(root, "crontab")
with open(target, "w") as f:
f.write(crontab)
LOG.debug("Archived crontab: %r", society.society)
# TOOD: for host in {"cavein", "sinkhole"}: get_crontab(society)
return Result(State.success, target)
@Result.collect
def archive_society_files(society: Society) -> Collect[None]:
"""
Create a backup of the society under /archive/societies.
"""
root = os.path.join("/archive/societies", society.society)
yield _archive_files(society, root)
yield _archive_crontab(society, root)
with open(os.path.join(root, "society_info"), "w") as f:
f.write(summarise_society(society))
@Result.collect
def delete_files(owner: Owner) -> Collect[None]:
"""
Remove all public and private files of a member or society.
"""
home = owner_home(owner)
public = owner_home(owner, True)
for path in (home, public):
try:
shutil.rmtree(path)
except FileNotFoundError:
yield Result(State.unchanged)
else:
LOG.debug("Deleted files: %r", path)
yield Result(State.success)
def slay_user(owner: Owner) -> Result[Unset]:
"""
Kill all processes belonging to the given account.
"""
try:
proc = command(["/usr/local/sbin/srcf-slay", owner_name(owner)], output=True)
except CalledProcessError as ex:
if ex.returncode == 2: # User not found.
return Result(State.unchanged)
else:
raise
else:
return Result(State.success if proc.stdout else State.unchanged)