okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
c9cb3a0
main
1"""A small asyncio WebSocket client, and a stdio bridge for ssh's ProxyCommand. 2 3python -m albedo_plugins.ws # ALBEDO_WS_URL, and ALBEDO_WS_HEADERS as a JSON object 4 5The bridge copies stdin to binary messages and messages to stdout until either 6side ends. The url and headers travel in the environment because they carry 7tokens, which a command line would show to every process on the host. 8""" 9 10from __future__import annotations 11 12import asyncio 13import base64 14from dataclasses import dataclass 15import hashlib 16import json 17import os 18import struct 19import sys 20import urllib .parse 21 22GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 23TEXT ,BINARY ,CLOSE ,PING ,PONG ,CONTINUATION = 0x1 ,0x2 ,0x8 ,0x9 ,0xA ,0x0 24 25 26@dataclass 27class CloseFrame : 28code :int 29reason :str 30 31 32class ConnectionClosed (Exception ): 33def __init__ (self ,code :int = 1006 ,reason :str = "" )-> None : 34super ().__init__ (f"WebSocket closed: { code } { reason } " ) 35self .code = code 36self .reason = reason 37self .rcvd = CloseFrame (code ,reason ) 38self .sent :CloseFrame | None = None 39 40 41def mask (payload :bytes ,key :bytes )-> bytes : 42"""`payload` XORed with the repeating 4-byte `key`; applying it twice undoes it.""" 43size = len (payload ) 44stream = (key * (size // 4 + 1 ))[:size ] 45return (int .from_bytes (payload ,"big" )^ int .from_bytes (stream ,"big" )).to_bytes ( 46size ,"big" 47 ) 48 49 50def frame (opcode :int ,payload :bytes )-> bytes : 51"""One final, masked client frame.""" 52key = os .urandom (4 ) 53size = len (payload ) 54if size <= 125 : 55head = struct .pack ("!BB" ,0x80 | opcode ,0x80 | size ) 56elif size <= 0xFFFF : 57head = struct .pack ("!BBH" ,0x80 | opcode ,0x80 | 126 ,size ) 58else : 59head = struct .pack ("!BBQ" ,0x80 | opcode ,0x80 | 127 ,size ) 60return head + key + mask (payload ,key ) 61 62 63class SimpleWebSocketClient : 64def __init__ ( 65self , 66reader :asyncio .StreamReader , 67writer :asyncio .StreamWriter , 68max_message_bytes :int = 16 * 1024 * 1024 , 69 )-> None : 70self .reader = reader 71self .writer = writer 72self .max_message_bytes = max_message_bytes 73self ._closed = False 74self ._close_sent = False 75self .close_code :int | None = None 76self .close_reason :str = "" 77 78@classmethod 79async def connect ( 80cls , 81url :str , 82* , 83headers :dict [str ,str ]| None = None , 84timeout :float = 15 , 85max_message_bytes :int = 16 * 1024 * 1024 , 86 )-> SimpleWebSocketClient : 87parsed = urllib .parse .urlsplit (url ) 88if parsed .scheme not in ("ws" ,"wss" ): 89raise ValueError (f"Expected ws:// or wss:// URL, got: { url !r } " ) 90hostname = parsed .hostname or "127.0.0.1" 91port = parsed .port or (443 if parsed .scheme == "wss" else 80 ) 92ssl_ctx = True if parsed .scheme == "wss" else None 93 94async with asyncio .timeout (timeout ): 95reader ,writer = await asyncio .open_connection ( 96hostname ,port ,ssl = ssl_ctx ,limit = max_message_bytes 97 ) 98key = base64 .b64encode (os .urandom (16 )).decode ("ascii" ) 99path = parsed .path or "/" 100if parsed .query : 101path = f" { path } ? { parsed . query } " 102 103host_header = f" { hostname } : { port } " if parsed .port else hostname 104lines = [ 105f"GET { path } HTTP/1.1" , 106f"Host: { host_header } " , 107"Upgrade: websocket" , 108"Connection: Upgrade" , 109f"Sec-WebSocket-Key: { key } " , 110"Sec-WebSocket-Version: 13" , 111 ] 112if headers : 113for k ,v in headers .items (): 114lines .append (f" { k } : { v } " ) 115raw_req = "\r\n" .join (lines )+ "\r\n\r\n" 116writer .write (raw_req .encode ("ascii" )) 117await writer .drain () 118 119status_line = await reader .readline () 120if not status_line : 121writer .close () 122raise ConnectionClosed (1006 ,"EOF during WebSocket handshake" ) 123parts = status_line .decode ("latin1" ).split (" " ,2 ) 124if len (parts )< 2 or parts [1 ]!= "101" : 125body = await reader .read (4096 ) 126writer .close () 127raise ConnectionClosed ( 1281002 , 129f"Handshake failed: { status_line . decode ( 'latin1' ). strip () } { body . decode ( 'latin1' , errors = 'replace' ) } " , 130 ) 131 132resp_headers :dict [str ,str ]= {} 133while True : 134line = await reader .readline () 135if line in (b"\r\n" ,b"\n" ,b"" ): 136break 137hparts = line .decode ("latin1" ).split (":" ,1 ) 138if len (hparts )== 2 : 139resp_headers [hparts [0 ].strip ().lower ()]= hparts [1 ].strip () 140 141expected_accept = base64 .b64encode ( 142hashlib .sha1 ((key + GUID ).encode ("ascii" )).digest () 143 ).decode ("ascii" ) 144accept = resp_headers .get ("sec-websocket-accept" ) 145if accept != expected_accept : 146writer .close () 147raise ConnectionClosed ( 1481002 , 149f"Sec-WebSocket-Accept mismatch: expected { expected_accept } , got { accept } " , 150 ) 151 152return cls (reader ,writer ,max_message_bytes = max_message_bytes ) 153 154async def send (self ,data :str | bytes )-> None : 155"""Send one text message for a str, one binary message for bytes.""" 156if self ._closed : 157raise ConnectionClosed (self .close_code or 1000 ,self .close_reason ) 158if isinstance (data ,str ): 159self .writer .write (frame (TEXT ,data .encode ("utf-8" ))) 160else : 161self .writer .write (frame (BINARY ,data )) 162await self .writer .drain () 163 164async def recv_bytes (self )-> bytes : 165"""The next message's bytes; control frames are answered on the way.""" 166fragments :list [bytes ]= [] 167total_len = 0 168 169while True : 170if self ._closed : 171raise ConnectionClosed (self .close_code or 1000 ,self .close_reason ) 172 173try : 174head = await self .reader .readexactly (2 ) 175except ( 176asyncio .IncompleteReadError , 177ConnectionResetError , 178BrokenPipeError , 179 )as exc : 180self ._closed = True 181raise ConnectionClosed (1006 ,"Connection closed abruptly" )from exc 182 183b1 ,b2 = head [0 ],head [1 ] 184fin = bool (b1 & 0x80 ) 185opcode = b1 & 0x0F 186is_masked = bool (b2 & 0x80 ) 187payload_len = b2 & 0x7F 188 189if payload_len == 126 : 190ext = await self .reader .readexactly (2 ) 191payload_len = struct .unpack ("!H" ,ext )[0 ] 192elif payload_len == 127 : 193ext = await self .reader .readexactly (8 ) 194payload_len = struct .unpack ("!Q" ,ext )[0 ] 195 196total_len += payload_len 197if total_len > self .max_message_bytes : 198await self .close (1009 ,"Message exceeds size limit" ) 199exc = ConnectionClosed ( 2001009 ,f"Message exceeded max_message_bytes { self . max_message_bytes } " 201 ) 202exc .sent = CloseFrame (1009 ,"Message exceeds size limit" ) 203raise exc 204 205key = await self .reader .readexactly (4 )if is_masked else b"" 206payload = await self .reader .readexactly (payload_len ) 207if is_masked : 208payload = mask (payload ,key ) 209 210if opcode == CLOSE : 211code = 1000 212reason = "" 213if len (payload )>= 2 : 214code = struct .unpack ("!H" ,payload [:2 ])[0 ] 215reason = payload [2 :].decode ("utf-8" ,errors = "replace" ) 216self .close_code = code 217self .close_reason = reason 218self ._closed = True 219if not self ._close_sent : 220try : 221self ._close_sent = True 222self .writer .write (frame (CLOSE ,b"" )) 223await self .writer .drain () 224self .writer .close () 225except Exception : 226pass 227raise ConnectionClosed (code ,reason ) 228elif opcode == PING : 229self .writer .write (frame (PONG ,payload )) 230await self .writer .drain () 231elif opcode == PONG : 232continue 233elif opcode in (TEXT ,BINARY ,CONTINUATION ): 234fragments .append (payload ) 235if fin : 236return b"" .join (fragments ) 237else : 238raise ConnectionClosed (1002 ,f"Unknown WebSocket opcode: { opcode } " ) 239 240async def recv_message (self )-> str : 241return (await self .recv_bytes ()).decode ("utf-8" ,errors = "replace" ) 242 243def __aiter__ (self )-> SimpleWebSocketClient : 244return self 245 246async def __anext__ (self )-> str : 247try : 248return await self .recv_message () 249except ConnectionClosed : 250raise StopAsyncIteration 251 252async def close (self ,code :int = 1000 ,reason :str = "" )-> None : 253if self ._closed and self ._close_sent : 254return 255self ._closed = True 256if not self ._close_sent : 257self ._close_sent = True 258try : 259payload = struct .pack ("!H" ,code )+ reason .encode ("utf-8" )[:123 ] 260self .writer .write (frame (CLOSE ,payload )) 261await self .writer .drain () 262self .writer .close () 263await self .writer .wait_closed () 264except Exception : 265pass 266 267 268async def bridge (url :str ,headers :dict [str ,str ])-> None : 269"""Copy stdin to the socket and the socket to stdout until either ends.""" 270socket = await SimpleWebSocketClient .connect (url ,headers = headers ) 271loop = asyncio .get_running_loop () 272stdin = asyncio .StreamReader () 273await loop .connect_read_pipe ( 274lambda :asyncio .StreamReaderProtocol (stdin ),sys .stdin .buffer 275 ) 276 277async def upstream ()-> None : 278while chunk := await stdin .read (65536 ): 279await socket .send (chunk ) 280 281async def downstream ()-> None : 282try : 283while True : 284sys .stdout .buffer .write (await socket .recv_bytes ()) 285sys .stdout .buffer .flush () 286except ConnectionClosed : 287pass 288 289tasks = [asyncio .create_task (upstream ()),asyncio .create_task (downstream ())] 290try : 291await asyncio .wait (tasks ,return_when = asyncio .FIRST_COMPLETED ) 292finally : 293for task in tasks : 294task .cancel () 295await asyncio .gather (* tasks ,return_exceptions = True ) 296await socket .close () 297 298 299def main ()-> int : 300try : 301url = os .environ ["ALBEDO_WS_URL" ] 302headers = json .loads (os .environ .get ("ALBEDO_WS_HEADERS" ,"{}" )) 303asyncio .run (bridge (url ,headers )) 304except (KeyError ,ValueError ,OSError ,ConnectionClosed )as error : 305sys .stderr .write (f"ws bridge: { error !r } \n" ) 306return 1 307return 0 308 309 310if __name__ == "__main__" : 311sys .exit (main ())