from __future__ import annotations import asyncio import hashlib from importlib.metadata import version import logging import uuid import weakref import xmlrpc.client from typing import * import aiohttp import multidict from hippolyzer.lib.base.datatypes import Vector3, StringEnum from hippolyzer.lib.base.helpers import proxify, get_resource_filename, create_logged_task from hippolyzer.lib.base.message.circuit import Circuit from hippolyzer.lib.base.message.llsd_msg_serializer import LLSDMessageSerializer from hippolyzer.lib.base.message.message import Message, Block from hippolyzer.lib.base.message.message_dot_xml import MessageDotXML from hippolyzer.lib.base.message.message_handler import MessageHandler from hippolyzer.lib.base.message.udpdeserializer import UDPMessageDeserializer from hippolyzer.lib.base.network.caps_client import CapsClient, CAPS_DICT from hippolyzer.lib.base.network.transport import ADDR_TUPLE, Direction, SocketUDPTransport, AbstractUDPTransport from hippolyzer.lib.base.settings import Settings, SettingDescriptor from hippolyzer.lib.base.templates import RegionHandshakeReplyFlags, ChatType, ThrottleData from hippolyzer.lib.base.transfer_manager import TransferManager from hippolyzer.lib.base.xfer_manager import XferManager from hippolyzer.lib.client.asset_uploader import AssetUploader from hippolyzer.lib.client.inventory_manager import InventoryManager from hippolyzer.lib.client.object_manager import ClientObjectManager, ClientWorldObjectManager from hippolyzer.lib.client.parcel_manager import ParcelManager from hippolyzer.lib.client.state import BaseClientSession, BaseClientRegion, BaseClientSessionManager LOG = logging.getLogger(__name__) class StartLocation(StringEnum): LAST = "last" HOME = "home" class ClientSettings(Settings): SSL_VERIFY: bool = SettingDescriptor(False) """Off by default for now, the cert validation is a big mess due to LL using an internal CA.""" SSL_CERT_PATH: str = SettingDescriptor(get_resource_filename("lib/base/network/data/ca-bundle.crt")) USER_AGENT: str = SettingDescriptor(f"Hippolyzer/v{version('hippolyzer')}") SEND_AGENT_UPDATES: bool = SettingDescriptor(True) """Generally you want to send these, lots of things will break if you don't send at least one.""" AUTO_REQUEST_PARCELS: bool = SettingDescriptor(True) """Automatically request all parcel details when connecting to a region""" AUTO_REQUEST_MATERIALS: bool = SettingDescriptor(True) """Automatically request all materials when connecting to a region""" class HippoCapsClient(CapsClient): def __init__( self, settings: ClientSettings, caps: Optional[CAPS_DICT] = None, session: Optional[aiohttp.ClientSession] = None, ) -> None: super().__init__(caps, session) self._settings = settings def _request_fixups(self, cap_or_url: str, headers: Dict, proxy: Optional[bool], ssl: Any): headers["User-Agent"] = self._settings.USER_AGENT return cap_or_url, headers, proxy, self._settings.SSL_VERIFY class HippoClientProtocol(asyncio.DatagramProtocol): def __init__(self, session: HippoClientSession): self.session = proxify(session) self.message_xml = MessageDotXML() self.deserializer = UDPMessageDeserializer( settings=self.session.session_manager.settings, ) def datagram_received(self, data, source_addr: ADDR_TUPLE): region = self.session.region_by_circuit_addr(source_addr) if not region: logging.warning("Received packet from invalid address %s", source_addr) return message = self.deserializer.deserialize(data) message.direction = Direction.IN message.sender = source_addr if not self.message_xml.validate_udp_msg(message.name): LOG.warning( f"Received {message.name!r} over UDP, when it should come over the event queue. Discarding." ) raise PermissionError(f"UDPBanned message {message.name}") region.circuit.collect_acks(message) should_handle = True if message.reliable: # This is a bit crap. We send an ACK immediately through a PacketAck. # This is pretty wasteful, we should batch them up and send them on a timer. # We should ACK even if it's a resend of something we've already handled, maybe # they never got the ACK. region.circuit.send_acks((message.packet_id,)) should_handle = region.circuit.track_reliable(message.packet_id) try: if should_handle: self.session.message_handler.handle(message) except: LOG.exception("Failed in session message handler") if should_handle: region.message_handler.handle(message) class HippoClientRegion(BaseClientRegion): def __init__(self, circuit_addr, seed_cap: Optional[str], session: HippoClientSession, handle=None): super().__init__() self.caps = multidict.MultiDict() self.message_handler: MessageHandler[Message, str] = MessageHandler(take_by_default=False) self.circuit_addr = circuit_addr self.handle = handle if seed_cap: self.caps["Seed"] = seed_cap self.session: Callable[[], HippoClientSession] = weakref.ref(session) self.caps_client = HippoCapsClient(session.session_manager.settings, self.caps, session.http_session) self.xfer_manager = XferManager(proxify(self), self.session().secure_session_id) self.transfer_manager = TransferManager(proxify(self), session.agent_id, session.id) self.asset_uploader = AssetUploader(proxify(self)) self.parcel_manager = ParcelManager(proxify(self)) self.objects = ClientObjectManager(self) self._llsd_serializer = LLSDMessageSerializer() self._eq_task: Optional[asyncio.Task] = None self.connected: asyncio.Future = asyncio.Future() self.message_handler.subscribe("StartPingCheck", self._handle_ping_check) def update_caps(self, caps: Mapping[str, str]) -> None: self.caps.update(caps) @property def cap_urls(self) -> multidict.MultiDict: return self.caps.copy() async def connect(self, main_region: bool = False): # Disconnect first if we're already connected if self.circuit and self.circuit.is_alive: self.disconnect() if self.connected.done(): self.connected = asyncio.Future() try: # TODO: What happens if a circuit code is invalid, again? Does it just refuse to ACK? await self.circuit.send_reliable( Message( "UseCircuitCode", Block( "CircuitCode", Code=self.session().circuit_code, SessionID=self.session().id, ID=self.session().agent_id, ), ) ) self.circuit.is_alive = True # Clear out any old caps urls except the seed URL, we're about to fetch new caps. seed_url = self.caps["Seed"] self.caps.clear() self.caps["Seed"] = seed_url # Kick this off and await it later seed_resp_fut = self.caps_client.post("Seed", llsd=list(self.session().session_manager.SUPPORTED_CAPS)) # Register first so we can handle it even if the ack happens after the message is sent region_handshake_fut = self.message_handler.wait_for(("RegionHandshake",)) # If we're connecting to the main region, it won't even send us a RegionHandshake until we # first send a CompleteAgentMovement. if main_region: await self.complete_agent_movement() self.name = str((await region_handshake_fut)["RegionInfo"][0]["SimName"]) self.session().objects.track_region_objects(self.handle) await self.circuit.send_reliable( Message( "RegionHandshakeReply", Block("AgentData", AgentID=self.session().agent_id, SessionID=self.session().id), Block( "RegionInfo", Flags=( RegionHandshakeReplyFlags.SUPPORTS_SELF_APPEARANCE | RegionHandshakeReplyFlags.VOCACHE_CULLING_ENABLED ) ) ) ) await self.circuit.send_reliable( Message( "AgentThrottle", Block( "AgentData", AgentID=self.session().agent_id, SessionID=self.session().id, CircuitCode=self.session().circuit_code, ), Block( "Throttle", GenCounter=0, # Reasonable defaults, I guess Throttles_=ThrottleData( resend=207360.0, land=165376.0, wind=33075.19921875, cloud=33075.19921875, task=682700.75, texture=682700.75, asset=269312.0 ), ) ) ) if self.session().session_manager.settings.SEND_AGENT_UPDATES: # Usually we want to send at least one, since lots of messages will never be sent by the sim # until we send at least one AgentUpdate. For example, ParcelOverlay and LayerData. await self.circuit.send_reliable( Message( "AgentUpdate", Block( 'AgentData', AgentID=self.session().agent_id, SessionID=self.session().id, # Don't really care about the other fields. fill_missing=True, ) ) ) async with seed_resp_fut as seed_resp: seed_resp.raise_for_status() self.update_caps(await seed_resp.read_llsd()) self._eq_task = create_logged_task(self._poll_event_queue(), "EQ Poll") settings = self.session().session_manager.settings if settings.AUTO_REQUEST_PARCELS: _ = create_logged_task(self.parcel_manager.request_dirty_parcels(), "Parcel Request") if settings.AUTO_REQUEST_MATERIALS: _ = create_logged_task(self.objects.request_all_materials(), "Request All Materials") except Exception as e: # Let consumers who were `await`ing the connected signal know there was an error if not self.connected.done(): self.connected.set_exception(e) raise self.connected.set_result(None) def disconnect(self) -> None: """Simulator has gone away, disconnect. Should be synchronous""" if self._eq_task is not None: self._eq_task.cancel() self._eq_task = None self.circuit.disconnect() self.objects.clear() if self.connected.done(): self.connected = asyncio.Future() # TODO: cancel XFers and Transfers and whatnot async def complete_agent_movement(self) -> None: await self.circuit.send_reliable( Message( "CompleteAgentMovement", Block( "AgentData", AgentID=self.session().agent_id, SessionID=self.session().id, CircuitCode=self.session().circuit_code ), ) ) self.session().main_region = self async def _poll_event_queue(self): ack: Optional[int] = None while True: payload = {"ack": ack, "done": False} try: async with self.caps_client.post("EventQueueGet", llsd=payload) as resp: if resp.status != 200: await asyncio.sleep(0.1) continue polled = await resp.read_llsd() for event in polled["events"]: if self._llsd_serializer.can_handle(event["message"]): msg = self._llsd_serializer.deserialize(event) else: msg = Message.from_eq_event(event) msg.sender = self.circuit_addr msg.direction = Direction.IN self.session().message_handler.handle(msg) self.message_handler.handle(msg) ack = polled["id"] await asyncio.sleep(0.001) except aiohttp.client_exceptions.ServerDisconnectedError: # This is expected to happen during long-polling, just pick up again where we left off. await asyncio.sleep(0.001) async def _handle_ping_check(self, message: Message): self.circuit.send( Message( "CompletePingCheck", Block("PingID", PingID=message["PingID"]["PingID"]), ) ) class HippoClientSession(BaseClientSession): """Represents a client's view of a remote session""" REGION_CLS = HippoClientRegion region_by_handle: Callable[[int], Optional[HippoClientRegion]] region_by_circuit_addr: Callable[[ADDR_TUPLE], Optional[HippoClientRegion]] regions: List[HippoClientRegion] session_manager: HippoClient main_region: Optional[HippoClientRegion] def __init__(self, id, secure_session_id, agent_id, circuit_code, session_manager: Optional[HippoClient] = None, login_data=None): super().__init__(id, secure_session_id, agent_id, circuit_code, session_manager, login_data=login_data) self.http_session = session_manager.http_session self.objects = ClientWorldObjectManager(proxify(self), session_manager.settings, None) self.inventory_manager = InventoryManager(proxify(self)) self.transport: Optional[SocketUDPTransport] = None self.protocol: Optional[HippoClientProtocol] = None self.message_handler.take_by_default = False for msg_name in ("DisableSimulator", "CloseCircuit"): self.message_handler.subscribe(msg_name, lambda msg: self.unregister_region(msg.sender)) for msg_name in ("TeleportFinish", "CrossedRegion", "EstablishAgentCommunication"): self.message_handler.subscribe(msg_name, self._handle_register_region_message) def register_region(self, circuit_addr: Optional[ADDR_TUPLE] = None, seed_url: Optional[str] = None, handle: Optional[int] = None) -> HippoClientRegion: return super().register_region(circuit_addr, seed_url, handle) # type:ignore def unregister_region(self, circuit_addr: ADDR_TUPLE) -> None: for i, region in enumerate(self.regions): if region.circuit_addr == circuit_addr: self.regions[i].disconnect() del self.regions[i] return raise KeyError(f"No such region for {circuit_addr!r}") def open_circuit(self, circuit_addr: ADDR_TUPLE): for region in self.regions: if region.circuit_addr == circuit_addr: valid_circuit = False if not region.circuit or not region.circuit.is_alive: region.circuit = Circuit(("127.0.0.1", 0), circuit_addr, self.transport) region.circuit.is_alive = False valid_circuit = True if region.circuit and region.circuit.is_alive: # Whatever, already open logging.debug("Tried to re-open circuit for %r" % (circuit_addr,)) valid_circuit = True return valid_circuit return False def _handle_register_region_message(self, msg: Message): # Handle events that inform us about new regions sim_addr, sim_handle, sim_seed = None, None, None moving_to_region = False # Sim is asking us to talk to a neighbour if msg.name == "EstablishAgentCommunication": ip_split = msg["EventData"]["sim-ip-and-port"].split(":") sim_addr = (ip_split[0], int(ip_split[1])) sim_seed = msg["EventData"]["seed-capability"] # We teleported or cross region, opening comms to new sim elif msg.name in ("TeleportFinish", "CrossedRegion"): sim_block = msg.get_block("RegionData", msg.get_block("Info"))[0] sim_addr = (sim_block["SimIP"], sim_block["SimPort"]) sim_handle = sim_block["RegionHandle"] sim_seed = sim_block["SeedCapability"] moving_to_region = True # Sim telling us about a neighbour # elif msg.name == "EnableSimulator": # sim_block = msg["SimulatorInfo"][0] # sim_addr = (sim_block["IP"], sim_block["Port"]) # sim_handle = sim_block["Handle"] # TODO: EnableSimulator is a little weird. It creates a region and establishes a # circuit, but with no seed cap. The viewer will send UseCircuitCode and all that, # but it's totally workable to just wait for an EstablishAgentCommunication to do that, # since that's when the region actually shows up. I guess EnableSimulator just gives the # viewer some lead time to set up the circuit before the region is actually shown through # EstablishAgentCommunication? Either way, messing around with regions that don't have seed # caps is annoying, so let's just not do it. # Register a region if this message was telling us about a new one if sim_addr is not None: region = self.register_region(sim_addr, handle=sim_handle, seed_url=sim_seed) # We can't actually connect without a sim seed, mind you, when we receive and EnableSimulator # we have to wait for the EstablishAgentCommunication to actually connect. need_connect = (region.circuit and region.circuit.is_alive) or moving_to_region self.open_circuit(sim_addr) if need_connect: create_logged_task(region.connect(main_region=moving_to_region), "Region Connect") elif moving_to_region: # No need to connect, but we do need to complete agent movement. create_logged_task(region.complete_agent_movement(), "CompleteAgentMovement") class HippoClient(BaseClientSessionManager): """A simple client, only connects to one region at a time currently.""" SUPPORTED_CAPS: Set[str] = { "AbuseCategories", "AcceptFriendship", "AcceptGroupInvite", "AgentPreferences", "AgentProfile", "AgentState", "AttachmentResources", "AvatarPickerSearch", "AvatarRenderInfo", "CharacterProperties", "ChatSessionRequest", "CopyInventoryFromNotecard", "CreateInventoryCategory", "DeclineFriendship", "DeclineGroupInvite", "DispatchRegionInfo", "DirectDelivery", "EnvironmentSettings", "EstateAccess", "DispatchOpenRegionSettings", "EstateChangeInfo", "EventQueueGet", "ExtEnvironment", "FetchLib2", "FetchLibDescendents2", "FetchInventory2", "FetchInventoryDescendents2", "IncrementCOFVersion", "InventoryAPIv3", "LibraryAPIv3", "InterestList", "InventoryThumbnailUpload", "GetDisplayNames", "GetExperiences", "AgentExperiences", "FindExperienceByName", "GetExperienceInfo", "GetAdminExperiences", "GetCreatorExperiences", "ExperiencePreferences", "GroupExperiences", "UpdateExperience", "IsExperienceAdmin", "IsExperienceContributor", "RegionExperiences", "ExperienceQuery", "GetMesh", "GetMesh2", "GetMetadata", "GetObjectCost", "GetObjectPhysicsData", "GetTexture", "GroupAPIv1", "GroupMemberData", "GroupProposalBallot", "HomeLocation", "LandResources", "LSLSyntax", "MapLayer", "MapLayerGod", "MeshUploadFlag", "NavMeshGenerationStatus", "NewFileAgentInventory", "ObjectAnimation", "ObjectMedia", "ObjectMediaNavigate", "ObjectNavMeshProperties", "ParcelPropertiesUpdate", "ParcelVoiceInfoRequest", "ProductInfoRequest", "ProvisionVoiceAccountRequest", "ReadOfflineMsgs", "RegionObjects", "RemoteParcelRequest", "RenderMaterials", "RequestTextureDownload", "ResourceCostSelected", "RetrieveNavMeshSrc", "SearchStatRequest", "SearchStatTracking", "SendPostcard", "SendUserReport", "SendUserReportWithScreenshot", "ServerReleaseNotes", "SetDisplayName", "SimConsoleAsync", "SimulatorFeatures", "StartGroupProposal", "TerrainNavMeshProperties", "TextureStats", "UntrustedSimulatorMessage", "UpdateAgentInformation", "UpdateAgentLanguage", "UpdateAvatarAppearance", "UpdateGestureAgentInventory", "UpdateGestureTaskInventory", "UpdateNotecardAgentInventory", "UpdateNotecardTaskInventory", "UpdateScriptAgent", "UpdateScriptTask", "UpdateSettingsAgentInventory", "UpdateSettingsTaskInventory", "UploadAgentProfileImage", "UploadBakedTexture", "UserInfo", "ViewerAsset", "ViewerBenefits", "ViewerMetrics", "ViewerStartAuction", "ViewerStats", } DEFAULT_OPTIONS = { "inventory-root", "inventory-skeleton", "inventory-lib-root", "inventory-lib-owner", "inventory-skel-lib", "initial-outfit", "gestures", "display_names", "event_notifications", "classified_categories", "adult_compliant", "buddy-list", "newuser-config", "ui-config", "advanced-mode", "max-agent-groups", "map-server-url", "voice-config", "tutorial_setting", "login-flags", "global-textures", # Not an official option, just so this can be tracked. "pyogp-client", } DEFAULT_LOGIN_URI = "https://login.agni.lindenlab.com/cgi-bin/login.cgi" def __init__(self, options: Optional[Set[str]] = None): self._username: Optional[str] = None self._password: Optional[str] = None self._mac = uuid.getnode() self._options = options if options is not None else self.DEFAULT_OPTIONS self.http_session: Optional[aiohttp.ClientSession] = aiohttp.ClientSession(trust_env=True) self.session: Optional[HippoClientSession] = None self.settings = ClientSettings() self._resend_task: Optional[asyncio.Task] = None @property def main_region(self) -> Optional[HippoClientRegion]: if not self.session: return None return self.session.main_region @property def main_circuit(self) -> Optional[Circuit]: if not self.main_region: return None return self.main_region.circuit @property def main_caps_client(self) -> Optional[CapsClient]: if not self.main_region: return None return self.main_region.caps_client async def aclose(self): try: self.logout() finally: if self.http_session: await self.http_session.close() self.http_session = None def __del__(self): # Make sure we don't leak resources if someone was lazy. try: self.logout() finally: if self.http_session: try: asyncio.create_task(self.http_session.close) except: pass self.http_session = None async def _create_transport(self) -> Tuple[AbstractUDPTransport, HippoClientProtocol]: loop = asyncio.get_event_loop_policy().get_event_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: HippoClientProtocol(self.session), local_addr=('0.0.0.0', 0)) transport = SocketUDPTransport(transport) return transport, protocol async def login( self, username: str, password: str, login_uri: Optional[str] = None, agree_to_tos: bool = False, start_location: Union[StartLocation, str, None] = StartLocation.LAST, connect: bool = True, ): if self.session: raise RuntimeError("Already logged in!") if not login_uri: login_uri = self.DEFAULT_LOGIN_URI if start_location is None: start_location = StartLocation.LAST # This isn't a symbolic start location and isn't a URI, must be a sim name. if start_location not in iter(StartLocation) and not start_location.startswith("uri:"): start_location = f"uri:{start_location}&128&128&128" split_username = username.split(" ") if len(split_username) < 2: first_name = split_username[0] last_name = "Resident" else: first_name, last_name = split_username payload = { "address_size": 64, "agree_to_tos": int(agree_to_tos), "channel": "Hippolyzer", "extended_errors": 1, "first": first_name, "last": last_name, "host_id": "", "id0": hashlib.md5(str(self._mac).encode("ascii")).hexdigest(), "mac": hashlib.md5(str(self._mac).encode("ascii")).hexdigest(), "mfa_hash": "", "passwd": "$1$" + hashlib.md5(str(password).encode("ascii")).hexdigest(), # TODO: actually get these "platform": "lnx", "platform_string": "Linux 6.6", # TODO: What is this? "platform_version": "2.38.0", "read_critical": 0, "start": str(start_location), "token": "", "version": version("hippolyzer"), "options": list(self._options), } async with self.http_session.post( login_uri, data=xmlrpc.client.dumps((payload,), "login_to_simulator"), headers={"Content-Type": "text/xml", "User-Agent": self.settings.USER_AGENT}, ssl=self.settings.SSL_VERIFY, ) as resp: resp.raise_for_status() login_data = xmlrpc.client.loads((await resp.read()).decode("utf8"))[0][0] self.session = HippoClientSession.from_login_data(login_data, self) self.session.transport, self.session.protocol = await self._create_transport() self._resend_task = create_logged_task(self._attempt_resends(), "Circuit Resend") self.session.message_handler.subscribe("AgentDataUpdate", self._handle_agent_data_update) self.session.message_handler.subscribe("AgentGroupDataUpdate", self._handle_agent_group_data_update) assert self.session.open_circuit(self.session.regions[-1].circuit_addr) if connect: region = self.session.regions[-1] await region.connect(main_region=True) def logout(self): if not self.session: return if self._resend_task: self._resend_task.cancel() self._resend_task = None if self.main_circuit and self.main_circuit.is_alive: # Don't need to send reliably, there's a good chance the server won't ACK anyway. self.main_circuit.send( Message( "LogoutRequest", Block("AgentData", AgentID=self.session.agent_id, SessionID=self.session.id), ) ) session = self.session self.session = None for region in session.regions: region.disconnect() session.transport.close() def send_chat(self, message: Union[bytes, str], channel: int = 0, chat_type=ChatType.NORMAL) -> asyncio.Future: return self.main_circuit.send_reliable(Message( "ChatFromViewer", Block("AgentData", SessionID=self.session.id, AgentID=self.session.agent_id), Block("ChatData", Message=message, Channel=channel, Type=chat_type), )) def teleport(self, region_handle: int, local_pos=Vector3(0, 0, 0)) -> asyncio.Future: """Synchronously requests a teleport, returning a Future for teleport completion""" teleport_fut = asyncio.Future() # Send request synchronously, await asynchronously. send_fut = self.main_circuit.send_reliable( Message( 'TeleportLocationRequest', Block('AgentData', AgentID=self.session.agent_id, SessionID=self.session.id), Block('Info', RegionHandle=region_handle, Position=local_pos, fill_missing=True), ) ) async def _handle_teleport(): # Subscribe first, we may receive an event before we receive the packet ACK. with self.session.message_handler.subscribe_async( ("TeleportLocal", "TeleportFailed", "TeleportFinish"), ) as get_tp_done_msg: try: await send_fut except Exception as e: # Pass along error if we failed to send reliably. teleport_fut.set_exception(e) return # Wait for a message that says we're done the teleport msg = await get_tp_done_msg() if msg.name == "TeleportFailed": teleport_fut.set_exception(RuntimeError("Failed to teleport")) elif msg.name == "TeleportLocal": # Within the sim, nothing else we need to do teleport_fut.set_result(None) elif msg.name == "TeleportFinish": # Non-local TP, wait until we receive the AgentMovementComplete to # set the finished signal. # Region should be registered by this point, wait for it to connect try: # just fail if it takes longer than 30 seconds for the handshake to complete await asyncio.wait_for(self.session.region_by_handle(region_handle).connected, 30) except Exception as e: teleport_fut.set_exception(e) return teleport_fut.set_result(None) create_logged_task(_handle_teleport(), "Teleport") return teleport_fut async def _attempt_resends(self): while True: if self.session is None: break for region in self.session.regions: if not region.circuit.is_alive: continue region.circuit.resend_unacked() await asyncio.sleep(0.5) def _handle_agent_data_update(self, msg: Message): self.session.active_group = msg["AgentData"]["ActiveGroupID"] def _handle_agent_group_data_update(self, msg: Message): self.session.groups.clear() for block in msg["GroupData"]: self.session.groups.add(block["GroupID"])