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