| OLD | NEW |
| (Empty) |
| 1 # -*- test-case-name: twisted.conch.test.test_conch -*- | |
| 2 # | |
| 3 # Copyright (c) 2001-2004 Twisted Matrix Laboratories. | |
| 4 # See LICENSE for details. | |
| 5 | |
| 6 # | |
| 7 # $Id: conch.py,v 1.65 2004/03/11 00:29:14 z3p Exp $ | |
| 8 | |
| 9 #""" Implementation module for the `conch` command. | |
| 10 #""" | |
| 11 from twisted.conch.client import agent, connect, default, options | |
| 12 from twisted.conch.error import ConchError | |
| 13 from twisted.conch.ssh import connection, common | |
| 14 from twisted.conch.ssh import session, forwarding, channel | |
| 15 from twisted.internet import reactor, stdio, defer, task | |
| 16 from twisted.python import log, usage | |
| 17 | |
| 18 import os, sys, getpass, struct, tty, fcntl, base64, signal, stat, errno | |
| 19 | |
| 20 class ClientOptions(options.ConchOptions): | |
| 21 | |
| 22 synopsis = """Usage: conch [options] host [command] | |
| 23 """ | |
| 24 | |
| 25 optParameters = [['escape', 'e', '~'], | |
| 26 ['localforward', 'L', None, 'listen-port:host:port Forwa
rd local port to remote address'], | |
| 27 ['remoteforward', 'R', None, 'listen-port:host:port Forw
ard remote port to local address'], | |
| 28 ] | |
| 29 | |
| 30 optFlags = [['null', 'n', 'Redirect input from /dev/null.'], | |
| 31 ['fork', 'f', 'Fork to background after authentication.'], | |
| 32 ['tty', 't', 'Tty; allocate a tty even if command is given.'], | |
| 33 ['notty', 'T', 'Do not allocate a tty.'], | |
| 34 ['noshell', 'N', 'Do not execute a shell or command.'], | |
| 35 ['subsystem', 's', 'Invoke command (mandatory) as SSH2 subsyste
m.'], | |
| 36 ] | |
| 37 | |
| 38 #zsh_altArgDescr = {"foo":"use this description for foo instead"} | |
| 39 #zsh_multiUse = ["foo", "bar"] | |
| 40 #zsh_mutuallyExclusive = [("foo", "bar"), ("bar", "baz")] | |
| 41 #zsh_actions = {"foo":'_files -g "*.foo"', "bar":"(one two three)"} | |
| 42 zsh_actionDescr = {"localforward":"listen-port:host:port", | |
| 43 "remoteforward":"listen-port:host:port"} | |
| 44 zsh_extras = ["*:command: "] | |
| 45 | |
| 46 localForwards = [] | |
| 47 remoteForwards = [] | |
| 48 | |
| 49 def opt_escape(self, esc): | |
| 50 "Set escape character; ``none'' = disable" | |
| 51 if esc == 'none': | |
| 52 self['escape'] = None | |
| 53 elif esc[0] == '^' and len(esc) == 2: | |
| 54 self['escape'] = chr(ord(esc[1])-64) | |
| 55 elif len(esc) == 1: | |
| 56 self['escape'] = esc | |
| 57 else: | |
| 58 sys.exit("Bad escape character '%s'." % esc) | |
| 59 | |
| 60 def opt_localforward(self, f): | |
| 61 "Forward local port to remote address (lport:host:port)" | |
| 62 localPort, remoteHost, remotePort = f.split(':') # doesn't do v6 yet | |
| 63 localPort = int(localPort) | |
| 64 remotePort = int(remotePort) | |
| 65 self.localForwards.append((localPort, (remoteHost, remotePort))) | |
| 66 | |
| 67 def opt_remoteforward(self, f): | |
| 68 """Forward remote port to local address (rport:host:port)""" | |
| 69 remotePort, connHost, connPort = f.split(':') # doesn't do v6 yet | |
| 70 remotePort = int(remotePort) | |
| 71 connPort = int(connPort) | |
| 72 self.remoteForwards.append((remotePort, (connHost, connPort))) | |
| 73 | |
| 74 def parseArgs(self, host, *command): | |
| 75 self['host'] = host | |
| 76 self['command'] = ' '.join(command) | |
| 77 | |
| 78 # Rest of code in "run" | |
| 79 options = None | |
| 80 conn = None | |
| 81 exitStatus = 0 | |
| 82 old = None | |
| 83 _inRawMode = 0 | |
| 84 _savedRawMode = None | |
| 85 | |
| 86 def run(): | |
| 87 global options, old | |
| 88 args = sys.argv[1:] | |
| 89 if '-l' in args: # cvs is an idiot | |
| 90 i = args.index('-l') | |
| 91 args = args[i:i+2]+args | |
| 92 del args[i+2:i+4] | |
| 93 for arg in args[:]: | |
| 94 try: | |
| 95 i = args.index(arg) | |
| 96 if arg[:2] == '-o' and args[i+1][0]!='-': | |
| 97 args[i:i+2] = [] # suck on it scp | |
| 98 except ValueError: | |
| 99 pass | |
| 100 options = ClientOptions() | |
| 101 try: | |
| 102 options.parseOptions(args) | |
| 103 except usage.UsageError, u: | |
| 104 print 'ERROR: %s' % u | |
| 105 options.opt_help() | |
| 106 sys.exit(1) | |
| 107 if options['log']: | |
| 108 if options['logfile']: | |
| 109 if options['logfile'] == '-': | |
| 110 f = sys.stdout | |
| 111 else: | |
| 112 f = file(options['logfile'], 'a+') | |
| 113 else: | |
| 114 f = sys.stderr | |
| 115 realout = sys.stdout | |
| 116 log.startLogging(f) | |
| 117 sys.stdout = realout | |
| 118 else: | |
| 119 log.discardLogs() | |
| 120 doConnect() | |
| 121 fd = sys.stdin.fileno() | |
| 122 try: | |
| 123 old = tty.tcgetattr(fd) | |
| 124 except: | |
| 125 old = None | |
| 126 try: | |
| 127 oldUSR1 = signal.signal(signal.SIGUSR1, lambda *a: reactor.callLater(0,
reConnect)) | |
| 128 except: | |
| 129 oldUSR1 = None | |
| 130 try: | |
| 131 reactor.run() | |
| 132 finally: | |
| 133 if old: | |
| 134 tty.tcsetattr(fd, tty.TCSANOW, old) | |
| 135 if oldUSR1: | |
| 136 signal.signal(signal.SIGUSR1, oldUSR1) | |
| 137 if (options['command'] and options['tty']) or not options['notty']: | |
| 138 signal.signal(signal.SIGWINCH, signal.SIG_DFL) | |
| 139 if sys.stdout.isatty() and not options['command']: | |
| 140 print 'Connection to %s closed.' % options['host'] | |
| 141 sys.exit(exitStatus) | |
| 142 | |
| 143 def handleError(): | |
| 144 from twisted.python import failure | |
| 145 global exitStatus | |
| 146 exitStatus = 2 | |
| 147 reactor.callLater(0.01, _stopReactor) | |
| 148 log.err(failure.Failure()) | |
| 149 raise | |
| 150 | |
| 151 def _stopReactor(): | |
| 152 try: | |
| 153 reactor.stop() | |
| 154 except: pass | |
| 155 | |
| 156 def doConnect(): | |
| 157 # log.deferr = handleError # HACK | |
| 158 if '@' in options['host']: | |
| 159 options['user'], options['host'] = options['host'].split('@',1) | |
| 160 if not options.identitys: | |
| 161 options.identitys = ['~/.ssh/id_rsa', '~/.ssh/id_dsa'] | |
| 162 host = options['host'] | |
| 163 if not options['user']: | |
| 164 options['user'] = getpass.getuser() | |
| 165 if not options['port']: | |
| 166 options['port'] = 22 | |
| 167 else: | |
| 168 options['port'] = int(options['port']) | |
| 169 host = options['host'] | |
| 170 port = options['port'] | |
| 171 vhk = default.verifyHostKey | |
| 172 uao = default.SSHUserAuthClient(options['user'], options, SSHConnection()) | |
| 173 connect.connect(host, port, options, vhk, uao).addErrback(_ebExit) | |
| 174 | |
| 175 def _ebExit(f): | |
| 176 global exitStatus | |
| 177 if hasattr(f.value, 'value'): | |
| 178 s = f.value.value | |
| 179 else: | |
| 180 s = str(f) | |
| 181 exitStatus = "conch: exiting with error %s" % f | |
| 182 reactor.callLater(0.1, _stopReactor) | |
| 183 | |
| 184 def onConnect(): | |
| 185 # if keyAgent and options['agent']: | |
| 186 # cc = protocol.ClientCreator(reactor, SSHAgentForwardingLocal, conn) | |
| 187 # cc.connectUNIX(os.environ['SSH_AUTH_SOCK']) | |
| 188 if hasattr(conn.transport, 'sendIgnore'): | |
| 189 _KeepAlive(conn) | |
| 190 if options.localForwards: | |
| 191 for localPort, hostport in options.localForwards: | |
| 192 s = reactor.listenTCP(localPort, | |
| 193 forwarding.SSHListenForwardingFactory(conn, | |
| 194 hostport, | |
| 195 SSHListenClientForwardingChannel)) | |
| 196 conn.localForwards.append(s) | |
| 197 if options.remoteForwards: | |
| 198 for remotePort, hostport in options.remoteForwards: | |
| 199 log.msg('asking for remote forwarding for %s:%s' % | |
| 200 (remotePort, hostport)) | |
| 201 conn.requestRemoteForwarding(remotePort, hostport) | |
| 202 reactor.addSystemEventTrigger('before', 'shutdown', beforeShutdown) | |
| 203 if not options['noshell'] or options['agent']: | |
| 204 conn.openChannel(SSHSession()) | |
| 205 if options['fork']: | |
| 206 if os.fork(): | |
| 207 os._exit(0) | |
| 208 os.setsid() | |
| 209 for i in range(3): | |
| 210 try: | |
| 211 os.close(i) | |
| 212 except OSError, e: | |
| 213 import errno | |
| 214 if e.errno != errno.EBADF: | |
| 215 raise | |
| 216 | |
| 217 def reConnect(): | |
| 218 beforeShutdown() | |
| 219 conn.transport.transport.loseConnection() | |
| 220 | |
| 221 def beforeShutdown(): | |
| 222 remoteForwards = options.remoteForwards | |
| 223 for remotePort, hostport in remoteForwards: | |
| 224 log.msg('cancelling %s:%s' % (remotePort, hostport)) | |
| 225 conn.cancelRemoteForwarding(remotePort) | |
| 226 | |
| 227 def stopConnection(): | |
| 228 if not options['reconnect']: | |
| 229 reactor.callLater(0.1, _stopReactor) | |
| 230 | |
| 231 class _KeepAlive: | |
| 232 | |
| 233 def __init__(self, conn): | |
| 234 self.conn = conn | |
| 235 self.globalTimeout = None | |
| 236 self.lc = task.LoopingCall(self.sendGlobal) | |
| 237 self.lc.start(300) | |
| 238 | |
| 239 def sendGlobal(self): | |
| 240 d = self.conn.sendGlobalRequest("conch-keep-alive@twistedmatrix.com", | |
| 241 "", wantReply = 1) | |
| 242 d.addBoth(self._cbGlobal) | |
| 243 self.globalTimeout = reactor.callLater(30, self._ebGlobal) | |
| 244 | |
| 245 def _cbGlobal(self, res): | |
| 246 if self.globalTimeout: | |
| 247 self.globalTimeout.cancel() | |
| 248 self.globalTimeout = None | |
| 249 | |
| 250 def _ebGlobal(self): | |
| 251 if self.globalTimeout: | |
| 252 self.globalTimeout = None | |
| 253 self.conn.transport.loseConnection() | |
| 254 | |
| 255 class SSHConnection(connection.SSHConnection): | |
| 256 def serviceStarted(self): | |
| 257 global conn | |
| 258 conn = self | |
| 259 self.localForwards = [] | |
| 260 self.remoteForwards = {} | |
| 261 if not isinstance(self, connection.SSHConnection): | |
| 262 # make these fall through | |
| 263 del self.__class__.requestRemoteForwarding | |
| 264 del self.__class__.cancelRemoteForwarding | |
| 265 onConnect() | |
| 266 | |
| 267 def serviceStopped(self): | |
| 268 lf = self.localForwards | |
| 269 self.localForwards = [] | |
| 270 for s in lf: | |
| 271 s.loseConnection() | |
| 272 stopConnection() | |
| 273 | |
| 274 def requestRemoteForwarding(self, remotePort, hostport): | |
| 275 data = forwarding.packGlobal_tcpip_forward(('0.0.0.0', remotePort)) | |
| 276 d = self.sendGlobalRequest('tcpip-forward', data, | |
| 277 wantReply=1) | |
| 278 log.msg('requesting remote forwarding %s:%s' %(remotePort, hostport)) | |
| 279 d.addCallback(self._cbRemoteForwarding, remotePort, hostport) | |
| 280 d.addErrback(self._ebRemoteForwarding, remotePort, hostport) | |
| 281 | |
| 282 def _cbRemoteForwarding(self, result, remotePort, hostport): | |
| 283 log.msg('accepted remote forwarding %s:%s' % (remotePort, hostport)) | |
| 284 self.remoteForwards[remotePort] = hostport | |
| 285 log.msg(repr(self.remoteForwards)) | |
| 286 | |
| 287 def _ebRemoteForwarding(self, f, remotePort, hostport): | |
| 288 log.msg('remote forwarding %s:%s failed' % (remotePort, hostport)) | |
| 289 log.msg(f) | |
| 290 | |
| 291 def cancelRemoteForwarding(self, remotePort): | |
| 292 data = forwarding.packGlobal_tcpip_forward(('0.0.0.0', remotePort)) | |
| 293 self.sendGlobalRequest('cancel-tcpip-forward', data) | |
| 294 log.msg('cancelling remote forwarding %s' % remotePort) | |
| 295 try: | |
| 296 del self.remoteForwards[remotePort] | |
| 297 except: | |
| 298 pass | |
| 299 log.msg(repr(self.remoteForwards)) | |
| 300 | |
| 301 def channel_forwarded_tcpip(self, windowSize, maxPacket, data): | |
| 302 log.msg('%s %s' % ('FTCP', repr(data))) | |
| 303 remoteHP, origHP = forwarding.unpackOpen_forwarded_tcpip(data) | |
| 304 log.msg(self.remoteForwards) | |
| 305 log.msg(remoteHP) | |
| 306 if self.remoteForwards.has_key(remoteHP[1]): | |
| 307 connectHP = self.remoteForwards[remoteHP[1]] | |
| 308 log.msg('connect forwarding %s' % (connectHP,)) | |
| 309 return SSHConnectForwardingChannel(connectHP, | |
| 310 remoteWindow = windowSize, | |
| 311 remoteMaxPacket = maxPacket, | |
| 312 conn = self) | |
| 313 else: | |
| 314 raise ConchError(connection.OPEN_CONNECT_FAILED, "don't know about t
hat port") | |
| 315 | |
| 316 # def channel_auth_agent_openssh_com(self, windowSize, maxPacket, data): | |
| 317 # if options['agent'] and keyAgent: | |
| 318 # return agent.SSHAgentForwardingChannel(remoteWindow = windowSize, | |
| 319 # remoteMaxPacket = maxPacket, | |
| 320 # conn = self) | |
| 321 # else: | |
| 322 # return connection.OPEN_CONNECT_FAILED, "don't have an agent" | |
| 323 | |
| 324 def channelClosed(self, channel): | |
| 325 log.msg('connection closing %s' % channel) | |
| 326 log.msg(self.channels) | |
| 327 if len(self.channels) == 1 and not (options['noshell'] and not options['
nocache']): # just us left | |
| 328 log.msg('stopping connection') | |
| 329 stopConnection() | |
| 330 else: | |
| 331 # because of the unix thing | |
| 332 self.__class__.__bases__[0].channelClosed(self, channel) | |
| 333 | |
| 334 class SSHSession(channel.SSHChannel): | |
| 335 | |
| 336 name = 'session' | |
| 337 | |
| 338 def channelOpen(self, foo): | |
| 339 log.msg('session %s open' % self.id) | |
| 340 if options['agent']: | |
| 341 d = self.conn.sendRequest(self, 'auth-agent-req@openssh.com', '', wa
ntReply=1) | |
| 342 d.addBoth(lambda x:log.msg(x)) | |
| 343 if options['noshell']: return | |
| 344 if (options['command'] and options['tty']) or not options['notty']: | |
| 345 _enterRawMode() | |
| 346 c = session.SSHSessionClient() | |
| 347 if options['escape'] and not options['notty']: | |
| 348 self.escapeMode = 1 | |
| 349 c.dataReceived = self.handleInput | |
| 350 else: | |
| 351 c.dataReceived = self.write | |
| 352 c.connectionLost = lambda x=None,s=self:s.sendEOF() | |
| 353 self.stdio = stdio.StandardIO(c) | |
| 354 fd = 0 | |
| 355 if options['subsystem']: | |
| 356 self.conn.sendRequest(self, 'subsystem', \ | |
| 357 common.NS(options['command'])) | |
| 358 elif options['command']: | |
| 359 if options['tty']: | |
| 360 term = os.environ['TERM'] | |
| 361 winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, '12345678') | |
| 362 winSize = struct.unpack('4H', winsz) | |
| 363 ptyReqData = session.packRequest_pty_req(term, winSize, '') | |
| 364 self.conn.sendRequest(self, 'pty-req', ptyReqData) | |
| 365 signal.signal(signal.SIGWINCH, self._windowResized) | |
| 366 self.conn.sendRequest(self, 'exec', \ | |
| 367 common.NS(options['command'])) | |
| 368 else: | |
| 369 if not options['notty']: | |
| 370 term = os.environ['TERM'] | |
| 371 winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, '12345678') | |
| 372 winSize = struct.unpack('4H', winsz) | |
| 373 ptyReqData = session.packRequest_pty_req(term, winSize, '') | |
| 374 self.conn.sendRequest(self, 'pty-req', ptyReqData) | |
| 375 signal.signal(signal.SIGWINCH, self._windowResized) | |
| 376 self.conn.sendRequest(self, 'shell', '') | |
| 377 #if hasattr(conn.transport, 'transport'): | |
| 378 # conn.transport.transport.setTcpNoDelay(1) | |
| 379 | |
| 380 def handleInput(self, char): | |
| 381 #log.msg('handling %s' % repr(char)) | |
| 382 if char in ('\n', '\r'): | |
| 383 self.escapeMode = 1 | |
| 384 self.write(char) | |
| 385 elif self.escapeMode == 1 and char == options['escape']: | |
| 386 self.escapeMode = 2 | |
| 387 elif self.escapeMode == 2: | |
| 388 self.escapeMode = 1 # so we can chain escapes together | |
| 389 if char == '.': # disconnect | |
| 390 log.msg('disconnecting from escape') | |
| 391 stopConnection() | |
| 392 return | |
| 393 elif char == '\x1a': # ^Z, suspend | |
| 394 def _(): | |
| 395 _leaveRawMode() | |
| 396 sys.stdout.flush() | |
| 397 sys.stdin.flush() | |
| 398 os.kill(os.getpid(), signal.SIGTSTP) | |
| 399 _enterRawMode() | |
| 400 reactor.callLater(0, _) | |
| 401 return | |
| 402 elif char == 'R': # rekey connection | |
| 403 log.msg('rekeying connection') | |
| 404 self.conn.transport.sendKexInit() | |
| 405 return | |
| 406 elif char == '#': # display connections | |
| 407 self.stdio.write('\r\nThe following connections are open:\r\n') | |
| 408 channels = self.conn.channels.keys() | |
| 409 channels.sort() | |
| 410 for channelId in channels: | |
| 411 self.stdio.write(' #%i %s\r\n' % (channelId, str(self.conn.
channels[channelId]))) | |
| 412 return | |
| 413 self.write('~' + char) | |
| 414 else: | |
| 415 self.escapeMode = 0 | |
| 416 self.write(char) | |
| 417 | |
| 418 def dataReceived(self, data): | |
| 419 self.stdio.write(data) | |
| 420 | |
| 421 def extReceived(self, t, data): | |
| 422 if t==connection.EXTENDED_DATA_STDERR: | |
| 423 log.msg('got %s stderr data' % len(data)) | |
| 424 sys.stderr.write(data) | |
| 425 | |
| 426 def eofReceived(self): | |
| 427 log.msg('got eof') | |
| 428 self.stdio.closeStdin() | |
| 429 | |
| 430 def closeReceived(self): | |
| 431 log.msg('remote side closed %s' % self) | |
| 432 self.conn.sendClose(self) | |
| 433 | |
| 434 def closed(self): | |
| 435 global old | |
| 436 log.msg('closed %s' % self) | |
| 437 log.msg(repr(self.conn.channels)) | |
| 438 if not options['nocache']: # fork into the background | |
| 439 if os.fork(): | |
| 440 if old: | |
| 441 fd = sys.stdin.fileno() | |
| 442 tty.tcsetattr(fd, tty.TCSANOW, old) | |
| 443 if (options['command'] and options['tty']) or \ | |
| 444 not options['notty']: | |
| 445 signal.signal(signal.SIGWINCH, signal.SIG_DFL) | |
| 446 os._exit(0) | |
| 447 os.setsid() | |
| 448 for i in range(3): | |
| 449 try: | |
| 450 os.close(i) | |
| 451 except OSError, e: | |
| 452 import errno | |
| 453 if e.errno != errno.EBADF: | |
| 454 raise | |
| 455 | |
| 456 def request_exit_status(self, data): | |
| 457 global exitStatus | |
| 458 exitStatus = int(struct.unpack('>L', data)[0]) | |
| 459 log.msg('exit status: %s' % exitStatus) | |
| 460 | |
| 461 def sendEOF(self): | |
| 462 self.conn.sendEOF(self) | |
| 463 | |
| 464 def stopWriting(self): | |
| 465 self.stdio.pauseProducing() | |
| 466 | |
| 467 def startWriting(self): | |
| 468 self.stdio.resumeProducing() | |
| 469 | |
| 470 def _windowResized(self, *args): | |
| 471 winsz = fcntl.ioctl(0, tty.TIOCGWINSZ, '12345678') | |
| 472 winSize = struct.unpack('4H', winsz) | |
| 473 newSize = winSize[1], winSize[0], winSize[2], winSize[3] | |
| 474 self.conn.sendRequest(self, 'window-change', struct.pack('!4L', *newSize
)) | |
| 475 | |
| 476 | |
| 477 class SSHListenClientForwardingChannel(forwarding.SSHListenClientForwardingChann
el): pass | |
| 478 class SSHConnectForwardingChannel(forwarding.SSHConnectForwardingChannel): pass | |
| 479 | |
| 480 def _leaveRawMode(): | |
| 481 global _inRawMode | |
| 482 if not _inRawMode: | |
| 483 return | |
| 484 fd = sys.stdin.fileno() | |
| 485 tty.tcsetattr(fd, tty.TCSANOW, _savedMode) | |
| 486 _inRawMode = 0 | |
| 487 | |
| 488 def _enterRawMode(): | |
| 489 global _inRawMode, _savedMode | |
| 490 if _inRawMode: | |
| 491 return | |
| 492 fd = sys.stdin.fileno() | |
| 493 try: | |
| 494 old = tty.tcgetattr(fd) | |
| 495 new = old[:] | |
| 496 except: | |
| 497 log.msg('not a typewriter!') | |
| 498 else: | |
| 499 # iflage | |
| 500 new[0] = new[0] | tty.IGNPAR | |
| 501 new[0] = new[0] & ~(tty.ISTRIP | tty.INLCR | tty.IGNCR | tty.ICRNL | | |
| 502 tty.IXON | tty.IXANY | tty.IXOFF) | |
| 503 if hasattr(tty, 'IUCLC'): | |
| 504 new[0] = new[0] & ~tty.IUCLC | |
| 505 | |
| 506 # lflag | |
| 507 new[3] = new[3] & ~(tty.ISIG | tty.ICANON | tty.ECHO | tty.ECHO | | |
| 508 tty.ECHOE | tty.ECHOK | tty.ECHONL) | |
| 509 if hasattr(tty, 'IEXTEN'): | |
| 510 new[3] = new[3] & ~tty.IEXTEN | |
| 511 | |
| 512 #oflag | |
| 513 new[1] = new[1] & ~tty.OPOST | |
| 514 | |
| 515 new[6][tty.VMIN] = 1 | |
| 516 new[6][tty.VTIME] = 0 | |
| 517 | |
| 518 _savedMode = old | |
| 519 tty.tcsetattr(fd, tty.TCSANOW, new) | |
| 520 #tty.setraw(fd) | |
| 521 _inRawMode = 1 | |
| 522 | |
| 523 if __name__ == '__main__': | |
| 524 run() | |
| 525 | |
| OLD | NEW |