fix(rocketmq): route tunneled traffic through SOCKS5

Closes #5742
This commit is contained in:
t8y2 committed 2026-08-11 03:16:08 +08:00
1 parent 2e9ed477a4
commit 5351fc5066
11 files changed
+883 -26

No files matched your search

+120 -1
View File
@@ -24,7 +24,7 @@ use crate::db::proxy_tunnel::ProxyTunnelManager;
use crate::db::ssh_tunnel::TunnelManager;
use crate::models::connection::{
database_info_from_protocol_value, parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host,
ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig,
ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, ProxyType, TransportLayerConfig,
};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
@@ -2465,6 +2465,15 @@ impl AppState {
return Err("Oracle TNS connections cannot be combined with SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.".to_string());
}
#[cfg(feature = "mq-admin")]
if config.db_type == DatabaseType::MessageQueue
&& crate::mq::config::MqAdminConfig::from_connection(config)?.system_kind
== crate::mq::types::MqSystemKind::RocketMq
{
self.rocketmq_socks_proxy_for_transport_layers(connection_id, &transport_layers).await?;
return Ok((config.host.clone(), config.port));
}
let (remote_host, remote_port) = connection_remote_endpoint(config);
let local_port = db::transport_layer_tunnel::start_transport_layers(
connection_id,
@@ -2669,6 +2678,66 @@ impl AppState {
Ok(routes)
}
#[cfg(feature = "mq-admin")]
async fn rocketmq_socks_proxy_for_transport_layers(
&self,
connection_id: &str,
transport_layers: &[TransportLayerConfig],
) -> Result<crate::mq::config::MqSocksProxy, String> {
let final_layer = transport_layers.last().ok_or("No transport layers configured")?;
match final_layer {
TransportLayerConfig::Ssh(_) => {
let local_port = db::transport_layer_tunnel::start_transport_layers_with_final_ssh_socks5(
connection_id,
transport_layers,
&self.tunnels,
&self.proxy_tunnels,
&self.http_tunnels,
)
.await?;
Ok(crate::mq::config::MqSocksProxy {
host: "127.0.0.1".to_string(),
port: local_port,
username: String::new(),
password: String::new(),
})
}
TransportLayerConfig::Proxy(proxy) if proxy.proxy_type == ProxyType::Socks5 => {
if transport_layers.len() == 1 {
Ok(crate::mq::config::MqSocksProxy {
host: proxy.host.clone(),
port: proxy.port,
username: proxy.username.clone(),
password: proxy.password.clone(),
})
} else {
let local_port = db::transport_layer_tunnel::start_transport_layers(
connection_id,
&transport_layers[..transport_layers.len() - 1],
&proxy.host,
proxy.port,
&self.tunnels,
&self.proxy_tunnels,
&self.http_tunnels,
)
.await?;
Ok(crate::mq::config::MqSocksProxy {
host: "127.0.0.1".to_string(),
port: local_port,
username: proxy.username.clone(),
password: proxy.password.clone(),
})
}
}
TransportLayerConfig::Proxy(_) => {
Err("RocketMQ requires a SOCKS5 proxy as the final proxy layer".to_string())
}
TransportLayerConfig::HttpTunnel(_) => {
Err("RocketMQ does not support an HTTP tunnel as the final transport layer".to_string())
}
}
}
#[cfg(feature = "mq-admin")]
pub async fn mq_admin_config_for_connection(
&self,
@@ -2680,6 +2749,12 @@ impl AppState {
return Ok(mqc);
}
if mqc.system_kind == crate::mq::types::MqSystemKind::RocketMq {
let transport_layers = self.resolved_transport_layers(config).await?;
let proxy = self.rocketmq_socks_proxy_for_transport_layers(connection_id, &transport_layers).await?;
return Ok(mqc.with_socks_proxy(&proxy.host, proxy.port, &proxy.username, &proxy.password));
}
if mqc.system_kind == crate::mq::types::MqSystemKind::RabbitMq {
let transport_layers = self.resolved_transport_layers(config).await?;
let (amqp_host, amqp_port) = crate::mq::adapters::rabbitmq::primary_amqp_endpoint(&mqc)?;
@@ -7829,6 +7904,50 @@ for line in sys.stdin:
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn rocketmq_transport_passes_socks_proxy_to_multi_endpoint_client() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(None);
config.id = "proxied-rocketmq".to_string();
config.db_type = DatabaseType::MessageQueue;
config.host = "172.19.191.166".to_string();
config.port = 9876;
config.external_config = Some(serde_json::json!({
"systemKind": "rocketmq",
"adminUrl": "",
"auth": { "kind": "none" },
"extra": { "namesrvAddr": "172.19.191.166:9876" }
}));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: "proxy".to_string(),
name: String::new(),
enabled: true,
proxy_type: ProxyType::Socks5,
host: "proxy.internal".to_string(),
port: 1080,
username: "proxy-user".to_string(),
password: "proxy-secret".to_string(),
test_target: None,
})];
let endpoint = state.connection_host_port("proxied-rocketmq", &config).await.unwrap();
assert_eq!(endpoint, ("172.19.191.166".to_string(), 9876));
assert!(state.proxy_tunnels.local_port("proxied-rocketmq:transport:0").await.is_none());
let mqc = state.mq_admin_config_for_connection("proxied-rocketmq", &config).await.unwrap();
let socks_proxy = mqc.socks_proxy.expect("RocketMQ transport should configure SOCKS5 routing");
assert_eq!(socks_proxy.host, "proxy.internal");
assert_eq!(socks_proxy.port, 1080);
assert_eq!(socks_proxy.username, "proxy-user");
assert_eq!(socks_proxy.password, "proxy-secret");
assert!(mqc.connect_override.is_none());
assert!(state.proxy_tunnels.local_port("proxied-rocketmq:transport:0").await.is_none());
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn rabbitmq_transport_uses_separate_amqp_and_management_tunnels() {
+21
View File
@@ -136,6 +136,27 @@ async fn connect_via_proxy(proxy: &ProxyEndpoint, remote: &RemoteEndpoint) -> Re
}
}
pub(crate) async fn connect_via_socks5_proxy(
proxy_host: &str,
proxy_port: u16,
proxy_username: &str,
proxy_password: &str,
remote_host: &str,
remote_port: u16,
) -> Result<TcpStream, String> {
connect_via_proxy(
&ProxyEndpoint {
proxy_type: ProxyType::Socks5,
host: proxy_host.to_string(),
port: proxy_port,
username: proxy_username.to_string(),
password: proxy_password.to_string(),
},
&RemoteEndpoint { host: remote_host.to_string(), port: remote_port },
)
.await
}
async fn http_connect(
mut stream: TcpStream,
proxy: &ProxyEndpoint,
+433 -24
View File
@@ -15,6 +15,7 @@ use russh::keys::{decode_secret_key, key::PrivateKeyWithHashAlg, PrivateKey};
use russh::MethodKind;
use russh::MethodSet;
use russh::{kex, mac, ChannelOpenFailure, Preferred};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
@@ -754,13 +755,90 @@ fn netcat_proxy_command(remote_host: &str, remote_port: u16) -> Result<String, S
Ok(format!("exec nc '{quoted_host}' {remote_port}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TunnelTarget {
Fixed { host: String, port: u16 },
Socks5,
}
impl TunnelTarget {
fn description(&self) -> String {
match self {
Self::Fixed { host, port } => format!("{host}:{port}"),
Self::Socks5 => "dynamic SOCKS5 targets".to_string(),
}
}
}
async fn read_socks5_target(stream: &mut tokio::net::TcpStream) -> Result<(String, u16), String> {
let mut greeting = [0_u8; 2];
stream.read_exact(&mut greeting).await.map_err(|e| format!("SOCKS5 greeting failed: {e}"))?;
if greeting[0] != 0x05 || greeting[1] == 0 {
return Err("Invalid SOCKS5 greeting".to_string());
}
let mut methods = vec![0_u8; greeting[1] as usize];
stream.read_exact(&mut methods).await.map_err(|e| format!("SOCKS5 methods failed: {e}"))?;
if !methods.contains(&0x00) {
let _ = stream.write_all(&[0x05, 0xff]).await;
return Err("SOCKS5 client does not support no-auth mode".to_string());
}
stream.write_all(&[0x05, 0x00]).await.map_err(|e| format!("SOCKS5 method reply failed: {e}"))?;
let mut request = [0_u8; 4];
stream.read_exact(&mut request).await.map_err(|e| format!("SOCKS5 request failed: {e}"))?;
if request[0] != 0x05 {
return Err("Invalid SOCKS5 request version".to_string());
}
if request[1] != 0x01 {
let _ = write_socks5_reply(stream, 0x07).await;
return Err("SOCKS5 command is not CONNECT".to_string());
}
let host = match request[3] {
0x01 => {
let mut addr = [0_u8; 4];
stream.read_exact(&mut addr).await.map_err(|e| format!("SOCKS5 IPv4 target failed: {e}"))?;
std::net::Ipv4Addr::from(addr).to_string()
}
0x03 => {
let mut len = [0_u8; 1];
stream.read_exact(&mut len).await.map_err(|e| format!("SOCKS5 domain length failed: {e}"))?;
if len[0] == 0 {
let _ = write_socks5_reply(stream, 0x08).await;
return Err("SOCKS5 target host is empty".to_string());
}
let mut host = vec![0_u8; len[0] as usize];
stream.read_exact(&mut host).await.map_err(|e| format!("SOCKS5 domain target failed: {e}"))?;
String::from_utf8(host).map_err(|_| "SOCKS5 target host is not UTF-8".to_string())?
}
0x04 => {
let mut addr = [0_u8; 16];
stream.read_exact(&mut addr).await.map_err(|e| format!("SOCKS5 IPv6 target failed: {e}"))?;
std::net::Ipv6Addr::from(addr).to_string()
}
_ => {
let _ = write_socks5_reply(stream, 0x08).await;
return Err("Unsupported SOCKS5 address type".to_string());
}
};
let mut port = [0_u8; 2];
stream.read_exact(&mut port).await.map_err(|e| format!("SOCKS5 target port failed: {e}"))?;
Ok((host, u16::from_be_bytes(port)))
}
async fn write_socks5_reply(stream: &mut tokio::net::TcpStream, status: u8) -> Result<(), String> {
stream
.write_all(&[0x05, status, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.await
.map_err(|e| format!("SOCKS5 reply failed: {e}"))
}
/// Accept connections on the local listener and forward them through the SSH session.
/// Returns when the SSH session dies (listener error or session.is_closed()).
async fn forward_loop(
session: &Handle<SshClient>,
listener: &TcpListener,
remote_host: &str,
remote_port: u16,
target: &TunnelTarget,
allow_exec_channel_proxy: bool,
) {
let mut idle_check = tokio::time::interval(IDLE_SESSION_CHECK_INTERVAL);
@@ -802,9 +880,20 @@ async fn forward_loop(
break;
}
let (remote_host, remote_port) = match target {
TunnelTarget::Fixed { host, port } => (host.clone(), *port),
TunnelTarget::Socks5 => match read_socks5_target(&mut stream).await {
Ok(target) => target,
Err(e) => {
log::debug!("SSH SOCKS5 request rejected: {e}");
continue;
}
},
};
let channel = match session
.channel_open_direct_tcpip(
remote_host,
&remote_host,
remote_port.into(),
peer_addr.ip().to_string(),
peer_addr.port().into(),
@@ -818,37 +907,63 @@ async fn forward_loop(
// JumpServer/Koko deliberately disables SSH direct-tcpip even
// for a directly selected asset. An exec channel is still
// proxied to that asset, so use netcat there as a byte stream.
let command = match netcat_proxy_command(remote_host, remote_port) {
let command = match netcat_proxy_command(&remote_host, remote_port) {
Ok(command) => command,
Err(e) => {
log::error!("SSH netcat fallback rejected the target: {e}");
continue;
if matches!(target, TunnelTarget::Socks5) {
let _ = write_socks5_reply(&mut stream, 0x01).await;
continue;
}
break;
}
};
let channel = match session.channel_open_session().await {
Ok(channel) => channel,
Err(e) => {
log::error!("SSH netcat fallback could not open a session channel: {e}");
if matches!(target, TunnelTarget::Socks5) {
let _ = write_socks5_reply(&mut stream, 0x01).await;
}
continue;
}
};
if let Err(e) = channel.exec(true, command).await {
log::error!("SSH netcat fallback could not start nc: {e}");
continue;
if matches!(target, TunnelTarget::Socks5) {
let _ = write_socks5_reply(&mut stream, 0x01).await;
continue;
}
break;
}
log::info!("SSH direct-tcpip is disabled; forwarding through a remote nc session");
channel
}
Err(russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)) => {
log::warn!("SSH direct-tcpip was administratively prohibited; exec-channel proxy fallback is disabled");
if matches!(target, TunnelTarget::Socks5) {
let _ = write_socks5_reply(&mut stream, 0x02).await;
continue;
}
break;
}
Err(e) => {
log::error!("SSH direct-tcpip failed: {e}");
if matches!(target, TunnelTarget::Socks5) {
let _ = write_socks5_reply(&mut stream, 0x01).await;
continue;
}
break;
}
};
if matches!(target, TunnelTarget::Socks5) {
if let Err(e) = write_socks5_reply(&mut stream, 0x00).await {
log::debug!("SSH SOCKS5 success reply failed: {e}");
continue;
}
}
tokio::spawn(async move {
let mut channel_stream = channel.into_stream();
if let Err(e) = tokio::io::copy_bidirectional(&mut stream, &mut channel_stream).await {
@@ -880,14 +995,13 @@ async fn tunnel_reconnect_loop(
connect_timeout_secs: u64,
known_hosts_path: PathBuf,
listener: TcpListener,
remote_host: String,
remote_port: u16,
target: TunnelTarget,
allow_exec_channel_proxy: bool,
) {
loop {
log::info!("SSH tunnel active: {}:{} -> {}:{}", connect_host, connect_port, remote_host, remote_port);
log::info!("SSH tunnel active: {}:{} -> {}", connect_host, connect_port, target.description());
forward_loop(&session, &listener, &remote_host, remote_port, allow_exec_channel_proxy).await;
forward_loop(&session, &listener, &target, allow_exec_channel_proxy).await;
log::warn!("SSH tunnel connection lost ({}:{}), reconnecting...", connect_host, connect_port);
@@ -947,9 +1061,16 @@ async fn tunnel_reconnect_loop(
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TunnelKind {
Fixed,
Socks5,
}
struct TunnelEntry {
handles: Vec<JoinHandle<()>>,
local_port: u16,
kind: TunnelKind,
}
#[cfg(test)]
@@ -1060,7 +1181,7 @@ impl TunnelManager {
) -> Result<u16, String> {
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) {
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed) {
if requested_local_port.is_none_or(|requested| requested == port) {
return Ok(port);
}
@@ -1073,7 +1194,7 @@ impl TunnelManager {
// A concurrent caller may have completed while this task waited.
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) {
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed) {
if requested_local_port.is_none_or(|requested| requested == port) {
return Ok(port);
}
@@ -1106,16 +1227,91 @@ impl TunnelManager {
)
.await?;
self.tunnels.lock().await.insert(connection_id.to_string(), TunnelEntry { handles: vec![handle], local_port });
self.tunnels.lock().await.insert(
connection_id.to_string(),
TunnelEntry { handles: vec![handle], local_port, kind: TunnelKind::Fixed },
);
Ok(local_port)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn start_socks5_proxy(
&self,
connection_id: &str,
connect_host: &str,
connect_port: u16,
host_key_host: &str,
host_key_port: u16,
ssh_user: &str,
ssh_password: &str,
ssh_key_path: &str,
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
allow_exec_channel_proxy: bool,
) -> Result<u16, String> {
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Socks5) {
return Ok(port);
}
}
let start_lock = self.start_lock(connection_id).await;
let _start_guard = start_lock.lock().await;
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Socks5) {
return Ok(port);
}
if let Some(entry) = tunnels.remove(connection_id) {
for handle in entry.handles {
handle.abort();
}
}
}
let (handle, local_port) = spawn_socks5_proxy(
connect_host,
connect_port,
host_key_host,
host_key_port,
ssh_user,
ssh_password,
ssh_key_path,
ssh_key_passphrase,
use_ssh_agent,
ssh_agent_sock_path,
auth_method,
connect_timeout_secs,
&self.known_hosts_path,
allow_exec_channel_proxy,
)
.await?;
self.tunnels.lock().await.insert(
connection_id.to_string(),
TunnelEntry { handles: vec![handle], local_port, kind: TunnelKind::Socks5 },
);
Ok(local_port)
}
/// Returns the local port for a cached tunnel entry, or `None` if the entry
/// is stale (all background handles have exited).
fn get_active_port(tunnels: &mut HashMap<String, TunnelEntry>, connection_id: &str) -> Option<u16> {
fn get_active_port(
tunnels: &mut HashMap<String, TunnelEntry>,
connection_id: &str,
expected_kind: TunnelKind,
) -> Option<u16> {
let entry = tunnels.get(connection_id)?;
if entry.handles.iter().all(|h| h.is_finished()) {
tunnels.remove(connection_id);
if entry.kind != expected_kind || entry.handles.iter().all(|h| h.is_finished()) {
if let Some(entry) = tunnels.remove(connection_id) {
for handle in entry.handles {
handle.abort();
}
}
return None;
}
Some(entry.local_port)
@@ -1133,7 +1329,7 @@ impl TunnelManager {
}
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) {
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed) {
return Ok(port);
}
}
@@ -1142,7 +1338,7 @@ impl TunnelManager {
let _start_guard = start_lock.lock().await;
{
let mut tunnels = self.tunnels.lock().await;
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) {
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed) {
return Ok(port);
}
}
@@ -1188,10 +1384,10 @@ impl TunnelManager {
next_connect_endpoint = Some(("127.0.0.1".to_string(), local_port));
}
self.tunnels
.lock()
.await
.insert(connection_id.to_string(), TunnelEntry { handles, local_port: final_local_port });
self.tunnels.lock().await.insert(
connection_id.to_string(),
TunnelEntry { handles, local_port: final_local_port, kind: TunnelKind::Fixed },
);
Ok(final_local_port)
}
@@ -1256,6 +1452,87 @@ async fn spawn_tunnel(
expose_to_lan: bool,
allow_exec_channel_proxy: bool,
requested_local_port: Option<u16>,
) -> Result<(JoinHandle<()>, u16), String> {
spawn_tunnel_target(
connect_host,
connect_port,
host_key_host,
host_key_port,
ssh_user,
ssh_password,
ssh_key_path,
ssh_key_passphrase,
use_ssh_agent,
ssh_agent_sock_path,
auth_method,
connect_timeout_secs,
known_hosts_path,
TunnelTarget::Fixed { host: remote_host.to_string(), port: remote_port },
expose_to_lan,
allow_exec_channel_proxy,
requested_local_port,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn spawn_socks5_proxy(
connect_host: &str,
connect_port: u16,
host_key_host: &str,
host_key_port: u16,
ssh_user: &str,
ssh_password: &str,
ssh_key_path: &str,
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
known_hosts_path: &Path,
allow_exec_channel_proxy: bool,
) -> Result<(JoinHandle<()>, u16), String> {
spawn_tunnel_target(
connect_host,
connect_port,
host_key_host,
host_key_port,
ssh_user,
ssh_password,
ssh_key_path,
ssh_key_passphrase,
use_ssh_agent,
ssh_agent_sock_path,
auth_method,
connect_timeout_secs,
known_hosts_path,
TunnelTarget::Socks5,
false,
allow_exec_channel_proxy,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn spawn_tunnel_target(
connect_host: &str,
connect_port: u16,
host_key_host: &str,
host_key_port: u16,
ssh_user: &str,
ssh_password: &str,
ssh_key_path: &str,
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
known_hosts_path: &Path,
target: TunnelTarget,
expose_to_lan: bool,
allow_exec_channel_proxy: bool,
requested_local_port: Option<u16>,
) -> Result<(JoinHandle<()>, u16), String> {
let (listener, local_port) = bind_tunnel_listener(expose_to_lan, requested_local_port).await?;
@@ -1293,8 +1570,7 @@ async fn spawn_tunnel(
connect_timeout_secs,
known_hosts_path.to_path_buf(),
listener,
remote_host.to_string(),
remote_port,
target,
allow_exec_channel_proxy,
));
@@ -1982,6 +2258,139 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
(port, task)
}
struct DirectEchoServer {
target: Arc<std::sync::Mutex<Option<(String, u32)>>>,
}
impl server::Server for DirectEchoServer {
type Handler = DirectEchoHandler;
fn new_client(&mut self, _peer: Option<std::net::SocketAddr>) -> DirectEchoHandler {
DirectEchoHandler { target: self.target.clone() }
}
}
struct DirectEchoHandler {
target: Arc<std::sync::Mutex<Option<(String, u32)>>>,
}
impl server::Handler for DirectEchoHandler {
type Error = russh::Error;
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
Ok(Auth::Accept)
}
async fn channel_open_direct_tcpip(
&mut self,
_channel: Channel<server::Msg>,
host_to_connect: &str,
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
_session: &mut server::Session,
) -> Result<bool, Self::Error> {
*self.target.lock().unwrap() = Some((host_to_connect.to_string(), port_to_connect));
Ok(true)
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
session.data(channel, data.to_vec())?;
Ok(())
}
}
#[tokio::test]
async fn socks5_proxy_forwards_requested_target_over_ssh() {
let _guard = PROMPT_TEST_LOCK.lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let server_key = decode_secret_key(TEST_SERVER_KEY_PEM, None).expect("decode test server key");
let server_config = server::Config { keys: vec![server_key], ..Default::default() };
let ssh_port = portpicker::pick_unused_port().expect("no free port");
let target = Arc::new(std::sync::Mutex::new(None));
let mut server = DirectEchoServer { target: target.clone() };
let server_task = tokio::spawn(async move {
let _ = server.run_on_address(Arc::new(server_config), ("127.0.0.1", ssh_port)).await;
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let dir = tempdir().unwrap();
let known_hosts_path = dir.path().join("known_hosts");
HostKeyVerifier::new(known_hosts_path).learn("127.0.0.1", ssh_port, &test_server_public_key()).unwrap();
let manager = TunnelManager::new(dir.path().to_path_buf());
let local_port = manager
.start_socks5_proxy(
"rocketmq-socks",
"127.0.0.1",
ssh_port,
"127.0.0.1",
ssh_port,
"user",
"",
"",
"",
false,
"",
"none",
5,
false,
)
.await
.unwrap();
let reused_port = manager
.start_socks5_proxy(
"rocketmq-socks",
"127.0.0.1",
ssh_port,
"127.0.0.1",
ssh_port,
"user",
"",
"",
"",
false,
"",
"none",
5,
false,
)
.await
.unwrap();
assert_eq!(reused_port, local_port);
let mut client = TcpStream::connect(("127.0.0.1", local_port)).await.unwrap();
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut greeting = [0_u8; 2];
client.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [0x05, 0x00]);
let host = b"broker.internal";
let mut request = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8];
request.extend_from_slice(host);
request.extend_from_slice(&10911_u16.to_be_bytes());
client.write_all(&request).await.unwrap();
let mut reply = [0_u8; 10];
client.read_exact(&mut reply).await.unwrap();
assert_eq!(reply[1], 0x00);
client.write_all(b"ping").await.unwrap();
let mut echoed = [0_u8; 4];
tokio::time::timeout(std::time::Duration::from_secs(5), client.read_exact(&mut echoed))
.await
.expect("SOCKS5 echo timeout")
.expect("SOCKS5 echo read");
assert_eq!(&echoed, b"ping");
assert_eq!(*target.lock().unwrap(), Some(("broker.internal".to_string(), 10911)));
manager.stop_tunnel("rocketmq-socks").await;
server_task.abort();
}
struct NetcatFallbackServer {
command: Arc<std::sync::Mutex<Vec<u8>>>,
}
@@ -70,6 +70,46 @@ pub(crate) async fn start_transport_layers_with_final_ssh_local_port(
ssh_tunnels: &TunnelManager,
proxy_tunnels: &ProxyTunnelManager,
http_tunnels: &HttpTunnelManager,
) -> Result<u16, String> {
start_transport_layers_internal(
connection_id,
layers,
remote_host,
remote_port,
final_ssh_local_port,
false,
ssh_tunnels,
proxy_tunnels,
http_tunnels,
)
.await
}
pub(crate) async fn start_transport_layers_with_final_ssh_socks5(
connection_id: &str,
layers: &[TransportLayerConfig],
ssh_tunnels: &TunnelManager,
proxy_tunnels: &ProxyTunnelManager,
http_tunnels: &HttpTunnelManager,
) -> Result<u16, String> {
if !matches!(layers.last(), Some(TransportLayerConfig::Ssh(_))) {
return Err("Dynamic SOCKS5 routing requires SSH as the final transport layer".to_string());
}
start_transport_layers_internal(connection_id, layers, "", 0, None, true, ssh_tunnels, proxy_tunnels, http_tunnels)
.await
}
#[allow(clippy::too_many_arguments)]
async fn start_transport_layers_internal(
connection_id: &str,
layers: &[TransportLayerConfig],
remote_host: &str,
remote_port: u16,
final_ssh_local_port: Option<u16>,
final_ssh_socks5: bool,
ssh_tunnels: &TunnelManager,
proxy_tunnels: &ProxyTunnelManager,
http_tunnels: &HttpTunnelManager,
) -> Result<u16, String> {
if layers.is_empty() {
return Err("No transport layers configured".to_string());
@@ -97,6 +137,25 @@ pub(crate) async fn start_transport_layers_with_final_ssh_local_port(
};
let local_port = match layer {
TransportLayerConfig::Ssh(resolved) if is_last && final_ssh_socks5 => ssh_tunnels
.start_socks5_proxy(
&layer_id,
&connect_endpoint.host,
connect_endpoint.port,
&resolved.host,
resolved.port,
&resolved.user,
&resolved.password,
&resolved.key_path,
&resolved.key_passphrase,
resolved.use_ssh_agent,
&resolved.ssh_agent_sock_path,
&resolved.auth_method,
effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs),
resolved.allow_exec_channel_proxy,
)
.await
.map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?,
TransportLayerConfig::Ssh(resolved) => ssh_tunnels
.start_tunnel_on_local_port(
&layer_id,
+1
View File
@@ -803,6 +803,7 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
socks_proxy: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
@@ -1154,6 +1154,7 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
socks_proxy: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra: serde_json::Value::Null,
@@ -1029,6 +1029,7 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
socks_proxy: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
+154 -1
View File
@@ -842,6 +842,12 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
"tls_skip_verify": cfg.tls_skip_verify,
"request_timeout_ms": cfg.request_timeout_ms(),
"connect_timeout_ms": cfg.connect_timeout_ms(),
"socks_proxy": cfg.socks_proxy.as_ref().map(|proxy| serde_json::json!({
"host": proxy.host,
"port": proxy.port,
"username": proxy.username,
"password": proxy.password,
})),
})
}
@@ -952,7 +958,90 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
/// Fail fast on unreachable NameServer before paying for JVM agent startup.
/// Called outside the connect-timeout wall so the probe does not steal JVM budget.
pub(crate) async fn probe_namesrv_before_connect(cfg: &MqAdminConfig, budget: Duration) -> Result<(), String> {
probe_namesrv_tcp(&namesrv_addr(cfg), budget).await
if let Some(proxy) = &cfg.socks_proxy {
probe_namesrv_via_socks5(&namesrv_addr(cfg), proxy, budget).await
} else {
probe_namesrv_tcp(&namesrv_addr(cfg), budget).await
}
}
async fn probe_namesrv_via_socks5(
namesrv: &str,
proxy: &crate::mq::config::MqSocksProxy,
budget: Duration,
) -> Result<(), String> {
let targets: Vec<String> =
namesrv.split(';').map(str::trim).filter(|part| !part.is_empty()).map(str::to_string).collect();
if targets.is_empty() {
return Err("RocketMQ namesrv_addr is empty".to_string());
}
let deadline = tokio::time::Instant::now() + budget;
let per_attempt =
if targets.len() <= 1 { budget } else { (budget / targets.len() as u32).max(Duration::from_millis(500)) };
let mut last_error = String::new();
for target in targets {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
let (host, port) = match parse_namesrv_target(&target) {
Ok(endpoint) => endpoint,
Err(error) => {
last_error = error;
continue;
}
};
let attempt = remaining.min(per_attempt);
match timeout(
attempt,
crate::db::proxy_tunnel::connect_via_socks5_proxy(
&proxy.host,
proxy.port,
&proxy.username,
&proxy.password,
&host,
port,
),
)
.await
{
Ok(Ok(_stream)) => return Ok(()),
Ok(Err(error)) => {
last_error = format!("Cannot reach RocketMQ NameServer {target} through SOCKS5 proxy: {error}");
}
Err(_) => {
last_error = format!(
"RocketMQ NameServer {target} connect through SOCKS5 proxy timed out after {}ms",
attempt.as_millis()
);
}
}
}
Err(if last_error.is_empty() {
format!("RocketMQ NameServer connect through SOCKS5 proxy timed out after {}s", budget.as_secs())
} else {
last_error
})
}
fn parse_namesrv_target(target: &str) -> Result<(String, u16), String> {
let target = target.trim();
let (host, port) = if let Some(rest) = target.strip_prefix('[') {
let (host, port) =
rest.split_once("]:").ok_or_else(|| format!("RocketMQ NameServer address '{target}' is invalid"))?;
(host, port)
} else {
target.rsplit_once(':').ok_or_else(|| format!("RocketMQ NameServer address '{target}' is invalid"))?
};
if host.trim().is_empty() {
return Err(format!("RocketMQ NameServer address '{target}' is invalid"));
}
let port = port
.trim()
.parse::<u16>()
.map_err(|_| format!("RocketMQ NameServer address '{target}' has an invalid port"))?;
Ok((host.trim().to_string(), port))
}
/// Probe NameServer addresses so connect fails before spawning the JVM agent.
@@ -1060,7 +1149,10 @@ fn peeked_message_from_agent_json(idx: usize, message: &serde_json::Value) -> Pe
mod tests {
use super::*;
use crate::mq::auth::MqAuth;
use crate::mq::config::MqSocksProxy;
use crate::mq::types::MqSystemKind;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn rocketmq_config(extra: serde_json::Value, auth: MqAuth) -> MqAdminConfig {
MqAdminConfig {
@@ -1072,6 +1164,7 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
socks_proxy: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
@@ -1084,6 +1177,41 @@ mod tests {
assert!(err.contains("empty"), "{err}");
}
#[tokio::test]
async fn probe_namesrv_uses_socks_proxy_for_logical_target() {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let proxy_port = listener.local_addr().unwrap().port();
let (target_tx, target_rx) = tokio::sync::oneshot::channel();
let proxy_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [0x05, 0x01, 0x00]);
stream.write_all(&[0x05, 0x00]).await.unwrap();
let mut request = [0_u8; 10];
stream.read_exact(&mut request).await.unwrap();
assert_eq!(&request[..4], &[0x05, 0x01, 0x00, 0x01]);
let host = std::net::Ipv4Addr::new(request[4], request[5], request[6], request[7]).to_string();
let port = u16::from_be_bytes([request[8], request[9]]);
let _ = target_tx.send((host, port));
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await.unwrap();
});
let mut cfg = rocketmq_config(serde_json::json!({ "namesrvAddr": "172.19.191.166:9876" }), MqAuth::None);
cfg.socks_proxy = Some(MqSocksProxy {
host: "127.0.0.1".to_string(),
port: proxy_port,
username: String::new(),
password: String::new(),
});
probe_namesrv_before_connect(&cfg, Duration::from_secs(2)).await.unwrap();
assert_eq!(target_rx.await.unwrap(), ("172.19.191.166".to_string(), 9876));
proxy_task.await.unwrap();
}
#[test]
fn connection_params_map_namesrv_and_acl_credentials() {
let cfg = rocketmq_config(
@@ -1118,6 +1246,31 @@ mod tests {
assert_eq!(params.get("connect_timeout_ms").and_then(|v| v.as_u64()), Some(15_000));
}
#[test]
fn connection_params_include_runtime_socks_proxy() {
let mut cfg = rocketmq_config(serde_json::json!({ "namesrvAddr": "mq.internal:9876" }), MqAuth::None);
cfg.socks_proxy = Some(MqSocksProxy {
host: "127.0.0.1".to_string(),
port: 41080,
username: "proxy-user".to_string(),
password: "proxy-secret".to_string(),
});
let params = build_connection_params(&cfg);
assert_eq!(params.pointer("/socks_proxy/host").and_then(|v| v.as_str()), Some("127.0.0.1"));
assert_eq!(params.pointer("/socks_proxy/port").and_then(|v| v.as_u64()), Some(41080));
assert_eq!(params.pointer("/socks_proxy/username").and_then(|v| v.as_str()), Some("proxy-user"));
assert_eq!(params.pointer("/socks_proxy/password").and_then(|v| v.as_str()), Some("proxy-secret"));
}
#[test]
fn parse_namesrv_target_accepts_ipv4_domain_and_ipv6() {
assert_eq!(parse_namesrv_target("172.19.191.166:9876").unwrap(), ("172.19.191.166".to_string(), 9876));
assert_eq!(parse_namesrv_target("mq.internal:9876").unwrap(), ("mq.internal".to_string(), 9876));
assert_eq!(parse_namesrv_target("[2001:db8::1]:9876").unwrap(), ("2001:db8::1".to_string(), 9876));
}
#[test]
fn reset_cursor_params_preserve_timestamp_position() {
let topic = TopicRef {
+23
View File
@@ -30,6 +30,17 @@ pub struct MqConnectOverride {
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MqSocksProxy {
pub host: String,
pub port: u16,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
}
/// Configuration for an MQ admin connection, decoded from
/// `ConnectionConfig.external_config`.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -60,6 +71,8 @@ pub struct MqAdminConfig {
/// its Management HTTP API listens on an independently configured port.
#[serde(skip)]
pub management_connect_override: Option<MqConnectOverride>,
#[serde(skip)]
pub socks_proxy: Option<MqSocksProxy>,
/// Runtime-only: from `ConnectionConfig.query_timeout_secs` (`0` = unlimited).
#[serde(skip)]
pub query_timeout_secs: u64,
@@ -141,6 +154,16 @@ impl MqAdminConfig {
self.management_connect_override = Some(MqConnectOverride { host: host.to_string(), port });
self
}
pub fn with_socks_proxy(mut self, host: &str, port: u16, username: &str, password: &str) -> Self {
self.socks_proxy = Some(MqSocksProxy {
host: host.to_string(),
port,
username: username.to_string(),
password: password.to_string(),
});
self
}
}
pub fn admin_url_with_endpoint(admin_url: &str, host: &str, port: u16) -> Result<String, String> {