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>
392 lines
12 KiB
Bash
Executable File
392 lines
12 KiB
Bash
Executable File
#!/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
|