mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
use aether_admission_core::{
|
||||
AdmissionDecision, AdmissionPolicy, AdmissionRejectReason, AdmissionRequest,
|
||||
DefaultAdmissionPolicy, ResourceClass,
|
||||
};
|
||||
|
||||
use crate::{DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES, DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TunnelAdmissionClass {
|
||||
Connection,
|
||||
Relay { streaming: bool },
|
||||
Probe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TunnelAdmissionRequest<'a> {
|
||||
pub trace_id: &'a str,
|
||||
pub class: TunnelAdmissionClass,
|
||||
pub body_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TunnelAdmissionPolicy {
|
||||
policy: DefaultAdmissionPolicy,
|
||||
}
|
||||
|
||||
impl TunnelAdmissionPolicy {
|
||||
pub fn decide(&self, request: TunnelAdmissionRequest<'_>) -> AdmissionDecision {
|
||||
let body_limit = body_limit(request.class);
|
||||
if request.body_bytes > body_limit {
|
||||
return AdmissionDecision::Reject(AdmissionRejectReason::BodyTooLarge);
|
||||
}
|
||||
|
||||
match self.policy.decide(AdmissionRequest {
|
||||
trace_id: request.trace_id,
|
||||
class: resource_class(request.class),
|
||||
body_bytes: request.body_bytes,
|
||||
}) {
|
||||
AdmissionDecision::Admit(mut budget) => {
|
||||
budget.body_bytes = body_limit;
|
||||
AdmissionDecision::Admit(budget)
|
||||
}
|
||||
rejected => rejected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn body_limit(class: TunnelAdmissionClass) -> usize {
|
||||
match class {
|
||||
TunnelAdmissionClass::Connection => 0,
|
||||
TunnelAdmissionClass::Relay { .. } => DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES,
|
||||
TunnelAdmissionClass::Probe => DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
const fn resource_class(class: TunnelAdmissionClass) -> ResourceClass {
|
||||
match class {
|
||||
TunnelAdmissionClass::Connection => ResourceClass::Streaming,
|
||||
TunnelAdmissionClass::Relay { streaming: true } => ResourceClass::Streaming,
|
||||
TunnelAdmissionClass::Relay { streaming: false } | TunnelAdmissionClass::Probe => {
|
||||
ResourceClass::Interactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TunnelAdmissionClass, TunnelAdmissionPolicy, TunnelAdmissionRequest};
|
||||
use aether_admission_core::{AdmissionDecision, AdmissionRejectReason, DbClass};
|
||||
|
||||
use crate::DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES;
|
||||
|
||||
#[test]
|
||||
fn stream_connection_reserves_stream_and_upstream_permits() {
|
||||
let decision = TunnelAdmissionPolicy::default().decide(TunnelAdmissionRequest {
|
||||
trace_id: "trace-1",
|
||||
class: TunnelAdmissionClass::Connection,
|
||||
body_bytes: 0,
|
||||
});
|
||||
let AdmissionDecision::Admit(budget) = decision else {
|
||||
panic!("connection should be admitted");
|
||||
};
|
||||
assert_eq!(budget.stream_permits, 1);
|
||||
assert_eq!(budget.upstream_permits, 1);
|
||||
assert_eq!(budget.db_class, DbClass::ForegroundWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_budget_exposes_and_enforces_body_limit() {
|
||||
let policy = TunnelAdmissionPolicy::default();
|
||||
let decision = policy.decide(TunnelAdmissionRequest {
|
||||
trace_id: "trace-2",
|
||||
class: TunnelAdmissionClass::Relay { streaming: false },
|
||||
body_bytes: DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES,
|
||||
});
|
||||
let AdmissionDecision::Admit(budget) = decision else {
|
||||
panic!("relay at the limit should be admitted");
|
||||
};
|
||||
assert_eq!(budget.body_bytes, DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES);
|
||||
|
||||
assert_eq!(
|
||||
policy.decide(TunnelAdmissionRequest {
|
||||
trace_id: "trace-3",
|
||||
class: TunnelAdmissionClass::Relay { streaming: false },
|
||||
body_bytes: DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES + 1,
|
||||
}),
|
||||
AdmissionDecision::Reject(AdmissionRejectReason::BodyTooLarge)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
pub mod protocol;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EmbeddedTunnelDefaults {
|
||||
pub proxy_idle_timeout: Duration,
|
||||
pub ping_interval: Duration,
|
||||
pub max_streams: usize,
|
||||
pub outbound_queue_capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for EmbeddedTunnelDefaults {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy_idle_timeout: Duration::ZERO,
|
||||
ping_interval: Duration::from_secs(15),
|
||||
max_streams: crate::MAX_TUNNEL_STREAMS,
|
||||
outbound_queue_capacity: 512,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EmbeddedTunnelDefaults;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_bounded_for_embedded_runtime() {
|
||||
let defaults = EmbeddedTunnelDefaults::default();
|
||||
assert_eq!(defaults.max_streams, 2_048);
|
||||
assert_eq!(defaults.outbound_queue_capacity, 512);
|
||||
assert!(defaults.proxy_idle_timeout.is_zero());
|
||||
assert_eq!(defaults.ping_interval.as_secs(), 15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Compatibility helpers used by the embedded gateway tunnel.
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
pub use aether_contracts::tunnel::{
|
||||
decode_payload, encode_connection_close, encode_frame, encode_goaway, encode_goaway_v3,
|
||||
encode_hello, encode_load_report, encode_ping, encode_pong, encode_reset_stream,
|
||||
encode_settings, encode_stream_error, encode_window_update, frame_payload_by_header,
|
||||
ConnectionClosePayload, FrameHeader, GoAwayPayload, HelloPayload, LoadReportPayload,
|
||||
RequestMeta, ResetStreamPayload, ResponseMeta, SettingsPayload, WindowUpdatePayload,
|
||||
CONNECTION_CLOSE, FLAG_END_STREAM, FLAG_GZIP_COMPRESSED, GOAWAY, HEADER_SIZE, HEARTBEAT_ACK,
|
||||
HEARTBEAT_DATA, HELLO, LOAD_REPORT, PING, PONG, REQUEST_BODY, REQUEST_HEADERS, RESET_STREAM,
|
||||
RESPONSE_BODY, RESPONSE_HEADERS, SETTINGS, STREAM_END, STREAM_ERROR, WINDOW_UPDATE,
|
||||
};
|
||||
|
||||
pub fn compress_payload(payload: &[u8]) -> Result<(Vec<u8>, u8), std::io::Error> {
|
||||
let (compressed, flags) =
|
||||
aether_contracts::tunnel::compress_payload(Bytes::copy_from_slice(payload));
|
||||
Ok((compressed.to_vec(), flags))
|
||||
}
|
||||
|
||||
pub fn raw_payload(payload: &[u8]) -> (Vec<u8>, u8) {
|
||||
let (payload, flags) = aether_contracts::tunnel::raw_payload(Bytes::copy_from_slice(payload));
|
||||
(payload.to_vec(), flags)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
compress_payload, decode_payload, encode_frame, raw_payload, FrameHeader,
|
||||
FLAG_GZIP_COMPRESSED, RESPONSE_BODY,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn vec_compatibility_helpers_round_trip_payloads() {
|
||||
let input = vec![b'a'; 4_096];
|
||||
let (compressed, flags) = compress_payload(&input).expect("payload should compress");
|
||||
assert_eq!(flags & FLAG_GZIP_COMPRESSED, FLAG_GZIP_COMPRESSED);
|
||||
let frame = encode_frame(1, RESPONSE_BODY, flags, &compressed);
|
||||
let header = FrameHeader::parse(&frame).expect("frame header should parse");
|
||||
let decoded = decode_payload(&frame, &header).expect("payload should decode");
|
||||
assert_eq!(decoded, input);
|
||||
|
||||
let (raw, raw_flags) = raw_payload(&input);
|
||||
assert_eq!(raw, input);
|
||||
assert_eq!(raw_flags, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use base64::Engine as _;
|
||||
use http::HeaderMap;
|
||||
|
||||
pub const MAX_TUNNEL_STREAMS: usize = 2_048;
|
||||
|
||||
pub fn resolve_proxy_max_streams(headers: &HeaderMap, fallback: usize) -> usize {
|
||||
headers
|
||||
.get("x-tunnel-max-streams")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(fallback)
|
||||
.clamp(1, MAX_TUNNEL_STREAMS)
|
||||
}
|
||||
|
||||
pub fn resolve_proxy_node_name(headers: &HeaderMap, node_id: &str) -> String {
|
||||
if let Some(decoded) = headers
|
||||
.get(aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(value.trim())
|
||||
.ok()
|
||||
})
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty() && value.chars().count() <= 100)
|
||||
{
|
||||
return decoded;
|
||||
}
|
||||
|
||||
headers
|
||||
.get("x-node-name")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(node_id)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn resolve_proxy_protocol_version(headers: &HeaderMap) -> u8 {
|
||||
headers
|
||||
.get(aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
.filter(|value| *value >= 1)
|
||||
.map(|value| value.min(aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
resolve_proxy_max_streams, resolve_proxy_node_name, resolve_proxy_protocol_version,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
|
||||
#[test]
|
||||
fn resolves_bounded_proxy_capacity() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-tunnel-max-streams", HeaderValue::from_static("8"));
|
||||
assert_eq!(resolve_proxy_max_streams(&headers, 128), 8);
|
||||
|
||||
headers.insert("x-tunnel-max-streams", HeaderValue::from_static("9999"));
|
||||
assert_eq!(resolve_proxy_max_streams(&headers, 128), 2_048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_protocol_version_with_v1_fallback() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(resolve_proxy_protocol_version(&headers), 1);
|
||||
|
||||
headers.insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
HeaderValue::from_static("2"),
|
||||
);
|
||||
assert_eq!(resolve_proxy_protocol_version(&headers), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_utf8_node_name_with_legacy_fallback() {
|
||||
let mut headers = HeaderMap::new();
|
||||
let utf8_name = "\u{65e5}\u{672c}\u{8282}\u{70b9}";
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(utf8_name);
|
||||
headers.insert(
|
||||
aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER,
|
||||
HeaderValue::from_str(&encoded).expect("encoded header value should parse"),
|
||||
);
|
||||
assert_eq!(resolve_proxy_node_name(&headers, "node-1"), utf8_name);
|
||||
|
||||
headers.remove(aether_contracts::tunnel::TUNNEL_NODE_NAME_B64_HEADER);
|
||||
headers.insert("x-node-name", HeaderValue::from_static("edge-1"));
|
||||
assert_eq!(resolve_proxy_node_name(&headers, "node-1"), "edge-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Shared tunnel request-lifecycle contracts.
|
||||
//!
|
||||
//! The executable tunnel agent and the embedded gateway tunnel both depend on
|
||||
//! this crate. Network runtimes, persistence, and HTTP handlers remain in
|
||||
//! their respective adapter crates.
|
||||
|
||||
pub mod admission;
|
||||
pub mod embedded;
|
||||
pub mod hub;
|
||||
pub mod protocol;
|
||||
pub mod relay;
|
||||
|
||||
pub use admission::{TunnelAdmissionClass, TunnelAdmissionPolicy, TunnelAdmissionRequest};
|
||||
pub use embedded::EmbeddedTunnelDefaults;
|
||||
pub use hub::{
|
||||
resolve_proxy_max_streams, resolve_proxy_node_name, resolve_proxy_protocol_version,
|
||||
MAX_TUNNEL_STREAMS,
|
||||
};
|
||||
pub use relay::{
|
||||
is_tunnel_heartbeat_path, is_tunnel_node_status_path, TunnelAttachmentRecord,
|
||||
DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES, DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES, PROXY_TUNNEL_PATH,
|
||||
TUNNEL_HEARTBEAT_PATH, TUNNEL_NODE_STATUS_PATH, TUNNEL_RELAY_PATH_PATTERN, TUNNEL_ROUTE_FAMILY,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Canonical tunnel wire protocol exports shared by both runtimes.
|
||||
|
||||
pub use aether_contracts::tunnel::*;
|
||||
@@ -0,0 +1,69 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
pub const TUNNEL_HEARTBEAT_PATH: &str = "/api/internal/tunnel/heartbeat";
|
||||
pub const TUNNEL_NODE_STATUS_PATH: &str = "/api/internal/tunnel/node-status";
|
||||
pub const TUNNEL_RELAY_PATH_PATTERN: &str = "/api/internal/tunnel/relay/{node_id}";
|
||||
pub const TUNNEL_ROUTE_FAMILY: &str = "tunnel_manage";
|
||||
|
||||
pub const DEFAULT_OWNER_RELAY_BODY_LIMIT_BYTES: usize = 5_242_880;
|
||||
pub const DEFAULT_TUNNEL_PROBE_BODY_LIMIT_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TunnelAttachmentRecord {
|
||||
pub gateway_instance_id: String,
|
||||
pub relay_base_url: String,
|
||||
pub conn_count: usize,
|
||||
pub observed_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl TunnelAttachmentRecord {
|
||||
pub fn is_routable(&self, now_unix_secs: u64, ttl_secs: u64) -> bool {
|
||||
self.conn_count > 0
|
||||
&& !self.relay_base_url.trim().is_empty()
|
||||
&& self.observed_at_unix_secs.saturating_add(ttl_secs) >= now_unix_secs
|
||||
}
|
||||
|
||||
pub fn is_owned_by(&self, gateway_instance_id: &str) -> bool {
|
||||
self.gateway_instance_id == gateway_instance_id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tunnel_heartbeat_path(path: &str) -> bool {
|
||||
path == TUNNEL_HEARTBEAT_PATH
|
||||
}
|
||||
|
||||
pub fn is_tunnel_node_status_path(path: &str) -> bool {
|
||||
path == TUNNEL_NODE_STATUS_PATH
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TunnelAttachmentRecord;
|
||||
|
||||
fn record() -> TunnelAttachmentRecord {
|
||||
TunnelAttachmentRecord {
|
||||
gateway_instance_id: "gateway-a".to_string(),
|
||||
relay_base_url: "http://gateway-a.internal".to_string(),
|
||||
conn_count: 1,
|
||||
observed_at_unix_secs: 100,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_is_routable_until_ttl_boundary() {
|
||||
let record = record();
|
||||
assert!(record.is_routable(190, 90));
|
||||
assert!(!record.is_routable(191, 90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_requires_connection_and_relay_url() {
|
||||
let mut record = record();
|
||||
record.conn_count = 0;
|
||||
assert!(!record.is_routable(100, 90));
|
||||
record.conn_count = 1;
|
||||
record.relay_base_url.clear();
|
||||
assert!(!record.is_routable(100, 90));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user