fix(nacos): route admin API through SSH/proxy transport

This commit is contained in:
二丫讲梵 2026-06-26 01:01:08 +08:00 committed by GitHub
parent fe01de3f17
commit a149d9baa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 131 additions and 32 deletions

View File

@ -34,6 +34,7 @@ import { copyToClipboard } from "@/lib/clipboard";
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
import { prestoSqlBuiltinDriverPaths } from "@/lib/prestoSqlBuiltinDriver";
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/databaseFileDetection";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connectionAttemptTimeout";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/visibleDatabases";
@ -56,6 +57,7 @@ type JdbcDriverSelectItem = {
const NACOS_DEFAULT_CONSOLE_URL = "http://127.0.0.1:8085";
const NACOS_LEGACY_SERVER_PORT = "8848";
const NACOS_DOCKER_CONSOLE_PORT = "8085";
const DEFAULT_SSH_USER = "root";
type LegacyTransportFields = {
ssh_enabled?: boolean;
@ -164,7 +166,7 @@ function defaultSshTunnel(): SshTunnelConfig {
enabled: true,
host: "",
port: 22,
user: "",
user: DEFAULT_SSH_USER,
password: "",
key_path: "",
key_passphrase: "",
@ -182,7 +184,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
enabled: hop.enabled !== false,
host: hop.host || "",
port: Number(hop.port) || 22,
user: hop.user || "",
user: hop.user?.trim() || DEFAULT_SSH_USER,
password: hop.password || "",
key_path: hop.key_path || "",
key_passphrase: hop.key_passphrase || "",
@ -725,7 +727,7 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
nacosServerAddr.value = fallbackUrl;
try {
const fallbackConfig = connectionConfigForSubmit(config.id);
const message = await api.testConnection(fallbackConfig);
const message = await testConnectionWithTimeout(fallbackConfig);
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
} catch {
nacosServerAddr.value = previousUrl;
@ -733,6 +735,39 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
}
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
async function testConnectionWithTimeout(config: ConnectionConfig): Promise<string> {
const timeoutMs = connectionAttemptTimeoutMs(config);
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
const promise = api.testConnection(config);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void promise.catch((error) => {
if (!timedOut) return;
testResult.value = {
ok: false,
message: connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error)),
};
});
try {
return await Promise.race([
promise,
new Promise<string>((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error(timeoutMessage));
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
let parsed: URL;
try {
@ -1398,7 +1433,7 @@ async function testConnection() {
testResult.value = null;
const config = connectionConfigForSubmit(editingId.value || uuid());
try {
const msg = await api.testConnection(config);
const msg = await testConnectionWithTimeout(config);
if (runId !== testRunId) return;
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
mongoDriverMode.value = "legacy";
@ -2286,7 +2321,7 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
throw new Error(t("connection.sshHopInvalidPort", { hop: label }));
}
if (layer.type === "ssh") {
if (!layer.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label }));
layer.user = layer.user?.trim() || DEFAULT_SSH_USER;
// Auth credentials are optional: the backend probes "none" authentication
// first, so hops that require no credential (e.g. passwordless SSH proxies)
// are valid with password, key, and agent all left empty.

View File

@ -857,7 +857,7 @@ onBeforeUnmount(() => {
<div class="flex shrink-0 items-center justify-between gap-3 border-b px-3 py-2">
<div class="flex min-w-0 items-center gap-2 text-sm">
<Network class="h-4 w-4 text-sky-600" />
<span class="truncate font-medium">{{ connectionInfo?.serverAddr || "Nacos" }}</span>
<span class="truncate font-medium">{{ connectionInfo?.displayServerAddr || connectionInfo?.serverAddr || "Nacos" }}</span>
<Badge v-if="connectionInfo?.serverVersion" variant="secondary">{{ connectionInfo.serverVersion }}</Badge>
<Badge variant="outline">{{ namespaceLabel }}</Badge>
<Badge v-if="namespaceIdLabel" variant="outline" class="max-w-72 truncate font-mono">{{ namespaceIdLabel }}</Badge>

View File

@ -8,6 +8,7 @@ export interface NacosCapabilities {
export interface NacosConnectionInfo {
serverAddr: string;
displayServerAddr: string;
namespace: string;
serverVersion?: string;
auth: string;

View File

@ -1117,7 +1117,7 @@ impl AppState {
}
let (host, port) = self.connection_host_port(connection_id, config).await?;
Ok(nacos_config.with_connect_override(&host, port))
nacos_config.with_server_endpoint(&host, port)
}
async fn remove_stale_connection_pool(&self, pool_key: &str) -> bool {
@ -3479,6 +3479,40 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn nacos_admin_config_rewrites_server_addr_to_forwarded_endpoint() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(None);
config.id = "proxied-nacos".to_string();
config.db_type = DatabaseType::Nacos;
config.host = "192.168.2.51".to_string();
config.port = 10840;
config.external_config = Some(serde_json::json!({
"serverAddr": "http://192.168.2.51:10840",
"namespace": "public",
"contextPath": "",
"auth": { "kind": "none" }
}));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
id: "proxy".to_string(),
name: String::new(),
enabled: true,
proxy_type: ProxyType::Socks5,
host: "127.0.0.1".to_string(),
port: 65000,
username: String::new(),
password: String::new(),
})];
let nacos_config = state.nacos_admin_config_for_connection("proxied-nacos", &config).await.unwrap();
assert!(nacos_config.server_addr.starts_with("http://127.0.0.1:"));
assert_ne!(nacos_config.server_addr, "http://192.168.2.51:10840");
assert!(nacos_config.connect_override.is_none());
state.proxy_tunnels.stop_tunnel("proxied-nacos:transport:0").await;
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
#[ignore = "requires a reachable GaussDB instance via environment variables"]
async fn live_gaussdb_native_connection_succeeds() {

View File

@ -19,6 +19,8 @@ impl Default for NacosAuthConfig {
#[serde(rename_all = "camelCase")]
pub struct NacosAdminConfig {
pub server_addr: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub display_server_addr: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
@ -53,6 +55,7 @@ impl NacosAdminConfig {
let scheme = if cfg.ssl { "https" } else { "http" };
NacosAdminConfig {
server_addr: format!("{scheme}://{}:{}", cfg.host.trim(), cfg.port),
display_server_addr: String::new(),
namespace: cfg.database.clone().unwrap_or_default(),
context_path: String::new(),
auth: if cfg.username.trim().is_empty() {
@ -73,6 +76,11 @@ impl NacosAdminConfig {
if self.server_addr.is_empty() {
return Err("Nacos server address is empty".to_string());
}
if self.display_server_addr.trim().is_empty() {
self.display_server_addr = self.server_addr.clone();
} else {
self.display_server_addr = self.display_server_addr.trim().trim_end_matches('/').to_string();
}
self.context_path = normalize_context_path(&self.context_path);
if self.page_size == 0 {
self.page_size = default_page_size();
@ -85,6 +93,16 @@ impl NacosAdminConfig {
self.connect_override = Some(NacosConnectOverride { host: host.to_string(), port });
self
}
pub fn with_server_endpoint(mut self, host: &str, port: u16) -> Result<Self, String> {
let mut url =
reqwest::Url::parse(&self.server_addr).map_err(|e| format!("Nacos server address is invalid: {e}"))?;
url.set_host(Some(host)).map_err(|_| format!("Nacos server address host is invalid: {host}"))?;
url.set_port(Some(port)).map_err(|_| format!("Nacos server address port is invalid: {port}"))?;
self.server_addr = url.to_string().trim_end_matches('/').to_string();
self.connect_override = None;
Ok(self)
}
}
pub fn normalize_context_path(path: &str) -> String {
@ -191,4 +209,20 @@ mod tests {
assert_eq!(parsed.context_path, "");
assert!(matches!(parsed.auth, NacosAuthConfig::UsernamePassword { .. }));
}
#[test]
fn with_server_endpoint_rewrites_only_host_and_port() {
let cfg = connection_with_external(serde_json::json!({
"serverAddr": "https://192.168.2.51:10840/nacos",
"namespace": "public",
"contextPath": "/console",
"auth": { "kind": "none" }
}));
let parsed = NacosAdminConfig::from_connection(&cfg).unwrap().with_server_endpoint("127.0.0.1", 49152).unwrap();
assert_eq!(parsed.server_addr, "https://127.0.0.1:49152/nacos");
assert_eq!(parsed.context_path, "/console");
assert!(parsed.connect_override.is_none());
}
}

View File

@ -32,22 +32,6 @@ impl NacosOpenApiAdmin {
if cfg.tls_skip_verify {
builder = builder.danger_accept_invalid_certs(true);
}
if let Some(connect_override) = cfg.connect_override.as_ref() {
let url =
reqwest::Url::parse(&cfg.server_addr).map_err(|e| format!("Nacos server address is invalid: {e}"))?;
let host = url.host_str().ok_or("Nacos server address host is empty")?;
let _port = url.port_or_known_default().ok_or("Nacos server address port is empty")?;
builder = builder.resolve(
host,
std::net::SocketAddr::new(
connect_override
.host
.parse()
.map_err(|e| format!("Nacos transport override host must be an IP address: {e}"))?,
connect_override.port,
),
);
}
let http = builder.build().map_err(|e| format!("Failed to build Nacos HTTP client: {e}"))?;
Ok(Self { cfg, http, token: Mutex::new(None) })
}
@ -359,6 +343,7 @@ impl NacosAdmin for NacosOpenApiAdmin {
let _ = self.access_token().await?;
Ok(NacosConnectionInfo {
server_addr: self.cfg.server_addr.clone(),
display_server_addr: self.cfg.display_server_addr.clone(),
namespace: self.cfg.namespace.clone(),
server_version: extract_server_version(&raw),
auth: match self.cfg.auth {

View File

@ -27,7 +27,7 @@ pub use crate::nacos::types::*;
#[derive(Default)]
pub struct NacosAdminRegistry {
instances: RwLock<HashMap<String, Arc<dyn NacosAdmin>>>,
instances: RwLock<HashMap<String, (NacosAdminConfig, Arc<dyn NacosAdmin>)>>,
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
}
@ -46,8 +46,10 @@ impl NacosAdminRegistry {
connection_id: &str,
cfg: NacosAdminConfig,
) -> Result<Arc<dyn NacosAdmin>, String> {
if let Some(admin) = self.instances.read().await.get(connection_id) {
return Ok(admin.clone());
if let Some((existing_cfg, admin)) = self.instances.read().await.get(connection_id) {
if existing_cfg == &cfg {
return Ok(admin.clone());
}
}
let lock = {
@ -56,12 +58,14 @@ impl NacosAdminRegistry {
};
let _guard = lock.lock().await;
if let Some(admin) = self.instances.read().await.get(connection_id) {
return Ok(admin.clone());
if let Some((existing_cfg, admin)) = self.instances.read().await.get(connection_id) {
if existing_cfg == &cfg {
return Ok(admin.clone());
}
}
let admin = build_admin(cfg)?;
self.instances.write().await.insert(connection_id.to_string(), admin.clone());
let admin = build_admin(cfg.clone())?;
self.instances.write().await.insert(connection_id.to_string(), (cfg, admin.clone()));
Ok(admin)
}

View File

@ -4,7 +4,11 @@ use crate::nacos::types::*;
pub async fn nacos_test_connection_core(state: &AppState, conn_id: &str) -> Result<NacosConnectionInfo, String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let admin = state.nacos_registry.build_transient(&cfg).await?;
if cfg.db_type != DatabaseType::Nacos {
return Err("Connection is not a Nacos admin connection".to_string());
}
let admin_config = state.nacos_admin_config_for_connection(conn_id, &cfg).await?;
let admin = state.nacos_registry.build_transient_config(admin_config).await?;
admin.test_connection().await
}
@ -140,7 +144,8 @@ async fn get_admin(
if cfg.db_type != DatabaseType::Nacos {
return Err("Connection is not a Nacos admin connection".to_string());
}
state.nacos_registry.get_or_build(&cfg).await
let admin_config = state.nacos_admin_config_for_connection(conn_id, &cfg).await?;
state.nacos_registry.get_or_build_config(conn_id, admin_config).await
}
async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> {

View File

@ -26,6 +26,7 @@ impl Default for NacosCapabilities {
#[serde(rename_all = "camelCase")]
pub struct NacosConnectionInfo {
pub server_addr: String,
pub display_server_addr: String,
pub namespace: String,
pub server_version: Option<String>,
pub auth: String,