diff --git a/package-lock.json b/package-lock.json index 51f3ca2..02fcc34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Oracle", - "version": "0.2.56", + "version": "0.2.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Oracle", - "version": "0.2.56", + "version": "0.2.60", "dependencies": { "adm-zip": "0.5.9", "async": "3.2.6", @@ -1043,9 +1043,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1271,7 +1271,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1953,7 +1952,6 @@ "integrity": "sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -2089,7 +2087,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/reverse_engineering/api.js b/reverse_engineering/api.js index c4b5af4..541f952 100644 --- a/reverse_engineering/api.js +++ b/reverse_engineering/api.js @@ -29,12 +29,29 @@ module.exports = { }, async testConnection(connectionInfo, logger, callback, app) { + const sshService = app.require('@hackolade/ssh-service'); + try { - await this.connect(connectionInfo, logger, () => {}, app); + logInfo('Test connection', connectionInfo, logger); + oracleHelper.logEnvironment(logger); + await oracleHelper.disconnect(sshService); + await oracleHelper.connect(connectionInfo, sshService, message => { + logger.log('info', message, 'Connection'); + }); callback(null); } catch (error) { logger.log('error', { message: error.message, stack: error.stack, error }, 'Test connection'); callback({ message: error.message, stack: error.stack }); + } finally { + try { + await oracleHelper.disconnect(sshService); + } catch (disconnectError) { + logger.log( + 'warn', + { message: disconnectError.message, stack: disconnectError.stack }, + 'Disconnect after test connection', + ); + } } }, @@ -42,7 +59,11 @@ module.exports = { try { logInfo('Get schemas', connectionInfo, logger); await this.connect(connectionInfo, logger, () => {}, app); - const schemas = await oracleHelper.getSchemaNames(); + const schemas = await oracleHelper.getSchemaNames(connectionInfo, { + info: data => logger.log('info', data, 'Get schemas'), + error: error => + logger.log('error', { message: error.message, stack: error.stack, error }, 'Get schemas'), + }); logger.log('info', schemas, 'All schemas list', connectionInfo.hiddenKeys); return callback(null, schemas); } catch (error) { diff --git a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json index 04e18d9..fcb3130 100644 --- a/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json +++ b/reverse_engineering/connection_settings_modal/connectionSettingsModalConfig.json @@ -99,9 +99,9 @@ } }, { - "inputLabel": "Wallet file", + "inputLabel": "Wallet archive", "inputKeyword": "walletFile", - "description": "Specify the full path location and name of the wallet zip file", + "description": "Specify the full path location and name of the wallet zip file.", "inputType": "file", "extensions": ["zip"], "dependency": { @@ -110,11 +110,23 @@ } }, { - "inputLabel": "Tnsnames Directory", + "inputLabel": "Tnsnames file", "inputKeyword": "TNSpath", - "description": "Specify the full path to directory with tnsnames.ora file", + "description": "Specify the full path to tnsnames.ora.", "inputType": "file", "openType": "openDirectory", + "extensions": ["ora", "txt"], + "dependency": { + "key": "connectionMethod", + "value": ["TNS"] + } + }, + { + "inputLabel": "Mutual TLS (mTLS)", + "inputKeyword": "mutualTLS", + "description": "Enable only when the database requires a client certificate (cloud wallet, mutual TLS).", + "inputType": "checkbox", + "defaultValue": false, "dependency": { "key": "connectionMethod", "value": ["TNS"] @@ -137,27 +149,19 @@ { "inputLabel": "Service Name", "inputKeyword": "serviceName", - "description": "Specify the service name of the Oracle Instance", + "description": "Specify the service name of the Oracle Instance.", "inputType": "text", + "regex": "([^\\s])", "dependency": { - "type": "or", + "type": "and", "values": [ { "key": "connectionMethod", - "value": ["TNS"] + "value": ["Basic"] }, { - "type": "and", - "values": [ - { - "key": "connectionMethod", - "value": ["Basic"] - }, - { - "key": "identifierType", - "value": ["serviceName"] - } - ] + "key": "identifierType", + "value": ["serviceName"] } ] } @@ -165,27 +169,59 @@ { "inputLabel": "Wallet Password", "inputKeyword": "walletPassword", - "description": "Specify the password used to protect the wallet", "inputType": "password", "isHiddenKey": true, "dependency": { - "type": "and", + "type": "or", "values": [ { - "key": "connectionMethod", - "value": ["Wallet"] + "type": "and", + "values": [ + { + "key": "connectionMethod", + "value": ["Wallet"] + }, + { + "key": "mode", + "value": ["thin"] + } + ] }, { - "key": "mode", - "value": ["thin"] + "type": "and", + "values": [ + { + "key": "connectionMethod", + "value": ["TNS"] + }, + { + "key": "mode", + "value": ["thin"] + }, + { + "key": "mutualTLS", + "value": [true, "true"] + } + ] } ] } }, + { + "inputLabel": "TNS alias", + "inputKeyword": "serviceName", + "description": "Leave empty to use the first entry in tnsnames.ora (e.g. _high).", + "inputType": "text", + "inputPlaceholder": "Optional TNS alias", + "dependency": { + "key": "connectionMethod", + "value": ["TNS"] + } + }, { "inputLabel": "SID", "inputKeyword": "sid", - "description": "Optionally specify the SID of the Oracle Instance", + "description": "Optionally specify the SID of the Oracle Instance.", "inputType": "text", "dependency": { "type": "and", @@ -209,21 +245,24 @@ { "inputLabel": "Authentication method", "inputKeyword": "authMethod", + "description": "OS and Kerberos require Thick mode and a configured Oracle client (Instant Client or Oracle Home).", "inputType": "select", "defaultValue": "Username / Password", - "options": [{ "value": "Username / Password", "label": "Username / Password" }] + "options": [ + { "value": "Username / Password", "label": "Username / Password" }, + { "value": "OS", "label": "OS" }, + { "value": "Kerberos", "label": "Kerberos" } + ] }, { "inputLabel": "User Name", "inputKeyword": "userName", "inputType": "text", "inputPlaceholder": "User Name", + "description": "For Kerberos proxy connections, use the [username] format.", "dependency": { "key": "authMethod", "value": ["Username / Password", "Kerberos"] - }, - "validation": { - "regex": "([^\\s])" } }, { @@ -233,7 +272,7 @@ "inputPlaceholder": "Password", "dependency": { "key": "authMethod", - "value": ["Username / Password", "Kerberos"] + "value": ["Username / Password"] }, "isHiddenKey": true, "validation": { diff --git a/reverse_engineering/helpers/connectStringDescription.js b/reverse_engineering/helpers/connectStringDescription.js new file mode 100644 index 0000000..8745fd6 --- /dev/null +++ b/reverse_engineering/helpers/connectStringDescription.js @@ -0,0 +1,32 @@ +const combine = (val, str) => (val ? str : ''); + +const normalizeConnectString = connectString => + typeof connectString === 'string' ? connectString.replace(/\s+/g, '') : connectString; + +const getConnectionDescription = ( + { protocol, host, port, sid, service, httpsProxy, httpsProxyPort, retryCount, retryDelay, sslServerDnMatch }, + logger, +) => { + const connectionString = normalizeConnectString(`(DESCRIPTION= + ${combine(retryCount, `(RETRY_COUNT=${retryCount})`)} + ${combine(retryDelay, `(RETRY_DELAY=${retryDelay})`)} + (ADDRESS= + (PROTOCOL=${protocol || 'tcp'}) + (HOST=${host}) + (PORT=${port})) + ${combine(httpsProxy, `(HTTPS_PROXY=${httpsProxy})`)} + ${combine(httpsProxyPort, `(HTTPS_PROXY_PORT=${httpsProxyPort})`)} + (CONNECT_DATA= + ${combine(sid, `(SID=${sid})`)} + ${combine(service, `(SERVICE_NAME=${service})`)} + ) + ${combine(sslServerDnMatch, `(SECURITY=(SSL_SERVER_DN_MATCH=${sslServerDnMatch}))`)} + )`); + logger({ message: 'connectString', connectString: connectionString }); + return connectionString; +}; + +module.exports = { + normalizeConnectString, + getConnectionDescription, +}; diff --git a/reverse_engineering/helpers/connectionAuth.js b/reverse_engineering/helpers/connectionAuth.js new file mode 100644 index 0000000..abd5bd3 --- /dev/null +++ b/reverse_engineering/helpers/connectionAuth.js @@ -0,0 +1,66 @@ +const _ = require('lodash'); +const { normalizeTnsAlias } = require('./tns/tnsConnectString'); + +const AUTH_METHOD_USERNAME_PASSWORD = 'Username / Password'; +const AUTH_METHOD_OS = 'OS'; +const AUTH_METHOD_KERBEROS = 'Kerberos'; + +const normalizeAuthMethod = authMethod => authMethod || AUTH_METHOD_USERNAME_PASSWORD; + +const assertExternalAuthMode = (authMethod, mode) => { + if (authMethod === AUTH_METHOD_USERNAME_PASSWORD) { + return; + } + + if (mode === 'thin') { + throw new Error( + `${authMethod} authentication requires Thick mode with Oracle Instant Client or Oracle Home configured for external authentication.`, + ); + } +}; + +const buildConnectionAuthParams = (authMethod, userName, userPassword) => { + if (authMethod === AUTH_METHOD_USERNAME_PASSWORD) { + if (!normalizeTnsAlias(userName) || !userPassword) { + throw new Error('User name and password are required for Username / Password authentication.'); + } + + return { username: userName, password: userPassword }; + } + + if (authMethod === AUTH_METHOD_OS) { + return { externalAuth: true }; + } + + if (authMethod === AUTH_METHOD_KERBEROS) { + const trimmedUserName = normalizeTnsAlias(userName); + const proxyUserName = + trimmedUserName && !trimmedUserName.startsWith('[') ? `[${trimmedUserName}]` : trimmedUserName; + + return _.omitBy( + { + externalAuth: true, + username: proxyUserName || undefined, + }, + _.isUndefined, + ); + } + + return { username: userName, password: userPassword }; +}; + +const logAuthMethodNotes = (authMethod, userPassword, logger) => { + if (authMethod === AUTH_METHOD_KERBEROS && userPassword) { + logger({ + message: + 'Password is not sent for Kerberos external authentication (oracledb uses the OS Kerberos ticket).', + }); + } +}; + +module.exports = { + normalizeAuthMethod, + assertExternalAuthMode, + buildConnectionAuthParams, + logAuthMethodNotes, +}; diff --git a/reverse_engineering/helpers/extractWallet.js b/reverse_engineering/helpers/extractWallet.js index d3ae40e..14a1ff4 100644 --- a/reverse_engineering/helpers/extractWallet.js +++ b/reverse_engineering/helpers/extractWallet.js @@ -92,3 +92,4 @@ const extractWallet = async ({ walletFile, tempFolder, name }) => { }; module.exports = extractWallet; +module.exports.fixSqlNetOraWalletPath = replaceSqlNetOraDirectoryPath; diff --git a/reverse_engineering/helpers/oracleHelper.js b/reverse_engineering/helpers/oracleHelper.js index 510d1bb..29457c1 100644 --- a/reverse_engineering/helpers/oracleHelper.js +++ b/reverse_engineering/helpers/oracleHelper.js @@ -1,11 +1,27 @@ const _ = require('lodash'); -const fs = require('fs'); -const path = require('path'); +const dns = require('dns'); +const dnsPromises = dns.promises; +const net = require('net'); const oracleDB = require('oracledb'); -const extractWallet = require('./extractWallet'); -const parseTns = require('./parseTns'); const { getSchemaSequences } = require('./getSchemaSequences'); const { getSchemaSynonyms } = require('./getSchemaSynonyms'); +const { normalizeConnectString, getConnectionDescription } = require('./connectStringDescription'); +const { clearPluginTnsAdmin } = require('./tns/tnsAdmin'); +const { normalizeTnsAlias, getResolvedTnsService } = require('./tns/tnsConnectString'); +const { + normalizeAuthMethod, + assertExternalAuthMode, + buildConnectionAuthParams, + logAuthMethodNotes, +} = require('./connectionAuth'); +const { + trySyncTnsEndpointEarly, + resolveConnectionConfigDir, + buildSessionConnectString, + shouldUseWalletForConnect, + logWalletConnectNotes, + isMutualTlsEnabled, +} = require('./tns/tnsConnectionSetup'); const noConnectionError = { message: 'Connection error' }; @@ -25,104 +41,36 @@ const parseProxyOptions = (proxyString = '') => { }; }; -const getTnsNamesOraFile = configDir => { - const tnsNamesOraFile = [ - configDir, - process.env.TNS_ADMIN, - path.join(process.env.ORACLE_HOME || '', 'network', 'admin'), - path.join(process.env.LD_LIBRARY_PATH || '', 'network', 'admin'), - ].reduce((filePath, configFolder) => { - if (filePath) { - return filePath; - } - - let file = path.join(configFolder, 'tnsnames.ora'); - - if (fs.existsSync(file)) { - return file; - } else { - return filePath; - } - }, ''); - - return tnsNamesOraFile; -}; - -const parseTnsNamesOra = filePath => { - const content = fs.readFileSync(filePath).toString(); - const result = parseTns(content); - return result; -}; - -const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger) => { - const filePath = getTnsNamesOraFile(configDir); +const UNUSABLE_RESOLVED_HOSTS = new Set(['255.255.255.255', '0.0.0.0']); - if (!fs.existsSync(filePath)) { - return serviceName; +const assertResolvableConnectHost = async (hostname, logger) => { + if (!hostname || net.isIP(hostname)) { + return; } - logger({ message: 'Found tnsnames.ora file: ' + filePath }); + let addresses; - const tnsData = parseTnsNamesOra(filePath); - - logger({ message: 'tnsnames.ora successfully parsed' }); - const tnsServicesNames = Object.keys(tnsData); - - if (!tnsData[serviceName] && tnsServicesNames.length === 0) { - logger({ message: `Cannot find '${serviceName}' in tnsnames.ora and no fallback found` }); - return serviceName; + try { + addresses = await dnsPromises.lookup(hostname, { all: true }); + } catch (error) { + throw new Error(`Cannot resolve hostname "${hostname}": ${error.message}`); } - const [firstTnsServiceName] = tnsServicesNames; - const tnsService = tnsData[serviceName] || tnsData[firstTnsServiceName]; - if (!tnsData[serviceName]) { - logger({ - message: `Connect using first TNS service ${firstTnsServiceName}' from ${path.join(configDir, 'tnsnames.ora')}.`, - }); - } else { - logger({ - message: `Connect using TNS service ${serviceName}' from ${path.join(configDir, 'tnsnames.ora')}.`, - }); - } + const resolvedAddresses = addresses.map(entry => entry.address); - const address = tnsService?.data?.description?.address; - const service = tnsService?.data?.description?.connect_data?.service_name; - const sid = tnsService?.data?.description?.connect_data?.sid; + logger({ + message: 'Resolved connection hostname for TCP connect', + hostname, + resolvedAddresses, + }); - logger({ message: 'tnsnames.ora', address, service }); + const unusableAddress = resolvedAddresses.find(address => UNUSABLE_RESOLVED_HOSTS.has(address)); - return getConnectionDescription( - _.omitBy( - { - ...address, - ...proxy, - protocol: address?.protocol || 'tcps', - service: service || serviceName, - sid: sid, - }, - _.isUndefined, - ), - logger, - ); -}; - -const combine = (val, str) => (val ? str : ''); - -const getConnectionDescription = ({ protocol, host, port, sid, service, httpsProxy, httpsProxyPort }, logger) => { - const connectionString = `(DESCRIPTION= - (ADDRESS= - (PROTOCOL=${protocol || 'tcp'}) - (HOST=${host}) - (PORT=${port})) - ${combine(httpsProxy, `(HTTPS_PROXY=${httpsProxy})`)} - ${combine(httpsProxyPort, `(HTTPS_PROXY_PORT=${httpsProxyPort})`)} - (CONNECT_DATA= - ${combine(sid, `(SID=${sid})`)} - ${combine(service, `(SERVICE_NAME=${service})`)} - ) - )`; - logger({ message: 'connectionString', connectionString }); - return connectionString; + if (unusableAddress) { + throw new Error( + `Hostname "${hostname}" resolves to ${unusableAddress}. This often means an Azure VM is stopped or its public IP was deallocated. Start the VM or update the hostname, then try again.`, + ); + } }; const getSshConnectionString = async (data, sshService, logger) => { @@ -134,32 +82,22 @@ const getSshConnectionString = async (data, sshService, logger) => { }; if (['Wallet', 'TNS'].includes(data.connectionMethod)) { - const filePath = getTnsNamesOraFile(data.configDir); + const resolved = getResolvedTnsService(data.configDir, data.serviceName, logger); - if (!fs.existsSync(filePath)) { + if (!resolved) { throw new Error( 'Cannot find tnsnames.ora file. Please, specify tnsnames folder or use Base connection method.', ); } - logger({ message: 'Found tnsnames.ora file: ' + filePath }); - - const tnsData = parseTnsNamesOra(filePath); - - if (!tnsData[data.serviceName]) { - throw new Error('Cannot find "' + data.serviceName + '" in tnsnames.ora'); - } - - const address = tnsData[data.serviceName]?.data?.description?.address; - const service = tnsData[data.serviceName]?.data?.description?.connect_data?.service_name; - const sid = tnsData[data.serviceName]?.data?.description?.connect_data?.sid; + const { address, service, sid } = resolved; logger({ message: 'tnsnames.ora', address, service }); connectionData.protocol = address?.protocol; connectionData.host = address?.host; connectionData.port = address?.port; - connectionData.service = service || data.serviceName; + connectionData.service = service || normalizeTnsAlias(data.serviceName); connectionData.sid = sid; } else { connectionData.host = data.host; @@ -190,8 +128,23 @@ const getSshConnectionString = async (data, sshService, logger) => { ); }; -const connect = async ( - { +const assertBasicServiceName = (connectionMethod, serviceName) => { + if (connectionMethod === 'Basic' && !normalizeTnsAlias(serviceName)) { + throw new Error('Service name is required for Basic connection method.'); + } +}; + +const applySshTunnelIfNeeded = async (ssh, connectString, tunnelParams, sshService, logger) => { + if (!ssh) { + return connectString; + } + + useSshTunnel = true; + return getSshConnectionString(tunnelParams, sshService, logger); +}; + +const connect = async (connectionInfo, sshService, logger) => { + const { walletFile, walletPassword, tempFolder, @@ -219,110 +172,110 @@ const connect = async ( ssh_password, authRole, mode, - }, - sshService, - logger, -) => { + mutualTLS, + } = connectionInfo; + + trySyncTnsEndpointEarly(connectionInfo, { connectionMethod, TNSpath, serviceName }, logger); + if (connection) { + logger({ message: 'Reusing existing Oracle connection' }); return connection; } - const MODES = { - thin: 'thin', - thick: 'thick', - }; - let configDir; - let libDir; - let credentials = {}; - let proxy = ''; - - if (connectionMethod === 'Wallet') { - configDir = await extractWallet({ walletFile, tempFolder, name }); - process.env.TNS_ADMIN = configDir; + if (connectionMethod === 'Basic') { + clearPluginTnsAdmin(); } - if (connectionMethod === 'TNS') { - configDir = TNSpath; - } + const useMutualTls = isMutualTlsEnabled(mutualTLS); + assertBasicServiceName(connectionMethod, serviceName); - if (clientType === 'InstantClient') { - libDir = clientPath; - } + const { configDir, tnsServicePort } = await resolveConnectionConfigDir( + connectionMethod, + connectionInfo, + { walletFile, walletPassword, tempFolder, name, TNSpath, serviceName }, + useMutualTls, + logger, + ); - if (options?.proxy) { - proxy = parseProxyOptions(options?.proxy); - } + const libDir = clientType === 'InstantClient' ? clientPath : undefined; + const proxy = options?.proxy ? parseProxyOptions(options.proxy) : ''; - if (mode !== MODES.thin) { + if (mode !== 'thin') { oracleDB.initOracleClient({ libDir, configDir }); } - let connectString = ''; + let connectString = buildSessionConnectString( + { connectionMethod, configDir, serviceName, proxy, useMutualTls, tnsServicePort, host, port, sid }, + logger, + ); - if (['Wallet', 'TNS'].includes(connectionMethod)) { - connectString = getConnectionStringByTnsNames(configDir, serviceName, proxy, logger); - } else { - connectString = getConnectionDescription( - { - host, - port, - sid, - service: serviceName, + connectString = await applySshTunnelIfNeeded( + ssh, + connectString, + { + host, + port, + configDir, + serviceName, + sid, + connectionMethod, + sshConfig: { + ssh_user, + ssh_host, + ssh_port, + ssh_method, + ssh_key_file, + ssh_password, + ssh_key_passphrase, }, - logger, - ); - } + }, + sshService, + logger, + ); - if (ssh) { - useSshTunnel = true; - connectString = await getSshConnectionString( - { - host, - port, - configDir, - serviceName, - sid, - connectionMethod, - sshConfig: { - ssh_user, - ssh_host, - ssh_port, - ssh_method, - ssh_key_file, - ssh_password, - ssh_key_passphrase, - }, - }, - sshService, - logger, - ); - } + const useWallet = shouldUseWalletForConnect({ + connectionMethod, + useMutualTls, + tnsServicePort, + connectString, + }); + logWalletConnectNotes({ connectionMethod, useMutualTls, useWallet, walletPassword }, logger); - if (authMethod === 'OS') { - credentials.externalAuth = true; - } else if (authMethod === 'Kerberos') { - credentials.username = userName; - credentials.password = userPassword; - credentials.externalAuth = true; - } else { - credentials.username = userName; - credentials.password = userPassword; + const resolvedAuthMethod = normalizeAuthMethod(authMethod); + assertExternalAuthMode(resolvedAuthMethod, mode); + logAuthMethodNotes(resolvedAuthMethod, userPassword, logger); + + const normalizedConnectString = normalizeConnectString(connectString); + const hostnameToResolve = connectionMethod === 'Basic' ? host : connectionInfo.host; + + if (!ssh && hostnameToResolve) { + await assertResolvableConnectHost(hostnameToResolve, logger); } + logger({ + message: 'Oracle connectString', + connectString: normalizedConnectString, + hostname: hostnameToResolve, + useWallet, + walletLocation: useWallet ? configDir : undefined, + configDir: useWallet ? configDir : undefined, + }); + return authByCredentials({ - connectString, - username: userName, - password: userPassword, + connectString: normalizedConnectString, + ...buildConnectionAuthParams(resolvedAuthMethod, userName, userPassword), queryRequestTimeout, authRole, - walletLocation: configDir, - walletPassword, + configDir: useWallet ? configDir : undefined, + walletLocation: useWallet ? configDir : undefined, + walletPassword: useWallet ? walletPassword : undefined, }); }; const disconnect = async sshService => { if (!connection) { - return Promise.reject(noConnectionError); + clearPluginTnsAdmin(); + return; } if (useSshTunnel) { @@ -333,6 +286,7 @@ const disconnect = async sshService => { return new Promise((resolve, reject) => { connection.close(err => { connection = null; + clearPluginTnsAdmin(); if (err) { return reject(err); } @@ -345,20 +299,27 @@ const authByCredentials = ({ connectString, username, password, + externalAuth, queryRequestTimeout, authRole, walletPassword, walletLocation, + configDir, }) => { return new Promise((resolve, reject) => { - const connectionConfig = { - username, - password, - connectString, - privilege: authRole === 'default' ? undefined : oracleDB[authRole], - walletLocation, - walletPassword, - }; + const connectionConfig = _.omitBy( + { + username, + password, + externalAuth, + connectString, + privilege: authRole === 'default' ? undefined : oracleDB[authRole], + walletLocation, + walletPassword, + configDir, + }, + _.isUndefined, + ); oracleDB.getConnection(connectionConfig, (err, conn) => { if (err) { connection = null; @@ -387,11 +348,7 @@ const getSchemaNames = async ({ includeSystemCollection, schemaName }, logger) = } else { query = `${selectStatement} WHERE ORACLE_MAINTAINED = 'N'${stmt ? ` AND ${stmt}` : ''}`; } - return await execute(query).catch(e => { - logger.info({ message: 'Cannot retrieve schema names' }); - logger.error(e); - return []; - }); + return execute(query); }; const pairToObj = pairs => { diff --git a/reverse_engineering/helpers/parseTns.js b/reverse_engineering/helpers/parseTns.js index 0cd5f90..8850ef6 100644 --- a/reverse_engineering/helpers/parseTns.js +++ b/reverse_engineering/helpers/parseTns.js @@ -19,7 +19,7 @@ function parseObject(lex, obj = {}) { return { ...obj, - [id]: value, + [id.toLowerCase()]: value, }; } diff --git a/reverse_engineering/helpers/tns/tnsAdmin.js b/reverse_engineering/helpers/tns/tnsAdmin.js new file mode 100644 index 0000000..8fbf555 --- /dev/null +++ b/reverse_engineering/helpers/tns/tnsAdmin.js @@ -0,0 +1,19 @@ +let pluginTnsAdmin; + +const setPluginTnsAdmin = configDir => { + pluginTnsAdmin = configDir; + process.env.TNS_ADMIN = configDir; +}; + +const clearPluginTnsAdmin = () => { + if (pluginTnsAdmin && process.env.TNS_ADMIN === pluginTnsAdmin) { + delete process.env.TNS_ADMIN; + } + + pluginTnsAdmin = null; +}; + +module.exports = { + setPluginTnsAdmin, + clearPluginTnsAdmin, +}; diff --git a/reverse_engineering/helpers/tns/tnsConfig.js b/reverse_engineering/helpers/tns/tnsConfig.js new file mode 100644 index 0000000..c48e41c --- /dev/null +++ b/reverse_engineering/helpers/tns/tnsConfig.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const path = require('path'); +const parseTns = require('../parseTns'); + +const TNS_NAMES_FILE = 'tnsnames.ora'; + +const resolveTnsConfigDir = tnsPath => { + if (!tnsPath) { + return tnsPath; + } + + const normalizedPath = path.normalize(String(tnsPath).trim()); + + if (!fs.existsSync(normalizedPath)) { + return normalizedPath; + } + + if (fs.statSync(normalizedPath).isDirectory()) { + return normalizedPath; + } + + if (path.basename(normalizedPath).toLowerCase() === TNS_NAMES_FILE) { + return path.dirname(normalizedPath); + } + + throw new Error(`Invalid TNS path "${normalizedPath}". Select the wallet directory or the ${TNS_NAMES_FILE} file.`); +}; + +const getTnsNamesOraFile = configDir => { + const resolvedConfigDir = resolveTnsConfigDir(configDir); + const tnsNamesOraFile = [ + resolvedConfigDir, + process.env.TNS_ADMIN, + path.join(process.env.ORACLE_HOME || '', 'network', 'admin'), + path.join(process.env.LD_LIBRARY_PATH || '', 'network', 'admin'), + ].reduce((filePath, configFolder) => { + if (filePath) { + return filePath; + } + + let file = path.join(configFolder, 'tnsnames.ora'); + + if (fs.existsSync(file)) { + return file; + } else { + return filePath; + } + }, ''); + + return tnsNamesOraFile; +}; + +const assertTnsConfigDir = configDir => { + const tnsNamesOraFile = getTnsNamesOraFile(configDir); + + if (!tnsNamesOraFile || !fs.existsSync(tnsNamesOraFile)) { + throw new Error( + `Cannot find ${TNS_NAMES_FILE} in "${configDir}". Select the wallet directory or the ${TNS_NAMES_FILE} file.`, + ); + } +}; + +const parseTnsNamesOra = filePath => { + const content = fs.readFileSync(filePath).toString(); + const result = parseTns(content); + return result; +}; + +module.exports = { + TNS_NAMES_FILE, + resolveTnsConfigDir, + assertTnsConfigDir, + getTnsNamesOraFile, + parseTnsNamesOra, +}; diff --git a/reverse_engineering/helpers/tns/tnsConnectString.js b/reverse_engineering/helpers/tns/tnsConnectString.js new file mode 100644 index 0000000..ab90495 --- /dev/null +++ b/reverse_engineering/helpers/tns/tnsConnectString.js @@ -0,0 +1,122 @@ +const fs = require('fs'); +const path = require('path'); +const _ = require('lodash'); +const { getConnectionDescription } = require('../connectStringDescription'); +const { getTnsNamesOraFile, parseTnsNamesOra } = require('./tnsConfig'); + +const normalizeTnsAlias = serviceName => (serviceName == null ? '' : String(serviceName).trim()); + +const getResolvedTnsService = (configDir, serviceName, logger) => { + const tnsAlias = normalizeTnsAlias(serviceName); + const filePath = getTnsNamesOraFile(configDir); + + if (!fs.existsSync(filePath)) { + return null; + } + + logger({ message: 'Found tnsnames.ora file: ' + filePath }); + + const tnsData = parseTnsNamesOra(filePath); + + logger({ message: 'tnsnames.ora successfully parsed' }); + const tnsServicesNames = Object.keys(tnsData); + + if (tnsServicesNames.length === 0) { + logger({ message: 'No TNS services found in tnsnames.ora' }); + return null; + } + + const [firstTnsServiceName] = tnsServicesNames; + const tnsService = (tnsAlias && tnsData[tnsAlias]) || tnsData[firstTnsServiceName]; + + if (!tnsAlias) { + logger({ + message: `No TNS alias provided. Using first TNS service ${firstTnsServiceName} from ${path.join(configDir, 'tnsnames.ora')}.`, + }); + } else if (!tnsData[tnsAlias]) { + logger({ + message: `TNS alias '${tnsAlias}' not found. Using first TNS service ${firstTnsServiceName} from ${path.join(configDir, 'tnsnames.ora')}.`, + }); + } else { + logger({ + message: `Connect using TNS service ${tnsAlias} from ${path.join(configDir, 'tnsnames.ora')}.`, + }); + } + + const description = tnsService?.data?.description; + const address = description?.address; + const resolvedAlias = tnsAlias && tnsData[tnsAlias] ? tnsAlias : firstTnsServiceName; + + return { + description, + address, + service: description?.connect_data?.service_name, + sid: description?.connect_data?.sid, + port: address?.port, + resolvedAlias, + }; +}; + +const syncConnectionEndpointFromTns = (connectionInfo, configDir, serviceName, logger) => { + const resolved = getResolvedTnsService(configDir, serviceName, logger); + + if (!resolved?.address?.host) { + return resolved; + } + + connectionInfo.host = resolved.address.host; + connectionInfo.port = resolved.address.port; + + logger({ + message: 'Synced connection host/port from tnsnames.ora for connections list', + host: connectionInfo.host, + port: connectionInfo.port, + tnsAlias: resolved.resolvedAlias, + }); + + return resolved; +}; + +const getConnectionStringByTnsNames = (configDir, serviceName, proxy, logger, useWallet = false) => { + const resolved = getResolvedTnsService(configDir, serviceName, logger); + + if (!resolved) { + return serviceName; + } + + const { description, address, service, sid, port, resolvedAlias } = resolved; + + logger({ message: 'tnsnames.ora', address, service, port }); + + if (useWallet) { + logger({ + message: 'Using TNS alias with mTLS wallet', + connectString: resolvedAlias, + }); + return resolvedAlias; + } + + return getConnectionDescription( + _.omitBy( + { + ...address, + ...proxy, + protocol: address?.protocol || 'tcps', + service: service || serviceName, + sid, + retryCount: description?.retry_count, + retryDelay: description?.retry_delay, + sslServerDnMatch: description?.security?.ssl_server_dn_match, + }, + _.isUndefined, + ), + logger, + ); +}; + +module.exports = { + normalizeTnsAlias, + getResolvedTnsService, + syncConnectionEndpointFromTns, + getConnectionStringByTnsNames, +}; diff --git a/reverse_engineering/helpers/tns/tnsConnectionSetup.js b/reverse_engineering/helpers/tns/tnsConnectionSetup.js new file mode 100644 index 0000000..04f672b --- /dev/null +++ b/reverse_engineering/helpers/tns/tnsConnectionSetup.js @@ -0,0 +1,143 @@ +const fs = require('fs'); +const path = require('path'); +const extractWallet = require('../extractWallet'); +const { fixSqlNetOraWalletPath } = require('../extractWallet'); +const { setPluginTnsAdmin } = require('./tnsAdmin'); +const { resolveTnsConfigDir, assertTnsConfigDir, getTnsNamesOraFile } = require('./tnsConfig'); +const { + WALLET_FILES, + MTLS_PORT, + hasWalletFiles, + isMutualTlsEnabled, + isMtlsPort, + connectStringUsesMtlsPort, + assertTnsMtlsRequirements, +} = require('./tnsMtls'); +const { syncConnectionEndpointFromTns, getConnectionStringByTnsNames } = require('./tnsConnectString'); +const { getConnectionDescription } = require('../connectStringDescription'); + +const trySyncTnsEndpointEarly = (connectionInfo, { connectionMethod, TNSpath, serviceName }, logger) => { + if (connectionMethod !== 'TNS' || !TNSpath) { + return; + } + + try { + const tnsConfigDir = resolveTnsConfigDir(TNSpath); + const tnsNamesOraFile = getTnsNamesOraFile(tnsConfigDir); + + if (tnsNamesOraFile && fs.existsSync(tnsNamesOraFile)) { + syncConnectionEndpointFromTns(connectionInfo, tnsConfigDir, serviceName, logger); + } + } catch (error) { + logger({ message: `Unable to sync host/port from tnsnames.ora: ${error.message}` }); + } +}; + +const setupWalletConfigDir = async ({ walletFile, tempFolder, name }, connectionInfo, serviceName, logger) => { + const configDir = await extractWallet({ walletFile, tempFolder, name }); + setPluginTnsAdmin(configDir); + const resolvedTnsService = syncConnectionEndpointFromTns(connectionInfo, configDir, serviceName, logger); + + return { configDir, tnsServicePort: resolvedTnsService?.port }; +}; + +const applyTnsMutualTlsWallet = (configDir, useMutualTls, tnsServicePort, logger) => { + if (useMutualTls && !isMtlsPort(tnsServicePort)) { + logger({ + message: `mTLS is enabled but TNS service uses port ${tnsServicePort ?? 'unknown'} (not ${MTLS_PORT}). Connecting without wallet.`, + }); + return; + } + + if (!useMutualTls || !isMtlsPort(tnsServicePort)) { + return; + } + + if (!hasWalletFiles(configDir)) { + throw new Error( + `Mutual TLS requires wallet files (${WALLET_FILES.join(', ')}) in the TNS directory "${configDir}".`, + ); + } + + fixSqlNetOraWalletPath(path.join(configDir, 'sqlnet.ora'), configDir); + setPluginTnsAdmin(configDir); +}; + +const setupTnsConfigDir = (TNSpath, connectionInfo, { serviceName, useMutualTls, walletPassword }, logger) => { + const configDir = resolveTnsConfigDir(TNSpath); + assertTnsConfigDir(configDir); + + const resolvedTnsService = syncConnectionEndpointFromTns(connectionInfo, configDir, serviceName, logger); + const tnsServicePort = resolvedTnsService?.port; + + assertTnsMtlsRequirements({ configDir, tnsServicePort, useMutualTls, walletPassword, logger }); + applyTnsMutualTlsWallet(configDir, useMutualTls, tnsServicePort, logger); + + return { configDir, tnsServicePort }; +}; + +const resolveConnectionConfigDir = async ( + connectionMethod, + connectionInfo, + { walletFile, walletPassword, tempFolder, name, TNSpath, serviceName }, + useMutualTls, + logger, +) => { + if (connectionMethod === 'Wallet') { + return setupWalletConfigDir({ walletFile, tempFolder, name }, connectionInfo, serviceName, logger); + } + + if (connectionMethod === 'TNS') { + return setupTnsConfigDir(TNSpath, connectionInfo, { serviceName, useMutualTls, walletPassword }, logger); + } + + return { configDir: undefined, tnsServicePort: undefined }; +}; + +const buildSessionConnectString = ( + { connectionMethod, configDir, serviceName, proxy, useMutualTls, tnsServicePort, host, port, sid }, + logger, +) => { + const useTnsWallet = + connectionMethod === 'Wallet' || (connectionMethod === 'TNS' && useMutualTls && isMtlsPort(tnsServicePort)); + + if (['Wallet', 'TNS'].includes(connectionMethod)) { + return getConnectionStringByTnsNames(configDir, serviceName, proxy, logger, useTnsWallet); + } + + return getConnectionDescription({ host, port, sid, service: serviceName }, logger); +}; + +const shouldUseWalletForConnect = ({ connectionMethod, useMutualTls, tnsServicePort, connectString }) => + connectionMethod === 'Wallet' || + (connectionMethod === 'TNS' && + useMutualTls && + (isMtlsPort(tnsServicePort) || connectStringUsesMtlsPort(connectString))); + +const logWalletConnectNotes = ({ connectionMethod, useMutualTls, useWallet, walletPassword }, logger) => { + if (connectionMethod === 'TNS' && useMutualTls && !useWallet) { + logger({ + message: + 'Skipping walletLocation, walletPassword, and configDir for thin connect (TNS service does not use mTLS port 1522).', + }); + } + + if (walletPassword && !useWallet) { + logger({ + message: + 'A wallet password is stored in the connection profile but is not sent to Oracle (mTLS disabled, non-mTLS port, or non-wallet connection method).', + }); + } +}; + +module.exports = { + trySyncTnsEndpointEarly, + setupWalletConfigDir, + applyTnsMutualTlsWallet, + setupTnsConfigDir, + resolveConnectionConfigDir, + buildSessionConnectString, + shouldUseWalletForConnect, + logWalletConnectNotes, + isMutualTlsEnabled, +}; diff --git a/reverse_engineering/helpers/tns/tnsMtls.js b/reverse_engineering/helpers/tns/tnsMtls.js new file mode 100644 index 0000000..2337a4a --- /dev/null +++ b/reverse_engineering/helpers/tns/tnsMtls.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); + +const WALLET_FILES = ['ewallet.pem', 'cwallet.sso', 'ewallet.p12']; +const MTLS_PORT = '1522'; + +const hasWalletFiles = configDir => + configDir && fs.existsSync(configDir) && WALLET_FILES.some(file => fs.existsSync(path.join(configDir, file))); + +const hasAutoLoginWallet = configDir => configDir && fs.existsSync(path.join(configDir, 'cwallet.sso')); + +const isMutualTlsEnabled = mutualTLS => mutualTLS === true || mutualTLS === 'true'; + +const isMtlsPort = port => String(port) === MTLS_PORT; + +const connectStringUsesMtlsPort = connectString => /\(PORT\s*=\s*1522\)/i.test(connectString); + +const assertTnsMtlsRequirements = ({ configDir, tnsServicePort, useMutualTls, walletPassword, logger }) => { + if (!useMutualTls) { + if (isMtlsPort(tnsServicePort)) { + logger({ + message: `TNS service uses port ${MTLS_PORT} without mutual TLS enabled. Connecting in legacy TNS mode (server TLS only, no wallet). Enable "Mutual TLS (mTLS)" if the database requires a client wallet.`, + }); + } + + return; + } + + if (!isMtlsPort(tnsServicePort)) { + return; + } + + if (!hasWalletFiles(configDir)) { + throw new Error( + `Mutual TLS requires wallet files (${WALLET_FILES.join(', ')}) in the TNS directory "${configDir}".`, + ); + } + + if (!walletPassword && !hasAutoLoginWallet(configDir)) { + throw new Error( + `Mutual TLS requires a wallet password (OCI wallet zip password), unless the directory contains an auto-login wallet (cwallet.sso).`, + ); + } + + if (!walletPassword && hasAutoLoginWallet(configDir)) { + logger({ + message: 'Using auto-login wallet (cwallet.sso); wallet password not required.', + }); + } +}; + +module.exports = { + WALLET_FILES, + MTLS_PORT, + hasWalletFiles, + isMutualTlsEnabled, + isMtlsPort, + connectStringUsesMtlsPort, + assertTnsMtlsRequirements, +};