1# This file is part of Buildbot.  Buildbot is free software: you can
2# redistribute it and/or modify it under the terms of the GNU General Public
3# License as published by the Free Software Foundation, version 2.
4#
5# This program is distributed in the hope that it will be useful, but WITHOUT
6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
7# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
8# details.
9#
10# You should have received a copy of the GNU General Public License along with
11# this program; if not, write to the Free Software Foundation, Inc., 51
12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
13#
14# Copyright Buildbot Team Members
15
16import base64
17import binascii
18import os
19import types
20
21from twisted.application import strports
22from twisted.conch import manhole
23from twisted.conch import telnet
24from twisted.conch.insults import insults
25from twisted.cred import checkers
26from twisted.cred import portal
27from twisted.internet import protocol
28from twisted.python import log
29from zope.interface import implementer  # requires Twisted-2.0 or later
30
31from buildbot import config
32from buildbot.util import ComparableMixin
33from buildbot.util import service
34from buildbot.util import unicode2bytes
35
36try:
37    from twisted.conch import checkers as conchc, manhole_ssh
38    from twisted.conch.openssh_compat.factory import OpenSSHFactory
39    _hush_pyflakes = [manhole_ssh, conchc, OpenSSHFactory]
40    del _hush_pyflakes
41except ImportError:
42    manhole_ssh = None
43    conchc = None
44    OpenSSHFactory = None
45
46
47# makeTelnetProtocol and _TelnetRealm are for the TelnetManhole
48
49
50class makeTelnetProtocol:
51    # this curries the 'portal' argument into a later call to
52    # TelnetTransport()
53
54    def __init__(self, portal):
55        self.portal = portal
56
57    def __call__(self):
58        auth = telnet.AuthenticatingTelnetProtocol
59        return telnet.TelnetTransport(auth, self.portal)
60
61
62@implementer(portal.IRealm)
63class _TelnetRealm:
64
65    def __init__(self, namespace_maker):
66        self.namespace_maker = namespace_maker
67
68    def requestAvatar(self, avatarId, *interfaces):
69        if telnet.ITelnetProtocol in interfaces:
70            namespace = self.namespace_maker()
71            p = telnet.TelnetBootstrapProtocol(insults.ServerProtocol,
72                                               manhole.ColoredManhole,
73                                               namespace)
74            return (telnet.ITelnetProtocol, p, lambda: None)
75        raise NotImplementedError()
76
77
78class chainedProtocolFactory:
79    # this curries the 'namespace' argument into a later call to
80    # chainedProtocolFactory()
81
82    def __init__(self, namespace):
83        self.namespace = namespace
84
85    def __call__(self):
86        return insults.ServerProtocol(manhole.ColoredManhole, self.namespace)
87
88
89if conchc:
90    class AuthorizedKeysChecker(conchc.SSHPublicKeyDatabase):
91
92        """Accept connections using SSH keys from a given file.
93
94        SSHPublicKeyDatabase takes the username that the prospective client has
95        requested and attempts to get a ~/.ssh/authorized_keys file for that
96        username. This requires root access, so it isn't as useful as you'd
97        like.
98
99        Instead, this subclass looks for keys in a single file, given as an
100        argument. This file is typically kept in the buildmaster's basedir. The
101        file should have 'ssh-dss ....' lines in it, just like authorized_keys.
102        """
103
104        def __init__(self, authorized_keys_file):
105            self.authorized_keys_file = os.path.expanduser(
106                authorized_keys_file)
107
108        def checkKey(self, credentials):
109            with open(self.authorized_keys_file, "rb") as f:
110                for l in f.readlines():
111                    l2 = l.split()
112                    if len(l2) < 2:
113                        continue
114                    try:
115                        if base64.decodebytes(l2[1]) == credentials.blob:
116                            return 1
117                    except binascii.Error:
118                        continue
119            return 0
120
121
122class _BaseManhole(service.AsyncMultiService):
123
124    """This provides remote access to a python interpreter (a read/exec/print
125    loop) embedded in the buildmaster via an internal SSH server. This allows
126    detailed inspection of the buildmaster state. It is of most use to
127    buildbot developers. Connect to this by running an ssh client.
128    """
129
130    def __init__(self, port, checker, ssh_hostkey_dir=None):
131        """
132        @type port: string or int
133        @param port: what port should the Manhole listen on? This is a
134        strports specification string, like 'tcp:12345' or
135        'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a
136        simple tcp port.
137
138        @type checker: an object providing the
139        L{twisted.cred.checkers.ICredentialsChecker} interface
140        @param checker: if provided, this checker is used to authenticate the
141        client instead of using the username/password scheme. You must either
142        provide a username/password or a Checker. Some useful values are::
143            import twisted.cred.checkers as credc
144            import twisted.conch.checkers as conchc
145            c = credc.AllowAnonymousAccess # completely open
146            c = credc.FilePasswordDB(passwd_filename) # file of name:passwd
147            c = conchc.UNIXPasswordDatabase # getpwnam() (probably /etc/passwd)
148
149        @type ssh_hostkey_dir: str
150        @param ssh_hostkey_dir: directory which contains ssh host keys for
151                                this server
152        """
153
154        # unfortunately, these don't work unless we're running as root
155        # c = credc.PluggableAuthenticationModulesChecker: PAM
156        # c = conchc.SSHPublicKeyDatabase() # ~/.ssh/authorized_keys
157        # and I can't get UNIXPasswordDatabase to work
158
159        super().__init__()
160        if isinstance(port, int):
161            port = "tcp:%d" % port
162        self.port = port  # for comparison later
163        self.checker = checker  # to maybe compare later
164
165        def makeNamespace():
166            master = self.master
167            namespace = {
168                'master': master,
169                'show': show,
170            }
171            return namespace
172
173        def makeProtocol():
174            namespace = makeNamespace()
175            p = insults.ServerProtocol(manhole.ColoredManhole, namespace)
176            return p
177
178        self.ssh_hostkey_dir = ssh_hostkey_dir
179        if self.ssh_hostkey_dir:
180            self.using_ssh = True
181            if not self.ssh_hostkey_dir:
182                raise ValueError("Most specify a value for ssh_hostkey_dir")
183            r = manhole_ssh.TerminalRealm()
184            r.chainedProtocolFactory = makeProtocol
185            p = portal.Portal(r, [self.checker])
186            f = manhole_ssh.ConchFactory(p)
187            openSSHFactory = OpenSSHFactory()
188            openSSHFactory.dataRoot = self.ssh_hostkey_dir
189            openSSHFactory.dataModuliRoot = self.ssh_hostkey_dir
190            f.publicKeys = openSSHFactory.getPublicKeys()
191            f.privateKeys = openSSHFactory.getPrivateKeys()
192        else:
193            self.using_ssh = False
194            r = _TelnetRealm(makeNamespace)
195            p = portal.Portal(r, [self.checker])
196            f = protocol.ServerFactory()
197            f.protocol = makeTelnetProtocol(p)
198        s = strports.service(self.port, f)
199        s.setServiceParent(self)
200
201    def startService(self):
202        if self.using_ssh:
203            via = "via SSH"
204        else:
205            via = "via telnet"
206        log.msg("Manhole listening {} on port {}".format(via, self.port))
207        return super().startService()
208
209
210class TelnetManhole(_BaseManhole, ComparableMixin):
211
212    compare_attrs = ("port", "username", "password")
213
214    def __init__(self, port, username, password):
215        self.username = username
216        self.password = password
217
218        c = checkers.InMemoryUsernamePasswordDatabaseDontUse()
219        c.addUser(unicode2bytes(username), unicode2bytes(password))
220
221        super().__init__(port, c)
222
223
224class PasswordManhole(_BaseManhole, ComparableMixin):
225
226    compare_attrs = ("port", "username", "password", "ssh_hostkey_dir")
227
228    def __init__(self, port, username, password, ssh_hostkey_dir):
229        if not manhole_ssh:
230            config.error("cryptography required for ssh mahole.")
231        self.username = username
232        self.password = password
233        self.ssh_hostkey_dir = ssh_hostkey_dir
234
235        c = checkers.InMemoryUsernamePasswordDatabaseDontUse()
236        c.addUser(unicode2bytes(username), unicode2bytes(password))
237
238        super().__init__(port, c, ssh_hostkey_dir)
239
240
241class AuthorizedKeysManhole(_BaseManhole, ComparableMixin):
242
243    compare_attrs = ("port", "keyfile", "ssh_hostkey_dir")
244
245    def __init__(self, port, keyfile, ssh_hostkey_dir):
246        if not manhole_ssh:
247            config.error("cryptography required for ssh mahole.")
248
249        # TODO: expanduser this, and make it relative to the buildmaster's
250        # basedir
251        self.keyfile = keyfile
252        c = AuthorizedKeysChecker(keyfile)
253        super().__init__(port, c, ssh_hostkey_dir)
254
255
256class ArbitraryCheckerManhole(_BaseManhole, ComparableMixin):
257
258    """This Manhole accepts ssh connections, but uses an arbitrary
259    user-supplied 'checker' object to perform authentication."""
260
261    compare_attrs = ("port", "checker")
262
263    def __init__(self, port, checker):
264        """
265        @type port: string or int
266        @param port: what port should the Manhole listen on? This is a
267        strports specification string, like 'tcp:12345' or
268        'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a
269        simple tcp port.
270
271        @param checker: an instance of a twisted.cred 'checker' which will
272                        perform authentication
273        """
274
275        if not manhole_ssh:
276            config.error("cryptography required for ssh mahole.")
277
278        super().__init__(port, checker)
279
280# utility functions for the manhole
281
282
283def show(x):
284    """Display the data attributes of an object in a readable format"""
285    print("data attributes of %r" % (x,))
286    names = dir(x)
287    maxlen = max([0] + [len(n) for n in names])
288    for k in names:
289        v = getattr(x, k)
290        if isinstance(v, types.MethodType):
291            continue
292        if k[:2] == '__' and k[-2:] == '__':
293            continue
294        if isinstance(v, str):
295            if len(v) > 80 - maxlen - 5:
296                v = repr(v[:80 - maxlen - 5]) + "..."
297        elif isinstance(v, (int, type(None))):
298            v = str(v)
299        elif isinstance(v, (list, tuple, dict)):
300            v = "{} ({} elements)".format(v, len(v))
301        else:
302            v = str(type(v))
303        print("{} : {}".format(k.ljust(maxlen), v))
304    return x
305