Skip to content

Instantly share code, notes, and snippets.

@holly
Created September 27, 2025 10:47
Show Gist options
  • Save holly/d17d122bbb52844fc8654e5a49d1256f to your computer and use it in GitHub Desktop.
Save holly/d17d122bbb52844fc8654e5a49d1256f to your computer and use it in GitHub Desktop.
SSH local port forward for MySQL with health-check & auto-reconnect
#!/usr/bin/env bash
# mysql_localforward.sh — SSH local port forward for MySQL with health-check & auto-reconnect
# - カレントの ./.env を読み込み(あれば)
# - さらに -f /path/to/.env で指定した .env を読み込み(上書き)
# - フォワード断検知で自動再接続、排他実行、ローカルバインドがデフォルト
set -Eeuo pipefail
show_help() {
cat <<'EOF'
Usage: mysql_localforward.sh [-f PATH_TO_ENV] [-h]
Options:
-f FILE 追加の .env ファイルを読み込む(カレントの ./.env を読んだ後に上書き)
-h このヘルプを表示
Environment keys (いずれも .env / 環境変数で設定可):
SSH_HOST (必須) : SSH 接続先ホスト(例: bastion.example.com)
SSH_USER : SSH ユーザー(未設定なら省略)
SSH_PORT : SSH ポート(既定: 22)
SSH_KEY : SSH 秘密鍵パス(未設定なら省略)
REMOTE_HOST : リモート側で接続する MySQL ホスト(既定: 127.0.0.1)
REMOTE_PORT : リモート MySQL ポート(既定: 3306)
LOCAL_PORT : ローカル転送ポート(既定: 3306)
ALLOW_EXTERNAL : true で 0.0.0.0 へバインド(既定: false)
HEARTBEAT_INTERVAL : ヘルスチェック間隔秒(既定: 60)
RECONNECT_DELAY : 再接続までの待機秒(既定: 5)
MYSQL_HEALTHCHECK : MySQL レベルの疎通チェックを行う(true/false、既定: true)
MYSQL_USER : ヘルスチェック用の MySQL ユーザー(省略可)
MYSQL_PASSWORD : 同パスワード(省略可、あれば -p で非対話指定)
MYSQL_DATABASE : 同データベース(省略可)
EOF
}
# ---------------- CLI options ----------------
DOTENV_FILE=""
while getopts ":f:h" opt; do
case "$opt" in
f) DOTENV_FILE="$OPTARG" ;;
h) show_help; exit 0 ;;
\?) echo "Invalid option: -$OPTARG" >&2; show_help; exit 2 ;;
:) echo "Option -$OPTARG requires an argument." >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
# ---------------- dotenv loader (source-safe) ----------------
dotenv_source() {
local file="$1"
[[ -z "$file" ]] && return 0
[[ ! -f "$file" ]] && { echo "dotenv not found: $file" >&2; exit 1; }
# .env はシェル互換の "KEY=VALUE" を想定。安全のため allexport で export、-u を一時無効化
set -a
set +u
# shellcheck disable=SC1090
. "$file"
set -u
set +a
}
# 1) カレントの .env(あれば)
[[ -f "$PWD/.env" ]] && dotenv_source "$PWD/.env"
# 2) 明示指定(あれば)— 後勝ち
[[ -n "$DOTENV_FILE" ]] && dotenv_source "$DOTENV_FILE"
# ---------------- Config (defaults after .env) ----------------
: "${LOCAL_PORT:=3306}"
: "${REMOTE_PORT:=3306}"
: "${REMOTE_HOST:=localhost}"
: "${SSH_PORT:=22}"
: "${SSH_HOST:?Set SSH_HOST (e.g., export SSH_HOST=bastion.example.com)}"
: "${SSH_USER:=${USER:-}}"
: "${SSH_KEY:=}"
: "${ALLOW_EXTERNAL:=false}"
: "${HEARTBEAT_INTERVAL:=60}"
: "${RECONNECT_DELAY:=5}"
: "${MYSQL_HEALTHCHECK:=true}"
: "${MYSQL_USER:=}"
: "${MYSQL_PASSWORD:=}"
: "${MYSQL_DATABASE:=}"
is_true() { case "${1,,}" in true|1|yes|y) return 0;; *) return 1;; esac; }
BIND_ADDR="127.0.0.1"; is_true "$ALLOW_EXTERNAL" && BIND_ADDR="0.0.0.0"
LOCKFILE="/tmp/mysql_lpf_${LOCAL_PORT}.lock"
STOP=0
TUNNEL_PID=""
log() { printf '[%(%Y-%m-%dT%H:%M:%S%z)T] %s\n' -1 "$*"; }
cleanup() {
STOP=1
if [[ -n "${TUNNEL_PID}" ]] && kill -0 "${TUNNEL_PID}" 2>/dev/null; then
log "Stopping tunnel (pid=${TUNNEL_PID})..."
kill "${TUNNEL_PID}" 2>/dev/null || true
wait "${TUNNEL_PID}" 2>/dev/null || true
fi
exec 9>&- || true
rm -f "${LOCKFILE}" 2>/dev/null || true
log "Exiting."
}
trap cleanup EXIT INT TERM HUP
port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -ltn "( sport = :${LOCAL_PORT} )" | tail -n +2 | grep -q .
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"${LOCAL_PORT}" -sTCP:LISTEN >/dev/null 2>&1
else
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(:|\\.)${LOCAL_PORT}$"
fi
}
health_check() {
# TCP が開いているか(軽量チェック)
if ! (exec 3<>"/dev/tcp/127.0.0.1/${LOCAL_PORT}") 2>/dev/null; then
log "Health: TCP not open"
return 1
fi
exec 3>&- || true
# MySQL レベルの疎通(任意・既定オン)
if is_true "$MYSQL_HEALTHCHECK" && command -v mysql >/dev/null 2>&1; then
local args=(-h 127.0.0.1 -P "${LOCAL_PORT}" -N -B)
[[ -n "$MYSQL_USER" ]] && args+=(-u "$MYSQL_USER")
[[ -n "$MYSQL_PASSWORD" ]] && args+=("-p${MYSQL_PASSWORD}")
[[ -n "$MYSQL_DATABASE" ]] && args+=("$MYSQL_DATABASE")
if ! mysql "${args[@]}" -e "SELECT 1" >/dev/null 2>&1; then
log "Health: MySQL check failed"
return 1
fi
fi
log "Health: OK"
return 0
}
start_tunnel() {
if port_in_use; then
log "Local port ${LOCAL_PORT} already in use. Abort."
return 1
fi
log "Starting tunnel: ${BIND_ADDR}:${LOCAL_PORT} -> ${SSH_HOST}:${SSH_PORT} -> ${REMOTE_HOST}:${REMOTE_PORT}"
# ssh コマンドを配列で構築(autossh は使用しない)
local -a cmd=(
ssh
-o ExitOnForwardFailure=yes
-o ServerAliveInterval=10
-o ServerAliveCountMax=3
-o StrictHostKeyChecking=accept-new
-N
-L "${BIND_ADDR}:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}"
-p "$SSH_PORT"
)
is_true "$ALLOW_EXTERNAL" && cmd+=(-g)
[[ -n "$SSH_KEY" ]] && cmd+=(-i "$SSH_KEY")
if [[ -n "$SSH_USER" ]]; then cmd+=("${SSH_USER}@${SSH_HOST}"); else cmd+=("${SSH_HOST}"); fi
# バックグラウンドで起動(-f は使わず PID を捕捉)
"${cmd[@]}" &
TUNNEL_PID=$!
# 起動確認(最大 10 秒)
for _ in {1..20}; do
if kill -0 "${TUNNEL_PID}" 2>/dev/null && health_check; then
log "Tunnel is up (pid=${TUNNEL_PID})."
return 0
fi
sleep 0.5
done
log "Tunnel failed to come up."
return 1
}
main_loop() {
while [[ "${STOP}" -eq 0 ]]; do
if start_tunnel; then
# 稼働中: 定期ヘルスチェック、失敗で再接続
while [[ "${STOP}" -eq 0 ]] && kill -0 "${TUNNEL_PID}" 2>/dev/null; do
sleep "${HEARTBEAT_INTERVAL}"
if ! health_check; then
log "Health check failed — restarting tunnel..."
kill "${TUNNEL_PID}" 2>/dev/null || true
wait "${TUNNEL_PID}" 2>/dev/null || true
break
fi
done
else
log "Start failed — will retry."
fi
[[ "${STOP}" -eq 1 ]] && break
sleep "${RECONNECT_DELAY}"
done
}
# -------- 単一起動ガード(同じ LOCAL_PORT で多重起動しない) --------
exec 9>"${LOCKFILE}" || { log "Cannot open lock ${LOCKFILE}"; exit 1; }
if ! flock -n 9; then
log "Another instance is running for port ${LOCAL_PORT}. Abort."
exit 1
fi
# -------- 実行 --------
log "mysql_localforward starting…"
main_loop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment