複数台のサーバに対して SSH 経由でコマンドを自動実行する

## はじめに

この記事は [ディップ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dip-dev) の18日目の投稿です。

この記事では、複数台のサーバに対して行うパッケージのバージョン調査の作業を自動化させたことについてご紹介いたします。

## 背景

複数のサーバに対して同じコマンドを叩いて調査を行いたいときはありますか?

Apache や Nginx など、主要なミドルウェアなら既に IaC 化しており、パッケージのバージョンを確認することが出来ると考えられますが、
`curl` , `gnupg` などといったものも含めて、すべてのパッケージを IaC 化してバージョン管理することは、負担が大きいすぎると思います。

例えば、`openssl` で脆弱性が発見され、対象のバージョンを調査しなくてはならない状態になったとき、
複数台のサーバに対して `ssh` をして `openssl version` コマンドを叩かなくてはなりません。

定型作業は、プログラムに任せてしまいましょう。

## やりたいこと

ローカルでプログラムを実行すると、対象のサーバに対して順次、指定したコマンドが実行され、結果がファイルとして出力される

## 知りたかったこと

やりたいことを実現させるために、リモートでInputさせたコマンドの出力をローカルでOutputさせるI/Oについて、方法を知らなかったので、調べる必要がありました。

## リモートでInputさせたコマンド出力をローカルでOutputさせるI/Oの方法

マニュアルコマンドで `ssh` を確認すると以下のような引数が使えることがわかります。

```sh
ssh  [-46AaCfGgKkMNnqsTtVvXxYy]  [-B  bind_interface]  [-b  bind_address]  [-c  cipher_spec]  [-D  [bind_address:]port] [-E log_file] [-e escape_char] [-F configfile] [-I pkcs11] [-i identity_file] [-J destination] [-L address] [-l login_name] [-m mac_spec]
           [-O ctl_cmd] [-o option] [-P tag] [-p port] [-Q query_option] [-R address] [-S ctl_path] [-W host:port] [-w local_tun[:remote_tun]] destination [command [argument ...]]
```

必要な情報を抽出すると
```sh
ssh destination [command [argument ...]]
```
になります。

そこへローカルのファイルに出力を記載するには、
```sh
ssh destination [command [argument ...]] > output.txt
```
という方法になります。

つまりワンライナーでは、
```sh
ssh user@192.168.1.1 openssl version > output.txt
````
で行うことが可能だと分かりました。

## プログラムについて

ディレクトリ構成は、以下のようになっております。

```sh
.
├── .env
└── main.go
```

`.env` ファイルには

```txt
user1@192.168.1.1
user2@192.168.1.2
user3@192.168.1.3
```

のように対象のサーバへのアクセスについて記載されています。

また、下記のプログラムは使用していく中で改良したものです。
改良した点として、`ssh` コマンドの引数に `ConnectTimeout` オプションの追加があります。
こちらは、接続不可のサーバに対して `ssh` コマンドを実行した際に数分間の待ち時間が発生し、テンポが悪くなってしまったからです。
下記のプログラムでは、コネクションタイムアウトまでの時間を 8 秒に設定していますが、サーバのリソースやネットワークの帯域など使用環境に合わせて変更して頂ければと思います。

```go
package main

import (
	"bufio"
	"bytes"
	"fmt"
	"log"
	"os"
	"os/exec"
	"regexp"
)

var buf bytes.Buffer

func main() {
	file, err := os.Open(".env")
	if err != nil {
		fmt.Println("ファイルのオープンに失敗:", err)
		return
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := scanner.Text()
		err := output(line)
		if err != nil {
			fmt.Println("Error:", err)
		}
	}

	if err := scanner.Err(); err != nil {
		fmt.Println("ファイルの読み込み中にエラー:", err)
	}
}

func execCmd(destination string) (err error) {
	fmt.Println("")
	fmt.Println(destination, "を実行します")

	cmd := exec.Command("ssh",
		"-o", "ConnectTimeout=8",
		destination,
		"openssl", "version",
	)
	cmd.Stdout = &buf
	cmd.Stderr = os.Stderr
	cmd.Stdin = os.Stdin

	return cmd.Run()
}

func output(destination string) (err error) {
	file, err := os.OpenFile("output.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
	if err != nil {
		log.Fatalf("ファイルのオープンに失敗しました: %v", err)
	}
	defer file.Close()

	err = execCmd(destination)
	if err != nil {
		fmt.Println("実行に失敗しました:", err)
		if _, err := file.WriteString(destination + "\n"); err != nil {
			log.Fatalf("ファイルの書き込みに失敗しました: %v", err)
			return err
		}
		if _, err := file.WriteString("接続に失敗しました" + "\n"); err != nil {
			log.Fatalf("ファイルの書き込みに失敗しました: %v", err)
			return err
		}
		return nil
	}

	r := regexp.MustCompile(`OpenSSL \d+\.\d+\.\d+ \([^)]+\)`)
	versionInfo := r.FindString(buf.String())
	if versionInfo == "" {
		log.Fatalf("情報が見つかりませんでした。")
	}

	if _, err := file.WriteString(destination + "\n"); err != nil {
		log.Fatalf("ファイルの書き込みに失敗しました: %v", err)
		return err
	}
	if _, err := file.WriteString(versionInfo + "\n"); err != nil {
		log.Fatalf("ファイルの書き込みに失敗しました: %v", err)
		return err
	}

	fmt.Println("バージョン情報が書き込まれました!")
	return nil
}

```

このプログラムは、 `cmd` 変数で叩くコマンドを定義しているので、`openssl` のバージョンを調べる部分を変更することで、様々なコマンドを叩けることが出来ます。

## まとめ

今回学んだことは、

- `ssh destination [command [argument ...]] > output.txt` によってローカルからリモートへコマンドが実行でき、ローカルに出力を保存出来る
- コネクションタイムアウトをオプションで設定できる

でした。

日常的に使用しているコマンドでもまだまだ知らないことがあり、やはり日々精進が必要だと感じました。

最後まで読んで頂き、ありがとうございました！