mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-14 00:06:15 -08:00
Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com> Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2477 lines
86 KiB
Rust
2477 lines
86 KiB
Rust
use std::{
|
|
collections::HashSet,
|
|
fmt::Debug,
|
|
str::FromStr as _,
|
|
sync::{Arc, Mutex, MutexGuard},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use anyhow::Context;
|
|
use easytier::proto::{
|
|
api::{
|
|
config::{ConfigRpc, ConfigRpcClientFactory},
|
|
manage::{WebClientService, WebClientServiceClientFactory},
|
|
},
|
|
rpc::bidirect::BidirectRpcManager,
|
|
rpc_types::{self, controller::BaseController},
|
|
web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer},
|
|
};
|
|
use easytier_core::tunnel::Tunnel;
|
|
use tokio::sync::{Notify, RwLock, broadcast};
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
|
|
use super::{
|
|
HeartbeatPolicy,
|
|
storage::{Storage, StorageToken, WeakRefStorage},
|
|
};
|
|
use crate::FeatureFlags;
|
|
use crate::webhook::SharedWebhookConfig;
|
|
|
|
mod runtime_revision;
|
|
mod webhook_validation;
|
|
|
|
#[cfg(test)]
|
|
mod lifecycle_tests;
|
|
|
|
const WEBHOOK_VALIDATION_HEARTBEAT_INTERVAL: u32 = 10;
|
|
const CONNECTED_WEBHOOK_RETRY_DELAYS: [Duration; 2] =
|
|
[Duration::from_millis(100), Duration::from_millis(500)];
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct Location {
|
|
pub country: String,
|
|
pub city: Option<String>,
|
|
pub region: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SessionAuthState {
|
|
Init,
|
|
Authorized,
|
|
Invalid,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(super) struct ManagedConfigPersistedChange {
|
|
pub expected_revision: String,
|
|
pub target_revision: String,
|
|
pub dirty_instance_ids: HashSet<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(super) enum ManagedConfigReconcileHint {
|
|
Full,
|
|
Dirty {
|
|
expected_revision: String,
|
|
target_revision: String,
|
|
instance_ids: HashSet<String>,
|
|
},
|
|
}
|
|
|
|
pub(super) fn record_managed_config_reconcile_hint(
|
|
pending: &mut Option<ManagedConfigReconcileHint>,
|
|
hint: ManagedConfigReconcileHint,
|
|
) {
|
|
match hint {
|
|
ManagedConfigReconcileHint::Full => {
|
|
*pending = Some(ManagedConfigReconcileHint::Full);
|
|
}
|
|
ManagedConfigReconcileHint::Dirty {
|
|
expected_revision,
|
|
target_revision,
|
|
instance_ids,
|
|
} => match pending {
|
|
Some(ManagedConfigReconcileHint::Full) => {}
|
|
Some(ManagedConfigReconcileHint::Dirty {
|
|
target_revision: pending_target,
|
|
instance_ids: pending_ids,
|
|
..
|
|
}) => {
|
|
if *pending_target == expected_revision {
|
|
*pending_target = target_revision;
|
|
pending_ids.extend(instance_ids);
|
|
} else {
|
|
*pending = Some(ManagedConfigReconcileHint::Full);
|
|
}
|
|
}
|
|
None => {
|
|
*pending = Some(ManagedConfigReconcileHint::Dirty {
|
|
expected_revision,
|
|
target_revision,
|
|
instance_ids,
|
|
});
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(super) struct ManagedRuntimeState {
|
|
pub(super) applied_config_revision: Option<String>,
|
|
pub(super) applied_config_revision_known: bool,
|
|
pub(super) known_runtime_base_revision: Option<String>,
|
|
pub(super) pending_managed_config_reconcile: Option<ManagedConfigReconcileHint>,
|
|
pub(super) runtime_config_epoch: u64,
|
|
pub(super) runtime_config_cache_epoch: u64,
|
|
}
|
|
|
|
pub(super) type SharedManagedRuntimeState = Arc<Mutex<ManagedRuntimeState>>;
|
|
|
|
impl SessionAuthState {
|
|
fn is_authorized(self) -> bool {
|
|
matches!(self, Self::Authorized)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SessionData {
|
|
storage: WeakRefStorage,
|
|
feature_flags: Arc<FeatureFlags>,
|
|
webhook_config: SharedWebhookConfig,
|
|
client_url: url::Url,
|
|
|
|
storage_token: Option<StorageToken>,
|
|
binding_version: Option<u64>,
|
|
managed_runtime: SharedManagedRuntimeState,
|
|
direct_run_failed_instance_ids: HashSet<String>,
|
|
notifier: broadcast::Sender<HeartbeatRequest>,
|
|
req: Option<HeartbeatRequest>,
|
|
location: Option<Location>,
|
|
heartbeat_count: std::sync::atomic::AtomicU32,
|
|
session_identity: Option<HeartbeatIdentity>,
|
|
auth_state: SessionAuthState,
|
|
webhook_connected_binding_version: Option<u64>,
|
|
webhook_validation_dirty: bool,
|
|
webhook_validation_change_epoch: u64,
|
|
webhook_validation_notify: Arc<Notify>,
|
|
session_epoch: u64,
|
|
}
|
|
|
|
impl SessionData {
|
|
fn new(
|
|
storage: WeakRefStorage,
|
|
client_url: url::Url,
|
|
location: Option<Location>,
|
|
feature_flags: Arc<FeatureFlags>,
|
|
webhook_config: SharedWebhookConfig,
|
|
) -> Self {
|
|
let (tx, _rx1) = broadcast::channel(2);
|
|
|
|
SessionData {
|
|
storage,
|
|
feature_flags,
|
|
webhook_config,
|
|
client_url,
|
|
storage_token: None,
|
|
binding_version: None,
|
|
managed_runtime: Arc::new(Mutex::new(ManagedRuntimeState::default())),
|
|
direct_run_failed_instance_ids: HashSet::new(),
|
|
notifier: tx,
|
|
req: None,
|
|
location,
|
|
heartbeat_count: std::sync::atomic::AtomicU32::new(0),
|
|
session_identity: None,
|
|
auth_state: SessionAuthState::Init,
|
|
webhook_connected_binding_version: None,
|
|
webhook_validation_dirty: false,
|
|
webhook_validation_change_epoch: 0,
|
|
webhook_validation_notify: Arc::new(Notify::new()),
|
|
session_epoch: 0,
|
|
}
|
|
}
|
|
|
|
pub fn req(&self) -> Option<HeartbeatRequest> {
|
|
self.req.clone()
|
|
}
|
|
|
|
pub fn heartbeat_waiter(&self) -> broadcast::Receiver<HeartbeatRequest> {
|
|
self.notifier.subscribe()
|
|
}
|
|
|
|
pub fn location(&self) -> Option<&Location> {
|
|
self.location.as_ref()
|
|
}
|
|
|
|
fn managed_runtime(&self) -> MutexGuard<'_, ManagedRuntimeState> {
|
|
self.managed_runtime
|
|
.lock()
|
|
.expect("managed runtime state lock poisoned")
|
|
}
|
|
}
|
|
|
|
async fn send_webhook_node_disconnected(
|
|
webhook: SharedWebhookConfig,
|
|
token: StorageToken,
|
|
binding_version: u64,
|
|
) {
|
|
let machine_id = token.machine_id.to_string();
|
|
let user_id = Some(token.user_id);
|
|
let token_value = token.token.clone();
|
|
let web_instance_id = webhook.web_instance_id.clone();
|
|
webhook
|
|
.notify_node_disconnected(&crate::webhook::NodeDisconnectedRequest {
|
|
machine_id,
|
|
token: token_value,
|
|
user_id,
|
|
web_instance_id,
|
|
binding_version: Some(binding_version),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
fn notify_webhook_node_disconnected(
|
|
webhook: SharedWebhookConfig,
|
|
token: StorageToken,
|
|
binding_version: u64,
|
|
) {
|
|
tokio::spawn(async move {
|
|
send_webhook_node_disconnected(webhook, token, binding_version).await;
|
|
});
|
|
}
|
|
|
|
struct WebhookDisconnectNotification {
|
|
webhook: SharedWebhookConfig,
|
|
storage_token: StorageToken,
|
|
binding_version: u64,
|
|
}
|
|
|
|
struct WebhookConnectNotification {
|
|
webhook: SharedWebhookConfig,
|
|
storage_token: StorageToken,
|
|
binding_version: u64,
|
|
req: crate::webhook::NodeConnectedRequest,
|
|
}
|
|
|
|
fn storage_tokens_match(left: &StorageToken, right: &StorageToken) -> bool {
|
|
left.token == right.token
|
|
&& left.client_url == right.client_url
|
|
&& left.machine_id == right.machine_id
|
|
&& left.user_id == right.user_id
|
|
}
|
|
|
|
fn connection_state_matches(
|
|
data: &SessionData,
|
|
storage_token: &StorageToken,
|
|
binding_version: u64,
|
|
) -> bool {
|
|
data.auth_state.is_authorized()
|
|
&& data.binding_version == Some(binding_version)
|
|
&& data
|
|
.storage_token
|
|
.as_ref()
|
|
.is_some_and(|current| storage_tokens_match(current, storage_token))
|
|
}
|
|
|
|
fn connected_delivery_state_matches(
|
|
data: &SessionData,
|
|
storage_token: &StorageToken,
|
|
binding_version: u64,
|
|
) -> bool {
|
|
connection_state_matches(data, storage_token, binding_version)
|
|
&& data.storage.upgrade().is_some_and(|storage| {
|
|
storage.owns_authorized_session(storage_token, data.session_epoch)
|
|
})
|
|
}
|
|
|
|
async fn connected_delivery_is_current(
|
|
session_data: &std::sync::Weak<RwLock<SessionData>>,
|
|
storage_token: &StorageToken,
|
|
binding_version: u64,
|
|
) -> bool {
|
|
let Some(session_data) = session_data.upgrade() else {
|
|
return false;
|
|
};
|
|
let data = session_data.read().await;
|
|
connected_delivery_state_matches(&data, storage_token, binding_version)
|
|
}
|
|
|
|
enum ConnectedBindingRecord {
|
|
Recorded,
|
|
/// The session identity moved on; the delivered connected webhook should
|
|
/// be compensated with a disconnect.
|
|
IdentityStale,
|
|
/// A newer session already owns the machine route; its bindings must be
|
|
/// left untouched so a stale disconnect cannot revoke them.
|
|
OwnershipLost,
|
|
}
|
|
|
|
async fn record_webhook_connected_binding_if_current(
|
|
session_data: &std::sync::Weak<RwLock<SessionData>>,
|
|
storage_token: &StorageToken,
|
|
binding_version: u64,
|
|
) -> Option<ConnectedBindingRecord> {
|
|
let session_data = session_data.upgrade()?;
|
|
let mut data = session_data.write().await;
|
|
if !connection_state_matches(&data, storage_token, binding_version) {
|
|
return Some(ConnectedBindingRecord::IdentityStale);
|
|
}
|
|
if !data
|
|
.storage
|
|
.upgrade()
|
|
.is_some_and(|storage| storage.owns_authorized_session(storage_token, data.session_epoch))
|
|
{
|
|
return Some(ConnectedBindingRecord::OwnershipLost);
|
|
}
|
|
data.webhook_connected_binding_version = Some(binding_version);
|
|
Some(ConnectedBindingRecord::Recorded)
|
|
}
|
|
|
|
async fn send_webhook_connection_transition(
|
|
session_data: std::sync::Weak<RwLock<SessionData>>,
|
|
disconnect: Option<WebhookDisconnectNotification>,
|
|
connect: Option<WebhookConnectNotification>,
|
|
) {
|
|
if let Some(disconnect) = disconnect {
|
|
send_webhook_node_disconnected(
|
|
disconnect.webhook,
|
|
disconnect.storage_token,
|
|
disconnect.binding_version,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let Some(connect) = connect else {
|
|
return;
|
|
};
|
|
let delivery_started_at = Instant::now();
|
|
let mut attempt = 1;
|
|
loop {
|
|
if !connected_delivery_is_current(
|
|
&session_data,
|
|
&connect.storage_token,
|
|
connect.binding_version,
|
|
)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
match connect.webhook.notify_node_connected(&connect.req).await {
|
|
Ok(()) => {
|
|
let elapsed = delivery_started_at.elapsed();
|
|
if attempt > 1 || elapsed >= Duration::from_secs(2) {
|
|
tracing::info!(
|
|
machine_id = %connect.storage_token.machine_id,
|
|
binding_version = connect.binding_version,
|
|
attempt,
|
|
elapsed_ms = elapsed.as_millis(),
|
|
"node-connected webhook delivery completed"
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
Err(error) => {
|
|
let retry_delay = if error.is_retryable() {
|
|
CONNECTED_WEBHOOK_RETRY_DELAYS.get(attempt - 1).copied()
|
|
} else {
|
|
None
|
|
};
|
|
tracing::warn!(
|
|
machine_id = %connect.storage_token.machine_id,
|
|
binding_version = connect.binding_version,
|
|
attempt,
|
|
elapsed_ms = delivery_started_at.elapsed().as_millis(),
|
|
will_retry = retry_delay.is_some(),
|
|
%error,
|
|
"node-connected webhook delivery failed"
|
|
);
|
|
let Some(retry_delay) = retry_delay else {
|
|
return;
|
|
};
|
|
if !connected_delivery_is_current(
|
|
&session_data,
|
|
&connect.storage_token,
|
|
connect.binding_version,
|
|
)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
tokio::time::sleep(retry_delay).await;
|
|
attempt += 1;
|
|
}
|
|
}
|
|
}
|
|
if !connected_delivery_is_current(
|
|
&session_data,
|
|
&connect.storage_token,
|
|
connect.binding_version,
|
|
)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
match record_webhook_connected_binding_if_current(
|
|
&session_data,
|
|
&connect.storage_token,
|
|
connect.binding_version,
|
|
)
|
|
.await
|
|
{
|
|
Some(ConnectedBindingRecord::Recorded) => {}
|
|
Some(ConnectedBindingRecord::OwnershipLost) => {
|
|
tracing::debug!(
|
|
machine_id = %connect.storage_token.machine_id,
|
|
binding_version = connect.binding_version,
|
|
"skip disconnect compensation because a newer session owns the route"
|
|
);
|
|
}
|
|
Some(ConnectedBindingRecord::IdentityStale) | None => {
|
|
send_webhook_node_disconnected(
|
|
connect.webhook,
|
|
connect.storage_token,
|
|
connect.binding_version,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for SessionData {
|
|
fn drop(&mut self) {
|
|
if let Ok(storage) = Storage::try_from(self.storage.clone())
|
|
&& let Some(token) = self.storage_token.as_ref()
|
|
{
|
|
let removed_current_session = storage.remove_session_client(token, self.session_epoch);
|
|
|
|
if removed_current_session {
|
|
tracing::info!(
|
|
machine_id = %token.machine_id,
|
|
user_id = token.user_id,
|
|
session_epoch = self.session_epoch,
|
|
"session disconnected"
|
|
);
|
|
}
|
|
|
|
// Notify the webhook receiver when a node disconnects.
|
|
if removed_current_session
|
|
&& self.webhook_config.is_enabled()
|
|
&& let Some(binding_version) = self.webhook_connected_binding_version
|
|
{
|
|
notify_webhook_node_disconnected(
|
|
self.webhook_config.clone(),
|
|
token.clone(),
|
|
binding_version,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type SharedSessionData = Arc<RwLock<SessionData>>;
|
|
|
|
#[derive(Clone)]
|
|
pub(super) struct SessionRpcService {
|
|
data: SharedSessionData,
|
|
heartbeat_policy: HeartbeatPolicy,
|
|
}
|
|
|
|
impl SessionRpcService {
|
|
fn heartbeat_response(&self) -> HeartbeatResponse {
|
|
self.heartbeat_policy.response()
|
|
}
|
|
}
|
|
|
|
fn heartbeat_response_delay(elapsed: Duration, min_response_delay: Duration) -> Option<Duration> {
|
|
min_response_delay
|
|
.checked_sub(elapsed)
|
|
.filter(|delay| !delay.is_zero())
|
|
}
|
|
|
|
fn should_delay_heartbeat_response(
|
|
supports_heartbeat_policy: bool,
|
|
is_paced_session: bool,
|
|
is_first_heartbeat: bool,
|
|
) -> bool {
|
|
!supports_heartbeat_policy && is_paced_session && !is_first_heartbeat
|
|
}
|
|
|
|
fn should_delay_session_heartbeat_response(
|
|
data: &SessionData,
|
|
supports_heartbeat_policy: bool,
|
|
) -> bool {
|
|
should_delay_heartbeat_response(
|
|
supports_heartbeat_policy,
|
|
data.webhook_config.is_enabled() || data.auth_state.is_authorized(),
|
|
data.req.is_none(),
|
|
)
|
|
}
|
|
|
|
fn should_notify_webhook_validation(heartbeat_count: u32) -> bool {
|
|
heartbeat_count % WEBHOOK_VALIDATION_HEARTBEAT_INTERVAL == 1
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct HeartbeatIdentity {
|
|
token: String,
|
|
machine_id: uuid::Uuid,
|
|
runtime_id: Option<uuid::Uuid>,
|
|
}
|
|
|
|
impl HeartbeatIdentity {
|
|
fn new(token: String, machine_id: uuid::Uuid, runtime_id: Option<uuid::Uuid>) -> Self {
|
|
Self {
|
|
token,
|
|
machine_id,
|
|
runtime_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SessionRpcService {
|
|
fn heartbeat_report_timestamp(req: &HeartbeatRequest) -> i64 {
|
|
match chrono::DateTime::<chrono::Local>::from_str(&req.report_time) {
|
|
Ok(report_time) => report_time.timestamp(),
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
report_time = %req.report_time,
|
|
%error,
|
|
"invalid heartbeat report time, using server time"
|
|
);
|
|
chrono::Local::now().timestamp()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn store_latest_heartbeat_req(
|
|
data: &mut SessionData,
|
|
req: HeartbeatRequest,
|
|
) -> HeartbeatRequest {
|
|
data.req = Some(req);
|
|
data.req
|
|
.clone()
|
|
.expect("heartbeat request should be initialized")
|
|
}
|
|
|
|
fn storage_token_matches_heartbeat(
|
|
storage_token: &StorageToken,
|
|
req: &HeartbeatRequest,
|
|
) -> bool {
|
|
req.user_token == storage_token.token
|
|
&& req.machine_id.map(uuid::Uuid::from) == Some(storage_token.machine_id)
|
|
}
|
|
|
|
async fn runtime_heartbeat_is_current(
|
|
session_data: &std::sync::Weak<RwLock<SessionData>>,
|
|
req: &HeartbeatRequest,
|
|
) -> bool {
|
|
let Some(session_data) = session_data.upgrade() else {
|
|
return false;
|
|
};
|
|
let data = session_data.read().await;
|
|
Self::runtime_heartbeat_is_current_locked(&data, req)
|
|
}
|
|
|
|
fn runtime_heartbeat_is_current_locked(data: &SessionData, req: &HeartbeatRequest) -> bool {
|
|
data.storage_token.as_ref().is_some_and(|storage_token| {
|
|
Self::storage_token_matches_heartbeat(storage_token, req)
|
|
&& data.req.as_ref().is_some_and(|current_req| {
|
|
Self::storage_token_matches_heartbeat(storage_token, current_req)
|
|
})
|
|
&& data.auth_state.is_authorized()
|
|
&& data.storage.upgrade().is_some_and(|storage| {
|
|
storage.owns_authorized_session(storage_token, data.session_epoch)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn heartbeat_matches_identity(
|
|
req: &HeartbeatRequest,
|
|
token: &str,
|
|
machine_id: uuid::Uuid,
|
|
) -> bool {
|
|
req.user_token == token && req.machine_id.map(uuid::Uuid::from) == Some(machine_id)
|
|
}
|
|
|
|
fn heartbeat_identity(req: &HeartbeatRequest, machine_id: uuid::Uuid) -> HeartbeatIdentity {
|
|
HeartbeatIdentity::new(
|
|
req.user_token.clone(),
|
|
machine_id,
|
|
Self::heartbeat_runtime_id(req),
|
|
)
|
|
}
|
|
|
|
fn heartbeat_runtime_id(req: &HeartbeatRequest) -> Option<uuid::Uuid> {
|
|
req.inst_id.map(uuid::Uuid::from).filter(|id| !id.is_nil())
|
|
}
|
|
|
|
fn ensure_session_identity_locked(
|
|
data: &mut SessionData,
|
|
req: &HeartbeatRequest,
|
|
machine_id: uuid::Uuid,
|
|
) -> anyhow::Result<()> {
|
|
let identity = Self::heartbeat_identity(req, machine_id);
|
|
match data.session_identity.as_ref() {
|
|
Some(existing) if existing != &identity => {
|
|
anyhow::bail!(
|
|
"Heartbeat identity does not match session token, machine_id: {:?}",
|
|
machine_id
|
|
);
|
|
}
|
|
Some(_) => {}
|
|
None => data.session_identity = Some(identity),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn mark_webhook_validation_dirty_locked(data: &mut SessionData) -> Arc<Notify> {
|
|
data.webhook_validation_dirty = true;
|
|
data.webhook_validation_notify.clone()
|
|
}
|
|
|
|
fn mark_webhook_validation_state_changed_locked(data: &mut SessionData) -> Arc<Notify> {
|
|
data.webhook_validation_change_epoch = data.webhook_validation_change_epoch.wrapping_add(1);
|
|
Self::mark_webhook_validation_dirty_locked(data)
|
|
}
|
|
|
|
fn failed_instance_ids(
|
|
req: Option<&HeartbeatRequest>,
|
|
direct_run_failed_instance_ids: &HashSet<String>,
|
|
) -> HashSet<String> {
|
|
let mut instance_ids = req
|
|
.into_iter()
|
|
.flat_map(|req| &req.failed_network_instances)
|
|
.map(ToString::to_string)
|
|
.collect::<HashSet<_>>();
|
|
instance_ids.extend(direct_run_failed_instance_ids.iter().cloned());
|
|
instance_ids
|
|
}
|
|
|
|
fn failed_instance_ids_locked(data: &SessionData) -> HashSet<String> {
|
|
Self::failed_instance_ids(data.req.as_ref(), &data.direct_run_failed_instance_ids)
|
|
}
|
|
|
|
fn sorted_failed_instance_ids_locked(data: &SessionData) -> Vec<String> {
|
|
let mut instance_ids = Self::failed_instance_ids_locked(data)
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
instance_ids.sort_unstable();
|
|
instance_ids
|
|
}
|
|
|
|
fn update_heartbeat_failed_instance_ids_locked(
|
|
data: &mut SessionData,
|
|
req: &HeartbeatRequest,
|
|
) -> Option<Arc<Notify>> {
|
|
let previous_instance_ids = Self::failed_instance_ids_locked(data);
|
|
let next_instance_ids =
|
|
Self::failed_instance_ids(Some(req), &data.direct_run_failed_instance_ids);
|
|
if next_instance_ids == previous_instance_ids {
|
|
return None;
|
|
}
|
|
tracing::info!(
|
|
machine_id = ?req.machine_id,
|
|
failed_instance_ids = ?next_instance_ids,
|
|
"heartbeat failed instance set changed"
|
|
);
|
|
Some(Self::mark_webhook_validation_state_changed_locked(data))
|
|
}
|
|
|
|
fn update_direct_run_failures_locked(
|
|
data: &mut SessionData,
|
|
update: impl FnOnce(&mut HashSet<String>),
|
|
) -> Option<Arc<Notify>> {
|
|
let previous_failed_instance_ids = Self::failed_instance_ids_locked(data);
|
|
update(&mut data.direct_run_failed_instance_ids);
|
|
let failed_instance_ids = Self::failed_instance_ids_locked(data);
|
|
(failed_instance_ids != previous_failed_instance_ids)
|
|
.then(|| Self::mark_webhook_validation_state_changed_locked(data))
|
|
}
|
|
|
|
fn update_direct_run_failure_locked(
|
|
data: &mut SessionData,
|
|
instance_id: &str,
|
|
failed: bool,
|
|
) -> Option<Arc<Notify>> {
|
|
Self::update_direct_run_failures_locked(data, |direct_run_instance_ids| {
|
|
if failed {
|
|
direct_run_instance_ids.insert(instance_id.to_owned());
|
|
} else {
|
|
direct_run_instance_ids.remove(instance_id);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn retain_direct_run_failures_locked(
|
|
data: &mut SessionData,
|
|
desired_instance_ids: &HashSet<String>,
|
|
) -> Option<Arc<Notify>> {
|
|
Self::update_direct_run_failures_locked(data, |direct_run_instance_ids| {
|
|
direct_run_instance_ids
|
|
.retain(|instance_id| desired_instance_ids.contains(instance_id));
|
|
})
|
|
}
|
|
|
|
fn remove_direct_run_failures_locked(
|
|
data: &mut SessionData,
|
|
instance_ids: &HashSet<String>,
|
|
) -> Option<Arc<Notify>> {
|
|
Self::update_direct_run_failures_locked(data, |direct_run_instance_ids| {
|
|
direct_run_instance_ids.retain(|instance_id| !instance_ids.contains(instance_id));
|
|
})
|
|
}
|
|
|
|
async fn handle_webhook_heartbeat(
|
|
&self,
|
|
storage: &Storage,
|
|
req: HeartbeatRequest,
|
|
machine_id: uuid::Uuid,
|
|
) -> rpc_types::error::Result<HeartbeatResponse> {
|
|
let (notify, runtime_notify) = {
|
|
let mut data = self.data.write().await;
|
|
Self::ensure_session_identity_locked(&mut data, &req, machine_id)
|
|
.map_err(rpc_types::error::Error::from)?;
|
|
if matches!(data.auth_state, SessionAuthState::Invalid) {
|
|
tracing::info!(
|
|
%machine_id,
|
|
"webhook session is invalid; failing heartbeat to require client reconnect"
|
|
);
|
|
return Err(anyhow::anyhow!("webhook session is invalid").into());
|
|
}
|
|
let failure_notify = Self::update_heartbeat_failed_instance_ids_locked(&mut data, &req);
|
|
let runtime_req = Self::store_latest_heartbeat_req(&mut data, req);
|
|
let heartbeat_count = data
|
|
.heartbeat_count
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
+ 1;
|
|
let notify = failure_notify.or_else(|| {
|
|
should_notify_webhook_validation(heartbeat_count)
|
|
.then(|| Self::mark_webhook_validation_dirty_locked(&mut data))
|
|
});
|
|
let authorized = data.auth_state.is_authorized();
|
|
if let Some(storage_token) = data.storage_token.clone() {
|
|
let report_time = Self::heartbeat_report_timestamp(&runtime_req);
|
|
storage.update_session_client(
|
|
storage_token,
|
|
report_time,
|
|
authorized,
|
|
data.session_epoch,
|
|
);
|
|
}
|
|
let runtime_notify = (authorized && data.storage_token.is_some())
|
|
.then(|| (data.notifier.clone(), runtime_req));
|
|
(notify, runtime_notify)
|
|
};
|
|
|
|
if let Some((notifier, runtime_req)) = runtime_notify {
|
|
let _ = notifier.send(runtime_req);
|
|
}
|
|
if let Some(notify) = notify {
|
|
notify.notify_one();
|
|
}
|
|
Ok(self.heartbeat_response())
|
|
}
|
|
|
|
async fn handle_heartbeat(
|
|
&self,
|
|
req: HeartbeatRequest,
|
|
) -> rpc_types::error::Result<HeartbeatResponse> {
|
|
let (storage, feature_flags, webhook_config) = {
|
|
let data = self.data.read().await;
|
|
let Ok(storage) = Storage::try_from(data.storage.clone()) else {
|
|
tracing::error!("Failed to get storage");
|
|
return Ok(self.heartbeat_response());
|
|
};
|
|
(
|
|
storage,
|
|
data.feature_flags.clone(),
|
|
data.webhook_config.clone(),
|
|
)
|
|
};
|
|
|
|
let machine_id: uuid::Uuid = req.machine_id.map(Into::into).ok_or(anyhow::anyhow!(
|
|
"Machine id is not set correctly, expect uuid but got: {:?}",
|
|
req.machine_id
|
|
))?;
|
|
|
|
if webhook_config.is_enabled() {
|
|
return self
|
|
.handle_webhook_heartbeat(&storage, req, machine_id)
|
|
.await;
|
|
}
|
|
|
|
{
|
|
let mut data = self.data.write().await;
|
|
Self::ensure_session_identity_locked(&mut data, &req, machine_id)
|
|
.map_err(rpc_types::error::Error::from)?;
|
|
}
|
|
|
|
let user_id = match storage
|
|
.db()
|
|
.get_user_id_by_token(req.user_token.clone())
|
|
.await
|
|
.with_context(|| "Failed to get user id by token from db".to_string())?
|
|
{
|
|
Some(id) => id,
|
|
None if feature_flags.allow_auto_create_user => storage
|
|
.auto_create_user(&req.user_token)
|
|
.await
|
|
.with_context(|| "Failed to auto-create user".to_string())?,
|
|
None => {
|
|
return Err(anyhow::anyhow!("User not found by token").into());
|
|
}
|
|
};
|
|
|
|
let (storage_token, notifier, runtime_req, session_epoch, validation_notify) = {
|
|
let mut data = self.data.write().await;
|
|
let is_new_storage_token = data.storage_token.is_none();
|
|
let validation_notify =
|
|
Self::update_heartbeat_failed_instance_ids_locked(&mut data, &req);
|
|
let runtime_req = Self::store_latest_heartbeat_req(&mut data, req.clone());
|
|
data.heartbeat_count
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
if is_new_storage_token {
|
|
assert!(data.storage_token.is_none());
|
|
data.managed_runtime = storage.bind_managed_runtime_state(
|
|
user_id,
|
|
machine_id,
|
|
Self::heartbeat_runtime_id(&runtime_req),
|
|
data.session_epoch,
|
|
);
|
|
data.storage_token = Some(StorageToken {
|
|
token: runtime_req.user_token.clone(),
|
|
client_url: data.client_url.clone(),
|
|
machine_id,
|
|
user_id,
|
|
});
|
|
tracing::info!(
|
|
%machine_id,
|
|
user_id,
|
|
session_epoch = data.session_epoch,
|
|
client_url = %data.client_url,
|
|
"session identity established"
|
|
);
|
|
}
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
|
|
let Some(storage_token) = data.storage_token.as_ref().cloned() else {
|
|
tracing::error!("Heartbeat succeeded before session token was initialized");
|
|
return Ok(self.heartbeat_response());
|
|
};
|
|
(
|
|
storage_token,
|
|
data.notifier.clone(),
|
|
runtime_req,
|
|
data.session_epoch,
|
|
validation_notify,
|
|
)
|
|
};
|
|
|
|
let report_time = Self::heartbeat_report_timestamp(&runtime_req);
|
|
storage.update_session_client(storage_token, report_time, true, session_epoch);
|
|
let _ = notifier.send(runtime_req);
|
|
if let Some(notify) = validation_notify {
|
|
notify.notify_one();
|
|
}
|
|
Ok(self.heartbeat_response())
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl WebServerService for SessionRpcService {
|
|
type Controller = BaseController;
|
|
|
|
async fn heartbeat(
|
|
&self,
|
|
_: BaseController,
|
|
req: HeartbeatRequest,
|
|
) -> rpc_types::error::Result<HeartbeatResponse> {
|
|
let started_at = Instant::now();
|
|
let support_heartbeat_policy = req.support_heartbeat_policy;
|
|
let should_delay_response = {
|
|
let data = self.data.read().await;
|
|
should_delay_session_heartbeat_response(&data, support_heartbeat_policy)
|
|
};
|
|
let ret = self.handle_heartbeat(req).await;
|
|
if ret.is_err() {
|
|
tracing::warn!("Failed to handle heartbeat: {:?}", ret);
|
|
// sleep for a while to avoid client busy loop
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
} else if should_delay_response
|
|
&& let Some(delay) = heartbeat_response_delay(
|
|
started_at.elapsed(),
|
|
self.heartbeat_policy.legacy_response_delay(),
|
|
)
|
|
{
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
ret
|
|
}
|
|
|
|
async fn get_feature(
|
|
&self,
|
|
_: BaseController,
|
|
_: easytier::proto::web::GetFeatureRequest,
|
|
) -> rpc_types::error::Result<easytier::proto::web::GetFeatureResponse> {
|
|
Ok(easytier::proto::web::GetFeatureResponse {
|
|
support_encryption: easytier_core::tunnel::web_security::web_secure_tunnel_supported(),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct Session {
|
|
rpc_mgr: BidirectRpcManager,
|
|
|
|
data: SharedSessionData,
|
|
|
|
webhook_validation_task: Option<AbortOnDropHandle<()>>,
|
|
config_reconcile_task: Option<AbortOnDropHandle<()>>,
|
|
route_ready: Arc<Notify>,
|
|
}
|
|
|
|
impl Debug for Session {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Session").field("data", &self.data).finish()
|
|
}
|
|
}
|
|
|
|
pub(super) type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
|
|
pub(super) type SessionConfigClient = Box<dyn ConfigRpc<Controller = BaseController> + Send>;
|
|
|
|
impl Session {
|
|
pub fn new(
|
|
storage: WeakRefStorage,
|
|
client_url: url::Url,
|
|
location: Option<Location>,
|
|
heartbeat_policy: HeartbeatPolicy,
|
|
feature_flags: Arc<FeatureFlags>,
|
|
webhook_config: SharedWebhookConfig,
|
|
session_epoch: u64,
|
|
) -> Self {
|
|
let mut session_data =
|
|
SessionData::new(storage, client_url, location, feature_flags, webhook_config);
|
|
session_data.session_epoch = session_epoch;
|
|
let data = Arc::new(RwLock::new(session_data));
|
|
|
|
let rpc_mgr =
|
|
BidirectRpcManager::new().set_rx_timeout(Some(heartbeat_policy.session_rx_timeout()));
|
|
|
|
rpc_mgr.rpc_server().registry().register(
|
|
WebServerServiceServer::new(SessionRpcService {
|
|
data: data.clone(),
|
|
heartbeat_policy,
|
|
}),
|
|
"",
|
|
);
|
|
|
|
Session {
|
|
rpc_mgr,
|
|
data,
|
|
webhook_validation_task: None,
|
|
config_reconcile_task: None,
|
|
route_ready: Arc::new(Notify::new()),
|
|
}
|
|
}
|
|
|
|
pub async fn serve(&mut self, tunnel: Box<dyn Tunnel>) {
|
|
self.rpc_mgr.run_with_tunnel(tunnel);
|
|
|
|
let data = self.data.read().await;
|
|
if data.webhook_config.is_enabled() {
|
|
let route_ready = self.route_ready.clone();
|
|
let session_data = Arc::downgrade(&self.data);
|
|
self.webhook_validation_task
|
|
.replace(AbortOnDropHandle::new(tokio::spawn(async move {
|
|
route_ready.notified().await;
|
|
webhook_validation::run_worker(session_data).await;
|
|
})));
|
|
}
|
|
self.config_reconcile_task
|
|
.replace(AbortOnDropHandle::new(tokio::spawn(
|
|
runtime_revision::reconcile_network_configs_on_heartbeat(
|
|
Arc::downgrade(&self.data),
|
|
data.heartbeat_waiter(),
|
|
data.storage.clone(),
|
|
self.scoped_rpc_client(),
|
|
self.scoped_config_client(),
|
|
),
|
|
)));
|
|
}
|
|
|
|
pub fn mark_route_ready(&self) {
|
|
self.route_ready.notify_one();
|
|
}
|
|
|
|
pub fn is_running(&self) -> bool {
|
|
self.rpc_mgr.is_running()
|
|
}
|
|
|
|
pub(super) async fn is_superseded(&self) -> bool {
|
|
let data = self.data.read().await;
|
|
data.storage_token.as_ref().is_some_and(|token| {
|
|
data.storage
|
|
.upgrade()
|
|
.is_some_and(|storage| storage.is_session_superseded(token, data.session_epoch))
|
|
})
|
|
}
|
|
|
|
pub async fn stop(&self) {
|
|
if let Some(task) = &self.webhook_validation_task {
|
|
task.abort();
|
|
}
|
|
if let Some(task) = &self.config_reconcile_task {
|
|
task.abort();
|
|
}
|
|
self.rpc_mgr.stop().await;
|
|
}
|
|
|
|
pub fn data(&self) -> SharedSessionData {
|
|
self.data.clone()
|
|
}
|
|
|
|
pub fn scoped_client<F: rpc_types::__rt::RpcClientFactory>(&self) -> F::ClientImpl {
|
|
self.rpc_mgr
|
|
.rpc_client()
|
|
.scoped_client::<F>(1, 1, "".to_string())
|
|
}
|
|
|
|
pub fn scoped_client_with_domain<F: rpc_types::__rt::RpcClientFactory>(
|
|
&self,
|
|
domain_name: String,
|
|
) -> F::ClientImpl {
|
|
self.rpc_mgr
|
|
.rpc_client()
|
|
.scoped_client::<F>(1, 1, domain_name)
|
|
}
|
|
|
|
pub fn scoped_rpc_client(&self) -> SessionRpcClient {
|
|
self.scoped_client::<WebClientServiceClientFactory<BaseController>>()
|
|
}
|
|
|
|
pub fn scoped_config_client(&self) -> SessionConfigClient {
|
|
self.scoped_client::<ConfigRpcClientFactory<BaseController>>()
|
|
}
|
|
|
|
pub(super) async fn notify_managed_runtime_state_changed(
|
|
&self,
|
|
user_id: i32,
|
|
machine_id: uuid::Uuid,
|
|
) {
|
|
let notify = {
|
|
let data = self.data.read().await;
|
|
if !data.auth_state.is_authorized() {
|
|
return;
|
|
}
|
|
if !data
|
|
.storage_token
|
|
.as_ref()
|
|
.is_some_and(|token| token.user_id == user_id && token.machine_id == machine_id)
|
|
{
|
|
return;
|
|
}
|
|
data.req.clone().map(|req| (data.notifier.clone(), req))
|
|
};
|
|
if let Some((notifier, req)) = notify {
|
|
let _ = notifier.send(req);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn invalidate_runtime_config_for_direct_mutation(&self) {
|
|
let notify = {
|
|
let data = self.data.write().await;
|
|
if data.storage_token.is_none() {
|
|
return;
|
|
}
|
|
let mut runtime = data.managed_runtime();
|
|
runtime.applied_config_revision = None;
|
|
runtime.applied_config_revision_known = true;
|
|
runtime.known_runtime_base_revision = None;
|
|
runtime.pending_managed_config_reconcile = Some(ManagedConfigReconcileHint::Full);
|
|
runtime.runtime_config_epoch = runtime.runtime_config_epoch.wrapping_add(1);
|
|
runtime.runtime_config_cache_epoch = runtime.runtime_config_cache_epoch.wrapping_add(1);
|
|
drop(runtime);
|
|
data.req.clone().map(|req| (data.notifier.clone(), req))
|
|
};
|
|
if let Some((notifier, req)) = notify {
|
|
let _ = notifier.send(req);
|
|
}
|
|
}
|
|
|
|
pub async fn get_token(&self) -> Option<StorageToken> {
|
|
self.data.read().await.storage_token.clone()
|
|
}
|
|
|
|
pub async fn get_heartbeat_req(&self) -> Option<HeartbeatRequest> {
|
|
self.data.read().await.req()
|
|
}
|
|
|
|
pub(crate) async fn applied_config_revision(&self) -> Option<String> {
|
|
let data = self.data.read().await;
|
|
data.managed_runtime().applied_config_revision.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
|
use serde_json::json;
|
|
use tokio::sync::{Mutex, Notify, oneshot};
|
|
|
|
use super::{super::storage::Storage, *};
|
|
|
|
#[test]
|
|
fn heartbeat_response_delay_only_fills_remaining_time() {
|
|
assert_eq!(
|
|
heartbeat_response_delay(Duration::from_millis(100), Duration::from_millis(3500)),
|
|
Some(Duration::from_millis(3400))
|
|
);
|
|
assert_eq!(
|
|
heartbeat_response_delay(Duration::from_millis(3500), Duration::from_millis(3500)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
heartbeat_response_delay(Duration::from_millis(3600), Duration::from_millis(3500)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn heartbeat_response_delay_skips_unpaced_and_first_heartbeat() {
|
|
assert!(!HeartbeatRequest::default().support_heartbeat_policy);
|
|
assert!(!should_delay_heartbeat_response(false, false, true));
|
|
assert!(!should_delay_heartbeat_response(false, false, false));
|
|
assert!(!should_delay_heartbeat_response(false, true, true));
|
|
assert!(should_delay_heartbeat_response(false, true, false));
|
|
assert!(!should_delay_heartbeat_response(true, true, false));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_heartbeat_response_pacing_does_not_require_authorized() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
|
|
assert!(!should_delay_session_heartbeat_response(&data, false));
|
|
|
|
data.req = Some(heartbeat_request("token", machine_id));
|
|
assert!(should_delay_session_heartbeat_response(&data, false));
|
|
assert!(!should_delay_session_heartbeat_response(&data, true));
|
|
|
|
data.auth_state = SessionAuthState::Invalid;
|
|
assert!(should_delay_session_heartbeat_response(&data, false));
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_validation_retry_delay_is_bounded() {
|
|
let retry_delay = webhook_validation::retry_delay(uuid::Uuid::new_v4());
|
|
assert!(retry_delay >= Duration::from_millis(webhook_validation::VALIDATION_RETRY_MS));
|
|
assert!(
|
|
retry_delay
|
|
<= Duration::from_millis(
|
|
webhook_validation::VALIDATION_RETRY_MS
|
|
+ webhook_validation::VALIDATION_RETRY_MS
|
|
)
|
|
);
|
|
}
|
|
|
|
fn heartbeat_request(token: &str, machine_id: uuid::Uuid) -> HeartbeatRequest {
|
|
HeartbeatRequest {
|
|
machine_id: Some(machine_id.into()),
|
|
user_token: token.to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn failure_state_test_data() -> SessionData {
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_instance_ids_merge_core_and_web_local_failures() {
|
|
let mut data = failure_state_test_data().await;
|
|
let core_failed = uuid::Uuid::new_v4();
|
|
let local_failed = uuid::Uuid::new_v4().to_string();
|
|
let core_failed_req = HeartbeatRequest {
|
|
failed_network_instances: vec![core_failed.into()],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
SessionRpcService::update_heartbeat_failed_instance_ids_locked(
|
|
&mut data,
|
|
&core_failed_req,
|
|
)
|
|
.is_some()
|
|
);
|
|
SessionRpcService::store_latest_heartbeat_req(&mut data, core_failed_req);
|
|
assert_eq!(
|
|
SessionRpcService::failed_instance_ids_locked(&data),
|
|
HashSet::from([core_failed.to_string()])
|
|
);
|
|
|
|
assert!(
|
|
SessionRpcService::update_direct_run_failure_locked(&mut data, &local_failed, true)
|
|
.is_some()
|
|
);
|
|
assert_eq!(
|
|
SessionRpcService::failed_instance_ids_locked(&data),
|
|
HashSet::from([core_failed.to_string(), local_failed.clone()])
|
|
);
|
|
|
|
let recovered_req = HeartbeatRequest::default();
|
|
SessionRpcService::update_heartbeat_failed_instance_ids_locked(&mut data, &recovered_req);
|
|
SessionRpcService::store_latest_heartbeat_req(&mut data, recovered_req);
|
|
assert_eq!(
|
|
SessionRpcService::failed_instance_ids_locked(&data),
|
|
HashSet::from([local_failed])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stable_failed_instance_ids_do_not_repeat_validation_work() {
|
|
let mut data = failure_state_test_data().await;
|
|
let failed = uuid::Uuid::new_v4();
|
|
let failed_req = HeartbeatRequest {
|
|
failed_network_instances: vec![failed.into()],
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
SessionRpcService::update_heartbeat_failed_instance_ids_locked(&mut data, &failed_req)
|
|
.is_some()
|
|
);
|
|
SessionRpcService::store_latest_heartbeat_req(&mut data, failed_req);
|
|
data.webhook_validation_dirty = false;
|
|
let change_epoch = data.webhook_validation_change_epoch;
|
|
|
|
assert!(
|
|
SessionRpcService::update_direct_run_failure_locked(
|
|
&mut data,
|
|
&failed.to_string(),
|
|
true,
|
|
)
|
|
.is_none()
|
|
);
|
|
let recovered_req = HeartbeatRequest::default();
|
|
assert!(
|
|
SessionRpcService::update_heartbeat_failed_instance_ids_locked(
|
|
&mut data,
|
|
&recovered_req,
|
|
)
|
|
.is_none()
|
|
);
|
|
SessionRpcService::store_latest_heartbeat_req(&mut data, recovered_req);
|
|
assert!(!data.webhook_validation_dirty);
|
|
assert_eq!(data.webhook_validation_change_epoch, change_epoch);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_run_failure_is_added_and_direct_success_clears_it() {
|
|
let mut data = failure_state_test_data().await;
|
|
let instance_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
assert!(
|
|
SessionRpcService::update_direct_run_failure_locked(&mut data, &instance_id, true)
|
|
.is_some()
|
|
);
|
|
assert_eq!(
|
|
SessionRpcService::failed_instance_ids_locked(&data),
|
|
HashSet::from([instance_id.clone()])
|
|
);
|
|
|
|
assert!(
|
|
SessionRpcService::update_direct_run_failure_locked(&mut data, &instance_id, false)
|
|
.is_some()
|
|
);
|
|
assert!(SessionRpcService::failed_instance_ids_locked(&data).is_empty());
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ValidateWebhookTestState {
|
|
received: Arc<Mutex<Option<oneshot::Sender<()>>>>,
|
|
release: Arc<Notify>,
|
|
connected_received: Arc<Mutex<Option<oneshot::Sender<()>>>>,
|
|
connected_release: Option<Arc<Notify>>,
|
|
}
|
|
|
|
async fn valid_validate_token_handler(
|
|
State(state): State<ValidateWebhookTestState>,
|
|
) -> Json<serde_json::Value> {
|
|
if let Some(sender) = state.received.lock().await.take() {
|
|
let _ = sender.send(());
|
|
}
|
|
state.release.notified().await;
|
|
|
|
Json(json!({
|
|
"valid": true,
|
|
"binding_version": 1,
|
|
"config_revision": "rev-1"
|
|
}))
|
|
}
|
|
|
|
async fn node_connected_handler(
|
|
State(state): State<ValidateWebhookTestState>,
|
|
) -> Json<serde_json::Value> {
|
|
if let Some(sender) = state.connected_received.lock().await.take() {
|
|
let _ = sender.send(());
|
|
}
|
|
if let Some(release) = state.connected_release {
|
|
release.notified().await;
|
|
}
|
|
|
|
Json(json!({}))
|
|
}
|
|
|
|
async fn test_webhook_config(
|
|
state: ValidateWebhookTestState,
|
|
) -> (SharedWebhookConfig, tokio::task::JoinHandle<()>) {
|
|
let app = Router::new()
|
|
.route("/validate-token", post(valid_validate_token_handler))
|
|
.route("/webhook/node-connected", post(node_connected_handler))
|
|
.with_state(state);
|
|
test_webhook_server(app).await
|
|
}
|
|
|
|
async fn test_webhook_server(
|
|
app: Router,
|
|
) -> (SharedWebhookConfig, tokio::task::JoinHandle<()>) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
let webhook_config = Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some(format!("http://{addr}")),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
));
|
|
|
|
(webhook_config, server)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct RetryingConnectedWebhookState {
|
|
attempts: Arc<AtomicUsize>,
|
|
second_received: Arc<Notify>,
|
|
second_release: Arc<Notify>,
|
|
}
|
|
|
|
async fn retrying_node_connected_handler(
|
|
State(state): State<RetryingConnectedWebhookState>,
|
|
) -> (StatusCode, Json<serde_json::Value>) {
|
|
let attempt = state.attempts.fetch_add(1, Ordering::Relaxed) + 1;
|
|
if attempt == 1 {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"status": "error"})),
|
|
);
|
|
}
|
|
state.second_received.notify_one();
|
|
state.second_release.notified().await;
|
|
(StatusCode::OK, Json(json!({"status": "ok"})))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_webhook_is_confirmed_only_after_successful_retry() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let second_received = Arc::new(Notify::new());
|
|
let second_release = Arc::new(Notify::new());
|
|
let app = Router::new()
|
|
.route(
|
|
"/webhook/node-connected",
|
|
post(retrying_node_connected_handler),
|
|
)
|
|
.with_state(RetryingConnectedWebhookState {
|
|
attempts: attempts.clone(),
|
|
second_received: second_received.clone(),
|
|
second_release: second_release.clone(),
|
|
});
|
|
let (webhook_config, server) = test_webhook_server(app).await;
|
|
let fixture = connected_delivery_fixture(webhook_config).await;
|
|
let session_data = fixture.session_data.clone();
|
|
let delivery = tokio::spawn(send_webhook_connection_transition(
|
|
Arc::downgrade(&session_data),
|
|
None,
|
|
Some(fixture.notification),
|
|
));
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), second_received.notified())
|
|
.await
|
|
.expect("5xx connected webhook should be retried");
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
None
|
|
);
|
|
|
|
second_release.notify_one();
|
|
delivery.await.unwrap();
|
|
server.abort();
|
|
|
|
assert_eq!(attempts.load(Ordering::Relaxed), 2);
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FailingConnectedWebhookState {
|
|
attempts: Arc<AtomicUsize>,
|
|
first_received: Arc<Notify>,
|
|
first_release: Option<Arc<Notify>>,
|
|
status: StatusCode,
|
|
}
|
|
|
|
async fn failing_node_connected_handler(
|
|
State(state): State<FailingConnectedWebhookState>,
|
|
) -> (StatusCode, Json<serde_json::Value>) {
|
|
let attempt = state.attempts.fetch_add(1, Ordering::Relaxed) + 1;
|
|
if attempt == 1 {
|
|
state.first_received.notify_one();
|
|
if let Some(first_release) = state.first_release {
|
|
first_release.notified().await;
|
|
}
|
|
}
|
|
(state.status, Json(json!({"status": "error"})))
|
|
}
|
|
|
|
struct ConnectedDeliveryFixture {
|
|
storage: Storage,
|
|
session_data: Arc<RwLock<SessionData>>,
|
|
notification: WebhookConnectNotification,
|
|
machine_id: uuid::Uuid,
|
|
user_id: i32,
|
|
}
|
|
|
|
async fn connected_delivery_fixture(
|
|
webhook_config: SharedWebhookConfig,
|
|
) -> ConnectedDeliveryFixture {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1:1000").unwrap(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
storage.update_session_client(storage_token.clone(), 1, true, 1);
|
|
let mut session = SessionData::new(
|
|
storage.weak_ref(),
|
|
storage_token.client_url.clone(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
webhook_config.clone(),
|
|
);
|
|
session.storage_token = Some(storage_token.clone());
|
|
session.auth_state = SessionAuthState::Authorized;
|
|
session.binding_version = Some(1);
|
|
session.session_epoch = 1;
|
|
|
|
ConnectedDeliveryFixture {
|
|
storage,
|
|
session_data: Arc::new(RwLock::new(session)),
|
|
notification: WebhookConnectNotification {
|
|
webhook: webhook_config,
|
|
storage_token,
|
|
binding_version: 1,
|
|
req: crate::webhook::NodeConnectedRequest {
|
|
machine_id: machine_id.to_string(),
|
|
token: "token".to_string(),
|
|
user_id: Some(user_id),
|
|
hostname: String::new(),
|
|
version: String::new(),
|
|
os_type: None,
|
|
os_version: None,
|
|
os_distribution: None,
|
|
web_instance_id: None,
|
|
binding_version: Some(1),
|
|
},
|
|
},
|
|
machine_id,
|
|
user_id,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_webhook_retry_stops_after_session_replacement() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let first_received = Arc::new(Notify::new());
|
|
let first_release = Arc::new(Notify::new());
|
|
let app = Router::new()
|
|
.route(
|
|
"/webhook/node-connected",
|
|
post(failing_node_connected_handler),
|
|
)
|
|
.with_state(FailingConnectedWebhookState {
|
|
attempts: attempts.clone(),
|
|
first_received: first_received.clone(),
|
|
first_release: Some(first_release.clone()),
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
});
|
|
let (webhook_config, server) = test_webhook_server(app).await;
|
|
|
|
let fixture = connected_delivery_fixture(webhook_config).await;
|
|
let session_data = fixture.session_data.clone();
|
|
let delivery = tokio::spawn(send_webhook_connection_transition(
|
|
Arc::downgrade(&session_data),
|
|
None,
|
|
Some(fixture.notification),
|
|
));
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), first_received.notified())
|
|
.await
|
|
.unwrap();
|
|
fixture.storage.update_session_client(
|
|
StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1:2000").unwrap(),
|
|
machine_id: fixture.machine_id,
|
|
user_id: fixture.user_id,
|
|
},
|
|
2,
|
|
true,
|
|
2,
|
|
);
|
|
first_release.notify_one();
|
|
delivery.await.unwrap();
|
|
server.abort();
|
|
|
|
assert_eq!(attempts.load(Ordering::Relaxed), 1);
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_binding_record_respects_route_ownership() {
|
|
let webhook_config = Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
));
|
|
let fixture = connected_delivery_fixture(webhook_config).await;
|
|
let session_data = fixture.session_data.clone();
|
|
let storage_token = fixture.notification.storage_token.clone();
|
|
|
|
let outcome = record_webhook_connected_binding_if_current(
|
|
&Arc::downgrade(&session_data),
|
|
&storage_token,
|
|
1,
|
|
)
|
|
.await;
|
|
assert!(matches!(outcome, Some(ConnectedBindingRecord::Recorded)));
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
Some(1)
|
|
);
|
|
|
|
// A replacement session wins the machine route; the stale task must
|
|
// neither record its binding nor earn disconnect compensation.
|
|
fixture.storage.update_session_client(
|
|
StorageToken {
|
|
token: storage_token.token.clone(),
|
|
client_url: url::Url::parse("http://127.0.0.1:2000").unwrap(),
|
|
machine_id: fixture.machine_id,
|
|
user_id: fixture.user_id,
|
|
},
|
|
2,
|
|
true,
|
|
2,
|
|
);
|
|
session_data.write().await.webhook_connected_binding_version = None;
|
|
let outcome = record_webhook_connected_binding_if_current(
|
|
&Arc::downgrade(&session_data),
|
|
&storage_token,
|
|
1,
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
outcome,
|
|
Some(ConnectedBindingRecord::OwnershipLost)
|
|
));
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
None
|
|
);
|
|
}
|
|
|
|
async fn run_failed_connected_delivery(status: StatusCode) -> (usize, Option<u64>) {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let app = Router::new()
|
|
.route(
|
|
"/webhook/node-connected",
|
|
post(failing_node_connected_handler),
|
|
)
|
|
.with_state(FailingConnectedWebhookState {
|
|
attempts: attempts.clone(),
|
|
first_received: Arc::new(Notify::new()),
|
|
first_release: None,
|
|
status,
|
|
});
|
|
let (webhook_config, server) = test_webhook_server(app).await;
|
|
let fixture = connected_delivery_fixture(webhook_config).await;
|
|
let session_data = fixture.session_data.clone();
|
|
|
|
send_webhook_connection_transition(
|
|
Arc::downgrade(&session_data),
|
|
None,
|
|
Some(fixture.notification),
|
|
)
|
|
.await;
|
|
server.abort();
|
|
|
|
let confirmed_binding_version = session_data.read().await.webhook_connected_binding_version;
|
|
(attempts.load(Ordering::Relaxed), confirmed_binding_version)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_webhook_retry_is_bounded_when_receiver_keeps_failing() {
|
|
let (attempts, confirmed_binding_version) =
|
|
run_failed_connected_delivery(StatusCode::INTERNAL_SERVER_ERROR).await;
|
|
|
|
assert_eq!(attempts, CONNECTED_WEBHOOK_RETRY_DELAYS.len() + 1);
|
|
assert_eq!(confirmed_binding_version, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_webhook_does_not_retry_or_confirm_client_error() {
|
|
let (attempts, confirmed_binding_version) =
|
|
run_failed_connected_delivery(StatusCode::BAD_REQUEST).await;
|
|
|
|
assert_eq!(attempts, 1);
|
|
assert_eq!(confirmed_binding_version, None);
|
|
}
|
|
|
|
#[test]
|
|
fn heartbeat_identity_requires_matching_token_and_machine_id() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let other_machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token-a", machine_id);
|
|
|
|
assert!(SessionRpcService::heartbeat_matches_identity(
|
|
&req, "token-a", machine_id
|
|
));
|
|
assert!(!SessionRpcService::heartbeat_matches_identity(
|
|
&req, "token-b", machine_id
|
|
));
|
|
assert!(!SessionRpcService::heartbeat_matches_identity(
|
|
&req,
|
|
"token-a",
|
|
other_machine_id
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn session_identity_includes_runtime_id() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let mut request = heartbeat_request("token", machine_id);
|
|
let first_runtime_id = uuid::Uuid::new_v4();
|
|
request.inst_id = Some(first_runtime_id.into());
|
|
let first = SessionRpcService::heartbeat_identity(&request, machine_id);
|
|
|
|
request.inst_id = Some(uuid::Uuid::new_v4().into());
|
|
let restarted = SessionRpcService::heartbeat_identity(&request, machine_id);
|
|
|
|
assert_eq!(first.runtime_id, Some(first_runtime_id));
|
|
assert_ne!(first, restarted);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_heartbeat_saves_latest_and_marks_validation_dirty() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let data = Arc::new(RwLock::new(SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
)));
|
|
let service = SessionRpcService {
|
|
data: data.clone(),
|
|
heartbeat_policy: HeartbeatPolicy::default(),
|
|
};
|
|
|
|
service
|
|
.handle_heartbeat(heartbeat_request("token", machine_id))
|
|
.await
|
|
.unwrap();
|
|
|
|
let data = data.read().await;
|
|
assert!(data.webhook_validation_dirty);
|
|
assert_eq!(data.webhook_validation_change_epoch, 0);
|
|
assert_eq!(data.auth_state, SessionAuthState::Init);
|
|
assert!(data.storage_token.is_none());
|
|
assert!(SessionRpcService::heartbeat_matches_identity(
|
|
data.req.as_ref().unwrap(),
|
|
"token",
|
|
machine_id,
|
|
));
|
|
drop(data);
|
|
assert!(
|
|
storage
|
|
.db()
|
|
.get_user_id_by_token("token")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_validation_round_sets_token_and_notifies_runtime() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let (received_tx, received_rx) = oneshot::channel();
|
|
let (connected_tx, connected_rx) = oneshot::channel();
|
|
let release = Arc::new(Notify::new());
|
|
let connected_release = Arc::new(Notify::new());
|
|
let (webhook_config, server) = test_webhook_config(ValidateWebhookTestState {
|
|
received: Arc::new(Mutex::new(Some(received_tx))),
|
|
release: release.clone(),
|
|
connected_received: Arc::new(Mutex::new(Some(connected_tx))),
|
|
connected_release: Some(connected_release.clone()),
|
|
})
|
|
.await;
|
|
let mut session = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
webhook_config.clone(),
|
|
);
|
|
session.req = Some(req.clone());
|
|
session.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
let session_data = Arc::new(RwLock::new(session));
|
|
let mut heartbeat_waiter = session_data.read().await.heartbeat_waiter();
|
|
|
|
let validation = tokio::spawn(webhook_validation::run_round(
|
|
Arc::downgrade(&session_data),
|
|
webhook_validation::WebhookValidationInput {
|
|
storage: storage.clone(),
|
|
webhook_config,
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
applied_config_revision: None,
|
|
applied_config_revision_known: false,
|
|
failed_instance_ids: Vec::new(),
|
|
req,
|
|
machine_id,
|
|
},
|
|
session_data.read().await.webhook_validation_change_epoch,
|
|
));
|
|
received_rx.await.unwrap();
|
|
release.notify_waiters();
|
|
connected_rx.await.unwrap();
|
|
let user_id = storage
|
|
.db()
|
|
.get_user_id_by_token("token")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id(user_id, &machine_id),
|
|
Some(url::Url::parse("http://127.0.0.1").unwrap())
|
|
);
|
|
connected_release.notify_waiters();
|
|
validation.await.unwrap().unwrap();
|
|
server.abort();
|
|
|
|
let data = session_data.read().await;
|
|
assert_eq!(data.auth_state, SessionAuthState::Authorized);
|
|
assert!(data.storage_token.is_some());
|
|
assert_eq!(data.binding_version, Some(1));
|
|
assert_eq!(data.webhook_connected_binding_version, Some(1));
|
|
drop(data);
|
|
assert_eq!(heartbeat_waiter.recv().await.unwrap().user_token, "token");
|
|
assert!(
|
|
storage
|
|
.db()
|
|
.get_user_id_by_token("token")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authenticated_heartbeat_rejects_mismatched_identity() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let other_machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let (received_tx, received_rx) = oneshot::channel();
|
|
let release = Arc::new(Notify::new());
|
|
let (webhook_config, server) = test_webhook_config(ValidateWebhookTestState {
|
|
received: Arc::new(Mutex::new(Some(received_tx))),
|
|
release,
|
|
connected_received: Arc::new(Mutex::new(None)),
|
|
connected_release: None,
|
|
})
|
|
.await;
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags {
|
|
allow_auto_create_user: true,
|
|
..Default::default()
|
|
}),
|
|
webhook_config,
|
|
);
|
|
data.storage_token = Some(StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
machine_id,
|
|
user_id,
|
|
});
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
data.req = Some(req);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let service = SessionRpcService {
|
|
data: session_data.clone(),
|
|
heartbeat_policy: HeartbeatPolicy::default(),
|
|
};
|
|
|
|
let err = service
|
|
.handle_heartbeat(heartbeat_request("other-token", other_machine_id))
|
|
.await
|
|
.expect_err("mismatched authenticated heartbeat must fail");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("Heartbeat identity does not match")
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(50), received_rx)
|
|
.await
|
|
.is_err()
|
|
);
|
|
server.abort();
|
|
|
|
let data = session_data.read().await;
|
|
assert!(SessionRpcService::storage_token_matches_heartbeat(
|
|
data.storage_token.as_ref().unwrap(),
|
|
data.req.as_ref().unwrap()
|
|
));
|
|
assert_eq!(
|
|
data.heartbeat_count
|
|
.load(std::sync::atomic::Ordering::Relaxed),
|
|
0
|
|
);
|
|
drop(data);
|
|
assert!(
|
|
storage
|
|
.db()
|
|
.get_user_id_by_token("other-token")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_webhook_mismatched_identity_does_not_auto_create_user() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let other_machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags {
|
|
allow_auto_create_user: true,
|
|
..Default::default()
|
|
}),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
machine_id,
|
|
user_id,
|
|
});
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.req = Some(req);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let service = SessionRpcService {
|
|
data: session_data,
|
|
heartbeat_policy: HeartbeatPolicy::default(),
|
|
};
|
|
|
|
let err = service
|
|
.handle_heartbeat(heartbeat_request("other-token", other_machine_id))
|
|
.await
|
|
.expect_err("mismatched heartbeat must fail before DB side effects");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("Heartbeat identity does not match")
|
|
);
|
|
assert!(
|
|
storage
|
|
.db()
|
|
.get_user_id_by_token("other-token")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_reject_keeps_session_visible_but_invalid() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
storage.update_client(storage_token.clone(), 1, true);
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(storage_token);
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.req = Some(req.clone());
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
data.webhook_validation_dirty = true;
|
|
data.webhook_connected_binding_version = Some(3);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
|
|
let validation_change_epoch = session_data.read().await.webhook_validation_change_epoch;
|
|
webhook_validation::apply_rejected(
|
|
&Arc::downgrade(&session_data),
|
|
&webhook_validation::WebhookValidationInput {
|
|
storage: storage.clone(),
|
|
webhook_config: Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
applied_config_revision: None,
|
|
applied_config_revision_known: false,
|
|
failed_instance_ids: Vec::new(),
|
|
req,
|
|
machine_id,
|
|
},
|
|
validation_change_epoch,
|
|
)
|
|
.await;
|
|
|
|
let data = session_data.read().await;
|
|
assert!(data.storage_token.is_some());
|
|
assert_eq!(data.auth_state, SessionAuthState::Invalid);
|
|
assert!(!data.webhook_validation_dirty);
|
|
assert_eq!(data.webhook_connected_binding_version, None);
|
|
drop(data);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id(user_id, &machine_id),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id_with_auth(user_id, &machine_id, false),
|
|
Some(url::Url::parse("http://127.0.0.1").unwrap())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_reject_prevents_reauthorize_same_session() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let client_url = url::Url::parse("http://127.0.0.1").unwrap();
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: client_url.clone(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
storage.update_client(storage_token.clone(), 1, true);
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
client_url.clone(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(storage_token);
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.req = Some(req.clone());
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
data.webhook_connected_binding_version = Some(6);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let weak_session = Arc::downgrade(&session_data);
|
|
|
|
let input = webhook_validation::WebhookValidationInput {
|
|
storage: storage.clone(),
|
|
webhook_config: Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
client_url: client_url.clone(),
|
|
applied_config_revision: None,
|
|
applied_config_revision_known: false,
|
|
failed_instance_ids: Vec::new(),
|
|
req: req.clone(),
|
|
machine_id,
|
|
};
|
|
let validation_change_epoch = session_data.read().await.webhook_validation_change_epoch;
|
|
webhook_validation::apply_rejected(&weak_session, &input, validation_change_epoch).await;
|
|
assert_eq!(
|
|
session_data.read().await.webhook_connected_binding_version,
|
|
None
|
|
);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id(user_id, &machine_id),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id_with_auth(user_id, &machine_id, false),
|
|
Some(client_url.clone())
|
|
);
|
|
|
|
let validation_change_epoch = session_data.read().await.webhook_validation_change_epoch;
|
|
webhook_validation::apply_success(
|
|
&weak_session,
|
|
input,
|
|
webhook_validation::WebhookHeartbeatValidation {
|
|
config_revision: "rev-1".to_string(),
|
|
binding_version: 7,
|
|
},
|
|
user_id,
|
|
validation_change_epoch,
|
|
)
|
|
.await;
|
|
|
|
let data = session_data.read().await;
|
|
assert!(data.storage_token.is_some());
|
|
assert_eq!(data.auth_state, SessionAuthState::Invalid);
|
|
assert_eq!(data.binding_version, None);
|
|
assert_eq!(data.webhook_connected_binding_version, None);
|
|
drop(data);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id(user_id, &machine_id),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id_with_auth(user_id, &machine_id, false),
|
|
Some(client_url)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_webhook_session_does_not_revalidate_on_same_connection() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let client_url = url::Url::parse("http://127.0.0.1").unwrap();
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
client_url.clone(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(StorageToken {
|
|
token: "token".to_string(),
|
|
client_url,
|
|
machine_id,
|
|
user_id,
|
|
});
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.auth_state = SessionAuthState::Invalid;
|
|
data.webhook_validation_dirty = false;
|
|
data.heartbeat_count.store(
|
|
WEBHOOK_VALIDATION_HEARTBEAT_INTERVAL,
|
|
std::sync::atomic::Ordering::Relaxed,
|
|
);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let service = SessionRpcService {
|
|
data: session_data.clone(),
|
|
heartbeat_policy: HeartbeatPolicy::default(),
|
|
};
|
|
|
|
service
|
|
.handle_heartbeat(req)
|
|
.await
|
|
.expect_err("invalid webhook session must fail heartbeat");
|
|
|
|
let data = session_data.read().await;
|
|
assert!(!data.webhook_validation_dirty);
|
|
assert_eq!(data.auth_state, SessionAuthState::Invalid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_webhook_heartbeat_returns_error() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let client_url = url::Url::parse("http://127.0.0.1").unwrap();
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: client_url.clone(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
storage.update_client(storage_token.clone(), 1, false);
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
client_url.clone(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(storage_token);
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.auth_state = SessionAuthState::Invalid;
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let service = SessionRpcService {
|
|
data: session_data,
|
|
heartbeat_policy: HeartbeatPolicy::default(),
|
|
};
|
|
|
|
service
|
|
.handle_heartbeat(req)
|
|
.await
|
|
.expect_err("invalid webhook heartbeat must fail");
|
|
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id(user_id, &machine_id),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
storage.get_client_url_by_machine_id_with_auth(user_id, &machine_id, false),
|
|
Some(client_url)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn webhook_success_replaces_connected_binding_version() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let client_url = url::Url::parse("http://127.0.0.1").unwrap();
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: client_url.clone(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
client_url.clone(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
);
|
|
data.storage_token = Some(storage_token);
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.req = Some(req.clone());
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
data.binding_version = Some(6);
|
|
data.webhook_connected_binding_version = Some(6);
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
|
|
let validation_change_epoch = session_data.read().await.webhook_validation_change_epoch;
|
|
webhook_validation::apply_success(
|
|
&Arc::downgrade(&session_data),
|
|
webhook_validation::WebhookValidationInput {
|
|
storage,
|
|
webhook_config: Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
client_url,
|
|
applied_config_revision: None,
|
|
applied_config_revision_known: false,
|
|
failed_instance_ids: Vec::new(),
|
|
req,
|
|
machine_id,
|
|
},
|
|
webhook_validation::WebhookHeartbeatValidation {
|
|
config_revision: "rev-1".to_string(),
|
|
binding_version: 7,
|
|
},
|
|
user_id,
|
|
validation_change_epoch,
|
|
)
|
|
.await;
|
|
|
|
let data = session_data.read().await;
|
|
assert_eq!(data.auth_state, SessionAuthState::Authorized);
|
|
assert_eq!(data.binding_version, Some(7));
|
|
assert_eq!(data.webhook_connected_binding_version, Some(7));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejected_session_stops_reconcile_without_clearing_runtime_state() {
|
|
let machine_id = uuid::Uuid::new_v4();
|
|
let req = heartbeat_request("token", machine_id);
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
|
let storage_token = StorageToken {
|
|
token: "token".to_string(),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
machine_id,
|
|
user_id,
|
|
};
|
|
let mut data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
Some("http://127.0.0.1:1".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)),
|
|
);
|
|
storage.update_session_client(storage_token.clone(), 1, true, 0);
|
|
data.storage_token = Some(storage_token);
|
|
data.session_identity = Some(SessionRpcService::heartbeat_identity(&req, machine_id));
|
|
data.req = Some(req.clone());
|
|
data.auth_state = SessionAuthState::Authorized;
|
|
{
|
|
let mut runtime = data.managed_runtime();
|
|
runtime.applied_config_revision = Some("rev-1".to_string());
|
|
runtime.applied_config_revision_known = true;
|
|
runtime.known_runtime_base_revision = Some("rev-1".to_string());
|
|
}
|
|
let session_data = Arc::new(RwLock::new(data));
|
|
let weak_session = Arc::downgrade(&session_data);
|
|
|
|
assert!(SessionRpcService::runtime_heartbeat_is_current(&weak_session, &req).await);
|
|
|
|
let validation_change_epoch = session_data.read().await.webhook_validation_change_epoch;
|
|
webhook_validation::apply_rejected(
|
|
&weak_session,
|
|
&webhook_validation::WebhookValidationInput {
|
|
storage,
|
|
webhook_config: Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
client_url: url::Url::parse("http://127.0.0.1").unwrap(),
|
|
applied_config_revision: None,
|
|
applied_config_revision_known: false,
|
|
failed_instance_ids: Vec::new(),
|
|
req: req.clone(),
|
|
machine_id,
|
|
},
|
|
validation_change_epoch,
|
|
)
|
|
.await;
|
|
|
|
assert!(!SessionRpcService::runtime_heartbeat_is_current(&weak_session, &req).await);
|
|
let data = session_data.read().await;
|
|
let runtime = data.managed_runtime();
|
|
assert_eq!(runtime.applied_config_revision.as_deref(), Some("rev-1"));
|
|
assert!(runtime.applied_config_revision_known);
|
|
assert_eq!(
|
|
runtime.known_runtime_base_revision.as_deref(),
|
|
Some("rev-1")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fresh_session_application_revision_is_unknown() {
|
|
let storage = Storage::new(crate::db::Db::memory_db().await);
|
|
let data = SessionData::new(
|
|
storage.weak_ref(),
|
|
url::Url::parse("http://127.0.0.1").unwrap(),
|
|
None,
|
|
Arc::new(FeatureFlags::default()),
|
|
Arc::new(crate::webhook::WebhookConfig::new(
|
|
None, None, None, None, None,
|
|
)),
|
|
);
|
|
|
|
let runtime = data.managed_runtime();
|
|
assert_eq!(runtime.applied_config_revision, None);
|
|
assert!(!runtime.applied_config_revision_known);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_token_request_includes_config_revisions() {
|
|
let req = crate::webhook::ValidateTokenRequest {
|
|
token: "token".to_string(),
|
|
machine_id: "machine".to_string(),
|
|
public_ip: Some("127.0.0.1".to_string()),
|
|
hostname: "host".to_string(),
|
|
version: "1.0.0".to_string(),
|
|
os_type: None,
|
|
os_version: None,
|
|
os_distribution: None,
|
|
web_instance_id: Some("web-1".to_string()),
|
|
web_instance_api_base_url: Some("http://console".to_string()),
|
|
persisted_config_revision: Some("rev-0".to_string()),
|
|
applied_config_revision: Some("rev-1".to_string()),
|
|
applied_config_revision_known: true,
|
|
failed_instance_ids: vec!["failed-instance".to_string()],
|
|
};
|
|
|
|
let value = serde_json::to_value(req).unwrap();
|
|
assert_eq!(
|
|
value
|
|
.get("persisted_config_revision")
|
|
.and_then(|v| v.as_str()),
|
|
Some("rev-0")
|
|
);
|
|
assert_eq!(
|
|
value
|
|
.get("applied_config_revision")
|
|
.and_then(|v| v.as_str()),
|
|
Some("rev-1")
|
|
);
|
|
assert_eq!(
|
|
value
|
|
.get("applied_config_revision_known")
|
|
.and_then(|v| v.as_bool()),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
value.get("failed_instance_ids"),
|
|
Some(&json!(["failed-instance"]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn managed_patch_hints_merge_while_runtime_lags() {
|
|
let mut hint = None;
|
|
|
|
record_managed_config_reconcile_hint(
|
|
&mut hint,
|
|
ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-a".to_string(),
|
|
target_revision: "rev-b".to_string(),
|
|
instance_ids: HashSet::from(["instance-a".to_string()]),
|
|
},
|
|
);
|
|
record_managed_config_reconcile_hint(
|
|
&mut hint,
|
|
ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-b".to_string(),
|
|
target_revision: "rev-c".to_string(),
|
|
instance_ids: HashSet::from(["instance-b".to_string()]),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
hint,
|
|
Some(ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-a".to_string(),
|
|
target_revision: "rev-c".to_string(),
|
|
instance_ids: HashSet::from(["instance-a".to_string(), "instance-b".to_string(),]),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_contiguous_managed_patch_hints_require_full_reconcile() {
|
|
let mut hint = Some(ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-a".to_string(),
|
|
target_revision: "rev-b".to_string(),
|
|
instance_ids: HashSet::from(["instance-a".to_string()]),
|
|
});
|
|
|
|
record_managed_config_reconcile_hint(
|
|
&mut hint,
|
|
ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-c".to_string(),
|
|
target_revision: "rev-d".to_string(),
|
|
instance_ids: HashSet::from(["instance-b".to_string()]),
|
|
},
|
|
);
|
|
|
|
assert_eq!(hint, Some(ManagedConfigReconcileHint::Full));
|
|
}
|
|
|
|
#[test]
|
|
fn managed_patch_hint_does_not_narrow_pending_full_reconcile() {
|
|
let mut hint = Some(ManagedConfigReconcileHint::Full);
|
|
|
|
record_managed_config_reconcile_hint(
|
|
&mut hint,
|
|
ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-0".to_string(),
|
|
target_revision: "rev-a".to_string(),
|
|
instance_ids: HashSet::from(["instance-a".to_string()]),
|
|
},
|
|
);
|
|
|
|
assert_eq!(hint, Some(ManagedConfigReconcileHint::Full));
|
|
}
|
|
|
|
#[test]
|
|
fn full_reconcile_hint_replaces_pending_dirty_instances() {
|
|
let mut hint = Some(ManagedConfigReconcileHint::Dirty {
|
|
expected_revision: "rev-0".to_string(),
|
|
target_revision: "rev-a".to_string(),
|
|
instance_ids: HashSet::from(["instance-a".to_string()]),
|
|
});
|
|
|
|
record_managed_config_reconcile_hint(&mut hint, ManagedConfigReconcileHint::Full);
|
|
|
|
assert_eq!(hint, Some(ManagedConfigReconcileHint::Full));
|
|
}
|
|
}
|