Chapter 05無料公開

socatを使用したUDPトンネル

ArkBig
ArkBig
2022.08.15に更新

ホスト⇒VM間にUDPトンネルを開通させましょう。

Dnsmasqをコンテナ起動した状態でsudo lsof -i:53の出力にUDPがあれば本章は不要です。興味がなければ次の章TraefikでのTLSリバースプロキシに進んでください。

MacのColima環境ではホストとVM間でUDPが自動で通信できませんでした。TCPはLimaがsshトンネルしてくれるので、socatを利用してUDP→TCPにリレーします。

https://linux.die.net/man/1/socat

socatはいろいろな通信を相互に変換してくれるコマンドラインツールです。標準入力やシリアル通信なんかも変換できるようです。
今回はDNS用にホストのUDPをTCPに、VMのTCPをUDPに変換する機能として利用します。

こちらのサイトを参考にさせていただきました。

https://dev.classmethod.jp/articles/relay-tcp-with-socat/

本章はarkbig/devbaseリポジトリのudptunnelフォルダの説明になります。

https://github.com/arkbig/devbase

本章で説明するファイル
📂 devbase/
├── 📄compose.override.yaml(必要なら作る)
├── 📄compose.yaml
└── 📂udptunnel/
    ├── 📄Dockerfile           # receive_udp.shを実行するコンテナ作成指示書
    ├── 📄entrypoint.sh        # コンテナ起動時に実行するヘルパースクリプト
    ├── 📄forward_udp.sh       # ホスト側のUDP→TCP変換処理
    ├── 📄loop_cmd.sh          # コマンドを繰り返すヘルパースクリプト
    ├── 📄receive_udp.sh       # VM側のTCP→UDP変換処理
    └── 📄udp_forwarding.conf  # UDP→TCP変換表

🍎Macの関連図

Dnsmasqの章で書いた図の再掲です。

この「socat_host」〜「socatコンテナ」までを説明します。

🍎ホスト側

まずはホスト側の処理を説明します。UDP→TCPに変換することで、LimaがTCPをsshトンネルを使ってVMへ通信してくれます。
socatを使ってUDPポートの待ち受け処理を実行しましょう。

socatのインストール

まずはsocatのインストールを行いましょう。

brew install socat

forward_udp.sh

UDP→TCPへ変換する待受を行うスクリプトです。

udptunnel/forward_udp.sh ※Shift+マウスホイールで横スクロール
#!/usr/bin/env sh
#====================================================================
# begin of 定型文
# このスクリプトを厳格に実行
set -u
# set -eux
# 環境に影響を受けないようにしておく
umask 0022
# PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_')
IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH
# end of 定型文
#--------------------------------------------------------------------

#---------------(1)--------------#
# ファイル からudp:tcpリストを受け取る
if [ -z "$1" ]; then
    echo "Usage: $0 <udp_forwarding.conf> [kill]"
    exit 1
else
    # 後でkillするとき検索できるようにフルパスにする
    self_sh=$(
        cd "$(dirname "$0")"
        pwd
    )/$(basename "$0")
    read_from=$(
        cd "$(dirname "$1")"
        pwd
    )/$(basename "$1")
    if [ "${self_sh}" != "$0" ] || [ "${read_from}" != "$1" ]; then
        shift
        exec "${self_sh}" "${read_from}" "$@"
    fi
fi
same_pid=$(pgrep -fo "^(/bin/)?sh +${self_sh} +${read_from}$")
if [ $# -ge 2 ]; then
    if [ "$2" = "kill" ]; then
        if [ -n "${same_pid}" ] && [ "$$" != "${same_pid}" ]; then
            # shellcheck disable=SC2086
            kill ${same_pid}
        fi
        exit
    else
        echo "Usage: $0 <udp_forwarding.conf> [kill]"
        exit 1
    fi
else
    if [ -n "${same_pid}" ] && [ "$$" != "${same_pid}" ]; then
        echo "Already running pid ${same_pid}"
        exit
    fi
fi

#---------------(2)--------------#
# 終了時に子プロセスも一緒に終了させる
exit_children() {
    oid=$$
    IFS=$(printf '\n_')
    IFS=${IFS%_}
    for pid in $(pgrep -P "${oid}"); do
        if ! ps "${pid}" >/dev/null; then
            continue
        fi
        kill "${pid}"
    done
    exit
}
trap 'exit_children' 1 2 3 15

#---------------(3)--------------#
loop_cmd=$(
    cd "$(dirname "$0")"
    pwd
)/loop_cmd.sh
while read -r line; do
    # "#〜"はコメント
    line=$(printf "%s" "${line}" | sed -e 's/#.*//')
    if [ -z "${line}" ]; then
        continue
    fi
    dest_host=$(printf "%s" "${line}" | cut -f 1 -d : -s)
    udp_port=$(printf "%s" "${line}" | cut -f 2 -d : -s)
    tcp_port=$(printf "%s" "${line}" | cut -f 3 -d : -s)
    if [ -z "${tcp_port}" ]; then
        tcp_port="${udp_port}"
        udp_port="${dest_host}"
        dest_host=127.0.0.1
    fi
    if [ -n "${udp_port}" ] && [ -n "${tcp_port}" ]; then
        cmd="socat ${UDPTUNNEL_ARGS--s} UDP4-RECVFROM:${udp_port},reuseaddr TCP:127.0.0.1:${tcp_port}"
        echo "Run: ${cmd}"
        # shellcheck disable=SC2086
        "${loop_cmd}" ${cmd} &
    fi
done <"${read_from}"

#---------------(4)--------------#
# 子プロセスの終了を待つ
wait

大きく4つのパートに分かれています。

  1. 引数チェック
  2. 割り込みトラップ
  3. ループコマンド実行
  4. 子プロセス終了待ち

1番目の引数チェックでは次の処理をしています。

  • 第1引数にUDP→TCP変換表ファイルが指定されているかチェック
  • 終了するとき用にコマンドをフルパスに変換
  • 第2引数にkillが指定されていたら、終了処理を行う
  • すでに起動ずみなら何もせず終了

このforward_udp.shはホスト側で&をつけてバックグラウンド実行するのを想定しています。(出力が標準出力のままなので手抜きですが)
そのため終了処理が面倒ですので、第2引数にkillを指定すると同じ引数で起動したスクリプトを終了できるようにしています。

2番目の割り込みトラップではCtrl-Cや上記のkillで終了するときに、子プロセスも終了するように処理しています。

ps
 PID TTY           TIME CMD
:
1001 ttys001    0:00.06 sh /Users/big/proj/devbase/udptunnel/forward_udp.sh /Users/big/proj/devbase/udptunnel/udp_forwarding.conf
2002 ttys001    0:10.73 sh /Users/big/proj/devbase/udptunnel/loop_cmd.sh socat -s UDP4-RECVFROM:53,reuseaddr TCP:127.0.0.1:1053
3003 ttys001    0:00.01 socat -s UDP4-RECVFROM:53,reuseaddr TCP:127.0.0.1:1053
:

psコマンドで確認できる上記3プロセスをKillしています。
kill引数によりPID 1001の自身がKillされ、トラップ先のexit_children()でPID 2002がkillされ、loop_cmd.sh内でトラップしてPID 3003がkillされます。

3番目のループコマンド実行socat -s UDP4-RECVFROM:$udp_port,reuseaddr TCP:127.0.0.1:$tcp_portをループ実行するよう指示しています。
ポート番号の指定はスクリプト起動の第1引数で指定した変換表を使用します。変換表には複数行の指定が可能なためループで子プロセスを起動しています。
socatの引数は次のとおりです。

  • -sを指定するとエラーでも処理を続けようとします
  • UDP4-RECVFROMはIPv4でUDPの待受を表しています
    • RECVFROMは1回のパケット通信で終了します
      • 1回でsocatが終了するので、loop_cmd.shでループ実行するしくみです
    • RECV(返答処理なし?)やLISTEN(ずっと通信する?)の指定もあります
  • reuseaddrは待ち受けに使ったソケットを再利用するようにします
  • TCPは出口としてTCPを使うことを表しています

4番目の子プロセス終了待ちでは3番目のループコマンドが子プロセスで実行されているので、そちらの終了を待ちます。
これは上記のkill処理のために行っています。

loop_cmd.sh

コマンドをループ実行するスクリプトです。

udptunnel/loop_cmd.sh
#!/usr/bin/env sh
#====================================================================
# begin of 定型文
# このスクリプトを厳格に実行
set -eu
# set -eux
# 環境に影響を受けないようにしておく
umask 0022
# PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_')
IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH
# end of 定型文
#--------------------------------------------------------------------

#---------------(1)--------------#
if [ -z "$1" ]; then
    echo "Usage: $0 command [args..]"
    exit 1
fi

#---------------(2)--------------#
# 終了時に子プロセスも一緒に終了させる
exit_children() {
    oid=$$
    IFS=$(printf '\n_')
    IFS=${IFS%_}
    for pid in $(pgrep -P "${oid}"); do
        if ! ps "${pid}" >/dev/null; then
            continue
        fi
        kill "${pid}"
    done
    exit
}
trap 'exit_children' 1 2 3 15

#---------------(3)--------------#
retry_count=10
err_coutinue=0
while true; do
    err_code=0
    "$@" &
    wait $! || err_code=$?
    if [ $err_code -ne 0 ]; then
        err_coutinue=$((err_coutinue + 1))
        if [ $err_coutinue -gt $retry_count ]; then
            exit $err_code
        fi
    else
        err_coutinue=0
    fi
done

大きく3つのパートに分かれています。

  1. 引数チェック
  2. 割り込みトラップ
  3. 無限ループ

2番目の割り込みトラップではforward_udp.shからのkillで終了するときに、子プロセスも終了するように処理しています。

3番目の無限ループで指定されたコマンドを実行し続けます。
ただしエラーが10回連続で続いた場合は異常として終了します。

udp_forwarding.conf

forward_udp.shに指定するUDP→TCP変換表です。

udptunnel/udp_forwarding.conf
# udp-port:[container-name:]tcp-port
# host udp-port -> host tcp-port -> (ssh tunnel) -> container-localhost tcp-port -> container-name udp-port
# container-name: default is 127.0.0.1

# "network_mode: host"ならこっち
53:1053
# そうでないなら、送信先ホスト名(エイリアス)を指定
# devbase-dnsmasq-1:53:1053

今回はDNSのUDP 53番ポートだけしか使っていませんが別ポート番号にリレーしたり、今後別のUDPが必要になってもいいように設定ファイルとして分離しておきました。
後述するVM側のコンテナをnetwork_mode: hostで実行するなら、「UDPポート番号:TCPポート番号」の形で指定します。VM側の出口がlocalhostのUDPポート番号になります。
もしhostモードでなかったり、宛先を指定したいときは「出口宛先:UDPポート番号:TCPポート番号」の形で指定します。

🐧VM側

次にVM側の処理を説明します。sshトンネルで渡ってきたTCPをUDPに変換します。宛先ホストは未指定時のlocalhostもしくはudp_forwarding.conf指定のホストです。

Dockerfile

コンテナのためのイメージを作るDockerfileです。

udptunnel/Dockerfile
FROM ubuntu

#---------------(1)--------------#
RUN apt-get update -y \
 && apt-get install -y --no-install-recommends \
        gosu \
        socat \
 && apt-get -y clean \
 && rm -rf /var/lib/apt/lists/*

#---------------(2)--------------#
ENV CONTAINER_UID=${CONTAINER_UID:-1000}
ENV CONTAINER_GID=${CONTAINER_GID:-1000}
RUN groupadd -g ${CONTAINER_GID} -o udpstaff \
 && useradd -g udpstaff -m -o -u ${CONTAINER_UID} udpstaff
WORKDIR /home/udpstaff/

#---------------(3)--------------#
COPY entrypoint.sh receive_udp.sh loop_cmd.sh udp_forwarding.conf ./
RUN chmod +x ./entrypoint.sh ./receive_udp.sh ./loop_cmd.sh

# EXPOSEはudp_forwarding.conf依存のため指定しない

ENTRYPOINT ["./entrypoint.sh"]
CMD ["./receive_udp.sh", "./udp_forwarding.conf"]

大きく3つのパートに分かれています。

  1. OSのパッケージマネージャーを使って必要なソフトgosusocatをインストール
  2. コマンド実行用のアカウントudpstaff作成
  3. 必要なファイルのコピー&設定

2番目のアカウント作成は書き込みがないのでそれほど重要ではありませんが、udp_forwarding.confはホストファイルをバインドマウントしたいので読み込み権限があるようにします。

3番目の必要な設定は通常EXPOSEで解放するポートに目印をつけますが、今回は可変のため設定していません。

entrypoint.sh

次にコンテナ実行のヘルパースクリプト(ENTRYPOINT)です。

udptunnel/entrypoint.sh
#!/usr/bin/env sh
#====================================================================
# begin of 定型文
# このスクリプトを厳格に実行
set -eu
# set -eux
# 環境に影響を受けないようにしておく
umask 0022
# PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_')
IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH
# end of 定型文
#--------------------------------------------------------------------

#---------------(1)--------------#
# UID,GIDを合わせる
uid=$(stat -c "%u" .)
gid=$(stat -c "%g" .)
ug_name=udpstaff
if [ "${CONTAINER_GID}" != "${gid}" ]; then
    groupmod -g "${CONTAINER_GID}" -o "${ug_name}"
    chgrp -R "${CONTAINER_GID}" .
fi
if [ "${CONTAINER_UID}" != "${uid}" ]; then
    usermod -g "${ug_name}" -o -u "${CONTAINER_UID}" "${ug_name}"
fi

#---------------(2)--------------#
if [ "$(id -u)" = "${CONTAINER_UID}" ]; then
    exec "$@"
else
    # ユーザー変更してコマンド実行
    exec /usr/sbin/gosu "${ug_name}" "$@"
fi

大きく2つのパートに分かれています。

  1. UID,GIDをホストに合わせる
  2. コマンド実行

1番目の処理はホスト側のUID/GIDと合わせるための変更をしています。

2番目のコマンド実行はユーザーを切り替えています。
gosusudoの代わりで、コンテナ内ではTTY関連で問題が起きるためsudoの利用は避けたほうがいいようです。

https://docs.docker.jp/engine/articles/dockerfile_best-practice.html

receive_udp.sh

TCPポートで待ち受けるスクリプトです。

udptunnel/receive_udp.sh ※Shift+マウスホイールで横スクロール
#!/usr/bin/env sh
#====================================================================
# begin of 定型文
# このスクリプトを厳格に実行
set -u
# set -eux
# 環境に影響を受けないようにしておく
umask 0022
# PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_')
IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH
# end of 定型文
#--------------------------------------------------------------------

# ファイル からudp:tcpリストを受け取る
if [ -z "$1" ]; then
    echo "Usage: $0 <udp_forwarding.conf> [kill]"
    exit 1
else
    # 後でkillするとき検索できるようにフルパスにする
    self_sh=$(
        cd "$(dirname "$0")"
        pwd
    )/$(basename "$0")
    read_from=$(
        cd "$(dirname "$1")"
        pwd
    )/$(basename "$1")
    if [ "${self_sh}" != "$0" ] || [ "${read_from}" != "$1" ]; then
        shift
        exec "${self_sh}" "${read_from}" "$@"
    fi
fi
same_pid=$(pgrep -fo "^(/bin/)?sh +${self_sh} +${read_from}$")
if [ $# -ge 2 ]; then
    if [ "$2" = "kill" ]; then
        if [ -n "${same_pid}" ] && [ "$$" != "${same_pid}" ]; then
            # shellcheck disable=SC2086
            kill ${same_pid}
        fi
        exit
    else
        echo "Usage: $0 <udp_forwarding.conf> [kill]"
        exit 1
    fi
else
    if [ -n "${same_pid}" ] && [ "$$" != "${same_pid}" ]; then
        echo "Already running pid ${same_pid}"
        exit
    fi
fi

# 終了時に子プロセスも一緒に終了させる
exit_children() {
    oid=$$
    IFS=$(printf '\n_')
    IFS=${IFS%_}
    for pid in $(pgrep -P "${oid}"); do
        if ! ps "${pid}" >/dev/null; then
            continue
        fi
        kill "${pid}"
    done
    exit
}
trap 'exit_children' 1 2 3 15

loop_cmd=$(
    cd "$(dirname "$0")"
    pwd
)/loop_cmd.sh
while read -r line; do
    # "#〜"はコメント
    line=$(printf "%s" "${line}" | sed -e 's/#.*//')
    if [ -z "${line}" ]; then
        continue
    fi
    dest_host=$(printf "%s" "${line}" | cut -f 1 -d : -s)
    udp_port=$(printf "%s" "${line}" | cut -f 2 -d : -s)
    tcp_port=$(printf "%s" "${line}" | cut -f 3 -d : -s)
    if [ -z "${tcp_port}" ]; then
        tcp_port="${udp_port}"
        udp_port="${dest_host}"
        dest_host=127.0.0.1
    fi
    if [ -n "${udp_port}" ] && [ -n "${tcp_port}" ]; then
        cmd="socat ${UDPTUNNEL_ARGS--s} TCP4-LISTEN:$tcp_port,reuseaddr,fork UDP:$dest_host:$udp_port"
        echo "Run: ${cmd}"
        # shellcheck disable=SC2086
        "${loop_cmd}" ${cmd} &
    fi
done <"${read_from}"

# 子プロセスの終了を待つ
wait

forward_udp.shとほぼ同じで、実行するコマンドの指定だけ違います。

diff ※Shift+マウスホイールで横スクロール
-        cmd="socat ${UDPTUNNEL_ARGS--s} UDP4-RECVFROM:$udp_port,reuseaddr TCP:127.0.0.1:$tcp_port"
+        cmd="socat ${UDPTUNNEL_ARGS--s} TCP4-LISTEN:$tcp_port,reuseaddr,fork UDP:$dest_host:$udp_port"

socatの引数は次のとおりです。

  • -sを指定するとエラーでも処理を続けようとします
  • TCP4-LISTENはIPv4でTCPの待受を表しています
    • こちらは1回の起動で何度も受け付けるようにします
  • reuseaddrは待ち受けに使ったソケットを再利用するようにします
  • forkはソケット接続時に別ポート番号へフォークして通信をするときに指定します
    • これがないと1回の起動で1パケットだけ受け取り終了します
  • UDPは出口としてUDPを使うことを表しています

compose.yaml(抜粋)

そして、dockerで使用するcomposeファイルです。

compose.yaml
services:
  udptunnel:
    image: arkbig/udptunnel
    restart: unless-stopped
    build:
      context: ./udptunnel
      args:
        no_proxy: ${no_proxy-}
        http_proxy: ${http_proxy-}
        https_proxy: ${https_proxy-}
    volumes:
      - ./udptunnel/udp_forwarding.conf:/home/udpstaff/udp_forwarding.conf
    # 他のcompose(network)にも送るかもしれないので、hostにしておく
    # (hostじゃなくて、networks:に指定すれば複数ネットワークに入ることもできるけど)
    network_mode: host
    profiles:
      - udptunnel

他のポート番号で処理をしたいときにコンテナをビルドし直さなくていいように、udp_forwarding.confファイルをバインドマウントしています。

またDNS以外の別サービスにも手軽に通信できるようnetwork_mode: hostにしています。

また、必要に応じてenvironment:で動作を変更できます。個人環境の設定ですので、Git管理外のcompose.override.yamlで指定します。
下記がデフォルト値での設定例です。

compose.override.yaml
services:
  udptunnel:
    environment:
      # コマンドを実行ユーザーを指定
      CONTAINER_UID: 1000
      CONTAINER_GID: 1000

CONTAINER_UIDCONTAINER_GIDはバインドマウントしたudp_forwarding.confが読み込めるアカウントを指定します。
これらの番号はidコマンドで確認できます。

udptunnelが必要な人はCOMPOSE_PROFILES=udptunnelを環境変数に設定しておくといいです。
もしsslcertも指定しているならCOMPOSE_PROFILES=sslcert,udptunnelとカンマ区切りで指定します。

udptunnelの実行と確認

それではudptunnelを実行してみましょう。

docker compose up -d udptunnel
./udptunnel/forward_udp.sh ./udptunnel/udp_forwarding.conf &

udptunnelコンテナの実行と、ホスト側のソケット変換をバックグラウンド実行します。
標準出力をしているのでRun: socat -s UDP4-RECVFROM:53,reuseaddr TCP:127.0.0.1:1053のような出力がありますが気にせずEnterで無視してください。
いずれLimaがUDPトンネル対応してくれたら不要になる処理です。

53番ポートの使用状況を確認してみましょう。Dnsmasq構築時は次のようにTCPしか待ち受けていませんでした。

sudo lsof -i:53
COMMAND PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
ssh     719  big   18u  IPv4 0x764ac823d019058d      0t0  TCP *:domain (LISTEN)

今回はsudo lsof -i:53を実行してみるとどうでしょうか。

sudo lsof -i:53
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
ssh       719  big   18u  IPv4 0x764ac823d019058d      0t0  TCP *:domain (LISTEN)
socat   62951  big    5u  IPv4 0x764ac823d113b945      0t0  UDP *:domain

socatコマンドがUDPで待ち受けするようになりました🎉。

Colimaの起動スクリプト同様、devbase/udptunnel/forward_udp.sh devbase/udptunnel/udp_forwarding.conf &を起動時に実行するように登録しましょう。

動作確認

実際に名前解決できているか確認してみましょう。下記コマンドを実行してみます。

nslookup a.dev.test

ping -c1 a.dev.test

nslookupはServerが$DNSMASQ_SERVERで指定したIPアドレスが表示されていれば成功です。サーバー自体はないのでSERVFAILエラーになります。

ping$DNSMASQ_SERVERで指定したIPアドレスに通信していたらOKです。

うまくいかなかったときの確認手順です。

  1. docker compose logs dnsmasqでDnsmasqコンテナがエラーを出していないか確認します
  2. Dnsmasqの動作確認をします
    1. docker compose run --rm dnsmasq /bin/bashで新しいコンテナを実行
    2. sudo apt update; sudo apt install dnsutilsでnslookupコマンドをインストール
    3. nslookup a.dev.test devbase-dnsmasq-1(devbaseフォルダで実行していないなら読み替える)
    4. Serverが$DNSMASQ_SERVERで指定したIPアドレスならOK、表示されないならDnsmasqが正しく起動できていません
  3. UDPトンネルの動作確認をします
    1. ./udptunnel/forward_udp.sh ./udptunnel/udp_forwarding.conf killでホスト側を終了させる
    2. compose.override.yamlでenvironments UDPTUNNEL_ARGS: -ddを設定
    3. docker compose up -d udptunnelでコンテナを起動
    4. ホスト側も同様にUDPTUNNEL_ARGS=-dd ./udptunnel/forward_udp.sh ./udptunnel/udp_forwarding.confで起動
    5. 標準出力やdocker compose logs udptunnelで正しく通信ができているか確認
  4. /etc/resolver/test(違うドメインを使っているなら読み替える)が正しく設定できているか確認

Macの場合はこれまでどおりなので影響がありませんが、Dnsmasqを使わない名前解決も試してみてください。

✔️本章の作業チェックリスト