feat(core): add axum HTTP router scaffold with auth middleware and stealth handoff
Add http feature flag with axum, hyper, hyper-util, tower, and http-body-util dependencies. Create http module with auth middleware (extracts Bearer token, calls IdentityProvider::resolve_from_token, attaches Identity to extensions) and router scaffold (default 404 fallback, no operational routes yet). Replace send_fake_nginx_404 with axum router handoff when http feature is enabled; fake 404 behavior preserved when http is disabled. Wire HttpInterface with build_router() method and pass IdentityProvider through Server to handle_connection.
This commit is contained in:
81
Cargo.lock
generated
81
Cargo.lock
generated
@@ -77,8 +77,12 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"futures",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"ipnetwork",
|
||||
"iroh",
|
||||
"rand 0.10.1",
|
||||
@@ -97,6 +101,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
@@ -402,6 +407,58 @@ dependencies = [
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.1",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backon"
|
||||
version = "1.6.0"
|
||||
@@ -2380,6 +2437,12 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
@@ -2392,6 +2455,12 @@ version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -4111,6 +4180,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -4738,6 +4818,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,6 +14,7 @@ default = []
|
||||
tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pki-types", "dep:webpki-roots"]
|
||||
iroh = ["dep:iroh", "dep:url"]
|
||||
acme = ["dep:rustls-acme", "dep:futures", "tls"]
|
||||
http = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:tower", "dep:http-body-util"]
|
||||
irpc = []
|
||||
testutil = []
|
||||
transport-traits = []
|
||||
@@ -40,9 +41,14 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
axum = { version = "0.8", optional = true }
|
||||
hyper = { version = "1", optional = true }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "server", "service"], optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
http-body-util = { version = "0.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
alknet-core = { path = ".", features = ["testutil", "tls", "iroh"] }
|
||||
alknet-core = { path = ".", features = ["testutil", "tls", "iroh", "http"] }
|
||||
tempfile = "3"
|
||||
rcgen = "0.14"
|
||||
rand_core = "0.6"
|
||||
|
||||
182
crates/alknet-core/src/http/auth.rs
Normal file
182
crates/alknet-core/src/http/auth.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::auth::{AuthToken, Identity, IdentityProvider};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityExt(pub Identity);
|
||||
|
||||
pub async fn auth_middleware(
|
||||
axum::extract::State(identity_provider): axum::extract::State<
|
||||
std::sync::Arc<dyn IdentityProvider>,
|
||||
>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let token_str = match auth_header {
|
||||
Some(h) if h.starts_with("Bearer ") => &h[7..],
|
||||
_ => {
|
||||
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let token = AuthToken {
|
||||
raw: token_str.as_bytes().to_vec(),
|
||||
};
|
||||
|
||||
match identity_provider.resolve_from_token(&token) {
|
||||
Some(identity) => {
|
||||
request.extensions_mut().insert(IdentityExt(identity));
|
||||
next.run(request).await
|
||||
}
|
||||
None => axum::http::StatusCode::UNAUTHORIZED.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request as HttpRequest, StatusCode};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct MockIdentityProvider {
|
||||
valid_token: String,
|
||||
identity: Identity,
|
||||
}
|
||||
|
||||
impl IdentityProvider for MockIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
let token_str = String::from_utf8_lossy(&token.raw);
|
||||
if token_str == self.valid_token {
|
||||
Some(self.identity.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_provider(valid_token: &str) -> Arc<dyn IdentityProvider> {
|
||||
let identity = Identity {
|
||||
id: "test-user".to_string(),
|
||||
scopes: vec!["relay:connect".to_string()],
|
||||
resources: HashMap::new(),
|
||||
};
|
||||
Arc::new(MockIdentityProvider {
|
||||
valid_token: valid_token.to_string(),
|
||||
identity,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_middleware_extracts_bearer_token() {
|
||||
let provider = make_provider("alk_validtoken1");
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/test",
|
||||
get(|request: Request| async move {
|
||||
let has_identity = request.extensions().get::<IdentityExt>().is_some();
|
||||
if has_identity {
|
||||
StatusCode::OK.into_response()
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
provider,
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/test")
|
||||
.header("authorization", "Bearer alk_validtoken1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_middleware_returns_401_for_missing_token() {
|
||||
let provider = make_provider("alk_validtoken1");
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { StatusCode::OK.into_response() }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
provider,
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_middleware_returns_401_for_invalid_token() {
|
||||
let provider = make_provider("alk_validtoken1");
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { StatusCode::OK.into_response() }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
provider,
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/test")
|
||||
.header("authorization", "Bearer alk_wrongtoken1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_middleware_attaches_identity_to_extensions() {
|
||||
let provider = make_provider("alk_testidentity1");
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/test",
|
||||
get(|request: Request| async move {
|
||||
let identity = request.extensions().get::<IdentityExt>().unwrap();
|
||||
identity.0.id.clone()
|
||||
}),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
provider,
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/test")
|
||||
.header("authorization", "Bearer alk_testidentity1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
assert_eq!(&body[..], b"test-user");
|
||||
}
|
||||
}
|
||||
5
crates/alknet-core/src/http/mod.rs
Normal file
5
crates/alknet-core/src/http/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod router;
|
||||
|
||||
pub use auth::IdentityExt;
|
||||
pub use router::{build_router, serve_connection};
|
||||
150
crates/alknet-core/src/http/router.rs
Normal file
150
crates/alknet-core/src/http/router.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Router;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use hyper_util::server::conn::auto::Builder;
|
||||
use hyper_util::service::TowerToHyperService;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
|
||||
|
||||
use crate::auth::IdentityProvider;
|
||||
use crate::http::auth::auth_middleware;
|
||||
|
||||
async fn default_404() -> impl IntoResponse {
|
||||
axum::http::StatusCode::NOT_FOUND
|
||||
}
|
||||
|
||||
pub fn build_router(identity_provider: Arc<dyn IdentityProvider>) -> Router {
|
||||
Router::new()
|
||||
.fallback(default_404)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
identity_provider,
|
||||
auth_middleware,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn serve_connection<S>(stream: S, identity_provider: Arc<dyn IdentityProvider>)
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let app = build_router(identity_provider);
|
||||
let io = TokioIo::new(stream);
|
||||
|
||||
let hyper_service = TowerToHyperService::new(app.into_service::<hyper::body::Incoming>());
|
||||
|
||||
let result = Builder::new(TokioExecutor::new())
|
||||
.serve_connection_with_upgrades(io, hyper_service)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("http connection error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_connection_from_reader<S>(
|
||||
reader: BufReader<S>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
) where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
serve_connection(reader, identity_provider).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{AuthToken, Identity};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request as HttpRequest, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NullIdentityProvider;
|
||||
|
||||
impl IdentityProvider for NullIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_404_handler_returns_not_found() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
|
||||
let app = build_router(provider);
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/anything")
|
||||
.header("authorization", "Bearer alk_sometoken1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_returns_401_before_404() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
|
||||
let app = build_router(provider);
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/anything")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_auth_returns_401_before_404() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NullIdentityProvider);
|
||||
let app = build_router(provider);
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/anything")
|
||||
.header("authorization", "Bearer alk_sometoken1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmatched_route_returns_404_with_valid_auth() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
|
||||
let app = build_router(provider);
|
||||
|
||||
let req = HttpRequest::builder()
|
||||
.uri("/v1/unknown/op")
|
||||
.header("authorization", "Bearer alk_valid")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
struct MockValidProvider;
|
||||
|
||||
impl IdentityProvider for MockValidProvider {
|
||||
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
Some(Identity {
|
||||
id: "test".to_string(),
|
||||
scopes: vec![],
|
||||
resources: HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ impl MessageInterface for HttpInterface {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
impl HttpInterface {
|
||||
pub fn build_router(&self) -> axum::Router {
|
||||
crate::http::router::build_router(Arc::clone(&self.identity_provider))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -42,4 +49,18 @@ mod tests {
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[test]
|
||||
fn http_interface_builds_router() {
|
||||
let registry = Arc::new(crate::call::OperationRegistry::new());
|
||||
let iface = HttpInterface {
|
||||
identity_provider: Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
|
||||
arc_swap::ArcSwap::new(Arc::new(crate::config::DynamicConfig::default())),
|
||||
))),
|
||||
env: OperationEnv::local(crate::call::OperationRegistry::new()),
|
||||
registry,
|
||||
};
|
||||
let _router = iface.build_router();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ pub mod server;
|
||||
pub mod socks5;
|
||||
pub mod transport;
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
pub mod http;
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
pub use http::IdentityExt;
|
||||
|
||||
#[cfg(feature = "testutil")]
|
||||
pub mod testutil;
|
||||
|
||||
|
||||
@@ -28,5 +28,6 @@ pub use serve::{
|
||||
|
||||
pub use crate::transport::TransportKind;
|
||||
pub use stealth::{
|
||||
detect_protocol, send_fake_nginx_404, validate_stealth_config, ProtocolDetection,
|
||||
detect_protocol, handle_http_stealth, send_fake_nginx_404, validate_stealth_config,
|
||||
ProtocolDetection,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,9 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::identity::ConfigIdentityProvider;
|
||||
use crate::auth::keys::KeySource;
|
||||
use crate::auth::IdentityProvider;
|
||||
use crate::config::{ConfigReloadHandle, DynamicConfig};
|
||||
use crate::error::ConfigError;
|
||||
use crate::interface::pairs::is_valid_pair;
|
||||
@@ -522,6 +524,7 @@ struct ActiveSession {
|
||||
pub struct Server {
|
||||
config: Arc<server::Config>,
|
||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
connection_limiter: Arc<ConnectionRateLimiter>,
|
||||
outbound_proxy: Option<ProxyConfig>,
|
||||
listeners: Vec<ListenerConfig>,
|
||||
@@ -551,10 +554,13 @@ impl Server {
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let dynamic = Arc::new(ArcSwap::new(Arc::new(dynamic_config)));
|
||||
let identity_provider: Arc<dyn IdentityProvider> =
|
||||
Arc::new(ConfigIdentityProvider::new(Arc::clone(&dynamic)));
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
dynamic,
|
||||
identity_provider,
|
||||
connection_limiter,
|
||||
outbound_proxy: static_config.proxy_config,
|
||||
listeners: static_config.listeners,
|
||||
@@ -734,11 +740,19 @@ impl Server {
|
||||
|
||||
let config = Arc::clone(&server.config);
|
||||
let sessions = Arc::clone(&server.sessions);
|
||||
let identity_provider = Arc::clone(&server.identity_provider);
|
||||
let transport_is_tls = matches!(transport_kind, TransportKind::Tls { .. });
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result =
|
||||
handle_connection(stream, config, handler, sessions, stealth, transport_is_tls)
|
||||
let result = handle_connection(
|
||||
stream,
|
||||
config,
|
||||
handler,
|
||||
sessions,
|
||||
identity_provider,
|
||||
stealth,
|
||||
transport_is_tls,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -765,6 +779,7 @@ async fn handle_connection<S>(
|
||||
config: Arc<Config>,
|
||||
handler: ServerHandler,
|
||||
sessions: Arc<tokio::sync::Mutex<Vec<ActiveSession>>>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
stealth: bool,
|
||||
transport_is_tls: bool,
|
||||
) -> Result<(), anyhow::Error>
|
||||
@@ -772,10 +787,10 @@ where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
if stealth && transport_is_tls {
|
||||
let (protocol, mut reader) = stealth::detect_protocol(stream).await;
|
||||
let (protocol, reader) = stealth::detect_protocol(stream).await;
|
||||
match protocol {
|
||||
ProtocolDetection::Http => {
|
||||
stealth::send_fake_nginx_404(&mut reader).await;
|
||||
stealth::handle_http_stealth(reader, identity_provider).await;
|
||||
return Ok(());
|
||||
}
|
||||
ProtocolDetection::Ssh => {
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
//!
|
||||
//! When stealth mode is enabled with TLS transport, the server peeks at the first
|
||||
//! bytes after the TLS handshake to determine whether the client is speaking SSH
|
||||
//! or HTTP. Non-SSH connections receive a fake nginx 404 response, making the
|
||||
//! server appear as an ordinary web server to port scanners and DPI systems.
|
||||
//! See ADR-017.
|
||||
//! or HTTP. When the `http` feature is enabled, detected HTTP traffic is routed to
|
||||
//! the axum router. When `http` is disabled, non-SSH connections receive a fake
|
||||
//! nginx 404 response, making the server appear as an ordinary web server to port
|
||||
//! scanners and DPI systems. See ADR-017.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::auth::IdentityProvider;
|
||||
|
||||
const SSH_BANNER_PREFIX: &[u8] = b"SSH-2.0-";
|
||||
const FAKE_NGINX_404: &[u8] = b"HTTP/1.1 404 Not Found\r\nServer: nginx\r\n\r\n";
|
||||
|
||||
@@ -52,6 +57,26 @@ where
|
||||
let _ = reader.get_mut().shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
pub async fn handle_http_stealth<S>(
|
||||
reader: BufReader<S>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
) where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
crate::http::router::serve_connection_from_reader(reader, identity_provider).await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "http"))]
|
||||
pub async fn handle_http_stealth<S>(
|
||||
mut reader: BufReader<S>,
|
||||
_identity_provider: Arc<dyn IdentityProvider>,
|
||||
) where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
send_fake_nginx_404(&mut reader).await
|
||||
}
|
||||
|
||||
pub fn validate_stealth_config(stealth: bool, transport_is_tls: bool) -> Result<(), &'static str> {
|
||||
if stealth && !transport_is_tls {
|
||||
return Err("stealth mode requires TLS transport (--transport tls)");
|
||||
@@ -232,4 +257,60 @@ mod tests {
|
||||
let result = client.read(&mut extra).await;
|
||||
assert!(result.is_err() || result.unwrap() == 0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[tokio::test]
|
||||
async fn stealth_handoff_routes_http_to_axum() {
|
||||
use crate::auth::{AuthToken, IdentityProvider};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
struct NullProvider;
|
||||
|
||||
impl IdentityProvider for NullProvider {
|
||||
fn resolve_from_fingerprint(
|
||||
&self,
|
||||
_fingerprint: &str,
|
||||
) -> Option<crate::auth::Identity> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<crate::auth::Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let (client, server) = duplex(4096);
|
||||
let (mut client_read, mut client_write) = tokio::io::split(client);
|
||||
|
||||
client_write
|
||||
.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(client_write);
|
||||
|
||||
let (detection, reader) = detect_protocol(server).await;
|
||||
assert_eq!(detection, ProtocolDetection::Http);
|
||||
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NullProvider);
|
||||
let handle = tokio::spawn(async move {
|
||||
handle_http_stealth(reader, provider).await;
|
||||
});
|
||||
|
||||
let mut buf = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut client_read, &mut buf)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = String::from_utf8_lossy(&buf);
|
||||
assert!(
|
||||
response.contains("401"),
|
||||
"expected 401 from axum auth middleware, got: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("nginx"),
|
||||
"should not contain fake nginx response when http feature is enabled"
|
||||
);
|
||||
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user