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