diff --git a/modules/drivers/oracle/src/metabase/driver/oracle.clj b/modules/drivers/oracle/src/metabase/driver/oracle.clj index 70909c1c7f3ed286f5fe9a367294f9c2ec54203e..d2eb18119cea6fbf6da52b8e17a451bee7db9d0a 100644 --- a/modules/drivers/oracle/src/metabase/driver/oracle.clj +++ b/modules/drivers/oracle/src/metabase/driver/oracle.clj @@ -81,7 +81,7 @@ (defmethod driver/can-connect? :oracle [driver details] - (let [connection (sql-jdbc.conn/connection-details->spec driver (ssh/include-ssh-tunnel details))] + (let [connection (sql-jdbc.conn/connection-details->spec driver (ssh/include-ssh-tunnel! details))] (= 1M (first (vals (first (jdbc/query connection ["SELECT 1 FROM dual"]))))))) (defmethod driver/db-start-of-week :oracle diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 8bd9ea33449acc0af051ade2584425322e534e61..ec9e6ae9caee0e05bb15ba78e4c15217ee3d2966 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -594,3 +594,11 @@ {:added "0.37.0" :arglists '([driver])} dispatch-on-initialized-driver :hierarchy #'hierarchy) + +(defmulti incorporate-ssh-tunnel-details + "A multimethod for driver-specific behavior required to incorporate details for an opened SSH tunnel into the DB + details. In most cases, this will simply involve updating the :host and :port (to point to the tunnel entry point, + instead of the backing database server), but some drivers may have more specific behavior." + {:added "0.39.0" :arglists '([driver db-details])} + dispatch-on-uninitialized-driver + :hierarchy #'hierarchy) diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index 5db8d6cdb8a1573617a1fa0ddd15ded5c6d1b15c..bb4cdb15ad2989ddfc348257e9bb37192157ec20 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -1,5 +1,6 @@ (ns metabase.driver.h2 (:require [clojure.string :as str] + [clojure.tools.logging :as log] [honeysql.core :as hsql] [java-time :as t] [metabase.db.jdbc-protocols :as jdbc-protocols] @@ -15,7 +16,8 @@ [metabase.query-processor.store :as qp.store] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx] - [metabase.util.i18n :refer [deferred-tru tru]]) + [metabase.util.i18n :refer [deferred-tru tru]] + [metabase.util.ssh :as ssh]) (:import [java.sql Clob ResultSet ResultSetMetaData] java.time.OffsetTime)) @@ -322,3 +324,14 @@ [driver prepared-statement i t] (let [local-time (t/local-time (t/with-offset-same-instant t (t/zone-offset 0)))] (sql-jdbc.execute/set-parameter driver prepared-statement i local-time))) + +(defmethod driver/incorporate-ssh-tunnel-details :h2 + [_ db-details] + (if (and (:tunnel-enabled db-details) (ssh/ssh-tunnel-open? db-details)) + (if (and (:db db-details) (str/starts-with? (:db db-details) "tcp://")) + (let [details (ssh/include-ssh-tunnel! db-details) + db (:db details)] + (assoc details :db (str/replace-first db (str (:orig-port details)) (str (:tunnel-entrance-port details))))) + (do (log/error (tru "SSH tunnel can only be established for H2 connections using the TCP protocol")) + db-details)) + db-details)) diff --git a/src/metabase/driver/impl.clj b/src/metabase/driver/impl.clj index aa473d0e5946547b11b9ef7789aaee8cce7c96fe..70877184ef212edec88fe19bc040b17c859c3d89 100644 --- a/src/metabase/driver/impl.clj +++ b/src/metabase/driver/impl.clj @@ -9,7 +9,7 @@ ;;; --------------------------------------------------- Hierarchy ---------------------------------------------------- -(defonce ^{:doc "Driver hierarchy. Used by driver multimethods for dispatch. Add new drivers with `regsiter!`."} +(defonce ^{:doc "Driver hierarchy. Used by driver multimethods for dispatch. Add new drivers with `register!`."} hierarchy (make-hierarchy)) diff --git a/src/metabase/driver/sql_jdbc/connection.clj b/src/metabase/driver/sql_jdbc/connection.clj index 5daa4619ce1c8e3632b5507e579006cd66600423..c418d78b50837c835660083e450f59a66f8ba639 100644 --- a/src/metabase/driver/sql_jdbc/connection.clj +++ b/src/metabase/driver/sql_jdbc/connection.clj @@ -83,10 +83,13 @@ [{:keys [id details], driver :engine, :as database}] {:pre [(map? database)]} (log/debug (u/format-color 'cyan (trs "Creating new connection pool for {0} database {1} ..." driver id))) - (let [details-with-tunnel (ssh/include-ssh-tunnel details) ;; If the tunnel is disabled this returned unchanged + (let [details-with-tunnel (driver/incorporate-ssh-tunnel-details driver details) ;; If the tunnel is disabled this returned unchanged spec (connection-details->spec driver details-with-tunnel) properties (data-warehouse-connection-pool-properties driver)] - (connection-pool/connection-pool-spec spec properties))) + (merge + (connection-pool/connection-pool-spec spec properties) + ;; also capture entries related to ssh tunneling for later use + (select-keys spec [:tunnel-enabled :tunnel-session :tunnel-tracker :tunnel-entrance-port :tunnel-entrance-host])))) (defn- destroy-pool! [database-id pool-spec] (log/debug (u/format-color 'red (trs "Closing old connection pool for database {0} ..." database-id))) @@ -112,12 +115,21 @@ (destroy-pool! database-id old-pool-spec)))) nil) +(defn invalidate-pool-for-db! + "Invalidates the connection pool for the given database by closing it and removing it from the cache." + [database] + (set-pool! (u/the-id database) nil)) + (defn notify-database-updated "Default implementation of `driver/notify-database-updated` for JDBC SQL drivers. We are being informed that a `database` has been updated, so lets shut down the connection pool (if it exists) under the assumption that the connection details have changed." [database] - (set-pool! (u/get-id database) nil)) + (invalidate-pool-for-db! database)) + +(defn- log-ssh-tunnel-reconnect-msg! [db-id] + (log/warn (u/format-color 'red (trs "ssh tunnel for database {0} looks closed; marking pool invalid to reopen it" + db-id)))) (defn db->pooled-connection-spec "Return a JDBC connection spec that includes a cp30 `ComboPooledDataSource`. These connection pools are cached so we @@ -126,10 +138,23 @@ (cond ;; db-or-id-or-spec is a Database instance or an integer ID (u/id db-or-id-or-spec) - (let [database-id (u/get-id db-or-id-or-spec)] + (let [database-id (u/the-id db-or-id-or-spec) + get-fn (fn [db-id log-tunnel-check] + (when-let [details (get @database-id->connection-pool db-id)] + (cond (nil? (:tunnel-session details)) + ;; no tunnel in use; valid + details + (ssh/ssh-tunnel-open? details) + ;; tunnel in use, and open; valid + details + :default + ;; tunnel in use, and not open; invalid + (do (when log-tunnel-check + (log-ssh-tunnel-reconnect-msg! db-id)) + nil))))] (or ;; we have an existing pool for this database, so use it - (get @database-id->connection-pool database-id) + (get-fn database-id true) ;; Even tho `set-pool!` will properly shut down old pools if two threads call this method at the same time, we ;; don't want to end up with a bunch of simultaneous threads creating pools only to have them destroyed the ;; very next instant. This will cause their queries to fail. Thus we should do the usual locking here and make @@ -137,7 +162,7 @@ (locking database-id->connection-pool (or ;; check if another thread created the pool while we were waiting to acquire the lock - (get @database-id->connection-pool database-id) + (get-fn database-id false) ;; create a new pool and add it to our cache, then return it (let [db (or (db/select-one [Database :id :engine :details] :id database-id) (throw (ex-info (tru "Database {0} does not exist." database-id) @@ -157,7 +182,6 @@ ;; don't log the actual spec lest we accidentally expose credentials {:input (class db-or-id-or-spec)})))) - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | metabase.driver impls | ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -166,7 +190,7 @@ "Return an appropriate JDBC connection spec to test whether a set of connection details is valid (i.e., implementing `can-connect?`)." [driver details] - (let [details-with-tunnel (ssh/include-ssh-tunnel details)] + (let [details-with-tunnel (driver/incorporate-ssh-tunnel-details driver details)] (connection-details->spec driver details-with-tunnel))) (defn can-connect-with-spec? diff --git a/src/metabase/util/ssh.clj b/src/metabase/util/ssh.clj index aceb1f4367b836e78fd3225043d577e474ae397c..1a75749761c046f178464751b9a941fb95c24197 100644 --- a/src/metabase/util/ssh.clj +++ b/src/metabase/util/ssh.clj @@ -1,5 +1,6 @@ (ns metabase.util.ssh (:require [clojure.tools.logging :as log] + [metabase.driver :as driver] [metabase.public-settings :as public-settings] [metabase.util :as u]) (:import java.io.ByteArrayInputStream @@ -120,7 +121,13 @@ [details] (:tunnel-enabled details)) -(defn include-ssh-tunnel +(defn ssh-tunnel-open? + "Is the SSH tunnel currently open for these connection details?" + [details] + (when-let [session (:tunnel-session details)] + (.isOpen ^ClientSession session))) + +(defn include-ssh-tunnel! "Updates connection details for a data warehouse to use the ssh tunnel host and port For drivers that enter hosts including the protocol (https://host), copy the protocol over as well" [details] @@ -129,27 +136,42 @@ [session ^PortForwardingTracker tracker] (start-ssh-tunnel! (assoc details :host host)) tunnel-entrance-port (.. tracker getBoundAddress getPort) tunnel-entrance-host (.. tracker getBoundAddress getHostName) + orig-port (:port details) details-with-tunnel (assoc details :port tunnel-entrance-port ;; This parameter is set dynamically when the connection is established :host (str proto "localhost") ;; SSH tunnel will always be through localhost + :orig-port orig-port :tunnel-entrance-host tunnel-entrance-host :tunnel-entrance-port tunnel-entrance-port ;; the input port is not known until the connection is opened + :tunnel-enabled true :tunnel-session session :tunnel-tracker tracker)] details-with-tunnel) details)) +(defmethod driver/incorporate-ssh-tunnel-details :sql-jdbc + [_ db-details] + (cond (not (use-ssh-tunnel? db-details)) + ;; no ssh tunnel in use + db-details + (ssh-tunnel-open? db-details) + ;; tunnel in use, and is open + db-details + :default + ;; tunnel in use, and is not open + (include-ssh-tunnel! db-details))) + (defn close-tunnel! "Close a running tunnel session" [details] - (when (use-ssh-tunnel? details) + (when (and (use-ssh-tunnel? details) (ssh-tunnel-open? details)) (.close ^ClientSession (:tunnel-session details)))) (defn do-with-ssh-tunnel "Starts an SSH tunnel, runs the supplied function with the tunnel open, then closes it" [details f] (if (use-ssh-tunnel? details) - (let [details-with-tunnel (include-ssh-tunnel details)] + (let [details-with-tunnel (include-ssh-tunnel! details)] (try (log/trace (u/format-color 'cyan "<< OPENED SSH TUNNEL >>")) (f details-with-tunnel) diff --git a/test/metabase/util/ssh_test.clj b/test/metabase/util/ssh_test.clj index ded3303e451d65b1109245a4cc1cdcd9a5b0c232..ee521dfd509087d3615d544a65f33e5bb3a2ad5c 100644 --- a/test/metabase/util/ssh_test.clj +++ b/test/metabase/util/ssh_test.clj @@ -2,12 +2,22 @@ (:require [clojure.java.io :as io] [clojure.test :refer :all] [clojure.tools.logging :as log] + [metabase.models.database :refer [Database]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.query-processor :as qp] + [metabase.query-processor-test :as qp.test] + [metabase.sync :as sync] + [metabase.test :as mt] [metabase.util :as u] - [metabase.util.ssh :as sshu]) + [metabase.util.ssh :as ssh] + [metabase.sync :as sync] + [metabase.test :as mt] + [metabase.test.data.interface :as tx]) (:import [java.io BufferedReader InputStreamReader PrintWriter] [java.net InetSocketAddress ServerSocket Socket] org.apache.sshd.server.forward.AcceptAllForwardingFilter - org.apache.sshd.server.SshServer)) + org.apache.sshd.server.SshServer + org.h2.tools.Server)) (def ^:private ssh-username "jsmith") (def ^:private ssh-password "supersecret") @@ -39,6 +49,7 @@ (.setPort ssh-mock-server-with-password-port) (.setKeyPairProvider keypair-provider) (.setPasswordAuthenticator password-auth) + (.setForwardingFilter AcceptAllForwardingFilter/INSTANCE) .start)] (log/debug "ssh mock server (with password) started") sshd) @@ -109,7 +120,7 @@ ;; correct password (deftest connects-with-correct-password - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-password-port @@ -121,7 +132,7 @@ (deftest throws-exception-on-incorrect-password (is (thrown? org.apache.sshd.common.SshException - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-password-port @@ -132,7 +143,7 @@ ;; correct ssh key (deftest connects-with-correct-ssh-key (is (some? - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-publickey-port @@ -144,7 +155,7 @@ (deftest throws-exception-on-incorrect-ssh-key (is (thrown? org.apache.sshd.common.SshException - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-publickey-port @@ -155,7 +166,7 @@ ;; correct ssh key (deftest connects-with-correct-ssh-key-and-passphrase (is (some? - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-publickey-passphrase-port @@ -167,7 +178,7 @@ (deftest throws-exception-on-incorrect-ssh-key-and-passphrase (is (thrown? java.io.StreamCorruptedException - (sshu/start-ssh-tunnel! + (ssh/start-ssh-tunnel! {:tunnel-user ssh-username :tunnel-host "127.0.0.1" :tunnel-port ssh-mock-server-with-publickey-passphrase-port @@ -186,16 +197,93 @@ out-server (PrintWriter. (.getOutputStream client-socket) true)] (.println out-server "hello from the ssh tunnel")))] ;; this will try to open a TCP connection via the tunnel. - (sshu/with-ssh-tunnel [details-with-tunnel {:tunnel-enabled true - :tunnel-user ssh-username - :tunnel-host "127.0.0.1" - :tunnel-port ssh-mock-server-with-publickey-passphrase-port - :tunnel-private-key (slurp ssh-key-with-passphrase) - :tunnel-private-key-passphrase ssh-key-passphrase - :host "127.0.0.1" - :port port}] + (ssh/with-ssh-tunnel [details-with-tunnel {:tunnel-enabled true + :tunnel-user ssh-username + :tunnel-host "127.0.0.1" + :tunnel-port ssh-mock-server-with-publickey-passphrase-port + :tunnel-private-key (slurp ssh-key-with-passphrase) + :tunnel-private-key-passphrase ssh-key-passphrase + :host "127.0.0.1" + :port port}] (.connect socket (InetSocketAddress. "127.0.0.1" ^Integer (:tunnel-entrance-port details-with-tunnel)) 3000) ;; cause our future to run to completion (u/deref-with-timeout server-thread 12000) (with-open [in-client (BufferedReader. (InputStreamReader. (.getInputStream socket)))] (is (= "hello from the ssh tunnel" (.readLine in-client))))))))) + +(defn- init-h2-tcp-server [port] + (let [args ["-tcp" "-tcpPort", (str port), "-tcpAllowOthers" "-tcpDaemon"] + server (Server/createTcpServer (into-array args))] + (doto server (.start)))) + +(deftest test-ssh-tunnel-reconnection + ;; for now, run against Postgres, although in theory it could run against many different kinds + (mt/test-drivers #{:postgres :mysql} + (testing "ssh tunnel is reestablished if it becomes closed, so subsequent queries still succeed" + (let [tunnel-db-details (assoc (:details (mt/db)) + :tunnel-enabled true + :tunnel-host "localhost" + :tunnel-auth-option "password" + :tunnel-port ssh-mock-server-with-password-port + :tunnel-user ssh-username + :tunnel-pass ssh-password)] + (mt/with-temp Database [tunneled-db {:engine (tx/driver), :details tunnel-db-details}] + (mt/with-db tunneled-db + (sync/sync-database! (mt/db)) + (letfn [(check-row [] + (is (= [["Polo Lounge"]] + (mt/rows (mt/run-mbql-query venues {:filter [:= $id 60] :fields [$name]})))))] + ;; check that some data can be queried + (check-row) + ;; kill the ssh tunnel; fortunately, we have an existing function that can do that + (ssh/close-tunnel! (sql-jdbc.conn/db->pooled-connection-spec (mt/db))) + ;; check the query again; the tunnel should have been reestablished + (check-row)))))))) + +(deftest test-ssh-tunnel-reconnection-h2 + "We need a customized version of this test for H2. It will bring up a new H2 TCP server, pointing to an existing DB + file (stored in source control, called 'tiny-db', with a single table called 'my_tbl' and a GUEST user with + password 'guest'); it will then use an SSH tunnel over localhost to connect to this H2 server's TCP port to execute + native queries against that table." + (mt/with-driver :h2 + (testing "ssh tunnel is reestablished if it becomes closed, so subsequent queries still succeed (H2 version)" + (let [h2-port (+ 49152 (rand-int (- 65535 49152))) ; https://stackoverflow.com/a/2675399 + server (init-h2-tcp-server h2-port) + uri (format "tcp://localhost:%d/./test_resources/ssh/tiny-db;USER=GUEST;PASSWORD=guest" h2-port) + h2-db {:port h2-port + :host "localhost" + :db uri + :tunnel-enabled true + :tunnel-host "localhost" + :tunnel-auth-option "password" + :tunnel-port ssh-mock-server-with-password-port + :tunnel-user ssh-username + :tunnel-pass ssh-password}] + (try + (mt/with-temp Database [db {:engine :h2, :details h2-db}] + (mt/with-db db + (sync/sync-database! db) + (letfn [(check-data [] (is (= {:cols [{:base_type :type/Text + :display_name "COL1" + :field_ref [:field-literal "COL1" :type/Text] + :name "COL1" + :source :native} + {:base_type :type/Decimal + :display_name "COL2" + :field_ref [:field-literal "COL2" :type/Decimal] + :name "COL2" + :source :native}] + :rows [["First Row" 19.10M] + ["Second Row" 100.40M] + ["Third Row" 91884.10M]]} + (-> {:query "SELECT col1, col2 FROM my_tbl;"} + (mt/native-query) + (qp/process-query) + (qp.test/rows-and-cols)))))] + ;; check that some data can be queried + (check-data) + ;; kill the ssh tunnel; fortunately, we have an existing function that can do that + (ssh/close-tunnel! (sql-jdbc.conn/db->pooled-connection-spec db)) + ;; check the query again; the tunnel should have been reestablished + (check-data)))) + (finally (.stop ^Server server))))))) diff --git a/test_resources/ssh/tiny-db.mv.db b/test_resources/ssh/tiny-db.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..e3f0113994797b045ef1d369bf1e20152675d4c0 Binary files /dev/null and b/test_resources/ssh/tiny-db.mv.db differ