xref: /curl/tests/negtelnetserver.py (revision 79d0b3c0)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3#
4#  Project                     ___| | | |  _ \| |
5#                             / __| | | | |_) | |
6#                            | (__| |_| |  _ <| |___
7#                             \___|\___/|_| \_\_____|
8#
9# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
10#
11# This software is licensed as described in the file COPYING, which
12# you should have received as part of this distribution. The terms
13# are also available at https://curl.se/docs/copyright.html.
14#
15# You may opt to use, copy, modify, merge, publish, distribute and/or sell
16# copies of the Software, and permit persons to whom the Software is
17# furnished to do so, under the terms of the COPYING file.
18#
19# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
20# KIND, either express or implied.
21#
22# SPDX-License-Identifier: curl
23#
24""" A telnet server which negotiates"""
25
26from __future__ import (absolute_import, division, print_function,
27                        unicode_literals)
28
29import argparse
30import logging
31import os
32import socket
33import sys
34import time
35
36from util import ClosingFileHandler
37
38if sys.version_info.major >= 3:
39    import socketserver
40else:
41    import SocketServer as socketserver
42
43log = logging.getLogger(__name__)
44HOST = "localhost"
45IDENT = "NTEL"
46
47
48# The strings that indicate the test framework is checking our aliveness
49VERIFIED_REQ = "verifiedserver"
50VERIFIED_RSP = "WE ROOLZ: {pid}"
51
52
53def telnetserver(options):
54    """
55    Starts up a TCP server with a telnet handler and serves DICT requests
56    forever.
57    """
58    if options.pidfile:
59        pid = os.getpid()
60        # see tests/server/util.c function write_pidfile
61        if os.name == "nt":
62            pid += 65536
63        with open(options.pidfile, "w") as f:
64            f.write(str(pid))
65
66    local_bind = (HOST, options.port)
67    log.info("Listening on %s", local_bind)
68
69    # Need to set the allow_reuse on the class, not on the instance.
70    socketserver.TCPServer.allow_reuse_address = True
71    with socketserver.TCPServer(local_bind, NegotiatingTelnetHandler) as server:
72        server.serve_forever()
73    # leaving `with` calls server.close() automatically
74    return ScriptRC.SUCCESS
75
76
77class NegotiatingTelnetHandler(socketserver.BaseRequestHandler):
78    """Handler class for Telnet connections.
79
80    """
81    def handle(self):
82        """
83        Negotiates options before reading data.
84        """
85        neg = Negotiator(self.request)
86
87        try:
88            # Send some initial negotiations.
89            neg.send_do("NEW_ENVIRON")
90            neg.send_will("NEW_ENVIRON")
91            neg.send_dont("NAWS")
92            neg.send_wont("NAWS")
93
94            # Get the data passed through the negotiator
95            data = neg.recv(4*1024)
96            log.debug("Incoming data: %r", data)
97
98            if VERIFIED_REQ.encode('utf-8') in data:
99                log.debug("Received verification request from test framework")
100                pid = os.getpid()
101                # see tests/server/util.c function write_pidfile
102                if os.name == "nt":
103                    pid += 65536
104                response = VERIFIED_RSP.format(pid=pid)
105                response_data = response.encode('utf-8')
106            else:
107                log.debug("Received normal request - echoing back")
108                response_data = data.decode('utf-8').strip().encode('utf-8')
109
110            if response_data:
111                log.debug("Sending %r", response_data)
112                self.request.sendall(response_data)
113
114            # put some effort into making a clean socket shutdown
115            # that does not give the client ECONNRESET
116            self.request.settimeout(0.1)
117            self.request.recv(4*1024)
118            self.request.shutdown(socket.SHUT_RDWR)
119
120        except IOError:
121            log.exception("IOError hit during request")
122
123
124class Negotiator(object):
125    NO_NEG = 0
126    START_NEG = 1
127    WILL = 2
128    WONT = 3
129    DO = 4
130    DONT = 5
131
132    def __init__(self, tcp):
133        self.tcp = tcp
134        self.state = self.NO_NEG
135
136    def recv(self, bytes):
137        """
138        Read bytes from TCP, handling negotiation sequences
139
140        :param bytes: Number of bytes to read
141        :return: a buffer of bytes
142        """
143        buffer = bytearray()
144
145        # If we keep receiving negotiation sequences, we won't fill the buffer.
146        # Keep looping while we can, and until we have something to give back
147        # to the caller.
148        while len(buffer) == 0:
149            data = self.tcp.recv(bytes)
150            if not data:
151                # TCP failed to give us any data. Break out.
152                break
153
154            for byte_int in bytearray(data):
155                if self.state == self.NO_NEG:
156                    self.no_neg(byte_int, buffer)
157                elif self.state == self.START_NEG:
158                    self.start_neg(byte_int)
159                elif self.state in [self.WILL, self.WONT, self.DO, self.DONT]:
160                    self.handle_option(byte_int)
161                else:
162                    # Received an unexpected byte. Stop negotiations
163                    log.error("Unexpected byte %s in state %s",
164                              byte_int,
165                              self.state)
166                    self.state = self.NO_NEG
167
168        return buffer
169
170    def no_neg(self, byte_int, buffer):
171        # Not negotiating anything thus far. Check to see if we
172        # should.
173        if byte_int == NegTokens.IAC:
174            # Start negotiation
175            log.debug("Starting negotiation (IAC)")
176            self.state = self.START_NEG
177        else:
178            # Just append the incoming byte to the buffer
179            buffer.append(byte_int)
180
181    def start_neg(self, byte_int):
182        # In a negotiation.
183        log.debug("In negotiation (%s)",
184                  NegTokens.from_val(byte_int))
185
186        if byte_int == NegTokens.WILL:
187            # Client is confirming they are willing to do an option
188            log.debug("Client is willing")
189            self.state = self.WILL
190        elif byte_int == NegTokens.WONT:
191            # Client is confirming they are unwilling to do an
192            # option
193            log.debug("Client is unwilling")
194            self.state = self.WONT
195        elif byte_int == NegTokens.DO:
196            # Client is indicating they can do an option
197            log.debug("Client can do")
198            self.state = self.DO
199        elif byte_int == NegTokens.DONT:
200            # Client is indicating they can't do an option
201            log.debug("Client can't do")
202            self.state = self.DONT
203        else:
204            # Received an unexpected byte. Stop negotiations
205            log.error("Unexpected byte %s in state %s",
206                      byte_int,
207                      self.state)
208            self.state = self.NO_NEG
209
210    def handle_option(self, byte_int):
211        if byte_int in [NegOptions.BINARY,
212                        NegOptions.CHARSET,
213                        NegOptions.SUPPRESS_GO_AHEAD,
214                        NegOptions.NAWS,
215                        NegOptions.NEW_ENVIRON]:
216            log.debug("Option: %s", NegOptions.from_val(byte_int))
217
218            # No further negotiation of this option needed. Reset the state.
219            self.state = self.NO_NEG
220
221        else:
222            # Received an unexpected byte. Stop negotiations
223            log.error("Unexpected byte %s in state %s",
224                      byte_int,
225                      self.state)
226            self.state = self.NO_NEG
227
228    def send_message(self, message_ints):
229        self.tcp.sendall(bytearray(message_ints))
230
231    def send_iac(self, arr):
232        message = [NegTokens.IAC]
233        message.extend(arr)
234        self.send_message(message)
235
236    def send_do(self, option_str):
237        log.debug("Sending DO %s", option_str)
238        self.send_iac([NegTokens.DO, NegOptions.to_val(option_str)])
239
240    def send_dont(self, option_str):
241        log.debug("Sending DONT %s", option_str)
242        self.send_iac([NegTokens.DONT, NegOptions.to_val(option_str)])
243
244    def send_will(self, option_str):
245        log.debug("Sending WILL %s", option_str)
246        self.send_iac([NegTokens.WILL, NegOptions.to_val(option_str)])
247
248    def send_wont(self, option_str):
249        log.debug("Sending WONT %s", option_str)
250        self.send_iac([NegTokens.WONT, NegOptions.to_val(option_str)])
251
252
253class NegBase(object):
254    @classmethod
255    def to_val(cls, name):
256        return getattr(cls, name)
257
258    @classmethod
259    def from_val(cls, val):
260        for k in cls.__dict__.keys():
261            if getattr(cls, k) == val:
262                return k
263
264        return "<unknown>"
265
266
267class NegTokens(NegBase):
268    # The start of a negotiation sequence
269    IAC = 255
270    # Confirm willingness to negotiate
271    WILL = 251
272    # Confirm unwillingness to negotiate
273    WONT = 252
274    # Indicate willingness to negotiate
275    DO = 253
276    # Indicate unwillingness to negotiate
277    DONT = 254
278
279    # The start of sub-negotiation options.
280    SB = 250
281    # The end of sub-negotiation options.
282    SE = 240
283
284
285class NegOptions(NegBase):
286    # Binary Transmission
287    BINARY = 0
288    # Suppress Go Ahead
289    SUPPRESS_GO_AHEAD = 3
290    # NAWS - width and height of client
291    NAWS = 31
292    # NEW-ENVIRON - environment variables on client
293    NEW_ENVIRON = 39
294    # Charset option
295    CHARSET = 42
296
297
298def get_options():
299    parser = argparse.ArgumentParser()
300
301    parser.add_argument("--port", action="store", default=9019,
302                        type=int, help="port to listen on")
303    parser.add_argument("--verbose", action="store", type=int, default=0,
304                        help="verbose output")
305    parser.add_argument("--pidfile", action="store",
306                        help="file name for the PID")
307    parser.add_argument("--logfile", action="store",
308                        help="file name for the log")
309    parser.add_argument("--srcdir", action="store", help="test directory")
310    parser.add_argument("--id", action="store", help="server ID")
311    parser.add_argument("--ipv4", action="store_true", default=0,
312                        help="IPv4 flag")
313
314    return parser.parse_args()
315
316
317def setup_logging(options):
318    """
319    Set up logging from the command line options
320    """
321    root_logger = logging.getLogger()
322    add_stdout = False
323
324    formatter = logging.Formatter("%(asctime)s %(levelname)-5.5s "
325                                  "[{ident}] %(message)s"
326                                  .format(ident=IDENT))
327
328    # Write out to a logfile
329    if options.logfile:
330        handler = ClosingFileHandler(options.logfile)
331        handler.setFormatter(formatter)
332        handler.setLevel(logging.DEBUG)
333        root_logger.addHandler(handler)
334    else:
335        # The logfile wasn't specified. Add a stdout logger.
336        add_stdout = True
337
338    if options.verbose:
339        # Add a stdout logger as well in verbose mode
340        root_logger.setLevel(logging.DEBUG)
341        add_stdout = True
342    else:
343        root_logger.setLevel(logging.INFO)
344
345    if add_stdout:
346        stdout_handler = logging.StreamHandler(sys.stdout)
347        stdout_handler.setFormatter(formatter)
348        stdout_handler.setLevel(logging.DEBUG)
349        root_logger.addHandler(stdout_handler)
350
351
352class ScriptRC(object):
353    """Enum for script return codes"""
354    SUCCESS = 0
355    FAILURE = 1
356    EXCEPTION = 2
357
358
359class ScriptException(Exception):
360    pass
361
362
363if __name__ == '__main__':
364    # Get the options from the user.
365    options = get_options()
366
367    # Setup logging using the user options
368    setup_logging(options)
369
370    # Run main script.
371    try:
372        rc = telnetserver(options)
373    except Exception as e:
374        log.exception(e)
375        rc = ScriptRC.EXCEPTION
376
377    if options.pidfile and os.path.isfile(options.pidfile):
378        os.unlink(options.pidfile)
379
380    log.info("Returning %d", rc)
381    sys.exit(rc)
382