377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { join } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import Database from "better-sqlite3";
|
|
import { dbPath as defaultDbPath } from "./paths.js";
|
|
|
|
export interface ConnectionConfig {
|
|
id: string;
|
|
name: string;
|
|
db_type: string;
|
|
driver_profile?: string;
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
database?: string;
|
|
url_params?: string;
|
|
transport_layers?: TransportLayerConfig[];
|
|
keepalive_interval_secs?: number;
|
|
ssl: boolean;
|
|
ca_cert_path?: string;
|
|
client_cert_path?: string;
|
|
client_key_path?: string;
|
|
oracle_connection_type?: "service_name" | "sid";
|
|
redis_connection_mode?: "standalone" | "sentinel" | "cluster";
|
|
redis_sentinel_master?: string;
|
|
redis_sentinel_nodes?: string;
|
|
redis_sentinel_username?: string;
|
|
redis_sentinel_password?: string;
|
|
redis_sentinel_tls?: boolean;
|
|
redis_cluster_nodes?: string;
|
|
redis_key_separator?: string;
|
|
read_only?: boolean;
|
|
is_production?: boolean;
|
|
production_databases?: string[];
|
|
}
|
|
|
|
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig);
|
|
|
|
export interface SshTunnelConfig {
|
|
id: string;
|
|
name?: string;
|
|
enabled?: boolean;
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
password?: string;
|
|
key_path?: string;
|
|
key_passphrase?: string;
|
|
connect_timeout_secs?: number;
|
|
expose_lan?: boolean;
|
|
use_ssh_agent?: boolean;
|
|
ssh_agent_sock_path?: string;
|
|
}
|
|
|
|
export interface ProxyTunnelConfig {
|
|
id: string;
|
|
name?: string;
|
|
enabled?: boolean;
|
|
proxy_type?: "socks5" | "http";
|
|
host: string;
|
|
port: number;
|
|
username?: string;
|
|
password?: string;
|
|
}
|
|
|
|
export interface ConnectionStoreOptions {
|
|
path?: string;
|
|
}
|
|
|
|
export interface ConnectionStoreDiagnostics {
|
|
dbPath: string;
|
|
dbPathExists: boolean;
|
|
connectionsTableExists: boolean;
|
|
connectionSecretsTableExists: boolean;
|
|
connectionRowCount: number;
|
|
loadConnectionsOk: boolean;
|
|
loadedConnectionCount: number;
|
|
loadConnectionsError?: string;
|
|
}
|
|
|
|
export class ConnectionStoreError extends Error {
|
|
readonly code = "CONNECTION_STORE_ERROR";
|
|
|
|
constructor(path: string, cause: unknown) {
|
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
super(`Failed to load DBX connections from ${path}: ${message}`);
|
|
this.name = "ConnectionStoreError";
|
|
}
|
|
}
|
|
|
|
export function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig {
|
|
if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") {
|
|
return {
|
|
...config,
|
|
db_type: "tdengine",
|
|
driver_profile: "tdengine",
|
|
port: config.port === 0 || config.port === 6030 ? 6041 : config.port,
|
|
};
|
|
}
|
|
if (config.db_type === "tdengine") {
|
|
return {
|
|
...config,
|
|
driver_profile: "tdengine",
|
|
port: config.port || 6041,
|
|
};
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function openDb(readonly = false, path = defaultDbPath()): Database.Database {
|
|
return new Database(path, { readonly });
|
|
}
|
|
|
|
function getSecret(db: Database.Database, connectionId: string, key: string): string {
|
|
const row = db.prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?").get(connectionId, key) as { secret: string } | undefined;
|
|
return row?.secret ?? "";
|
|
}
|
|
|
|
function transportLayerSecretSegment(index: number, layer: TransportLayerConfig): string {
|
|
return layer.id?.trim() || String(index);
|
|
}
|
|
|
|
function transportLayerSshPasswordKey(index: number, layer: TransportLayerConfig): string {
|
|
return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_password`;
|
|
}
|
|
|
|
function transportLayerSshKeyPassphraseKey(index: number, layer: TransportLayerConfig): string {
|
|
return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_key_passphrase`;
|
|
}
|
|
|
|
function transportLayerProxyPasswordKey(index: number, layer: TransportLayerConfig): string {
|
|
return `transport_layers.${transportLayerSecretSegment(index, layer)}.proxy_password`;
|
|
}
|
|
|
|
type LegacyConnectionConfig = ConnectionConfig & {
|
|
ssh_enabled?: boolean;
|
|
ssh_host?: string;
|
|
ssh_port?: number;
|
|
ssh_user?: string;
|
|
ssh_password?: string;
|
|
ssh_key_path?: string;
|
|
ssh_key_passphrase?: string;
|
|
ssh_expose_lan?: boolean;
|
|
ssh_connect_timeout_secs?: number;
|
|
ssh_tunnels?: SshTunnelConfig[];
|
|
proxy_enabled?: boolean;
|
|
proxy_type?: "socks5" | "http";
|
|
proxy_host?: string;
|
|
proxy_port?: number;
|
|
proxy_username?: string;
|
|
proxy_password?: string;
|
|
};
|
|
|
|
function normalizeTransportLayers(config: LegacyConnectionConfig): TransportLayerConfig[] {
|
|
if (Array.isArray(config.transport_layers) && config.transport_layers.length > 0) return config.transport_layers;
|
|
const layers: TransportLayerConfig[] = [];
|
|
if (config.ssh_enabled && Array.isArray(config.ssh_tunnels) && config.ssh_tunnels.length > 0) {
|
|
layers.push(...config.ssh_tunnels.map((hop) => ({ type: "ssh" as const, ...hop })));
|
|
} else if (config.ssh_enabled && config.ssh_host) {
|
|
layers.push({
|
|
type: "ssh",
|
|
id: "legacy",
|
|
enabled: true,
|
|
host: config.ssh_host,
|
|
port: config.ssh_port || 22,
|
|
user: config.ssh_user || "",
|
|
password: config.ssh_password || "",
|
|
key_path: config.ssh_key_path || "",
|
|
key_passphrase: config.ssh_key_passphrase || "",
|
|
connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
|
|
expose_lan: !!config.ssh_expose_lan,
|
|
use_ssh_agent: false,
|
|
});
|
|
}
|
|
if (config.proxy_enabled && config.proxy_host) {
|
|
layers.push({
|
|
type: "proxy",
|
|
id: "legacy-proxy",
|
|
enabled: true,
|
|
proxy_type: config.proxy_type || "socks5",
|
|
host: config.proxy_host,
|
|
port: config.proxy_port || 1080,
|
|
username: config.proxy_username || "",
|
|
password: config.proxy_password || "",
|
|
});
|
|
}
|
|
return layers;
|
|
}
|
|
|
|
function hydrateTransportLayerSecrets(db: Database.Database, config: ConnectionConfig, connectionId: string) {
|
|
config.transport_layers = normalizeTransportLayers(config as LegacyConnectionConfig);
|
|
config.transport_layers.forEach((layer, index) => {
|
|
if (layer.type === "ssh") {
|
|
layer.password ||= getSecret(db, connectionId, transportLayerSshPasswordKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_password") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.password`));
|
|
layer.key_passphrase ||= getSecret(db, connectionId, transportLayerSshKeyPassphraseKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_key_passphrase") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.key_passphrase`));
|
|
} else {
|
|
layer.password ||= getSecret(db, connectionId, transportLayerProxyPasswordKey(index, layer)) || (layer.id === "legacy-proxy" ? getSecret(db, connectionId, "proxy_password") : "");
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function loadConnections(options: ConnectionStoreOptions = {}): Promise<ConnectionConfig[]> {
|
|
const path = options.path ?? defaultDbPath();
|
|
if (!existsSync(path)) return [];
|
|
|
|
let db: Database.Database | undefined;
|
|
try {
|
|
db = openDb(true, path);
|
|
const rows = db.prepare("SELECT id, config_json FROM connections").all() as { id: string; config_json: string }[];
|
|
const configs: ConnectionConfig[] = [];
|
|
|
|
for (const row of rows) {
|
|
const config: ConnectionConfig = canonicalizeConnection(JSON.parse(row.config_json));
|
|
config.id = row.id;
|
|
if (!config.password) config.password = getSecret(db, row.id, "password");
|
|
hydrateTransportLayerSecrets(db, config, row.id);
|
|
if (!config.redis_sentinel_password) {
|
|
config.redis_sentinel_password = getSecret(db, row.id, "redis_sentinel_password");
|
|
}
|
|
configs.push(config);
|
|
}
|
|
|
|
return configs;
|
|
} catch (error) {
|
|
throw new ConnectionStoreError(path, error);
|
|
} finally {
|
|
db?.close();
|
|
}
|
|
}
|
|
|
|
export async function inspectConnectionStore(options: ConnectionStoreOptions = {}): Promise<ConnectionStoreDiagnostics> {
|
|
const path = options.path ?? defaultDbPath();
|
|
const diagnostics: ConnectionStoreDiagnostics = {
|
|
dbPath: path,
|
|
dbPathExists: existsSync(path),
|
|
connectionsTableExists: false,
|
|
connectionSecretsTableExists: false,
|
|
connectionRowCount: 0,
|
|
loadConnectionsOk: true,
|
|
loadedConnectionCount: 0,
|
|
};
|
|
|
|
if (!diagnostics.dbPathExists) return diagnostics;
|
|
|
|
let db: Database.Database | undefined;
|
|
try {
|
|
db = openDb(true, path);
|
|
diagnostics.connectionsTableExists = tableExists(db, "connections");
|
|
diagnostics.connectionSecretsTableExists = tableExists(db, "connection_secrets");
|
|
if (diagnostics.connectionsTableExists) {
|
|
const row = db.prepare("SELECT COUNT(*) AS count FROM connections").get() as { count: number };
|
|
diagnostics.connectionRowCount = row.count;
|
|
}
|
|
} catch (error) {
|
|
diagnostics.loadConnectionsOk = false;
|
|
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
|
|
return diagnostics;
|
|
} finally {
|
|
db?.close();
|
|
}
|
|
|
|
try {
|
|
const connections = await loadConnections({ path });
|
|
diagnostics.loadedConnectionCount = connections.length;
|
|
} catch (error) {
|
|
diagnostics.loadConnectionsOk = false;
|
|
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function tableExists(db: Database.Database, name: string): boolean {
|
|
const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { "1": number } | undefined;
|
|
return !!row;
|
|
}
|
|
|
|
export async function findConnection(name: string): Promise<ConnectionConfig | undefined> {
|
|
const connections = await loadConnections();
|
|
return connections.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
}
|
|
|
|
export async function findConnectionById(id: string): Promise<ConnectionConfig | undefined> {
|
|
const connections = await loadConnections();
|
|
return connections.find((c) => c.id === id);
|
|
}
|
|
|
|
export async function addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig> {
|
|
const id = randomUUID();
|
|
const db = openDb();
|
|
const normalized = canonicalizeConnection({ ...config, id } as ConnectionConfig);
|
|
|
|
const full = {
|
|
id,
|
|
name: normalized.name,
|
|
db_type: normalized.db_type,
|
|
driver_profile: normalized.driver_profile ?? normalized.db_type,
|
|
driver_label: null,
|
|
url_params: normalized.url_params ?? "",
|
|
host: normalized.host,
|
|
port: normalized.port,
|
|
username: normalized.username,
|
|
password: "",
|
|
database: normalized.database ?? null,
|
|
color: null,
|
|
transport_layers: normalizeTransportLayers(normalized as LegacyConnectionConfig).map((layer) => {
|
|
if (layer.type === "ssh") return { ...layer, password: "", key_passphrase: "" };
|
|
return { ...layer, password: "" };
|
|
}),
|
|
ssl: normalized.ssl ?? false,
|
|
sysdba: false,
|
|
oracle_connection_type: normalized.oracle_connection_type ?? null,
|
|
connection_string: null,
|
|
redis_connection_mode: normalized.redis_connection_mode ?? "standalone",
|
|
redis_sentinel_master: normalized.redis_sentinel_master ?? "",
|
|
redis_sentinel_nodes: normalized.redis_sentinel_nodes ?? "",
|
|
redis_sentinel_username: normalized.redis_sentinel_username ?? "",
|
|
redis_sentinel_password: "",
|
|
redis_sentinel_tls: normalized.redis_sentinel_tls ?? false,
|
|
};
|
|
const configJson = JSON.stringify(full);
|
|
|
|
const insert = db.transaction(() => {
|
|
db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson);
|
|
if (normalized.password) {
|
|
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", normalized.password);
|
|
}
|
|
normalizeTransportLayers(normalized as LegacyConnectionConfig).forEach((layer, index) => {
|
|
if (layer.type === "ssh") {
|
|
if (layer.password) {
|
|
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshPasswordKey(index, layer), layer.password);
|
|
}
|
|
if (layer.key_passphrase) {
|
|
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshKeyPassphraseKey(index, layer), layer.key_passphrase);
|
|
}
|
|
} else if (layer.password) {
|
|
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerProxyPasswordKey(index, layer), layer.password);
|
|
}
|
|
});
|
|
if (normalized.redis_sentinel_password) {
|
|
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "redis_sentinel_password", normalized.redis_sentinel_password);
|
|
}
|
|
});
|
|
insert();
|
|
db.close();
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export async function removeConnection(name: string): Promise<boolean> {
|
|
const connection = await findConnection(name);
|
|
if (!connection) return false;
|
|
|
|
const db = openDb();
|
|
const remove = db.transaction(() => {
|
|
db.prepare("DELETE FROM connections WHERE id = ?").run(connection.id);
|
|
db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(connection.id);
|
|
});
|
|
remove();
|
|
db.close();
|
|
|
|
return true;
|
|
}
|
|
|
|
export async function removeConnectionById(id: string): Promise<boolean> {
|
|
const db = openDb();
|
|
const remove = db.transaction(() => {
|
|
const result = db.prepare("DELETE FROM connections WHERE id = ?").run(id);
|
|
db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(id);
|
|
return result.changes > 0;
|
|
});
|
|
const deleted = remove();
|
|
db.close();
|
|
return deleted;
|
|
}
|