okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntest: the websocket client test waits for the server to read the pong73f5e4e

main
6.6 KiB177 linesraw
1"""WebSocket masking and the ssh bridge need a controlled peer that daemon E2E cannot expose."""
2
3from __future__ import annotations
4
5import asyncio
6import base64
7import hashlib
8import json
9import os
10from pathlib import Path
11import struct
12import sys
13import unittest
14
15ROOT = Path(__file__).resolve().parents[2]
16PRIV = ROOT / "priv" / "python"
17sys.path.insert(0, str(PRIV))
18
19from albedo_plugins.ws import SimpleWebSocketClient, mask  # noqa: E402
20
21GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
22
23
24async def handshake(
25    reader: asyncio.StreamReader, writer: asyncio.StreamWriter
26) -> dict[str, str]:
27    """Accept an upgrade; the request headers, lowercased."""
28    headers: dict[str, str] = {}
29    await reader.readline()
30    while (line := await reader.readline()) not in (b"\r\n", b"\n", b""):
31        name, _, value = line.decode("latin1").partition(":")
32        headers[name.strip().lower()] = value.strip()
33    accept = base64.b64encode(
34        hashlib.sha1((headers["sec-websocket-key"] + GUID).encode("ascii")).digest()
35    ).decode("ascii")
36    writer.write(
37        (
38            "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n"
39            f"Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n"
40        ).encode("ascii")
41    )
42    await writer.drain()
43    return headers
44
45
46async def read_frame(reader: asyncio.StreamReader) -> tuple[int, bytes]:
47    """One client frame, which must be final and masked: (opcode, payload)."""
48    b1, b2 = await reader.readexactly(2)
49    assert b1 & 0x80 and b2 & 0x80, "client frames are final and masked"
50    size = b2 & 0x7F
51    if size == 126:
52        (size,) = struct.unpack("!H", await reader.readexactly(2))
53    elif size == 127:
54        (size,) = struct.unpack("!Q", await reader.readexactly(8))
55    key = await reader.readexactly(4)
56    payload = await reader.readexactly(size)
57    return b1 & 0x0F, bytes(byte ^ key[i % 4] for i, byte in enumerate(payload))
58
59
60def server_frame(opcode: int, payload: bytes) -> bytes:
61    if len(payload) <= 125:
62        head = struct.pack("!BB", 0x80 | opcode, len(payload))
63    else:
64        head = struct.pack("!BBH", 0x80 | opcode, 126, len(payload))
65    return head + payload
66
67
68class WebSocketClientTest(unittest.IsolatedAsyncioTestCase):
69    async def test_mask_is_its_own_inverse(self):
70        key = b"\x01\x02\x03\x04"
71        for size in (0, 1, 3, 4, 5, 1000):
72            payload = os.urandom(size)
73            masked = mask(payload, key)
74            self.assertEqual(
75                masked, bytes(b ^ key[i % 4] for i, b in enumerate(payload))
76            )
77            self.assertEqual(mask(masked, key), payload)
78
79    async def test_text_and_binary_messages(self):
80        received: list[tuple[int, bytes]] = []
81        answered = asyncio.Event()
82        big = os.urandom(70_000)
83
84        async def handler(reader, writer):
85            await handshake(reader, writer)
86            received.append(await read_frame(reader))
87            received.append(await read_frame(reader))
88            received.append(await read_frame(reader))
89            writer.write(server_frame(0x9, b"are you there"))  # ping
90            writer.write(server_frame(0x1, b"echo: hello albedo"))
91            writer.write(server_frame(0x2, b"\xff\x00\xfe"))
92            await writer.drain()
93            pong = await read_frame(reader)
94            received.append(pong)
95            answered.set()
96            writer.close()
97
98        server = await asyncio.start_server(handler, "127.0.0.1", 0)
99        port = server.sockets[0].getsockname()[1]
100        async with server:
101            client = await SimpleWebSocketClient.connect(f"ws://127.0.0.1:{port}/")
102            await client.send("hello albedo")
103            await client.send(b"\x00\x01binary")
104            await client.send(big)
105            self.assertEqual(await client.recv_message(), "echo: hello albedo")
106            self.assertEqual(await client.recv_bytes(), b"\xff\x00\xfe")
107            await client.close()
108            # Closing the client does not wait for the server to read the
109            # pong, and leaving `async with server` waits for handlers only
110            # from Python 3.12.
111            await asyncio.wait_for(answered.wait(), 10)
112        self.assertEqual(
113            received[:2], [(0x1, b"hello albedo"), (0x2, b"\x00\x01binary")]
114        )
115        self.assertEqual(received[2], (0x2, big))
116        self.assertEqual(received[3], (0xA, b"are you there"))
117
118
119class BridgeTest(unittest.IsolatedAsyncioTestCase):
120    async def test_stdio_is_copied_both_ways_with_headers_from_the_environment(self):
121        seen: dict[str, str] = {}
122
123        async def handler(reader, writer):
124            seen.update(await handshake(reader, writer))
125            while True:
126                opcode, payload = await read_frame(reader)
127                if opcode == 0x8:
128                    break
129                writer.write(server_frame(0x2, payload.upper()))
130                await writer.drain()
131            writer.close()
132
133        server = await asyncio.start_server(handler, "127.0.0.1", 0)
134        port = server.sockets[0].getsockname()[1]
135        async with server:
136            process = await asyncio.create_subprocess_exec(
137                sys.executable,
138                "-m",
139                "albedo_plugins.ws",
140                stdin=asyncio.subprocess.PIPE,
141                stdout=asyncio.subprocess.PIPE,
142                stderr=asyncio.subprocess.PIPE,
143                env={
144                    **os.environ,
145                    "PYTHONPATH": str(PRIV),
146                    "ALBEDO_WS_URL": f"ws://127.0.0.1:{port}/colab/ssh?token=secret",
147                    "ALBEDO_WS_HEADERS": json.dumps({"X-Test": "yes"}),
148                },
149            )
150            assert process.stdin and process.stdout
151            process.stdin.write(b"ssh bytes")
152            await process.stdin.drain()
153            echoed = await asyncio.wait_for(process.stdout.readexactly(9), 10)
154            process.stdin.close()
155            await asyncio.wait_for(process.wait(), 10)
156
157        self.assertEqual(echoed, b"SSH BYTES")
158        self.assertEqual(seen["x-test"], "yes")
159        self.assertEqual(process.returncode, 0)
160
161    async def test_missing_url_fails_with_a_message(self):
162        environment = {k: v for k, v in os.environ.items() if k != "ALBEDO_WS_URL"}
163        process = await asyncio.create_subprocess_exec(
164            sys.executable,
165            "-m",
166            "albedo_plugins.ws",
167            stdin=asyncio.subprocess.DEVNULL,
168            stderr=asyncio.subprocess.PIPE,
169            env={**environment, "PYTHONPATH": str(PRIV)},
170        )
171        _, stderr = await process.communicate()
172        self.assertEqual(process.returncode, 1)
173        self.assertIn("ALBEDO_WS_URL", stderr.decode())
174
175
176if __name__ == "__main__":
177    unittest.main()