Created
September 27, 2025 10:47
-
-
Save holly/d17d122bbb52844fc8654e5a49d1256f to your computer and use it in GitHub Desktop.
SSH local port forward for MySQL with health-check & auto-reconnect
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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