如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

在 Oracle Cloud 建立免費的 VM 後,我思考著可以用來作些什麼?此時想到,或許可以讓他成為網站 backup!但網站是架設在本地的 rock pi 上,並且讓資料庫躲在防火牆後面,如何讓兩者能夠穩定加密連線避免入侵呢?

這個問題可以整理成以下目標:

  1. 如何讓本地資料庫與遠端資料庫建立穩定加密連線?
  2. 如何建立 Read Only Slave DB?
  3. 如何讓本地的 WordPress source code 與 static files 與遠端同步?

這篇文章主要探討第一點,如何透過 SSH reverse tunnel 來達成這件事,以及如何包裝成 docker container。

SSH tunnel 簡介

本篇文章主要關注在如何實現,我們簡單帶過 SSH tunnel 原理。

基本定義

SSH tunnel 就是透過 SSH 協定建立一個隧道連線,在一台機器上監聽一個 port ,並把送到此 port 的流量通過隧道轉發到另一個 port 上。

名詞定義

SSH tunnel 建立有兩種方向,為了方便解釋,這邊定義幾個名詞:

  • user 執行 SSH tunnel 指令的機器,我們稱呼為 SSH client
  • 反之,SSH 連線的遠端機器(也就是另一台機器),我們稱呼為 SSH server

為了方便解釋,我額外定義兩個名詞:

  • entry point:指流量入口,也就是 SSH 建立一個監聽 port 的地方
  • exit point:指流量出口,相對於 tunnel 的另一端

順向 SSH tunnel

或稱呼為 Local Port Forwarding,快速理解方式是,SSH tunnel 在 user 的電腦(SSH client)監聽一個 port (entry point) ,並把送到此 port 的流量,轉發到 遠端機器(SSH server) 對應的 port (exit point)

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

應用情景:

user 想要取用遠端機器的某個服務(比如 website 8080 port),但因為防火牆限制,無法直接連線,但有開放 SSH 22 port,因此透過 Local Port Forwarding 的方式,把本來要直接送到遠端機器的流量,先送至 local 的某一個 port,透過 SSH tunnel 過遠端機器 22 port 轉發 8080 port。

簡單總結:

本地端想 使用遠端服務 ,但遠端被防火牆保護,使用 SSH tunnel 打出一條通道進去,讓兩端靠此通道能夠溝通

逆向 SSH tunnel

或稱呼為 Remote Port Forwarding,或是常說的 Reverse SSH tunnel。從名字可以得知,相對於順向,逆向 SSH tunnel 會在 遠端機器(SSH server) 監聽一個 port (entry point),並把送到此 port 的流量,轉發至 user 的電腦(SSH client) 對應的 port (exit point)

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

應用情境:

user 的電腦想要提供某一個服務(比如 MySQL 3306 port),但因為 user 電腦有防火牆,因此朋友無法直接連線。於是透過 Remote Port Forwarding 的方式,建立一個 SSH tunnel 到某台遠端機器上,並監聽某一個 port,此時朋友即可將流量送入此遠端機器的 port,透過 SSH tunnel 轉發至 user 電腦的 3306 port,成功 access MySQL。

簡單總結:

本地端想 提供服務 給遠端,但本地端有防火牆保護,外界無法連入,因此由本地端主動與遠端建立 SSH tunnel,讓兩端靠此通道溝通

兩者差異可以理解成 轉發方向提供服務方的不同。同時,兩種 SSH tunnel 還能將 entry point 進入的流量,再發至相對於 exit point 機器的 另一台機器上,實現類似跳板的機制。

相信大家看到這裡頭一定痛了,詳細可以參考這篇文章,有詳細的解說與精美的圖片可以輔助理解,我們先進實例解說。

如何應用 SSH tunnel 讓本地資料庫與雲端資料庫建立加密連線?

回到我們的問題,目標是想讓在 防火牆後面,且是 浮動 IP 的本地 MySQL Master DB,能夠與遠端 DB 連線,建立一 Slave DB 呢?

問題分析

首先分析一下

  • 方向是 遠端的 Slave DB 想要連回 本地 的 Master DB (本地端提供服務給遠端)
  • 本地 機器有防火牆,且 IP 不固定 (遠端電腦無法直接穿過防火牆連線)

由以上兩點可以看出,必須使用 Reverse SSH tunnel (Remote Port Forwarding)的方式,由本地端機器建立起 SSH tunnel,綁定遠端機器的 port,將 Slave DB access Master DB 的流量,透過 SSH tunnel 轉發回來本地。

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

情境設定

我們先設定

  • 本地的 MySQL DB 是 3306 port
  • 遠端 SSH 是 22 port,防火牆已允許 22 port 通過,並且開啟 SSH Key Auth

並且在本地建立一個 SSH config (~/.ssh/config) 範例如下:

Host remote-server
Hostname xxx.xxx.xxx.xxx  // remote server ip
IdentityFile ~/.ssh/id_rsa // private key path
IdentitiesOnly yes
Port 22
User ubuntu

建立 SSH tunnel

參考 Remote Port Forwarding (Reverse SSH tunnel) 的指令語法:

ssh -R [bind_address:]<port>:<host>:<host_port> <SSH Server>

我們寫出以下建立 Reverse SSH tunnel 的指令:

$ ssh -fNR 3306:localhost:3306 remote-server

解說一下:

  1. 首先 3306:localhost:3306 要拆成兩部分看, 3306 : localhost:3306
  2. 白色冒號前半部是指在 遠端機器 (SSH server) 監聽 3306 port,如同指令語法所示,bind_address 是可以省略的。省略時相當於只有在遠端機器 localhost 內的 request 才能連入 3306
  3. 白色冒號後半部是指將流量轉發至 本地機器(SSH client)的 3306 port,也就是轉發到 local MySQL DB
  4. -f 參數讓 ssh 跑在背景
  5. -R 參數表示我們使用 Remote Port Forwarding Mode
  6. -N 參數表示建立 SSH tunnel 連線後,不開啟 Remote Shell。因為我們只需要建立通道,不需要遠端機器的 shell 。
  7. 最後給定遠端機器在 ssh config 裡的名稱

測試 SSH tunnel

此時可以另外開一個 SSH 連線到遠端機器,在 shell 裡使用 mysql client 測試是否能夠連回本地 DB

$ mysql -u root -p<your password>

因為我們的 SSH tunnel 在 遠端機器 綁定 他的 localhost 3306 port,因此 mysql 不需額外改定 host 和 port 參數,使用預設值即可。

如果 SSH tunnel 設定正確的話,你就能進入 MySQL 的 shell 介面囉!

用 Bastion Server 保護遠端 DB

直接將遠端 DB 的 22 port 公開在網路上其實有風險,於是我們在前面擋一台 Bastion Server 作保護,DB server 只能在遠端內網中 access。

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

作法和我們用 Bastion 作 SSH proxy 一樣,先在 local 端的 SSH config 增加 Bastion Server 連線設定

Host bastion
Hostname xxx.xxx.xxx.xxx  // bastion server ip
IdentityFile ~/.ssh/id_rsa // private key path
IdentitiesOnly yes
Port 22
User ubuntu

修改遠端機器的 SSH config,增加一行 ProxyCommand 設定

Host remote-server
Hostname xxx.xxx.xxx.xxx  // remote server ip
IdentityFile ~/.ssh/id_rsa // private key path
IdentitiesOnly yes
Port 22
User ubuntu
ProxyCommand ssh -qW%h:%p bastion  // 增加此行

注意:遠端機器們的防火牆記得修改設定,讓 remote-server 22 port 能開放給 bastion 連入,詳細操作不在此篇範疇,就不特別描述了。

先 SSH 遠端機器看有沒有通,如果沒通檢查一下防火牆設定

$ ssh remote-server

之後用同樣的指令建立 Reverse SSH tunnel,就會先通過 Bastion Server 再到遠端機器了!

$ ssh -fNR 3306:localhost:3306 remote-server

如何 Dockerize SSH server / client?

如果 Master / Slave DB 的 port 都是綁定在 host 上,前述的方法已經可以解決問題。但實際上我兩台機器的 MySQL 都是跑在 docker 裡,使用 bridge network configuration,且 port 沒有綁到 host 上。如何讓 Docker 中的 DB 也能透過 SSH tunnel 的方式建立連線呢?

這個問題可以分兩個部分討論,本地端 Master DB 和遠端 Slave DB。

本地端:

本地端簡單的解法是,將 MySQL 的 3306 port 綁定到 host 機器上,如此即可 Port Forwarding。但如果前提是不要綁定,該如何解決呢?

遠端:

遠端簡單的解法是,在 docker-compose 的 MySQL container 增加 extra_hosts 參數,讓他可以由 Docker router 回遠端機器 localhost:3306,就能接上 SSH tunnel 回到本地端的 Master DB,但實際上實驗了很久依然無法連線,只能放棄這個方法。

統合以上兩個問題,最後想到的方法是,在兩台機器個起一個 SSH container ,遠端 container 扮演 SSH server ,本地 container 扮演 SSH client,透過他們建立起 SSH tunnel,即可串起兩端的 db container。

在 Docker 中,每一個 container 可以視為 概念上 的一台 VM,前面的範例 SSH Server / Client 和 MySQL 都跑在同一台機器上,換成 container 等於分別跑在不同的 VM,還可以建立 SSH tunnel 嗎?

答案是可以的!我們分兩部分來討論

遠端機器的設定

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

我們先準備好 SSH server 的 docker image,以下是 Dockerfile

FROM ubuntu:22.04

EXPOSE 22

RUN apt-get update \
 && apt-get install openssh-server -y

RUN echo "Port 22" >> /etc/ssh/sshd_config \
 && echo "PasswordAuthentication no" >> /etc/ssh/sshd_config \
 && echo "PermitRootLogin yes" >> /etc/ssh/sshd_config \
 && echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config \
 && echo "GatewayPorts yes" >> /etc/ssh/sshd_config \
 && echo "ClientAliveInterval 10" >> /etc/ssh/sshd_config \
 && echo "ClientAliveCountMax 6" /etc/ssh/sshd_config \
 && echo "TCPKeepAlive yes" >> /etc/ssh/sshd_config


RUN mkdir /run/sshd \
 && mkdir /root/.ssh
COPY authorized_keys /root/.ssh/

CMD ["/usr/sbin/sshd", "-f", "/etc/ssh/sshd_config", "-D", "-e"]

說明一下 sshd_config 參數用意

  • PasswordAuthentication:關閉密碼登入
  • PubkeyAuthentication:使用 key 方式登入
  • PermitRootLogin:沒有另外新增帳號,使用預設的 root 帳號連線,因此需要 enable。此部分為方便測試,若連線成功,可修改 Dockerfile 中新增建立 user account 的指令,取代使用 root 帳號連線。
  • GatewayPorts:要讓 SSH tunnel 監聽的 port 可以從另一個 container 連入,需要開啟此設定
  • TCPKeepAlive:讓 SSH tunnel 可以保持長時間的連線
  • ClientAliveInterval、ClientAliveCountMax:定時送訊息詢問 client,如果超過次數沒有回應則中斷連線。目前設定是每隔 10 秒 (ClientAliveInterval) 詢問一次,如果超過 6 次 (ClientAliveCountMax) 沒有回應,SSH server 就會關閉連線,釋放監聽的 port

預設使用 key 的方式來連線,請先在 Dockerfile 同一目錄下新建一個 authorized_keys 檔案,並把 public key 寫入。

用以下指令 build image

docker build -t ssh-tunnel-server .

假定原來 Slave MySQL container 是用以下 docker-compose.yml 起的

version: "3.7"
services:
  slave-db:
    image: mysql/mysql-server:8.0.22
    networks:
        wp:
...

networks:
   wp:
     driver: bridge

加上 SSH server container,這邊將 SSH server 的 22 port 綁定到 host 上的 2222 port,目的是讓本地端機器能夠直接連入遠端 SSH server container 裡,因此需要將 port 綁出來。

services:
  slave-db:
...

  ssh-server:
    image: ssh-tunnel-server:latest
    networks:
      wp-network:
    ports:
      - "2222:22"
    restart: always
...

docker-compose up -d 啟動後,設定防火牆讓 2222 port 能通,並回頭設定本地端。

本地機器的設定

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

先準備 init-ssh-reverse-tunnel.sh 檔案,作為 SSH client container 啟動時的 entry point

#!/bin/bash
ssh -NR 0.0.0.0:3306:${SSH_REVERSE_HOST}:3306 \
    -o StrictHostKeyChecking=no \
    -o UserKnownHostsFile=/dev/null \
    -o ExitOnForwardFailure=yes \
    ${SSH_CONFIG_NAME} \
    -v
echo 'Forward Fail, sleep 90 seconds'
sleep 90
echo 'Restart'

說明一下參數用意

  • StrictHostKeyChecking:container 每次重建,host key 都會變換,因此關閉驗證。
  • UserKnownHostsFile:不紀錄 know host,因此導向 /dev/null
  • ExitOnForwardFailure:當 Forward 連線中斷,結束 SSH process。此設定目的讓 SSH tunnel 在發生問題斷線後,能夠自行恢復,原理後面講述。
  • sleep 90:與 ExitOnForwardFailure 搭配使用,一樣後面解說。

和之前的 SSH tunnel 指令作一下比對

// 先前 for run in host
ssh -fNR 3306:localhost:3306 remote-server

// 現在 for run in container
ssh -NR 0.0.0.0:3306:${SSH_REVERSE_HOST}:3306

我們觀察 SSH tunnel 指令的結構

ssh -R [bind_address:]<port>:<host>:<host_port> <SSH Server>

會發現有三個差異

  1. 遠端 forwarding 相關設定:之前 bind_address 省略,現在給定 0.0.0.0

沒有給定 bind_address 表示只監聽在 同一台 host (VM) 裡流入 3306 port 的流量,先前 Slave MySQL 和 SSH server 兩者跑在 同樣的 host (VM) 裡,因此可以正常運作。

改成 docker 跑後,Slave MySQL 和 SSH server 各自跑在 自己的 container 裡,因此可視為 兩個 VM 間的流量,為了讓 3306 port 能夠接受來自另一個 container 的 request,我們需要設定 0.0.0.0 ,同時 SSH server 的 sshd_config 必須設定 GatewayPorts yes

  1. 本地端 forwarding 相關設定:先前轉發流量的 host 設定為 localhost:3306 ,現在給定 ${SSH_REVERSE_HOST}:3306

類似的原因,之前 Master MySQL 和 SSH client 跑在 同樣的 host (VM) 裡,因此流量是轉發到 localhost 身上的 3306。

改成 docker 後,Master MySQL 和 SSH server 各自跑在 自己的 container 裡 ,所以流量要轉發到 MySQL container 身上的 3306 port。為了增加彈性,MySQL container 的位置用環境變數送入。

  1. 沒有使用 -f

先前是直接跑在 host shell 裡,因此要把他丟到背景執行,shell 關閉後隨即被中止。

docker container 則需要一個前景的 process 執行,才能保持運作(否則會被視為執行結束而關閉),因此取消 -f 參數。

緊接著準備 SSH client 需要的 Dockerfile。

為了簡化,會將 host 上的 SSH config 直接掛入。於是在 Dockerfile 給定和 host 一樣的 user id,避免一些帳號權限的問題。此部分可依情況自行調整。

FROM ubuntu:22.04

RUN apt-get update \
 && apt-get install openssh-server -y


RUN groupadd -r ubuntu
RUN useradd -r -u 1000 -g ubuntu ubuntu

RUN mkdir /run/sshd \
 && mkdir /root/.ssh

COPY init-ssh-reverse-tunnel.sh /bin/

USER ubuntu
CMD ["/bin/init-ssh-reverse-tunnel.sh"]

準備好後,build 一下 image

docker build -t ssh-tunnel-client .

假定原來 Master MySQL container 是用以下 docker-compose.yml 起的

version: "3.7"
services:
  master-db:
    image: mysql/mysql-server:8.0.22
    networks:
        wp:
...

networks:
   wp:
     driver: bridge

加上 SSH client container 的設定

services:
  master-db:
...

  ssh-client:
     depends_on:
       - master-db
     image: ssh-tunnel-client
     volumes:
       - ~/.ssh:/home/ubuntu/.ssh
     networks:
       wp:
     restart: always
     environment:
       SSH_REVERSE_HOST: master-db # 表示導流至 master-db container,因為兩者在同一個 docker network,可以直接使用 domain name 取代
       SSH_CONFIG_NAME: slave-conainer # 需與 ssh config 設定的 host 一致
...

並在 host 的 ssh config 新增對應設定。參數對照在 遠端機器的設定 章節建立 SSH server container 時的設定

Host slave-container
Hostname xxx.xxx.xxx.xxx
IdentityFile ~/.ssh/id_rsa
IdentitiesOnly yes
Port 2222
User root
AddressFamily inet

docker-compose up -d 啟動,即可自動建立 SSH tunnel 連線。

如何用 SSH reverse tunnel 建立遠端與本地資料庫連線?

若連線成功,SSH server container log 會有以下訊息

Server listening on 0.0.0.0 port 22.
Server listening on :: port 22.
Accepted publickey for root from x.x.x.x port 48630 ssh2: RSA SHA256:RFJnYt46MjG5EwpFGUOeiY70mh193VKq//9q83Jqya0

疑難雜症

如何解決意外斷線後無法自動重連?

這部份是前面鋪梗很久的問題。在架設上你很可能在 SSH tunnel 連線一段時間後,發現 tunnel 意外中斷後再也無法連線,同時看到如下錯誤訊息:

bind [::]:3306: Address already in use

意思是,SSH server 那邊並不知道 SSH client 斷線,因此並沒有釋放在 host 身上監聽的 3306 port,導致 SSH client 連入後,無法再次要求 SSH server 監聽同樣的 3306 port 而卡在這裡。

估狗網路上會看到解法是在 SSH server 端設定以下參數:

  • ClientAliveInterval
  • ClientAliveCountMax
  • TCPKeepAlive

這也是在 SSH server 的 Dockerfile 裡我們增加這些參數的原因。但只這樣設定還是會落入一個陷阱,導致此問題再度發生!

以我們設定的值為例:

ClientAliveInterval 10
ClientAliveCountMax 6

也就是說

  1. SSH server 每隔 10 秒會送訊息給 SSH client,確認他是否活著。
  2. 只要 SSH client 在 10 sec * 6 次 = 60 sec 內沒有回應,SSH server 就會判定 client 已死,並且釋放監聽的 3306 port。

但問題就出在對於 SSH client 而言,建立 SSH 連線和 tunnel 可以看成兩個步驟

  1. 第一步驟是建立 SSH 連線
  2. 緊接著才是建立 SSH tunnel

預設如果建立 SSH tunnel 失敗,SSH process 並不會退出,只會顯示如上的錯誤訊息,但是 SSH 連線還是存在!

假想一個情境,在 SSH tunnel 建立好連線的情況下,不知為何網路發生問題,因此

  1. SSH client 認為連線中斷所以自動發起重連,先建立 SSH 連線,再建立 SSH tunnel
  2. 但 SSH server 監聽的 port 並未釋出,所以建立 SSH tunnel 失敗,但 SSH 連線不會中斷,於是 SSH client 狀態卡在這裡

整個重連過程可能不用幾秒鐘,因此尚未頂到 SSH server 判定 client 斷線的 60 秒。隨後因為 SSH client 依然保持 SSH 連線,所以 SSH server 也能接收到每次的測試訊息回應,導致 SSH server 認為 client 依然存活,不釋放監聽的 port。

解決的關鍵就是要想辦法在網路出問題時,觸發 SSH server 判定 client 已死的條件,讓他可以釋放監聽的 port,以利後續 SSH client 重新連入時可以順利建立 SSH tunnel。

因此這邊的技巧在於引入兩個調整:

  1. 讓 SSH client 在建立 tunnel 發現失敗時,立刻退出 SSH process,因此不會保持 SSH 連線
  2. 退出後等待 90 秒再重啟 SSH client,讓 SSH server 能夠在 60 秒後確認 client 已死,釋放 port

如此再次重啟,SSH client 就能順利與 SSH server 建立 tunnel。

這兩個調整也反應在我們為 SSH client 準備的 entrypoint script 中

#!/bin/bash
ssh -NR 0.0.0.0:3306:${SSH_REVERSE_HOST}:3306 \
    -o StrictHostKeyChecking=no \
    -o UserKnownHostsFile=/dev/null \
    -o ExitOnForwardFailure=yes \  // <= tunnel 建立失敗,立刻退出 process
    ${SSH_CONFIG_NAME} \
    -v
echo 'Forward Fail, sleep 90 seconds'
sleep 90  // <= 故意睡 90 sec 卡住 container,之後再讓他自己重啟
echo 'Restart'

無法監聽 0.0.0.0

如果發現以下錯誤訊息

bind [::1]:3306: Cannot assign requested address

檢查你的 SSH tunnel 建立指令,如果有監聽 0.0.0.0

ssh -R 0.0.0.0:3306:localhost:3306

表示希望允許外部連入 SSH server 監聽的 3306,此時必須開啟 SSH server 的GatewayPorts 功能。只需要到 SSH server 的 sshd_config 中增加 GatewayPorts = yes 並重啟 sshd 即可。

參考資料

SSH Tunneling (Port Forwarding) 詳解
https://github.com/efrecon/rsshd
想用 SSH 連線到 Docker container
How to make SSH remote port forward that listens 0.0.0.0

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。