IT/기초 지식

[Linux] expect를 이용한 Linux 커맨드 입력 자동화

개발자 두더지 2021. 10. 20. 23:28
728x90

 Linux환경에서 어떤 패키지를 설치할 때 yes를 입력해야거나 선택지를 입력해야할 경우가 있는데, 이럴 때 그러한 입력을 자동화할 수 있는 것이 expect이다.  

 

 

expect란?


Linux에서의 커맨드 대화를 자동화하도록 할 수 있는 모듈의 하나이다. Tcl이라는 프로그래밍 언어 베이스의 커맨드로 커맨드라고 이야기했지만, 사용방법은 스크립트형식이 될 것이다. 

expect는 Tcl의 슈퍼 세트로, expect 스크립트내에서 Tcl를 이용할 수 있다. 또한 expect는 yum이나 apt-get에서 설치할 수 있다.

 

 

expect의 사용법


 예를 들어, passwd 커맨드.

 이것은 유저의 패스워드를 변경하는 커맨드이므로, 옵션이나 어떠한 것을 붙이지 않은 보통 사용할 경우에는 반드시 커맨드 "대화"가 발생한다. 다음과 같다. (--stdin 옵션을 붙이면 이야기가 달라질지 모르겠지만) 

$ passwd hoge
Changing password for user hoge.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.

 또 다른 예로를 ssh 커맨드에서도 동일하다.

$ ssh 192.168.0.1
The authenticity of host '192.168.0.1 (192.168.0.1)' can't be established.
ECDSA key fingerprint is d3:8f:4f:46:04:2f:ea:5b:ad:fd:bb:c5:7a:35:56:67.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.0.1' (ECDSA) to the list of known hosts.
root@192.168.0.1's password:
Last login: Tue Jul  7 22:02:08 2015 from 192.168.0.2

패스워드 인증이 있는 한, SSH접속시에는 반드시 입력해야한다. 이럴 때 이러한 "대화"를 자동화하는 것이 expect이다.

SSH를 자동화하기

실제로 ssh커맨드를 자동화하는 expect의 스크립트를 작성해보자.

#ssh-exp.sh

#!/bin/sh

PW="Password"

expect -c "
set timeout 5
spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect \"password:\"
send \"${PW}\n\"
expect \"$\"
exit 0
"

 간단하게 작성하면위와 같이 작성할 수 있다. 이번에는 쉘 스크립트를 작성해, 그 안에 expect를 호출하고 있다. 위에서 사용한 expect커맨드는 아래의 다섯 개이다.

커맨드 설명
set timeout 디폴트의 타임 아웃 시간을 지정한다. 지정하지 않은 경우 10초가 된다.
expect expect 스크립트로, 머신으로 부터의 응답을 읽어 들여, 패턴 매치할 때 사용한다.
패턴 매치는 switch문, case문과 동일한 처리를 실현할 수 있다.
spawn expect내에서 프로세스를 생성하는 커맨드이다.
send 머신에 문자열에 응답하는 커맨드이다.
exit expect의 처리를 종료시켜, 반환값으로써 지정한 숫자를 반환한다.

 쉘 부분은 생략하여, expect에 관련된 부분을 위에서 부터 순서대로 해설하도록 하겠다.

expect -c "
set timeout 5

 쉘에서 expect를 움직일 경우는 -c 옵션을 붙인다. 옵션을 붙인 후에, expect로 처리할 내용을 "(쌍따옴표로)로 감싼다.

 다음에, set timeout 5를 선언한다. 이것은 "expect의 처리중에 5초를 경과해도 응답이 없는 경우 강제적으로 처리를 중단합니다"는 의미가 된다. 앞서 설명했듯, 기본적으로 10초로 설정되어 있다. expect의 자동화로부터 일시적인 대화식으로 돌아가고 싶은경우에 설정하는듯하다. 

spawn env LANG=C /usr/bin/ssh hoge@ServerName

 ssh커맨드로, 유저 hoge가 ServerName에 로그인한다. spawn는 expect내에서 사용할 수 있는 커맨드로, expect의 처리중에 새로운 프로세스를 생성한다. 

 spawn으로 시작한 프로세스의 표준 입력과 표준 출력은 expect에 결부되어, expect내의 커맨드로 읽거나 쓰는 것이 가능하다. 단순히 얘기하자면, spawn으로 실행한 커맨드가 expect의 자동화 대상이 된다. 

 이번에는 ssh커맨드로 자동화하므로, 이것을 spawn으로 실행하고 있다. 전체 경로이거나, 맨 앞에 LANG=C를 붙이거나하지만, 이야기가 복잡해지므로 생략한다.

expect \"password:\"
send \"${PW}\n\"

 그럼 이제 메인 처리부분이다.

 "expect안에 또 expect라고?"라고 생각할지 모르므로, 설명한다. expect -c는 '지금부터 expect 처리르 시작한다"고 선언하는 것이다. 

 처리중에 다시 선언하고 있는 expect는 spawn으로 실행한 커맨드의 응답을 취득하고, 준비한 문자열과 비교하여 진실인 경우 처리를 계속한다는 의미가 된다.

 이러한 차이가 있기 때문에, man에서는 expect프로그램을 Expect로 기재하고, 프로그램에 구현되어 있는 expect커맨드는 expect로 표기한다. 

이번에는 password:이라는 문자열과 비교하여 일치하면 처리를 계속해, PW변수에 정의되어 있는 문자열을 반환한다. 완전 일치할 필요는 없고, 부분일치도 상관없다.

 또한 위에서의 expect커맨드에서는 "(쌍따옴표)의 앞에 \(백슬래시)를 넣는다. 이것은 expect-c의 쌍따옴표와 다른 쌍따옴표로 인식하기 위해 입력하는 것이다.

 쉘에서 Expect를 움직이는 경우, expect -c에서 Exepct의 처리 전체를 쌍따옴표로 처리를 묶어하는데, Expect처리내의 expect커맨드에서도, 문자열을 지정하는 경우도 쌍따옴표로 문자열을 감싸야한다.

 백슬래시를 그대로 기재해버리면, expect -c로 선언한 쌍따옴표의 끝을 의미해버리게 되므로, 이스케이프로써 필수이다.

expect \"\\\$\"
exit 0
"

 위 내용도 동일한 것이다. $이라는 문자열와 비교하고, 일치하고 있다면, exit으로 반환한 값을 0으로 지정하여 처리후에 던진다. 이번의 $는 프롬프터가 된다.

  백 슬래시를 3개 연속해서 넣은 것은 그렇게 하지 않으면 $가 제대로 이스케이프되지 않기 때문이다. 특수 문자이기 때문일지도 모르겠지만, 내 환경에서는 백슬래시를 하나만 넣은 경우 제대로 동작하지 않았다.

 마지막의 쌍따옴표는 expect -c의 처리를 닫는 용도이다. 

 아무튼 이로 인해 ssh커맨드가 자동화되었다. 그러나 이것만으로는 아마도 이해가 되지 않을 수도 있을지도 모른다. "쉘에서만 사용할 수 있는 것인가?", "처음 ssh에 접속했을 때는 Yes/No가 나오면 어떻게 처리할 수 있는 것인가?" 등에 관련되서 의문이 생길지도 모른다. 조금 더 개선해보자.

SSH를 자동화하기(개선판)

 먼저 Expect자체로 실행되도록 하고,  잡다한 코드군을 프로그램 언어처럼 만들어보자. yes/no도 자동화하자면 다음과 같다.

#!/usr/bin/expect

set PW "Password"

set timeout 5

spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    "password:" {
        send "${PW}\n"
    }
}

expect {
    "\\\$" {
        exit 0
    }
}

 이번에도 위에서 순서대로 설명하도록 하겠다.

#!/usr/bin/expect

 이것은 "지금부터 작성하는 스크립트의 인터프리터는 Expect이다"이라는 선언이다. 이것은 expect -c와 동일한 것이다.

set PW "Password"

set timeout 5

 다음은 PW변수에 패스워드로 설정하고 싶은 문자열을 입력한다. 앞쪽에서도 설명했지만, Expect는 Tcl의 슈퍼세트이다. 그 때문에, 변수의 선언도 Tcl에 준거한 것을 사용한다. 그 아래의 timeout은 쉘에서 작성한 것과 동일하므로 설명은 생략한다.

spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    "password:" {
        send "${PW}\n"
    }
}

 그 다음으로 메인 부분이다. spawn은 똑같지만, 아래의 작성법은 다르다. 쉘에서 작성한 것을 알기 쉽게 핑거 프린트 처리에 대응하도록 작성한 것이다.

 먼저 1개의 expect 처리에 어떤 패턴이 대응하고 있는 것인가를 명확히하기 위해, expect가 대응하고 있는 부분을 {}로 감싼다. 다음은 패턴 매치로써 (yes/no)?와 password: 두 개를 지정하고 있다. 이것은 위에서 설명한대로이지만, exepct 패턴 매치는 switch문, case문과 동일한 처리를 실현할 수 있으므로, 여러 개의 패턴을 1개의 expect에 넣는 것이 가능하다.  그리고 각 패턴에 대응하는 액션을 한 번 더 {}로 묶는다. 이것도 패턴에 대응하는 액션을 명확히 하기 위함이다.

 (yes/no)?패턴에 매치되면, 문자열 yes를 반환한다. 그 아래에 exp_continue이라는 커맨드를 작성했다. 이것은 man에 의하면, "expect 자체가 가지고 있던 값을 가지고 않았을 때에, expect의 실행을 계속할 수 있도록"하는 것 같다.  이번의 스크립트와 관련지어 얘기하자면, (yes/no)? 패턴에 매치하여 해당하는 액션을 실행했다고해도, expect로부터 전달된 것이아닌, 그 아래에 있는 password:의 패턴 매치처리를 계속할 수 있는 것이다.

 앞서 말했지만 expect는 swith문이나 case문과 같은 것이다. 각 패턴을 정의하고 그 안에 어떠한 패턴에 맞으면 선언한 액션을 실행할 수 있으나, 액션 실행후에 그 expect로 부터 벗어나 다음 처리를 이행하고 있다. exp_continue를 액션부에 선언해두면, 그 액션을 실행해도 expect로부터 벗어나지 않고 expect안에서의 처리르 계속하는 것이 가능하다. 이야기가 길어졌지만 그러한 기능을 한다.

 다음은 password:에 매치하면 변수 PW의 내용을 반환한다. 변수의 지정 방법은 쉘과 동일하다.

expect {
    "\\\$" {
        exit 0
    }
}

 이 경우 패턴 매치도 액션도 1개 밖에 없지만, 각각 알기 쉽게 하도록 {}로 범위를 나눴다. 

 결론적으로 이러한 작성법으로 인해 프로그램같이졌다. 그러나 아직 부족하다. 더욱 개선해나가도록하자.

 

 

SSH의 자동화를 개선하기


  조금 더 개선해보자. 아래의 항목을 개선하려고 한다.

- 실행시의 로그를 로그 파일에 기록

- 여러 개의 프롬프터에 대응할 수 있도록 하기

- 호스트와 패스워드를 인수로부터 읽어들이도록하기

- 패턴 매치의 표시를 명확히하기

- 앞에 하이픈이 있는 패스워트가 입력되어도 제대로 처리되도록하기

- SSH접속도 그대로 남겨두기

 이러한 내용들을 전부 반영한 것이 아래의 것이다.

#!/usr/bin/expect

log_file /var/log/expect.log

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]
set Prompt "\[#$%>\]"

set timeout 5

spawn env LANG=C /usr/bin/ssh ${RemoteHost}
expect {
    -glob "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    -glob "password:" {
        send -- "${PW}\n"
    }
}

expect {
    -glob "${Prompt}" {
        interact
        exit 0
    }
}

사용법은 다음과 같다.

[user@srv ~]$ ssh.exp user@remote password

[user@remote ~]$

실행시의 로그를 로그 파일에 기록

아래와 같이 추가하였다.

log_file /var/log/expect.log

Expect에는 log_file커맨드가 있어, log_file 파일명으로 로그를 확인할 수 있다. 옵션을 붙이지 않은 경우는 Expect를 실행할 때마다 로그가 추가 기입된다. 

- noappend 옵션을 붙이면 로그가 덮어씌어진다.

여러 개의 프롬프터에 대응할 수 있도록 하기

set Prompt "\[#$%>\]"

변수에 여러 개의 프롬프터를 선언해뒀다.

변수의 경우에도 정규표현과 같이 []로 전개할 수 있다. 이때 백슬래시가 필요하다. 이번의 경우는 #, $, %, >에 매치되도록하였다.

호스트와 패스워드를 인수로부터 읽어들이도록하기

커다란 변경점은 다음과 같다.

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]

 인수를 사용하는 경우도 Tcl의 기법에 준거한다. 인수는 [lindex $argv n]으로 이용할 수 있다. [lindex $argv n]는 첫 번째 인수를 의미한다. [lindex $argv 1]은 두 번째 인수이다. 읽어들인 인수를 각각의 변수에 대입한다.

spawn env LANG=C /usr/bin/ssh ${RemoteHost}

 SSH로 연결될 곳의 호스트를 변수로 하고 있다. 변경점은 이뿐이다.

패턴 매치의 표시를 명확히하기

 변경점은 아래와 같다.

-glob "(yes/no)?"

-glob "password:"

 패턴 매치로써 지정하고 있는 문자열의 앞에 -glob이라는 옵션을 추가했다. 이것은 "패턴 매치에 glob룰을 사용하고 있다"를 선언하는 옵션이다. 패턴 매치에는 아래와 같은 주요 룰이 존재한다.

옵션 패턴매치의 룰
-glob glob을 사용한다. 디폴트로는 이것이 지정되어 있다.
-regexp 정규표현을 사용한다.
-exact glob이나 정규표현의 전개방법을 사용하지 않고 기본적인 문자를 그대로 해석한다.
Tcl의 변수 전개도 이용가능하다.

앞에 하이픈이 있는 패스워트가 입력되어도 제대로 처리되도록하기

 변경점은 아래와 같다.

send -- "${PW}\n"

 send의 뒤에 하이픈을 두 개 붙인 옵션처럼 해뒀다. 하이픈을 추가하는 것으로 앞에 하이픈이 붙은 패스워드가 입력되어도 제대로 처리되게 된다. 시험삼아 이전의 스크립트에 SSH의 로그인처 유저의 패스워드를 -Password등으로 변경한 후 SSH로그인을 해보길 바란다. 

 이것은 Expect의 사양과 관련있는 내용으로 "-"로 시작하는 문자는 모두 옵션으로써 예약되어있기 때문이다. 그때문에 보통 문자열이라도 앞에 하이픈이 붙어있으면 모두 옵션으로 인식되어버리고 만다.이러한 것을 억제하기 위한 것이 -- 옵션이다.

SSH접속도 그대로 남겨두기

 변경점은 다음과 같다.

expect {
    -glob "${Prompt}" {
        interact
        exit 0
    }
}

 

interact이라는 커맨드가 추가되어 있다. 이것은 프로세스의 제어를 유저에게 넘겨주는 커맨드이다. 유저로부터 표준 입력을 받을 수 있게 된다.

 

 

Expect의 디버그 방법


 디버그는 아래의 두 가지 방법으로 가능하다.

- 스크립트 실행시에 -d 옵션을 붙이기

- exp_internal커맨드를 사용하기

스크립트 실행시에 -d 옵션을 붙이기

$ expect -d Script.exp

옵션을 붙임으로써 자세한 디버그 로그가 출력된다. 아래와 같이 파일의 맨 앞에 선언해도 로그가 출력된다. 마음에 드는 방법을 사용하면 될 것이다.

#!/usr/bin/expect -d

exp_internal커맨드를 사용하기

이것은 스크립트 안에 작성하는 방법이된다.

exp_internal 1

이것으로 선언한 곳 이후의 디버그 로그가 출력된다.

 

 

Expect내에서 Linux 커맨드를 실행하는 방법


구현하길 바란다면 아래의 두 가지 방법이 있다.

- 로컬 호스트에서 커맨드를 실행

- SSH의 접속처에서 커맨드를 실행

로컬 호스트에서 커맨드를 실행

 이 방법은 간단하다. 아래를 스크립트의 임의의 장소에 선언해두면, exec이후의 커맨드가 실행된다.

puts [exec date]

 대화이외의 커맨드는 spawn에서는 잘 움직이지 않는 경우가 있어, man에서도 "보통의 커맨드는 exec를 사용하길 바란다고' 권장해뒀다.

SSH의 접속처에서 커맨드를 실행

 SSH의 자동화 스크립트를 수정한 것을 사용해 설명하도록 하겠다.

#!/usr/bin/expect

log_file /var/log/expect.log

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]
set Prompt "\[#$%>\]"

set timeout 5

spawn env LANG=C /usr/bin/ssh ${RemoteHost}
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    -re "password:" {
        send -- "${PW}\n"
    }
}

expect {
    -glob "${Prompt}" {
        log_user 0
        send "date\n"
    }
}

expect {
    -regexp "\n.*\r" {
        log_user 1
        send "exit\n"
        exit 0
    }
}

 변경점은 expect의 부분이다. 순서대로 설명하자면 다음과 같다.

expect {
    -glob "${Prompt}" {
        log_user 0
        send "date\n"
    }
}

 프롬프터가 돌아 온다면, 먼저 log_user 0으로, 표준 출력으로의 출력을 억제한다. 그 다음 send 커맨드에 전달한다. 개행(\n)을 잊지않도록하자.

expect {
    -regexp "\n.*\r" {
        log_user 1
        send "exit\n"
        exit 0
    }
}

 개행(\n)을 사이에 두고 복귀(\r)까지의 모든 문자열에 매치하도록 하고 있다. 먼저 매치를 -regexp ".*" 로써, 디버그 로그를 확인해보자.

send: sending "date\n" to { exp5 }
Gate keeper glob pattern for '.*' is ''. Not usable, disabling the performance booster.

expect: does " " (spawn_id exp5) match regular expression ".*"? (No Gate, RE only) gate=yes re=yes
expect: set expect_out(0,string) " "
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " "

 expect: does " " (spawn_id exp5) match regular expression ".*"? (No Gate, RE only) gate=yes re=yes가 매치된 부분이다. 왜 그렇게 되는지 모르겠지만, send로 문자열을 보내면 맨 앞에 스페이가 들어와버리게 된다.

 그럼 조금 변경해보자. 하길 원하는 것은 send커맨드를 통한 Linux커맨드의 결과이다. send를 보낼 때, Linux커맨드의 뒤에 개행(\n)을 하고 있으므로, 패턴 매치를 -regexp "\n.*"로 수정해봤다.

expect: does " " (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=no
date

expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=yes re=yes
expect: set expect_out(0,string) "\n"
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " date\r\n"

 send로 보낸 "date"는 출력되게 되었다. 그러나 커맨드의 결과를 출력되지 않았다.

 주의깊게 살펴보면, expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=yes re=yes 로 되어있음을 알 수 있다. 문자열 뒤에 복귀, 개행이 붙어있다. 다른 디버그 로그를 확인해봐도, 문자열은 출력시에 반드시 맨 끝에 \r\n가 붙어있다.

 그럼 -regexp "\n.*\r"로 수정해보자.

expect: does " " (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=no
date

expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=no
Sat Aug  8 14:47:30 JST 2015

expect: does " date\r\nSat Aug  8 14:47:30 JST 2015\r\n" (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=yes re=yes
expect: set expect_out(0,string) "\nSat Aug  8 14:47:30 JST 2015\r"
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " date\r\nSat Aug  8 14:47:30 JST 2015\r"

expect: does " date\r\nSat Aug 8 14:47:30 JST 2015\r\n" (spawn_id exp5) match regular로 되어있다. 제대로 출력되고 있다는 것이다.

이로써 SSH의 접속처에 커맨드를 실행할 수 있게 됐다. data만이 아니라 다양한 커맨드를 실행할 수 있다. 


참고자료

https://qiita.com/ine1127/items/cd6bc91174635016db9b

728x90