feat(plugins): hand SOCKS5 transport proxy routes to connections

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-19 09:47:15 +08:00
1 parent 8518a5bd25
commit 2b15327de3
13 files changed
+330 -46

No files matched your search

+3 -1
View File
@@ -23,7 +23,9 @@ pub use filesystem::{
PLUGIN_FILESYSTEM_CREATE_DIRECTORY_METHOD, PLUGIN_FILESYSTEM_DELETE_METHOD, PLUGIN_FILESYSTEM_LIST_METHOD,
PLUGIN_FILESYSTEM_READ_METHOD, PLUGIN_FILESYSTEM_RENAME_METHOD, PLUGIN_FILESYSTEM_WRITE_METHOD,
};
pub use host::{ActivePluginSession, PluginConnectionActionResult, PluginConnectionHandle, PluginHost};
pub use host::{
ActivePluginSession, PluginConnectionActionResult, PluginConnectionHandle, PluginHost, PluginRuntimeProxy,
};
pub use installer::{
PluginInstallPolicy, PluginInstallResponse, PluginInstallResult, PluginPackageInstaller, PluginRollbackResponse,
PluginRollbackResult, PluginSignatureStatus, PluginTrustStore, PluginTrustedKey, DBXP_EXTENSION,
+93 -13
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use crate::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseType};
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, Mutex, RwLock};
use super::{
@@ -191,13 +191,26 @@ impl PluginHost {
/// to an installed connection provider.
pub fn connection_params_standalone(&self, config: &ConnectionConfig) -> Result<serde_json::Value, String> {
let (_, provider) = self.resolve_connection_provider(config)?;
plugin_connection_params(config, &provider, &config.host, config.port)
plugin_connection_params(config, &provider, &config.host, config.port, None)
}
/// Reports whether the connection's provider declared
/// `proxy_route` (multi-endpoint targets that want a SOCKS5
/// runtime route over transport layers instead of a static tunnel).
/// Unresolvable configs report `false` so the caller falls back to the
/// static-tunnel path (and its empty-endpoint guard).
pub async fn wants_proxy_route(&self, config: &ConnectionConfig) -> bool {
match self.resolve_connection_provider(config) {
Ok((_, provider)) => provider.proxy_route,
Err(_) => false,
}
}
pub async fn test_connection(
&self,
config: &ConnectionConfig,
runtime_host: &str,
runtime_port: u16,
runtime_proxy: Option<PluginRuntimeProxy>,
) -> Result<ConnectionTestResult, String> {
let _activity = self
.inner
@@ -214,7 +227,7 @@ impl PluginHost {
let result: serde_json::Value = session
.invoke_with_timeout(
PLUGIN_CONNECTION_TEST_METHOD,
plugin_connection_params(config, &provider, runtime_host, runtime_port)?,
plugin_connection_params(config, &provider, runtime_host, runtime_port, runtime_proxy.as_ref())?,
None,
Some(plugin_connect_deadline(config, &provider)),
)
@@ -227,6 +240,7 @@ impl PluginHost {
config: &ConnectionConfig,
runtime_host: &str,
runtime_port: u16,
runtime_proxy: Option<PluginRuntimeProxy>,
) -> Result<PluginConnectionHandle, String> {
let activity = self
.inner
@@ -235,7 +249,7 @@ impl PluginHost {
.begin_connection(config.plugin_id.as_deref().unwrap_or_default(), &config.name)?;
let (_, provider) = self.resolve_connection_provider(config)?;
validate_plugin_connection_values(config, &provider)?;
let params = plugin_connection_params(config, &provider, runtime_host, runtime_port)?;
let params = plugin_connection_params(config, &provider, runtime_host, runtime_port, runtime_proxy.as_ref())?;
let needs_session = provider.has_capability(PluginConnectionCapability::Connect)
|| provider.has_capability(PluginConnectionCapability::Disconnect);
let session = if needs_session {
@@ -274,6 +288,7 @@ impl PluginHost {
action_id: &str,
runtime_host: &str,
runtime_port: u16,
runtime_proxy: Option<PluginRuntimeProxy>,
) -> Result<PluginConnectionActionResult, String> {
let _activity = self
.inner
@@ -284,7 +299,8 @@ impl PluginHost {
let action = plugin_invoke_connection_action(&provider, action_id)?;
validate_plugin_connection_values_for_action(config, &provider, action.requires_valid_form)?;
let session = self.activate(config.plugin_id.as_deref().unwrap_or_default()).await?;
let mut params = plugin_connection_params(config, &provider, runtime_host, runtime_port)?;
let mut params =
plugin_connection_params(config, &provider, runtime_host, runtime_port, runtime_proxy.as_ref())?;
params
.as_object_mut()
.ok_or("Plugin connection action params must be an object")?
@@ -433,21 +449,50 @@ fn plugin_connection_params(
provider: &PluginConnectionProviderContribution,
runtime_host: &str,
runtime_port: u16,
runtime_proxy: Option<&PluginRuntimeProxy>,
) -> Result<serde_json::Value, String> {
let mut runtime = serde_json::json!({
"host": runtime_host,
"port": runtime_port,
});
if let Some(proxy) = runtime_proxy {
runtime["proxy"] = serde_json::to_value(proxy).map_err(|error| error.to_string())?;
}
Ok(serde_json::json!({
"provider": {
"id": provider.id,
"databaseType": provider.database_type,
},
"connection": serde_json::to_value(config).map_err(|error| error.to_string())?,
"runtime": {
"host": runtime_host,
"port": runtime_port,
},
"runtime": runtime,
"operationId": uuid::Uuid::new_v4().to_string(),
}))
}
/// A host-managed SOCKS5 route handed to a plugin through
/// `runtime.proxy`. Providers declaring `proxy_route` (multi-endpoint
/// targets such as Kafka) dial every advertised broker through this route
/// instead of a static tunnel, which can only reach a single endpoint.
/// Credentials ride the same encrypted lifecycle channel as connection
/// secrets and must never be logged by the plugin.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRuntimeProxy {
#[serde(rename = "type")]
pub proxy_type: String,
pub host: String,
pub port: u16,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub username: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub password: String,
}
impl PluginRuntimeProxy {
pub fn socks5(host: String, port: u16, username: String, password: String) -> Self {
Self { proxy_type: "socks5".to_string(), host, port, username, password }
}
}
fn validate_plugin_connection_values(
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
@@ -775,9 +820,9 @@ fn ensure_permission(plugin: &super::InstalledPlugin, required_permission: Optio
#[cfg(test)]
mod tests {
use super::{
plugin_connect_deadline, plugin_connection_action_result, plugin_field_is_visible,
plugin_connect_deadline, plugin_connection_action_result, plugin_connection_params, plugin_field_is_visible,
plugin_invoke_connection_action, validate_plugin_connection_values,
validate_plugin_connection_values_for_action,
validate_plugin_connection_values_for_action, PluginRuntimeProxy,
};
use crate::models::connection::ConnectionConfig;
use crate::plugins::PluginConnectionProviderContribution;
@@ -824,9 +869,9 @@ mod tests {
let lifecycle = registry.lifecycle();
let host = super::PluginHost::new(registry);
let update = lifecycle.begin_update("sample.ui").unwrap();
assert!(host.connect_connection(&config, "localhost", 0).await.is_err());
assert!(host.connect_connection(&config, "localhost", 0, None).await.is_err());
drop(update);
let connection = host.connect_connection(&config, "localhost", 0).await.unwrap();
let connection = host.connect_connection(&config, "localhost", 0, None).await.unwrap();
assert!(lifecycle.begin_update("sample.ui").unwrap_err().contains("Saved UI connection"));
connection.disconnect().await.unwrap();
drop(connection);
@@ -885,6 +930,41 @@ mod tests {
);
}
#[test]
fn connection_params_embed_optional_socks5_proxy_route() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "plugin-connection",
"name": "Plugin connection",
"db_type": "plugin",
"host": "",
"port": 0,
"username": "",
"password": "",
"database": null,
"plugin_id": "sample",
"plugin_connection_provider": "sample.connection",
"plugin_connection_type": "sample"
}))
.unwrap();
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"label": "Sample",
"database_type": "sample",
"fields": []
}))
.unwrap();
let without = plugin_connection_params(&config, &provider, "", 0, None).unwrap();
assert!(without["runtime"].get("proxy").is_none());
let proxy = PluginRuntimeProxy::socks5("127.0.0.1".to_string(), 1080, "user".to_string(), "pass".to_string());
let with = plugin_connection_params(&config, &provider, "k1", 9092, Some(&proxy)).unwrap();
assert_eq!(with["runtime"]["proxy"]["type"], "socks5");
assert_eq!(with["runtime"]["proxy"]["host"], "127.0.0.1");
assert_eq!(with["runtime"]["proxy"]["port"], 1080);
assert_eq!(with["runtime"]["proxy"]["username"], "user");
}
#[test]
fn rejects_zero_port_for_port_bound_connection_field() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
@@ -560,6 +560,14 @@ pub struct PluginConnectionProviderContribution {
pub capabilities: Vec<PluginConnectionCapability>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<PluginConnectionActionContribution>,
/// Providers whose targets have multiple reachable endpoints (Kafka
/// bootstrap + advertised listeners) declare this flag so the host hands
/// them a SOCKS5 `runtime.proxy` route instead of a static tunnel, which
/// can only reach a single endpoint. Without the flag, transport layers
/// keep today's static-tunnel behavior (fine for single-endpoint
/// providers such as SSH or LDAP, which declare binding host/port fields).
#[serde(default, skip_serializing_if = "is_false")]
pub proxy_route: bool,
}
impl PluginConnectionProviderContribution {
@@ -1470,6 +1478,26 @@ mod tests {
PluginManifest,
};
#[test]
fn connection_provider_proxy_route_defaults_false_and_parses() {
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"database_type": "sample",
"fields": []
}))
.unwrap();
assert!(!provider.proxy_route);
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"database_type": "sample",
"fields": [],
"proxy_route": true
}))
.unwrap();
assert!(provider.proxy_route);
}
#[test]
fn parses_only_strict_https_network_permissions() {
assert_eq!(