SSH 터널링 어플리케이션을 만들어보자

Introduction

예전에 SSH 터널링에 대해 드립을 친 적이 있었다.

SSH 터널링은 “네트워크 보안을 이유로 변태적으로 꼬아 놓은 곳에서 터널을 뚫을 용도”로 쓸 때 유용성이 임계질량을 넘어선 우라늄마냥 빛난다.

개발 머신과 업무용 컴퓨터가 분리된 상황에서는 여러가지 이유로 터널링이 유용하다. 그런 경우 보통 서버에는 openssh 서버가 설치돼 있으므로 단순히 ssh 명령 만으로도 터널을 뚫을 수 있어서 간단하게 사용하기 좋다.

다만 ssh 명령은 서버 하나당 하나밖에 실행할 수 없고 쓰다가 끊어지면 다시 실행해야 해서 귀찮다. 그리고 unix socket과 tcp socket은 터널링을 지원하지만 다른 걸 사용하려면 일단 socat 같은 명령어로 소켓으로 변환시켜줘야 포워딩할 수 있어서 역시나 귀찮다. 이러한 귀찮음이 만개할 때는 결국 내 입맛에 맞는 야크 털을 깎을 수 밖에 없는 것이다.

그래서 목적은 다음과 같다.

Requirement analysis

Configurable in a plain text file

텍스트 문서 중 구조화된 설정을 담을 수 있는 포맷은 몇 가지가 있는데, 제일 보편적으로 쓰이는 것은 json, toml, yaml 정도를 들 수 있겠다. 각각 장단점이 명확하여, 최종적으로 yaml 을 쓰기로 결정했다.

JSON

JSON 은 역사와 전통의 텍스트 파일 포맷이다. 웹 브라우저에서 쓰이는 JavaScript 엔진으로 해석 가능한 데이터 형식이기 때문에 가장 널리 쓰인다고 봐도 좋다. 굉장히 단순한 구조를 가지고 있어서 이해하기 어렵지 않다. 더해서 Go 언어에서는 JSON 파서가 표준 라이브러리에 포함 되어 있다.

다만 개인적으로 싫어하는 포맷이다. 배열이나 객체 표기에서 마지막 요소 뒤에 , 를 찍으면 에러가 난다.

1
2
3
{
    "answer": 42,  // <- error at ,
}

그리고 주석도 지원하지 않는다! 매번 중괄호나 대괄호를 맞춰서 열고 닫는 것도 귀찮은 일이다.

TOML

TOML 도 좋은 포맷이다. 최근에 러스트 쪽 프로그램의 설정 포맷으로 많이 쓰이는 것 같다. 열어보면 90년대와 2000년대 초반에 윈도 좀 만지던 친구들은 익숙하게 보일만한 형태로 나오는데, ini 파일에 영향을 받았기 때문이다. [section] 으로 section을 나누며, 섹션 안에 여러 개의 key = value pair들을 가져서 구조화된 값을 표현할 수 있다. Section 안에 또 다른 subsection을 넣으려면 [section.subsection] 과 같이 하위 분류를 만들어 넣는다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# This is a TOML document

title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00

[database]
enabled = true
ports = [ 8000, 8001, 8002 ]
data = [ ["delta", "phi"], [3.14] ]
temp_targets = { cpu = 79.5, case = 72.0 }

[servers]

[servers.alpha]
ip = "10.0.0.1"
role = "frontend"

[servers.beta]
ip = "10.0.0.2"
role = "backend"

공식 사이트에 있는 예시 파일을 긁어왔다. 여기서 JSON과 가장 큰 차이점이라면 주석이 가능하다는 것과 section을 닫는 표현이 없다는 점이다.

다만 역시나 여기서 이 방법을 선택하지는 않았는데, 보다시피 구조화 깊이가 깊어질수록 설정파일이 복잡해지고 이해하기 어려워지기 때문이다. 여러 호스트와 터널 설정들을 나열해야 하기 때문에 좀 더 일반적인 형태를 나타내기에 적합한 yaml 을 택했다.

YAML

YAML 은 의외로 굉장히 오래된 포맷으로, JSON보다 빨리 표준화가 끝났다. 몇 번 버전이 올라가면서 개정됐지만, 일반적인 용도로는 새롭게 추가된 기능을 쓸 일은 없을 것이다.

다른 포맷과 차별화되는 점 중 하나는 들여쓰기이다. 똑같이 의외로 오래된 파이썬과 마찬가지로 들여쓰기로 블럭을 정의하는데, 다음과 같이 생겼다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
options:
  timeout: 5s
sshhosts:
  server:
    address: 10.20.30.40:22
    user: gwangyi
    auth:
    - pubkeyfile: "C:\\Users\\gwangyi\\.ssh\\id_ed25519"
tunnels:
- listen: tcp:localhost:2222
  dial: tcp:10.20.30.50@server
- listen: tcp:localhost:9003@server
  dial: serial:COM3?baudrate=115200&opt=8N1
knownhosts:
  10.20.30.40:22: SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

일단 string 키를 가진 map을 구성하기에 좋은 문법이다. key: 로 시작하면 map의 각 mapping entry 를 작성하고 있다는 의미가 되고 그 뒤에 오는 내용이 값이 된다.

줄을 바꾸면 들여쓰기 여부에 따라 의미가 좀 달라지는데, 들여쓰기 없이 같은 레벨이라면 새로운 entry를 만들게 되며 들여쓰기가 있다면 마찬가지로 주어진 키의 값으로 인식한다.

- 로 시작하는 경우 이 것은 배열의 요소가 된다. 이 뒤에는 값이 올 수도, 또 다른 배열이 올 수도, 맵이 올 수도 있다.

YAML을 쓸 때 주의해야 할 점 중 하나는 내장값이다. 보통 값 부분에 기본형 값을 넣을 때는 따옴표 없이 그냥 값을 적게 되는데, true/false 에 해당하는 몇 가지 값이 더 있다. yes/no 나 on/off 등인데, 적은 값이 스트링으로 들어가는게 아니라 boolean으로 바뀌니까 주의해야 한다.

좀 더 자세한 스펙은 홈페이지 내용을 참고하시고, 눈으로 봤을 때 대충 이런 뜻이겠지 싶어서 적으면 보통은 보이는대로 구성이 되는 편이다.

Run once to establish whole tunneling network

호스트마다 따로 두고 싶지 않기 때문에, 전체 네트워크를 단일 파일에 적어놓고 이 파일을 읽어서 터널을 열도록 만들고 싶다. 따라서 설정 파일에는 접속할 호스트에 대한 정의와 연결할 터널들에 대한 정의가 반드시 들어가야 한다.

로컬과 리모트간의 터널링 뿐만 아니라 리모트에서 리모트로 가는 것도 적을 수 있으므로 터널링 설정은 호스트에 종속될 수 없다.

또한 실행 중에 추후에 터널이나 호스트를 추가·삭제 할 수 있어야 하므로 최대한 선언적으로 터널링 명세를 작성할 수 있도록 하고 diff를 통해 어떤 터널을 열고 어떤 터널을 닫을지 알 수 있으면 더 좋을 것이다.

Support non-socket tunneling

임베디드 개발을 하게 되면 UART를 우리의 친구 삼아야 할 일이 많다. 하지만 개발은 다른 머신에서 하고 싶은데 UART는 로컬 컴퓨터에나 붙어 있으므로 별도의 터미널를 준비하지 않으면 안되는 불상사가 생긴다. 이러면 여러모로 귀찮기 때문에 이런 것도 터널링 할 수 있으면 좋을 것이다.

다만 소켓들은 bind - listen - accept / dial 의 일련의 과정이 있는데 시리얼은 bind - accept 동작을 상상하기가 힘들다. 또 여러 터널이 한 시리얼 포트에 연결되어 있을 경우 동시에 여러 터널에 접근이 들어왔을 때 처리도 깔끔하게 해 줘야 한다. 한번에 한 터널만 접근할 수 있게 할 것인가, 아니면 broadcast할 것인가? 고민할 필요가 있다. 일단은 나 혼자 쓴다고 가정하고 기능부터 구현한 다음, exclusiveness를 구현하고 시간이 있다면 broadcast를 구현하는 것도 좋은 접근 방법이라고 생각한다.

Support run-time configuration change

설정파일을 모니터링하고 있다가 수정사항이 발견되면 diff해서 추가된 것은 추가하고 빠진 것은 빼는 방식으로 구현해볼 수 있을 것이다. 다만 이 때는 diff를 계산하고 이걸 다시 반영하는 등의 작업이 필요한데, 그렇게 안 할 거면 지금 있는 터널을 전부 한번 종료한 다음 다시 실행해야 하기 때문이다.

상대적으로 조금 더 쉬운 방법은 수정사항의 diff로 동작하기보다는 호스트 추가/삭제, 터널 추가/삭제 기능을 각각 별도의 endpoint로 노출해서 이걸 호출하면 업데이트를 해주는 방식일 것이다. 이 경우는 간단하게 구현하기 위해 http server를 도입하면 좋을 것 같다.

Programming language

태그에 있듯이 Go를 쓸 것이다. 왜 Go냐면 제일 대충 짤 수 있는 언어(..) 이기 때문이고 특히 동시성과 네트워크 면에서 간단하게 구현할 수 있고 ssh 클라이언트도 라이브러리로 지원해서 작성하기가 어렵지 않다. io.Copy 등 포워딩에 쓸만한 좋은 기능들도 많아서 손이 덜 갈 것으로 기대한다.

차회예고

대강 계획을 세웠으니, 인터페이스들을 정의하고 기본적인 포워딩 기능을 구현해 볼 것이다. 그리고 제때 종료되는지(…) 블록되지는 않는지 간단한 테스트를 작성해보자.