From 70715ebd03255470209ed4660b130eee966eaa3a Mon Sep 17 00:00:00 2001 From: warner Date: Sun, 16 Jul 2006 05:55:25 +0100 Subject: [PATCH] implement manhole (ssh-based, colorized/line-editing terminal) --- ChangeLog | 10 ++ buildbot/manhole.py | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++ buildbot/master.py | 33 ++----- docs/buildbot.texinfo | 59 +++++++++-- 4 files changed, 332 insertions(+), 35 deletions(-) create mode 100644 buildbot/manhole.py diff --git a/ChangeLog b/ChangeLog index a3615a6..04abacb 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,15 @@ 2006-07-15 Brian Warner + * buildbot/manhole.py: move all Manhole-related code out to this + module. Implement SSH-based manholes (with TwistedConch), and move + to conch's nifty line-editing syntax-coloring REPL shell instead + of the boring non-editing monochromatic (and deprecated) old + 'telnet' protocol. + * buildbot/master.py: remove all Manhole-related code + (BuildMaster.loadConfig._add): make sure the old manhole is + removed before we add the new one + * docs/buildbot.texinfo (Debug options): document new Manhole options + * buildbot/twcompat.py (_which): fix some epydoc issues * buildbot/status/html.py (Waterfall.__init__): same diff --git a/buildbot/manhole.py b/buildbot/manhole.py new file mode 100644 index 0000000..e5479b3 --- /dev/null +++ b/buildbot/manhole.py @@ -0,0 +1,265 @@ + +import os.path +import binascii, base64 +from twisted.python import log +from twisted.application import service, strports +from twisted.cred import checkers, portal +from twisted.conch import manhole, telnet, manhole_ssh, checkers as conchc +from twisted.conch.insults import insults +from twisted.internet import protocol + +from buildbot.util import ComparableMixin +from zope.interface import implements # requires Twisted-2.0 or later + +# makeTelnetProtocol and _TelnetRealm are for the TelnetManhole + +class makeTelnetProtocol: + # this curries the 'portal' argument into a later call to + # TelnetTransport() + def __init__(self, portal): + self.portal = portal + + def __call__(self): + auth = telnet.AuthenticatingTelnetProtocol + return telnet.TelnetTransport(auth, self.portal) + +class _TelnetRealm: + implements(portal.IRealm) + + def __init__(self, namespace_maker): + self.namespace_maker = namespace_maker + + def requestAvatar(self, avatarId, *interfaces): + if telnet.ITelnetProtocol in interfaces: + namespace = self.namespace_maker() + p = telnet.TelnetBootstrapProtocol(insults.ServerProtocol, + manhole.ColoredManhole, + namespace) + return (telnet.ITelnetProtocol, p, lambda: None) + raise NotImplementedError() + + +class chainedProtocolFactory: + # this curries the 'namespace' argument into a later call to + # chainedProtocolFactory() + def __init__(self, namespace): + self.namespace = namespace + + def __call__(self): + return insults.ServerProtocol(manhole.ColoredManhole, self.namespace) + +class AuthorizedKeysChecker(conchc.SSHPublicKeyDatabase): + """Accept connections using SSH keys from a given file. + + SSHPublicKeyDatabase takes the username that the prospective client has + requested and attempts to get a ~/.ssh/authorized_keys file for that + username. This requires root access, so it isn't as useful as you'd + like. + + Instead, this subclass looks for keys in a single file, given as an + argument. This file is typically kept in the buildmaster's basedir. The + file should have 'ssh-dss ....' lines in it, just like authorized_keys. + """ + + def __init__(self, authorized_keys_file): + self.authorized_keys_file = os.path.expanduser(authorized_keys_file) + + def checkKey(self, credentials): + f = open(self.authorized_keys_file) + for l in f.readlines(): + l2 = l.split() + if len(l2) < 2: + continue + try: + if base64.decodestring(l2[1]) == credentials.blob: + return 1 + except binascii.Error: + continue + return 0 + + +class _BaseManhole(service.MultiService): + """This provides remote access to a python interpreter (a read/exec/print + loop) embedded in the buildmaster via an internal SSH server. This allows + detailed inspection of the buildmaster state. It is of most use to + buildbot developers. Connect to this by running an ssh client. + """ + + def __init__(self, port, checker, using_ssh=True): + """ + @type port: string or int + @param port: what port should the Manhole listen on? This is a + strports specification string, like 'tcp:12345' or + 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a + simple tcp port. + + @type checker: an object providing the + L{twisted.cred.checkers.ICredentialsChecker} interface + @param checker: if provided, this checker is used to authenticate the + client instead of using the username/password scheme. You must either + provide a username/password or a Checker. Some useful values are:: + import twisted.cred.checkers as credc + import twisted.conch.checkers as conchc + c = credc.AllowAnonymousAccess # completely open + c = credc.FilePasswordDB(passwd_filename) # file of name:passwd + c = conchc.UNIXPasswordDatabase # getpwnam() (probably /etc/passwd) + + @type using_ssh: bool + @param using_ssh: If True, accept SSH connections. If False, accept + regular unencrypted telnet connections. + """ + + # unfortunately, these don't work unless we're running as root + #c = credc.PluggableAuthenticationModulesChecker: PAM + #c = conchc.SSHPublicKeyDatabase() # ~/.ssh/authorized_keys + # and I can't get UNIXPasswordDatabase to work + + service.MultiService.__init__(self) + if type(port) is int: + port = "tcp:%d" % port + self.port = port # for comparison later + self.checker = checker # to maybe compare later + + def makeNamespace(): + # close over 'self' so we can get access to .parent later + master = self.parent + namespace = { + 'master': master, + 'status': master.getStatus(), + } + return namespace + + def makeProtocol(): + namespace = makeNamespace() + p = insults.ServerProtocol(manhole.ColoredManhole, namespace) + return p + + self.using_ssh = using_ssh + if using_ssh: + r = manhole_ssh.TerminalRealm() + r.chainedProtocolFactory = makeProtocol + p = portal.Portal(r, [self.checker]) + f = manhole_ssh.ConchFactory(p) + else: + r = _TelnetRealm(makeNamespace) + p = portal.Portal(r, [self.checker]) + f = protocol.ServerFactory() + f.protocol = makeTelnetProtocol(p) + s = strports.service(self.port, f) + s.setServiceParent(self) + + + def startService(self): + service.MultiService.startService(self) + if self.using_ssh: + via = "via SSH" + else: + via = "via telnet" + log.msg("Manhole listening %s on port %s" % (via, self.port)) + + +class TelnetManhole(_BaseManhole, ComparableMixin): + """This Manhole accepts unencrypted (telnet) connections, and requires a + username and password authorize access. You are encouraged to use the + encrypted ssh-based manhole classes instead.""" + + compare_attrs = ["port", "username", "password"] + + def __init__(self, port, username, password): + """ + @type port: string or int + @param port: what port should the Manhole listen on? This is a + strports specification string, like 'tcp:12345' or + 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a + simple tcp port. + + @param username: + @param password: username= and password= form a pair of strings to + use when authenticating the remote user. + """ + + self.username = username + self.password = password + + c = checkers.InMemoryUsernamePasswordDatabaseDontUse() + c.addUser(username, password) + + _BaseManhole.__init__(self, port, c, using_ssh=False) + +class PasswordManhole(_BaseManhole, ComparableMixin): + """This Manhole accepts encrypted (ssh) connections, and requires a + username and password to authorize access. + """ + + compare_attrs = ["port", "username", "password"] + + def __init__(self, port, username, password): + """ + @type port: string or int + @param port: what port should the Manhole listen on? This is a + strports specification string, like 'tcp:12345' or + 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a + simple tcp port. + + @param username: + @param password: username= and password= form a pair of strings to + use when authenticating the remote user. + """ + + self.username = username + self.password = password + + c = checkers.InMemoryUsernamePasswordDatabaseDontUse() + c.addUser(username, password) + + _BaseManhole.__init__(self, port, c) + +class AuthorizedKeysManhole(_BaseManhole, ComparableMixin): + """This Manhole accepts ssh connections, and requires that the + prospective client have an ssh private key that matches one of the public + keys in our authorized_keys file. It is created with the name of a file + that contains the public keys that we will accept.""" + + compare_attrs = ["port", "keyfile"] + + def __init__(self, port, keyfile): + """ + @type port: string or int + @param port: what port should the Manhole listen on? This is a + strports specification string, like 'tcp:12345' or + 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a + simple tcp port. + + @param keyfile: the name of a file (relative to the buildmaster's + basedir) that contains SSH public keys of authorized + users, one per line. This is the exact same format + as used by sshd in ~/.ssh/authorized_keys . + """ + + # TODO: expanduser this, and make it relative to the buildmaster's + # basedir + self.keyfile = keyfile + c = AuthorizedKeysChecker(keyfile) + _BaseManhole.__init__(self, port, c) + +class ArbitraryCheckerManhole(_BaseManhole, ComparableMixin): + """This Manhole accepts ssh connections, but uses an arbitrary + user-supplied 'checker' object to perform authentication.""" + + compare_attrs = ["port", "checker"] + + def __init__(self, port, checker): + """ + @type port: string or int + @param port: what port should the Manhole listen on? This is a + strports specification string, like 'tcp:12345' or + 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a + simple tcp port. + + @param checker: an instance of a twisted.cred 'checker' which will + perform authentication + """ + + _BaseManhole.__init__(self, port, checker) + + diff --git a/buildbot/master.py b/buildbot/master.py index 784807b..00b88d9 100644 --- a/buildbot/master.py +++ b/buildbot/master.py @@ -17,7 +17,6 @@ from twisted.spread import pb from twisted.cred import portal, checkers from twisted.application import service, strports from twisted.persisted import styles -from twisted.manhole import telnet # sibling imports from buildbot import util @@ -424,28 +423,7 @@ class BotMaster(service.Service): ######################################## -class Manhole(service.MultiService, util.ComparableMixin): - compare_attrs = ["port", "username", "password"] - def __init__(self, port, username, password): - service.MultiService.__init__(self) - if type(port) is int: - port = "tcp:%d" % port - self.port = port - self.username = username - self.password = password - self.f = f = telnet.ShellFactory() - f.username = username - f.password = password - s = strports.service(port, f) - s.setServiceParent(self) - - def startService(self): - log.msg("Manhole listening on port %s" % self.port) - service.MultiService.startService(self) - master = self.parent - self.f.namespace['master'] = master - self.f.namespace['status'] = master.getStatus() class DebugPerspective(NewCredPerspective): def attached(self, mind): @@ -842,10 +820,15 @@ class BuildMaster(service.MultiService, styles.Versioned): if self.manhole: # disownServiceParent may return a Deferred d.addCallback(lambda res: self.manhole.disownServiceParent()) - self.manhole = None + def _remove(res): + self.manhole = None + return res + d.addCallback(_remove) if manhole: - self.manhole = manhole - manhole.setServiceParent(self) + def _add(res): + self.manhole = manhole + manhole.setServiceParent(self) + d.addCallback(_add) # add/remove self.botmaster.builders to match builders. The # botmaster will handle startup/shutdown issues. diff --git a/docs/buildbot.texinfo b/docs/buildbot.texinfo index 526b276..888d18f 100644 --- a/docs/buildbot.texinfo +++ b/docs/buildbot.texinfo @@ -2162,13 +2162,49 @@ c['debugPassword'] = "debugpassword" @end example @cindex c['manhole'] -If you set @code{c['manhole']} to an instance of the -@code{buildbot.master.Manhole} class, you can telnet into the -buildmaster and get an interactive Python shell, which may be useful -for debugging buildbot internals. It is probably only useful for -buildbot developers. It exposes full access to the buildmaster's -account (including the ability to modify and delete files), so it -should not be enabled with a weak or easily guessable password. +If you set @code{c['manhole']} to an instance of one of the classes in +@code{buildbot.manhole}, you can telnet or ssh into the buildmaster +and get an interactive Python shell, which may be useful for debugging +buildbot internals. It is probably only useful for buildbot +developers. It exposes full access to the buildmaster's account +(including the ability to modify and delete files), so it should not +be enabled with a weak or easily guessable password. + +There are three separate @code{Manhole} classes. Two of them use SSH, +one uses unencrypted telnet. Two of them use a username+password +combination to grant access, one of them uses an SSH-style +@file{authorized_keys} file which contains a list of ssh public keys. + +@table @code +@item manhole.AuthorizedKeysManhole +You construct this with the name of a file that contains one SSH +public key per line, just like @file{~/.ssh/authorized_keys}. If you +provide a non-absolute filename, it will be interpreted relative to +the buildmaster's base directory. + +@item manhole.PasswordManhole +This one accepts SSH connections but asks for a username and password +when authenticating. It accepts only one such pair. + + +@item manhole.TelnetManhole +This accepts regular unencrypted telnet connections, and asks for a +username/password pair before providing access. Because this +username/password is transmitted in the clear, and because Manhole +access to the buildmaster is equivalent to granting full shell +privileges to both the buildmaster and all the buildslaves (and to all +accounts which then run code produced by the buildslaves), it is +highly recommended that you use one of the SSH manholes instead. + +@end table + +@example +# some examples: +from buildbot import manhole +c['manhole'] = manhole.AuthorizedKeysManhole(1234, "authorized_keys") +c['manhole'] = manhole.PasswordManhole(1234, "alice", "mysecretpassword") +c['manhole'] = manhole.TelnetManhole(1234, "bob", "snoop_my_password_please") +@end example The @code{Manhole} instance can be configured to listen on a specific port. You may wish to have this listening port bind to the loopback @@ -2176,15 +2212,18 @@ interface (sometimes known as ``lo0'', ``localhost'', or 127.0.0.1) to restrict access to clients which are running on the same host. @example -from buildbot.master import Manhole -c['manhole'] = Manhole("tcp:9999:interface=127.0.0.1", "admin", "password") +from buildbot.manhole import PasswordManhole +c['manhole'] = PasswordManhole("tcp:9999:interface=127.0.0.1","admin","passwd") @end example To have the @code{Manhole} listen on all interfaces, use -@code{"tcp:9999"}. This port specification uses +@code{"tcp:9999"} or simply 9999. This port specification uses @code{twisted.application.strports}, so you can make it listen on SSL or even UNIX-domain sockets if you want. +Note that using any Manhole requires that the TwistedConch package be +installed, and that you be using Twisted version 2.0 or later. + @node Getting Source Code Changes, Build Process, Configuration, Top @chapter Getting Source Code Changes -- 2.11.4.GIT