mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
766 lines
30 KiB
Rust
766 lines
30 KiB
Rust
//! Shared application state passed to all subsystems.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
use std::time::Duration;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use aether_runtime::{
|
|
service_up_sample, AdmissionPermit, ConcurrencyError, ConcurrencyGate, ConcurrencySnapshot,
|
|
MetricKind, MetricLabel, MetricSample,
|
|
};
|
|
use aether_runtime_state::{RuntimeSemaphore, RuntimeSemaphoreError, RuntimeSemaphoreSnapshot};
|
|
|
|
use crate::config::Config;
|
|
use crate::config::TunnelSecurity;
|
|
use crate::hardware::RuntimeResourceMonitor;
|
|
use crate::registration::client::AetherClient;
|
|
use crate::runtime::SharedDynamicConfig;
|
|
use crate::target_filter::DnsCache;
|
|
use crate::upstream_client::UpstreamClientPool;
|
|
|
|
/// Central application state shared across all servers/tunnels.
|
|
pub struct AppState {
|
|
pub config: Arc<Config>,
|
|
/// DNS cache for upstream target resolution (shared).
|
|
pub dns_cache: Arc<DnsCache>,
|
|
/// Profile-keyed upstream client pool used by tunnel requests.
|
|
pub upstream_client_pool: UpstreamClientPool,
|
|
/// Shared TLS config for tunnel WebSocket connections (avoids re-parsing root CAs on each reconnect).
|
|
pub tunnel_tls_config: Arc<rustls::ClientConfig>,
|
|
/// Runtime CPU/memory monitor sampled by heartbeat payloads.
|
|
pub resource_monitor: Arc<RuntimeResourceMonitor>,
|
|
/// Optional per-process stream admission gate.
|
|
pub stream_gate: Option<Arc<ConcurrencyGate>>,
|
|
/// Optional cross-instance stream admission gate.
|
|
pub distributed_stream_gate: Option<Arc<RuntimeSemaphore>>,
|
|
}
|
|
|
|
/// Per-server state: one instance per Aether server connection.
|
|
pub struct ServerContext {
|
|
/// Human-readable label for logging (e.g. "server-0").
|
|
pub server_label: String,
|
|
/// Aether server URL for this connection.
|
|
pub aether_url: String,
|
|
/// Management token for this server.
|
|
pub management_token: String,
|
|
/// Effective security mode for this server connection.
|
|
pub tunnel_security: TunnelSecurity,
|
|
/// PSK used for secure non-TLS tunnel frames.
|
|
pub tunnel_encryption_key: Option<String>,
|
|
/// Resolved node name at registration time (per-server override or global fallback).
|
|
/// After startup, the active node_name is read from `dynamic` (may be updated remotely).
|
|
#[allow(dead_code)]
|
|
pub node_name: String,
|
|
/// Node ID assigned by this Aether server.
|
|
pub node_id: Arc<RwLock<String>>,
|
|
/// Server-issued node incarnation bound into tunnel authentication.
|
|
pub tunnel_generation: String,
|
|
/// API client for this server.
|
|
pub aether_client: Arc<AetherClient>,
|
|
/// Dynamic config from this server's heartbeat ACKs.
|
|
pub dynamic: SharedDynamicConfig,
|
|
/// Per-server active connection count.
|
|
pub active_connections: Arc<AtomicU64>,
|
|
/// Per-server request/latency metrics.
|
|
pub metrics: Arc<TunnelRequestMetrics>,
|
|
/// Per-server tunnel stability/traffic metrics.
|
|
pub tunnel_metrics: Arc<TunnelMetrics>,
|
|
}
|
|
|
|
impl ServerContext {
|
|
pub fn metric_samples(&self) -> Vec<MetricSample> {
|
|
let mut samples = self.metrics.to_metric_samples(&self.server_label);
|
|
samples.extend(self.tunnel_metrics.to_metric_samples(&self.server_label));
|
|
samples.push(
|
|
MetricSample::new(
|
|
"tunnel_active_connections",
|
|
"Current number of active tunneled streams handled by this tunnel server context.",
|
|
MetricKind::Gauge,
|
|
self.active_connections.load(Ordering::Acquire),
|
|
)
|
|
.with_labels(vec![MetricLabel::new("server", self.server_label.clone())]),
|
|
);
|
|
samples
|
|
}
|
|
}
|
|
|
|
/// Aggregate metrics for reporting to Aether.
|
|
pub struct TunnelRequestMetrics {
|
|
pub total_requests: AtomicU64,
|
|
/// Cumulative connection-establishment latency in nanoseconds
|
|
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
|
|
pub total_latency_ns: AtomicU64,
|
|
pub failed_requests: AtomicU64,
|
|
pub dns_failures: AtomicU64,
|
|
pub stream_errors: AtomicU64,
|
|
pub slow_requests: AtomicU64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
|
|
pub struct TunnelRequestMetricsSnapshot {
|
|
pub total_requests: u64,
|
|
pub total_latency_ns: u64,
|
|
pub failed_requests: u64,
|
|
pub dns_failures: u64,
|
|
pub stream_errors: u64,
|
|
pub slow_requests: u64,
|
|
}
|
|
|
|
impl TunnelRequestMetricsSnapshot {
|
|
pub fn average_latency_ns(self) -> Option<u64> {
|
|
self.total_latency_ns.checked_div(self.total_requests)
|
|
}
|
|
|
|
pub fn average_latency_ms(self) -> Option<f64> {
|
|
self.average_latency_ns()
|
|
.map(|value| value as f64 / 1_000_000.0)
|
|
}
|
|
|
|
pub fn delta_since(self, baseline: Self) -> Self {
|
|
Self {
|
|
total_requests: self.total_requests.saturating_sub(baseline.total_requests),
|
|
total_latency_ns: self
|
|
.total_latency_ns
|
|
.saturating_sub(baseline.total_latency_ns),
|
|
failed_requests: self
|
|
.failed_requests
|
|
.saturating_sub(baseline.failed_requests),
|
|
dns_failures: self.dns_failures.saturating_sub(baseline.dns_failures),
|
|
stream_errors: self.stream_errors.saturating_sub(baseline.stream_errors),
|
|
slow_requests: self.slow_requests.saturating_sub(baseline.slow_requests),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TunnelRequestMetrics {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
total_requests: AtomicU64::new(0),
|
|
total_latency_ns: AtomicU64::new(0),
|
|
failed_requests: AtomicU64::new(0),
|
|
dns_failures: AtomicU64::new(0),
|
|
stream_errors: AtomicU64::new(0),
|
|
slow_requests: AtomicU64::new(0),
|
|
}
|
|
}
|
|
|
|
/// Record a completed request with its connection-establishment latency
|
|
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
|
|
pub fn record_request(&self, connect_elapsed: Duration) {
|
|
let nanos = u64::try_from(connect_elapsed.as_nanos()).unwrap_or(u64::MAX);
|
|
self.total_requests.fetch_add(1, Ordering::Release);
|
|
self.total_latency_ns.fetch_add(nanos, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_slow_request(&self) {
|
|
self.slow_requests.fetch_add(1, Ordering::Release);
|
|
}
|
|
|
|
pub fn snapshot(&self) -> TunnelRequestMetricsSnapshot {
|
|
TunnelRequestMetricsSnapshot {
|
|
total_requests: self.total_requests.load(Ordering::Acquire),
|
|
total_latency_ns: self.total_latency_ns.load(Ordering::Acquire),
|
|
failed_requests: self.failed_requests.load(Ordering::Acquire),
|
|
dns_failures: self.dns_failures.load(Ordering::Acquire),
|
|
stream_errors: self.stream_errors.load(Ordering::Acquire),
|
|
slow_requests: self.slow_requests.load(Ordering::Acquire),
|
|
}
|
|
}
|
|
|
|
pub fn to_metric_samples(&self, server_label: &str) -> Vec<MetricSample> {
|
|
let snapshot = self.snapshot();
|
|
let labels = vec![MetricLabel::new("server", server_label)];
|
|
vec![
|
|
MetricSample::new(
|
|
"tunnel_requests_total",
|
|
"Total number of tunneled upstream requests completed by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.total_requests,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_request_latency_total_ns",
|
|
"Cumulative tunnel request latency in nanoseconds through upstream response headers.",
|
|
MetricKind::Counter,
|
|
snapshot.total_latency_ns,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_request_latency_avg_ns",
|
|
"Average tunnel request latency in nanoseconds through upstream response headers.",
|
|
MetricKind::Gauge,
|
|
snapshot.average_latency_ns().unwrap_or(0),
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_failed_requests_total",
|
|
"Total number of tunneled upstream requests that failed before response headers.",
|
|
MetricKind::Counter,
|
|
snapshot.failed_requests,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_dns_failures_total",
|
|
"Total number of tunneled upstream requests rejected or failed during target validation or DNS.",
|
|
MetricKind::Counter,
|
|
snapshot.dns_failures,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_stream_errors_total",
|
|
"Total number of tunneled response body stream errors.",
|
|
MetricKind::Counter,
|
|
snapshot.stream_errors,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_slow_requests_total",
|
|
"Total number of tunneled requests crossing the tunnel slow-request threshold.",
|
|
MetricKind::Counter,
|
|
snapshot.slow_requests,
|
|
)
|
|
.with_labels(labels),
|
|
]
|
|
}
|
|
}
|
|
|
|
const RECENT_TUNNEL_ERROR_CAPACITY: usize = 64;
|
|
const TUNNEL_ERROR_CATEGORY_MAX_CHARS: usize = 48;
|
|
const TUNNEL_ERROR_MESSAGE_MAX_CHARS: usize = 320;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct TunnelErrorEvent {
|
|
pub timestamp_unix_secs: u64,
|
|
pub timestamp_unix_ms: u64,
|
|
pub category: String,
|
|
pub message: String,
|
|
pub severity: String,
|
|
pub component: String,
|
|
pub summary: String,
|
|
pub operator_action: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
|
|
pub struct TunnelMetricsSnapshot {
|
|
pub connect_attempts: u64,
|
|
pub connect_successes: u64,
|
|
pub connect_errors: u64,
|
|
pub disconnects: u64,
|
|
pub last_connected_at_unix_secs: u64,
|
|
pub last_disconnected_at_unix_secs: u64,
|
|
pub last_connected_duration_ms: u64,
|
|
pub connected_duration_total_ms: u64,
|
|
pub heartbeat_sent: u64,
|
|
pub heartbeat_ack: u64,
|
|
pub heartbeat_rtt_last_ms: u64,
|
|
pub heartbeat_rtt_total_ms: u64,
|
|
pub ws_in_frames: u64,
|
|
pub ws_in_bytes: u64,
|
|
pub ws_out_frames: u64,
|
|
pub ws_out_bytes: u64,
|
|
pub error_events_total: u64,
|
|
}
|
|
|
|
impl TunnelMetricsSnapshot {
|
|
pub fn heartbeat_rtt_avg_ms(self) -> Option<f64> {
|
|
if self.heartbeat_ack == 0 {
|
|
None
|
|
} else {
|
|
Some(self.heartbeat_rtt_total_ms as f64 / self.heartbeat_ack as f64)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct TunnelMetrics {
|
|
connect_attempts: AtomicU64,
|
|
connect_successes: AtomicU64,
|
|
connect_errors: AtomicU64,
|
|
disconnects: AtomicU64,
|
|
last_connected_at_unix_secs: AtomicU64,
|
|
last_disconnected_at_unix_secs: AtomicU64,
|
|
last_connected_duration_ms: AtomicU64,
|
|
connected_duration_total_ms: AtomicU64,
|
|
heartbeat_sent: AtomicU64,
|
|
heartbeat_ack: AtomicU64,
|
|
heartbeat_rtt_last_ms: AtomicU64,
|
|
heartbeat_rtt_total_ms: AtomicU64,
|
|
ws_in_frames: AtomicU64,
|
|
ws_in_bytes: AtomicU64,
|
|
ws_out_frames: AtomicU64,
|
|
ws_out_bytes: AtomicU64,
|
|
error_events_total: AtomicU64,
|
|
recent_errors: Mutex<VecDeque<TunnelErrorEvent>>,
|
|
}
|
|
|
|
impl TunnelMetrics {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
connect_attempts: AtomicU64::new(0),
|
|
connect_successes: AtomicU64::new(0),
|
|
connect_errors: AtomicU64::new(0),
|
|
disconnects: AtomicU64::new(0),
|
|
last_connected_at_unix_secs: AtomicU64::new(0),
|
|
last_disconnected_at_unix_secs: AtomicU64::new(0),
|
|
last_connected_duration_ms: AtomicU64::new(0),
|
|
connected_duration_total_ms: AtomicU64::new(0),
|
|
heartbeat_sent: AtomicU64::new(0),
|
|
heartbeat_ack: AtomicU64::new(0),
|
|
heartbeat_rtt_last_ms: AtomicU64::new(0),
|
|
heartbeat_rtt_total_ms: AtomicU64::new(0),
|
|
ws_in_frames: AtomicU64::new(0),
|
|
ws_in_bytes: AtomicU64::new(0),
|
|
ws_out_frames: AtomicU64::new(0),
|
|
ws_out_bytes: AtomicU64::new(0),
|
|
error_events_total: AtomicU64::new(0),
|
|
recent_errors: Mutex::new(VecDeque::with_capacity(RECENT_TUNNEL_ERROR_CAPACITY)),
|
|
}
|
|
}
|
|
|
|
pub fn record_connect_attempt(&self) {
|
|
self.connect_attempts.fetch_add(1, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_connect_success(&self) {
|
|
self.connect_successes.fetch_add(1, Ordering::Release);
|
|
self.last_connected_at_unix_secs
|
|
.store(now_unix_secs(), Ordering::Release);
|
|
}
|
|
|
|
pub fn record_connect_error(&self) {
|
|
self.connect_errors.fetch_add(1, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_disconnect(&self, connected_for: Duration) {
|
|
let duration_ms = duration_to_millis_u64(connected_for);
|
|
self.disconnects.fetch_add(1, Ordering::Release);
|
|
self.last_disconnected_at_unix_secs
|
|
.store(now_unix_secs(), Ordering::Release);
|
|
self.last_connected_duration_ms
|
|
.store(duration_ms, Ordering::Release);
|
|
self.connected_duration_total_ms
|
|
.fetch_add(duration_ms, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_heartbeat_sent(&self) {
|
|
self.heartbeat_sent.fetch_add(1, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_heartbeat_ack(&self, rtt: Duration) {
|
|
let rtt_ms = duration_to_millis_u64(rtt);
|
|
self.heartbeat_ack.fetch_add(1, Ordering::Release);
|
|
self.heartbeat_rtt_last_ms.store(rtt_ms, Ordering::Release);
|
|
self.heartbeat_rtt_total_ms
|
|
.fetch_add(rtt_ms, Ordering::Release);
|
|
}
|
|
|
|
pub fn record_ws_incoming_frame(&self, payload_len: usize) {
|
|
self.ws_in_frames.fetch_add(1, Ordering::Release);
|
|
self.ws_in_bytes.fetch_add(
|
|
u64::try_from(payload_len).unwrap_or(u64::MAX),
|
|
Ordering::Release,
|
|
);
|
|
}
|
|
|
|
pub fn record_ws_outgoing_frame(&self, payload_len: usize) {
|
|
self.ws_out_frames.fetch_add(1, Ordering::Release);
|
|
self.ws_out_bytes.fetch_add(
|
|
u64::try_from(payload_len).unwrap_or(u64::MAX),
|
|
Ordering::Release,
|
|
);
|
|
}
|
|
|
|
pub fn record_error(&self, category: &str, message: &str) {
|
|
self.error_events_total.fetch_add(1, Ordering::Release);
|
|
let category = normalize_error_field(category, TUNNEL_ERROR_CATEGORY_MAX_CHARS, "unknown");
|
|
let message = normalize_error_field(message, TUNNEL_ERROR_MESSAGE_MAX_CHARS, "n/a");
|
|
let diagnostic = classify_tunnel_error(category.as_str(), message.as_str());
|
|
|
|
let timestamp_unix_ms = now_unix_ms();
|
|
let event = TunnelErrorEvent {
|
|
timestamp_unix_secs: timestamp_unix_ms / 1_000,
|
|
timestamp_unix_ms,
|
|
category,
|
|
message,
|
|
severity: diagnostic.severity.to_string(),
|
|
component: diagnostic.component.to_string(),
|
|
summary: diagnostic.summary.to_string(),
|
|
operator_action: diagnostic.operator_action.to_string(),
|
|
};
|
|
|
|
let mut recent_errors = match self.recent_errors.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
if recent_errors.len() >= RECENT_TUNNEL_ERROR_CAPACITY {
|
|
recent_errors.pop_front();
|
|
}
|
|
recent_errors.push_back(event);
|
|
}
|
|
|
|
pub fn recent_errors(&self, limit: usize) -> Vec<TunnelErrorEvent> {
|
|
if limit == 0 {
|
|
return Vec::new();
|
|
}
|
|
let recent_errors = match self.recent_errors.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
let start = recent_errors.len().saturating_sub(limit);
|
|
recent_errors.iter().skip(start).cloned().collect()
|
|
}
|
|
|
|
pub fn snapshot(&self) -> TunnelMetricsSnapshot {
|
|
TunnelMetricsSnapshot {
|
|
connect_attempts: self.connect_attempts.load(Ordering::Acquire),
|
|
connect_successes: self.connect_successes.load(Ordering::Acquire),
|
|
connect_errors: self.connect_errors.load(Ordering::Acquire),
|
|
disconnects: self.disconnects.load(Ordering::Acquire),
|
|
last_connected_at_unix_secs: self.last_connected_at_unix_secs.load(Ordering::Acquire),
|
|
last_disconnected_at_unix_secs: self
|
|
.last_disconnected_at_unix_secs
|
|
.load(Ordering::Acquire),
|
|
last_connected_duration_ms: self.last_connected_duration_ms.load(Ordering::Acquire),
|
|
connected_duration_total_ms: self.connected_duration_total_ms.load(Ordering::Acquire),
|
|
heartbeat_sent: self.heartbeat_sent.load(Ordering::Acquire),
|
|
heartbeat_ack: self.heartbeat_ack.load(Ordering::Acquire),
|
|
heartbeat_rtt_last_ms: self.heartbeat_rtt_last_ms.load(Ordering::Acquire),
|
|
heartbeat_rtt_total_ms: self.heartbeat_rtt_total_ms.load(Ordering::Acquire),
|
|
ws_in_frames: self.ws_in_frames.load(Ordering::Acquire),
|
|
ws_in_bytes: self.ws_in_bytes.load(Ordering::Acquire),
|
|
ws_out_frames: self.ws_out_frames.load(Ordering::Acquire),
|
|
ws_out_bytes: self.ws_out_bytes.load(Ordering::Acquire),
|
|
error_events_total: self.error_events_total.load(Ordering::Acquire),
|
|
}
|
|
}
|
|
|
|
pub fn to_metric_samples(&self, server_label: &str) -> Vec<MetricSample> {
|
|
let snapshot = self.snapshot();
|
|
let labels = vec![MetricLabel::new("server", server_label)];
|
|
vec![
|
|
MetricSample::new(
|
|
"tunnel_connect_attempts_total",
|
|
"Total number of WebSocket tunnel connection attempts.",
|
|
MetricKind::Counter,
|
|
snapshot.connect_attempts,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_connect_successes_total",
|
|
"Total number of successful WebSocket tunnel connections.",
|
|
MetricKind::Counter,
|
|
snapshot.connect_successes,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_connect_errors_total",
|
|
"Total number of WebSocket tunnel connection errors.",
|
|
MetricKind::Counter,
|
|
snapshot.connect_errors,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_disconnects_total",
|
|
"Total number of WebSocket tunnel disconnects.",
|
|
MetricKind::Counter,
|
|
snapshot.disconnects,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_heartbeat_sent_total",
|
|
"Total number of tunnel heartbeats sent.",
|
|
MetricKind::Counter,
|
|
snapshot.heartbeat_sent,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_heartbeat_ack_total",
|
|
"Total number of tunnel heartbeat acknowledgements received.",
|
|
MetricKind::Counter,
|
|
snapshot.heartbeat_ack,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_heartbeat_rtt_last_ms",
|
|
"Last observed tunnel heartbeat round-trip time in milliseconds.",
|
|
MetricKind::Gauge,
|
|
snapshot.heartbeat_rtt_last_ms,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_heartbeat_rtt_avg_ms",
|
|
"Average observed tunnel heartbeat round-trip time in milliseconds.",
|
|
MetricKind::Gauge,
|
|
snapshot.heartbeat_rtt_avg_ms().unwrap_or(0.0) as u64,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_ws_in_frames_total",
|
|
"Total number of WebSocket frames received by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.ws_in_frames,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_ws_in_bytes_total",
|
|
"Total number of WebSocket bytes received by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.ws_in_bytes,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_ws_out_frames_total",
|
|
"Total number of WebSocket frames sent by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.ws_out_frames,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_ws_out_bytes_total",
|
|
"Total number of WebSocket bytes sent by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.ws_out_bytes,
|
|
)
|
|
.with_labels(labels.clone()),
|
|
MetricSample::new(
|
|
"tunnel_error_events_total",
|
|
"Total number of classified tunnel error events recorded by the tunnel.",
|
|
MetricKind::Counter,
|
|
snapshot.error_events_total,
|
|
)
|
|
.with_labels(labels),
|
|
]
|
|
}
|
|
}
|
|
|
|
fn now_unix_secs() -> u64 {
|
|
now_unix_ms() / 1_000
|
|
}
|
|
|
|
fn now_unix_ms() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn duration_to_millis_u64(duration: Duration) -> u64 {
|
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
|
}
|
|
|
|
fn normalize_error_field(value: &str, max_chars: usize, fallback: &str) -> String {
|
|
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if normalized.is_empty() {
|
|
return fallback.to_string();
|
|
}
|
|
normalized.chars().take(max_chars).collect()
|
|
}
|
|
|
|
struct TunnelErrorDiagnostic {
|
|
severity: &'static str,
|
|
component: &'static str,
|
|
summary: &'static str,
|
|
operator_action: &'static str,
|
|
}
|
|
|
|
fn classify_tunnel_error(category: &str, _message: &str) -> TunnelErrorDiagnostic {
|
|
match category {
|
|
"stale_timeout" => TunnelErrorDiagnostic {
|
|
severity: "warning",
|
|
component: "tunnel_read",
|
|
summary: "No inbound tunnel frames before stale timeout",
|
|
operator_action:
|
|
"Check gateway or reverse-proxy idle timeouts, packet loss, and WebSocket ping/pong reachability. Increase AETHER_TUNNEL_STALE_TIMEOUT_MS if the network is high-latency.",
|
|
},
|
|
"ws_write_error" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_write",
|
|
summary: "WebSocket write failed because the peer closed or reset the connection",
|
|
operator_action:
|
|
"Check gateway restarts, load balancer resets, NAT/firewall connection tracking, and whether the tunnel is reconnecting successfully.",
|
|
},
|
|
"ws_ping_error" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_write",
|
|
summary: "WebSocket keepalive ping could not be sent",
|
|
operator_action:
|
|
"Check whether the peer closed the socket or an intermediary is dropping idle WebSocket connections.",
|
|
},
|
|
"ws_read_error" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_read",
|
|
summary: "WebSocket read failed",
|
|
operator_action:
|
|
"Check gateway logs and network stability around the same timestamp; compare with reconnect and heartbeat ACK counters.",
|
|
},
|
|
"tunnel_connect_error" => TunnelErrorDiagnostic {
|
|
severity: "critical",
|
|
component: "tunnel_connect",
|
|
summary: "Tunnel connection attempt failed",
|
|
operator_action:
|
|
"Check Aether URL reachability, DNS, TLS, management token validity, and any configured AETHER_TUNNEL_AETHER_OUTBOUND_PROXY_URL.",
|
|
},
|
|
"frame_decode_error" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_protocol",
|
|
summary: "Received tunnel frame could not be decoded",
|
|
operator_action:
|
|
"Check tunnel and gateway version compatibility and whether traffic is being modified by an intermediary.",
|
|
},
|
|
"stream_dispatch_timeout" => TunnelErrorDiagnostic {
|
|
severity: "warning",
|
|
component: "stream_dispatch",
|
|
summary: "Request body frame could not be delivered to its stream handler in time",
|
|
operator_action:
|
|
"Check tunnel CPU, memory, stream concurrency saturation, and slow upstream provider requests.",
|
|
},
|
|
"heartbeat_ack_empty" | "heartbeat_ack_parse" => TunnelErrorDiagnostic {
|
|
severity: "warning",
|
|
component: "heartbeat",
|
|
summary: "Heartbeat ACK from gateway was missing or invalid",
|
|
operator_action:
|
|
"Check gateway heartbeat handler logs and tunnel/gateway version compatibility.",
|
|
},
|
|
"writer_task_panic" | "writer_task_cancelled" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_writer",
|
|
summary: "Tunnel writer task exited unexpectedly",
|
|
operator_action:
|
|
"Check tunnel logs for the preceding write or ping error and confirm the tunnel reconnect loop is active.",
|
|
},
|
|
"dispatcher_error" => TunnelErrorDiagnostic {
|
|
severity: "error",
|
|
component: "tunnel_dispatcher",
|
|
summary: "Tunnel dispatcher exited with an error",
|
|
operator_action:
|
|
"Check the proxied request stream and gateway tunnel logs around the same timestamp.",
|
|
},
|
|
_ => TunnelErrorDiagnostic {
|
|
severity: "info",
|
|
component: "tunnel",
|
|
summary: "Tunnel reported an unclassified error",
|
|
operator_action:
|
|
"Inspect the raw message and compare it with tunnel, gateway, and network logs at the same time.",
|
|
},
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
|
pub enum TunnelAdmissionError {
|
|
#[error("tunnel stream admission saturated at {limit} for gate {gate}")]
|
|
Saturated { gate: &'static str, limit: usize },
|
|
#[error("tunnel stream admission unavailable for gate {gate}: {message}")]
|
|
Unavailable {
|
|
gate: &'static str,
|
|
limit: usize,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
impl AppState {
|
|
pub async fn metric_samples(&self) -> Vec<MetricSample> {
|
|
let mut samples = vec![service_up_sample("aether-tunnel")];
|
|
if let Some(snapshot) = self.stream_concurrency_snapshot() {
|
|
samples.extend(snapshot.to_metric_samples("tunnel_streams"));
|
|
}
|
|
if let Some(gate) = self.distributed_stream_gate.as_ref() {
|
|
match gate.snapshot().await {
|
|
Ok(snapshot) => {
|
|
samples.extend(snapshot.to_metric_samples("tunnel_streams_distributed"));
|
|
}
|
|
Err(_) => samples.push(
|
|
MetricSample::new(
|
|
"concurrency_unavailable",
|
|
"Whether the distributed concurrency gate is currently unavailable.",
|
|
MetricKind::Gauge,
|
|
1,
|
|
)
|
|
.with_labels(vec![MetricLabel::new("gate", "tunnel_streams_distributed")]),
|
|
),
|
|
}
|
|
}
|
|
samples
|
|
}
|
|
|
|
pub fn with_stream_concurrency_gate(mut self, gate: Arc<ConcurrencyGate>) -> Self {
|
|
self.stream_gate = Some(gate);
|
|
self
|
|
}
|
|
|
|
pub fn with_distributed_stream_concurrency_gate(mut self, gate: Arc<RuntimeSemaphore>) -> Self {
|
|
self.distributed_stream_gate = Some(gate);
|
|
self
|
|
}
|
|
|
|
pub fn stream_concurrency_snapshot(&self) -> Option<ConcurrencySnapshot> {
|
|
self.stream_gate.as_ref().map(|gate| gate.snapshot())
|
|
}
|
|
|
|
pub async fn distributed_stream_concurrency_snapshot(
|
|
&self,
|
|
) -> Result<Option<RuntimeSemaphoreSnapshot>, RuntimeSemaphoreError> {
|
|
match &self.distributed_stream_gate {
|
|
Some(gate) => gate.snapshot().await.map(Some),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub async fn try_acquire_stream_permit(
|
|
&self,
|
|
) -> Result<Option<AdmissionPermit>, TunnelAdmissionError> {
|
|
let local = match &self.stream_gate {
|
|
Some(gate) => Some(gate.try_acquire().map_err(|err| {
|
|
match err {
|
|
ConcurrencyError::Saturated { gate, limit } => {
|
|
TunnelAdmissionError::Saturated { gate, limit }
|
|
}
|
|
ConcurrencyError::Closed { gate } => TunnelAdmissionError::Unavailable {
|
|
gate,
|
|
limit: self
|
|
.stream_gate
|
|
.as_ref()
|
|
.map(|inner| inner.snapshot().limit)
|
|
.unwrap_or(0),
|
|
message: "local stream gate is closed".to_string(),
|
|
},
|
|
}
|
|
})?),
|
|
None => None,
|
|
};
|
|
|
|
let distributed = match &self.distributed_stream_gate {
|
|
Some(gate) => Some(gate.try_acquire().await.map_err(|err| {
|
|
match err {
|
|
RuntimeSemaphoreError::Saturated { gate, limit } => {
|
|
TunnelAdmissionError::Saturated { gate, limit }
|
|
}
|
|
RuntimeSemaphoreError::Unavailable {
|
|
gate,
|
|
limit,
|
|
message,
|
|
} => TunnelAdmissionError::Unavailable {
|
|
gate,
|
|
limit,
|
|
message,
|
|
},
|
|
RuntimeSemaphoreError::InvalidConfiguration(message) => {
|
|
TunnelAdmissionError::Unavailable {
|
|
gate: "tunnel_streams_distributed",
|
|
limit: self
|
|
.distributed_stream_gate
|
|
.as_ref()
|
|
.map(|inner| inner.limit())
|
|
.unwrap_or(0),
|
|
message,
|
|
}
|
|
}
|
|
}
|
|
})?),
|
|
None => None,
|
|
};
|
|
|
|
Ok(AdmissionPermit::from_parts(local, distributed))
|
|
}
|
|
}
|