Implement native login and session initialization (#56)
All checks were successful
Native code generation / deterministic (push) Successful in 11m53s
Imaging and meshing gate / native (push) Successful in 4m3s
JPEG 2000 feature / linux (push) Successful in 2m33s
Native Rust workspace compile / compile (push) Successful in 4m3s
Skia feature / linux (push) Successful in 31m24s

This commit is contained in:
2026-08-09 18:18:28 +00:00
parent aa269f1785
commit 3951bdb809
15 changed files with 3992 additions and 1355 deletions

View File

@@ -19,6 +19,8 @@ futures-util = "0.3.31"
libremetaverse-imaging = { path = "../libremetaverse-imaging" }
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
libremetaverse-types = { path = "../libremetaverse-types" }
mac_address2 = "2.0.2"
os_info = { version = "3.15.0", default-features = false }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "stream"] }
roxmltree = "0.21.1"
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }

View File

@@ -116,6 +116,7 @@ struct ClientRuntime {
agent_throttle_sender: Mutex<Option<Arc<dyn crate::udp_transport::AgentThrottleSender>>>,
http_caps_client: Mutex<crate::caps_http::HttpCapsClient>,
caps_rate_limiter: Mutex<crate::caps_http::CapsRateLimiter>,
network_manager: Mutex<std::sync::Weak<crate::network_manager::NetworkManagerInner>>,
shutdown_complete: Condvar,
shutdown_wait: Mutex<()>,
}
@@ -142,6 +143,7 @@ impl ClientRuntime {
agent_throttle_sender: Mutex::new(None),
http_caps_client: Mutex::new(http_caps_client),
caps_rate_limiter: Mutex::new(caps_rate_limiter),
network_manager: Mutex::new(std::sync::Weak::new()),
shutdown_complete: Condvar::new(),
shutdown_wait: Mutex::new(()),
}
@@ -350,6 +352,31 @@ impl GridClient {
.clone()
}
pub(crate) fn native_network(&self) -> Result<crate::NetworkManager, crate::Error> {
let mut cached = self
.runtime
.network_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.upgrade() {
return Ok(crate::network_manager::NetworkManager::native_from_inner(
inner,
));
}
let manager = crate::network_manager::NetworkManager::native_new(self.clone())?;
*cached = manager.native_inner_weak();
Ok(manager)
}
#[allow(clippy::needless_pass_by_value)] // The mapped C# property setter owns its value.
pub(crate) fn native_set_network(&self, value: crate::NetworkManager) {
*self
.runtime
.network_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = value.native_inner_weak();
}
pub(crate) fn native_set_caps_rate_limiter(
&mut self,
value: crate::caps_http::CapsRateLimiter,

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ mod generated;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod genepool_catalog;
mod j2k;
mod login;
mod message_codec;
mod message_decoder;
mod network_manager;

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
#![allow(clippy::missing_errors_doc)] // Result shapes are fixed by the compatibility map.
#![allow(clippy::needless_pass_by_value)] // Owned mapped parameters preserve the public API.
#![allow(clippy::too_many_arguments)] // Login overloads preserve the mapped public API.
#![allow(clippy::too_many_lines)] // Packet dispatch keeps the complete protocol switch together.
#![allow(clippy::type_complexity)] // Delegate signatures are fixed by the compatibility map.
@@ -18,11 +19,15 @@ use crate::packets::{
};
use crate::udp_transport::{UDPBase, UDPPacketBuffer, UdpPacketHandler, UdpTransportConfig};
use crate::{
Avatar, Caps, Error, GenericStreamingMethod, GridClient, Helpers, LoginResponseData, Primitive,
RegionFlags, RegionProtocols, SimAccess, SimulatorDataPool, SimulatorFeatures,
SimulatorSimStats, TerrainPatch,
AccountLevelBenefits, Avatar, Caps, Error, GenericStreamingMethod, GridClient, Helpers,
LoginCredential, LoginParams, LoginProgressEventArgs, LoginResponseData, LoginStatus,
NetworkManagerLoginResponseCallback, Primitive, RegionFlags, RegionProtocols, SimAccess,
SimulatorDataPool, SimulatorFeatures, SimulatorSimStats, TerrainPatch,
};
use libremetaverse_structured_data::{OSD, OSDFormat, OSDMap, OSDParser};
use libremetaverse_types::compat::{
CancellationToken, CancellationTokenSource, EventHandler, Object, Subscription, Uri,
};
use libremetaverse_types::compat::{EventHandler, Object, Subscription, Uri};
use libremetaverse_types::{UUID, Vector2};
use std::collections::HashMap;
use std::fmt;
@@ -90,6 +95,76 @@ fn positive_millisecond_duration(value: i32) -> Duration {
Duration::from_millis(u64::try_from(value.max(1)).unwrap_or(1))
}
fn validate_login_uri(value: &str) -> Result<Uri, Error> {
let parsed = reqwest::Url::parse(value).map_err(|_| Error::Argument)?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err(Error::Argument);
}
Ok(Uri(parsed.to_string()))
}
struct LoginWireSecrets {
password: String,
token: String,
mfa_hash: String,
}
impl Drop for LoginWireSecrets {
fn drop(&mut self) {
// Overwrite the live buffers before releasing them. This cannot erase
// allocator copies, but it prevents the owned request state surviving
// the login/redirect sequence.
self.password
.replace_range(.., &"\0".repeat(self.password.len()));
self.token.replace_range(.., &"\0".repeat(self.token.len()));
self.mfa_hash
.replace_range(.., &"\0".repeat(self.mfa_hash.len()));
self.password.clear();
self.token.clear();
self.mfa_hash.clear();
}
}
fn login_request(params: &LoginParams, secrets: &LoginWireSecrets) -> OSD {
let mut map = HashMap::from([
("first".to_owned(), OSD::String(params.first_name.clone())),
("last".to_owned(), OSD::String(params.last_name.clone())),
("passwd".to_owned(), OSD::String(secrets.password.clone())),
("start".to_owned(), OSD::String(params.start.clone())),
("channel".to_owned(), OSD::String(params.channel.clone())),
("version".to_owned(), OSD::String(params.version.clone())),
("platform".to_owned(), OSD::String(params.platform.clone())),
(
"platform_version".to_owned(),
OSD::String(params.platform_version.clone()),
),
("mac".to_owned(), OSD::String(params.mac.clone())),
("agree_to_tos".to_owned(), OSD::Boolean(params.agree_to_tos)),
(
"read_critical".to_owned(),
OSD::Boolean(params.read_critical),
),
(
"viewer_digest".to_owned(),
OSD::String(params.viewer_digest.clone()),
),
("id0".to_owned(), OSD::String(params.id0.clone())),
(
"last_exec_event".to_owned(),
OSD::Integer(params.last_exec_event as i32),
),
(
"options".to_owned(),
OSD::Array(params.options.iter().cloned().map(OSD::String).collect()),
),
]);
if params.mfa_enabled {
map.insert("token".to_owned(), OSD::String(secrets.token.clone()));
map.insert("mfa_hash".to_owned(), OSD::String(secrets.mfa_hash.clone()));
}
OSD::Map(map)
}
async fn run_blocking_compat<T: Send + 'static>(
name: &str,
operation: impl FnOnce() -> T + Send + 'static,
@@ -857,6 +932,8 @@ pub struct SimulatorData {
handshake_wait_lock: Mutex<()>,
disconnect_candidate: AtomicBool,
circuit_code: AtomicU32,
agent_id: RwLock<UUID>,
session_id: RwLock<UUID>,
seed_caps: RwLock<Option<Uri>>,
caps_state: RwLock<Option<Arc<crate::caps::CapsInner>>>,
event_queue_generation: Mutex<u64>,
@@ -1018,6 +1095,8 @@ impl Simulator {
handshake_wait_lock: Mutex::new(()),
disconnect_candidate: AtomicBool::new(false),
circuit_code: AtomicU32::new(0),
agent_id: RwLock::new(UUID::zero()),
session_id: RwLock::new(UUID::zero()),
seed_caps: RwLock::new(None),
caps_state: RwLock::new(None),
event_queue_generation: Mutex::new(0),
@@ -1058,6 +1137,8 @@ impl Simulator {
fn attach_manager(&self, manager: &Arc<NetworkManagerInner>, circuit_code: u32) {
*mutex(&self.manager) = Arc::downgrade(manager);
self.circuit_code.store(circuit_code, Ordering::Release);
*write(&self.agent_id) = *read(&manager.agent_id);
*write(&self.session_id) = *read(&manager.session_id);
}
pub fn native_connect(&self, _move_to_sim: bool) -> Result<bool, Error> {
@@ -1414,8 +1495,8 @@ impl Simulator {
fn use_circuit_code_bytes(&self) -> Result<Vec<u8>, Error> {
let mut packet = UseCircuitCodePacket::new_with_constructor()?;
packet.circuit_code.code = self.circuit_code.load(Ordering::Acquire);
packet.circuit_code.id = UUID::zero();
packet.circuit_code.session_id = UUID::zero();
packet.circuit_code.id = *read(&self.agent_id);
packet.circuit_code.session_id = *read(&self.session_id);
packet.to_bytes_with_method()
}
@@ -1566,6 +1647,7 @@ struct NetworkEvents {
disconnected: EventRegistry<DisconnectedEventArgs>,
event_queue_running: EventRegistry<EventQueueRunningEventArgs>,
generic_streaming_message: EventRegistry<GenericStreamingMessageEventArgs>,
login_progress: EventRegistry<LoginProgressEventArgs>,
packet_sent: EventRegistry<PacketSentEventArgs>,
sim_changed: EventRegistry<SimChangedEventArgs>,
sim_connected: EventRegistry<SimConnectedEventArgs>,
@@ -1573,7 +1655,23 @@ struct NetworkEvents {
sim_disconnected: EventRegistry<SimDisconnectedEventArgs>,
}
struct NetworkManagerInner {
struct LoginRuntimeState {
next_generation: u64,
active: Option<(u64, CancellationTokenSource)>,
response_callbacks: Vec<(NetworkManagerLoginResponseCallback, Option<Vec<String>>)>,
}
impl Default for LoginRuntimeState {
fn default() -> Self {
Self {
next_generation: 1,
active: None,
response_callbacks: Vec::new(),
}
}
}
pub(crate) struct NetworkManagerInner {
client: GridClient,
simulators: SimulatorCollection,
packet_events: PacketEventDictionary,
@@ -1582,6 +1680,14 @@ struct NetworkManagerInner {
current_sim: RwLock<Option<Simulator>>,
connected: AtomicBool,
circuit_code: AtomicU32,
agent_id: RwLock<UUID>,
session_id: RwLock<UUID>,
login_status: RwLock<LoginStatus>,
login_error_key: RwLock<String>,
login_message: RwLock<String>,
login_seed_capability: RwLock<Option<Uri>>,
login_response: LoginResponseData,
login_runtime: Mutex<LoginRuntimeState>,
inbox_count: AtomicI32,
outbox_count: AtomicI32,
workers: Mutex<Option<NetworkWorkers>>,
@@ -2039,10 +2145,23 @@ impl fmt::Debug for NetworkManager {
}
impl NetworkManager {
pub(crate) fn native_from_inner(inner: Arc<NetworkManagerInner>) -> Self {
Self {
login_response_data: Some(inner.login_response.clone()),
simulators: inner.simulators.clone(),
inner,
}
}
pub(crate) fn native_inner_weak(&self) -> Weak<NetworkManagerInner> {
Arc::downgrade(&self.inner)
}
pub fn native_new(client: GridClient) -> Result<Self, Error> {
let simulators = SimulatorCollection::default();
let packet_events = PacketEventDictionary::new(client.clone())?;
let caps_events = CapsEventDictionary::new(client.clone())?;
let login_response = LoginResponseData::new()?;
let inner = Arc::new(NetworkManagerInner {
client,
simulators: simulators.clone(),
@@ -2052,6 +2171,14 @@ impl NetworkManager {
current_sim: RwLock::new(None),
connected: AtomicBool::new(false),
circuit_code: AtomicU32::new(0),
agent_id: RwLock::new(UUID::zero()),
session_id: RwLock::new(UUID::zero()),
login_status: RwLock::new(LoginStatus::None),
login_error_key: RwLock::new(String::new()),
login_message: RwLock::new(String::new()),
login_seed_capability: RwLock::new(None),
login_response: login_response.clone(),
login_runtime: Mutex::new(LoginRuntimeState::default()),
inbox_count: AtomicI32::new(0),
outbox_count: AtomicI32::new(0),
workers: Mutex::new(None),
@@ -2075,7 +2202,7 @@ impl NetworkManager {
return;
};
let manager = NetworkManager {
login_response_data: None,
login_response_data: Some(inner.login_response.clone()),
simulators: inner.simulators.clone(),
inner: Arc::clone(&inner),
};
@@ -2099,12 +2226,623 @@ impl NetworkManager {
}),
)?;
Ok(Self {
login_response_data: None,
login_response_data: Some(login_response),
simulators,
inner,
})
}
pub fn native_subscribe_login_progress(
&self,
handler: EventHandler<LoginProgressEventArgs>,
) -> Subscription {
self.inner.events.login_progress.subscribe(handler)
}
fn update_login_status(&self, status: LoginStatus, message: String) {
if status == LoginStatus::Failed {
*write(&self.inner.agent_id) = UUID::zero();
*write(&self.inner.session_id) = UUID::zero();
*write(&self.inner.login_seed_capability) = None;
self.inner.login_response.clear_secrets();
}
*write(&self.inner.login_status) = status;
write(&self.inner.login_message).clone_from(&message);
let fail_reason = read(&self.inner.login_error_key).clone();
if let Ok(args) = LoginProgressEventArgs::new(status, message, fail_reason) {
self.inner.events.login_progress.emit_with(|| args.clone());
}
}
fn set_login_error(&self, value: &str) {
let mut error = write(&self.inner.login_error_key);
error.clear();
error.push_str(value);
}
#[must_use]
pub fn native_login_status_code(&self) -> LoginStatus {
*read(&self.inner.login_status)
}
pub fn native_set_login_status_code(&self, value: LoginStatus) {
*write(&self.inner.login_status) = value;
}
#[must_use]
pub fn native_login_error_key(&self) -> String {
read(&self.inner.login_error_key).clone()
}
pub fn native_set_login_error_key(&self, value: String) {
*write(&self.inner.login_error_key) = value;
}
#[must_use]
pub fn native_login_message(&self) -> String {
read(&self.inner.login_message).clone()
}
pub fn native_set_login_message(&self, value: String) {
*write(&self.inner.login_message) = value;
}
#[must_use]
pub fn native_login_seed_capability(&self) -> Option<Uri> {
read(&self.inner.login_seed_capability).clone()
}
pub fn native_set_login_seed_capability(&self, value: Option<Uri>) {
*write(&self.inner.login_seed_capability) = value;
}
#[must_use]
pub fn native_account_level_benefits(&self) -> Option<AccountLevelBenefits> {
self.inner.login_response.account_level_benefits()
}
#[must_use]
pub fn native_agent_appearance_service_url(&self) -> Option<String> {
let value = self.inner.login_response.agent_appearance_service_url();
(!value.is_empty()).then_some(value)
}
#[must_use]
pub fn native_max_agent_groups(&self) -> i32 {
let fallback = self.inner.login_response.max_agent_groups();
self.inner
.login_response
.account_level_benefits()
.map_or(fallback, |benefits| {
let limit = benefits.group_membership_limit();
if limit > 0 { limit } else { fallback }
})
}
pub fn native_default_login_params(
&self,
first_name: String,
last_name: String,
password: String,
channel: String,
version: String,
) -> Result<LoginParams, Error> {
LoginParams::new_with_grid_client_string_string_string_string_string(
self.inner.client.clone(),
first_name,
last_name,
password,
channel,
version,
)
}
pub fn native_register_login_response_callback(
&self,
callback: NetworkManagerLoginResponseCallback,
options: Option<Vec<String>>,
) -> Result<(), Error> {
let mut state = mutex(&self.inner.login_runtime);
if state
.response_callbacks
.iter()
.any(|(candidate, _)| candidate.ptr_eq(&callback))
{
return Err(Error::Argument);
}
state.response_callbacks.push((callback, options));
Ok(())
}
pub fn native_unregister_login_response_callback(
&self,
callback: &NetworkManagerLoginResponseCallback,
) -> Result<(), Error> {
let mut state = mutex(&self.inner.login_runtime);
state
.response_callbacks
.retain(|(candidate, _)| !candidate.ptr_eq(callback));
Ok(())
}
fn reserve_login(
&self,
cancellation_token: Option<CancellationToken>,
) -> Result<(u64, CancellationTokenSource), Error> {
let mut runtime = mutex(&self.inner.login_runtime);
if runtime.active.is_some() {
return Err(Error::InvalidOperation);
}
let caller = cancellation_token.unwrap_or_default();
let source =
CancellationTokenSource::new_linked(&[caller, self.inner.client.cancellation_token()]);
let generation = runtime.next_generation;
runtime.next_generation = runtime.next_generation.wrapping_add(1).max(1);
runtime.active = Some((generation, source.clone()));
Ok((generation, source))
}
fn release_login(&self, generation: u64) {
let mut runtime = mutex(&self.inner.login_runtime);
if runtime
.active
.as_ref()
.is_some_and(|(active, _)| *active == generation)
{
runtime.active = None;
}
}
pub fn native_abort_login(&self) -> Result<(), Error> {
let active = mutex(&self.inner.login_runtime).active.take();
if let Some((_, source)) = active {
source.cancel();
}
self.update_login_status(LoginStatus::Failed, "Abort Requested".to_owned());
Ok(())
}
pub fn native_begin_login(&self, login_params: LoginParams) -> Result<(), Error> {
let (generation, source) = self.reserve_login(None)?;
let manager = Self {
login_response_data: Some(self.inner.login_response.clone()),
simulators: self.simulators.clone(),
inner: Arc::clone(&self.inner),
};
thread::Builder::new()
.name("libremetaverse-login".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let result = match runtime {
Ok(runtime) => {
runtime.block_on(manager.perform_login(login_params, source.token()))
}
Err(_) => Err(Error::InvalidOperation),
};
if let Err(error) = result {
manager.login_transport_failure(&error);
}
manager.release_login(generation);
})
.map_err(|_| {
self.release_login(generation);
Error::InvalidOperation
})?;
Ok(())
}
pub async fn native_login(
&self,
login_params: LoginParams,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
Ok(self
.native_login_response_async(login_params, cancellation_token)
.await?
.is_some_and(|response| response.success()))
}
pub async fn native_login_response_async(
&self,
login_params: LoginParams,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<LoginResponseData>, Error> {
let timeout = positive_millisecond_duration(login_params.timeout);
let (generation, source) = self.reserve_login(cancellation_token)?;
let token = source.token();
let result = tokio::select! {
result = tokio::time::timeout(timeout, self.perform_login(login_params, token.clone())) => {
if let Ok(result) = result {
result
} else {
source.cancel();
self.set_login_error("timeout");
self.update_login_status(LoginStatus::Failed, "Login timed out".to_owned());
Ok(None)
}
}
() = token.cancelled() => {
self.set_login_error("canceled");
self.update_login_status(LoginStatus::Failed, "Canceled".to_owned());
Ok(None)
}
};
self.release_login(generation);
match result {
Ok(value) => Ok(value),
Err(error) => {
self.login_transport_failure(&error);
Ok(None)
}
}
}
pub async fn native_login_credential(
&self,
credential: LoginCredential,
channel: String,
start: Option<String>,
version: String,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let mut login_params = LoginParams::new_with_grid_client_login_credential_string_string(
self.inner.client.clone(),
credential,
channel,
version,
)?;
if let Some(start) = start {
login_params.start = start;
}
self.native_login(login_params, cancellation_token).await
}
pub async fn native_login_credential_response(
&self,
credential: LoginCredential,
channel: String,
start: Option<String>,
version: String,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<LoginResponseData>, Error> {
let mut login_params = LoginParams::new_with_grid_client_login_credential_string_string(
self.inner.client.clone(),
credential,
channel,
version,
)?;
if let Some(start) = start {
login_params.start = start;
}
self.native_login_response_async(login_params, cancellation_token)
.await
}
pub async fn native_login_strings(
&self,
first_name: String,
last_name: String,
password: String,
channel: String,
start: String,
version: String,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let mut params =
self.native_default_login_params(first_name, last_name, password, channel, version)?;
params.start = start;
self.native_login(params, cancellation_token).await
}
pub async fn native_login_strings_response(
&self,
first_name: String,
last_name: String,
password: String,
channel: String,
start: String,
version: String,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<LoginResponseData>, Error> {
let mut params =
self.native_default_login_params(first_name, last_name, password, channel, version)?;
params.start = start;
self.native_login_response_async(params, cancellation_token)
.await
}
async fn perform_login(
&self,
mut params: LoginParams,
cancellation_token: CancellationToken,
) -> Result<Option<LoginResponseData>, Error> {
if params.password.len() != 35 && !params.password.starts_with("$1$") {
params.password = libremetaverse_types::Utils::md5_with_string(params.password)?;
}
if params.channel.trim().is_empty() {
params.channel = crate::Settings::user_agent();
}
if params.version.trim().is_empty() {
params.version = String::from("?.?.?");
}
let wire_secrets = LoginWireSecrets {
password: std::mem::take(&mut params.password),
token: std::mem::take(&mut params.token),
mfa_hash: std::mem::take(&mut params.mfa_hash),
};
params.clear_secrets();
let requested_start = if params.login_location.trim().is_empty() {
params.start.clone()
} else {
params.login_location.clone()
};
params.start = crate::login::normalize_start(&requested_start)?;
let callback_options: Vec<String> = mutex(&self.inner.login_runtime)
.response_callbacks
.iter()
.filter_map(|(_, options)| options.as_ref())
.flatten()
.cloned()
.collect();
for option in callback_options {
if !params.options.contains(&option) {
params.options.push(option);
}
}
let mut redirects = 0_usize;
loop {
cancellation_token.throw_if_cancellation_requested()?;
let login_uri = validate_login_uri(&params.uri)?;
self.update_login_status(
LoginStatus::ConnectingToLogin,
format!(
"Logging in as {} {}...",
params.first_name, params.last_name
),
);
let request = login_request(&params, &wire_secrets);
let response = self
.inner
.client
.native_http_caps_client()
.post_with_uri_osd_format_osd_cancellation_token_i_progress(
login_uri,
OSDFormat::Xml,
request,
cancellation_token.clone(),
None,
)
.await;
let (http_response, response_data) = match response {
Ok(response) => response,
Err(Error::Cancelled) => {
self.set_login_error("canceled");
self.update_login_status(LoginStatus::Failed, "Login canceled".to_owned());
return Ok(None);
}
Err(error) => {
self.login_transport_failure(&error);
return Ok(None);
}
};
if !http_response.is_success_status_code() {
self.set_login_error("bad response");
self.update_login_status(
LoginStatus::Failed,
format!("Login service returned HTTP {}", http_response.status_code),
);
return Ok(None);
}
self.update_login_status(
LoginStatus::ReadingResponse,
"Reading login response...".to_owned(),
);
let parsed = match OSDParser::deserialize_with_bytes(response_data) {
Ok(OSD::Map(map)) => OSDMap::new_with_dictionary(map),
_ => Err(Error::Argument),
};
let Ok(parsed) = parsed else {
self.set_login_error("bad response");
self.update_login_status(
LoginStatus::Failed,
"Empty or corrupt login response".to_owned(),
);
return Ok(None);
};
let has_login = parsed.get("login").is_some();
self.inner.login_response.parse_with_osd_map(parsed)?;
let response = self.inner.login_response.clone();
if !has_login {
self.update_login_status(
LoginStatus::Failed,
"Login parameter missing in the response".to_owned(),
);
return Ok(None);
}
match response.login() {
crate::LoginState::Indeterminate => {
redirects += 1;
if redirects > 10 {
self.set_login_error("redirect loop");
self.update_login_status(
LoginStatus::Failed,
"Too many login redirects".to_owned(),
);
return Ok(None);
}
self.update_login_status(LoginStatus::Redirecting, response.message());
params.uri = response.next_url();
validate_login_uri(&params.uri)?;
let delay = Duration::from_secs(
u64::try_from(response.next_duration().clamp(0, 300)).unwrap_or(0),
);
tokio::select! {
() = tokio::time::sleep(delay) => {}
() = cancellation_token.cancelled() => {
self.set_login_error("canceled");
self.update_login_status(LoginStatus::Failed, "Login canceled".to_owned());
return Ok(None);
}
}
}
crate::LoginState::True => {
self.invoke_login_response(true, false, &response);
if response.seed_capability().is_empty() {
self.set_login_error("bad response");
self.update_login_status(
LoginStatus::Failed,
"Login response did not include a seed capability".to_owned(),
);
return Ok(None);
}
if self.initialize_login_response(
response.clone(),
response.message(),
"Unable to establish a UDP connection to the simulator",
"Login server did not return a simulator address",
)? {
return Ok(Some(response));
}
return Ok(None);
}
crate::LoginState::False => {
let reason = response.reason();
self.set_login_error(if reason.is_empty() {
"unknown"
} else {
&reason
});
self.update_login_status(LoginStatus::Failed, response.message());
return Ok(None);
}
}
}
}
fn invoke_login_response(&self, success: bool, redirect: bool, response: &LoginResponseData) {
let callbacks: Vec<_> = mutex(&self.inner.login_runtime)
.response_callbacks
.iter()
.map(|(callback, _)| callback.clone())
.collect();
for callback in callbacks {
let _ = callback.invoke(
success,
redirect,
response.message(),
response.reason(),
Some(response.clone()),
);
}
}
fn login_transport_failure(&self, error: &Error) {
let (key, message) = match error {
Error::Cancelled => ("canceled", "Login canceled"),
Error::HttpRequest => ("no connection", "Unable to contact login service"),
Error::Argument => ("bad response", "Invalid login request or response"),
_ => ("no connection", "Login failed"),
};
self.set_login_error(key);
self.update_login_status(LoginStatus::Failed, message.to_owned());
}
pub fn native_login_response(&self, response: LoginResponseData) -> Result<bool, Error> {
self.initialize_login_response(
response,
"Login success".to_owned(),
"Unable to connect to simulator",
"Unable to connect to simulator",
)
}
fn initialize_login_response(
&self,
response: LoginResponseData,
success_message: String,
connect_failure_message: &str,
address_failure_message: &str,
) -> Result<bool, Error> {
self.inner.login_response.copy_from(&response);
self.inner
.circuit_code
.store(response.circuit_code().cast_unsigned(), Ordering::Release);
*write(&self.inner.agent_id) = response.agent_id();
*write(&self.inner.session_id) = response.session_id();
let seed = if response.seed_capability().is_empty() {
None
} else {
Some(validate_login_uri(&response.seed_capability())?)
};
write(&self.inner.login_seed_capability).clone_from(&seed);
self.update_login_status(
LoginStatus::ConnectingToSim,
"Connecting to simulator...".to_owned(),
);
let Some(ip) = response.sim_ip() else {
self.set_login_error("bad response");
self.update_login_status(LoginStatus::Failed, address_failure_message.to_owned());
return Ok(false);
};
if response.sim_port() == 0 {
self.set_login_error("bad response");
self.update_login_status(LoginStatus::Failed, address_failure_message.to_owned());
return Ok(false);
}
let handle = (u64::from(response.region_x()) << 32) | u64::from(response.region_y());
let endpoint = std::net::SocketAddr::new(ip, response.sim_port());
let Ok(connected) = self.native_connect(
endpoint,
handle,
true,
seed,
response.region_size_x(),
response.region_size_y(),
) else {
self.cleanup_failed_login_connection(endpoint);
self.set_login_error("no connection");
self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned());
return Ok(false);
};
if let Some(simulator) = connected {
let economy_request =
Packet::build_packet_with_packet_type(PacketType::EconomyDataRequest);
if economy_request
.and_then(|packet| simulator.native_send_packet(packet))
.is_err()
{
self.cleanup_failed_login_connection(endpoint);
self.set_login_error("no connection");
self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned());
return Ok(false);
}
self.update_login_status(LoginStatus::Success, success_message);
Ok(true)
} else {
self.cleanup_failed_login_connection(endpoint);
self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned());
Ok(false)
}
}
fn cleanup_failed_login_connection(&self, endpoint: std::net::SocketAddr) {
if let Some(simulator) = self.native_find_endpoint(endpoint) {
let _ = simulator.native_disconnect(false);
self.simulators.remove(&simulator);
let mut current = write(&self.inner.current_sim);
if current.as_ref() == Some(&simulator) {
*current = None;
}
}
if self.simulators.is_empty() {
self.inner.connected.store(false, Ordering::Release);
}
}
pub fn native_subscribe_disconnected(
&self,
handler: EventHandler<DisconnectedEventArgs>,
@@ -2299,7 +3037,7 @@ impl NetworkManager {
size_y: u32,
) -> Result<Option<Simulator>, Error> {
let manager = Self {
login_response_data: None,
login_response_data: Some(self.inner.login_response.clone()),
simulators: self.simulators.clone(),
inner: Arc::clone(&self.inner),
};

View File

@@ -7,10 +7,16 @@ use libremetaverse::packets::{
RegionHandshakePacket, RegionHandshakeReplyPacket, StartPingCheckPacket, UseCircuitCodePacket,
};
use libremetaverse::{
CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, NetworkManager,
NetworkManagerDisconnectType, PacketEventDictionary, Simulator,
CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, HttpCapsClient, LoginState,
LoginStatus, NetworkManager, NetworkManagerDisconnectType, NetworkManagerLoginResponseCallback,
PacketEventDictionary, Simulator,
};
use libremetaverse_types::compat::{EventHandler, Uri};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{
CancellationTokenSource, EventHandler, HttpMessageHandler, HttpRequest, HttpResponse, Uri,
};
use libremetaverse_types::{UUID, Utils};
use std::collections::{BTreeMap, HashMap};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
@@ -204,6 +210,35 @@ fn synchronous_specific_dispatch_suppresses_default_async_chain_like_reference()
);
}
#[test]
fn abort_and_callback_unregistration_preserve_reference_noop_behavior() {
let client = GridClient::new().unwrap();
let manager = client.network();
let progress = Arc::new(Mutex::new(Vec::new()));
let observed = Arc::clone(&progress);
let _guard = manager.subscribe_login_progress(Arc::new(move |args| {
observed.lock().unwrap().push(args);
}));
let callback = NetworkManagerLoginResponseCallback::from_handler(|_, _, _, _, _| {});
manager
.register_login_response_callback_with_login_response_callback(callback.clone())
.unwrap();
manager
.unregister_login_response_callback(callback.clone())
.unwrap();
manager
.unregister_login_response_callback(callback)
.unwrap();
manager.abort_login().unwrap();
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_message(), "Abort Requested");
let progress = progress.lock().unwrap();
assert_eq!(progress.len(), 1);
assert_eq!(progress[0].status(), LoginStatus::Failed);
assert_eq!(progress[0].message(), "Abort Requested");
}
struct TestCapsMessage;
impl IMessage for TestCapsMessage {}
@@ -319,6 +354,7 @@ fn sim_connecting_can_cancel_without_leaking_collection_state() {
enum ServerCommand {
Ping,
Disable,
ExpectEconomy,
Stop,
}
@@ -327,6 +363,7 @@ enum ServerReport {
Ack(u32),
Circuit(u32),
HandshakeReply(u32),
Economy,
Ping(u8),
}
@@ -472,6 +509,13 @@ fn spawn_fake_server() -> FakeServer {
bytes[0] &= !(Helpers::MSG_RELIABLE | Helpers::MSG_ZEROCODED);
socket.send_to(&bytes, client_endpoint).unwrap();
}
ServerCommand::ExpectEconomy => loop {
let (length, _) = socket.recv_from(&mut buffer).expect("EconomyDataRequest");
if packet_type(&buffer[..length]) == Some(PacketType::EconomyDataRequest) {
report_sender.send(ServerReport::Economy).unwrap();
break;
}
},
ServerCommand::Stop => break,
}
}
@@ -620,7 +664,6 @@ fn fake_server_drives_circuit_handshake_ping_disable_and_disconnect_reasons() {
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::HandshakeReply(0x1 | 0x2 | 0x4)
);
assert_reuse_preserves_connected_override(&mut manager, &server, &sim);
assert_ping_exchange(&server, &packet_receiver);
@@ -897,3 +940,396 @@ fn handshake_timeout_preserves_reference_connect_result_and_shutdown_tears_down_
assert_eq!(sim_disconnects.load(Ordering::Relaxed), 1);
drop(guard);
}
fn login_http_response(body: OSD) -> HttpResponse {
let body = OSDParser::serialize_llsd_xml_bytes(body).expect("serialize LLSD");
HttpResponse {
status_code: 200,
headers: BTreeMap::from([("Content-Length".into(), body.len().to_string())]),
content_type: Some("application/llsd+xml".into()),
body,
}
}
fn successful_login_response(endpoint: SocketAddr, seed: &str) -> OSD {
OSD::Map(HashMap::from([
("login".into(), OSD::Boolean(true)),
("message".into(), OSD::String("Welcome".into())),
(
"agent_id".into(),
OSD::UUID(
UUID::new_with_string("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into()).unwrap(),
),
),
(
"session_id".into(),
OSD::UUID(
UUID::new_with_string("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".into()).unwrap(),
),
),
(
"secure_session_id".into(),
OSD::UUID(
UUID::new_with_string("cccccccc-cccc-cccc-cccc-cccccccccccc".into()).unwrap(),
),
),
("circuit_code".into(), OSD::Integer(0x1234)),
("region_x".into(), OSD::Integer(1000)),
("region_y".into(), OSD::Integer(2000)),
("region_size_x".into(), OSD::Integer(256)),
("region_size_y".into(), OSD::Integer(256)),
("sim_ip".into(), OSD::String(endpoint.ip().to_string())),
("sim_port".into(), OSD::Integer(i32::from(endpoint.port()))),
("seed_capability".into(), OSD::String(seed.into())),
(
"account_level_benefits".into(),
OSD::Map(HashMap::from([(
"group_membership_limit".into(),
OSD::Integer(70),
)])),
),
]))
}
#[tokio::test(flavor = "current_thread")]
async fn login_hashes_credentials_parses_response_and_hands_off_session_to_udp_and_caps() {
let server = spawn_fake_server();
let requests = Arc::new(Mutex::new(Vec::<HttpRequest>::new()));
let recorded = Arc::clone(&requests);
let endpoint = server.endpoint;
let handler = HttpMessageHandler::new(move |request, _| {
let recorded = Arc::clone(&recorded);
async move {
let response = if request.uri.0.contains("/login") {
login_http_response(successful_login_response(
endpoint,
"http://caps.test/seed?token=secret",
))
} else {
login_http_response(OSD::Map(HashMap::new()))
};
recorded.lock().unwrap().push(request);
response
}
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 2_000;
client.settings().connection_mut().mfa_enabled = true;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let callback_calls = Arc::new(AtomicUsize::new(0));
let observed_calls = Arc::clone(&callback_calls);
let callback = NetworkManagerLoginResponseCallback::from_handler(
move |success, redirect, message, reason, response| {
assert!(success);
assert!(!redirect);
assert_eq!(message, "Welcome");
assert!(reason.is_empty());
assert!(response.is_some_and(|value| value.success()));
observed_calls.fetch_add(1, Ordering::Relaxed);
},
);
manager
.register_login_response_callback_with_login_response_callback_string_array(
callback,
Some(vec!["custom-login-option".into()]),
)
.unwrap();
let progress = Arc::new(Mutex::new(Vec::new()));
let observed = Arc::clone(&progress);
let _progress_guard = manager.subscribe_login_progress(Arc::new(move |args| {
observed.lock().unwrap().push(args.status());
}));
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
params.token = "123456".into();
params.mfa_hash = "saved-mfa-hash".into();
let response = manager
.login_with_response_with_login_params_cancellation_token(params, None)
.await
.unwrap()
.expect("successful response");
assert_eq!(response.login(), LoginState::True);
assert_eq!(manager.login_status_code(), LoginStatus::Success);
assert_eq!(manager.login_message(), "Welcome");
assert_eq!(manager.max_agent_groups(), 70);
assert_eq!(callback_calls.load(Ordering::Relaxed), 1);
assert!(manager.connected());
assert!(manager.current_sim().is_some());
assert_eq!(
manager.login_seed_capability(),
Some(Uri("http://caps.test/seed?token=secret".into()))
);
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::Circuit(0x1234)
);
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::HandshakeReply(0x1 | 0x2 | 0x4)
);
server.commands.send(ServerCommand::ExpectEconomy).unwrap();
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::Economy
);
let login_request = requests
.lock()
.unwrap()
.iter()
.find(|request| request.uri.0.contains("/login"))
.cloned()
.expect("login request");
let request = OSDParser::deserialize_with_bytes(login_request.body).unwrap();
let OSD::Map(request) = request else {
panic!("login request must be an LLSD map");
};
assert_eq!(
request["passwd"].as_string().unwrap(),
Utils::md5_with_string("plain-secret".into()).unwrap()
);
assert_eq!(request["token"].as_string().unwrap(), "123456");
assert_eq!(request["mfa_hash"].as_string().unwrap(), "saved-mfa-hash");
assert_eq!(request["start"].as_string().unwrap(), "last");
assert_eq!(request["channel"].as_string().unwrap(), "MetaCrate");
assert_eq!(request["version"].as_string().unwrap(), "1.0");
assert_eq!(
request["platform"].as_string().unwrap(),
NetworkManager::get_platform().unwrap()
);
assert!(!request["platform_version"].as_string().unwrap().is_empty());
assert_eq!(
request["mac"].as_string().unwrap(),
request["id0"].as_string().unwrap()
);
let OSD::Array(options) = &request["options"] else {
panic!("login options must be an LLSD array");
};
assert!(
options
.iter()
.any(|option| option.as_string().as_deref() == Ok("custom-login-option"))
);
assert_eq!(
*progress.lock().unwrap(),
[
LoginStatus::ConnectingToLogin,
LoginStatus::ReadingResponse,
LoginStatus::ConnectingToSim,
LoginStatus::Success,
]
);
manager
.shutdown_with_disconnect_type(NetworkManagerDisconnectType::ClientInitiated)
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn redirect_reuses_hashed_credentials_and_failure_is_typed_without_partial_session() {
let requests = Arc::new(Mutex::new(Vec::<HttpRequest>::new()));
let recorded = Arc::clone(&requests);
let calls = Arc::new(AtomicUsize::new(0));
let handler_calls = Arc::clone(&calls);
let handler = HttpMessageHandler::new(move |request, _| {
let recorded = Arc::clone(&recorded);
let index = handler_calls.fetch_add(1, Ordering::Relaxed);
async move {
recorded.lock().unwrap().push(request);
if index == 0 {
login_http_response(OSD::Map(HashMap::from([
("login".into(), OSD::String("indeterminate".into())),
(
"next_url".into(),
OSD::String("http://login.test/redirected".into()),
),
("next_duration".into(), OSD::Integer(0)),
("message".into(), OSD::String("Redirecting".into())),
])))
} else {
login_http_response(OSD::Map(HashMap::from([
("login".into(), OSD::Boolean(false)),
("reason".into(), OSD::String("key".into())),
("message".into(), OSD::String("Denied".into())),
])))
}
}
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let callback_calls = Arc::new(AtomicUsize::new(0));
let observed_calls = Arc::clone(&callback_calls);
manager
.register_login_response_callback_with_login_response_callback(
NetworkManagerLoginResponseCallback::from_handler(move |_, _, _, _, _| {
observed_calls.fetch_add(1, Ordering::Relaxed);
}),
)
.unwrap();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "key");
assert_eq!(manager.login_message(), "Denied");
assert!(!manager.connected());
assert!(manager.current_sim().is_none());
assert_eq!(
callback_calls.load(Ordering::Relaxed),
0,
"the C# callback only runs for a successful login"
);
let passwords: Vec<_> = requests
.lock()
.unwrap()
.iter()
.map(|request| {
let OSD::Map(map) = OSDParser::deserialize_with_bytes(request.body.clone()).unwrap()
else {
panic!("request must be map");
};
map["passwd"].as_string().unwrap()
})
.collect();
assert_eq!(passwords.len(), 2);
assert_eq!(passwords[0], passwords[1]);
assert_ne!(passwords[0], "plain-secret");
}
#[tokio::test(flavor = "current_thread")]
async fn successful_authentication_with_invalid_simulator_clears_partial_session() {
let handler = HttpMessageHandler::new(|_, _| async move {
let mut response = successful_login_response(
"127.0.0.1:1".parse().unwrap(),
"http://caps.test/seed?token=must-not-survive",
);
let OSD::Map(ref mut fields) = response else {
unreachable!();
};
fields.insert("sim_port".into(), OSD::Integer(0));
login_http_response(response)
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert!(manager.current_sim().is_none());
assert!(manager.simulators.is_empty());
assert!(!manager.connected());
assert!(manager.login_seed_capability().is_none());
let response = manager.login_response_data.as_ref().unwrap();
assert_eq!(response.session_id(), UUID::zero());
assert_eq!(response.secure_session_id(), UUID::zero());
assert!(response.seed_capability().is_empty());
assert!(response.account_level_benefits().is_none());
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn login_timeout_is_typed_and_leaves_no_partial_session() {
let handler =
HttpMessageHandler::new(|_, _| async move { std::future::pending::<HttpResponse>().await });
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 25;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "timeout");
assert_eq!(manager.login_message(), "Login timed out");
assert!(manager.current_sim().is_none());
assert!(manager.login_seed_capability().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn canceled_login_finishes_failed_without_retaining_simulator_work() {
let handler = HttpMessageHandler::new(|_, cancellation| async move {
cancellation.cancelled().await;
login_http_response(OSD::Map(HashMap::new()))
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
let cancellation = CancellationTokenSource::new();
cancellation.cancel();
assert!(
!manager
.login_with_login_params_cancellation_token(params, Some(cancellation.token()))
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "canceled");
assert!(!manager.connected());
assert!(manager.simulators.is_empty());
}