SSH tunnel support for JDBC-based DWs. TODO -- it seems like this code is JDBC-specific, or at least big parts of
this all. We should consider moving some or all of this code to a new namespace like
| (ns metabase.util.ssh (:require [metabase.driver :as driver] [metabase.models.setting :refer [defsetting]] [metabase.util :as u] [metabase.util.i18n :refer [deferred-tru]] [metabase.util.log :as log]) (:import (java.io ByteArrayInputStream) (java.util.concurrent TimeUnit) (org.apache.sshd.client SshClient) (org.apache.sshd.client.future ConnectFuture) (org.apache.sshd.client.session ClientSession) (org.apache.sshd.client.session.forward PortForwardingTracker) (org.apache.sshd.common.config.keys FilePasswordProvider FilePasswordProvider$Decoder FilePasswordProvider$ResourceDecodeResult) (org.apache.sshd.common.future CancelOption) (org.apache.sshd.common.session SessionHeartbeatController$HeartbeatType SessionHolder) (org.apache.sshd.common.util GenericUtils) (org.apache.sshd.common.util.io.resource AbstractIoResource) (org.apache.sshd.common.util.net SshdSocketAddress) (org.apache.sshd.common.util.security SecurityUtils) (org.apache.sshd.server.forward AcceptAllForwardingFilter))) |
(defsetting ssh-heartbeat-interval-sec (deferred-tru "Controls how often the heartbeats are sent when an SSH tunnel is established (in seconds).") :visibility :public :type :integer :default 180 :audit :getter) | |
(set! *warn-on-reflection* true) | |
The default port for SSH tunnels (22) used if no port is specified | (def default-ssh-tunnel-port 22) |
(def ^:private ^Long default-ssh-timeout 30000) | |
(def ^:private ^SshClient client (doto (SshClient/setUpDefaultClient) (.start) (.setForwardingFilter AcceptAllForwardingFilter/INSTANCE))) | |
(def ^:private ^"[Lorg.apache.sshd.common.future.CancelOption;" no-cancel-options (make-array CancelOption 0)) | |
(defn- maybe-add-tunnel-password! [^ClientSession session ^String tunnel-pass] (when tunnel-pass (.addPasswordIdentity session tunnel-pass))) | |
(defn- maybe-add-tunnel-private-key! [^ClientSession session ^String tunnel-private-key tunnel-private-key-passphrase] (when tunnel-private-key (let [resource-key (proxy [AbstractIoResource] [(class "key") "key"]) password-provider (proxy [FilePasswordProvider] [] (getPassword [_ _ _] tunnel-private-key-passphrase) (handleDecodeAttemptResult [_ _ _ _ _] FilePasswordProvider$ResourceDecodeResult/TERMINATE) (decode [_ _ ^FilePasswordProvider$Decoder decoder] (.decode decoder tunnel-private-key-passphrase))) ids (with-open [is (ByteArrayInputStream. (.getBytes tunnel-private-key "UTF-8"))] (SecurityUtils/loadKeyPairIdentities session resource-key is password-provider)) keypair (GenericUtils/head ids)] (.addPublicKeyIdentity session keypair)))) | |
Opens a new ssh tunnel and returns the connection along with the dynamically assigned tunnel entrance port. It's the callers responsibility to call [[close-tunnel!]] on the returned connection object. | (defn- start-ssh-tunnel! [{:keys [^String tunnel-host ^Integer tunnel-port ^String tunnel-user tunnel-pass tunnel-private-key tunnel-private-key-passphrase host port]}] {:pre [(integer? port)]} (let [^Integer tunnel-port (or tunnel-port default-ssh-tunnel-port) ^ConnectFuture conn-future (.connect client tunnel-user tunnel-host tunnel-port) ^SessionHolder conn-status (.verify conn-future default-ssh-timeout no-cancel-options) hb-sec (ssh-heartbeat-interval-sec) session (doto ^ClientSession (.getSession conn-status) (maybe-add-tunnel-password! tunnel-pass) (maybe-add-tunnel-private-key! tunnel-private-key tunnel-private-key-passphrase) (.setSessionHeartbeat SessionHeartbeatController$HeartbeatType/IGNORE TimeUnit/SECONDS hb-sec) (.. auth (verify default-ssh-timeout no-cancel-options))) tracker (.createLocalPortForwardingTracker session (SshdSocketAddress. "" 0) (SshdSocketAddress. host port)) input-port (.. tracker getBoundAddress getPort)] (log/trace (u/format-color 'cyan "creating ssh tunnel (heartbeating every %d seconds) %s@%s:%s -L %s:%s:%s" hb-sec tunnel-user tunnel-host tunnel-port input-port host port)) [session tracker])) |
Is the SSH tunnel currently turned on for these connection details | (defn use-ssh-tunnel? [details] (:tunnel-enabled details)) |
Is the SSH tunnel currently open for these connection details? | (defn ssh-tunnel-open? [details] (when-let [session (:tunnel-session details)] (.isOpen ^ClientSession session))) |
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 | (defn include-ssh-tunnel! [details] (if (use-ssh-tunnel? details) (let [[_ proto host] (re-find #"(.*://)?(.*)" (:host details)) [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)) |
TODO Seems like this definitely belongs in [[metabase.driver.sql-jdbc.connection]] or something like that. | (defmethod driver/incorporate-ssh-tunnel-details :sql-jdbc [_driver db-details] (cond ;; no ssh tunnel in use (not (use-ssh-tunnel? db-details)) db-details ;; tunnel in use, and is open (ssh-tunnel-open? db-details) db-details ;; tunnel in use, and is not open :else (include-ssh-tunnel! db-details))) |
Close a running tunnel session | (defn close-tunnel! [details] (when (and (use-ssh-tunnel? details) (ssh-tunnel-open? details)) (log/tracef "Closing SSH tunnel: %s" (:tunnel-session details)) (.close ^ClientSession (:tunnel-session details)))) |
Starts an SSH tunnel, runs the supplied function with the tunnel open, then closes it | (defn do-with-ssh-tunnel [details f] (if (use-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) (finally (close-tunnel! details-with-tunnel) (log/trace (u/format-color 'cyan "<< CLOSED SSH TUNNEL >>"))))) (f details))) |
Starts an ssh tunnel, and binds the supplied name to a database details map with it's values adjusted to use the tunnel TODO -- I think | (defmacro with-ssh-tunnel [[details-binding details] & body] `(do-with-ssh-tunnel ~details (fn [~details-binding] ~@body))) |