README.md

RS-SSH: SSH клиент для Clojure

Данный клиент представляет собой обертку над форком JSch.

Цель данного проекта является предоставить возможность полной автоматизации работы с удаленными узлами по SSH, используя язык Clojure.

Проект создан на основе Шаблона библиотеки.

Перед началом работы с проектом см. раздел Установка зависимостей.

Подключение библиотеки

В файл CLI/deps.edn добавьте в секцию :deps {…} координаты:

org.rssys/rs-ssh {:git/tag "v0.1.36"
                  :git/sha "6a0ce15"
                  :git/url "https://gitflic.ru/project/red-stars-systems/rs-ssh.git"}

Примеры использования библиотеки

Аутентификация по паролю

exec-pwd-demo.clj

(require '[org.rssys.rs-ssh :as ssh])

(defn exec-pwd-demo
  [{:keys [host cmd password]}]
  (let [ctx (ssh/new-ssh-context host {:auth-type :password})]
    (ssh/set-ctx-password ctx password)
    (println (ssh/exec ctx cmd {:in password}))))

exec-pwd-demo.sh

$ clojure -A:dev-run -X user/exec-pwd-demo :host '"192.168.64.16"' :cmd '"ls -la"' :password '"Secret13"'
{:exit 0, :out total 144
drwx------ 14 mike mike  4096 May 25 18:28 .
drwxr-xr-x  3 root root  4096 May 21 16:40 ..
-rw-------  1 mike mike 47960 May 25 17:51 .bash_history
-rw-------  1 mike mike   217 Jul 26  2021 .bash_logout
-rw-------  1 mike mike   259 Jul 26  2021 .bash_profile
-rw-------  1 mike mike   188 Jul 26  2021 .bashrc
drwx------  3 mike mike  4096 May 22 01:57 .cache
drwxr-xr-x  2 mike mike  4096 May 22 05:35 bin
-rw-------  1 mike mike   979 May 24 00:00 mbox
drwxr-xr-x  8 mike mike  4096 May 25 18:28 mylib01
, :err }

Аутентификация по SSH-ключам

Функция set-ctx-ssh-keys ожидает ключи как InputStream. Поэтому ключи можно предоставить не только в виде имен файлов, но и например как ByteArrayInputStream, полученный из байтов строкового представления ключа. Таким образом, ключи можно не “приземлять” на файловую систему, а поставлять из оперативной памяти.

Если у закрытого ключа нет пароля, то в качестве оного можно передать nil или пустую строку.

exec-keys-demo.clj

(require '[org.rssys.rs-ssh :as ssh])

(defn exec-keys-demo
  [{:keys [host port cmd priv-key pub-key]}]
  (let [ctx (ssh/new-ssh-context host {:port port :auth-type :ssh-keys})
        priv-password (do
                        (print "Введите пароль ssh-ключа:")
                        (flush)
                        (String/valueOf (.readPassword (System/console))))]
    (ssh/set-ctx-ssh-keys ctx priv-key pub-key priv-password)
    (println (ssh/exec ctx cmd))))

exec-keys-demo.sh

$ clojure -A:dev-run -X user/exec-keys-demo :host '"server.org"' :port 22 :cmd '"ls -la"' :priv-key '"/Users/mike/.ssh/id_ed25519"' :pub-key '"/Users/mike/.ssh/id_ed25519.pub"'
Введите пароль ssh-ключа:
{:exit 0, :out total 5572
drwx------. 17 mike mike    4096 Jun  1 15:39 .
drwxr-xr-x.  5 root root    4096 Nov  5  2021 ..
-rw-------.  1 mike mike    2841 Jan  5 11:22 .bash_history
-rw-r--r--.  1 mike mike      18 Oct 30  2018 .bash_logout
-rw-r--r--.  1 mike mike     193 Oct 30  2018 .bash_profile
-rw-r--r--.  1 mike mike     231 Oct 30  2018 .bashrc
drwx------.  3 mike mike    4096 Apr 11  2022 snap
, :err }

Запуск интерактивного shell

Данный пример показывает запуск интерактивного сеанса работы (shell).
Данный режим предназначен для работы человека и не рекомендуется для автоматизации команд.

Режим shell нужно использовать исключительно для работы с консольными командами и утилитами.
Сложные tui приложения, типа Midnight commander, в данном режиме работать не будут.

shell-demo.clj

(require '[org.rssys.rs-ssh :as ssh])

(defn shell-demo
  [{:keys [host password]}]
  (let [ctx (ssh/new-ssh-context host {:auth-type :password})]
    (ssh/set-ctx-password ctx password)
    (ssh/shell ctx nil)))

shell-demo.sh

$ clojure -A:dev-run -X user/shell-demo :host '"192.168.64.16"' :password '"Secret13"'
Last login: Thu May 25 17:15:52 2023 from 192.168.64.1
[mike@alt-server01 ~]$ ls
ls
bin  mbox  mylib01
[mike@alt-server01 ~]$ exit
exit
выход

Нужно обратить внимание, что из-за буфферизированного ввода в Java команда дублируется дважды: сначала при вводе на локальном терминале, а потом повторяется при считывании stdout удалённого процесса. Из чего следует, что на экране будет виден пароль при вызове sudo. Поэтому команды с вводом пароля запускать крайне не рекомендуется в этом режиме.

Запуск sudo

В данном примере пароль используется дважды: для входа на удалённый узел и при вызове команды sudo.
В стандартный поток ввода in подставляется строка с паролем, который будет вытолкнут из stdin при запуске sudo и запросе пароля.
Ключ -S заставляет sudo считывать пароль со стандартного ввода, а не с устройства терминала.

sudo-demo.clj

(require '[org.rssys.rs-ssh :as ssh])

(defn sudo-demo
  [{:keys [host cmd password]}]
  (let [ctx (ssh/new-ssh-context host {:auth-type :password})]
    (ssh/set-ctx-password ctx password)
    (println (ssh/exec ctx (str "sudo -S " cmd) {:in password}))))

sudo-demo.sh

$ clojure -A:dev-run -X user/sudo-demo :host '"192.168.64.16"' :cmd '"ls -la /root"' :password '"Secret13"'
{:exit 0, :out total 60
drwx------  5 root root 4096 May 21 16:38 .
drwxr-xr-x 23 root root 4096 May 21 16:40 ..
-rw-------  1 root root  217 Jun 19  2018 .bash_logout
-rw-------  1 root root  168 Jun 19  2018 .bash_profile
-rw-------  1 root root  175 Jun 19  2018 .zshrc
drwx------  2 root root 4096 May 25 06:37 tmp
, :err [sudo] password for mike:}

При возврате, пример sudo показывает, что :err содержит данные, но их можно игнорировать, если код возврата 0.

Запуск процессов с большим выводом в stdout

Если удаленный процесс производит большой вывод в stdout, в этом случае org.rssys.rs-ssh/exec с параметрами по умолчанию не сработает, т.к. вывод переполнит буфер локального процесса и процесс завершится аварийно.

crash-big-stdout.sh

$ clojure -A:dev-run -X user/exec-pwd-demo :host '"192.168.64.16"' :cmd '"clojure -M -e \"(dotimes [n 1000000000000000000] (println n))\""' :password '"Secret13"'
Exception in thread "Connect thread 192.168.64.16 session" java.lang.OutOfMemoryError: Java heap space
        at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
        at java.base/java.io.ByteArrayOutputStream.ensureCapacity(ByteArrayOutputStream.java:100)
        at java.base/java.io.ByteArrayOutputStream.write(ByteArrayOutputStream.java:132)
        at com.jcraft.jsch.IO.put(IO.java:74)
        at com.jcraft.jsch.Channel.write(Channel.java:484)
        at com.jcraft.jsch.Session.run(Session.java:1697)
        at com.jcraft.jsch.Session$$Lambda$53/0x00000008012d1da8.run(Unknown Source)
        at java.base/java.lang.Thread.runWith(Thread.java:1636)
        at java.base/java.lang.Thread.run(Thread.java:1623)
...

Необходимо переопределить выходной поток данных OutputStream для удаленного процесса и вычитывать его в отдельном потоке исполнения в локальном процессе.

big-stdout-demo.clj

(require '[org.rssys.rs-ssh :as ssh])
(import '(java.io PipedInputStream PipedOutputStream))

(defn big-stdout-demo
  [{:keys [host cmd password]}]
  (let [ctx       (ssh/new-ssh-context host {:auth-type :password})
        N         102400
        buffer    (byte-array N)
        out       (PipedOutputStream.)
        piped-out (PipedInputStream. out N)]
    (ssh/set-ctx-password ctx password)

    ;; Запуск процесса с большим выводом данных в другом потоке исполнения
    ;; и переопределение выходного потока данных
    (let [p (future (ssh/exec ctx cmd {:in password :out out}))]

      ;; Вычитываем данные из выходного потока данных и выводим в консоль
      (loop [bytes-read-len (.read piped-out buffer)]
        (when (pos-int? bytes-read-len)
          (print (String. buffer 0 bytes-read-len))
          (flush))

        (if (realized? p)
          (println "done.")
          (recur (.read piped-out buffer)))))

    (.close piped-out)
    (.close out)))

В этом случае неограниченного потребления памяти не произойдет.

big-stdout-demo.sh

$ clojure -A:dev-run -X user/big-stdout-demo :host '"192.168.64.16"' :cmd '"clojure -M -e \"(dotimes [n 1000000000000000000] (println n))\""' :password '"Secret13"'
0
1
2
3
...

SFTP

sftp-demo.clj

(require '[clojure.java.io :as io])
(require '[org.rssys.rs-ssh :as ssh])
(require '[org.rssys.sftp :as sftp])

(import (com.jcraft.jsch SftpProgressMonitor))
(import (java.io File OutputStream PipedInputStream PipedOutputStream))

(defn ssh-ctx
  "Создать SSH-контекст с аутентификацией по паролю"
  [{:keys [host password]}]
  (let [ctx (ssh/new-ssh-context host {:auth-type :password})]
    (ssh/set-ctx-password ctx password)
    ctx))


;; SSH-контекст
(def ctx (ssh-ctx {:host "192.168.64.16" :password "Secret13"}))


;; Инициализировать и открыть SFTP-канал
(def ch (sftp/connect ctx))


;; Получить список файлов
(sftp/ls ch "." :longname)
;; =>
;; ("drwx------   14 mike     mike         4096 May 26 16:52 ."
;; "drwxr-xr-x    3 root     root         4096 May 21 16:40 .."
;; "-rw-------    1 mike     mike        48000 May 26 11:48 .bash_history"
;; "-rw-------    1 mike     mike          217 Jul 26  2021 .bash_logout"
;; "-rw-------    1 mike     mike          259 Jul 26  2021 .bash_profile"
;; "-rw-------    1 mike     mike          188 Jul 26  2021 .bashrc"
;; "drwx------    3 mike     mike         4096 May 22 01:57 .cache"
;; "drwxr-xr-x    4 mike     mike         4096 May 21 21:19 .clojure"
;; "drwx------    4 mike     mike         4096 May 26 08:24 .config" )

;; Домашний каталог на удалённом сервере
(sftp/get-home ch)
;; => "/home/mike"

;; Сменить текущий каталог на удаленном сервере
(sftp/cd ch "bin")
;; => "/home/mike/bin"

;; Текущий каталог на удалённом сервере
(sftp/pwd ch)
;; => "/home/mike/bin"

;; Текущий каталог на локальном сервере
(sftp/lpwd ch)
;; => "/Users/mike"

;; Сменить текущий локальный каталог
(sftp/lcd ch "bin")
;; => "/Users/mike/bin


;; Показать атрибуты файла. permissions рассчитывается как 32768 + oct(права доступа)
(sftp/stat ch "a.sh")
;; =>
;; {:permissions 33252,
;; :fifo false,
;; :dir false,
;; :MTime 1685127759,
;; :extended nil,
;; :sock false,
;; :atimeString "Fri May 26 22:02:39 MSK 2023",
;; :size 0,
;; :mtimeString "Fri May 26 22:02:39 MSK 2023",
;; :permissionsString "-rwxr--r--",
;; :link false,
;; :reg true,
;; :blk false,
;; :flags 15,
;; :GId 500,
;; :chr false,
;; :ATime 1685127759,
;; :UId 500}


;; Изменить права файла
(sftp/chmod ch 0755 "a.sh")
;; => "-rwxr-xr-x"

(sftp/chmod ch 0744 "a.sh")
;; => "-rwxr--r--"


;; Сменить владельца файла. Чтобы узнать id пользователя: id -u username
(sftp/chown ch 500 "a.sh")
;; => 500

;; Сменить группу файла. Чтобы узнать группу пользователя: id -g username
(sftp/chgrp ch 500 "a.sh")
;; => 500

;; Удалить файл
(sftp/rm ch "a.sh")
;; => nil

;; Создать каталог
(sftp/mkdir ch "123")
;; => nil

;; Переименовать файл или каталог
(sftp/rename ch "123" "456")
;; => nil

;; Удалить каталог
(sftp/rmdir ch "456")
;; => nil

;; Получить атрибуты самой ссылки, а не файла, на который она ссылается.
(sftp/lstat ch "/usr/bin/java")
;; =>
;; {:permissions 41471,
;; :fifo false,
;; :dir false,
;; :MTime 1685078553,
;; :extended nil,
;; :sock false,
;; :atimeString "Fri May 26 08:22:38 MSK 2023",
;; :size 32,
;; :mtimeString "Fri May 26 08:22:33 MSK 2023",
;; :permissionsString "lrwxrwxrwx",
;; :link true,
;; :reg false,
;; :blk false,
;; :flags 15,
;; :GId 0,
;; :chr false,
;; :ATime 1685078558,
;; :UId 0}

;; Создать новую ссылку
(sftp/symlink ch "/home/mike/bin/cljstyle.jar" "/home/mike/bin/cljstyle1.jar")
;; => nil

;; Получить полный путь к файлу, на который ссылается ссылка
(sftp/readlink ch "bin/cljstyle1.jar")
;;=> "/home/mike/bin/cljstyle.jar"

;; Вернуть полный путь к файлу или каталогу
(sftp/realpath ch "1")
;; => "/home/mike/1"


;; Запись в файл в режиме перезаписи
(with-open [os ^OutputStream (sftp/put-output-stream ch "remote-file.txt")]
  (.write os (.getBytes "Привет мир!\n")))


;; Запись в файл в режиме добавления
(with-open [os ^OutputStream (sftp/put-output-stream ch "remote-file.txt" :append)]
  (.write os (.getBytes "Привет мир!\n")))


;; Реализация прогресса загрузки
;;см. https://github.com/ePaul/jsch-documentation/blob/master/src/main/java/com/jcraft/jsch/SftpProgressMonitor.java
(defrecord ProgressMon [max-bytes *transferred-bytes]
           SftpProgressMonitor
           (init [this _ _ _ _]
             (println "Начало записи. Всего байт:" max-bytes))
           (^boolean count [^ProgressMon this ^long next-written-count]
             (swap! *transferred-bytes + next-written-count)
             (println "Передано:" @*transferred-bytes)
             ;;(println (format "Передано: %2.0f%%" (/ (* @*transferred-bytes 100.0) max-bytes)))
             true)                                       ;; если нужно прервать запись, то надо вернуть false
           (end [_] (println "Конец записи.")))


(def lic-file ^File (io/file "LICENSE"))


;; Запись в файл в режиме перезаписи с монитором прогресса
(let [pm (->ProgressMon (.length lic-file) (atom 0))]
  (with-open [os ^OutputStream (sftp/put-output-stream ch "remote-file.txt" pm :overwrite)]
    (.transferTo (io/input-stream lic-file) os)))
;;Начало записи. Всего байт: 19174
;;Передано: 8192
;;Передано: 16384
;;Передано: 19174
;;Конец записи.
;;=> 19174

;; Запись в файл в режиме добавления с монитором прогресса и с отрицательным смещением относительно конца файла
(let [pm (->ProgressMon (.length lic-file) (atom 0))]
  (with-open [os ^OutputStream (sftp/put-output-stream ch "remote-file.txt" pm :append -8)]
    (.transferTo (io/input-stream lic-file) os)))
;; Начало записи. Всего байт: 19174
;;Передано: 8192
;;Передано: 16384
;;Передано: 19174
;;Конец записи.
;;=> 19174


;; Передача локального файла на удаленный узел в режиме перезаписи
(sftp/put-file ch "LICENSE" "LICENSE.remote.txt")


;; Передача локального файла на удаленный узел в режиме добавления
(sftp/put-file ch "LICENSE" "LICENSE.remote.txt" :append)


;; Передача локального файла на удаленный узел с монитором прогресса в режиме перезаписи
(let [pm (->ProgressMon (.length lic-file) (atom 0))]
  (sftp/put-file ch "LICENSE" "LICENSE.remote.txt" pm :overwrite))
;; Начало записи. Всего байт: 19174
;; Передано: 19174
;; Конец записи.

;; Передача данных из InputStream на удаленный узел в режиме перезаписи
(sftp/put-input-stream ch (io/input-stream "LICENSE") "LICENSE.remote.txt")


;; Передача данных из InputStream на удаленный узел в режиме добавления
(sftp/put-input-stream ch (io/input-stream "LICENSE") "LICENSE.remote.txt" :append)


;; Передача данных из InputStream на удаленный узел с монитором прогресса в режиме перезаписи
(let [pm (->ProgressMon (.length lic-file) (atom 0))]
  (sftp/put-input-stream ch (io/input-stream "LICENSE") "LICENSE.remote.txt" pm :overwrite))
;; Начало записи. Всего байт: 19174
;; Передано: 19174
;; Конец записи.


;; Скачать удалённый файл с помощью InputStream
(with-open [is (sftp/get-input-stream ch "LICENSE.remote.txt")
            os (io/output-stream "target/LICENSE.local.txt")]
  (.transferTo is os))


;; Скачать удалённый файл с помощью InputStream пропустив первые 6 байт с начала
(let [pm (->ProgressMon -1 (atom 0))]
  (with-open [is (sftp/get-input-stream ch "LICENSE.remote.txt" pm 6)
              os (io/output-stream "target/LICENSE.local.txt")]
    (.transferTo is os)))
;; Начало записи. Всего байт: -1
;;Передано: 8192
;;Передано: 16384
;;Передано: 19168
;;Конец записи.
;;=> 19168


;; Скачать удалённый файл с помощью чтения из InputStream
(with-open [is (sftp/get-input-stream ch "LICENSE.remote.txt")
            os (io/output-stream "target/LICENSE.local.txt")]
  (.transferTo is os))


;; Скачать удалённый файл с помощью чтения из InputStream пропустив первые 6 байт с начала
(let [pm (->ProgressMon -1 (atom 0))]
  (with-open [is (sftp/get-input-stream ch "LICENSE.remote.txt" pm 6)
              os (io/output-stream "target/LICENSE.local.txt")]
    (.transferTo is os)))
;; Начало записи. Всего байт: -1
;;Передано: 8192
;;Передано: 16384
;;Передано: 19168
;;Конец записи.
;;=> 19168

;; Скачать удаленный файл на локальный узел
(sftp/get-file ch "LICENSE.remote.txt" "target/LICENSE.local.txt")

;; Скачать удаленный файл на локальный узел в режиме добавления
(let [pm (->ProgressMon -1 (atom 0))]
  (sftp/get-file ch "LICENSE.remote.txt" "target/LICENSE.local.txt" pm :append))

;; Скачать удалённый файл и записать его содержимое в OutputStream
(with-open [os (io/output-stream "target/LICENSE.local.txt")]
  (sftp/get-output-stream ch "LICENSE.remote.txt" os))

;; Скачать удалённый файл и записать его содержимое в OutputStream, пропустив первые 6 байт
(let [pm (->ProgressMon -1 (atom 0))]
  (with-open [os (io/output-stream "target/LICENSE.local.txt")]
   (sftp/get-output-stream ch "LICENSE.remote.txt" os pm :resume 6)))

(sftp/disconnect ch)

Раздел для разработчиков

Цели проекта

Для настройки целей проекта используйте файлы bb.edn и build.clj.

Для вывода списка целей проекта запустите команду bb tasks:

clean        Очистить содержимое папки target
build        Собрать дистрибутив библиотеки в виде jar файла
install      Установить локально jar файл (требуется pom.xml файл)
deploy       Опубликовать jar файл в публичный репозиторий
test         Запустить тесты
repl         Запустить Clojure REPL
outdated     Проверить устаревшие зависимости
outdated:fix Проверить устаревшие зависимости и обновить
format       Форматировать исходный код
lint         Проверить исходный код линтером
docmd        Конвертация README.adoc в markdown формат
requirements Установить зависимости необходимые проекту

Установка зависимостей

Для работы над проектом необходимо однократно выполнить установку зависимостей в среде Alt Linux p10.

Получение прав sudo

Установка разрешения на запуск sudo для текущего пользователя:

SUDO_USER=`whoami`; su -c "echo '$SUDO_USER ALL=(ALL:ALL) ALL' > /etc/sudoers.d/$SUDO_USER"

Установка Babashka

  1. Скачать и распаковать babashka:

    1. Для aarch64

      curl -O https://cdn01.rssys.org/babashka/babashka-1.3.186-linux-aarch64-static.tar.gz
      tar xvf babashka-1.3.186-linux-aarch64-static.tar.gz
      
    2. для amd64/x86_64:

      curl -O https://cdn01.rssys.org/babashka/babashka-1.3.186-linux-amd64-static.tar.gz
      tar xvf babashka-1.3.186-linux-amd64-static.tar.gz
      
  2. Установка babashka:

    sudo mv ./bb /usr/local/bin/
    rm -f bb babashka*
    

Установка JDK

Скрипт установки автоматизирует установку OpenJDK 21 в ОС Alt Linux.

jdk21-install.sh

bb <(curl -s https://cdn01.rssys.org/bin/install-jdk.bb)

Установка bb как утилиты clojure

Скрипт установки автоматизирует установку babashka в качестве утилиты clojure и clj

install-deps.sh

bb <(curl -s https://cdn01.rssys.org/bin/install-clj.bb)

Установка инструмента управления шаблонами

Установка инструмента работы с шаблонами Leiningen/Boot/clj-template deps-new

clojure -Ttools install io.github.seancorfield/deps-new '{:git/tag "v0.5.2"}' :as new

Данный инструмент будет установлен в каталог ~/.gitlibs/libs/

Лицензия

© 2023 Михаил Ананьев.

Данный проект распространяется под Открытой лицензией на программное обеспечение РЭД СТАРС СИСТЕМС 1.0
Текст лицензии находится в файле LICENSE или по ссылке.

Описание

SSH клиент для языка Clojure

Конвейеры
0 успешных
0 с ошибкой