class UserProfile(DirtyFieldsMixin, models.Model): nickname = models.CharField(max_length=256) user = models.OneToOneField(User, primary_key=True, related_name='_profile_cache') ssh_key = PublicKeyField(blank=True) @property def formatted_public_key(self): name = self.user.get_full_name() return "## %s%s\n%s" % (self.user.email or self.user.username, " (%s)" % name if name else "", self.ssh_key) def __str__(self): return self.nickname def __unicode__(self): return self.nickname or self.user.username def save(self, *args, **kwargs): if 'ssh_key' in self.get_dirty_fields(): Login.objects.filter(users___profile_cache=self).update( is_dirty=True) super(UserProfile, self).save(*args, **kwargs)
class ApplicationKey(models.Model): private_key = models.TextField() public_key = PublicKeyField() is_named = models.BooleanField(default=False) def save(self, *args, **kwargs): if not self.private_key or not self.public_key: self.generate_key_pair() self.private_key = self.private_key.strip() self.public_key = self.public_key.strip() super(ApplicationKey, self).save(*args, **kwargs) @property def formatted_public_key(self): return "%s ssheepdog_%s" % (self.public_key, self.pk) def __unicode__(self): return "...%s ssheepdog_%s" % (self.public_key[-10:], self.pk) def generate_key_pair(self): Random.atfork() key = RSA.generate(app_settings.RSA_KEY_LENGTH) self.private_key = key.exportKey() # This magic is from # http://stackoverflow.com/questions/2466401/how-to-generate-ssh-key-pairs-with-python exponent = '%x' % (key.e, ) if len(exponent) % 2: exponent = '0' + exponent ssh_rsa = '00000007' + base64.b16encode('ssh-rsa') ssh_rsa += '%08x' % (len(exponent) / 2, ) ssh_rsa += exponent modulus = '%x' % (key.n, ) if len(modulus) % 2: modulus = '0' + modulus if modulus[0] in '89abcdef': modulus = '00' + modulus ssh_rsa += '%08x' % (len(modulus) / 2, ) ssh_rsa += modulus self.public_key = 'ssh-rsa %s' % (base64.b64encode( base64.b16decode(ssh_rsa.upper())), ) @staticmethod def get_latest(create_new=False): if not create_new: try: return ApplicationKey.objects.exclude( is_named=True).latest('pk') except ApplicationKey.DoesNotExist: pass key = ApplicationKey() key.save() return key
class PublicKeyFieldTests(TestCase): def setUp(self): self.field = PublicKeyField() self.key = "AAAAB3NzaC1yc2EAAAABIwAAAQEAvRY/4gdg/V6sKShGk/Cx6qqRUiCWybdEokMsTEf502BRe/uD0qP8Y8zQ2fJSPZ5FcySIMorTQ9cl8tSeqVDOhAiwelJW7EB8qCMxc+Nkn8urtXmLTCS26lG/bF5A1XA33ToL3EadLpllUu2oQ8ebetmAuKpjKjVH/oi+ghP2P9yaLOrr6uQT1BGaFTa0dtAN2KSFBNeVejuhbZLgB8/uHEnsdEu3kxeqL9E4WXGbvPKgvrg3J/U6bAMG326yw/C43OHrZEi6OJ+yroRrdKkmHDAHTIZRRgaEkYCXlULBdZMrO2vrIjVTdJSOjeQ324if24L7p3HQx/KOnG4WhMuYbQ==" def good(self, key, expected=None): result = self.field.clean(key, None) if expected: self.assertEqual(result, expected) def bad(self, key): self.assertRaises(exceptions.ValidationError, self.field.clean, key, None) def test_good(self): self.good("ssh-rsa %s comment" % self.key, "ssh-rsa %s comment" % self.key) def test_whitespace_ok(self): self.good(" \n\n ssh-rsa %s comment " % self.key, "ssh-rsa %s comment" % self.key) def test_two_keys(self): self.good("ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key), "ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key)) def test_two_keys_with_whitespace(self): self.good( "\nssh-rsa %s row1\n\nssh-rsa %s row1\n" % (self.key, self.key), "ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key)) def test_comment_with_whitespace(self): self.good(" \n\n ssh-rsa %s comment comment2 " % self.key, "ssh-rsa %s comment comment2" % self.key) def test_no_comment(self): self.good("ssh-rsa %s" % self.key, "ssh-rsa %s" % self.key) def test_long_enough(self): # This isn't really an ssh key, but it will pass the weak test self.good("ssh-rsa %s comment" % self.key[0:104]) def test_too_short(self): self.bad("ssh-rsa %s comment" % self.key[0:96]) def test_not_base64(self): # This isn't really an ssh key, but it will pass the weak test self.bad("ssh-rsa %s comment" % self.key[0:97]) def test_bad_key_type_name(self): self.assertRaises(exceptions.ValidationError, self.field.clean, "sh-rs %s comment" % self.key, None)
class PublicKeyFieldTests(TestCase): def setUp(self): self.field = PublicKeyField() self.key = "AAAAB3NzaC1yc2EAAAABIwAAAQEAvRY/4gdg/V6sKShGk/Cx6qqRUiCWybdEokMsTEf502BRe/uD0qP8Y8zQ2fJSPZ5FcySIMorTQ9cl8tSeqVDOhAiwelJW7EB8qCMxc+Nkn8urtXmLTCS26lG/bF5A1XA33ToL3EadLpllUu2oQ8ebetmAuKpjKjVH/oi+ghP2P9yaLOrr6uQT1BGaFTa0dtAN2KSFBNeVejuhbZLgB8/uHEnsdEu3kxeqL9E4WXGbvPKgvrg3J/U6bAMG326yw/C43OHrZEi6OJ+yroRrdKkmHDAHTIZRRgaEkYCXlULBdZMrO2vrIjVTdJSOjeQ324if24L7p3HQx/KOnG4WhMuYbQ==" def good(self, key, expected=None): result = self.field.clean(key, None) if expected: self.assertEqual(result, expected) def bad(self, key): self.assertRaises(exceptions.ValidationError, self.field.clean, key, None) def test_good(self): self.good("ssh-rsa %s comment" % self.key, "ssh-rsa %s comment" % self.key) def test_whitespace_ok(self): self.good(" \n\n ssh-rsa %s comment " % self.key, "ssh-rsa %s comment" % self.key) def test_two_keys(self): self.good("ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key), "ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key)) def test_two_keys_with_whitespace(self): self.good("\nssh-rsa %s row1\n\nssh-rsa %s row1\n" % (self.key, self.key), "ssh-rsa %s row1\nssh-rsa %s row1" % (self.key, self.key)) def test_comment_with_whitespace(self): self.good(" \n\n ssh-rsa %s comment comment2 " % self.key, "ssh-rsa %s comment comment2" % self.key) def test_no_comment(self): self.good("ssh-rsa %s" % self.key, "ssh-rsa %s" % self.key) def test_long_enough(self): # This isn't really an ssh key, but it will pass the weak test self.good("ssh-rsa %s comment" % self.key[0:104]) def test_too_short(self): self.bad("ssh-rsa %s comment" % self.key[0:96]) def test_not_base64(self): # This isn't really an ssh key, but it will pass the weak test self.bad("ssh-rsa %s comment" % self.key[0:97]) def test_bad_key_type_name(self): self.assertRaises(exceptions.ValidationError, self.field.clean, "sh-rs %s comment" % self.key, None)
def setUp(self): self.field = PublicKeyField() self.key = "AAAAB3NzaC1yc2EAAAABIwAAAQEAvRY/4gdg/V6sKShGk/Cx6qqRUiCWybdEokMsTEf502BRe/uD0qP8Y8zQ2fJSPZ5FcySIMorTQ9cl8tSeqVDOhAiwelJW7EB8qCMxc+Nkn8urtXmLTCS26lG/bF5A1XA33ToL3EadLpllUu2oQ8ebetmAuKpjKjVH/oi+ghP2P9yaLOrr6uQT1BGaFTa0dtAN2KSFBNeVejuhbZLgB8/uHEnsdEu3kxeqL9E4WXGbvPKgvrg3J/U6bAMG326yw/C43OHrZEi6OJ+yroRrdKkmHDAHTIZRRgaEkYCXlULBdZMrO2vrIjVTdJSOjeQ324if24L7p3HQx/KOnG4WhMuYbQ=="
class Login(DirtyFieldsMixin, models.Model): machine = models.ForeignKey('Machine') username = models.CharField(max_length=256) users = models.ManyToManyField(User, blank=True) client = models.ForeignKey('Client', null=True, blank=True) application_key = models.ForeignKey('ApplicationKey', null=True, verbose_name="SSHeepdog Public Key") is_active = models.BooleanField(default=True) is_dirty = models.BooleanField(default=True) additional_public_keys = PublicKeyField( blank=True, help_text=_("These are public keys which will be pushed to the login" " in addition to user keys.")) class Meta: ordering = ( 'username', 'client__nickname', ) permissions = ( # Managed by South so added by data migration! ("can_view_access_summary", "Can view access summary"), ("can_sync", "Can sync login keys"), ("can_edit_own_public_key", "Can edit one's own public key"), ("can_view_all_users", "Can view other users"), ("can_view_all_logins", "Can view other's logins"), ) def get_address(self): return "%s@%s" % (self.username, self.machine.ip or self.machine.hostname) def get_last_log(self): try: return LoginLog.objects.filter(login=self).latest('date') except LoginLog.DoesNotExist: return None def get_change_url(self): return reverse('admin:ssheepdog_login_change', args=(self.pk, )) @staticmethod def sync_all(actor=None): try: for login in Login.objects.exclude(machine__manual=True): login.sync(actor=actor) finally: with settings(hide(*ALL_FABRIC_WARNINGS)): disconnect_all() def __unicode__(self): if self.client: return "%s@%s (%s)" % (self.username, self.machine.hostname or self.machine.ip, self.client) else: return "%s@%s" % (self.username, self.machine) def get_application_key(self): if self.application_key is None: self.application_key = ApplicationKey.get_latest() self.save() return self.application_key @property def formatted_public_key(self): return self.get_application_key().formatted_public_key def save(self, *args, **kwargs): if not self.client: self.client = self.machine.client # Updates to these fields require a push of keys to login fields = set(['machine_pk', 'username', 'is_active']) made_dirty = bool(fields.intersection(self.get_dirty_fields())) self.is_dirty = self.is_dirty or made_dirty super(Login, self).save(*args, **kwargs) def run(self, command, private_key=None): """ Ssh in to Login to run command. Return True on success, False ow. """ mach = self.machine env.abort_on_prompts = True env.reject_unknown_hosts = False # TODO: Squash potential man-in-middle attack! Bottom of http://docs.fabfile.org/en/1.3.4/usage/ssh.html env.disable_known_hosts = True env.key_filename = private_key or ApplicationKey.get_latest( ).private_key env.host_string = "%s@%s:%d" % (self.username, (mach.ip or mach.hostname), mach.port) try: with capture_output() as captured: run(command) return True, captured except SystemExit: return False, captured except fabric.exceptions.NetworkError: return False, captured def get_client(self): return self.client or self.machine.client def get_authorized_keys(self): """ Return a list of authorized keys strings which should be deployed to the machine. """ keys = [ "%s\n%s" % ("######################################################\n" "### This public keys file is managed by ssheepdog. ###\n" "### Changes made manually will be overwritten. ###\n" "######################################################", ApplicationKey.get_latest().formatted_public_key) ] if self.is_active and self.machine.is_active: if self.additional_public_keys: keys.append("## Additional keys specified in Login\n%s" % self.additional_public_keys) for user in (self.users.filter( is_active=True).select_related('_profile_cache')): keys.append(user.get_profile().formatted_public_key) return keys def formatted_keys(self): formatted_keys = "\n\n".join(self.get_authorized_keys()) # Switch back and forth between ' and "" to quote ' return formatted_keys.replace("'", "'\"'\"'") def flag_as_manually_synced_by(self, actor): self.is_dirty = False self.application_key = ApplicationKey.get_latest() self.save() LoginLog.objects.create(actor=actor, login=self, message="Manual sync was performed") def sync(self, actor=None): """ Updates the authorized_keys file on the machine attached to this login adding or deleting users public keys Returns True if successfully changed the authorized files and False if not (status stays dirty). If no change attempted, return None. """ if self.machine.is_down or self.machine.manual or not self.is_dirty: # No update required (either impossible or not needed) return None success, output = self.run( "echo '%s' > ~/.ssh/authorized_keys" % self.formatted_keys(), self.get_application_key().private_key) message = "%successful %s" % ("S" if success else "Uns", "key deployment") LoginLog.objects.create(stderr=output.stderr, stdout=output.stdout, actor=actor, login=self, message=message) with settings(hide(*ALL_FABRIC_WARNINGS)): disconnect_all() if success: self.is_dirty = False self.application_key = ApplicationKey.get_latest() self.save() return success