001  (ns passwordless-ssh.core
002    (:require
003      [clojure.java.io :as io]
004      [clojure.java.shell :as shell]
005      [clojure.string :as str]
006      [taoensso.timbre :as log])
007    (:import
008      (com.jcraft.jsch
009        ChannelExec
010        JSch
011        Session)
012      (java.io
013        ByteArrayOutputStream
014        InputStream)))
015  
016  
017  (def ^:private ssh-connect-timeout-ms 10000)
018  
019  
020  (defn- current-os-user
021    "Returns the current OS user name for the running JVM process."
022    []
023    (System/getProperty "user.name"))
024  
025  
026  (defn localhost-machine?
027    "Returns true when the machine should be treated as the local host."
028    [machine]
029    (contains? #{"localhost" "127.0.0.1"} (:ip machine)))
030  
031  
032  (defn- build-machine-id
033    "Builds a human-readable SSH identifier like user@host for logging and diagnostics."
034    [{:keys [ssh-user ip]}]
035    (str ssh-user "@" ip))
036  
037  
038  (defn- non-blank-string
039    "Returns a trimmed string when the input contains non-whitespace characters."
040    [value]
041    (when (some? value)
042      (let [trimmed (str/trim value)]
043        (when (not-empty trimmed)
044          trimmed))))
045  
046  
047  (defn- private-key-path
048    "Normalizes and validates a private key path, returning it only when readable."
049    [secret]
050    (when-let [candidate (non-blank-string secret)]
051      (let [file (io/file candidate)]
052        (when (and (.exists file)
053                   (.canRead file))
054          candidate))))
055  
056  
057  (defn- shell-quote
058    "Safely single-quotes a string for inclusion in a shell command."
059    [s]
060    (str "'" (str/replace (str s) "'" "'\\''") "'"))
061  
062  
063  (defn- stream->string
064    "Reads an input stream fully into a UTF-8 string."
065    [^InputStream stream]
066    (let [buffer (byte-array 4096)
067          output (ByteArrayOutputStream.)]
068      (loop []
069        (let [read-count (.read stream buffer 0 (alength buffer))]
070          (when (pos? read-count)
071            (.write output buffer 0 read-count)
072            (recur))))
073      (.toString output "UTF-8")))
074  
075  
076  (defn connection-options
077    "Builds normalized SSH connection options for a machine map.
078  
079    Supported auth types are:
080    - \"password\" with :auth-secret as the password
081    - \"private-key\" with :auth-secret as a readable private key path"
082    [machine]
083    (let [{:keys [ssh-user ip auth-type auth-secret]} machine]
084      (log/debug "Building SSH connection options" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])})
085      (cond
086        (or (str/blank? ssh-user) (str/blank? ip))
087        (do
088          (log/warn "Missing ssh-user or ip for machine" {:machine machine})
089          {:error "Missing ssh-user or ip for machine."})
090  
091        (= "password" auth-type)
092        (if-let [password (non-blank-string auth-secret)]
093          {:machine (build-machine-id machine)
094           :password password}
095          (do
096            (log/warn "Missing auth-secret for password authentication" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])})
097            {:error "Missing auth-secret for password authentication."}))
098  
099        (= "private-key" auth-type)
100        (if-let [identity-file (private-key-path auth-secret)]
101          {:machine (build-machine-id machine)
102           :identity-file identity-file}
103          (do
104            (log/warn "Unreadable private key path for machine" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])
105                                                                 :auth-secret auth-secret})
106            {:error "Private key auth expects auth-secret to be a readable file path on the backend host."}))
107  
108        :else
109        (do
110          (log/warn "Unsupported auth-type for machine" {:machine (select-keys machine [:hostname :ip :ssh-user])
111                                                         :auth-type auth-type})
112          {:error (str "Unsupported auth-type: " auth-type)}))))
113  
114  
115  (defn- build-session
116    "Creates and configures a JSch SSH session for the given machine."
117    [{:keys [ssh-user ip auth-type auth-secret]}]
118    (let [password (non-blank-string auth-secret)
119          identity-file (private-key-path auth-secret)
120          jsch (JSch.)
121          session ^Session (if (= "private-key" auth-type)
122                             (do
123                               (.addIdentity jsch identity-file)
124                               (.getSession jsch ssh-user ip 22))
125                             (.getSession jsch ssh-user ip 22))]
126      (when (= "password" auth-type)
127        (.setPassword session password))
128      (.setConfig session "StrictHostKeyChecking" "no")
129      (.setConfig session "PreferredAuthentications" "publickey,password,keyboard-interactive")
130      session))
131  
132  
133  (defn exec-remote-command
134    "Executes a shell command on a remote machine over SSH."
135    [machine cmd]
136    (let [session (build-session machine)]
137      (log/debug "Executing remote SSH command" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])
138                                                 :command cmd})
139      (try
140        (.connect session ssh-connect-timeout-ms)
141        (let [channel ^ChannelExec (.openChannel session "exec")]
142          (try
143            (.setInputStream channel nil)
144            (.setCommand channel (str "bash -lc " (shell-quote cmd)))
145            (let [stdout-stream (.getInputStream channel)
146                  stderr-stream (.getExtInputStream channel)
147                  stdout-future (future (stream->string stdout-stream))
148                  stderr-future (future (stream->string stderr-stream))]
149              (.connect channel ssh-connect-timeout-ms)
150              (while (not (.isClosed channel))
151                (Thread/sleep 25))
152              (let [result {:exit (.getExitStatus channel)
153                            :out @stdout-future
154                            :err @stderr-future}]
155                (log/debug "Completed remote SSH command" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])
156                                                           :command cmd
157                                                           :exit (:exit result)})
158                result))
159            (finally
160              (.disconnect channel))))
161        (finally
162          (.disconnect session)))))
163  
164  
165  (defn exec-machine
166    "Executes a shell command locally for localhost targets, otherwise over SSH."
167    [machine cmd]
168    (if (and (localhost-machine? machine)
169             (let [configured-user (some-> (:ssh-user machine) str/trim not-empty)]
170               (or (nil? configured-user)
171                   (= configured-user (current-os-user)))))
172      (do
173        (log/debug "Executing local shell command" {:machine (select-keys machine [:hostname :ip :ssh-user])
174                                                    :command cmd})
175        (let [result (shell/sh "bash" "-lc" cmd)]
176          (log/debug "Completed local shell command" {:machine (select-keys machine [:hostname :ip :ssh-user])
177                                                      :command cmd
178                                                      :exit (:exit result)})
179          result))
180      (let [options (connection-options machine)]
181        (if-let [error (:error options)]
182          (do
183            (log/warn "Skipping command because connection options could not be built" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])
184                                                                                        :command cmd
185                                                                                        :error error})
186            {:exit 64 :out "" :err error})
187          (try
188            (exec-remote-command machine cmd)
189            (catch Exception ex
190              (log/error ex "SSH command execution failed" {:machine (select-keys machine [:hostname :ip :ssh-user :auth-type])
191                                                            :command cmd})
192              {:exit 255 :out "" :err (.getMessage ex)}))))))
193  
194  
195  (defn- passwordless-group-key
196    "Builds the grouping key used to cluster machines by compatible SSH auth details."
197    [{:keys [ssh-user auth-type auth-secret]}]
198    [ssh-user auth-type (some-> auth-secret str/trim not-empty)])
199  
200  
201  (defn- machine-sort-key
202    "Builds a stable sort key for machine ordering, preferring explicit ids when present."
203    [machine]
204    [(boolean (:id machine))
205     (:id machine)
206     (:ip machine)
207     (:hostname machine)
208     (:ssh-user machine)
209     (:auth-type machine)])
210  
211  
212  (defn- ensure-ssh-keypair!
213    "Ensures that the target machine has an RSA SSH keypair ready for mesh setup."
214    [machine]
215    (log/info "Ensuring SSH keypair exists" {:machine (select-keys machine [:hostname :ip :ssh-user])})
216    (let [result (exec-machine machine
217                               (str "mkdir -p ~/.ssh "
218                                    "&& chmod 700 ~/.ssh "
219                                    "&& if [ ! -f ~/.ssh/id_rsa ]; then ssh-keygen -t rsa -b 4096 -N '' -f ~/.ssh/id_rsa >/dev/null 2>&1; fi "
220                                    "&& chmod 600 ~/.ssh/id_rsa "
221                                    "&& chmod 644 ~/.ssh/id_rsa.pub"))]
222      (when-not (zero? (:exit result))
223        (throw (ex-info "Unable to create/read SSH keypair on machine."
224                        {:machine machine :result result})))))
225  
226  
227  (defn- known-host-identifiers
228    "Returns the distinct hostname/IP values that should be added to known_hosts."
229    [{:keys [hostname ip]}]
230    (->> [hostname ip]
231         (remove str/blank?)
232         distinct))
233  
234  
235  (defn- known-host-authorization-command
236    "Builds the shell command that adds a host entry to ~/.ssh/known_hosts when needed."
237    [host]
238    (let [quoted-host (shell-quote host)]
239      (format (str "mkdir -p ~/.ssh "
240                   "&& chmod 700 ~/.ssh "
241                   "&& touch ~/.ssh/known_hosts "
242                   "&& chmod 600 ~/.ssh/known_hosts "
243                   "&& (ssh-keygen -F %s -f ~/.ssh/known_hosts >/dev/null "
244                   "|| ssh-keyscan -H %s >> ~/.ssh/known_hosts 2>/dev/null)")
245              quoted-host
246              quoted-host)))
247  
248  
249  (defn- ensure-command-succeeded!
250    "Throws an ex-info when a machine command exits with a non-zero status."
251    [message machine context result]
252    (when-not (zero? (:exit result))
253      (throw (ex-info message
254                      (assoc context
255                             :machine machine
256                             :result result)))))
257  
258  
259  (defn- authorize-known-host!
260    "Adds the target machine's hostname/IP SSH host keys to the source machine's known_hosts."
261    [source-machine target-machine]
262    (log/debug "Authorizing known host entries" {:source (select-keys source-machine [:hostname :ip :ssh-user])
263                                                 :target (select-keys target-machine [:hostname :ip :ssh-user])})
264    (run! (fn [host]
265            (let [result (exec-machine source-machine
266                                       (known-host-authorization-command host))]
267              (ensure-command-succeeded! "Unable to authorize SSH known host on machine."
268                                         source-machine
269                                         {:target target-machine}
270                                         result)))
271          (known-host-identifiers target-machine)))
272  
273  
274  (defn- read-public-key!
275    "Reads and returns the public key from the target machine's default SSH keypair."
276    [machine]
277    (log/debug "Reading public key from machine" {:machine (select-keys machine [:hostname :ip :ssh-user])})
278    (let [result (exec-machine machine "cat ~/.ssh/id_rsa.pub")
279          public-key (non-blank-string (:out result))]
280      (when-not (zero? (:exit result))
281        (throw (ex-info "Unable to read machine public key."
282                        {:machine machine :result result})))
283      (when-not public-key
284        (throw (ex-info "Machine public key is empty."
285                        {:machine machine :result result})))
286      public-key))
287  
288  
289  (defn- authorize-public-key!
290    "Appends a public key to the target machine's authorized_keys when it is not already present."
291    [machine public-key]
292    (log/debug "Authorizing public key on machine" {:machine (select-keys machine [:hostname :ip :ssh-user])})
293    (let [quoted-key (shell-quote public-key)
294          result (exec-machine machine
295                               (str "mkdir -p ~/.ssh "
296                                    "&& chmod 700 ~/.ssh "
297                                    "&& touch ~/.ssh/authorized_keys "
298                                    "&& chmod 600 ~/.ssh/authorized_keys "
299                                    "&& (grep -qxF " quoted-key " ~/.ssh/authorized_keys "
300                                    "|| echo " quoted-key " >> ~/.ssh/authorized_keys)"))]
301      (when-not (zero? (:exit result))
302        (throw (ex-info "Unable to authorize SSH key on machine."
303                        {:machine machine :result result})))))
304  
305  
306  (defn setup-passwordless-group-via-ssh!
307    "Bootstraps passwordless SSH for one already-compatible group of machines."
308    [machines]
309    (log/info "Bootstrapping passwordless SSH for compatible group" {:machine-count (count machines)
310                                                                     :machines (mapv #(select-keys % [:hostname :ip :ssh-user :auth-type]) machines)})
311    (let [machine-keys (mapv (fn [machine]
312                               (ensure-ssh-keypair! machine)
313                               {:machine machine
314                                :public-key (read-public-key! machine)})
315                             machines)]
316      (run! (fn [source-machine]
317              (run! (fn [target-machine]
318                      (authorize-known-host! source-machine target-machine))
319                    machines))
320            machines)
321      (run! (fn [target-machine]
322              (run! (fn [{:keys [public-key]}]
323                      (authorize-public-key! target-machine public-key))
324                    machine-keys))
325            machines)
326      (log/info "Finished passwordless SSH group bootstrap" {:machine-count (count machines)})
327      :ok))
328  
329  
330  (defn- setup-passwordless-group!
331    "Validates and applies passwordless SSH setup for one grouped set of compatible machines."
332    [machines]
333    (let [{:keys [ssh-user auth-type]} (first machines)
334          ips (mapv :ip machines)]
335      (log/info "Evaluating passwordless SSH group" {:ssh-user ssh-user
336                                                     :auth-type auth-type
337                                                     :machine-ips ips})
338      (cond
339        (< (count machines) 2)
340        (do
341          (log/info "Skipping passwordless SSH group with fewer than two machines" {:machine-ips ips})
342          {:applied false
343           :skipped true
344           :ssh-user ssh-user
345           :auth-type auth-type
346           :machine-ips ips
347           :reason "Need at least two machines with matching credentials to configure passwordless SSH mesh."})
348  
349        (and (= "password" auth-type)
350             (nil? (non-blank-string (:auth-secret (first machines)))))
351        (do
352          (log/info "Skipping passwordless SSH password-auth group without a password" {:machine-ips ips})
353          {:applied false
354           :skipped true
355           :ssh-user ssh-user
356           :auth-type auth-type
357           :machine-ips ips
358           :reason "Password auth requires auth-secret to be set for passwordless SSH setup."})
359  
360        (and (= "private-key" auth-type)
361             (nil? (private-key-path (:auth-secret (first machines)))))
362        (do
363          (log/info "Skipping passwordless SSH private-key group with unreadable key" {:machine-ips ips})
364          {:applied false
365           :skipped true
366           :ssh-user ssh-user
367           :auth-type auth-type
368           :machine-ips ips
369           :reason "Private key auth requires auth-secret to point to a readable private key file."})
370  
371        :else
372        (try
373          (setup-passwordless-group-via-ssh! machines)
374          (log/info "Applied passwordless SSH group" {:machine-ips ips})
375          {:applied true
376           :skipped false
377           :ssh-user ssh-user
378           :auth-type auth-type
379           :machine-ips ips}
380          (catch Exception ex
381            (log/error ex "Passwordless SSH group bootstrap failed" {:machine-ips ips
382                                                                     :ssh-user ssh-user
383                                                                     :auth-type auth-type})
384            {:applied false
385             :skipped false
386             :ssh-user ssh-user
387             :auth-type auth-type
388             :machine-ips ips
389             :error (.getMessage ex)})))))
390  
391  
392  (defn setup-passwordless-mesh!
393    "Groups machines by compatible SSH authentication details and bootstraps
394    passwordless SSH within each eligible group."
395    [machines]
396    (log/info "Starting passwordless SSH mesh bootstrap" {:machine-count (count machines)})
397    (let [remote-machines (->> machines
398                               (remove localhost-machine?)
399                               (sort-by machine-sort-key))
400          grouped-machines (->> remote-machines
401                                (group-by passwordless-group-key)
402                                vals)
403          results (mapv setup-passwordless-group! grouped-machines)
404          summary {:eligible-machines (count remote-machines)
405                   :groups (count grouped-machines)
406                   :results results}]
407      (log/info "Completed passwordless SSH mesh bootstrap" summary)
408      summary))