tildetown-admin/ttadmin/users/models.py

263 lines
9.8 KiB
Python
Raw Permalink Normal View History

2017-04-21 04:26:31 +00:00
import logging
2017-02-16 07:25:39 +00:00
import os
2017-02-16 08:49:26 +00:00
from subprocess import run, CalledProcessError
2017-02-16 07:25:39 +00:00
from tempfile import TemporaryFile
2016-11-30 07:48:02 +00:00
2017-01-14 06:17:23 +00:00
from django.db.models import Model
2018-02-22 07:31:16 +00:00
from django.db.models.signals import pre_save, post_save
2016-11-20 05:34:38 +00:00
from django.dispatch import receiver
from django.contrib.auth.models import User
2017-01-14 06:17:23 +00:00
from django.db.models import TextField, BooleanField, CharField, ForeignKey
from django.template.loader import get_template
2016-11-20 05:34:38 +00:00
from common.mailing import send_email, ADMIN_NAME
from help.models import Ticket
2016-11-30 07:48:02 +00:00
2017-04-21 04:18:13 +00:00
logger = logging.getLogger()
2016-11-22 05:14:47 +00:00
SSH_TYPE_CHOICES = (
('ssh-rsa', 'ssh-rsa',),
('ssh-dss', 'ssh-dss',),
2018-02-22 04:35:11 +00:00
('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256'),
2016-11-22 05:14:47 +00:00
)
2016-11-20 05:34:38 +00:00
2017-04-21 04:18:13 +00:00
DEFAULT_INDEX_PATH = '/etc/skel/public_html/index.html'
if os.path.exists(DEFAULT_INDEX_PATH):
DEFAULT_INDEX_PAGE = open(DEFAULT_INDEX_PATH).read().rstrip()
else:
logger.warning('No default html page found in skel. using empty string.')
DEFAULT_INDEX_PAGE = ''
2017-02-16 08:49:26 +00:00
KEYFILE_HEADER = """########## GREETINGS! ##########
# Hi! This file is automatically managed by tilde.town. You
2018-02-22 07:40:19 +00:00
# seriously shouldn't change it. If you want to add more public keys that's
2017-02-16 08:49:26 +00:00
# totally fine: you can put them in ~/.ssh/authorized_keys"""
2017-02-16 07:25:39 +00:00
2016-11-30 07:48:02 +00:00
2016-11-20 05:34:38 +00:00
class Townie(User):
"""Both an almost normal Django User as well as an abstraction over a
system user."""
2017-01-13 21:22:52 +00:00
class Meta:
verbose_name = 'Townie'
verbose_name_plural = 'Townies'
2016-11-21 07:56:55 +00:00
shell = CharField(max_length=50, default="/bin/bash")
reviewed = BooleanField(default=False)
2016-11-30 07:48:02 +00:00
reasons = TextField(blank=True, null=False, default='')
2016-11-21 07:56:55 +00:00
displayname = CharField(max_length=100, blank=False, null=False)
2016-11-20 05:34:38 +00:00
2017-04-21 04:18:13 +00:00
@property
def home(self):
return os.path.join('/home', self.username)
def send_welcome_email(self):
welcome_tmpl = get_template('users/welcome_email.txt')
context = {
'username': self.username,
'admin_name': ADMIN_NAME,
}
text = welcome_tmpl.render(context)
success = send_email(self.email, text, subject='tilde.town!')
if not success:
Ticket.objects.create(name='system',
email='root@tilde.town',
issue_type='other',
issue_text='was not able to send welcome email to {} ({})'.format(
self.username,
2017-12-14 08:37:45 +00:00
self.email))
2017-01-14 06:17:23 +00:00
2017-02-16 07:25:39 +00:00
# managing concrete system state
2017-04-21 04:18:13 +00:00
def has_modified_page(self):
"""Returns whether or not the user has modified index.html. If they
don't have one, returns False."""
index_path = os.path.join(self.home, 'public_html/index.html')
if not os.path.exists(index_path):
return False
index_page = open(index_path).read().rstrip()
return index_page != DEFAULT_INDEX_PAGE
2017-02-16 07:25:39 +00:00
2017-02-16 08:49:26 +00:00
def create_on_disk(self):
"""A VERY NOT IDEMPOTENT create function. Originally, I had ambitions
to have this be idempotent and able to incrementally update a user as
needed, but decided that was overkill for now."""
2017-02-16 07:25:39 +00:00
assert(self.reviewed)
dot_ssh_path = '/home/{}/.ssh'.format(self.username)
error = _guarded_run(['sudo',
'adduser',
'--quiet',
'--shell={}'.format(self.shell),
'--gecos="{}"'.format(self.displayname),
'--disabled-password',
self.username])
if error:
2018-02-25 02:35:03 +00:00
logger.error(error)
return
error = _guarded_run(['sudo',
'usermod',
'-a',
'-Gtown',
self.username])
if error:
2018-02-25 02:35:03 +00:00
logger.error(error)
return
2017-12-14 07:43:58 +00:00
2017-02-16 08:49:26 +00:00
# Create .ssh
error = _guarded_run(['sudo',
'--user={}'.format(self.username),
'mkdir',
dot_ssh_path])
if error:
2018-02-25 02:35:03 +00:00
logger.error(error)
return
2017-02-16 08:49:26 +00:00
2018-02-18 08:10:23 +00:00
def write_authorized_keys(self):
2017-02-16 08:49:26 +00:00
# Write out authorized_keys file
# Why is this a call out to a python script? There's no secure way with
# sudoers to allow this code to write to a file; if this code was to be
# compromised, the ability to write arbitrary files with sudo is a TKO.
# By putting the ssh key file creation into its own script, we can just
# give sudo access for that one command to this code.
#
# We could put the other stuff from here into that script and then only
# grant sudo for the script, but then we're moving code out of this
# virtual-env contained, maintainable thing into a script. it's my
# preference to have the script be as minimal as possible.
with TemporaryFile(dir="/tmp") as fp:
fp.write(self.generate_authorized_keys().encode('utf-8'))
fp.seek(0)
error = _guarded_run(['sudo',
'--user={}'.format(self.username),
2019-01-15 03:47:18 +00:00
'/town/src/tildetown-admin/scripts/create_keyfile.py',
self.username],
stdin=fp)
if error:
2018-02-25 02:35:03 +00:00
logger.error(error)
2017-02-16 07:25:39 +00:00
def generate_authorized_keys(self):
"""returns a string suitable for writing out to an authorized_keys
file"""
content = KEYFILE_HEADER
2018-02-22 04:36:09 +00:00
for pubkey in self.pubkey_set.all():
2018-07-09 02:28:29 +00:00
prefix = pubkey.key.split(' ')
prefix = prefix[0] if len(prefix) > 0 else None
if prefix in [p[0] for p in SSH_TYPE_CHOICES]:
2018-02-22 04:36:09 +00:00
content += '\n{}'.format(pubkey.key)
2017-02-16 07:25:39 +00:00
else:
2018-02-22 07:32:47 +00:00
content += '\n{} {}'.format(pubkey.key_type, pubkey.key)
2017-02-16 07:25:39 +00:00
return content
2018-02-23 22:31:43 +00:00
def rename_on_disk(self, old_username):
"""Assuming that this instance has a new name set, renames this user on
disk with self.username."""
2019-01-15 03:47:18 +00:00
# TODO use systemd thing to end their session
2018-02-23 22:31:43 +00:00
error = _guarded_run([
'sudo',
2019-01-15 03:47:18 +00:00
'/town/src/tildetown-admin/scripts/rename_user.py',
2018-02-23 22:31:43 +00:00
old_username,
self.username])
if error:
2018-02-25 02:35:03 +00:00
logger.error(error)
2018-02-23 22:31:43 +00:00
return
2018-02-25 02:35:03 +00:00
logger.info('Renamed {} to {}'.format(old_username, self.username))
2018-02-23 22:31:43 +00:00
# send user an email
rename_tmpl = get_template('users/rename_email.txt')
context = {
'old_username': old_username,
'new_username': self.username
}
text = rename_tmpl.render(context)
success = send_email(self.email, text, subject='Your tilde.town user has been renamed!')
if not success:
Ticket.objects.create(name='system',
email='root@tilde.town',
issue_type='other',
issue_text='was not able to send rename email to {} ({})'.format(
self.username,
self.email))
2017-02-16 07:25:39 +00:00
2017-01-14 06:17:23 +00:00
class Pubkey(Model):
2017-01-14 07:57:50 +00:00
key_type = CharField(max_length=50,
2017-01-14 06:17:23 +00:00
blank=False,
null=False,
2018-02-23 22:31:43 +00:00
choices=SSH_TYPE_CHOICES)
2017-01-14 06:17:23 +00:00
key = TextField(blank=False, null=False)
townie = ForeignKey(Townie)
2018-02-22 07:31:16 +00:00
@receiver(post_save, sender=Pubkey)
def on_pubkey_post_save(sender, instance, **kwargs):
# Ensure we're checking the townie as it exists at the point of pubkey
# save. If a user is being reviewed, we'll write their key file in the
# townie pre save.
townie = Townie.objects.filter(username=instance.townie.username)
if not townie:
return
townie = townie[0]
if townie.reviewed:
townie.write_authorized_keys()
2018-02-22 07:31:16 +00:00
@receiver(pre_save, sender=Townie)
def on_townie_pre_save(sender, instance, **kwargs):
if instance.id is None:
2018-02-25 02:35:03 +00:00
logger.info('Signup from {}'.format(instance.username))
return
2016-11-20 05:34:38 +00:00
existing = Townie.objects.get(id=instance.id)
2018-02-22 04:36:09 +00:00
# See if we need to create this user on disk.
if not existing.reviewed and instance.reviewed is True:
2018-02-25 02:35:03 +00:00
logger.info('Creating user {} on disk.'.format(instance.username))
2017-02-16 08:49:26 +00:00
instance.create_on_disk()
instance.send_welcome_email()
2018-02-22 04:36:09 +00:00
instance.write_authorized_keys()
2018-02-25 02:23:28 +00:00
return
2018-02-22 04:36:09 +00:00
# See if this user needs a rename on disk
2018-02-25 02:35:03 +00:00
logger.info('checking for rename {} vs {}'.format(
2018-02-25 02:23:28 +00:00
existing.username, instance.username))
if existing.username != instance.username:
2018-02-25 02:35:03 +00:00
logger.info('username do not match, going to rename')
instance.rename_on_disk(existing.username)
2017-02-16 08:49:26 +00:00
def _guarded_run(cmd_args, **run_args):
"""Given a list of args representing a command invocation as well as var
args to pass onto subprocess.run, run the command and check for an error.
if there is one, files a helpdesk ticket and returns it. Returns None on
success."""
2017-02-16 08:49:26 +00:00
try:
run(cmd_args,
check=True,
**run_args)
except CalledProcessError as e:
Ticket.objects.create(name='system',
email='root@tilde.town',
issue_type='other',
issue_text='error while running {}: {}'.format(
cmd_args, e))
return e
2017-02-16 07:25:39 +00:00
2017-02-16 08:49:26 +00:00
2018-02-18 08:12:23 +00:00
# things to consider:
2017-02-16 07:25:39 +00:00
# * what happens when a user is marked as not reviewed?
# * does this signal user deletion? Or does literal Townie deletion signal
# "needs to be removed from disk"? I think it makes the most sense for the
# latter to imply full user deletion.
# * I honestly can't even think of a reason to revert a user to "not reviewed"
# and perhaps it's best to just not make that possible. for now, though, I
# think I can ignore it.
# * what happens when a user needs to be banned?
# * the Townie should be deleted via post_delete signal