feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
391
ssh-tunnel.sh
Executable file
391
ssh-tunnel.sh
Executable file
@@ -0,0 +1,391 @@
|
||||
#!/bin/bash
|
||||
# ROA2WEB SSH Tunnel Manager (Multi-Server Support)
|
||||
# Manages SSH tunnels to Oracle server(s) for development
|
||||
#
|
||||
# Configuration:
|
||||
# - Reads from backend/ssh-tunnels.json (next to .env)
|
||||
# - SSH passwords from backend/secrets/{server_id}.ssh_pass
|
||||
# - SSH keys from backend/secrets/{server_id}.ssh_key or ssh_key field in config
|
||||
# - Falls back to legacy single-server config from backend/.env if JSON not found
|
||||
#
|
||||
# Usage: ./ssh-tunnel.sh {start|stop|status|restart|help}
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/backend/.env"
|
||||
SECRETS_DIR="$SCRIPT_DIR/backend/secrets"
|
||||
SSH_TUNNELS_FILE="$SCRIPT_DIR/backend/ssh-tunnels.json"
|
||||
PID_DIR="/tmp/roa_tunnels"
|
||||
mkdir -p "$PID_DIR"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ============================================================================
|
||||
# Parse configuration from .env and secrets/
|
||||
# ============================================================================
|
||||
# Global arrays to store tunnel configs
|
||||
declare -a SERVER_IDS
|
||||
declare -A SERVER_NAMES
|
||||
declare -A LOCAL_PORTS
|
||||
declare -A SSH_HOSTS
|
||||
declare -A SSH_PORTS
|
||||
declare -A SSH_USERS
|
||||
declare -A SSH_PASSWORDS
|
||||
declare -A SSH_KEYS
|
||||
declare -A ORACLE_HOSTS
|
||||
declare -A ORACLE_PORTS
|
||||
|
||||
parse_config() {
|
||||
SERVER_IDS=()
|
||||
|
||||
# Primary: Read from secrets/ssh-tunnels.json
|
||||
if [ -f "$SSH_TUNNELS_FILE" ]; then
|
||||
echo -e "${CYAN}Reading config from: $SSH_TUNNELS_FILE${NC}"
|
||||
# Parse JSON with Python
|
||||
eval "$(python3 << PYTHON_SCRIPT
|
||||
import json
|
||||
import os
|
||||
|
||||
secrets_dir = "$SECRETS_DIR"
|
||||
tunnels_file = "$SSH_TUNNELS_FILE"
|
||||
|
||||
with open(tunnels_file, 'r') as f:
|
||||
tunnels = json.load(f)
|
||||
|
||||
for t in tunnels:
|
||||
sid = t.get('id', 'default')
|
||||
ssh_host = t.get('ssh_host', '')
|
||||
|
||||
# Skip entries without ssh_host
|
||||
if not ssh_host:
|
||||
print(f'# Skipping [{sid}] - no ssh_host configured')
|
||||
continue
|
||||
|
||||
print(f'SERVER_IDS+=("{sid}")')
|
||||
print(f'SERVER_NAMES["{sid}"]="{t.get("name", sid)}"')
|
||||
print(f'LOCAL_PORTS["{sid}"]="{t.get("local_port", 1521)}"')
|
||||
print(f'SSH_HOSTS["{sid}"]="{ssh_host}"')
|
||||
print(f'SSH_PORTS["{sid}"]="{t.get("ssh_port", 22)}"')
|
||||
print(f'SSH_USERS["{sid}"]="{t.get("ssh_user", "root")}"')
|
||||
print(f'ORACLE_HOSTS["{sid}"]="{t.get("oracle_host", "localhost")}"')
|
||||
print(f'ORACLE_PORTS["{sid}"]="{t.get("oracle_port", 1521)}"')
|
||||
|
||||
# SSH key path (from config or default)
|
||||
ssh_key = t.get('ssh_key', f'{secrets_dir}/{sid}.ssh_key')
|
||||
# Resolve relative paths
|
||||
if not ssh_key.startswith('/'):
|
||||
ssh_key = f'$SCRIPT_DIR/backend/{ssh_key}'
|
||||
print(f'SSH_KEYS["{sid}"]="{ssh_key}"')
|
||||
PYTHON_SCRIPT
|
||||
)"
|
||||
# Load SSH passwords from secrets files
|
||||
for sid in "${SERVER_IDS[@]}"; do
|
||||
local pass_file="$SECRETS_DIR/${sid}.ssh_pass"
|
||||
if [ -f "$pass_file" ]; then
|
||||
SSH_PASSWORDS["$sid"]="$(cat "$pass_file" | tr -d '\n')"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Fallback: Legacy single-server config from .env
|
||||
if [ ${#SERVER_IDS[@]} -eq 0 ]; then
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo -e "${YELLOW}No ssh-tunnels.json found, using legacy config from .env${NC}"
|
||||
# Source .env for legacy vars
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
SERVER_IDS+=("default")
|
||||
SERVER_NAMES["default"]="Default Server (Legacy)"
|
||||
LOCAL_PORTS["default"]="${ORACLE_PORT:-1521}"
|
||||
SSH_HOSTS["default"]="${SSH_HOST:-roa.romfast.ro}"
|
||||
SSH_PORTS["default"]="${SSH_PORT:-22122}"
|
||||
SSH_USERS["default"]="${SSH_USER:-roa2web}"
|
||||
ORACLE_HOSTS["default"]="${ORACLE_TUNNEL_REMOTE:-10.0.20.36}"
|
||||
ORACLE_PORTS["default"]="${ORACLE_TUNNEL_PORT:-1521}"
|
||||
SSH_KEYS["default"]="$SECRETS_DIR/roa_oracle_server"
|
||||
|
||||
# Legacy password file
|
||||
if [ -f "$SECRETS_DIR/default.ssh_pass" ]; then
|
||||
SSH_PASSWORDS["default"]="$(cat "$SECRETS_DIR/default.ssh_pass" | tr -d '\n')"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Tunnel management functions
|
||||
# ============================================================================
|
||||
|
||||
get_pid_file() {
|
||||
local sid=$1
|
||||
echo "$PID_DIR/tunnel_${sid}.pid"
|
||||
}
|
||||
|
||||
check_tunnel() {
|
||||
local sid=$1
|
||||
local pid_file=$(get_pid_file "$sid")
|
||||
|
||||
if [ -f "$pid_file" ]; then
|
||||
local pid=$(cat "$pid_file")
|
||||
if ps -p "$pid" > /dev/null 2>&1; then
|
||||
return 0 # Running
|
||||
else
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
fi
|
||||
return 1 # Not running
|
||||
}
|
||||
|
||||
start_single_tunnel() {
|
||||
local sid=$1
|
||||
local name="${SERVER_NAMES[$sid]}"
|
||||
local local_port="${LOCAL_PORTS[$sid]}"
|
||||
local ssh_host="${SSH_HOSTS[$sid]}"
|
||||
local ssh_port="${SSH_PORTS[$sid]}"
|
||||
local ssh_user="${SSH_USERS[$sid]}"
|
||||
local ssh_key="${SSH_KEYS[$sid]}"
|
||||
local ssh_pass="${SSH_PASSWORDS[$sid]}"
|
||||
local oracle_host="${ORACLE_HOSTS[$sid]}"
|
||||
local oracle_port="${ORACLE_PORTS[$sid]}"
|
||||
local pid_file=$(get_pid_file "$sid")
|
||||
|
||||
echo -e "${CYAN}[$sid]${NC} ${name}"
|
||||
echo -e " Tunnel: localhost:${local_port} → ${oracle_host}:${oracle_port}"
|
||||
echo -e " Via: ${ssh_user}@${ssh_host}:${ssh_port}"
|
||||
|
||||
if check_tunnel "$sid"; then
|
||||
echo -e " ${YELLOW}⚠️ Already running (PID: $(cat $pid_file))${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Prepare SSH key if exists
|
||||
local tmp_key="/tmp/ssh_key_${sid}"
|
||||
local use_key=false
|
||||
if [ -f "$ssh_key" ]; then
|
||||
cp "$ssh_key" "$tmp_key"
|
||||
chmod 600 "$tmp_key"
|
||||
use_key=true
|
||||
fi
|
||||
|
||||
# Build SSH command
|
||||
local ssh_cmd="ssh -f -N -L ${local_port}:${oracle_host}:${oracle_port}"
|
||||
ssh_cmd+=" -p ${ssh_port}"
|
||||
ssh_cmd+=" -o StrictHostKeyChecking=no"
|
||||
ssh_cmd+=" -o ServerAliveInterval=60"
|
||||
ssh_cmd+=" -o ServerAliveCountMax=3"
|
||||
ssh_cmd+=" -o ExitOnForwardFailure=yes"
|
||||
|
||||
if [ "$use_key" = true ]; then
|
||||
ssh_cmd+=" -i ${tmp_key}"
|
||||
ssh_cmd+=" ${ssh_user}@${ssh_host}"
|
||||
|
||||
# Execute with key
|
||||
eval "$ssh_cmd" 2>/dev/null
|
||||
elif [ -n "$ssh_pass" ]; then
|
||||
# Use sshpass for password authentication
|
||||
if ! command -v sshpass &> /dev/null; then
|
||||
echo -e " ${RED}❌ sshpass not installed (needed for password auth)${NC}"
|
||||
echo -e " ${YELLOW} Install: sudo apt install sshpass${NC}"
|
||||
return 1
|
||||
fi
|
||||
ssh_cmd+=" ${ssh_user}@${ssh_host}"
|
||||
sshpass -p "$ssh_pass" $ssh_cmd 2>/dev/null
|
||||
else
|
||||
echo -e " ${RED}❌ No SSH key or password found${NC}"
|
||||
echo -e " ${YELLOW} Add: secrets/${sid}.ssh_key or secrets/${sid}.ssh_pass${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if tunnel started
|
||||
sleep 1
|
||||
local tunnel_pid=$(pgrep -f "ssh.*-L ${local_port}:${oracle_host}:${oracle_port}" | head -1)
|
||||
|
||||
if [ -n "$tunnel_pid" ]; then
|
||||
echo "$tunnel_pid" > "$pid_file"
|
||||
echo -e " ${GREEN}✅ Started (PID: $tunnel_pid)${NC}"
|
||||
|
||||
# Test connectivity
|
||||
if timeout 3 bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/$local_port" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✅ Port $local_port accessible${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Port $local_port not responding (Oracle may be down)${NC}"
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}❌ Failed to start tunnel${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_single_tunnel() {
|
||||
local sid=$1
|
||||
local pid_file=$(get_pid_file "$sid")
|
||||
local name="${SERVER_NAMES[$sid]}"
|
||||
|
||||
echo -e "${CYAN}[$sid]${NC} ${name}"
|
||||
|
||||
if [ -f "$pid_file" ]; then
|
||||
local pid=$(cat "$pid_file")
|
||||
if ps -p "$pid" > /dev/null 2>&1; then
|
||||
kill "$pid" 2>/dev/null
|
||||
rm -f "$pid_file"
|
||||
echo -e " ${GREEN}✅ Stopped (was PID: $pid)${NC}"
|
||||
else
|
||||
rm -f "$pid_file"
|
||||
echo -e " ${YELLOW}⚠️ Was not running${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Was not running${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
status_single_tunnel() {
|
||||
local sid=$1
|
||||
local name="${SERVER_NAMES[$sid]}"
|
||||
local local_port="${LOCAL_PORTS[$sid]}"
|
||||
local pid_file=$(get_pid_file "$sid")
|
||||
|
||||
if check_tunnel "$sid"; then
|
||||
local pid=$(cat "$pid_file")
|
||||
echo -e "${GREEN}●${NC} ${CYAN}[$sid]${NC} ${name}"
|
||||
echo -e " localhost:${local_port} (PID: $pid)"
|
||||
|
||||
if timeout 2 bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/$local_port" 2>/dev/null; then
|
||||
echo -e " ${GREEN}Port accessible${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}Port not responding${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}○${NC} ${CYAN}[$sid]${NC} ${name}"
|
||||
echo -e " localhost:${local_port} (stopped)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main commands
|
||||
# ============================================================================
|
||||
|
||||
print_header() {
|
||||
echo -e "${BLUE}════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} ROA2WEB SSH Tunnel Manager (Multi-Server)${NC}"
|
||||
echo -e "${BLUE}════════════════════════════════════════════${NC}"
|
||||
echo
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
print_header
|
||||
parse_config
|
||||
|
||||
echo -e "${BLUE}Starting ${#SERVER_IDS[@]} tunnel(s)...${NC}"
|
||||
echo
|
||||
|
||||
local failed=0
|
||||
for sid in "${SERVER_IDS[@]}"; do
|
||||
start_single_tunnel "$sid" || ((failed++))
|
||||
echo
|
||||
done
|
||||
|
||||
if [ $failed -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠️ $failed tunnel(s) failed to start${NC}"
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✅ All tunnels started successfully${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
print_header
|
||||
parse_config
|
||||
|
||||
echo -e "${BLUE}Stopping tunnels...${NC}"
|
||||
echo
|
||||
|
||||
for sid in "${SERVER_IDS[@]}"; do
|
||||
stop_single_tunnel "$sid"
|
||||
done
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}✅ All tunnels stopped${NC}"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
print_header
|
||||
parse_config
|
||||
|
||||
echo -e "${BLUE}Tunnel Status:${NC}"
|
||||
echo "────────────────────────────────────────────"
|
||||
|
||||
for sid in "${SERVER_IDS[@]}"; do
|
||||
status_single_tunnel "$sid"
|
||||
echo
|
||||
done
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
cmd_stop
|
||||
sleep 2
|
||||
cmd_start
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
print_header
|
||||
parse_config
|
||||
|
||||
echo -e "${BLUE}Usage:${NC} $0 {start|stop|status|restart|help}"
|
||||
echo
|
||||
echo -e "${BLUE}Commands:${NC}"
|
||||
echo " start - Start all configured SSH tunnels"
|
||||
echo " stop - Stop all SSH tunnels"
|
||||
echo " status - Show status of all tunnels"
|
||||
echo " restart - Restart all tunnels"
|
||||
echo " help - Show this help"
|
||||
echo
|
||||
echo -e "${BLUE}Configuration:${NC}"
|
||||
echo " SSH Tunnels: $SSH_TUNNELS_FILE"
|
||||
echo " Secrets: $SECRETS_DIR/"
|
||||
echo " Fallback: $ENV_FILE (legacy mode)"
|
||||
echo
|
||||
echo -e "${BLUE}Configured Tunnels (${#SERVER_IDS[@]}):${NC}"
|
||||
for sid in "${SERVER_IDS[@]}"; do
|
||||
echo " - [$sid] ${SERVER_NAMES[$sid]}"
|
||||
echo " localhost:${LOCAL_PORTS[$sid]} → ${ORACLE_HOSTS[$sid]}:${ORACLE_PORTS[$sid]}"
|
||||
echo " via ${SSH_USERS[$sid]}@${SSH_HOSTS[$sid]}:${SSH_PORTS[$sid]}"
|
||||
done
|
||||
echo
|
||||
echo -e "${BLUE}Secrets Files (per server_id):${NC}"
|
||||
echo " secrets/{id}.ssh_key - SSH private key (preferred)"
|
||||
echo " secrets/{id}.ssh_pass - SSH password (fallback, needs sshpass)"
|
||||
echo
|
||||
echo -e "${BLUE}Example ssh-tunnels.json:${NC}"
|
||||
echo ' [{"id": "romfast", "name": "Romfast", "local_port": 1521,'
|
||||
echo ' "ssh_host": "roa.romfast.ro", "ssh_port": 22122, "ssh_user": "roa2web",'
|
||||
echo ' "oracle_host": "10.0.20.36", "oracle_port": 1521}]'
|
||||
echo
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
case "${1:-help}" in
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
status) cmd_status ;;
|
||||
restart) cmd_restart ;;
|
||||
help|--help|-h) cmd_help ;;
|
||||
*)
|
||||
echo -e "${RED}❌ Unknown command: $1${NC}"
|
||||
echo
|
||||
cmd_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user