|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +################################################################################ |
| 4 | +## ## |
| 5 | +## PyTCP - Python TCP/IP stack ## |
| 6 | +## Copyright (C) 2020-present Sebastian Majewski ## |
| 7 | +## ## |
| 8 | +## This program is free software: you can redistribute it and/or modify ## |
| 9 | +## it under the terms of the GNU General Public License as published by ## |
| 10 | +## the Free Software Foundation, either version 3 of the License, or ## |
| 11 | +## (at your option) any later version. ## |
| 12 | +## ## |
| 13 | +## This program is distributed in the hope that it will be useful, ## |
| 14 | +## but WITHOUT ANY WARRANTY; without even the implied warranty of ## |
| 15 | +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## |
| 16 | +## GNU General Public License for more details. ## |
| 17 | +## ## |
| 18 | +## You should have received a copy of the GNU General Public License ## |
| 19 | +## along with this program. If not, see <https://www.gnu.org/licenses/>. ## |
| 20 | +## ## |
| 21 | +## Author's email: ccie18643@gmail.com ## |
| 22 | +## Github repository: https://github.com/ccie18643/PyTCP ## |
| 23 | +## ## |
| 24 | +################################################################################ |
| 25 | + |
| 26 | + |
| 27 | +""" |
| 28 | +This module contains an async TCP Echo server (RFC 862) for the PyTCP |
| 29 | +daemon — an asyncio program that runs over the daemon's drop-in stream |
| 30 | +socket. On connect it sends a greeting, then echoes every chunk it receives |
| 31 | +back to the client; as an easter egg carried over from the legacy examples, |
| 32 | +a chunk naming a monkey ('malpka' / 'malpa' / 'malpi') is answered with the |
| 33 | +matching ASCII-art payload. A 'quit' / 'close' / 'bye' / 'exit' line, or the |
| 34 | +client closing its write half, ends the session with a farewell. |
| 35 | +
|
| 36 | +It is built on 'asyncio.start_server', passing an explicit PyTCP socket via |
| 37 | +'sock=' — the supported integration path (a global sys.modules['socket'] |
| 38 | +swap is NOT viable because asyncio's own event loop needs the real socket |
| 39 | +module for its self-pipe / selector). Each accepted connection is handled by |
| 40 | +its own coroutine, so many clients are served concurrently on one loop. |
| 41 | +
|
| 42 | +The socket source is injectable ('make_socket') so the same server logic is |
| 43 | +exercised over real loopback sockets in the project's test suite; the 'main' |
| 44 | +entry point wires it to a PyTCP daemon socket. |
| 45 | +
|
| 46 | +Needs a running daemon that owns the TAP interface, e.g.: |
| 47 | +
|
| 48 | + pytcp stack start -i tap7 --ip4-host <stack-ip>/24 |
| 49 | + PYTCP_DAEMON_SOCKET=/tmp/pytcp.sock \ |
| 50 | + python -m examples.tcp_echo_server__async --host <stack-ip> |
| 51 | + # then, from a peer on the link: |
| 52 | + printf 'malpi\n' | nc <stack-ip> 7 |
| 53 | +
|
| 54 | +examples/tcp_echo_server__async.py |
| 55 | +
|
| 56 | +ver 3.0.8 |
| 57 | +""" |
| 58 | + |
| 59 | +import asyncio |
| 60 | +import socket |
| 61 | +from collections.abc import Callable |
| 62 | +from typing import cast |
| 63 | + |
| 64 | +import click |
| 65 | + |
| 66 | +from examples.malpi import echo_reply |
| 67 | +from net_addr import Ip4Address |
| 68 | +from pytcp import socket as pytcp_socket |
| 69 | + |
| 70 | +# A 'make_socket' returns a fresh, unbound AF_INET stream socket. The daemon |
| 71 | +# wiring returns a PyTCP drop-in socket; tests pass a stdlib factory. |
| 72 | +type MakeSocket = Callable[[], socket.socket] |
| 73 | + |
| 74 | +ECHO__PORT: int = 7 |
| 75 | +ECHO__CHUNK_SIZE: int = 65536 |
| 76 | + |
| 77 | +GREETING: bytes = b"***CLIENT OPEN / SERVICE OPEN***\n" |
| 78 | +FAREWELL__PEER_CLOSED: bytes = b"***CLIENT CLOSED, SERVICE CLOSING***\n" |
| 79 | +FAREWELL__QUIT: bytes = b"***CLIENT OPEN, SERVICE CLOSING***\n" |
| 80 | +QUIT_WORDS: frozenset[bytes] = frozenset({b"quit", b"close", b"bye", b"exit"}) |
| 81 | + |
| 82 | + |
| 83 | +async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: |
| 84 | + """ |
| 85 | + Drive one echo connection: greet, echo each received chunk (answering a |
| 86 | + monkey request with its ASCII art), and close with a farewell when the |
| 87 | + client sends a quit word or closes its write half. |
| 88 | + """ |
| 89 | + |
| 90 | + writer.write(GREETING) |
| 91 | + await writer.drain() |
| 92 | + try: |
| 93 | + while True: |
| 94 | + data = await reader.read(ECHO__CHUNK_SIZE) |
| 95 | + if not data: |
| 96 | + writer.write(FAREWELL__PEER_CLOSED) |
| 97 | + await writer.drain() |
| 98 | + break |
| 99 | + if data.strip().lower() in QUIT_WORDS: |
| 100 | + writer.write(FAREWELL__QUIT) |
| 101 | + await writer.drain() |
| 102 | + break |
| 103 | + writer.write(echo_reply(data)) |
| 104 | + await writer.drain() |
| 105 | + except ConnectionError: |
| 106 | + pass |
| 107 | + finally: |
| 108 | + writer.close() |
| 109 | + |
| 110 | + |
| 111 | +def _pytcp_make_socket() -> socket.socket: |
| 112 | + """ |
| 113 | + Create a fresh PyTCP daemon-backed AF_INET stream socket (typed as a |
| 114 | + 'socket.socket' for the duck-typed asyncio plumbing). |
| 115 | + """ |
| 116 | + |
| 117 | + return cast(socket.socket, pytcp_socket.socket(pytcp_socket.AF_INET, pytcp_socket.SOCK_STREAM)) |
| 118 | + |
| 119 | + |
| 120 | +async def serve(*, host: str, port: int, make_socket: MakeSocket) -> None: |
| 121 | + """ |
| 122 | + Bind a listener via 'make_socket' and serve TCP echo forever. |
| 123 | + """ |
| 124 | + |
| 125 | + listener = make_socket() |
| 126 | + listener.bind((host, port)) |
| 127 | + server = await asyncio.start_server(_handle, sock=listener) |
| 128 | + async with server: |
| 129 | + await server.serve_forever() |
| 130 | + |
| 131 | + |
| 132 | +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) |
| 133 | +@click.option("--host", default=None, help="Stack IPv4 address to bind (the daemon's configured host).") |
| 134 | +@click.option("--port", type=click.IntRange(1, 0xFFFF), default=ECHO__PORT, show_default=True) |
| 135 | +def tcp_echo_server(host: str | None, port: int) -> None: |
| 136 | + """ |
| 137 | + Run an async TCP Echo server (RFC 862) over the PyTCP daemon. |
| 138 | + """ |
| 139 | + |
| 140 | + if host is None: |
| 141 | + raise click.UsageError("--host is required (the stack IPv4 address the daemon is configured with).") |
| 142 | + Ip4Address(host) # validate; raises a clear net_addr error on a bad literal |
| 143 | + |
| 144 | + click.echo(f"PyTCP async TCP echo server on {host}:{port} (send 'malpi' for a surprise)") |
| 145 | + try: |
| 146 | + asyncio.run(serve(host=host, port=port, make_socket=_pytcp_make_socket)) |
| 147 | + except KeyboardInterrupt: |
| 148 | + click.echo("\nShutting down.") |
| 149 | + |
| 150 | + |
| 151 | +if __name__ == "__main__": |
| 152 | + tcp_echo_server() # pylint: disable=no-value-for-parameter # click injects the arguments |
0 commit comments