ホスト⇒VM間にUDPトンネルを開通させましょう。
Dnsmasqをコンテナ起動した状態でsudo lsof -i:53
の出力にUDPがあれば本章は不要です。興味がなければ次の章TraefikでのTLSリバースプロキシに進んでください。
MacのColima環境ではホストとVM間でUDPが自動で通信できませんでした。TCPはLimaがsshトンネルしてくれるので、socatを利用してUDP→TCPにリレーします。
socatはいろいろな通信を相互に変換してくれるコマンドラインツールです。標準入力やシリアル通信なんかも変換できるようです。
今回はDNS用にホストのUDPをTCPに、VMのTCPをUDPに変換する機能として利用します。
こちらのサイトを参考にさせていただきました。
本章はarkbig/devbaseリポジトリのudptunnelフォルダの説明になります。
📂 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へ変換する待受を行うスクリプトです。
#!/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番目の引数チェックでは次の処理をしています。
- 第1引数にUDP→TCP変換表ファイルが指定されているかチェック
- 終了するとき用にコマンドをフルパスに変換
- 第2引数に
kill
が指定されていたら、終了処理を行う - すでに起動ずみなら何もせず終了
このforward_udp.shはホスト側で&
をつけてバックグラウンド実行するのを想定しています。(出力が標準出力のままなので手抜きですが)
そのため終了処理が面倒ですので、第2引数にkill
を指定すると同じ引数で起動したスクリプトを終了できるようにしています。
2番目の割り込みトラップではCtrl-Cや上記のkill
で終了するときに、子プロセスも終了するように処理しています。
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(ずっと通信する?)の指定もあります
- RECVFROMは1回のパケット通信で終了します
-
reuseaddr
は待ち受けに使ったソケットを再利用するようにします -
TCP
は出口としてTCPを使うことを表しています
4番目の子プロセス終了待ちでは3番目のループコマンドが子プロセスで実行されているので、そちらの終了を待ちます。
これは上記のkill
処理のために行っています。
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つのパートに分かれています。
- 引数チェック
- 割り込みトラップ
- 無限ループ
2番目の割り込みトラップではforward_udp.shからのkill
で終了するときに、子プロセスも終了するように処理しています。
3番目の無限ループで指定されたコマンドを実行し続けます。
ただしエラーが10回連続で続いた場合は異常として終了します。
udp_forwarding.conf
forward_udp.shに指定するUDP→TCP変換表です。
# 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です。
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つのパートに分かれています。
- OSのパッケージマネージャーを使って必要なソフト
gosu
、socat
をインストール - コマンド実行用のアカウント
udpstaff
作成 - 必要なファイルのコピー&設定
2番目のアカウント作成は書き込みがないのでそれほど重要ではありませんが、udp_forwarding.confはホストファイルをバインドマウントしたいので読み込み権限があるようにします。
3番目の必要な設定は通常EXPOSEで解放するポートに目印をつけますが、今回は可変のため設定していません。
entrypoint.sh
次にコンテナ実行のヘルパースクリプト(ENTRYPOINT)です。
#!/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つのパートに分かれています。
- UID,GIDをホストに合わせる
- コマンド実行
1番目の処理はホスト側のUID/GIDと合わせるための変更をしています。
2番目のコマンド実行はユーザーを切り替えています。
gosu
はsudo
の代わりで、コンテナ内ではTTY関連で問題が起きるためsudo
の利用は避けたほうがいいようです。
receive_udp.sh
TCPポートで待ち受けるスクリプトです。
#!/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とほぼ同じで、実行するコマンドの指定だけ違います。
- 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ファイルです。
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で指定します。
下記がデフォルト値での設定例です。
services:
udptunnel:
environment:
# コマンドを実行ユーザーを指定
CONTAINER_UID: 1000
CONTAINER_GID: 1000
CONTAINER_UID
とCONTAINER_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しか待ち受けていませんでした。
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ssh 719 big 18u IPv4 0x764ac823d019058d 0t0 TCP *:domain (LISTEN)
今回は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です。
うまくいかなかったときの確認手順です。
-
docker compose logs dnsmasq
でDnsmasqコンテナがエラーを出していないか確認します - Dnsmasqの動作確認をします
-
docker compose run --rm dnsmasq /bin/bash
で新しいコンテナを実行 -
sudo apt update; sudo apt install dnsutils
でnslookupコマンドをインストール -
nslookup a.dev.test devbase-dnsmasq-1
(devbaseフォルダで実行していないなら読み替える) - Serverが
$DNSMASQ_SERVER
で指定したIPアドレスならOK、表示されないならDnsmasqが正しく起動できていません
-
- UDPトンネルの動作確認をします
-
./udptunnel/forward_udp.sh ./udptunnel/udp_forwarding.conf kill
でホスト側を終了させる - compose.override.yamlでenvironments
UDPTUNNEL_ARGS: -dd
を設定 -
docker compose up -d udptunnel
でコンテナを起動 - ホスト側も同様に
UDPTUNNEL_ARGS=-dd ./udptunnel/forward_udp.sh ./udptunnel/udp_forwarding.conf
で起動 - 標準出力や
docker compose logs udptunnel
で正しく通信ができているか確認
-
- /etc/resolver/test(違うドメインを使っているなら読み替える)が正しく設定できているか確認
Macの場合はこれまでどおりなので影響がありませんが、Dnsmasqを使わない名前解決も試してみてください。