launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #17663
[Merge] lp:~cjwatson/launchpad/split-lazr.sshserver into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/split-lazr.sshserver into lp:launchpad.
Commit message:
Split out lp.services.sshserver to lazr.sshserver.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/split-lazr.sshserver/+merge/245647
Split out lp.services.sshserver to lazr.sshserver.
(lazr.sshserver isn't in lp-source-dependencies yet, because we first want to make sure that poppy can be split out and use it; but I'm proposing this merge in advance of that anyway to allow parallelising review.)
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/split-lazr.sshserver into lp:launchpad.
=== modified file 'daemons/poppy-sftp.tac'
--- daemons/poppy-sftp.tac 2012-03-26 05:50:20 +0000
+++ daemons/poppy-sftp.tac 2015-01-06 12:50:32 +0000
@@ -7,6 +7,13 @@
import logging
+from lazr.sshserver.auth import (
+ LaunchpadAvatar,
+ PublicKeyFromLaunchpadChecker,
+ )
+from lazr.sshserver.service import SSHService
+from lazr.sshserver.session import DoNothingSession
+
from twisted.application import service
from twisted.conch.interfaces import ISession
from twisted.conch.ssh import filetransfer
@@ -26,10 +33,6 @@
FTPServiceFactory,
)
from lp.poppy.twistedsftp import SFTPServer
-from lp.services.sshserver.auth import (
- LaunchpadAvatar, PublicKeyFromLaunchpadChecker)
-from lp.services.sshserver.service import SSHService
-from lp.services.sshserver.session import DoNothingSession
from lp.services.twistedsupport.loggingsupport import set_up_oops_reporting
=== modified file 'daemons/sftp.tac'
--- daemons/sftp.tac 2011-12-29 05:29:36 +0000
+++ daemons/sftp.tac 2015-01-06 12:50:32 +0000
@@ -5,6 +5,7 @@
# twistd -noy sftp.tac
# or similar. Refer to the twistd(1) man page for details.
+from lazr.sshserver.service import SSHService
from twisted.application import service
from twisted.protocols.policies import TimeoutFactory
@@ -20,7 +21,6 @@
PRIVATE_KEY_FILE,
PUBLIC_KEY_FILE,
)
-from lp.services.sshserver.service import SSHService
from lp.services.twistedsupport.gracefulshutdown import (
ConnTrackingFactoryWrapper,
make_web_status_service,
=== modified file 'lib/lp/codehosting/sftp.py'
--- lib/lp/codehosting/sftp.py 2012-06-29 08:40:05 +0000
+++ lib/lp/codehosting/sftp.py 2015-01-06 12:50:32 +0000
@@ -30,6 +30,7 @@
urlutils,
)
from bzrlib.transport.local import LocalTransport
+from lazr.sshserver.sftp import FileIsADirectory
from twisted.conch.interfaces import (
ISFTPFile,
ISFTPServer,
@@ -45,7 +46,6 @@
LaunchpadServer,
)
from lp.services.config import config
-from lp.services.sshserver.sftp import FileIsADirectory
from lp.services.twistedsupport import gatherResults
=== modified file 'lib/lp/codehosting/sshserver/daemon.py'
--- lib/lp/codehosting/sshserver/daemon.py 2012-01-01 02:58:52 +0000
+++ lib/lp/codehosting/sshserver/daemon.py 2015-01-06 12:50:32 +0000
@@ -17,6 +17,10 @@
import os
+from lazr.sshserver.auth import (
+ LaunchpadAvatar,
+ PublicKeyFromLaunchpadChecker,
+ )
from twisted.conch.interfaces import ISession
from twisted.conch.ssh import filetransfer
from twisted.cred.portal import (
@@ -30,10 +34,6 @@
from lp.codehosting import sftp
from lp.codehosting.sshserver.session import launch_smart_server
from lp.services.config import config
-from lp.services.sshserver.auth import (
- LaunchpadAvatar,
- PublicKeyFromLaunchpadChecker,
- )
# The names of the key files of the server itself. The directory itself is
# given in config.codehosting.host_key_pair_path.
=== modified file 'lib/lp/codehosting/sshserver/session.py'
--- lib/lp/codehosting/sshserver/session.py 2012-06-29 08:40:05 +0000
+++ lib/lp/codehosting/sshserver/session.py 2015-01-06 12:50:32 +0000
@@ -14,6 +14,8 @@
import sys
import urlparse
+from lazr.sshserver.events import AvatarEvent
+from lazr.sshserver.session import DoNothingSession
from twisted.internet import (
error,
interfaces,
@@ -25,8 +27,6 @@
from lp.codehosting import get_bzr_path
from lp.services.config import config
-from lp.services.sshserver.events import AvatarEvent
-from lp.services.sshserver.session import DoNothingSession
class BazaarSSHStarted(AvatarEvent):
=== modified file 'lib/lp/codehosting/sshserver/tests/test_daemon.py'
--- lib/lp/codehosting/sshserver/tests/test_daemon.py 2010-10-30 22:44:21 +0000
+++ lib/lp/codehosting/sshserver/tests/test_daemon.py 2015-01-06 12:50:32 +0000
@@ -5,6 +5,11 @@
__metaclass__ = type
+from lazr.sshserver.auth import (
+ NoSuchPersonWithName,
+ SSHUserAuthServer,
+ )
+from lazr.sshserver.service import Factory
from twisted.conch.ssh.common import NS
from twisted.conch.ssh.keys import Key
from twisted.test.proto_helpers import StringTransport
@@ -15,9 +20,8 @@
PRIVATE_KEY_FILE,
PUBLIC_KEY_FILE,
)
-from lp.services.sshserver.auth import SSHUserAuthServer
-from lp.services.sshserver.service import Factory
from lp.testing import TestCase
+from lp.xmlrpc import faults
class StringTransportWith_setTcpKeepAlive(StringTransport):
@@ -85,3 +89,14 @@
mind2 = server_transport2.service.getMind()
self.assertIsNot(mind1.cache, mind2.cache)
+
+
+class TestXMLRPC(TestCase):
+ """Test XML-RPC protocol integrity."""
+
+ def test_NoSuchPersonWithName_error_code(self):
+ # The error code for NoSuchPersonWithName in lazr.sshserver matches
+ # that in lp.xmlrpc.faults.
+ self.assertEqual(
+ faults.NoSuchPersonWithName.error_code,
+ NoSuchPersonWithName.error_code)
=== modified file 'lib/lp/codehosting/tests/test_sftp.py'
--- lib/lp/codehosting/tests/test_sftp.py 2011-12-22 09:05:46 +0000
+++ lib/lp/codehosting/tests/test_sftp.py 2015-01-06 12:50:32 +0000
@@ -13,6 +13,7 @@
from bzrlib.tests import TestCaseInTempDir
from bzrlib.transport import get_transport
from bzrlib.transport.memory import MemoryTransport
+from lazr.sshserver.sftp import FileIsADirectory
from testtools.deferredruntest import (
assert_fails_with,
AsynchronousDeferredRunTest,
@@ -33,7 +34,6 @@
TransportSFTPServer,
)
from lp.codehosting.sshserver.daemon import CodehostingAvatar
-from lp.services.sshserver.sftp import FileIsADirectory
from lp.services.utils import file_exists
from lp.testing import TestCase
from lp.testing.factory import LaunchpadObjectFactory
=== modified file 'lib/lp/poppy/tests/test_twistedsftp.py'
--- lib/lp/poppy/tests/test_twistedsftp.py 2011-11-30 16:27:15 +0000
+++ lib/lp/poppy/tests/test_twistedsftp.py 2015-01-06 12:50:32 +0000
@@ -8,9 +8,9 @@
import os
from fixtures import TempDir
+from lazr.sshserver.sftp import FileIsADirectory
from lp.poppy.twistedsftp import SFTPServer
-from lp.services.sshserver.sftp import FileIsADirectory
from lp.testing import (
NestedTempfile,
TestCase,
=== modified file 'lib/lp/poppy/twistedsftp.py'
--- lib/lp/poppy/twistedsftp.py 2012-06-29 08:40:05 +0000
+++ lib/lp/poppy/twistedsftp.py 2015-01-06 12:50:32 +0000
@@ -14,6 +14,8 @@
import os
import tempfile
+from lazr.sshserver.events import SFTPClosed
+from lazr.sshserver.sftp import FileIsADirectory
from twisted.conch.interfaces import (
ISFTPFile,
ISFTPServer,
@@ -26,8 +28,6 @@
from lp.poppy.filesystem import UploadFileSystem
from lp.poppy.hooks import Hooks
-from lp.services.sshserver.events import SFTPClosed
-from lp.services.sshserver.sftp import FileIsADirectory
class SFTPServer:
=== removed directory 'lib/lp/services/sshserver'
=== removed file 'lib/lp/services/sshserver/__init__.py'
--- lib/lp/services/sshserver/__init__.py 2010-04-15 14:49:55 +0000
+++ lib/lp/services/sshserver/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,8 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""The Launchpad SSH server."""
-
-__metaclass__ = type
-__all__ = []
-
=== removed file 'lib/lp/services/sshserver/accesslog.py'
--- lib/lp/services/sshserver/accesslog.py 2014-08-29 04:29:16 +0000
+++ lib/lp/services/sshserver/accesslog.py 1970-01-01 00:00:00 +0000
@@ -1,86 +0,0 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Logging for the SSH server."""
-
-__metaclass__ = type
-__all__ = [
- 'LoggingManager',
- ]
-
-import logging
-from logging.handlers import WatchedFileHandler
-
-from twisted.python import log as tplog
-from zope.component import (
- adapter,
- getGlobalSiteManager,
- provideHandler,
- )
-# This non-standard import is necessary to hook up the event system.
-import zope.component.event
-
-from lp.services.sshserver.events import ILoggingEvent
-from lp.services.utils import synchronize
-
-
-class LoggingManager:
- """Class for managing SSH server logging."""
-
- def __init__(self, main_log, access_log, access_log_path):
- """Construct the logging manager.
-
- :param main_log: The main log. Twisted will log to this.
- :param access_log: The access log object.
- :param access_log_path: The path to the file where access log
- messages go.
- """
- self._main_log = main_log
- self._access_log = access_log
- self._access_log_path = access_log_path
- self._is_set_up = False
-
- def setUp(self):
- """Set up logging for the smart server.
-
- This sets up a debugging handler on the main logger and makes sure
- that things logged there won't go to stderr. It also sets up an access
- logger.
- """
- log = self._main_log
- self._orig_level = log.level
- self._orig_handlers = list(log.handlers)
- self._orig_observers = list(tplog.theLogPublisher.observers)
- log.setLevel(logging.INFO)
- log.addHandler(logging.NullHandler())
- handler = WatchedFileHandler(self._access_log_path)
- handler.setFormatter(
- logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
- self._access_log.addHandler(handler)
- self._access_log.setLevel(logging.INFO)
- # Make sure that our logging event handler is there, ready to receive
- # logging events.
- provideHandler(self._log_event)
- self._is_set_up = True
-
- @adapter(ILoggingEvent)
- def _log_event(self, event):
- """Log 'event' to the access log."""
- self._access_log.log(event.level, event.message)
-
- def tearDown(self):
- if not self._is_set_up:
- return
- log = self._main_log
- log.level = self._orig_level
- synchronize(
- log.handlers, self._orig_handlers, log.addHandler,
- log.removeHandler)
- synchronize(
- self._access_log.handlers, self._orig_handlers,
- self._access_log.addHandler, self._access_log.removeHandler)
- synchronize(
- tplog.theLogPublisher.observers, self._orig_observers,
- tplog.addObserver, tplog.removeObserver)
- getGlobalSiteManager().unregisterHandler(self._log_event)
- self._is_set_up = False
=== removed file 'lib/lp/services/sshserver/auth.py'
--- lib/lp/services/sshserver/auth.py 2014-01-30 15:04:06 +0000
+++ lib/lp/services/sshserver/auth.py 1970-01-01 00:00:00 +0000
@@ -1,308 +0,0 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Custom authentication for the SSH server.
-
-Launchpad's SSH server authenticates users against a XML-RPC service (see
-`lp.services.authserver.interfaces.IAuthServer` and
-`PublicKeyFromLaunchpadChecker`) and provides richer error messages in the
-case of failed authentication (see `SSHUserAuthServer`).
-"""
-
-__metaclass__ = type
-__all__ = [
- 'LaunchpadAvatar',
- 'PublicKeyFromLaunchpadChecker',
- 'SSHUserAuthServer',
- ]
-
-import binascii
-
-from twisted.conch import avatar
-from twisted.conch.checkers import SSHPublicKeyDatabase
-from twisted.conch.error import ConchError
-from twisted.conch.interfaces import IConchUser
-from twisted.conch.ssh import (
- keys,
- userauth,
- )
-from twisted.conch.ssh.common import (
- getNS,
- NS,
- )
-from twisted.cred import credentials
-from twisted.cred.checkers import ICredentialsChecker
-from twisted.cred.error import UnauthorizedLogin
-from twisted.internet import defer
-from twisted.python import failure
-from zope.event import notify
-from zope.interface import implements
-
-from lp.services.sshserver import events
-from lp.services.sshserver.session import PatchedSSHSession
-from lp.services.sshserver.sftp import FileTransferServer
-from lp.services.twistedsupport.xmlrpc import trap_fault
-from lp.xmlrpc import faults
-
-
-class LaunchpadAvatar(avatar.ConchUser):
- """An account on the SSH server, corresponding to a Launchpad person.
-
- :ivar channelLookup: See `avatar.ConchUser`.
- :ivar subsystemLookup: See `avatar.ConchUser`.
- :ivar user_id: The Launchpad database ID of the Person for this account.
- :ivar username: The Launchpad username for this account.
- """
-
- def __init__(self, user_dict):
- """Construct a `LaunchpadAvatar`.
-
- :param user_dict: The result of a call to
- `IAuthServer.getUserAndSSHKeys`.
- """
- avatar.ConchUser.__init__(self)
- self.user_id = user_dict['id']
- self.username = user_dict['name']
-
- # Set the only channel as a standard SSH session (with a couple of bug
- # fixes).
- self.channelLookup = {'session': PatchedSSHSession}
- # ...and set the only subsystem to be SFTP.
- self.subsystemLookup = {'sftp': FileTransferServer}
-
- def logout(self):
- notify(events.UserLoggedOut(self))
-
-
-class UserDisplayedUnauthorizedLogin(UnauthorizedLogin):
- """UnauthorizedLogin which should be reported to the user."""
-
-
-class ISSHPrivateKeyWithMind(credentials.ISSHPrivateKey):
- """Marker interface for SSH credentials that reference a Mind."""
-
-
-class SSHPrivateKeyWithMind(credentials.SSHPrivateKey):
- """SSH credentials that also reference a Mind."""
-
- implements(ISSHPrivateKeyWithMind)
-
- def __init__(self, username, algName, blob, sigData, signature, mind):
- credentials.SSHPrivateKey.__init__(
- self, username, algName, blob, sigData, signature)
- self.mind = mind
-
-
-class UserDetailsMind:
- """A 'Mind' object that answers and caches requests for user details.
-
- A mind is a (poorly named) concept from twisted.cred that basically can be
- passed to portal.login to represent the client side view of
- authentication. In our case we attach a mind to the SSHUserAuthServer
- object that corresponds to an attempt to authenticate against the server.
- """
-
- def __init__(self):
- self.cache = {}
-
- def lookupUserDetails(self, proxy, username):
- """Find details for the named user, including registered SSH keys.
-
- This method basically wraps `IAuthServer.getUserAndSSHKeys` -- see the
- documentation of that method for more details -- and caches the
- details found for any particular user.
-
- :param proxy: A twisted.web.xmlrpc.Proxy object for the authentication
- endpoint.
- :param username: The username to look up.
- """
- if username in self.cache:
- return defer.succeed(self.cache[username])
- else:
- d = proxy.callRemote('getUserAndSSHKeys', username)
- d.addBoth(self._add_to_cache, username)
- return d
-
- def _add_to_cache(self, result, username):
- """Add the results to our cache."""
- self.cache[username] = result
- return result
-
-
-class SSHUserAuthServer(userauth.SSHUserAuthServer):
- """Subclass of Conch's SSHUserAuthServer to customize various behaviours.
-
- There are two main differences:
-
- * We override ssh_USERAUTH_REQUEST to display as a banner the reason why
- an authentication attempt failed.
-
- * We override auth_publickey to create credentials that reference a
- UserDetailsMind and pass the same mind to self.portal.login.
-
- Conch is not written in a way to make this easy; we've had to copy and
- paste and change the implementations of these methods.
- """
-
- def __init__(self, transport=None, banner=None):
- self.transport = transport
- self._banner = banner
- self._configured_banner_sent = False
- self._mind = UserDetailsMind()
- self.interfaceToMethod = userauth.SSHUserAuthServer.interfaceToMethod
- self.interfaceToMethod[ISSHPrivateKeyWithMind] = 'publickey'
-
- def sendBanner(self, text, language='en'):
- bytes = '\r\n'.join(text.encode('UTF8').splitlines() + [''])
- self.transport.sendPacket(userauth.MSG_USERAUTH_BANNER,
- NS(bytes) + NS(language))
-
- def _sendConfiguredBanner(self, passed_through):
- if not self._configured_banner_sent and self._banner:
- self._configured_banner_sent = True
- self.sendBanner(self._banner)
- return passed_through
-
- def ssh_USERAUTH_REQUEST(self, packet):
- # This is copied and pasted from twisted/conch/ssh/userauth.py in
- # Twisted 8.0.1. We do this so we can add _ebLogToBanner between
- # two existing errbacks.
- user, nextService, method, rest = getNS(packet, 3)
- if user != self.user or nextService != self.nextService:
- self.authenticatedWith = [] # clear auth state
- self.user = user
- self.nextService = nextService
- self.method = method
- d = self.tryAuth(method, user, rest)
- if not d:
- self._ebBadAuth(failure.Failure(ConchError('auth returned none')))
- return
- d.addCallback(self._sendConfiguredBanner)
- d.addCallbacks(self._cbFinishedAuth)
- d.addErrback(self._ebMaybeBadAuth)
- # This line does not appear in the original.
- d.addErrback(self._ebLogToBanner)
- d.addErrback(self._ebBadAuth)
- return d
-
- def _cbFinishedAuth(self, result):
- ret = userauth.SSHUserAuthServer._cbFinishedAuth(self, result)
- # Tell the avatar about the transport, so we can tie it to the
- # connection in the logs.
- avatar = self.transport.avatar
- avatar.transport = self.transport
- notify(events.UserLoggedIn(avatar))
- return ret
-
- def _ebLogToBanner(self, reason):
- reason.trap(UserDisplayedUnauthorizedLogin)
- self.sendBanner(reason.getErrorMessage())
- return reason
-
- def getMind(self):
- """Return the mind that should be passed to self.portal.login().
-
- If multiple requests to authenticate within this overall login attempt
- should share state, this method can return the same mind each time.
- """
- return self._mind
-
- def makePublicKeyCredentials(self, username, algName, blob, sigData,
- signature):
- """Construct credentials for a request to login with a public key.
-
- Our implementation returns a SSHPrivateKeyWithMind.
-
- :param username: The username the request is for.
- :param algName: The algorithm name for the blob.
- :param blob: The public key blob as sent by the client.
- :param sigData: The data the signature was made from.
- :param signature: The signed data. This is checked to verify that the
- user owns the private key.
- """
- mind = self.getMind()
- return SSHPrivateKeyWithMind(
- username, algName, blob, sigData, signature, mind)
-
- def auth_publickey(self, packet):
- # This is copied and pasted from twisted/conch/ssh/userauth.py in
- # Twisted 8.0.1. We do this so we can customize how the credentials
- # are built and pass a mind to self.portal.login.
- hasSig = ord(packet[0])
- algName, blob, rest = getNS(packet[1:], 2)
- pubKey = keys.Key.fromString(blob).keyObject
- signature = hasSig and getNS(rest)[0] or None
- if hasSig:
- b = NS(self.transport.sessionID) + \
- chr(userauth.MSG_USERAUTH_REQUEST) + NS(self.user) + \
- NS(self.nextService) + NS('publickey') + chr(hasSig) + \
- NS(keys.objectType(pubKey)) + NS(blob)
- # The next three lines are different from the original.
- c = self.makePublicKeyCredentials(
- self.user, algName, blob, b, signature)
- return self.portal.login(c, self.getMind(), IConchUser)
- else:
- # The next four lines are different from the original.
- c = self.makePublicKeyCredentials(
- self.user, algName, blob, None, None)
- return self.portal.login(
- c, self.getMind(), IConchUser).addErrback(
- self._ebCheckKey, packet[1:])
-
-
-class PublicKeyFromLaunchpadChecker(SSHPublicKeyDatabase):
- """Cred checker for getting public keys from launchpad.
-
- It knows how to get the public keys from the authserver.
- """
- credentialInterfaces = ISSHPrivateKeyWithMind,
- implements(ICredentialsChecker)
-
- def __init__(self, authserver):
- self.authserver = authserver
-
- def checkKey(self, credentials):
- """Check whether `credentials` is a valid request to authenticate.
-
- We check the key data in credentials against the keys the named user
- has registered in Launchpad.
- """
- d = credentials.mind.lookupUserDetails(
- self.authserver, credentials.username)
- d.addCallback(self._checkForAuthorizedKey, credentials)
- d.addErrback(self._reportNoSuchUser, credentials)
- return d
-
- def _reportNoSuchUser(self, failure, credentials):
- """Report the user named in the credentials not existing nicely."""
- trap_fault(failure, faults.NoSuchPersonWithName)
- raise UserDisplayedUnauthorizedLogin(
- "No such Launchpad account: %s" % credentials.username)
-
- def _checkForAuthorizedKey(self, user_dict, credentials):
- """Check the key data in credentials against the keys found in LP."""
- if credentials.algName == 'ssh-dss':
- wantKeyType = 'DSA'
- elif credentials.algName == 'ssh-rsa':
- wantKeyType = 'RSA'
- else:
- # unknown key type
- return False
-
- if len(user_dict['keys']) == 0:
- raise UserDisplayedUnauthorizedLogin(
- "Launchpad user %r doesn't have a registered SSH key"
- % credentials.username)
-
- for keytype, keytext in user_dict['keys']:
- if keytype != wantKeyType:
- continue
- try:
- if keytext.decode('base64') == credentials.blob:
- return True
- except binascii.Error:
- continue
-
- raise UnauthorizedLogin(
- "Your SSH key does not match any key registered for Launchpad "
- "user %s" % credentials.username)
=== removed file 'lib/lp/services/sshserver/events.py'
--- lib/lp/services/sshserver/events.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/sshserver/events.py 1970-01-01 00:00:00 +0000
@@ -1,148 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Events generated by the SSH server."""
-
-__metaclass__ = type
-__all__ = [
- 'AuthenticationFailed',
- 'AvatarEvent',
- 'ILoggingEvent',
- 'LoggingEvent',
- 'ServerStarting',
- 'ServerStopped',
- 'SFTPClosed',
- 'SFTPStarted',
- 'UserConnected',
- 'UserDisconnected',
- 'UserLoggedIn',
- 'UserLoggedOut',
- ]
-
-import logging
-
-from zope.interface import (
- Attribute,
- implements,
- Interface,
- )
-
-
-class ILoggingEvent(Interface):
- """An event is a logging event if it has a message and a severity level.
-
- Events that provide this interface will be logged in the SSH server access
- log.
- """
-
- level = Attribute("The level to log the event at.")
- message = Attribute("The message to log.")
-
-
-class LoggingEvent:
- """An event that can be logged to a Python logger.
-
- :ivar level: The level to log itself as. This should be defined as a
- class variable in subclasses.
- :ivar template: The format string of the message to log. This should be
- defined as a class variable in subclasses.
- """
-
- implements(ILoggingEvent)
-
- def __init__(self, level=None, template=None, **data):
- """Construct a logging event.
-
- :param level: The level to log the event as. If specified, overrides
- the 'level' class variable.
- :param template: The format string of the message to log. If
- specified, overrides the 'template' class variable.
- :param **data: Information to be logged. Entries will be substituted
- into the template and stored as attributes.
- """
- if level is not None:
- self._level = level
- if template is not None:
- self.template = template
- self._data = data
-
- @property
- def level(self):
- """See `ILoggingEvent`."""
- return self._level
-
- @property
- def message(self):
- """See `ILoggingEvent`."""
- return self.template % self._data
-
-
-class ServerStarting(LoggingEvent):
-
- level = logging.INFO
- template = '---- Server started ----'
-
-
-class ServerStopped(LoggingEvent):
-
- level = logging.INFO
- template = '---- Server stopped ----'
-
-
-class UserConnected(LoggingEvent):
-
- level = logging.INFO
- template = '[%(session_id)s] %(address)s connected.'
-
- def __init__(self, transport, address):
- LoggingEvent.__init__(
- self, session_id=id(transport), address=address)
-
-
-class AuthenticationFailed(LoggingEvent):
-
- level = logging.INFO
- template = '[%(session_id)s] failed to authenticate.'
-
- def __init__(self, transport):
- LoggingEvent.__init__(self, session_id=id(transport))
-
-
-class UserDisconnected(LoggingEvent):
-
- level = logging.INFO
- template = '[%(session_id)s] disconnected.'
-
- def __init__(self, transport):
- LoggingEvent.__init__(self, session_id=id(transport))
-
-
-class AvatarEvent(LoggingEvent):
- """Base avatar event."""
-
- level = logging.INFO
-
- def __init__(self, avatar):
- self.avatar = avatar
- LoggingEvent.__init__(
- self, session_id=id(avatar.transport), username=avatar.username)
-
-
-class UserLoggedIn(AvatarEvent):
-
- template = '[%(session_id)s] %(username)s logged in.'
-
-
-class UserLoggedOut(AvatarEvent):
-
- template = '[%(session_id)s] %(username)s disconnected.'
-
-
-class SFTPStarted(AvatarEvent):
-
- template = '[%(session_id)s] %(username)s started SFTP session.'
-
-
-class SFTPClosed(AvatarEvent):
-
- template = '[%(session_id)s] %(username)s closed SFTP session.'
=== removed file 'lib/lp/services/sshserver/service.py'
--- lib/lp/services/sshserver/service.py 2012-04-16 23:02:44 +0000
+++ lib/lp/services/sshserver/service.py 1970-01-01 00:00:00 +0000
@@ -1,191 +0,0 @@
-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Twisted `service.Service` class for the Launchpad SSH server.
-
-An `SSHService` object can be used to launch the SSH server.
-"""
-
-__metaclass__ = type
-__all__ = [
- 'SSHService',
- ]
-
-
-import logging
-import os
-
-from twisted.application import (
- service,
- strports,
- )
-from twisted.conch.ssh.factory import SSHFactory
-from twisted.conch.ssh.keys import Key
-from twisted.conch.ssh.transport import SSHServerTransport
-from twisted.internet import defer
-from zope.event import notify
-
-from lp.services.sshserver import (
- accesslog,
- events,
- )
-from lp.services.sshserver.auth import SSHUserAuthServer
-from lp.services.twistedsupport import gatherResults
-
-
-class KeepAliveSettingSSHServerTransport(SSHServerTransport):
-
- def connectionMade(self):
- SSHServerTransport.connectionMade(self)
- self.transport.setTcpKeepAlive(True)
-
-
-class Factory(SSHFactory):
- """SSH factory that uses Launchpad's custom authentication.
-
- This class tells the SSH service to use our custom authentication service
- and configures the host keys for the SSH server. It also logs connection
- to and disconnection from the SSH server.
- """
-
- protocol = KeepAliveSettingSSHServerTransport
-
- def __init__(self, portal, private_key, public_key, banner=None):
- """Construct an SSH factory.
-
- :param portal: The portal used to turn credentials into users.
- :param private_key: The private key of the server, must be an RSA
- key, given as a `twisted.conch.ssh.keys.Key` object.
- :param public_key: The public key of the server, must be an RSA
- key, given as a `twisted.conch.ssh.keys.Key` object.
- :param banner: The text to display when users successfully log in.
- """
- # Although 'portal' isn't part of the defined interface for
- # `SSHFactory`, defining it here is how the `SSHUserAuthServer` gets
- # at it. (Look for the beautiful line "self.portal =
- # self.transport.factory.portal").
- self.portal = portal
- self.services['ssh-userauth'] = self._makeAuthServer
- self._private_key = private_key
- self._public_key = public_key
- self._banner = banner
-
- def _makeAuthServer(self, *args, **kwargs):
- kwargs['banner'] = self._banner
- return SSHUserAuthServer(*args, **kwargs)
-
- def buildProtocol(self, address):
- """Build an SSH protocol instance, logging the event.
-
- The protocol object we return is slightly modified so that we can hook
- into the 'connectionLost' event and log the disconnection.
- """
- transport = SSHFactory.buildProtocol(self, address)
- transport._realConnectionLost = transport.connectionLost
- transport.connectionLost = (
- lambda reason: self.connectionLost(transport, reason))
- notify(events.UserConnected(transport, address))
- return transport
-
- def connectionLost(self, transport, reason):
- """Call 'connectionLost' on 'transport', logging the event."""
- try:
- return transport._realConnectionLost(reason)
- finally:
- # Conch's userauth module sets 'avatar' on the transport if the
- # authentication succeeded. Thus, if it's not there,
- # authentication failed. We can't generate this event from the
- # authentication layer since:
- #
- # a) almost every SSH login has at least one failure to
- # authenticate due to multiple keys on the client-side.
- #
- # b) the server doesn't normally generate a "go away" event.
- # Rather, the client simply stops trying.
- if getattr(transport, 'avatar', None) is None:
- notify(events.AuthenticationFailed(transport))
- notify(events.UserDisconnected(transport))
-
- def getPublicKeys(self):
- """Return the server's configured public key.
-
- See `SSHFactory.getPublicKeys`.
- """
- return {'ssh-rsa': self._public_key}
-
- def getPrivateKeys(self):
- """Return the server's configured private key.
-
- See `SSHFactory.getPrivateKeys`.
- """
- return {'ssh-rsa': self._private_key}
-
-
-class SSHService(service.Service):
- """A Twisted service for the SSH server."""
-
- def __init__(self, portal, private_key_path, public_key_path,
- oops_configuration, main_log, access_log,
- access_log_path, strport='tcp:22', factory_decorator=None,
- banner=None):
- """Construct an SSH service.
-
- :param portal: The `twisted.cred.portal.Portal` that turns
- authentication requests into views on the system.
- :param private_key_path: The path to the SSH server's private key.
- :param public_key_path: The path to the SSH server's public key.
- :param oops_configuration: The section of the configuration file with
- the OOPS config details for this server.
- :param main_log: The name of the logger to log most of the server
- stuff to.
- :param access_log: The name of the logger object to log the server
- access details to.
- :param access_log_path: The path to the access log file.
- :param strport: The port to run the server on, expressed in Twisted's
- "strports" mini-language. Defaults to 'tcp:22'.
- :param factory_decorator: An optional callable that can decorate the
- server factory (e.g. with a
- `twisted.protocols.policies.TimeoutFactory`). It takes one
- argument, a factory, and must return a factory.
- :param banner: An announcement printed to users when they connect.
- By default, announce nothing.
- """
- ssh_factory = Factory(
- portal,
- private_key=Key.fromFile(private_key_path),
- public_key=Key.fromFile(public_key_path),
- banner=banner)
- if factory_decorator is not None:
- ssh_factory = factory_decorator(ssh_factory)
- self.service = strports.service(strport, ssh_factory)
- self._oops_configuration = oops_configuration
- self._main_log = main_log
- self._access_log = access_log
- self._access_log_path = access_log_path
-
- def startService(self):
- """Start the SSH service."""
- manager = accesslog.LoggingManager(
- logging.getLogger(self._main_log),
- logging.getLogger(self._access_log_path),
- self._access_log_path)
- manager.setUp()
- notify(events.ServerStarting())
- # By default, only the owner of files should be able to write to them.
- # Perhaps in the future this line will be deleted and the umask
- # managed by the startup script.
- os.umask(0022)
- service.Service.startService(self)
- self.service.startService()
-
- def stopService(self):
- """Stop the SSH service."""
- deferred = gatherResults([
- defer.maybeDeferred(service.Service.stopService, self),
- defer.maybeDeferred(self.service.stopService)])
-
- def log_stopped(ignored):
- notify(events.ServerStopped())
- return ignored
-
- return deferred.addBoth(log_stopped)
=== removed file 'lib/lp/services/sshserver/session.py'
--- lib/lp/services/sshserver/session.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/sshserver/session.py 1970-01-01 00:00:00 +0000
@@ -1,124 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Patched SSH session for the Launchpad server."""
-
-__metaclass__ = type
-__all__ = [
- 'DoNothingSession',
- 'PatchedSSHSession',
- ]
-
-from twisted.conch.interfaces import ISession
-from twisted.conch.ssh import (
- channel,
- connection,
- session,
- )
-from zope.interface import implements
-
-
-class PatchedSSHSession(session.SSHSession, object):
- """Session adapter that corrects bugs in Conch.
-
- This object provides no custom logic for Launchpad, it just addresses some
- simple bugs in the base `session.SSHSession` class that are not yet fixed
- upstream.
- """
-
- def closeReceived(self):
- # Without this, the client hangs when it's finished transferring.
- # XXX: JonathanLange 2009-01-05: This does not appear to have a
- # corresponding bug in Twisted. We should test that the above comment
- # is indeed correct and then file a bug upstream.
- self.loseConnection()
-
- def loseConnection(self):
- # XXX: JonathanLange 2008-03-31: This deliberately replaces the
- # implementation of session.SSHSession.loseConnection. The default
- # implementation will try to call loseConnection on the client
- # transport even if it's None. I don't know *why* it is None, so this
- # doesn't necessarily address the root cause.
- # See http://twistedmatrix.com/trac/ticket/2754.
- transport = getattr(self.client, 'transport', None)
- if transport is not None:
- transport.loseConnection()
- # This is called by session.SSHSession.loseConnection. SSHChannel is
- # the base class of SSHSession.
- channel.SSHChannel.loseConnection(self)
-
- def stopWriting(self):
- """See `session.SSHSession.stopWriting`.
-
- When the client can't keep up with us, we ask the child process to
- stop giving us data.
- """
- # XXX: MichaelHudson 2008-06-27: Being cagey about whether
- # self.client.transport is entirely paranoia inspired by the comment
- # in `loseConnection` above. It would be good to know if and why it is
- # necessary. See http://twistedmatrix.com/trac/ticket/2754.
- transport = getattr(self.client, 'transport', None)
- if transport is not None:
- # For SFTP connections, 'transport' is actually a _DummyTransport
- # instance. Neither _DummyTransport nor the protocol it wraps
- # (filetransfer.FileTransferServer) support pausing.
- pauseProducing = getattr(transport, 'pauseProducing', None)
- if pauseProducing is not None:
- pauseProducing()
-
- def startWriting(self):
- """See `session.SSHSession.startWriting`.
-
- The client is ready for data again, so ask the child to start
- producing data again.
- """
- # XXX: MichaelHudson 2008-06-27: Being cagey about whether
- # self.client.transport is entirely paranoia inspired by the comment
- # in `loseConnection` above. It would be good to know if and why it is
- # necessary. See http://twistedmatrix.com/trac/ticket/2754.
- transport = getattr(self.client, 'transport', None)
- if transport is not None:
- # For SFTP connections, 'transport' is actually a _DummyTransport
- # instance. Neither _DummyTransport nor the protocol it wraps
- # (filetransfer.FileTransferServer) support pausing.
- resumeProducing = getattr(transport, 'resumeProducing', None)
- if resumeProducing is not None:
- resumeProducing()
-
-
-class DoNothingSession:
- """A Conch user session that allows nothing."""
-
- implements(ISession)
-
- def __init__(self, avatar):
- self.avatar = avatar
-
- def closed(self):
- """See ISession."""
-
- def eofReceived(self):
- """See ISession."""
-
- def errorWithMessage(self, protocol, msg):
- protocol.session.writeExtended(
- connection.EXTENDED_DATA_STDERR, msg)
- protocol.loseConnection()
-
- def execCommand(self, protocol, command):
- """See ISession."""
- self.errorWithMessage(
- protocol, "Not allowed to execute commands on this server.\r\n")
-
- def getPty(self, term, windowSize, modes):
- """See ISession."""
- # Do nothing, as we don't provide shell access. openShell will get
- # called and handle this error message and disconnect.
-
- def openShell(self, protocol):
- """See ISession."""
- self.errorWithMessage(protocol, "No shells on this server.\r\n")
-
- def windowChanged(self, newWindowSize):
- """See ISession."""
- raise NotImplementedError(self.windowChanged)
=== removed file 'lib/lp/services/sshserver/sftp.py'
--- lib/lp/services/sshserver/sftp.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/sshserver/sftp.py 1970-01-01 00:00:00 +0000
@@ -1,45 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Generic SFTP server functionality."""
-
-__metaclass__ = type
-__all__ = [
- 'FileIsADirectory',
- 'FileTransferServer',
- ]
-
-from bzrlib import errors as bzr_errors
-from twisted.conch.ssh import filetransfer
-from zope.event import notify
-
-from lp.services.sshserver import events
-
-
-class FileIsADirectory(bzr_errors.PathError):
- """Raised when writeChunk is called on a directory.
-
- This exists mainly to be translated into the appropriate SFTP error.
- """
-
- _fmt = 'File is a directory: %(path)r%(extra)s'
-
-
-class FileTransferServer(filetransfer.FileTransferServer):
- """SFTP protocol implementation that logs key events."""
-
- def __init__(self, data=None, avatar=None):
- filetransfer.FileTransferServer.__init__(self, data, avatar)
- notify(events.SFTPStarted(avatar))
- self.avatar = avatar
-
- def connectionLost(self, reason):
- # This method gets called twice: once from `SSHChannel.closeReceived`
- # when the client closes the channel and once from `SSHSession.closed`
- # when the server closes the session. We change the avatar attribute
- # to avoid logging the `SFTPClosed` event twice.
- filetransfer.FileTransferServer.connectionLost(self, reason)
- if self.avatar is not None:
- avatar = self.avatar
- self.avatar = None
- notify(events.SFTPClosed(avatar))
=== removed directory 'lib/lp/services/sshserver/tests'
=== removed file 'lib/lp/services/sshserver/tests/__init__.py'
--- lib/lp/services/sshserver/tests/__init__.py 2010-04-15 14:49:55 +0000
+++ lib/lp/services/sshserver/tests/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,8 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Tests for the Launchpad SSH server."""
-
-__metaclass__ = type
-__all__ = []
-
=== removed directory 'lib/lp/services/sshserver/tests/keys'
=== removed file 'lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa'
--- lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa 2010-04-15 15:27:30 +0000
+++ lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa 1970-01-01 00:00:00 +0000
@@ -1,15 +0,0 @@
------BEGIN RSA PRIVATE KEY-----
-MIICXAIBAAKBgQDSSpVRPhCiU9PPuZN7QyJdMOgTVwPyYpZGOHutR/9kxFvOLa39
-nY0Eqo39OTumfZBMEEVqIPadQanO9LcdTnl9/Z4LcBGn09EFQ2y7VUkC6J2dSQtr
-YMY0tV+C5HGZ2oYBWKBl5PZ1RI4+qrJpAMMmINdnF0uEE/x8B1iMWGB3PwIBIwKB
-gQCcN2ebb+8ZgBmwQLazVnFMirsHDXClbc630i/9EOmbUAmvGp6B4sCHH5ytevkc
-l8pHIget7JnxKXbUQMKKzJTCpPwwEyL3ZVDxYXg37WQU74cVf93CjOjChs+hOeS1
-sW5m9JFr9oomL5JWnGXr+TV/kYBCNVW++J1Bckn6kYpH+wJBAPUw0ZunXlBRuugA
-YTSmXUUX+ALu6maDD1t7gAk37waxNQMaH5DMk5R4IQtoxeQgCLqL2yEJUqK1lxOy
-wlp1k8UCQQDbj+Vr4poE9MpYNtPDiDqv2aXe6CJ3p1qQuNE0rSxk+0G9h3ASISRw
-DDLgcapg2xkOvG3pidfAJG9P827XiVszAkEA4CyiYm0jB5suivkIaqa7rOK23hxE
-BfQrTFOoQvFPkRcL5ZQ6Hfzes6EIRPIUA8WEUskC3GBLjXLTRTW5Aj+dCwJBAMi+
-E5XWfjBqx6EcL1OvwKDG/g2hCZH4GEnNjBLneQveZ/29qEsW/L43CfHHAiyrD5hx
-w5OxOklF4h02VrZu9EsCQBEZcTAQOrWnkmp7uBrz1V8nFLAE6zFh/6Wj1Mn8k08j
-pcsLJAhm+qlV7EtV/5rk+v3WcXrKiRIiiC0Ron96Dx0=
------END RSA PRIVATE KEY-----
=== removed file 'lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa.pub'
--- lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa.pub 2010-04-15 15:27:30 +0000
+++ lib/lp/services/sshserver/tests/keys/ssh_host_key_rsa.pub 1970-01-01 00:00:00 +0000
@@ -1,1 +0,0 @@
-ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA0kqVUT4QolPTz7mTe0MiXTDoE1cD8mKWRjh7rUf/ZMRbzi2t/Z2NBKqN/Tk7pn2QTBBFaiD2nUGpzvS3HU55ff2eC3ARp9PRBUNsu1VJAuidnUkLa2DGNLVfguRxmdqGAVigZeT2dUSOPqqyaQDDJiDXZxdLhBP8fAdYjFhgdz8= andrew@frobozz
=== removed file 'lib/lp/services/sshserver/tests/test_accesslog.py'
--- lib/lp/services/sshserver/tests/test_accesslog.py 2012-03-26 06:08:39 +0000
+++ lib/lp/services/sshserver/tests/test_accesslog.py 1970-01-01 00:00:00 +0000
@@ -1,146 +0,0 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Tests for the logging system of the sshserver."""
-
-__metaclass__ = type
-
-import codecs
-import logging
-from logging.handlers import WatchedFileHandler
-import os
-from StringIO import StringIO
-import sys
-import tempfile
-
-from bzrlib.tests import TestCase as BzrTestCase
-import zope.component.event
-
-from lp.services.sshserver.accesslog import LoggingManager
-from lp.testing import TestCase
-
-
-class LoggingManagerMixin:
-
- _log_count = 0
-
- def makeLogger(self, name=None):
- if name is None:
- self._log_count += 1
- name = '%s-%s' % (self.id().split('.')[-1], self._log_count)
- return logging.getLogger(name)
-
- def installLoggingManager(self, main_log=None, access_log=None,
- access_log_path=None):
- if main_log is None:
- main_log = self.makeLogger()
- if access_log is None:
- access_log = self.makeLogger()
- if access_log_path is None:
- fd, access_log_path = tempfile.mkstemp()
- os.close(fd)
- self.addCleanup(os.unlink, access_log_path)
- manager = LoggingManager(main_log, access_log, access_log_path)
- manager.setUp()
- self.addCleanup(manager.tearDown)
- return manager
-
-
-class TestLoggingBazaarInteraction(BzrTestCase, LoggingManagerMixin):
-
- def setUp(self):
- BzrTestCase.setUp(self)
-
- # Trap stderr.
- self._real_stderr = sys.stderr
- sys.stderr = codecs.getwriter('utf8')(StringIO())
-
- def tearDown(self):
- sys.stderr = self._real_stderr
- BzrTestCase.tearDown(self)
-
- def test_leaves_bzr_handlers_unchanged(self):
- # Bazaar's log handling is untouched by logging setup.
- root_handlers = logging.getLogger('').handlers
- bzr_handlers = logging.getLogger('bzr').handlers
-
- self.installLoggingManager()
-
- self.assertEqual(root_handlers, logging.getLogger('').handlers)
- self.assertEqual(bzr_handlers, logging.getLogger('bzr').handlers)
-
- def test_log_doesnt_go_to_stderr(self):
- # Once logging setup is called, any messages logged to the
- # SSH server logger should *not* be logged to stderr. If they are,
- # they will appear on the user's terminal.
- log = self.makeLogger()
- self.installLoggingManager(log)
-
- # Make sure that a logged message does not go to stderr.
- log.info('Hello hello')
- self.assertEqual(sys.stderr.getvalue(), '')
-
-
-class TestLoggingManager(TestCase, LoggingManagerMixin):
-
- def test_main_log_handlers(self):
- # There needs to be at least one handler for the root logger. If there
- # isn't, we'll get constant errors complaining about the lack of
- # logging handlers.
- log = self.makeLogger()
- self.assertEqual([], log.handlers)
- self.installLoggingManager(log)
- self.assertNotEqual([], log.handlers)
-
- def _get_handlers(self):
- registrations = (
- zope.component.getGlobalSiteManager().registeredHandlers())
- return [
- registration.factory
- for registration in registrations]
-
- def test_set_up_registers_event_handler(self):
- manager = self.installLoggingManager()
- self.assertIn(manager._log_event, self._get_handlers())
-
- def test_teardown_restores_event_handlers(self):
- handlers = self._get_handlers()
- manager = self.installLoggingManager()
- manager.tearDown()
- self.assertEqual(handlers, self._get_handlers())
-
- def test_teardown_restores_level(self):
- log = self.makeLogger()
- old_level = log.level
- manager = self.installLoggingManager(log)
- manager.tearDown()
- self.assertEqual(old_level, log.level)
-
- def test_teardown_restores_main_log_handlers(self):
- # tearDown restores log handlers for the main logger.
- log = self.makeLogger()
- handlers = list(log.handlers)
- manager = self.installLoggingManager(log)
- manager.tearDown()
- self.assertEqual(handlers, log.handlers)
-
- def test_teardown_restores_access_log_handlers(self):
- # tearDown restores log handlers for the access logger.
- log = self.makeLogger()
- handlers = list(log.handlers)
- manager = self.installLoggingManager(access_log=log)
- manager.tearDown()
- self.assertEqual(handlers, log.handlers)
-
- def test_access_handlers(self):
- # The logging setup installs a rotatable log handler that logs output
- # to the SSH server access log.
- directory = self.makeTemporaryDirectory()
- access_log = self.makeLogger()
- access_log_path = os.path.join(directory, 'access.log')
- self.installLoggingManager(
- access_log=access_log,
- access_log_path=access_log_path)
- [handler] = access_log.handlers
- self.assertIsInstance(handler, WatchedFileHandler)
- self.assertEqual(access_log_path, handler.baseFilename)
=== removed file 'lib/lp/services/sshserver/tests/test_auth.py'
--- lib/lp/services/sshserver/tests/test_auth.py 2012-01-01 02:58:52 +0000
+++ lib/lp/services/sshserver/tests/test_auth.py 1970-01-01 00:00:00 +0000
@@ -1,516 +0,0 @@
-# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-import os
-
-from testtools.deferredruntest import (
- assert_fails_with,
- AsynchronousDeferredRunTest,
- flush_logged_errors,
- )
-from twisted.conch.checkers import SSHPublicKeyDatabase
-from twisted.conch.error import ConchError
-from twisted.conch.ssh import userauth
-from twisted.conch.ssh.common import (
- getNS,
- NS,
- )
-from twisted.conch.ssh.keys import (
- BadKeyError,
- Key,
- )
-from twisted.conch.ssh.transport import (
- SSHCiphers,
- SSHServerTransport,
- )
-from twisted.cred.error import UnauthorizedLogin
-from twisted.cred.portal import (
- IRealm,
- Portal,
- )
-from twisted.internet import defer
-from twisted.python import failure
-from twisted.python.util import sibpath
-from zope.interface import implements
-
-from lp.services.sshserver import auth
-from lp.services.twistedsupport import suppress_stderr
-from lp.testing import TestCase
-from lp.xmlrpc import faults
-
-
-class MockRealm:
- """A mock realm for testing userauth.SSHUserAuthServer.
-
- This realm is not actually used in the course of testing, so calls to
- requestAvatar will raise an exception.
- """
-
- implements(IRealm)
-
- def requestAvatar(self, avatar_id, mind, *interfaces):
- user_dict = {
- 'id': avatar_id, 'name': avatar_id, 'teams': [],
- 'initialBranches': []}
- return (
- interfaces[0], auth.LaunchpadAvatar(user_dict), lambda: None)
-
-
-class MockSSHTransport(SSHServerTransport):
- """A mock SSH transport for testing userauth.SSHUserAuthServer.
-
- SSHUserAuthServer expects an SSH transport which has a factory attribute
- which in turn has a portal attribute. Because the portal is important for
- testing authentication, we need to be able to provide an interesting portal
- object to the SSHUserAuthServer.
-
- In addition, we want to be able to capture any packets sent over the
- transport.
- """
-
- class Factory:
- def getService(self, transport, nextService):
- return lambda: None
-
- def __init__(self, portal):
- # In Twisted 8.0.1, Conch's transport starts referring to
- # currentEncryptions where it didn't before. Provide a dummy value for
- # it.
- self.currentEncryptions = SSHCiphers('none', 'none', 'none', 'none')
- self.packets = []
- self.factory = self.Factory()
- self.factory.portal = portal
-
- def sendPacket(self, messageType, payload):
- self.packets.append((messageType, payload))
-
- def setService(self, service):
- pass
-
-
-class UserAuthServerMixin(object):
- def setUp(self):
- self.portal = Portal(MockRealm())
- self.transport = MockSSHTransport(self.portal)
- self.user_auth = auth.SSHUserAuthServer(self.transport)
-
- def _getMessageName(self, message_type):
- """Get the name of the message for the given message type constant."""
- return userauth.SSHUserAuthServer.protocolMessages[message_type]
-
- def assertMessageOrder(self, message_types):
- """Assert that SSH messages were sent in the given order."""
- messages = userauth.SSHUserAuthServer.protocolMessages
- self.assertEqual(
- [messages[msg_type] for msg_type in message_types],
- [messages[packet_type]
- for packet_type, contents in self.transport.packets])
-
- def assertBannerSent(self, banner_message, expected_language='en'):
- """Assert that 'banner_message' was sent as an SSH banner."""
- # Check that we received a BANNER, then a FAILURE.
- for packet_type, packet_content in self.transport.packets:
- if packet_type == userauth.MSG_USERAUTH_BANNER:
- bytes, language, empty = getNS(packet_content, 2)
- self.assertEqual(banner_message, bytes.decode('UTF8'))
- self.assertEqual(expected_language, language)
- self.assertEqual('', empty)
- break
- else:
- self.fail("No banner logged.")
-
-
-class TestUserAuthServer(TestCase, UserAuthServerMixin):
-
- def setUp(self):
- TestCase.setUp(self)
- UserAuthServerMixin.setUp(self)
-
- def test_sendBanner(self):
- # sendBanner should send an SSH 'packet' with type MSG_USERAUTH_BANNER
- # and two fields. The first field is the message itself, and the
- # second is the language tag.
- #
- # sendBanner automatically adds a trailing newline, because openssh
- # and Twisted don't add one when displaying the banner.
- #
- # See RFC 4252, Section 5.4.
- message = u"test message"
- self.user_auth.sendBanner(message, language='en-US')
- self.assertBannerSent(message + '\r\n', 'en-US')
- self.assertEqual(
- 1, len(self.transport.packets),
- "More than just banner was sent: %r" % self.transport.packets)
-
- def test_sendBannerUsesCRLF(self):
- # sendBanner should make sure that any line breaks in the message are
- # sent as CR LF pairs.
- #
- # See RFC 4252, Section 5.4.
- self.user_auth.sendBanner(u"test\nmessage")
- [(messageType, payload)] = self.transport.packets
- bytes, language, empty = getNS(payload, 2)
- self.assertEqual(bytes.decode('UTF8'), u"test\r\nmessage\r\n")
-
- def test_requestRaisesConchError(self):
- # ssh_USERAUTH_REQUEST should raise a ConchError if tryAuth returns
- # None. Added to catch a bug noticed by pyflakes.
- # Whitebox test.
- def mock_try_auth(kind, user, data):
- return None
- def mock_eb_bad_auth(reason):
- reason.trap(ConchError)
- tryAuth = self.user_auth.tryAuth
- self.user_auth.tryAuth = mock_try_auth
- _ebBadAuth, self.user_auth._ebBadAuth = (self.user_auth._ebBadAuth,
- mock_eb_bad_auth)
- self.user_auth.serviceStarted()
- try:
- packet = NS('jml') + NS('foo') + NS('public_key') + NS('data')
- self.user_auth.ssh_USERAUTH_REQUEST(packet)
- finally:
- self.user_auth.serviceStopped()
- self.user_auth.tryAuth = tryAuth
- self.user_auth._ebBadAuth = _ebBadAuth
-
-
-class MockChecker(SSHPublicKeyDatabase):
- """A very simple public key checker which rejects all offered credentials.
-
- Used by TestAuthenticationBannerDisplay to test that errors raised by
- checkers are sent to SSH clients.
- """
-
- error_message = u'error message'
-
- def requestAvatarId(self, credentials):
- if credentials.username == 'success':
- return credentials.username
- else:
- return failure.Failure(
- auth.UserDisplayedUnauthorizedLogin(self.error_message))
-
-
-class TestAuthenticationBannerDisplay(UserAuthServerMixin, TestCase):
- """Check that auth error information is passed through to the client.
-
- Normally, SSH servers provide minimal information on failed authentication.
- With Launchpad, much more user information is public, so it is helpful and
- not insecure to tell users why they failed to authenticate.
-
- SSH doesn't provide a standard way of doing this, but the
- MSG_USERAUTH_BANNER message is allowed and seems appropriate. See RFC 4252,
- Section 5.4 for more information.
- """
-
- run_tests_with = AsynchronousDeferredRunTest
-
- def setUp(self):
- UserAuthServerMixin.setUp(self)
- TestCase.setUp(self)
- self.portal.registerChecker(MockChecker())
- self.user_auth.serviceStarted()
- self.key_data = self._makeKey()
-
- def tearDown(self):
- self.user_auth.serviceStopped()
- TestCase.tearDown(self)
-
- def _makeKey(self):
- keydir = sibpath(__file__, 'keys')
- public_key = Key.fromString(
- open(os.path.join(keydir, 'ssh_host_key_rsa.pub'), 'rb').read())
- if isinstance(public_key, str):
- return chr(0) + NS('rsa') + NS(public_key)
- else:
- return chr(0) + NS('rsa') + NS(public_key.blob())
-
- def requestFailedAuthentication(self):
- return self.user_auth.ssh_USERAUTH_REQUEST(
- NS('failure') + NS('') + NS('publickey') + self.key_data)
-
- def requestSuccessfulAuthentication(self):
- return self.user_auth.ssh_USERAUTH_REQUEST(
- NS('success') + NS('') + NS('publickey') + self.key_data)
-
- def requestUnsupportedAuthentication(self):
- # Note that it doesn't matter how the checker responds -- the server
- # doesn't get that far.
- return self.user_auth.ssh_USERAUTH_REQUEST(
- NS('success') + NS('') + NS('none') + NS(''))
-
- def test_bannerNotSentOnSuccess(self):
- # No banner is printed when the user authenticates successfully.
- self.user_auth._banner = None
-
- d = self.requestSuccessfulAuthentication()
- def check(ignored):
- # Check that no banner was sent to the user.
- self.assertMessageOrder([userauth.MSG_USERAUTH_SUCCESS])
- return d.addCallback(check)
-
- def test_defaultBannerSentOnSuccess(self):
- # If a banner was passed to the user auth agent then we send it to the
- # user when they log in.
- self.user_auth._banner = "Boogedy boo"
- d = self.requestSuccessfulAuthentication()
- def check(ignored):
- self.assertMessageOrder(
- [userauth.MSG_USERAUTH_BANNER, userauth.MSG_USERAUTH_SUCCESS])
- self.assertBannerSent(self.user_auth._banner + '\r\n')
- return d.addCallback(check)
-
- def test_defaultBannerSentOnlyOnce(self):
- # We don't send the banner on each authentication attempt, just on the
- # first one. It is usual for there to be many authentication attempts
- # per SSH session.
- self.user_auth._banner = "Boogedy boo"
-
- d = self.requestUnsupportedAuthentication()
- d.addCallback(lambda ignored: self.requestSuccessfulAuthentication())
-
- def check(ignored):
- # Check that no banner was sent to the user.
- self.assertMessageOrder(
- [userauth.MSG_USERAUTH_FAILURE, userauth.MSG_USERAUTH_BANNER,
- userauth.MSG_USERAUTH_SUCCESS])
- self.assertBannerSent(self.user_auth._banner + '\r\n')
-
- return d.addCallback(check)
-
- def test_defaultBannerNotSentOnFailure(self):
- # Failed authentication attempts do not get the default banner
- # sent.
- self.user_auth._banner = "You come away two hundred quid down"
-
- d = self.requestFailedAuthentication()
-
- def check(ignored):
- self.assertMessageOrder(
- [userauth.MSG_USERAUTH_BANNER, userauth.MSG_USERAUTH_FAILURE])
- self.assertBannerSent(MockChecker.error_message + '\r\n')
-
- return d.addCallback(check)
-
- def test_loggedToBanner(self):
- # When there's an authentication failure, we display an informative
- # error message through the SSH authentication protocol 'banner'.
- d = self.requestFailedAuthentication()
- def check(ignored):
- # Check that we received a BANNER, then a FAILURE.
- self.assertMessageOrder(
- [userauth.MSG_USERAUTH_BANNER, userauth.MSG_USERAUTH_FAILURE])
- self.assertBannerSent(MockChecker.error_message + '\r\n')
- return d.addCallback(check)
-
- def test_unsupportedAuthMethodNotLogged(self):
- # Trying various authentication methods is a part of the normal
- # operation of the SSH authentication protocol. We should not spam the
- # client with warnings about this, as whenever it becomes a problem,
- # we can rely on the SSH client itself to report it to the user.
- d = self.requestUnsupportedAuthentication()
- def check(ignored):
- # Check that we received only a FAILRE.
- self.assertMessageOrder([userauth.MSG_USERAUTH_FAILURE])
- return d.addCallback(check)
-
-
-class TestPublicKeyFromLaunchpadChecker(TestCase):
- """Tests for the SSH server authentication mechanism.
-
- PublicKeyFromLaunchpadChecker accepts the SSH authentication information
- and contacts the authserver to determine if the given details are valid.
-
- Any authentication errors are displayed back to the user via an SSH
- MSG_USERAUTH_BANNER message.
- """
-
- run_tests_with = AsynchronousDeferredRunTest
-
- class FakeAuthenticationEndpoint:
- """A fake client for enough of `IAuthServer` for this test.
- """
-
- valid_user = 'valid_user'
- no_key_user = 'no_key_user'
- valid_key = 'valid_key'
-
- def __init__(self):
- self.calls = []
-
- def callRemote(self, function_name, *args, **kwargs):
- return getattr(
- self, 'xmlrpc_%s' % function_name)(*args, **kwargs)
-
- def xmlrpc_getUserAndSSHKeys(self, username):
- self.calls.append(username)
- if username == self.valid_user:
- return defer.succeed({
- 'name': username,
- 'keys': [('DSA', self.valid_key.encode('base64'))],
- })
- elif username == self.no_key_user:
- return defer.succeed({
- 'name': username,
- 'keys': [],
- })
- else:
- try:
- raise faults.NoSuchPersonWithName(username)
- except faults.NoSuchPersonWithName:
- return defer.fail()
-
- def makeCredentials(self, username, public_key, mind=None):
- if mind is None:
- mind = auth.UserDetailsMind()
- return auth.SSHPrivateKeyWithMind(
- username, 'ssh-dss', public_key, '', None, mind)
-
- def makeChecker(self, do_signature_checking=False):
- """Construct a PublicKeyFromLaunchpadChecker.
-
- :param do_signature_checking: if False, as is the default, monkeypatch
- the returned instance to not verify the signatures of the keys.
- """
- checker = auth.PublicKeyFromLaunchpadChecker(self.authserver)
- if not do_signature_checking:
- checker._cbRequestAvatarId = self._cbRequestAvatarId
- return checker
-
- def _cbRequestAvatarId(self, is_key_valid, credentials):
- if is_key_valid:
- return credentials.username
- return failure.Failure(UnauthorizedLogin())
-
- def setUp(self):
- TestCase.setUp(self)
- self.authserver = self.FakeAuthenticationEndpoint()
-
- def test_successful(self):
- # Attempting to log in with a username and key known to the
- # authentication end-point succeeds.
- creds = self.makeCredentials(
- self.authserver.valid_user, self.authserver.valid_key)
- checker = self.makeChecker()
- d = checker.requestAvatarId(creds)
- return d.addCallback(self.assertEqual, self.authserver.valid_user)
-
- @suppress_stderr
- def test_invalid_signature(self):
- # The checker requests attempts to authenticate if the requests have
- # an invalid signature.
- creds = self.makeCredentials(
- self.authserver.valid_user, self.authserver.valid_key)
- creds.signature = 'a'
- checker = self.makeChecker(True)
- d = checker.requestAvatarId(creds)
- def flush_errback(f):
- flush_logged_errors(BadKeyError)
- return f
- d.addErrback(flush_errback)
- return assert_fails_with(d, UnauthorizedLogin)
-
- def assertLoginError(self, checker, creds, error_message):
- """Logging in with 'creds' against 'checker' fails with 'message'.
-
- In particular, this tests that the login attempt fails in a way that
- is sent to the client.
-
- :param checker: The `ICredentialsChecker` used.
- :param creds: SSHPrivateKey credentials.
- :param error_message: String excepted to match the exception's message.
- :return: Deferred. You must return this from your test.
- """
- d = assert_fails_with(
- checker.requestAvatarId(creds),
- auth.UserDisplayedUnauthorizedLogin)
- d.addCallback(
- lambda exception: self.assertEqual(str(exception), error_message))
- return d
-
- def test_noSuchUser(self):
- # When someone signs in with a non-existent user, they should be told
- # that. The usual security issues don't apply here because the list of
- # Launchpad user names is public.
- checker = self.makeChecker()
- creds = self.makeCredentials(
- 'no-such-user', self.authserver.valid_key)
- return self.assertLoginError(
- checker, creds, 'No such Launchpad account: no-such-user')
-
- def test_noKeys(self):
- # When you sign into an existing account with no SSH keys, the SSH
- # server informs you that the account has no keys.
- checker = self.makeChecker()
- creds = self.makeCredentials(
- self.authserver.no_key_user, self.authserver.valid_key)
- return self.assertLoginError(
- checker, creds,
- "Launchpad user %r doesn't have a registered SSH key"
- % self.authserver.no_key_user)
-
- def test_wrongKey(self):
- # When you sign into an existing account using the wrong key, you
- # are *not* informed of the wrong key. This is because SSH often
- # tries several keys as part of normal operation.
- checker = self.makeChecker()
- creds = self.makeCredentials(
- self.authserver.valid_user, 'invalid key')
- # We cannot use assertLoginError because we are checking that we fail
- # with UnauthorizedLogin and not its subclass
- # UserDisplayedUnauthorizedLogin.
- d = assert_fails_with(
- checker.requestAvatarId(creds),
- UnauthorizedLogin)
- d.addCallback(
- lambda exception:
- self.failIf(isinstance(exception,
- auth.UserDisplayedUnauthorizedLogin),
- "Should not be a UserDisplayedUnauthorizedLogin"))
- return d
-
- def test_successful_with_second_key_calls_authserver_once(self):
- # It is normal in SSH authentication to be presented with a number of
- # keys. When the valid key is presented after some invalid ones (a)
- # the login succeeds and (b) only one call is made to the authserver
- # to retrieve the user's details.
- checker = self.makeChecker()
- mind = auth.UserDetailsMind()
- wrong_key_creds = self.makeCredentials(
- self.authserver.valid_user, 'invalid key', mind)
- right_key_creds = self.makeCredentials(
- self.authserver.valid_user, self.authserver.valid_key, mind)
- d = checker.requestAvatarId(wrong_key_creds)
- def try_second_key(failure):
- failure.trap(UnauthorizedLogin)
- return checker.requestAvatarId(right_key_creds)
- d.addErrback(try_second_key)
- d.addCallback(self.assertEqual, self.authserver.valid_user)
- def check_one_call(r):
- self.assertEqual(
- [self.authserver.valid_user], self.authserver.calls)
- return r
- d.addCallback(check_one_call)
- return d
-
- def test_noSuchUser_with_two_keys_calls_authserver_once(self):
- # When more than one key is presented for a username that does not
- # exist, only one call is made to the authserver.
- checker = self.makeChecker()
- mind = auth.UserDetailsMind()
- creds_1 = self.makeCredentials(
- 'invalid-user', 'invalid key 1', mind)
- creds_2 = self.makeCredentials(
- 'invalid-user', 'invalid key 2', mind)
- d = checker.requestAvatarId(creds_1)
- def try_second_key(failure):
- return assert_fails_with(
- checker.requestAvatarId(creds_2),
- UnauthorizedLogin)
- d.addErrback(try_second_key)
- def check_one_call(r):
- self.assertEqual(
- ['invalid-user'], self.authserver.calls)
- return r
- d.addCallback(check_one_call)
- return d
=== removed file 'lib/lp/services/sshserver/tests/test_events.py'
--- lib/lp/services/sshserver/tests/test_events.py 2011-08-12 11:37:08 +0000
+++ lib/lp/services/sshserver/tests/test_events.py 1970-01-01 00:00:00 +0000
@@ -1,91 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Tests for the logging events."""
-
-__metaclass__ = type
-
-import logging
-
-from zope.component import (
- adapter,
- getGlobalSiteManager,
- provideHandler,
- )
-# This non-standard import is necessary to hook up the event system.
-import zope.component.event
-from zope.event import notify
-
-from lp.services.sshserver.events import (
- ILoggingEvent,
- LoggingEvent,
- )
-from lp.testing import TestCase
-
-
-class ListHandler(logging.Handler):
- """Logging handler that just appends records to a list.
-
- This handler isn't intended to be used by production code -- memory leak
- city! -- instead it's useful for unit tests that want to make sure the
- right events are being logged.
- """
-
- def __init__(self, logging_list):
- """Construct a `ListHandler`.
-
- :param logging_list: A list that will be appended to. The handler
- mutates this list.
- """
- logging.Handler.__init__(self)
- self._list = logging_list
-
- def emit(self, record):
- """Append 'record' to the list."""
- self._list.append(record)
-
-
-class TestLoggingEvent(TestCase):
-
- def assertLogs(self, records, function, *args, **kwargs):
- """Assert 'function' logs 'records' when run with the given args."""
- logged_events = []
- handler = ListHandler(logged_events)
- self.logger.addHandler(handler)
- result = function(*args, **kwargs)
- self.logger.removeHandler(handler)
- self.assertEqual(
- [(record.levelno, record.getMessage())
- for record in logged_events], records)
- return result
-
- def assertEventLogs(self, record, logging_event):
- self.assertLogs([record], notify, logging_event)
-
- def setUp(self):
- TestCase.setUp(self)
- logger = logging.getLogger(self.factory.getUniqueString())
- logger.setLevel(logging.DEBUG)
- self.logger = logger
-
- @adapter(ILoggingEvent)
- def _log_event(event):
- logger.log(event.level, event.message)
-
- provideHandler(_log_event)
- self.addCleanup(getGlobalSiteManager().unregisterHandler, _log_event)
-
- def test_level(self):
- event = LoggingEvent(logging.CRITICAL, "foo")
- self.assertEventLogs((logging.CRITICAL, 'foo'), event)
-
- def test_formatting(self):
- event = LoggingEvent(logging.DEBUG, "foo: %(name)s", name="bar")
- self.assertEventLogs((logging.DEBUG, 'foo: bar'), event)
-
- def test_subclass(self):
- class SomeEvent(LoggingEvent):
- template = "%(something)s happened."
- level = logging.INFO
- self.assertEventLogs(
- (logging.INFO, 'foo happened.'), SomeEvent(something='foo'))
=== removed file 'lib/lp/services/sshserver/tests/test_session.py'
--- lib/lp/services/sshserver/tests/test_session.py 2011-08-12 11:37:08 +0000
+++ lib/lp/services/sshserver/tests/test_session.py 1970-01-01 00:00:00 +0000
@@ -1,99 +0,0 @@
-# Copyright 2010 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Tests for generic SSH session support."""
-
-__metaclass__ = type
-
-from twisted.conch.interfaces import ISession
-from twisted.conch.ssh import connection
-
-from lp.services.sshserver.session import DoNothingSession
-from lp.testing import TestCase
-
-
-class MockSSHSession:
- """Just enough of SSHSession to allow checking of reporting to stderr."""
-
- def __init__(self, log):
- self.log = log
-
- def writeExtended(self, channel, data):
- self.log.append(('writeExtended', channel, data))
-
-
-class MockProcessTransport:
- """Mock transport used to fake speaking with child processes that are
- mocked out in tests.
- """
-
- def __init__(self, executable):
- self._executable = executable
- self.log = []
- self.session = MockSSHSession(self.log)
-
- def closeStdin(self):
- self.log.append(('closeStdin',))
-
- def loseConnection(self):
- self.log.append(('loseConnection',))
-
- def signalProcess(self, signal):
- self.log.append(('signalProcess', signal))
-
- def write(self, data):
- self.log.append(('write', data))
-
-
-class TestDoNothing(TestCase):
- """Tests for DoNothingSession."""
-
- def setUp(self):
- super(TestDoNothing, self).setUp()
- self.session = DoNothingSession(None)
-
- def test_getPtyIsANoOp(self):
- # getPty is called on the way to establishing a shell. Since we don't
- # give out shells, it should be a no-op. Raising an exception would
- # log an OOPS, so we won't do that.
- self.assertEqual(None, self.session.getPty(None, None, None))
-
- def test_openShellNotImplemented(self):
- # openShell closes the connection.
- protocol = MockProcessTransport('bash')
- self.session.openShell(protocol)
- self.assertEqual(
- [('writeExtended', connection.EXTENDED_DATA_STDERR,
- 'No shells on this server.\r\n'),
- ('loseConnection',)],
- protocol.log)
-
- def test_windowChangedNotImplemented(self):
- # windowChanged raises a NotImplementedError. It doesn't matter what
- # we pass it.
- self.assertRaises(NotImplementedError,
- self.session.windowChanged, None)
-
- def test_providesISession(self):
- # DoNothingSession must provide ISession.
- self.failUnless(ISession.providedBy(self.session),
- "DoNothingSession doesn't implement ISession")
-
- def test_closedDoesNothing(self):
- # closed is a no-op.
- self.assertEqual(None, self.session.closed())
-
- def test_execCommandNotImplemented(self):
- # DoNothingSession.execCommand spawns the appropriate process.
- protocol = MockProcessTransport('bash')
- command = 'cat /etc/hostname'
- self.session.execCommand(protocol, command)
- self.assertEqual(
- [('writeExtended', connection.EXTENDED_DATA_STDERR,
- 'Not allowed to execute commands on this server.\r\n'),
- ('loseConnection',)],
- protocol.log)
-
- def test_eofReceivedDoesNothingWhenNoCommand(self):
- # When no process has been created, 'eofReceived' is a no-op.
- self.assertEqual(None, self.session.eofReceived())
=== modified file 'setup.py'
--- setup.py 2014-01-17 02:01:54 +0000
+++ setup.py 2015-01-06 12:50:32 +0000
@@ -55,6 +55,7 @@
'lazr.restful',
'lazr.jobrunner',
'lazr.smtptest',
+ 'lazr.sshserver',
'lazr.testing',
'lazr.uri',
'lpjsmin',
=== modified file 'versions.cfg'
--- versions.cfg 2014-12-08 02:23:41 +0000
+++ versions.cfg 2015-01-06 12:50:32 +0000
@@ -53,6 +53,7 @@
lazr.restful = 0.19.10
lazr.restfulclient = 0.13.2
lazr.smtptest = 1.3
+lazr.sshserver = 0.1
lazr.testing = 0.1.1
lazr.uri = 1.0.2
lpjsmin = 0.5
Follow ups