fix(nacos): route admin API through SSH/proxy transport
This commit is contained in:
parent
fe01de3f17
commit
a149d9baa4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface NacosCapabilities {
|
|||
|
||||
export interface NacosConnectionInfo {
|
||||
serverAddr: string;
|
||||
displayServerAddr: string;
|
||||
namespace: string;
|
||||
serverVersion?: string;
|
||||
auth: string;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue