Compare commits
1 Commits
feat/trans
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
| 24b92227e7 |
@@ -10,7 +10,7 @@ name = "wraith_core"
|
|||||||
default = []
|
default = []
|
||||||
tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pki-types", "dep:webpki-roots"]
|
tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pki-types", "dep:webpki-roots"]
|
||||||
iroh = ["dep:iroh", "dep:url"]
|
iroh = ["dep:iroh", "dep:url"]
|
||||||
acme = ["dep:rustls-acme", "dep:futures", "tls"]
|
acme = ["dep:rustls-acme", "tls"]
|
||||||
testutil = []
|
testutil = []
|
||||||
transport-traits = []
|
transport-traits = []
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ tokio-rustls = { version = "0.26", optional = true }
|
|||||||
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
|
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
|
||||||
rustls-pki-types = { version = "1", optional = true }
|
rustls-pki-types = { version = "1", optional = true }
|
||||||
rustls-acme = { version = "0.12", optional = true }
|
rustls-acme = { version = "0.12", optional = true }
|
||||||
futures = { version = "0.3", optional = true }
|
|
||||||
webpki-roots = { version = "0.26", optional = true }
|
webpki-roots = { version = "0.26", optional = true }
|
||||||
iroh = { version = "0.34", optional = true }
|
iroh = { version = "0.34", optional = true }
|
||||||
url = { version = "2", optional = true }
|
url = { version = "2", optional = true }
|
||||||
|
|||||||
303
crates/wraith-core/src/server/handler.rs
Normal file
303
crates/wraith-core/src/server/handler.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use russh::keys::ssh_key::HashAlg;
|
||||||
|
use russh::server::{Auth, Handler, Msg, Session};
|
||||||
|
use russh::Channel;
|
||||||
|
|
||||||
|
use crate::auth::ServerAuthConfig;
|
||||||
|
|
||||||
|
const WRAITH_PREFIX: &str = "wraith-";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ProxyMode {
|
||||||
|
Direct,
|
||||||
|
Socks5(SocketAddr),
|
||||||
|
HttpConnect(SocketAddr),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProxyConfig {
|
||||||
|
pub mode: ProxyMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerHandler {
|
||||||
|
auth_config: Arc<ServerAuthConfig>,
|
||||||
|
outbound_proxy: Option<ProxyConfig>,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHandler {
|
||||||
|
pub fn new(
|
||||||
|
auth_config: Arc<ServerAuthConfig>,
|
||||||
|
outbound_proxy: Option<ProxyConfig>,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
auth_config,
|
||||||
|
outbound_proxy,
|
||||||
|
remote_addr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Handler for ServerHandler {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn auth_publickey(
|
||||||
|
&mut self,
|
||||||
|
user: &str,
|
||||||
|
public_key: &russh::keys::ssh_key::PublicKey,
|
||||||
|
) -> Result<Auth, Self::Error> {
|
||||||
|
let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
|
||||||
|
let remote_addr_display = self
|
||||||
|
.remote_addr
|
||||||
|
.map_or("unknown".to_string(), |a| a.to_string());
|
||||||
|
|
||||||
|
let russh_pub = russh::keys::PublicKey::new(public_key.key_data().clone(), user);
|
||||||
|
let result = self.auth_config.authenticate_publickey(&russh_pub);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
remote_addr = %remote_addr_display,
|
||||||
|
key_fingerprint = %fingerprint,
|
||||||
|
result = "accept",
|
||||||
|
"auth attempt"
|
||||||
|
);
|
||||||
|
Ok(Auth::Accept)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::info!(
|
||||||
|
remote_addr = %remote_addr_display,
|
||||||
|
key_fingerprint = %fingerprint,
|
||||||
|
result = "reject",
|
||||||
|
"auth attempt"
|
||||||
|
);
|
||||||
|
Ok(Auth::Reject {
|
||||||
|
proceed_with_methods: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_direct_tcpip(
|
||||||
|
&mut self,
|
||||||
|
channel: Channel<Msg>,
|
||||||
|
host_to_connect: &str,
|
||||||
|
port_to_connect: u32,
|
||||||
|
originator_address: &str,
|
||||||
|
originator_port: u32,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
if host_to_connect.starts_with(WRAITH_PREFIX) {
|
||||||
|
tracing::info!(
|
||||||
|
host = host_to_connect,
|
||||||
|
port = port_to_connect,
|
||||||
|
"routing to internal control channel handler"
|
||||||
|
);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy_info = self
|
||||||
|
.outbound_proxy
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| format!("{:?}", p.mode))
|
||||||
|
.unwrap_or_else(|| "direct".to_string());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
host = host_to_connect,
|
||||||
|
port = port_to_connect,
|
||||||
|
originator_address = originator_address,
|
||||||
|
originator_port = originator_port,
|
||||||
|
proxy = %proxy_info,
|
||||||
|
"spawning tcp proxy task"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = channel;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
_channel: Channel<Msg>,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_x11(
|
||||||
|
&mut self,
|
||||||
|
_channel: Channel<Msg>,
|
||||||
|
_originator_address: &str,
|
||||||
|
_originator_port: u32,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_forwarded_tcpip(
|
||||||
|
&mut self,
|
||||||
|
_channel: Channel<Msg>,
|
||||||
|
_host_to_connect: &str,
|
||||||
|
_port_to_connect: u32,
|
||||||
|
_originator_address: &str,
|
||||||
|
_originator_port: u32,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::auth::keys::KeySource;
|
||||||
|
use russh::keys::{decode_secret_key, PrivateKey};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||||
|
|
||||||
|
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
|
||||||
|
|
||||||
|
fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile {
|
||||||
|
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
f.write_all(keys_content.as_bytes()).unwrap();
|
||||||
|
f.flush().unwrap();
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_key() -> PrivateKey {
|
||||||
|
decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_auth_config(keys_content: &str) -> Arc<ServerAuthConfig> {
|
||||||
|
let f = make_authorized_keys_file(keys_content);
|
||||||
|
Arc::new(
|
||||||
|
ServerAuthConfig::from_keys_and_ca(
|
||||||
|
Some(KeySource::File(f.path().to_path_buf())),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_empty_auth_config() -> Arc<ServerAuthConfig> {
|
||||||
|
Arc::new(ServerAuthConfig::from_keys_and_ca(None, None).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_delegation_accepts_known_key() {
|
||||||
|
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
|
||||||
|
let mut handler = ServerHandler::new(auth_config, None, None);
|
||||||
|
|
||||||
|
let ssh_key = load_key().public_key().clone();
|
||||||
|
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
|
||||||
|
assert_eq!(result, Auth::Accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_delegation_rejects_unknown_key() {
|
||||||
|
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
|
||||||
|
let mut handler = ServerHandler::new(auth_config, None, None);
|
||||||
|
|
||||||
|
let other_key_text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
|
||||||
|
let other_ssh_key = russh::keys::parse_public_key_base64(
|
||||||
|
other_key_text.split_whitespace().nth(1).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.auth_publickey("testuser", &other_ssh_key)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Auth::Reject {
|
||||||
|
proceed_with_methods: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_delegation_empty_config_rejects_all() {
|
||||||
|
let auth_config = make_empty_auth_config();
|
||||||
|
let mut handler = ServerHandler::new(auth_config, None, None);
|
||||||
|
|
||||||
|
let ssh_key = load_key().public_key().clone();
|
||||||
|
let result = handler
|
||||||
|
.auth_publickey("testuser", &ssh_key)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Auth::Reject {
|
||||||
|
proceed_with_methods: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_logging_includes_remote_addr() {
|
||||||
|
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
|
||||||
|
let remote_addr: SocketAddr = "203.0.113.50:12345".parse().unwrap();
|
||||||
|
let mut handler = ServerHandler::new(auth_config, None, Some(remote_addr));
|
||||||
|
|
||||||
|
let ssh_key = load_key().public_key().clone();
|
||||||
|
let _ = handler.auth_publickey("root", &ssh_key).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_wraith_destination_routing() {
|
||||||
|
assert!("wraith-control".starts_with(WRAITH_PREFIX));
|
||||||
|
assert!("wraith-status".starts_with(WRAITH_PREFIX));
|
||||||
|
assert!("wraith-events".starts_with(WRAITH_PREFIX));
|
||||||
|
assert!(!"example.com".starts_with(WRAITH_PREFIX));
|
||||||
|
assert!(!"localhost".starts_with(WRAITH_PREFIX));
|
||||||
|
assert!(!"wraith.example.com".starts_with(WRAITH_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_mode_variants() {
|
||||||
|
let direct = ProxyMode::Direct;
|
||||||
|
let socks5 = ProxyMode::Socks5("127.0.0.1:9050".parse().unwrap());
|
||||||
|
let http = ProxyMode::HttpConnect("127.0.0.1:8080".parse().unwrap());
|
||||||
|
|
||||||
|
match direct {
|
||||||
|
ProxyMode::Direct => {}
|
||||||
|
_ => panic!("expected Direct"),
|
||||||
|
}
|
||||||
|
match socks5 {
|
||||||
|
ProxyMode::Socks5(_) => {}
|
||||||
|
_ => panic!("expected Socks5"),
|
||||||
|
}
|
||||||
|
match http {
|
||||||
|
ProxyMode::HttpConnect(_) => {}
|
||||||
|
_ => panic!("expected HttpConnect"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_handler_holds_config() {
|
||||||
|
let auth_config = make_empty_auth_config();
|
||||||
|
let proxy = Some(ProxyConfig {
|
||||||
|
mode: ProxyMode::Socks5("127.0.0.1:9050".parse().unwrap()),
|
||||||
|
});
|
||||||
|
let remote: Option<SocketAddr> = Some("10.0.0.1:22".parse().unwrap());
|
||||||
|
|
||||||
|
let handler = ServerHandler::new(auth_config, proxy.clone(), remote);
|
||||||
|
assert!(handler.outbound_proxy.is_some());
|
||||||
|
assert!(handler.remote_addr.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_handler_per_connection() {
|
||||||
|
let auth_config = make_empty_auth_config();
|
||||||
|
let handler1 = ServerHandler::new(auth_config.clone(), None, Some("10.0.0.1:22".parse().unwrap()));
|
||||||
|
let handler2 = ServerHandler::new(auth_config.clone(), None, Some("10.0.0.2:22".parse().unwrap()));
|
||||||
|
|
||||||
|
assert!(handler1.remote_addr != handler2.remote_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/wraith-core/src/server/mod.rs
Normal file
3
crates/wraith-core/src/server/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod handler;
|
||||||
|
|
||||||
|
pub use handler::{ProxyConfig, ProxyMode, ServerHandler};
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use rustls::crypto::aws_lc_rs::default_provider;
|
|
||||||
use rustls::ServerConfig;
|
|
||||||
use rustls_acme::caches::DirCache;
|
|
||||||
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme};
|
|
||||||
use tracing::{error, info};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio_rustls::TlsAcceptor as TokioTlsAcceptor;
|
|
||||||
|
|
||||||
use super::{TransportAcceptor, TransportInfo, TransportKind};
|
|
||||||
|
|
||||||
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum AcmeMode {
|
|
||||||
Domain { domain: String },
|
|
||||||
Ip,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AcmeCertProvider {
|
|
||||||
mode: AcmeMode,
|
|
||||||
cache_dir: Option<PathBuf>,
|
|
||||||
directory_url: String,
|
|
||||||
contact: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for AcmeCertProvider {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("AcmeCertProvider")
|
|
||||||
.field("mode", &self.mode)
|
|
||||||
.field("cache_dir", &self.cache_dir)
|
|
||||||
.field("directory_url", &self.directory_url)
|
|
||||||
.field("contact", &self.contact)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AcmeCertProvider {
|
|
||||||
pub fn new(mode: AcmeMode) -> Self {
|
|
||||||
Self {
|
|
||||||
mode,
|
|
||||||
cache_dir: None,
|
|
||||||
directory_url: rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY.to_string(),
|
|
||||||
contact: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn domain(domain: impl Into<String>) -> Self {
|
|
||||||
Self::new(AcmeMode::Domain {
|
|
||||||
domain: domain.into(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ip() -> Self {
|
|
||||||
Self::new(AcmeMode::Ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
|
||||||
self.cache_dir = Some(dir.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_directory(mut self, url: impl Into<String>) -> Self {
|
|
||||||
self.directory_url = url.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_production_directory(mut self) -> Self {
|
|
||||||
self.directory_url = rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY.to_string();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_contact(mut self, contact: impl Into<String>) -> Self {
|
|
||||||
self.contact.push(contact.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mode(&self) -> &AcmeMode {
|
|
||||||
&self.mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_acme_state(&self) -> (AcmeState<std::io::Error>, Arc<ResolvesServerCertAcme>) {
|
|
||||||
let domains: Vec<String> = match &self.mode {
|
|
||||||
AcmeMode::Domain { domain } => vec![domain.clone()],
|
|
||||||
AcmeMode::Ip => vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let base_config = AcmeConfig::new(domains)
|
|
||||||
.directory(&self.directory_url)
|
|
||||||
.contact(self.contact.clone());
|
|
||||||
|
|
||||||
let state = match &self.cache_dir {
|
|
||||||
Some(cache_dir) => {
|
|
||||||
base_config.cache(DirCache::new(cache_dir.clone())).state()
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
base_config
|
|
||||||
.cache(rustls_acme::caches::NoCache::default())
|
|
||||||
.state()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolver = state.resolver();
|
|
||||||
(state, resolver)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_server_config_with_resolver(
|
|
||||||
&self,
|
|
||||||
resolver: Arc<ResolvesServerCertAcme>,
|
|
||||||
) -> Result<Arc<ServerConfig>> {
|
|
||||||
let provider = default_provider().into();
|
|
||||||
let mut config = ServerConfig::builder_with_provider(provider)
|
|
||||||
.with_safe_default_protocol_versions()
|
|
||||||
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_cert_resolver(resolver);
|
|
||||||
config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
|
||||||
Ok(Arc::new(config))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AcmeTlsAcceptor {
|
|
||||||
listener: TcpListener,
|
|
||||||
listen_addr: SocketAddr,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
server_config: Arc<ServerConfig>,
|
|
||||||
tokio_acceptor: TokioTlsAcceptor,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AcmeTlsAcceptor {
|
|
||||||
pub async fn bind_acme(
|
|
||||||
addr: SocketAddr,
|
|
||||||
provider: Arc<AcmeCertProvider>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let (state, resolver) = provider.build_acme_state();
|
|
||||||
|
|
||||||
let server_config = provider.build_server_config_with_resolver(resolver.clone())?;
|
|
||||||
|
|
||||||
Self::spawn_state_worker(state, resolver);
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(addr).await?;
|
|
||||||
let listen_addr = listener.local_addr()?;
|
|
||||||
|
|
||||||
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
listener,
|
|
||||||
listen_addr,
|
|
||||||
server_config,
|
|
||||||
tokio_acceptor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn listen_addr(&self) -> SocketAddr {
|
|
||||||
self.listen_addr
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_state_worker(state: AcmeState<std::io::Error>, resolver: Arc<ResolvesServerCertAcme>) {
|
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
let task = async move {
|
|
||||||
let mut state = state;
|
|
||||||
while let Some(event) = state.next().await {
|
|
||||||
match event {
|
|
||||||
Ok(ok) => {
|
|
||||||
if let rustls_acme::EventOk::DeployedNewCert = ok {
|
|
||||||
info!("ACME: new certificate deployed");
|
|
||||||
} else {
|
|
||||||
info!("ACME event: {:?}", ok);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => error!("ACME event error: {:?}", err),
|
|
||||||
}
|
|
||||||
if Arc::strong_count(&resolver) == 1 {
|
|
||||||
info!("ACME resolver dropped, stopping background task");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::spawn(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl TransportAcceptor for AcmeTlsAcceptor {
|
|
||||||
type Stream = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
|
|
||||||
|
|
||||||
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
|
|
||||||
let (tcp_stream, remote_addr) = self.listener.accept().await?;
|
|
||||||
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
|
|
||||||
|
|
||||||
let server_name = tls_stream
|
|
||||||
.get_ref()
|
|
||||||
.1
|
|
||||||
.server_name()
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let info = TransportInfo {
|
|
||||||
remote_addr: Some(remote_addr),
|
|
||||||
transport_kind: TransportKind::Tls { server_name },
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((tls_stream, info))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_domain_mode() {
|
|
||||||
let provider = AcmeCertProvider::domain("example.com");
|
|
||||||
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
|
|
||||||
if let AcmeMode::Domain { domain } = provider.mode() {
|
|
||||||
assert_eq!(domain, "example.com");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_ip_mode() {
|
|
||||||
let provider = AcmeCertProvider::ip();
|
|
||||||
assert!(matches!(provider.mode(), AcmeMode::Ip));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_default_staging_directory() {
|
|
||||||
let provider = AcmeCertProvider::domain("example.com");
|
|
||||||
assert_eq!(
|
|
||||||
provider.directory_url,
|
|
||||||
rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_production_directory() {
|
|
||||||
let provider = AcmeCertProvider::domain("example.com").with_production_directory();
|
|
||||||
assert_eq!(
|
|
||||||
provider.directory_url,
|
|
||||||
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_custom_directory() {
|
|
||||||
let provider =
|
|
||||||
AcmeCertProvider::domain("example.com").with_directory("https://custom.acme.dir/");
|
|
||||||
assert_eq!(provider.directory_url, "https://custom.acme.dir/");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_with_cache_dir() {
|
|
||||||
let provider = AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/acme_cache");
|
|
||||||
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/acme_cache")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_with_contact() {
|
|
||||||
let provider =
|
|
||||||
AcmeCertProvider::domain("example.com").with_contact("mailto:admin@example.com");
|
|
||||||
assert_eq!(
|
|
||||||
provider.contact,
|
|
||||||
vec!["mailto:admin@example.com".to_string()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_build_state_domain() {
|
|
||||||
let provider = AcmeCertProvider::domain("example.com");
|
|
||||||
let (_state, resolver) = provider.build_acme_state();
|
|
||||||
assert!(Arc::strong_count(&resolver) >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_build_state_with_cache() {
|
|
||||||
let provider =
|
|
||||||
AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/test_cache");
|
|
||||||
let (_state, resolver) = provider.build_acme_state();
|
|
||||||
assert!(Arc::strong_count(&resolver) >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_build_server_config() {
|
|
||||||
let _ = default_provider().install_default();
|
|
||||||
let provider = AcmeCertProvider::domain("example.com");
|
|
||||||
let (_, resolver) = provider.build_acme_state();
|
|
||||||
let config = provider.build_server_config_with_resolver(resolver).unwrap();
|
|
||||||
assert!(!config.alpn_protocols.is_empty());
|
|
||||||
assert!(config
|
|
||||||
.alpn_protocols
|
|
||||||
.iter()
|
|
||||||
.any(|p| p == ACME_TLS_ALPN_NAME));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_mode_domain_debug() {
|
|
||||||
let mode = AcmeMode::Domain {
|
|
||||||
domain: "test.example.com".to_string(),
|
|
||||||
};
|
|
||||||
let debug_str = format!("{:?}", mode);
|
|
||||||
assert!(debug_str.contains("test.example.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_mode_ip_debug() {
|
|
||||||
let mode = AcmeMode::Ip;
|
|
||||||
let debug_str = format!("{:?}", mode);
|
|
||||||
assert!(debug_str.contains("Ip"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acme_cert_provider_builder_chain() {
|
|
||||||
let provider = AcmeCertProvider::domain("test.example.com")
|
|
||||||
.with_production_directory()
|
|
||||||
.with_cache_dir("/tmp/cache")
|
|
||||||
.with_contact("mailto:admin@test.example.com");
|
|
||||||
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
provider.directory_url,
|
|
||||||
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
|
|
||||||
);
|
|
||||||
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/cache")));
|
|
||||||
assert_eq!(provider.contact.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn acme_tls_acceptor_bind_acme() {
|
|
||||||
let _ = default_provider().install_default();
|
|
||||||
let provider = Arc::new(AcmeCertProvider::domain("example.com"));
|
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
|
||||||
let acceptor = AcmeTlsAcceptor::bind_acme(addr, provider).await.unwrap();
|
|
||||||
assert_ne!(acceptor.listen_addr().port(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn acme_staging_domain_cert_provisioning() {
|
|
||||||
let _ = default_provider().install_default();
|
|
||||||
|
|
||||||
let cache_dir = tempfile::tempdir().unwrap();
|
|
||||||
let provider = Arc::new(
|
|
||||||
AcmeCertProvider::domain("acme-test.example.com")
|
|
||||||
.with_cache_dir(cache_dir.path())
|
|
||||||
.with_contact("mailto:admin@example.com"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
|
|
||||||
let result = AcmeTlsAcceptor::bind_acme(addr, provider).await;
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"ACME TlsAcceptor should bind: {:?}",
|
|
||||||
result.err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let acceptor = result.unwrap();
|
|
||||||
assert_eq!(acceptor.listen_addr().port(), 443);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,12 +12,6 @@ mod tls;
|
|||||||
#[cfg(feature = "tls")]
|
#[cfg(feature = "tls")]
|
||||||
pub use tls::{AcmeConfig, TlsAcceptor, TlsTransport};
|
pub use tls::{AcmeConfig, TlsAcceptor, TlsTransport};
|
||||||
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
mod acme;
|
|
||||||
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
pub use acme::{AcmeCertProvider, AcmeMode, AcmeTlsAcceptor};
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|||||||
@@ -9,16 +9,8 @@ use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, ServerConfig};
|
|||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio_rustls::{client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector};
|
use tokio_rustls::{client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector};
|
||||||
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
use rustls::crypto::aws_lc_rs::default_provider;
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
use rustls_acme::ResolvesServerCertAcme;
|
|
||||||
|
|
||||||
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
||||||
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
|
|
||||||
|
|
||||||
/// A TLS-based client transport that connects to a remote address over TLS.
|
/// A TLS-based client transport that connects to a remote address over TLS.
|
||||||
///
|
///
|
||||||
/// Wraps a TCP connection with a TLS client session via `tokio_rustls::TlsConnector`.
|
/// Wraps a TCP connection with a TLS client session via `tokio_rustls::TlsConnector`.
|
||||||
@@ -118,10 +110,8 @@ pub struct AcmeConfig {
|
|||||||
/// A TLS-based server transport acceptor that accepts TCP connections
|
/// A TLS-based server transport acceptor that accepts TCP connections
|
||||||
/// and wraps them with TLS server sessions via `tokio_rustls::TlsAcceptor`.
|
/// and wraps them with TLS server sessions via `tokio_rustls::TlsAcceptor`.
|
||||||
///
|
///
|
||||||
/// Supports three certificate modes (ADR-008):
|
/// Requires certificate and private key configuration. Supports manual
|
||||||
/// - Manual certs via `bind()` with explicit cert/key
|
/// cert/key paths and an ACME config stub (ADR-008).
|
||||||
/// - ACME certs via `bind_acme()` with an `AcmeCertProvider`
|
|
||||||
/// - The stub `AcmeConfig` parameter in `bind()` is kept for backward compat
|
|
||||||
pub struct TlsAcceptor {
|
pub struct TlsAcceptor {
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
listen_addr: SocketAddr,
|
listen_addr: SocketAddr,
|
||||||
@@ -155,33 +145,6 @@ impl TlsAcceptor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "acme")]
|
|
||||||
pub async fn bind_acme(
|
|
||||||
addr: SocketAddr,
|
|
||||||
acme_resolver: Arc<ResolvesServerCertAcme>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let listener = TcpListener::bind(addr).await?;
|
|
||||||
let listen_addr = listener.local_addr()?;
|
|
||||||
|
|
||||||
let provider = default_provider().into();
|
|
||||||
let mut server_config = ServerConfig::builder_with_provider(provider)
|
|
||||||
.with_safe_default_protocol_versions()
|
|
||||||
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_cert_resolver(acme_resolver);
|
|
||||||
server_config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
|
||||||
|
|
||||||
let server_config = Arc::new(server_config);
|
|
||||||
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
listener,
|
|
||||||
listen_addr,
|
|
||||||
server_config,
|
|
||||||
tokio_acceptor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn listen_addr(&self) -> SocketAddr {
|
pub fn listen_addr(&self) -> SocketAddr {
|
||||||
self.listen_addr
|
self.listen_addr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,14 +43,8 @@ This integrates with `TlsAcceptor` by providing ACME-resolved certificates inste
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `AcmeCertProvider` is the main entry point. It creates `AcmeState` and `ResolvesServerCertAcme` from `rustls-acme`.
|
> To be filled by implementation agent
|
||||||
- The `ResolvesServerCertAcme` resolver is shared between the `AcmeState` background task and the `ServerConfig`, so cert updates propagate automatically.
|
|
||||||
- `AcmeTlsAcceptor::bind_acme()` creates a TLS acceptor that uses ACME-provisioned certs and spawns a background tokio task for auto-renewal.
|
|
||||||
- `TlsAcceptor::bind_acme()` also added for users who want to use ACME with the standard `TlsAcceptor` type directly.
|
|
||||||
- The `AcmeConfig` stub in `tls.rs` is retained for backward compat with existing `TlsAcceptor::bind()`.
|
|
||||||
- `acme` feature implies `tls` and adds `rustls-acme` + `futures` dependencies.
|
|
||||||
- TLS-ALPN-01 challenge handling works via the `acme-tls/1` ALPN protocol registered in `ServerConfig` — the resolver dispatches challenge vs regular certs automatically.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Implemented ACME/Let's Encrypt certificate provisioning (ADR-008) behind the `acme` feature flag. `AcmeCertProvider` supports domain-based and IP-based modes using `rustls-acme`. `AcmeTlsAcceptor::bind_acme()` and `TlsAcceptor::bind_acme()` provide ACME-integrated TLS acceptance with automatic certificate renewal via a background tokio task. Unit tests cover config construction, builder patterns, and server config generation. Integration test for LE staging is marked `#[ignore]`.
|
> To be filled on completion
|
||||||
Reference in New Issue
Block a user