1###
2# Copyright (c) 2002-2005, Jeremiah Fincher
3# Copyright (c) 2008-2010, James McCoy
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are met:
8#
9#   * Redistributions of source code must retain the above copyright notice,
10#     this list of conditions, and the following disclaimer.
11#   * Redistributions in binary form must reproduce the above copyright notice,
12#     this list of conditions, and the following disclaimer in the
13#     documentation and/or other materials provided with the distribution.
14#   * Neither the name of the author of this software nor the name of
15#     contributors to this software may be used to endorse or promote products
16#     derived from this software without specific prior written consent.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28# POSSIBILITY OF SUCH DAMAGE.
29###
30
31import os
32import re
33import pwd
34import sys
35import crypt
36import errno
37import random
38import select
39import struct
40import subprocess
41import shlex
42
43import supybot.conf as conf
44import supybot.utils as utils
45from supybot.commands import *
46import supybot.utils.minisix as minisix
47import supybot.plugins as plugins
48import supybot.ircutils as ircutils
49import supybot.registry as registry
50import supybot.callbacks as callbacks
51from supybot.i18n import PluginInternationalization, internationalizeDocstring
52_ = PluginInternationalization('Unix')
53
54def checkAllowShell(irc):
55    if not conf.supybot.commands.allowShell():
56        irc.error(_('This command is not available, because '
57            'supybot.commands.allowShell is False.'), Raise=True)
58
59_progstats_endline_remover = utils.str.MultipleRemover('\r\n')
60def progstats():
61    pw = pwd.getpwuid(os.getuid())
62    response = format('Process ID %i running as user %q and as group %q '
63                      'from directory %q with the command line %q.  '
64                      'Running on Python %s.',
65                      os.getpid(), pw[0], pw[3],
66                      os.getcwd(), ' '.join(sys.argv),
67                      _progstats_endline_remover(sys.version))
68    return response
69
70class TimeoutError(IOError):
71    pass
72
73def pipeReadline(fd, timeout=2):
74    (r, _, _) = select.select([fd], [], [], timeout)
75    if r:
76        return r[0].readline()
77    else:
78        raise TimeoutError
79
80class Unix(callbacks.Plugin):
81    """Provides Utilities for Unix-like systems."""
82    threaded = True
83    @internationalizeDocstring
84    def errno(self, irc, msg, args, s):
85        """<error number or code>
86
87        Returns the number of an errno code, or the errno code of a number.
88        """
89        try:
90            i = int(s)
91            name = errno.errorcode[i]
92        except ValueError:
93            name = s.upper()
94            try:
95                i = getattr(errno, name)
96            except AttributeError:
97                irc.reply(_('I can\'t find the errno number for that code.'))
98                return
99        except KeyError:
100            name = _('(unknown)')
101        irc.reply(format(_('%s (#%i): %s'), name, i, os.strerror(i)))
102    errno = wrap(errno, ['something'])
103
104    @internationalizeDocstring
105    def progstats(self, irc, msg, args):
106        """takes no arguments
107
108        Returns various unix-y information on the running supybot process.
109        """
110        irc.reply(progstats())
111
112    @internationalizeDocstring
113    def pid(self, irc, msg, args):
114        """takes no arguments
115
116        Returns the current pid of the process for this Supybot.
117        """
118        irc.reply(format('%i', os.getpid()), private=True)
119    pid = wrap(pid, [('checkCapability', 'owner')])
120
121    _cryptre = re.compile(b'[./0-9A-Za-z]')
122    @internationalizeDocstring
123    def crypt(self, irc, msg, args, password, salt):
124        """<password> [<salt>]
125
126        Returns the resulting of doing a crypt() on <password>.  If <salt> is
127        not given, uses a random salt.  If running on a glibc2 system,
128        prepending '$1$' to your salt will cause crypt to return an MD5sum
129        based crypt rather than the standard DES based crypt.
130        """
131        def makeSalt():
132            s = b'\x00'
133            while self._cryptre.sub(b'', s) != b'':
134                s = struct.pack('<h', random.randrange(-(2**15), 2**15))
135            return s
136        if not salt:
137            salt = makeSalt().decode()
138        irc.reply(crypt.crypt(password, salt))
139    crypt = wrap(crypt, ['something', additional('something')])
140
141    @internationalizeDocstring
142    def spell(self, irc, msg, args, word):
143        """<word>
144
145        Returns the result of passing <word> to aspell/ispell.  The results
146        shown are sorted from best to worst in terms of being a likely match
147        for the spelling of <word>.
148        """
149        # We are only checking the first word
150        spellCmd = self.registryValue('spell.command')
151        if not spellCmd:
152           irc.error(_('The spell checking command is not configured.  If one '
153                     'is installed, reconfigure '
154                     'supybot.plugins.Unix.spell.command appropriately.'),
155                     Raise=True)
156        spellLang = self.registryValue('spell.language') or 'en'
157        if word and not word[0].isalpha():
158            irc.error(_('<word> must begin with an alphabet character.'))
159            return
160        try:
161            inst = subprocess.Popen([spellCmd, '-l', spellLang, '-a'], close_fds=True,
162                                    stdout=subprocess.PIPE,
163                                    stderr=subprocess.PIPE,
164                                    stdin=subprocess.PIPE)
165        except OSError as e:
166            irc.error(e, Raise=True)
167        ret = inst.poll()
168        if ret is not None:
169            s = inst.stderr.readline().decode('utf8')
170            if not s:
171                s = inst.stdout.readline().decode('utf8')
172            s = s.rstrip('\r\n')
173            s = s.lstrip('Error: ')
174            irc.error(s, Raise=True)
175        (out, err) = inst.communicate(word.encode())
176        inst.wait()
177        lines = [x.decode('utf8') for x in out.splitlines() if x]
178        lines.pop(0) # Banner
179        if not lines:
180            irc.error(_('No results found.'), Raise=True)
181        line = lines.pop(0)
182        line2 = ''
183        if lines:
184            line2 = lines.pop(0)
185        # parse the output
186        # aspell will sometimes list spelling suggestions after a '*' or '+'
187        # line for complex words.
188        if line[0] in '*+' and line2:
189            line = line2
190        if line[0] in '*+':
191            resp = format(_('%q may be spelled correctly.'), word)
192        elif line[0] == '#':
193            resp = format(_('I could not find an alternate spelling for %q'),
194                          word)
195        elif line[0] == '&':
196            matches = line.split(':')[1].strip()
197            resp = format(_('Possible spellings for %q: %L.'),
198                          word, matches.split(', '))
199        else:
200            resp = _('Something unexpected was seen in the [ai]spell output.')
201        irc.reply(resp)
202    spell = thread(wrap(spell, ['something']))
203
204    @internationalizeDocstring
205    def fortune(self, irc, msg, args):
206        """takes no arguments
207
208        Returns a fortune from the *nix fortune program.
209        """
210        channel = msg.args[0]
211        fortuneCmd = self.registryValue('fortune.command')
212        if fortuneCmd:
213            args = [fortuneCmd]
214            if self.registryValue('fortune.short', channel):
215                args.append('-s')
216            if self.registryValue('fortune.equal', channel):
217                args.append('-e')
218            if self.registryValue('fortune.offensive', channel):
219                args.append('-a')
220            args.extend(self.registryValue('fortune.files', channel))
221            try:
222                with open(os.devnull) as null:
223                    inst = subprocess.Popen(args,
224                                            stdout=subprocess.PIPE,
225                                            stderr=subprocess.PIPE,
226                                            stdin=null)
227            except OSError as e:
228                irc.error(_('It seems the configured fortune command was '
229                          'not available.'), Raise=True)
230            (out, err) = inst.communicate()
231            inst.wait()
232            if minisix.PY3:
233                lines = [i.decode('utf-8').rstrip() for i in out.splitlines()]
234                lines = list(map(str, lines))
235            else:
236                lines = out.splitlines()
237                lines = list(map(str.rstrip, lines))
238            lines = filter(None, lines)
239            irc.replies(lines, joiner=' ')
240        else:
241            irc.error(_('The fortune command is not configured. If fortune is '
242                      'installed on this system, reconfigure the '
243                      'supybot.plugins.Unix.fortune.command configuration '
244                      'variable appropriately.'))
245
246    @internationalizeDocstring
247    def wtf(self, irc, msg, args, foo, something):
248        """[is] <something>
249
250        Returns wtf <something> is.  'wtf' is a *nix command that first
251        appeared in NetBSD 1.5.  In most *nices, it's available in some sort
252        of 'bsdgames' package.
253        """
254        wtfCmd = self.registryValue('wtf.command')
255        if wtfCmd:
256            something = something.rstrip('?')
257            try:
258                with open(os.devnull, 'r+') as null:
259                    inst = subprocess.Popen([wtfCmd, something],
260                                            stdout=subprocess.PIPE,
261                                            stderr=subprocess.STDOUT,
262                                            stdin=null)
263            except OSError:
264                irc.error(_('It seems the configured wtf command was not '
265                          'available.'), Raise=True)
266            (out, foo) = inst.communicate()
267            inst.wait()
268            if out:
269                response = out.decode('utf8').splitlines()[0].strip()
270                response = utils.str.normalizeWhitespace(response)
271                irc.reply(response)
272        else:
273            irc.error(_('The wtf command is not configured.  If it is installed '
274                      'on this system, reconfigure the '
275                      'supybot.plugins.Unix.wtf.command configuration '
276                      'variable appropriately.'))
277    wtf = thread(wrap(wtf, [optional(('literal', ['is'])), 'something']))
278
279    def _make_ping(command):
280        def f(self, irc, msg, args, optlist, host):
281            """[--c <count>] [--i <interval>] [--t <ttl>] [--W <timeout>] [--4|--6] <host or ip>
282
283            Sends an ICMP echo request to the specified host.
284            The arguments correspond with those listed in ping(8). --c is
285            limited to 10 packets or less (default is 5). --i is limited to 5
286            or less. --W is limited to 10 or less.
287            --4 and --6 can be used if and only if the system has a unified
288            ping command.
289            """
290            pingCmd = self.registryValue(registry.join([command, 'command']))
291            if not pingCmd:
292               irc.error('The ping command is not configured.  If one '
293                         'is installed, reconfigure '
294                         'supybot.plugins.Unix.%s.command appropriately.' %
295                         command, Raise=True)
296            else:
297                try: host = host.group(0)
298                except AttributeError: pass
299
300                args = [pingCmd]
301                for opt, val in optlist:
302                    if opt == 'c' and val > 10: val = 10
303                    if opt == 'i' and val >  5: val = 5
304                    if opt == 'W' and val > 10: val = 10
305                    args.append('-%s' % opt)
306                    if opt not in ('4', '6'):
307                        args.append(str(val))
308                if '-c' not in args:
309                    args.append('-c')
310                    args.append(str(self.registryValue('ping.defaultCount')))
311                args.append(host)
312                try:
313                    with open(os.devnull) as null:
314                        inst = subprocess.Popen(args,
315                                                stdout=subprocess.PIPE,
316                                                stderr=subprocess.PIPE,
317                                                stdin=null)
318                except OSError as e:
319                    irc.error('It seems the configured ping command was '
320                              'not available (%s).' % e, Raise=True)
321                result = inst.communicate()
322                if result[1]: # stderr
323                    irc.error(' '.join(result[1].decode('utf8').split()))
324                else:
325                    response = result[0].decode('utf8').split("\n");
326                    if response[1]:
327                        irc.reply(' '.join(response[1].split()[3:5]).split(':')[0]
328                                  + ': ' + ' '.join(response[-3:]))
329                    else:
330                        irc.reply(' '.join(response[0].split()[1:3])
331                                  + ': ' + ' '.join(response[-3:]))
332
333        f.__name__ = command
334        _hostExpr = re.compile(r'^[a-z0-9][a-z0-9\.-]*[a-z0-9]$', re.I)
335        return thread(wrap(f, [getopts({'c':'positiveInt','i':'float',
336                                        't':'positiveInt','W':'positiveInt',
337                                        '4':'', '6':''}),
338                           first('ip', ('matches', _hostExpr, 'Invalid hostname'))]))
339
340    ping = _make_ping('ping')
341    ping6 = _make_ping('ping6')
342
343    def sysuptime(self, irc, msg, args):
344        """takes no arguments
345
346        Returns the uptime from the system the bot is running on.
347        """
348        uptimeCmd = self.registryValue('sysuptime.command')
349        if uptimeCmd:
350            args = [uptimeCmd]
351            try:
352                with open(os.devnull) as null:
353                    inst = subprocess.Popen(args,
354                                            stdout=subprocess.PIPE,
355                                            stderr=subprocess.PIPE,
356                                            stdin=null)
357            except OSError as e:
358                irc.error('It seems the configured uptime command was '
359                          'not available.', Raise=True)
360            (out, err) = inst.communicate()
361            inst.wait()
362            lines = out.splitlines()
363            lines = [x.decode('utf8').rstrip() for x in lines]
364            lines = filter(None, lines)
365            irc.replies(lines, joiner=' ')
366        else:
367            irc.error('The uptime command is not configured. If uptime is '
368                      'installed on this system, reconfigure the '
369                      'supybot.plugins.Unix.sysuptime.command configuration '
370                      'variable appropriately.')
371
372    def sysuname(self, irc, msg, args):
373        """takes no arguments
374
375        Returns the uname -a from the system the bot is running on.
376        """
377        unameCmd = self.registryValue('sysuname.command')
378        if unameCmd:
379            args = [unameCmd, '-a']
380            try:
381                with open(os.devnull) as null:
382                    inst = subprocess.Popen(args,
383                                            stdout=subprocess.PIPE,
384                                            stderr=subprocess.PIPE,
385                                            stdin=null)
386            except OSError as e:
387                irc.error('It seems the configured uptime command was '
388                          'not available.', Raise=True)
389            (out, err) = inst.communicate()
390            inst.wait()
391            lines = out.splitlines()
392            lines = [x.decode('utf8').rstrip() for x in lines]
393            lines = filter(None, lines)
394            irc.replies(lines, joiner=' ')
395        else:
396            irc.error('The uname command is not configured. If uname is '
397                      'installed on this system, reconfigure the '
398                      'supybot.plugins.Unix.sysuname.command configuration '
399                      'variable appropriately.')
400
401    def call(self, irc, msg, args, text):
402        """<command to call with any arguments>
403        Calls any command available on the system, and returns its output.
404        Requires owner capability.
405        Note that being restricted to owner, this command does not do any
406        sanity checking on input/output. So it is up to you to make sure
407        you don't run anything that will spamify your channel or that
408        will bring your machine to its knees.
409        """
410        checkAllowShell(irc)
411        self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
412                      irc.network)
413        args = shlex.split(text)
414        try:
415            with open(os.devnull) as null:
416                inst = subprocess.Popen(args,
417                                        stdout=subprocess.PIPE,
418                                        stderr=subprocess.PIPE,
419                                        stdin=null)
420        except OSError as e:
421            irc.error('It seems the requested command was '
422                      'not available (%s).' % e, Raise=True)
423        result = inst.communicate()
424        if result[1]: # stderr
425            irc.error(' '.join(result[1].decode('utf8').split()))
426        if result[0]: # stdout
427            response = result[0].decode('utf8').splitlines()
428            response = [l for l in response if l]
429            irc.replies(response)
430    call = thread(wrap(call, ["owner", "text"]))
431
432    def shell(self, irc, msg, args, text):
433        """<command to call with any arguments>
434        Calls any command available on the system using the shell
435        specified by the SHELL environment variable, and returns its
436        output.
437        Requires owner capability.
438        Note that being restricted to owner, this command does not do any
439        sanity checking on input/output. So it is up to you to make sure
440        you don't run anything that will spamify your channel or that
441        will bring your machine to its knees.
442        """
443        checkAllowShell(irc)
444        self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
445                      irc.network)
446        try:
447            with open(os.devnull) as null:
448                inst = subprocess.Popen(text,
449                                        shell=True,
450                                        stdout=subprocess.PIPE,
451                                        stderr=subprocess.PIPE,
452                                        stdin=null)
453        except OSError as e:
454            irc.error('It seems the shell (%s) was not available (%s)' %
455                      (os.getenv('SHELL'), e), Raise=True)
456        result = inst.communicate()
457        if result[1]: # stderr
458            irc.error(' '.join(result[1].decode('utf8').split()))
459        if result[0]: # stdout
460            response = result[0].decode('utf8').splitlines()
461            response = [l for l in response if l]
462            irc.replies(response)
463    shell = thread(wrap(shell, ["owner", "text"]))
464
465
466Class = Unix
467# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
468