以前、VPN のルーティングを変更する方法を共有したことがあります。
当時使用していた VPN は L2TP 方式で、/etc/ppp/ip-up
スクリプトを使ってルートを変更できました。
しかし、仕事の都合で新しい VPN ツールであるFortiClientに切り替えました。
すると、従来の方法は使えなくなりました。
FortiClient は Fortinet 社が提供するセキュアエンドポイントソリューションで、主な用途は以下の通りです:
-
VPN 接続ツール FortiClient は SSL VPN と IPSec VPN に対応しており、ユーザーが安全にリモートから社内ネットワークにアクセスできます。FortiGate ファイアウォールのポリシーと連携し、企業レベルのネットワークセキュリティと接続管理を提供します。
-
エンドポイント保護 VPN 機能に加え、マルウェア防御、ウェブフィルタリング、行動解析などの機能を備え、ユーザー端末のセキュリティを強化します。
-
Fortinet エコシステムとの統合 FortiGate、FortiAnalyzer、EMS などの Fortinet 製品と連携し、統一されたセキュリティ監視・管理を実現します。
-
クロスプラットフォーム対応 Windows、macOS、Linux、iOS、Android など多様なプラットフォームをサポートし、企業内の一括展開と管理が容易です。
簡単に言えば、FortiClient はリモート接続とセキュリティ保護を兼ね備えた企業向けクライアントツールであり、特に社内ネットワーク接続の厳密な管理が求められる組織に適しています。
なぜ無効になるのか?
FortiClient は macOS 上で utunX
という仮想インターフェースを作成し、以下のような デフォルトルート を積極的にプッシュします。よくある組み合わせは:
0.0.0.0/1 → utunX
128.0.0.0/1 → utunX
default → utunX
これら 3 つを合わせると「すべてのトラフィックが VPN を経由する」ことになります。また、接続するたびにローカル設定が上書きされるため、単に /etc/ppp/ip-up
だけでは分割ルーティングの役割を果たせません。
では、どうすれば良いのか?💢
ただの VPN ツールなのに、全てのトラフィックを奪おうとするのか?
つまり?
今回の私の目的はシンプルで、以下の条件だけ満たせば良い:
- 会社の内部ネットワーク(例:
10.0.0.0/8
)は VPN 経由 - ローカル LAN/一般的なインターネット は物理ネットワークインターフェース(Wi-Fi や有線 LAN など)を直接通る
- 現在の VPN インターフェースとローカルゲートウェイを自動検出
- ルート設定が正しく反映されているか検証できる仕組みを提供
以下、まずは手動コマンドを説明し、最後に自動化スクリプトを紹介します。
1. ルートの修正
FortiClient は接続時に以下の 3 つの強制ルートを密かに追加し、すべてのトラフィックを VPN に引き込みます:
0.0.0.0/1
128.0.0.0/1
default
これら 3 つは IPv4 全体のネットワークをカバーしており、全てのネットワーク要求を丸ごと捕捉してしまいます。
なので最初にやることは、これらのルートを削除することです:
for cidr in default 0.0.0.0/1 128.0.0.0/1; do
sudo route -q -n delete -ifscope "$VPN_IF" "$cidr"
done
$VPN_IF
は私のスクリプトで自動的にインターフェース(例:utun4、utun6 など)を見つけるための変数です。
このステップは非常に重要で、削除に失敗すると VPN が全トラフィックを奪い続けます。削除時に "not in table" と表示されることがありますが、それは該当ルートが存在しないだけなので無視して構いません。
2. ローカルのデフォルトルートを復元
VPN のデフォルトルートを削除したら、元々のローカルゲートウェイを戻す必要があります。
こうすることで google.com
や youtube.com
のような一般的なインターネット通信は物理ネットワークインターフェース(en0、en1 など)を経由します。
if ! sudo route -n change default "$LOCAL_GW"; then
sudo route -n add default "$LOCAL_GW"
fi
$LOCAL_GW
はnetstat
コマンドなどから自動的に取得した元のゲートウェイ(例:192.168.0.1)で、手動入力は不要です。
3. 社内ネットワーク専用ルートを追加
次に VPN が社内ネットワークのトラフィックのみを扱うように設定します。
仮に社内ネットワークが 10.0.0.0/8
だとすると、VPN のピア先 IP にルートを向けます:
sudo route -n add -net 10.0.0.0/8 "$VPN_PEER"
既にルートが存在していれば change
に切り替えます:
sudo route -n change -net 10.0.0.0/8 "$VPN_PEER"
$VPN_PEER
は VPN の Point-to-Point 対向先 IP で、スクリプト内でifconfig utunX
を解析して自動取得しています。
もし社内ネットワークが 10.0.0.0/8 以外の場合は、この部分を必ず修正してください。例えば、社内ネットワークが 172.16.0.0/12 の場合は、以下のように対応させます:
sudo route -n add -net 172.16.0.0/12 "$VPN_PEER"
4. 設定が有効か確認する
最後のステップは私が強化した部分です。
このスクリプトを実行し、いくつかのテストターゲットを指定すると、スクリプトは自動的に:
- 各 IP またはホスト名が実際にどのインターフェースを通っているかをチェック
- そのホストに対して ping を送り、通信可能かどうかを確認します
例えば:
スクリプトを実行し、テストターゲットとして以下を指定します:
bash forticlient_split.sh 10.1.1.1 192.168.0.100 google.com
すると、次のような出力が得られます:
🌐 10.1.1.1 ➜ インターフェース utun4 (期待値 utun4) ✅ 接続成功 ✅
🌐 192.168.0.100 ➜ インターフェース en0 (期待値 ローカル) ✅ 接続成功 ✅
🌐 google.com ➜ インターフェース en0 (期待値 ローカル) ✅ 接続成功 ✅
これにより「VPN は会社の内部ネットワークのみを担当する」という設定が正しく機能しているか、素早く検証できます。
接続状況やインターフェース、通信可否が一目でわかるので便利です!
なかなか良い感じでしょう?
完全版スクリプト
macOS、FortiClient VPN 対応、自動検出、エラーチェック、接続テスト、ルート表示、VPN ピア IP 解析などの機能を備えた完全版スクリプトを以下にまとめました。
このスクリプトを forticlient_split.sh
という名前で保存し、実行権限を付与してから実行してください:
chmod +x forticlient_split.sh
./forticlient_split.sh 10.1.1.1 192.168.0.100 google.com
以下がコードになります。ご活用ください。
#!/usr/bin/env bash
# =============================================================================
# FortiClient VPN 分割ルーティング(詳細出力付き)
#
# このスクリプトは FortiClient によって挿入されたルートをリセットし、
# VPN 以外のトラフィック用にローカルのデフォルトゲートウェイを復元し、
# 10.0.0.0/8 ネットワークを VPN にバインドし、
# ルーティングテーブル、インターフェースマッピング、接続テスト(ping)を詳細に表示します。
#
# 使い方:
# forticlient_split.sh [ -h | --help ] host1 [host2 ...]
#
# オプション:
# -h, --help ヘルプメッセージを表示して終了します。
# =============================================================================
set -euo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# エラーと情報表示のヘルパー関数
# -----------------------------------------------------------------------------
# die はエラーメッセージを表示し、ステータス1で終了します。
# 引数:
# $@: 表示するエラーメッセージ
die() {
printf '❌ %s\n' "$*" >&2
exit 1
}
# info は情報メッセージを表示します。
# 引数:
# $@: 表示するメッセージ
info() {
printf 'ℹ️ %s\n' "$*"
}
# -----------------------------------------------------------------------------
# ユーティリティ関数
# -----------------------------------------------------------------------------
# get_vpn_interface は最初に見つかった PtP アドレスを持つ utun/tun/ppp インターフェース名を出力します。
# 戻り値:
# 見つかったインターフェース名を出力、見つからなければ空出力
get_vpn_interface() {
while read -r iface; do
iface=${iface%:}
if ifconfig "$iface" | grep -q 'inet .*-->'; then
printf '%s\n' "$iface"
return
fi
done < <(ifconfig | awk '/^(utun|tun|ppp)[0-9]+:/{print $1}')
}
# get_vpn_peer は指定された VPN インターフェースの PtP 対向 IP を取得します。
# 引数:
# $1: インターフェース名(例: utun4)
# 戻り値:
# 対向 IP を出力、見つからなければ空出力
get_vpn_peer() {
local iface="$1"
ifconfig "$iface" | awk '/inet / && /-->/ {
for (i = 1; i <= NF; i++) {
if ($i == "-->") { print $(i+1); exit }
}
}'
}
# get_local_gateway は VPN 以外のデフォルトゲートウェイを取得します。
# 戻り値:
# ゲートウェイ IP を出力、見つからなければ空出力
get_local_gateway() {
netstat -rn \
| awk '$1=="default" && $NF !~ /(utun|tun|ppp)/ { print $2; exit }'
}
# iface_of は指定ホストにアクセスする際に使われるインターフェース名を返します。
# 引数:
# $1: ホスト名またはIP
# 戻り値:
# インターフェース名を出力
iface_of() {
route get "$1" 2>/dev/null \
| awk '/interface:/{print $2}'
}
# show_route_info は現在のデフォルトルートと10.0.0.0/8のルート情報を表示します。
show_route_info() {
printf '\n──────────────────────────────────────────────────\n'
info "🔎 現在のデフォルトルート:"
route get default 2>/dev/null | awk 'NR<=5'
info "🔎 現在の 10.0.0.0/8 ルート:"
route get 10.0.0.1 2>/dev/null | awk 'NR<=5'
printf '──────────────────────────────────────────────────\n\n'
}
# test_connect は指定ホストへのping疎通確認を行います。
# 引数:
# $1: ホスト名またはIP
test_connect() {
local host="$1"
printf ' ↳ %s への疎通テスト: ' "$host"
if ping -c2 -W1 "$host" &>/dev/null; then
printf '✅ 到達可能\n'
else
printf '❌ 到達不可\n'
fi
}
# -----------------------------------------------------------------------------
# 使用方法表示
# -----------------------------------------------------------------------------
usage() {
cat <<-EOF
使い方: $(basename "$0") [ -h | --help ] host1 [host2 ...]
オプション:
-h, --help ヘルプメッセージを表示して終了します。
EOF
exit 0
}
# -----------------------------------------------------------------------------
# メイン処理
# -----------------------------------------------------------------------------
# フラグ解析
if [[ "${1-}" =~ ^-h|--help$ ]]; then
usage
fi
# ホスト収集
HOSTS=("$@")
(( ${#HOSTS[@]} )) || die "少なくとも一つのホストを指定してください。"
# 1. VPNインターフェースとゲートウェイの検出
info "🔍 VPNインターフェースとローカルゲートウェイを検出中"
VPN_IF=$(get_vpn_interface) || die "VPNインターフェース(utun/tun/ppp)を検出できませんでした。"
VPN_PEER=$(get_vpn_peer "$VPN_IF") || die "VPNのPtP対向IPを取得できませんでした。"
LOCAL_GW=$(get_local_gateway) || die "ローカルゲートウェイを取得できませんでした。"
info " • VPNインターフェース:$VPN_IF"
info " • VPNピアIP:$VPN_PEER"
info " • ローカルゲートウェイ:$LOCAL_GW"
# 変更前のルートを表示
show_route_info
# 2. FortiClient によって強制追加されたルートを削除
info "🧹 FortiClientが挿入した強制ルートを削除中"
for cidr in default 0.0.0.0/1 128.0.0.0/1; do
sudo route -q -n delete -ifscope "$VPN_IF" "$cidr" 2>/dev/null || true
done
# 3. ローカルのデフォルトゲートウェイを復元
info "🚧 ローカルのデフォルトルートを $LOCAL_GW に設定中"
if ! sudo route -n change default "$LOCAL_GW" 2>/dev/null; then
sudo route -n add default "$LOCAL_GW"
fi
# 4. 10.0.0.0/8 ネットワークをVPNにバインド
info "🔗 10.0.0.0/8 ネットワークを $VPN_PEER ($VPN_IF) にバインド"
if ! sudo route -n add -net 10.0.0.0/8 "$VPN_PEER" 2>/dev/null; then
sudo route -n change -net 10.0.0.0/8 "$VPN_PEER"
fi
# 変更後のルートを表示
show_route_info
# 5. ルーティングインターフェースと接続性のテスト
info "🌐 ルートインターフェースと接続性をテスト中"
for host in "${HOSTS[@]}"; do
actual_if=$(iface_of "$host" || echo "不明")
if [[ $host == 10.* ]]; then
expected_if="$VPN_IF"
else
expected_if="ローカル(非VPN)"
fi
printf ' • %-15s ➜ インターフェース %-8s (期待値 %s)\n' \
"$host" "$actual_if" "$expected_if"
test_connect "$host"
done
info "🎉 設定、ルート、接続性テストが完了しました"
☕ 1杯のコーヒーが支えになります
AIやフルスタックの情報発信を続けるため、ご支援お願いします。
AI・開発・運用まで一括対応
アイデアからリリースまで、技術面はまるごとお任せください。
対応内容
- 技術相談 + 開発 + デプロイ
- 継続サポート & 拡張
🚀 次のプロジェクト、始めましょう!
カスタム開発や長期支援をご希望の方は、ぜひご相談ください。