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))