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:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

391
ssh-tunnel.sh Executable file
View 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