Go言語でtelnetを自動化してルータの設定を行う

# はじめに

この記事は さくらインターネット [Advent Calendar 2024](https://qiita.com/advent-calendar/2024/sakura) 2日目の記事です。

お久しぶりです。さくらインターネットの大久保です。今回は小ネタです。

入社以来、長らくネットワークの自動制御システムを作っています。以前からネットワーク機器への設定投入や状態取得処理をPerlで書いていました。Net::Telnetモジュールには20年ほどお世話になってます。

最近はメンテナンス性の観点からGo言語で実装する機会も多いです。

当方の環境では、残念ながらtelnetでしか操作できないネットワーク機器も多数残っています。いまだに対話型インターフェイス(CLI)を自動化する必要があったりするんですね。

そこで今回は、Go言語でネットワーク機器のCLIを自動化し、簡単なルータの設定を試しましたので紹介します。

ここでは以下のパッケージを利用しています。

- https://github.com/google/goexpect
- https://github.com/ziutek/telnet

# サンプルコード

早速ですが、サンプルコードです。操作対象の機器は `Cisco Catalyst IOS-XE` の例となります。
処理のおおまかな流れは以下の通りです。

- 対象機器にtelnet接続する
- パスワードを送信し、ログイン処理を経てenableモードに遷移する
- ページャをオフにする
- configureモードに移行し、設定を投入する
  ここではサンプルとして `mac address-table aging-time 86400` を入れてます
- 設定を不揮発性メモリに保存する
- show running-config の出力結果を取得する

```Go:main.go
package main

import (
	"fmt"
	"os"
	"regexp"
	"time"

	expect "github.com/google/goexpect"
	"github.com/ziutek/telnet"
)

const (
	remoteHostName     = "TEST-Cat4500X-1"
	remoteHostAddrPort = "192.168.XX.XX:23"
	loginPW            = "secret"
	enablePW           = "secret"
)

var (
	passwordPrompt = `Password: `
	userPrompt     = remoteHostName + ">"
	enablePrompt   = remoteHostName + "#"
	configPrompt   = remoteHostName + `\(config.*\)#`
	timeout        = 10 * time.Second
)

func main() {
	// telnet接続をオープン
	conn, err := telnet.DialTimeout("tcp", remoteHostAddrPort, timeout)
	if err != nil {
		panic(err)
	}

    // expectを開始
	resCh := make(chan error)
	e, _, err := expect.SpawnGeneric(&expect.GenOptions{
		In:  conn,
		Out: conn,
		Wait: func() error {
			return <-resCh
		},
		Close: func() error {
			close(resCh)
			return conn.Close()
		},
		Check: func() bool {
			return true
		},
	}, timeout, expect.Tee(os.Stderr))
	if err != nil {
		panic(err)
	}
	defer e.Close()

	_, err = e.ExpectBatch([]expect.Batcher{
		// パスワードプロンプトを待つ
		&expect.BExp{R: passwordPrompt},

		// ログインパスワードを送信しuserモードプロンプトを待つ
		&expect.BSnd{S: loginPW + "\n"},
		&expect.BExp{R: userPrompt},

		// enableモードに移行
		&expect.BSnd{S: "enable\n"},
		&expect.BExp{R: passwordPrompt},
		&expect.BSnd{S: enablePW + "\n"},
		&expect.BExp{R: enablePrompt},

		// ページャをオフにする
		&expect.BSnd{S: "terminal length 0\n"},
		&expect.BExp{R: enablePrompt},

		// configureモードに移行し、設定を投入する
		&expect.BSnd{S: "configure terminal\n"},
		&expect.BExp{R: configPrompt},
		&expect.BSnd{S: "mac address-table aging-time 86400\n"},
		&expect.BExp{R: configPrompt},

		// configモードを抜ける
		&expect.BSnd{S: "end\n"},
		&expect.BExp{R: enablePrompt},

		// 設定を保存する
		&expect.BSnd{S: "write memory\n"},
		&expect.BExp{R: enablePrompt},
    }, timeout)
	if err != nil {
		panic(err)
	}

	// running-configを取得する
	if err = e.Send("show running-config\n"); err != nil {
		panic(err)
	}
	resp, _, err := e.Expect(regexp.MustCompile(enablePrompt), timeout)
	if err != nil {
		panic(err)
	}
	fmt.Print(resp)
}
```

## 実行例

`expect.SpawnGeneric` に `expect.Tee(os.Stderr)` を渡しています。これにより、ターミナルログが標準エラー出力に表示されます。

```
$ go run .

User Access Verification

Password:
TEST-Cat4500X-1>enable
Password:
TEST-Cat4500X-1#terminal length 0
TEST-Cat4500X-1#configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
TEST-Cat4500X-1(config)#mac address-table aging-time 86400
TEST-Cat4500X-1(config)#end
TEST-Cat4500X-1#write memory
Building configuration...
Compressed configuration from 4345 bytes to 2150 bytes[OK]
TEST-Cat4500X-1#show running-config
Building configuration...

Current configuration : 4345 bytes
!
! Last configuration change at 09:51:20 JST Mon Dec 2 2024
! NVRAM config last updated at 09:51:20 JST Mon Dec 2 2024
!
version 15.2
no service pad
service timestamps debug datetime msec localtime
service timestamps log datetime msec localtime
service password-encryption
service compress-config
!
hostname TEST-Cat4500X-1
!
boot-start-marker
boot system flash bootflash:cat4500e-universalk9.SPA.XX.XX.XX.X.XXX-X.XXX.bin
boot-end-marker

...途中省略...

line vty 0 4
 exec-timeout 0 0
 password 7 xxxxxxxxx
 login
 transport input telnet
line vty 5 16
 no login
 transport input none
!
ntp server vrf mgmtVrf xx.xx.xx.xx
mac address-table aging-time 86400
!
end

TEST-Cat4500X-1#
```

投入した設定がきちんと反映されていることが確認できます。

# 補足1

以下の部分でパスワードをハードコーディングするようになっていますが、本来はファイルから読み込んだり、シークレットマネージャから取得したり、安全な方法をとるのが望ましいです。

```go
const (
	remoteHostName     = "TEST-Cat4500X-1"
	remoteHostAddrPort = "192.168.XX.XX:23"
	loginPW            = "secret"
	enablePW           = "secret"
)
```

環境変数で渡す方法もありますが、環境変数は他のプロセスから見えてしまうため、あまりお薦めできません。それしか方法がない場合のみ使った方が良いでしょう。

# 補足2

以下の部分は、SpawnGenericを用いてexpectの初期化を行っています。こちらのコードを参考にしています。
https://github.com/google/goexpect/blob/master/examples/newspawner/telnet.go

```go
    // expectを開始
	resCh := make(chan error)
	e, _, err := expect.SpawnGeneric(&expect.GenOptions{
		In:  conn,
		Out: conn,
		Wait: func() error {
			return <-resCh
		},
    <snip>
```

# 補足3

注意が必要なところとして、ExpectBatchで待ち受ける以下の部分は、正規表現の文字列(string)を渡します。

```go
	_, err = e.ExpectBatch([]expect.Batcher{
		// パスワードプロンプトを待つ
		&expect.BExp{R: passwordPrompt},
```

一方で、Expectで待ち受ける場合はコンパイル済み正規表現(regexp.Regexp)を渡す必要があります。ここではMustCompileを用いてますが、関数の中で実装する場合はエラーハンドリングをした方がよいでしょう。

```go
    resp, _, err := e.Expect(regexp.MustCompile(enablePrompt), timeout)
```

# 最後に

ところで、さくらインターネットではエンジニア採用を強化しております。

[![5377ecb4-941b-e505-a1c9-9f2de6b16398.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108990/9cdd9341-2511-3a1c-590b-3708148df35a.png)](https://www.sakura.ad.jp/lp/recruit-engineer/)

カジュアル面談もやってます。ご興味ありましたら、お気軽にお声がけいただけると幸いです。最後までお読みいただきありがとうございました。