본문 바로가기

ssh프로토콜과, 파이썬 paramiko 대화형 쉘

ssh 터미널을 개발하며 공부하게된 ssh프로토콜에 관련한 기본적인 내용과,

paramiko 모듈의 ssh 인터렉티브 쉘을 얻을 수 있는 invoke_shell()에 대한 팁이다.

(다시 삽질하지 않으려고 하는 기록이다)

 

쉘 입력 출력에 대해

ssh를 사용하기 전 쉘이 어떻게 사용자의 입력을 받아들이고, 보여주는지에 대해 이해햐아 한다.

 

쉘은 사용자가 커널을 사용하기 위한 인터페이스다.

 

사용자는 엔터(\r)를 통해 쉘에 명령을 입력하게 된다.

좀 더 자세한 이해를 위해 사용자가 쉘에 ls명령을 입력하여 해당 명령의 결과를 표준 출력(터미널창, 모니터)로 받는 과정을 알아보자.

 

사용자는 표준 입력(키보드)를 통해  'l', 's', '\r(CR, 엔터)'라는 세가지 입력을 수행해야 한다. (명령을 한번에 복사하지 않는다는 가정하에)

이때 쉘은 각 입력 'l', 's', '\r'을 표준입력을 통해 받게 되고, 그에대한 결과를 각각 표준 출력으로 보여주게 된다.

 

여기서 말하고 싶은점은 두가지다,

첫번째, 우리가 터미널 명령줄에 입력을 할때 보여지는 우리의 입력은 우리의 입력이 그대로 보여지는것이 아닌 쉘을 한번 거친 출력이라는 것이다.

위의 그림이 아니라

 

이러한 그림이 되어야 한다는 것이다.

 

 

두번째, 쉘은 표준 입력이 들어갈때마다 그 입력에 대한 결과를 반환한다.

 

위의 예시에서 말했던것 처럼 ls 명령을 수행한다 하면

'l' 입력이 이루어지면 쉘은 명령줄에 l 입력이 이루어 졌으므로, 그대로 'l' 라는 결과를 출력한다.

's' 입력이 이루어지면 쉘은 명령줄에 s 입력이 이루어 졌으므로, 그대로 's' 라는 결과를 출력한다.

'\r' 입력이 이루어지면 쉘은 명령줄에 입력된 'ls'가 한번에 넘어가게 되므로 'ls'의 결과를 출력한다.

 

쉘이 위와같은 동작 방식을 가진다는것을 이해하고 ssh 동작이 어떻게 이루어지는지 확인한다.

 

ssh 동작

아래 사진은 클라이언트와 서버간 ssh 연결이 수립되고 클라이언트에서 "ls -al" 명령을 전송한 결과다.

(단 예시를 위해 빠르게 입력하지 않고, 약간의 텀을 두고 입력했다.)

 

ssh 프로토콜로 ls -al 명령을 전송한 결과.

세션을 확인해보면 클라이언트로부터 'l', 's', ' ', '-', 'a', 'l', '\r' 이라는 입력이 각각 서버로 전송된다.

 

서버는 각 입력에 대해 쉘에서 줄 수 있는 결과를 클라이언트로 돌려주게 된다.

이때, \r (CR)이 입력되기 전까진 서버에서 클라이언트의 에코서버 역할만 하는것을 볼 수 있다.

우리가 컴퓨터의 터미널에 키보드를 통해 입력하는 표준 입력표준 출력이 되어 화면에 나오는 것처럼, 클라이언트의 입력들은 모든 입력에 대해 서버로부터 출력을 받게 된다.

 

 

서버로 \r (CR)이 전달되기 전까진 아직 서버의 쉘에 명령이 입력되지 않은 상태다.

이때 클라이언트로부터 \r (CR)을 입력받게 되면, 서버는 "ls -al" 이라는 명령을 쉘에 입력하게 되고 서버는 해당 명령의 수행 결과를 리턴해주게 된다.

클라이언트에서 서버로 ls -al 명령 전송 결과

 

 

paramiko 대화형 쉘

paramiko 라이브러리에는 대화형 쉘 채널을 얻을 수 있는 메소드가 있다.

client = paramiko.SSHClient()
client.load_system_host_keys()
client.connect('hostname', username='username', password='password')
ssh = client.invoke_shell(term="xterm-256color")

위 코드를 통해 ssh 라는 캡슐화된 서버와의 ssh 채널 객체를 얻을 수 있다.

 

해당 객체를 사용하여 서버로 데이터를 전송하기 위해선 ssh.send()

데이터를 받기 위해선 ssh.recv()를 통해 받을 수 있다.

 

ssh.recv()의 동작

여기서부턴 paramiko 모듈의 동작에 관한 내용이다.

ssh.recv()는 ssh 채널에 클라이언트가 읽을 수 있는 메시지가 있다면 해당 메시지를 읽고,

만약 메시지가 없다면 메시지가 올때까지 블록된다.

 

이러한 특징 덕분에 서버로 메시지를 보낸 후 바로 recv()를 걸어두게 되면, 개발자는 편하게 데이터를 받을 수 있게 된다.

 

표준 입력이벤트(키보드 입력)가 일어나면, 서버로 해당 이벤트(입력된 문자)를 전송한다. (ssh.send())

이후 서버로부터 데이터를 받기위해 ssh.recv()를 호출 (이때 데이터를 받을때까지 block됨)

데이터를 서버로부터 받게되면 ssh.recv()는 값을 리턴해줌.

 

서버에서 클라이언트로 데이터가 넘어오기 전까지 아주 짧은시간 (서버 처리시간, 네트워크 레이턴시)동안은 블록되지만 바로 데이터가 넘어와 클라이언트에 보여지게 된다.

 

하지만 이 구조는 서버로부터 데이터가 넘어와야지 recv()블록을 풀 수 있다는 단점이 있다.

만약 서버로부터 데이터가 넘어오지 않는다면 recv() 블록에서 영원히 멈추게 된다.

 

거의 모든 상황에서 쉘은 입력에 대한 리턴을 해주게 된다. 하지만 vi에디터나 특정한 상황에서는 입력에 대해 출력이 없을 수 있다.

 

예를 들어 쉘의 vi에디터 명령모드에서는 두개의 문자 입력이 주어져야 명령이 수행되는 경우가 있다.

ex) dd, dw, gg, yy 등

이 명령 모드일때는 두개의 입력이 입력되어야 쉘이 리턴을 해주게 된다.

 

위의 모델대로라면 첫번째 입력이 쉘로 전송된 이후 쉘은 아무런 반응도 하지 않으므로 블럭되게 된다.

 

클라이언트의 block을 풀어주지 못하므로 해당 프로그램은 멈추게 된다.

 

이를 위해 클라이언트에서는 일정시간 동안 서버의 응답을 대기(timeout과 비슷함)하다 일정 시간 뒤에 버퍼를 확인함으로 이 문제를 해결한다.

 

def on_message(self, message):
    self.ssh.send(message)
    print()
    sys.stdout.write(f'send : {message}\n')
    time.sleep(0.1)
    while(self.ssh.recv_ready()):
        msg = self.ssh.recv(10000)
        sys.stdout.write(f'recv : {msg}\n')

이 메소드는 클라이언트에서 표준 입력이 실행될 경우 실행되는 메소드다.

 

입력이 이루어지면 해당 이벤트는 서버로 send 되게 되고 약 0.1초동안 블록시킨다.

이후 서버로부터 전송받을 버퍼를 확인 후 (버퍼가 차있으면 True, 없으면 False) 메시지가 있다면 해당 버퍼의 메시지를 읽어온다.

 

 

결론

쉘은 입력과 동시에 출력이 진행됨.

ssh는 서버의 쉘에 접속하는것.

paramiko 라이브러리는 대화형 쉘을 얻을 수 있음.

ssh의 입력과 출력은 별개로 이루어짐.

명확한 동작을 위해선 비동기 프로그래밍이 되어야함.